Author SHA1 Message Date
Martino Ferrari 931b0d108c layout editor: in-between split buttons sit above the divider handle; doc nits 2026-11-04 07:26:30 +01:00
Martino FerrariandClaude Opus 4.6 7972646dd2 layout editor: horizontal-resize icon, divider drag follows the pointer; x caption on every labelled plot; probes and docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-04 07:20:03 +01:00
Martino Ferrari 0c228c4822 pin deps/plotter: per-column x axes, tighter cell gap; docs 2026-11-04 07:04:07 +01:00
Martino Ferrari 03cb85e015 shared-y: final review fixes 2026-11-04 01:14:36 +01:00
Martino FerrariandClaude Opus 4.6 fa9f0c6da9 docs: split rows share one y axis
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-04 01:04:21 +01:00
Martino Ferrari b8177cc25a layout editor: column drag measures plot widths, not cell rects 2026-11-04 01:01:42 +01:00
Martino FerrariandClaude Opus 4.6 5f0bc75c27 plot host: split rows share y, rulers per row
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-04 00:55:59 +01:00
Martino FerrariandClaude Opus 4.6 135310ceca pin deps/plotter: shared y axis for split rows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-04 00:43:41 +01:00
Martino FerrariandClaude Opus 4.6 4c54b2dead docs: plan for the shared y axis in split rows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 23:57:27 +01:00
Martino FerrariandClaude Opus 4.6 10a8f2e33c docs: design spec for shared y axis in split rows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 23:52:43 +01:00
Martino FerrariandClaude Opus 4.6 053edd521a final review fixes: reject inf plot width, nearest-cell distance in double, tour wording
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 19:28:24 +01:00
Martino FerrariandClaude Opus 4.6 4fab14949c layout editor: column drag follows the pointer through the cell floor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 19:18:18 +01:00
Martino Ferrari 2183317953 docs: horizontal plot split 2026-11-03 19:14:57 +01:00
Martino FerrariandClaude Opus 4.6 7730b35fe3 probes: print cells and column weights; split-row ruler label
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 19:06:04 +01:00
Martino Ferrari 9f3e3597af signals: label plots by row and cell, drop onto a cell 2026-11-03 18:04:58 +01:00
Martino FerrariandClaude Opus 4.6 645cb6f4fa layout editor: split a row into cells, resize cells by drag
Add cell `+` buttons (n+1 per figure row), column resize handles with
drag, and update the `x` handler to call remove_cell instead of
remove_row. Fix row_weights_ misalignment in insert_row/remove_row.
Split probe steps 9-15 exercise content movement, column drag clamp,
row-weight tracking, and sensitivity at caps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 17:33:29 +01:00
Martino Ferrari 62ceb69278 session: plots carry row and width; files without row load one cell per row 2026-11-03 17:16:08 +01:00
Martino FerrariandClaude Opus 4.6 ae1d262be4 plot host: rows hold 1..4 cells; split probe scaffold
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 17:11:01 +01:00
Martino Ferrari 79e69c28fa deps/plotter: figure rows can hold several plots 2026-11-03 17:04:16 +01:00
Martino Ferrari 18b91c8016 docs: implementation plan for the horizontal plot split 2026-11-03 15:13:45 +01:00
Martino FerrariandClaude Opus 4.6 50078f0217 docs: design spec for the horizontal plot split
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 14:57:53 +01:00
Martino FerrariandClaude Opus 4.6 c15c89c00a ui: editable signal values and their pencil align right with the read-only ones
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 12:04:01 +01:00
Martino FerrariandClaude Opus 4.6 e29c889483 ui: signal details name the type and quantisation; editable values sit by the pencil, read-only ones align right
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 12:01:05 +01:00
Martino FerrariandClaude Opus 4.6 87f61ea364 ui: a signal's details sit directly under its name; its unit is user-changeable and saved with the session
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 11:42:30 +01:00
Martino FerrariandClaude Opus 4.6 bbef7faf3d ui: a signal's page shows gain and offset as aligned rows with a pencil each, replacing Calibrate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 11:36:58 +01:00
Martino FerrariandClaude Opus 4.6 6ff7dd786e ui: the Dock toggle carries the ruler icon, the dock being the readouts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 11:30:05 +01:00
Martino FerrariandClaude Sonnet 4.6 d82d9f8213 docs: the readouts strip hides when empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 11:15:19 +01:00
Martino FerrariandClaude Sonnet 4.6 5653a3a66b ui: the readouts strip hides when there are no cursors or measurements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 11:14:49 +01:00
Martino FerrariandClaude Sonnet 4.6 61d0925144 docs: the readouts strip follows the collapsed state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 10:50:55 +01:00
Martino FerrariandClaude Sonnet 4.6 1d144663b9 ui: the readouts strip is also shown in a collapsed window
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 10:50:55 +01:00
Martino FerrariandClaude Sonnet 4.6 3526ce0541 docs: the readouts strip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 10:47:11 +01:00
Martino FerrariandClaude Sonnet 4.6 c8d9d04f59 ui: the readouts show in a strip under the plot when the dock is hidden
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 10:47:11 +01:00
Martino FerrariandClaude Sonnet 4.6 54f2722fea ui: the tour names the dock button; the user guide's key table follows shortcuts.cpp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:32:20 +01:00
Martino FerrariandClaude Sonnet 4.6 328588a8b9 docs: the manual and the user guide describe the badge bar, the dock and the slide-over pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:28:16 +01:00
Martino FerrariandClaude Sonnet 4.6 86ea0bac38 ui: stale tooltip wording
The dock button's tooltip, the guided-tour menu row's tooltip and the m / g shortcut labels still described a side panel and tabs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:28:16 +01:00
Martino FerrariandClaude Sonnet 4.6 c4b79da057 docs: events in the dock and on its page
The dock's Events block, the events slide-over page, the availability-rule deviation, the probe lines and the removal of the left pane are described; the false remembered-width claim is gone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:19:33 +01:00
Martino FerrariandClaude Sonnet 4.6 9db5e4a49c ui: tour and help describe the events block
The dock tour stop names the Events block (one click replays, Manage opens the list) and the event-history and record hints point at the page and the dock instead of a left pane.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:15:38 +01:00
Martino FerrariandClaude Sonnet 4.6 794df9b4d0 ui: one way to open the events page, and stale wording after the pane's removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:11:34 +01:00
Martino FerrariandClaude Sonnet 4.6 8c866300d8 ui: the events list is a slide-over page; the left pane, its divider and the header toggle are gone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:07:57 +01:00
Martino FerrariandClaude Sonnet 4.6 cb098eff75 ui: cap and cheapen the dock's events rebuild
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:56:24 +01:00
Martino FerrariandClaude Sonnet 4.6 baf1c73b21 ui: compact events block in the dock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:48:35 +01:00
Martino Ferrari b08494dd36 docs: plan for the events dock block and the left pane's removal (step 4 of the redesign) 2026-11-03 06:43:19 +01:00
Martino FerrariandClaude Sonnet 4.6 ce3cc97b91 docs: the readouts dock, the cursors and measurement pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:37:57 +01:00
Martino FerrariandClaude Sonnet 4.6 a2ce02db92 ui: tour and help follow the readouts dock
The dock tour stop describes the read-only readouts and their Edit buttons, and the cursors and measures hints point to the dock copy and the pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:37:57 +01:00
Martino FerrariandClaude Sonnet 4.6 38b31360cc ui: the cursors and measure pages are insensitive until the stream has signals
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:29:32 +01:00
Martino FerrariandClaude Sonnet 4.6 3d307a0185 ui: the cursors and measurements panels are slide-over pages; the dock is the readouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:24:52 +01:00
Martino FerrariandClaude Sonnet 4.6 a0fc0762d5 ui: compact cursor and measurement readouts at the top of the dock
Both views are written from the same variable at the same call site, so the dock cannot disagree with the full panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 05:22:41 +01:00
Martino Ferrari a7a1609d48 docs: plan for the compact readouts dock (step 3 of the redesign) 2026-11-03 05:19:19 +01:00
Martino FerrariandClaude Opus 4.6 1627abcd4a docs: the sample budget in samples per signal, the memory limit and the planner probe lines
Converts every --buffer-mb / buffer_mb mention to --buffer-samples / buffer_samples,
records the unit change as a deviation (README, SPECS, CLAUDE.md), documents the
buffer: and planner: dump lines and the new Preferences row, and updates the help
pages and the help-shot script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:49:21 +01:00
Martino FerrariandClaude Opus 4.6 034c82f7d7 Budget the sample buffer in samples per signal, under a hard memory limit
--buffer-samples (k/M/G) replaces --buffer-mb: the request is rounded down to a
power of two for every signal and the shared time ring. A separate memory limit
(--memory-limit-mb, a Preferences row, default 8 GiB) halves the plan until the
real ring bytes fit and says so on stderr and in the status line. The per-source
key buffer_mb becomes buffer_samples; an old buffer_mb is ignored.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:47:04 +01:00
Martino FerrariandClaude Opus 4.6 616cde5b13 badges: hide the Source badge when the source has no panel of its own
A Source pill that opens nothing (UDPS, Flat UDP, files) was noise. It is now shown only while the slide-over has a source page; the [badges] source line gains visible=.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:38:12 +01:00
Martino FerrariandClaude Opus 4.6 d8f82bd610 docs: source badge name only, no signals lock, the Add-a-signal page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:14:37 +01:00
Martino FerrariandClaude Opus 4.6 b98a3478ff badges: source badge shows the name only; drop the signals lock; the signals page proposes what can be added
The link state is the status bar's, so the Source badge loses its state text. The signals have no lock
(the badges and signal pages are direct manipulation), and the signals page lists only unacquired
signals and uncomputed math channels (plus the acquired ones while the badge bar has overflowed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:14:37 +01:00
Martino Ferrari dad424f1de ui: read the signal count under the lock in the badge bar; validate the drop payload 2026-11-03 01:53:09 +01:00
Martino FerrariandClaude Opus 4.6 5242babf99 docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign)
Also wrap the signals page's hint line, which at 375 px minimum widened the whole slide-over panel to 376.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 01:48:43 +01:00
Martino FerrariandClaude Opus 4.6 f315c677f3 ui: reword the docs that still spoke of the list drag; keep disabled signals' badge order; stamp the signal generation before the snapshot
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 01:37:31 +01:00
Martino FerrariandClaude Opus 4.6 ca10dfb323 ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 01:30:55 +01:00
Martino Ferrari f27925da59 ui: one badge per enabled signal, an N signals overflow, and the order kept in memory 2026-11-03 01:19:13 +01:00
Martino Ferrari ad5e30f338 ui: park the slide-over stack only when the dying signal page is the visible one 2026-11-03 01:11:06 +01:00
Martino FerrariandClaude Opus 4.6 3b1cda50e5 ui: signal pages honour the signal lock and disable the plot a signal is already on
The per-signal page bypassed the signals lock for the acquire tick and the math
Edit/Delete buttons, and offered "Plot on r" for a plot the signal was already on.
A removed page also let another signal's page show during the close animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 01:09:29 +01:00
Martino FerrariandClaude Opus 4.6 baf4a1f20f ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 01:03:51 +01:00
Martino Ferrari 0dd235e41c ui: the signal list is the signals slide-over page, opened by a + badge or g 2026-11-03 00:54:25 +01:00
Martino Ferrari e36f2a202b docs: plan for the signal badges (step 2 of the redesign) 2026-11-03 00:46:43 +01:00
Martino FerrariandClaude Opus 4.6 54d7f63f35 ui: the s shortcut says dock, not side panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 21:07:56 +01:00
Martino FerrariandClaude Opus 4.6 0c94caf866 docs: the badge bar, the slide-over and the dock (step 1 of the redesign)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 21:03:03 +01:00
Martino FerrariandClaude Opus 4.6 a1672acc70 ui: tighten the badge/dock/tour probes after review and fix the tour's stale hole
The session old-file probe and save_scheduled now discriminate, Esc is read through the real key path, the dock-section probe proves the Measure section starts out of view, and the tour's spotlight is measured independently of the helper it is drawn from. A target scrolled out of its scroller no longer leaves the previous hole standing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 20:56:09 +01:00
Martino FerrariandClaude Opus 4.6 2826042156 ui: the tour and the contextual help follow the badge bar, the slide-over and the dock
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 20:46:59 +01:00
Martino FerrariandClaude Opus 4.6 4b65197f56 ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 20:37:55 +01:00
Martino Ferrari 31ff778e07 ui: the Source badge, and the source panels as slide-over pages 2026-11-02 20:19:47 +01:00
Martino FerrariandClaude Opus 4.6 5fb479bd69 ui: a badge bar with a Trigger badge, and the trigger form in the slide-over
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 20:12:16 +01:00
Martino FerrariandClaude Opus 4.6 a40863fa45 ui: a slide-over panel over the plot's right edge, with its probe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 19:57:17 +01:00
Martino FerrariandClaude Opus 4.6 73f74ca495 docs: plan for step 1 of the badge bar / slide-over / dock redesign
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 19:50:12 +01:00
Martino FerrariandClaude Opus 4.6 c572ec280f docs: design spec for the badge bar, readouts dock and slide-over panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 19:28:40 +01:00
Martino Ferrari 3ac7a8f230 Merge feature/gen-rate-and-tabs into feature/source-plugins 2026-11-02 14:21:08 +01:00
Martino FerrariandClaude Opus 4.6 0d8c583bbb e1608: a configurable generator update rate, and the achieved rate in the Analog out panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 12:19:23 +01:00
Martino FerrariandClaude Opus 4.6 0c6e69957d ui: draw the sidebar tabs without the placeholder icon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 12:11:16 +01:00
Martino FerrariandClaude Opus 4.6 5bb23e6a0a probe: name the MathNode fields; the build baseline is now one warning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 12:06:45 +01:00
Martino Ferrari 7b9c62c911 Merge feature/e1608-scan into feature/source-plugins 2026-11-02 11:51:25 +01:00
Martino FerrariandClaude Opus 4.6 f74df49d33 e1608: scan review fixes (EINTR in Scan::poll, cfg-based broadcast test, no per-tick copy)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:27:00 +01:00
Martino FerrariandClaude Opus 4.6 8245324b51 docs: reconcile the E-1608 README with the real-board run and the scan lookup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:22:47 +01:00
Martino FerrariandClaude Opus 4.6 dbf145094a e1608: probe and docs for the network scan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:20:16 +01:00
Martino FerrariandClaude Opus 4.6 24bdd6b930 e1608: a Scan network row in the connection form, with click-to-fill results
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:17:01 +01:00
Martino FerrariandClaude Opus 4.6 43c21a0cc2 e1608: discover boards on every interface, not only the default route's
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:14:47 +01:00
Martino Ferrari 9426a61919 docs: implementation plan for the E-1608 network scan 2026-11-02 10:07:50 +01:00
Martino Ferrari eb4e59ac50 Merge feature/text-tabs into feature/source-plugins 2026-11-02 10:03:28 +01:00
Martino FerrariandClaude Opus 4.6 16e6d7fa75 ui: sidebar tab selector uses text labels instead of icons
The two drawn tab glyphs were judged not understandable enough; the
switcher goes to the wide policy with titles only, and the glyphs,
their gresource entries and the icon-naming code are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 09:49:55 +01:00
Martino Ferrari b6822cd32d docs: tidy the scan spec 2026-11-02 09:42:54 +01:00
Martino Ferrari 19b7f25b82 docs: design spec for the E-1608 network scan 2026-11-02 09:42:50 +01:00
Martino Ferrari a8e12b26a2 flatudp: restore the datagram_size declaration deleted by the previous comment fix 2026-11-02 07:06:02 +01:00
Martino Ferrari bec9942d3d flatudp: correct the all_scalar doc comment 2026-11-02 01:27:15 +01:00
Martino FerrariandClaude Opus 4.6 d955baf612 flatudp: the signal-row editor form with a per-row Vector as drop-down, its probe line, README and architecture notes
Multicast was verified on a loopback group (239.255.42.99 on 127.0.0.1); a real network was not exercised.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 01:23:51 +01:00
Martino FerrariandClaude Opus 4.6 e3bc1f72f3 flatudp: vector rows as channels or as a waveform, a re-openable source, probes for both
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 01:18:43 +01:00
Martino Ferrari a523bc2bd9 docs: flat UDP spec and plan amendment -- vector rows as channels or as a waveform 2026-11-02 01:12:58 +01:00
Martino FerrariandClaude Opus 4.6 78a29fbc39 flatudp: the source, kind, mock sender and stream probes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 01:05:38 +01:00
Martino Ferrari 6e8e71b32f flatudp: the signal-list codec, with a hand-computed decode check 2026-11-02 00:58:02 +01:00
Martino Ferrari dde2dad5b1 docs: the flat UDP source implementation plan 2026-11-02 00:53:37 +01:00
Martino FerrariandClaude Opus 4.6 6c01689bfe docs: flat UDP spec -- drop time_mode, record the batching and event rules found in acquisition.cpp
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 00:36:14 +01:00
Martino FerrariandClaude Opus 4.6 cf4d105ece docs: design spec for the flat UDP (MARTe2 UDPSender) source
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 00:32:54 +01:00
Martino FerrariandClaude Opus 4.6 e8db5602a2 e1608: cosmetic minors -- 2-digit frequency spin, smaller mock catch-up cap, docs wording
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 00:24:15 +01:00
Martino FerrariandClaude Opus 4.6 87822ed4f5 Clear the open minors: lost marking and overrun logging, form teardown guard, line breaks and ERANGE in config, probe strength
The E-1608 source marks every restart as lost and logs each overrun; its form stops touching
siblings after the chooser destroys them; the link skips command id 0, retries a full-buffer
discover send and ignores a zero-length scan read. Session files escape \n and \r; get_int and
get_double reject out-of-range values; the UDPS port spin reports changes; a skipped source panel
says why on stderr. The E-1608 probe now checks the signal names and the reconnect itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 00:19:27 +01:00
Martino FerrariandClaude Sonnet 4.6 6245811699 e1608: do not fight a hardware trigger, stop the scan on a failed connect, daemon drop timer in the mock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-01 10:01:38 +01:00
Martino FerrariandClaude Sonnet 4.6 3093568d11 docs: the E-1608 source kind, its probe readings and its unverified commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-01 09:54:49 +01:00
Martino FerrariandClaude Sonnet 4.6 2e9f9664cf e1608: UDPSCOPE_PROBE_E1608 and the e1608 form sample in the source-kinds probe
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-01 09:50:59 +01:00
Martino FerrariandClaude Sonnet 4.6 407051959a e1608: the Analog out and Digital I/O panels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-01 09:46:26 +01:00
Martino FerrariandClaude Sonnet 4.6 bacc960776 e1608: the settings, the DataSource state machine, and the connect form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-01 09:43:45 +01:00
Martino FerrariandClaude Sonnet 4.6 5ac1b36a1f e1608: link and device layers (discovery, handshake, framed commands, calibration, decode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-01 09:40:58 +01:00
Martino FerrariandClaude Sonnet 4.6 558d13d521 tools: a protocol mock of the MCC E-1608, with its unverified commands listed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-01 09:39:25 +01:00
Martino Ferrari 5380a0d26c plan: the E-1608 source plugin implementation plan 2026-11-01 09:00:41 +01:00
Martino Ferrari 69585a963b spec: MCC E-1608 source plugin (sub-project 3) 2026-11-01 07:48:20 +01:00
Martino Ferrari dd4aed8dec fix: escape the source title in markup; capture peer comes from the source config 2026-10-31 20:27:47 +01:00
Martino FerrariandClaude Opus 4.6 4d859d6a37 docs: the source-plugin framework, the --source options and the source-kinds probe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-31 20:22:22 +01:00
Martino Ferrari 5cee00aaee source: capability panels as side-stack pages, with a probe for the path 2026-10-31 20:18:28 +01:00
Martino Ferrari 79c33b25c5 chooser: refuse to edit a session of an unregistered kind 2026-10-31 20:15:15 +01:00
Martino Ferrari 14f1421b2b chooser: pick the source type; each kind supplies its own connection form 2026-10-31 20:08:39 +01:00
Martino FerrariandClaude Opus 4.6 059b660586 source: a synthetic probe kind, and sessions that remember their source kind
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-31 20:04:42 +01:00
Martino Ferrari 4ae91bd58b source: normalise UDPS signal dimensions in to_info; comment tidy 2026-10-31 20:02:55 +01:00
Martino Ferrari 504bc193ea acquisition: drive a DataSource from the registry; --source and --source-opt 2026-10-31 19:59:51 +01:00
Martino Ferrari 46076312cc source: the UDPS client behind DataSource, with its config form 2026-10-31 19:57:11 +01:00
Martino FerrariandClaude Opus 4.6 3896f404a9 source: DataSource, Frame, SourceConfig and the registry (unused yet)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-31 19:56:00 +01:00
Martino Ferrari 71b904a93b docs: settle source-plugin implementation details in the spec 2026-10-31 19:54:23 +01:00
Martino Ferrari 5bc14b1163 docs: implementation plan for the source-plugin framework 2026-10-31 19:52:21 +01:00
Martino FerrariandClaude Opus 4.6 1a3393229a docs: design for the data-source plugin framework (sub-project 1 of 4)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-31 19:25:21 +01:00
Martino Ferrari f3f8f67a62 tools: a remote display script, Xvfb plus x11vnc over an SSH tunnel
Over SSH with X11 forwarding the app does not appear: no usable Vulkan device means GTK cannot
create a swapchain, and `--renderer cairo` fixes that half only -- the plot is a GtkGLArea and asks
for its own GL context, so indirect GLX is the second wall. A virtual X server on the machine that
runs the app sidesteps both, because the GL context then lives next door to the samples.

`tools/udpscope-remote.sh` starts Xvfb on a free display, runs the app there with
`--renderer cairo`, and serves the screen through x11vnc bound to localhost only -- the SSH tunnel
is the authentication, so `-nopw` is deliberate and `-localhost` is what keeps a mistake from being
a remotely reachable screen. It prints the tunnel and viewer commands rather than assuming a
client, mentions the websockify/noVNC route for a browser, and takes the X and VNC servers with it
when the app exits. `--no-vnc` leaves just the virtual display, for another viewer; the preflight
names the install command instead of a bare "command not found".

Verified: the preflight refuses with exit 1 and the install line when x11vnc is absent; with
`--no-vnc` the app runs against the virtual display and reads `buffer allocated: 4 channels` with
no Vulkan warning and no critical, and no Xvfb is left behind when the timeout ends it. That run
also exercises what the earlier log showed by omission: nothing is enabled, so a working app
plots nothing.

The README's Limitations gains the bullet a person actually needs -- the exact warning string, that
both halves need the software preset, the script itself, and that a window which appears but stays
empty usually means no signal is enabled.
2026-10-28 16:16:32 +01:00
Martino Ferrari fcf268d249 Add --renderer for displays with no usable GPU; gate the header reading
GTK4 defaults to Vulkan, and an X11-forwarded display has no device to make a
swapchain from; the plot is a GtkGLArea, so it then asks for a GL context that
is not there either. --renderer cairo is the software preset for that display:
it exports GSK_RENDERER=cairo and LIBGL_ALWAYS_SOFTWARE=1 in main() before
gtk_init()/adw_init(), because both are read when the renderer and the context
are created. --renderer gl is the faster choice when indirect GL works, and
--renderer vulkan makes the default explicit. Nothing is exported, and GTK's
own choice stands, when the option is absent.

The once-a-second header line is now behind UDPSCOPE_PROBE_HEADER, following
the [notrace] rule: its widths are still sampled for the exit dump, but the
line is detail behind that reading rather than output every run prints.
UDPSCOPE_PROBE_RENDERER prints the exported pair before GTK starts and the
GskRenderer GTK actually built.

README, the manual's overview and docs/claude record the two symptoms and the
cure.
2026-10-28 16:03:05 +01:00
Martino Ferrari ac054a30c9 fix: bound the density grid, and make [notrace] a reading instead of a flood
The plotter caps a density grid at kDensityCellCap now (deps/plotter), so a 2 s
window with persistence on no longer scales the image with the window: the
probe's four one-signal rows read 1 044 714 cells / 4 178 856 bytes where they
read 2 226 150 / 8 904 600, and a moved frame uploads 1 044 714 bytes where it
uploaded 2 226 150. The probe's own legs are unchanged (on peak=1.0000,
step-acquisitions peak=1.5000, step-time ratio=0.367879).

The paint probe no longer counts a frame the density is drawing as *traceless*.
`Renderer::draw_traces()` returns while the ink is the image, so every frame of
a persistence display stages no trace: the dump read `traceless paints: 653
(637 after the first bind)` for a run whose real figure is 4 (0), and the
`[notrace]` line printed for each of those frames. It is now behind
UDPSCOPE_PROBE_NOTRACE -- a per-paint line is a log flood and not a reading --
while the counters it details stay in every build, and the line stays reachable
for the state it was written for (a bound row that draws nothing).

Docs corrected where they stated the old rule: the README, the trigger
invariants, the density probe's readings, the verification list (the new
variable) and the manual page behind the panel's tooltip.
2026-10-16 21:09:11 +02:00
Martino Ferrari da826f2cb5 fix: persistence is the trace's own phosphor, not a density behind a stroke
The plotter now deposits every acquisition -- the newest included -- into one grid per trace and
draws no solid trace over it, each grid in that trace's colour and with a one-sweep ceiling (its own
commit, **1b8b55d**). This side needed almost no change of its own, because the colour was already
the trace's and the trace's colour is `RowSet::signal_color()` -- the invariants' one route -- but the
figures it reads back are now per trace:

- **The ceiling is left at the library's default of one sweep**, no longer derived from the fade.
  Deriving it from the steady state is the gain offset the user reported between the latest
  acquisition and the persistent image; a signal crossing the same place every acquisition now settles
  at the trace's colour, at full strength, and the newest sweep reads as the trace it is a picture of.
- **`cells`/`grid_bytes` are the sum over the row's traces** -- one grid each, a trace with no series
  holding none -- and both the probe and the exit dump carry `traces=` beside `rows=`. That is what
  makes the per-signal memory figure read: **556 537 cells and 2.2 MB per signal**, at 8904600 bytes
  over the four one-signal rows of the probe's run.

The probes, re-read on this build (the `verification.md` command, `--rate 500000 --batch 1000`):

    [density-probe] off enabled=0 ... rows=0 traces=0 cells=0 ink=0.0 peak=0.0000
    [density-probe] on enabled=1 ... rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000
    [density-probe] step-acquisitions ... peak=1.5000
    [density-probe] time-before ... peak=2.1359
    [density-probe] step-time ... ratio=0.367879
    [density-probe] frame cost: tick 0.62 -> 6.96 ms (184/23 frames), draw 2.02 -> 6.16 ms (177/18)
    [udpscope]   density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150

`on` `peak=1.0000`, `step-acquisitions` `peak=1.5000` and `step-time` `ratio=0.367879` are the legs
that were expected, unchanged; the *draw* mean is now **below** the run before the split (2.02 -> 6.16
against 2.09 -> 20.91), because the solid traces are no longer drawn over the image.

Docs updated with it: the trigger manual page (`persistence` is the trace, one image per signal, the
newest included), the README's persistence section, `invariants-trigger-and-pinning.md` (five rules,
the ceiling and the no-stroke rule among them), `verification.md` and `probe-readings.md` (the lines
above), the trigger panel's comment and tooltips, and the plotter submodule.

`cmake --build build --clean-first -j4`: **0 errors, the documented three warnings** (two
`MathNode::source`, one `-Woverlength-strings` at 12352); `make -C build test-ui` **13 ok**;
`make -C build test-help` **green** (15 pages); the self-check **0 `Gtk-WARNING`/`Gtk-CRITICAL`** and
`gtk warnings: 0`, two `buffer allocated:` lines.
2026-10-16 20:21:57 +02:00
Martino Ferrari 5e32199597 fix: the density's ceiling comes from the fade, so the ramp is visible
The library's `full_scale` now defaults to the default fade's steady state and `gamma` to 1.0 (its
own commit); this side states the ceiling for the fade *it* configured, because that is what the
ceiling means: the ink the display settles at, so the newest acquisition is a fraction of full ink
and white is reached only where acquisitions agree.

- **acquisitions**: `1/(1 - factor)` -- the geometric sum, 2.0 at the panel's 0.5, and 1.0 at 0
  (only the newest sweep, at full ink).
- **time**: the library's default of two sweeps. A time fade has no fixed steady state -- it
  settles where the trigger's rate and the constant meet, which is the *point* of it -- so a fast
  trigger reads brighter than a slow one the way a phosphor's does.
- `gamma` is left at the library's 1.0: a cell is a count of sweeps, so half as many must read as
  half the ink. With the ceiling at one sweep and gamma at 0.45 (the library's first defaults) one
  sweep was already full white and every display looked the same -- which is exactly what the user
  reported seeing, and why the assertion for it is a framebuffer one in the library's case
  (`empty=21 full=83 quarter=54`) rather than anything `ink`/`peak` can say.

The probe's own figures are unchanged in kind (they are the grid's): `on` `peak=1.0000`,
`step-acquisitions` `peak=1.5000`, `step-time` `ratio=0.367879`, `off` all zeros -- re-read on this
build, with the frame cost `tick 0.53 -> 7.49 ms`, `draw 1.89 -> 16.78 ms` and `gtk warnings: 0` in
the same run. The trigger/pinning invariants carry the ceiling rule and why the eye found it.
2026-10-16 17:38:06 +02:00
Martino Ferrari 974d6aa2d9 trigger: a phosphor persistence, driven by the plotter's density image
The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.

**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).

- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
  and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
  grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
  reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
  way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
  deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
  computed inside the paint that follows the tick -- so `density_pending` is set beside
  `record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
  transition and wrong on the first latch, whose axis was still the live ring's. Switching the
  display on under an acquisition already on screen deposits it too, so the switch does not appear
  to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
  the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
  or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
  file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
  sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
  sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
  now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
  own commit.

`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.

Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).

Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
2026-10-16 17:28:54 +02:00
Martino Ferrari e14e1ee069 trigger: a phosphor display keeps the last N acquisitions; and a Force button
Two changes in the trigger pane, landed together.

**Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward
the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour
exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per
(signal, age) in the row's free trace slots, named with the accumulate vocabulary so
`layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is
not a signal: the signal list, the colours and the measurements see one row per signal.
`ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the
snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because
the channel list is renumbered by any structural change since that acquisition was cut.
`RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and
`event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The
schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the
previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the
ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth,
which is the bound that cannot be driven into an OOM, and the panel says when either bound bites.
This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was
reading -- so the documented 0.35 translucency of an accumulation was opaque in fact.

**Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one
acquisition now, with the configured window. It is a synthetic edge rather than a second publisher
-- the engine opens one capture at the present moment and everything else is the ordinary latch, so
a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and
the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now
is a request for the history that exists, and the snapshot reports its own shorter pre_s), a
capture already filling is refused rather than restarted, and the state afterwards is the mode's:
Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex
with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so
a request written between the two would be dropped -- and consumed at the end of a packet, after
the channel loop and before the pending publish.

**A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed
`TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into
`signals()`). With one signal acquired, arming switched on the stream's clock signal and left the
watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt
the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was
about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`.
`trigger_signal_index()` is now the one resolution, used by both buttons.
2026-10-16 16:46:17 +02:00
Martino Ferrari fc4700d1c9 perf: a measurement pane is priced by its window, and its fold is inline
A pane is recomputed over the whole visible window on a cadence, and at a 15 s window
(11.8 M samples, 184 k level-0 level buckets, `--buffer-mb 4000` so the stride stays 1)
that is priced per *measured channel* and per *distinct share*. Measured with the new
`[levels-probe]` sweep, per recompute: one window with one level 6.58 ms, four shares
10.28 ms, sixteen shares 24.55 ms, and four measurements one share each over four
channels **35.69 ms** -- against a tick mean of 1.34 ms with an empty pane and 4.58 ms
with that one. That is the reported "very slow with multiple measurements": the pass
runs inside `tick()`, so it is a *frame* cost, and not an ingest one -- `gaps` and
`packets` stay clean even though each `MeasureWindowReader` holds the model mutex for
its whole life, because a socket buffer absorbs a few tens of milliseconds of producer.

The largest single term inside a pass was a call. `measure_accumulate_bucket()` folds
one bucket into one level, and it is the innermost loop of a walk whose iteration count
is the window's buckets *times* the pane's levels; it lived out of line in
`measure.cpp`, so every one of those iterations was a cross-translation-unit call.
Moving it, and the crossing state it updates, into `measure.hpp` as `inline` takes
sixteen shares on one channel from 24.55 to **17.25 ms** a recompute -- with every value
in the dump's `measurements:` unchanged. Two levers remain and both are measured: the
fetch's three out-of-line pyramid accessors plus a 48-byte copy per bucket (3.3 ms of an
11.8 M-sample window's 11.6 ms pass) needs a combined accessor upstream, and `raw` is
the *signal's* share (4.6 % on the mock's noisy channel against 0.1 % on its sine),
because a level inside a bucket's own `[lo, hi]` has to be walked sample by sample.

Bisected with the pane populated (the earlier bisect had it empty, which cannot see
this): the parent of the recent range and HEAD read **8.29** and **7.92 ms** a
recompute for the same four-type pane at the same window, and neither of the two
commits between them touches `ui_measure.cpp` or `measure.cpp` at all. So the cost is
the scale and not a regression, and the fix makes a pass cheaper rather than removing
one -- the plot's own per-frame cost was checked the same way (four commits, equal
within 3 %, and unchanged by the window, the ring size and the visible sample count).

`UDPSCOPE_PROBE_LEVELS_MS` with `UDPSCOPE_PROBE_LEVELS` and
`UDPSCOPE_PROBE_LEVELS_CHANNELS` is the sweep; `docs/claude/invariants-measurements.md`
records the curve and the inline rule, `verification.md` and `probe-readings.md` the
probe and what its numbers must read, and the README the pane's cost at this scale.
2026-10-16 08:00:54 +02:00
Martino Ferrari 0a209becb1 fix: a still frame's measurements are the frame's, not the viewport's
A measurement is a statistic of the window the pane is *about*, and on a
pinned view -- a held event, a paused stream, an opened file -- that is the
frame, not the part of it on screen. The pane followed the viewport
instead, so a wheel on a latched event moved `average` and `top`: the
reported "when measuring a still frame, moving the zoom makes measurements
such as average or top change". Nothing about the data had changed; the
user was only looking closer.

The window is `RowSet::pinned_range()` while the pin frames the view and
the viewport otherwise, and the choice is made in `update_measures()` --
the call site -- rather than inside `MeasureWindowReader`, because it is
*booked* as well as read: that window is what the "it moved" test compares
against `App::measure_window`, so a gesture inside the pin spends no
recompute either, and a reader that chose its own would leave a pinned
view recomputing every gesture frame for a number that cannot change.
`pinned_range()` is now the one expression of the pin's extent, shared
with the framing (`hold_pinned_window()`, `fit_x()`), and the reader is
handed the window instead of reading the plot. Live is unchanged: there
the window really moves and the statistic follows it, and the
accumulator's rule about what resets the history is about neither.

`UDPSCOPE_PROBE_MEASUREPIN_MS` is the guard, three legs of before/after a
gesture on the row's own axis. On a latched event zoomed to a quarter
(`view=[-1..1]` -> `[-0.25..0.25]`, `pin` unchanged): `same=4/4
for_same=1 pass_delta=0`, every value bit-identical -- `average=0.000000
top=0.957975 peak to peak=2.000000 amplitude=1.915950` both sides. The
same on a pause taken from it. Since the key `for=` follows `pin=` now,
`[measure-trace]` gained the field, so a held run reads `for==pin` with
`swin` free to sit inside it. On a live view a zoom to an eighth reads
`same=0/4 for_same=0 pass_delta=3 span_after=0.125000 follows=1` with the
values moving -- the case the rule must not swallow.

Before the fix the same probe reads `same=1/4 for_same=0 pass_delta=1
span_after=0.500000` on the held event (`top` `0.957975` -> `0.957966`)
and `same=0/4 span_after=0.125000` on the pause (`top` `0.957944` ->
`0.358614`). Live reads the same either way.

Also fixed, found by the pause leg: pausing while an event is *held*
froze an **empty** picture. `on_pause_toggled()` read `stream_at_x0()`
after setting the pause state, and that accessor follows the display --
`paused` with no pause view published yet answers `-display_epoch()`,
which on a held event is 0, so the cut searched the ring at edge-relative
x, found nothing, and the frame bound an empty snapshot: every
measurement `--`, every ruler gone (`ev=650001` on the probe's line now,
`ev=0` before). The offset and the window are read first, which is the
offset the axis is actually displaying. The live case is unaffected, which
is why the pause probe, which pauses a live view, never saw it.

Docs corrected where they asserted the old half-truth: the invariants now
separate the accumulator's rule (unchanged) from the window a value is
computed over (this change), the trigger/pinning file names the pin as
load-bearing for both the framing and the numbers and records the pause
ordering rule, `verification.md`/`probe-readings.md` describe the new
probe and `pin=`, and the README and the manual say what a still frame
measures.

Checked: `cmake --build build --clean-first -j4` 0 errors and the
documented three warnings; `make -C build test-ui` 13 ok; `make -C build
test-help` green; an exit-dump run 0 Gtk-WARNING/Gtk-CRITICAL and `gtk
warnings: 0`; `[measurepin-probe]`, `[pan-probe]` (`held=1`,
`allowed=1`, `limited=1`), `[pause-probe]` (`event_size` non-zero exactly
while paused, `axis` inside `frozen_x`, `skips` 0 live / 30 paused),
`[stats-probe]` (`dn=1` across the pause), `[zoom-probe]` (`gap_px=0.0`
at every step) and `[measure-probe]` + the dump's `measures
window:` (all-types run `marks_in_window=4/4`, `pin=off` live) on the fast
mock. `measure cost:` is unchanged in kind: `raw=37298 (0.0%)` of
`covered=121704537`, 1.04 ms a pass -- the runs that read higher are the
probe's own zoomed-to-a-slice windows, where the window is legitimately
shorter than a bucket.
2026-10-16 06:10:24 +02:00
Martino FerrariandClaude Opus 4.6 d91957a2fe fix: a recompute that renumbered its ring, and readouts frozen under a zoom
Three defects, all of them a window describing data that is not there.

A math edit refilled the derived ring with `clear()`, which renumbers it
from 0, leaving `produced()` short by the inputs' retained base. Since
`live_window_locked()` clamps against every channel's `produced()`, that
one lagging channel answered `n = 0` for the live view, every pause view
and every trigger capture until a whole ring of fresh data had gone by --
the reported freeze, invisible until the ring first wraps, hence its
intermittence. It now restarts with `clear_to(base)`, and
`ChannelBuffer::republish_from(base)` puts the published window back:
`acquire()` is incremental, so a rewrite in place published nothing and
the pyramid went on describing the samples the edit replaced.

The trigger's channel is an index, re-resolved by name after a renumbering
-- but only in `rebuild_enabled_locked()`, so a math add or delete went
through `set_math_channels()` with no restore, the index was clamped to
-1, and `process()` returned at once while the panel still named the
channel. The restore moves into `rebuild_channels()`, which *is* the
renumbering, so no caller can forget it.

And `update_measures()` suppressed its "the window moved" test while an
event was held -- the one mode where a gesture is the only thing that can
move it. Zooming a latched event left the pane describing the whole
captured event. A pinned window needs no exemption: it lands on the range
it already measured and fails the 2 % test by itself.

The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a
graph whose ring has already wrapped, which `math_probe` structurally
cannot reach; `[measure-trace]` now prints the mode, the window on screen
and the window the numbers describe; and the dump's `marks_in_window`
counts against the window on screen instead of against the one the marks
were computed over, where it was a tautology.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-15 16:30:48 +02:00
Martino Ferrari 074cb611bc fix: a clock that stalled on a re-plan, and a dialog that could not close
**The clock.** Trigger mode showed a blank plot and never latched when the session defined a math
channel, because such a session gives the model a plan *before* the stream's own signals are in it:
one derived channel, declared rate 0, so `plan_partition()`'s 1 kHz fallback is the interval those
early frames are stamped at -- 1300x too fast for a 1.3 MS/s stream -- and they have nowhere to go,
since the value loop skips derived channels. Relative mode then stamped a *position* times the plan's
*current* interval, so when the CONFIG's channels landed and the plan took the real rate (769 ns) the
same positions produced times 1300x *smaller* than what was already in the ring, and the monotonic
clamp held the clock at the old value for the rest of the run: one repeated stamp, `history=0`, no
latch, and a window the renderer cannot draw. The same family as the restarted-client poisoning
fixed in 620473a, through a different door. The filter's own output was finite and correct
throughout, and disabling the math channel only appeared to cure it because that is a structural
change which rebuilds the store -- the third time that channel has looked guilty while being a
bystander.

Relative mode now accumulates *seconds* (`rel_base_s_ += n * dt`), so a re-plan is a slope change and
the clock cannot jump backwards by construction; a frame is only stamped into a model that has a
*streamed* channel; and the epoch is adopted once per connection, with "unset" a state rather than
the value zero. A one-shot warning names the family if it returns: `the clock stalled at ...: N
samples stamped [a..b] are all behind it`.

**The dialog.** The Configurations window's Close button and the window-manager close both did
nothing: `close-request` was connected to a bare `void` lambda, and that signal is `gboolean` -- TRUE
means "handled" and suppresses the default handler that destroys the window, while `G_CALLBACK` is a
cast, so GTK read whatever the compiler left in the return register. A standalone GTK program of that
shape reproduces it: `void` handler -> `window_alive=1` after `gtk_window_close()`, `gboolean FALSE`
-> 0. The same audit found `gtk_window_present(GTK_WINDOW(...))` on an `AdwDialog`, a type assertion
and a no-op; it is `adw_dialog_present()` now. The menu row stays: it is the only way to switch or
save a configuration without restarting, and what made it look useless was that it could not close.

Verified: trigger mode with that session's graph reads `ring=[0.0058..0.2446] -> [0.0058..1.9969]`,
`history 0.237 -> 2.0 s`, `edge at 1.120 s, latched at 2.120 s`, `display=event(frozen) latches=1`,
`traceless paints 266` against 1519 before the fix. Baselines hold: 0 errors, the documented three
warnings, test-ui 13 ok, test-help green, `gtk warnings: 0`.
2026-10-15 10:50:09 +02:00
Martino Ferrari 27c9b34277 fix: a claimed gap must be corroborated before it is believed, and its hole must fit
The user's real host reported "connected and live" with one missing-data band over the whole window
and no trace in it. The counter is the only thing that claims a packet existed, and a reservation
was booked on its word alone: a source whose DATA counter counts samples, or frames of an inner
layer, advances by more than 1 per datagram, the client cannot check that claim (it has no clock of
its own to compare against), so it holds each packet, saturates its reorder ring, delivers it in
order and reports the skipped counters as lost. Every frame therefore reported a phantom gap, and
`reserve_hole()` wrote `lost * batch_` NaN samples with no bound but the 256-PACKET cap -- the
sample form of that rule ("the number to weigh against the ring is 256 * batch_, not the 256") was
never implemented. With relative time on -- which that session carries -- each phantom gap is
materialised as NaN slots by construction: the window filled with reservations, there was never
anything missing to repair them, and the gaps also stretched the relative clock, which is why the
status bar read live at ten times the true rate.

`Acquisition::gap_plausible()` now gates every reservation on two independent tests: the arrival
clock must corroborate the claim (that many missing packets would take that many frame intervals;
deliberately permissive, because a false refusal costs a shading and never data), and the ring must
be able to hold the hole. The verdict is taken before the stamps, and relative mode's slot advance
is skipped with it, since the advance and the reservation are the same claim. Refusals are counted
in `StreamStats::gaps_refused` and said once per connection with the numbers, naming the wire shape
they describe.

tools/mock_udpstreamer.py gains `--counter-step N`: a counter that is not a packet counter on an
intact link, which is the one shape the mock could not produce and the shape the report came from.

Verified in both directions. A counter stepping 1000 per datagram reads `holes=0 hole_slots=0
backfilled=0` with the traces present (3 traces, 1.4 M samples) against 7.9 M claimed gaps, and the
warning above names the cause. Every other packet withheld -- a real loss -- still reads
`holes=3579`, so genuine gaps are still reserved: a fix that stopped believing real losses would
have traded one wrong picture for another. The user's control read as "the math channel is the
problem" and was confounded -- disabling it is a structural change, which rebuilds the buffer and
clears the accumulated NaN.
2026-10-15 08:13:28 +02:00
Martino Ferrari 22e7a8efcc measure: the split's own means are back as above/below/between
`top`, `bottom` and `amplitude` became the *flat levels* in e83ad86 -- the level the
signal dwells at, read from the density of the window's buckets -- and that change kept
the split's own two means as its internal fallback for a window the pyramid cannot
summarize. Those means are now three types of their own, so the two different questions
about one split can be asked side by side: `above %` and `below %` are the plain mean of
the samples on one side of a share -- the reading the old `top`/`bottom` gave -- and
`between % %` is the mean of the band between two shares, which no single split can
answer, because a band's two edges are two thresholds.

Nothing about the arithmetic is new, and nothing is a new pass. `Above`/`Below` are the
`sum[]`/`count[]` split `measure_accumulate()` already folds -- the very figures
`Top`/`Bottom` fall back to -- and `Between` is the **difference of two levels' upper
sides**, both of which come out of one pass two over one window. It rides the bucket
fast path unchanged: a bucket wholly inside the band is strictly above the lower level
and strictly below the upper one, so both accumulators fold it whole, and only the
buckets that straddle either edge are opened. `measure_level_pcts()` is the one place the
shares are read, and it clamps both and puts `between`'s pair **in order**, so "between
80 % and 20 %" is the band between 20 % and 80 % -- normalized rather than refused,
because the shares name two edges rather than a direction. Nothing to measure reads `--`,
as for every other type: a share at 100 % has no sample above it, and a band can hold
none at all.

Measured, `[bandavg-probe]`, fast mock at a 2 s window (`--rate 1300000 --batch 1000
--freq 1`):

  sine   `above 50` 0.636620 (2/pi), `below 50` -0.636620, `between 50 75` 0.255873
  square `above 50` 0.986496 (30 harmonics) / 1.000000 (--square-harmonics 0),
         `between 50 75` 0.302744 / --
  ramp   `between 50 75` 0.250000 and `above 50` 0.500000, `above 100` --

The first three are the figures the old `top`/`bottom` documented; the ramp's are what a
uniform density predicts (a band's mean is its own centre); the ideal square's empty band
is the nothing-to-measure rule. The pane offers the types and edits their percentages
through the dialog's second share -- which only `between` shows -- and a measurement's
pair survives add/edit/delete and the session file (`threshold2`), where a file written
before it existed loads at the default.

No raw pass: with the measurements probe adding one of every type at a 2 s window, `raw`
went from 113966 of 240719031 covered (0.0%) to 134978 of 255372033 (0.1%), 0.8 -> 1.1
ns a covered sample.

Docs: the band-average invariant (and the two assertions the flat-level change moved),
the probe readings for the new probe, the verification recipe, the manual's measurements
page and the README.
2026-10-15 07:56:37 +02:00
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
Martino Ferrari cc8335c514 ui: each pane carries its own switch, the same one the header bar has
The trigger pane and the cursors pane now have a `GtkSwitch` in their heading, and it is *one control in
two places* with the header bar's button: both run the same action (`run_app_action()`), and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change, so clicking either
end moves both. A pane whose feature is off is disabled, which is exactly the state you need the switch
in, so it belongs *in* the pane rather than only in the toolbar.

Three things it needed:

- **The sensitive target moved to the pane's body.** A control inside an insensitive parent cannot be
  clicked, so a switch that greyed out with its own pane could never turn it back on. `on_trigger_toggled`
  and `on_cursors_toggled` now disable `trigger_body` / `cursors_body`, and so does the startup default;
  the headings, with their switches, stay live.
- **The trigger's heading moved out of `trigger-panel.ui`** and is built in `build_trigger_panel()`,
  which returns the heading row and the panel together. It has to be a sibling of the body for the switch
  to stay usable, and keeping it in the file would have meant the file owning a widget the code must
  arrange. The file's own panel is unchanged otherwise, so the `[trigger panel:]` counts still read.
- **A guard on the sync.** Setting a `GtkSwitch` emits `notify::active` for a programmatic write just as
  it does for a click, so without `App::panel_switch_sync` the program's own write would come back as a
  click and flip the feature again.

Readings, both directions in one line:

    trigger panel sensitive before the probe toggled it: 0; … ; pane switches live while their panes are off=1
    panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1  (pane switch / header bar button)

`pane switches live …=1` is the design's point and is read before anything turns either pane on; `0` would
be a switch drawn, greyed and useless. The trigger pair is moved from the *toolbar* end and the cursors
pair from the *pane* end, so the line carries both halves of the sync, and `drove_toolbar=1` is the pane's
own switch having turned the cursors on with the button following. `trigger panel sensitive …=0` is the
body, not the panel.

Verified: the two lines above; `[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon. Docs: the new reading in `probe-readings.md` with what a `0` means,
the invariants bullet for the one-control-in-two-places rule and its two traps, the sidebar bullet's
sensitive target, and the README note on why the switch is in the pane.
2026-10-14 14:13:49 +02:00
Martino Ferrari d9287ea826 ui: the trigger's wave is a full period again
The bold pass had turned the trigger into half a period — a signal rising above its level — and that
lost the thing the glyph is for: the crossings. A crest and a trough read as a wave; a hump reads as a
hill, and a trigger that shows no crossing names the "above the level" half of an event. The full-period
sine with the dashed level through it is restored, exactly as it was, and the Signals pill carries the
same wave again so the pill and the toolbar button stay one picture.

The Measure glyph keeps the bold redesign, which is the part that was better: two cursors (a cap over two
4px dashes) beside a ruler of two bands with three 2x4 marks and no end caps. The Signals pill also keeps
its two bold channel rows.

Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero
`Gtk-CRITICAL`. The icons' blob is 12352 bytes, still over the C99 limit, so the clean-build warning count
stays four.
2026-10-14 14:00:38 +02:00
Martino Ferrari 8c9c6ff7fd ui: bolder tab glyphs, and the trigger shares the Signals pill's picture
The previous pair were detailed miniatures — 1px ticks, 2px dashes, a 13-column wave — and at 32x16 that
reads as *texture* rather than as a shape, which is what "not clear" looks like. All three glyphs are
redrawn bold: one thick band, two thick dashes, few features.

- **Measure** is two cursors beside the ruler they read against. Each cursor is a cap over two bold
  dashes (4px), where three 2px dashes read as a dotted column; the ruler is two bands with three 2x4
  marks between them and **no end caps**, because a closed frame reads as a rectangle and costs a fifth
  of the ink on two lines that say nothing. This is the layout whose first attempt looked like two icons
  in a box — the difference is entirely the weight and the open ends.
- **Signals** is a signal rising above its level beside two bold channel rows. Half a period instead of
  a full one: at this size a full period is a zigzag and a hump is unmistakably a wave, and the level's
  marks survive on both sides of it instead of being swallowed by the crest.
- **The trigger's own icon** is now the same picture as the Signals pill, so the pill and the button that
  turns the trigger on are recognisably about the same thing.

Each was rendered and read back as an ink map at 1:1 and at 2x before it was installed, and three
candidates were compared for the Measure glyph (markers *on* a scale, cursors *over* a ruler, and this):
the first two turned the marks and the shafts into blobs at the crossings. Ink is 18%, 30% and 29%,
inside the range the icons above them set, and everything stays inset two pixels.

Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero
`Gtk-CRITICAL`. The icons' blob is 11774 bytes, still over the C99 limit, so the clean-build warning
count stays four.
2026-10-14 13:58:36 +02:00
Martino Ferrari 574f47f769 ui: the Measure pill is two cursors on a scale, not a boxed ruler beside them
The previous Measure glyph was two icons sharing a box: a heavy closed ruler frame on the right and
two light caps on the left, with nothing relating them. It is now one instrument — two cursors
*standing on* the scale they read against, each marker centred on one of the scale's marks, so the
pair reads the way the pane works: bracket a window, read it against the axis.

Three things the render said, each fixed:

- **The closed frame was most of the ink.** Two lines and two end caps cost about a fifth of the
  glyph's ink and say nothing a ruler's marks do not, so the scale is now one band with alternating
  marks — long, short, long — hanging above it.
- **The caps were dashes, not grips.** 5x2 at 32px read as a horizontal stroke; they are 5x3, like the
  caps on the toolbar's cursors icon.
- **The ticks were finer than everything else.** At 1px they were thinner than the 2px weight every
  icon here uses, so they are 2px, and every mark alternates in height the way a ruler's do.

The Markers' shafts are dashed and meet the band rather than hovering over it, and the whole glyph is
inset two pixels like the others, so nothing is flush with an edge that a button's padding or a crop
takes a pixel off. Ink: 27%, inside the range the icons above it set.

Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present" (the plain ruler it uses is untouched);
`make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' blob is 12624 bytes, still over the C99 limit, so
the clean-build warning count stays four.
2026-10-14 13:48:55 +02:00
Martino Ferrari 75aa62cc3c ui: the trigger glyph is a sine crossing its level, and every glyph is inset
The trigger's toolbar icon was a rising edge, which names only one of the four shape triggers the panel
offers. It is now a sine with a dashed level through it — the trigger's actual job, find the signal where
it crosses this — and the Signals tab's pill carries the *same* picture beside the channel list, so the
pill and the button that turns the trigger on read as the same idea. Both are one period of one-pixel
stairs, the way the plot rasterises its own traces.

Everything is inset two pixels now. The previous drafts ran their level and their bars flush to the cell
edge, which is what makes art look shaved wherever a button's padding or a crop takes a pixel: nothing in
either tab glyph, or in the trigger's, touches an edge.

Each shape was drawn and then read back as an ink map rather than trusted — the first sine came out as a
thin antialiased diagonal (a polygon through whole-pixel vertices has diagonal *edges*), which is why the
band is now per-column stairs; the level-through-the-centre arrangement was chosen over level-above-the-
centre after rendering both, since at these sizes a level nearer the crest merges with it into a blob. The
four glyphs measure 18%, 22%, 25% and 25% ink, inside the range the icons above them set.

Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)` and
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`;
`[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero `Gtk-CRITICAL` and no missing icon. The
icons' GResource blob is 12408 bytes — the SVG sources are embedded, comments included — still over the
C99 limit, so the clean-build warning count stays four.
2026-10-14 13:45:57 +02:00
Martino Ferrari dbe6377415 ui: the tab glyphs are 32x16 pairs, drawn twice as wide
Each tab's icon is now two motifs side by side instead of one in a 16px square: the Measure pill is the
two cursors beside the ruler, the Signals pill the trigger beside three channel rows. A 16px cell carries
one motif — squeezing a pair into it is what turned both into a smear — and the pill has the width for a
pair in a 300px column with two pills in it.

Two measurements decided the size, both of which I had wrong from the outside:

- **GDK paints an icon into a square box whatever the document's aspect.** `gtk_icon_theme_lookup_icon()`
  at 32 reports a `32x32` paintable for a 32x16 document, and librsvg fits it: rendered with aspect kept,
  the artwork comes out `32x16` inside a 32 request. So the pair is letterboxed, not stretched, and
  `pixel_size` 32 draws it at exactly the size it is written — 1:1, so the 2px bars stay crisp. A
  fractional scale (24 or 48) is what would blur them, which is why nothing here is drawn at one.
- **The bar does not get taller for it.** `switcher: … icon=32x32 size=308x36`: 36px is what the switcher
  measured with 16px icons too, because the pill's fixed height takes a 32px box without growing.

Both glyphs were drawn and then read back as ink maps rather than trusted: the first drafts came out at
39% and 44% ink (a blot beside the house 25–36%), and the tab bar is the last place a shape can be
checked, since no screenshot works under this X setup. The finished two measure 23% and 33%.

Verified: `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present";
`make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' GResource blob is 11661 bytes, still over the
C99 limit, so the clean-build warning count stays four.
2026-10-14 13:40:09 +02:00
Martino Ferrari e615465366 ui: each tab's glyph is a pair, not one icon
The Measure tab is now `udpscope-tab-measure-symbolic` — the two cursors standing across a ruler — and
the Signals tab is `udpscope-tab-signals-symbolic` — the trigger's rising edge over the channel list it
acts on. Each is a 16px composite rather than one of the icons the toolbar carries: a glyph the toolbar
already uses for one thing means two things the moment a tab shows it too, which is why the ruler alone
and the trigger alone were both wrong here.

The plain ruler stays exactly where it was. `udpscope-measure-symbolic` is the Preferences window's Plot
page icon and is untouched; the tab's pair is a new file beside it, so nothing that already meant "the
measurement ruler" changed meaning. Seven action icons bundled, nine with the two app icons.

Both composites are at the limit of what 16px holds — four features each — so each was drawn and then
*measured*: rasterized and read back as an ink map, since the shapes have to be checked as pixels and the
house range is 25-36% ink (trigger 25, cursors 27, ruler 36). The Signals list is two rows and not three,
so the edge and the list get half the cell each; its bars are all one length, because unequal ones read as
a bar chart and a chart is not a list. The Measure ruler's ends are open on purpose: a closed frame puts
four more pixels of ink at the left and right edges, which is what turns a ruler into a rectangle at this
size. First drafts came out at 39% and 44% ink and were rebalanced to 32% and 36%.

Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2`; `[side-tab-probe] … advanced=4` (switching by page
name still works); the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14
files; zero `Gtk-CRITICAL`. The icons' GResource blob is now 11309 bytes, still over C99's 4095-byte
string-literal limit, so the clean-build warning count is still four.
2026-10-14 13:33:24 +02:00
Martino Ferrari 0b146f1d53 ui: the tabs are icons, and the pills keep their names
Each sidebar page gets its own glyph and no visible text. The Measure tab reuses the ruler
(`udpscope-measure-symbolic`, the icon its deleted toolbar toggle used and the Plot preferences page
still carries); the Signals tab gets a new `udpscope-signals-symbolic` — three rows of a colour chip
and a name, which is what that tab mostly is — rather than a second copy of the trigger glyph, since
one shape meaning two things in one window is worse than a new one. Its bars are all one length: a
set of unequal ones reads as a bar chart, and a chart is not a list.

`AdwViewSwitcherPolicy.narrow` alone does **not** give an icon-only bar. Measured on libadwaita 1.10
by walking the switcher's tree: with `narrow` and a title, each pill holds a `GtkImage` *and* a
`GtkLabel` — 48px of "Measure" inside a 54px pill. So the pages' `title` is **empty** (their `name` is
what identifies them to `select_side_tab()` and to the session file, and both are unchanged) and the
words are re-attached in code: `name_side_tabs()` runs on the first tick — the pills are
`AdwViewSwitcher`'s own and exist only once it is mapped — and gives each pill a **tooltip and an
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 the page icon
that does not resolve leaves a *blank* pill, which is why it is read like the toolbar's icons are.

Two readings, because the claim is two claims:

    switcher: policy=narrow pills=2 labels=0 names=2
    page icons: measure=udpscope-measure-symbolic(ok) signals=udpscope-signals-symbolic(ok)

`labels` counts *mapped labels with text* — a plain mapped count reads 2 either way, because the
narrow pill keeps an empty label in its layout — and `pills=2` is the guard that makes it mean
something: with nothing mapped the rest of the line is void rather than a pass.

Docs: the icon census (six action icons, eight bundled, and what each of the two new users is), the
switcher bullet in `invariants-gtk-and-chrome.md` with the measurement above, the two new dump lines
in `probe-readings.md`, the re-measured GResource figures in `verification.md` (icons 8613 -> 9842
bytes, help 514660 after this session's help rewrite; the count stays four), and the README recording
that the design spec had the switcher showing titles and neither page an icon.

Verified: `make test-ui` 14 files; `[side-tab-probe] … advanced=4` (tab switching by name still
works with empty titles); `signals panel: … divider=1`; `[math-probe] 3 math channels`; zero
`Gtk-CRITICAL` and no missing icon.
2026-10-14 13:29:11 +02:00
Martino Ferrari f9cea4885b fix: an add or a delete outlives the list it rebuilds under itself
The `+` lives *inside* the signal list now, and both handlers it reaches rebuild that list:
`add_math_channel()` and `apply_math_channels()` → `rebuild_signal_panels()` destroys every row,
the divider's button and the row's menu popover with it. GLib holds a reference for the duration
of one emission, but a button's own code keeps running *after* the handler returns -- the same
last-reference hazard `on_signal_switch()` already repairs, and the same repair is used here:
hold one reference on the emitting widget and drop it from an idle.

Found by driving the three new controls the way a user reaches them, which the plan left to a
hand check:

    [side-list] divider + found=1
    [side-list] add: math 0 -> 1 editor_open=1
    [side-list] math row tick found=1 name='math1'
    [side-list] tick off: App::math enabled=0 (want 0)
    [side-list] math row Delete found=1
    [side-list] delete: math 0 (want 0)

So the `+` creates a channel *and* opens the editor on it, a math row carries its own tick, that
tick reaches `App::math` -- the copy the session file is written from -- and Delete removes the
channel, with the popover dismissed first so nothing points at the row that is going away. The
probe that measured this was a throwaway and is not in the tree.
2026-10-14 12:59:21 +02:00
Martino Ferrari 6492bb910f test: a probe reads the hidden tab's accumulator, and the docs follow the tabs
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the design's §5 rule -- the statistics
accumulator keeps running on the hidden page -- which nothing tested before. It switches to
Measure, forces one pass so there is a baseline, switches away to Signals and reads again two
seconds later: `advanced=4` is the assertion, and `advanced=0` is the defect where the
accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as
long as the trigger was on screen.

Two things the probe has to do for itself, and getting either wrong makes `advanced=0` a lie
about the tab:

- it **enables its own signal**, because a signal starts disabled (enabling is what starts
  acquisition) and a probe that waited for one to be on would be reading another probe's side
  effects -- and `UDPSCOPE_ENABLE_ALL_MS` is not part of this run;
- it **assigns that channel to a plot**, because a channel with no trace leaves the live window
  *still*, and a still window is precisely the state `update_measures()` is supposed to skip.
  Measured the hard way: without the assignment the probe read `advanced=0 min_n=0` with the view
  pinned and only four passes in twelve seconds, none of them the tab's doing.

Docs: `probe-readings.md` gains the probe's entry and the signals panel's new shape
(`rows == signals + 1 + math`, `divider=0` a list that lost its `+`), loses the whole `math
panel:` entry, and follows `pane_sensitive=`, the gone `revealed=` field, the six toolbar
controls and the tab-reading `m`/`g` key pairs. `verification.md` gains the probe's bullet and
the re-measured warning figures. The invariants record the sidebar as a switcher above a stack
with a scroller per page, the hidden-page trap that replaces the unrevealed-revealer one for it,
that a feature's on/off is sensitivity rather than visibility, that a math channel is a row of
the signal list, and that a cursors-off readout blanks to `--` with the panel left on screen.
`shortcuts.ui` and the help page's window and sidebar bullets stop describing five revealers.
The README records the deviation from `SPECS.md` that names the panes as separate collapsible
sections.

Verified: `[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=16`;
`signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `panels: … (page_current=1)
(sensitive=0) switcher=1`; `toolbar icons:` six controls with every name `(ok)`, with the
Preferences dialog opened in the same run; zero `Gtk-CRITICAL` and no missing icon.
2026-10-14 12:56:43 +02:00
Martino Ferrari cb866774c6 chore: drop the math icon, which lost its last user with the toggles
`udpscope-math-symbolic` was the toolbar's math-pane toggle, and that toggle is gone. Nothing
else names it: the census after the change is **five** action icons
(`udpscope-{trigger,cursors,measure,events,resize-rows}-symbolic.svg`) plus the two application
icons, seven bundled.

`udpscope-measure-symbolic` **stays**, and the plan's premise ("both lost their last user") is
wrong for it: `src/ui/preferences.ui` carries it as the Plot preferences page's icon -- a live
user, unrelated to the toggle that introduced it. Deleting it would have shipped a page whose
icon silently fails to resolve (GTK substitutes a placeholder and says nothing), so it stays and
the doc's census names it as the Preferences icon rather than as a toolbar one.

The warning baseline is unchanged: **four**, because the icons' generated GResource blob is
**8613 bytes** against C99's 4095-byte string-literal limit with the five action icons and the
two app icons -- removing one file did not put it back under, and neither figure moved on a
clean build. Measured by touching the two `*.gresource.xml` files and rebuilding, which
regenerates both blobs without one.

Verified: `make test-ui` 14 files; a run with the Preferences dialog opened resolves every icon
(`toolbar icons:` all `(ok)`, zero `Gtk-CRITICAL`, no "not present").
2026-10-14 12:56:36 +02:00
Martino Ferrari a493737ca1 ui: one channel list, and the math pane is gone
`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()` already
appends one synthetic entry per math channel -- "they go through the ordinary signal path on
purpose: a derived channel is a channel" -- so **every math channel was already a row of the
signal list**, with its tick, chip, name, unit and menu. The math pane listed the same channels a
second time, so this is mostly a deletion: `build_math_panel()`, `rebuild_math_pane()`,
`src/ui/math-panel.ui` and its resource entry, `App::math_list`, and the `math panel:` dump line.

What the one list gains:

- a non-selectable `MATH` divider before the first math row, carrying the `+` that creates a
  channel -- appended even when there are no math channels at all, because a `+` conditional on
  one existing could never create the first one;
- a math row's own menu: *Edit graph…*, *Default colour*, *Delete*. No Information and no
  Calibrate (calibration is applied at ingest, and a derived channel has no stream to describe),
  and no rename, because the node editor owns a channel's name and unit -- a second place to
  change them would be a second answer to the same question. An item dismisses the row's popover
  before opening or destroying anything from it;
- `App::math` mirrored from a math row's tick: `enable_locked()` mirrors it into the model, but
  `App::math` is the copy the session file is written from. The mirror sits *before* the
  `apply_math_channels()` in the `!on` path, which would otherwise push a stale `enabled = true`
  straight back into the model;
- a tick visible in a file view too: a derived channel is computed, and a file view has no
  acquisition to start, which is the other tick's question.

`rebuild_signal_list()` also publishes the list's own shape -- `rows`, `signals`, `math` -- and
the dump prints `divider=1` when `rows == signals + 1 + math`: the reading for a list that lost
its `+`.

Verified: `make test-ui` 14 files (was 15); `signals panel: labels=2 list=1 rows=9 signals=4
math=4 divider=1`; `[math-probe] 3 math channels`, every other line unchanged; no `math panel:`
line, and zero `Gtk-CRITICAL`.
2026-10-14 12:56:31 +02:00
Martino FerrariandClaude Sonnet 4.6 132041f79e feat: the session file remembers which sidebar tab was showing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 12:09:21 +02:00
Martino Ferrari d2ec89d866 docs: task 7 owns the shortcut window and the overview page
Task 1's review found two user-facing files that no task owned: src/ui/shortcuts.ui
still describes four accelerators as showing and hiding panes, and
src/help/01-overview.txt still lists the five-revealer column. Both were made false
by the tabbed sidebar and neither is caught by a probe, so give them an owner.
2026-10-14 12:05:33 +02:00
Martino FerrariandClaude Opus 4.6 619b5e356c fix: five review findings on the tabbed-sidebar commit
Finding 2 — four builder ids had no null check. `side_stack`,
`side_switcher`, `measure_page` and `signals_page` were resolved at
lines 4709-4712, well past the skeleton null-guard. A typo in the `.ui`
would have been a silent segfault.  The four resolutions are moved to
the guard block and added to its condition, in the same form the guard
already uses.

Finding 3 — the 'editable focused: m refused' leg of keys_probe was
deleted instead of ported.  It is restored, adapted for the new
behaviour: 'm' now selects the Measure tab instead of toggling a pane,
so the refusal is observed as the side_tab name being unchanged across
the synthesised key, not as a toggle that did not flip.  The dump line
keeps its existing name and `refused=` field so probe-readings.md:601
still describes it.

Finding 4 — `exit_probe_step3` assigned `sidebar_shown` to both
`dbg_side_visible` and `dbg_side_column_after_hide`, and the panels:
dump line read the wrong one (dbg_side_visible) for its `(visible=)`
field.  Two readings that cannot disagree diagnose nothing.  Resolved
by making `panels:` read `dbg_side_column_after_hide` and removing the
redundant `dbg_side_visible` field.

Finding 5 — the three-line rationale comment above `reload_file_view`
in `apply_math_channels` was removed by the commit under review.
Nothing it said became untrue.  Restored verbatim from e10a92f.

Finding 6 — three comments the change made false:
- `on_activate`: 'the sidebar's scroller' replaced with 'the sidebar's
  tab switcher and two-page stack', matching the actual ui file.
- `update_side_visibility`: 'Hiding the scroller instead left an empty
  column where the panels should be' removed; the design it described
  no longer exists.
- `header-bar.ui`: 'two of them are toggles' updated to 'one of them
  is a toggle'; after signal_toggle's deletion the overflow menu holds
  one toggle (layout).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 11:45:00 +02:00
Martino FerrariandClaude Opus 4.6 f224f56c4c fix: hold the check-button reference across the signal-panel rebuild
`gtk_check_button_set_active` emits both `notify::active` and `toggled`
in sequence.  GLib takes a per-emission reference, so it protects the
instance only through the end of `notify::active` — but our
`on_signal_switch` handler calls `rebuild_signal_panels`, which removes
every row from `signal_list`.  The row that contained the emitting check
button is destroyed, dropping its reference.  After `notify::active`
finishes, GLib unrefs the instance: with no external holder left that
is refcount 0, the instance is finalised.  The subsequent `toggled`
emission then fires into freed memory and produces two
`GLib-GObject-CRITICAL` diagnostics.

The fix: take a `g_object_ref` at the top of the handler and release it
from a `g_idle_add_full` destroy function.  The idle fires after
`set_active` has returned and all its emissions are complete, so the
instance lives until the emission chain is fully done.  The rebuild
itself stays synchronous — it has run before the next frame reads the
list — and the behaviour the handler delivers is unchanged.

This is a latent bug predating Task 1.  Previously the window's focus
reference happened to land on the row's `GtkListBoxRow` and kept the
row alive as a side effect; the new `AdwViewSwitcher` takes initial
focus instead, so nothing holds that reference any more.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 11:44:40 +02:00
Martino FerrariandClaude Sonnet 4.6 44d2fd35dc ui: the sidebar is two tabs, and a toggle disables its pane
Replace the sidebar's five-panel scrolling column with an AdwViewStack
carrying two pages — Measure (cursors + measurements) and Signals
(trigger + math + channel list) — with an AdwViewSwitcher above it. The
trigger and cursors toggles change from "reveal/hide my pane" to
"enable/disable my feature, leaving the pane insensitive", following the
disable-not-hide principle. The keys probe loses a duplicate registration
that was toggling the sidebar back on before the exit probe captured its
state; the exit-probe captures now read adw_overlay_split_view_get_show_
sidebar() instead of gtk_widget_get_visible(), which AdwOverlaySplitView
never changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 11:11:21 +02:00
Martino FerrariandClaude Opus 4.6 e10a92fc52 docs: correct the sidebar spec, and plan its implementation
The spec claimed the math pane and the signal list were two lists of the same
channels; they are one. `append_math_signals_locked()` already puts every math
channel into `signals()` and nothing filters `sig.math`, so the merge is mostly
a deletion -- the list gains a `MATH` divider and a second menu, and the pane
goes. §2, §3 and §6 say so now.

The plan is seven tasks, each ending in a build, `make test-ui` and one short
headless probe read.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 10:48:21 +02:00
Martino FerrariandClaude Opus 4.6 a519e13b67 docs: design a tabbed side panel with one channel list
Five revealers in one column leave every section short of room once the
trigger, cursors, measurements and math panes are open. The design splits
the column into two tabs, folds the math list into the signal list -- the
two were the same list drawn twice -- and makes a feature's toggle disable
its pane rather than hide it, because a tab cannot carry an on/off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 10:23:19 +02:00
175 changed files with 42495 additions and 3398 deletions

No files matched your search

+14 -8
View File
@@ -5,8 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this project is
udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one
UDP stream with `deps/udpsclient`, buffers it in a large ring (default 200 MB), and draws it
with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of
data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered
source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 10 M samples per signal, under an 8 GiB memory limit), and draws it
with `deps/plotter` — a figure of up to eight plots in stacked rows (a row can be split into up to four side by side) sharing one x window. On top of
the live view it provides the things a bench scope provides: a trigger with a pre/post event
window, a pause that freezes the visible window, cursors, statistical measurements,
user-defined math channels built in a node editor, and an export of the visible window to
@@ -18,9 +19,14 @@ Two documents bound the work and are **not** interchangeable with this one:
- **`README.md`** documents what was actually *built*, including deliberate deviations from
the spec and known limitations. **Read it before concluding something is a bug.**
**Deliberate deviation from `SPECS.md` line 1:** the sample buffer is budgeted in *samples per signal*
(`--buffer-samples`, rounded down to a power of two), not in megabytes, under a separate hard
`--memory-limit-mb` (also a Preferences row). There is no `--buffer-mb`; see README and `SPECS.md`'s
"Deviations from the spec".
Three things shape almost every change here, and the linked files expand on each:
- **There are exactly two threads** — a producer that owns the UDP client and the GTK main
- **There are exactly two threads** — a producer that owns the data source and the GTK main
thread that owns every widget and the GL context — and they share `ScopeModel` under one
mutex. Samples cross through lock-free rings, never through the lock.
- **There is no unit test suite.** The only automated check is the application's own headless
@@ -38,26 +44,26 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| File | What it covers |
| --- | --- |
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly four build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly one build warning; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
### How the application is put together
| File | What it covers |
| --- | --- |
| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`). |
| [architecture.md](docs/claude/architecture.md) | The two threads and what they share; the one `BufferStore` and the four display modes (`Live`, `Capturing`, `Held`, `Waiting`); the display clock and `stream_at_x0()`; the layers to keep straight (`SignalInfo` → `ChannelBuffer` → `SlotSeries` → `RowSet`); the source plugins; the window's chrome (slide-over, badge bar, dock) and when each is built. |
### Invariants worth knowing before editing
| File | What it covers |
| --- | --- |
| [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's overlay children, and the rule that **layout must not depend on content**. |
| [invariants-plot-and-layout.md](docs/claude/invariants-plot-and-layout.md) | What `plot::Figure`/`plot::Plot` can and cannot be asked to do, what a row's contents belong to, the layout editor's and the slide-over's overlay children (with the pick rule), the badge bar's fixed height, and the rule that **layout must not depend on content**. |
| [invariants-readouts-and-cursors.md](docs/claude/invariants-readouts-and-cursors.md) | A readout quotes a stored sample and never a value between two; how the cursors are read back from the plots; the one formatter both panes share. |
| [invariants-data-path.md](docs/claude/invariants-data-path.md) | What rebuilds the buffer and drops history, why decimation at ingest is an **average** and never a stride, and the two rules the `f64` time ring and its cached first stamp impose. |
| [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. |
| [invariants-trigger-and-pinning.md](docs/claude/invariants-trigger-and-pinning.md) | The event window counted in stream seconds end to end, what pins an axis in each mode, and what each pin is measured *from* (the snapshot's own stamps, not the request). |
| [invariants-time-axis-and-window.md](docs/claude/invariants-time-axis-and-window.md) | **No plot interaction changes the time scale**; `apply_time_window()` is the only path that does; how the x labels pick their form; what a fit restores. |
| [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, how a crossing is counted with hysteresis, and which mean over a split is which. |
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and the help pages. |
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the bundled icons and their authoring rules, how the slide-over's click-away coexists with the plot's gestures, how the guided tour opens a page, and how the Mallard manual is opened. |
+120 -18
View File
@@ -11,6 +11,8 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
endif()
find_package(Threads REQUIRED)
# `CMAKE_INSTALL_DATADIR` (the `share` under the prefix) for the help set's install path.
include(GNUInstallDirs)
# --- libadwaita: the shortcut overview ---------------------------------------
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
@@ -61,21 +63,72 @@ add_custom_command(
COMMENT "Compiling udpscope icon resources"
VERBATIM)
# --- help pages: Pango markup in a GResource ----------------------------------
# A second bundle beside the icons'. --c-name is given explicitly so the two generated
# sources cannot collide on symbol names.
set(UDPSCOPE_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/help)
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS ${UDPSCOPE_HELP_DIR}/*.txt)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
COMMAND ${GLIB_COMPILE_RESOURCES}
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
--sourcedir=${UDPSCOPE_HELP_DIR}
--c-name=udpscope_help
--generate-source
${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml
DEPENDS ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml ${UDPSCOPE_HELP_FILES}
COMMENT "Compiling udpscope help resources"
# --- the manual: a Mallard help set, installed for the `help:` URI ------------
# The manual is no longer a GResource compiled into the binary. It is a Mallard set that the
# desktop help viewer (Yelp on GNOME) reads off disk, because that is the only way `F1` can
# open a real, searchable, translatable document and the only way the freedesktop Help System
# Specification's `help:` URI resolves it. The user documentation is then *files*, checked and
# installed rather than compiled in.
#
# The document identifier -- the `<name>` in `help:<name>` -- is derived from the desktop
# file's own basename (its desktop-file id), not written out twice: the same string is the
# `.desktop` file, the GApplication id and the installed directory. The specification installs
# a document at `<datadir>/help/<lang>/<document>/` and resolves `help:<document>` to the first
# match under $XDG_DATA_HOME/$XDG_DATA_DIRS, so the directory name *is* the identifier.
# NAME_WE is not used: it strips the *longest* extension, so it would cut `org.udpscope.app`
# down to `org`. Strip the two suffixes this one file actually carries, in order.
get_filename_component(_udpscope_desktop
${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in NAME)
string(REGEX REPLACE "\\.desktop\\.in$" "" UDPSCOPE_HELP_NAME "${_udpscope_desktop}")
unset(_udpscope_desktop)
set(UDPSCOPE_HELP_SRC ${CMAKE_CURRENT_SOURCE_DIR}/help/C/${UDPSCOPE_HELP_NAME})
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS
${UDPSCOPE_HELP_SRC}/*.page ${UDPSCOPE_HELP_SRC}/figures/*.png)
# The install rule the specification asks for. `CMAKE_INSTALL_DATADIR` is `share` by default,
# so the set lands at `<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)
# --- UI files: GtkBuilder XML in a GResource ----------------------------------
@@ -97,8 +150,9 @@ add_custom_command(
VERBATIM)
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon and
# help bundles are small enough per file never to need it), so the build stays at its four warnings.
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle
# is small enough per file never to need it), so the build stays at its one warning. The manual is
# no longer compiled in, so the help bundle's own warning is gone with it.
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
@@ -127,6 +181,28 @@ else()
message(WARNING "python3 was not found: the test-ui target is not available")
endif()
# --- the manual: an opt-in check, never part of `cmake --build` -----------------
# The manual is hand-written Mallard, and `cmake --build` never reads it -- a broken xref or a
# missing figure is a page in the viewer with no content or no image, not a build failure.
# `tools/check_help.sh` runs yelp-check's four offline subcommands over the set (links, media,
# orphans, ids) and renders the whole set to HTML with yelp-build, so the manual is checked the
# way the `.ui` files are. `yelp-check validate` is *not* used: it fetches the Mallard RelaxNG
# schema over the network, which is not available here and would make the check depend on it.
#
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
# yelp-tools is not a build dependency.
find_program(UDPSCOPE_YELP_CHECK NAMES yelp-check)
find_program(UDPSCOPE_YELP_BUILD NAMES yelp-build)
if(UDPSCOPE_YELP_CHECK AND UDPSCOPE_YELP_BUILD)
add_custom_target(test-help
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh ${UDPSCOPE_HELP_SRC}
DEPENDS ${UDPSCOPE_HELP_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh
COMMENT "Checking the Mallard help set with yelp-check and yelp-build"
VERBATIM)
else()
message(WARNING "yelp-check/yelp-build were not found: the test-help target is not available")
endif()
# --- build info: version, commit and timestamp compiled into the binary -------
# Written at configure time so add_executable can name the file, and again on every
# build so the commit and the timestamp belong to the binary that is running rather
@@ -148,7 +224,6 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
# --- the application ----------------------------------------------------------
add_executable(udpscope
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
${UDPSCOPE_BUILD_INFO}
src/main.cpp
@@ -161,6 +236,20 @@ add_executable(udpscope
src/waveform.cpp
src/event_store.cpp
src/acquisition.cpp
src/source/source_config.cpp
src/source/data_source.cpp
src/source/registry.cpp
src/source/udps_source.cpp
src/source/udps_kind.cpp
src/source/probe_source.cpp
src/source/e1608/e1608_link.cpp
src/source/e1608/e1608_device.cpp
src/source/e1608/e1608_source.cpp
src/source/e1608/e1608_kind.cpp
src/source/e1608/e1608_panels.cpp
src/source/flatudp/flatudp_signals.cpp
src/source/flatudp/flatudp_source.cpp
src/source/flatudp/flatudp_kind.cpp
src/plot_host.cpp
src/export.cpp
src/search.cpp
@@ -180,13 +269,26 @@ add_executable(udpscope
src/ui_status.cpp
src/ui_measure.cpp
src/ui_cursors.cpp
src/ui_dock.cpp
src/ui_math.cpp
src/ui_layout.cpp
src/ui_source.cpp
src/ui_slideover.cpp
src/ui_badges.cpp
src/ui_trigger.cpp
src/ui_signals.cpp
src/ui_signal_page.cpp
src/ui_context_help.cpp
src/ui_tour.cpp
)
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
PkgConfig::ADWAITA)
add_dependencies(udpscope udpscope_build_info)
+897 -110
View File
File diff suppressed because it is too large. Load diff
+12 -3
View File
@@ -42,9 +42,18 @@
The following were built differently from what was asked for, each for a reason `README.md` states
in full:
- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. The retained history
in this mode is **41.943 s** against the default **83.886 s** (measured at 100 kS/s, 4 channels,
`--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
- **The buffer is budgeted in samples per signal, not megabytes** (line 1 asked for "default 200MB
each, configurable as cli argument"). `--buffer-samples N` (default 10M, k/M/G suffixes) is the
number of samples kept for every acquired signal and for the shared time ring, rounded **down** to a
power of two ("up to"; 10M gives 8 388 608), and a separate application setting, `--memory-limit-mb`
(default 8192, also in Preferences), is the hard ceiling on the ring bytes, halving the sample budget
when the rings would not fit. The retained history therefore no longer depends on the channel count
or on relative time. There is no `--buffer-mb` and no compatibility shim; a stored `buffer_mb` is
ignored. The "2 identical buffers" were already a single ring plus latched snapshots (see below).
- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. *As first built* (under
the old byte budget; superseded by the item above, where both modes keep the same span and only
the memory differs) the retained history in this mode was **41.943 s** against the default
**83.886 s** (measured at 100 kS/s, 4 channels, `--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
cost from 24 to 32 bytes — a third more — and `Partition::time_capacity` is floored to a power
of two, so a third more weight costs a full halving. The design document and the implementation
plan both put the cost at ~22%, which is wrong; the per-byte figure of 75% is wrong for the same
+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()
+1 -1
+92 -36
View File
@@ -6,9 +6,23 @@ what was built and why, and `SPECS.md` is the requirement document.
![the main window](img/main-window.png)
*The main window with a source streaming: the signal panel on the right, the plot in the middle, the
status line directly under it, and the toolbar above. The window control — `2 s` — is the first thing
on the left.*
*The main window with a source streaming: the header bar above (the window control — `2 s` — first on
the left), the plot, the badge bar under it with one badge per signal, the status line at the foot, and
the dock on the right with the cursor and measurement readouts and the events block.*
The window is a plot with three things around it:
- the **badge bar** under the plot: a badge for each signal you have switched on, a `+` to add one
(past six they fold into one `N signals` badge), and, on the right, **Trigger** and — only for a
source with controls of its own — **Source**;
- the **side panel**, which a badge opens over the plot's right edge, one page at a time: the trigger,
a signal, the list of signals to add, the cursors, the measurements, the events, or a source's own
controls. A second click on the badge, `Esc`, **Close** or a click on the plot closes it, and it
never resizes the plot;
- the **dock** on the right, which the dock button in the header bar (`s`) shows and hides: read-only
**Readouts** of the cursors and of the measurements, each with an **Edit** button that opens its
page, and an **Events** block. With the dock hidden, or in a narrow window where the dock slides over the plot, the readouts show in a
one-line strip under the plot instead (the cursors' deltas and each measurement's value; **Cursors** and **Measures** open the pages); the strip is there only while the cursors are on or there is a measurement.
## 1. Connecting
@@ -30,7 +44,7 @@ Every launch is its own process, so running two scopes side by side is fine.
## 2. The status line
Directly under the plot, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
At the foot of the window, under the badge bar, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
detail: packets, frames, gaps, per-channel sample counts, the buffer's span and its decimation.
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
@@ -44,14 +58,43 @@ detail: packets, frames, gaps, per-channel sample counts, the buffer's span and
## 3. Signals
The signal panel lists every signal the CONFIG described, with a switch each. **Signals start
disabled — enabling one is what starts acquiring it**, and a signal nobody enables costs nothing.
Drag a signal from the panel onto a plot row to add a trace; drag it again to another row to plot the
same signal twice. The colour is per *signal*, so the same signal is the same colour wherever it
appears.
The **`+` badge** (or `g`) opens the **Signals** page, which lists what can be added: the signals the
CONFIG described that are not acquired yet, each with a tick. **Signals start disabled — ticking one
is what starts acquiring it**, and a signal nobody enables costs nothing. Its badge then appears in
the badge bar. Drag the badge onto a plot row to add a trace; drag it again to another row to plot
the same signal twice, or onto another badge to reorder the badges. The colour is per *signal*, so
the same signal is the same colour wherever it appears.
The panel also holds the per-signal calibration: gain and offset, applied at ingest, so the
measurements and the export carry the same numbers the plot shows.
Clicking a signal's badge opens its own page. Directly under the name are its **Unit**, **Gain** and
**Offset** — each with a pencil button that edits that one value — and then what the stream declared about
it (type, size, range, sample rate, timing). Below come the colour and the **Plot on** buttons for the
rows. Gain and offset are applied at ingest, so the measurements and the export carry the same numbers the
plot shows; a renamed unit is used there too and is saved with the session.
### Splitting a row
Press `l` (or pick **Layout editor** from the overflow menu) to arrange the plots: a **`×`** at the top
left of each plot removes it, a **`+`** on each boundary between rows adds a row, and a **Done** button
in the plot's top-right corner leaves the editor. The plots can also be put **side by side**: a row holds
up to **4** plots, and the figure up to **8** in all.
- **To split a row**, click the `+` at its left or right edge, or the `+` between two of its plots. The
new plot is empty and the plots after it keep their traces. The `+` is dim at 4 plots in the row or 8
in the figure.
- **To resize**, drag the double-headed arrow between two plots left or right. The two neighbours trade width (each
stays at least about 96 px wide); the sizes are saved with the session.
- **To remove a plot**, click its `×`. In a split row it removes just that plot and its traces; in a row
of one plot it removes the row.
Every plot shares **one time window**: panning or zooming in any of them moves them all, the crosshair
sits at the same time in each, and the vertical cursors are the same two times in each. The plots of a split
row also share **one y range**: the y labels are drawn at the left of the first plot only, the
horizontal rulers are the same two values in every plot, and a y zoom, pan, fit or fixed range in any of
them applies to the whole row. The time labels sit under the bottom row, and
under any row above it whose columns differ from the row below. A plot of a split row is named by its row and a letter, `2a` to `2d`: that is what the signals
page's **Plot on** buttons and the status line call it, and a signal dragged
from its badge goes onto the plot under the pointer. The arrangement is saved per source, and a session
saved before rows could be split opens with one plot per row, as it was.
## 4. The window and navigation
@@ -83,8 +126,9 @@ should look like.
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
sits at the edge, and the status line reads `event held`.*
The trigger panel has the edge, the pre/post split (as a share of the event span), Normal or Single,
and Arm/Disarm. Selecting a signal for the trigger enables it.
The trigger button in the header bar (`t`) turns trigger mode on. The **Trigger** badge opens the
trigger page, which has the signal, the condition, the edge, the pre/post split (as a share of the
event span), Normal or Single, and Arm/Disarm. Selecting a signal for the trigger enables it.
- **While a capture fills** the plot keeps drawing live data on the event's clock, so the post side
fills in view; the axis does not move.
@@ -94,6 +138,10 @@ and Arm/Disarm. Selecting a signal for the trigger enables it.
when you disarm is the picture you keep.
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
jumps still gets the window it asked for.
- **Events.** With *record events* on (trigger page), each latch is stored. The dock's **Events** block
lists them newest first and **one click replays** one; **Manage** opens the full list as a page,
where a click selects, a double click replays, and **Compare**, **Accumulate** and **Live** act on
the selection or go back to the stream. The block is dimmed until something is recorded.
## 6. Pause
@@ -104,22 +152,24 @@ does not walk it off its snapshot.
## 7. Cursors
The cursors toolbar toggle switches on two instruments per row:
The cursors toggle in the header bar (`c`) switches on two instruments per row:
- **vertical** — two times, shared by every row;
- **horizontal** — two rulers per row;
- **vertical** — two times, shared by every row (and every plot of a split row);
- **horizontal** — two rulers per row (every plot of a split row shows the same two values);
- **both** — the two together;
- **mix** — the two times, read off each *trace* where they cross it.
The pane beside it reports each trace's values at the markers, and **Reset** puts them back at the
window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
The dock reports each trace's values at the markers; **Edit** beside **Cursors** opens the page with
the mode and **Reset**, which puts them back at the window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
marker is over data.
## 8. Measurements
The measurements pane computes statistics of the **visible window** of a signal: average, top,
bottom, min, max, peak to peak, amplitude, pulse duration, period and frequency. `+` adds one, the
row's pencil edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
A measurement is a statistic of the **visible window** of a signal: average, top, bottom, min, max,
peak to peak, amplitude, pulse duration, period and frequency. `m`, or **Edit** beside
**Measurements** in the dock, opens the page; its `+` adds one, a click on a row (in the page or in
the dock) edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
The dock shows each measurement's value.
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
@@ -127,7 +177,7 @@ split: they count rising crossings of the mid level, one edge per cycle, and a s
setting you can only get wrong.
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
it stops, and once per mode change — so a live view cannot cost you frames. The pane's cost is
it stops, and once per mode change — so a live view cannot cost you frames. The cost is
reported in the exit dump with the app's self-check.
## 9. Math channels
@@ -139,13 +189,14 @@ the square on the right. The orange dot marks the node that carries the channel'
A math channel is derived from the others and is then an ordinary channel: enable it and it is
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
as a source.
as a source. The **Signals** page lists them under a `MATH` heading whose `+` creates one; a math
channel's own page has **Edit graph** and **Delete**.
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
- **Click** a node to select it; its parameters appear in the form beside the canvas.
- **Click an output square, then an input square**, to wire them. An input is only offered the nodes
- **Drag from an output square to an input square** to wire them. An input is only offered the nodes
above it — the graph is a list walked in order, so a cycle cannot be drawn.
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
@@ -180,19 +231,24 @@ per sample, `time`, `stream_time`, then one column per signal.
|---|---|
| `space` | pause / resume |
| `a` | arm / disarm the trigger |
| `t` | the trigger panel |
| `l` | the layout editor |
| `g` | go live |
| `f` | fit to data |
| `d` | add the first free signal to the focused row |
| `c` | cursors on/off |
| `m` | measurements pane |
| `e` | export |
| `?` | shortcuts |
| `t` | enable / disable trigger mode |
| `c` | enable / disable the cursors |
| `m` | open or close the Measurements page |
| `g` | open or close the Signals page (the `+` badge opens it too) |
| `l` | show / hide the layout editor |
| `s` | show / hide the dock |
| `Esc` | close the slide-over page (the guided tour takes it first) |
| `f` | fit to data (the plot's own key) |
| `Ctrl+E` | export visible range |
| `F1` | open the manual |
| `Ctrl+/` | open the shortcut overview |
| `Ctrl+T` | start the guided tour |
| `Ctrl+H` | turn the contextual help on or off |
| `Ctrl+Q` | quit |
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
trigger.
The plot also answers `+`/`-` (zoom both axes), `Ctrl+plus`/`Ctrl+minus` (zoom x only),
`Alt+plus`/`Alt+minus` (zoom y only) and the arrow keys (pan). A keystroke is ignored while a text
field has focus, so typing in the window control never arms the trigger.
## 13. Troubleshooting
+170 -9
View File
@@ -10,8 +10,8 @@ concluding something is a bug.
### Threads
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
Exactly two: a producer thread in `Acquisition` owns a `DataSource` and calls
`source->poll(50)`; the GTK main thread owns every widget and GL context. They share
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
@@ -21,11 +21,169 @@ consumer) and never through the lock. Cross-thread scalars are atomics on `Scope
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
capture open sees which edge it belongs to.
### Sources
What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
- The kinds live in `src/source/`. `register_builtin_sources()` fills the registry explicitly --
there is no static-initialiser registration, which a linker may drop -- and a future `dlopen`
loader would only call `register_kind()`.
- A `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`. `Frame` is
source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept.
- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained
at the top of `poll()`. A panel never calls a source directly.
- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in
`src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling).
- The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and
the data path together; do not bend one frame to carry two clocks.
- A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-<id>`. A disconnected source
leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`).
- Session file naming for a non-UDPS kind: the pseudo-host is `<kind>-<identity>` (characters outside
`[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is
stored as `source_kind` and its settings as `source_opt_<key>`. A UDPS file keeps `<host>_<port>` and
has neither key, so a legacy session is read as UDPS and nothing existing moves.
- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link`
(discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands,
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is
`Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main
thread, the source's lookup goes through `discover_broadcast`);
`e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
never reaches the UI files.
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital
input/counter poll and the waveform tick (every `1 / gen_rate` s, 20 ms by default; one synchronous DAC write per running channel, so a slow board lowers the rate it achieves, which the Analog out panel reports). There is no second thread. A connect attempt (discovery,
handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each
step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end
the producer thread, and `drain_commands()` runs inside the same `try` for that reason.
- Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not
whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the
first one's. Digital lines and the counter are held across a block (a step trace).
- The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes
and a tick callback throttled to 100 ms reads; a panel never touches the source's internals, and writes
only through `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`,
so the source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per
retry); that line is where a user sees why a connection failed.
- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec,
the validity rules and the decoders, with no GTK and no sockets (the probe round-trips it);
`flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form, a signal-row
editor (a name, a type, a count and, while the count is above 1, a "Vector as" drop-down per row). The
datagram has no description, so a list that is wrong is invisible except as dropped datagrams: the size
check is the only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per
size.
- A vector row is shown one of two ways, chosen per row (`Row::wave`, the `w` suffix in the text). As
**channels** it is `count` scalar channels of one sample per cycle, so the list batches like an
all-scalar one. As a **waveform** it is one channel with `rows = count` and
`sampling_rate = rate * count`, one datagram per `Frame` (`num_samples = 1`). The two cannot share a
list because the model spans a frame by its largest element block and `Frame::value` is 0 past a scalar's
one value: a scalar beside a vector block would be zero-padded, so `problem()` demands that a list with a
waveform row be waveform rows of one count, and the form shows that message in the subtitle. The channel
total is capped at 256.
- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the
sample count varies): `round(rate / 100)` datagrams for a list with no waveform row, a partial block
flushed after 100 ms. `rate` is required because `sampling_rate = 0` makes the model size the ring and
the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping
path that ignores the declared cadence.
- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets
`Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the
history survives (`CONFIG` is sent once, from the first `poll()`).
- The form's row editor removes a row from inside its own button's `clicked` emission (GTK keeps the emitter
alive to the end of the emission, and the handler copies the form and the row out of its context first);
every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have
destroyed the sibling widgets the handlers reach by raw pointer.
### The window's chrome: plot, slide-over, badge bar, dock
The main window is a plot with three things around it, each a unit of `src/ui_*.cpp` and none of them
touching the data path.
- **`SlideOver`** (`src/ui_slideover.cpp`) is one `GtkRevealer` holding a `GtkStack`, added as the second
child placed *over* the plot (after the layout editor's buttons) in `GtkOverlay#plot_overlay`. A page is
a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its
own and registered under its id (`trigger`, `source-<id>`, `signals` (the signal list) and
`signal:<name>` (one signal's page, `src/ui_signal_page.cpp`, created on demand by `signal_page_ensure()`
and pruned by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with), `cursors` (the cursors
panel, unchanged) and `measure` (the measurements panel, unchanged; `m` toggles it); `events` (the full events list, `events-pane.ui`'s `events_box`: the Live, Compare and Accumulate
controls and the multi-select list with its thumbnails; open it only through `open_events_page()`, which
refreshes the list first, because the slide-over has no on-open hook). They are registered whether or not
they are open (a closed page's widgets exist, unmapped); `cursors` and `measure` are insensitive until the
stream has signals, by the same `update_signal_availability()` call that sets the dock's sensitivity, and
`events` follows `sync_events_availability()` (below). One page is open at a time and `App::slideover.page`
is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the
badge and a click on the plot close it; the click-away is a capture-phase observer (see
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an
`AdwToggleGroup` under the header is the segmented switch between them.
- **`BadgeBar`** (`src/ui_badges.cpp`) is the fixed-height row between the plot and the status line
(`main-window.ui` `badge_bar`, two slots: signals, left, a scroller holding one badge per enabled signal
and the `+`, or a single "N signals" badge past six; system, right, holding the **Trigger** and
**Source** badges). `sync_signal_badges()` rebuilds the signal badges (from `rebuild_signal_panels()`);
their order is `App::badge_order`, in memory only. A badge is a flat button whose `page` is the slide-over page it
toggles; the Source badge's page is the first `source-*` page, and a kind with none (UDPS, Flat UDP, a
file) leaves it **hidden** (the user asked for hide here, not the usual disable): `sync_badge_bar()` sets its visibility,
pick and focus from `slideover.source_pages` being non-empty every tick, so anything that indexes the badge
(the tour's `badges` step targets the whole bar, `[badges]`) must tolerate it being absent.
- **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`), **280 px**
and fixed. It holds two things, the **Readouts** box and, under a separator, the **Events** block (below). The Readouts box (`src/ui_dock.cpp`, `build_readouts_dock()`): a
**Cursors** block (heading, an **Edit** button that opens the `cursors` page, a `GtkGrid` of a header row
`A B delta` and one line per page line) and a **Measurements** block (heading, **Edit** opening the
`measure` page, one line per measurement: chip, type, value; a click on a line opens that measurement's
dialog; no measurements shows one dim "no measurements" line). It is read-only and **owns no formatting**:
`update_cursors_pane()` and `update_measures()` hand the string they wrote to the page's label to
`dock_cursor_cell()` / `dock_measure_value()` as well, and `rebuild_cursors_pane()` /
`rebuild_measures_pane()` end by rebuilding the dock's lines to match (`dock_rebuild_cursors()`,
`dock_rebuild_measures()`). It holds no tab switcher, no revealer and none of the full panels, nor a source
kind's panels (those are slide-over pages). The window's measurement editor is a dialog
(`open_measure_dialog()`), not part of the `measure` page.
**The readouts strip** (`readouts_strip` in `main-window.ui`, between `plot_overlay` and `badge_bar` in
`left_column`; filled by `ui_dock.cpp`) carries the same readouts when the dock is not beside the plot: a flat
**Cursors** button and one chip (`who` + delta) per cursor line, then a **Measures** button and one chip per
measurement. `sync_readouts_strip()` shows it exactly when `(!adw_overlay_split_view_get_show_sidebar() ||
adw_overlay_split_view_get_collapsed())` **and** (the cursors toggle is active **or** `app.measures` is
non-empty) -- the Dock button put the dock away, or the window is collapsed and the dock is an overlay on the
plot (the strip stays on screen even while that overlay is open), and there is something to read. Inside, the
Cursors group (`strip_cursor_group`) is shown with the cursors on, the Measures group (`strip_measure_group`)
with measurements, the separator (`strip_sep`) with both; with neither the strip is hidden. Besides the tick,
the cursors toggle handler and `rebuild_measures_pane()` call it, so a toggle or an add/delete shows at once. The tick
calls it right after re-asserting `sidebar_wanted` and *before* `set_collapsed()` (so the split view's own
collapse-time `show-sidebar` write is never read); that means the `collapsed` it reads is the previous
frame's, and the strip follows a collapse or a widening one tick late, never through a wrong state. Its labels (`strip_cursor_who`, `strip_cursor_deltas`, `strip_measure_labels`) are rebuilt by
`dock_rebuild_cursors()` / `dock_rebuild_measures()` and written by `dock_cursor_cell()` /
`dock_measure_value()` from the same argument as the dock's, so there is no second formatter;
`dock_set_cursors_sensitive()` is the one writer of the cursor readouts' sensitivity (the dock's grid and
the strip's chip box).
The **Events** block (`build_events_dock()`, also `ui_dock.cpp`) is a heading with a **Manage** button
(`open_events_page()`) over a short list (`dock_events_list`, 112 px, scrolled, no thumbnails): one compact
row per `EventRecord`, newest first, capped at `kDockEventsMax` (24) with a dim "... N more, see Manage"
row after them. A row's cells are a one-character marker (filled = the event on screen; hollow = a member
of the group on screen -- dock only, the page's rows do not mark members; blank otherwise), the title
(`event_row_title`, ellipsized, full text in the tooltip) and `#<seq>`; **one click replays** it
(`replay_event()`, the body of the page's double-click handler). Both views are rebuilt from one
`EventStore::list()` by `refresh_events_pane()`, but on different cadences: **the dock follows every
latch** (`record_event()` refreshes it unconditionally), while the page's list is refreshed per latch only
while the page is open and by `open_events_page()` otherwise. `mark_events_on_screen()` updates titles and
markers in place and never rebuilds either list. The block, and the page, are insensitive (never hidden)
unless `sync_events_availability()` says `event_recording || replaying || events_cached` -- a different
rule from the spec's (see [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)).
There is no events pane, divider or header toggle any more: the page is reached from Manage (or by a
caller of `open_events_page()`), and the plot is never squeezed by events.
**When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour
overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over
too); the trigger form is registered as
a page by `build_trigger_panel`; `build_source_panels()` registers the source pages; and
`build_badge_bar()` runs *after* all of them, because the Source badge needs to know the first page.
Per-tick state (the Trigger word, the Source name, the open marks) is written by
`sync_badge_bar()`, called from `tick()` and from `slideover_open`/`slideover_close`; it writes a label
only when its text changed. Nothing in the badge bar or the slide-over may be read from the producer
thread.
### Data path
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
within the byte budget. The producer always writes it, whatever the trigger is doing —
value ring per channel, sized by `plan_partition()`: `--buffer-samples` ticks (rounded down to a power of two) per ring, halved
until the real bytes fit `--memory-limit-mb` (`Partition::memory_limited` records a clip), and the shared
clock decimated until `--max-window` seconds fit. The producer always writes it, whatever the trigger is doing —
that is what guarantees a full pre-trigger history on the *first* latch after arming.
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
immutable copy of the captured window; see README for why two live rings were dropped.
@@ -101,12 +259,15 @@ Layers to keep straight:
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
to either a `ChannelBuffer` or an `EventSeries`.
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
and removed one at a time by the layout editor), each a
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
everything the rows have to agree on: the x window (one x link group every row joins),
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` **plots** (rows are added
and removed one at a time by the layout editor, and a row can be split into up to
`kMaxCellsPerRow` cells side by side), each plot a
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots; `row(i)` indexes the plots row-major,
`figure_row_count()` counts the rows. The figure owns
everything the plots have to agree on: the x window (one x link group every plot joins),
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
`Figure::link_x()` refuses a `Live` member), the y range of each figure row's cells (`set_share_row_y`: a split
row has one y axis, one ruler pair and one fit), the left gutter and the crosshair.
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
+179 -2
View File
@@ -13,8 +13,11 @@ cmake --build build
./build/udpscope --server 127.0.0.1 --port 8000
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port
```
`--help` lists the registered source kinds.
The app can be launched from the shell rather than only from a terminal, but only on request:
```sh
@@ -52,16 +55,130 @@ here is needed to *build* the application. `blueprint-compiler` and `python3` ar
time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also
prints the linter's advice, which is advice and deliberately not a gate.
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
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: `--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings;
`--server`, `--port`, `--multicast` and `--interface` populate the UDPS entries of the same options),
`--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples` (samples
per acquired signal, default 10M, k/M/G suffixes, rounded down to a power of two),
`--memory-limit-mb` (hard ceiling on the ring bytes, default 8192; also a Preferences row),
`--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards),
`--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000,
the value is required, and the popover button uses 1000 when the flag was absent).
The flag is **not** persisted in the session file: persistence of the enable state
belongs with `todo.md` item 3, alongside the relative-time toggle.
`--renderer cairo|gl|vulkan` exports `GSK_RENDERER` **at the top of `main()`** —
before `choose_source()`'s `adw_init()` and `run_ui()`'s, because GSK reads the variable when
it creates its renderer and setting it later is a silent no-op. `cairo` is the software preset
and also exports `LIBGL_ALWAYS_SOFTWARE=1`: the plot is a `GtkGLArea`, so a display with no
usable GPU fails twice, and this is the pair `tools/make-help-shots.sh` already sets. Nothing
is exported when the option is absent, so GTK's own choice stands.
`--source e1608` selects the MCC E-1608 Ethernet DAQ; its settings are `--source-opt` keys, all optional
(`address` or `mac`+`subnet`, `port` 54211, `code` 0, `timeout_ms` 2000, `scan_rate` 1000 per channel,
`ext_clock`, `trigger` `none|rising|falling|high|low`, `auto_restart` 1, `calibrate` 1, `poll_rate` 10,
`gen_rate` 50 (Hz, 1 to 200: the waveform generator's DAC update rate), `mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all
drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1
alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`,
`plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is
required; a MAC with no `address` is looked up by a broadcast sent on every interface (`Scan`), or by a sweep of
`subnet` (`a.b.c`).
The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol
mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine
per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S`
switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see
the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message.
```sh
python3 tools/mock_e1608.py --port 54711 &
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
--source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1
```
`--source flatudp` receives the MARTe2 `UDPSender` datasource (one datagram per cycle: the signals
concatenated in declaration order, no header, no padding). The datagram carries **no description**, so the
settings are the whole contract: `port` 44488, `bind`, `multicast` and `iface` (a group to join and the local
interface for it), `rate` (the sender's cycle rate in Hz; **required**, it sizes the ring and the ingest
decimation and spaces the samples within a frame), `endian` `little|big`, and `signals`
`name:type[count][w],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`, e.g.
`Trigger:uint8,Ia:float32,Wave:float32[16]`. A count above 1 makes a vector signal, shown one of two ways: by
default as `count` **channels** `Wave[0]`..`Wave[15]` of one sample per cycle each (batched like any scalar
list); with a trailing `w` (`Wave:float32[16]w`) as ONE **waveform** channel receiving `count` consecutive
samples per cycle at `rate * count`, as a UDPStreamer block signal. A waveform row needs a count above 1, a
list with one holds only waveform rows of one count (no zero-padding of scalars), and a list may make at most
256 channels (a scalar 1, a `channels` row `count`, a waveform 1). A datagram of any other size than
the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as
`[flatudp] ...`. Loss is never reported: the stream has no counter. `tools/mock_flatudp.py` is a sender for
a signal spec (`--rate`, `--signals`, `--big-endian`, `--gap S,D`, `--bad-every S`, `--multicast G --mcast-if A`,
`--log FILE`).
```sh
python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals 'Trigger:uint8,Ia:float32,Wave:int16[4]' &
./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \
--source-opt 'signals=Trigger:uint8,Ia:float32,Wave:int16[4]'
```
(Quote the `signals` value: zsh globs the `[4]`. The mock takes the same text with or without the trailing `w`;
the datagram is the same, `w` only says how the app shows it.)
`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
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.
@@ -152,6 +269,66 @@ adapter `PlotArea`'s **figure mode** —
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
A figure row can hold **several plots side by side**, which the host's horizontal split needs and
the library had no concept of: `Figure(std::span<const u32> cells_per_row)` builds one, with
`plot_count()`, `cells_in_row()`, `first_plot_of_row()` and `row_of_plot()` to move between rows and the
flattened plot index, `set_cell_weights()` and `set_col_gap()` to size the cells, and `FigureLayout::cells`
(one rect per plot, flattened) and `FigureLayout::col_gap` to read the result back. `Figure(u32 rows)`
still means one cell per row, so every row-only caller and test is unchanged, and `row_count()` stays the
number of *figure rows* (the host's `RowSet::row_count()` is the plot count, `figure_row_count()` the
rows). `plot(i)`, `link_x()` and the crosshair's hovered index all take the **flattened** plot index.
Cell widths use the height scheme: a floor of **64 px logical** per cell, the weights share the rest,
the last cell closes the row exactly, and a figure too narrow for the floors shrinks the cells
proportionally and drops the column gap. The x labels and title follow the **column structure**: a row
hides its x strip (`XStrip::None`) only when the row directly below has the *same columns* (same cell
count, same normalised cell weights within 1e-4, empty weights = equal), so `[1][2]/[3][4]` has an axis
under 3 and 4 only while `[1][2]/[3]` has one under 1, 2 and 3; the rule is structural, transitive down a
chain and decided before the measurement pass (`same_columns()` in `figure.cpp`); `set_x_labels_only_on_bottom(false)`
labels every row. The left y gutter stays one maximum over the whole figure.
The cells of a figure row can **share one y range**, symmetric to the x link
(`docs/superpowers/specs/2026-11-03-split-shared-y-design.md`):
- `Figure::set_share_row_y(bool)`, default off so every existing figure and `tests/test_figure.cpp`
read as before; the host turns it on in `RowSet::rebuild_all()` before the cells are populated. When
on, the cells of each figure row form an implicit y link group, rebuilt with the cells.
- Link semantics: `layout()` runs a `sync_y_links()` that adopts a member's changed `y_range` as the
group's and pushes it to the others, so a wheel, pan, box zoom or axis drag (all `set_y_range`) needs
no controller change; a manual `set_y_range` on a member clears the group's auto flag. A member's own
autoscale is suppressed and the group owns the flag: it fits to the union of its members' visible
traces (cells with no trace are ignored; a log axis takes the union in axis space), and `fit_all` and
a double-click fit the group, not one cell. The group only follows on a laid-out frame, so a caller
that sets a range or a fit and reads it back needs one `layout()` in between.
- `YStrip {Both, None}` and `Plot::set_y_strip_mode`, mirroring `XStrip`: `None` forces the y label
width, title width and reserved floor to 0. `Figure::layout` sets `None` on every cell but the first
of its row, structurally from the column index and never from content, and skips `None` cells in the
common-gutter maximum. A y axis drag can therefore start only in the leftmost cell.
- **Cell placement is by plot width**: the row's y gutter comes off the row width first, then the cells
share the rest by the existing `floor + extra * w / total` rule, the floors applying to plot widths.
Equal weights give equal plot areas; the first cell's rect is gutter plus its plot width and the rects
still tile the row exactly.
- **The right gutter belongs to the last cell.** In a shared-y split row only the last cell gets
`set_right_gutter()`; it is taken off the row beside the y gutter (capped so both fit in half the row
and never beyond the figure's right gutter) and the last cell's rect grows right by it. The gap between
two plot frames is then exactly `col_gap` rather than `col_gap` plus a gutter; unsplit rows and rows
with sharing off lay out as before (`figure_split_row_gap_*`, `figure_row_gutter_without_sharing_is_the_old_layout`).
In the GTK4 and GLFW adapters the pointer is routed by **cell rect** (`row_in(rows, x, y)`: the
containing cell, else the nearest by squared distance), one `input::Controller` per plot. The tests are
the `figure_cell*` cases in `tests/test_figure.cpp` and `glfw_host_routes_by_cell`.
`plot::data::RingBuffer` carries two writes a plain SPSC ring has no use for and this app
does: `amend(absolute, v)` rewrites a retained slot when a late packet's data finally
arrives, and `clear_to(absolute)` empties a ring and **resumes its count at a given
origin**. The second exists because a math channel's ring is recomputed from its inputs'
*retained* window: `clear()` would restart it at 0 and leave every absolute index —
`base()`, `produced()`, `copy_from()` — short by that window's base, which is the
desynchronisation `live_window_locked()` then reads as an empty window (see
[invariants-math-channels.md](invariants-math-channels.md)). Its guards are
`ring_clear_to_resumes_the_absolute_count` — a derived ring refilled from a source's retained
window must come back with the source's own `produced()`/`base()` — and
`ring_clear_to_zero_is_clear`.
The render-quality work went there for the same reason: `kTextFS` now **filters** the
glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling
it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto
+77 -9
View File
@@ -20,7 +20,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
comparison on purpose: it is not a size, but it is what the slots *mean*, and keeping samples
under a changed `base_dt` would re-date the whole ring.
**Construction is the cost.** A `RingBuffer<T>` holds a `std::vector<std::atomic<T>>`, which is
value-initialised, so making a store touches every byte of `--buffer-mb` — about a second per
value-initialised, so making a store touches every byte of the planned rings — about a second per
gigabyte, mostly first-touch page faults — while `reset()` is one relaxed store per ring plus an
`invalidate()` and an epoch back to zero. It must leave nothing of the old history behind, which
is why it clears the time ring, the absolute-time ring, every value ring, and both the published
@@ -65,8 +65,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
trade and is why the status line's `rate` field is worth reading.
- **A file is ingested too, so the loader decimates by the same factor.** `load_waveform_into_model()`
sets the target span to the recording's own length and enables every column, and the plan that
comes back may well decimate: 20 s of 10 channels at 500 kS/s is 480 MB of rings, and the budget is
`--buffer-mb`. The loader therefore reads `partition().time_stride` **after** the enables and pushes
comes back may well decimate: 20 s of 10 channels at 500 kS/s is 10 M samples a channel, and the budget is
`--buffer-samples` (rounded down to a power of two, 8.39 M by default). The loader therefore reads `partition().time_stride` **after** the enables and pushes
one slot per that many rows, carrying the group's mean at the group's mean stamp — the packet path's
filter, written a second time because a file does not arrive in packets. Pushing every row instead
overruns the ring and leaves the file as **its own tail**, with nothing to say so: a 20 s event
@@ -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
`tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see
[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
`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
@@ -157,7 +169,36 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
(`kGlitchPackets`, 256) is counted in **packets, not samples**, so one hole's sample cost
is `256 × batch_` and scales with the stream's batch — 25 600 samples at a batch of 100,
2.56 M at a batch of 10 000 — which is the number to weigh against the ring, not the 256.
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
- **A reservation is weighed against the wire, and never booked on the counter's word alone.**
The counter is a *claim* about the stream; the arrival clock is a *measurement* of it, and only
the two together justify writing NaN into the ring (`Acquisition::gap_plausible()`, called
before anything is booked *and* before relative mode's own clock advance, because the advance
and the reservation are the same claim). Two tests, either one refusing is enough:
- **The arrival clock.** `missing` packets would have taken `missing` frame intervals to arrive,
so a gap whose elapsed arrival time is under half of that is refused. Half and not one because
the socket clock jitters and a drain can compress two frames' arrival times -- and a refused
gap costs a *shading*, never data, so the test is deliberately permissive.
- **The ring.** `missing × batch_` samples become `missing × batch_ / time_stride` slots, and the
hole may not lap `time_capacity`. This is the *sample* form of the rule the 256-packet cap
states in packets -- the cap's own comment says 256 packet spans in one go is an outage, and
the invariant above says the number to weigh against the ring is `256 × batch_` and not the
256 -- and it has to exist in samples because a hole that laps the ring erases the history it
is meant to annotate, can never be backfilled (its slots are already overwritten) and is never
forgotten (`forget_holes_before()` waits for slots the ring has passed), so it leaves a
*permanent* band over a buffer that is all NaN.
What its absence looks like, which is why it is written down: a device whose counter counts
*samples*, or frames of an inner layer, reports a forward jump on every datagram while the wire
delivers on time. Every frame then reserves, and with `relative_time` every frame books NaN
slots by construction -- one band over the whole window with the samples washed out,
`backfilled=0` (no packet was ever missing, so nothing can repair it), and the status bar reading
`live` at the right rate the whole time. The refusal is counted in `StreamStats::gaps_refused`
and said once per connection with the numbers, because the status bar's `gaps` climbs either way
and the state is otherwise invisible; `tools/mock_udpstreamer.py --counter-step N` is the mock
shape (its counter is otherwise always +1, so this is the one wire shape it could not produce),
and `gaps_refused` rising while `nan_slots` stays 0 is the reading. A refused gap is not a silent
one: the timeline is not stretched over loss that did not happen, so it shows as the x
discontinuity it is.
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
the store, so `produced()` restarts at 0 and a hole's `slot_begin`/`tick_begin`/`x_begin`
describe a buffer that is gone; it therefore clears `holes_` and bumps
`ScopeModel::plan_generation()`, which the producer compares against the value it last saw
@@ -165,9 +206,36 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`backfill_from()` derives a slot index from — without it the amend falls outside the ring
and repairs nothing while the hole is still marked repaired, a band saying the opposite of
the truth. The generation is the signal and the stride/channel-count comparison is not: a
reconnect with an identical CONFIG rebuilds the store leaving both unchanged. `slot_base_`
is **not** reset — it is relative mode's clock origin, samples since the stream began, and
relative time must not jump backwards when the user retypes the window.
reconnect with an identical CONFIG rebuilds the store leaving both unchanged.
- **The relative clock is accumulated in *seconds*, and a frame is only stamped into a model that has a
streamed channel.** Relative time's stamp is the declared interval counted from the stream's own
origin — `Acquisition::rel_base_s_`, which is advanced by the frame's own samples and by the samples a
reported gap spans. It is deliberately **not** `position × Partition::base_dt`, and the difference is
one defect: a plan is rebuilt whenever the channel list changes (a CONFIG, an enable/disable, a
session's math channels), and `base_dt` is the **1 kHz fallback** until a *streamed* channel declares a
rate. Recomputing from a position made a plan that *shortened* the interval put every later stamp
behind the one already written, and the frame loop's monotonic clamp then held the clock there for the
rest of the run. What that looks like: `ring=[a..a]` standing still while `n` climbs,
`history` never reaching the pre-window (so a trigger never latches and the display stays in `Waiting`,
which is blank), and — the reading that names it — `[udpscope] warning: the clock stalled ...`, printed
once per connection the first time a whole frame's stamps are all behind the clock. Accumulated, a plan
change is only a slope change and the clock cannot go backwards. The entry is the *other* half: the
ingest used to accept a frame as soon as `channels()` was non-empty, and a session's math channel
applied before the stream's own signals arrive is exactly one channel with a fallback plan. Such a
frame has nowhere to go (the value loop skips every derived channel) and its plan's interval is not the
stream's rate, so `on_data()` now requires a **streamed** channel and drops the frame otherwise, the
same way it always did for an empty list. `rel_base_s_` is **not** reset across a reconnect — the
origin survives, it is relative mode's clock origin — while `base_dt`'s *use* is what this rule took
away: it is read to *advance* the clock, never to recompute where the clock already was.
- **The stream's epoch is adopted *once per connection*, and "unset" is a state, not the value zero.**
`epoch` is the stream time the display's x = 0 means, taken from the first frame's stamp. The test for
"not adopted yet" used to be `model.epoch() == 0.0`, which relative mode breaks by construction: its
first frame is stamped exactly `0`, so the *second* frame adopted the origin — and with
`rel = max(stamp - epoch, last_stamp_)` that put a whole frame's samples on one stamp and shifted every
later x by a frame's span. `Acquisition::epoch_adopted_` is the flag, re-armed by
`reset_session_state()` because a new session's clock is a new clock; the warning line above is what
caught it.
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
the published window to the value ring's base and both rings keep sliding afterwards —
their capacities are equal, so there is no slack — which means the oldest few samples
@@ -253,8 +321,8 @@ already scaled against, and re-anchoring mid-stream would move x. The clock *cho
per connection (`time_source_ = 0` on CONFIG) for the same reason: a session that dropped may
come back with a different answer.
Relative time's `slot_base_` also survives: it is samples-since-the-stream-began, the user's
chosen origin, and must not jump backwards because the link blipped.
Relative time's clock (`Acquisition::rel_base_s_`, seconds accumulated at the declared interval)
also survives: it is the user's chosen origin, and must not jump backwards because the link blipped.
## There may be no store at all
+392 -82
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.
@@ -49,58 +49,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
one of them with reality): it is computed on the first *tick*, because the dump
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
how the first version of that line earned them. What stays in the menu is what acts on the *data*
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
- **Seven of this app's icons are its own, and they are a `GResource` compiled into the
(Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list
is the `signals` slide-over page, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
- **An overlay handle that follows what it drags reports gesture offsets in its own moving
coordinates; add its displacement back.** A `GtkGestureDrag` measures its offset from where the
press began *in the widget's own coordinates*. The layout editor's column handle sits on the
divider and `sync_layout_editor()` moves it every frame, so after a few motion events the
offset is the pointer's travel **minus** the handle's travel, and the divider converges on half
the pointer's travel (`D = P - D`). Record the handle's x in the overlay's coordinates at
begin (`gtk_widget_compute_point`) and add `handle_now - handle_begin` to the offset on every update
(`layout_col_total_offset()`). The same applies to any future handle that is repositioned
from the state its own drag changes.
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
measurement ruler, and the stock names those buttons used to carry said something else
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math,events}-symbolic.svg`
are the five action icons; beside them in `src/icons/scalable/apps/` are the two
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
application's own icon and is where the **in-process** window icon resolves from; it is also
the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files
into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed
copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell
cannot read a GResource compiled into the binary).
All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs
`glib-compile-resources`) and reached through **one**
`gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top
of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon,
set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place
ahead of both. `adw_init()` calls `gtk_init()`, which is what makes
`gdk_display_get_default()` valid there.
A resource rather than files beside the binary because udpscope is run straight out of the
build tree and has no install step. Five authoring rules, each of which fails quietly:
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is
therefore a filled `path`** — including anything that wants to look stroked, which is drawn as
a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on
purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes
its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the
other.
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
cancel instead of uniting. All of them run clockwise.
* **A malformed SVG is a build error, and that is the only place it can be caught.** Every
`<file>` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which
makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which
is illegal and which librsvg refuses the whole file for, fails the build with a file, a line
and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen
within comment`). It has to be here because **no runtime check can see it**: librsvg refuses
the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and
the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under
Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and
`gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this
project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it
ships with libxml2, which GTK already requires.
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
`toolbar icons:` in [probe-readings.md](probe-readings.md)): a resource path that was never added does not raise an
error, it draws `image-missing`.
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,resize-cols}-symbolic.svg`
are the six action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
the Preferences window's icon and the **Dock toggle's** (the ruler: the dock is the readouts).
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
@@ -129,19 +97,35 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
the dump is the assertion.
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
- **The About dialog and the shortcut overview are opened by `show_about()` and `show_shortcuts()`,
and each closes with the matching call; the manual is *not* one of these.** `show_help()` hands
the `help:<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 *that pair*: the shortcut overview. It closes with
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
the shape that prevents the double-open defect across all three.
next attempt to open the same window. **A `close-request` handler's return value is load-bearing
too, and its absence is silent**: the signal is `gboolean`, `TRUE` means "handled" and suppresses
the default handler that destroys the window, and `G_CALLBACK` is a cast — so a `void` lambda in
the slot compiles without a warning and GTK reads whatever the compiler left in the return
register as the verdict. The Configurations dialog (`open_configurations()`) was written that
way, and the built binary returns non-zero from the lambda, so **both its Close button and the
window manager's close button emitted `close-request`, were told "handled", and the dialog stayed
up** — "the configurations dialog cannot be closed", which no reading of the callback's type can
see. Every `close-request` handler therefore returns `FALSE` explicitly (the chooser's
`on_chooser_close()`, the overview's `on_window_close()`, the math dialog's, and this one). The About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
and not a `GtkWindow`**: it closes with `adw_dialog_close()` and clears on `::closed`, there being
no `::close-request` on an `AdwDialog`. It shares one `on_dialog_closed()` handler with the About
pointer, keyed by a **pointer to the pointer** it must null (`&g_about`), which keeps the handler
honest without knowing which dialog it is. One `GtkWidget*` (or `AdwDialog*`) per window, nulled
in its own close handler, is the shape that prevents the double-open defect. `close_help_windows()`
(misnamed now) closes just the two dialogs; a failed *launch* is reported by the launcher's own
callback with a `GtkAlertDialog`, so a click that cannot open the manual says so. 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
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).
@@ -155,19 +139,90 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
at all inside a box, so without the request it is allocated nothing and the page renders its text
with a silent gap where its screenshots should be — which is what the first version did.
- **A help page is a Mallard file, and the manual is *not* in the binary.** `help/C/<id>/` holds
`index.page`, one `<topic>.page` per subject, and the `figures/*.png` the pages link, and the
whole set is installed (the `install()` rule in CMakeLists) to `<datadir>/help/C/<id>/` — the
path the freedesktop Help System Specification's `help:` URI resolves `help:<id>` to. The
document identifier is *derived from the desktop file's basename* (`org.udpscope.app.desktop.in`
→ `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install
directory at once; `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one
place. `show_help()` opens `help:<id>` with `GtkUriLauncher` (Yelp runs in its own process, so
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.**
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
while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that
(The events pane that taught this is gone -- its list is a slide-over page now, with no divider -- but
the rule holds for any paned.) With the
position left alone, `shrink-start-child` or not, a closed side child's minimum is reserved as an empty
column, and the plot pays for a pane nobody opened. The position is the only knob that
says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before*
zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is
still needed, or the minimum would clamp even a zero position.
@@ -175,13 +230,68 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a
310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel
window. Every vertical revealer in the side panel hides this, because there the leftover goes down
window. (That pane was a revealer beside the plot; it is a slide-over page now, so no revealer sits in
a horizontal box any more.) Every vertical revealer in the side panel hid this, because there the leftover goes down
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
**The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow`
(`dock_scroller`) holding only the compact **readouts** (`src/ui_dock.cpp`). It
replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source
can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one
this trap teaches: full panels competing for one narrow column each left every one of them short of room.
The full cursors and measurements panels are now the `cursors` and `measure` **slide-over pages**, reached
by the readouts' **Edit** buttons, and `m` toggles `measure` (it no longer scrolls the dock;
`dock_show_section()` is gone); the signal list is the `signals` slide-over page, which `g` and the `+` badge
open. The header's Cursors toggle no longer opens anything: it only enables the cursors. A *feature's* on/off is no longer a visibility at all -- the
**cursors'** toggle sets its pane's **body** insensitive (and the dock's cursor grid beside it) -- the readout has nothing to show with the
instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch
(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 section scrolled out of view is still laid out, but
**ask about state, not about size** still holds for anything a tick reads.
- **The dock is shown but insensitive until the stream has signals ("disable, do not hide").**
`update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish
alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays
sensitive throughout. The `cursors` and `measure` **pages follow the same condition** through
`update_signal_availability()` (called from `update_side_visibility()` and the per-frame block), so a
page opened by `m` before the stream has signals is insensitive, not refused. `[readouts] ... pages_sensitive=`
reads it. The wish is persisted (`dock = true|false` in the session file) and a change of it
schedules a save. `dock:` in the dump reads the result.
- **The signal badge is the drag handle, and a drop handler only moves widgets.** Dragging a signal onto a plot
starts on its badge in the badge bar (a `GtkDragSource` on the badge's button, payload the signal index as a
decimal string, taken by the plot area's existing drop target); the list's rows are not drag sources. A drag
starting with the slide-over open would have part of the plot hidden under the panel, so the source's
`drag-begin` closes it (which only restyles the badges: nothing is destroyed mid-drag). A click without movement
never reaches the drag threshold and still opens the page; past it the source claims the sequence and the
button's click is cancelled. Each badge is also a `GtkDropTarget` for reordering: `on_badge_drop` calls
`gtk_box_reorder_child_after` and rewrites `App::badge_order` from the widgets, and **never rebuilds the badges** --
the dragged badge and the target whose `drop` emission is running must both survive it. A foreign drag whose string is not a
signal index is refused. `[sigbadges]` reads the controllers, the payload and the reorder; `pane drag:` reads the close.
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The
trigger pane's `[edit]` button is `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
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
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking
either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps:
a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard
(`App::panel_switch_sync`) or the program's own write comes back as a click; and the switch must
stay *out* of what the pane disables, because a control inside an insensitive parent cannot be
clicked — a switch that greyed out with its own pane could never turn it back on. That is why the
sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane.
- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to
choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the
list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four
choose.** Tearing the window down finalizes the events page's buttons, then its list box, and the
list's own `selected-rows-changed` then reaches a handler holding the freed pointers (`update_group_buttons()`
therefore keeps its `app.window == nullptr` guard): four
`Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
*not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
@@ -198,22 +308,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
picture across `refresh_events_pane()`, which removes every row first: the next reader of that
pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals
read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it.
read as "the page is empty"). A rebuild has to null what it is about to destroy, not only rebuild it
(`rebuild_events_pane_list()`, the page half of `refresh_events_pane()`, nulls `events_thumb` before it removes the page list's children). A handler on a
row that rebuilds the list destroys the widget it was emitted from: `outlive_the_rebuild(widget)`. The dock's
rows are a second list built from the same records; the dock row's click handler is safe only because
`replay_event()` never rebuilds the dock list.
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the
picture keeps a *paint-able* and takes an allocation, so a probe that checks
`gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event
pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
`gtk_picture_get_paintable()` reads success while the page shows an empty rectangle (the event
page's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says
so. A picture that must be on screen is also a picture with a **height request**: `can_shrink`
alone asks for no height at all in a box.
- **A cache that is the *only* source of a widget's content must be created by the thing that reads
it.** The event pane's thumbnails come from an `EventThumbCache` hung off the list box with
it.** The events page's thumbnails (the dock's rows have none) come from an `EventThumbCache` hung off the page's list box with
`g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all.
Nothing ever created it — so for as long as the pane existed, **every** row came up without a
Nothing ever created it — so for as long as the list existed, **every** row came up without a
picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache
there is not a slow path, it is a blank one; `event_thumb_cache()` therefore creates one on first
use. Owned by the **list**, because nothing else has the pane's lifetime: textures of a list that
use. Owned by the **list**, because nothing else has the page's lifetime: textures of a list that
no longer exists are so much held memory, and the destroy notify is where they are unref'd.
- **Hover magnification is a tooltip, not a popover of ours.** A row's thumbnail is 138 px wide and
the stored preview is 480: the small one says *which* event this is, and the zoom says what is in
@@ -412,3 +526,199 @@ 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 --
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.
## 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.
- **A hint on a slide-over page's heading is attached all the time and drawn only while the page is open.**
The trigger's, signals', cursors' and measurements' headings (`trigger_head`, `signals_head`,
`cursors_head`, `measures_head`) live on pages that exist, unmapped, whether or not they are open, so
`context_help_sync()` attaches their "(i)" like any other and the count (`hints=12`) does not depend on
which page is up. There is **no second code path** and the dock's readouts carry no hint of their own: the
pages' hints say what the dock shows too (the cursors' and measurements' bodies mention the read-only copy
and the Edit button). A probe that must *see* one drawn needs its page open, like every other reading off a page.
- **The tour has one stop for the readouts and none for the pages behind them.** The dock's stop
(`side`, target `side_box`, no `page`, so it closes the panel) describes the readouts, the **Edit**
buttons and the click on a measurement; the `cursors` and `measure` pages are reached from there rather than
walked. A stop for either would be one more `TourStop::page` (see the next bullet), and the plan's
length is asserted (`steps=6`, `steps=5` with the dock put away) -- a new stop changes both.
- **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.
- **Click-away coexists with the plot's gestures because the observer never claims the sequence.** The
slide-over closes on a press anywhere outside it, and the plot underneath already owns pan, zoom, drop and
right-click. A `GtkGestureClick` added to `plot_overlay` in `GTK_PHASE_CAPTURE` sees the press before the
plot's own controllers and **does not call `gtk_gesture_set_state()`**, so the sequence stays unclaimed and
the plot's gestures run exactly as before; claiming it (or putting a full-area target over the plot) would
swallow the very pan the click was part of. The observer decides inside/outside by
`gtk_widget_compute_point()` into the revealer plus `gtk_widget_contains()`, never by pick. The press
that closes the panel still reaches the plot, which is accepted: it is one click and not a drag the user
started on purpose. `[slideover]` reads `click_inside_closes=0 click_outside_closes=1`.
- **`AdwToggleGroup` is the segmented switch** (libadwaita >= 1.7; the build asks for 1.8). It replaces the
`AdwViewSwitcher` for a source with several panels (the E-1608: Analog out | Digital I/O). Its toggles are
named by the slide-over page ids, and the group's `notify::active-name` is the *input*; writing it
ourselves (`slideover_open` on a source page) goes through `SlideOver::syncing`, or the program's own
write comes back as a click and re-opens the page it just opened. It is hidden unless a source page is
open and there are two or more, which `[badges] switch_visible=` reads.
- **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 --
by a hook in the main window and by a stack in the chooser.** The main window has no page stack any
more: its pages are the slide-over's. A `TourStop::page` there is a slide-over page id (`trigger`), and
`TourView::on_page(page_user, page)` opens it -- or, for a stop whose `page` is null, **closes** the
panel, so the card never points at the status line over an open panel. `tour_end()` calls
`on_page(nullptr)` *only if a walk was running* (so Skip, Esc and the last Next close a panel the tour
opened, while dismissing an idle tour does not close one the user opened). The chooser has no hook and
keeps `TourView::page_stack` (an `AdwViewStack`, selected by name). `select_view_page()` is the single
entry for both, and `show_step()` calls it before anything is measured.
Two rules follow from a panel that *slides*. The page switch is **not** followed by a one-shot measure:
`tour_update()`, which already runs every frame, fills the spotlight in on a later one, and for a stop
with a page it waits for `TourView::page_settled` (the slide-over's `gtk_revealer_get_child_revealed()`)
-- until then the target is moving, so the surface is dimmed with an empty hole. And **the hole is the
target's bounds clipped by every `GtkScrolledWindow` above it** (`tour_target_bounds()`): a form taller
than the panel's scroller overruns it, and an unclipped hole spotlights the badge bar and the status
line under the panel. A target scrolled out entirely is a *measured, empty* rectangle (the function
returns true), not a failure -- `tour_update()` zeroes the hole on it, because taking "false" for "not
measured yet" keeps the previous frame's hole standing. A stack allocates a 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)) -- 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**; a stop that names a page of a view with an `on_page` hook is **always**
kept (the hook opens the page whatever the dock is doing, since the slide-over does not depend on it);
and in a view with a `page_stack` a stop that names a page is kept when the stack is mapped -- a wrong
page leaves the stack mapped, so the page switch reveals it, while a collapsed pane leaves it unmapped
too and the stop is dropped. So the dock's stop is dropped in a window narrow enough to overlay it, and
the main window's tour is five steps there (`[toursurface] collapsed: ... steps=5`: the signals stop names
the `signals` slide-over page, which does not depend on the dock either). The filter
lives in `tour_begin()`, not in each window's `start_tour()`, so the two windows cannot drift
into two rules.
- **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()` *and* `get_mapped()`, 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 widget on a closed slide-over page, `trigger_*`
while the panel is shut, would read a bogus `630,46,0,0` without the mapped test), 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.
+33
View File
@@ -301,6 +301,17 @@ with at most the transient a re-start costs. The exit dump's `math '<name>': fin
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
`hole_slots` is this defect returned.
- **A math channel is a row of the *signal* list, under a `MATH` divider, and there is no math pane.**
`append_math_signals_locked()` already puts every channel in `signals()`, so the pane was the same
list drawn twice; the divider is what the pane uniquely contributed. It is non-selectable and
non-activatable -- a heading that highlighted under the pointer would read as a row you can plot --
and it carries the `+` that creates a channel, appended even when there are none, because a `+` that
waited for a channel to exist could never create the first one. A math row's menu is *Edit graph…*,
*Default colour*, *Delete*: no Information and no Gain/Offset rows, because calibration is applied at
ingest and a derived channel has no stream to describe, and no rename, because the node editor owns
a channel's name and unit and a second place to change them would be a second answer to the same
question. A row's tick is the channel's own switch and must reach `App::math` -- the copy the
session file is written from -- or the list and the file disagree about what "enabled" was.
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
@@ -312,6 +323,28 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
while the edit runs -- and never 0.
- **A recompute rewrites a ring *in place*, and both of the ring's counters have to survive it.**
`recompute_math_locked()` replays the inputs' **retained** window, which begins at the time ring's
`base()` and not at zero, so an emptied derived ring is restarted with `clear_to(base)` and never
`clear()`. With `clear()` the refill renumbered the channel from 0 and left its `produced()` short by
exactly `base` -- and `live_window_locked()` clamps its window against *every* channel's `produced()`,
so that one lagging channel answered `n = 0` for the live view, every pause view **and** every trigger
capture, until a whole ring of fresh data had gone by. That is the "a math edit freezes acquisition,
and the trigger stops firing on a channel that is not even derived" report, and it is **invisible
before the ring first wraps** (`base == 0`, where the two numberings coincide), which is why it reads
as intermittent and uptime-dependent. The dump's `math '<name>': N samples vs M time slots` is the
reading -- both numbers are `produced()`, so a mismatch *is* this defect -- but only on a run whose
ring has wrapped: give it a small `--buffer-samples`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
arranges exactly that, and it exists because **`math_probe` structurally cannot** reach this state:
every edit that probe makes follows a structural change, which replans the buffer and restarts every
ring at absolute 0, where both halves of this are identities.
The published window is the second counter. `acquire()` is incremental, so a rewrite that leaves
`produced()` where it was publishes **nothing** and the pyramid -- what the renderer and every
measurement read -- goes on describing the samples the edit replaced. `ChannelBuffer::republish_from(base)`
is what puts it back, and it takes the ring's base rather than 0 because `invalidate()` would make the
next `acquire()` walk every absolute index ever produced (an hour at 1 MS/s is 3.6 G appends) to publish
one window.
- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so
a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute
nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside.
+215 -14
View File
@@ -1,6 +1,6 @@
# Invariants: the measurements pane
Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted.
Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted, and which mean over a split is which.
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.
@@ -16,9 +16,42 @@ 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)
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
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
`stats` argument is for.
first positions, the sum and the count, so average/min/max/peak-to-peak need no pass of their
own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
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 window a value is computed over is the *pin* while the view is pinned, and the viewport
otherwise.** A pinned view -- a held event, a paused stream, an opened file -- is a *still
frame*: the data behind it does not change, so a pan or a zoom only looks closer. Measuring the
pin (`RowSet::pinned_range()`, the same accessor `hold_pinned_window()` clamps into, so "what
frames this view" has one expression) is what makes a gesture change **no value at all** --
and it is decided in `update_measures()`, at the call site, rather than inside
`MeasureWindowReader`, because the decision is also *booked*: the window
`MeasureWindowReader` is handed is the same one the "it moved" test compares against
`App::measure_window`, so a gesture inside the pin spends no recompute either. A reader that
chose its own window would leave a pinned view recomputing on every gesture frame for a number
that cannot change (457 passes in one run is the shape that mistake took).
This test has been wrong in both directions and the reasoning is worth keeping. Suppressing it
while held (`moved = ... && !held`) left the pane describing the whole 2 s event
(`for=[0.47..2.47]`) while a 50 ms slice of it was on screen; *removing* the exemption was the
opposite mistake, and it is the reported "when measuring a still frame (a triggered event, or a
paused stream) moving the zoom makes measurements such as average or top change, which should
never happen". The two are one question asked of two different windows: **which window is this
pane about** (the pin, while pinned) and **has it moved** (asked of that window in every mode).
Answers: a pinned view still recomputes when it should -- a fresh latch, a re-stated pin, a new
snapshot, the held/live change -- and a settled one satisfies `swin == measure_window` exactly
and so reads `moved == false` on its own.
`[measurepin-probe]` is the reading: on a latched event zoomed to a quarter of itself,
`same=4/4 for_same=1 pass_delta=0` with the values bit-identical and `for=` still at the pin;
the same on a pause taken from a held event. Live is the case that must **not** be swallowed,
and it reads `same=0/4 for_same=0` with a positive `pass_delta`, `span_before=2.000000`,
`span_after=0.125000` and `follows=1` -- the results window narrowed with the viewport and the
values moved with it. The
one path that is *not* this rule is the window control: `apply_time_window()` unpins
(`set_time_window()` clears `pinned_`), so for the frames before the held branch re-states the
pin the view really is live-framed and the pane measures the viewport -- `[measure-trace]`
prints `pin=off` for exactly those frames, which is what makes them legible rather than looking
like the defect again.
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
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
@@ -57,10 +90,19 @@ 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
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`
double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must
be. Four things this rests on, each of which breaks it silently:
double-counted every opened bucket. That read as `top=0.614304` on a unit sine against the 2/pi a
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
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, so the assertion that used to be
`top == 0.636620` on a sine now belongs to `above 50`, which *is* a mean over a side and reads
exactly 2/pi again -- see the band-average invariant below, and the flat-level one for what
`top` reads instead. 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
`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
@@ -76,9 +118,160 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **A pass costs the window's buckets times the pane's *measured channels* and its *distinct
levels*, and the window's own size multiplies all of it.** The cadence above bounds *how
often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at
the user's scale -- a **15 s** window, `--buffer-samples 100M` (so `time_stride` stays 1 and the
window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with
`[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading
`recomputes`/`each`/`covered`/`buckets`/`raw`:
| pane | `each` | `raw` | tick mean |
|---|---|---|---|
| empty | -- | -- | 1.34 ms |
| 1 measurement, 1 level | 6.58 ms | 0.1 % | 1.99 ms |
| 4 measurements, 4 shares on **one** channel | 10.28 ms | 0.4 % | 2.38 ms |
| 16 measurements, 16 shares on one channel | 24.55 ms | 1.2 % | 3.86 ms |
| 4 measurements, 1 share each on **four** channels | 35.69 ms | 2.6 % | 4.58 ms |
So a pane is priced per measured **channel** (one `MeasureWindowReader`, one fetch, one scan)
and per **distinct share** (one accumulator), and the window's samples are the multiplier on
both: the same pane over a 2 M-sample window is the 1.2--1.6 ms a recompute this pane's
budget was fixed at, and a machine's ten noisy channels at a 15 s window is **tens of ms a
pass**, several times a second. That lands on the **tick** -- `update_measures()` runs inside
it -- so `frame cost:`'s tick mean is where a pane's cost shows (1.34 -> 4.58 ms above), and
it is *not* an ingest problem: `gaps=`/`packets=` stay exactly clean even though each
`MeasureWindowReader` holds `model->mutex()` for its whole life, because a socket buffer
absorbs a few tens of ms of producer. Read the tick mean and `each` before blaming the
producer.
Inside a pass the terms are, instrumented per window at that scale: the **fetch** 3.3 ms
(three out-of-line `MinMaxPyramid` accessors and a 48-byte `MeasureBucket` per bucket),
`measure_scan_buckets()` 0.8 ms, and **pass two** 7.5 ms -- dominated by the per-(bucket,
level) fold. That fold is why `measure_accumulate_bucket()` is `inline` in `measure.hpp`: it
is the innermost loop of a walk whose iteration count is the window's buckets *times* the
pane's levels, and as an out-of-line call in its own translation unit it cost **30 % of the
pass** (sixteen shares on one channel: `24.55 -> 17.25 ms` a recompute). A caller adding a
per-bucket type keeps it in the header, and a caller adding a bulk one may not let it grow a
call per bucket either.
The third term is `raw`, and it is the **signal's**: a level that falls inside a bucket's
`[lo, hi]` has to be walked sample by sample (the fold cannot split a bucket), so a signal
whose local range covers the level everywhere -- white noise, a noisy machine channel --
degrades the pass to O(window) *per level*: measured `raw=4.6 %` on the mock's `noise` channel
against `0.1 %` on its `sine` at the same window. It is the field to read before blaming the
pane for a slow run, and no cheaper walk fixes it.
- **`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.
- **`Above`/`Below`/`Between` are the *means over a side of a share*, and they are the readings
`Top`/`Bottom` gave before a flat level replaced them.** That change kept the split's two means as
its *internal fallback* for a window the pyramid cannot summarize (above); these three types are
that fallback, exposed and named, which is what lets the two different questions about one split be
asked side by side -- on a unit sine `above 50` reads `0.636620` (2/pi) where `top` reads
`0.957988`, and on the mock's band-limited square `above 50` reads `0.986496` where the plateau is
`1.000000` and `top` `0.999983`: the edge's ring is *inside* this mean and not inside that level.
Five rules, and the first two are what a reader is most likely to assume wrongly:
* **A band is not a wider split, and needs no walk of its own.** `Above`/`Below` are
`MeasureLevel::sum[1]`/`count[1]` and `sum[0]`/`count[0]` at the measurement's own share.
`Between` is the **difference of two levels' upper sides** -- `sum[1](lo) - sum[1](hi)` over
`count[1](lo) - count[1](hi)`, i.e. the samples above the lower edge and *not* above the upper
one -- and no single level's two sums can bound a band, because a band's two edges are two
thresholds. Both accumulators come out of one pass two over one window (the pane registers one
level per edge, `w.pct`, and both are deduplicated into it), so the difference is exact rather
than approximate. `v == level` is the below side, as everywhere, so the band is `(lo, hi]`.
* **It rides the bucket fast path unchanged.** A bucket wholly inside the band is strictly above
the lower level *and* strictly below the upper one, so both accumulators fold it whole; only the
buckets that straddle either edge are opened, exactly as for one level. `measure cost:` is the
guard: adding all three types to a pane of every type left `raw` at `0.1%` of `covered`
(`113966` of `240719031` with the ten types, `134978` of `255372033` with the thirteen), and a
band is *two entries of the level list*, not a second pass.
* **`measure_level_pcts()` is the only place the shares are read**, and it clamps both to `[0,100]`
and puts `Between`'s pair **in order**: "between 80 % and 20 %" is the band between 20 % and
80 %. Normalized rather than refused because the shares name two *edges*, and the band does not
depend on which the user typed first; the pane's dedup key and both accumulators are keyed on the
ordered pair, so one band is one walk however it was written.
* **Nothing to measure reads `--`**, exactly as for every other type: a side holding no sample (a
share at `100 %`, whose level is the window's own maximum) and an *interior* holding none are
both `invalid`/`--` and never `0`. The ideal `--square-harmonics 0` square is where the second
shows -- every sample is exactly +/-1, so the band `(0, 0.5]` holds nothing -- against the same
band on the band-limited square, which holds the edges' transitions (`between 50 75` =
`0.302744`, `between 25 75` = `0.000000`: the symmetric band's two halves cancel, the transition
being odd about its own midpoint).
* **The reading is a mean over the window's *samples*, so its sixth decimal follows the window's
boundary.** A live window is closed at both ends, so its oldest and newest sample share a phase
and the mean carries one sample that is not in an open interval -- about one unit in the sixth
decimal out of 2.6 M samples. Measured over one fast-mock run, `[measure-trace]`: `above 50` on
the sine printed `0.636620` on 48 lines and `0.636621` on 2, `below 50` `-0.636620` on 37 and
`-0.636619` on 18, `above 50` on the square `0.986496` on 32 and `0.986495` on 18, and
`between 50 75` on the ramp `0.250000`/`0.250001`/`0.249999`. The old `Top`/`Bottom` spread the
same way -- it is a property of a mean over a finite window, not of these types, and a reading
that has to be exact to six figures is read off `[bandavg-probe]`'s nine.
Measured on the fast mock, 2 s window, the mock's default 30-harmonic square, `[bandavg-probe]`
(nine significant digits, then what the pane prints):
`above 50` on the sine `0.636620262` -> `636.62 mV`; `below 50` on the sine `-0.636619283`;
`between 50 75` on the sine `0.255873597` -- the analytic `3(2-sqrt 3)/pi = 0.255873`, which is
neither the window's mean (`0`) nor the band's edge (`0.5`); `above 50` on the square
`0.986494746` / `0.986495505` (one or two samples at the window's end); `between 50 75` on the
`ramp` `0.250000385` and `above 50` `0.500000` -- a uniform density makes every band's mean that
band's own centre, so a quarter and a half of the range are the shape's prediction and not a
coincidence; `above 100` on the `ramp` `--`; and the ideal square's `between 50 75` `--`.
Editing the sine's band through the dialog to `50/90` takes it to `0.431361697`
(`0.8 / (2 asin 0.8)`), which is the check that the second share is the one the engine reads.
- **`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
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
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.
@@ -105,9 +298,13 @@ 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
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.
* **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean
above and below the threshold -- so a band there changes a documented exact reading. `top` on a
unit sine must still be `0.636620`.
* **The `sum`/`count` split stays at the bare `level`.** Four things read it and none may be
moved by a band: a crossing's *side* is decided by the sample's own value against the level
(`v == level` is the below side, as it always was), `Top`/`Bottom` fall back to the mean of
each side on a window with no bucket histogram, and `Above`/`Below`/`Between` read it on
*every* window -- their whole definition is that split, so a band folded into it would move a
documented exact reading (2/pi on a sine) for no gain to any crossing count. The band is about
*deciding a crossing*, not about where a sample lives.
* **`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.
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
@@ -133,6 +330,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
changed".** An explicit Reset, an add/edit/delete (all three funnel through
`rebuild_measures_pane()`), a CONFIG, and a change of display window each change the
*question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do
not: they change which samples answer it, which is precisely what averaging over many windows
is for. The pane-wide toggle does not reset either — it only shows and hides labels that are
always created and always fed, so reading the plot for a moment does not cost the history.
not: on a **live** view they change which samples answer it, which is precisely what averaging
over many windows is for -- and on a **pinned** one they change *nothing*, not even a value
(the window invariant above), so there is nothing for the history to see either way. The rule
is about the *accumulator* only; it is not a claim that a gesture leaves a value alone, which is
a property of the window a value is computed over and is the other half of this change. The
pane-wide toggle does not reset either — it only shows and hides labels that are always created
and always fed, so reading the plot for a moment does not cost the history.
+165 -7
View File
@@ -1,6 +1,6 @@
# Invariants: the plot, the figure and the layout
How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content.
How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's and the slide-over's overlay children, and the rule that layout must not depend on content.
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.
@@ -12,9 +12,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
from the paned's own width, re-parenting the column, the property form of its children, size requests):
the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in
GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` =
`max-sidebar-width` = 320 in **pixels**, so the width is its own at any window size, and `collapsed` (set
`max-sidebar-width` = 280 in **pixels**, so the width is its own at any window size, and `collapsed` (set
from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=320` under a 2x scale is exactly
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=280` under a 2x scale is exactly
the case that used to be zero.
- **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split
view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading
@@ -23,12 +23,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own
write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted`
holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own
`set_active`), `update_side_visibility()` is the single place that turns that wish plus "are there signals"
into the panel's *and* the button's state together, and `tick()` re-asserts them when the widget disagrees —
`set_active`), `update_side_visibility()` is the single place that turns that wish into the panel's visibility
and "are there signals" into the dock's *sensitivity* (the button stays sensitive), and `tick()` re-asserts both when the widget disagrees —
which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The
no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed.
The wish is the session's `dock` key: a change of it (not a program's own write, and not a restore) schedules a save.
- **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever
`sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently
`sidebar-width-unit` says.** Setting a pixel count there (280) is a `GObject` critical, and it silently
falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no
error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`.
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
@@ -39,11 +40,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
taller figure.
- **Events never take the plot's width.** The events list is a slide-over page (an overlay child over the plot) and
the dock's Events block lives in the fixed 280 px column, so there is no events divider, revealer or pane beside the
plot to squeeze it; the first latch must not move the plot area (`[events] ... plot_w_stable=1`, which compares the
allocated width before and after). A dock row is a fixed-width ellipsized label (title at 18 characters, `#seq`), the
list is a fixed 112 px scroller capped at `kDockEventsMax` rows plus one "N more" row, and it has no thumbnails: the
dock's width and height do not depend on how many events there are or what they say ("layout must not depend on
content"). The thumbnails are the *page's* (138 px wide).
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
thing that forces a rebuild. Cursors are parked outside the axis range instead of
removed.
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
- **A `Figure`'s shape (its cells per row) is fixed at construction**, so `RowSet::rebuild_all()` builds
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
state. That makes one path for "a row's traces changed" and "the row count changed",
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
@@ -96,6 +105,153 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
an equal division. `sync_layout_editor()` runs every frame on purpose:
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
the comparisons and nothing else, and a resize needs no notification.
- **A figure row can be split into cells, and every plot index in the host is the flattened one.**
`RowSet::row_count()` is the number of **plots** and `row(i)`, `Row::plot`, the cursors, the
measurements, `bind()`, the readouts and the persistence layers all index plots row-major (row 0's cells
left to right, then row 1's); `figure_row_count()` is the number of figure rows, and
`cells_in_row()`/`first_plot_of_row()`/`figure_row_of()`/`cell_of()` translate. **Code that means "the
rows of the figure" -- the editor's row `+`, the row drag, the row weights, `fl.rows` -- must say
`figure_row_count()`**: with no split the two are equal and a slip passes every unsplit probe, which
is how a row weight once drifted off its row (see the `row-weights` reading). The caps are
`kMaxCellsPerRow` = 4 per row and `kMaxRows` = 8 **plots** in all (the constant bounds plots now, not
figure rows), so every per-plot structure sized for 8 is unchanged. `plot_label()` is what the UI
prints for a plot: the row's number (`2`) for a one-cell row, the number and a letter (`2a`..`2d`) for
a split one -- never a flat index, which is 0-based and says nothing about where the plot is.
What the cells share and what each keeps:
- **One time window.** Every cell of every row is a member of the one x link group, so a pan or zoom
in any cell moves them all, the crosshair is one x drawn in every cell (the hovered cell shows its
y), and the **vertical cursor pair is one shared time drawn in every cell** (`cursors_aligned`).
A split is an arrangement of more plots, not a second time scale: nothing about the time axis changes
(see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)).
- **One y per figure row.** The cells of a figure row share one y range (the plotter's
`Figure::set_share_row_y(true)`, which `rebuild_all()` sets before the cells are populated): one y
axis (auto or fixed), one pair of horizontal rulers, one fit. A wheel or axis drag in any cell moves
the row, an x gesture on any cell that carries an x strip moves the shared window. The host therefore acts on the
**figure row** of a plot it is given (`set_y_auto`, `set_y_range`, `fit_all`, the Auto/Fixed dialog,
`row_unit`, which joins the units of every cell and reads "" if they differ). The rulers are stored on
the row's first plot index, copied to the other cells by `sync_cursors()` / `insert_cell()` /
`rebuild_all()` and placed in every cell by `place_cursors()`. The cursors pane lists **one ruler line
per figure row**: `row N` with N the 1-based figure row for a split row, and the 0-based plot index
for an unsplit row (the pane read that before splitting existed). Readouts and measurements stay per
trace and per plot index; only the axis is shared.
- **The x axis is the bottom row's for every run of rows with the same columns.** A figure row gets
`XStrip::None` only if the row directly below has the same columns (same cell count and same
normalised cell weights); a row whose columns differ keeps its own strip under each of its cells
(`[1][2]/[3]` has axes on 1, 2 and 3), because the axis below is at another width and scale. The
rule is structural and transitive, never a function of content. Every cell that carries a strip is
a place a middle-drag on the axis works. The `t0` caption belongs on every cell that carries a strip,
not only the last figure row's.
- **The right gutter is the last cell's.** In a split row with a shared y the plotter gives
`right_gutter` to the last cell alone and takes it off the row like the y gutter, so the visible gap
between two plots is `col_gap` and equal weights give equal axes widths.
- **One left gutter, on the first cell only.** The y gutter is one maximum for the whole figure with the
same reserved floor as before (`Figure::set_min_y_gutter()`). It is carried by the first cell of each
row alone: `Figure::layout` gives every other cell `YStrip::None` from its column index (structurally,
never from what it holds), which forces its y label width, title width and reserved floor to 0. A
`None` cell contributes 0 to the common maximum, so a split row's non-first cells cannot widen the
gutter and a row's gutter does not depend on how many cells it has. A per-column gutter sized by the
labels would make a cell's width depend on its content, which "layout must not depend on content"
forbids. A row whose labels are narrower than the gutter simply has a wider margin. The weights apply
to **plot widths**, not cell rects (below).
- **Widths are weights with a floor.** The plotter keeps 64 px logical per plot and a column gap
(`kColGap`, 6 px); the editor's drag is stricter (below). The floors and the weights act on **plot widths**: the row's y gutter (carried by the first
cell) is taken off the row width first, then the cells share the rest, so equal weights give equal plot
areas and the first cell's rect is its gutter plus its plot width. A weight is a share of what is
left *after* the gutter and the floors, so cells of `{3, 1}` read 524:229 (rects) in a 756 px
figure, not 3:1, and the editor's 96 px drag floor is a floor on the plot *width*, not on the share. Because a cell's width is
`64 + extra * weight / total` and not proportional to its weight, `layout_col_apply()` does not scale
the pair's weights by the width ratio: it keeps the pair's weight sum `S` from the drag's *start*
(`layout_col_drag_start`) and gives the left cell `S * (want_left - 64) / (pair - 128)`, clamped to
`[0.001, 0.999]` (the plotter refuses non-positive weights; `0.5` if the pair is not wider than 128).
`want_left` and the pair are plot widths (`layout_col_drag_begin` reads each plot's `layout().axes.w`, after a `layout()` on the current figure, so the gutter and the inter-axes gap are never counted as weight); `want_left` is the pointer's width clamped to `[96, pair - 96]` logical px. The divider therefore
follows the pointer, the shrinking cell reads 96 px at the extreme and the pair keeps its total width
(the split probe's `col-drag` line asserts all three). `kPlotterMinCellPx` (64) mirrors the plotter's
`kMinCellWidthLogical`; both are logical px (the plotter scales it by `ui_scale`, which is
`PlotArea::device_scale()`, the divisor used to turn layout rects into logical px), so the two
must change together.
- **A cell is removed with its traces, and a new cell is empty.** `insert_cell(fr, at)` pushes the
cells at and after `at` right *with their contents* (the same rule as `insert_row()`), and
`remove_cell(plot)` on a one-cell row removes the row. Both return false at a cap or when they would
leave no plot, and never grow past 4 / 8.
**The editor's controls** (`rebuild_layout_editor()`): a cell `+` (`kLayoutCellKey`, tooltip "split
here") at a row's left edge, at its right edge and at every internal boundary -- `n + 1` of them for a
row of `n` cells, so a one-cell row can be split from either side -- inserting at that position; a drag
double-headed arrow (`kLayoutColKey`, `udpscope-resize-cols-symbolic`) at each internal boundary that moves the boundary between
its two neighbours, **each floored at `kMinCellPx` = 96 px logical** (or half the pair when the pair is
narrower) and measured from the two widths at drag start, so the drag does not compound. **The handle follows the divider it
drags, so the gesture's offset shrinks by the handle's own displacement**: `on_layout_col_update` adds it back
(`layout_col_total_offset(offset, handle_x_now, handle_x_at_begin)`, the handle's x in overlay coordinates, stored by
`on_layout_col_begin` in `layout_col_drag_handle_x0`) before multiplying by the scale; without it the divider settles at half the pointer's travel. The per-plot
`×`, which removes the cell in a split row ("remove this plot") and the row in a one-cell row ("remove
this row"), insensitive when it would leave no plot. Cell `+` is insensitive at 4 cells in its row or 8
plots in all; the row `+` is insensitive at 8 plots. **The cell `+` is keyed by `kLayoutCellKey` (figure row * 8 + position + 1) and
never by `set_row_key()`**: `row_key()` is a plot or boundary index, `layout_button()` finds a button by it,
and a cell `+` that also carried one would be found as a row button; `sync_layout_editor()` tells the kinds
apart by which key it finds. Like every editor control the
cell buttons are placed from `FigureLayout::cells`, divided by `device_scale()`, and the editor does
not draw while the figure's rects are not yet one per plot.
- **The slide-over is the second thing placed over the plot, and it is one opaque widget, not a
transparent area.** The same pick rule as the editor's buttons applies, harder: the panel is a
`GtkRevealer` aligned to the end edge (`halign=END`, `valign=FILL`) at `kSlideOverWidth` = 360 px, so
its allocation *is* the part of the plot it covers and everything to its left is still the plot's --
pan, zoom, drop and hover are untouched. A full-area child with a panel drawn in it would swallow all
of them. Closed, the revealer is `can_target(FALSE)` (and `gtk_overlay_set_clip_overlay()` keeps its
slide-out inside the plot), so the frames of a slide-out do not eat input either. `[slideover]` reads
it back: `outside_hits_plot=1 inside_hits_panel=1`. Three consequences:
the panel **never resizes the plot** (it is an overlay child; `plot_unchanged=1`), its height is the
plot's and never the page's (each page has a `GtkScrolledWindow` with a `NEVER` horizontal policy that
does not propagate its child's height), and content that is taller than the plot -- the trigger form
on a 470 px window -- scrolls inside the panel instead of growing it. The widgets that go in a page
are reparented into it; their widths must fit 360 minus the border (the trigger form measures 267
minimum, the E-1608's panels 197). **One page wider than that widens the panel
for all of them** (the stack measures every page): the signals page's hint line once did, unwrapped, and
`[slideover] panel=` read 376 instead of 360; the label now wraps.
- **The badge bar has a fixed height and sits outside the overlay.** `badge_bar` is 36 px
(`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a
badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on
content" for the new row: the Trigger badge's state is a word from a short fixed set and the Source badge
(shown only when the kind has source pages; hidden otherwise) is a kind's display name only (no connection state: that is the status bar's), so what changes with the stream is the row's *width* (the bar has room to
spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to
need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear
inside the slide-over's scroller, never in the bar.
- **The signal badges live in a scroller, so the bar's minimum width never depends on the signal count.**
The bar's signals slot holds a `GtkScrolledWindow` (horizontal `EXTERNAL`, vertical `NEVER`) around one
row of badges and the `+`, because a button row's minimum width is the sum of its children: nine badges
(the E-1608: eight inputs and the counter) would push the window's minimum width past a laptop screen.
Past `kMaxSignalBadges` (6) the row is replaced by one "N signals" badge, which also stands for the `+`.
The bar's height stays 36 px in every one of these states (`bar_h_fixed` in `[sigbadges]`), and
`bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 124 on UDPS:
the Source badge is on screen for the E-1608 and hidden for UDPS, and its text differs by kind). A change that makes it grow with the signal count has
taken the scroller out of the measurement.
- **The dock's width is fixed too, and it is the readouts' width that holds it there** (`side_column`
`width-request` 260, the split view's 280 px floor and cap). The dock holds only the compact readouts and the Events block (a fixed-height list, below), so a
section's visibility is never what makes it taller; the scroller absorbs the rest. **The readouts' minimum
width is the sum of their cells' character widths, and it must stay under 280** (the Readouts box is 268:
280 less its 6 px margins): a cursor line is the `who` cell (5 characters) plus three value cells (9 each),
every one ellipsized at that width (`width-chars` = `max-width-chars`), so a long value is cut and its
tooltip carries the text and the layout never depends on what a value reads. The `who` cell was 8
characters, which made the readouts' minimum width 304 px; that pushed the sidebar to 304 px
(`dock_w=304`) and defeated the 280 pin. Widening any cell is the same
trap -- read `[readouts] ... dock_w=` (must be 280) and the `dock cursors:` / `dock measures:` `fits=`, and for the Events block `dock events: ... dock_w=` (268).
- **The readouts strip is a fixed 26 px, scrolls sideways, and is shown only when the dock is not beside the plot and there is something to read.**
`readouts_strip` (a `GtkScrolledWindow`, `height-request` 26, horizontal `external`, vertical `never`) sits
in `left_column` between `plot_overlay` and `badge_bar`. Its height is a request and not a measurement of
its chips (the buttons are trimmed by `button.udpscope-strip-button` in `ui_widgets.cpp`, or Adwaita's
34 px flat button would push it past 26), the chips' cells are fixed-width ellipsized labels like the
dock's, and an overflow scrolls with no bar taking height: "layout must not depend on content" for the
strip, the same as for the badge bar. It is *visible* exactly when (`show-sidebar` is false **or** the split view is `collapsed`)
**and** (the cursors are on **or** there is a measurement)
(`sync_readouts_strip()`, from the tick after the wish is re-asserted and before `set_collapsed()` -- so
`show-sidebar` is the wish and not the split view's collapse-time write, and `collapsed` is the previous
frame's value). Hidden with a wide window and the dock beside the plot, so the plot's 26 px go to one
pane of readouts and never both, and hidden with nothing to read (cursors off, no measurement). That last
is a deliberate, user-requested exception to "disable, do not hide": an empty strip is 26 px of nothing, and
the dock keeps both blocks on screen so the empty state is never invisible; the strip's two groups and the
separator follow the same rule (`strip_cursor_group` with the cursors, `strip_measure_group` with
measurements, `strip_sep` with both). In a collapsed window the dock is an overlay on the plot and the strip
stays, overlay open or not. The plot is 26 px shorter while it shows; that is the dock's and the window's
state moving the layout, not content. Reading `h=26` with `visible=1` (and `h=0` with `visible=0`) in
`strip:` is the assertion, `visible_ok=1` that `visible` equals `expected` and `groups_ok=1` that the groups agree.
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
or a new CONFIG renumbers the channel list, so an index would silently re-point a
trace at another signal.
@@ -118,6 +274,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
letting the counter grow: each is a status field whose *width* would otherwise depend
on the stream, and the status row is directly above the plot.
A split row keeps this rule: cell widths come from the weights and the 64 px floor and never from what
a cell's labels need, and the gutter stays one figure-wide maximum.
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
plotter for this), not on the rows: `Figure::layout()` clears every row's
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
@@ -41,6 +41,38 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
window's **first sample** and never from history before the window, so a measurement never depends on data
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
from earlier samples.
- **The dock's readouts and the pages' panels are one string, never two.** The dock (`src/ui_dock.cpp`)
shows a read-only copy of the cursors table and of the measurement values, and it must not format
anything: `update_cursors_pane()` and `update_measures()` format each value **once**, into a local, and
hand that same variable to the page's label **and** to `dock_cursor_cell()` / `dock_measure_value()` --
on every branch, including the `"--"` blanking one (cursors off, no sample). A second formatter in the
dock would be two spellings of one reading that drift the first time `format_measure()` changes, and
the dock exists so that the two can be read side by side. The `who` cell is copied from the page's label
verbatim (markup chip and name), and the dock's lines are rebuilt from the page's whenever the page
rebuilds (`dock_rebuild_cursors()`, `dock_rebuild_measures()`), so the line counts agree by
construction. A cell is **ellipsized at a fixed character count** (who 5, values 9, measurement type 12) with
the full text in its tooltip: the dock is 280 px wide and its layout must not depend on what a value
reads, so a long value is cut and quoted whole in the tooltip rather than widening the column (the
`who` cell was 8 characters, which made the readouts 304 px wide and pushed the sidebar past its
pin). `[cursors-probe] ... dock cursors:` and `[measures-probe] ... dock measures:` assert it: `same_text=1`
compares `gtk_label_get_text()` (the untruncated text) of every dock label with the page's, and
`fits=1` that the Readouts box is no wider than the dock.
- **The readouts strip is a third reader of the same string, never a second formatter.** When the dock is
not beside the plot (hidden, or the window collapsed so the dock is an overlay) the readouts are shown in the strip under the plot (`readouts_strip`, `src/ui_dock.cpp`): the
cursor chips' `who` and *delta* labels and the measurement chips' value labels. They are written **in the
same call** as the dock's -- `dock_cursor_cell()` and `dock_measure_value()` set the strip's label from the
argument they were handed, and `dock_rebuild_cursors()` / `dock_rebuild_measures()` rebuild its chips
beside the dock's lines -- so a value is still formatted once, into one local, and a feature that writes a
dock cell without going through those two setters leaves the strip stale. The strip keeps the dock's
rules: the same fixed-width ellipsized cells (who 6, type 10, value 9 characters, full text in the
tooltip), the delta cell reads `--` on the same branches, and the cursor chips are *insensitive* with the
cursors off -- through `dock_set_cursors_sensitive()`, which writes the dock's grid and the
strip's chip box together; a second `gtk_widget_set_sensitive()` on either is two spellings of one state.
The one place the strip hides what the dock only disables: with the cursors off the strip's Cursors group is
not shown, with no measurement its Measures group is not, and with neither the strip is not (the dock keeps
both blocks, `no measurements` included; the strip has no placeholder). That is
`sync_readouts_strip()`, not a sensitivity.
`[cursors-probe]` / `[measures-probe]` `strip: ... same_text=1` asserts it.
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
@@ -54,6 +86,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
that range falls back to `%.3g`.
- **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the
pane *insensitive* (the page's body, and the dock's cursor grid beside it) rather than hiding it -- a panel you can still see is a panel you can read, and the
settings you are about to switch back on must not vanish while you look for them -- and every cell in
it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample;
with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting
a sample the cursors no longer name. Two spellings of one state in one grid would be the defect.
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
+96 -8
View File
@@ -4,14 +4,42 @@ 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.
- **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 trigger pane.** Locked is the returning user's mode: the
trigger pane shows one compact line and offers no control that changes the configuration (the signals
have no lock). 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 signal onto a plot is not configuration and survives
the lock**: it is the signal *badge's* drag (the list's rows are no longer drag sources, and the badges do
not follow the lock). `[sigbadges] drag_sources` is the reading, and `drag_sources` on the `signals panel:`
line must be 0.
- **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 unit renames**
(`[[unit]]`, `name` + `unit`, only the overrides; `ScopeModel::set_signal_unit` re-applies them to every CONFIG,
and `set_signals()` takes them off the incoming units before the "same stream" comparison so a reconnect still
keeps its history), **the enabled
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
*overrides*, so a stream's new signals still take the positional palette and a saved session does
not freeze a colour that is meant to follow the signal list), **the math channels with their
graphs**, **the measurements pane**, and **`measure_stats`** — the measurements statistics
toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false`
when absent. Signal identity
when absent, and **`dock`** -- `LayoutState::dock_shown`, the Dock button's state (the user's last word on it,
`App::sidebar_wanted`): a bare top-level boolean written where `side_tab` used to be, default `true`.
It is the **only** thing the dock persists -- its 280 px width is fixed, so there is nothing to store --
and **the open slide-over page is deliberately not persisted** (a panel that opens by itself at start-up
is a surprise, and a page id from another source kind may not exist). The parser ignores unknown keys
(its key chain has no erroring final `else`), so a file written before the tabs became a dock
(`side_tab = "measure"`, or `side_tab = "source-ao"`) still loads and opens with the dock shown;
nothing reads the old key, and `[dock-session]` asserts it. Signal identity
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
@@ -23,9 +51,35 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
was parsed into the last calibration record and the enable state was lost without a word.
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
- **Measurement options persist under *named* keys -- `noise` and `hf` -- so a file written before them
loads at the defaults**, which are exactly the behaviour it was written against: `noise = 10` is the
hysteresis band the app has always counted with, and `hf = 0` is no filter.
- **A `[[plot]]` table is one *plot* (one cell), and carries `row` and `width` for the horizontal split.**
`plots = N` counts plots, as it always did. `row` is the 0-based **figure row** the plot sits in and
`width` the cell's share of that row's width (`LayoutState::Row::row`, `::width`); the tables run in
the flattened order, so a row's cells are consecutive and left to right. The row's height share
`weight` stays per *figure row* but is **written on every cell of it** (so a table read alone is
complete) and **read from the row's first cell**; `collect_layout()` takes it from
`row_weights()[figure_row_of(plot)]`, not from `row_weights()[plot]` -- the latter is a plot index into
a figure-row vector, right until the first split. The file is written `row`, `width`, `weight`,
`signals`; a state built with `row < 0` (nothing in the app does) omits `row` and `width`.
Three loading rules, all in `apply_layout()` and none of them a refusal, because a session is the
user's arrangement and not worth losing to a malformed key:
(1) **a file with no `row` loads as one cell per row** -- every session written before the split --
with the per-plot `weight`s as the row heights, exactly as it opened before;
(2) **a `row` sequence that cannot be a layout loads the same way**: some plot without a `row`, a row
number that goes backwards or skips one, a first row that is not 0, more than 4 cells in a row, or
more than 8 plots; the signals stay on the plots they were on (`invalid-rows` is the reading);
(3) **cell widths are applied after the arrangement**, because `set_cells_per_row()` resets them, and
only for rows of two or more cells -- a one-cell row has no widths and its `width` is ignored.
Both new keys are parsed with the file's other forgiving rules: `row` must be an integer >= 0 and
`width` a number > 0, anything else leaves the default. A save after loading a pre-split file writes
the `row`/`width` keys, which is how an old session is upgraded without anyone asking.
- **Measurement options persist under *named* keys -- `threshold`, `threshold2`, `noise` and `hf` -- so a
file written before them loads at the defaults**, which are exactly the behaviour it was written against:
`noise = 10` is the
hysteresis band the app has always counted with, `hf = 0` is no filter, and a file with no
`threshold2` -- every file written before `between` existed -- leaves that measurement at
`threshold2 = 75`, which is the value the dialog offers a new one. The *pair* is stored as written
rather than as ordered: `measure_level_pcts()` is what puts a reversed pair in order on read, so a
file records what the user typed and a measurement file round-trips unchanged.
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
@@ -33,6 +87,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
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.
- **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
`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
@@ -81,7 +169,7 @@ expression of the scheme, and the name is `sanitize()`d exactly as the host is,
with a slash cannot escape the directory.
A configuration carries the whole session *and* the settings that can only be decided before the
first socket: `buffer_mb`, `relative_time`, `capture_packets`. The startup chooser applies them
first socket: `buffer_samples`, `relative_time`, `capture_packets`. The startup chooser applies them
(`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed
— the one moment a buffer size can be applied at all. Switching configurations inside a running
app applies what can change live and says in the status line that the rest takes effect on the
@@ -92,9 +180,9 @@ A configuration's *startup* settings reach the model from two directions, and bo
named with `--server`, which never reaches the chooser at all. Without the second, a saved host's
buffer size, clock and capture ring were silently ignored — the layout still applied, because the
app loads that separately once a CONFIG names the signals — so an edit to any of the three read as
one that never took effect. **A flag the user typed wins over the file** (`--buffer-mb`,
one that never took effect. **A flag the user typed wins over the file** (`--buffer-samples`,
`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and
`Config::buffer_mb_specified` is what tells "not asked" from "asked for the default".
`Config::buffer_samples_specified` is what tells "not asked" from "asked for the default".
The `active` file is written on every save and by the chooser's pick, so "which configuration was
I on" survives a restart. A session written before configurations existed is **imported once** as
@@ -73,8 +73,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
the caption has to name the anchor precisely enough that label + caption is the exact absolute
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom
row** of the figure carries one, every other row being set to `""`; and the renderer measures it
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only a plot
whose x strip draws labels (`plot::x_strip_has_labels(x_strip_mode())`: the bottom row, and any row above it whose
columns differ from the row below's) carries one, every other plot being set to `""`; and the renderer measures it
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
no rect to read back, and a caption explaining the x labels must not be able to move the layout
those labels were measured into. The guards are the library's
@@ -86,7 +87,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
**pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower*
than the requested window. It must write the per-row **member** axes as well as the link group:
`hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it
`hold_pinned_window()` prefers a member that differs from `pinned_stated_` (it scans **every**
row's member, in order, since a labelled strip on a row other than the first takes x gestures too), which is how it
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
the two cannot drift apart.
+291 -20
View File
@@ -18,8 +18,43 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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
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.
**The same function states the Force button beside the arm one, and the Force button is in the body
only.** Its sensitivity is the same `enabled` (a trigger that is off has no engine to force) and it
is stated in `sync_arm_buttons()` for the same reason the pair above is stated there — one place, so
nothing can drift — but it is *not* armed-gated: in Single mode the previous acquisition leaves the
engine disarmed, which is exactly when a user reaches for Force again, so a button that greyed out
with the engine's armed state would be unavailable for the one thing it is for. It stays in the body
because the heading's single control is the runtime *state* a locked pane exists to change, while a
force is an action whose subject — the condition, the level, the pre/post split and the mode that
decides whether one acquisition is all of it — is in the body; a second Force in the heading would
be a second control for one action whose settings are off screen.
- **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
signal nobody acquires can never fire, so selecting/arming enables it.
signal nobody acquires can never fire, so selecting/arming enables it. **And the signal is resolved
*through* the channel, never taken as one.** `TriggerConfig::channel` indexes `channels()` (the
acquired ones) while `ScopeModel::set_signal_enabled()` indexes the stream's `signals()`, and the
two are not the same index: with one signal acquired, channel 0 *is* that signal while signal 0 is
the stream's own clock. Passing one for the other switched on the time base, left the signal the
trigger watches exactly as it was — and, because a signal the panel was not showing *is* a change,
rebuilt the buffer under the click and dropped its history, which is precisely the pre-trigger
window an arming, and now a force, was about to capture. `trigger_signal_index()` is the one
resolution and both buttons go through it.
- **The trigger's event window is separate from the display window**, and
`pretrigger_pct` is a share of the *event* span, not of the ring.
- **The event window is counted in stream seconds end to end; nothing converts it to
@@ -35,8 +70,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
`ScopeModel::set_signal_enabled()` re-resolves the trigger by name and writes the *new* index
back. Keying the reset on the index therefore made any such rebuild abandon the capture **and
**`ScopeModel::rebuild_channels()` re-resolves the trigger by name and writes the *new* index
back, for every caller.** It lives there and not at the call sites because the function *is* the
renumbering, and a caller that forgot is a trigger that silently stops: the name-based restore
used to be in `rebuild_enabled_locked()` alone, so adding or removing a math channel went through
`set_math_channels()`'s structural path with no restore at all, `rebuild_locked()` clamped the
now-wrong index to `-1`, and `process()` returns at once on a channel of `-1` while the panel went
on naming the channel. That is the second half of the "it sometimes does not trigger, even with
the trigger on a non-math channel" report. Keying the reset on the index made any such rebuild
abandon the capture **and
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
and no user action beyond enabling a channel. The name changes when the source genuinely
@@ -57,13 +99,20 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
trigger is not supposed to see.
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
**A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
clamps the position into it (and states `Off`, since a following group would re-derive from a
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
in single trigger mode: an acquired event could not be examined at all.
**What a gesture inside the pin may *not* move is a measurement**, and it is not this function
that holds that: `pinned_range()` is the one expression of the pin's extent, and
`update_measures()` measures *it* rather than the viewport while the view is pinned, so a pan
or a zoom on a still frame changes no value and spends no recompute (see
[invariants-measurements.md](invariants-measurements.md); `[measurepin-probe]` is the reading,
`pin=`/`for=` in `[measure-trace]` the field to watch). The pin is therefore load-bearing twice
over, and a change to what `pinned_range()` says changes both the framing and the numbers.
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
@@ -124,6 +173,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
pause now agrees.)
- **The pause reads the display offset *before* it sets the pause state, and a pause taken on a
held event depends on that order.** `on_pause_toggled()` reads the row's axis and
`stream_at_x0()`, cuts `view + offset` out of the ring, and hands the same offset to the
snapshot as its `x_offset`. `stream_at_x0()` follows the *display*: the moment `paused` is true
with no pause view published yet it answers `-display_epoch()`, which on a `Held` view is **0**
-- so the cut searched the ring at the event's edge-relative x, found nothing, and froze an
**empty** picture: `ev=0` on the probe's line, every measurement `--` and every ruler gone,
which is a much louder symptom than the empty margin above and reaches a user the same way
(pause while an event is on screen). Read first, it is the offset the axis is actually
displaying, which is what the whole scheme requires. The live case is unchanged -- there the
epoch does not depend on `paused()`, which is why the pause probe, which pauses a *live* view,
never saw it.
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
@@ -158,6 +219,42 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
export.** Both need the same absolute index range over the time ring and every value
ring, or an export and a capture of the same window would hold different samples. Call
it under `mutex()`.
- **Force is a synthetic edge, and the latch is still the only publisher.** The trigger panel's
Force button opens one capture at the *present* moment through `TriggerEngine::force()`: the
newest stored stamp becomes the event's own time and the engine enters the same pending state an
edge leaves it in, so the ordinary latch cuts, publishes, counts and frames the acquisition. A
forced acquisition therefore obeys every rule a latched one does — the axis pinned from the
snapshot's own stamps, a `pre_s` shorter than the request when the ring does not reach back that
far, and the frame that frames it being the one that consumes its `event_seq()` — and the button
owns no window, no publish and no framing of its own: **the event it publishes is cut by the same
code as a latched one.** Two things it deliberately does *not* do, both of them choices:
- **It skips the pre-window gate.** `force()` does not test `event_t - oldest_s_ >= pre_seconds_`:
"capture now" is a request for the history that *exists*, and refusing it because the ring is
young would refuse the one thing the button is for. The snapshot reports its own `pre_s`, and the
pin follows it — which is also how the pin gets *read*: with a request the ring cannot satisfy (a
100 s window at 99 % pre) the axis must come out a few seconds wide, where a pin stated from the
request would come out a hundred.
- **It does not restart a capture already filling.** That capture *is* the acquisition being asked
for: the display is already showing it fill (`live(capturing)`), and restarting it would throw
away the post window it has collected and move the marker to the press. The click is refused, and
named on stderr, rather than silently reinterpreted.
What the trigger *is* afterwards is still the mode's decision, and it is made where it always was —
in the latch: Normal leaves the engine armed with the latch's hold-off, Single disarms it. One
acquisition, whether an edge produced it or the button.
- **The Force request is written under the model lock and consumed at the end of a packet.** It is
one atomic flag on `Acquisition` (`request_force()`), read with `exchange` after the channel loop
and before the `trigger_pending()`/`capture_edge()` publish — after the loop because the cut
`publish_event()` is about to make clamps to every value ring's `produced()`, so the newest sample
of every channel has to be in by then, and before the publish so the capture the click opened is
reported by the same packet that opened it. The *write* belongs under `ScopeModel::mutex()` and in
the same critical section as the `armed` flag the click also sets: the producer holds that lock for
a whole packet, reading the panel's `armed` at the top of one and the request at the bottom, so a
request written in between would be read by a packet whose engine was still disarmed and then
dropped — and dropped is right, because a momentary request must not be replayed later on a trigger
the user has stopped watching. With **no channel resolved** (no signal acquired) the click is
refused in the handler and not left to the producer: `on_data()` returns at once for a model with
no *streamed* channel, so a request parked behind that return would fire on the first signal
switched on afterwards.
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
so the engine would not latch; but the real reason the samples are withheld is the other one:
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
@@ -180,18 +277,187 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
all three, or the marker, the cut and the gate would disagree with each other by the width of
a pulse.
- **The trigger's whole input is the stored clock, so a clock that stops is a trigger that never
fires — and *that* is what a blank trigger-mode display is.** `Acquisition` feeds
`TriggerEngine::process(value, rel)` with the same `rel` it stamps the ring with, and the engine
has no other clock: the pre-window gate measures the event against the retained history
(`event_t - oldest_s_ >= pre_seconds_`), the post window counts *stream seconds* past the edge,
and `holdoff_until_s_` is set from `now_s` at the arm. A timeline that stands still therefore
reads as `history=0.0000/1.0000 s` for ever, on every line of `trig engine:`, no matter how many
samples arrive — and with no latch, `display_mode()` never leaves `Waiting`, whose bind is
*nothing* by design, so the plot is blank and the other traces are blank with it (the renderer's
bound window is empty too, so the same emptiness shows with the trigger off). Nothing about that
picture says "clock": it is "in trigger mode, with a math channel enabled, the data disappears",
which is how it reached a user. The reading is the `trig engine:` line's own pair — **`n` climbing
while `ring=[a..a]` stands still** — and the one-shot
`[udpscope] warning: the clock stalled ...` that `on_data()` prints at the first whole frame whose
stamps are all behind the clock. See
[invariants-data-path.md](invariants-data-path.md) for the two entries (a plan whose interval
moved under the stream; a frame accepted into a model with no streamed channel) and for why the
relative clock is accumulated in seconds.
- **Persistence is the older acquisitions drawn *over* the current one, and "a layer is not a
signal" is what keeps them from becoming one.** Trigger mode may keep the last `N`
acquisitions -- `TriggerConfig::persist_depth`, the panel's *persistence* spin, 1 = the event
on screen alone -- and draw the older ones dimmer, which is the whole feature. The machinery is
the layer machinery the accumulate path already has: one **layer** per (signal, age), in the
same trace slot the accumulate path puts its own extra events in, named with the same suffix
vocabulary (` (A)`, ` (B)`, ` #4`), so `layer_base_name()` gives the base signal back and the
signal list, the colour resolver, the measurements and the layout writer all keep seeing **one**
signal where the plot draws several traces of it. Four consequences, each of which is a rule:
* **A layer slot holds no channel.** `RowSet::bind()` resolves a layer's *base* signal (it must
still be acquired -- switching a signal off takes its traces off every plot, layers included)
and then finds the channel **by name inside the snapshot** (`EventSnapshot::channel_names`),
because the channel list is renumbered by any structural change since that acquisition was
cut -- the rule `TriggerEngine` already follows, for the same reason. It leaves
`Row::channel[]` at `-1`, which is what makes every reader that asks "is this slot a trace of
a signal" skip it: the trace counts, the cursors pane's `mix` mask, the measurement marks, the
dump's per-slot lines, `collect_layout()`. A persistence layer is therefore invisible to the
signal list, the calibration table and the measurements by construction and not by a check;
the *measurements* go on describing the current frame because they resolve a channel from the
signal name the user configured, and `sine (A)` is not a signal anybody can configure.
* **Its brightness is a property of the slot, and it is applied where the trace is created.**
`Row::slot_alpha` is `RowSet::persist_alpha(age)` -- **half per acquisition**, so the schedule
is 1, 0.5, 0.25, 0.125, ... and the seventh layer is 0.8 %, i.e. gone; it does *not* depend on
the depth, so raising N adds dimmer acquisitions instead of restating the ones already on
screen. `install()` mixes `slot_alpha * trace_alpha_` into the trace's colour, because a
`Plot` fixes a trace's colour **at `add_trace()`** and a brightness nobody can set later is a
brightness that has to be a rebuild. Two things follow: a per-layer alpha is impossible
without a per-slot one, and **`set_trace_alpha()` is finally read** -- it was set by the
accumulate path and read by nothing, so an accumulation's "translucent, so agreement is
brightness" was opaque in fact while the docs claimed otherwise.
* **The dimmest trace is created first.** A `Plot` draws its traces in the order they were
created and cannot reorder them, so `install()` creates them sorted by brightness and the
*newest* acquisition is the ink on top. A layer drawn over the event would take its colour out
of the event's own trace wherever the two agree -- the opposite of what a persistence display
shows.
* **Every slot the plan does not fill is freed, and the plan does not depend on the data.**
`RowSet::sync_layers()` (called from the tick, before the frame's `bind()`) re-plans one layer
per (signal, age) into the row's free slots, *layer by layer* -- age 1 for every signal first,
then age 2, as the accumulate path does -- so a row that runs out of room draws the sharper
layer for each of its signals and stops. The plan is a function of the rows, the depth and
nothing else, so a **latch costs no rebuild**: the slot names and ages stay put and only the
snapshot behind them changes.
- **An older acquisition is *drawn*, and it may not move anything.** The current event is the one
the axis is pinned to, and persistence must not disturb that, so `RowSet::event_data_window()`
-- the union the pin is re-stated from every frame while an event is held -- **skips layer
slots**: a wider older acquisition would otherwise re-pin the axis, and with it the window a
pinned measurement reads, which is a *measurement* moving because a display option was turned
up. The layers are bound only in `Held` and `Capturing` (a live view has no current event to be
the newest of them, a `Waiting` display is blank by design, and a pause view's stamps are on the
live window's clock, not on an edge): in `Waiting` and `Live` the layer slots are planned but
bound to nothing, and a trace with no series is not drawn and not listed in the legend. The one
thing they do share with the signal is the **row's y axis**, so the y auto-fit sees them -- a
y axis that ignored the traces it draws would clip the excursion the display exists to show.
- **A retained acquisition is a whole window copy, so the depth is capped by bytes and by what a
row can draw.** `publish_event()` moves the outgoing `event()` into the retention before it
publishes the new one: `ScopeModel::persist_event(age)` is the event `age` latches back, one
atomic `shared_ptr` slot per age, read by the tick without a lock exactly as `event()` is.
`snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples
and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of
clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and
`persist_budget_bytes()`, which is the planned ring bytes (`Partition::bytes()`), because persistence is a copy
*of* what the rings hold and holding as much again is the honest ceiling for a display of it.
`kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already
invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only
thing that limits what is *drawn* -- a row of `S` signals with 8 slots shows
`floor((8 - S) / S)` ages of each, so the default four-signal row shows one older acquisition
and a row per signal shows all seven. The two shortfalls are **separate readings**, and a caller
that conflated them would report a shrunken display as complete: a *slot* shortfall is
`placed < want` (what `RowSet::sync_layers()` returns against what the depth asked for), while a
*budget* shortfall is a `kept` below the depth plus the count `persist_dropped()` carries -- and in
that case the layer slots **exist and draw nothing at all**, so `layers=`/`placed=` alone say
nothing is wrong. The panel's notice therefore names both numbers -- `D of W layers fit, K of A
older acquisitions retained` -- because a user who asked for eight layers and sees two has no other
way to tell "the display is deliberately shallower" from "the feature is broken".
- **Depth 1 is not a mode, it is the *absence* of one.** `persist_depth = 1` retains nothing at
all -- no copy, no layer, no rebuild -- and `set_persist_depth()` prunes the set **at once**
rather than at the next latch, because lowering the depth is a user asking to stop paying, and a
trigger that has stopped firing never reaches another publish. A depth change is therefore also
how the memory comes back.
- **The phosphor density is the *trace*, and that is a different display from the layers above, not a
mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple
events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence*
is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, an intensity grid
**per trace, drawn in that trace's own colour**, every acquisition deposited into it, which fades
either by a time constant or by a per-acquisition factor. There is **no depth** on the density and
no per-acquisition memory: the image is `traces * columns * rows * 4` bytes, and both factors are
bounded -- the grid is the row's axes rect in device pixels **up to the plotter's
`kDensityCellCap`**, and there is one grid a trace the row draws. Four traces in a row
therefore hold at most 4 MiB between them, whoever dragged the window where (the probe's
one-signal-per-row run reads `cells=1044714`, 4 178 856 bytes), so a thousand acquisitions cost
what one costs and *neither* a wide window nor a long run can make the image grow. A cell's value
is a number of *sweeps*: the library
deposits a cell once per trace per sweep however many of that trace's own samples crossed it
(`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit deposit read 18) -- dwell is
`Style::intensity_grading`'s question, not this one's. The colour is the trace's own
(`RowSet::signal_color()`, the same source the trace was built with and never a second one), so a
saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two
quads of two colours rather than a blend.
Five things hold it together, each of which is a rule:
* **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as
the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops
the grids **at once** rather than at an acquisition a quiet trigger never sends (the rule
`set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an
empty image rather than a stale one at another scale.
* **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()`
deposits what the plot currently holds through the transforms of its **last** layout, because the
frame's own layout is computed inside the paint that follows the tick: `density_pending` is set
where a snapshot is *framed* -- beside `record_pending` -- and consumed by the next tick, so the
deposit is of the new acquisition drawn at the axis that acquisition is pinned to. Doing it in the
same tick was right on the common Held-to-Held transition and wrong on the first latch, whose axis
was still the live ring's.
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
latch that `single` mode may never send.
* **No trace is drawn over it, and the newest acquisition is inside it.** `Renderer::draw_traces()`
returns at once while a density is on: the trace ink *is* the image, so the acquisition just
deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid,
unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition",
where the last acquisition never faded in time mode -- and the assertion is a **framebuffer** one:
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the same pixel before and after one
time constant and requires it dimmer (`full=224 -> faded=115`, the trace's own `0xE0` green),
which a stroke over the density could not do.
* **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** The
app leaves `DensityConfig::full_scale` at the library's default of **one sweep** and `gamma` at
1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing
the same place every acquisition settles *at* the trace's colour (the ceiling clamps), rather than
a fraction of a brighter steady state. The old ceiling -- the fade's steady state, `1/(1-factor)`
for the acquisition fade -- is the gain offset a user reported between the latest acquisition and
the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed
here.
* **It is the plot's, so a figure rebuild resets it** -- the grids live on each `Plot`, and
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
and the alternative -- keeping the grids in the `RowSet` -- is what would make it not a plot
feature. The layout is unaffected either way: the density reserves no space and nothing on it can
be picked, exactly as `set_invalid_spans()` does.
* **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and
`trigger_density_factor` in the layout file, written whether or not it is on -- a display set up
once and switched off is still the display you come back to. The decay is the *word* the panel's
dropdown shows ("acquisitions"/"time"), for the reason the condition's shapes are text.
## A latch is also a file
The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are
collapsed until it is asked for, because a column of stale events beside a live plot is what a user
who has stopped looking at events does not want. What makes it *available* -- (recording is on) or
(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and
the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and*
collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is
deliberately **not** one of the conditions: a stored event being examined is a file, which has no
trigger, and tying the pane to the trigger would close the list under the click that opened an event.
The recording switch lives in the trigger panel, not in the pane it empties.
The cache is listed twice, from one `EventStore::list()`: the dock's compact **Events block** and the full list on the
`events` slide-over page (the left pane, its divider and the header toggle are gone, so events no longer take
any of the plot's width). What makes it *available* -- recording is on, **or** a replay is on screen, **or** the
cache already holds something (`event_recording || replaying || events_cached`) -- is decided in one place,
`sync_events_availability()`, and it sets the block's sensitivity (`dock_events_set_sensitive()`) and the page's
(`events_box`) from the one `available`, so they cannot disagree. Unavailable means **insensitive, never hidden**
("disable, do not hide"): the block stays on screen greyed, and turning recording off with the page open leaves it up
and greyed rather than stale and live.
**This deliberately differs from the redesign spec**, which has the block insensitive "outside trigger mode". Trigger
mode is *not* one of the conditions: a stored event being examined is a file, which has no trigger, and a replay or a
group *leaves* trigger mode, so a block that went grey outside trigger mode would grey itself under the very click
that started the replay -- the one-click replay in the dock would disable the dock it was clicked in. Do not "fix" this
to match the spec. The recording switch lives in the trigger panel, not in the block it empties.
The two views follow the latch on different cadences: the dock is rebuilt on **every** latch (`record_event()`), the
page's list only while the page is open, and `open_events_page()` refreshes it when it was closed; a caller that
opens the page any other way shows a list that is as stale as the last time it was open.
## Opening a file is a detour, so *Go live* is a restore
@@ -224,13 +490,18 @@ is the complaint this answers.
- `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
`trigger_level_touched` is restored with the configuration.
- **It waits for the stream's own channel list, gated on `generation()` having moved.** The restored
name has to be resolved against the live channels, and those do not exist until the first CONFIG of
the restarted client lands on the producer thread. `App::live_restore_generation` is the generation
read when *Go live* was pressed; `live_restore_pending` stays set until it differs **and**
`channels()` is non-empty. A `pending=1` that never clears is therefore a reading about the
*stream*, not about the restore -- see `invariants-session-and-export.md` for the save that made it
stick at `channels=0`.
- **It waits for the trigger's channel *name* to resolve again, not for `generation()` to move.** The
restored name has to be resolved against the live channels, and a file view that renamed its channels
(`square (A)`) must be gone first or the name selects the wrong signal. `App::live_restore_pending`
stays set until `channels()` is non-empty **and** a channel carries the snapshot's name (or the
snapshot named none). Gating that on the generation was wrong 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()`) -- 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
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
File diff suppressed because it is too large. Load diff
+559 -70
View File
@@ -13,30 +13,57 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8000
```
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
generated GResource `.c` files — the help pages' and the icons', whose blob crossed C99's
4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
generated file, which GCC itself supports). The fifth warning that used to appear here was an unused
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
all. A fifth warning, or any critical, is a finding.
Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**:
a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it.
For the badge bar / slide-over / dock redesign the same diff is taken **against the commit before that
series (`c572ec2`)**, from a fresh `XDG_CONFIG_HOME` on each side (a stored mock session changes the
trigger lines), and it is "unchanged except" exactly these lines: `panels:` loses `switcher=` (the
trigger form is read through the slide-over, so its width and height change, and the `compact arm
probe` width with them); `switcher:` becomes `dock: shown= sensitive= w= scroller_pages=`;
`panel controls:` prints `dock=` for `switcher=`; `help figures:` loses `side_switcher=`, replaces
`side_stack=` with `badge_bar=` and `dock=`, moves the `trigger_*` rectangles into the slide-over and
shortens `plot=` by the 36 px bar; and the `tour started: steps=` count. A self-check taken **before**
`c572ec2` is no longer the baseline for those lines. The rest is the mock's own jitter (the sign of a
`square y=[...]` or a `trig engine:` figure, the position of one `trigger: edge` line), which the
normalisation does not remove -- anything else is a finding.
For the horizontal plot split the same diff is taken against the commit before it (`18b91c8`, with
`deps/plotter` at its pre-split pin `f79a97b`), and it is "unchanged except" exactly two things on the
layout lines: **`buttons=`** grows (the editor now carries the cell `+` buttons and the column arrows:
`3P + 2F + 2` for `P` plots in `F` figure rows, so `5 -> 7` for one row and `8 -> 12` for two), and the
layout probe line and the exit dump's layout line gain the **`cells=[..] colweights=[..]`** fields.
Everything else -- cursors, measurements, readouts, signal pages, math, trigger, help figures, the
E-1608 -- reads as before; a difference there is a finding, not the feature. Take a second baseline run
as a jitter control before believing one (a few mock readings, such as the sine's membership of the
cursors mix list or a V/mV flip, move between two runs of the *same* build).
A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons'
generated GResource `.c`, whose blob is **10146** against C99's 4095-byte string-literal limit with
the six action icons and the two app icons that remain (a note about a generated file, which GCC
itself supports). History: the help pages' generated `.c` (**516044** bytes) went when the manual
became a Mallard set installed for Yelp; an unused `output_built` in `math_probe()` was deleted as
dead code; and the last two, `-Wmissing-field-initializers` on `MathNode::source` in `math_probe()`,
went when the probe's `MathNode{MathOp::Gain, {}}` / `MathNode{MathOp::LowPass, {}}` named the
field (a partial brace-initialiser on `MathNode` re-adds one: `source` has no default member
initialiser). Re-measure by touching `src/icons/udpscope.gresource.xml` and rebuilding; any
second warning is a finding. And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's
`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
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
An *incremental* build reads **zero**: the generated GResource `.c` is not recompiled
when only `src/` changed, so its `-Woverlength-strings` does not reappear.
A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it
printed.** The line is not behind a probe — it is in every build — and one line is one *real* change
of buffer plan, each of which blocks the main thread for the time it takes to touch the whole
`--buffer-mb`. A normal run prints two or three. Fifteen at startup is the defect it was added for;
planned buffer. A normal run prints two or three. Fifteen at startup is the defect it was added for;
see [probe-readings.md](probe-readings.md).
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
the lucky reading of it. A run that had built and destroyed other windows first —
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
`UDPSCOPE_PROBE_HELP_MS`, which opens the About and shortcut dialogs and then closes them — has had
that memory reused, and the same two calls
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
@@ -50,6 +77,115 @@ precedent, not an exception.
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present
moment, with the pre/post window the panel is set to, without waiting for the condition. It is a
chain, not an instant — `MS` is when it *starts*: it forces trigger mode **off** through the toggle
(a session file remembers the trigger, so the off state is stated rather than assumed) and reads the
button's stated state 300 ms later; it then sets a level no mock signal reaches (9 V), a 2 s window
at 50 % pre, acquires the mock's `sine` and turns the trigger on; it presses Force 2.5 s after that
(the window write re-plans the buffer and clears the history, so the ring needs the time to fill
past the 1 s leg A asks for); it presses a second time on a 100 s window at 99 % pre — a request no
run this short can satisfy — 400 ms after the first latch. **Run it alone, against the plain mock**,
with time for both legs:
```sh
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 &
sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_FORCE_MS=3000 \
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | \
grep -E 'force-probe|trigger:'
```
Being alone is load-bearing twice: the level is chosen so the engine *cannot* latch on its own, so
a latch count that moves is the force's and nothing else's — any other probe that arms a reachable
condition adds latches of its own — and `UDPSCOPE_ENABLE_ALL_MS` before it would acquire four
signals, which changes the level's default range and the plan. Two lines per pass are in
[probe-readings.md](probe-readings.md); **`latches=P->Q` with `Q == P + 1`, `display=event(frozen)`,
`matches_request=1` in leg A, `pin_is_snapshot=1` in both** are the assertions, and leg B's
`matches_request=0` is *expected* — a ring that cannot reach back 99 s is the only state where
"pinned to the snapshot, not to the request" is a reading rather than a coincidence.
- `UDPSCOPE_PROBE_PERSIST_MS` (+ `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, 1..8) — the **persistence
display**: trigger mode keeps the last N acquisitions on screen, each older one drawn at half the
previous one's brightness. It enables the signals itself (one call, as a restored session does) and
arranges one signal per row — the arrangement the app gives an opened file, and the one with room
for every age — sets the depth **through the panel's own spin**, arms the trigger on the mock's
`square` and polls until the retention is full: an event on screen and as many older ones kept as
the depth asks for. The depth is *left* where it put it, so the run's own tail is the display at
that depth:
```sh
python3 tools/mock_udpstreamer.py --port 9541 --rate 500000 --batch 1000 &
PY=$!; sleep 0.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 \
UDPSCOPE_PROBE_PERSIST_MS=2500 UDPSCOPE_PROBE_PERSIST_DEPTH=4 \
./build/udpscope --server 127.0.0.1 --port 9541 2>&1 | grep -E 'persist-probe|persistence:|layout:'
kill $PY
```
**Not with `UDPSCOPE_ENABLE_ALL_MS`**: that probe ends by switching a signal off two seconds later,
which takes the row that plotted it — and its layers with it — out of the middle of the measurement.
**Read it as a pair of runs**: the same command at `..._PERSIST_DEPTH=1` is the display *without*
the feature, so its `kept=0`, `layers=0`, `bytes=0` and its `layout:` line holding only the signals
is the "N = 1 is exactly the old behaviour" reading, and it is also the run the exit dump's
`frame cost:` line is compared against — the tick/draw means are means over the whole run, and the
depth is raised at `MS`, so the difference is a *lower bound* on what the layers cost. **`ev_is_snap`
and `pin_is_snap` must be 1 in both runs**: the older acquisitions are drawn and never pinned, so
the axis, the framing and the measurements still describe the current event. `alphas=` is the decay
schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump
lines are in [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_DENSITY_MS` (+ `UDPSCOPE_PROBE_DENSITY_TAU`, default 1.0 s) — the **phosphor
density**: an intensity image **per trace**, in that trace's own colour, the axes rect in device
pixels up to the plotter's `kDensityCellCap` (past which it scales the rect down uniformly and
magnifies the image back over it with a linear filter), every
triggered acquisition accumulated into it (the newest included), drawn *in place of* the traces,
fading by a per-acquisition factor or by a time constant. It states the display
**off** through the panel's own switch first, so the `off` leg is the run without it, then arms the
trigger on the mock's `square` with one signal per row — the arrangement the persistence probe uses
— and only then switches it on, so the acquisition already on screen is the first image. Four legs,
each a separate `g_timeout`, so whole frames happen between them:
```sh
python3 tools/mock_udpstreamer.py --port 9551 --rate 500000 --batch 1000 &
PY=$!; sleep 0.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 \
UDPSCOPE_PROBE_DENSITY_MS=2500 ./build/udpscope --server 127.0.0.1 --port 9551 2>&1 | \
grep -E 'density-probe|density:'
kill $PY
```
**The assertions, in order:** `off` reads `rows=0 traces=0 cells=0 ink=0.0 peak=0.0000` (there is no
grid at all); `on` reads `rows=4` — every row has its own image, one per trace in its own colour,
so `traces=4` with one signal a row — and **`peak=1.0000`**, one sweep's ink
being one per cell it covers; `step-acquisitions` reads **`peak=1.5000`**, which is the panel's
0.5 applied to the old image plus the new sweep's own pass; and `step-time` reads
**`ratio=0.367879`**, one step of `dt = tau` through the plot's own `decay_density()`. A `peak` in
the tens for the `on` leg is the *per-hit* deposit -- one sweep counted once per sample that
crossed a cell -- which saturates a single acquisition and hides the sweeps behind it. The line
also carries the app's own A/B of the frame cost (`tick`/`draw` before and after the switch, in
one run), and the exit dump's `density:` line says what the last frame it was on held. Both lines
and the dump line are in [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_NOTRACE` (no value; **not** a scheduled probe, and off unless it is set) — the
per-paint detail behind the exit dump's `traceless paints:` counter: one `[notrace]` line for each
of the first 40 paints after the first bind that staged no trace, naming the mode, the axis the
frame was asked for and every slot's bounds. It is behind a variable because a state that draws
nothing lasts as long as it lasts, so an ungated line is one per frame for the whole time -- and it
is *not* printed for a frame a phosphor density is drawing, which stages no trace by construction
(the frame's ink is the image) and would otherwise be counted as well. The counter itself is in
every build; see [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_HEADER` (no value; **not** a scheduled probe) — the once-a-second
`[udpscope] header: window_button_w=… bar_w=…` line. The widths are sampled either way (the
exit dump reads them); the line is behind this variable because it is detail behind that
reading, not a line every run should print. An unallocated window reads `0` for both.
- `UDPSCOPE_PROBE_RENDERER` (no value; **not** a scheduled probe) — the renderer pair
`--renderer` should have exported, printed from `main()` before GTK initialises
(`[renderer] option=… GSK_RENDERER=… LIBGL_ALWAYS_SOFTWARE=…`), then the renderer GTK
built (`[renderer] realized=…`) once the window has one. Run it with `--renderer cairo` to
check the option reaches the environment before `gtk_init()`/`adw_init()`; run it without
the option to check the default is untouched.
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share,
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
@@ -69,6 +205,35 @@ precedent, not an exception.
with, so a synchronous sequence would read every position one action stale. It clicks by
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
- `UDPSCOPE_PROBE_SPLIT_MS` — drive the horizontal plot split, 28 steps 250 ms apart (so a run needs
`UDPSCOPE_PROBE_SPLIT_MS=1500 UDPSCOPE_EXIT_AFTER_MS=12000` at least; it enables and assigns the
signals itself, so `UDPSCOPE_ENABLE_ALL_MS` is not needed). Each step restructures the cells,
re-points the area at the new figure and lets the *next* step read a frame that was actually laid out
with them, which is why every report line describes the previous step's change. It splits and
weights a row, fills the caps, round-trips a session through `collect_layout()` / `save_layout()` /
`load_layout()` / `apply_layout()` (and a copy with its `row`/`width` lines stripped), clicks the layout
editor's real cell `+` button, drives the column drag handler, and checks the signals page, the drop's
`row_at()`, the cursors pane's ruler labels and the shared y axis of a split row (`shared-y`). Run it once, serially, on its own mock port, from a
fresh `XDG_CONFIG_HOME` (the probe deletes the temp files `udpscope-split-probe.toml` and `.legacy` it
writes under `g_get_tmp_dir()`, but the editor's clicks schedule a real session save). Read it against
the `[split-probe]` section of [probe-readings.md](probe-readings.md); the run must still end with
`gtk warnings: 0`.
- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level
walk(s)` (stderr, `UDPSCOPE_PROBE_LEVELS_MS`) — **prices a measurement pane**, which is what a
report of "it is slow with many measurements" has to become before it is a number. It adds one
`above` per share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`), spread in turn
over the first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and reports the two
things a count does not name: how many **windows** (one `MeasureWindowReader`, one fetch, one
scan per measured channel) and how many **level walks** (one accumulator per *distinct* share)
the pass then had to take. `50,50,50,50` on one channel is four pane rows and **one** walk;
`5,10,15,20` is four rows and **four**; the same four shares over four channels is four
windows *and* four walks. Read the `measure cost:` line it leaves in the exit dump and the
sweep in [invariants-measurements.md](invariants-measurements.md) — and read it against a run
with `UDPSCOPE_PROBE_LEVELS_MS` unset, because the pane's cost is a *difference* between two
tick means. **Run it at the scale being asked about**: the cost is the window's, so a 2 s
window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a
recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at
the same window).
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
@@ -77,13 +242,37 @@ precedent, not an exception.
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
(`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
mean of a sine, which is the check that the threshold types are not merely plausible.
`[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
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
number. It must never print `marks=0` while measurements with `show_on_plot` are on and the
data is there -- that was the rulers blinking off at a mode change.
`peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
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] mode=M view=[..] s0=S swin=[..] pin=[..|off] for=[..] passes=N
<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 placed, which is the only way a
stale result or a mark that should not be drawn shows up as a number. `swin` is `view` moved onto
the stream's clock by `s0` (`stream_at_x0()`), `pin` is the pin's own extent in that clock (`off`
when nothing is pinned) and `for` is the window the printed numbers were computed over -- so a
reading that moved while `view` stood still is a change of `s0` (a new snapshot under the same
mode) and not a stale pass, and only these fields tell those apart. `passes` beside them says
whether a recompute answered the move. **While the view is pinned, `for` must equal `pin` -- and
`swin` is free to sit anywhere inside it** -- through any number of zooms and pans: a latched
event's numbers are the *frame's*, so a gesture inside the pin changes neither them nor `passes`.
Read the other way round it is the defect: `for` following `swin` into the zoom while the values
move is the reported "on a still frame the measurements change when I zoom". Neither reading is
the old "measurements stand still while the data moves", which was `for=[0.47..2.47]` against a
`swin=[1.417..1.467]` with `passes` frozen -- the pane describing a window the plot had left.
The line must also never print `marks=0` while measurements with `show_on_plot` are on and the
data is there -- that was the rulers blinking off at a mode change. `pin=off` while the mode is
`held` is not a stale reading but the window control: `apply_time_window()` unpins, and for those
frames the view is live-framed and measuring the viewport is right.
`measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
raw=R (F%), N ns/covered sample` follows it: **read it against a run with
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
@@ -99,13 +288,64 @@ precedent, not an exception.
(`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
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). It is the guard on the band averages for the
same reason: `between` is a *second level in the same walk*, not a second pass, and measured on
the fast mock at a 2 s window with the measurements probe adding one of **every** type, `raw`
stayed at a fraction of a percent of `covered` —
`covered=240719031 buckets=3760828 raw=113966 (0.0%)` for the ten types before, against
`covered=255372033 buckets=3989978 raw=134978 (0.1%)` for the thirteen after, at 0.8 → 1.1
ns/covered sample (`data` 156.85 → 178.38 ms: the pane holds three more measurements and one more
level, and both walk the same fetched bucket list).
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`
with nothing bound and the axis at its default. The race-free reading is an earlier
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
- `[bandavg-probe]` (stderr, `UDPSCOPE_PROBE_BANDAVG_MS`) — the **band averages**:
`above`/`below`/`between`, the plain means over one side of a share and over a band, which are
*different numbers* from `top`/`bottom`'s flat levels (see the band-average invariant in
[invariants-measurements.md](invariants-measurements.md)). It adds seven measurements, on the three
shapes whose band means can be computed by hand, and the four figures to read are:
* the 1 Hz unit sine at a **2 s window**: `above 50` = **`0.636620`** (2/pi) and `below 50` =
**`-0.636620`** — the exact figures the old `top`/`bottom` documented, and the reason this type
exists at all; `between 50 75` = `0.255873` (`3(2-sqrt 3)/pi`), an interior mean no side and no
whole-window statistic reports;
* the mock's band-limited `square` (30 harmonics, its default): `above 50` = **`0.986496`**, the
reading the commit that made `top` a flat level recorded as the *old* `top`;
* the mock's `ramp`, whose density is uniform: `between 50 75` = `0.250000` and `above 50` =
`0.500000` — a band's mean *is* that band's centre, so the shape predicts the number;
* and the two nothing-to-measure readings: `above 100` on the `ramp` and `between 50 75` on
`--square-harmonics 0` (whose samples are all exactly +/-1) must be `invalid`/`--`, never 0.
**Run it at a 2 s window** (the probe waits for a full one before it reads anything — the mock
delivers about 0.5 MS/s of the 1.3 MS/s it declares, so a freshly enabled ring needs seconds of
wall clock), and **not** with `UDPSCOPE_PROBE_MEASURE_MS` or `UDPSCOPE_PROBE_MEASURES_MS`: all
three add measurements to one pane. `UDPSCOPE_EXIT_AFTER_MS=17000` covers its sequence. The
command, both mock flavours and the lines are in [probe-readings.md](probe-readings.md):
`--square-harmonics 30` for the `0.986496` and the `0.302744`, `--square-harmonics 0` for the
`1.000000` and the empty band.
- `[measurepin-probe]` (stderr, `UDPSCOPE_PROBE_MEASUREPIN_MS`) — **what window a measurement
describes**: on a still frame the pane is about the *frame*, so a pan or a zoom changes no
number, and on a live view the numbers still follow the window. Three legs — a latched event, a
pause taken from it, and a live view after the trigger is switched off — each read the pane
before and after a *gesture* written on the row's own axis (never the window control, which
calls off the pin). The assertions, in order: the pinned legs read **`same=4/4 for_same=1
pass_delta=0`** with `for=` still at the pin (the values bit-identical and no recompute spent on
the gesture), and the live leg reads **`follows=1`** with `for_same=0`, `pass_delta>0` and the
results window's span narrowed to the axis'. Its sequence is about 12 s, so
`UDPSCOPE_EXIT_AFTER_MS=20000`; run it on the fast mock with a batch the datagram limit allows
(`--rate 1300000 --batch 1500 --freq 1`, **not** `--batch 2000`, which is 80 kB of payload and
dies with `Message too long`). The lines, the fields and the settle wait are in
[probe-readings.md](probe-readings.md); the window rule itself is in
[invariants-measurements.md](invariants-measurements.md).
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
@@ -124,7 +364,7 @@ precedent, not an exception.
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) —
a ring that holds *more* than the window (`--buffer-samples 2M` past the window's own seconds) —
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
@@ -191,8 +431,8 @@ precedent, not an exception.
nothing. Its four lines are in
[probe-readings.md](probe-readings.md).
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
only the file chooser.
`/tmp/udpscope-export`) — collect the export table and write it all three ways (`.npy`,
`.csv`, `.mf3`), skipping only the file chooser.
- `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
@@ -205,6 +445,139 @@ precedent, not an exception.
pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three
phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs
`UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first.
- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements while the
`measure` page is **closed**: it adds four measurements, opens the page for a forced pass (the baseline),
closes it, and reads the accumulators again two seconds later. **`advanced=<rows>` is the assertion**
(every accumulator gained passes while the page was off screen); `advanced=0` is the defect where the
accumulation is gated on what is visible, which would leave a gap in min/max/avg for exactly as long as the
page was shut. It enables *and plots* its own signal, so it needs no other probe ahead of it, and it
re-arms once after 400 ms (a buffer rebuild between baseline and report reads as a gated pass). It needs a
config that has seen the first-run tour, which would close the page it opens (see the next-but-one bullet).
The reading's own entry is in `probe-readings.md`. (Probe name `dock_section_probe`; the variable and the
`[side-tab-probe]` tag keep their old names so existing scripts keep working. The measurements used to be a
dock section the probe scrolled out of view, with padding; the dock now holds the readouts and the Events block, so there is
no scroll and no padding.)
- `UDPSCOPE_PROBE_CURSORS_MS` and `UDPSCOPE_PROBE_MEASURES_MS` also print the dock's copy of what they read:
a `dock cursors:` line after every `[cursors-probe]` report and a `dock measures:` line at the
measures probe's added / settled / final stops, each with a `strip:` line beside it (the readouts strip,
shown when `(!dock_shown || collapsed)` and the cursors are on or there is a measurement; `expected=` is that, `visible_ok=1` that the strip agrees, `groups=`/`groups_ok=1` the same for its Cursors / Measures groups and separator; the measures probe's `start` line, before it adds anything, is the hidden "neither" state -- run it from a fresh config); each probe
ends by hiding the dock for 0.6 s and reading the strip (`dock hidden`, `dock restored`: under xvfb the
window is collapsed, so `visible=1 expected=1 h=26 same_text=1` on both, given something to read). **`same_text=1` on every one** is the assertion that the
dock, the strip and the page show one string; `fits=1` that the Readouts box is no wider than the dock. Entries in
`probe-readings.md`. The exit chain also prints one `[readouts]` line (pages registered, the dock page's three
children, the dock's width, the pages' sensitivity, whether the stream has signals) on every exit-dump run.
- `UDPSCOPE_PROBE_DOCK_SESSION_MS=2500` with `UDPSCOPE_EXIT_AFTER_MS=8000` -- the session file's `dock` key,
end to end: the dock hidden, saved and reloaded; shown again; and a file from the previous version
(`side_tab = "measure"`, no `dock`) loaded without an error. Three `[dock-session]` lines, each `ok=1`;
entries in `probe-readings.md`.
- `UDPSCOPE_PROBE_SLIDEOVER_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` (and `UDPSCOPE_ENABLE_ALL_MS=500`) -- the
slide-over panel over the plot's right edge, opened on a throw-away `probe` page, read 0.5 s later. Two
`[slideover]` lines. Line 1 must read `panel=360x<overlay height> ... edge_flush=1 full_height=1
outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`: a 360 px opaque panel flush to the overlay's
right edge and as tall as it, a pick outside it landing in the plot and one inside it in the panel, and the
plot's width unchanged by the panel being open (`plot_w before=` equal to `open=`).
`outside_hits_plot=0` means the panel has an allocation wider than 360 or a transparent parent that
swallows the plot's pan, drop and hover; `plot_unchanged=0` means the panel resized the plot. Line 2 must
read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot
add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo`
and `import -window root`): the panel must be drawn over the traces, opaque.
- **Slide-over and page probes (`SLIDEOVER`, `BADGES`, `SIGNAL_PAGES`, `SIGNAL_BADGES`, `SIDE_TAB`, `KEYS`) need a config that has already seen
the first-run tour**: a fresh `XDG_CONFIG_HOME` auto-starts the tour at about 1.5 s, and a tour stop with no page closes the
slide-over under the probe. Seed it with `mkdir -p $D/udpscope && printf 'tour_seen = true\n' > $D/udpscope/settings.toml`
(or one short throw-away run, which writes the key when the tour starts) and run with `XDG_CONFIG_HOME=$D`. Delete the
per-source subdirectory (`$D/udpscope/<host>_<port>`) between runs that must start from a first-run layout.
- `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the
per-signal `signal:<name>` slide-over pages. **Against a config that has already seen the tour** (a first run's
auto-tour closes the slide-over at about 1.5 s, which is when this probe opens its page). One `[signalpage]` line;
see [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the
signal badges in the badge bar. **Against a config that has already seen the tour**, as above (the probe clicks
the badges, and the auto-tour would close the page). One `[sigbadges]` line; see
[probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_BADGES_MS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the badge
bar under the plot. One `[badges]` line, which must read `table_bad=0 fixed=1 open_marked=1 page_open=1
closed_unmarked=1`: `table_bad` counts the rows of the 16-input `trigger_badge_state()` table the probe
gets wrong; `fixed=1` is the bar's height being the same (and at least 36 px) before and after the
`trigger` page was opened and closed -- `0` means the bar's height depends on content and the plot above it
moves; `open_marked`/`closed_unmarked` are the `udpscope-badge-open` class following the slide-over's page.
`trigger_state=` is the state word as read then (`off` with the trigger toggle off). **The trigger form is
a slide-over page**, so a probe that reads one of its widgets' *allocation* (a width, a height) must
`slideover_open(*a, "trigger")` and read after the 120 ms reveal, as `exit_probe_step1` and `force_probe` do;
a sensitivity, a visibility or a value needs no open page.
**The source kind's panels are slide-over pages too** (`source-<id>`: the E-1608's `source-ao` and
`source-dio`, Flat UDP's, the probe kind's), so the same rule holds for them. The probe appends a second
`[badges]` line for the Source badge: `[badges] source name=<kind> pages=<n>
visible=<0|1> switch_visible=<0|1> can_target=<0|1> second_page=<id|-> second_ok=<0|1> open_marked=<0|1|-1>
closed_unmarked=1`. `pages` is the number of source pages, `switch_visible` is the segmented switch
being on screen after the first source page was opened (`1` for the E-1608's two pages, `0` for one page
or none), `second_page` is what `slideover_page()` read after the switch selected the second page
(`second_ok=1` when it is that page), `visible=0` with `pages=0` is the UDPS badge being **hidden** (a kind with no source page
has nothing for it to open; `can_target=0` goes with it) and `open_marked=-1` is that no-page case (there is
nothing to open); `visible=1` with `pages>0`.
- `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500`
-- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a
synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms.
Nine `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `flatudp`, `probe`); the
reading's own entry is in `probe-readings.md`.
- `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against
`tools/mock_e1608.py` -- the MCC E-1608 end to end: panels present and sensitive, a DAC write, a
digital-output toggle and a counter reset each reaching the mock, the stream's channels and rate, the
factory calibration applied, the capture button disabled, and a hand-computed decode. It enables the
signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS` (that probe switches the first signal off
after two seconds). Three runs, each short and serial, with the mock killed afterwards:
- **no board:** panels `sensitive=0`, `connected=0`, no retry flood on stderr (the one
`[e1608] handshake with ...: Connection refused` line is printed once, not per retry);
- **the mock:** `--drop-after 3 --log /tmp/e1608-mock.log`, options `scan_rate=1000 ai0=BIP10V
ai1=BIP5V plot0=1 plot_counter=1 dir1=o ao0_init=1.5`, with `UDPSCOPE_PROBE_E1608=1500
UDPSCOPE_PROBE_E1608_RECONNECT=8000 UDPSCOPE_EXIT_AFTER_MS=10500`; then read the mock's log: the AO
levels `1.5, 2.5, 1.5` (the log prints the quantised voltage, so `1.4999, 2.4999, 1.4999` is the
expected text), the DOUT bytes `0x00, 0x02, 0x00`, one `COUNTER_RESET`, one `DROP`;
- **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop.
- `UDPSCOPE_PROBE_E1608_GEN=1500` with `UDPSCOPE_EXIT_AFTER_MS=5500`, run as `--source e1608 --source-opt
address=127.0.0.1 --source-opt port=54711 --source-opt ao0_init=0 --source-opt gen_rate=100` against
`tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine
on DAC 0 through the Analog out panel (and opens the `source-ao` slide-over page, since the panel refreshes on
its frame clock, which does not tick for a page that is off screen; the page is closed again at the end),
waits 2.5 s, reads the achieved rate and the panel's "Update rate" row, stops the generator and reads both
again 400 ms later. Two lines, in `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with
`gen_rate=100` (the generator ran for about 2.5 s) and about 125 for a run without the option (default
50 Hz). Short and serial, the mock killed afterwards. It enables nothing else, so it can run alone.
- `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms
later checks the frame count moved on. The reading's own entry is in `probe-readings.md`.
- `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt
address=127.0.0.1`. Start `tools/mock_e1608.py` first on its default port 54211 (the form scans that port; do
not pass `--port`) and kill it afterwards. The value is the delay of phase 1; phase 2 follows 2 s after it and
phase 3 (the line) 2 s after that, so phase 3 runs 4 s after phase 1 and the delay must stay below about
2500 ms for the line to print before the exit at 6500 ms. It builds
the connection form, presses **Scan network**, expects the mock to be listed, clicks the result (address
filled, MAC and subnet cleared), presses **Use MAC** (MAC filled, the others cleared), scans again and checks
the results were replaced. `UDPSCOPE_PROBE_E1608_SCAN_MAC` names another board (a real one) instead of the
mock's `00:80:2f:00:16:08`. One `[e1608] scan:` line. It needs a non-loopback up interface with a broadcast
address; when the board did not answer a second line says so, and the run proves nothing about the scan.
- `UDPSCOPE_PROBE_FLATUDP=2500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source flatudp` against
`tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both
need no source; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the
float64/uint16/int32/uint32/int64 types in both byte orders), the stream's channel names (`Wave[0]`..
for a `channels` vector) and declared rates (`rate * count` for a waveform), the channel count, value
continuity per row (and the offset between a vector's elements, or a waveform's block phase), the
wrong-size accounting and the capture button. It enables the signals itself, so do **not** add
`UDPSCOPE_ENABLE_ALL_MS`. Runs, each short and serial, with the mock killed afterwards (quote the `signals`
value: zsh globs the `[4]`):
- **mixed list, gap and bad datagrams:** mock `--rate 1000 --signals 'Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4]'
--gap 8,4 --bad-every 1` started 1 s before the app, the same `signals` as a `--source-opt`, with
`UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_PROBE_FLATUDP_GAP=1500 UDPSCOPE_EXIT_AFTER_MS=14500`; stderr carries
exactly one `datagram of 22 bytes, expected 21` line. The silence probe is a 100 ms poll that waits for the
stream, then for the 2 s of silence to take the source down, then for it to come back, so it does not
depend on when `xvfb-run` finished starting; the only requirement is that the mock's gap begins after
the app connected (the mock's clock starts about 4 s before the app's) and ends in time to read the return
(`--gap 8,4` with a 14.5 s run does both with several seconds of margin);
- **waveform-only list:** mock `--signals 'Wave:int16[4]w'`, app `--source-opt 'signals=Wave:int16[4]w'`,
`UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`: one channel at `rate=4000`;
- **big endian (decode of every width through the stream):** mock `--big-endian` on the mixed list, app
`--source-opt endian=big`, `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`;
- **no sender:** `connected=0` throughout, a reading and not a failure.
- `UDPSCOPE_PROBE_FLATUDP_GAP=1500` -- starts the silence poll described above (2 s after the source's last
datagram it must be disconnected; then it must reconnect and the frame count move on).
- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live
trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to
hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.
@@ -224,9 +597,14 @@ as distinct from the fault injection below, which exercises ingest:
conditions for the first four: the mock at `--rate 10000 --batch 100 --freq 5`, udpscope with
no extra options, under `xvfb-run` with
`UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 UDPSCOPE_EXIT_DUMP=1
UDPSCOPE_PROBE_RESEQ_MS=5000`. Delete `~/.config/udpscope/127.0.0.1_8000.toml` first: a
session left by an earlier run restores its math channel, and five channels instead of four
change `channels=`, `span=` and every count derived from them.
UDPSCOPE_PROBE_RESEQ_MS=5000`. Move `~/.config/udpscope/127.0.0.1_8000/` aside first (the
per-endpoint session is a *directory* now -- `active` plus one `.toml` per named configuration;
a bare `127.0.0.1_8000.toml` is a pre-migration leftover): a session left by an earlier run
restores its math channel, and five channels instead of four change `channels=`, `span=` and
every count derived from them. It restores the **window and the measurements** too, which is
the subtler trap: a run after a zoom-sweep probe starts at `window_s = 0.05` with the previous
runs' measurements still in the pane, so `measures window:` then reports a short-window
sawtooth (144 %, marks off screen) on a run that asked for none of it.
| case | mock extra flags | key reading |
| --- | --- | --- |
@@ -269,11 +647,19 @@ as distinct from the fault injection below, which exercises ingest:
and **`finite=0` beside a non-zero `hole_slots` is the defect** (a biquad that integrated the
hole's NaN, permanently). The filters' `sd` must stay near the no-drop value; run the same
command without `--drop-every` as the control, where every `finite` reads 4000.
- **A math edit on a ring that has already wrapped.** `UDPSCOPE_PROBE_MATHWRAP_MS` — installs one
`Gain` graph over the mock's sine, waits for the ring to **saturate**, and only then edits the gain,
which is the one state `UDPSCOPE_PROBE_MATH_MS` cannot reach (each of its edits follows a structural
change, and a replan restarts every ring at absolute 0). It therefore needs a **small buffer and a long
run**: `--buffer-samples 65536` against `--rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`. `base > 0` on its second line is the
precondition — **`base=0` means the run tested nothing** — and the third line's `produced == slots` and
`y=±0.5` are the two assertions; both are in [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_CONFIGS_MS` — saves the running session as a configuration named `probe` with a
note, through `save_configuration_as()` (the same entry point the dialog's button uses), then
reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the
round trip must carry the note **and** the startup settings —
`comment='written by the configs probe' buffer_mb=8 relative=0 capture=0`. A `buffer_mb=0` there
`comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0`. A `buffer_samples=0` there
is the per-source buffer size not being written, which is the one setting a user notices only
after a capture has been sized wrong. Run it with a throwaway `XDG_CONFIG_HOME`; the probe
deletes its own configuration at the end (`after forget names=…`).
@@ -293,6 +679,34 @@ 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
measured a step after the click that swapped it in, because a popover allocates its new child on
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 opens a slide-over page (`trigger`),
starts the window's tour from that state and walks all six stops a stop per tick, reading the
slide-over's open page and the spotlight/card at each. The trigger stop must read `slideover=trigger` and
the next `slideover=none`; the first stop closes the panel the probe left open, and the end of the walk
(and a dismissal on the trigger stop) leave none. It then puts the dock *away* and starts the tour again:
the dock's stops are dropped while the trigger stop, which does not depend on the dock, stays.
`[toursurface] stop=3/6 … slideover=trigger … contains=1` and
`[toursurface] collapsed: side_box_mapped=0 steps=5 trigger_stop=1 signals_stop=1` are the 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
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
@@ -301,10 +715,26 @@ as distinct from the fault injection below, which exercises ingest:
`[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored
calibration**, and the two halves must match.
- The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one
entry per configuration (`host:port/name … buffer= relative= note='…'`), and
entry per configuration (`host:port/name … samples= relative= note='…'`), and
`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
configuration carried, into the `Config` the model is built from. A legacy file
`[sources] picked host:port … samples=2097152 relative=0 capture=0 config=… note='…'` — the settings the
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`
holding `default`, and the old file renamed `.imported`.
- **The loading window covers the gap, and only on the path that asked.** The same
@@ -316,41 +746,50 @@ as distinct from the fault injection below, which exercises ingest:
leg of it (`title='Opening <basename>' detail='reading the recording'`, before `waveform loaded:`).
A run that names its source with `--server` prints **no** `loading:` line at all: every other probe
here is on that path and must stay on it.
- **The event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` reads
the pane's availability first, in the state the app starts in -- `[events] live: button_visible=0
pane=0 recording=1`, **no pane and no button without trigger mode** -- then turns trigger mode on
through the real toggle (`[events] after trigger on: button_visible=1 pane=0`: the button appears,
the pane stays collapsed) and clicks the button, waits for a latch, then reads the cache and replays
the newest event through the pane's own row:
- **The event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` needs a
config that has seen the tour (it opens a slide-over page) and a throwaway `XDG_CACHE_HOME`. It reads the
availability first, in the state the app starts in -- `[events] live, recording off: available=0
dock_sensitive=0 page_sensitive=0 page=none` (the dock block and the page agree, both insensitive and neither
hidden) -- then switches recording on (`available=1 dock_sensitive=1 page_sensitive=1`; **trigger mode is not
a condition**, and the line after the trigger is turned on reads the same), opens the `events` page with
`open_events_page()` (`page=events`, `[events] open alone: page=events page_box=347`), waits for a latch, then
reads the cache and replays the newest event through the page's own row:
`[events] dir=… n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=… signals=4 samples=20001
rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1`, then
`[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is
what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4
samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on
the pane's availability, through the trigger panel's switch: `[events] recording off, trigger off` and
`[events] recording off, trigger on` -- once the cache holds events (the probe's own run has written
some by this point) both read `button_visible=1 pane=1 recording=0`, because a button that shows the
cache must not go away with the switch that wrote it; against an *empty* cache the same two lines read
`button_visible=0 pane=0`, which is the empty column the item exists to prevent. **`samples` must equal the
export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be
a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded
the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
rate=10000 seq=1 thumb_bytes=230454 recorded=1 page=1 plot_w_stable=1 plot_w=756/756` (**`plot_w_stable=1`**: the
first latch did not move the plot, which the old pane did), then
`[events] activated row0: replaying=1 page=events … thumbnail=1 thumb_size=138x46 thumb_visible=1` (the size is
what says the picture is *on screen*: a paint-able alone is not), the dock's reading of the same cache --
`[events] dock events: rows=N pane_rows=N same_titles=1 sensitive=1 dock_w=268 on_screen=1 … at=after pane
replay` -- then **one click on a dock row**, `[events] dock replay: clicked=1 replaying=1 waveform_changed=1`,
and the dock read again (`marked=1`, `expected_marked=1`: exactly one filled marker, on the replayed record),
`[events] replayed: channels=4 samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0
waveform=0`. It ends on the availability, through the trigger panel's switch: `[events] recording off,
trigger off` and `[events] recording off, trigger on` -- once the cache holds events (the probe's own run has
written some by this point) both read `available=1 dock_sensitive=1 page_sensitive=1`, because a block that
shows the cache must not go away with the switch that wrote it; against an *empty* cache (and on the E-1608,
which never latches) the same two lines read `0 0 0` with the page still open -- insensitive, never hidden.
**`samples` must equal the export's row count for the same window** (`20001` for a 2 s event at 10 kS/s),
`thumb_bytes` must be a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the
row loaded the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
door. It then **waits for a second latch** (bounded: twelve retries) and drives the two group layouts
through the buttons: `[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1` (the
buttons read the selection), `[events] halve`… `[events] halves a_first=20001 a_second=0 b_first=0
b_second=20001` -- **the two halves of the ring, and the reading that says a comparison's gap is a
gap: the first event's channel is finite only in the first half and the second's only in the second**
-- `[events] compare: kind=1 rows=4 channels=8 samples0=40002 traces0=2 traces1=2 window=4.000
recording=1 pane=1` (`samples0` must be exactly the stamps pushed, **not more**: a live producer left
running pushes its own four signals into the group's eight channels), `[events] compare plots:
recording=1 page=events` (`samples0` must be exactly the stamps pushed, **not more**: a live producer left
running pushes its own four signals into the group's eight channels), the dock line `members=2 … at=comparison
up` (the hollow markers on the group's members, which only the dock shows), `[events] compare plots:
area_b=1 rows_a=1 rows_b=1 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1` (the second plot exists, is
on screen, mirrors the row count, and both are on one x window), then the sync -- the probe sets a y
range nobody would arrive at by accident on the *first* plot and reads the second a few frames later:
`[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1` -- and `[events] accumulate: kind=2 rows=1
channels=8 samples0=33045 selected=2 traces0=8 window=2.000` (`traces0=8`: the four signals the layout
holds plus one layer each for the second event, **in the same row**, which is what an accumulation
means). Run it with a throwaway
`XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s).
means). The exit dump adds `events pane: compare=1 accumulate=1 list=1 nested=1` and the readouts line's
`dock_children=3`. Run it with a throwaway
`XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s). The dock row cap
(24) is only exercised by a cache that has filled past it: `max_ok=1`.
- **A waveform round-trips.** Export from a live run (`UDPSCOPE_EXPORT_MS` with
`UDPSCOPE_EXPORT_PATH`, against the mock at 10 kS/s), then open that file with `--open` and an
exit dump. The load must print
@@ -368,7 +807,7 @@ as distinct from the fault injection below, which exercises ingest:
python3 -c "import numpy as np; n=1000000; t=-4.0+np.arange(n)/50000.0; \
a=np.zeros(n, dtype=[('time','<f8'),('abs_seconds','<f8'),('a','<f4'),('b','<f4'),('c','<f4'),('d','<f4')]); \
a['time']=t; a['abs_seconds']=1000.0+t; a['d']=np.linspace(-1,1,n); np.save('/tmp/long.npy', a)"
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --open /tmp/long.npy --buffer-mb 1
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --open /tmp/long.npy --buffer-samples 32768
```
must read `samples=31250 rows=1000000 decim=32 span=19.999980s time=[-4.000000..15.999980]` and
`stored=31250 … published=31250` per channel — **the whole span, at a thirty-second of the rate**.
@@ -401,21 +840,21 @@ as distinct from the fault injection below, which exercises ingest:
the field the file dialog's callback sets and prints `[sources] picked waveform <path>` — then the
load line above, with no socket involved.
- **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME`
holding `127.0.0.1_8101/bench.toml` (`buffer_mb = 64`, `relative_time = true`,
holding `127.0.0.1_8101/bench.toml` (`buffer_samples = 2097152`, `relative_time = true`,
`capture_packets = 500`, `active` = `bench`), `./build/udpscope --server 127.0.0.1 --port 8101`
must print `[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500`, the
`relative time:` line, and a `span=` of about 209 s (64 MB at 10 kS/s) — **not** the 200 MB
default's ~655 s. The same command with `--buffer-mb 8` must read `buffer=8` and `span=26.214s`:
must print `[sources] settings from configuration 'bench': samples=2097152 relative=1 capture=500`, the
`relative time:` line, and a `span=` of about 209 s (2 097 152 ticks at 10 kS/s) — **not** the 10M default's ~839 s. The same command with
`--buffer-samples 262144` must read `samples=262144` and `span=26.214s`:
**an explicit flag wins over the file**. Without this, the settings a host was edited to have were
ignored whenever the address came from the command line.
- The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it:
`UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints
`[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1`, then
`[sources] configuration created: host:port/probe-host … buffer=32 relative=1` and a
`[sources] form: name='probe-host' note='…' samples_m=0.5 relative=1 connect_sensitive=1`, then
`[sources] configuration created: host:port/probe-host … samples=500000 relative=1` and a
`picked … config=probe-host`; a following `UDPSCOPE_PROBE_SOURCES=1` must list it
(`buffer=32 relative=1 note='…'`) — a configuration the form wrote that the chooser does not
(`samples=500000 relative=1 note='…'`) — a configuration the form wrote that the chooser does not
list cannot be edited next time. `UDPSCOPE_PROBE_EDITHOST=host:port` opens the same form on that
configuration and prints `[sources] edit form: name='…' note='…' buffer=… relative=… button='Save'`
configuration and prints `[sources] edit form: name='…' note='…' samples_m=… relative=… button='Save'`
— **the pre-fill is the assertion**: an edit form that opens empty cannot edit anything — then
saves and reports the new values. `UDPSCOPE_PROBE_EDITNAME=new-name` renames it as well: one
`known:` row afterwards, with the old file gone.
@@ -489,7 +928,7 @@ as distinct from the fault injection below, which exercises ingest:
at 64. The `mixed` case's `open_holes=43` against `bands=43` has no such spread because
nothing re-planned. The per-field reference is in [probe-readings.md](probe-readings.md).
The mock also carries three **fault injections** that no probe schedules, because what they exercise
The mock also carries four **fault injections** that no probe schedules, because what they exercise
is the ingest rather than the UI. The first: `--time-glitch-at <stream seconds> --time-glitch-by <seconds>` makes
**one** packet's time signal jump ahead and then carries on from where the true timeline was, which
is what a device whose clock glitches puts on the wire. Run it with the trigger armed, e.g.
@@ -514,7 +953,7 @@ Four readings, all off the once-a-second `[udpscope] trig engine:` line:
Before the fix the same run read a fallback warning, `history` collapsing 4.03 → 0.95, `now_t`
jumping 5.040 → 16.040, and two latches **1.2 s** apart. Note the window here comes from the saved
session for that port; the figures above are the default 2 s window (50 % pre), so move
`~/.config/udpscope/<host>_<port>.toml` aside to reproduce them.
`~/.config/udpscope/<host>_<port>/` aside to reproduce them.
The other two are **packet loss** (`--drop-every N`, `--drop-burst K`) and **wire inversion**
(`--reorder-every N`). Loss withholds a datagram the counter and the stream clock have already
@@ -524,6 +963,41 @@ arrives within the 20 ms reorder window is resequenced with no hole, and one tha
leaves a committed hole that it then backfills (`UDPSCOPE_PROBE_RESEQ_MS` exercises both paths).
There is **no interpolation** anywhere below the ring.
The fourth is the counter itself: **`--counter-step N`** advances the header counter by `N` per
datagram instead of one -- a device whose counter counts *samples*, or frames of an inner layer,
rather than packets. Nothing is withheld and every datagram goes out on schedule, so the link is
intact and the status bar is right to say `live`; what is wrong is the counter's claim, which the
client cannot check (it has no clock) and only the app can (it has the arrival times). It is the
one wire shape the mock could not otherwise produce, and the run that reads it is the reseq probe
with relative time on (the mode that frames a counter gap as NaN by construction):
```sh
# mock: --counter-step 10
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 \
UDPSCOPE_PROBE_RESEQ_MS=5000 UDPSCOPE_EXIT_DUMP=1 \
./build/udpscope --server 127.0.0.1 --port 8000 --relative-time
```
```
# fixed: [reseq-probe] … holes=1 hole_slots=900 backfilled=0 gaps_refused=419 open_holes=0
# bands=0 bands_repaired=0 nan_slots=0 scanned=21700 trigger_in_hole=0
# status bar: glance=[● live · 9.97 kS/s · 2 s window]
# broken: [reseq-probe] … holes=420 hole_slots=378000 backfilled=0 open_holes=64 bands=64
# bands_repaired=0 nan_slots=198000 scanned=220000 trigger_in_hole=0
# status bar: glance=[● live · 101 kS/s · 2 s window]
```
**`gaps_refused` above 0 with `nan_slots` at 0 is the assertion** — the gap was refused and the
samples were kept. The broken reading is `nan_slots` at 90% of `scanned` with `backfilled=0`: a
reserved hole per datagram, `relative_time` framing each one as NaN, and nothing ever missing to
backfill it, i.e. one missing-data band over the whole window with no trace in it, under a status
bar reading `live` at ten times the stream's real rate. The same field
must be **0** on every honest shape above (`--drop-every`, `--drop-burst 200`,
`--reorder-delay-ms 50`, at 10 kS/s: `holes=210 hole_slots=21000 nan_slots=11000 scanned=22000
gaps_refused=0` for `--drop-every 2`, `holes=3 hole_slots=60000 nan_slots=20000` for the 200-packet
burst at 100 kS/s) — a non-zero reading there means the arrival test is refusing real loss, whose
only cost is a gap with no shading.
All of the readings below are off `UDPSCOPE_EXIT_DUMP`'s `[udpscope] frames= packets= gaps=` line,
over an 8 s run against `--rate 10000 --batch 100 --freq 5` (100 packets a second) except where the
burst needs the faster stream. **`gaps` is the exact reading** and repeats to the packet; `frames`
@@ -576,13 +1050,24 @@ same run).
```bash
cd /home/martino/Projects/tools/udpscope
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
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
UDPSCOPE_PROBE_NOISE_MS=4000 \
./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
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
@@ -594,8 +1079,9 @@ stages into one is the failure mode**: a probe that rebuilt the buffer at 11.5 s
immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator.
**Why 4 s and not 3.** `enable_all_probe`'s last act is a timeout of its own, `ENABLE_ALL_MS + 2000` -- 3 s
under this recipe -- which walks `signal_list` for the front channel's acquire tick and clicks it. A probe
that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then
under this recipe -- which opens the front channel's signal page and clicks its "Acquire this signal" tick (an
acquired signal is not in the Add-a-signal list, so the list cannot be walked). A probe
that installs a math graph at that same instant rebuilds the signal panels under it, and the off-step then
emits `active` on a freed `GtkCheckButton`: `GLib-GObject-CRITICAL: instance with invalid (NULL) class
pointer`, with the backtrace in `enable_all_probe()::{lambda}`. It is a real fragility of that probe's lookup
and not of the feature, and it is why every documented probe time is offset from `ENABLE_ALL_MS + 2000`, not
@@ -611,8 +1097,11 @@ have run. `--spike-every`/`--spike-amp`/
The three lines and what each must read are in `probe-readings.md`.
`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
in the same run. The noise and measurement probes both push onto `app.measures`, and the later one would read
the earlier one's rows as its own; the noise and math probes both write `app.math`, so the later one's graph
`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS`, `UDPSCOPE_PROBE_MEASURES_MS`,
`UDPSCOPE_PROBE_BANDAVG_MS`, `UDPSCOPE_PROBE_MATH_MS` and `UDPSCOPE_PROBE_MATHWRAP_MS` must **not** be
scheduled in the same run. The noise, measurement, measurements and band-average probes all push onto
`app.measures`, and the later one would read
the earlier one's rows as its own; the noise, math and math-wrap probes all write `app.math`, so the later
one's graph
lands on top of the earlier one's and both sets of readings describe a state neither probe set up. The noise probe carries
its own measurement legs (see `probe-readings.md`), so nothing is lost by keeping them apart.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 129 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,681 @@
# E-1608 Network Scan Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The E-1608 connection form gets a "Scan network" row that finds boards on every local interface and fills the address or the MAC from a click; the MAC lookup in `E1608Source` stops depending on the default route.
**Architecture:** A non-blocking `Scan` class in `e1608_link` opens one UDP socket per up IPv4 broadcast interface (bound to the device, or to its address), sends `D` on each and drains replies on `poll()`. The form drives it with a 50 ms GLib timer on the GTK main thread (the project has exactly two threads, so no worker). `discover_broadcast()` is `Scan` plus a blocking wait and replaces the limited-broadcast `discover()` call in the source's MAC lookup.
**Tech Stack:** C++20, GTK4/libadwaita (`AdwExpanderRow`, `AdwActionRow`), POSIX sockets (`getifaddrs`, `SO_BINDTODEVICE`), `tools/mock_e1608.py`, the in-app headless probe under `xvfb-run`.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md` (approved 2026-11-02).
- Exactly two threads: no worker thread for the scan; the form polls with `g_timeout_add*`.
- Every source file carries the SPDX MIT header (no new source files in this plan).
- Text buttons only (no theme icons) on the new controls.
- A `subnet` that is given still uses `sweep_targets` + `discover` unchanged.
- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty result.
- Scan results are never persisted: the session reads and writes only the filled fields.
- Baselines: a clean build has exactly 3 warnings (an incremental build shows the 2 `ui_probe.cpp` MathNode ones); `gtk warnings: 0`; no `Gtk-CRITICAL`.
- GTK traps (`docs/claude/invariants-gtk-and-chrome.md`): handler signatures are notify `(GObject*, GParamSpec*, gpointer)`, clicked `(GtkButton*, gpointer)`, changed `(GtkEditable*, gpointer)`; handlers behind `Form::alive`; probes use `g_object_ref_sink` + `g_object_unref` on never-parented rows.
- Probe code must avoid `x({...}).empty() || y({...}).empty()` (GCC `-Wdangling-pointer`).
- Verification runs are short and serial; kill every mock afterwards. Do not push or merge.
## File Structure
- Modify `src/source/e1608/e1608_link.hpp` / `.cpp`: `class Scan`, `discover_broadcast()`, a shared reply parser used by `discover()` too.
- Modify `src/source/e1608/e1608_source.cpp`: the MAC lookup with an empty subnet uses `discover_broadcast()`.
- Modify `src/source/e1608/e1608_kind.cpp`: the scan row, result rows, fills, timer.
- Modify `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp`: the `UDPSCOPE_PROBE_E1608_SCAN` probe.
- Modify docs: `README.md`, `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`.
One design note the spec leaves open: `AdwExpanderRow` can only append rows, so "results right after the scan row" is done by making the scan row itself an `AdwExpanderRow` ("Scan network", a text "Scan" suffix button) at the top of the Connection expander, with the results as its child rows. It reads the same: results sit directly under the scan row.
---
### Task 1: The `Scan` class, `discover_broadcast()`, and the MAC lookup
**Files:**
- Modify: `src/source/e1608/e1608_link.hpp` (after `discover`, line ~77)
- Modify: `src/source/e1608/e1608_link.cpp` (includes; anonymous namespace; `discover`; new definitions)
- Modify: `src/source/e1608/e1608_source.cpp:270-293`
**Interfaces:**
- Produces (used by Task 2):
```cpp
class Scan {
public:
explicit Scan(int port);
Scan(const Scan&) = delete;
Scan& operator=(const Scan&) = delete;
const std::vector<DeviceInfo>& poll(); // drains non-blocking; cumulative, one entry per MAC
std::size_t interfaces() const noexcept; // sockets opened
};
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
```
- Consumes: existing `Fd`, `DeviceInfo`, `kProductId`, `Clock`, `ms_left`.
- [ ] **Step 1: Declare the API**
In `e1608_link.hpp`, after the `discover` declaration add:
```cpp
/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default
/// route only, so on a host with several interfaces it can miss a board on another one; this opens
/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or
/// to the interface address when binding to the device is refused), sends `D` to
/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the
/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan.
class Scan {
public:
explicit Scan(int port);
Scan(const Scan&) = delete;
Scan& operator=(const Scan&) = delete;
/// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC.
const std::vector<DeviceInfo>& poll();
/// How many interfaces a probe was sent on.
std::size_t interfaces() const noexcept { return socks_.size(); }
private:
std::vector<Fd> socks_;
std::vector<DeviceInfo> found_;
};
/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses.
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
```
- [ ] **Step 2: Share the reply parser**
In `e1608_link.cpp` add includes `#include <ifaddrs.h>`, `#include <net/if.h>`, `#include <thread>` (keep the list alphabetical with the existing ones). Inside the existing anonymous namespace, just before its closing `} // namespace` (after `tcp_connect`), add:
```cpp
/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board
/// that `found` did not already hold (one entry per MAC).
bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from,
std::vector<DeviceInfo>& found) {
if (n < 29 || buf[0] != 'D') return false;
DeviceInfo info;
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
if (info.product_id != kProductId) return false;
std::copy(buf + 1, buf + 7, info.mac.begin());
for (const DeviceInfo& d : found) {
if (d.mac == info.mac) return false;
}
char ip[INET_ADDRSTRLEN] = {};
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
info.ip = ip;
found.push_back(info);
return true;
}
```
In `discover()`, replace the block from `if (n < 29 || buf[0] != 'D') continue;` through `found.push_back(info);` (the lines that build `info`, test the MAC for a duplicate and push) with the single call:
```cpp
take_reply(buf, n, from, found);
```
- [ ] **Step 3: Implement `Scan` and `discover_broadcast`**
After `discover()` (before the `// --- Link` banner) add:
```cpp
Scan::Scan(int port) {
ifaddrs* list = nullptr;
if (::getifaddrs(&list) != 0) return;
sockaddr_in to{};
to.sin_family = AF_INET;
to.sin_port = htons(static_cast<std::uint16_t>(port));
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
const std::uint8_t probe = 'D';
std::vector<std::string> done; // an interface with several addresses needs one probe, not several
for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) {
if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue;
const unsigned fl = a->ifa_flags;
if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue;
const std::string name = a->ifa_name;
if (std::find(done.begin(), done.end(), name) != done.end()) continue;
Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
if (!s.valid()) continue;
const int one = 1;
::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(),
static_cast<socklen_t>(name.size())) != 0) {
sockaddr_in me = *reinterpret_cast<const sockaddr_in*>(a->ifa_addr);
me.sin_port = 0;
if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&me), sizeof me) != 0) continue;
}
if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&to),
sizeof to) != 1)
continue;
done.push_back(name);
socks_.push_back(std::move(s));
}
::freeifaddrs(list);
}
const std::vector<DeviceInfo>& Scan::poll() {
for (const Fd& s : socks_) {
for (;;) {
std::uint8_t buf[128];
sockaddr_in from{};
socklen_t flen = sizeof from;
const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT,
reinterpret_cast<sockaddr*>(&from), &flen);
if (n < 0) break; // nothing more on this socket for now
take_reply(buf, n, from, found_);
}
}
return found_;
}
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms) {
Scan scan(port);
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
while (ms_left(deadline) > 0) {
scan.poll();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return scan.poll();
}
```
- [ ] **Step 4: Use it in the source's lookup**
In `e1608_source.cpp` `connect_and_start()`, replace
```cpp
const std::vector<std::string> targets =
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
const std::vector<DeviceInfo> found =
discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500));
```
with
```cpp
const std::vector<std::string> targets =
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
const int wait_ms = std::min(cfg_.timeout_ms, 1500);
// No address and no subnet: the limited broadcast, sent on every interface rather than only
// the default route's.
const bool broadcast =
host.empty() && targets.size() == 1 && targets[0] == "255.255.255.255";
const std::vector<DeviceInfo> found =
broadcast ? discover_broadcast(cfg_.port, wait_ms) : discover(targets, cfg_.port, wait_ms);
```
- [ ] **Step 5: Build**
Run: `cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done`
Expected: no `error:`; only the 2 baseline `ui_probe.cpp` MathNode warnings (incremental) -- nothing new from `e1608_link.cpp` or `e1608_source.cpp`.
- [ ] **Step 6: Verify the MAC lookup end to end against the mock**
The mock answers a broadcast `D` on UDP 54211. A run with only a `mac` option (no address, no subnet) must find it and stream:
```sh
python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 &
MOCK=$!
UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a ./build/udpscope --source e1608 \
--source-opt mac=00:80:2f:00:16:08 --source-opt scan_rate=1000 2>&1 | grep -E "^\[e1608\] (stream|state)"
kill $MOCK
```
Expected: `[e1608] stream: connected=1 ... ok=1` and `state: connects=1 message=''`.
If it reads `connected=0` with `no E-1608 with MAC ... answered`: the sandbox did not loop the broadcast back to the mock. Check with `ip -4 -o addr` that an up non-loopback interface with a broadcast address exists. If one does and it still fails, report that to the user as a finding; do not paper over it.
- [ ] **Step 7: Commit**
```bash
git add src/source/e1608/e1608_link.hpp src/source/e1608/e1608_link.cpp src/source/e1608/e1608_source.cpp
git commit -m "e1608: discover boards on every interface, not only the default route's"
```
---
### Task 2: The form's "Scan network" row, results and fills
**Files:**
- Modify: `src/source/e1608/e1608_kind.cpp`
**Interfaces:**
- Consumes: `Scan`, `DeviceInfo`, `format_mac` (Task 1 / existing, via `e1608_source.hpp`'s includes of `e1608_link.hpp`; add the include if the build says otherwise).
- Produces (used by Task 3's probe), by widget name: the scan row `AdwExpanderRow` named `e1608-scan-row`; its suffix `GtkButton` named `e1608-scan`; each result an `AdwActionRow` named `e1608-result` (title IP, subtitle MAC, `activated` fills the address) holding a suffix button named `e1608-use-mac`. Subtitle texts: `Scanning...`, `1 board found`, `N boards found`, `No board found`.
- [ ] **Step 1: Add the state and `fill()` to `Form`**
In `struct Form`, after `bool alive = true;...` add:
```cpp
GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows
std::vector<GtkWidget*> results; ///< the result rows of the current scan
std::unique_ptr<Scan> scan; ///< non-null while a scan is running
guint timer = 0; ///< the GLib source polling `scan`, 0 when none
int scan_ms = 0; ///< time spent in the running scan
```
and, after `write(...)`:
```cpp
/// A result click: puts one way of naming the board in and the other two fields out, under the
/// `updating` guard so the dialog is told once.
void fill(const std::string& addr, const std::string& mac_text) {
if (!alive) return;
updating = true;
gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str());
gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str());
gtk_editable_set_text(GTK_EDITABLE(subnet), "");
updating = false;
if (changed && *changed) (*changed)();
}
void stop_scan() {
if (timer != 0) g_source_remove(timer);
timer = 0;
scan.reset();
}
```
Add `#include <vector>` to the includes if absent.
- [ ] **Step 2: Destroy hook stops the timer**
Replace `on_form_destroy` with:
```cpp
void on_form_destroy(GtkWidget*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
f.alive = false;
f.stop_scan(); // the poll must not outlive the rows it writes to
}
```
- [ ] **Step 3: The scan functions**
After `hook_entry(...)` and before `class E1608Kind`, add:
```cpp
constexpr int kScanMs = 1500; ///< how long a scan listens
constexpr int kTickMs = 50; ///< how often it drains the sockets
/// A result row's two ways to name the board, with the form they fill.
struct Pick {
FormPtr form;
std::string ip, mac;
};
void free_pick(gpointer d, GClosure*) { delete static_cast<Pick*>(d); }
void on_pick_address(AdwActionRow*, gpointer user) {
Pick& p = *static_cast<Pick*>(user);
p.form->fill(p.ip, "");
}
void on_pick_mac(GtkButton*, gpointer user) {
Pick& p = *static_cast<Pick*>(user);
p.form->fill("", p.mac);
}
void add_result(const FormPtr& f, const DeviceInfo& d) {
const std::string mac = format_mac(d.mac);
GtkWidget* row = adw_action_row_new();
gtk_widget_set_name(row, "e1608-result");
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str());
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str());
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE);
GtkWidget* use_mac = gtk_button_new_with_label("Use MAC");
gtk_widget_set_name(use_mac, "e1608-use-mac");
gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER);
adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac);
g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address),
new Pick{f, d.ip, mac}, free_pick, static_cast<GConnectFlags>(0));
g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac},
free_pick, static_cast<GConnectFlags>(0));
adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row);
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE);
f->results.push_back(row);
}
void set_scan_status(Form& f, const std::string& text) {
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str());
}
/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is
/// settled and the scan (its sockets) is released.
gboolean on_scan_tick(gpointer user) {
const FormPtr& fp = *static_cast<FormPtr*>(user);
Form& f = *fp;
if (!f.alive || !f.scan) {
f.timer = 0;
return G_SOURCE_REMOVE;
}
const std::vector<DeviceInfo> found = f.scan->poll();
while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]);
f.scan_ms += kTickMs;
if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE;
if (found.empty())
set_scan_status(f, "No board found");
else if (found.size() == 1)
set_scan_status(f, "1 board found");
else
set_scan_status(f, std::to_string(found.size()) + " boards found");
f.scan.reset();
f.timer = 0;
return G_SOURCE_REMOVE;
}
void free_form_ptr(gpointer d) { delete static_cast<FormPtr*>(d); }
/// A new scan on the Port field's value: the previous results go, a running scan is replaced.
void start_scan(const FormPtr& fp) {
Form& f = *fp;
if (!f.alive) return;
f.stop_scan();
for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r);
f.results.clear();
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE);
f.scan = std::make_unique<Scan>(static_cast<int>(value_of(f.port)));
f.scan_ms = 0;
set_scan_status(f, "Scanning...");
f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp),
free_form_ptr);
}
void on_scan_clicked(GtkButton*, gpointer user) {
start_scan(*static_cast<FormPtr*>(user));
}
```
(`f.stop_scan()` removes the timer; `g_source_remove` fires `free_form_ptr`, which only drops one `FormPtr` copy, never the last. `on_scan_tick` never calls `g_source_remove` on itself: it zeroes `timer` and returns `G_SOURCE_REMOVE`.)
- [ ] **Step 4: Build the row in `make_config_form()`**
After the `f->ao_init[1] = ...` line, add:
```cpp
f->scan_row = adw_expander_row_new();
gtk_widget_set_name(f->scan_row, "e1608-scan-row");
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network");
GtkWidget* scan_button = gtk_button_new_with_label("Scan");
gtk_widget_set_name(scan_button, "e1608-scan");
gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER);
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button);
g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f),
free_form, static_cast<GConnectFlags>(0));
```
Change the Connection group to start with the scan row:
```cpp
GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
f->code, f->timeout});
```
Update the description string to:
```cpp
out.description =
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), "
"or scan the network for boards";
```
- [ ] **Step 5: Build, then check the existing form round trip and warnings**
```sh
cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done
UDPSCOPE_PROBE_SOURCE_KINDS=1000 UDPSCOPE_EXIT_AFTER_MS=2500 xvfb-run -a ./build/udpscope \
--source probe --source-opt rate=500 2>&1 | grep -E "form e1608|gtk warnings|Gtk-CRITICAL"
```
Expected: no `error:`, only the 2 baseline warnings; `[source-kinds] form e1608: round_trip=1 valid=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`.
- [ ] **Step 6: Commit**
```bash
git add src/source/e1608/e1608_kind.cpp
git commit -m "e1608: a Scan network row in the connection form, with click-to-fill results"
```
---
### Task 3: The scan probe, docs, and final verification
**Files:**
- Modify: `src/ui_probe.cpp` (after `e1608_probe_reconnect`, ~line 2459)
- Modify: `src/ui_app.hpp:1874-1875`
- Modify: `src/ui.cpp:6031-6033`
- Modify: `README.md` (E-1608 paragraph, lines ~118-146), `docs/claude/build-and-run.md:118-130`, `docs/claude/verification.md` (after the `UDPSCOPE_PROBE_E1608_RECONNECT` bullet, ~line 443), `docs/claude/probe-readings.md` (`## [e1608]` section, ~line 1848-1890), `docs/claude/architecture.md:45-48`
**Interfaces:**
- Consumes: the widget names from Task 2; `find_named()`, `find_kind()` in `ui_probe.cpp`.
- Produces: env var `UDPSCOPE_PROBE_E1608_SCAN=<ms>` and optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` (default `00:80:2f:00:16:08`, the mock's); one stderr line `[e1608] scan: ...`.
- [ ] **Step 1: Declare and register the probe**
In `ui_app.hpp`, after `gboolean e1608_probe_resumed(gpointer user);` add:
```cpp
/// The E-1608 form's "Scan network" row: presses Scan against the mock, then (phases `_fill`,
/// `_rescan`) clicks a result to fill the address, "Use MAC" to fill the MAC, scans again and checks
/// the results were replaced, not added to.
gboolean e1608_scan_probe(gpointer user);
gboolean e1608_scan_probe_fill(gpointer user);
gboolean e1608_scan_probe_rescan(gpointer user);
```
In `ui.cpp`, after the `UDPSCOPE_PROBE_E1608_RECONNECT` block add:
```cpp
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_scan_probe, &app);
}
```
- [ ] **Step 2: Write the probe**
In `ui_probe.cpp`, after `e1608_probe_reconnect`, add (this is outside the file's anonymous namespace, like its neighbours; the helpers it uses, `find_named` and `find_kind`, are visible):
```cpp
namespace {
/// The scan probe's state, alive from phase 1 to phase 3.
struct E1608ScanProbe {
ConfigForm form;
int changes = 0; ///< how many times the form told the dialog it changed
std::string want_mac; ///< the board the probe expects (the mock's by default)
std::string status; ///< the scan row's subtitle, as read at the end of the first scan
std::string ip; ///< the expected board's address, as listed
std::size_t first = 0; ///< result rows after the first scan
bool listed = false; ///< the expected board was among them
bool fill_addr = false;
bool fill_mac = false;
};
E1608ScanProbe* g_scan_probe = nullptr;
void collect_named(GtkWidget* root, const char* name, std::vector<GtkWidget*>& out) {
if (root == nullptr) return;
if (std::strcmp(gtk_widget_get_name(root), name) == 0) out.push_back(root);
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
c = gtk_widget_get_next_sibling(c))
collect_named(c, name, out);
}
std::string scan_status(E1608ScanProbe& p) {
GtkWidget* row = find_named(p.form.rows[0], "e1608-scan-row");
const char* t = row != nullptr ? adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(row)) : nullptr;
return t != nullptr ? t : "";
}
/// A form holding stale values in all three naming fields, so a fill is seen to clear the others.
void scan_probe_stale(E1608ScanProbe& p) {
SourceConfig c;
c.set("address", "1.2.3.4");
c.set("mac", "aa:bb:cc:dd:ee:ff");
c.set("subnet", "10.0.0");
p.form.write(c);
p.changes = 0;
}
void scan_probe_click(E1608ScanProbe& p) {
GtkWidget* button = find_named(p.form.rows[0], "e1608-scan");
if (button != nullptr) g_signal_emit_by_name(button, "clicked");
}
void scan_probe_cleanup() {
if (g_scan_probe == nullptr) return;
for (GtkWidget* row : g_scan_probe->form.rows) {
g_object_ref_sink(row);
g_object_unref(row);
}
delete g_scan_probe;
g_scan_probe = nullptr;
}
} // namespace
/// Phase 3, 2 s after the second Scan: the results were replaced (same count), and the one line.
gboolean e1608_scan_probe_rescan(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
E1608ScanProbe& p = *g_scan_probe;
std::vector<GtkWidget*> rows;
collect_named(p.form.rows[0], "e1608-result", rows);
const bool replaced = rows.size() == p.first;
const std::string status = scan_status(p);
const bool status_ok = status.find("found") != std::string::npos && status.rfind("No", 0) != 0;
const bool ok = p.listed && p.fill_addr && p.fill_mac && replaced && status_ok;
std::fprintf(stderr,
"[e1608] scan: found=%zu ip=%s mac=%s status='%s' fill_addr=%d fill_mac=%d "
"rescan_replaced=%d ok=%d\n",
p.first, p.ip.c_str(), p.want_mac.c_str(), p.status.c_str(), p.fill_addr ? 1 : 0,
p.fill_mac ? 1 : 0, replaced ? 1 : 0, ok ? 1 : 0);
if (!p.listed)
std::fprintf(stderr,
"[e1608] scan: %s did not answer; no up broadcast interface reaches it, or the "
"broadcast was refused (this run proves nothing about the scan)\n",
p.want_mac.c_str());
std::fflush(stderr);
scan_probe_cleanup();
return G_SOURCE_REMOVE;
}
/// Phase 2, 2 s after the first Scan: the expected board is listed, a result click fills the address
/// only, "Use MAC" fills the MAC only (each telling the dialog exactly once), then Scan again.
gboolean e1608_scan_probe_fill(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
E1608ScanProbe& p = *g_scan_probe;
p.status = scan_status(p);
std::vector<GtkWidget*> rows;
collect_named(p.form.rows[0], "e1608-result", rows);
p.first = rows.size();
GtkWidget* mine = nullptr;
for (GtkWidget* r : rows) {
const char* sub = adw_action_row_get_subtitle(ADW_ACTION_ROW(r));
if (sub != nullptr && p.want_mac == sub) mine = r;
}
p.listed = mine != nullptr;
if (mine != nullptr) {
const char* title = adw_preferences_row_get_title(ADW_PREFERENCES_ROW(mine));
p.ip = title != nullptr ? title : "";
scan_probe_stale(p);
g_signal_emit_by_name(mine, "activated");
const SourceConfig by_addr = p.form.read();
p.fill_addr = by_addr.get("address") == p.ip && by_addr.get("mac").empty() &&
by_addr.get("subnet").empty() && p.changes == 1;
scan_probe_stale(p);
GtkWidget* use = find_named(mine, "e1608-use-mac");
if (use != nullptr) g_signal_emit_by_name(use, "clicked");
const SourceConfig by_mac = p.form.read();
p.fill_mac = by_mac.get("mac") == p.want_mac && by_mac.get("address").empty() &&
by_mac.get("subnet").empty() && p.changes == 1;
}
scan_probe_click(p);
g_timeout_add(2000, e1608_scan_probe_rescan, a);
return G_SOURCE_REMOVE;
}
/// Phase 1: the form, Scan pressed (the row must read `Scanning...` at once), phase 2 in 2 s.
gboolean e1608_scan_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
SourceKind* k = find_kind("e1608");
if (k == nullptr) {
std::fprintf(stderr, "[e1608] scan: no e1608 kind\n");
return G_SOURCE_REMOVE;
}
g_scan_probe = new E1608ScanProbe;
E1608ScanProbe& p = *g_scan_probe;
p.form = k->make_config_form();
const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN_MAC");
p.want_mac = env != nullptr ? env : "00:80:2f:00:16:08";
p.form.on_change([]() {
if (g_scan_probe != nullptr) ++g_scan_probe->changes;
});
scan_probe_click(p);
const std::string now = scan_status(p);
if (now != "Scanning...")
std::fprintf(stderr, "[e1608] scan: status right after Scan reads '%s', expected 'Scanning...'\n",
now.c_str());
g_timeout_add(2000, e1608_scan_probe_fill, a);
return G_SOURCE_REMOVE;
}
```
Notes for the implementer: `SourceConfig::get` returns `std::string` (the existing probe calls `f.read().get("signals") == "..."`); `find_kind`, `SourceConfig` and `ConfigForm` are already used in this file. Do not write `x({...}).empty() || y({...}).empty()`.
- [ ] **Step 3: Build and run against the mock**
```sh
cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done
python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 &
MOCK=$!
UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_EXIT_AFTER_MS=6500 xvfb-run -a ./build/udpscope \
--source e1608 --source-opt address=127.0.0.1 --source-opt scan_rate=1000 2>&1 \
| grep -E "^\[e1608\] scan|gtk warnings|Gtk-CRITICAL"
kill $MOCK
```
Expected: `[e1608] scan: found=1 ip=<a local address> mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`. `found=2` or more is fine if a real board is on the network (status then reads `N boards found`; the line still needs `ok=1`).
If `ok=0` and the second line says the board did not answer, check Task 1 Step 6's loopback note, and tell the user honestly; do not mark this step passed.
If a flag other than `listed` reads 0, debug that flag (the fill reads the form back; `changes` must be exactly 1 per fill).
- [ ] **Step 4: Clean-build warning count**
```sh
rm -rf /tmp/scan-clean && cmake -S . -B /tmp/scan-clean >/dev/null && cmake --build /tmp/scan-clean -j8 2>&1 | grep -c "warning:"; rm -rf /tmp/scan-clean
```
Expected: `3`.
- [ ] **Step 5: Docs**
- `README.md`, E-1608 paragraph (line ~119): after "...(optionally with the subnet to sweep)," add the scan: the sentence "A **Scan network** row at the top of the form probes every network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not saved: only the filled field is." Also add one sentence that a MAC with no subnet is now looked up on every interface, not only the default route's, because a single limited broadcast leaves through the default route alone and missed a board on another interface (found on a real board 2026-11-02, where the board was on `net0` and the default route was `wlan0`).
- `docs/claude/build-and-run.md` line ~125: change "a MAC with no `address` is looked up by broadcast" to "...looked up by a broadcast sent on every interface (`Scan`), or by a sweep of `subnet`".
- `docs/claude/verification.md`: after the `UDPSCOPE_PROBE_E1608_RECONNECT=8000` bullet add a bullet for `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt address=127.0.0.1` against the mock on its default port 54211 (the form scans that port), optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` for a real board; one `[e1608] scan:` line; note it needs a non-loopback up interface with a broadcast address, and that the line says so when the board did not answer.
- `docs/claude/probe-readings.md`, `## [e1608]`: add the sample line `[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1` to the block, and one bullet per field: `found` (result rows after the first scan; 0 with the "did not answer" second line is an environment without a reachable broadcast interface, not a pass), `status` (the row subtitle; "No board found" with a mock running is the probe sockets not reaching it), `fill_addr=0` (a result click did not set only the address, or told the dialog other than once), `fill_mac=0` (same for Use MAC), `rescan_replaced=0` (the results of the first scan were kept or doubled: `start_scan` did not remove them).
- `docs/claude/architecture.md` line ~45: in the E-1608 three-layers bullet, add that discovery is `Scan` (one socket per interface, polled by the caller; the form polls it from a GLib timer on the main thread, the source's lookup through `discover_broadcast`).
- [ ] **Step 6: Commit**
```bash
git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp README.md docs/claude
git commit -m "e1608: probe and docs for the network scan"
```
- [ ] **Step 7: Manual run on the real board (only if it is attached)**
Check `ip -4 -o addr` for the board's interface and `ping`-free: simply run the probe without the mock, naming the board's MAC (the user knows it; the MAC printed by the earlier hardware run was on `net0`, address 169.254.88.75):
```sh
UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_PROBE_E1608_SCAN_MAC=<board mac> UDPSCOPE_EXIT_AFTER_MS=6500 \
xvfb-run -a ./build/udpscope --source e1608 --source-opt address=127.0.0.1 2>&1 | grep -E "^\[e1608\] scan"
```
If it finds the board (`listed`, `ok=1`), add one sentence with the date to the README's E-1608 paragraph ("the network scan was checked against a real board on <date>") and commit `docs: record the scan check on a real board`. If the board is not attached or the MAC is unknown, say so; claim nothing.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,950 @@
# Badge bar, slide-over and dock -- step 1 (the shell) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the sidebar's tab switcher with a plot-edge slide-over panel reached from a badge bar under the plot (Trigger and Source badges), keeping Measure and Signals reachable in a temporary always-on dock, so the app is usable after every task.
**Architecture:** Two new units, `SlideOver` (`src/ui_slideover.cpp`, a `GtkRevealer` + `GtkStack` placed by margins as one opaque `GtkOverlay` child above the plot) and `BadgeBar` (`src/ui_badges.cpp`, a fixed-height row between the plot and the status line). The existing trigger form and the source kind's `SourcePanel` widgets are *reparented* into slide-over pages. The right `AdwOverlaySplitView` sidebar stays, but its `AdwViewSwitcher`/`AdwViewStack` become one scroller holding the signal list, the cursors and the measurements. The left events pane and everything else is untouched (spec step 4).
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8 (`AdwToggleGroup` is 1.7+), CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui.cpp` / `src/ui_probe.cpp` probes, never a separate harness.
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` (read it first). Branch: `feature/ui-badges-dock`.
## Global Constraints
Copied from the spec and the repository's rules; every task includes them.
- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the dock has a fixed width, the badge bar a fixed height, the slide-over overlays the plot and never resizes it. Rows that appear by trigger condition appear inside the slide-over's scroller.
- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget aligned to the end edge by `halign=END` and a 360 px width, never a full-area transparent child, so pan, drop and hover on the rest of the plot are untouched.
- The header's Trigger / Cursors / Pause toggles keep their two-way mirroring with the panels.
- `SourcePanel{id, title, widget}` is unchanged: the E-1608 and Flat UDP panels need no change.
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. (Step 1 implements `trigger` and `source-<id>` only.)
- The open slide-over page is **not** saved. The session file drops `side_tab`; an old file's `side_tab` is ignored.
- Esc or a click outside closes the slide-over; one page at a time; each page has its own scroller.
- "Disable, do not hide": the dock is shown but insensitive until the stream has signals.
- Text buttons only for new controls (no icon-only buttons, no icons in badges).
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` (the host has `WAYLAND_DISPLAY`) and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`.
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
## Deliberate step-1 deviations from the spec (state them in the final report)
- The dock keeps the sidebar's **320 px** (spec: ~280). The panels it holds were built for 320; the width change belongs to step 3 when they become compact readouts.
- The session file gains the dock's *shown* state only, not a width: the dock's width is fixed, so there is nothing to persist.
- The Trigger badge shows `off / armed / triggered` plus `capturing` and `disarmed`, so the label never claims a state the engine is not in.
## File Structure
| File | Create/Modify | Responsibility |
| --- | --- | --- |
| `src/ui_slideover.cpp` | Create | The slide-over: build, page registry, open/close/toggle, Esc, click-away, `slideover` probe. |
| `src/ui_badges.cpp` | Create | The badge bar: Trigger and Source badges, per-tick state, pure `trigger_badge_state()`, `badges` probe. |
| `src/ui_app.hpp` | Modify | `SlideOver`/`BadgeBar` structs on `App`, declarations, removal of `side_stack`/`side_switcher`/`side_tab`/`keep_tabs_icon_free`. |
| `src/ui/main-window.ui` | Modify | `plot_overlay` around `plot_box`; `badge_bar` row; the sidebar tab switcher/stack replaced by one dock scroller. |
| `src/ui_widgets.cpp` | Modify | CSS for badges and the slide-over (`install_css`). |
| `src/ui.cpp` | Modify | Build/wire both units in `on_activate`; tick sync; Esc; dock visibility; remove tab code. |
| `src/ui_source.cpp` | Modify | Source panels become slide-over pages; `keep_tabs_icon_free` deleted. |
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Tour/context-help re-pointed (task 5). |
| `src/ui_layout.cpp`, `src/layout_store.{hpp,cpp}` | Modify | `side_tab` out, `dock_shown` in. |
| `src/shortcuts.{hpp,cpp}`, `src/ui/shortcuts.ui` | Modify | `SideTabMeasure/Signals` -> `ShowMeasure/ShowSignals`. |
| `src/ui_probe.cpp` | Modify | New probes; every probe that touched the tab stack re-pointed. |
| `CMakeLists.txt` | Modify | Add the two new `.cpp` files to the `udpscope` sources (next to `src/ui_source.cpp`). |
| `README.md`, `docs/claude/*.md` | Modify | Task 6. (`USER-GUIDE.md` and the Mallard manual's figures are the spec's step 4.) |
---
### Task 1: SlideOver unit and the plot overlay (confirms the compositing risk)
Spec step 1 says: confirm the overlay-above-GL risk *before* anything else is built on it. This task builds the slide-over with a throw-away probe page and proves composite, geometry and input.
**Files:**
- Create: `src/ui_slideover.cpp`
- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_probe.cpp` (or put the probe in `ui_slideover.cpp`; `ui_app.hpp` declares it either way), `CMakeLists.txt`, `docs/claude/verification.md` (probe env var line)
**Interfaces:**
- Produces (all in `namespace udpscope::ui_impl`, declared in `ui_app.hpp`):
```cpp
/// The slide-over panel's widgets and state (src/ui_slideover.cpp). `page` is the open page's id, empty when closed.
struct SlideOver {
GtkWidget* overlay = nullptr; ///< the GtkOverlay around the plot (main-window.ui `plot_overlay`)
GtkWidget* revealer = nullptr; ///< the one overlay child; halign END, valign FILL, 360 px
GtkWidget* title = nullptr; ///< GtkLabel in the panel header
GtkWidget* stack = nullptr; ///< GtkStack: one scroller per page
GtkWidget* source_switch = nullptr; ///< AdwToggleGroup, shown on a source page when there are >= 2
std::vector<std::string> source_pages;
std::string page;
bool syncing = false; ///< true while we write the toggle group ourselves
};
constexpr int kSlideOverWidth = 360;
void build_slideover(App& app); // once, after the plot_overlay exists
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content);
bool slideover_has_page(const App& app, const std::string& id);
bool slideover_open(App& app, const std::string& id); // false when the id is unknown
void slideover_close(App& app);
bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close; false if unknown
const std::string& slideover_page(const App& app); // "" when closed
bool slideover_escape(App& app); // closes if open; true when it did
bool slideover_click_away(App& app, double x, double y); // x,y in overlay coords; closes unless inside the panel; true when it closed
gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS
```
`App` gains `SlideOver slideover;` and `GtkWidget* plot_overlay = nullptr;`. Task 2 consumes `slideover_add_page`, `slideover_toggle`, `slideover_page`; task 3 consumes `source_pages`.
- [ ] **Step 1: Capture the UDPS baseline from the current HEAD**
The tabs are about to go, so freeze today's self-check first. Build `HEAD` (`cmake --build build -j`), then:
```sh
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-badges-base.log 2>&1
kill $MOCK
sed 's/[0-9.]\+/N/g' /tmp/ui-badges-base.log > .superpowers/sdd/ui-badges-baseline-udps.txt
grep -c 'Gtk-CRITICAL' /tmp/ui-badges-base.log; grep 'gtk warnings' /tmp/ui-badges-base.log
```
Expected: `0` Gtk-CRITICAL, `gtk warnings: 0`. Also keep `/tmp/ui-badges-base.log` un-normalised. Every later task diffs its own normalised run against this file and must **explain every differing line** from that task's "allowed baseline changes" list; any other difference is a defect.
- [ ] **Step 2: Wrap the plot in `plot_overlay` in `src/ui/main-window.ui`**
In `left_column`, replace the first `<child>` (the bare `plot_box`) with:
```xml
<child>
<!-- The plot, and the slide-over panel that opens over its right edge. The panel is
one opaque overlay child placed by `halign=end` and a 360 px width -- never a
full-area transparent child, which would be picked wherever its allocation is
and swallow the plot's pan, drop and hover (docs/claude/invariants-plot-and-layout.md).
The overlay is the only parent change: `plot_box` keeps its id and its content. -->
<object class="GtkOverlay" id="plot_overlay">
<property name="hexpand">1</property>
<property name="vexpand">1</property>
<child>
<object class="GtkBox" id="plot_box">
... (the existing plot_box, unchanged) ...
</object>
</child>
</object>
</child>
```
Update the file's header comment: the plot column is `plot_overlay` > `plot_box`.
- [ ] **Step 3: Add the CSS to `install_css()` (`src/ui_widgets.cpp`)**
Append to `kCss` before the final `menubutton.udpscope-hint` rules (keep the string a single literal, each rule on its own `"...\n"` line):
```cpp
// The slide-over panel over the plot's right edge: opaque (it must hide the plot under it),
// with an edge and a shadow so it reads as above the plot rather than beside it.
".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;"
" box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n"
".udpscope-slideover-title { font-weight: bold; }\n"
```
- [ ] **Step 4: Write `src/ui_slideover.cpp`**
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The slide-over panel: one panel at a time over the plot's right edge, a page per setup surface.
// See docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md.
#include "ui_app.hpp"
#include <adwaita.h>
#include <cstdio>
#include <cstring>
namespace udpscope {
namespace ui_impl {
namespace {
void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast<App*>(user)); }
void on_source_switch(GObject* o, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->slideover.syncing) return;
const char* name = adw_toggle_group_get_active_name(ADW_TOGGLE_GROUP(o));
if (name != nullptr && app->slideover.page != name) slideover_open(*app, name);
}
/// Observes every press on the plot overlay, in the capture phase, and never claims the sequence: the
/// plot's own pan/zoom/drop gestures see the press exactly as they did before.
void on_press_capture(GtkGestureClick*, int, double x, double y, gpointer user) {
slideover_click_away(*static_cast<App*>(user), x, y);
}
bool is_source_page(const std::string& id) { return id.rfind("source-", 0) == 0; }
} // namespace
void build_slideover(App& app) {
SlideOver& s = app.slideover;
s.overlay = app.plot_overlay;
if (s.overlay == nullptr) return;
GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(panel, "udpscope-slideover");
gtk_widget_set_size_request(panel, kSlideOverWidth, -1);
gtk_widget_set_hexpand(panel, FALSE);
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(head, 12);
gtk_widget_set_margin_end(head, 6);
gtk_widget_set_margin_top(head, 6);
gtk_widget_set_margin_bottom(head, 6);
s.title = gtk_label_new("");
gtk_widget_add_css_class(s.title, "udpscope-slideover-title");
gtk_label_set_xalign(GTK_LABEL(s.title), 0.0f);
gtk_widget_set_hexpand(s.title, TRUE);
gtk_label_set_ellipsize(GTK_LABEL(s.title), PANGO_ELLIPSIZE_END);
GtkWidget* close = gtk_button_new_with_label("Close");
gtk_widget_add_css_class(close, "flat");
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &app);
gtk_box_append(GTK_BOX(head), s.title);
gtk_box_append(GTK_BOX(head), close);
gtk_box_append(GTK_BOX(panel), head);
// A source with several panels (the E-1608: Analog out, Digital I/O) gets a segmented switch under
// the header; it is hidden unless the open page is a source page and there are two or more.
s.source_switch = adw_toggle_group_new();
gtk_widget_set_margin_start(s.source_switch, 12);
gtk_widget_set_margin_end(s.source_switch, 12);
gtk_widget_set_margin_bottom(s.source_switch, 6);
gtk_widget_set_visible(s.source_switch, FALSE);
g_signal_connect(s.source_switch, "notify::active-name", G_CALLBACK(on_source_switch), &app);
gtk_box_append(GTK_BOX(panel), s.source_switch);
gtk_box_append(GTK_BOX(panel), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
s.stack = gtk_stack_new();
gtk_widget_set_vexpand(s.stack, TRUE);
gtk_box_append(GTK_BOX(panel), s.stack);
s.revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(s.revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT);
gtk_revealer_set_transition_duration(GTK_REVEALER(s.revealer), 120);
gtk_revealer_set_child(GTK_REVEALER(s.revealer), panel);
gtk_widget_set_halign(s.revealer, GTK_ALIGN_END);
gtk_widget_set_valign(s.revealer, GTK_ALIGN_FILL);
// Closed, it must not be a pick target for the frames of its slide-out.
gtk_widget_set_can_target(s.revealer, FALSE);
gtk_overlay_add_overlay(GTK_OVERLAY(s.overlay), s.revealer);
gtk_overlay_set_clip_overlay(GTK_OVERLAY(s.overlay), s.revealer, TRUE);
GtkGesture* click = gtk_gesture_click_new();
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE);
g_signal_connect(click, "pressed", G_CALLBACK(on_press_capture), &app);
gtk_widget_add_controller(s.overlay, GTK_EVENT_CONTROLLER(click));
}
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content) {
SlideOver& s = app.slideover;
if (s.stack == nullptr || content == nullptr || id.empty() || slideover_has_page(app, id)) {
return false;
}
// A scroller per page: pages differ in height, and one shared scroller would leave a page scrolled
// to somewhere it never asked for. It does not propagate the content's height, so the panel's
// height is the plot's and never the page's.
GtkWidget* scroller = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), content);
gtk_stack_add_titled(GTK_STACK(s.stack), scroller, id.c_str(), title.c_str());
if (is_source_page(id)) {
s.source_pages.push_back(id);
AdwToggle* t = adw_toggle_new();
adw_toggle_set_name(t, id.c_str());
adw_toggle_set_label(t, title.c_str());
adw_toggle_group_add(ADW_TOGGLE_GROUP(s.source_switch), t); // takes the reference
}
return true;
}
bool slideover_has_page(const App& app, const std::string& id) {
return app.slideover.stack != nullptr &&
gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr;
}
bool slideover_open(App& app, const std::string& id) {
SlideOver& s = app.slideover;
if (!slideover_has_page(app, id)) return false;
GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str());
gtk_stack_set_visible_child(GTK_STACK(s.stack), child);
s.page = id;
GtkStackPage* sp = gtk_stack_get_page(GTK_STACK(s.stack), child);
const char* title = gtk_stack_page_get_title(sp);
gtk_label_set_text(GTK_LABEL(s.title), title != nullptr ? title : "");
const bool several = is_source_page(id) && s.source_pages.size() >= 2;
gtk_widget_set_visible(s.source_switch, several);
if (several) {
s.syncing = true;
adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str());
s.syncing = false;
}
gtk_widget_set_can_target(s.revealer, TRUE);
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE);
sync_badge_bar(app);
return true;
}
void slideover_close(App& app) {
SlideOver& s = app.slideover;
if (s.revealer == nullptr || s.page.empty()) return;
s.page.clear();
gtk_widget_set_can_target(s.revealer, FALSE);
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE);
sync_badge_bar(app);
}
bool slideover_toggle(App& app, const std::string& id) {
if (!slideover_has_page(app, id)) return false;
if (app.slideover.page == id) {
slideover_close(app);
} else {
slideover_open(app, id);
}
return true;
}
const std::string& slideover_page(const App& app) { return app.slideover.page; }
bool slideover_escape(App& app) {
if (app.slideover.page.empty()) return false;
slideover_close(app);
return true;
}
bool slideover_click_away(App& app, double x, double y) {
SlideOver& s = app.slideover;
if (s.page.empty() || s.overlay == nullptr || s.revealer == nullptr) return false;
graphene_point_t in = GRAPHENE_POINT_INIT(static_cast<float>(x), static_cast<float>(y));
graphene_point_t out;
if (gtk_widget_compute_point(s.overlay, s.revealer, &in, &out) &&
gtk_widget_contains(s.revealer, out.x, out.y)) {
return false;
}
slideover_close(app);
return true;
}
// --- the probe ---------------------------------------------------------------------------------
namespace {
struct ProbeState {
App* app;
int plot_w_before = 0;
};
bool under(GtkWidget* w, GtkWidget* ancestor) {
return w != nullptr && ancestor != nullptr && (w == ancestor || gtk_widget_is_ancestor(w, ancestor));
}
gboolean slideover_probe_read(gpointer user) {
ProbeState* ps = static_cast<ProbeState*>(user);
App& a = *ps->app;
SlideOver& s = a.slideover;
const int ow = gtk_widget_get_width(s.overlay);
const int oh = gtk_widget_get_height(s.overlay);
graphene_rect_t r;
const bool have = gtk_widget_compute_bounds(s.revealer, s.overlay, &r);
const int rw = have ? static_cast<int>(r.size.width + 0.5f) : -1;
const int rx = have ? static_cast<int>(r.origin.x + 0.5f) : -1;
const int rh = have ? static_cast<int>(r.size.height + 0.5f) : -1;
// Outside the panel, a quarter in: the plot must be what answers. Inside it, the panel.
GtkWidget* outside = gtk_widget_pick(s.overlay, ow / 4.0, oh / 2.0, GTK_PICK_DEFAULT);
GtkWidget* inside = gtk_widget_pick(s.overlay, ow - kSlideOverWidth / 2.0, oh / 2.0, GTK_PICK_DEFAULT);
const int plot_w_open = gtk_widget_get_width(a.plot_box);
std::fprintf(stderr,
"[slideover] open page=%s panel=%dx%d at x=%d overlay=%dx%d edge_flush=%d full_height=%d "
"outside_hits_plot=%d inside_hits_panel=%d plot_w before=%d open=%d plot_unchanged=%d\n",
s.page.c_str(), rw, rh, rx, ow, oh, rx + rw == ow ? 1 : 0, rh == oh ? 1 : 0,
under(outside, a.plot_box) ? 1 : 0, under(inside, s.revealer) ? 1 : 0, ps->plot_w_before,
plot_w_open, ps->plot_w_before == plot_w_open ? 1 : 0);
// Click-away from outside the panel closes; a press inside does not.
const bool inside_closed = slideover_click_away(a, ow - kSlideOverWidth / 2.0, oh / 2.0);
const bool open_after_inside = !s.page.empty();
const bool outside_closed = slideover_click_away(a, ow / 4.0, oh / 2.0);
slideover_open(a, "probe");
const bool esc_closed = slideover_escape(a);
std::fprintf(stderr,
"[slideover] click_inside_closes=%d still_open=%d click_outside_closes=%d esc_closes=%d "
"closed=%d\n",
inside_closed ? 1 : 0, open_after_inside ? 1 : 0, outside_closed ? 1 : 0, esc_closed ? 1 : 0,
s.page.empty() ? 1 : 0);
delete ps;
return G_SOURCE_REMOVE;
}
} // namespace
gboolean slideover_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->slideover.stack == nullptr) return G_SOURCE_REMOVE;
// A page of the probe's own: the real pages arrive in later tasks, and this one has an entry so the
// panel has something that takes focus and keys.
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_box_append(GTK_BOX(box), gtk_label_new("slide-over probe page"));
gtk_box_append(GTK_BOX(box), gtk_entry_new());
slideover_add_page(*a, "probe", "Probe", box);
ProbeState* ps = new ProbeState{a, gtk_widget_get_width(a->plot_box)};
slideover_open(*a, "probe");
// The revealer's slide is 120 ms; the reading is taken well after it has settled.
g_timeout_add(500, slideover_probe_read, ps);
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope
```
`sync_badge_bar(App&)` does not exist until task 2: in this task add to `ui_app.hpp` a declaration and, in `ui_slideover.cpp`'s neighbour, nothing -- instead **temporarily omit** the two `sync_badge_bar(app);` calls and add them in task 2 step 4 (they are marked `// task 2` if you prefer a comment). Check every GTK/libadwaita signature against the installed headers (`/usr/include/gtk-4.0`, `/usr/include/libadwaita-1`); fix names rather than guessing.
- [ ] **Step 5: Declare, wire, register**
- `ui_app.hpp`: add the `SlideOver` struct, `kSlideOverWidth`, the function declarations from **Interfaces**, and on `App`: `SlideOver slideover; GtkWidget* plot_overlay = nullptr;`.
- `ui.cpp` `on_activate`: next to the other `gtk_builder_get_object` lookups add `app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));`, add it to the null guard, and call `build_slideover(*app);` right after `app->plot_split` is resolved and the tour overlay is built (the plot overlay exists from the builder, so any point before `make_area` works).
- `ui.cpp` probe scheduling (next to line ~5906 `side_tab_probe`): `if (const char* env = std::getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) g_timeout_add(static_cast<guint>(std::atoi(env)), slideover_probe, &app);`
- `CMakeLists.txt`: add `src/ui_slideover.cpp` after `src/ui_source.cpp`.
- `docs/claude/verification.md`: add an entry for `UDPSCOPE_PROBE_SLIDEOVER_MS=2000` (with `UDPSCOPE_EXIT_AFTER_MS=5000`): the two `[slideover]` lines and what each field must read (below).
- [ ] **Step 6: Build and run the probe**
```sh
cmake --build build -j 2>&1 | grep -E 'warning|error' ; echo "build rc=$?"
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 \
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[slideover\]|Gtk-|gtk warnings'
kill $MOCK
```
Expected: the build has zero warnings (incremental) and `[slideover]` line 1 reads `panel=360x<overlay height> ... edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`; line 2 reads `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`; `gtk warnings: 0`. If `outside_hits_plot=0` the panel has an allocation wider than 360 or a transparent parent -- stop and fix, do not go on.
- [ ] **Step 7: Confirm the compositing by eye (the risk the spec names)**
```sh
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 \
UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8114 \
>/tmp/so.log 2>&1 & sleep 2.4; import -window root /tmp/slideover.png; wait'
kill $MOCK
```
The probe closes the page at ~2.0 s, so for the screenshot only: temporarily leave it open by setting `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which makes `slideover_probe_read` return before the click/esc part (add that early `if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD")) { delete ps; return G_SOURCE_REMOVE; }` after the first `fprintf`). Read `/tmp/slideover.png` with the Read tool and confirm: the panel is drawn **over** the traces on the plot's right edge, opaque, with its header, "Close" button, entry; the plot to its left is intact. Record the result in the task report. If the GL surface draws over the panel or the panel is transparent, stop: the design's premise failed, report BLOCKED.
- [ ] **Step 8: Baseline diff and commit**
Re-run Step 1's command into `/tmp/ui-badges-t1.log`, normalise, `diff` against `.superpowers/sdd/ui-badges-baseline-udps.txt`.
Allowed baseline changes: none (a layout line that prints the plot area's size may differ by the panel not being open -- it must not).
```bash
git add src/ui_slideover.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp CMakeLists.txt docs/claude/verification.md
git commit -m "ui: a slide-over panel over the plot's right edge, with its probe"
```
---
### Task 2: BadgeBar with the Trigger badge, and the trigger form into the slide-over
**Files:**
- Create: `src/ui_badges.cpp`
- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_slideover.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, `docs/claude/verification.md`
**Interfaces:**
- Consumes: Task 1's `slideover_add_page`, `slideover_toggle`, `slideover_page`, `slideover_open`.
- Produces:
```cpp
/// One badge: a button with a name and a state line. `page` is the slide-over page it opens.
struct Badge {
GtkWidget* button = nullptr;
GtkWidget* name = nullptr;
GtkWidget* state = nullptr;
std::string page;
};
struct BadgeBar {
GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height
GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it
GtkWidget* system_slot = nullptr; ///< right: Trigger, Source
Badge trigger;
Badge source; ///< task 3
};
/// The Trigger badge's state word. Pure, so the probe can assert the whole table.
const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event);
void build_badge_bar(App& app);
void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed
gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS
```
`App` gains `BadgeBar badges;` and, via the builder, `badge_bar`. `trigger_badge_state` truth table (enabled, armed, pending, has_event) -> word: `!enabled` -> `off`; `has_event && !pending` -> `triggered`; `pending` -> `capturing`; `armed` -> `armed`; else `disarmed`.
- [ ] **Step 1: Add the badge bar row to `main-window.ui`**
In `left_column`, between `plot_overlay` and `status_row`:
```xml
<child>
<!-- Setup is reached by the object it configures: a badge per signal on the left
(step 2), the system badges (Trigger, Source) on the right. A fixed height, so
the plot above it never moves when badges come and go. -->
<object class="GtkBox" id="badge_bar">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="height-request">36</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<child>
<object class="GtkBox" id="badge_signals">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="hexpand">1</property>
</object>
</child>
<child>
<object class="GtkBox" id="badge_system">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="halign">end</property>
</object>
</child>
</object>
</child>
```
Add the three ids to the guard in `on_activate`.
- [ ] **Step 2: CSS (`install_css`)**
```cpp
// The badge bar: a badge is a pill carrying a name and a state; the one whose page is open is
// marked, as a bench scope marks the open menu's button.
".udpscope-badge { padding: 2px 12px; min-height: 0; border-radius: 14px; }\n"
".udpscope-badge-open { background-color: alpha(@accent_bg_color, 0.28); }\n"
".udpscope-badge-state { opacity: 0.7; }\n"
```
- [ ] **Step 3: Write `src/ui_badges.cpp`**
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The badge bar under the plot: setup is reached by the object it configures.
#include "ui_app.hpp"
#include <cstdio>
#include <cstring>
namespace udpscope {
namespace ui_impl {
const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event) {
if (!enabled) return "off";
if (has_event && !pending) return "triggered";
if (pending) return "capturing";
return armed ? "armed" : "disarmed";
}
namespace {
void set_text_if_changed(GtkWidget* label, const char* text) {
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text);
}
void set_open(GtkWidget* button, bool open) {
if ((gtk_widget_has_css_class(button, "udpscope-badge-open") != FALSE) == open) return;
if (open) {
gtk_widget_add_css_class(button, "udpscope-badge-open");
} else {
gtk_widget_remove_css_class(button, "udpscope-badge-open");
}
}
void on_badge_clicked(GtkButton*, gpointer user) {
Badge* b = static_cast<Badge*>(user);
// The App is reached through the badge's button: the badges live inside App, so the pointer is stable.
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(b->button), "udpscope-app"));
if (app != nullptr) slideover_toggle(*app, b->page);
}
/// A badge: a flat pill, name on the left, state dimmed beside it. Words only.
void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const std::string& page) {
b.page = page;
b.button = gtk_button_new();
gtk_widget_add_css_class(b.button, "flat");
gtk_widget_add_css_class(b.button, "udpscope-badge");
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
b.name = gtk_label_new(name);
b.state = gtk_label_new("");
gtk_widget_add_css_class(b.state, "udpscope-badge-state");
gtk_box_append(GTK_BOX(row), b.name);
gtk_box_append(GTK_BOX(row), b.state);
gtk_button_set_child(GTK_BUTTON(b.button), row);
g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app);
g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b);
gtk_box_append(GTK_BOX(parent), b.button);
}
} // namespace
void build_badge_bar(App& app) {
BadgeBar& bb = app.badges;
bb.bar = app.badge_bar;
bb.signals_slot = app.badge_signals;
bb.system_slot = app.badge_system;
make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger");
sync_badge_bar(app);
}
void sync_badge_bar(App& app) {
BadgeBar& bb = app.badges;
if (bb.trigger.button == nullptr) return;
const bool enabled = app.trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE;
const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
const bool pending = app.model->trigger_pending();
const bool has_event = app.model->event() != nullptr;
set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event));
set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page);
}
gboolean badges_probe(gpointer user) {
App* a = static_cast<App*>(user);
// The whole table of the pure function: 16 inputs, 5 words.
struct Row { bool e, a, p, v; const char* want; };
static const Row kRows[] = {
{false, false, false, false, "off"}, {false, true, true, true, "off"},
{true, false, false, true, "triggered"}, {true, true, false, true, "triggered"},
{true, true, true, false, "capturing"}, {true, false, true, true, "capturing"},
{true, true, false, false, "armed"}, {true, false, false, false, "disarmed"},
};
int bad = 0;
for (const Row& r : kRows) {
if (std::strcmp(trigger_badge_state(r.e, r.a, r.p, r.v), r.want) != 0) ++bad;
}
// The geometry: fixed height whatever the state, and the open mark follows the slide-over.
const int h_closed = gtk_widget_get_height(a->badges.bar);
slideover_toggle(*a, "trigger");
const bool marked_open = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") != FALSE;
const bool page_open = slideover_page(*a) == "trigger";
slideover_toggle(*a, "trigger");
const bool marked_closed = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") == FALSE;
const int h_after = gtk_widget_get_height(a->badges.bar);
std::fprintf(stderr,
"[badges] table_bad=%d trigger_state=%s bar_h=%d bar_h_after=%d fixed=%d open_marked=%d "
"page_open=%d closed_unmarked=%d\n",
bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after,
h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0,
marked_closed ? 1 : 0);
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope
```
(The probe checks `trigger_badge_state` against the full table the declaration gives. Add the 8 remaining combinations if you want all 16; the 8 above cover every word and every branch.)
- [ ] **Step 4: Wire it**
- `ui_app.hpp`: `Badge`, `BadgeBar`, declarations; on `App`: `BadgeBar badges; GtkWidget* badge_bar = nullptr; GtkWidget* badge_signals = nullptr; GtkWidget* badge_system = nullptr;`.
- `ui_slideover.cpp`: restore the two `sync_badge_bar(app);` calls from task 1 (end of `slideover_open` and `slideover_close`).
- `ui.cpp` `on_activate`: resolve the three ids, then `build_badge_bar(*app);` **after** `app->trigger_toggle` exists (it is created in `build_toolbar`, which runs before) and after `build_slideover`.
- `ui.cpp` `tick()`: call `sync_badge_bar(*app);` next to `sync_source_panels(*app);`.
- `ui.cpp`: probe scheduling `UDPSCOPE_PROBE_BADGES_MS` -> `badges_probe`.
- `CMakeLists.txt`: add `src/ui_badges.cpp`.
- [ ] **Step 5: Move the trigger form into the slide-over's `trigger` page**
In `on_activate` (currently ~ui.cpp:5187):
```cpp
// Signals: the trigger picks a channel from the list beside it.
app->trigger_panel = build_trigger_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->trigger_panel);
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
```
becomes
```cpp
// The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the
// sidebar until the dock replaces it.
app->trigger_panel = build_trigger_panel(*app);
slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel);
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
```
Then deal with everything that assumed the trigger was on the Signals page **and mapped**: `grep -n "trigger_panel\|trigger_body\|trigger_head\|trigger_summary\|dbg_trigger_\|dbg_signal_page_current" src/*.cpp`. Specifically `exit_probe_step3` (`ui_probe.cpp` ~100-206) reads `gtk_widget_get_width/height(a->trigger_panel)`, `on_signals && gtk_widget_get_sensitive(a->trigger_body)`; any probe that reads a trigger widget's *allocation* must call `slideover_open(*a, "trigger")` and wait one settled frame (the reveal is 120 ms) before reading, and close it after. Reads of sensitivity/visibility/value need no open page. `dbg_signal_page_current` / `dbg_trigger_sensitive` keep their meaning but `on_signals` is now about the sidebar tab only for the signal list: `dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body)`.
The trigger form's header toggle mirroring (`trigger_switch` <-> `trigger_toggle`), `sync_trigger_rows` and the locked summary view are all widget-state, not page-state; they must be unaffected -- the existing trigger probes are the proof (Step 6).
- [ ] **Step 6: Verify**
Build (0 warnings). Then, serially, with a mock each:
1. `[badges]` probe: `UDPSCOPE_PROBE_BADGES_MS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500` against the UDPS mock -> `table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1`, `gtk warnings: 0`.
2. Every existing trigger probe in `docs/claude/verification.md` (grep `UDPSCOPE_PROBE_.*TRIGGER\|FORCE\|ARM\|PERSIST\|DENSITY\|CONDITION\|LOCK\|SUMMARY`; run those whose names mention the trigger panel, arming, locking, the summary or the panel switch) and compare against the reading each entry in `docs/claude/probe-readings.md` states. A probe that now reads zero because its widget is unallocated is the fix described in Step 5, not a regression to hide.
3. Screenshot with `UDPSCOPE_PROBE_BADGES_HOLD`-style early exit is not needed: add `slideover_open(*a,"trigger")` to nothing; instead take one screenshot (Task 1 step 7's command with `UDPSCOPE_PROBE_BADGES_MS` replaced by a one-off env `UDPSCOPE_OPEN_PAGE=trigger` read in `on_activate`'s tail -- remove it after) and view it: badge bar under the plot, Trigger badge pressed, the trigger form in the panel. Do not commit the temporary env.
4. Baseline diff (normalised) vs `.superpowers/sdd/ui-badges-baseline-udps.txt`.
Allowed baseline changes: the `side`/`trigger` size lines (`trigger_panel` is read through the slide-over), plus the new `[badges]` lines.
- [ ] **Step 7: Commit**
```bash
git add src/ui_badges.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp src/ui_slideover.cpp src/ui_probe.cpp CMakeLists.txt docs/claude/verification.md
git commit -m "ui: a badge bar with a Trigger badge, and the trigger form in the slide-over"
```
---
### Task 3: The Source badge and the source panels in the slide-over
**Files:**
- Modify: `src/ui_source.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_probe.cpp`, `docs/claude/verification.md`
**Interfaces:**
- Consumes: `slideover_add_page`, `slideover.source_pages`, `Badge`/`BadgeBar::source`.
- Produces: `build_source_panels(App&)` now registers `source-<id>` slide-over pages (id and `SourcePanelPage::name` unchanged); `sync_badge_bar` also writes the Source badge. `SourcePanelPage` is unchanged.
- [ ] **Step 1: `build_source_panels` registers slide-over pages**
In `src/ui_source.cpp`, replace the scroller + `adw_view_stack_add_titled` block with:
```cpp
const std::string name = "source-" + p.id;
// The panel is a slide-over page now: the slide-over wraps it in its own scroller, and with two or
// more source pages it draws the segmented switch (the E-1608: Analog out, Digital I/O).
if (!slideover_add_page(app, name, p.title, p.widget)) continue;
app.source_panels.push_back({name, p.title, p.widget});
```
and change the guard at the top of the function from `app.side_stack == nullptr` to `app.slideover.stack == nullptr`. `apply_source_panels`/`sync_source_panels` (sensitivity from the connection flag) are unchanged.
Leave `keep_tabs_icon_free` in place for now (the sidebar tabs still exist until task 4); it must not be called with source pages any more -- it only hides images, so it is harmless.
- [ ] **Step 2: The Source badge**
In `build_badge_bar`, after the Trigger badge:
```cpp
// The first source page is where the badge goes; a source with none (UDPS) has nothing to open, so its
// badge is a read-out: not a target, not focusable, but still on screen.
const std::string first = app.source_panels.empty() ? std::string() : app.source_panels.front().name;
make_badge(app, bb.source, bb.system_slot, "Source", first);
if (first.empty()) {
gtk_widget_set_can_target(bb.source.button, FALSE);
gtk_widget_set_focusable(bb.source.button, FALSE);
}
```
`build_badge_bar` must therefore run **after** `build_source_panels(*app)` -- move its call there. (Order in `on_activate`: `build_slideover`, trigger page, `build_source_panels`, `build_badge_bar`.) For a source's open mark, the Source badge is "open" for any `source-*` page: in `sync_badge_bar`, `set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0)`.
State text (written in `sync_badge_bar`): the name label is the source kind's `display_name()` (`find_kind(app.source_kind)`, include `source/registry.hpp`; fall back to `"Source"`), the state is `connected` / `disconnected` from `app.model->stats().connected.load(std::memory_order_relaxed)`; in file mode (`app.replaying` or `!app.model->config().open_waveform.empty()`) the name is `"File"` and the state is empty. Set the name label with `set_text_if_changed` too.
The badge click on a multi-page source opens the **first** page; the segmented switch inside the panel changes page (Task 1's `on_source_switch`). If the open page is already a source page, a click closes it (that is `slideover_toggle`'s same-page rule, which the badge's `page` = first page gives for the first page; for any other open source page make `on_badge_clicked` close the panel when `b == &app->badges.source` and the open page starts with `source-`).
- [ ] **Step 3: Re-point the probes that read the source pages**
- `ui_probe.cpp` ~1958 and ~2400: `adw_view_stack_get_child_by_name(side_stack, name) != nullptr` -> `slideover_has_page(*a, name)`.
- `ui_probe.cpp` ~2495: `adw_view_stack_set_visible_child_name(side_stack, "source-ao")` -> `slideover_open(*a, "source-ao")`; the probe reads panel widgets' values and sensitivity, and any read of an allocation needs the page open and one settled frame (>= 150 ms) -- keep the page open for the probe's reads and close it at the end.
- Add to the dump (or a `UDPSCOPE_PROBE_BADGES_MS` extension) a `[badges] source name=<...> state=<...> pages=<n> switch_visible=<0|1>` line: after `slideover_open(*a, <first source page>)`, `switch_visible` must read `1` for the E-1608 (two pages) and `0` for the probe source (one page); then select the second page via `adw_toggle_group_set_active_name` and read back `slideover_page(*a)`.
- [ ] **Step 4: Verify (serial; kill each mock)**
1. Probe source: `UDPSCOPE_PROBE_SOURCE_KINDS=3000 UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_PROBE_BADGES_MS=2500 ./build/udpscope --source probe --source-opt rate=500` -> `[badges] source name=... pages=1 switch_visible=0`; `[source-kinds]` lines unchanged.
2. E-1608: `python3 tools/mock_e1608.py` (default port 54211), run the app with `--source e1608` the way `docs/claude/verification.md` states, with the E-1608 panel probes (`UDPSCOPE_PROBE_E1608_*`) and `UDPSCOPE_PROBE_BADGES_MS` -> `pages=2 switch_visible=1`, the panel probes' readings unchanged, `gtk warnings: 0`.
3. UDPS mock baseline diff. Allowed baseline changes: none beyond Task 2's; the Source badge on UDPS is the not-a-target read-out.
4. Screenshot of the E-1608 run with the Source page open (cairo, as Task 1 step 7), view it: the segmented switch under the header, the Analog out page below it.
- [ ] **Step 5: Commit**
```bash
git add src/ui_source.cpp src/ui_badges.cpp src/ui_app.hpp src/ui.cpp src/ui_probe.cpp docs/claude/verification.md
git commit -m "ui: the Source badge, and the source panels as slide-over pages"
```
---
### Task 4: Replace the sidebar tabs with the temporary dock; session and keyboard
**Files:**
- Modify: `src/ui/main-window.ui`, `src/ui.cpp`, `src/ui_source.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/shortcuts.hpp`, `src/shortcuts.cpp`, `src/ui/shortcuts.ui` (if it lists the two actions), `src/ui_probe.cpp`, `docs/claude/*` entries touched by their probes
- Read first: `docs/claude/invariants-session-and-export.md`, `docs/claude/invariants-plot-and-layout.md`
**Interfaces:**
- Produces: `App::dock_page` (the dock's `GtkBox`), `void dock_show_section(App&, const char* section)` (`"signals"` or `"measure"`; scrolls the dock to that section; unknown name is a no-op), `AppAction::ShowMeasure` / `AppAction::ShowSignals` (renamed from `SideTabMeasure` / `SideTabSignals`; same keys `m` and `g`, labels "Go to Measure" / "Go to Signals"), `SessionState::dock_shown` (bool, default true). **Removed:** `App::side_stack`, `App::side_switcher`, `App::side_tab`, `select_side_tab`, `on_side_tab_changed`, `keep_tabs_icon_free`, `SessionState::side_tab`, `App::dbg_switcher_*`, `dbg_signal_page_current`'s tab meaning.
- [ ] **Step 1: The dock in `main-window.ui`**
Replace the `side_column` subtree (switcher + stack + the two page scrollers) with:
```xml
<!-- The dock: one scroller holding the signal list, then the cursors and the measurements.
It replaces the tab switcher (four text tabs did not fit 320 px and a source can add
more); it is the temporary home of Measure and Signals until steps 2 and 3 turn them
into the badge bar's signals and the compact readouts. -->
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
<child>
<object class="GtkScrolledWindow" id="dock_scroller">
<property name="vexpand">1</property>
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="child">
<object class="GtkBox" id="dock_page">
<property name="orientation">vertical</property>
</object>
</property>
</object>
</child>
</object>
```
Update the file's header comment (the sidebar is the dock; the panels appended to `dock_page`).
- [ ] **Step 2: Fill the dock in `on_activate` and delete the tab code**
- Resolve `dock_page`, `dock_scroller` instead of `side_stack`, `side_switcher`, `measure_page`, `signals_page`; guard them; `app->side_box = side;` stays (the tour and the layout probes measure it). Add `GtkWidget* dock_page; GtkWidget* dock_scroller;` to `App`.
- Order in the dock, top to bottom: `signal_panel`, separator, `cursors_panel`, separator, `build_measures_panel(*app)`. The signal list is first because it is what is dragged onto the plot.
- Delete: the `adw_view_stack_set_visible_child_name(side_stack, "signals")` block and its `notify::visible-child-name` connection, `keep_tabs_icon_free(*app)` and its definition/declaration in `ui_source.cpp`/`ui_app.hpp` (with `hide_tab_icons`, `on_tab_pages_changed`), `on_side_tab_changed`, `select_side_tab` (`ui.cpp` ~2867-2882), `App::side_stack/side_switcher/side_tab`, and the `dbg_switcher_*` members.
- Add in `ui.cpp`:
```cpp
void dock_show_section(App& app, const char* section) {
if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return;
GtkWidget* target = std::strcmp(section, "measure") == 0 ? app.cursors_panel
: std::strcmp(section, "signals") == 0 ? app.signal_panel
: nullptr;
if (target == nullptr) return;
graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f);
graphene_point_t at;
if (!gtk_widget_compute_point(target, app.dock_page, &zero, &at)) return;
GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller));
gtk_adjustment_set_value(adj, at.y);
}
```
- `run_app_action`: `case AppAction::ShowMeasure: dock_show_section(app, "measure"); break;` / `ShowSignals` likewise. Rename in `shortcuts.hpp`, `shortcuts.cpp` (labels "Go to Measure" / "Go to Signals", same group "Panes and editors"), `shortcuts.ui` if present, and the keyboard probe (`ui_probe.cpp` ~6278-6305: it compared `side_tab` before/after; compare the dock scroller's `vadjustment` value instead -- `m` must move it to the cursors panel's y, `g` back to 0).
- Add `case Escape`: in `on_app_key_pressed`, directly after the tour's Escape branch and before the editable check: `if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE;`.
- [ ] **Step 3: Dock visibility -- shown, insensitive until signals**
`update_side_visibility` (`ui.cpp` ~2887) and the per-frame block in `tick()` (~4029-4047) both compute `want = have_signals && sidebar_wanted`. Change both to `want = sidebar_wanted`, and state the dock's availability instead:
```cpp
// "Disable, do not hide": the dock is on screen from the start and does nothing until the stream has signals.
gtk_widget_set_sensitive(app.side_box, have_signals);
gtk_widget_set_sensitive(app.sidebar_toggle, TRUE);
```
(`trigger_toggle`'s visibility from `have_signals` is unchanged; `update_side_visibility` keeps the early-return guard on `app.side_box == nullptr` instead of `side_stack`.) Update the comments above both blocks.
- [ ] **Step 4: Session -- `side_tab` out, `dock_shown` in**
- `layout_store.hpp`: remove `side_tab`; add `bool dock_shown = true;` documented as "the dock's shown state (the user's last word on the toolbar's Dock button)".
- `layout_store.cpp`: writer (~743) emits `dock = true|false` in the same top section where `side_tab` was written (keep key order per the session invariants); parser: replace the `key == "side_tab"` branch with `key == "dock"` -> `st.dock_shown = value == "true"`; an old file's `side_tab = ...` falls through to the existing unknown-key handling (check the final `else` of that chain ignores it -- read it; do not add a branch to swallow it if it already does). Bump nothing else.
- `ui_layout.cpp:67` -> `st.dock_shown = app.sidebar_wanted;`; `:430` -> `app.sidebar_wanted = st.dock_shown;` then `update_side_visibility(app);`.
- A change of the user's wish must schedule a save: in `on_sidebar_toggled`, after recording `sidebar_wanted` when `!sidebar_syncing`, `if (!app->updating_panels) schedule_layout_save(*app);`.
- If there are session round-trip probes (`grep -n "side_tab" src/ui_probe.cpp docs/claude/*.md`), extend them: save with the dock hidden, reload, read `sidebar_wanted == false`; and a file containing `side_tab = "measure"` loads with no error and `dock_shown` default.
- [ ] **Step 5: Re-point the layout/sidebar probes**
`ui_probe.cpp`: `exit_probe_step3` (~100-206), `side_tab_probe*` (~1800-1875), `sidebar_probe` (~3532-3567), the `select_side_tab(*a, "measure")` calls at ~4519 and ~4678, and the tour-surface probes (done in Task 5). Rules:
- `dbg_switcher_*` and the `switcher:` dump line (ui.cpp ~2998, 3055-3090, and the printing at ~6224-6436) are deleted; replace with one `dock: shown=<0|1> sensitive=<0|1> w=<px> scroller_pages=1` line and document it.
- `side_tab_probe` keeps its job (measurements keep accumulating while the Measure section is scrolled out of view) with `dock_show_section(*a, "measure")` / `"signals"` in place of `select_side_tab`; rename it `dock_section_probe`, env `UDPSCOPE_PROBE_SIDE_TAB_MS` is kept as is (do not break scripts); its `advanced=4` assertion is unchanged.
- `dbg_signal_page_current` becomes "the signal list has rows" (no tab), `dbg_trigger_sensitive` is read directly.
- [ ] **Step 6: Verify**
Build (0 warnings incremental; clean build has exactly one). Serially: UDPS mock baseline diff; `UDPSCOPE_PROBE_SIDE_TAB_MS=3000 UDPSCOPE_EXIT_AFTER_MS=11000` (`advanced=4`); the sidebar/collapse probe; the keyboard probe; the session round-trip probe; the pane-drag probe (`dbg_drag_hid_sidebar`/`restored` both 1: dragging a signal from an overlaid dock still hides it for the drag); one run with **no signals** (UDPS mock with `--source` that has none, or a run before CONFIG) reading `dock: shown=1 sensitive=0`; E-1608 and probe-source runs from Task 3 again. Screenshot at 1280x800: plot, badge bar, dock with the list, cursors, measurements.
Allowed baseline changes: the `switcher:` line goes; a `dock:` line appears; `tab`/`page` readings that named `signals`/`measure` become the section readings; `[sidebar]` lines may differ only by `shown` when no signals existed.
- [ ] **Step 7: Commit**
```bash
git add -A src docs/claude
git commit -m "ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state"
```
---
### Task 5: Tour, contextual help and the help figures
**Files:**
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`; read `tools/check_help.sh`, `tools/make-help-shots.sh`
**Interfaces:**
- Produces: `TourView::on_page` -- `void (*on_page)(void* user, const char* page) = nullptr; void* page_user = nullptr;`. When set it replaces the `page_stack` mechanism for that view: `select_view_page` calls `on_page(page_user, stop->page)` (page may be `nullptr`, meaning "no page needed"). The chooser keeps `page_stack`. `TourStep::page` for the main window now names a slide-over page (`"trigger"`) or is `nullptr`.
- [ ] **Step 1: The page hook in the shared tour machinery (`ui_tour.cpp` / `ui_app.hpp`)**
- Add the two members to `TourView`.
- `select_view_page`: first line `if (view.on_page != nullptr) { view.on_page(view.page_user, page); return; }`.
- `stop_is_walkable`: `if (stop.page != nullptr && view.on_page != nullptr) return true;` before the `page_stack` rule -- a slide-over page can always be opened, whatever the dock is doing.
- `start_tour`: replace `app.tour.page_stack = app.side_stack;` with `app.tour.on_page = +[](void* u, const char* page) { App& a = *static_cast<App*>(u); if (page != nullptr) slideover_open(a, page); else slideover_close(a); }; app.tour.page_user = &app;`. A stop with `page == nullptr` closes the panel, so the card never walks to the status line over an open panel. On `tour_end`, close the panel too (find where the tour tears down and call `slideover_close` through the same hook with `nullptr`).
- [ ] **Step 2: Rewrite the main window's stops**
`kTourSteps` becomes (titles/bodies in plain words; keep the existing style):
| id | page | target | text (replace with your own wording of this meaning) |
| --- | --- | --- | --- |
| `plot` | nullptr | `&App::area_overlay` | unchanged |
| `badges` | nullptr | `&App::badge_bar` | "Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the Source badge opens the source's own controls. Click it again, press Esc or click the plot to close the panel." |
| `trigger` | `"trigger"` | `&App::trigger_body` | existing trigger text (describe an event, arm it) |
| `side` | nullptr | `&App::side_box` | "The dock beside the plot: the list of signals, then the cursors and the measurements. The buttons in the bar above show and hide it." |
| `signals` | nullptr | `&App::signal_list` | unchanged |
| `status` | nullptr | `&App::status_row` | unchanged |
Add `GtkWidget* App::badge_bar` to the table (it is already an `App` member from Task 2). Order matters for the walk: the trigger stop opens the panel, the `side` stop closes it. Update `kFigureSurfaces` (`ui_tour.cpp` ~484): drop `side_switcher` and `side_stack`; add `{"badge_bar", &App::badge_bar}` and `{"dock", &App::side_box}`. If `tools/check_help.sh`, `tools/make-help-shots.sh` or any `docs/help/**` figure data names `side_switcher`/`side_stack`, grep for them (`grep -rn "side_switcher\|side_stack" tools docs src data 2>/dev/null`): keep `side_stack` as a **second name for the dock** in `kFigureSurfaces` until the manual's figures are redone in step 4 (record that in the ledger), and drop `side_switcher` only if nothing names it.
- [ ] **Step 3: Contextual help (`ui_context_help.cpp`)**
The hint table (~line 92 `{"signals", "The signals", ...}`) anchors "(i)" marks to panel hosts. Read the whole table. Hints whose host is now in the slide-over (the trigger's) are on an unmapped widget while the panel is closed: confirm the code that places them tolerates an unmapped host (hints are overlay children of the host; they should appear with the panel) and that `context_help_count` / the `hints=N ids={...}` reading is the same set of ids as before. Add a hint for the badge bar only if the table's pattern is "one hint per panel"; otherwise do not.
- [ ] **Step 4: Re-point the tour probes**
`ui_probe.cpp` ~6565-6875 (`[tour]`, `[toursurface]`): `side_tab=` becomes `slideover=<page|none>` printed from `slideover_page(a)`; `select_side_tab(*a, "measure")` + `mapped(a->side_stack)` become `slideover_open(*a, "trigger")` and `mapped(a->side_box)`; the "collapsed" leg (dock collapsed) must show that the trigger stop is still walkable (it is the new behaviour: the slide-over does not depend on the dock) and the signals stop is dropped. Read `docs/claude/probe-readings.md` for the `[toursurface]` entry and update it in Task 6.
- [ ] **Step 5: Verify**
Tour probes (`grep -n "TOUR\|HELPUI\|CONTEXT" docs/claude/verification.md` for their env names) -> the walk lists the stops above in order, `slideover=trigger` at the trigger stop and `slideover=none` at the next, spotlight rectangles contained in the overlay, `hints=` ids as before; `tools/check_help.sh` passes. UDPS baseline diff; allowed changes: tour step count and stop ids/titles, `side_tab=` fields.
- [ ] **Step 6: Commit**
```bash
git add src/ui_tour.cpp src/ui_context_help.cpp src/ui_app.hpp src/ui_probe.cpp
git commit -m "ui: the tour and the contextual help follow the badge bar, the slide-over and the dock"
```
---
### Task 6: Documentation for step 1, and the final check
**Files:**
- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/invariants-gtk-and-chrome.md`, `docs/claude/build-and-run.md` (only if it names a changed file), `CLAUDE.md` (only its table if a row's text is now wrong)
- Not in this plan (spec step 4): `USER-GUIDE.md`, the Mallard manual's text and figures.
- [ ] **Step 1: Sweep the references**
`grep -rn "side_switcher\|side_stack\|side_tab\|switcher:\|keep_tabs_icon_free\|SideTab\|Measure tab\|Signals tab\|tab switcher\|sidebar tab" README.md CLAUDE.md docs/claude src tools` -- every hit outside `docs/superpowers/` is rewritten or deleted. Specifically:
- `README.md`: the layout description (badge bar, slide-over, dock), the keyboard table (`m`/`g` are "Go to Measure / Signals"), the session file's keys, the deviations list (the three step-1 deviations above, as known limitations of the in-progress redesign).
- `docs/claude/architecture.md`: the three new units and the rule that the slide-over/badge bar are built in `on_activate`, per-tick state in `sync_badge_bar`.
- `docs/claude/invariants-plot-and-layout.md`: the slide-over as the second thing placed over the plot (after the layout editor's buttons), with the pick rule; the badge bar's fixed height.
- `docs/claude/invariants-gtk-and-chrome.md`: delete the tab-icon paragraph and `keep_tabs_icon_free` (the switcher is gone); add: a capture-phase observer gesture that never claims the sequence is how click-away coexists with the plot's gestures; `AdwToggleGroup` is the segmented switch.
- `docs/claude/probe-readings.md` and `verification.md`: the new lines `[slideover]`, `[badges]`, `dock:`, the removal of `switcher:`; the new baseline statement for the UDPS diff ("unchanged except ..." now relative to the commit before this series, with the allowed list); the renamed keyboard actions.
- `docs/claude/verification.md` header sentence about the UDPS baseline: say a self-check taken **before** `c572ec2` is no longer the baseline for these lines.
- [ ] **Step 2: The final run, serially**
Clean build count: `touch src/icons/udpscope.gresource.xml && cmake --build build -j 2>&1 | grep -c warning` -> `1`; incremental -> `0`. UDPS mock full self-check: `gtk warnings: 0`, no `Gtk-CRITICAL`, `buffer allocated:` lines 2-3. Each probe touched in Tasks 1-5 once more, on the final tree. A last screenshot at 1280x800 with the trigger page open and one with a source page open (E-1608 mock) for the report. Kill every mock.
- [ ] **Step 3: Commit**
```bash
git add README.md CLAUDE.md docs/claude
git commit -m "docs: the badge bar, the slide-over and the dock (step 1 of the redesign)"
```
---
## Self-review (against the spec)
- **Spec step 1 -- "plot overlay, badge bar with Trigger and Source, slide-over holding the existing trigger form and source pages, sidebar tabs removed, existing Measure and Signals in a temporary dock":** Task 1 (overlay + slide-over), Task 2 (badge bar, Trigger, trigger form), Task 3 (Source, source pages), Task 4 (tabs removed, temporary dock).
- **Risks the spec assigns to step 1:** overlay above GL -> Task 1 steps 6-7 before anything else; reparenting the trigger form -> Task 2 step 6 (existing trigger probes). "Many signals" is step 2.
- **Header:** the Events toggle removal and the Dock toggle rename are *not* step 1 (the events pane stays until step 4); the sidebar toggle already is the dock toggle in behaviour -- its tooltip/label text, if it says "sidebar", is renamed "Dock" in Task 4 step 3 (look at `header-bar.ui`).
- **Keyboard / session / probes / tour / docs:** Tasks 4, 4, 4-5, 5, 6.
- **Left to later steps (by the spec's rollout):** signal badges and drag (2), compact readouts and the 280 px dock (3), events section and deleting the left pane (4), USER-GUIDE and manual figures (4).
- **Names used across tasks:** `slideover_add_page/has_page/open/close/toggle/page/escape/click_away`, `SlideOver::source_pages`, `build_badge_bar`, `sync_badge_bar`, `trigger_badge_state`, `BadgeBar::{trigger,source}`, `dock_show_section`, `ShowMeasure/ShowSignals`, `SessionState::dock_shown`, `TourView::on_page/page_user` are used with one spelling throughout.
@@ -0,0 +1,836 @@
# Horizontal plot split Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let each row of the stacked plots be split into 1..4 plots side by side, all sharing one time window, with draggable cell widths saved in the session.
**Architecture:** Extend the vendored plotter's `plot::Figure` so a figure row holds several plots (flattened plot index, `FigureLayout.cells`). Adapters hit-test by cell rect. The host `RowSet` keeps its flattened plot index (`row_count()` = plot count) and gains `cells_per_row_` plus per-row cell weights. The layout editor, session file, signals page and a new headless probe follow.
**Tech Stack:** C++20, GTK4/libadwaita, vendored `deps/plotter` (own `PLOT_TEST` suite, GLFW + GTK4 adapters), CMake, headless probes under `xvfb-run` against `tools/mock_udpstreamer.py`.
**Spec:** `docs/superpowers/specs/2026-11-03-horizontal-split-design.md`
## Global Constraints
- All plots (cells in a row, rows in the figure) are members of the one x link group. No new time semantics; a split is not an independent time window and not an XY plot.
- Each figure row holds 1..4 cells, independently of the other rows. At most 4 cells per row and at most 8 plots in total (`RowSet::kMaxRows` keeps its value and now bounds plots, not figure rows).
- `Figure(u32 rows)` stays and means one cell per row; existing behaviour and `tests/test_figure.cpp` are unchanged.
- `plot(i)` indexes the flattened cells, top to bottom, left to right. Everything keyed by a plot index in the host keeps meaning "flattened plot index".
- Widths use the height scheme: a floor per cell, the weights share the rest, the last cell closes the row exactly (no seam). A figure too narrow for the floors shrinks the cells proportionally and drops the column gap; no rect is ever negative.
- Only the bottom figure row's cells carry x labels and the x title; other cells get `XStrip::None`.
- The left y gutter stays one common maximum for the whole figure with the same reserved floor (layout must not depend on content).
- `FigureLayout` gains `cells` (one `Rect` per plot, flattened). `rows` stays the full-width rect per figure row.
- Cursors: the vertical pair is one shared time drawn in every cell; horizontal rulers are per cell. The crosshair is one x on every cell; the hovered cell shows its y. Y axes are per cell. An x gesture anywhere moves the shared window.
- Layout editor: cell `+` insensitive at 4 cells in the row or 8 plots in total; row `+` insensitive at 8 plots in total; each cell keeps its top-left `×` (removes the cell in a multi-cell row, the row in a one-cell row; insensitive when it would leave no plot). Divider drag floors each neighbour at about 96 px logical and is saved on drag end.
- Session file: `[[plot]]` tables gain `row` (the figure row) and `width` (the cell's weight). A session without `row` loads as one cell per row. Row `weight` stays per figure row (carried on the row's first cell).
- Every source file carries an SPDX MIT header; match it in new files.
- No unit-test suite for the app: extend the headless probes in `src/ui.cpp` / `src/ui_probe.cpp`. The plotter has its own `PLOT_TEST` suite.
- Baselines for every run: exactly one build warning on a clean build; `gtk warnings: 0`; no `Gtk-CRITICAL`.
- Keep verification runs short and serial; kill the mock servers you start (never the foreign mock on port 8114).
- Plotter changes are committed inside `deps/plotter` (not pushed), then the new pin is committed in the main repo with `git add deps/plotter`.
- Branch is `feature/source-plugins`. Do not push or merge to `main`.
## File Structure
| File | Responsibility |
| --- | --- |
| `deps/plotter/include/plot/plot/figure.hpp`, `src/plot/figure.cpp` (paths as found in the submodule) | `Figure` cell grid, `place_cells`, `FigureLayout.cells` |
| `deps/plotter/src/.../renderer.cpp` | draw every plot (`plot_count()`) |
| `deps/plotter/tests/test_figure.cpp`, `tests/test_glfw_host.cpp` | plotter tests |
| `deps/plotter/adapters/gtk4/gtk_plot_area.*`, `adapters/glfw/glfw_host.*` | x-aware pointer routing over cell rects |
| `src/plot_host.hpp/.cpp` | `RowSet` cell state and operations |
| `src/layout_store.hpp/.cpp` | `row`/`width` in the session file |
| `src/ui_layout.cpp` | `collect_layout` / `apply_layout` |
| `src/ui.cpp`, `src/ui_app.hpp` | layout editor controls, notices, probe scheduling |
| `src/ui_signal_page.cpp`, `src/ui_signals.cpp` | "Plot on" labels, drop target notices |
| `src/ui_probe.cpp` | `UDPSCOPE_PROBE_SPLIT_MS` probe, layout probe re-baseline |
| docs (see Task 8) | user and developer docs |
Locate the exact plotter paths first with `git -C deps/plotter ls-files | grep -E 'figure|renderer|gtk_plot_area|glfw_host'`.
---
### Task 1: Plotter `Figure` cell grid
**Files:**
- Modify: `deps/plotter/.../figure.hpp`, `deps/plotter/.../figure.cpp`, `deps/plotter/.../renderer.cpp` (~line 474)
- Test: `deps/plotter/tests/test_figure.cpp`
**Interfaces:**
- Produces (all in `plot::Figure`):
- `explicit Figure(std::span<const u32> cells_per_row);` (a count of 0 is clamped to 1). `explicit Figure(u32 rows)` stays and means one cell per row. No `initializer_list` constructor (`Figure{3u}` must keep its meaning).
- `u32 row_count() const` = figure rows; `u32 plot_count() const`; `u32 cells_in_row(u32 row) const`; `u32 first_plot_of_row(u32 row) const` (returns `plot_count()` for `row == row_count()`); `u32 row_of_plot(u32 plot) const`.
- `Status set_cell_weights(u32 row, std::span<const f32> weights)`; `void clear_cell_weights(u32 row)`; `bool has_cell_weights(u32 row) const`; `void set_col_gap(f32 px)`; `f32 col_gap() const`.
- `FigureLayout::cells` (`std::vector<Rect>`, flattened, one per plot) and `FigureLayout::col_gap` (`f32`, 0 when any row dropped the gap).
- `Plot& plot(u32 i)` indexes flattened cells; `set_row_weights` validates against the figure row count.
- [ ] **Step 1: Read the current code**
Read `figure.hpp` fully and `figure.cpp` `layout()`, the constructor, `set_row_weights`, `set_crosshair`. Note member names `plots_`, `row_weights_`, `row_gap_`, `min_y_gutter_`, `right_gutter_`, `labels_bottom_only_`, `links_`, and `kMinRowHeightLogical = 24`.
- [ ] **Step 2: Write the failing tests**
Append to `tests/test_figure.cpp` (add `#include <span>` and `#include <vector>` if missing). Use the file's existing helpers `figure_with_axes`, `give_magnitude_row`, `whole_figure()` if present; if the viewport helper has a different name, use the one the neighbouring tests use for a 640x480 viewport.
```cpp
namespace {
plot::Figure split_figure(const std::vector<plot::u32>& cells)
{
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a most-vexing parse
figure.set_viewport(plot::Rect{0, 0, 640, 480});
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
figure.plot(i).add_x_axis();
figure.plot(i).add_y_axis();
}
return figure;
}
} // namespace
PLOT_TEST(figure_cells_all_ones_equals_rows)
{
auto split = split_figure({1, 1, 1});
auto plain = figure_with_axes(3);
split.layout();
plain.layout();
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
for (plot::u32 i = 0; i < 3; ++i) {
PLOT_CHECK_NEAR(split.layout_info().cells[i].y, plain.layout_info().rows[i].y, 0.01);
PLOT_CHECK_NEAR(split.layout_info().cells[i].h, plain.layout_info().rows[i].h, 0.01);
PLOT_CHECK_NEAR(split.layout_info().cells[i].w, plain.layout_info().rows[i].w, 0.01);
}
}
PLOT_TEST(figure_cells_tile_exactly)
{
auto figure = split_figure({1, 2, 1});
figure.set_col_gap(10.0f);
figure.layout();
const auto& fl = figure.layout_info();
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
PLOT_CHECK(figure.cells_in_row(1) == 2u);
PLOT_CHECK(figure.first_plot_of_row(1) == 1u && figure.first_plot_of_row(3) == 4u);
PLOT_CHECK(figure.row_of_plot(2) == 1u);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
}
PLOT_TEST(figure_cell_weights_and_floor)
{
auto figure = split_figure({2});
figure.set_col_gap(0.0f);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0, w));
figure.layout();
{
const auto& c = figure.layout_info().cells;
PLOT_CHECK_NEAR((c[0].w - 64.0) / (c[1].w - 64.0), 3.0, 0.01);
}
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
PLOT_CHECK(figure.set_cell_weights(0, tiny));
figure.layout();
PLOT_CHECK(figure.layout_info().cells[1].w >= 64.0f - 0.001f);
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
const std::vector<plot::f32> zero{1.0f, 0.0f};
PLOT_CHECK(!figure.set_cell_weights(0, three));
PLOT_CHECK(!figure.set_cell_weights(0, zero));
PLOT_CHECK(!figure.set_cell_weights(5, w));
figure.clear_cell_weights(0);
PLOT_CHECK(!figure.has_cell_weights(0));
figure.layout();
PLOT_CHECK_NEAR(figure.layout_info().cells[0].w, figure.layout_info().cells[1].w, 0.01);
}
PLOT_TEST(figure_cells_narrow_degrades)
{
std::vector<plot::u32> cells{4};
plot::Figure figure{std::span<const plot::u32>(cells)};
figure.set_viewport(plot::Rect{0, 0, 20, 100});
for (plot::u32 i = 0; i < 4; ++i) { figure.plot(i).add_x_axis(); figure.plot(i).add_y_axis(); }
figure.set_col_gap(10.0f);
figure.layout();
const auto& fl = figure.layout_info();
double sum = 0;
for (const auto& c : fl.cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
}
PLOT_TEST(figure_cells_share_x_link_and_crosshair)
{
auto figure = split_figure({2, 1});
const auto group = figure.add_x_link();
for (plot::u32 i = 0; i < 3; ++i) figure.link_x(group, i, 0);
figure.plot(1).set_x_range(0, {3.0, 9.0});
figure.layout();
for (plot::u32 i = 0; i < 3; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).x_range(0).lo, 3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).x_range(0).hi, 9.0, 1e-9);
}
figure.set_crosshair(4.0, true);
for (plot::u32 i = 0; i < 3; ++i) {
PLOT_CHECK(figure.plot(i).crosshair().active);
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
}
figure.clear_crosshair();
for (plot::u32 i = 0; i < 3; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
}
PLOT_TEST(figure_cells_x_labels_bottom_row_only)
{
auto figure = split_figure({1, 2});
figure.layout();
PLOT_CHECK(figure.plot(0).layout().x_label_area.h == 0.0f);
PLOT_CHECK(figure.plot(1).layout().x_label_area.h > 0.0f);
PLOT_CHECK(figure.plot(2).layout().x_label_area.h > 0.0f);
PLOT_CHECK_NEAR(figure.layout_info().x_axis_strip.h, figure.plot(2).layout().x_label_area.h, 0.001);
}
PLOT_TEST(figure_cells_common_y_gutter)
{
auto figure = split_figure({2});
give_magnitude_row(figure.plot(0), 0);
give_magnitude_row(figure.plot(1), 1);
figure.layout();
const auto& fl = figure.layout_info();
const double g0 = figure.plot(0).layout().axes.x - fl.cells[0].x;
const double g1 = figure.plot(1).layout().axes.x - fl.cells[1].x;
PLOT_CHECK(g0 > 0.0);
PLOT_CHECK_NEAR(g0, g1, 0.01);
}
```
Adjust accessor names (`layout_info()`, `set_viewport`, `link_x`, `x_range`, `add_x_axis`) to the ones the existing tests in the same file use; the intent of each test is fixed.
- [ ] **Step 3: Run to verify it fails**
Run: `cmake --build deps/plotter/build --target plot_tests 2>&1 | tail -20` (use the plotter build dir the repo already has; if none, configure with the plotter's own `CMakeLists.txt` and `-DPLOT_BUILD_TESTS=ON`).
Expected: compile failure, `plot_count` / `set_cell_weights` not members of `Figure`.
- [ ] **Step 4: Implement the header**
In `figure.hpp`:
```cpp
explicit Figure(u32 rows);
explicit Figure(std::span<const u32> cells_per_row);
u32 row_count() const; // figure rows
u32 plot_count() const; // flattened cells
u32 cells_in_row(u32 row) const;
u32 first_plot_of_row(u32 row) const; // plot_count() for row == row_count()
u32 row_of_plot(u32 plot) const;
Status set_cell_weights(u32 row, std::span<const f32> weights);
void clear_cell_weights(u32 row);
bool has_cell_weights(u32 row) const;
void set_col_gap(f32 px);
f32 col_gap() const;
```
Members: `std::vector<u32> cells_per_row_; std::vector<u32> row_first_; // prefix sums, rows+1 entries; std::vector<std::vector<f32>> cell_weights_; f32 col_gap_ = 0.0f; void build_cells(std::span<const u32>);`. In `FigureLayout` add `std::vector<Rect> cells; f32 col_gap = 0.0f;`.
- [ ] **Step 5: Implement the constructors and accessors**
```cpp
Figure::Figure(u32 rows)
{
const std::vector<u32> ones(rows, 1u);
build_cells(std::span<const u32>(ones));
}
Figure::Figure(std::span<const u32> cells_per_row) { build_cells(cells_per_row); }
void Figure::build_cells(std::span<const u32> cells)
{
cells_per_row_.assign(cells.begin(), cells.end());
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
row_first_.assign(cells_per_row_.size() + 1, 0u);
for (std::size_t r = 0; r < cells_per_row_.size(); ++r)
row_first_[r + 1] = row_first_[r] + cells_per_row_[r];
plots_ = std::vector<Plot>(row_first_.back());
cell_weights_.assign(cells_per_row_.size(), {});
// keep whatever the old Figure(u32) constructor initialised besides plots_ (row_weights_ etc.)
}
u32 Figure::row_count() const { return static_cast<u32>(cells_per_row_.size()); }
u32 Figure::plot_count() const { return static_cast<u32>(plots_.size()); }
u32 Figure::cells_in_row(u32 row) const { return row < row_count() ? cells_per_row_[row] : 0u; }
u32 Figure::first_plot_of_row(u32 row) const { return row <= row_count() ? row_first_[row] : plot_count(); }
u32 Figure::row_of_plot(u32 plot) const
{
u32 r = 0;
while (r + 1 < row_count() && row_first_[r + 1] <= plot) ++r;
return r;
}
Status Figure::set_cell_weights(u32 row, std::span<const f32> w)
{
if (row >= row_count() || w.size() != cells_per_row_[row]) return Status::invalid_argument();
for (f32 v : w) if (!(v > 0.0f)) return Status::invalid_argument();
cell_weights_[row].assign(w.begin(), w.end());
return Status::ok();
}
void Figure::clear_cell_weights(u32 row) { if (row < row_count()) cell_weights_[row].clear(); }
bool Figure::has_cell_weights(u32 row) const { return row < row_count() && !cell_weights_[row].empty(); }
void Figure::set_col_gap(f32 px) { col_gap_ = std::max(0.0f, px); }
f32 Figure::col_gap() const { return col_gap_; }
```
Use the `Status` constructors the file already uses for `set_row_weights` failures (copy that spelling). Remove any other `plots_` initialisation from the old constructor and make `set_row_weights` validate against `row_count()` (it already did if it used `row_count()`; verify it did not use `plots_.size()`).
- [ ] **Step 6: Implement `place_cells` and wire it into `layout()`**
In an anonymous namespace of `figure.cpp`:
```cpp
constexpr f32 kMinCellWidthLogical = 64.0f;
// Splits the row rect `r` into n cells. Returns the gap actually used.
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap, f32 ui_scale,
Rect* out)
{
if (n == 1) { out[0] = r; return col_gap; }
f32 gap = col_gap;
f32 usable = r.w - gap * static_cast<f32>(n - 1);
if (usable <= 0.0f) { gap = 0.0f; usable = r.w; }
usable = std::max(0.0f, usable);
const f32 floor_px = std::min(kMinCellWidthLogical * ui_scale, usable / static_cast<f32>(n));
const f32 extra = usable - floor_px * static_cast<f32>(n);
f32 total = 0.0f;
if (weights.size() == n) for (f32 w : weights) total += w;
f32 x = r.x;
f32 consumed = 0.0f;
for (u32 i = 0; i < n; ++i) {
const f32 share = (weights.size() == n && total > 0.0f) ? weights[i] / total
: 1.0f / static_cast<f32>(n);
const f32 end = (i + 1 == n) ? usable : consumed + floor_px + extra * share;
out[i] = Rect{x, r.y, end - consumed, r.h};
consumed = end;
x = out[i].right() + gap;
}
return gap;
}
```
In `Figure::layout()`, after the row rects (`layout_.rows`) are computed and before viewports are set: size `layout_.cells` to `plot_count()`, call `place_cells` per figure row (pass `&layout_.cells[row_first_[r]]`), track `layout_.col_gap = (any row with n>1 and returned gap < col_gap_) ? 0 : col_gap_` (set `col_gap` to `col_gap_` otherwise), and set each plot's viewport to `layout_.cells[i]`. The strip rule becomes: per plot `i`, `XStrip::None` when `labels_bottom_only_` and `row_of_plot(i) + 1 != row_count()`, else the existing strip. Every loop that ran over rows for `set_right_gutter`, `sync_x_links()`, the two gutter passes and crosshair now runs over `plots_` (flattened). Keep `layout_.x_axis_strip = plots_.back().layout().x_label_area`. The ui_scale reference stays `plots_[0].ui_scale()`.
In `renderer.cpp` ~474 replace `const u32 rows = figure.row_count();` with `figure.plot_count()`.
- [ ] **Step 7: Run tests**
Run: `cmake --build deps/plotter/build --target plot_tests && deps/plotter/build/plot_tests 2>&1 | tail -15`
Expected: all tests pass, including the pre-existing `figure_*` tests.
- [ ] **Step 8: Commit in the submodule**
```bash
git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: rows can hold several plots (cells), cell weights and column gap"
```
---
### Task 2: Adapters route pointers by cell
**Files:**
- Modify: `deps/plotter/adapters/gtk4/gtk_plot_area.cpp/.hpp`, `deps/plotter/adapters/glfw/glfw_host.cpp/.hpp`
- Test: `deps/plotter/tests/test_glfw_host.cpp`
- Modify (main repo): `deps/plotter` pin
**Interfaces:**
- Consumes: `Figure::plot_count()`, `FigureLayout::cells`.
- Produces: `GtkPlotArea::row_at(x, y)` returns the flattened plot index under (x, y) (nearest cell when outside all); GLFW `Host::controller_at(x, y)` is x-aware. `row_controller(i)` indexes flattened plots.
- [ ] **Step 1: Write the failing GLFW routing test**
Add `#include <span>` and, in `tests/test_glfw_host.cpp`, next to the existing `Routed(rows, gap)` fixture test:
```cpp
PLOT_TEST(glfw_host_routes_by_cell)
{
std::vector<plot::u32> cells{1, 2};
plot::Figure figure{std::span<const plot::u32>(cells)};
figure.set_viewport(plot::Rect{0, 0, 300, 600});
figure.set_col_gap(6.0f);
for (plot::u32 i = 0; i < 3; ++i) { figure.plot(i).add_x_axis(); figure.plot(i).add_y_axis(); }
figure.layout();
plot::glfw::Host host;
host.set_figure(&figure);
const auto& c = figure.layout_info().cells;
for (plot::u32 i = 0; i < 3; ++i) {
const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5;
PLOT_CHECK(&host.controller_at(cx, cy) == host.row_controller(i));
}
// in the gap: nearest cell wins
PLOT_CHECK(&host.controller_at(c[1].right() + 1.0, c[1].y + 5.0) == host.row_controller(1));
PLOT_CHECK(&host.controller_at(c[2].x - 1.0, c[2].y + 5.0) == host.row_controller(2));
}
```
Mirror the existing fixture for how `controller_at` / `row_controller` are called (names may differ slightly; keep the three assertions).
- [ ] **Step 2: Run to verify it fails**
Run: `cmake --build deps/plotter/build --target test_glfw_host 2>&1 | tail` then run the binary.
Expected: FAIL (the cell-2 centre routes to cell 1, or build error).
- [ ] **Step 3: GLFW host**
In `glfw_host.cpp`: `set_figure` uses `plot_count()` for the controllers; `refresh_row_rects` copies `layout().cells`; replace `row_in(rows, y)` with
```cpp
static int row_in(const std::vector<Rect>& rects, double x, double y)
{
int best = -1;
double best_d = 0.0;
for (std::size_t i = 0; i < rects.size(); ++i) {
const Rect& r = rects[i];
const double dx = x < r.x ? r.x - x : (x > r.right() ? x - r.right() : 0.0);
const double dy = y < r.y ? r.y - y : (y > r.bottom() ? y - r.bottom() : 0.0);
const double d = dx * dx + dy * dy;
if (best < 0 || d < best_d) { best = static_cast<int>(i); best_d = d; }
if (d == 0.0) break;
}
return best;
}
```
(Use `r.y + r.h` / `r.x + r.w` if `Rect` has no `bottom()`.) For unsplit figures this is equivalent to the old y-only nearest-row rule. Make `controller_at(x, y)`, `pointer_target(x, y, ...)` and `pointer_row(rows)` pass `x` (use `last_pointer_x_` in `pointer_row`) and drop the `(void)x`. `sync_figure_crosshair` loops over the plot count. Update the header comments.
- [ ] **Step 4: GTK adapter**
In `gtk_plot_area.cpp`: `set_figure` uses `plot_count()` (line ~248); `refresh_row_rects` caches `.layout().cells`; add the same x-aware `row_in(rows, x, y)`; change `controller_at`, `row_at(x, y)` (use `to_device(x, y)`), `target_controller(f32 x, f32 y)`, `pointer_row()` (`pointer_.x/.y`) and `press_row_` (set with `row_in(row_rects_, p.x, p.y)` at ~619). Update every call site of `target_controller` (~637 `p`, ~646 `position`, ~751 `sample.position`, ~798 `g`, ~837 `at`, ~861 `p`) to pass both coordinates. Update the header declarations.
- [ ] **Step 5: Build and run plotter tests**
Run: `cmake --build deps/plotter/build 2>&1 | tail -5 && for t in deps/plotter/build/test_*; do $t >/dev/null || echo FAIL $t; done`
Expected: no FAIL lines.
- [ ] **Step 6: Commit the submodule, then the pin; check the app still builds**
```bash
git -C deps/plotter add -u && git -C deps/plotter commit -m "adapters: route pointer events by cell rect"
git add deps/plotter && git commit -m "deps/plotter: figure rows can hold several plots"
cmake --build build 2>&1 | grep -c warning
```
Expected: the build succeeds with the single baseline warning (count as before the change; compare with `git stash`-free run of an untouched clean build if unsure).
---
### Task 3: `RowSet` cell state and the split probe scaffold
**Files:**
- Modify: `src/plot_host.hpp`, `src/plot_host.cpp` (`rebuild_all` ~309, `set_row_count` ~156, `carry_row` ~164, `clear_row` ~184, `insert_row` ~195, `remove_row` ~205, `apply_row_weights` ~303, `x_note` caption ~920)
- Modify: `src/ui_app.hpp` (`dbg_split_step`, declarations near ~2093), `src/ui_probe.cpp` (new `split_probe_*`), `src/ui.cpp` (scheduling next to `UDPSCOPE_PROBE_LAYOUT_MS`, ~6003)
**Interfaces:**
- Produces in `RowSet`:
- `static constexpr int kMaxCellsPerRow = 4;`
- `int figure_row_count() const; int cells_in_row(int fr) const; int first_plot_of_row(int fr) const; int figure_row_of(int plot) const /* -1 if invalid */; int cell_of(int plot) const; const std::vector<float>& cell_weights(int fr) const;`
- `std::string plot_label(int plot) const` ("2" for a single-cell row, "2a".."2d" for a split row; the figure row is `fr + 1`)
- `bool insert_cell(int fr, int at)`, `bool remove_cell(int plot)`, `void set_cell_weights(int fr, std::vector<float> w)`, `void set_cells_per_row(std::vector<int> cells)`
- `insert_row(at)` / `remove_row(at)` act on figure rows; `set_row_count(n)` means n single-cell rows; `row_count()` stays the plot count.
- Invariant: `sum(cells_per_row_) == rows_` (rows_ ≤ kMaxRows = 8).
- [ ] **Step 1: Read** `plot_host.hpp` 35–215, 280–320 and `plot_host.cpp` 135–345, 905–950 to see `carry_row`, `clear_row`, `storage_`, `rebuild_all`.
- [ ] **Step 2: Header additions**
```cpp
static constexpr int kMaxCellsPerRow = 4;
int figure_row_count() const { return static_cast<int>(cells_per_row_.size()); }
int cells_in_row(int fr) const;
int first_plot_of_row(int fr) const; // rows_ for fr == figure_row_count()
int figure_row_of(int plot) const; // -1 if plot is out of range
int cell_of(int plot) const; // 0-based position inside its row, -1 if invalid
const std::vector<float>& cell_weights(int fr) const;
std::string plot_label(int plot) const;
bool insert_cell(int fr, int at); // false at kMaxCellsPerRow cells or kMaxRows plots
bool remove_cell(int plot); // a single-cell row removes the row; false if it would leave no plot
void set_cell_weights(int fr, std::vector<float> w);
void set_cells_per_row(std::vector<int> cells);
private:
std::vector<int> cells_per_row_{1};
std::vector<std::vector<float>> cell_weights_ = std::vector<std::vector<float>>(1);
void apply_cell_weights();
```
- [ ] **Step 3: Implement queries and labels** in `plot_host.cpp`
```cpp
int RowSet::cells_in_row(int fr) const
{
return fr >= 0 && fr < figure_row_count() ? cells_per_row_[fr] : 0;
}
int RowSet::first_plot_of_row(int fr) const
{
int p = 0;
for (int r = 0; r < fr && r < figure_row_count(); ++r) p += cells_per_row_[r];
return p;
}
int RowSet::figure_row_of(int plot) const
{
if (plot < 0 || plot >= rows_) return -1;
int p = 0;
for (int r = 0; r < figure_row_count(); ++r) {
p += cells_per_row_[r];
if (plot < p) return r;
}
return -1;
}
int RowSet::cell_of(int plot) const
{
const int fr = figure_row_of(plot);
return fr < 0 ? -1 : plot - first_plot_of_row(fr);
}
const std::vector<float>& RowSet::cell_weights(int fr) const
{
static const std::vector<float> none;
return fr >= 0 && fr < static_cast<int>(cell_weights_.size()) ? cell_weights_[fr] : none;
}
std::string RowSet::plot_label(int plot) const
{
const int fr = figure_row_of(plot);
if (fr < 0) return std::to_string(plot + 1);
if (cells_per_row_[fr] == 1) return std::to_string(fr + 1);
return std::to_string(fr + 1) + static_cast<char>('a' + cell_of(plot));
}
```
- [ ] **Step 4: Figure construction in `rebuild_all`**
Replace the `std::make_unique<plot::Figure>(rows_)` with
```cpp
std::vector<plot::u32> cells(cells_per_row_.begin(), cells_per_row_.end());
figure_ = std::make_unique<plot::Figure>(std::span<const plot::u32>(cells));
```
and after `set_row_gap(kRowGap)` add `figure_->set_col_gap(kColGap);` (`constexpr float kColGap = 6.0f;` beside `kRowGap`), then call `apply_row_weights(); apply_cell_weights();`. The per-plot loop stays per plot index (it already iterates `rows_`).
```cpp
void RowSet::apply_cell_weights()
{
if (!figure_) return;
cell_weights_.resize(cells_per_row_.size());
for (int fr = 0; fr < figure_row_count(); ++fr) {
auto& w = cell_weights_[fr];
if (cells_per_row_[fr] < 2) { w.clear(); continue; }
if (w.empty()) { figure_->clear_cell_weights(fr); continue; }
w.resize(cells_per_row_[fr], 1.0f);
figure_->set_cell_weights(fr, std::span<const float>(w));
}
}
```
`apply_row_weights` resizes to `figure_row_count()` instead of `rows_`.
- [ ] **Step 5: Mutators**
```cpp
bool RowSet::insert_cell(int fr, int at)
{
if (fr < 0 || fr >= figure_row_count()) return false;
if (cells_per_row_[fr] >= kMaxCellsPerRow || rows_ >= kMaxRows) return false;
const int n = cells_per_row_[fr];
const int p = first_plot_of_row(fr) + std::clamp(at, 0, n);
for (int i = rows_; i > p; --i) carry_row(i, i - 1); // carry_row(dst, src), as insert_row uses it
clear_row(p);
++rows_;
++cells_per_row_[fr];
auto& w = cell_weights_[fr];
if (!w.empty()) {
float mean = 0.0f;
for (float v : w) mean += v;
mean /= static_cast<float>(w.size());
w.insert(w.begin() + std::clamp(at, 0, n), mean);
}
rebuild_all();
return true;
}
bool RowSet::remove_cell(int plot)
{
const int fr = figure_row_of(plot);
if (fr < 0) return false;
if (cells_per_row_[fr] == 1) {
if (figure_row_count() == 1) return false;
remove_row(fr);
return true;
}
for (int i = plot; i + 1 < rows_; ++i) carry_row(i, i + 1);
--rows_;
clear_row(rows_);
const int k = plot - first_plot_of_row(fr);
--cells_per_row_[fr];
auto& w = cell_weights_[fr];
if (cells_per_row_[fr] < 2) w.clear();
else if (k < static_cast<int>(w.size())) w.erase(w.begin() + k);
rebuild_all();
return true;
}
void RowSet::set_cell_weights(int fr, std::vector<float> w)
{
if (fr < 0 || fr >= figure_row_count()) return;
cell_weights_[fr] = std::move(w);
if (figure_) apply_cell_weights();
}
void RowSet::set_cells_per_row(std::vector<int> cells)
{
std::vector<int> next;
int total = 0;
for (int c : cells) {
c = std::clamp(c, 1, kMaxCellsPerRow);
if (total + c > kMaxRows) c = kMaxRows - total;
if (c < 1) break;
next.push_back(c);
total += c;
}
if (next.empty()) next.push_back(1), total = 1;
cells_per_row_ = std::move(next);
cell_weights_.assign(cells_per_row_.size(), {});
rows_ = total;
rebuild_all();
}
```
Check against the real `carry_row` signature (read in Step 1) and keep its argument order; if `insert_row` uses a different shifting idiom, copy it. `insert_row(at)` / `remove_row(at)`: `at` is a figure row; the plot position is `first_plot_of_row(at)`; insert/erase the matching `cells_per_row_` entry (one cell for an inserted row) and `cell_weights_` entry; `remove_row` shifts storage down by the removed row's cell count `n` and decrements `rows_` by `n`. `insert_row` refuses when `rows_ >= kMaxRows`. `set_row_count(n)`: sets all cells to one per row (`cells_per_row_.assign(n, 1)`, `cell_weights_.assign(n, {})`) and early-returns only when `n == rows_` and every row already has one cell.
- [ ] **Step 6: The `x_note` caption** (~line 920): replace `const bool bottom = i == rows_ - 1;` with `const bool bottom = i >= first_plot_of_row(figure_row_count() - 1);`.
- [ ] **Step 7: Probe scaffold**
In `ui_app.hpp` add `int dbg_split_step = 0;` beside `dbg_layout_step` and declarations beside the layout probe's: `void split_probe_report(App& app, const char* tag); void split_probe_next(App& app); gboolean split_probe_step(gpointer data);`. In `ui_probe.cpp` (SPDX header already in the file) add, modelled on `layout_probe_*` (read `ui_probe.cpp` 3386–3471 and 3751–3890 first, reuse `enable_all_probe`'s channel assignment loop):
```cpp
void split_probe_report(App& app, const char* tag)
{
RowSet& rs = *app.rows;
const auto& fl = rs.figure().layout();
int tile = 1;
for (int fr = 0; fr < rs.figure_row_count(); ++fr) {
const int first = rs.first_plot_of_row(fr), n = rs.cells_in_row(fr);
const auto& row = fl.rows[fr];
if (std::fabs(fl.cells[first].x - row.x) > 0.5f) tile = 0;
if (std::fabs(fl.cells[first + n - 1].right() - row.right()) > 0.5f) tile = 0;
for (int k = 0; k < n; ++k) {
const auto& c = fl.cells[first + k];
if (std::fabs(c.y - row.y) > 0.5f || std::fabs(c.h - row.h) > 0.5f) tile = 0;
if (k > 0 && std::fabs((c.x - fl.cells[first + k - 1].right()) - fl.col_gap) > 0.5f) tile = 0;
}
}
// x_equal: every plot's x range equals plot 0's; cursors_aligned: every plot's cursor a/b x equal
int x_equal = 1, aligned = 1;
const auto r0 = rs.plot(0)->x_range(rs.axis_x(0));
for (int p = 1; p < rs.row_count(); ++p) {
const auto r = rs.plot(p)->x_range(rs.axis_x(p));
const double span = std::max(1e-12, std::fabs(r0.hi - r0.lo));
if (std::fabs(r.lo - r0.lo) > 1e-9 * span || std::fabs(r.hi - r0.hi) > 1e-9 * span) x_equal = 0;
if (std::fabs(rs.cursor_x(p, 0) - rs.cursor_x(0, 0)) > 1e-9 * span) aligned = 0;
if (std::fabs(rs.cursor_x(p, 1) - rs.cursor_x(0, 1)) > 1e-9 * span) aligned = 0;
}
// print: [split-probe] %-14s plots=%d frows=%d cells=[a,b] widths=[logical px, | between rows] tile=%d x_equal=%d cursors_aligned=%d weights=[...] traces=[P:name,...|...]
}
```
The accessor names `plot(p)`, `axis_x(p)` and `cursor_x(p, which)` are placeholders for the host's real accessors for the plot object, its x axis index and cursor A/B x (the layout probe and `RowSet::cursor_a_x()` show them: `r.plot->cursor(r.cursor_a).x`, `r.plot->x_range(r.axis_x)`); use those, adding a tiny `RowSet::cursor_x(int plot, int which)` if none exists. Fill in the print line with `g_print`/`std::printf` in the style of `layout_probe_report`, with widths in logical px (`cell.w / scale`), weights from `rs.cell_weights(fr)` and traces as `P:name` from the slot names the layout probe already prints.
`split_probe_step` follows `layout_probe_step` (200 ms) but with 250 ms steps; after any structural change call `app.area->set_figure(&app.rows->figure()); app.relayout_pending = true;` and let a frame run before the report. Steps:
0. `rows->set_cells_per_row({1}); ` free the slots; enable all signals and assign channels via the `enable_all_probe` loop; report `"start"` (expect `plots=1 frows=1 cells=[1]`).
1. `insert_cell(0, 1)`; assign channel 0 to plot 0 and channel 1 to plot 1; `set_cursors_visible(true)`; report `"split"` (expect `cells=[2] tile=1 x_equal=1 cursors_aligned=1`).
2. `set_cell_weights(0, {3, 1})`; report `"weights 3:1"` (widths ratio about 3:1 after the floors; `tile=1`).
3. `insert_row(1)` then `insert_cell(1, 1)`; report `"row+cell"` (expect `cells=[2,2] plots=4`).
4. Call `insert_cell` until it returns false and then `insert_row` until the plot total is 8; report `"caps"` printing the refusals (`insert_cell(0, 2)` true, `…` false at 4 cells; `insert_row` false at 8 plots). Keep these as separate printed `refused=` fields.
5. `remove_cell` on the last plot of the widest row, report `"remove"`; then finish by returning `G_SOURCE_REMOVE`.
In `ui.cpp` next to `UDPSCOPE_PROBE_LAYOUT_MS` (~6003) add:
```cpp
if (const char* ms = g_getenv("UDPSCOPE_PROBE_SPLIT_MS"))
g_timeout_add(static_cast<guint>(std::atoi(ms)), split_probe_step, app);
```
(Use the exact `g_timeout_add` argument shape the layout probe uses.)
- [ ] **Step 8: Build and run the probe**
```bash
cmake --build build 2>&1 | grep -E 'warning|error' | head
python3 tools/mock_udpstreamer.py --port 8171 --rate 1000 --batch 20 & MOCK=$!; sleep 2
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_SPLIT_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8171 2>&1 | grep -E 'split-probe|gtk warnings|Gtk-CRITICAL'
kill $MOCK
```
Expected: the six `[split-probe]` lines above with `tile=1 x_equal=1 cursors_aligned=1`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Also confirm that the first build output still has exactly one warning.
- [ ] **Step 9: Commit**
```bash
git add src/plot_host.hpp src/plot_host.cpp src/ui_app.hpp src/ui_probe.cpp src/ui.cpp
git commit -m "plot host: rows hold 1..4 cells; split probe scaffold"
```
---
### Task 4: Session file and probe round-trip
**Files:**
- Modify: `src/layout_store.hpp` (`LayoutState::Row`), `src/layout_store.cpp` (writer ~786, `plots = N` ~729, parser `[[plot]]` ~457 and keys ~605–614, fallback ~718), `src/ui_layout.cpp` (`collect_layout` 17–36, `apply_layout` ~445–520)
- Modify: `src/ui_probe.cpp` (new probe steps)
**Interfaces:**
- Consumes: `RowSet::cells_in_row`, `figure_row_of`, `cell_weights`, `set_cells_per_row`, `set_cell_weights`, `set_row_weights`.
- Produces: `LayoutState::Row { …; int row = -1; double width = 1.0; }`.
- [ ] **Step 1: Format**
`LayoutState::Row` gains `int row = -1; // figure row; -1 = file had none` and `double width = 1.0; // cell weight`. The writer emits, per `[[plot]]`: `row = <fr>`, `width = <w>`, then the existing `weight` (the figure row's weight, written on every cell) and `signals`. Parser: keys `row` (int ≥ 0) and `width` (double > 0) next to `weight`. A file where no `[[plot]]` has `row` leaves every `row == -1`.
- [ ] **Step 2: `collect_layout`** — for each plot `p`: `fr = rs.figure_row_of(p)`, `row.row = fr`, `row.weight = row_weights()[fr]`, `row.width = cell_weights(fr)` entry `cell_of(p)` or 1.0 when the row has no weights.
- [ ] **Step 3: `apply_layout`** — before `set_row_count`: if every `Row.row >= 0`, validate that rows are non-decreasing, contiguous from 0 (each step 0 or +1), at most `kMaxCellsPerRow` cells per row and at most `kMaxRows` plots, otherwise fall back to the one-cell-per-row path. Valid: build `cells_per_row` counts and call `rows->set_cells_per_row(counts)`; row weights come from the first cell of each figure row (`set_row_weights`); for rows with ≥ 2 cells call `set_cell_weights(fr, {widths…})`. Fallback (no `row`, or invalid): `set_row_count(min(n, kMaxRows))` as today. The per-plot signal assignment loop is unchanged (it is per flattened plot index).
- [ ] **Step 4: Probe steps** — append to `split_probe_step`:
6. Build `{1,2}` with weights `{3,1}` and signals in each cell, `collect_layout` → `save_layout(tmp)` (use a path under `g_get_tmp_dir()` and delete it afterwards) → set to `{1}` → `load_layout` → `apply_layout`; report `"roundtrip"` and add `rt=1/0` comparing cells, widths (tolerance 1e-6) and traces.
7. Write a copy of that file with all `row = …` lines stripped, load/apply it, report `"legacy"` (expect `cells=[1,1,1]`, or the plot count of the file, one cell per row).
- [ ] **Step 5: Run the probe** (same recipe as Task 3 Step 8). Expected: `roundtrip rt=1`, `legacy` with one cell per row, `gtk warnings: 0`.
- [ ] **Step 6: Commit**
```bash
git add src/layout_store.hpp src/layout_store.cpp src/ui_layout.cpp src/ui_probe.cpp
git commit -m "session: plots carry row and width; files without row load one cell per row"
```
---
### Task 5: Layout editor cell controls and column drag
**Files:**
- Modify: `src/ui.cpp` (layout editor 568–807), `src/ui_layout.cpp` (534–598 handlers), `src/ui_app.hpp` (constants ~1600–1607, new `App` fields), `src/ui_probe.cpp` (`layout_probe_*` re-baseline, new split-probe click steps)
**Interfaces:**
- Consumes: Task 3 `RowSet` API, Task 2 `FigureLayout.cells`.
- Produces: `void layout_col_apply(App&, int plot, double offset_px)`, `GtkWidget* layout_cell_button(App&, int fr, int pos)`, `GtkWidget* layout_col_handle(App&, int plot)`; `App::layout_col_drag_plot`, `App::layout_col_drag_left0` / `layout_col_drag_right0` (start widths, logical px); constants `kLayoutCellKey = "udpscope-layout-cell"`, `kLayoutColKey = "udpscope-layout-col"`, `kMinCellPx = 96`.
- [ ] **Step 1: Read** `ui.cpp` 568–807 (overlay build, `sync_layout_editor`, `layout_overlay_button`), `ui_layout.cpp` 534–598 and the drag handlers, `ui_app.hpp` ~1600–1607.
- [ ] **Step 2: Row-keyed controls by figure row.** The row boundary `+` buttons and the row resize handles are keyed by **figure row** (`figure_row_count() + 1` boundaries, positioned from `fl.rows`). `on_layout_row_insert(boundary)` calls `rows->insert_row(boundary)`. The per-cell `×` stays keyed by plot index (existing `set_row_key`), positioned from `fl.cells[idx]` top-left, and its handler calls `rows->remove_cell(idx)`; it is insensitive when `remove_cell` would fail (`row_count() == 1`). After each handler: `app->area->set_figure(&app->rows->figure())`, `rebuild_layout_editor`, `rebuild_signal_panels`, `schedule_layout_save` (same tail as the existing handlers). The row `+` is insensitive at `row_count() >= RowSet::kMaxRows`.
- [ ] **Step 3: Cell `+` buttons.** For each figure row `fr` with `n = cells_in_row(fr)`, create `n + 1` buttons via `layout_overlay_button(app, "list-add-symbolic", tooltip)` with `g_object_set_data(btn, kLayoutCellKey, GINT_TO_POINTER(fr * 8 + pos + 1))` (do **not** call `set_row_key`). Positions: `pos 0` at `cells[first].x/scale + kLayoutButtonPad`; internal `pos k` centred on the gap between cells `k-1` and `k`; `pos n` at `cells[last].right()/scale - kLayoutButtonPx - kLayoutButtonPad`; vertically centred in `fl.rows[fr]`. Clicking calls `rows->insert_cell(fr, pos)` then the same tail as Step 2. Sensitivity: `cells_in_row(fr) < kMaxCellsPerRow && row_count() < kMaxRows`. Convert with `/scale` as the existing code does in `sync_layout_editor`, and re-place them in `sync_layout_editor` every frame.
- [ ] **Step 4: Column resize handles.** At each internal boundary `k` (1..n-1) of a split row create a drag handle with `kLayoutColKey` = plot index of the right-hand cell (`first + k`), placed just right of the cell `+` there, with a left-right arrow icon (`go-next-symbolic` is acceptable, use whatever the row handle's icon pair provides for the horizontal direction), copying the row handle's `GtkGestureDrag` ("udpscope-layout-drag" pattern with its own begin/update/end handlers). Handlers:
```cpp
void layout_col_apply(App& app, int plot, double offset_px)
{
RowSet& rs = *app.rows;
const int fr = rs.figure_row_of(plot);
const int k = rs.cell_of(plot);
if (fr < 0 || k < 1) return;
const double scale = gtk_widget_get_scale_factor(app.window) > 0 ? ui_scale_of(app) : 1.0; // use the helper sync_layout_editor uses
const double pair = app.layout_col_drag_left0 + app.layout_col_drag_right0;
const double want_left = std::clamp(app.layout_col_drag_left0 + offset_px / scale, double(kMinCellPx), pair - kMinCellPx);
std::vector<float> w = rs.cell_weights(fr);
w.resize(rs.cells_in_row(fr), 1.0f);
const float sum = w[k - 1] + w[k];
w[k - 1] = static_cast<float>(sum * want_left / pair);
w[k] = sum - w[k - 1];
rs.set_cell_weights(fr, w);
app.area->queue_draw(); // same redraw call the row drag uses
}
```
`begin` records `layout_col_drag_plot = plot` and the logical widths `cells[plot-1].w/scale` and `cells[plot].w/scale`, and also snapshots `rs.cell_weights(fr)` (resized with 1.0) so repeated `update` calls are relative to drag start, as the row drag is. If the pair is narrower than `2 * kMinCellPx`, make the clamp degenerate to `pair / 2` rather than inverted bounds. `end` calls `schedule_layout_save(app)`.
- [ ] **Step 5: Tooltips and sensitivity wording.** Cell `+`: "Split here"; column handle: "Drag to resize the plots"; `×` in a multi-cell row: "Remove this plot"; one-cell row: unchanged ("Remove this row").
- [ ] **Step 6: Helpers for the probe.** `layout_cell_button(App&, fr, pos)` and `layout_col_handle(App&, plot)` find the widget by walking the overlay children and matching `kLayoutCellKey` / `kLayoutColKey` data (as `layout_button` does for `row_key`).
- [ ] **Step 7: Probe steps.** Append to `split_probe_step`: with the layout editor open (reuse how `layout_probe_step` opens it), 8. click `layout_cell_button(app, 0, 1)` via `layout_click` → expect `cells=[2]`; 9. call `layout_col_apply` through the real handle's begin/update/end (or `layout_col_apply(app, 1, 80.0)` after setting the start widths as the begin handler does) → widths shift by 80 logical px and neither cell is below 96; a drag of ±10000 px clamps both to ≥ 96; 10. click the `×` of the new cell → `cells=[1]`; 11. report the sensitivity of the cell `+` at 4 cells in the row (`cell_plus_sensitive=0`) and of the row `+` at 8 plots.
- [ ] **Step 8: Re-baseline the layout probe.** Run the existing `UDPSCOPE_PROBE_LAYOUT_MS` probe on the previous commit and on this one (fresh `XDG_CONFIG_HOME` per side) and diff number-normalised (`sed 's/[0-9.]\+/N/g'`). The only expected difference is the button count: `+2` per figure row (the two edge `+`s). Update the expected count in `docs/claude/probe-readings.md` in Task 8; do not change anything else in the baseline.
- [ ] **Step 9: Build, run both probes** (`UDPSCOPE_PROBE_LAYOUT_MS` and `UDPSCOPE_PROBE_SPLIT_MS`, serially). Expected: split-probe lines as designed, `gtk warnings: 0`, no `Gtk-CRITICAL`.
- [ ] **Step 10: Commit**
```bash
git add src/ui.cpp src/ui_layout.cpp src/ui_app.hpp src/ui_probe.cpp
git commit -m "layout editor: split a row into cells, resize cells by drag"
```
---
### Task 6: Signals page, notices and drop target
**Files:**
- Modify: `src/ui_signal_page.cpp` (~260–285), `src/ui_signals.cpp` (~557, ~570, ~579), `src/ui.cpp` (~325, ~1680, ~2762, ~2766, ~2942)
**Interfaces:**
- Consumes: `RowSet::plot_label(int)`, x-aware `area->row_at(x, y)` from Task 2.
- [ ] **Step 1:** In `ui_signal_page.cpp` "Plot on" buttons keep their `udpscope-row` data (plot index) and the `assign_signal_to_row` callback but their label becomes `rows->plot_label(r)` and, for split rows, the tooltip "row 2, cell 1 of 2" (`"row %d, cell %d of %d"` with `figure_row_of + 1`, `cell_of + 1`, `cells_in_row`). This amends the spec's "row 2, left" wording to a unified numeric form; a single-cell row keeps its existing tooltip.
- [ ] **Step 2:** Replace `"row %d"` / `r + 1` notices at `ui_signals.cpp:557`, `:570`, `ui.cpp:1680`, `:2762`, `:2766` with `"row %s"` and `plot_label(i).c_str()`.
- [ ] **Step 3:** Confirm `row_at(app, x, y)` (`ui.cpp:325`) forwards `x` to `area->row_at` (it did not need it before) and that the two callers (`ui_signals.cpp:579`, `ui.cpp:2942`) pass the pointer position unchanged. Persistence layers (`sync_layers`) fill free slots of the same plot, with no change.
- [ ] **Step 4:** Extend the split probe: after step 3 of Task 3, call `assign_signal_to_row` for a signal onto plot 1 of a split row and report the notice text / `traces=` showing the signal on `1b`. Expected probe line shows `traces=[…|1b:name…]`.
- [ ] **Step 5:** Build, run the split probe and `gtk warnings: 0`; commit:
```bash
git add src/ui_signal_page.cpp src/ui_signals.cpp src/ui.cpp src/ui_probe.cpp
git commit -m "signals: label plots by row and cell, drop onto a cell"
```
---
### Task 7: Regression runs
**Files:** none (verification); `docs/claude/probe-readings.md` is updated in Task 8 for any feature-explained difference.
- [ ] **Step 1:** Clean build from scratch in a temporary build dir; expect exactly one warning.
- [ ] **Step 2:** Serially run the probes listed in `docs/claude/verification.md` that touch layout, cursors, measurements, readouts and the E-1608 source (one mock per run, killed afterwards). For each, run once on the pre-feature commit (`git worktree` or checkout of `HEAD` before Task 1, built separately) and once now with a fresh `XDG_CONFIG_HOME`; diff number-normalised. The only acceptable differences are those the feature explains (layout probe's button count, new `cells`/column-weights lines in layout dumps).
- [ ] **Step 3:** Layout dumps and probes that print per-row layout gain a `cells=[…]` list and a `colweights=[…]` line: add those to `layout_probe_report` and to the layout dump in `ui.cpp`, then re-run to confirm the numbers.
- [ ] **Step 4:** Run the plotter suite once more (`deps/plotter/build`, all `test_*`).
- [ ] **Step 5:** Commit any report-line additions: `git add src/ui_probe.cpp src/ui.cpp && git commit -m "probes: print cells and column weights"`.
---
### Task 8: Docs
**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`, `docs/claude/verification.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `src/ui_context_help.cpp`, `CLAUDE.md` (only if the "figure of up to eight stacked rows" sentence needs "plots").
- [ ] **Step 1: Write each doc paragraph** (real text, in the style of the neighbouring sections):
- `build-and-run.md` (vendored plotter changes list): "`plot::Figure` rows can hold several plots: `Figure(std::span<const u32> cells_per_row)`, `plot_count()`, `cells_in_row()`, `first_plot_of_row()`, `row_of_plot()`, `set_cell_weights()`, `set_col_gap()`, `FigureLayout::cells`/`col_gap`. `Figure(u32 rows)` still means one cell per row. Cell widths use the height scheme with a 64 px (logical) floor; the GTK4 and GLFW adapters route pointer events by cell rect."
- `invariants-plot-and-layout.md` (cell rules): plots are indexed flat; `row_count()` is the plot count and `figure_row_count()` the rows; the x link, the crosshair and the vertical cursor pair are shared by all cells; y axes, horizontal rulers and fit are per cell; only the bottom row's cells carry x labels; the left gutter is one maximum for the whole figure; caps 4 cells per row / 8 plots; the editor's `+` and drag rules.
- `verification.md` and `probe-readings.md`: `UDPSCOPE_PROBE_SPLIT_MS`, each `[split-probe]` line, the reading it must give (`tile=1 x_equal=1 cursors_aligned=1`, `rt=1`, legacy file → one cell per row), and the new layout-probe button count / the `cells` and `colweights` fields.
- `README.md` and `docs/USER-GUIDE.md`: a "Splitting a row" subsection: Layout editor → `+` at a row's left/right edge or between cells; drag the arrow to resize; `×` removes a cell; all cells share one time window; limits; session `row`/`width` keys.
- Mallard layout page and `ui_context_help.cpp` layout-editor text: the same user-facing paragraph, in the page's format.
- [ ] **Step 2:** Build (the help page is validated at build/install, if the project has a `yelp-check` target run it) and run the context-help probe if `verification.md` lists one.
- [ ] **Step 3: Commit**
```bash
git add README.md docs/USER-GUIDE.md help docs/claude src/ui_context_help.cpp CLAUDE.md
git commit -m "docs: horizontal plot split"
```
---
## Self-Review
- **Spec coverage:** shared time window (Task 1 x link test, Task 3 probe `x_equal`); per-row 1..4 cells and caps (Tasks 3, 5); draggable widths saved (Tasks 4, 5); plotter API and layout rules including narrow degradation, bottom-only labels, common gutter (Task 1); hit-testing by cell (Task 2); host model, flattened indices, caps, cursors/crosshair/y axes per cell (Tasks 1, 3); drop onto a cell and "place on row" listing (Task 6, tooltip wording amended to "row 2, cell 1 of 2"); layout editor controls (Task 5); session format and legacy loading (Task 4); verification probes and regression (Tasks 3–5, 7); docs list (Task 8).
- **Placeholders:** accessor names that the plan could not confirm without re-reading code (`layout_info()`, `rs.plot(p)`, `axis_x`, `cursor_x`, `carry_row` argument order, `Status` constructors) are flagged where used with how to resolve them from the neighbouring code; no behaviour is left unspecified.
- **Type consistency:** `insert_cell(fr, at)`, `remove_cell(plot)`, `set_cell_weights(fr, vector<float>)`, `set_cells_per_row(vector<int>)`, `plot_label(plot)`, `figure_row_count()`, `cells_in_row(fr)`, `first_plot_of_row(fr)`, `figure_row_of(plot)`, `cell_of(plot)` are used with the same signatures in Tasks 3–6; plotter names (`plot_count`, `cells_in_row`, `set_cell_weights(u32, span<const f32>)`, `set_col_gap`, `FigureLayout::cells/col_gap`) match across Tasks 1–3.
@@ -0,0 +1,342 @@
# Split rows share one y axis -- Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The cells of a split row share one y range and one y axis (labels on the leftmost cell only), as the figure's rows share one x window and one x axis.
**Architecture:** The vendored plotter's `Figure` gets `set_share_row_y(bool)`: when on, every figure row with more than one cell keeps its cells' y axis 0 in step (range and auto flag) from `Figure::layout()`, and every cell but the first loses its y gutter (`YStrip::None`). The host turns it on in `RowSet::rebuild_all()`, mirrors the ruler pair across a row, and maps the layout editor's divider drag through plot widths.
**Tech Stack:** C++20, GTK4/libadwaita, the vendored `deps/plotter` (git submodule; commit inside it first, then the pin in the main repo), headless probes under `xvfb-run`.
Spec: `docs/superpowers/specs/2026-11-03-split-shared-y-design.md` (amends `2026-11-03-horizontal-split-design.md`).
## Global Constraints
- Shared y is always on for split rows, no option; an unsplit row behaves exactly as before. (spec Decisions 1)
- Only the leftmost cell of a row has a y gutter and y labels; the choice is structural (column index), never from content. (Decisions 2; "layout must not depend on content", `docs/claude/invariants-plot-and-layout.md`)
- `Figure::set_share_row_y` defaults to off so existing figures and every existing test in `deps/plotter/tests` are unchanged.
- Cell rects still tile the row exactly (last cell closes the row); no rect is ever negative; `Figure(u32 rows)` and `{1}` rows lay out as today.
- The row's y gutter is taken off the row width first; the cells then share the rest by the existing `floor + extra*w/total` rule (floor `kMinCellWidthLogical = 64` x ui_scale), so equal weights give equal plot areas. The first cell's rect is gutter + its plot width.
- Rulers: one pair per figure row, drawn at the same y in every cell; a drag in any cell moves them. Measure level marks, readouts, measurements stay per trace / per plot index.
- Session file unchanged (y ranges were never persisted).
- Every source file carries the SPDX MIT header (existing files already do).
- Verification is by the plotter suite and the app's headless probes only. Build: exactly 1 warning (baseline). Probes: `gtk warnings: 0`, no `Gtk-CRITICAL`. Run recipe in `docs/claude/verification.md`; use a fresh throwaway `XDG_CONFIG_HOME` per run (never write `~/.config/udpscope`); run probes short and serial; kill the mock afterwards. The plotter test `staging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_build` may hang: run the suite under `ulimit -c 0; timeout 300`.
---
## File map
| File | Change |
| --- | --- |
| `deps/plotter/include/plot/plot/plot.hpp`, `src/plot/plot.cpp` | `YStrip`, `set_y_strip_mode`, `set_y_link_member`, `y_data_bounds`, `fit_y_bounds`; skip autoscale of a linked y axis; zero y chrome for `YStrip::None` |
| `deps/plotter/include/plot/plot/figure.hpp`, `src/plot/figure.cpp` | `set_share_row_y`, `row_y_auto`, `sync_row_y()`, strip modes, gutter maximum over leftmost cells, plot-width cell placement |
| `deps/plotter/src/render/renderer.cpp` (+ adapters if needed) | no y labels for a `None` cell |
| `deps/plotter/tests/test_figure.cpp` | new cases |
| `src/plot_host.cpp`, `src/ui.cpp`, `src/ui_cursors.cpp`, `src/ui_probe.cpp` | host wiring, rulers, pane, drag, probes |
| docs | see Task 5 |
---
### Task 1: Plotter -- y link of a row's cells (range and auto)
**Files:**
- Modify: `deps/plotter/include/plot/plot/plot.hpp`, `deps/plotter/src/plot/plot.cpp`
- Modify: `deps/plotter/include/plot/plot/figure.hpp`, `deps/plotter/src/plot/figure.cpp`
- Test: `deps/plotter/tests/test_figure.cpp`
**Interfaces:**
- Produces (Plot): `void set_y_link_member(u32 axis, bool linked) noexcept;` `bool y_data_bounds(u32 axis, Range& out) const;` (union of the visible traces' y bounds on that axis, false if none) `bool fit_y_bounds(u32 axis, Range bounds);` (the axis' own padded fit of `bounds`, as `fit_y_axis` does; leaves `auto_scale` alone).
- Produces (Figure): `void set_share_row_y(bool on);` `bool share_row_y() const noexcept;` `bool row_y_auto(u32 row) const noexcept;` (figure row; true for a row with fewer than two cells or when sharing is off, i.e. "no group").
- Behaviour: with sharing on, for each figure row with `n > 1` cells, y axis 0 of its cells is one group. A range set on any member (wheel, drag, `set_y_range`) becomes the group's and is pushed to all; the group is auto or manual as one; auto fits the union of all members' visible traces (cells without a trace ignored; padding/log handling is `fit_axis_range`'s, via `fit_y_bounds` on the first member). Members' own autoscale is suppressed.
Group state per row (`Figure::RowY`): `Range pushed; bool have = false; bool auto_scale = true;`. Sync, run from `Figure::layout()` right after `sync_x_links()` (before any `Plot::layout()` call, because that is where a plot would otherwise autoscale):
```cpp
void Figure::sync_row_y() {
if (!share_row_y_) return;
row_y_.resize(cells_per_row_.size());
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
const u32 n = cells_per_row_[row];
if (n < 2u) continue;
RowY& g = row_y_[row];
const u32 first = row_first_[row];
if (!g.have) {
g.pushed = plots_[first].y_axis(0).range;
g.auto_scale = plots_[first].y_axis(0).auto_scale;
}
// What happened since the last push. A member's auto flag that differs from the group's is a
// request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it); otherwise a
// member whose range differs from the pushed one was moved by a gesture.
bool want_auto = g.auto_scale;
Range target = g.pushed;
bool flag_request = false;
for (u32 i = first; i < first + n && !flag_request; ++i) {
const Axis& a = plots_[i].y_axis(0);
if (a.auto_scale != g.auto_scale) {
want_auto = a.auto_scale;
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
flag_request = true;
}
}
if (!flag_request && !want_auto) {
for (u32 i = first; i < first + n; ++i) {
const Range r = plots_[i].y_axis(0).range;
if (r.min != g.pushed.min || r.max != g.pushed.max) { target = r; break; }
}
}
if (want_auto) {
Range bounds{};
bool any = false;
for (u32 i = first; i < first + n; ++i) {
Range b{};
if (!plots_[i].y_data_bounds(0, b)) continue;
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
any = true;
}
if (any && plots_[first].fit_y_bounds(0, bounds)) target = plots_[first].y_axis(0).range;
// else: nothing to fit -- keep the range the group had
}
for (u32 i = first; i < first + n; ++i) {
plots_[i].set_y_range(0, target); // clears the flag ...
plots_[i].set_y_auto_scale(0, want_auto); // ... so restate it: every member mirrors the group
}
g.pushed = target;
g.auto_scale = want_auto;
g.have = true;
}
}
```
`set_share_row_y(on)` stores the flag, clears `row_y_`, and calls `set_y_link_member(0, on)` on every cell of every row with `n > 1` (cells whose plot has no y axis yet are fine: `set_y_link_member` ignores a missing axis, so also re-apply in `sync_row_y()` before the loop so axes added after the call are covered). `Plot::autoscale_axes()` must skip a y axis whose `link_member` is set (reuse `Axis::link_member`, currently x-only in meaning; update its comment). `y_data_bounds` is `trace_bounds_union(false, axis, out)`. `fit_y_bounds` is `return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds);`.
- [ ] **Step 1: Write the failing tests** -- append to `deps/plotter/tests/test_figure.cpp` (they use the existing `split_figure`, `whole_figure`, `plot::data::ArraySeries`):
```cpp
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
// The unsplit row is its own thing.
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
PLOT_CHECK(!figure.row_y_auto(0u));
}
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
plot::Figure figure = split_figure({3});
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true); // cell 2 has no trace
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
const plot::Range r0 = figure.plot(0u).y_range(0u);
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
for (plot::u32 i = 1; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
}
}
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
plot::Figure figure = split_figure({2});
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
figure.layout();
PLOT_CHECK(!figure.row_y_auto(0u));
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
figure.layout(); // manual holds: no fight with the plots' own autoscale
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
}
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
plot::Figure figure = split_figure({2});
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
PLOT_CHECK(!figure.share_row_y());
}
```
- [ ] **Step 2: Run -- expect compile failure** (`set_share_row_y` undeclared). Build and run the plotter tests per `docs/claude/build-and-run.md` (the plotter's own CMake test target; run the binary with `ulimit -c 0; timeout 300`).
- [ ] **Step 3: Implement** the Plot additions, `Figure` members (`bool share_row_y_ = false; struct RowY {...}; std::vector<RowY> row_y_;`), `set_share_row_y`, `share_row_y`, `row_y_auto` (`row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true`), `sync_row_y` as above (declare it private next to `sync_x_links`), and call `sync_row_y();` in `Figure::layout()` immediately after `sync_x_links();`. Doc comments in the file's style (what, and why the group, not the plot, owns auto scale).
- [ ] **Step 4: Run the whole plotter suite.** Expect all new cases pass and 0 failed overall (was 407 cases).
- [ ] **Step 5: Commit** inside the submodule: `git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: cells of a row can share one y range"`. Do not touch the pin yet.
---
### Task 2: Plotter -- one y axis per row (strip mode, gutter, cell widths)
**Files:**
- Modify: `deps/plotter/include/plot/plot/plot.hpp`, `deps/plotter/src/plot/plot.cpp` (`Plot::layout`, ~lines 1286-1390)
- Modify: `deps/plotter/src/plot/figure.cpp` (`Figure::layout`, ~lines 550-625)
- Modify: `deps/plotter/src/render/renderer.cpp` (~lines 900-920, y label staging)
- Test: `deps/plotter/tests/test_figure.cpp`
**Interfaces:**
- Consumes: Task 1's `set_share_row_y`.
- Produces: `enum class YStrip { Both, None };` next to `XStrip` in `plot.hpp`; `Plot::set_y_strip_mode(YStrip)` / `y_strip_mode()`. With `None`, `Plot::layout()` reserves no y label width and no y title width (`y_axis_width == 0`, `y_title_width == 0`, `y_label_area.w == 0`) and ignores `min_y_gutter_`; the grid lines are still built. With sharing on, `Figure::layout()` sets `None` on every cell but the first of its row; the common-gutter maximum is taken over `Both` cells only; `None` cells get `set_min_y_gutter(0)`.
- Cell widths with sharing on, for a row with `n > 1` cells: after the gutter `G` is known (end of the measurement pass), `G_eff = min(G, row.w / 2)`; call `place_cells` on the rect `{row.x + G_eff, row.y, row.w - G_eff, row.h}`, then grow cell 0 to the left by `G_eff` (`x -= G_eff; w += G_eff`). Update `layout_.cells`, call `set_viewport` on each cell, then run the final `p.layout()` pass. With sharing off nothing changes.
- [ ] **Step 1: Write the failing tests** (append to `test_figure.cpp`; `give_magnitude_row` and `split_figure` exist):
```cpp
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
plot::Figure figure = split_figure({1, 3}, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_share_row_y(true);
figure.layout();
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
for (plot::u32 i = 2; i < 4u; ++i) {
const plot::PlotLayout& l = figure.plot(i).layout();
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
PLOT_CHECK(l.axes.w > 0.0f);
}
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
}
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
plot::Figure figure = split_figure({3}, false);
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(8.0f);
figure.set_share_row_y(true);
const plot::FigureLayout& fl = figure.layout();
const double w0 = figure.plot(0u).layout().axes.w;
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
}
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
plot::Figure figure = split_figure({2}, false);
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(0.0f);
figure.set_share_row_y(true);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
figure.layout();
const double a0 = figure.plot(0u).layout().axes.w;
const double a1 = figure.plot(1u).layout().axes.w;
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
}
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(10.0f);
figure.set_share_row_y(true);
double sum = 0.0;
for (const plot::Rect& c : figure.layout().cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
}
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
}
```
Plus a renderer check: find how existing tests count or inspect staged text (see `tests/test_render_smoke.cpp` / the `staging_a_glyph_...` neighbourhood). Add one case that a `None` cell stages no y tick labels (the staged label count of a two-cell shared row equals that of a one-cell row of the same height, plus nothing for the second cell's y side). If the harness cannot observe staged text, instead assert in the renderer change below with a comment and report that no automated check was possible.
- [ ] **Step 2: Run -- expect failures** (the gutter/width cases; `YStrip` undeclared).
- [ ] **Step 3: Implement.**
- `Plot::layout`: `const bool y_none = y_strip_ == YStrip::None;` seed `y_width = y_none ? 0 : ...`, `y_title_w = y_none ? 0 : ...`, `y_floor = y_none ? 0 : min(min_y_gutter_, y_budget)`, and `need_y = y_none ? 0 : ...` in the fixed-point test so the loop still settles in two passes. Read the loop first; every use of the y strip widths must collapse to 0 and the `axes` must start at `viewport_.x`.
- `Figure::layout`: before the viewport loop compute `const bool shared = share_row_y_ && cells_per_row_[row] > 1u;` and set `p.set_y_strip_mode(shared && i != row_first_[row] ? YStrip::None : YStrip::Both)` (reset to `Both` when not shared). In the gutter maximum loop skip `None` plots and give them `set_min_y_gutter(0.0f)`. After the measurement pass and before the final `p.layout()` loop, re-place the cells of each shared row as specified in Interfaces and re-apply `set_viewport`.
- Renderer: do not stage y tick labels (and y title) when the plot's `layout().y_label_area.w <= 0.0f`; read the staging code at `renderer.cpp:~906-917` first. Check `src/input/controller.cpp:~524` still cannot start a y axis-drag in a zero-width label area (it hit-tests `y_label_area`); if it can, guard it.
- Check both adapters (`adapters/gtk4`, `adapters/glfw`) route by cell rect only, so nothing else changes.
- [ ] **Step 4: Run the whole plotter suite.** Expect 0 failed, including all pre-existing figure cases (`figure_cells_common_y_gutter` still reads the old behaviour because sharing defaults off).
- [ ] **Step 5: Commit** in the submodule: `git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: shared-y rows draw one y axis, on the first cell"`. Then in the main repo: `git add deps/plotter && git commit -m "pin deps/plotter: shared y axis for split rows"`.
---
### Task 3: Host -- turn sharing on; rulers per row; pane and units
**Files:**
- Modify: `src/plot_host.cpp` (`rebuild_all`, `sync_cursors`, `place_cursors` call sites), `src/plot_host.hpp` if a helper is declared
- Modify: `src/ui.cpp` (`row_unit` ~1759, cursors-pane line construction ~1840-1855), `src/ui_cursors.cpp` (~55-70)
- Modify: `src/ui_probe.cpp` (split probe: new steps), `src/ui_app.hpp` if a probe state is needed
- Test: the split probe (`UDPSCOPE_PROBE_SPLIT_MS`), layout probe
**Interfaces:**
- Consumes: Task 2's `Figure::set_share_row_y(bool)` and its pin.
- Produces: no new public host API required. Behaviour: after `rebuild_all()` every split figure row shares y; `RowSet::set_y_auto/set_y_range/fit_all` need no change (they call per-plot setters that the figure's sync turns into row-wide effects).
- [ ] **Step 1: Probe first (RED).** Add a new split-probe step after the existing steps (follow the existing step style in `ui_probe.cpp` near the `[split-probe] ...` lines; note the next free step index) that, on a figure with a split row holding a trace in each cell:
1. `y_shared`: set the y range on cell A via `rs.set_y_range(a, -3, 7)`, pump a frame, assert cell B's `plot->y_range(axis_y)` equals it.
2. `y_gutter`: cell B (non-first) `layout().y_axis_width == 0` and `y_label_area.w == 0`; cell A `> 0`; and `axes.w` of A and B equal within 1 px with default weights.
3. `y_fit`: `rs.fit_all()` then one frame: both cells' ranges equal and cover both traces' y bounds.
4. `ruler_mirror`: with cursor mode showing rulers, set cell A's ruler (via `set_cursor_y` on its plot) then call `rs.sync_cursors(a)`; `ruler_y(a)==ruler_y(b)` and `ruler_b_y` likewise.
Print one line: `[split-probe] shared-y y_shared=%d y_gutter=%d y_fit=%d ruler_mirror=%d ranges=[...] axes_w=[..]`. Build and run it; expect `y_shared=0 y_gutter=0` (sharing not wired) -- RED.
- [ ] **Step 2: Wire it.**
- `RowSet::rebuild_all()`: call `figure_->set_share_row_y(true);` right after the `Figure` is created (before `install`, so the axes added by `install` are picked up -- Task 1 re-applies membership in `sync_row_y`).
- `RowSet::sync_cursors(int drag_row)`: after `r.ruler_y/ruler_b_y` are read back from the dragged cell, copy both values to every other cell of the same figure row (`first_plot_of_row(figure_row_of(drag_row))` .. `+ cells_in_row`) and `place_cursors(that, plot::CursorPlacement::Free)` on them.
- `reset_cursors()` already writes each plot's rulers from its y range; confirm both cells of a row get identical values (the range is shared). Rulers that exist before the row was split: when `insert_cell` creates the new cell, copy the row's first cell's `ruler_y/ruler_b_y` into it (read `insert_cell` and `copy`/reset helpers near `plot_host.cpp:224-240`).
- Cursors pane (`ui.cpp:~1840`): the ruler line (`slot = -1`) is created once per figure row, for the row's first plot only (`app.rows->cell_of(r) == 0`). Label: unsplit rows keep today's wording; a split row uses `"row N"` with the same numbering `plot_label` uses for the row (without the cell letter). Check `ui_cursors.cpp:~60` and every other reader of `cursors_lines` / `slot == -1` for assumptions of one ruler line per plot, and update the probe line `ruler-labels` accordingly (its expected value changes by design -- say so in the report).
- `row_unit(app, row)` (`ui.cpp:~1759`): join the units over every cell of `row`'s figure row (loop `first_plot_of_row .. + cells_in_row`, same lock), "" if they differ.
- `sync_compare_axes` (`ui_events.cpp:~555`): leave as is; the probe in Step 3 includes a comparison-mode check only if the existing compare probe covers split rows, otherwise note it in the report as unverified.
- `ui.cpp:~4237` gutter reading takes a max over all plots and `~4361` reads plot 0: both stay correct (None cells read 0). Confirm by reading, change nothing.
- [ ] **Step 3: Verify.** Incremental `cmake --build build` (1 warning baseline). Run the split probe and the layout probe per `docs/claude/verification.md` and diff against the baseline commit `18b91c8`-era numbers as in the earlier tasks (number-normalised): only lines this feature explains may differ (`ruler-labels`, `buttons=` unchanged, `cells=` unchanged; widths of non-first cells grow by the gutter). Expect the new line `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, all earlier split-probe steps still reading as before (col-drag may change -- that is Task 4), `gtk warnings: 0`, no `Gtk-CRITICAL`.
- [ ] **Step 4: Commit** `git add src/plot_host.cpp src/plot_host.hpp src/ui.cpp src/ui_cursors.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "plot host: split rows share y, rulers per row"` (add only files actually changed).
---
### Task 4: Layout editor -- divider drag through plot widths
**Files:**
- Modify: `src/ui.cpp` (`layout_col_drag_begin` ~786, `layout_col_apply` ~755)
- Modify: `src/ui_probe.cpp` (the `col-drag` step ~4190-4250), `src/ui_app.hpp` (`layout_col_drag_left0/right0` docs)
- Test: split probe `col-drag` line
**Interfaces:**
- Consumes: Tasks 2-3. A cell's rect width is no longer its weight's width: weights apply to **plot widths** (`plot->layout().axes.w + right_gutter`, i.e. rect width minus the y gutter, which only cell 0 has).
- Produces: `layout_col_drag_begin` stores the left/right **plot** widths in logical px (rect width minus `y_axis_width + y_title_width` of that cell's plot; the right cell's is 0). `layout_col_apply` is unchanged otherwise: the 96 px floor and the 64 px plotter floor both refer to plot widths now (`kMinCellPx`, `kPlotterMinCellPx`), the formula `frac = (want_left - 64)/(pair - 128)` stays, and the pair's weight sum stays.
- [ ] **Step 1: Probe first (RED).** Extend the `col-drag` step so the cells compared are plot widths (use `axes.w` of the two plots, in logical px) on a row whose first cell is the left one, in addition to the existing checks (+80 px tracking within 1 px, both extremes 96 +/- 1, pair sum preserved). With the old begin code the tracking is off by the gutter; expect `ok=0` before the fix. Keep the other split-probe lines as they are.
- [ ] **Step 2: Fix** `layout_col_drag_begin` to subtract the gutter from the left cell: `gutter = plot.layout().y_axis_width + plot.layout().y_title_width` of the left plot (0 for a non-first left cell, which has `None`), converted by the same `scale`. Update the comment above `layout_col_apply` to say "plot width".
- [ ] **Step 3: Verify.** Build; run the split probe; expect `col-drag ok=1` and the previous steps unchanged; layout probe unchanged; `gtk warnings: 0`.
- [ ] **Step 4: Commit** `git add src/ui.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "layout editor: column drag measures plot widths, not cell rects"`.
---
### Task 5: Docs
**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/layout.page`, `docs/claude/probe-readings.md`, `docs/claude/verification.md` (new probe line), `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `docs/claude/architecture.md` if it describes per-cell y, `CLAUDE.md` if it mentions per-cell y, `src/ui_context_help.cpp`, `src/ui_tour.cpp` if they say cells have their own y axis.
- [ ] **Step 1:** `grep -rn -i "per cell\|own y\|each cell\|y axis" README.md docs help src/ui_context_help.cpp src/ui_tour.cpp CLAUDE.md` to find every statement that the cells of a split row have their own y axis or one common gutter "not per column"; rewrite each to the new rule: one y range and one y axis per figure row, labels on the left cell, y zoom/pan/fit/Auto-Fixed act on the whole row, one ruler pair per row, cells share the y gutter's width only through the first cell.
- [ ] **Step 2:** `build-and-run.md`: add the plotter change (`set_share_row_y`, y link semantics, `YStrip`, plot-width cell placement) beside the other vendored changes. `invariants-plot-and-layout.md`: replace the "one gutter, not a per-column one" wording with the structural rule (first cell only, a `None` cell contributes 0, never content-dependent). `probe-readings.md` / `verification.md`: document the `[split-probe] shared-y` line and the changed `ruler-labels` / `col-drag` readings with the defect each wrong value names.
- [ ] **Step 3:** Build (help/manual and context help compile), run the split probe once more for `gtk warnings: 0`.
- [ ] **Step 4: Commit** `git add <the files changed> && git commit -m "docs: split rows share one y axis"`.
@@ -0,0 +1,832 @@
# Signal badges -- step 2 of the badge-bar redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Put the signals on the badge bar: one badge per enabled signal (a colour dot and the name; up to 6, then one "N signals" badge), each opening a per-signal slide-over page, each a drag handle (to a plot row, or to reorder), plus a trailing `+` that opens the full signal list as a slide-over page. The signal list leaves the dock.
**Architecture:** The existing `signal_panel` (heading, hint, list, math divider) is *reparented* from the temporary dock into the `signals` slide-over page. A new unit, `src/ui_signal_page.cpp`, owns the lazily-created `signal:<name>` pages (colour, calibration, information, "plot on row N", math edit/delete) and keeps them in step with the model. `src/ui_badges.cpp` grows the signal badges (inside a scroller so the bar's minimum width never depends on how many there are). The drop that today puts a dragged list row on a plot becomes `assign_signal_to_row()`, shared by the drop and by the page's buttons; the badge replaces the list row as the drag source.
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` / `src/ui_badges.cpp` probes, never a separate harness.
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Badge bar", "Slide-over panel", rollout step 2). Step 1 plan: `docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md`. Branch: `feature/ui-badges-dock` (continue on it).
## Global Constraints
Copied from the spec and the repository's rules; every task includes them.
- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the badge bar keeps its fixed 36 px height and its minimum width must not grow with the number of signals (the signal badges sit in a `GtkScrolledWindow`, `GTK_POLICY_EXTERNAL` / `GTK_POLICY_NEVER`); the slide-over still overlays and never resizes the plot.
- **A `GtkOverlay` child is picked wherever its allocation is**: nothing in this step adds an overlay child.
- Signal badges: "one badge per enabled signal, up to 6; beyond that a single 'N signals' badge. A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which replaces today's drag from the signal list), and opens that signal's page (colour, calibration, row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor entry)." (spec, verbatim)
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. This step adds `signals` and `signal:<name>`.
- One slide-over page at a time; Esc or a click outside closes it; each page has its own scroller; the open page is not saved.
- "Disable, do not hide": a control that does not apply is insensitive and stays on screen (the one precedent kept is the acquire tick, hidden in a file view -- see `rebuild_signal_list`).
- Text buttons only for new controls (the `+` is the character, not an icon); no icons in badges. The colour dot is the existing `chip_markup()` disc.
- The widget-lifetime trap (`docs/claude/invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_signal_panels()` destroys the page/row that owns the emitting button; hold the emitter with `outlive_the_rebuild(widget)` first. A drop handler never destroys the widgets it was emitted from.
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`.
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
## Deliberate step-2 deviations from the spec (state them in the final report)
- **Reorder is in memory only.** The badge order lives in `App::badge_order` for the session; it is not written to the session file (the spec's session section lists only the dock state). A new signal sorts after the ordered ones, in model order.
- **Beyond 6 enabled signals the "N signals" badge replaces the per-signal badges *and* the `+`** (it opens the same `signals` page, so a second button for it would be a duplicate). Per-signal pages for those signals are reached by activating a row of the `signals` list (new in this step); drag-to-row for them is by the page's "Plot on" buttons.
- **The list's drag source is removed**, as the spec says ("replaces today's drag from the signal list"); `row_drag_sidebar()` goes with it if nothing else calls it.
- A signal page opened for a signal that is not acquired shows its acquire tick (so a list row can be activated without first being ticked).
## File Structure
| File | Create/Modify | Responsibility |
| --- | --- | --- |
| `src/ui_signal_page.cpp` | Create | The `signal:<name>` pages: ensure/fill/prune, their buttons, the `signal_pages` probe helpers. |
| `src/ui_badges.cpp` | Modify | Signal badges, the `+` badge, "N signals" overflow, scroller, reorder drop, drag source, `signal_badges_probe`. |
| `src/ui_signals.cpp` | Modify | `listed_signals()`, `assign_signal_to_row()`, `reset_signal_color()`, row-activated -> page, list drag source removed, header comment fixed. |
| `src/ui_slideover.cpp` | Modify | `slideover_remove_page()`. |
| `src/ui_app.hpp` | Modify | `Badge` fields, `BadgeBar` fields, `App::badge_order` / `App::signal_pages`, declarations. |
| `src/ui.cpp` | Modify | Signal panel into the `signals` page; `ShowSignals` opens it; probe env var; `row_drag_sidebar` removal. |
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | The signals step / hint re-pointed to the page; badge text. |
| `src/ui_probe.cpp` | Modify | Every probe that measured or walked `signal_panel` / `signal_list` while it lived in the dock. |
| `src/ui_widgets.cpp` | Modify | CSS for the signal badge dot/name if needed. |
| `CMakeLists.txt` | Modify | Add `src/ui_signal_page.cpp` next to `src/ui_badges.cpp`. |
| `README.md`, `docs/claude/*.md` | Modify | Task 5. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) |
---
### Task 1: The `signals` page and the `+` badge
The list moves out of the dock into a slide-over page; the `+` badge and the `g` key open it. Nothing else changes yet (the list rows keep their menu and their drag source).
**Files:**
- Modify: `src/ui.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (header comment), `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_probe.cpp`
**Interfaces:**
- Consumes (step 1): `slideover_add_page(App&, const std::string& id, const std::string& title, GtkWidget* content) -> bool`, `slideover_open/close/toggle(App&, ...)`, `slideover_page(const App&) -> std::string`, `Badge{button,name,state,page}`, `make_badge(...)`, `BadgeBar{bar,signals_slot,system_slot,trigger,source}`.
- Produces: `BadgeBar::plus` (`Badge`, page `"signals"`, name label `"+"`); the `signals` page registered once in `on_activate`.
- [ ] **Step 1: Freeze today's self-check**
Build `HEAD` (`cmake --build build -j`), then:
```sh
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-base.log 2>&1
kill $MOCK
sed 's/[0-9.]\+/N/g' /tmp/ui-signals-base.log > .superpowers/sdd/ui-signals-baseline-udps.txt
grep -c 'Gtk-CRITICAL' /tmp/ui-signals-base.log; grep 'gtk warnings' /tmp/ui-signals-base.log
```
Expected: `0` and `gtk warnings: 0`. Every later task diffs its own normalised run against this file and must explain every differing line from that task's "allowed baseline changes"; any other difference is a defect.
- [ ] **Step 2: Fix the stray `\n` in `src/ui_signals.cpp` line 4**
The header comment reads `...with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row.` on one line (a literal backslash-n). Replace it with two comment lines:
```cpp
// The signal list: one row per signal with its colour chip and acquire tick, the panel around it,
// and the drop that puts a signal on a row.
```
- [ ] **Step 3: Reparent the panel into a `signals` page (`src/ui.cpp`, `on_activate`, ~line 5172)**
Replace
```cpp
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel);
gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
```
with
```cpp
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
```
and register the page where the other pages are registered (after `build_slideover`, next to the `"trigger"` page; the panel must exist first, so if that registration happens before line 5172, add the `slideover_add_page(*app, "signals", "Signals", app->signal_panel);` call right after the panel is built instead):
```cpp
slideover_add_page(*app, "signals", "Signals", app->signal_panel);
```
Read the surrounding code before editing: the registration must happen once, after `build_slideover(*app)` and after `build_signal_panel`. Update the comments that say the dock holds "the signal list, the cursors and the measurements" (`dock_show_section`, `ui_app.hpp` `dock_page`) to "the cursors and the measurements".
- [ ] **Step 4: `g` opens the page (`run_app_action`, ~line 4682)**
```cpp
case AppAction::ShowSignals: slideover_toggle(app, "signals"); break;
```
and in `dock_show_section` delete the `"signals"` branch (the dock no longer holds the list); keep `"measure"`.
- [ ] **Step 5: The `+` badge (`src/ui_badges.cpp`, `src/ui_app.hpp`)**
In `BadgeBar` add `Badge plus;`. In `build_badge_bar`, after the Source badge:
```cpp
// The full signal list. Insensitive until the stream has declared signals ("disable, do not hide").
make_badge(app, bb.plus, bb.signals_slot, "+", "signals");
gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel");
```
(`make_badge` appends a second, empty `state` label; for `+` leave it empty, it takes no width.) In `sync_badge_bar`, before the early return at the `bb.source.button == nullptr` check:
```cpp
if (bb.plus.button != nullptr) {
gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty());
set_open(bb.plus.button, slideover_page(app) == "signals");
}
```
Step 2's later tasks insert the signal badges *before* the `+` in `signals_slot`; Task 3 handles the ordering, so do not worry about it here.
- [ ] **Step 6: Tour and context help**
`src/ui_tour.cpp`: the `"signals"` step (line ~460) becomes `{"signals", "signals", "The signals", ...}` (second field is the slide-over page; see the `"trigger"` step), text: `"Every signal the source declares, each with the switch that acquires it. The + badge opens this list; a signal has to be switched on before it can be plotted, measured or searched."`. The `"side"` step text becomes `"The dock beside the plot: the cursors, then the measurements. The buttons in the bar above show and hide it."`. Keep `&App::signal_list` as the rect. `src/ui_context_help.cpp` line ~92: replace the last sentence of the `"signals"` hint (`A signal that is dragged onto a plot is drawn there.`) with `A signal's badge, dragged onto a plot, draws it there.`; the `"signals_config"` hint's `which plot a signal is dragged onto` becomes `which plot a signal is drawn on`.
- [ ] **Step 7: Re-point the probes that treated the list as part of the dock**
`src/ui_probe.cpp` references `signal_panel`/`signal_list` at ~103-186, ~1828, ~1853, ~3843-3847, ~5827, ~6165, ~6431 and the tour-surface probe ~7024-7097. The page is closed by default, so widgets inside it are unmapped and have no allocation. For each: (a) a probe that measures the panel's allocated size -- open the `signals` page first (`slideover_open(*a, "signals")`) and close it after, or measure with `gtk_widget_measure`; (b) a probe that walks the rows needs nothing (children exist while unmapped); (c) the stress line at ~1853 (`gtk_widget_set_size_request(a->signal_panel, -1, 3000)`) existed to overflow the *dock's* scroller; re-point it to `a->cursors_panel`; (d) the `ShowSignals` check at ~6431 asserts `slideover_page(*a) == "signals"` and then closes it; (e) `~6165` (`gtk_widget_get_parent(app.signal_list)`) -- read what it needs and take it from the page instead. Print the same lines as before wherever the value is still meaningful.
- [ ] **Step 8: Verify**
```sh
cmake --build build -j 2>&1 | grep -E 'warning|error'; echo "rc=$?"
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-t1.log 2>&1
kill $MOCK
sed 's/[0-9.]\+/N/g' /tmp/ui-signals-t1.log | diff .superpowers/sdd/ui-signals-baseline-udps.txt - ; grep -c Gtk-CRITICAL /tmp/ui-signals-t1.log; grep 'gtk warnings' /tmp/ui-signals-t1.log
```
Then run `UDPSCOPE_PROBE_SLIDEOVER_MS=1500`, `UDPSCOPE_PROBE_BADGES_MS=1500` and `UDPSCOPE_PROBE_TOUR_SURFACE_MS=<as in verification.md>` the same way. Expected: zero build warnings; the slide-over and badge probes read as in step 1 (`table_bad=0`, `open_marked=1`, `closed_unmarked=1`); the tour-surface probe shows the `signals` step now opens the `signals` page and `tour_plan_holds(signal_list)=1`. **Allowed baseline changes:** the dock's size/position lines that included the list (re-pointed per Step 7), any `signal_panel` geometry line. Explain each in the report.
- [ ] **Step 9: Commit**
```sh
git add -A src docs
git commit -m "ui: the signal list is the signals slide-over page, opened by a + badge or g"
```
---
### Task 2: Per-signal pages, `assign_signal_to_row()` and `reset_signal_color()`
Builds `signal:<name>` pages (nothing opens them yet except activating a list row) and extracts the two actions the page shares with the list.
**Files:**
- Create: `src/ui_signal_page.cpp`
- Modify: `src/ui_signals.cpp`, `src/ui_slideover.cpp`, `src/ui_app.hpp`, `src/ui.cpp` (probe env var), `src/ui_probe.cpp` or `src/ui_signal_page.cpp` (probe), `CMakeLists.txt`
**Interfaces:**
- Produces (declare in `ui_app.hpp`, namespace `udpscope::ui_impl`):
```cpp
/// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order.
/// The one rule the list and the badges share.
std::vector<std::size_t> listed_signals(const std::vector<SignalInfo>& signals);
/// Puts `signal` (an index into `signals()`) on plot `row` in the first free slot, switching it on first if
/// need be. Says why not through `set_notice` and returns false when it cannot. The one path a drop and a
/// signal page's "Plot on" button share.
bool assign_signal_to_row(App& app, std::size_t signal, int row);
/// Back to the palette colour for the signal's position.
void reset_signal_color(App& app, std::size_t signal);
/// Holds `widget` alive across the current emission (was `static` in ui_signals.cpp).
void outlive_the_rebuild(GtkWidget* widget);
bool slideover_remove_page(App& app, const std::string& id); // ui_slideover.cpp
/// Creates the page `signal:<name>` if it is missing, fills it, and returns whether it exists afterwards.
bool signal_page_ensure(App& app, const std::string& name);
/// Called at the end of `rebuild_signal_panels`: refills every existing signal page, drops the pages of
/// signals the model no longer has (closing the slide-over first if one of them is open).
void signal_pages_sync(App& app);
```
- `App` gains `std::map<std::string, GtkWidget*> signal_pages;` (name -> the page's content box).
- [ ] **Step 1: `listed_signals()`**
In `src/ui_signals.cpp`, add (before `rebuild_signal_list`):
```cpp
std::vector<std::size_t> listed_signals(const std::vector<SignalInfo>& signals) {
std::vector<std::size_t> out;
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
const std::string base = layer_base_name(signals[i].name);
bool seen = false;
for (std::size_t j = 0; j < i && !seen; ++j) {
seen = !ScopeModel::is_time_signal(signals, j) && layer_base_name(signals[j].name) == base;
}
if (!seen) out.push_back(i);
}
return out;
}
```
and make `rebuild_signal_list` use it:
```cpp
std::vector<std::size_t> rows;
for (std::size_t i : listed_signals(signals)) {
// Locked: a row per signal that is *on* ... (keep the existing comment)
if (app.signals_locked && !app.model->signal_enabled(i)) continue;
rows.push_back(i);
}
```
Behaviour is unchanged (the dump's `rows == signals + 1 + math` line must not move).
- [ ] **Step 2: `assign_signal_to_row()`**
Move lines `on_drop_channel` from `if (signal >= app->model->signals().size()) return FALSE;` through `notice("plotting '" ...)` into the new function, with `app->` -> `app.`; keep the `notice` lambda, the "enable it, then take the channel that appears" branch, the free-slot search and the two refusal messages verbatim, `return false`/`return true`. `on_drop_channel` becomes:
```cpp
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user) {
App* app = static_cast<App*>(user);
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
const int row = row_at(*app, x, y); // where the drop landed decides the row: one area covers them all
const char* text = g_value_get_string(value);
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
if (!assign_signal_to_row(*app, signal, row)) return FALSE;
cancel_drag_on(*app, row);
return TRUE;
}
```
`rebuild_signal_panels(app)` stays inside `assign_signal_to_row` (before the notice), as it is today.
- [ ] **Step 3: `reset_signal_color()`**
Extract the body of the `recolor` lambda (`ui_signals.cpp` ~386-401) into:
```cpp
void reset_signal_color(App& app, std::size_t signal) {
std::string name;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& sigs = app.model->signals();
if (signal >= sigs.size()) return;
name = sigs[signal].name;
}
app.rows->clear_signal_color(name);
app.relayout_pending = true;
rebuild_signal_panels(app);
schedule_layout_save(app);
}
```
The lambda keeps `outlive_the_rebuild(GTK_WIDGET(b))` first (the list rebuild destroys it) and then calls this. Remove `static` from `outlive_the_rebuild` and move its definition above `rebuild_signal_list` users unchanged.
- [ ] **Step 4: `slideover_remove_page()` (`src/ui_slideover.cpp`)**
Read how `slideover_add_page` records a page (stack child name, any title/page registry on `SlideOver`). Implement the inverse: returns false if the id is unknown; refuses (returns false) if it is the open page; otherwise removes the stack child (`gtk_stack_remove`) and erases whatever registry entry `slideover_add_page` made.
- [ ] **Step 5: `src/ui_signal_page.cpp`**
Create the file with the SPDX header, `#include "ui_app.hpp"`, `<gtk/gtk.h>`, `<string>`, namespaces `udpscope::ui_impl`. Page ids are `"signal:" + name`. Contents (an anonymous namespace plus the two public functions):
```cpp
namespace {
std::string page_id(const std::string& name) { return "signal:" + name; }
/// The index of `name` in the model's signals, or npos.
std::size_t signal_index(App& app, const std::string& name) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& s = app.model->signals();
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i].name == name) return i;
}
return std::string::npos;
}
GtkWidget* text_button(const char* label, GtkWidget* parent) {
GtkWidget* b = gtk_button_new_with_label(label);
gtk_box_append(GTK_BOX(parent), b);
return b;
}
/// Stores the signal's index and the page's name on the button, so a handler resolves identity the way the
/// list does.
void tag(GtkWidget* b, std::size_t index) {
g_object_set_data(G_OBJECT(b), "udpscope-signal", GSIZE_TO_POINTER(index));
}
std::size_t tagged(GtkButton* b) {
return static_cast<std::size_t>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
}
void fill(App& app, const std::string& name, GtkWidget* page) {
for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) {
GtkWidget* next = gtk_widget_get_next_sibling(c);
gtk_box_remove(GTK_BOX(page), c);
c = next;
}
const std::size_t index = signal_index(app, name);
if (index == std::string::npos) return;
// A snapshot, taken under the lock: the chip helper wants the whole vector, and the producer replaces it
// on every CONFIG.
std::vector<SignalInfo> all;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
all = app.model->signals();
}
if (index >= all.size()) return;
const SignalInfo sig = all[index];
const bool enabled = app.model->signal_enabled(index);
const bool plotted = signal_plotted(app, sig.name);
const bool unlocked = !app.signals_locked;
// Back to the list: a page reached from a list row has no other way home.
GtkWidget* back = text_button("All signals", page);
gtk_widget_set_halign(back, GTK_ALIGN_START);
gtk_widget_add_css_class(back, "flat");
g_signal_connect(back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
slideover_open(*static_cast<App*>(user), "signals");
}),
&app);
GtkWidget* title = gtk_label_new(sig.name.c_str());
gtk_widget_add_css_class(title, "title-3");
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(page), title);
if (!sig.unit.empty()) {
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_widget_set_halign(unit, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), unit);
}
// Acquire / compute. The same handler as the list's tick, which reads the signal index off the widget.
GtkWidget* enable = gtk_check_button_new_with_label(sig.math ? "Compute this channel" : "Acquire this signal");
app.updating_panels = true;
gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), enabled);
app.updating_panels = false;
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(index));
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
gtk_box_append(GTK_BOX(page), enable);
// Colour.
GtkWidget* colour_head = gtk_label_new("Colour");
gtk_widget_add_css_class(colour_head, "heading");
gtk_widget_set_halign(colour_head, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), colour_head);
GtkWidget* chip = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, all, index).c_str());
GtkWidget* colour_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(colour_row), chip);
GtkWidget* pick = text_button("Change colour...", colour_row);
gtk_widget_set_sensitive(pick, plotted && unlocked);
gtk_widget_set_tooltip_text(pick, plotted ? "the colour this signal is plotted in"
: "on no plot, so there is no trace to colour");
tag(pick, index);
g_signal_connect(pick, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
open_signal_color(*static_cast<App*>(user), tagged(b));
}),
&app);
GtkWidget* reset = text_button("Default colour", colour_row);
gtk_widget_set_sensitive(reset, unlocked && app.rows->signal_colors().count(sig.name) != 0);
tag(reset, index);
g_signal_connect(reset, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
outlive_the_rebuild(GTK_WIDGET(b));
reset_signal_color(*static_cast<App*>(user), tagged(b));
}),
&app);
gtk_box_append(GTK_BOX(page), colour_row);
// Row: "Plot on 1 2 3 ..." and the way back off.
GtkWidget* row_head = gtk_label_new("Plot on");
gtk_widget_add_css_class(row_head, "heading");
gtk_widget_set_halign(row_head, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), row_head);
GtkWidget* rows_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
for (int r = 0; r < app.rows->row_count(); ++r) {
GtkWidget* b = text_button(std::to_string(r + 1).c_str(), rows_box);
tag(b, index);
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r));
gtk_widget_set_tooltip_text(b, "draw this signal on this plot");
g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
outlive_the_rebuild(GTK_WIDGET(b));
assign_signal_to_row(*static_cast<App*>(user), tagged(b),
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row")));
}),
&app);
}
gtk_box_append(GTK_BOX(page), rows_box);
GtkWidget* off = text_button("Remove from plots", page);
gtk_widget_set_halign(off, GTK_ALIGN_START);
gtk_widget_set_sensitive(off, plotted);
tag(off, index);
g_signal_connect(off, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
outlive_the_rebuild(GTK_WIDGET(b));
std::string n;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& s = a->model->signals();
if (tagged(b) >= s.size()) return;
n = s[tagged(b)].name;
}
const int gone = remove_signal_from_plots(*a, n);
a->relayout_pending = true;
rebuild_signal_panels(*a);
schedule_layout_save(*a);
set_notice(*a, "'" + n + "' left " + std::to_string(gone) + (gone == 1 ? " plot" : " plots"), 4);
}),
&app);
// Information (a streamed signal's), calibration (ditto), and a math channel's editor.
GtkWidget* more_head = gtk_label_new(sig.math ? "Math channel" : "Signal");
gtk_widget_add_css_class(more_head, "heading");
gtk_widget_set_halign(more_head, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(page), more_head);
GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
if (sig.math) {
GtkWidget* edit = text_button("Edit graph...", more);
tag(edit, index);
g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app);
GtkWidget* del = text_button("Delete", more);
gtk_widget_add_css_class(del, "destructive-action");
tag(del, index);
g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app);
} else {
GtkWidget* info = text_button("Information", more);
tag(info, index);
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
GtkWidget* cal = text_button("Calibrate...", more);
gtk_widget_set_sensitive(cal, unlocked);
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
tag(cal, index);
g_signal_connect(cal, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
open_calibration(*a, GTK_WINDOW(a->window), tagged(b));
}),
&app);
}
gtk_box_append(GTK_BOX(page), more);
}
} // namespace
```
`on_math_edit_clicked`, `on_math_delete_clicked`, `on_info_clicked`, `open_calibration`, `open_signal_color`, `signal_plotted` and `signal_chip_markup` are already declared in `ui_app.hpp` (check; if `on_math_edit_clicked` / `on_math_delete_clicked` are not, add the declarations). `on_math_edit_clicked` calls `dismiss_row_menu(button)`, which looks for a `GtkPopover` ancestor and does nothing when there is none -- confirm that by reading it. `on_math_delete_clicked` already calls `outlive_the_rebuild`. Then the two public functions:
```cpp
bool signal_page_ensure(App& app, const std::string& name) {
if (signal_index(app, name) == std::string::npos) return false;
auto it = app.signal_pages.find(name);
if (it == app.signal_pages.end()) {
GtkWidget* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_top(page, 12);
gtk_widget_set_margin_bottom(page, 12);
gtk_widget_set_margin_start(page, 12);
gtk_widget_set_margin_end(page, 12);
if (!slideover_add_page(app, page_id(name), name, page)) return false;
it = app.signal_pages.emplace(name, page).first;
}
fill(app, name, it->second);
return true;
}
void signal_pages_sync(App& app) {
for (auto it = app.signal_pages.begin(); it != app.signal_pages.end();) {
if (signal_index(app, it->first) == std::string::npos) {
if (slideover_page(app) == page_id(it->first)) slideover_close(app);
slideover_remove_page(app, page_id(it->first));
it = app.signal_pages.erase(it);
} else {
fill(app, it->first, it->second);
++it;
}
}
}
```
`slideover_add_page` wraps `content` in a scroller and returns it through the stack, so `page` stays the box `fill` clears. Check that the gtk widget `page` is not destroyed by `slideover_remove_page` before `erase` (it is not used afterwards). Call `signal_pages_sync(app)` at the end of `rebuild_signal_panels` (before `app.signal_generation = ...`). `signal_index` takes the model mutex, so `signal_pages_sync` must not be called with it held (`rebuild_signal_panels` releases it before the list rebuild).
- [ ] **Step 6: A list row opens its page**
In `rebuild_signal_list`, tag each row: `g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i));` (the math divider row has none and is not activatable). In `build_signal_panel`, after the null check:
```cpp
g_signal_connect(app.signal_list, "row-activated",
G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal")));
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& s = a->model->signals();
if (index >= s.size()) return;
name = s[index].name;
}
if (signal_page_ensure(*a, name)) slideover_open(*a, "signal:" + name);
}),
&app);
```
If `signals-panel.ui`'s list box has `activate-on-single-click` off or selection mode that swallows activation, set `activate-on-single-click` to true in code. Read the file; the row's own check button / colour-chip gesture / menu button must still work (they consume their own clicks).
- [ ] **Step 7: Probe `UDPSCOPE_PROBE_SIGNAL_PAGES_MS`**
Declare `gboolean signal_page_probe(gpointer)` (put it in `ui_signal_page.cpp`), schedule it in `ui.cpp` next to the badges probe. It takes the first non-math listed signal (from `listed_signals`), calls `signal_page_ensure`, `slideover_open("signal:<name>")` and prints:
```
[signalpage] name=<n> open=<slideover_page==id> pages=<signal_pages.size()> enable_visible=<0|1> plot_buttons=<count of the "Plot on" buttons> plot_on_row0=<1 if assign_signal_to_row(...,0) returned true and signal_plotted> removed=<remove button click leaves signal_plotted false> back_to_list=<slideover_open("signals") -> page "signals"> pruned=<1 after the page's signal is gone>
```
To check `pruned` without a CONFIG, call `slideover_close`, `slideover_remove_page` directly is *not* a test of `signal_pages_sync`; instead leave `pruned=-1` when it cannot be exercised, and exercise `signal_pages_sync` by the ordinary reconfiguration paths (adding then deleting a math channel with `add_math_channel` / `a->math.erase(...)`+`apply_math_channels` if the probe harness already does that elsewhere; otherwise `-1`). Close the slide-over at the end.
- [ ] **Step 8: Verify**
Build (zero warnings), then the UDPS baseline run as in Task 1 Step 8 diffed against `.superpowers/sdd/ui-signals-baseline-udps.txt` (expected: the Task 1 differences only), then:
```sh
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500 \
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[signalpage\]|\[udpscope\] drop|Gtk-|gtk warnings'
kill $MOCK
```
Expected: `open=1 plot_on_row0=1 removed=1 back_to_list=1`, `plot_buttons` equal to the figure's row count, `gtk warnings: 0`, no `Gtk-CRITICAL`. The existing drop probes (`[drop]` lines in the baseline) must read unchanged: they now run through `assign_signal_to_row`.
- [ ] **Step 9: Commit**
```sh
git add -A src CMakeLists.txt docs
git commit -m "ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions"
```
---
### Task 3: The signal badges, the "N signals" overflow and the order
**Files:**
- Modify: `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (call), `src/ui.cpp` (probe env var), `src/ui_widgets.cpp` (CSS, if any)
**Interfaces:**
- Consumes: `listed_signals`, `signal_page_ensure`, `signal_chip_markup`, `signal_pages_sync` (Task 2).
- Produces:
```cpp
// ui_app.hpp
struct Badge { /* existing */ std::string signal_name; std::size_t signal = 0; }; // the two new fields: empty/0 for non-signal badges
struct BadgeBar {
/* existing */
GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then "+"
std::vector<std::unique_ptr<Badge>> signal_badges; ///< one per shown enabled signal; empty when overflowed
Badge overflow; ///< the "N signals" badge, used only beyond kMaxSignalBadges
Badge plus; ///< (Task 1)
};
inline constexpr std::size_t kMaxSignalBadges = 6;
// App: std::vector<std::string> badge_order; ///< names, in the order the user left the badges (memory only)
void sync_signal_badges(App& app); ///< rebuilds the signal badges from the model; ui_badges.cpp
gboolean signal_badges_probe(gpointer user);
```
Include `<memory>` in `ui_app.hpp` if it is not already.
- [ ] **Step 1: Put the signals in a scroller (`build_badge_bar`)**
Before creating any badge:
```cpp
// The signal badges sit in a scroller: a bar whose minimum width grew with the number of signals would
// widen the window (layout must not depend on content). EXTERNAL scrolls by wheel/touch with no bar.
GtkWidget* scroller = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_EXTERNAL, GTK_POLICY_NEVER);
gtk_widget_set_hexpand(scroller, TRUE);
bb.signals_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), bb.signals_row);
gtk_box_append(GTK_BOX(bb.signals_slot), scroller);
```
and change the `+` badge to `make_badge(app, bb.plus, bb.signals_row, "+", "signals")` (it follows the signal badges because they are inserted before it; see Step 3). Move `make_badge`'s parent argument usage accordingly.
- [ ] **Step 2: `make_signal_badge`**
In the anonymous namespace of `ui_badges.cpp`:
```cpp
/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon.
void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signals, std::size_t index) {
b.signal = index;
b.signal_name = signals[index].name;
b.page = "signal:" + b.signal_name;
b.button = gtk_button_new();
gtk_widget_add_css_class(b.button, "flat");
gtk_widget_add_css_class(b.button, "udpscope-badge");
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* dot = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str());
b.name = gtk_label_new(b.signal_name.c_str());
gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14);
gtk_box_append(GTK_BOX(row), dot);
gtk_box_append(GTK_BOX(row), b.name);
gtk_button_set_child(GTK_BUTTON(b.button), row);
gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str());
g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app);
g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b);
}
```
`b.state` stays `nullptr`; `sync_badge_bar` never touches it for these. In `on_badge_clicked`, before `slideover_toggle(*app, b->page)`:
```cpp
// A signal's page is made on first use (and kept in step by `signal_pages_sync`).
if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return;
```
- [ ] **Step 3: `sync_signal_badges`**
```cpp
void sync_signal_badges(App& app) {
BadgeBar& bb = app.badges;
if (bb.signals_row == nullptr) return;
std::vector<SignalInfo> signals;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
signals = app.model->signals();
}
// The enabled signals, in the user's order: names in `badge_order` first, the rest in model order.
std::vector<std::size_t> shown;
for (std::size_t i : listed_signals(signals)) {
if (app.model->signal_enabled(i)) shown.push_back(i);
}
const auto rank = [&](std::size_t i) {
const auto it = std::find(app.badge_order.begin(), app.badge_order.end(), signals[i].name);
return static_cast<std::size_t>(it - app.badge_order.begin()); // == size() when unknown: sorts last
};
std::stable_sort(shown.begin(), shown.end(), [&](std::size_t a, std::size_t b) { return rank(a) < rank(b); });
// Clear: every signal badge, and the overflow if it was there. The "+" is kept.
for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr;) {
GtkWidget* next = gtk_widget_get_next_sibling(c);
if (c != bb.plus.button) gtk_box_remove(GTK_BOX(bb.signals_row), c);
c = next;
}
bb.signal_badges.clear();
bb.overflow = Badge{};
const bool overflowed = shown.size() > kMaxSignalBadges;
if (overflowed) {
make_badge(app, bb.overflow, bb.signals_row, "", "signals");
gtk_label_set_text(GTK_LABEL(bb.overflow.name), (std::to_string(shown.size()) + " signals").c_str());
} else {
for (std::size_t i : shown) {
bb.signal_badges.push_back(std::make_unique<Badge>());
make_signal_badge(app, *bb.signal_badges.back(), signals, i);
gtk_box_insert_child_after(GTK_BOX(bb.signals_row), bb.signal_badges.back()->button,
gtk_widget_get_prev_sibling(bb.plus.button));
}
}
// The overflow badge stands for the "+" (it opens the same page); the "+" is shown only when it is not.
gtk_widget_set_visible(bb.plus.button, !overflowed);
sync_badge_bar(app);
}
```
`make_badge` appends to its parent; for the overflow badge it lands after the `+`, which is hidden then, so the order is immaterial. `make_badge(..., "", ...)` with an empty name: fine, the text is set right after. (`Badge{}` assignment: `Badge` holds only pointers/strings so this is a plain reset; the overflow button was destroyed by the clear loop.) The "+" being hidden when overflowed is the one deliberate use of hiding: the overflow badge replaces it. In `sync_badge_bar` add, for each signal badge and the overflow, `set_open(button, slideover_page(app) == badge.page)` (the overflow's page is `"signals"`, so it shares the `+`'s rule: both read the same state, only one is visible).
- [ ] **Step 4: Rebuild when the model's structure or a plot assignment changes**
At the end of `rebuild_signal_panels` (after `signal_pages_sync(app)` from Task 2) add `sync_signal_badges(app);`. Also call it once at the end of `build_badge_bar` (after the `+` is created) so the first frame is right. Also add `std::vector<std::string> badge_order;` and `#include <algorithm>` / `<memory>` where needed. Every event that changes a dot (assign, remove, colour, enable) already calls `rebuild_signal_panels`.
- [ ] **Step 5: Probe `UDPSCOPE_PROBE_SIGNAL_BADGES_MS`**
Schedule `signal_badges_probe` in `ui.cpp` (like the badges probe). It prints one line:
```
[sigbadges] enabled=<shown count> badges=<signal_badges.size()> overflow=<0|1> overflow_text=<text|-> plus_visible=<0|1> dot_plotted=<number of badges whose dot is not the unplotted grey; compute by signal_plotted> bar_h=<gtk_widget_get_height(bar)> bar_h_fixed=<1 if bar_h before == after toggling one signal off and on> bar_min_w=<gtk_widget_measure min width of badge_bar> opens=<1 if emitting "clicked" on badges[0] makes slideover_page == signal:<name>, and a second emit closes it> plus_opens=<1 if the same for plus (or the overflow when overflowed)>
```
Toggle a signal with `app.model->set_signal_enabled(i, false)` then `rebuild_signal_panels(app)`, and back. `bar_min_w` is only a record: the check is that it is the same with the mock's N signals as `bar_min_w` printed by the E-1608 run in Task 5 (stated there).
- [ ] **Step 6: Verify**
Build (zero warnings). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` against the mock: expected `badges == enabled` (or `overflow=1` if the mock declares more than 6), `bar_h=36` or the bar's measured value with `bar_h_fixed=1`, `opens=1 plus_opens=1`, `gtk warnings: 0`. Then the Task 1 baseline diff and the slide-over/badges/tour-surface probes: **allowed baseline changes:** none beyond Tasks 1-2. A screenshot at 1400x900 (`GSK_RENDERER=cairo`, `import -window root`) to look at the bar once.
- [ ] **Step 7: Commit**
```sh
git add -A src docs
git commit -m "ui: one badge per enabled signal, an N signals overflow, and the order kept in memory"
```
---
### Task 4: Badge drag (to a plot row, and to reorder); the list stops being a drag source
**Files:**
- Modify: `src/ui_badges.cpp`, `src/ui_signals.cpp`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`
**Interfaces:**
- Consumes: `on_drag_prepare(GtkDragSource*, double, double, gpointer)` (payload: the signal index as a decimal string), `DragContext{App*, size_t}`, `on_drop_channel` (the plot area's drop target, unchanged), `badge_order`, `signal_badges`.
- Produces: nothing new in `ui_app.hpp` except removals.
- [ ] **Step 1: The badge is a drag source and a reorder target (`make_signal_badge`)**
Append to `make_signal_badge`:
```cpp
// A drag handle: the same payload the list's rows used to carry, so the plot area's drop target takes it
// unchanged. The slide-over steps aside for the drag, since it covers the plot's right edge.
DragContext* ctx = new DragContext{&app, index};
GtkDragSource* source = gtk_drag_source_new();
gtk_drag_source_set_actions(source, GDK_ACTION_COPY);
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
G_CONNECT_DEFAULT);
g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) {
slideover_close(*static_cast<App*>(user));
}),
&app);
gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(source));
// Dropping another signal's badge on this one moves it in front of this one.
GtkDropTarget* drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
g_signal_connect(drop, "drop", G_CALLBACK(on_badge_drop), &b);
gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(drop));
```
and, above it in the anonymous namespace:
```cpp
/// The reorder: widgets are moved, never rebuilt, so neither the dragged badge nor this target (whose "drop"
/// emission is running) is destroyed under the drag.
gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpointer user) {
Badge* target = static_cast<Badge*>(user);
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(target->button), "udpscope-app"));
if (app == nullptr || !G_VALUE_HOLDS_STRING(value)) return FALSE;
const char* text = g_value_get_string(value);
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
Badge* moved = nullptr;
for (auto& b : app->badges.signal_badges) {
if (b->signal == signal) moved = b.get();
}
if (moved == nullptr) return FALSE;
if (moved == target) return TRUE;
GtkWidget* prev = gtk_widget_get_prev_sibling(target->button);
if (prev != moved->button) { // already directly in front: nothing to move
gtk_box_reorder_child_after(GTK_BOX(app->badges.signals_row), moved->button, prev);
}
// The order, as the widgets now stand.
app->badge_order.clear();
for (GtkWidget* c = gtk_widget_get_first_child(app->badges.signals_row); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
for (auto& b : app->badges.signal_badges) {
if (b->button == c) app->badge_order.push_back(b->signal_name);
}
}
return TRUE;
}
```
`gtk_box_reorder_child_after(box, child, nullptr)` puts the child first, which is what a `nullptr` `prev` means here.
- [ ] **Step 2: Remove the list's drag source (`rebuild_signal_list`)**
Delete the `DragContext* ctx ...` block through `gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));` (~454-471). `grep -n row_drag_sidebar src` : if `rebuild_signal_list` was its only caller, delete `row_drag_sidebar`, its declaration and `App::sidebar_hidden_for_drag`; if something else calls it, leave it. Update the comment above the list's row loop that mentions dragging, if any.
- [ ] **Step 3: The probes that drove the list's drag**
`src/ui_probe.cpp` ~3843-3847 walks `signal_list` rows (read what it does: if it performs a drag or a drop by index, it now takes the index from `signal_badges[0]->signal` and calls `assign_signal_to_row` / `on_drop_channel` with the same payload). Keep every printed line it printed before.
- [ ] **Step 4: Extend `signal_badges_probe`**
After the existing line print a second:
```
[sigbadges] drag_sources=<number of badges with a GtkDragSource controller> drop_targets=<... GtkDropTarget> prepare_ok=<1 if on_drag_prepare for badges[0]'s DragContext returns a non-null provider> reorder_before=<names comma-separated> reorder_after=<...> reorder_ok=<1 if dropping the last badge on the first moved it to the front and badge_order matches the widget order> payload_drop=<1 if on_drop_channel-equivalent: assign_signal_to_row(badges[0]->signal, 0) leaves it plotted> list_drag=<number of GtkDragSource controllers on the first list row; must be 0>
```
Controllers are enumerated with `gtk_widget_observe_controllers()` (a `GListModel`) and `G_TYPE_CHECK_INSTANCE_TYPE`/`GTK_IS_DRAG_SOURCE`. With fewer than 2 badges print `reorder_ok=-1`. Drop the reordered badge back into its place at the end so later probes see the original order. For the `GValue`: `GValue v = G_VALUE_INIT; g_value_init(&v, G_TYPE_STRING); g_value_set_string(&v, std::to_string(signal).c_str());` and call `on_badge_drop` directly -- it is in the anonymous namespace of `ui_badges.cpp` where the probe lives, so no export is needed.
- [ ] **Step 5: Verify**
Build (zero warnings; `-Wunused` would flag a leftover `row_drag_sidebar`). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500`. Expected: `drag_sources == drop_targets == badges`, `prepare_ok=1`, `reorder_ok=1` (or `-1` if fewer than 2), `payload_drop=1`, `list_drag=0`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Then the Task 1 baseline diff: **allowed changes:** a drag-by-list-row probe line that now reports through the badge (explain it). A real pointer drag cannot be simulated headless: say so in the report, and note that the drop path itself is `assign_signal_to_row` (probed in Task 2).
- [ ] **Step 6: Commit**
```sh
git add -A src docs
git commit -m "ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging"
```
---
### Task 5: Docs, the E-1608 run and the sweep of what the list's move touched
**Files:**
- Modify: `README.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/architecture.md`, `docs/claude/invariants-gtk-and-chrome.md` (only if it names the list as being in the sidebar), `src/ui_tour.cpp` (badges step text), `SPECS.md` is **not** touched.
- [ ] **Step 1: The E-1608 run (the spec's stress case)**
```sh
python3 tools/mock_e1608.py & MOCK=$!
sleep 1
xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=3000 ./build/udpscope --source e1608 --source-opt address=127.0.0.1 > /tmp/ui-signals-e1608.log 2>&1 & sleep 4.5; import -window root /tmp/ui-signals-e1608.png; wait'
kill $MOCK
grep -E '\[sigbadges\]|Gtk-|gtk warnings' /tmp/ui-signals-e1608.log
```
Expected: `overflow=1 overflow_text=<N> signals plus_visible=0`, `bar_h_fixed=1`, and `bar_min_w` equal to the UDPS run's (the scroller keeps it constant -- if it differs by more than the one badge's rounding, the scroller is not constraining: fix before going on). Look at the screenshot once: the "N signals" badge, Trigger and Source on the right, bar height unchanged. Then open the `signals` page from a probe or by `g`, activate a row (E-1608 has up to ~17): the page for it exists (`[signalpage]` run against the mock too: `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=3000`). Kill the mock.
- [ ] **Step 2: Docs**
- `README.md`: the signals section describes the badge bar (signal badges, `+`, the six-badge overflow, drag to a plot or to reorder, the per-signal page) instead of the list in the sidebar; state that the order is kept for the session only.
- `docs/claude/verification.md`: add `UDPSCOPE_PROBE_SIGNAL_PAGES_MS` and `UDPSCOPE_PROBE_SIGNAL_BADGES_MS` next to the badge probes; `docs/claude/probe-readings.md`: the `[signalpage]` line and the two `[sigbadges]` lines, each field with the value it must read and the defect a wrong reading names (the fields above).
- `docs/claude/invariants-plot-and-layout.md`: one rule -- the badge bar's signal badges live in a scroller so the bar's minimum width never depends on the signal count (the E-1608 case), and the bar's height is fixed.
- `docs/claude/architecture.md`: the page ids now implemented (`signals`, `signal:<name>`), `ui_signal_page.cpp`, and that the dock holds the cursors and measurements only. `docs/claude/invariants-gtk-and-chrome.md`: if it describes `outlive_the_rebuild` or the list's drag/sidebar hiding, say it now covers the signal page's buttons and that the list is no longer a drag source.
- `src/ui_tour.cpp`: the `"badges"` step text becomes `"Setup is reached by the thing it configures. A badge per signal opens that signal's colour, calibration and plot; the + opens the full list; Trigger and Source open their own forms. Drag a signal badge onto a plot to draw it there, or onto another badge to reorder. Click again, press Esc or click the plot to close the panel."`
- [ ] **Step 3: Final run**
Build from scratch (`rm -rf build/CMakeFiles/udpscope.dir; cmake --build build -j 2>&1 | grep -c warning` -- expected exactly `1`, the icons' warning; if a clean rebuild is too slow, report the incremental result instead), the UDPS baseline diff, and the slide-over / badges / signal-pages / signal-badges / tour-surface probes once each. Report every baseline difference with its explanation.
- [ ] **Step 4: Commit**
```sh
git add -A src docs README.md
git commit -m "docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign)"
```
@@ -0,0 +1,88 @@
# Events in the dock, the left pane deleted -- Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Step 4 of the badges/dock redesign (spec `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md`): a compact Events section in the dock, the full events list and its actions on an `events` slide-over page, the left events pane / divider / header toggle deleted, and the Mallard manual, USER-GUIDE and remaining docs swept for the new layout.
**Architecture:** The dock gets a third block under the readouts (`build_events_dock` in `src/ui_dock.cpp`): one compact row per `EventRecord` (title, time, small marker; no thumbnails), a "Manage" button, one click replays. The existing pane widgets (`events-pane.ui`: head with Live, Compare, Accumulate, the multi-select list with thumbnails) stop being a `GtkRevealer` beside the plot and become the content of the `events` slide-over page; the list keeps its builder, thumbnail cache and selection logic. Both views are rebuilt from the same `event_store->list()` by the same refresh entry point.
**Tech Stack:** C++20, GTK4/libadwaita, the headless self-check probes (no unit tests).
## Global Constraints
- The dock is fixed at 280 px; dock event rows are ellipsized labels with fixed `width-chars`/`max-width-chars`; no thumbnails in the dock (layout must not depend on content).
- No new overlay child; the page uses `slideover_add_page(App&, id, title, widget)`; the page id is `events`.
- "Disable, do not hide": the dock's Events block stays on screen, insensitive, when unavailable. **Availability rule (deviation from the spec's "insensitive outside trigger mode", kept on purpose):** the existing `sync_events_availability` condition (`event_recording || replaying || events_cached`) -- a replay or a group leaves trigger mode, and a block that greys itself under the click that started it is the trap `invariants-trigger-and-pinning.md` records. Document the deviation in README and `docs/claude`.
- A handler that rebuilds destroys the widgets it was emitted from: use `outlive_the_rebuild(widget)`. `refresh_events_pane` must keep nulling `events_thumb` before it removes children. `update_group_buttons` keeps its `app.window == nullptr` teardown guard.
- `model->signals()` is read only under `model->mutex()`.
- The page content is the pane's `events_box`, never the `GtkRevealer` (an unrevealed revealer measures 0x0).
- Selection survives marking: `mark_events_on_screen` updates titles in place and now also updates the dock rows' marker; it never rebuilds the page list.
- Every new source file carries the SPDX MIT header (this plan adds none).
- Baselines: clean build = 1 warning (icons `-Woverlength-strings`), incremental = 0, `gtk warnings: 0`, no `Gtk-CRITICAL`. Probes need a tour-seen config for slide-over page legs (see `.superpowers/sdd/task-5-report.md`, `verification.md`). Mocks: `tools/mock_udpstreamer.py` (8114), `tools/mock_e1608.py` (54211); run serially, kill them.
- The session file has no events-pane field (`events_pane_width` is memory only); there is nothing to migrate. README's claim that the width is remembered across sessions is wrong and is removed.
---
### Task 1: The Events block in the dock (the left pane still exists)
Adds the dock block so the app stays whole; nothing is removed.
**Files:**
- Modify: `src/ui_dock.cpp`, `src/ui_app.hpp`, `src/ui_events.cpp`, `src/ui.cpp` (dock assembly, availability sync, tick), `src/ui_probe.cpp`, `CMakeLists.txt` (only if a file is added; none expected)
**Interfaces:**
- Produces (declared in `ui_app.hpp`, defined in `ui_dock.cpp`):
- `GtkWidget* build_events_dock(App& app);` -- vertical `GtkBox`: a row with the "Events" label and a "Manage" text `GtkButton` (calls `slideover_open(app, "events")`; a no-op until Task 2 registers the page), then a `GtkListBox` (`app.dock_events_list`, selection mode none, single-click activation) of compact rows, inside a `GtkScrolledWindow` with a fixed `min-content-height`/`max-content-height` (about 4 rows) and `vscrollbar-policy` automatic. Stores `app.dock_events_box`, `app.dock_events_list`, `app.dock_events_manage`.
- `void dock_rebuild_events(App& app);` -- clears and refills `dock_events_list` from `app.event_store->list()` (newest first, as the pane does): each row = a small marker label (`dock_event_marker`, see below), the title (ellipsized, same text as `event_row_title`), the time as the pane shows it (ellipsized, dim). With no records: one dim "no events yet" row. Attaches the `EventRecord` to the row as `"udpscope-event"` (same key as the pane). Null guard: a no-op before `build_events_dock` ran or when `app.event_store` is null.
- `void dock_mark_events(App& app);` -- updates each dock row's marker/title from the same data `mark_events_on_screen` uses (the event on screen and the group members), without rebuilding rows.
- `void dock_events_set_sensitive(App& app, bool available);` -- the one place the block's sensitivity is set; `sync_events_availability` calls it with its own `available` value.
- Row activation (single click): calls the existing `replay_event(app, record)` that `on_event_row_activated` (`ui_events.cpp:961`) calls, with the same argument it passes; do not duplicate the replay logic -- extract the shared body if `on_event_row_activated` has any.
- Consumes: `App::event_store`, `EventRecord`, `event_row_title()` (`ui_events.cpp:784`), `replay_event`, `mark_events_on_screen`, `slideover_open`.
- [ ] **Step 1: Build the block.** Write `build_events_dock`, `dock_rebuild_events`, `dock_mark_events`, `dock_events_set_sensitive` in `ui_dock.cpp`. Marker: a single-character label -- the on-screen event gets a filled marker, the group members another, others none (read how `mark_events_on_screen` (`ui_events.cpp:796`) decides the on-screen/group titles and reuse its predicate; do not invent a second one). CSS: check `ui_widgets.cpp` for existing `.udpscope-event-*` classes and `dim-label` before adding any.
- [ ] **Step 2: Wire it.** Append `build_events_dock` to the dock after the readouts box (and a separator), in `ui.cpp` where `build_readouts_dock` is assembled. Call `dock_rebuild_events(app)` at the end of `refresh_events_pane`'s non-empty and empty paths (every place it finishes) **and** from `record_event` unconditionally (today's `record_event` refreshes the pane only if the revealer is revealed; the dock must follow every latch), and `dock_mark_events` from `mark_events_on_screen`. Call `dock_events_set_sensitive` from `sync_events_availability` (`ui_events.cpp:40-70`) with its `available` result, and once after building (insensitive until available).
- [ ] **Step 3: Probe.** In the existing events probe (`events_probe_next`, `UDPSCOPE_PROBE_EVENTS_MS`) print `dock events: rows=<n> pane_rows=<n> same_titles=<0|1> sensitive=<0|1> dock_w=<n>` after at least two latched events (the probe already produces them), where `same_titles` compares each dock title to the pane row's title label (`"udpscope-event-title"`) in order, and again after a replay (`on_screen` marker present on exactly one dock row). Add one click on a dock row to the probe and print `dock replay: clicked=<0|1> replaying=<0|1>` (the dock row click starts a replay as the pane's activate does).
- [ ] **Step 4: Build; run** the events probe (UDPS mock), the readouts probes (regression), and check `dock_w` <= 280, `gtk warnings: 0`.
- [ ] **Step 5: Commit** (`git add src/ui_dock.cpp src/ui_app.hpp src/ui_events.cpp src/ui.cpp src/ui_probe.cpp`; message `ui: compact events block in the dock`).
---
### Task 2: The `events` page; the left pane, divider and header toggle deleted
**Files:**
- Modify: `src/ui_events.cpp`, `src/ui.cpp`, `src/ui/main-window.ui`, `src/ui/header-bar.ui`, `src/ui/events-pane.ui`, `src/ui_app.hpp`, `src/ui_trigger.cpp` (`:597-601`, `:723-724`), `src/ui_layout.cpp` (`:380` comment), `src/ui_widgets.cpp` (`:58-60` comment), `src/ui_probe.cpp`, `src/ui_dock.cpp`
**Interfaces:**
- Consumes: Task 1's `build_events_dock`, `dock_rebuild_events`, `dock_mark_events`, `dock_events_set_sensitive`; `slideover_add_page`, `slideover_open`.
- Produces: slide-over page `events` (title "Events") whose content is `events_box` of `events-pane.ui`; `build_events_pane(App&)` now returns that box (not a revealer); `App::events_revealer`, `App::events_divider`, `App::events_pane_width`, `sync_events_divider`, the header `events_toggle` and its handler are gone; `events_cached`/`sync_events_availability` keep driving the dock block's sensitivity (and the page's: set `events_box` insensitive with it).
- [ ] **Step 1: The page.** `events-pane.ui`: drop the `events_pane` revealer so `events_box` is the root (rename nothing else); keep `events_head`, `events_live`, `events_compare`, `events_accumulate`, `events_list`. `build_events_pane` stops creating `events_revealer`. Register the page next to the `cursors`/`measure` registrations in `ui.cpp`: `slideover_add_page(*app, "events", "Events", app->events_box)` (add the `events_box` member if absent). `record_event`'s refresh condition `gtk_revealer_get_child_revealed` becomes "the `events` page is open" (`slideover_page(app) == "events"`), because the dock refreshes independently; opening the page (the slide-over's open path or the Manage button) must refresh the list once so it is current -- put this in a `slideover_set_on_open`-style hook only if the slide-over already has one; otherwise refresh in the Manage button's handler and in `refresh_events_pane` callers that run while closed (cheap; the list is small) -- state in the report which you chose.
- [ ] **Step 2: Delete the pane.** `main-window.ui`: remove `events_divider`; `left_column` becomes the `side_view` content directly (keep every id inside it); fix the header comment (lines ~6, 23, 29). `ui.cpp`: remove `sync_events_divider`, its tick call, the `events_divider` creation (`~5201-5206`), the null guard (`~4998`), the toggle handler (`~4951-4959`) and the toolbar-order probe string entry (`~3010`); `header-bar.ui`: remove `events_toggle`; keep the `udpscope-events-symbolic` icon (used by `preferences.ui`). `ui_app.hpp`: remove the members and declarations (`events_divider`, `events_pane_width`, `events_revealer`, the toggle pointer). `sync_events_availability` loses the toggle/revealer branches, keeps computing `available`, and drives `dock_events_set_sensitive` plus the page's sensitivity. Fix the comments at `ui_trigger.cpp:597-601`, `:723-724`, `ui_layout.cpp:380`, `ui_widgets.cpp:58-60`.
- [ ] **Step 3: Probes.** Rewrite `events_probe_next` (`ui_probe.cpp:5590-5860`) to open the page with `slideover_open(*a, "events")` instead of clicking the toggle, then drive selection/Compare/Accumulate exactly as before on the page's list; delete the divider/revealer/`pane_w` reads; rewrite `report_events_availability` (`ui.cpp:5712-5724`) as `[events] available=<0|1> dock_sensitive=<0|1> page_sensitive=<0|1> page=<id>` and the `events pane:` dump (`ui.cpp:6242-6255`) as `events pane: compare= accumulate= list= nested=` (no revealer). Any layout dump or toolbar probe naming the toggle is updated. The plot's width must no longer change when events exist: add `plot_w_stable=<0|1>` comparing the plot area's allocated width before and after the first latched event (it must read 1; the old pane squeezed it).
- [ ] **Step 4: Run** the events probe, the trigger probes, the layout/toolbar probes, the readouts probes, one E-1608 run; `gtk warnings: 0`, no `Gtk-CRITICAL`, in particular none from `events_thumb`, `update_group_buttons` at teardown, or the removed revealer. Re-baseline `.superpowers/sdd/ui-signals-baseline-udps.txt` only where this step explains a difference.
- [ ] **Step 5: Commit** (`git add` the modified files by name; message `ui: the events list is a slide-over page; the left pane, its divider and the header toggle are gone`).
---
### Task 3: Tour, context help, docs/claude, README
**Files:**
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp` (`:102-110`), `README.md` (97, 841-850, 883, 1032, 1194, 3025-3027), `docs/claude/{architecture,verification,probe-readings,invariants-gtk-and-chrome,invariants-trigger-and-pinning,invariants-plot-and-layout}.md`
- [ ] **Step 1: Tour and help.** The `dock` tour stop text mentions the Events block (what it lists, that one click replays, that Manage opens the list). The context-help hints `events` (anchored to `&App::events_head`, now on a page) and `events_record` follow how the `cursors`/`measures` hints were handled in step 3 (anchor on the page heading, shown while the page is open; reword "the events pane"). Run the help-figures and tour probes.
- [ ] **Step 2: docs/claude.** `architecture.md` (chrome section: the dock now has readouts and events; page `events`; `events` no longer "reserved", ~line 105), `invariants-gtk-and-chrome.md` (paned/revealer traps at ~212-228 lose the events divider; teardown ~281 and `events_thumb` ~297, thumb cache ~309 now say "the page's list"), `invariants-trigger-and-pinning.md` (~441-453: the availability rule drives the dock block's sensitivity; the spec's "outside trigger mode" deviation and why), `invariants-plot-and-layout.md` (no events divider; the plot is never squeezed by events), `verification.md` (~724 and the events probe wording), `probe-readings.md` (`dock events:`, `dock replay:`, the new `[events]` and `events pane:` lines and what each must read; the old `pane_w=240` rows and the `[events]` table near 1660-1675 rewritten).
- [ ] **Step 3: README.** Layout/dock description, the header (no Events toggle), the events page, the single-click replay in the dock, deviations: availability rule instead of "outside trigger mode"; the dock list has no thumbnails (the page does); delete the false "remembers the width" claim; update the pending-removal note at ~3025-3027 (it is done).
- [ ] **Step 4: Run** the tour and help-figure probes, one UDPS and one E-1608 run; `gtk warnings: 0`.
- [ ] **Step 5: Commit** (code: `ui: tour and help describe the events block`; docs: `docs: events in the dock and on its page`).
---
### Task 4: The Mallard manual and USER-GUIDE sweep for the whole redesign
**Files:**
- Modify: `help/C/org.udpscope.app/*.page` (`trigger.page` 169-196 "Events pane on the left", the history button, "Drag the pane's edge"; `overview.page` incl. its layout description and any figure caption; `signals.page`, `math.page`, `sessions.page`, plus any page that mentions the side panel, the sidebar tabs ("Measure", "Signals" tab), the header's sidebar toggle, or the Events toggle), `docs/USER-GUIDE.md`, `README.md` leftovers
- [ ] **Step 1: Find every stale mention.** `grep -rniE "side panel|sidebar|side pane|tab switcher|\bMeasure tab|Signals tab|events pane|history button|Events toggle|drag.*(edge|divider)" help/C docs/USER-GUIDE.md README.md src/ui_*.cpp` and read each hit in context; also check figures the manual embeds (`help/C/org.udpscope.app/figures/` and how they are generated -- `UDPSCOPE_*` help-figures probe in `verification.md`). List every hit and its disposition in the report.
- [ ] **Step 2: Rewrite** each page to the real UI: header bar (time window, Trigger, Pause, Cursors, Dock toggle, menu), the plot, the badge bar (signal badges, `+`, N signals, Trigger, Source), the slide-over pages (trigger, signals, signal:<name>, cursors, measure, events, source), the dock (Readouts: cursors, measurements; Events). Read the code or `docs/claude/architecture.md` to be accurate; do not invent shortcuts (`m` toggles Measurements, `g` the signals page -- check `src/shortcuts.cpp`). Regenerate figures only if the repository already has a script that does so headlessly; otherwise say which figures remain stale.
- [ ] **Step 3: Validate** the Mallard pages if `yelp-check` / `xmllint` is available (`xmllint --noout help/C/org.udpscope.app/*.page`); build with `cmake --build build -j` (the help may be compiled/installed by a target).
- [ ] **Step 4: Commit** (`docs: the manual and the user guide describe the badge bar, the dock and the slide-over pages`).
@@ -0,0 +1,107 @@
# Compact readouts dock -- step 3 of the badge-bar redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The dock shows the cursors and the measurements as compact, read-only readouts (about 280 px wide, always on screen); the full cursors panel and the full measurements panel (mode, reset, add, statistics, edit) become the `cursors` and `measure` slide-over pages, reached by an "Edit" text button in the dock, by the `m` key, and by clicking a measurement's dock line.
**Architecture:** A new unit `src/ui_dock.cpp` (spec: `ReadoutsDock`) builds the dock's "Readouts" section: a cursors block (a small grid mirroring `App::cursors_lines`) and a measurements block (one line per `App::measures`). It owns no formatting: `update_cursors_pane()` and `update_measures()` already compute the strings, and they now write them to the page's widget *and* to the dock's, from the one string. `cursors_panel` and the measures panel are *reparented* (as the trigger form and the signal list were) into slide-over pages with ids `cursors` and `measure`. The dock's old contents (the two full panels) leave it.
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` probes, never a separate harness.
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Right dock", "Slide-over panel", rollout step 3). Earlier plans: `2026-11-02-ui-badges-dock-shell.md` (step 1), `2026-11-03-ui-badges-signals.md` (step 2). Branch: `feature/ui-badges-dock` (continue on it).
## Global Constraints
Copied from the spec and the repository's rules; every task includes them.
- **Right dock (spec, verbatim):** "Fixed width, about 280 px; always on screen, shown but insensitive until the stream has signals ('disable, do not hide'). It stays an `AdwOverlaySplitView` sidebar ... **Readouts** (top): the active cursor values and measurement values, read-only, one compact line per value, through the one formatter both panes share (`invariants-readouts-and-cursors.md`). An 'Edit' affordance opens the slide-over's `measure` or `cursors` page; clicking a measurement row opens that measurement's editor."
- **One formatter:** a readout quotes a stored sample and never a value between two (`docs/claude/invariants-readouts-and-cursors.md`); the dock and the page show the *same string*, set from the same variable in the same call -- no second `format_measure()` call site for the same value.
- **Layout must not depend on content** (`invariants-plot-and-layout.md`): the dock has a fixed width (280 px: `min-sidebar-width` = `max-sidebar-width` in `main-window.ui`), every dock label is ellipsized with a fixed `width-chars`/`max-width-chars`, so a long unit or value never widens the dock; the badge bar keeps its fixed height; the slide-over overlays and never resizes the plot.
- **A `GtkOverlay` child is picked wherever its allocation is**: this step adds no overlay child.
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. This step adds `measure` and `cursors`. One slide-over page at a time; each page has its own scroller (`slideover_add_page` provides it); the open page is not saved.
- "Disable, do not hide": with the cursors off, the dock's cursor block stays on screen with `--` cells (the existing blanking in `update_cursors_pane`), insensitive; an empty measurement list shows one dim line, never nothing.
- Text buttons only for new controls ("Edit", not an icon). Existing icon buttons that move into the pages are not changed in this step.
- The widget-lifetime trap (`invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_measures_pane()` / `rebuild_cursors_pane()` destroys widgets it may have been emitted from; hold the emitter with `outlive_the_rebuild(widget)` first. The dock's lines are rebuilt by those two functions, so a click on a dock line must not be handled by a widget that the rebuild destroys synchronously (open the dialog from the handler, rebuild later).
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
- Verification runs are short and serial; kill every mock you start (`tools/mock_udpstreamer.py` port 8114, `tools/mock_e1608.py` port 54211); seed a tour-seen config so the first-run tour does not close slide-over pages (see `.superpowers/sdd/task-5-report.md` for commands). Do not re-run to chase cosmetics.
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
## Deliberate step-3 deviations from the spec (state them in the final report)
- **The measurement editor stays a dialog window** (`open_measure_dialog`): the `measure` page holds the list, Add, the Statistics switch and each row's edit button, which open that dialog. Moving the dialog's form into the slide-over is not needed for the readouts and is not done here.
- The `measure` and `cursors` pages hold the existing panels unchanged (heading, mode dropdown, reset, switch, grid / add, statistics, rows), so their content is not re-designed.
- Dock cursor lines carry the same cells as the page's grid (`who`, A, B, delta), at a smaller size; they are not reduced to the time line only, because the modes that read per-row values would then have no readout on screen.
## File Structure
| File | Create/Modify | Responsibility |
| --- | --- | --- |
| `src/ui_dock.cpp` | Create | The dock's Readouts section: build, rebuild of the cursor lines and the measurement lines, the Edit buttons, the click-to-edit on a measurement line, the `[dock]` probe line. |
| `CMakeLists.txt` | Modify | Add `src/ui_dock.cpp` next to `src/ui_badges.cpp`. |
| `src/ui_app.hpp` | Modify | `App::DockCursorLine`, `App::dock_cursor_lines`, `App::dock_measure_labels`, `App::dock_readouts` widgets; declarations. |
| `src/ui_cursors.cpp` | Modify | `update_cursors_pane()` writes each cell's string to the page and to the dock; the rebuild calls the dock's rebuild. |
| `src/ui_measure.cpp` | Modify | `update_measures()` writes each reading to the page's label and to the dock's; `rebuild_measures_pane()` rebuilds the dock lines. |
| `src/ui.cpp` | Modify | Pages `cursors` / `measure` registered; panels removed from `dock_page`; `dock_show_section()` removed; `ShowMeasure` opens the page; dock assembly. |
| `src/ui/main-window.ui` | Modify | Dock width 280 (`min/max-sidebar-width`), `side_column` `width-request` 260, comments. |
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Steps / hints that pointed at the cursors and measurements panels in the dock now open the page (as the signals step does). |
| `src/ui_probe.cpp` | Modify | Probes that walked, measured or scrolled the panels while they lived in the dock; the `dock:` readings. |
| `README.md`, `docs/claude/*.md` | Modify | Task 3. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) |
---
### Task 1: The compact readouts in the dock (panels still in the dock)
The Readouts section is added at the top of the dock, above the existing full panels, so the app stays whole: nothing is removed yet. Both read from the same strings.
**Files:**
- Create: `src/ui_dock.cpp`
- Modify: `CMakeLists.txt`, `src/ui_app.hpp`, `src/ui_cursors.cpp`, `src/ui_measure.cpp`, `src/ui.cpp` (dock assembly, ~line 5161), `src/ui_probe.cpp` (a `[dock]` line in an existing probe)
**Interfaces:**
- Produces (declared in `ui_app.hpp`, defined in `ui_dock.cpp`):
- `GtkWidget* build_readouts_dock(App& app);` -- the Readouts box (a vertical `GtkBox`: a "Readouts" heading; a cursors block = a row with the "Cursors" label and an "Edit" `GtkButton` (text), then a `GtkGrid` of dock cursor lines; a measurements block = a row with the "Measurements" label and an "Edit" button, then a vertical box of lines). Stores `app.dock_cursor_grid`, `app.dock_measures_box`, `app.dock_cursors_edit`, `app.dock_measures_edit`.
- `void dock_rebuild_cursors(App& app);` -- called at the end of `rebuild_cursors_pane()`: rebuilds `app.dock_cursor_lines` (one `DockCursorLine {GtkWidget *label,*a,*b,*d;}` per `app.cursors_lines`, same order) in `dock_cursor_grid`. The first row is the same header `cursors_header()` writes (`who`/A/B/delta), small.
- `void dock_rebuild_measures(App& app);` -- called from `rebuild_measures_pane()` after the page's rows: one line per `app.measures` (signal chip, type name ellipsized, value label), pushing the value label to `app.dock_measure_labels` (same index as `app.measure_labels`). With no measurements: one dim line "no measurements".
- `void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, const std::string& d);` and `void dock_measure_value(App& app, std::size_t i, const std::string& text);` -- set the dock's labels; no-ops when the index is out of range (mid-rebuild).
- Consumes: `App::cursors_lines`, `App::measures`, `App::measure_labels`, `measure_color()`, `chip_markup()`, `signal_color_index()`, `open_measure_dialog(App&, int)`, `slideover_open(App&, const std::string&)` (page ids used in Task 2; in this task the Edit buttons call it and the page may not exist yet -- `slideover_open` of an unknown id is a no-op; Task 2 registers the pages).
- [ ] **Step 1: Dock widgets and rebuild functions.** Create `ui_dock.cpp` with the functions above. Fixed sizes: dock label `width-chars`/`max-width-chars` of 9 for value cells, ellipsize end, CSS class `caption` and `numeric` (check `ui_widgets.cpp` for the existing CSS before adding any). The whole Readouts box is the first child of `app.dock_page` (insert before the cursors panel with `gtk_box_prepend`).
- [ ] **Step 2: One string, two widgets.** In `update_cursors_pane()` (`ui_cursors.cpp`) replace the `put(line.a, va)` etc. by a helper that sets the page cell and calls `dock_cursor_cell(app, index, va, vb, vd)`; do the same for the blanking branch (`"--"`). In `update_measures()` (`ui_measure.cpp`, the two `gtk_label_set_text(label, ...)` sites that also write `app.measure_text[i]`) call `dock_measure_value(app, i, text)` from the same variable. Never format twice.
- [ ] **Step 3: Rebuild hooks.** `rebuild_cursors_pane()` (ui.cpp ~1564) ends with `dock_rebuild_cursors(app)`; `rebuild_measures_pane()` calls `dock_rebuild_measures(app)` before its final `update_measures(...)`. Both are no-ops until `build_readouts_dock` ran (null guards).
- [ ] **Step 4: Assemble.** In `ui.cpp` where the dock is assembled (after `gtk_box_append(dock_page, measures_panel)`), prepend `build_readouts_dock(*app)` and add its separator. The Edit buttons call `slideover_open(app, "cursors")` / `slideover_open(app, "measure")`; clicking a measurement line (a `GtkGestureClick` on the line box, not on the value label which is selectable) calls `open_measure_dialog(app, i)` with the index stored in the widget data, as `measures_row`'s edit button does.
- [ ] **Step 5: Probe.** Add to the existing cursors probe (find it with `grep -n "cursors" src/ui_probe.cpp`) and measures probe a reading proving the dock agrees with the page: print `dock cursors: lines=<n> page_lines=<n> same_text=<0|1>` and `dock measures: lines=<n> page_rows=<n> same_text=<0|1>` where `same_text` compares every dock label's text to the page label's text. Also `dock_w=<allocated width of the dock readouts box>` and assert it does not exceed the sidebar width. Run with 3 measurements and the cursors on in `Both` mode (cursors mode set via `rows->set_cursor_mode`).
- [ ] **Step 6: Build, run the probes against `tools/mock_udpstreamer.py`, verify `same_text=1`, `gtk warnings: 0`.**
- [ ] **Step 7: Commit** (`git add src/ui_dock.cpp CMakeLists.txt src/ui_app.hpp src/ui_cursors.cpp src/ui_measure.cpp src/ui.cpp src/ui_probe.cpp`; message `ui: compact cursor and measurement readouts at the top of the dock`).
---
### Task 2: The `cursors` and `measure` pages; the dock is only readouts
**Files:**
- Modify: `src/ui.cpp`, `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui_probe.cpp`, `src/ui_dock.cpp`
**Interfaces:**
- Consumes: Task 1's `build_readouts_dock`, `slideover_add_page(App&, id, title, widget)`, `slideover_toggle(App&, id)`.
- Produces: slide-over pages `cursors` (title "Cursors", content `app.cursors_panel`) and `measure` (title "Measurements", content the measures panel); `AppAction::ShowMeasure` toggles the `measure` page; `dock_show_section()` and its declaration/uses are deleted.
- [ ] **Step 1: Register the pages** next to the `trigger` and `signals` registrations (`ui.cpp` ~5156): `slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel)` and `slideover_add_page(*app, "measure", "Measurements", measures_panel)`. Remove both `gtk_box_append(dock_page, ...)` of the panels and the separator between them; the dock keeps only `build_readouts_dock` (and, later, step 4's events).
- [ ] **Step 2: Keys and actions.** `AppAction::ShowMeasure` -> `slideover_toggle(app, "measure")`. Delete `dock_show_section()`. Make sure the tour/help code does not call it (`grep -n dock_show_section src`).
- [ ] **Step 3: Dock width.** `main-window.ui`: `min-sidebar-width`/`max-sidebar-width` 280, `side_column` `width-request` 260; update its comments.
- [ ] **Step 4: Badge/header state.** The page ids join the "open" bookkeeping: `slideover_page(app)` reports them; nothing in the badge bar marks them (no badge). The header's Cursors toggle still mirrors the `cursors_switch` inside the page (both exist).
- [ ] **Step 5: Probes.** Fix every probe that read the panels' allocation, sensitivity or scroll position while they sat in the dock (`dock_section_probe`, the `dock:` reading in the layout dump, the `cursors=... in the toolbar, dock=..., reset=... in the cursors pane` line, any that walks `dock_page`): they open the page first or read the widget tree without allocation. Add `[readouts] pages cursors=<0|1> measure=<0|1> dock_children=<n> dock_w=<n>` (pages registered; dock holds only the readouts box; width 280). Re-baseline `.superpowers/sdd/ui-signals-baseline-udps.txt` only where this step explains the difference.
- [ ] **Step 6: Run** the UDPS and E-1608 probe runs serially; `gtk warnings: 0`; Esc closes the pages; the measure Add and the measurement dialog still work from the page (the existing measure probes).
- [ ] **Step 7: Commit** (`ui: the cursors and measurements panels are slide-over pages; the dock is the readouts`).
---
### Task 3: Tour, context help, docs
**Files:**
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `README.md`, `docs/claude/{architecture,verification,probe-readings,invariants-plot-and-layout,invariants-readouts-and-cursors,invariants-gtk-and-chrome}.md`, `help/C/*.page` mentions of the cursors / measurements panels in the dock or the side panel (the full Mallard sweep and `USER-GUIDE.md` are step 4)
- [ ] **Step 1: Tour.** Steps pointing at `cursors_panel`/`cursors_head`/`measures_head`/`side_box` now target the dock's readouts, or open the `cursors` / `measure` page through the existing "tour opens a page" mechanism (`invariants-gtk-and-chrome.md`), as the signals step does. Rewrite the step text: readouts in the dock, "Edit" opens the page.
- [ ] **Step 2: Context help.** Hints attached to `cursors_head` / `measures_head` live in the pages: show only while the page is open, or re-attach to the dock block headings -- whichever the existing layer supports without a second code path (follow how `signals_head` was handled). Run the help-figures probe and the tour probe (`UDPSCOPE_PROBE_*` in `verification.md`) and keep their lines sane.
- [ ] **Step 3: Docs.** README (layout, keys, the dock, readouts, deviations listed above), `architecture.md` (chrome section), `verification.md` and `probe-readings.md` (new `[dock]`/`[readouts]` lines, changed `dock:` readings), `invariants-readouts-and-cursors.md` (the dock and the page share one string: rule and why), `invariants-plot-and-layout.md` (dock width 280, ellipsized cells).
- [ ] **Step 4: Run** the tour/help-figure probes and one E-1608 run; confirm baselines (build 0 warnings, `gtk warnings: 0`).
- [ ] **Step 5: Commit** (`docs: the readouts dock, the cursors and measurement pages`).
@@ -0,0 +1,199 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# A tabbed side panel, and one channel list: design
The side panel stacks five sections in one scrolling column — trigger, cursors, measurements,
math, signals — each behind a `GtkRevealer` opened by its own toolbar toggle. With trigger,
measurements, cursors and math all open the column is unusable: every section is short of room,
and finding one means scrolling past three others.
Two changes, aimed at that one problem:
1. **The column becomes two tabs.** *Measure* holds the cursors readout and the measurements;
*Signals* holds the trigger and the channel list. One tab is on screen at a time, so no
section competes with four others for height.
2. **The math list is folded into the signal list.** A math channel already *is* a channel in
the model, and the two lists were the same list drawn twice. One list, with a `MATH`
divider in it.
A third change falls out of the first. A tab is one-of-N, so it can no longer carry an on/off:
**no toggle hides a pane any more.** Trigger-off and cursors-off put their pane in a disabled
state and leave it where it is.
## The decisions taken, and what each one rules out
| Decision | Alternative rejected |
| --- | --- |
| `AdwViewStack` + `AdwViewSwitcher` | `GtkStack` + `GtkStackSwitcher`: plain buttons beside an `AdwOverlaySplitView`, which is what the sidebar already is. `GtkNotebook`: draws its own tab strip, which costs width in a 320 px sidebar and reads as off-platform. libadwaita ≥ 1.8 is already a hard requirement (`CMakeLists.txt:22`), so the platform widget costs nothing new |
| **A scroller per page**, inside the stack | One shared scroller around the stack: the two pages are different heights, so a tab switch would leave the scroll position somewhere the new page did not ask for |
| The switcher sits **above** the scrolled area | Inside it: the one control that chooses what you are looking at must not be the first thing to scroll away |
| **Two** tabs, exactly as grouped | A tab per section (five tabs): that is the stack again, laid sideways, and a 320 px sidebar has no room for five switcher pills. Measurements read what the cursors bracket, and the trigger picks a channel from the list beside it — the grouping follows the way the panes are used |
| Nothing inside a tab collapses | Keeping a revealer per section inside the tab, or a `GtkPaned` between them: the first reintroduces exactly the state-management the tabs remove, two sections at a time instead of five; the second adds a divider position to persist per tab for a column that already scrolls |
| Trigger-off and cursors-off **disable** their pane | Hiding it, which is today's behaviour: a pane you can see is a pane you can read, and the settings you are about to turn back on should not vanish while you look for them |
| The `measurements` and `math` toolbar toggles are **deleted** | Keeping them as tab selectors: two buttons that select the same tab, whose pressed state duplicates the switcher's |
| The switcher shows **titles only**, and the two icons the deleted toggles used are **removed** | Giving each page an `icon-name`: the measurements icon would carry over, but *Signals* has no glyph — reusing the trigger icon for a page that is mostly a channel list misnames it, and drawing a new one is new artwork under the icon authoring rules for a label two words long |
| One row widget for both kinds of channel, differing **only in its menu** | Inline edit and delete buttons on math rows (today's math pane): math rows then carry visibly more controls than signal rows, and the list stops reading as one column |
| A `+` on the `MATH` divider **only** | A `+` on the signals header too: a signal comes from CONFIG and cannot be added, so the button would have nothing to do |
| The statistics accumulator keeps running on the **hidden** page | Gating `update_measures()` on the visible tab: the accumulator is history, so min/max/avg would gain a gap for exactly as long as you looked at the trigger |
| The probe reads the **page name and sensitivity** | Dropping the two revealer readings: the defect they catch — a panel that is built but not on screen — still exists, it is just spelled differently |
## 1. The container
The sidebar child of `side_view` becomes a vertical `GtkBox`:
```
AdwOverlaySplitView.side_view
sidebar: GtkBox (vertical)
AdwViewSwitcher <- never scrolls
AdwViewStack
page "measure" GtkScrolledWindow -> GtkBox: cursors, separator, measurements
page "signals" GtkScrolledWindow -> GtkBox: trigger, separator, signal list
```
`side_scroll` moves inside the pages; `App::side_box` becomes the stack, and the five revealer
fields in `ui_app.hpp` are removed with the code that sets them (`ui.cpp:4728-4767`).
The switcher carries the two page titles and no icons. `udpscope-measure-symbolic` and
`udpscope-math-symbolic` lose their last user with the toggles that used them, so both leave
`src/icons/scalable/actions/` and `udpscope.gresource.xml`: the bundled set goes from six
action icons to four, which `invariants-gtk-and-chrome.md` counts and must be corrected with
the change.
The pages are built by the functions that build them today — `build_trigger_panel()`,
`build_cursors_panel()`, `build_measures_panel()`, `build_signal_panel()` — unchanged except
that they are appended to a page's box instead of to a revealer.
## 2. State instead of visibility
| Control | Today | After |
| --- | --- | --- |
| `trigger_toggle` | trigger mode on/off **and** reveals the trigger pane (`ui_trigger.cpp:198`) | trigger mode on/off; the pane's body goes sensitive/insensitive |
| `cursors_toggle` | cursors on/off **and** reveals the readout (`ui_cursors.cpp:124-126`) | cursors on/off; the readout goes insensitive and its values blank to `—` |
| `cursors_reset` | hidden while cursors are off (`ui_cursors.cpp:122`) | insensitive while cursors are off |
| `measures_toggle` | reveals the measurements (`ui.cpp:4541-4550`) | **deleted** |
| `math_toggle` | reveals the math pane (`ui.cpp:4551-4558`) | **deleted** |
| `sidebar_toggle`, `events_toggle`, `pause_button` | — | unchanged |
The two deleted toggles leave `src/ui/header-bar.ui` with their handlers, so the bar is
declared with five controls and no id resolves to a button nothing listens to.
`update_side_visibility()` (`ui.cpp:2830-2850`) keeps its job — the whole sidebar is still the
split view's to show, still gated on a CONFIG having named a signal — minus the line that showed
`signal_toggle`, which no longer exists.
Everything else those handlers do is untouched: `tc.armed`, the epoch rule and the
`set_time_window()` call in `on_trigger_toggled()`, `sync_events_availability()`, the
Arm/Disarm label, and `rows->set_cursors_visible()` with its `reset_cursors()`.
Blanking the cursor readout to `—` rather than leaving the last values is the readout invariant
restated: a readout quotes a stored sample, and with the instruments off there is no sample it
quotes.
## 3. One channel list
**The duplication is literal, and the merge is mostly a deletion.**
`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()`
(`model.cpp:252-269`) has already appended one synthetic entry per math channel — *"they go
through the ordinary signal path on purpose: a derived channel is a channel"*, says its own
comment. Nothing in the list filters `sig.math`, so **every math channel is already a row in
the signal list**, with its tick, chip, name, unit and menu. The math pane listed the same
channels a second time.
What the list gains:
- a non-selectable `MATH` divider row before the first math row, carrying the `+` that opens
the node editor on a new channel;
- two items in a math row's menu.
| | signal row | math row |
| --- | --- | --- |
| tick | acquire (hidden in a file view) | enabled |
| colour chip | clickable while plotted | unchanged |
| unit | from CONFIG | the channel's own |
| menu | Information, Default colour, Calibrate… | **Edit graph…**, Default colour, **Delete** |
Information and Calibrate… are a *streamed* signal's — calibration is applied at ingest — so a
math row carries neither. No rename item either: the node editor owns a math channel's name and
unit (`src/ui/math-editor.ui:37-43`), so *Edit graph…* is where both are changed, and a second
place to change them would be a second answer to the same question.
Two things already work and must not be rebuilt: the tick is already the right switch —
`enable_locked()` (`model.cpp:626-639`) mirrors it into the `MathChannel`'s own `enabled` flag —
and the row's `GtkDragSource` already lets a math channel be dropped onto a plot row, which is
how `math1` reached row 0 in the session this work started from.
One defect to fix while merging: `App::math` is the copy the session file is written from, so a
tick on a math row must mirror `enabled` into it before `schedule_layout_save()`. Today the pane's
checkbox did that (`ui.cpp:2276`) and the list's tick did not, so the two controls disagreed
about what gets saved.
Deleted: `build_math_panel()` (`ui_math.cpp:1044`), `rebuild_math_pane()` (`ui.cpp:2251-2319`),
`src/ui/math-panel.ui` and its entry in `src/ui/udpscope-ui.gresource.xml`, `App::math_revealer`,
`App::math_list`, `App::math_toggle`, `App::measures_toggle`, `App::signal_revealer`, and
`App::signal_toggle` with `on_signals_toggled()` (`ui_signals.cpp:303-307`) and its overflow-menu
row (`ui.cpp:4452-4455`) — the list is a page now, so "show or hide the signal list" has nothing
to hide. The node editor dialog itself is untouched; only the button that opens it moves.
## 4. Persistence
One key, top level in the session file beside `window_s` and `measure_stats`
(`layout_store.cpp`):
```toml
side_tab = "signals" # or "measure"
```
Written on save; applied once where the rest of the layout is applied, so an unknown or missing
value leaves the stack on its first page. Nothing else about the panel is persisted today and
nothing else needs to be: no revealer states remain, and the 320 px width is still the `.ui`'s.
## 5. What runs on a hidden page
`update_cursors_pane()` and `update_measures(force=false, accumulate=true)` are called every
frame from the tick (`ui.cpp:3275-3276`) and keep being called whichever page is current.
Writing a label on an unrealized widget is legal; **reading its allocation is not**. Every
allocation read in the sidebar is a debug capture — `dbg_measures_pane_w/h`
(`ui.cpp:3237-3239`), `dbg_signal_w/h` and `dbg_trigger_w/h` (`ui_probe.cpp:77-80`) — and each
reads 0 on the page that is not current. That is a change in what the reading means, which §6
handles; it is not a defect, and no functional code may start depending on those numbers.
This is the revealer trap in a new widget. `invariants-gtk-and-chrome.md:174-181` records that
an unrevealed `GtkRevealer` measures 0×0 and is still given the leftover width; a stack's
hidden page measures 0×0 too, and the rule it teaches — ask about *state*, not about size —
carries over unchanged.
## 6. Verification
Readings that change, and what each must say instead:
| Reading | Was | Becomes |
| --- | --- | --- |
| `dbg_signal_revealed` (`ui_probe.cpp:90`) | the signal revealer's child is revealed | the `signals` page is current **and** its list is non-empty |
| `dbg_trigger_revealed` (`ui_probe.cpp:92`, `:2190`) | the trigger revealer's child is revealed | the `signals` page is current **and** the trigger body is sensitive |
| `dbg_measures_pane_w/h` | the pane's size while revealed | the pane's size while its page is current; 0 otherwise, and read as such |
| the panel-toggle reading (`ui_probe.cpp:104`) | both menu toggles are visible | the switcher is visible, which is the one control those two became |
One new probe, `UDPSCOPE_PROBE_SIDE_TAB_MS`: select the page that is not current, then dump the
page name, the measurement row count and the accumulator's sample count. The count must have
advanced across the switch — that is the §5 rule under test, and the defect it names is a
statistics pane that silently restarts its history every time you look at the trigger.
New reading for the merged list, on the existing signal-panel probe: the row count must be
signals + 1 + math channels, and the math rows must carry the same widget shape as a signal
row. A count short by the divider is a list that lost its `+`.
Baselines, unchanged and re-read on every run: exactly four warnings on a clean build, no
`Gtk-CRITICAL`, `test-ui` 15/15.
Docs to update with the work: `probe-readings.md` (the three changed lines and the two new
ones), `verification.md` (a bullet for the tab switch), `invariants-gtk-and-chrome.md` (the
sidebar is a view stack, the hidden-page trap replaces the unrevealed-revealer one for it, the
bar carries five controls and the bundled set four action icons),
`invariants-math-channels.md` (the list lives in the signal panel), `invariants-readouts-and-cursors.md`
(the readout blanks when the instruments are off), and a README note recording the deviation
from `SPECS.md` where it names the panes.
## Out of scope
The events pane left of the plot, export, the node editor's internals, and the status bar.
@@ -0,0 +1,153 @@
# MCC E-1608 source plugin (sub-project 3 of 4)
Date: 2026-10-31
Branch: `feature/source-e1608` (from `feature/source-plugins`, sub-project 1)
## Context
Sub-project 1 added the `DataSource` / `SourceKind` / `SourceRegistry` framework and ported
UDPS onto it. This sub-project adds a second kind, `e1608`: the Measurement Computing E-1608
Ethernet DAQ (8 SE / 4 DIFF 16-bit analog inputs, 2 analog outputs, 8 DIO bits, one 32-bit
counter), with its own connect form and two side-stack panels.
References for the wire protocol, both written by the user:
- Primary: `~/Projects/marte2_projects/MCC_E1608_DataSource`, a MARTe2 DataSource
(`E1608DataSource.{h,cpp}`, `E1608ScanFifo`). Its class comment in the header is the
authoritative list of parameters and behaviour. It is bound to MARTe2 (`BasicTCPSocket`,
`StreamString`, `SingleThreadService`), so it is **ported** to plain C++, not linked.
- Cross-check: `~/Projects/uopi/internal/datasource/mcc/{protocol,e1608}.go`, an independent
Go implementation of the same protocol.
No board is available. The mock is the only verification until one is attached.
## Decisions
| Question | Decision |
| --- | --- |
| DAC / generator | Static level plus a software waveform (sine, square, triangle, ramp), 10 Hz at most. Runs as a timer inside `poll()`; no extra thread. |
| Hardware | None available. A protocol mock (`tools/mock_e1608.py`) is the contract. Commands whose layout rests on a single reference (or where the two disagree) are listed as unverified. |
| Analog input config | Full: per-channel enable and range, global SE/DIFF, MAC discovery, external clock, trigger, DI/counter poll rate. |
| Digital lines and counter | Plot channels the user chooses, off by default, enabled per line in the connect form. |
| Acquisition modes | Hardware-paced only (scan rate or external clock). Software-paced mode (`ScanRate = 0`) is not ported. |
| Structure | Three layers: link, device, source. The bottom two have no GTK or `DataSource` types. |
## Design
### 1. Files (new, `src/source/e1608/`, plus the mock)
- `e1608_link.{hpp,cpp}`: UDP discovery (broadcast, or a unicast sweep of a given subnet
matched by MAC and the E-1608 product id) and connection-code handshake; TCP command
framing with a timeout; the scan socket on `port + 1`. Plain POSIX sockets.
- `e1608_device.{hpp,cpp}`: typed commands (AI, AO, DIO read/write/direction, counter
read/reset, scan queue, scan start/stop, status), the factory calibration read from the board's
calibration memory, and `decode()`, which turns raw interleaved `uint16` scans into calibrated
volts (`double`).
- `e1608_source.{hpp,cpp}`: the `DataSource`: the `poll()` state machine, `Frame` building, the
DI/counter and generator timers, the atomic readouts for the panels.
- `e1608_kind.cpp`: the `SourceKind`: the connect form and the two panels.
- `tools/mock_e1608.py`: the protocol mock.
- `register_builtin_sources()` registers `e1608` after `udps`.
### 2. Data path and threading
Everything runs inside `poll()` on the producer thread. The GTK thread reaches the board only
through `post(Command)`.
`poll(timeout)`:
1. Drains the posted commands (DAC writes, DIO toggles, counter reset, generator edits).
2. Runs the connection state machine: optional MAC lookup, handshake, calibration read, DIO
direction and initial outputs, scan queue and scan start take Disconnected to Scanning. A
link error fires `on_event(Disconnected)` and retries every 2 s. A connect attempt can block
`poll()` for up to the command timeout; that delays only the command queue.
3. Waits on the scan socket for at most the time to the next timer, reads what arrived, keeps a
partial scan for the next call, decodes the complete scans with the calibration and
delivers a `Frame`.
4. Runs the due timers: the DI/counter poll (`DiPollRate`) is a command round trip; the
generator tick (at most 10 Hz) is a DAC write.
The frame:
- Signals: the enabled AI channels (unit `V`, `range_min`/`range_max` from the channel's range),
the DI lines the user chose (0/1), and the counter if chosen.
- All signals are scalar; `num_samples` is the number of scans decoded in that read.
- DI and counter values are held across the block (a step trace).
- `sampling_rate` is the scan rate on every signal. `counter` is a running frame count.
- The aggregate rate (enabled channels times scan rate) must stay at or below 250 kS/s. The
form refuses a configuration above it.
Overrun: if the board reports a scan overrun the source fires `on_event(Error)` and restarts
the scan when `AutoRestart` is set; the next frame carries `lost = 1`. This is approximate,
because the board does not report how many scans it dropped.
Panel readouts: the source keeps the live DIO inputs, the counter, the DAC levels and the
generator state in atomics. The panels read them from a GTK timer, so no widget touches the
source's internals.
### 3. Connect form and panels
Connect form: grouped `AdwExpanderRow`s in the chooser's group. `SourceConfig` stays flat; a
per-channel setting is a key such as `ai0=BIP10V` (absent means off).
- Connection: address, MAC, discovery subnet, port, connection code, timeout. `valid()` needs
an address, or a MAC plus a subnet.
- Acquisition: scan rate, external clock, trigger mode, auto-restart, calibrate (default on),
DI/counter poll rate.
- Analog inputs: global SE or DIFF mode and 8 channel rows (enable, range). In DIFF mode rows
4 to 7 are insensitive, not hidden. `valid()` also enforces the 250 kS/s aggregate.
- Digital I/O: direction per bit (I or O), a "plot" switch per input bit, a "plot counter"
switch, the initial DO value and the initial DAC levels.
`identity()` is the address, or the MAC when there is no address. A session file is named
`e1608-<identity>`, port 1, as sub-project 1 defines for non-UDPS kinds. `has_datagrams()` is
false, so the capture button is disabled.
Two side-stack panels, both insensitive while disconnected (disabled, not hidden):
- Analog out: per DAC, a mode (Level, Sine, Square, Triangle, Ramp), a level or amplitude plus
offset, a frequency of 0.1 to 10 Hz, and an on/off switch. It shows the last voltage written
and clamps to ±10 V. The generator stops on disconnect and the DACs return to their initial
levels on reconnect.
- Digital I/O: eight bit rows; an input bit shows a live LED, an output bit has a toggle that
posts a write. The counter shows its value and a Reset button. Directions come from the form
and are not changed at runtime.
Tab icons are chosen in the plan under the project's bundled-icon rules.
### 4. Verification
Mock (`tools/mock_e1608.py`): UDP discovery with a MAC and the connection-code handshake; TCP
commands for AI, AO, DIO, the counter, scan queue, scan start/stop, calibration memory and
status; a scan stream on port+1 at the requested rate with a sine per channel; switches to drop
the link after N seconds and to report an overrun. Its header lists every command whose layout
is unverified (single reference, or the references disagree).
Probe `UDPSCOPE_PROBE_E1608=<delay ms>`, run against the mock with `--source e1608`, extends the
probe/dump in `src/ui.cpp`. It checks:
- Frames arrive with the expected channel count and sample rate.
- The panels are present and insensitive while disconnected.
- A DAC write, a DIO toggle and a counter reset each reach the mock.
- A forced link drop leads to a reconnect and a resumed stream.
- The calibration is applied (the mock serves non-trivial coefficients).
A decode check needs no mock: a fixed raw buffer decoded against hand-computed volts.
Baselines are unchanged: the UDPS self-check reads as on `main`, exactly three build warnings,
no `Gtk-CRITICAL`.
Errors: a rejected handshake (bad connection code), no device found, timeouts and scan-socket
loss surface through `on_event(Error)` with a readable message and follow the reconnect cycle.
A rejected connection code stops the retries: the source stays in Error with that message until the user reconnects.
Docs: update `architecture.md`, `build-and-run.md`, `verification.md`, `probe-readings.md` and
`README.md`.
## Out of scope
- Software-paced acquisition (`ScanRate = 0`).
- A generator faster than 10 Hz, and a dedicated I/O thread.
- Changing DIO directions at runtime.
- Per-channel SE/DIFF mixing (one global mode).
- Verification against a real board.
@@ -0,0 +1,127 @@
# Source plugins: framework and UDPS port (sub-project 1 of 4)
Date: 2026-10-31
Branch: `feature/source-plugins`
## Context
udpscope reads exactly one kind of source, a UDPStreamer stream through `deps/udpsclient`.
The goal is to accept several kinds of data source as plugins, each with its own config
dialog and, where the hardware offers it, its own UI (signal generator, digital I/O,
multiple timebases).
The work is split into four sub-projects, each with its own spec, plan and implementation:
1. **Plugin framework + UDPS client ported onto it** (this document).
2. Flat UDP plugin (standard MARTe2 UDPWriter stream; the wire format must be supplied or
located before that spec is written -- none was found in `~/workspace` or `~/Projects`).
3. MCC E-1608 plugin. The protocol code in `MCC_E1608_DataSource` is bound to MARTe2
(`BasicTCPSocket`, `StreamString`, `SingleThreadService`); it is ported to plain C++, not
linked against MARTe2. AI streaming, DIO and DAC/generator panels.
4. ABScope plugin. Needs a multi-timebase data path and a wire format: the ABScope design
only sketches the UDP header and `streamerd` is unwritten.
Sub-project 1 changes no UDPS behaviour. It is a refactor plus the hosting machinery the
later plugins need.
## Decisions
| Question | Decision |
| --- | --- |
| What "plugin" means | Compiled-in registry; the interface is kept clean enough for a `dlopen` loader to be added later (not built now). |
| Threading | `Acquisition` keeps the single producer thread and calls `source->poll()`. UI actions reach the source through a thread-safe command queue drained inside `poll()`. CLAUDE.md's two-thread rule holds. (A per-source I/O thread was considered and deferred.) |
| Source UI | Widget factory: a source returns `GtkWidget*` for its config form and each capability panel. |
| Timebases in the frame | The neutral frame is single-timebase. Sub-project 4 extends interface and data path together; this may break the interface once, accepted. |
| CLI / sessions | `--server/--port/...` keep meaning UDPS; `--source NAME` and `--source-opt k=v` added. Sessions keyed by kind + identity; legacy `host_port.toml` read as `udps`. |
## Design
### 1. The source interface (new `src/source/`)
- **`SourceConfig`**: ordered string key/value map with typed getters. It is the stored form
(a TOML table), the `--source-opt` form, and what a config widget reads and writes.
- **`Frame`**: source-neutral, mirrors `udps_frame_t`: `counter`, `lost`, `late`,
`recv_time`, `publish_mode`, `num_samples`, `signals[]` (the existing `SignalInfo`) and
`values[]` (doubles). `signals` and `values` are pointers into the source's own buffers,
valid only for the duration of `on_data`; `Acquisition` copies what it keeps. The hole, backfill and time-base logic needs `counter/lost/late`; a
source with no counter supplies a running counter and `lost = 0`. `Frame::value(sig, k,
col)` replaces `udps_frame_value`.
- **`DataSource`**: the per-connection object.
- `open()`, `poll(timeout_ms)`, `close()`.
- `stats()` returns the counters now in `StreamStats`; `connected()` reports link state.
- `post(Command)`: thread-safe queue, drained inside `poll()`. `Command` is
`std::function<void()>`.
- `set_datagram_tap(fn)`: optional; lets UDP-based sources feed the raw-packet capture.
- Output goes to a `SourceSink` (`on_config`, `on_data`, `on_event`) called inside
`poll()`, as `udps_client` does today.
- **`SourceKind`**: factory and UI description of one plugin type: `id`, `display_name`,
`identity(cfg)`, `make(cfg)`, `make_config_form()` and `make_panels(source)` (each
`{id, title, GtkWidget*}`). `make_config_form()` takes no argument: the chooser calls
`form.write(cfg)` to fill the form. A `ConfigForm` is a list of `AdwPreferencesRow` widgets
plus `write` / `read` / `valid` / `on_change` closures, so the chooser can place the rows in
its own group and gate its Connect button on `valid()`.
- **`SourceRegistry`**: filled by an explicit `register_builtin_sources()` call. No
static-init self-registration (linkers can drop it). A future `dlopen` loader would only
call `register_kind()`.
### 2. Acquisition and the UDPS plugin
- `Acquisition` takes a `std::unique_ptr<DataSource>` instead of building a `udps_client_t`
and implements `SourceSink`; it creates the source lazily in `start()`, from the kind and
`SourceConfig` it was given; the four trampolines go away.
- `on_config` / `on_data` change only in the type they read. Time base, holes, backfill,
decimation, trigger and math code are untouched.
- `run()` calls `source_->poll(50)` and copies `source_->stats()` into
`ScopeModel::stats()`. `PacketCapture` stays in `Acquisition`, fed through the datagram
tap. Sources without datagrams (E-1608) leave the capture UI disabled, not hidden.
- `src/source/udps_source.cpp` wraps `deps/udpsclient` unchanged: it maps `udps_signal_t`
to `SignalInfo` and `udps_frame_t` to `Frame`, and carries the existing knobs (server,
port, multicast, interface, recv buffer). Identity is `host:port`.
- The `udps_*` types are used only in `acquisition.cpp` today, so no other file changes for
this step beyond the `Frame` type.
### 3. UI hosting, config and persistence
- **Connect dialog** (`source_dialog.cpp`): gains a "Source type" dropdown; choosing a kind
swaps in that kind's `make_config_form()`. The UDPS form (server, port, multicast,
interface) moves into the UDPS plugin. The dialog keeps the known-sources list, buffer
size, relative time and capture ring size.
- **Capability panels**: after the source is created the app calls `make_panels()` and adds
each panel as a page of the existing `side_stack` (the side area's page stack, owned by
the tab switcher). A panel is insensitive when
disconnected (disabled, not hidden; the tab switcher owns visibility). A source with no
panels adds no tab; UDPS has none. Panels call `source->post(Command)`, never the source
directly, so cross-thread safety lives in one place. A helper kit for common panels (DIO
bank, generator form) lives in `src/source/widgets/` but is only added when a plugin uses
it, so it arrives with sub-project 3.
- **Config / CLI**: `Config` gains `source_kind` (default `udps`) and a `SourceConfig`.
`--server/--port/--multicast/--interface` populate the UDPS entries. `--source NAME` and
repeatable `--source-opt k=v` work for any kind. `--help` lists the registered kinds.
- **Sessions**: the source's config is stored as top-level TOML keys, `source_kind` and
`source_opt_<key>`, written only for non-UDPS kinds (a UDPS session file is unchanged, so
legacy `host_port.toml` files read as `udps` with nothing migrated). It is not a `[source]`
table. A non-UDPS session file is named by a pseudo-host `<kind>-<identity>` with port 1.
- **Mocks**: `tools/mock_udpstreamer.py` remains the UDPS mock. Each later plugin ships its
own mock under `tools/` so the headless self-check keeps working.
## Verification
- This is a pure refactor for UDPS: the existing headless self-check must read exactly as on
`main` -- exactly three build warnings, no `Gtk-CRITICAL` (see `docs/claude/verification.md`).
- New probe `UDPSCOPE_PROBE_SOURCE_KINDS` (`UDPSCOPE_PROBE_SOURCES` is already the chooser
discovery probe). Its value is a delay in ms, and its presence also registers the stub
`probe` kind. It extends the probe/dump in `src/ui.cpp` rather than
adding a harness. It checks that the registry lists its kinds, that a config form
round-trips its `SourceConfig`, and -- with a stub test-only kind carrying one panel --
that the panel's tab is built, insensitive while disconnected, and that a posted command is
drained by `poll()`.
- Document the new probe lines in `docs/claude/probe-readings.md`; update `architecture.md`
(a `DataSource` replaces the client in the thread description), `build-and-run.md`
(new CLI options) and `README.md`.
## Out of scope
- Runtime `.so` loading, and a per-source I/O thread.
- Multi-timebase data path (sub-project 4).
- The flat UDP, E-1608 and ABScope plugins (sub-projects 2-4).
- The DIO / generator helper widgets (arrive with sub-project 3).
@@ -0,0 +1,63 @@
# E-1608 network scan -- design
Date: 2026-11-02. Status: approved in conversation, awaiting written-spec review.
## Goal
The E-1608 connection form gets a "Scan network" row that finds boards and fills the address or the
MAC from a click. Today a user must already know the address, or give a MAC and rely on a lookup.
## Finding (tested on a real board, 2026-11-02)
The board was at 169.254.88.75 on `net0`; the default route is `wlan0`. A `D` probe sent to
255.255.255.255 with no binding found nothing (it left through `wlan0`). The same probe sent with
`SO_BINDTODEVICE net0`, or bound to the interface address, or sent to a directed broadcast, or by
unicast, was answered (64 bytes, first byte `D`). So a single limited broadcast is not enough on a
host with several interfaces; the existing "find by MAC" lookup has the same flaw.
## Discovery layer (`e1608_link.{hpp,cpp}`)
- `class Scan` (non-blocking, one instance per scan): `Scan(port)` enumerates up, non-loopback IPv4
interfaces with `IFF_BROADCAST` (`getifaddrs`), opens one UDP socket per interface bound to it
(`SO_BINDTODEVICE`, falling back to binding the interface address if the call is refused), enables
`SO_BROADCAST`, and sends `D` to 255.255.255.255 on each. `poll()` drains every socket without
blocking and returns the devices found so far (E-1608 product id only, one entry per MAC, the same
reply parsing as `discover`). The caller owns the clock.
- `discover()` keeps its signature. A new overload or helper `discover_broadcast(port, wait_ms)` is
`Scan` plus a blocking wait. `E1608Source`'s lookup uses it when the subnet is empty; a given
subnet still uses `sweep_targets` + `discover` as today.
- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty
result.
## Form (`e1608_kind.cpp`)
- At the top of the Connection expander, an `AdwActionRow` "Scan network" with a text suffix button
"Scan" (widget name `e1608-scan`). The row subtitle reads "Scanning...", "N boards found" or
"No board found". No theme icons.
- A scan starts a `Scan` on the port in the Port field and a 50 ms `g_timeout_add` poll for about
1.5 s. There is no thread: the project has exactly two. The timer is removed when the form is
destroyed (`Form::alive` guard plus the destroy hook) and when a new scan starts.
- Each device found becomes an `AdwActionRow` in the Connection expander right after the scan row,
replacing the previous results. Title: IP. Subtitle: MAC. Activating the row fills Address and
clears MAC and Subnet. A suffix text button "Use MAC" (`e1608-use-mac`) fills MAC and clears
Address and Subnet. Fill is done under the form's `updating` guard, then `changed` fires once.
- Result rows are not read or written by the session: only the filled fields persist.
- The description line mentions the scan.
## Verification
- A scan probe (env var in the existing pattern): starts against `tools/mock_e1608.py` on a
loopback-reachable port, presses the scan button, and prints `[e1608] scan: found=N ip=... mac=...
fill_addr=1 fill_mac=1 ok=1`, covering both fills and that the results are replaced on a rescan.
If the sandbox refuses the broadcast, the line says so and the limitation is documented, not claimed.
- A manual run of the scan against the real board, recorded in the README.
- Baselines unchanged: 3 warnings clean, `gtk warnings: 0`, no `Gtk-CRITICAL`.
## Docs
`README.md` (E-1608 paragraph), `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`.
## Out of scope
Remembering scan results across runs; scanning other subnets than the host's interfaces (the
`subnet` sweep field remains for that); a worker thread.
@@ -0,0 +1,147 @@
# Flat UDP source plugin (sub-project 2 of 4)
Date: 2026-11-02
Branch: to be cut from `feature/source-plugins`
## Context
Sub-project 1 added the `DataSource` / `SourceKind` / `SourceRegistry` framework. Sub-project 3
added the E-1608. This sub-project adds `flatudp`: a receiver for the MARTe2 `UDPSender`
datasource (`MARTe2-components/Source/Components/DataSources/UDP/UDPSender.{h,cpp}`).
## The wire format
Read from `UDPSender::Synchronise()` and `MemoryDataSourceI`:
- One `Write` of the datasource memory per `Synchronise()`: **one datagram is one cycle**.
- The memory is the signals concatenated in declaration order, **no padding**, in the sender's
native byte order. `signalOffsets[s]` is the running sum of `size * elements`.
- There is no header, counter, timestamp or signal description. In IndependentThread mode the
first signal is a `uint8` `Trigger`; it is sent like any other signal.
- The user must therefore enter the exact signal list by hand.
## Decisions
| Question | Decision |
| --- | --- |
| Time base | The user declares the nominal cycle rate in Hz (`rate`, required). The existing pipeline stamps the frames: anchored at arrival, samples spaced by the declared cadence, corrected to the measured cadence when the arrivals are more than 25% off it; `--relative-time` gives a purely counted clock. There is no `time_mode` switch: `acquisition.cpp` has no stamping path that ignores the declared cadence, and `sampling_rate = 0` would size the ring and the ingest decimation for 1 kHz (`model.cpp:60`). Loss is never reported (`lost = 0`). |
| Signal list entry | A row editor: name entry, type dropdown, element count and a remove button per row, plus an "Add signal" row. |
| Network | Port and optional bind address, plus an optional multicast group and interface. No source-address filter. |
| Byte order | `little` (default) or `big`. |
| Out of scope | Auto-detecting the list, importing a MARTe2 `.cfg`, a source filter, loss detection. |
## Config keys
Persisted in the session, settable with `--source-opt`. Kind id: `flatudp`.
| Key | Default | Meaning |
| --- | --- | --- |
| `port` | 44488 | UDP port to listen on (the sender's own default). |
| `bind` | empty | Local address to bind; empty is any. |
| `multicast` | empty | Group to join; empty is plain unicast. |
| `iface` | empty | Local interface address for the join. |
| `rate` | none | Nominal cycle rate in Hz. Required; sizes the ring and the decimation and spaces samples within a frame. |
| `endian` | `little` | `little` or `big`. |
| `signals` | none | `name:type[count],...`, e.g. `Trigger:uint8,Ia:float32,Wave:float32[16]`. A persistence format only: the form builds and parses it. |
Types: `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`. Count 1 is a scalar signal; N
is a vector signal. Expected datagram size is the sum of `size * count` over the rows.
Validity (form and `--source-opt` alike): at least one signal; names non-empty and unique;
count 1-4096; total size at most 65507 bytes; `rate` finite and positive.
## Architecture
New files under `src/source/flatudp/`:
- `flatudp_signals.{hpp,cpp}` -- the `signals` string codec: parse, format, validate, size
arithmetic. No GTK, so a probe can round-trip it.
- `flatudp_source.{hpp,cpp}` -- the `DataSource`.
- `flatudp_kind.{hpp,cpp}` -- the `SourceKind`, the form, and the registration in
`register_builtin_sources()`.
Threading is unchanged: the source lives on the producer thread and `poll()` does all the work.
### Data flow
`poll(timeout_ms)` waits on a non-blocking UDP socket and drains it.
- A datagram of exactly the expected size is decoded row by row to doubles (byte-swapped when
`endian=big`).
- **Batching.** When every row is scalar, decoded datagrams are queued and delivered
`block` at a time in one `Frame` (`num_samples = block`), where
`block = clamp(round(rate / 100), 1, 1024)`, about 100 frames per second as the E-1608 does. A
partial block is flushed after 100 ms so a slow or stopped sender does not hold samples back.
When any row is a vector, a vector signal carries one sample per `Frame`, so each datagram is
its own frame (`num_samples = 1`).
- `Frame`: `counter` a running frame count, `lost = 0`, `late = false`, `recv_time` the arrival
clock at delivery.
- `set_datagram_tap` is honoured for every datagram, valid or not, and `has_datagrams()` is
true, so packet capture works.
- `on_config` is called from the first `poll()` with one `SignalInfo` per row (`rows = count`,
`cols = 1`, `time_mode = kTimePacket`, `sampling_rate = rate`).
### Errors and state
- A wrong-size datagram is dropped, never decoded. Each distinct size raises one `Error`
event ("datagram of N bytes, expected M, check the signal list") and increments `stale`.
- `Connected` is raised on the first valid datagram and `Disconnected` after 2 s of silence; a
later datagram raises `Connected` again.
- A bind or multicast-join failure raises an `Error` and the source stays disconnected.
### Connect form
Built from `AdwPreferencesRow`s, with the destroy-notify guard used by the E-1608 form.
- Network group: port, bind, multicast, interface.
- Timing: a rate spin.
- Byte order combo.
- Signals group: one row per signal, "Add signal" at the bottom, and a live subtitle with the
implied size ("41 bytes per datagram"). `valid()` is false while the list breaks a validity
rule.
- The `signals` string is rebuilt from the rows on every change; `write()` parses it back.
## Verification
No unit suite; the self-check is the contract.
- `tools/mock_flatudp.py`: emits packed structs for a given signal spec and rate; options for
big-endian and for one wrong-size datagram.
- `UDPSCOPE_PROBE_FLATUDP` in `src/ui_probe.cpp`: stream names and count; a hand-computed
decode of known values including a vector signal and a `uint64`; the declared rate reaching the model; the
wrong-size error reported once and counted in `stale`; disconnect after silence; the form's
validity rules; the `signals` codec round trip. A multicast loopback check runs if the
sandbox allows it.
- Baselines unchanged: exactly 3 build warnings, `gtk warnings: 0`, no `Gtk-CRITICAL`.
## Documentation
README (the kind and its limits: no loss detection, the signal list is the user's
responsibility), `docs/claude/build-and-run.md` (options and mock usage),
`docs/claude/architecture.md` (source plugins), `docs/claude/probe-readings.md` and
`docs/claude/verification.md` (the new probe lines).
## Amendment 2026-11-02: how a vector signal is shown (decided with the user during Task 2)
Task 2 found that treating every vector row as one channel of `count` consecutive samples zero-pads
the scalar channels of a mixed list (the model spans a frame by its largest element block, and
`Frame::value` is 0 past a scalar's one value) and declares the wrong rate for the vector channel.
Both readings of a vector are useful depending on the sender, so each vector row picks one:
- **`channels` (default).** `Wave:float32[16]` becomes 16 scalar channels named `Wave[0]` .. `Wave[15]`,
one sample per cycle each. All such lists are batched like an all-scalar list.
- **`waveform`.** `Wave:float32[16]w` is ONE channel receiving a block of `count` consecutive samples per
cycle: `SignalInfo{rows=count, cols=1, sampling_rate=rate*count}`, one datagram per `Frame`
(`num_samples = 1`).
Rules:
- The `signals` string takes an optional trailing `w` after the count; `format` emits it only for a waveform row.
- A waveform row needs `count > 1`.
- A list with a waveform row must contain only waveform rows, all with the same `count`; otherwise it is
invalid, with a message the form shows. (No zero-padding, no holding scalars across a block.)
- The channel total is capped at 256: scalars count 1, a `channels` row counts `count`, a waveform row counts 1.
Over the cap is invalid.
- `block` batching applies whenever the list has no waveform row; with a waveform row it is 1.
- The form shows a "Vector as" dropdown (`Channels` / `Waveform`) on rows whose count is above 1, and
the channel total in the subtitle.
- The earlier "Data flow" text saying every vector row forces `num_samples = 1` applies to waveform rows only.
@@ -0,0 +1,175 @@
# Badge bar, readouts dock and slide-over panel -- design
Date: 2026-11-02. Status: approved in conversation, awaiting written-spec review.
## Goal
Replace the sidebar's tab switcher and the separate events pane with a layout built the way bench
scopes build theirs: setup is reached by *the object it configures* (a badge under the plot), results
are always on screen (a compact dock), and anything bigger than a readout opens in one slide-over
panel. The sidebar tabs and the left events pane go away.
## Why (the problems, as found in the code)
- **Unreadable tab titles.** `side_view` is pinned at 320 px (`min-sidebar-width` = `max-sidebar-width`
in `main-window.ui`), so four text tabs ("Measure / Signals / Analog out / Digital I/O") ellipsize,
and a source can add more.
- **Hard to find things.** The Signals tab stacks a trigger form of up to 15 rows (plus two
expanders) on top of the signal list. Cursors and measurements are in Measure, source controls are
in their own tabs, the header is icon-only, and the trigger/cursor toggles live apart from their
panels.
- **The events pane is a different application.** A `GtkPaned` + `GtkRevealer` on the left with its
own bold title and header, a dragged width that is not saved, thumbnails the rest of the UI does
not have, and a header toggle that is hidden until events exist.
## State of the art this follows
- Tektronix 5/6 Series: a badge bar under the waveform (a badge per channel/math waveform, plus
Horizontal, Trigger, Acquisition badges); a tap opens *only that item's* settings; results
(measurements, cursors, search) are a separate bar that does not cover the waveform.
- Scopy and PicoScope 7: a channel list at the edge and a settings panel that slides in for the
selected item (PicoScope lets it overlay or squeeze the trace).
- Saleae Logic 2: one side panel at a time; decoded results also in a list beside the waveform.
- Keysight InfiniiVision: dialogs that can be undocked so cursor and measurement values show together.
## Layout
```
┌ header: [window 2 s ▾] [Trigger] [Pause] [Cursors] [Dock] [☰] ┐
├──────────────────────────────────────────────┬────────────────────┤
│ plot rows ┌───────────┐ │ READOUTS │
│ │ slide-over│ │ cursors, measures │
│ │ settings │ ├────────────────────┤
│ └───────────┘ │ EVENTS (n) │
├───────────────────────────────────────────────┴────────────────────┤
│ [●sine][●square][+] … [Trigger ▸][Source ▸]│
├ status line ───────────────────────────────────────────────────────┤
```
### Header bar
Keeps the time-window control, the Trigger, Pause and Cursors toggles and the overflow menu. The
Events toggle is removed (Events is always in the dock). The sidebar toggle becomes the **Dock**
toggle, so a narrow window can still hide the dock. There is no "Window" badge: the header's control
is the timebase, and a second one would duplicate it.
### Right dock ("Readouts" and "Events")
- Fixed width, about 280 px; always on screen, shown but insensitive until the stream has signals
("disable, do not hide"). It stays an `AdwOverlaySplitView` sidebar, which already gives it its
own fixed width, the hide toggle and the overlay behaviour on a narrow window
(`invariants-plot-and-layout.md`).
- **Readouts** (top): the active cursor values and measurement values, read-only, one compact line per
value, through the one formatter both panes share (`invariants-readouts-and-cursors.md`).
An "Edit" affordance opens the slide-over's `measure` or `cursors` page; clicking a measurement row
opens that measurement's editor.
- **Events** (bottom): compact rows -- title, time, a small marker; no thumbnails. Insensitive outside
trigger mode. A "Manage" affordance opens the slide-over's `events` page.
### Badge bar
A fixed-height row between the plot and the status line.
- **Signals** (left): one badge per enabled signal, up to 6; beyond that a single "N signals" badge.
A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which
replaces today's drag from the signal list), and opens that signal's page (colour, calibration,
row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor
entry).
- **System** (right): **Trigger** shows its state (off / armed / triggered) and opens the trigger
form; **Source** shows the connection state and the source name and opens that source's pages
(`SourcePanel` pages, behind a small segmented switch inside the panel when there are several, as
E-1608 has Analog out and Digital I/O).
### Slide-over panel
- Overlays the plot's right edge (about 360 px wide), never the dock; one page at a time; Esc or a
click outside closes it. The plot is not resized by it.
- Pages, by id: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`,
`source-<id>`. Each page has its own scroller, so a tall page does not leave a shorter one scrolled
(the reason the sidebar had a scroller per page).
- Content: the trigger form (conditional rows, Advanced, Search the buffer), signal and calibration
settings, measurement add/edit/remove and statistics, cursor options, event actions (Live, Compare,
Accumulate), the math editor entry, and source pages.
### Status line
Unchanged.
## Components
New files, because `ui.cpp` is already very large.
| Unit | File | Responsibility |
| --- | --- | --- |
| `BadgeBar` | `ui_badges.cpp` | Builds signal / Trigger / Source badges from the model; emits "open page X"; owns badge drag. |
| `SlideOver` | `ui_slideover.cpp` | A `GtkOverlay` child of the plot area: revealer + page stack keyed by id; Esc/click-away; open/close API. |
| `ReadoutsDock` | `ui_dock.cpp` | The compact readouts and the compact events list, reading the same model the panes read today. |
Reuse, not rewrite:
- The trigger form, signal list and calibration, measurement editor and cursor options are
**reparented** into slide-over pages.
- `SourcePanel{id, title, widget}` is unchanged, so the E-1608 and Flat UDP panels need no change.
- Only the events *list and actions* of the old pane move; the left `GtkPaned`/`GtkRevealer` and its
divider sync are deleted.
## Invariants kept
- **Layout must not depend on content** (`invariants-plot-and-layout.md`): the dock has a fixed width,
the badge bar a fixed height, and the slide-over overlays instead of resizing the plot. Rows that
appear by trigger condition appear inside the slide-over's scroller.
- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget
aligned to the end edge by margin and width, never a full-area transparent child, so pan, drop and
hover on the rest of the plot are untouched.
- The header's Trigger/Cursors/Pause toggles keep their two-way mirroring with the panels.
## State and session file
- The session file drops `side_tab` and gains the dock's shown state and width. An old file's
`side_tab` is ignored (an unknown name is already harmless to `select_side_tab`).
- The open slide-over page is not saved.
- The tab ids `measure`, `signals` and `source-<id>` stop being a session format; they remain page ids.
## What must be rewritten around it
- **Tour and context help:** steps and help-figure surfaces that point at `side_switcher`,
`side_stack` and `side_box` (`ui_tour.cpp`, `ui_context_help.cpp`) are re-pointed at the badge bar,
slide-over and dock, and the tour text is rewritten.
- **Probes:** the `switcher: policy=... pills=... labels=... images=... pages=...` line and
`side_tab_probe` become probes that count badges, dock rows and the open slide-over page; the events
divider probe goes away with the divider.
- **Keyboard:** `SideTabMeasure` / `SideTabSignals` become "open the measure / signals page".
- **Docs:** `README.md`, `USER-GUIDE.md`, `docs/claude/*` (architecture, verification,
probe-readings, the plot-and-layout and gtk-and-chrome invariants), and the Mallard manual's
figures.
## Rollout
Four steps, each its own plan, each leaving the app building, passing the probes and usable.
1. **Shell.** The plot overlay, the badge bar with Trigger and Source, the slide-over holding the
existing trigger form and source pages. The sidebar tabs are removed; the existing Measure and
Signals panels move into a temporary dock so nothing is lost.
2. **Signal badges.** Per-signal badges, the `+` and "N signals" overflow, drag to reorder and to
move to a row.
3. **Compact readouts.** Read-only cursors and measurements in the dock; their editors in the
slide-over.
4. **Events.** The compact events section in the dock, the left pane and divider deleted, then the
tour / help / docs sweep.
## Risks
- **Overlay above the GL plot.** The panel must composite and take input correctly above the
plotter's GL area. The layout editor already places overlay children over the plot, so this is
expected to work; step 1 confirms it before anything else is built on it.
- **Reparenting the trigger form.** Its two-way mirroring with the header toggle, `sync_trigger_rows`
and the locked summary view must keep working from a hidden-by-default page. Step 1 verifies it with
the existing trigger probes.
- **Many signals.** The badge bar's six-badge limit and horizontal behaviour are the one place the
E-1608 (up to about 17 signals) stresses the design; step 2 includes a run on it.
## Out of scope
User-arranged or undockable panels, saved layouts of the panels themselves, a touch-specific mode,
and any change to the plot, trigger, measurement or source engines. Per-source tab names do not
change.
@@ -0,0 +1,92 @@
# Horizontal plot split -- design
## Goal
Let each row of the stacked plots be split into several plots side by side. All plots share one
time window (one x link, one zoom/pan, one crosshair). A split only arranges more plots on screen; it
is not an independent time window and not an XY plot.
## Decisions
1. **Shared time window.** Cells in a row, and rows in the figure, are all members of the one x link
group. No new time semantics.
2. **Per-row split.** Each figure row holds 1..4 cells, independently of the other rows.
3. **Draggable cell widths**, saved with the session, mirroring row heights (`weight`).
4. **Caps.** At most 4 cells per row and at most 8 plots in total (`RowSet::kMaxRows` keeps its value
and now bounds plots, not figure rows). Every per-plot structure sized for 8 stays as is.
5. **Approach.** Extend the vendored plotter's `Figure` so a figure row holds several plots; the host
treats each cell as one plot. Rejected: several `Figure`/`PlotArea`s linked by hand (cannot express a
per-row split, reintroduces manual x/crosshair sync) and a general tiling tree (not asked for).
## Plotter (`deps/plotter`)
- `Figure` is constructed from cells-per-row (e.g. `{1,2,1}`). `Figure(u32 rows)` stays and means one
cell per row, so existing behaviour and `tests/test_figure.cpp` are unchanged.
- `plot(i)` indexes the flattened cells, top to bottom, left to right. `sync_x_links()` and the
crosshair already work on any set of plots and need no change.
- New: `set_cell_weights(row, weights)` and `set_col_gap(px)`. Widths use the height scheme: a floor
per cell, the weights share the rest, the last cell closes the row exactly (no seam).
- `FigureLayout` gains `cells` (one `Rect` per plot, flattened). `rows` stays the full-width rect per
figure row.
- Only the bottom figure row's cells carry x labels and the x title; other cells get `XStrip::None`.
- The left y gutter stays one common maximum for the whole figure with the same reserved floor. A
per-column gutter would make cell width depend on content, which the layout rule forbids.
- A figure too narrow for the floor widths shrinks the cells proportionally and drops the column gap,
as heights do; no rect is ever negative.
- Pointer hit-testing finds the plot under the cursor by its cell rect.
- The change is documented in `docs/claude/build-and-run.md` with the other vendored changes.
## Host (`src/plot_host.*`, `src/ui_layout.cpp`, `src/layout_store.*`, `src/ui.cpp`)
- Everything keyed by a plot index keeps meaning "flattened plot index": cursors, measurements,
`bind()`, readouts, persistence layers, `cursors_pane_bound`.
- `RowSet` gains `cells_per_row_` and per-figure-row cell weights, re-applied after every rebuild like
`row_weights_`. `split_cell(i)` / `remove_cell(i)` change a row's cell count; a removed cell drops its
traces, a new cell starts empty. `insert_row` / `remove_row` act on figure rows and carry their cells.
- `row_count()` stays the plot count; `figure_row_count()` is added. The plan may rename if this reads
badly.
- Cursors: the vertical pair is one shared time drawn in every cell; horizontal rulers are per cell.
The crosshair is one x on every cell, the hovered cell shows its y.
- Y axes are per cell (auto or manual); fit-all fits each; wheel and axis drags act on the cell under
the pointer; an x gesture anywhere moves the shared window.
- A signal dropped on a cell lands in that cell with the same slot rule as a row. The signals page's
"place on row" choice lists cells ("row 2, left") only for split rows.
- Persistence layers fill free slots of the same cell.
## Layout editor
Mirrors the row controls:
- Each vertical boundary between two cells gets a `+` (insert a cell) and a drag arrow (resize the two
neighbours). Each row's left and right edge gets a `+`, so a one-cell row can be split from either side.
- Each cell keeps its top-left `×`: in a multi-cell row it removes the cell; in a one-cell row it
removes the row, as today. Insensitive when it would leave no plot.
- Cell `+` is insensitive at 4 cells in the row or 8 plots in total; the row `+` is insensitive at 8
plots in total.
- A divider drag moves the two neighbouring widths, each floored at about 96 px logical, relative to the
weights at drag start (as the row drag is). Saved on drag end.
- Outside the editor nothing changes except the extra plots.
## Session file
`[[plot]]` tables gain `row` (the figure row) and `width` (the cell's weight). A session without `row`
loads as one cell per row, so existing sessions open unchanged. Row `weight` stays per figure row (carried
on the row's first cell).
## Verification
- `tests/test_figure.cpp`: `{1}` identical to today; `{1,2}` tiles exactly; weights and floors respected;
a narrow figure degrades without negative rects; x link and crosshair span all cells.
- New headless probe `UDPSCOPE_PROBE_SPLIT_MS`: split a row, drop a signal in each cell, drag a divider;
check the cells tile the width, the shared x range is identical, both vertical cursors line up in every
cell, widths survive a session round-trip, and a session without `row` still loads. Expect
`gtk warnings: 0`, no `Gtk-CRITICAL`.
- Regression: layout, cursors, measurements, readouts and E-1608 probes read as before; the baseline file
is re-baselined only where this feature explains a difference.
- Layout dumps and probes that print per-row layout gain a cells list and a column-weights line.
## Docs
`README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`,
`docs/claude/verification.md`, `docs/claude/invariants-plot-and-layout.md` (cell rules),
`docs/claude/build-and-run.md` (plotter change), and `ui_context_help.cpp` where the layout editor is described.
@@ -0,0 +1,80 @@
# Split rows share one y axis -- design
Amends `2026-11-03-horizontal-split-design.md`. Where the two disagree, this one governs.
## Goal
The cells of a split row share one y range and draw one y axis, the way the rows of the figure share
one x window and draw one x axis. A split row reads as one wide plot with dividers.
## Decisions
1. **Always shared, no option.** All cells of a figure row have one y range. An unsplit row is
unaffected.
2. **One y axis per row.** Only the leftmost cell has a y gutter and y labels. The other cells have
none, as only the bottom row carries x labels.
3. **Rulers are per row.** The two horizontal rulers (cursor pair) belong to the figure row and are
drawn at the same y in every cell. A drag in any cell moves them.
4. **Approach.** Extend the vendored plotter with a y link, symmetric to the x link. Rejected: syncing
the ranges from the host every frame (the plotter's own per-plot autoscale and the controller's
gestures would fight it, which is the problem the x link already solves).
## Plotter (`deps/plotter`)
- `Figure::set_share_row_y(bool)`, default off so existing figures and `tests/test_figure.cpp` are
unchanged. When on, the cells of each figure row form an implicit y link group. The group is rebuilt
with the cells.
- A y link syncs like the x link: in `layout()` a new `sync_y_links()` adopts a member's changed
`y_range` as the group range and pushes it to the other members. Wheel, pan, box zoom and axis drag
go through `set_y_range`, so they need no controller change.
- Auto scale: a member's own autoscale is suppressed (as `Axis::link_member` already blocks `Live`).
The group owns the auto flag and fits to the union of its members' visible traces; cells with no
trace are ignored; a log axis takes the union in axis space. `fit_all` and double-click fit the
group, not one cell. A manual `set_y_range` on a member clears the group's auto flag.
- `YStrip {Both, None}` and `Plot::set_y_strip_mode`, mirroring `XStrip`. `None` forces the y label
width, title width and reserved floor to 0. `Figure::layout` sets `None` on every cell but the
first of its row, structurally from the column index, never from content. `None` cells are skipped
in the common-gutter maximum.
- Cell widths: the row's y gutter is taken off the row width first, then the cells share the rest by
the existing `floor + extra*w/total` rule. Equal weights therefore give equal plot areas; the first
cell's rect is gutter plus its plot width, and the cell rects still tile the row exactly. The
floors apply to plot widths.
- A y axis-drag can only start in the leftmost cell (the others have no label area).
- Documented in `docs/claude/build-and-run.md` with the other vendored changes.
## Host (`src/plot_host.*`, `src/ui*.cpp`)
- `set_y_auto`, `set_y_range`, `fit_all`, and the Auto/Fixed dialog act on the figure row of the plot
they are given. The dialog reads the row's range.
- Rulers: one pair per figure row, stored on the row's first plot index and placed in every cell by
`place_cursors`; `sync_cursors` reads the dragged one back; `reset_cursors` and the measure level
marks use the row's range. The cursors pane lists the pair once per figure row.
- `row_unit` joins the units over all cells of the row, "" if mixed.
- `sync_compare_axes` is unchanged: it writes x and y once per `RowSet` row. The comparison panes are flat single-cell rows, so that is once per figure row and no y sharing is involved.
- `rebuild_all` turns the sharing on for the new `Figure` before the cells are populated.
- Readouts and measurements stay per trace and per plot index; only the axis is shared.
- Session file: unchanged (y ranges were never persisted).
## Layout editor
Divider drag maps the pointer to weights through plot widths, not cell widths: for the first cell,
subtract the row's gutter from the left rect before applying `frac = (want_left-64)/(pair-128)`. The
96 px logical floor applies to the plot area. Nothing else changes.
## Verification
- `tests/test_figure.cpp`: with sharing on, a `{2}` row has equal y ranges after a `set_y_range` on
either cell; a second cell has zero y gutter and the first the common maximum; widths tile exactly and
equal weights give equal plot widths; group autoscale covers the union and ignores empty cells; sharing
off reproduces today's layout; `{1}` rows unaffected.
- Split probe gains: after a y gesture on one cell the other's range is identical; non-first cells have
no y gutter; fit-all covers both cells' traces; both rulers line up across cells; a divider drag still
tracks the pointer within 1 px.
- Regression: layout, cursors, measurements, readouts, E-1608 probes read as before; re-baseline only
what this feature explains. `gtk warnings: 0`, no `Gtk-CRITICAL`.
## Docs
`README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`,
`docs/claude/invariants-plot-and-layout.md` (the "one gutter, not per column" rule and the per-cell y
rule), `docs/claude/build-and-run.md`, `ui_context_help.cpp`, `CLAUDE.md` if it mentions per-cell y.
+90
View File
@@ -0,0 +1,90 @@
<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 header bar, or <key>c</key>. It switches the cursors on
and off.</p>
</item>
<item>
<p>The readings are in the dock (a read-only copy) and on the <gui>Cursors</gui> page, which
<gui>Edit</gui> beside <gui>Cursors</gui> opens over the plot and which owns the mode
and 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. The plots of a split row are one row here: they share their y axis, so the
rulers are at the same values in each.</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: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+67
View File
@@ -0,0 +1,67 @@
<page xmlns="http://projectmallard.org/1.0/"
type="guide" style="task"
id="index">
<info>
<desc>udpscope plots a live UDPStreamer source as one figure of up to eight stacked
plot rows sharing a single time axis, with a trigger, cursors, measurements, math
channels and an export. This manual covers connecting, the window and its panels,
navigating and pausing the plot, the trigger and its event cache, analysis, and
saving and exporting.</desc>
</info>
<title>udpscope</title>
<section id="main">
<title>Getting started</title>
<list>
<item><p><link xref="overview">Overview</link> — what udpscope is, how it reaches a
source, the window and its panels, and a first five minutes.</p></item>
<item><p><link xref="source">Choosing a source</link> — the command line, the
chooser, and opening an exported waveform instead of a live stream.</p></item>
<item><p><link xref="signals">Signals and calibration</link> — how a signal becomes
a channel, what starts acquisition, and how a scale and offset are applied.</p></item>
<item><p><link xref="layout">Rows, layout and drag &amp; drop</link> — up to eight
rows, four traces each, and the layout editor.</p></item>
</list>
</section>
<section id="viewing">
<title>Viewing</title>
<list>
<item><p><link xref="window">Time window</link> — the one control that changes the
window, and the decimation a long window causes.</p></item>
<item><p><link xref="navigate">Navigating the plot</link> — panning, zooming and
fitting with the mouse and the keyboard.</p></item>
<item><p><link xref="pause">Pause and play</link> — freezing the display without
stopping the stream, and how long you have been looking.</p></item>
<item><p><link xref="trigger">Trigger</link> — the four trigger shapes, arming,
hold-off, the event cache, and searching the buffer.</p></item>
</list>
</section>
<section id="analysis">
<title>Analysis</title>
<list>
<item><p><link xref="cursors">Cursors</link> — the shared vertical pair and the
per-row horizontal rulers.</p></item>
<item><p><link xref="measurements">Measurements</link> — a statistic of the visible
window, its on-plot marks, and its statistics line.</p></item>
<item><p><link xref="math">Math channels</link> — building a derived channel from a
graph of source nodes, operations and one output.</p></item>
</list>
</section>
<section id="output">
<title>Output and state</title>
<list>
<item><p><link xref="export">Saving and export</link> — the visible range to
<file>.npy</file>, <file>.csv</file> or <file>.mf3</file>.</p></item>
<item><p><link xref="sessions">Sessions and settings</link> — the per-source
configuration files and what they carry.</p></item>
<item><p><link xref="status">Status bar and diagnostics</link> — the glance line, the
ⓘ popover, and the activity badge.</p></item>
</list>
</section>
</page>
+99
View File
@@ -0,0 +1,99 @@
<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, plots and traces are arranged, how to drop a signal onto a plot, and
how the layout editor adds and removes rows and splits a row into plots side by side.</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 holding 1 to 8 plots in all, and a row can be split into up
to 4 plots side by side. Each plot holds up to 4 traces. Every plot shares one time axis and
the figure has one pair of side margins, so the y labels line up, the data areas start and
end on the same two columns, and the 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 badge from the badge bar onto 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 plot 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="split">
<title>Splitting a row</title>
<steps>
<item>
<p>In the layout editor, click the <gui>+</gui> at a row's left or right edge, or the
<gui>+</gui> between two plots, to add a plot to that row. The new plot starts empty.</p>
</item>
<item>
<p>Drag the double-headed arrow on the boundary between two plots to resize them. Neither plot can be
made narrower than about 96 px, and the widths are saved with the session.</p>
</item>
<item>
<p>Click the <gui>×</gui> of a plot in a split row to remove that plot and its traces.
In a row with a single plot the <gui>×</gui> removes the row instead.</p>
</item>
</steps>
<p>All the plots of a row, and of the whole figure, share one time window: zooming or
panning along time in any of them moves all of them, and the vertical cursors are drawn in
every plot. The plots of a split row also share one y range: the y labels are drawn at the
left of the first plot only, the horizontal cursors sit at the same values in every plot, and a
zoom, pan, fit or fixed range in any of them applies to the whole row. The time labels
sit under the bottom row, and under any row above it whose columns differ from the row below. A row holds at most 4 plots and the figure at most 8
in total; the <gui>+</gui> buttons go grey at the limit.</p>
</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 (for the whole row), 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 plot the drop is refused.</p></item>
<item><p>Inserting a row moves the rows below it <em>with their contents</em>, split
rows included. 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 split row's plots are named
<em>2a</em>, <em>2b</em> and so on.</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 7 buttons: the <gui>×</gui>,
the two <gui>+</gui> above and below the row with their two resize arrows, and the two
<gui>+</gui> at the row's left and right edges. Clicking the <gui>+</gui> above row 1 gives 2 rows and 12 buttons,
with the three traces now on row 2 — the layout probe reports exactly this as
<code>rows=2 buttons=12 traces=[0:-|1:sine,ramp,noise]</code>.</p>
</section>
</page>
+112
View File
@@ -0,0 +1,112 @@
<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>Open the <gui>Signals</gui> page (the <gui>+</gui> badge, or <key>g</key>). Math channels
are listed under a <gui>MATH</gui> heading.</p>
</item>
<item>
<p>The heading's <gui>+</gui> adds a channel. <gui>Edit graph</gui>, on a channel's page,
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 is listed with the signals, gets a badge once computed, 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>
+159
View File
@@ -0,0 +1,159 @@
<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 one signal over the window the pane is about — the
visible range, or the frozen frame while a view is pinned — 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 one signal over the window the pane is about, recomputed as
that window moves. The pane lists them with their values; each can also draw its result on
the plot.</p>
<p>That window is the <em>visible range</em> — so on a live view the numbers follow what you
pan and zoom to. On a <em>still frame</em> — a held event, a paused stream, an opened file — it
is the frame's own extent instead, so panning or zooming inside it changes nothing: the data
being measured has not changed and you are only looking closer.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Press <key>m</key>, or <gui>Edit</gui> beside <gui>Measurements</gui> in the dock. Either opens the
<gui>Measurements</gui> page over the plot; the dock itself shows each measurement's value,
and a click on one opens its dialog.</p>
</item>
<item>
<p>The page's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
the type, the threshold share or shares 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, above,
below, between, 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>above</gui>, <gui>below</gui> and <gui>between</gui> are the opposite kind of
number: the plain <em>mean of the samples</em> above a share, below it, or between two
shares. That is a different question from <gui>top</gui>/<gui>bottom</gui> even about the
same split, and it is the older one — on a unit sine <gui>above 50%</gui> reads 0.64 V
(2/π, the mean of a half cycle) where <gui>top</gui> reads 0.96 V (near the peak, which is
where a sine dwells most), and on a band-limited square <gui>above 50%</gui> reads 0.99 V
where <gui>top</gui> reads the plateau, 1.00 V: the edge's ring is inside the first and not
inside the second. <gui>between</gui> takes <em>two</em> shares — <gui>from %</gui> and
<gui>to %</gui> — and reports the mean of the samples inside that band, which no single
split can answer; the order you type them in does not matter, since a band between 80 % and
20 % is the band between 20 % and 80 %.</p></item>
<item><p>A mean over a side carries whatever is on that side, so these three are the right
numbers when you want the <em>energy</em> or the <em>offset</em> of part of the waveform,
and the wrong ones when you want the level a plateau sits at. Like every other measurement,
one with nothing to measure reads <code>--</code> rather than a made-up number: a share at
100 % has no sample above it, and a band can hold none at all — on an ideal
(non-ringing) square every sample is exactly ±1, so a band between 50 % and 75 % is
empty.</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, and
<gui>between</gui>'s second share is one more level in the same summary walk rather than a
walk of its own.</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. On a
still frame a pan or a zoom changes nothing for the history to record in the first place —
the values do not move there at all.</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>
<p>The mean-over-a-side types on the same two windows: on the sine
<code>above 50=0.636620</code>, <code>below 50=-0.636620</code> and
<code>between 50 75=0.255873</code>; on the rippled square <code>above 50=0.986496</code> and
<code>between 50 75=0.302744</code>, against <code>1.000000</code> for the same
<code>above 50</code> on an ideal (non-ringing) square. A <code>ramp</code>, whose density is
uniform, is the shape that names what a band mean <em>is</em>: <code>above 50=0.500000</code>
and <code>between 50 75=0.250000</code>, each band's own centre.</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>
+227
View File
@@ -0,0 +1,227 @@
<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 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-samples</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 badge bar and the status bar under them, and
the dock on the right.</desc>
<media type="image" mime="image/png" src="figures/main-window.png" width="1280" height="800">
<p>One figure of three rows with three signals plotted, a badge for each in the badge
bar, the dock with its readouts and its events block, 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
empty badge bar is working correctly, not failing to connect. For the same reason a start
that has enabled nothing prints no <code>buffer allocated:</code> line on stderr.</p>
<note style="warning">
<p>Over SSH with X11 forwarding the window may never appear, and the cure is
<cmd>udpscope --renderer cairo</cmd>. Two symptoms tell the two halves apart: a
<code>vkCreateSwapchainKHR() ... VK_ERROR_INITIALIZATION_FAILED</code> warning is
GTK's default Vulkan renderer failing to make a swapchain and is what the option's
<code>GSK_RENDERER=cairo</code> answers; a window whose allocation stays zero
(<code>window_button_w=0</code>) means the plot's GL area has no context either, and
that is what its <code>LIBGL_ALWAYS_SOFTWARE=1</code> answers. Both variables are
exported before GTK initialises, because they are read when the renderer and the GL
context are created. <cmd>--renderer gl</cmd> is faster when indirect GL works.</p>
</note>
<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; then, on
the right, the cursors switch, the dock switch and the menu, which holds
<gui>Fit to data</gui>, the <gui>Layout editor</gui>, <gui>Export visible range</gui>,
<gui>Configurations</gui>, <gui>Help</gui>, <gui>Preferences</gui> and
<gui>About</gui>. The trigger button turns trigger mode on and off; the trigger's
settings are in the panel its badge opens.</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 badge bar</title>
<p>The strip under the plot. On the left, one badge for each signal you have switched
on, then a <gui>+</gui>; past six signals they fold into a single <gui>N signals</gui>
badge. On the right, <gui>Trigger</gui>, which names the trigger's state, and <gui>Source</gui>, which is there only when the
source has controls of its own. A badge opens its page in the panel described next;
a signal's badge is also what you drag onto a row.</p>
</item>
<item>
<title>The side panel over the plot</title>
<p>One panel, 360 px wide, slides over the plot's right edge and shows one page at a
time: the trigger, a signal's colour, calibration and rows, the list of signals to
add, the cursors, the measurements, the events, or a source's own controls. It closes
with a second click on the badge that opened it, with <key>Esc</key>, with
<gui>Close</gui>, or with a click on the plot, and it never resizes the plot.</p>
</item>
<item>
<title>The dock</title>
<p>The column on the right, which the dock button in the header bar (<key>s</key>)
shows and hides. Its <gui>Readouts</gui> are a read-only copy of the cursors and of
every measurement; <gui>Edit</gui> beside each heading opens that instrument's full
page. Under them, <gui>Events</gui> lists the captured events, newest first; a click
replays one and <gui>Manage</gui> opens the full list. While the dock is hidden, or the window is narrow and the
dock slides over the plot, the readouts show in a strip under the plot instead, as long as the cursors are on or there is a measurement.</p>
</item>
<item>
<title>The status bar</title>
<p>The last line, under the badge bar — 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 capture looks like: the trigger marker at the edge
and the axis pinned to the event.</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 <gui>Signals</gui> page (the <gui>+</gui> badge, or <key>g</key>). 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 its badge 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> — press <key>m</key>, or <gui>Edit</gui> beside
<gui>Measurements</gui> in the dock. A measurement is a statistic of the visible
window, its value is shown in the dock, 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>
+63
View File
@@ -0,0 +1,63 @@
<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 header bar, 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. A measurement goes on describing
that frozen frame rather than the part of it you are looking at, so a zoom on a pause
changes no reading — see <link xref="measurements"/>.</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>samples per signal</gui> (<cmd>--buffer-samples</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 — and the startup settings: samples
per signal, 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>
+79
View File
@@ -0,0 +1,79 @@
<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>Click the <gui>+</gui> badge in the badge bar, or press <key>g</key>. The
<gui>Signals</gui> page lists what can be added — the signals the source declares that
are not acquired yet, each with a tick that acquires it. Past six acquired signals the
badges fold into one <gui>N signals</gui> badge, and the page then lists every signal.</p>
</item>
<item>
<p>Once a signal is acquired its badge appears. Click it to open that signal's page: its
details directly under its name — <gui>Unit</gui>, <gui>Gain</gui> and <gui>Offset</gui>,
each with a pencil button that edits that one value, then the type, size, range, sample
rate and timing the stream declared — its colour and <gui>Plot on</gui> buttons for the
rows.</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>A signal's page shows the gain and offset it is <em>currently</em> drawn
through, so a trace already scaled by a gain of 2 reads 2. The pencil beside a number opens
an editor for that number only, at its current value —
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
calibrated shows the identity, 1 and 0. The unit can be renamed the same way (an empty
entry restores the one the stream declares); the new name is used in the readouts, the
measurements and the export, and is remembered with the session.</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 dot on its badge shows it. A signal on no row gets a
grey dot rather than none.</p></item>
<item><p><em>Change a signal's colour</em> with <gui>Change colour</gui> on its page; a
signal on no row has no trace to colour, so the button is dimmed. The colour is saved
with the session; <gui>Default colour</gui> on the same page 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>samples per signal</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 at the foot of the window, under the badge bar. 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>
+288
View File
@@ -0,0 +1,288 @@
<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, forcing an acquisition 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>Click the <gui>Trigger</gui> badge at the right of the badge bar to open the
trigger page: the signal, the level, the shape, the edge (rising, falling, either), the
mode (Normal or Single) and the pre-trigger share. The trigger button in the header bar
(<key>t</key>) turns trigger mode on and off; it does not open the page.</p>
</item>
<item>
<p><gui>Arm</gui> starts watching; <key>a</key> arms and disarms from the keyboard.
When the page is locked with its edit 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 page. 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>Force</gui> takes one acquisition <em>now</em>, with the event window above,
without waiting for the condition to hold — for when the event you are waiting for is not
coming and you want to see what the signal is doing this instant. The capture runs out its
post-trigger share and then freezes, exactly like a latch: the marker sits at the instant
you pressed. In <gui>Normal</gui> mode the trigger goes back to waiting afterwards; in
<gui>Single</gui> mode it disarms, because one acquisition is the whole of what that mode
means either way. It needs a signal to be acquired — the trigger cannot watch one that is
switched off — and pressing it while a capture is already filling leaves that capture
alone, since it is the acquisition you asked for.</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="multiple-events">
<title>Multiple events: keeping the last few acquisitions</title>
<p><gui>multiple events</gui> decides how many of the most recent triggered acquisitions stay on
screen at once. It is <em>1</em> by default, which is the event alone — the display you get
when you ask for no persistence — and it can be raised to 8. Each older acquisition is drawn
at half the brightness of the one before it (1, 0.5, 0.25, 0.125, …), and they are all aligned
on their own trigger edge, so the pre-trigger part of the event lines up from acquisition to
acquisition and whatever changes between them — jitter, ringing, the occasional glitch — shows
as a faint ghost behind the current trace. That is what a phosphor display does with
successive sweeps.</p>
<p>The older acquisitions are drawn <em>behind</em> the current one and nothing else changes
because of them: the axis still frames the event on screen, the trigger marker still marks
<em>its</em> edge, and the measurements still describe the current frame. Lowering
<gui>multiple events</gui> back to 1 remembers nothing and frees the retained acquisitions
immediately.</p>
<p>Two things bound how many are drawn, and the panel tells you when one of them bites.</p>
<list>
<item><p><em>Room in the plot.</em> Each older acquisition is an extra trace in the row that
plots its signal, so it needs a free trace slot there. A row holding four signals — the
layout the app starts with — has room for one older acquisition of each of them; a row per
signal has room for all seven.</p></item>
<item><p><em>Memory.</em> A retained acquisition is a whole window copied out of the ring,
so persistence may hold at most as much again as the buffer takes (see <cmd>--buffer-samples</cmd> and <cmd>--memory-limit-mb</cmd>).
On a long window at a high sample rate that bound is reached before the eighth layer.</p></item>
</list>
<section id="persistence">
<title>Persistence: how often the trace has been here</title>
<p><gui>persistence</gui> — the switch in the trigger panel's <gui>Advanced</gui> block — keeps
an <em>image</em> of the last acquisitions instead of their traces. Every acquisition is
accumulated into it, the one on screen included, so a cell's brightness means how many of them
passed through it: the sweep on screen is the brightest, the ones behind it add on top, and
where several agree the ink is brighter still. There is no stroke drawn over the image — the
acquisition on screen <em>is</em> its newest ink — so it fades with the rest in both modes. Each
signal is kept as its own image in that signal's own colour, so where two signals cross the same
place you see both, not one blended line. It is off by default.</p>
<p>Two things make it different from <gui>multiple events</gui> beside it, and the panel offers
both because they answer different questions. <em>It never runs out of room</em>: each signal's
image is sized to the plot on screen and capped, so a display of a thousand acquisitions costs
what a display of one costs — one image per signal, whatever the plot's trace slots, and a very
wide window does not make it grow — and there is no count to choose. <em>And it fades</em>, in
one of two ways — <gui>fades by</gui> picks them:</p>
<list>
<item><p><gui>acquisitions</gui>: every acquisition keeps
<gui>per acquisition</gui> of what was there before it (0.5, the default, is the schedule a
phosphor's afterglow has: 1, 0.5, 0.25, …). The display then shows the last few sweeps
whatever the trigger rate.</p></item>
<item><p><gui>time</gui>: the image fades by <gui>time constant s</gui> as the display runs —
one constant is the time for it to fall to a third of its ink — so a slow trigger leaves
faint sweeps and a fast one leaves bright ones, and the picture is the same whichever the
stream is doing.</p></item>
</list>
<p>Persistence is a <em>trigger mode</em> display, like the trigger itself: it is drawn while a
captured acquisition is on screen and goes away with the trigger, on a pause, and when you open
a stored waveform. Nothing about it moves the framing — the axis is still the current event's,
the older sweeps only add ink — and it reserves no space in the plot and cannot be clicked, so
it is never in the way of a cursor or a drop.</p>
<p>Both displays live in the trigger panel's <gui>Advanced</gui> block, together with the
trigger's other settings that are set up once and left alone.</p>
</section>
<p>If a depth cannot be shown in full, a notice says how many layers fit, and the panel's
tooltip carries the same rule. Persistence is a display of what has already been captured: it
changes neither what is acquired nor what is measured, and it does nothing while the trigger
is off.</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> block at the foot of the dock lists them, newest first, one
short row each: a marker for the event on screen, its title and its number. It is dimmed
until recording is on, an event is stored or one is being replayed. <gui>Manage</gui>
beside its heading opens the <gui>Events</gui> page over the plot, which keeps the last 32
(time, duration, signals, rate), each row with its own picture beside the numbers.</p>
<p>Clicking a row in the dock <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. <gui>Live</gui>, on the
<gui>Events</gui> page, goes back to the stream; the status bar names the file until
you do.</p>
<p>On the <gui>Events</gui> page, select two or more events in the list and the two buttons
under its 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. On this page a
single click selects and a <em>double</em> click replays.</p>
<p><gui>Record events</gui>, on the trigger page 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, and nothing stored, the dock's events block and
the page stay dimmed.</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. <gui>Force</gui> is the one
acquisition that skips that gate — it captures the history that exists, because "now"
is the whole of what you asked for — so on a ring that has just started filling its
left side is short even though the axis still shows only what it 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>
+65
View File
@@ -0,0 +1,65 @@
<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 the default buffer of 8 388 608 samples per signal (8.39 s). A 2 s window
stores every sample (<code>time x1</code>). Widen it to 600 s and the stride becomes
<code>x128</code>: 7.8 kS/s stored, anti-aliased at 3.9 kHz. A 5 kHz sine seen through that
window is attenuated by the filter, not folded down to a full-amplitude low-frequency one.</p>
</section>
</page>
+295 -158
View File
@@ -3,9 +3,10 @@
#include "acquisition.hpp"
#include <udps_client.h>
#include <limits>
#include "layout_store.hpp"
#include "source/registry.hpp"
#include <glib/gstdio.h>
@@ -27,22 +28,6 @@ namespace {
/// ring to make room for samples that are not coming.
constexpr double kGlitchPackets = 256.0;
SignalInfo to_info(const udps_signal_t& s) {
SignalInfo info;
info.name = s.name;
info.unit = s.unit;
info.type_code = s.type_code;
info.quant_type = s.quant_type;
info.rows = s.num_rows;
info.cols = s.num_cols;
info.range_min = s.range_min;
info.range_max = s.range_max;
info.time_mode = s.time_mode;
info.sampling_rate = s.sampling_rate;
info.time_signal_idx = s.time_signal_idx;
return info;
}
} // namespace
Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model), cfg_(cfg) {}
@@ -50,25 +35,21 @@ Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model),
Acquisition::~Acquisition() { stop(); }
void Acquisition::start() {
udps_client_config_t c;
udps_client_config_init(&c);
c.server_addr = cfg_.server.c_str();
c.server_port = cfg_.port;
c.multicast_group = cfg_.multicast.empty() ? nullptr : cfg_.multicast.c_str();
c.interface_addr = cfg_.interface_addr.empty() ? nullptr : cfg_.interface_addr.c_str();
c.recv_buffer_bytes = static_cast<std::uint32_t>(cfg_.recv_buffer_bytes);
c.silence_timeout_s = 2.0;
c.reconnect_delay_s = 0.5;
c.keepalive_interval_s = 1.0;
client_ = udps_client_create(&c);
if (client_ == nullptr) {
throw std::runtime_error("could not create the UDPS client for " + cfg_.server + ":" +
std::to_string(cfg_.port));
// A started -- or *restarted* -- source 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();
if (!source_) {
SourceKind* kind = find_kind(cfg_.source_kind);
if (kind == nullptr) throw std::runtime_error("unknown source kind: " + cfg_.source_kind);
source_ = kind->make(cfg_.source_config());
}
udps_client_set_callbacks(client_, &Acquisition::config_trampoline, &Acquisition::data_trampoline,
&Acquisition::event_trampoline, this);
udps_client_set_datagram_cb(client_, &Acquisition::datagram_trampoline, this);
source_->set_datagram_tap([this](const std::uint8_t* bytes, std::size_t len, double recv_time) {
on_datagram(bytes, len, recv_time);
});
source_->open(*this);
// `--capture-packets`: armed before the first datagram, so the first CONNECT and
// its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring.
// The open itself still happens on the producer thread, at the first datagram.
@@ -82,35 +63,24 @@ void Acquisition::start() {
}
void Acquisition::stop() {
if (!running_.exchange(false, std::memory_order_acq_rel)) {
if (client_ != nullptr) {
udps_client_destroy(client_);
client_ = nullptr;
}
return;
}
if (thread_.joinable()) thread_.join();
if (client_ != nullptr) {
udps_client_destroy(client_);
client_ = nullptr;
}
if (running_.exchange(false, std::memory_order_acq_rel) && thread_.joinable()) thread_.join();
if (source_) source_->close();
}
void Acquisition::run() {
while (running_.load(std::memory_order_acquire)) {
udps_client_poll(client_, 50);
udps_stats_t s;
udps_client_stats(client_, &s);
source_->poll(50);
const SourceStats s = source_->stats();
StreamStats& out = model_.stats();
out.packets.store(s.packets_received, std::memory_order_relaxed);
out.frames.store(s.frames_delivered, std::memory_order_relaxed);
out.gaps.store(s.counter_gaps, std::memory_order_relaxed);
out.resequenced.store(s.late_resequenced, std::memory_order_relaxed);
out.late.store(s.late_delivered, std::memory_order_relaxed);
out.stale.store(s.stale_packets, std::memory_order_relaxed);
out.overflows.store(s.reorder_overflows, std::memory_order_relaxed);
out.packets.store(s.packets, std::memory_order_relaxed);
out.frames.store(s.frames, std::memory_order_relaxed);
out.gaps.store(s.gaps, std::memory_order_relaxed);
out.resequenced.store(s.resequenced, std::memory_order_relaxed);
out.late.store(s.late, std::memory_order_relaxed);
out.stale.store(s.stale, std::memory_order_relaxed);
out.overflows.store(s.overflows, std::memory_order_relaxed);
out.reconnects.store(s.reconnects, std::memory_order_relaxed);
out.connected.store(udps_client_is_connected(client_) != 0, std::memory_order_relaxed);
out.connected.store(s.connected, std::memory_order_relaxed);
}
}
@@ -119,19 +89,25 @@ void Acquisition::set_capture_want(bool want, std::uint32_t slots) {
if (slots > 0) capture_slots_ = slots;
if (capture_path_.empty()) {
// Keyed on the source: one path per source, the previous capture kept one deep.
std::string host = cfg_.server;
std::string host = cfg_.session_host();
for (char& ch : host) {
if (ch == ':' || ch == '/' || ch == ' ') ch = '_';
}
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.port);
std::string dir = cache_dir() + "/" + host + "-" + std::to_string(cfg_.session_port());
g_mkdir_with_parents(dir.c_str(), 0700);
capture_path_ = dir + "/capture.bin";
std::uint32_t addr = 0;
struct ::in_addr a;
if (::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) {
addr = a.s_addr; // already network order, which is what the header wants
std::uint16_t peer_port = 0;
if (cfg_.source_kind == "udps") {
// The source config, not cfg_.server/port: --source-opt overrides only the former.
const SourceConfig sc = cfg_.source_config();
struct ::in_addr a;
if (::inet_pton(AF_INET, sc.get("server", "127.0.0.1").c_str(), &a) == 1) {
addr = a.s_addr; // already network order, which is what the header wants
}
peer_port = static_cast<std::uint16_t>(sc.get_int("port", 8000));
}
capture_.set_peer(addr, cfg_.port);
capture_.set_peer(addr, peer_port);
}
}
capture_want_.store(want, std::memory_order_release);
@@ -163,29 +139,20 @@ void Acquisition::on_datagram(const std::uint8_t* bytes, std::size_t len, double
}
}
void Acquisition::datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
double recv_time, void* user) {
static_cast<Acquisition*>(user)->on_datagram(bytes, len, recv_time);
}
void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
std::uint8_t publish_mode) {
std::vector<SignalInfo> info;
info.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) info.push_back(to_info(signals[i]));
void Acquisition::on_config(const std::vector<SignalInfo>& info, std::uint8_t publish_mode) {
// One line per CONFIG on stderr: what the stream says it is. This is the first
// thing to look at when a signal refuses to plot.
static const char* const kModes[] = {"strict", "accumulate", "decimate"};
std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n", count,
std::fprintf(stderr, "[udpscope] CONFIG: %u signals, publish=%s\n",
static_cast<unsigned>(info.size()),
publish_mode < 3 ? kModes[publish_mode] : "?");
for (const SignalInfo& sig : info) {
std::fprintf(stderr,
"[udpscope] '%s' %ux%u (%u elem) rate=%g Hz time=%s idx=%u unit='%s'\n",
sig.name.c_str(), sig.rows, sig.cols, sig.elements(), sig.sampling_rate,
sig.time_mode == UDPS_TIME_PACKET ? "packet"
: sig.time_mode == UDPS_TIME_FULL_ARRAY ? "full-array"
: sig.time_mode == UDPS_TIME_FIRST_SAMPLE ? "first-sample"
sig.time_mode == kTimePacket ? "packet"
: sig.time_mode == kTimeFullArray ? "full-array"
: sig.time_mode == kTimeFirstSample ? "first-sample"
: "last-sample",
sig.time_signal_idx, sig.unit.c_str());
}
@@ -195,22 +162,24 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
// next session is the failure mode worth avoiding in a feature whose whole job is diagnosis.
warned_batch_ = false;
warned_outage_ = false;
warned_phantom_gap_ = false;
warned_bridge_ = false;
warned_time_source_ = false;
warned_clock_stall_ = false;
// The clock choice is per connection too: a reconnect re-derives it from the frames
// the new session actually delivers, rather than trusting a decision made about a
// session that dropped.
time_source_ = 0;
reset_session_state();
model_.stats().have_config.store(true, std::memory_order_relaxed);
model_.set_signals(std::move(info));
model_.set_signals(info);
}
void Acquisition::on_event(int event, const char* detail) {
void Acquisition::on_event(SourceEvent event, const char* detail) {
(void)detail;
if (event == UDPS_EVENT_CONNECTED) {
if (event == SourceEvent::Connected) {
model_.stats().connected.store(true, std::memory_order_relaxed);
} else if (event == UDPS_EVENT_DISCONNECTED) {
} else if (event == SourceEvent::Disconnected) {
model_.stats().connected.store(false, std::memory_order_relaxed);
// The session dropped mid-flight: everything the producer knows about *that*
// session's numbering and clocks is now a lie about the next one. Clearing it here
@@ -235,21 +204,75 @@ void Acquisition::reset_session_state() noexcept {
have_arrival_interval_ = false;
arrival_interval_ = 0.0;
arrival_prev_ = 0.0;
have_prev_frame_recv_ = false;
prev_frame_recv_ = 0.0;
// The clock origin is adopted again: the next session's first stamp is that session's epoch.
epoch_adopted_ = false;
}
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
void Acquisition::note_frame_arrival(double recv_time) noexcept {
const bool first = !have_prev_frame_recv_;
const double elapsed = first ? 0.0 : recv_time - prev_frame_recv_;
prev_frame_recv_ = recv_time;
have_prev_frame_recv_ = true;
// The arrival *cadence* belongs to the arrival clock, and `build_stamps()`'s arrival branch keeps
// it whenever that clock is the one in play (`time_source_ == 2`, decided once per connection and
// sticky). Every other case needs it here: relative mode returns before that branch, and a stream
// whose declared time signal checks out never reaches it either -- while the reservation test
// below weighs the counter's claim against a measured cadence whatever clock the display uses,
// because a counter that does not count packets lies in all of them. The two writers are therefore
// exclusive by `time_source_`, and this is the branch's own update (0.8/0.2) so the estimate does
// not jump at the one frame where the clock changes hands.
if (first || time_source_ == 2) return;
if (elapsed > 0.0 && std::isfinite(elapsed)) {
arrival_interval_ =
have_arrival_interval_ ? 0.8 * arrival_interval_ + 0.2 * elapsed : elapsed;
have_arrival_interval_ = true;
}
}
bool Acquisition::gap_plausible(std::uint32_t missing, double elapsed, const Partition& part,
u64 g) const {
if (missing == 0) return true;
// The ring cannot hold a hole bigger than itself, and this test needs no measurement: the
// invariant's own rule is that the reservation's *sample* cost -- `missing * batch_`, not the
// packet cap -- is what has to be weighed against the ring. A hole that laps the ring erases the
// history it is meant to annotate, can never be backfilled (its slots are already overwritten),
// and is never forgotten (`forget_holes_before()` waits for slots the ring has passed), so it
// leaves a band over a buffer that is all NaN.
if (g == 0 || part.time_capacity == 0) return false;
if (u64(missing) * u64(batch_) / g >= part.time_capacity) return false;
// And the counter has to agree with the wire about how long the loss took. The counter is a claim
// about the stream; the arrival clock is a *measurement* of it: `missing` packets would have taken
// `missing` frame intervals to arrive, and the frame before this one is the witness of how long
// actually passed. A device whose counter counts samples, or frames of an inner layer, reports a
// forward jump on *every* datagram while the wire delivers on time -- and believing that booked a
// NaN hole per packet, i.e. a band over the whole window filled with samples that were never
// missing, which no backfill can repair because no packet was ever lost. The factor is a half and
// not one: the socket clock jitters and a drain can compress two frames' arrival times, so only a
// claim the wire flatly contradicts (half the time it needs, or less) is refused. A refused gap
// costs a shading, never data.
if (have_arrival_interval_ && arrival_interval_ > 0.0 && elapsed > 0.0 &&
elapsed < 0.5 * double(missing) * arrival_interval_) {
return false;
}
return true;
}
std::uint32_t Acquisition::build_stamps(const Frame& frame, int time_signal,
bool counter_gap_refused) {
// The frame's span in base ticks: a scalar signal carries `num_samples` values
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
// element block. The largest of those is how many base ticks this frame spans --
// in `strict` mode `num_samples` is 1 while a 1x1000 block carries 1000 samples.
std::uint32_t n = frame->num_samples;
for (std::uint32_t i = 0; i < frame->num_signals; ++i) {
n = std::max(n, udps_signal_num_elements(&frame->signals[i]));
std::uint32_t n = frame.num_samples;
for (std::uint32_t i = 0; i < frame.num_signals; ++i) {
n = std::max(n, frame.signals[i].elements());
}
stamps_.assign(n, 0.0);
abs_stamps_.assign(n, 0.0);
// Samples per packet, needed in both modes: relative stamping must advance `slot_base_`
// Samples per packet, needed in both modes: relative stamping must advance its own clock
// past the reserved hole before placing this frame's own stamps, and `on_data` must size
// the hole before calling this function -- both operations require knowing how many samples
// a missing packet represents, which is only knowable from the first frame that arrives.
@@ -277,35 +300,43 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
}
}
// Relative mode: the stamp is a pure function of position and the *declared* rate. Nothing
// below this block runs -- not the arrival-cadence override, not the degeneracy guard, not
// the glitch bridge, not the monotonic clamp's reason for existing -- because a position
// cannot jitter, cannot fail to advance and cannot jump.
// Relative mode: the stamp is the *declared* interval counted from the stream's own origin.
// Nothing below this block runs -- not the arrival-cadence override, not the degeneracy guard,
// not the glitch bridge, not the monotonic clamp's reason for existing -- because a counted
// clock cannot jitter, cannot fail to advance and cannot jump.
//
// The clock is **accumulated in seconds**, never recomputed as `position * interval`: see
// `Acquisition::rel_base_s_` for the defect that distinction cost. The interval is read from the
// plan, which is the stream's declared rate as soon as a streamed channel exists -- and
// `on_data` refuses a frame until one does, so the fallback interval can never be the origin.
if (cfg_.relative_time) {
double dt = model_.partition().base_dt;
if (!(dt > 0.0)) dt = 1.0e-3;
// The reserved hole for the missing packets advances `slot_base_` so this frame's
// own stamps land *after* the hole rather than on top of it. The same conditions
// the reservation call uses: not late, gap present, batch known, within the cap.
if (!frame->late && frame->lost > 0 && batch_ > 0 &&
double(frame->lost) <= kGlitchPackets) {
slot_base_ += u64(frame->lost) * batch_;
// The reserved hole for the missing packets advances the clock so this frame's own stamps land
// *after* the hole rather than on top of it. The same conditions the reservation call uses: not
// late, gap present, batch known, within the cap -- and not one the arrival clock or the ring
// refused, because the advance and the reservation are the *same* claim about the wire:
// believing a phantom gap here would stretch the timeline by exactly the loss that did not
// happen.
if (!frame.late && frame.lost > 0 && batch_ > 0 && !counter_gap_refused &&
double(frame.lost) <= kGlitchPackets) {
rel_base_s_ += double(frame.lost) * double(batch_) * dt;
}
for (std::uint32_t k = 0; k < n; ++k) {
stamps_[k] = double(slot_base_ + k) * dt;
stamps_[k] = rel_base_s_ + double(k) * dt;
}
slot_base_ += n;
rel_base_s_ += double(n) * dt;
// The absolute clock is still read, for export only. When the stream carries no usable
// time signal there is no absolute clock to record and the relative stamp is the honest
// answer -- an export then says what the counter says, which is all anyone had.
if (time_signal >= 0 && time_signal < static_cast<int>(frame->num_signals)) {
const udps_signal_t& ts = frame->signals[time_signal];
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
if (time_signal >= 0 && time_signal < static_cast<int>(frame.num_signals)) {
const SignalInfo& ts = frame.signals[time_signal];
const bool scalar_time = ts.elements() == 1;
for (std::uint32_t k = 0; k < n; ++k) {
abs_stamps_[k] =
scalar_time
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
? frame.value(static_cast<std::uint32_t>(time_signal), k, 0)
: frame.value(static_cast<std::uint32_t>(time_signal), 0, k);
}
} else {
for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k];
@@ -339,27 +370,27 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
// arrival clock for the rest of its life (until a CONFIG says otherwise): letting
// the decision flip per frame is what interleaved two timelines in one ring.
if (time_source_ != 2 && time_signal >= 0 &&
time_signal < static_cast<int>(frame->num_signals)) {
const udps_signal_t& ts = frame->signals[time_signal];
// How the client indexes a signal's values depends on its shape
// (`udps_frame_value`): a scalar carries one value per batched cycle, so the
time_signal < static_cast<int>(frame.num_signals)) {
const SignalInfo& ts = frame.signals[time_signal];
// How a frame indexes a signal's values depends on its shape
// (`Frame::value`): a scalar carries one value per batched cycle, so the
// stamp of sample k is (sample=k, elem=0); a multi-element signal carries one
// *sample* of `elements` values per packet, so the stamp of sample k is
// (sample=0, elem=k). Passing the scalar form for both is why FULL_ARRAY time
// signals -- the only mode that carries a stamp per element -- read as
// element 0 for every sample and were then judged degenerate.
const bool scalar_time = udps_signal_num_elements(&ts) == 1;
const bool scalar_time = ts.elements() == 1;
auto value_of = [&](std::uint32_t k) {
return scalar_time
? udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), k, 0)
: udps_frame_value(frame, static_cast<std::uint32_t>(time_signal), 0, k);
? frame.value(static_cast<std::uint32_t>(time_signal), k, 0)
: frame.value(static_cast<std::uint32_t>(time_signal), 0, k);
};
switch (ts.time_mode) {
case UDPS_TIME_FULL_ARRAY:
case kTimeFullArray:
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = value_of(k);
used_stream_time = true;
break;
case UDPS_TIME_FIRST_SAMPLE: {
case kTimeFirstSample: {
// Only element 0 is meaningful here: the producer stamps the batch's first
// sample and says nothing about the rest, so the cadence has to space them --
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
@@ -373,7 +404,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
used_stream_time = true;
break;
}
case UDPS_TIME_LAST_SAMPLE: {
case kTimeLastSample: {
const double anchor = value_of(n > 0 ? n - 1 : 0);
for (std::uint32_t k = 0; k < n; ++k)
stamps_[k] = anchor - double(n - 1 - k) * cadence;
@@ -477,7 +508,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
// was produced, and the earlier ones are dated backwards. A gap (`lost`)
// shows up as extra elapsed time between arrivals, which is exactly the
// spacing the samples had.
const double end = frame->recv_time;
const double end = frame.recv_time;
if (!have_arrival_epoch_) {
arrival_epoch_ = end;
have_arrival_epoch_ = true;
@@ -533,7 +564,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
// to a dead clock; they do not, and the wipe is what made a single glitching packet
// look like the stream restarting from zero.
time_source_ = 2;
const double end = frame->recv_time;
const double end = frame.recv_time;
arrival_epoch_ = end - (last_stamp_ + model_.epoch());
arrival_prev_ = end;
}
@@ -553,7 +584,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
return n;
}
void Acquisition::on_data(const udps_frame_t* frame) {
void Acquisition::on_data(const Frame& frame) {
std::lock_guard<std::mutex> lock(model_.mutex());
if (!running_.load(std::memory_order_relaxed)) return;
@@ -561,14 +592,30 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// The counter is this frame's identity and the hole's address. A *late* frame's counter is
// behind the sequence, so it must not move the mark -- the hole it belongs in was addressed
// from the counter that revealed it.
if (!have_counter_ || static_cast<std::int32_t>(frame->counter - last_counter_) > 0) {
last_counter_ = frame->counter;
if (!have_counter_ || static_cast<std::int32_t>(frame.counter - last_counter_) > 0) {
last_counter_ = frame.counter;
have_counter_ = true;
}
const Partition& part = model_.partition();
const std::vector<ChannelInfo>& channels = model_.channels();
if (part.time_capacity == 0 || channels.empty()) return;
// A frame is only stored into a model that has a **streamed** channel. A model holding only
// *derived* ones -- a session's math channels, applied before the stream's own have arrived, or
// a file's channels while the math is re-applied -- has nowhere to put a streamed sample, and its
// plan is built from the 1 kHz fallback interval, which is **not the stream's rate**. Accepting
// such a frame (which the `channels.empty()` test below did) stamps the timeline at that fallback,
// and the frame after the stream's own channels arrive makes every later stamp *behind* the one
// already written -- the monotonic clamp holds the clock there for the rest of the run, the
// trigger's `history` never fills and the plot draws nothing. The reading is the `trig engine:`
// line: `n` climbing while `ring=[a..a]` stands still.
bool streamed = false;
for (const ChannelInfo& c : channels) {
if (!c.math) {
streamed = true;
break;
}
}
if (part.time_capacity == 0 || channels.empty() || !streamed) return;
// A frame delivered behind the sequence: the slots it owns were reserved as NaN by
// `reserve_hole()`, so it writes values into them and does nothing else. It is deliberately
@@ -580,7 +627,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// Placed here and not earlier because `part`/`channels` are not in scope above, and not
// later because `build_stamps()` is stateful: a late frame's stamps would walk the clock
// backwards and can throw the whole stream onto the arrival clock.
if (frame->late) {
if (frame.late) {
backfill_from(frame, part, channels, model_.live(), part.time_stride);
return;
}
@@ -600,20 +647,40 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// (full-array, more than one element, no declared rate).
for (std::size_t i = 0; i < model_.signals().size(); ++i) {
const SignalInfo& sig = model_.signals()[i];
if (sig.time_mode == UDPS_TIME_FULL_ARRAY && sig.elements() > 1 &&
if (sig.time_mode == kTimeFullArray && sig.elements() > 1 &&
sig.sampling_rate == 0.0) {
time_signal = static_cast<int>(i);
break;
}
}
}
const std::uint32_t n = build_stamps(frame, time_signal);
// The wire's own cadence, and the verdict on the counter's claim, both taken *before* the
// stamps: relative mode's clock advance and the reservation below are the same claim
// about the wire (see `build_stamps`), so they have to agree about it. The verdict is taken
// before the cadence is updated, too: folding this frame's own arrival into the estimate would
// let one burst raise the very number that is supposed to corroborate it -- a 200-packet outage
// smooths the interval to 40 ms and then refuses itself.
const bool first_frame = !have_prev_frame_recv_;
const double since_prev = first_frame ? 0.0 : frame.recv_time - prev_frame_recv_;
const std::uint32_t missing = frame.lost;
const bool gap_refused =
missing > 0 && !gap_plausible(missing, since_prev, part, part.time_stride);
note_frame_arrival(frame.recv_time);
const std::uint32_t n = build_stamps(frame, time_signal, gap_refused);
if (n == 0) return;
if (model_.epoch() == 0.0) {
// The epoch -- the stream time the display's x = 0 means -- is the first frame's stamp, adopted
// **once per connection**. The test is a state and not a value: relative mode's first frame is
// stamped exactly 0, so an `epoch() == 0.0` test reads that as "no epoch yet" and lets the
// *second* frame move the origin, which stalls the timeline through that frame (the samples of
// one frame all land on one stamp) and shifts every later x by a frame's span. The flag is
// re-armed by `reset_session_state()`, because a new session's clock is a new clock.
if (!epoch_adopted_) {
epoch_adopted_ = true;
model_.set_epoch_if_unset(stamps_[0]);
std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
model_.epoch(), frame->recv_time, n);
model_.epoch(), frame.recv_time, n);
std::fflush(stderr);
}
const double epoch = model_.epoch();
@@ -640,7 +707,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// The generation is the signal rather than the stride/channel-count comparison below: only
// a CONFIG that *changes* the stream re-plans the store -- a reconnect's identical CONFIG
// keeps it, history and all (see `ScopeModel::set_signals`).
// `slot_base_` is deliberately *not* reset -- it is relative mode's clock origin, samples
// `rel_base_s_` is deliberately *not* reset -- it is relative mode's clock origin, seconds
// since the stream began, and relative time must not jump backwards when the user retypes
// the window.
if (const unsigned gen = model_.plan_generation(); gen != seen_plan_generation_) {
@@ -687,8 +754,29 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// through the same decimation accumulators (value_acc_, value_acc_n_, math_sample_),
// which must already be sized to the current channel count and stride before any
// per-sample write.
const std::uint32_t missing = frame->lost;
if (missing > 0 && batch_ > 0 && double(missing) <= kGlitchPackets) {
if (missing > 0 && gap_refused) {
// A claim the wire contradicted, or a hole the ring cannot hold: framing it as NaN is what
// turns one phantom gap into a band over the whole window (and, at a batch big enough, into
// minutes of the producer's time under this lock). The samples are kept, no hole is booked,
// and the timeline above was not advanced either -- so the picture shows the data and the gap
// shows as the x discontinuity it is. Said once per connection with the numbers, because it is
// otherwise invisible: the status bar's `gaps` climbs either way.
model_.stats().gaps_refused.fetch_add(1, std::memory_order_relaxed);
if (!warned_phantom_gap_) {
warned_phantom_gap_ = true;
std::fprintf(stderr,
"[udpscope] warning: the counter reports %u packet(s) missing (%llu samples, "
"%.3f s at the measured cadence of %.6f s/frame) but only %.3f s passed since "
"the previous frame, or the hole is larger than the ring (%llu slots); "
"refusing the reservation and keeping the samples. A counter that does not "
"count packets -- samples, or frames of an inner layer -- reports a jump on "
"every datagram and reads exactly like this.\n",
missing, static_cast<unsigned long long>(std::uint64_t(missing) * batch_),
double(missing) * arrival_interval_, arrival_interval_, since_prev,
static_cast<unsigned long long>(u64(missing) * u64(batch_) / part.time_stride));
std::fflush(stderr);
}
} else if (missing > 0 && batch_ > 0 && double(missing) <= kGlitchPackets) {
reserve_hole(missing, part, channels, store, g);
} else if (missing > 0 && !warned_outage_) {
warned_outage_ = true;
@@ -801,13 +889,36 @@ void Acquisition::on_data(const udps_frame_t* frame) {
edge, last_stamp_, span - post_s, post_s, snap ? snap->pre_s : 0.0,
snap ? snap->post_s : 0.0,
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0),
frame->recv_time, static_cast<unsigned long long>(tick_),
frame.recv_time, static_cast<unsigned long long>(tick_),
std::chrono::duration<double>(std::chrono::steady_clock::now() - started_)
.count());
std::fflush(stderr);
if (tc.mode == TriggerMode::Single) tc.armed = false;
};
// The monotonic clamp, and the one state it must not be allowed to sit in. `max()` is for a
// stream clock that glitches, fails to advance for a sample or arrives out of order -- one
// sample, or a few, held back. A **whole frame** whose stamps are all behind the clock is not
// that: the timeline has stopped while the wire has not, and nothing downstream can recover. It
// is staged as a reading because the picture it produces is a *blank* plot beside a healthy
// status bar, which names no cause at all: the trigger's `history` never reaches its pre-window,
// so it never latches and the display sits in `Waiting`; the renderer's bound window is empty, so
// nothing is drawn even with the trigger off. `relative_time` with a session's math channel was
// how this was reached (see `rel_base_s_`); the guard is `build_stamps`' own accumulator.
if (!warned_clock_stall_ && n > 0 && stamps_[0] - epoch < last_stamp_ &&
stamps_[n - 1] - epoch < last_stamp_) {
warned_clock_stall_ = true;
const u64 held = model_.live().time().size();
std::fprintf(stderr,
"[udpscope] warning: the clock stalled at %.6f s: %u samples stamped [%.6f..%.6f] s "
"are all behind it, so the timeline stands still while frames keep arriving -- "
"nothing can be drawn and no trigger can latch. The plan's interval is %.9g s now; "
"ring n=%llu\n",
last_stamp_, n, stamps_[0] - epoch, stamps_[n - 1] - epoch, part.base_dt,
static_cast<unsigned long long>(held));
std::fflush(stderr);
}
for (std::uint32_t k = 0; k < n; ++k) {
const u64 tick = tick_++;
// The shared clock's group boundary, read once and reused by the time ring, the channel
@@ -860,9 +971,9 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// the cycle's sample at element `k`. Either way element 0 is the channel.
const double raw =
ch.elements > 1
? udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), 0,
std::min<std::uint32_t>(k, ch.elements - 1))
: udps_frame_value(frame, static_cast<std::uint32_t>(ch.signal), k, 0);
? frame.value(static_cast<std::uint32_t>(ch.signal), 0,
std::min<std::uint32_t>(k, ch.elements - 1))
: frame.value(static_cast<std::uint32_t>(ch.signal), k, 0);
// Every sample is read and accumulated even when only one in `period` is stored:
// that is the whole point of the filter. Skipping the read -- which is what the
// old stride did -- is what made decimation alias.
@@ -899,6 +1010,38 @@ void Acquisition::on_data(const udps_frame_t* frame) {
}
last_stamp_ = last_stamp_used;
// A Force pressed since the last packet: one acquisition, at the present moment, without waiting
// for the condition to hold. Read *here* -- the end of the packet, past the channel loop -- for
// the reason the latch itself is deferred out of that loop: `last_stamp_` is the newest stored
// sample by now, so the capture opens on the moment the user pressed, and every channel has
// pushed that sample, so the cut the latch goes on to make cannot clamp it away
// (`publish_event()` clamps to each value ring's `produced()`).
//
// It opens the capture and nothing else. The latch below -- reached through `engine.process()`
// by an edge, through `engine.force()` here -- is what cuts the window, bumps the latch count,
// sets the trigger time and applies the mode's re-arm rule, so a forced acquisition is framed,
// pinned and recorded exactly like one the condition produced. A capture already in flight is
// not cancelled: it *is* the acquisition being asked for, and the engine refuses the request
// rather than restarting it.
if (force_want_.exchange(false, std::memory_order_acq_rel)) {
if (engine.force(last_stamp_)) {
std::fprintf(stderr,
"[udpscope] trigger: forced acquisition, edge at %.3f s -- want "
"[-%.4f..%.4f] s, %.4f s of history behind it, mode=%s\n",
last_stamp_, event_span - post_want, post_want, last_stamp_ - oldest_s,
tc.mode == TriggerMode::Single ? "single (disarms at the latch)" : "normal");
std::fflush(stderr);
} else {
// Nothing to force. Every cause is a state the user can see from the panel -- a trigger that
// is off, a signal that is not acquired, the engine disarmed, a capture already filling --
// so it is named on stderr rather than swallowed.
std::fprintf(stderr,
"[udpscope] trigger: force ignored (enabled=%d armed=%d ch=%d pending=%d)\n",
int(tc.enabled), int(engine.armed()), trig_channel, int(engine.pending()));
std::fflush(stderr);
}
}
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
// The edge goes out before the flag that makes the UI read it (release/acquire on
// `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A
@@ -907,7 +1050,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
if (engine.pending()) model_.set_capture_edge(engine.last_trigger_time());
model_.set_trigger_pending(engine.pending());
static std::int64_t next_engine_log = 0;
const std::int64_t now_e = static_cast<std::int64_t>(frame->recv_time * 1.0e6);
const std::int64_t now_e = static_cast<std::int64_t>(frame.recv_time * 1.0e6);
if (now_e >= next_engine_log) {
next_engine_log = now_e + 1000000;
// `elapsed/post_want` is the capture's progress in the only unit that matters now
@@ -917,12 +1060,20 @@ void Acquisition::on_data(const udps_frame_t* frame) {
std::fprintf(stderr,
"[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 "
"holdoff_until=%.3f\n",
"holdoff_until=%.3f ring=[%.4f..%.4f] n=%zu\n",
trig_channel, int(engine.armed()), int(engine.pending()),
engine.post_elapsed(), engine.post_seconds(),
static_cast<unsigned long long>(engine.post_accum()), engine.history(),
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);
}
}
@@ -1004,7 +1155,7 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
model_.stats().hole_slots.fetch_add(h.slot_count, std::memory_order_relaxed);
}
void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part,
void Acquisition::backfill_from(const Frame& frame, const Partition& part,
const std::vector<ChannelInfo>& channels, BufferStore& store,
u64 g) {
// `batch_` is learned in `build_stamps()`, which a late frame never reaches. A late frame
@@ -1021,7 +1172,7 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part
// late packets would be dropped on the next line.
Hole* hole = nullptr;
for (Hole& h : model_.holes()) {
const std::int32_t into = static_cast<std::int32_t>(frame->counter - h.counter_first);
const std::int32_t into = static_cast<std::int32_t>(frame.counter - h.counter_first);
if (into >= 0 && into < static_cast<std::int32_t>(h.packets)) {
hole = &h;
break;
@@ -1032,7 +1183,7 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part
// The packet's own span in absolute base ticks. `tick_begin` is the hole's first raw sample
// and the packets inside a hole are consecutive, so the counter's distance from
// `counter_first` places this one exactly.
const u64 lo = hole->tick_begin + u64(frame->counter - hole->counter_first) * u64(batch_);
const u64 lo = hole->tick_begin + u64(frame.counter - hole->counter_first) * u64(batch_);
const u64 hi = lo + u64(batch_);
u64 filled = 0;
@@ -1056,11 +1207,11 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part
const u64 k = s * period + j - lo; // this packet's own sample index, 0..batch_-1
const double raw =
channels[i].elements > 1
? udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal), 0,
std::min<std::uint32_t>(static_cast<std::uint32_t>(k),
channels[i].elements - 1))
: udps_frame_value(frame, static_cast<std::uint32_t>(channels[i].signal),
static_cast<std::uint32_t>(k), 0);
? frame.value(static_cast<std::uint32_t>(channels[i].signal), 0,
std::min<std::uint32_t>(static_cast<std::uint32_t>(k),
channels[i].elements - 1))
: frame.value(static_cast<std::uint32_t>(channels[i].signal),
static_cast<std::uint32_t>(k), 0);
acc += raw * channels[i].gain + channels[i].offset;
}
const f32 value = static_cast<f32>(acc / double(period));
@@ -1082,18 +1233,4 @@ void Acquisition::backfill_from(const udps_frame_t* frame, const Partition& part
model_.stats().backfilled.fetch_add(filled, std::memory_order_relaxed);
}
// Static trampolines: the client's C ABI cannot call a member function directly.
void Acquisition::config_trampoline(const udps_signal_t* signals, std::uint32_t count,
std::uint8_t publish_mode, void* user) {
static_cast<Acquisition*>(user)->on_config(signals, count, publish_mode);
}
void Acquisition::data_trampoline(const udps_frame_t* frame, void* user) {
static_cast<Acquisition*>(user)->on_data(frame);
}
void Acquisition::event_trampoline(udps_event_t event, const char* detail, void* user) {
static_cast<Acquisition*>(user)->on_event(static_cast<int>(event), detail);
}
} // namespace udpscope
+83 -26
View File
@@ -8,21 +8,22 @@
#include "model.hpp"
#include "trigger.hpp"
#include <udps_client.h>
#include "source/data_source.hpp"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <limits>
#include <memory>
#include <thread>
#include <vector>
namespace udpscope {
/// Owns the UDPStreamer client and the producer thread that feeds the model's
/// Owns the data source and the producer thread that feeds the model's
/// acquisition buffer. Everything it touches from the thread is serialised on
/// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame.
class Acquisition {
class Acquisition : private SourceSink {
public:
Acquisition(ScopeModel& model, const Config& cfg);
~Acquisition();
@@ -30,8 +31,8 @@ class Acquisition {
Acquisition(const Acquisition&) = delete;
Acquisition& operator=(const Acquisition&) = delete;
/// Opens the client (no socket until the first poll) and starts the thread.
/// Throws std::runtime_error when the client cannot be created.
/// Opens the source (no socket until the first poll) and starts the thread.
/// Throws std::runtime_error when the source cannot be created.
void start();
/// Stops the thread and destroys the client. Idempotent.
void stop();
@@ -48,22 +49,33 @@ class Acquisition {
bool capture_want() const noexcept {
return capture_want_.load(std::memory_order_acquire);
}
/// Asks for one **forced trigger acquisition** -- the trigger panel's Force button. The producer
/// consumes the request on its next packet and opens a capture at that packet's newest stamp,
/// which the engine's ordinary latch then publishes with the configured pre/post window. The
/// request is dropped rather than retried when there is nothing to force: trigger mode off, no
/// channel resolved, the engine disarmed, or a capture already filling (a capture in flight *is*
/// the acquisition being asked for).
///
/// **Write it under `ScopeModel::mutex()`, together with the `armed` flag the click also sets.**
/// The producer holds that lock for a whole packet -- it reads the panel's `armed` at the top of
/// one and consumes this request at the bottom -- so a request written between the two would be
/// read by a packet whose engine was still disarmed, and then dropped.
void request_force() noexcept { force_want_.store(true, std::memory_order_release); }
/// The source this acquisition drives, for the UI's capability panels: it may only call
/// `post()` on it. Null until `start()`.
DataSource* source() noexcept { return source_.get(); }
/// The capture, for the popover's row and the probe. Counters and errors are read
/// cross-thread through PacketCapture's own published fields.
const PacketCapture& capture() const noexcept { return capture_; }
private:
void run();
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
void on_data(const udps_frame_t* frame);
void on_event(int event, const char* detail);
static void config_trampoline(const udps_signal_t* signals, std::uint32_t count,
std::uint8_t publish_mode, void* user);
static void data_trampoline(const udps_frame_t* frame, void* user);
static void event_trampoline(udps_event_t event, const char* detail, void* user);
static void datagram_trampoline(const std::uint8_t* bytes, std::size_t len,
double recv_time, void* user);
void on_config(const std::vector<SignalInfo>& signals, std::uint8_t publish_mode) override;
void on_data(const Frame& frame) override;
void on_event(SourceEvent event, const char* detail) override;
/// Drops the producer state that is per *connection*: the counter mark, the time
/// anchors and the clock choice. A reconnect re-sends the CONFIG and the stream may
/// have restarted its numbering; carrying the old session's marks across makes the
@@ -75,9 +87,10 @@ class Acquisition {
void on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time);
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
/// and returns how many were written (`frame->num_samples` unless the frame is
/// and returns how many were written (`frame.num_samples` unless the frame is
/// malformed).
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
std::uint32_t build_stamps(const Frame& frame, int time_signal,
bool counter_gap_refused);
/// Pushes NaN for the `missing` packets' worth of samples that were never delivered,
/// advances the decimation accumulators and tick_, records the hole in the model, and
@@ -89,17 +102,29 @@ class Acquisition {
/// Writes a late frame's values into the NaN-filled holes their counter addresses,
/// amending the value ring directly (producer path) and queuing pyramid amends for
/// `ChannelBuffer::acquire()` to apply on the consumer thread.
void backfill_from(const udps_frame_t* frame, const Partition& part,
void backfill_from(const Frame& frame, const Partition& part,
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
/// Keeps the wire's own cadence up to date from the arrival times, in the modes where
/// `build_stamps()`'s arrival branch does not (see the definition).
void note_frame_arrival(double recv_time) noexcept;
/// Whether the counter's claim of `missing` lost packets can be believed *and* booked: the
/// arrival clock has to corroborate it and the ring has to be able to hold it. See the
/// definition for both tests and for what believing a claim that fails them looks like.
bool gap_plausible(std::uint32_t missing, double elapsed, const Partition& part, u64 g) const;
ScopeModel& model_;
Config cfg_;
udps_client* client_ = nullptr;
std::unique_ptr<DataSource> source_;
std::thread thread_;
std::atomic<bool> running_{false};
std::atomic<bool> paused_{false};
PacketCapture capture_;
std::atomic<bool> capture_want_{false};
/// The pending Force request (see `request_force()`): a flag and not a queue, because two clicks
/// inside one packet are one acquisition, which is what a momentary button means.
std::atomic<bool> force_want_{false};
std::uint32_t capture_slots_ = 1000;
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
/// the producer reads it only after the flag, so no lock is needed.
@@ -125,22 +150,42 @@ class Acquisition {
double arrival_epoch_ = 0.0;
bool have_arrival_epoch_ = false;
/// Smoothed interval between two frames' arrivals, and the previous arrival: the
/// spine of the arrival-anchored clock (see `build_stamps`).
/// spine of the arrival-anchored clock (see `build_stamps`). In relative mode no frame
/// reaches that branch, so `on_data` keeps the same pair up to date itself -- the
/// reservation's plausibility test below needs a measured cadence whatever clock the
/// user chose, and the two writers are mutually exclusive by mode.
double arrival_interval_ = 0.0;
double arrival_prev_ = 0.0;
bool have_arrival_interval_ = false;
/// The previous *accepted* frame's receive time, for the one measurement the reservation
/// test needs: how much wall time actually passed. Kept apart from `arrival_prev_` on
/// purpose -- that one is the arrival clock's own bookkeeping and is re-anchored by the
/// clock-choice code, while this is a bare witness of the wire and must never be moved by
/// a mode change.
double prev_frame_recv_ = 0.0;
bool have_prev_frame_recv_ = false;
/// The stream's time source, chosen once and sticky: 0 undecided, 1 the declared
/// time signal, 2 arrival. Re-evaluated only on a CONFIG. A per-frame choice
/// interleaved two timelines in one ring -- some frames stamped from a declared
/// signal carrying volt-like values, others from the arrival clock -- which is a
/// time base no reader can make sense of (see `build_stamps`).
int time_source_ = 0;
/// Relative-time mode's position counter: samples seen since the stream began, which is
/// what the counter names. Advanced by `batch_` for every packet the counter reports
/// missing (before the frame's own samples) and then by the frame's own `n`, so it is
/// exactly `counter * batch + sample_index` for a constant batch and degrades gracefully
/// when a frame's length disagrees.
std::uint64_t slot_base_ = 0;
/// Relative-time mode's clock: seconds since the stream began, accumulated at the interval
/// the plan declares. Advanced by `lost * batch_` samples' worth for every packet the counter
/// reports missing (before the frame's own samples) and then by the frame's own `n`, so it is
/// exactly `(counter * batch + sample_index) * dt` for a constant batch and degrades
/// gracefully when a frame's length disagrees.
///
/// It is a **time**, not a position multiplied by the current plan's interval, and the
/// difference is the whole of one defect: a plan is rebuilt whenever the channel list changes
/// (a CONFIG, an enable, a session's math channel) and `Partition::base_dt` -- which is the
/// 1 kHz fallback until a *streamed* channel declares a rate -- can therefore change under a
/// running stream. With `position * dt` a plan that shortened the interval made every later
/// stamp *behind* the one already written, which the frame loop's monotonic clamp then held
/// for the rest of the run: the timeline stood still, the trigger's `history` never reached
/// its pre-window, and nothing was drawn (see `on_data`). Accumulated, a plan change is what
/// it is -- a slope change -- and the clock cannot go backwards.
double rel_base_s_ = 0.0;
/// Samples per DATA packet, fixed by the first frame. Only ever needed to size what is
/// *absent*: a delivered frame carries its own length. 0 until the first frame.
std::uint32_t batch_ = 0;
@@ -158,11 +203,23 @@ class Acquisition {
/// Warn-once latch for a loss past the reservation cap. A member, not a function-local
/// `static`, matching the latch Task 2 added for the same reason.
bool warned_outage_ = false;
/// Warn-once latch for a reservation the arrival clock did not corroborate, or that the ring
/// could not hold (see `gap_plausible()`). Once per connection, like the two above.
bool warned_phantom_gap_ = false;
/// Warn-once latch for the one bridged packet whose time signal glitched.
bool warned_bridge_ = false;
/// Warn-once latch for the declared time signal failing for good, so the arrival clock is
/// taken instead.
bool warned_time_source_ = false;
/// Warn-once latch for a frame whose every stamp was held back by the frame loop's monotonic
/// clamp. The clamp exists for a stream clock that glitches; a *whole frame* behind the clock is
/// the timeline standing still, and while it lasts nothing is drawn and no trigger can latch.
bool warned_clock_stall_ = false;
/// Whether this connection has adopted its clock origin (`ScopeModel::set_epoch_if_unset()`).
/// A state and not `epoch() == 0.0`, which re-fires in relative mode -- where the first frame's
/// stamp *is* zero -- and moves the origin under the timeline. Per connection, like every other
/// piece of session state here: a new session's clock is a new clock.
bool epoch_adopted_ = false;
/// The re-plan generation this object last reacted to. The buffer is re-planned whenever the
/// model rebuilds it, and the state tied to the old store (`tick_` and the accumulators) is
/// reset when this falls behind `ScopeModel::plan_generation()`.
+12
View File
@@ -78,6 +78,18 @@ void ChannelBuffer::invalidate() noexcept {
pending_amends_.clear();
}
void ChannelBuffer::republish_from(u64 absolute) noexcept {
published_ = absolute;
window_x0_ = 0.0;
pending_amends_.clear();
// `clear()` leaves the pyramid empty at absolute 0, and `drop_before()` past the end
// restarts an empty window at the index it is given -- which is the only way to say
// "nothing is published, and the next append is sample `absolute`". The no-op guard is
// for `absolute == 0`, where `clear()` has already said exactly that.
pyramid_.clear();
if (absolute > 0) pyramid_.drop_before(absolute);
}
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// Channel sample `j` is produced on the tick that also pushes time slot
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
+10
View File
@@ -48,6 +48,16 @@ class ChannelBuffer final : public plot::data::Series {
void acquire() noexcept;
/// Consumer thread: forgets the published window (after a rebind or a reset).
void invalidate() noexcept;
/// Consumer thread: drops the published window and re-publishes it from absolute sample
/// `absolute` on the next `acquire()`.
///
/// For a ring whose contents were *rewritten in place* rather than appended to -- a math
/// channel recomputed over its inputs' retained window. `acquire()` is incremental, so
/// a rewrite that leaves `produced()` where it was publishes nothing and the pyramid goes
/// on describing the samples the edit replaced. `invalidate()` would do it too, but it
/// restarts at 0 and `acquire()` would then walk every absolute index ever produced; this
/// restarts at the oldest index the ring still holds, so the rebuild is one window.
void republish_from(u64 absolute) noexcept;
/// Producer thread, under `ScopeModel::mutex()`: records that absolute sample `absolute`
/// has been backfilled, for `acquire()` to fold into the pyramid.
+85 -7
View File
@@ -6,6 +6,7 @@
// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling
// is what stops the message and the clamp from drifting apart.
#include "plot_host.hpp"
#include "source/registry.hpp"
#include <cstdio>
#include <cstdlib>
@@ -38,17 +39,61 @@ double parse_double(const char* name, const std::string& v) {
}
}
/// A count with an optional decimal suffix: `250000`, `64k`, `100M`, `1.5G`. k, M and G are 10^3, 10^6
/// and 10^9 -- a *sample* count, not a byte size -- and either case is accepted.
std::uint64_t parse_count(const char* name, const std::string& v) {
std::string digits = v;
double scale = 1.0;
if (!digits.empty()) {
switch (digits.back()) {
case 'k': case 'K': scale = 1.0e3; break;
case 'm': case 'M': scale = 1.0e6; break;
case 'g': case 'G': scale = 1.0e9; break;
default: break;
}
if (scale != 1.0) digits.pop_back();
}
const double d = parse_double(name, digits) * scale;
if (!(d >= 1.0) || d > 1.0e12) fail(std::string(name) + " out of range: " + v);
return static_cast<std::uint64_t>(d + 0.5);
}
} // namespace
SourceConfig Config::source_config() const {
SourceConfig out;
if (source_kind == "udps") {
out.set("server", server);
out.set("port", std::to_string(port));
if (!multicast.empty()) out.set("multicast", multicast);
if (!interface_addr.empty()) out.set("interface", interface_addr);
out.set("recv_buffer", std::to_string(recv_buffer_bytes));
}
for (const auto& kv : source_opts.items()) out.set(kv.first, kv.second);
return out;
}
std::string Config::session_host() const { return udpscope::session_host(source_kind, source_config()); }
std::uint16_t Config::session_port() const { return udpscope::session_port(source_kind, source_config()); }
void print_usage(std::FILE* out, const char* argv0) {
std::string names;
for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id();
std::fprintf(out,
"usage: %s --server ADDR --port PORT [options]\n"
"usage: %s --server ADDR --port PORT [--source NAME] [options]\n"
"\n"
" --server ADDR stream source IPv4 address (default 127.0.0.1)\n"
" --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n"
" --multicast GROUP join GROUP for DATA instead of unicast\n"
" --interface ADDR local IPv4 of the interface to join on\n"
" --buffer-mb MB size of each of the two sample buffers (default 200)\n"
" --source NAME source kind (%s; default udps). --server/--port/\n"
" --multicast/--interface configure udps\n"
" --source-opt K=V a setting for the source kind; repeatable\n"
" --buffer-samples N samples kept per acquired signal, e.g. 100M or 250000 (k, M\n"
" and G are decimal; default 10M). Rounded down to a power\n"
" of two: 100M gives 67108864\n"
" --memory-limit-mb MB hard ceiling on the sample rings, all signals together\n"
" (default 8192); the request is halved until it fits\n"
" --max-window SEC largest time window the toolbar offers (default 600)\n"
" --holdoff SEC default trigger hold-off (default 0.010)\n"
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
@@ -62,8 +107,14 @@ void print_usage(std::FILE* out, const char* argv0) {
" time is then kept for export only\n"
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
" signals from its columns, no socket, no acquisition\n"
" --renderer NAME render with cairo (software), gl or vulkan. cairo also\n"
" exports LIBGL_ALWAYS_SOFTWARE=1, because the plot is a\n"
" GtkGLArea and still needs a GL context. Over SSH/X11\n"
" forwarding use cairo: Vulkan cannot make a swapchain there\n"
" and the GL area has no hardware context, so the window\n"
" never paints or allocates\n"
" -h, --help this text\n",
argv0);
argv0, names.c_str());
}
Config parse_args(int argc, char** argv) {
@@ -85,10 +136,28 @@ Config parse_args(int argc, char** argv) {
c.multicast = value_of(argc, argv, i, "--multicast");
} else if (a == "--interface" || a.rfind("--interface=", 0) == 0) {
c.interface_addr = value_of(argc, argv, i, "--interface");
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
c.buffer_mb_specified = true;
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
} else if (a == "--source" || a.rfind("--source=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--source");
if (find_kind(v) == nullptr) {
std::string names;
for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id();
fail("unknown source '" + v + "' (available: " + names + ")");
}
c.source_kind = v;
c.source_specified = true;
} else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--source-opt");
if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v);
} else if (a == "--buffer-samples" || a.rfind("--buffer-samples=", 0) == 0) {
c.buffer_samples = parse_count("--buffer-samples", value_of(argc, argv, i, "--buffer-samples"));
c.buffer_samples_specified = true;
if (c.buffer_samples < 128) fail("--buffer-samples must be at least 128");
} else if (a == "--memory-limit-mb" || a.rfind("--memory-limit-mb=", 0) == 0) {
const double mb =
parse_double("--memory-limit-mb", value_of(argc, argv, i, "--memory-limit-mb"));
if (!(mb >= 1.0) || mb > 1.0e9) fail("--memory-limit-mb must be 1..1000000000 (MiB)");
c.memory_limit_mb = static_cast<std::uint64_t>(mb);
c.memory_limit_specified = true;
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
@@ -113,6 +182,15 @@ Config parse_args(int argc, char** argv) {
c.capture_packets = static_cast<std::uint32_t>(n);
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
c.open_waveform = value_of(argc, argv, i, "--open");
} else if (a == "--renderer" || a.rfind("--renderer=", 0) == 0) {
const std::string v = value_of(argc, argv, i, "--renderer");
// Only the three a source or a forwarded display actually chooses between. Anything
// GTK does not know would fall back with a warning and leave the user reading a
// renderer failure that is really a typo, so it is refused here instead.
if (v != "cairo" && v != "gl" && v != "vulkan") {
fail("--renderer must be cairo, gl or vulkan: " + v);
}
c.renderer = v;
} else if (a == "--relative-time") {
c.relative_time = true;
c.relative_time_specified = true;
+51 -8
View File
@@ -6,10 +6,12 @@
#include <cstdint>
#include <string>
#include "source/source_config.hpp"
namespace udpscope {
/// Everything the command line can say. Defaults are the ones the spec names
/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them.
/// (10 M samples per signal, 10 ms trigger hold-off) unless the user overrides them.
struct Config {
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
@@ -21,13 +23,38 @@ struct Config {
/// `server` valid". The default stays a usable value for every other path.
bool server_specified = false;
/// Size of *each* of the two sample buffers, in megabytes. The application
/// always allocates two identical buffers (spec item 1).
double buffer_mb = 200.0;
/// Whether `--buffer-mb` was given. A stored configuration's buffer size fills in only when the
/// flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
/// question as "asked for 200".
bool buffer_mb_specified = false;
/// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/
/// --multicast/--interface` keep meaning UDPS.
std::string source_kind = "udps";
/// Whether `--source` was given (the other half of "did the user choose a source").
bool source_specified = false;
/// Repeatable `--source-opt k=v`: the kind's own settings. For `udps` they overlay the fields above.
SourceConfig source_opts;
/// A source was named on the command line (`--server` or `--source`), so the chooser is skipped.
bool has_source() const noexcept { return server_specified || source_specified; }
/// The settings the kind's `make()` receives: for UDPS the four connection fields plus the receive
/// buffer, then `source_opts` on top.
SourceConfig source_config() const;
/// The (host, port) that name this source's session file; see `session_host()` in the registry.
std::string session_host() const;
std::uint16_t session_port() const;
/// The sample budget: how many samples to keep for **every acquired signal**, and for the shared
/// time ring. The rings are powers of two, so the request is rounded *down* to one (`buffer_ticks()`
/// in model.hpp) -- "up to" this many -- and the UI shows both figures. 10 M asks for 8 388 608.
std::uint64_t buffer_samples = 10'000'000;
/// Whether `--buffer-samples` was given. A stored configuration's sample budget fills in only when
/// the flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
/// question as "asked for 10 M".
bool buffer_samples_specified = false;
/// The hard ceiling on the ring storage, in MiB, for the whole application rather than per source.
/// `plan_partition()` halves the rings until they fit under it, so it can clip `buffer_samples`.
/// Default 8 GiB; the preferences carry it between runs and `--memory-limit-mb` overrides it.
std::uint64_t memory_limit_mb = 8192;
/// Whether `--memory-limit-mb` was given, so the stored preference only fills the gap.
bool memory_limit_specified = false;
/// Largest time window the toolbar offers, in seconds. The buffer partition is
/// sized to hold at least this much time (spec: decimation).
@@ -59,6 +86,22 @@ struct Config {
/// 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.
std::string open_waveform;
/// `--renderer NAME`: the GSK renderer to force, by exporting `GSK_RENDERER` **before any GTK
/// call**. Empty leaves GTK's own choice alone. `cairo` is the software preset over SSH/X11
/// forwarding: it also exports `LIBGL_ALWAYS_SOFTWARE=1`, because the plot's `GtkGLArea` asks
/// for a GL context whatever GSK draws with. `gl` is the faster alternative when indirect GL
/// works. The variables are read when the renderer/context is created, so setting them anywhere
/// after `gtk_init()`/`adw_init()` is a no-op -- which is why this is a `Config` field applied
/// at the top of `main()` and not a preference.
std::string renderer;
/// 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
+23
View File
@@ -7,6 +7,7 @@
#include <algorithm>
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
@@ -63,8 +64,30 @@ struct EventSnapshot {
/// already O(n), and the same channel on two rows shares it instead of building and
/// storing it twice.
std::vector<plot::data::MinMaxPyramid> pyramids;
/// The channel *names* `values` is indexed by, so a snapshot can be re-bound after the channel
/// list was rebuilt -- and renumbered -- under it: a persistence layer names its signal and
/// resolves that name here, exactly as the trigger engine names its channel and for the same
/// reason (an index is a position in a list that any enable, disable or CONFIG renumbers).
/// Empty on a snapshot nobody re-binds later (a pause view).
std::vector<std::string> channel_names;
};
/// Bytes one snapshot holds: both stamp arrays, every channel's samples, the pyramids built for
/// them and the names above. The number the persistence retention is measured in -- a retained
/// snapshot is a whole window copied out of the ring, so what bounds it is bytes, not a count.
inline std::size_t snapshot_bytes(const EventSnapshot& s) {
std::size_t bytes = s.stamps.size() * sizeof(f64) + s.abs_stamps.size() * sizeof(f64) +
s.y.size() * sizeof(plot::Range) +
s.channel_names.size() * sizeof(std::string);
for (const std::vector<f32>& v : s.values) bytes += v.size() * sizeof(f32);
// A pyramid entry is 32 bytes (`MinMaxPyramid::Entry`), one per level-`l` bucket of
// `kBucket << l` samples -- the whole index is about one byte per sample.
for (const plot::data::MinMaxPyramid& p : s.pyramids) {
for (u32 l = 0; l < p.level_count(); ++l) bytes += p.level_size(l) * 32u;
}
return bytes;
}
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
/// the exact stamp; gather decimates by 2^level like the ring series does.
+354 -12
View File
@@ -6,8 +6,11 @@
#include <algorithm>
#include <bit>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <utility>
namespace udpscope {
namespace {
@@ -34,18 +37,29 @@ std::string sanitise(const std::string& name) {
return out;
}
/// The final labels: sanitised, and de-duplicated against each other *and* against the
/// two time columns, 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{"time", "abs_seconds"};
/// `base` carrying a `_2`, `_3` … suffix for the `n`-th copy (`n == 1` is the name itself),
/// cut to `max_len` bytes when one is given -- MDF's name field is 32 bytes wide and the file
/// is re-read by a reader that keeps only the *first* occurrence of a name, so a cut that made
/// two channels equal would silently lose one.
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;
out.reserve(table.columns.size());
for (const ExportTable::Column& col : table.columns) {
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) {
label = base + "_" + std::to_string(n);
label = numbered(base, n, max_len);
}
taken.push_back(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;
}
/// 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
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
: 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`
// `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;
const std::vector<f32>& src = snap->values[ch];
if (src.size() < first + n) continue;
table.columns.push_back({name_of(c), std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
table.columns.push_back({name_of(c), unit_of(c),
std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
}
return table;
}
@@ -177,7 +266,7 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
if (ch >= model.channel_count()) continue;
std::vector<f32> values(static_cast<std::size_t>(n));
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;
}
@@ -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) {
ExportTable::Column column;
column.name = channels[c].name;
column.unit = channels[c].unit;
column.values = snapshot.values[c];
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";
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
// 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);
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";
for (const std::string& label : labels) line += "," + label;
line += "\n";
@@ -302,4 +392,256 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
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
+28
View File
@@ -21,6 +21,11 @@ namespace udpscope {
struct ExportTable {
struct Column {
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`
};
@@ -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.
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
-78
View File
@@ -1,78 +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, then the pane switches -- signal list, cursors, measurements, math, side panel -- and
the menu, which holds Fit to data, the layout editor, Export, Keyboard shortcuts, Help and
About.
• <b>The side panel</b> -- the trigger, cursors, measurements and math panes at the top, then
the signal list: one row per signal with its colour, an enable switch, its name and its unit.
Signals are dragged from here onto a plot row.
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
the axis pinned to the event, and the trigger marker at the edge.
<b>The first five minutes</b>
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
second while the first window fills -- the buffer holds only what has arrived.
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
a second view of it. The colour is per signal, so it is the same one wherever it appears.
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
a decade. The trigger's event window is a share of this span, so a window change moves it.
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
you can zoom in on it. Disarming mid-capture keeps what the capture got.
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
<b>show on plot</b> marks it as a ruler or a pair of markers.
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
<b>Worth knowing</b>
• A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
• The window remembers itself. Rows, traces, the time window, calibration, which signals were
enabled, the math channels with their graphs, and the measurements are written per host and
port, and reloaded the next time you open that source.
• A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
signal back as a slow one, which looks entirely plausible on screen.
• Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
the pointer's reach. The cursors are yours to drag; those are not.
-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 <cstdio>
#include <functional>
#include <string>
#include <vector>
#include "build_info.hpp"
#include "layout_store.hpp"
#include "shortcuts.hpp"
// Both come from CMakeLists: the document identifier is derived from the desktop file's
// basename (its desktop-file id), and the directory is where the Mallard set lives in the
// source tree, which is what the self-check scans. Yelp resolves `help:<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 {
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
/// One pointer per window, file-scope because there is one application.
///
/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
/// `::closed` and closes with `adw_dialog_close()`.
GtkWidget* g_help = nullptr;
/// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the
/// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by
/// the window's own close handler: the page switch reads them, and it cannot run after the window is
/// gone.
GtkBuilder* g_help_ui = nullptr;
GtkWidget* g_help_body = nullptr;
GtkWidget* g_help_shots = nullptr;
/// Nulled by each dialog's own close handler, so a second open cannot present a dead widget.
/// One pointer per dialog, file-scope because there is one application. The help manual is no
/// longer one of these: it opens in a separate viewer process through the `help:` URI, and a
/// process with no window of ours has nothing to null.
AdwDialog* g_about = nullptr;
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
/// The overview is built from a `.ui` file, so its builder is held too: the window belongs to
/// the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
/// builder is replaced on the next opening rather than reused.
GtkWidget* g_shortcuts = nullptr;
GtkBuilder* g_shortcuts_ui = nullptr;
/// The help window's own close handler, rather than the shared one: this window also owns the label a
/// page is written into and the column its screenshots are built in, and a page switch that ran after
/// the window was gone would read both.
gboolean on_help_close(GtkWindow* window, gpointer) {
(void)window;
g_help = nullptr;
g_help_body = nullptr;
g_help_shots = nullptr;
return FALSE; // let the default handler destroy it
}
gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window;
*static_cast<GtkWidget**>(slot) = nullptr;
@@ -58,100 +46,58 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
*static_cast<AdwDialog**>(slot) = nullptr;
}
struct HelpPage {
const char* section; ///< the group heading in the sidebar
const char* title; ///< the row's label and the page's heading
const char* resource; ///< a path under /org/udpscope/help
/// The screenshots shown above the text, as resource paths and a null terminator. A page
/// with none is the common case and the reason this is a list rather than one field: the
/// overview carries two, the window and a held capture.
const char* const* images = nullptr;
/// What an asynchronous launch needs to survive the call: the window it is attributed to (held,
/// because the call outlives the stack frame that made it) and the caller's reporter, if it asked
/// for one. One small struct rather than two `gpointer`s, because a callback that takes both through
/// one `user_data` still has to get them there.
struct HelpLaunch {
GtkWindow* parent = nullptr;
std::function<void(bool, const std::string&)> done;
};
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
constexpr int kHelpShotHeight = 260;
/// The screenshots each page carries, in the order it shows them.
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
"/org/udpscope/help/img/trigger-capture.png",
nullptr};
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
nullptr};
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
constexpr HelpPage kPages[] = {
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots},
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots},
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
};
constexpr int kPageCount = static_cast<int>(sizeof(kPages) / sizeof(kPages[0]));
/// The page's markup, or a one-line stand-in. `ok` reports whether the real body loaded
/// *and* parsed: a missing resource and markup Pango rejects both render the stand-in,
/// because a help window that fails to open is worse than one page that says so.
std::string load_page(const HelpPage& page, bool* ok) {
*ok = false;
/// The launch failed. Report it rather than leaving a click that silently did nothing -- and say
/// *which* thing is missing: `gtk_uri_launcher_launch_finish()` reports that the URI could not be
/// handed over, which is the handler's absence, while a document that is not installed is a
/// different failure the launcher cannot tell it from. `help_installed_path()` settles that from the
/// 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
/// expected path and the install command -- and a viewer as well, because that second possibility
/// cannot be ruled out from here.
void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
auto* launch = static_cast<HelpLaunch*>(user);
GError* error = nullptr;
GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE,
&error);
if (bytes == nullptr) {
if (error != nullptr) g_error_free(error);
return "<i>This page is missing from the build.</i>";
}
gsize size = 0;
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
std::string body(data, size);
g_bytes_unref(bytes);
if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) {
if (error != nullptr) g_error_free(error);
return "<i>This page could not be displayed: its markup is malformed.</i>";
}
*ok = true;
return body;
}
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer) {
if (row == nullptr || g_help_body == nullptr) return;
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
if (idx < 0 || idx >= kPageCount) return;
bool ok = false;
const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(g_help_body), body.c_str());
// The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than
// being found as the label's previous sibling: refilling it is a page *switch*, not a frame, so
// rebuilding it here is cheap and is also the only way to be sure the pictures are this page's and
// not the last one's.
GtkWidget* shots = g_help_shots;
if (shots != nullptr && GTK_IS_BOX(shots)) {
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
gtk_box_remove(GTK_BOX(shots), old);
const bool ok = gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error);
if (!ok) {
const std::string installed = help_installed_path();
GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
std::string detail = error != nullptr ? error->message : std::string("the launch failed");
detail += "\n\n";
if (!installed.empty()) {
detail += "The manual is installed at " + installed +
", so what is missing is a viewer for the `help:` URI it is opened with: install "
"one (for example `yelp`) or make it the desktop's default for that scheme.";
} else {
// The system-wide install is named *first* on purpose: a set under the user's data
// directory is one a sandboxed viewer (Yelp) cannot read its figures from -- the pages
// 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.
detail += "The manual does not look installed: nothing named \"help:" UDPSCOPE_HELP_NAME
"\" 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 "
"your data directory instead: a sandboxed viewer shows those pages without their "
"figures. A `help:` viewer (for example `yelp`) is needed as well.";
}
for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) {
GtkWidget* shot = gtk_picture_new_for_resource(*img);
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
// A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may
// shrink asks for no height at all in a box, so without this it is allocated nothing and
// the page shows its text with a gap where the screenshots should be.
gtk_widget_set_size_request(shot, -1, kHelpShotHeight);
gtk_box_append(GTK_BOX(shots), shot);
}
gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr);
gtk_alert_dialog_set_detail(alert, detail.c_str());
gtk_alert_dialog_show(alert, launch->parent);
g_object_unref(alert);
}
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
@@ -195,86 +141,81 @@ const char* about_icon_name() {
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 report;
report.pages = kPageCount;
for (const HelpPage& page : kPages) {
bool ok = false;
const std::string body = load_page(page, &ok);
if (ok && !body.empty()) ++report.parsed;
if (report.shortest_title.empty() || body.size() < report.shortest_bytes) {
report.shortest_title = page.title;
report.shortest_bytes = body.size();
// The manual is a set of files on disk now, so the reading is a file-system one: a missing
// directory leaves `topics=0 index=false`, which is a manual that is not where the build
// says it is -- and Yelp would show nothing for `help:<identifier>`.
GError* error = nullptr;
GDir* dir = g_dir_open(UDPSCOPE_HELP_DIR, 0, &error);
if (dir == nullptr) {
if (error != nullptr) g_error_free(error);
return report;
}
while (const char* name = g_dir_read_name(dir)) {
if (!g_str_has_suffix(name, ".page")) continue;
if (g_strcmp0(name, "index.page") == 0) report.index = true;
else ++report.topics;
}
g_dir_close(dir);
// The figures the pages reference, counted for the same reason: an image that went missing
// is a broken figure in the viewer rather than a failed build.
const std::string figures = std::string(UDPSCOPE_HELP_DIR) + "/figures";
if (GDir* figdir = g_dir_open(figures.c_str(), 0, &error)) {
while (const char* name = g_dir_read_name(figdir)) {
if (g_str_has_suffix(name, ".png") || g_str_has_suffix(name, ".jpg")) ++report.figures;
}
g_dir_close(figdir);
} else if (error != nullptr) {
g_error_free(error);
}
return report;
}
void show_help(GtkWindow* parent) {
if (g_help != nullptr) {
gtk_window_present(GTK_WINDOW(g_help));
return;
}
// A builder per opening, as with every other `.ui` here: the window belongs to its object list and
// `close-request` destroys the window, so the resource is parsed again rather than a dead window
// fetched. This is where the previous one goes, and it is safe because that window has been closed
// for as long as it takes someone to ask for help again.
g_clear_object(&g_help_ui);
g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui");
if (g_help_ui == nullptr) return;
GObject* window = gtk_builder_get_object(g_help_ui, "help_window");
GObject* list = gtk_builder_get_object(g_help_ui, "page_list");
GObject* body = gtk_builder_get_object(g_help_ui, "body");
GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box");
// A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a
// null widget on the first page switch.
if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return;
g_help = GTK_WIDGET(window);
g_help_body = GTK_WIDGET(body);
g_help_shots = GTK_WIDGET(shots);
gtk_window_set_transient_for(GTK_WINDOW(g_help), parent);
// The rows are the pages, which is data and not template: a heading row per section, then one row per
// page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
// what a GtkListView with one item factory can draw.
const char* current_section = nullptr;
for (int i = 0; i < kPageCount; ++i) {
if (current_section == nullptr || std::string(current_section) != kPages[i].section) {
current_section = kPages[i].section;
GtkWidget* heading = gtk_label_new(nullptr);
char markup[128];
std::snprintf(markup, sizeof(markup), "<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_help(GtkWindow* parent, std::function<void(bool, const std::string&)> done) {
// The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
// GNOME. The launch is asynchronous, so both the parent and the reporter are carried across it in
// a heap `HelpLaunch` and released in the callback; `parent` may legitimately be nullptr (the
// chooser runs before any window exists).
auto* launch = new HelpLaunch;
launch->parent = parent != nullptr ? GTK_WINDOW(g_object_ref(parent)) : nullptr;
launch->done = std::move(done);
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched, launch);
g_object_unref(launcher);
}
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
// section per page, with the sidebar and the search box drawn by the window itself.
//
// A UI file rather than widget construction because of the mouse rows. An accelerator cannot spell
// "Ctrl and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget --
// has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic
// gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps
// would be. (The widget is deprecated since 4.20, but this uses none of its functions: the file is
// loaded as data and presented as a `GtkWindow`, so the build stays at its four warnings.)
// section per page, with the sidebar and the search box drawn by the window itself. A UI file
// rather than widget construction because of the mouse rows: an accelerator cannot spell "Ctrl
// and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement -- has no icon
// property, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic gesture type
// draws the `icon` the row names. (The widget is deprecated since 4.20, but this uses none of
// its functions: the file is loaded as data and presented as a `GtkWindow`.)
//
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
// destroys the window, so the resource is parsed again rather than an already-dead window fetched.
// destroys the window, so the resource is parsed again rather than an already-dead window
// fetched.
g_clear_object(&g_shortcuts_ui);
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
if (g_shortcuts_ui == nullptr) return;
@@ -307,7 +247,6 @@ void show_shortcuts(GtkWindow* parent) {
}
void close_help_windows() {
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
if (g_about != nullptr) adw_dialog_close(g_about);
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
}
+35 -15
View File
@@ -4,6 +4,7 @@
#include <gtk/gtk.h>
#include <cstddef>
#include <functional>
#include <string>
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.
const char* about_icon_name();
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
/// the scope runs. Already open: presented.
void show_help(GtkWindow* parent);
/// Opens the installed Mallard manual in the desktop help viewer — Yelp, through the
/// `help:` URI the freedesktop Help System Specification defines — rather than drawing a
/// window of our own. `parent` is the window the launch is attributed to (and the one an
/// error is reported on); the viewer is a separate process, so nothing here can be closed
/// with `close_help_windows()`.
///
/// `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
/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`.
/// Where `help:<id>` resolves to on this system: the first of `$XDG_DATA_HOME/help/<lang>/<id>/`
/// 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 {
int pages = 0;
int parsed = 0;
std::string shortest_title;
std::size_t shortest_bytes = 0;
int topics = 0;
bool index = false;
int figures = 0;
};
/// Loads and validates every page without building a window. Safe to call headless.
/// Scans the manual's directory without opening a viewer. Safe to call headless.
HelpReport help_self_check();
/// The shortcut overview: the application accelerators and the plot's own interactions,
@@ -39,11 +59,11 @@ HelpReport help_self_check();
/// deprecated as of GTK 4.18 and this project builds warning-free.
void show_shortcuts(GtkWindow* parent);
/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
/// what prevents a second open presenting a dead widget. Used by the probe.
/// Closes whichever of the two dialogs are open. Both are `AdwDialog`s, which are not
/// `GtkWindow`s: they close with `adw_dialog_close()`, which emits `::closed`, and the
/// file-scope pointer is nulled by the handler that prevents a second open presenting a dead
/// widget. There is no help window left to close -- the manual opens in a separate viewer
/// process. Used by the probe.
void close_help_windows();
} // namespace udpscope
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A sigma: the one pane that computes rather than reads. Four filled subpaths, all
wound clockwise so the default nonzero rule unions them where they overlap; the
diagonals run from the bars' left ends to a vertex right of centre, which is the
way the glyph actually points. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M3 2 H13 V4 H3 Z
M3 12 H13 V14 H3 Z
M4.64 2.23 L10.64 7.23 L9.36 8.77 L3.36 3.77 Z
M10.64 8.77 L4.64 13.77 L3.36 12.23 L9.36 7.23 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 762 B

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A boundary that can be dragged sideways: a horizontal double-headed arrow, the turned twin of
udpscope-resize-rows-symbolic. It says *which* edge the handle moves, which a single chevron (a
"go next") does not. Every coordinate is a whole pixel of the 16px cell, every subpath is a filled
`path` wound the same way (a stroke would keep this colour on a dark theme), and the two heads
meet the shaft exactly, at y 5..11 and y 7..9. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M4 7 H12 V9 H4 Z
M1 8 L5 5 V11 Z
M15 8 L11 11 V5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -1,16 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A rising edge crossing a dashed trigger level: what the trigger *is*. The dash
rhythm is 2 on, 1 off (tight enough to read as one line rather than as a row of
dots), with the one wide gap at x 5..9 that the edge rises through. Every
coordinate is a whole pixel of the 16px cell so nothing blurs at 1x. -->
<!-- The trigger: a sine and the dashed level it is looked for at, which is what a trigger *is* on a
waveform that is not a step. It used to be a rising edge, and one edge names only one of the four
shape triggers the panel offers — a wave crossing a level names the trigger's job instead. One
period, one-pixel stairs, the way the plot rasterises its own traces, with the level through the
centre so the crest and the trough stand clear and the crossings fall on the steep parts. Inset
two pixels, like the tabs' glyphs, so no art is flush with an edge a button might clip. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M0 11 H8 V13 H0 Z
M6 3 H8 V13 H6 Z
M6 3 H16 V5 H6 Z
M0 7 H2 V9 H0 Z
M3 7 H5 V9 H3 Z
M9 7 H11 V9 H9 Z
M12 7 H14 V9 H12 Z"/>
<path fill="#2e3436" d="M2 7 H3 V10 H2 Z
M3 5 H4 V8 H3 Z
M4 3 H5 V6 H4 Z
M5 3 H6 V6 H5 Z
M6 4 H7 V7 H6 Z
M7 6 H8 V9 H7 Z
M8 8 H9 V11 H8 Z
M9 10 H10 V13 H9 Z
M10 11 H11 V14 H10 Z
M11 11 H12 V14 H11 Z
M12 9 H13 V12 H12 Z
M13 7 H14 V10 H13 Z
M2 7 H5 V9 H2 Z
M7 7 H10 V9 H7 Z
M11 7 H14 V9 H11 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 857 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -20,9 +20,9 @@
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-cols-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
</gresource>
+165 -13
View File
@@ -4,6 +4,7 @@
#include "layout_store.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
@@ -55,12 +56,23 @@ namespace {
std::string toml_escape(const std::string& s) {
std::string out = "\"";
for (char c : s) {
if (c == '"' || c == '\\') out.push_back('\\');
out.push_back(c);
// The file is read line by line, so a raw newline in a value would end the line early.
if (c == '\n') {
out += "\\n";
} else if (c == '\r') {
out += "\\r";
} else {
if (c == '"' || c == '\\') out.push_back('\\');
out.push_back(c);
}
}
return out + "\"";
}
/// The character an escape `\x` stands for: `\n` and `\r` are the two line breaks, anything else
/// (the quote, the backslash) stands for itself.
char unescaped(char c) { return c == 'n' ? '\n' : c == 'r' ? '\r' : c; }
bool unescape(std::string& s) {
std::string out;
for (std::size_t i = 0; i < s.size(); ++i) {
@@ -69,8 +81,8 @@ bool unescape(std::string& s) {
continue;
}
if (++i >= s.size()) return false;
if (s[i] == '"' || s[i] == '\\') {
out.push_back(s[i]);
if (s[i] == '"' || s[i] == '\\' || s[i] == 'n' || s[i] == 'r') {
out.push_back(unescaped(s[i]));
} else {
return false;
}
@@ -317,11 +329,15 @@ std::vector<KnownSource> known_sources() {
s.port = port;
s.config = name;
s.comment = st.comment;
s.buffer_mb = st.buffer_mb;
s.buffer_samples = st.buffer_samples;
s.relative_time = st.relative_time;
s.capture_packets = st.capture_packets;
s.multicast = st.multicast;
s.interface_addr = st.interface_addr;
if (!st.source_kind.empty()) {
s.kind = st.source_kind;
s.opts = st.source_opts;
}
s.rows = static_cast<int>(st.rows.size());
for (const LayoutState::Row& row : st.rows) {
for (const std::string& sig : row.signals) {
@@ -332,7 +348,7 @@ std::vector<KnownSource> known_sources() {
// session file per probe run and every one holds `signals = ["", "", "", ""]`. One that
// carries a *setting* is worth a row even before it has ever streamed -- that is how a host is
// set up from the chooser's form, and the settings are the whole point of editing one.
const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() ||
const bool configured = s.signals > 0 || s.buffer_samples > 0 || !st.comment.empty() ||
st.relative_time || st.capture_packets > 0;
if (!configured) continue;
struct stat fb {};
@@ -424,6 +440,7 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.window_s = 0.0;
st.rows.clear();
st.calibration.clear();
st.units.clear();
st.colors.clear();
st.enabled.clear();
st.math.clear();
@@ -431,7 +448,7 @@ bool load_layout(const std::string& path, LayoutState& out) {
std::string line;
// What the last `[[...]]` header opened, so keys land in the right record.
enum class Section { Top, Plot, Calibration, Color, Math, Measure } section = Section::Top;
enum class Section { Top, Plot, Calibration, Unit, Color, Math, Measure } section = Section::Top;
int plot_count = 0;
bool plot_section_seen = false;
while (std::getline(in, line)) {
@@ -445,6 +462,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
} else if (t == "[[calibration]]") {
section = Section::Calibration;
st.calibration.emplace_back();
} else if (t == "[[unit]]") {
section = Section::Unit;
st.units.emplace_back();
} else if (t == "[[color]]") {
section = Section::Color;
st.colors.emplace_back();
@@ -468,14 +488,71 @@ bool load_layout(const std::string& path, LayoutState& out) {
std::string s = v.substr(1, v.size() - 2);
std::string out;
for (std::size_t i = 0; i < s.size(); ++i) {
if (s[i] == '\\' && i + 1 < s.size()) ++i;
out.push_back(s[i]);
if (s[i] == '\\' && i + 1 < s.size()) {
out.push_back(unescaped(s[++i]));
} else {
out.push_back(s[i]);
}
}
return out;
}
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);
} else if (k == "persistence") {
double n = 0.0;
num(n);
t.persistence = static_cast<int>(n);
} else if (k == "density") {
t.density = v == "true";
} else if (k == "density_decay") {
t.density_decay = v == "time" ? "time" : "acquisitions";
} else if (k == "density_tau") {
num(t.density_tau_s);
} else if (k == "density_factor") {
num(t.density_factor);
}
};
if (section == Section::Top) {
if (key == "window_s") {
try {
@@ -496,15 +573,26 @@ bool load_layout(const std::string& path, LayoutState& out) {
}
} else if (key == "measure_stats") {
st.measure_stats = value == "true";
} else if (key == "dock") {
st.dock_shown = value == "true";
} else if (key.rfind("trigger_", 0) == 0) {
trigger_key(st.trigger, key.substr(8), strip_quote(value));
} else if (key == "multicast") {
st.multicast = strip_quote(value);
} else if (key == "interface") {
st.interface_addr = strip_quote(value);
} else if (key == "source_kind") {
st.source_kind = strip_quote(value);
} else if (key.rfind("source_opt_", 0) == 0) {
const std::string k = key.substr(11);
if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value));
} else if (key == "comment") {
st.comment = strip_quote(value);
} else if (key == "buffer_mb") {
} else if (key == "buffer_samples") {
try {
st.buffer_mb = std::stod(value);
// A negative or absurd count is "not stored", not a request: `stoull` wraps a minus sign.
const unsigned long long n = value.find('-') == std::string::npos ? std::stoull(value) : 0;
st.buffer_samples = n >= 128 && n <= 1000000000000ull ? n : 0;
} catch (...) {
}
} else if (key == "relative_time") {
@@ -524,6 +612,15 @@ bool load_layout(const std::string& path, LayoutState& out) {
st.rows.back().signals = names;
} else if (key == "weight") {
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
} else if (key == "row") {
try {
const int r = std::stoi(value);
if (r >= 0) st.rows.back().row = r;
} catch (...) {
}
} else if (key == "width") {
const double w = std::strtod(value.c_str(), nullptr);
if (std::isfinite(w) && w > 0.0) st.rows.back().width = w;
}
} else if (section == Section::Math && !st.math.empty()) {
MathChannel& m = st.math.back();
@@ -566,6 +663,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
m.options.threshold_pct = std::stod(value);
} catch (...) {
}
} else if (key == "threshold2") {
// The band's other edge. A file written before `Between` existed has no such key, and the
// struct's own default is what such a measurement then reads -- which is the same value the
// dialog offers a new one.
try {
m.options.threshold_pct2 = std::stod(value);
} catch (...) {
}
} else if (key == "noise") {
try {
m.options.noise_pct = std::stod(value);
@@ -595,6 +700,12 @@ bool load_layout(const std::string& path, LayoutState& out) {
} catch (...) {
}
}
} else if (section == Section::Unit && !st.units.empty()) {
if (key == "name") {
st.units.back().name = strip_quote(value);
} else if (key == "unit") {
st.units.back().unit = strip_quote(value);
}
} else if (section == Section::Color && !st.colors.empty()) {
if (key == "name") {
st.colors.back().name = strip_quote(value);
@@ -633,6 +744,12 @@ bool save_layout(const std::string& path, const LayoutState& state) {
if (!state.interface_addr.empty()) {
o << "interface = " << toml_escape(state.interface_addr) << "\n";
}
if (!state.source_kind.empty() && state.source_kind != "udps") {
o << "source_kind = " << toml_escape(state.source_kind) << "\n";
for (const auto& kv : state.source_opts.items()) {
o << "source_opt_" << kv.first << " = " << toml_escape(kv.second) << "\n";
}
}
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
// never saw it and a session's enable state was silently lost.
@@ -645,12 +762,43 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "]\n";
}
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
o << "dock = " << (state.dock_shown ? "true" : "false") << "\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";
o << "trigger_persistence = " << state.trigger.persistence << "\n";
// The phosphor density, the display that is an image rather than a set of traces. Its four fields
// are written whether or not it is on, like every other trigger setting: a display set up once and
// switched off is still the display you come back to.
o << "trigger_density = " << (state.trigger.density ? "true" : "false") << "\n";
o << "trigger_density_decay = " << toml_escape(state.trigger.density_decay) << "\n";
o << "trigger_density_tau = " << state.trigger.density_tau_s << "\n";
o << "trigger_density_factor = " << state.trigger.density_factor << "\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_samples > 0) o << "buffer_samples = " << state.buffer_samples << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
for (const auto& row : state.rows) {
o << "\n[[plot]]\nweight = " << row.weight << "\nsignals = [";
o << "\n[[plot]]\n";
// A row of -1 is a state built without the split (nothing in the app does that now); leaving the
// key out keeps it readable as the one-cell-per-row file it is.
if (row.row >= 0) o << "row = " << row.row << "\nwidth = " << row.width << "\n";
o << "weight = " << row.weight << "\nsignals = [";
for (std::size_t i = 0; i < row.signals.size(); ++i) {
if (i != 0) o << ", ";
o << toml_escape(row.signals[i]);
@@ -661,6 +809,9 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
<< "\noffset = " << cal.offset << "\n";
}
for (const auto& u : state.units) {
o << "\n[[unit]]\nname = " << toml_escape(u.name) << "\nunit = " << toml_escape(u.unit) << "\n";
}
for (const auto& color : state.colors) {
char rgb[16];
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
@@ -687,6 +838,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
}
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
<< "\nthreshold = " << m.options.threshold_pct
<< "\nthreshold2 = " << m.options.threshold_pct2
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
<< "\nhf = " << m.options.hf_fc << "\n";
+85 -4
View File
@@ -8,6 +8,7 @@
#include "math_channel.hpp"
#include "measure.hpp"
#include "source/source_config.hpp"
namespace udpscope {
@@ -23,6 +24,13 @@ struct LayoutState {
/// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a
/// user dragged is an arrangement, and an arrangement that is not saved is one that is lost.
double weight = 1.0;
/// The figure row this plot sits in; plots of one row are the cells of a horizontal split, in
/// order. -1 means the file had no `row` key (a session from before the split), which loads as
/// one cell per row.
int row = -1;
/// This cell's share of its row's width, 1 for equal cells. `weight` is the *row's* and is
/// written on every cell of the row; `width` is the cell's own.
double width = 1.0;
};
double window_s = 0.0; ///< 0 keeps the default window
@@ -35,6 +43,13 @@ struct LayoutState {
};
std::vector<Calibration> calibration;
/// The unit names the user gave streamed signals, by signal name. Only the overrides.
struct Unit {
std::string name;
std::string unit;
};
std::vector<Unit> units;
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
/// without a session having to name them. Saved by name like every identity here.
@@ -54,6 +69,12 @@ struct LayoutState {
/// unicast; empty `interface_addr` is the default route.
std::string multicast;
std::string interface_addr;
/// Which source kind this session belongs to. Empty means `udps`: every file written before the
/// plugins existed. Only a kind other than `udps` is written, so a UDPS session file is
/// byte-identical to what earlier versions wrote.
std::string source_kind;
/// That kind's settings (`SourceConfig`), stored as top-level `source_opt_<key>` entries.
SourceConfig source_opts;
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
/// process. A graph is stored as one string per node so the file stays in this store's TOML
@@ -66,9 +87,64 @@ struct LayoutState {
/// stream and mean nothing restored against another one.
bool measure_stats = false;
/// The dock's shown state (the user's last word on the toolbar's Dock button). The one thing the dock
/// persists -- its 320 px width is the `.ui`'s. An older file's `side_tab` key is not read: the parser
/// ignores keys it does not know.
bool dock_shown = true;
/// The measurements pane, by signal *name* and with the type and options a row was built with.
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;
/// How many of the most recent acquisitions the trigger mode keeps on screen (1 = the event
/// alone). Part of the trigger's own setup, like the window it captures and the shape it waits
/// for: it is what the display does with the acquisitions, and there is no second switch.
///
/// This is the **multiple event** display -- N retained acquisitions, drawn as N traces. The
/// phosphor density beside it is a different display with a different cost (one bounded image a
/// trace against N window copies), which is why it has its own four fields below and why this
/// one keeps the key it has always had: an older file's `trigger_persistence` is still the
/// multiple-event depth.
int persistence = 1;
/// The **phosphor density** (`TriggerConfig::density` and the three beside it): whether the
/// trigger-mode display accumulates every acquisition into one image, what it fades by, and the
/// constant or factor that fade uses. Persisted because it is a display the user set up and
/// comes back to -- the trigger settings around it already are.
bool density = false;
/// "acquisitions" | "time": the same two words the panel's dropdown shows, for the reason the
/// shapes above are stored as text -- an index would tie the file to a declaration order.
std::string density_decay = "acquisitions";
double density_tau_s = 1.0;
double density_factor = 0.5;
/// 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.
/// A configuration is a *named* session, and the name alone rarely says what makes it different
/// from its neighbour.
@@ -77,9 +153,12 @@ struct LayoutState {
/// The startup settings the configuration carries, applied when the chooser opens it -- which is
/// why they live here and not only on the command line: two sources on one machine want different
/// buffers and different clocks, and retyping the flags to look at the second one is how a
/// setting gets forgotten. A zero `buffer_mb` and an empty `capture_packets` mean "not stored":
/// the command line's value stands, so an old file cannot silently shrink a buffer.
double buffer_mb = 0.0;
/// setting gets forgotten. A zero `buffer_samples` and an empty `capture_packets` mean "not stored":
/// the command line's value stands, so an old file cannot silently shrink a buffer. The budget is
/// samples per acquired signal (the memory limit is the application's, not a source's). A file from
/// before that unit carries `buffer_mb`, which is not read: it reads as "not stored" and the
/// default stands, rather than a megabyte count being taken for a sample count.
std::uint64_t buffer_samples = 0;
bool relative_time = false;
std::uint32_t capture_packets = 0;
};
@@ -90,6 +169,8 @@ struct KnownSource {
std::uint16_t port = 0;
std::string multicast; ///< empty = unicast
std::string interface_addr; ///< empty = the default route
std::string kind = "udps"; ///< the session's source kind
SourceConfig opts; ///< that kind's settings (empty for UDPS, whose fields are above)
int rows = 0; ///< plots the session had
int signals = 0; ///< named slots across those plots
std::int64_t mtime = 0; ///< orders the list; never shown
@@ -98,7 +179,7 @@ struct KnownSource {
std::string config;
std::string comment; ///< the configuration's note, shown beside its name
/// The startup settings this configuration carries, so the chooser can apply them.
double buffer_mb = 0.0;
std::uint64_t buffer_samples = 0;
bool relative_time = false;
std::uint32_t capture_packets = 0;
};
+36 -3
View File
@@ -7,36 +7,69 @@
#include "waveform.hpp"
#include "model.hpp"
#include "plot_host.hpp"
#include "source/registry.hpp"
#include "source_dialog.hpp"
#include "ui.hpp"
#include <glib.h>
#include <cstdio>
#include <exception>
int main(int argc, char** argv) {
udpscope::Config cfg;
try {
udpscope::register_builtin_sources();
cfg = udpscope::parse_args(argc, argv);
// The preferences fill in what the command line did not decide. `--relative-time` is an explicit
// instruction for this run, so a preference can only turn the mode on, never off -- which is also
// why this is read here, before the buffers are planned: the mode changes how they are filled.
if (udpscope::load_settings().relative_time) cfg.relative_time = true;
const udpscope::Settings prefs = udpscope::load_settings();
if (prefs.relative_time) cfg.relative_time = true;
// The memory limit is the same kind of statement, the other way round: the flag is the explicit
// instruction for this run and the preference only fills in when it was absent.
if (!cfg.memory_limit_specified) cfg.memory_limit_mb = static_cast<std::uint64_t>(prefs.memory_limit_mb);
} catch (const std::exception& e) {
std::fprintf(stderr, "udpscope: %s\n", e.what());
return 2;
}
// `--renderer` is exported here and nowhere else, because GSK reads `GSK_RENDERER` when it
// *creates* its renderer: inside the first `adw_init()`/`gtk_init()`, which `choose_source()`
// below may call. Setting it after that point is a silent no-op, so the one place it can be
// honoured is before anything touches GTK. Overwrite: a flag on the command line is a
// statement about this run, and the environment is only a default it may override.
//
// `cairo` is the whole software preset and not just GSK's half: the plot is a `GtkGLArea`,
// which asks GDK for a GL context of its own whatever GSK draws with. Over X11 forwarding
// that request is what leaves the window unallocated (no `buffer allocated:` line, zero
// width) once the Vulkan swapchain failure is out of the way, so `LIBGL_ALWAYS_SOFTWARE`
// makes Mesa answer it with llvmpipe. This is the pair `tools/make-help-shots.sh` sets.
if (!cfg.renderer.empty()) g_setenv("GSK_RENDERER", cfg.renderer.c_str(), TRUE);
if (cfg.renderer == "cairo") g_setenv("LIBGL_ALWAYS_SOFTWARE", "1", TRUE);
// The startup half of the renderer probe, printed before GTK exists: the request and the
// variables this process actually exported. The window's `[renderer] realized=` line is the
// other half and says which renderer GTK built.
if (g_getenv("UDPSCOPE_PROBE_RENDERER") != nullptr) {
const char* gsk = g_getenv("GSK_RENDERER");
const char* gl = g_getenv("LIBGL_ALWAYS_SOFTWARE");
std::fprintf(stderr, "[renderer] option=%s GSK_RENDERER=%s LIBGL_ALWAYS_SOFTWARE=%s\n",
cfg.renderer.empty() ? "(unset)" : cfg.renderer.c_str(),
gsk != nullptr ? gsk : "(unset)", gl != nullptr ? gl : "(unset)");
std::fflush(stderr);
}
// No `--server` means the user has not said which source to open, so ask. Before the model:
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
// `--open` is the other answer to that question, and the chooser knows it: its Waveforms page
// fills `open_waveform` in instead of the connection fields.
const bool asked = !cfg.server_specified && cfg.open_waveform.empty();
const bool asked = !cfg.has_source() && cfg.open_waveform.empty();
if (asked && !udpscope::choose_source(cfg)) return 0;
// A source named on the command line skips the chooser, but its *stored* configuration still
// applies: the buffer size, the clock and the capture ring are decided here, before the model --
// which is the only moment they can be -- and a saved host whose buffer was ignored because the
// address came from the command line is an edit that looks like it never took effect.
if (cfg.server_specified) udpscope::apply_stored_settings(cfg);
if (cfg.has_source()) udpscope::apply_stored_settings(cfg);
// Everything from here to `gtk_window_present()` inside `run_ui()` runs with no main loop: the
// buffer is allocated, a recording is read, then the whole UI is built. The chooser's window is
+242 -91
View File
@@ -14,24 +14,27 @@ namespace {
/// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge
/// records -- an interval's width is the distance from the edge that entered it to the edge that
/// left it, which is the pairing the pulse duration averages.
void enter_side(MeasureLevel& acc, std::size_t e, double t) {
const std::size_t other = 1 - e;
if (acc.enters[e] == 0) acc.first_enter[e] = t;
acc.last_enter[e] = t;
++acc.enters[e];
if (acc.open[other]) {
acc.width_sum[other] += t - acc.open_at[other];
++acc.widths[other];
acc.open[other] = false;
}
acc.open_at[e] = t;
acc.open[e] = true;
}
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
// setting now, and a second copy of the number here would be a second thing to keep in step.
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
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
@@ -116,14 +119,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;
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) {
const MeasureBucket& k = buckets[b];
// 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.
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;
finite += k.count;
if (!s.valid) {
@@ -147,6 +165,27 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
}
s.sum += sum;
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,
@@ -187,69 +226,96 @@ void measure_accumulate(const MeasureChunk& chunk,
}
}
bool measure_accumulate_bucket(const MeasureBucket& k,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& acc) {
if (k.count == 0) return true;
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false;
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
const bool above = k.lo > acc.level;
const bool below = k.hi < acc.level;
if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u;
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
// whose position is inside the bucket is refused.
const double top = acc.level + acc.hysteresis;
const double bot = acc.level - acc.hysteresis;
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
// raw sample inside it decides none, so the walk state is left alone.
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
// the bucket's first sample, exactly, which is where the raw walk would have found it.
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
// which adds the same samples again. Sums added above the refusal double-counted every opened
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
if (change && !fully_outside) return false;
acc.sum[e] += k.sum;
acc.count[e] += k.count;
if (change) enter_side(acc, e, time_of(k.offset));
if (reaches) {
acc.side = e != 0;
acc.have_side = 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 true;
return out;
}
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
int measure_level_pcts(MeasureType type, const MeasureOptions& options, double pct[2]) {
const auto share = [](double v) { return std::clamp(v, 0.0, 100.0) / 100.0; };
switch (type) {
case MeasureType::Top:
case MeasureType::Bottom:
pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0;
return true;
case MeasureType::Above:
case MeasureType::Below:
pct[0] = share(options.threshold_pct);
return 1;
case MeasureType::Between: {
// The band's two edges, clamped and *ordered*: the shares name a lower and an upper edge, so
// "between 80 % and 20 %" is the band between 20 % and 80 %. Normalizing rather than refusing
// is the honest reading of a band -- the set of samples does not depend on which edge the user
// typed first -- and the two accumulators the pair is answered from are keyed on these ordered
// values, so the same band is one walk however it was written.
const double a = share(options.threshold_pct);
const double b = share(options.threshold_pct2);
pct[0] = std::min(a, b);
pct[1] = std::max(a, b);
return 2;
}
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency:
// The *mid* level, for all three. No option here on purpose: a period is the spacing of
// one crossing per cycle, and a threshold near either extreme counts noise as crossings.
pct = 0.5;
return true;
pct[0] = 0.5;
return 1;
default:
return false;
return 0;
}
}
const char* measure_type_name(MeasureType type) {
switch (type) {
case MeasureType::Average: return "average";
@@ -262,11 +328,14 @@ const char* measure_type_name(MeasureType type) {
case MeasureType::PulseDuration: return "pulse duration";
case MeasureType::Period: return "period";
case MeasureType::Frequency: return "frequency";
case MeasureType::Above: return "above";
case MeasureType::Below: return "below";
case MeasureType::Between: return "between";
}
return "?";
}
int measure_type_count() { return 10; }
int measure_type_count() { return 13; }
MeasureType measure_type_at(int index) {
switch (index) {
@@ -279,15 +348,24 @@ MeasureType measure_type_at(int index) {
case 6: return MeasureType::Amplitude;
case 7: return MeasureType::PulseDuration;
case 8: return MeasureType::Frequency;
default: return MeasureType::Period;
case 9: return MeasureType::Period;
// Appended rather than filed beside `Top`/`Bottom`, where they read best in the dropdown:
// `layout_store` writes *this* index into the session file, so renumbering would turn a saved
// `max` into an `above` on the next start -- silently, and only for users who had one.
case 10: return MeasureType::Above;
case 11: return MeasureType::Below;
default: return MeasureType::Between;
}
}
bool measure_uses_threshold(MeasureType type) {
return type == MeasureType::Top || type == MeasureType::Bottom ||
type == MeasureType::PulseDuration;
type == MeasureType::PulseDuration || type == MeasureType::Above ||
type == MeasureType::Below || type == MeasureType::Between;
}
bool measure_uses_band(MeasureType type) { return type == MeasureType::Between; }
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
bool measure_uses_noise(MeasureType type) {
@@ -297,8 +375,8 @@ bool measure_uses_noise(MeasureType type) {
bool measure_can_show_on_plot(MeasureType) { return true; }
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
const MeasureOptions& options) {
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const levels[2],
MeasureType type, const MeasureOptions& options) {
MeasureResult out;
if (!b.valid) return out;
@@ -323,11 +401,11 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
out.valid = true;
return out;
}
case MeasureType::PeakToPeak:
case MeasureType::Amplitude: {
// Both come straight out of the window's own extremes, which every type already needs:
// the two rulers are the same pair, and the numbers differ by a factor of two.
out.primary = type == MeasureType::PeakToPeak ? b.hi - b.lo : 0.5 * (b.hi - b.lo);
case MeasureType::PeakToPeak: {
// Straight out of the window's own extremes, which every type already needs: this one *is*
// the extremes, ripple and overshoot included -- that is the difference between it and
// `Amplitude`, which reads the two levels the signal dwells at instead.
out.primary = b.hi - b.lo;
// 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.
out.marks.push_back({true, b.lo});
@@ -335,15 +413,88 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
out.valid = true;
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::Bottom: {
// The mean of one side of the level, straight out of the second pass' running sums. A flat
// window has no range at all, so every sample lands on the "below" side -- which is why
// `top` on a signal that never moves reads `--` rather than that one value.
if (level == nullptr) return out;
const std::size_t s = type == MeasureType::Top ? 1 : 0;
if (level->count[s] == 0) return out;
out.primary = level->sum[s] / static_cast<double>(level->count[s]);
// The flat level of one side of the split: the densest band there, whose samples' exact mean
// is the value. The band is a *value* band, so the reading does not follow the window's
// 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[2] = {0.0, 0.0};
if (measure_level_pcts(type, options, pct) == 0) return out;
const bool want_top = type == MeasureType::Top;
const MeasureFlatLevels flat = measure_flat_levels(b, pct[0]);
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 -- and they
// are what `Above`/`Below` report on *every* window.
const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
const std::size_t s = want_top ? 1 : 0;
if (lv == nullptr || lv->count[s] == 0) return out;
out.primary = lv->sum[s] / static_cast<double>(lv->count[s]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
case MeasureType::Above:
case MeasureType::Below: {
// The plain mean of the samples on one side of the share -- what `Top`/`Bottom` reported
// before they became flat levels, and a different question from them: a mean over a side
// carries the ripple and the edge's overshoot (0.986496 on the rippled square where the
// plateau is 1), which is exactly why the flat-level types exist beside these.
const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
const std::size_t s = type == MeasureType::Above ? 1 : 0;
if (lv == nullptr || lv->count[s] == 0) return out;
out.primary = lv->sum[s] / static_cast<double>(lv->count[s]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
case MeasureType::Between: {
// The band's interior: what lies above the lower edge and *not* above the upper one. Both
// accumulators split the same samples the same way -- pass two walks one bucket list and one
// filter for the whole window -- so the band is the *difference* of the two upper sides, and
// it is exact rather than approximate. It needs no third walk of its own, and the bucket
// fast path serves it unchanged: a bucket wholly inside the band is strictly above the lower
// level and strictly below the upper, so both accumulators fold it whole.
const MeasureLevel* lower = levels != nullptr ? levels[0] : nullptr;
const MeasureLevel* upper = levels != nullptr ? levels[1] : nullptr;
if (lower == nullptr || upper == nullptr) return out;
// Equal counts mean an empty interior: nothing lies in the band, so there is nothing to
// average -- the pane reads `--`, as it does for any other type with no samples on the side
// it was asked about. `lower` may not count *fewer*, which would say the two accumulators
// saw different samples.
if (lower->count[1] <= upper->count[1]) return out;
out.primary = (lower->sum[1] - upper->sum[1]) /
static_cast<double>(lower->count[1] - upper->count[1]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
@@ -351,18 +502,19 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency: {
if (level == nullptr) return out;
if (levels == nullptr || levels[0] == nullptr) return out;
const MeasureLevel& level = *levels[0];
// The pulse is one side of the mid level: its widths run from the edge that enters it to
// the edge that leaves it, and a period is the spacing of the edges that *enter* it -- one
// per cycle, whatever else the waveform does in between.
const std::size_t s = options.above ? 1 : 0;
const double enter_span = level->last_enter[s] - level->first_enter[s];
const double enter_period = level->enters[s] >= 2
? enter_span / static_cast<double>(level->enters[s] - 1)
const double enter_span = level.last_enter[s] - level.first_enter[s];
const double enter_period = level.enters[s] >= 2
? enter_span / static_cast<double>(level.enters[s] - 1)
: 0.0;
if (type == MeasureType::PulseDuration) {
if (level->widths[s] == 0) return out;
out.primary = level->width_sum[s] / static_cast<double>(level->widths[s]);
if (level.widths[s] == 0) return out;
out.primary = level.width_sum[s] / static_cast<double>(level.widths[s]);
// Duty as width/period, not as pulse-time over the window: a window that starts or
// ends mid-pulse holds partial cycles, and counting only the complete ones against
// the whole span would report a duty that depends on where the window happened to
@@ -370,15 +522,15 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
// them -- a window holding one pulse reports the width alone.
out.secondary = enter_period > 0.0 ? out.primary / enter_period : 0.0;
out.has_secondary = enter_period > 0.0;
out.marks.push_back({false, level->first_enter[s]});
out.marks.push_back({false, level->first_enter[s] + out.primary});
out.marks.push_back({false, level.first_enter[s]});
out.marks.push_back({false, level.first_enter[s] + out.primary});
out.valid = true;
return out;
}
if (!(enter_period > 0.0)) return out;
out.primary = type == MeasureType::Period ? enter_period : 1.0 / enter_period;
out.marks.push_back({false, level->first_enter[s]});
out.marks.push_back({false, level->first_enter[s] + enter_period});
out.marks.push_back({false, level.first_enter[s]});
out.marks.push_back({false, level.first_enter[s] + enter_period});
out.valid = true;
return out;
}
@@ -386,5 +538,4 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
return out;
}
} // namespace udpscope
+193 -26
View File
@@ -17,28 +17,58 @@ enum class MeasureType {
Min, ///< the smallest visible value
Max, ///< the largest visible value
/// 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,
/// Half the peak-to-peak swing, `(max - min) / 2` -- the amplitude of a symmetric waveform
/// about its own mean, which is the figure a datasheet quotes and a signal generator is set
/// to. Deliberately not `max(|value|)`: that is a *distance from zero*, which for a signal
/// riding on an offset is a different and less useful number.
/// The delta between the two **flat** levels, `top - bottom` (a bench scope's `Vamplitude`,
/// which is `Vtop - Vbase`). Deliberately *not* the peak-to-peak swing: a band-limited edge
/// overshoots and rings, so the extremes of a nominal 2 V square read about 2.36 V, while the
/// 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,
Top, ///< the signal's high level (the mean of the samples above the threshold)
Bottom, ///< the signal's low level (the mean of the samples below it)
/// The signal's high level: the densest band of the window's upper side, whose samples' exact
/// 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
Period, ///< the time between two successive crossings of the threshold
Frequency, ///< 1 / period
/// The mean of the samples **above** `threshold_pct` -- the split's own upper mean, which is
/// what `Top` reported before a flat level replaced it. `Top`/`Bottom` are the level the signal
/// *dwells at* (a density question, so a ringing edge does not drag them); these two are the
/// plain mean of the samples on one side of the share, ripple and overshoot included, which is
/// a *different* question about the same split and the reason the two coexist: on a unit sine
/// this reads `0.636620` (2/pi) where `Top` reads `0.957988`, and on the mock's band-limited
/// square `0.986496` where its plateau is 1.
Above,
/// The same from the split's lower side.
Below,
/// The mean of the samples **between** `threshold_pct` and `threshold_pct2`: the band's
/// interior, which neither of the two means above can answer (one of them gives the two sides
/// and the other the whole window, because the band's two edges are not one split).
Between,
};
/// Type-specific behaviour. Every field is meaningful only for the types named below; the
/// others ignore it (an option that does nothing for a type is left out of the dialog
/// rather than stored and ignored).
struct MeasureOptions {
/// `Top`/`Bottom`/`PulseDuration`/`Period`/`Frequency`: where the split is, as a share of
/// `Top`/`Bottom`/`PulseDuration`/`Above`/`Below`/`Between`: where the split is, as a share of
/// the window's own min..max. The middle by default, which is what a symmetric waveform
/// wants; a 3.3 V logic signal with ringing wants it nearer the top.
/// wants; a 3.3 V logic signal with ringing wants it nearer the top. For `Above`/`Below` it is
/// the level the mean is taken on one side of; for `Between` it is the band's *lower* edge.
double threshold_pct = 50.0;
/// `Between`: the band's other edge, the same unit. The two are read through
/// `measure_level_pcts()`, which clamps both and puts the pair in order, so the band between
/// 80 % and 20 % is the band between 20 % and 80 % -- the shares name two *edges* and the order
/// they were typed in carries no meaning, which is why a reversed pair is normalized rather
/// than refused.
double threshold_pct2 = 75.0;
/// `PulseDuration`: which side of the threshold is "the pulse". `Period`/`Frequency`
/// use it as the edge direction, so one cycle is counted once.
bool above = true;
@@ -92,6 +122,35 @@ struct MeasureResult {
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
/// 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
@@ -119,6 +178,10 @@ struct MeasureStats {
/// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0.
double at_lo = 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;
};
@@ -195,7 +258,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
/// 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.
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
/// count of the samples on either side of it, and its crossings reduced to running figures -- the
@@ -215,12 +285,19 @@ struct MeasureLevel {
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
///
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold --
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi).
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level.
/// so a band can never change it -- the split is the split, and a sample's side of it is a
/// fact rather than a threshold decision. `measure_set_level()` is what sizes it; zero reduces
/// the walk exactly to a single level.
double hysteresis = 0.0;
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
/// `bottom` has always reported), index 1 the side above it.
/// The samples either side of `level`, exactly: the reading of `Above`/`Below` is one of these
/// two means, `Between`'s is the difference of two *levels'* upper sides, and `Top`/`Bottom` fall
/// back to them on a window with no 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};
std::size_t count[2] = {0, 0};
double first_enter[2] = {0.0, 0.0};
@@ -272,16 +349,102 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& level);
/// 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
/// measurement's own threshold; the crossing types read the mid level, deliberately -- see
/// `measure_uses_threshold()`.
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
inline void enter_side(MeasureLevel& acc, std::size_t e, double t) {
const std::size_t other = 1 - e;
if (acc.enters[e] == 0) acc.first_enter[e] = t;
acc.last_enter[e] = t;
++acc.enters[e];
if (acc.open[other]) {
acc.width_sum[other] += t - acc.open_at[other];
++acc.widths[other];
acc.open[other] = false;
}
acc.open_at[e] = t;
acc.open[e] = true;
}
/// 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
/// none. The function reads nothing else, which is what keeps the answer independent of the plot.
MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* level,
inline bool measure_accumulate_bucket(const MeasureBucket& k,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& acc) {
if (k.count == 0) return true;
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false;
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
const bool above = k.lo > acc.level;
const bool below = k.hi < acc.level;
if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u;
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
// whose position is inside the bucket is refused.
const double top = acc.level + acc.hysteresis;
const double bot = acc.level - acc.hysteresis;
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
// raw sample inside it decides none, so the walk state is left alone.
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
// the bucket's first sample, exactly, which is where the raw walk would have found it.
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
// which adds the same samples again. Sums added above the refusal double-counted every opened
// bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi 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;
acc.sum[e] += k.sum;
acc.count[e] += k.count;
if (change) enter_side(acc, e, time_of(k.offset));
if (reaches) {
acc.side = e != 0;
acc.have_side = true;
}
return true;
}
/// The levels a type reads, as shares of the window's own min..max, **lower first**, or 0 when it
/// reads none (the pure statistics, answered from `MeasureStats` alone). `pct` must hold two: a
/// level type fills `pct[0]` and answers 1, `Between` fills both and answers 2.
///
/// `Top`/`Bottom`/`Above`/`Below` read the measurement's own threshold; the crossing types read the
/// mid level, deliberately -- see `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()`.
///
/// This is the one place `Between`'s pair is clamped and ordered, so the pane's dedup key, the
/// engine's two accumulators and the dialog all describe the same band whatever was typed.
int measure_level_pcts(MeasureType type, const MeasureOptions& options, double pct[2]);
/// 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. `levels` is the
/// accumulators `measure_level_pcts(type, options)` asked for, in the same order -- `nullptr` for
/// the types that read none, and either entry may be null when the caller could not resolve it.
/// The function reads nothing else, which is what keeps the answer independent of the plot.
MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* const levels[2],
MeasureType type, const MeasureOptions& options);
/// A measurement type's name, as the pane's row and the dialog's dropdown spell it.
@@ -294,12 +457,16 @@ MeasureType measure_type_at(int index);
/// spacing of the crossings of the *mid* level, one rising edge per cycle, and offering a
/// threshold there only invites a setting that makes the count wrong.
bool measure_uses_threshold(MeasureType type);
/// Whether a type reads a *pair* of shares, so the dialog shows its second share only then: the
/// two edges of a band are one measurement's two parameters, and one field of the two is a
/// measurement of a different band than the user asked for.
bool measure_uses_band(MeasureType type);
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
/// the waveform is the pulse).
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
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` split their
/// sums at the bare level and read the same answer at any band.
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` read a flat
/// level out of the bucket histogram, which no band touches.
bool measure_uses_noise(MeasureType type);
/// Whether a type draws its result on the plot when asked.
bool measure_can_show_on_plot(MeasureType type);
+230 -61
View File
@@ -3,7 +3,7 @@
#include "model.hpp"
#include <udps_client.h>
#include "status_text.hpp" // `format_samples()` / `format_bytes()`, for the one line a clipped plan prints
#include <algorithm>
#include <chrono>
@@ -42,12 +42,24 @@ constexpr std::uint64_t kMathCostSample = 1024;
} // namespace
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s, bool with_abs_time) {
u64 buffer_ticks(u64 requested_samples) {
// 2^40 slots is already 8 TiB of clock alone: a request past it is nonsense and the cap keeps
// `bytes_for()` far from overflowing.
constexpr u64 kMaxTicks = u64(1) << 40;
return std::min(pow2_floor(std::max<u64>(requested_samples, 128)), kMaxTicks);
}
u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time) {
return ticks * sizeof(f64) * (with_abs_time ? 2u : 1u) + ticks * sizeof(f32) * channels;
}
Partition plan_partition(const std::vector<double>& channel_dt, u64 samples_per_signal,
u64 max_bytes, double target_span_s, bool with_abs_time) {
Partition p;
const std::size_t n = channel_dt.size();
p.stride.assign(n, 1);
p.value_capacity.assign(n, 0);
p.requested_samples = samples_per_signal;
if (n == 0) return p;
// The fastest channel drives the shared time base: every other channel is
@@ -71,25 +83,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
p.abs_time = with_abs_time;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
// slices are not interchangeable. Counting both as one f32 is what would make
// `--buffer-mb` quietly understate the time ring by its own size.
double weight = static_cast<double>(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s)
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
if (time_capacity == 0) {
throw std::runtime_error("buffer too small for one time-base sample per channel");
}
// The target span sets a floor, not a ceiling: the rest of the budget buys a
// longer ring, which is strictly more history to scroll back through. Growth
// stops before the total would exceed the budget. A channel's slots are rounded
// *up* on the power of two `RingBuffer` will round them to anyway, so every
// channel covers at least the time ring's span.
// The ring is a power of two, so the user's count is a ceiling and the plan takes the largest
// power of two under it. The budget is in *samples* now: it is the memory limit, not the request,
// that is in bytes -- and a time slot (f64) and a value sample (f32) are not the same size, which
// is why the limit below is weighed with `bytes_for()` and not as a count of slots.
//
// A channel's slots are rounded *down* on the power of two `RingBuffer` will round them to
// anyway, so every channel covers at most the time ring's span.
// Floor, not ceiling: a channel's window must not be *longer* than the time ring
// it reads its stamps from. Rounding up (with a stride that is not a power of two)
// gave a channel more samples than the shared timeline covers, and the oldest of
@@ -105,19 +105,24 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
return total;
};
// `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
while (bytes_for(time_capacity * 2) <= budget_bytes) {
time_capacity *= 2;
u64 time_capacity = buffer_ticks(samples_per_signal);
const u64 wanted_ticks = time_capacity;
// The memory limit halves the request until the rings fit, never the other way: a bigger plan than
// asked for is not "up to".
while (time_capacity > 128 && bytes_for(time_capacity) > max_bytes) time_capacity /= 2;
if (bytes_for(time_capacity) > max_bytes) {
throw std::runtime_error("memory limit too small for the sample rings of " +
std::to_string(n) + " channels");
}
p.memory_limited = time_capacity < wanted_ticks;
p.time_capacity = time_capacity;
for (std::size_t i = 0; i < n; ++i) {
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
}
// Only now, with the ring as long as the budget can buy, decimate the shared clock
// Only now, with the ring as long as the request and the memory limit allow, decimate the shared clock
// enough for the requested window to fit in it: deciding this earlier would over-
// decimate by whatever factor the growth loop was about to add. A power of two keeps
// decimate by whatever the ring's final length would have made unnecessary. A power of two keeps
// the ring capacities (themselves powers of two) in step and lets the producer's group
// test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride
// this function chose at runtime is a real 64-bit division, which the test *was* paying
@@ -180,6 +185,7 @@ void BufferStore::acquire_all() {
// --- ScopeModel -------------------------------------------------------------
ScopeModel::ScopeModel(const Config& cfg) : cfg_(cfg) {
memory_limit_bytes_.store(cfg.memory_limit_mb * 1024u * 1024u, std::memory_order_relaxed);
trigger_.holdoff_s = cfg.trigger_holdoff_s;
}
@@ -226,6 +232,13 @@ void ScopeModel::set_signals(std::vector<SignalInfo> signals) {
signals.erase(std::remove_if(signals.begin(), signals.end(),
[](const SignalInfo& s) { return s.math; }),
signals.end());
// The renames come off the stream's own units first, so that an identical CONFIG still compares
// equal against `signals_` (which holds the renamed ones) and a reconnect keeps its history.
declared_unit_.clear();
for (SignalInfo& s : signals) {
declared_unit_[s.name] = s.unit;
if (const auto it = unit_override_.find(s.name); it != unit_override_.end()) s.unit = it->second;
}
if (!signals_.empty() && same_stream_signals(signals, signals_)) {
// A reconnect's identical CONFIG: the honest recovery is *nothing* -- the ring keeps
// its history, the trigger keeps its state and its held event, and the outage shows
@@ -449,6 +462,14 @@ void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
const std::size_t slots = static_cast<std::size_t>(store.time().size());
if (slots == 0) return;
// The refill replays the inputs' *retained* window, which starts at this absolute index and not at
// zero, so an emptied derived ring has to resume its count there: `clear()` would renumber it from 0
// and leave its `produced()` short by `base` for a whole ring's worth of fresh data. `live_window_locked()`
// clamps the window against every channel's `produced()`, so that one lagging channel collapses the
// live view, every pause view and every trigger capture to nothing -- the "acquisition freezes after a
// math edit" defect, invisible until the ring has wrapped once (`base == 0` before that).
const std::size_t base = static_cast<std::size_t>(store.time().base());
std::vector<bool> derived(channels_.size(), false);
for (std::size_t m = 0; m < math_.size(); ++m) {
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
@@ -456,14 +477,18 @@ void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
derived[static_cast<std::size_t>(channel)] = true;
// Only the affected channels start again. The rest keep their samples *and* their runtimes: their graphs
// are untouched, so the state they hold is the answer to the inputs they have always had.
if (affected[m]) store.values(static_cast<std::size_t>(channel)).clear();
if (!affected[m]) continue;
store.values(static_cast<std::size_t>(channel)).clear_to(base);
// And the published window with them: this is a rewrite in place, so `produced()` lands back where it
// started and the incremental `acquire()` would publish nothing at all -- leaving the pyramid, which is
// what the renderer and every measurement read, describing the samples the edit just replaced.
store.channel(static_cast<std::size_t>(channel)).republish_from(base);
}
// One bulk copy per channel per chunk, through the ring's own reader. A derived channel is fed a zero:
// `math_apply_locked()` overwrites its value as it walks the list in order, and reading its half-rebuilt
// ring here would read out of range.
constexpr std::size_t kChunk = 1u << 14;
const std::size_t base = static_cast<std::size_t>(store.time().base());
std::vector<std::vector<f32>> inputs(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (!derived[c]) inputs[c].resize(kChunk);
@@ -482,10 +507,22 @@ void ScopeModel::recompute_math_locked(const std::vector<bool>& affected) {
math_apply_locked(slot, &affected, /*timed=*/false);
}
}
// The pyramid is the consumer's and the tick publishes it; the samples are written here.
// The pyramid is the consumer's and the tick publishes it (`republish_from()` above put its window
// back at `base` so that publish is one window's worth of work); the samples are written here.
}
void ScopeModel::rebuild_channels() {
// The trigger names a channel by its display name, which is stable across the renumbering this
// function *is*; the index is not. Captured and restored here rather than at the call sites
// because every caller renumbers and every caller needs it: adding a math channel appends a
// signal and shifts nothing, but removing one, or a CONFIG that reorders the stream's own
// signals, moves every index after it. `rebuild_locked()` then only clamps an out-of-range
// index to -1, and `TriggerEngine::process()` returns immediately on a channel of -1 -- so the
// panel went on naming the channel while nothing could ever fire again.
const std::string trigger_name =
(trigger_.channel >= 0 && trigger_.channel < static_cast<int>(channels_.size()))
? channels_[static_cast<std::size_t>(trigger_.channel)].name
: std::string();
channels_.clear();
// Which channel each math channel ended up with, by name: a disabled one has none, and the
// producer must not push a derived sample into another channel's ring.
@@ -524,6 +561,15 @@ void ScopeModel::rebuild_channels() {
}
channels_.push_back(std::move(ch));
}
// A name that is no longer a channel -- its signal was disabled, or the stream stopped
// declaring it -- leaves the trigger unassigned, which is a wait rather than a silent
// mis-aim; re-enabling the signal brings it back here.
trigger_.channel = -1;
if (trigger_name.empty()) return;
for (std::size_t i = 0; i < channels_.size(); ++i) {
if (channels_[i].name == trigger_name) trigger_.channel = static_cast<int>(i);
}
}
void ScopeModel::rebuild_locked() {
@@ -536,22 +582,16 @@ void ScopeModel::rebuild_locked() {
holes_.clear();
++plan_generation_;
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
if (dts.empty()) {
if (channels_.empty()) {
part_ = Partition{};
store_.reset();
return;
}
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
target_span_.load(std::memory_order_relaxed),
cfg_.relative_time);
const Partition want = plan_locked(target_span_.load(std::memory_order_relaxed));
// A plan that describes the storage already allocated is cleared, not rebuilt. Allocating
// a store is what costs -- its rings are value-initialised, so it touches the whole
// `--buffer-mb` -- and most structural changes do not move the plan at all: a calibration
// a store is what costs -- its rings are value-initialised, so it touches all of the
// planned rings -- and most structural changes do not move the plan at all: a calibration
// keeps the channel set, and so does leaving an event for the live view. Startup asked for
// sixteen of these and eight of them planned the same buffer twice. Clearing has the same
// meaning (`BufferStore::reset()`): the history is gone either way, which is why
@@ -570,6 +610,17 @@ void ScopeModel::rebuild_locked() {
// the defect the line exists to name (it was fifteen of them at startup).
std::fprintf(stderr, "[udpscope] buffer allocated: %zu channels, %.3g s span, %.0f ms\n",
channels_.size(), part_.span_s, double(now_ns() - t0) / 1.0e6);
if (part_.memory_limited) {
// The one line that says the request was not honoured, and why: a smaller ring than the source
// form promised is otherwise explained nowhere.
std::fprintf(stderr,
"[udpscope] buffer limited to %s samples per signal (%s requested) by the %s "
"memory limit; the rings take %s\n",
format_samples(part_.time_capacity).c_str(),
format_samples(part_.requested_samples).c_str(),
format_bytes(memory_limit_bytes()).c_str(),
format_bytes(part_.bytes()).c_str());
}
std::fflush(stderr);
}
@@ -577,6 +628,47 @@ void ScopeModel::rebuild_locked() {
prepare_math_locked();
}
Partition ScopeModel::plan_locked(double seconds) const {
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
return plan_partition(dts, cfg_.buffer_samples, memory_limit_bytes(), seconds, cfg_.relative_time);
}
bool ScopeModel::set_memory_limit_bytes(std::uint64_t bytes, bool replan) {
std::lock_guard<std::mutex> lock(mutex_);
const std::uint64_t previous = memory_limit_bytes_.load(std::memory_order_relaxed);
if (bytes == previous) return false;
if (channels_.empty() || !replan) {
memory_limit_bytes_.store(bytes, std::memory_order_relaxed);
return false;
}
memory_limit_bytes_.store(bytes, std::memory_order_relaxed);
Partition want;
try {
want = plan_locked(target_span_.load(std::memory_order_relaxed));
} catch (const std::exception& e) {
memory_limit_bytes_.store(previous, std::memory_order_relaxed);
std::fprintf(stderr, "[udpscope] memory limit %s refused: %s\n", format_bytes(bytes).c_str(),
e.what());
std::fflush(stderr);
return false;
}
if (store_ && part_.same_storage_as(want)) {
// The same rings: only what the plan *says* about itself moved. Written in place, because the
// producer reads `part_` under the lock we hold and nothing here reallocates it.
part_.requested_samples = want.requested_samples;
part_.memory_limited = want.memory_limited;
return false;
}
std::fprintf(stderr, "[udpscope] memory limit %s -> %s: buffer re-planned\n",
format_bytes(previous).c_str(), format_bytes(bytes).c_str());
std::fflush(stderr);
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
return true;
}
bool ScopeModel::set_target_span(double seconds) {
if (!(seconds > 0.0)) return false;
std::lock_guard<std::mutex> lock(mutex_);
@@ -585,10 +677,7 @@ bool ScopeModel::set_target_span(double seconds) {
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than
// reimplementing its arithmetic here -- two expressions for the same decision is how
// the buffer and the view end up disagreeing about the stored rate.
std::vector<double> dts;
dts.reserve(channels_.size());
for (const ChannelInfo& c : channels_) dts.push_back(c.dt);
const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds, cfg_.relative_time);
const Partition want = plan_locked(seconds);
if (want.time_stride == part_.time_stride) return false;
std::fprintf(stderr,
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
@@ -613,7 +702,7 @@ bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
// signal: it has stamps but no cadence of its own, because it is not data.
// Data channels can also be declared full-array (per-element stamps), and they
// keep a declared rate -- treating them as time signals hid every real channel.
if (s.time_mode == UDPS_TIME_FULL_ARRAY && s.elements() > 1 && s.sampling_rate == 0.0) {
if (s.time_mode == kTimeFullArray && s.elements() > 1 && s.sampling_rate == 0.0) {
return true;
}
return false;
@@ -639,22 +728,8 @@ bool ScopeModel::enable_locked(std::size_t signal, bool on) {
}
void ScopeModel::rebuild_enabled_locked() {
// The trigger names a channel by its display name, which is stable across
// renumbering: when its own signal is disabled the channel vanishes (the trigger
// then waits, and comes back when the signal is re-enabled). Read before
// `rebuild_channels()`, which is what renumbers.
const int old_trigger = trigger_.channel;
const std::string trigger_name =
(old_trigger >= 0 && old_trigger < static_cast<int>(channels_.size()))
? channels_[static_cast<std::size_t>(old_trigger)].name
: std::string();
// `rebuild_channels()` carries the trigger's channel across the renumbering by name.
rebuild_channels();
trigger_.channel = -1;
if (!trigger_name.empty()) {
for (std::size_t i = 0; i < channels_.size(); ++i) {
if (channels_[i].name == trigger_name) trigger_.channel = static_cast<int>(i);
}
}
rebuild_locked();
generation_.fetch_add(1, std::memory_order_release);
}
@@ -702,6 +777,38 @@ void ScopeModel::set_calibration(std::size_t signal, double gain, double offset)
}
}
void ScopeModel::set_signal_unit(std::size_t signal, const std::string& unit) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal >= signals_.size() || signals_[signal].math) return;
const std::string name = signals_[signal].name;
std::string shown = unit;
if (unit.empty()) {
unit_override_.erase(name);
const auto it = declared_unit_.find(name);
shown = it != declared_unit_.end() ? it->second : std::string();
} else {
unit_override_[name] = unit;
}
if (signals_[signal].unit == shown) return;
signals_[signal].unit = shown;
for (ChannelInfo& c : channels_) {
if (c.signal == signal) c.unit = shown;
}
generation_.fetch_add(1, std::memory_order_release);
}
std::string ScopeModel::declared_unit(std::size_t signal) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal >= signals_.size()) return std::string();
const auto it = declared_unit_.find(signals_[signal].name);
return it != declared_unit_.end() ? it->second : signals_[signal].unit;
}
std::vector<std::pair<std::string, std::string>> ScopeModel::unit_overrides() {
std::lock_guard<std::mutex> lock(mutex_);
return {unit_override_.begin(), unit_override_.end()};
}
double ScopeModel::channel_dt(std::size_t channel) const {
return channel < channels_.size() ? channels_[channel].dt : 0.0;
}
@@ -872,9 +979,11 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
}
snap->values.resize(channels_.size());
snap->channel_names.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
snap->pyramids.resize(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) {
snap->channel_names[c] = channels_[c].name;
auto& vals = snap->values[c];
vals.resize(static_cast<std::size_t>(n));
if (n > 0) src.values(c).copy_from(first, n, vals.data());
@@ -899,6 +1008,24 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// capture at the first sample past `post_s` rather than exactly on it.
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front());
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back());
// The persistence retention: what was on screen until now becomes the *newest of the older*
// acquisitions, and the set is cut back to the depth the panel asks for and to the retention
// budget. Nothing is retained at depth 1, so the default costs no memory and no copy at all.
if (const int depth = persist_depth_locked(); depth > 1) {
std::shared_ptr<const EventSnapshot> prev = event_.load(std::memory_order_relaxed);
if (prev != nullptr) {
// Only the slots the depth wants are shifted: the ones past it are the prune's business, and
// moving an acquisition into a slot nothing can ever read is work for a snapshot about to be
// dropped.
const std::size_t top = std::min(static_cast<std::size_t>(depth - 1), persist_.size());
for (std::size_t i = top; i-- > 1;) {
persist_[i].store(persist_[i - 1].load(std::memory_order_relaxed),
std::memory_order_relaxed);
}
persist_[0].store(std::move(prev), std::memory_order_relaxed);
}
}
prune_persist_locked(static_cast<std::size_t>(persist_depth_locked() - 1));
event_.store(std::move(snap), std::memory_order_release);
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
// number sees the snapshot it counts.
@@ -944,6 +1071,48 @@ void ScopeModel::set_trigger_enabled(bool on) {
trigger_enabled_.store(on, std::memory_order_relaxed);
}
void ScopeModel::prune_persist_locked(std::size_t keep) {
const std::size_t limit = std::min(keep, persist_.size());
const std::size_t budget = persist_budget_bytes();
std::size_t bytes = 0;
std::size_t kept = 0;
std::uint64_t dropped = 0;
bool full = false;
for (std::size_t i = 0; i < persist_.size(); ++i) {
const std::shared_ptr<const EventSnapshot> snap = persist_[i].load(std::memory_order_relaxed);
if (snap == nullptr) continue;
if (i < limit) {
// Either bound can cut the set: the depth the panel asks for, and the budget, which a long
// window on a fast stream reaches long before the seventh layer.
const std::size_t b = snapshot_bytes(*snap);
if (!full && bytes + b <= budget) {
bytes += b;
++kept;
continue;
}
full = true; // does not fit, and neither does anything older
// Only what the *budget* refused is counted: a slot past the depth is not a drop, it is the
// depth -- and counting it would make the reading say the memory was the reason when the
// panel's own number was.
++dropped;
}
persist_[i].store(nullptr, std::memory_order_relaxed);
}
persist_count_.store(kept, std::memory_order_relaxed);
persist_bytes_.store(bytes, std::memory_order_relaxed);
persist_dropped_.store(persist_dropped_.load(std::memory_order_relaxed) + dropped,
std::memory_order_relaxed);
}
void ScopeModel::set_persist_depth(int depth) {
const int n = std::clamp(depth, 1, kMaxPersistDepth);
std::lock_guard<std::mutex> lock(mutex_);
trigger_.persist_depth = n;
// Pruned here and not only at the next latch: lowering the depth is the user asking to stop
// paying for the layers, and a trigger that has stopped firing never reaches another publish.
prune_persist_locked(static_cast<std::size_t>(n - 1));
}
void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
if (epoch_ == 0.0) epoch_ = e;
}
+169 -9
View File
@@ -10,6 +10,8 @@
#include <plot/data/ring_buffer.hpp>
#include <algorithm>
#include <array>
#include <atomic>
#include <unordered_map>
#include <cstdint>
@@ -22,6 +24,13 @@
namespace udpscope {
/// `SignalInfo::time_mode` values. They equal the wire protocol's `UDPS_TIME_*`
/// (`udps_source.cpp` static_asserts it), but the model must not name a source's own header.
constexpr std::uint8_t kTimePacket = 0;
constexpr std::uint8_t kTimeFullArray = 1;
constexpr std::uint8_t kTimeFirstSample = 2;
constexpr std::uint8_t kTimeLastSample = 3;
/// One streamed signal, as the CONFIG packet described it.
struct SignalInfo {
std::string name;
@@ -95,9 +104,27 @@ struct Partition {
/// True when a second f64 ring parallel to the clock was planned and paid for in the
/// byte budget. Relative mode only -- in stream mode `time()` already *is* the absolute
/// clock and a copy would cost ~22% of the user's `--buffer-mb` for nothing.
/// clock and a copy would cost ~22% of the user's sample budget for nothing.
bool abs_time = false;
/// The samples-per-signal the user asked for, as given (not rounded). `time_capacity` is what
/// the plan actually holds: the request rounded down to a power of two, then halved further
/// while the rings did not fit the memory limit. These two are readings, not storage -- they are
/// not in `same_storage_as()` -- so a re-plan that only moves them does not drop history.
u64 requested_samples = 0;
/// True when the memory limit, and not the power-of-two rounding, made `time_capacity` smaller than
/// the request: the one case the UI has to explain.
bool memory_limited = false;
/// The bytes the rings of this plan occupy: the time ring (twice in relative mode) plus every
/// value ring. This is the figure to quote and to budget against; a count of the user's
/// request is not, because the rings round and the limit can clip.
u64 bytes() const noexcept {
u64 total = time_capacity * sizeof(f64) * (abs_time ? 2u : 1u);
for (u64 c : value_capacity) total += c * sizeof(f32);
return total;
}
/// Seconds between two time-base slots (the sample interval of every channel).
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
/// The rate samples are actually stored at, in samples per second.
@@ -128,13 +155,24 @@ struct Partition {
}
};
/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least
/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever
/// power of two that takes. Throws std::runtime_error when the budget cannot hold even
/// one sample per channel. `with_abs_time` adds a second f64 per time slot to the budget
/// and sets `Partition::abs_time`.
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s, bool with_abs_time = false);
/// The ring length a request of `requested_samples` per signal buys: the largest power of two not
/// above it (at least 128). The rings are powers of two, so the user's number is a ceiling.
u64 buffer_ticks(u64 requested_samples);
/// What `ticks` slots cost for `channels` unit-stride channels: the f64 time ring (twice with the
/// absolute clock) plus one f32 ring per channel. The planner's own byte formula, for a caller
/// that has no `Partition` yet (the source form, the probes).
u64 planned_bytes(u64 ticks, std::size_t channels, bool with_abs_time);
/// Plans a partition for `channel_dt` cadences: `samples_per_signal` slots for the time ring and for
/// every channel, rounded down to a power of two, then halved while the rings (`planned_bytes()`)
/// exceed `max_bytes` -- the memory limit. Covers at least `target_span_s` seconds of stream,
/// decimating (with the ingest filter) by whatever power of two that takes. Throws
/// std::runtime_error when even 128 slots do not fit `max_bytes`. `with_abs_time` adds a second f64
/// per time slot to the budget and sets `Partition::abs_time`. `Partition::memory_limited` says
/// whether the limit clipped the request.
Partition plan_partition(const std::vector<double>& channel_dt, u64 samples_per_signal,
u64 max_bytes, double target_span_s, bool with_abs_time = false);
/// One of the two identical sample buffers: a shared time-base ring plus one
/// value ring and one `ChannelBuffer` per channel.
@@ -178,7 +216,7 @@ class BufferStore {
///
/// Why it exists: constructing a store *is* its cost. A ring is a
/// `vector<std::atomic<T>>`, which is value-initialised, so building one touches every
/// byte of the user's `--buffer-mb` -- about 300 ms per GB, nearly all of it first-touch
/// byte of the planned buffer (`--buffer-samples`) -- about 300 ms per GB, nearly all of it first-touch
/// page faults. Clearing is a count store per ring. Most structural changes (a
/// calibration, a re-enable of what was just disabled, going back to live) re-plan to the
/// *same* partition, and paying a second full allocation for them is what made a large
@@ -268,6 +306,15 @@ struct StreamStats {
/// a reading of 0 beside a non-zero `holes` is exactly the honest answer until the
/// backfill lands.
std::atomic<std::uint64_t> backfilled{0};
/// Reservations refused because the counter's claim was not corroborated by the arrival
/// cadence, or because the hole it would book is bigger than the ring can hold. A counter
/// is a claim about the wire and the arrival clock is the measurement of it: a stream whose
/// counter does not count packets (a sample counter, say) reports a forward jump on every
/// datagram, and believing it booked a NaN hole per packet -- a band over the whole window
/// filled with samples that were never missing, which the backfill cannot repair because no
/// packet was ever lost. Counted here because it is otherwise *invisible*: the status bar's
/// `gaps` climbs either way, and only this field says the gap was refused.
std::atomic<std::uint64_t> gaps_refused{0};
};
/// What the plot is showing, which is one decision made once per frame: the bind, the
@@ -418,6 +465,17 @@ class ScopeModel {
/// no channel to read it from, and the record is what a dialog must show.
std::pair<double, double> calibration(std::size_t signal);
/// Renames the unit a streamed signal is shown, read and exported in. Stored by the signal's *name*
/// like the calibration, and re-applied to every later CONFIG, so a rename survives a reconnect.
/// An empty `unit` removes the override and restores the unit the stream declared. A math channel's
/// unit belongs to its graph and is ignored here. Bumps `generation()` when it changed something,
/// which is what makes the panels re-read the signal list.
void set_signal_unit(std::size_t signal, const std::string& unit);
/// The unit the stream declared for that signal, whatever the user renamed it to.
std::string declared_unit(std::size_t signal);
/// The renames in force, `{name, unit}`, for the session file.
std::vector<std::pair<std::string, std::string>> unit_overrides();
std::size_t channel_count() const noexcept { return channels_.size(); }
const Partition& partition() const noexcept { return part_; }
@@ -502,6 +560,55 @@ class ScopeModel {
/// while the latch count keeps answering "how many events fired".
std::uint64_t event_seq() const noexcept { return event_seq_.load(std::memory_order_acquire); }
// --- persistence ----------------------------------------------------------
/// The acquisitions retained for the persistence display, newest first: `persist_event(1)` is
/// the event one latch back, `(2)` the one before that. Null for an age nobody kept.
///
/// Read **without a lock**, like `event()`: the producer publishes these in `publish_event()`
/// with the same atomic-`shared_ptr` discipline, and a snapshot is immutable and shared, so a
/// reader holds it alive for as long as it draws it. What the *ring* would have given is not
/// needed afterwards -- a retained snapshot is a whole window copied out of it.
std::shared_ptr<const EventSnapshot> persist_event(std::size_t age) const noexcept {
if (age < 1 || age > persist_.size()) return nullptr;
return persist_[age - 1].load(std::memory_order_acquire);
}
/// How many acquisitions are retained right now: what the depth asks for, or fewer -- never more
/// -- when the retention budget cut the set short. A reading, so it is counted as the slots that
/// hold a snapshot rather than derived from the depth.
std::size_t persist_count() const noexcept {
return persist_count_.load(std::memory_order_relaxed);
}
/// What those snapshots hold in bytes, and how many acquisitions the *budget* -- as against the
/// depth -- has dropped. Both are producer-written and read as readings.
std::size_t persist_bytes() const noexcept {
return persist_bytes_.load(std::memory_order_relaxed);
}
std::uint64_t persist_dropped() const noexcept {
return persist_dropped_.load(std::memory_order_relaxed);
}
/// The bytes persistence may retain, which is the rings' own planned byte total
/// (`Partition::bytes()`): the snapshots are copies *of* what the rings hold, so holding as much
/// again as the buffer does is the honest ceiling for a display of it -- and it is the bound that
/// cannot be driven into an OOM by a long window on a fast stream, whatever depth the panel asks
/// for. Zero until a stream has planned a buffer. Read under `mutex()`, like `partition()`.
std::size_t persist_budget_bytes() const noexcept {
return static_cast<std::size_t>(part_.bytes());
}
/// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and
/// prunes the retained set to it **at once**: lowering the depth is a user asking to stop paying
/// for the layers, and the next latch -- when the retention would otherwise catch up -- may never
/// come on a trigger that has stopped firing.
///
/// Takes `mutex()` itself; do not call it holding the lock.
void set_persist_depth(int depth);
/// How many acquisitions the persistence display keeps: the depth, clamped to the cap. Reading
/// `trigger()` needs the lock, which every caller of this already holds.
int persist_depth_locked() const noexcept {
return std::clamp(trigger_.persist_depth, 1, kMaxPersistDepth);
}
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
void acquire_all();
@@ -604,6 +711,22 @@ class ScopeModel {
/// `mutex()` itself; do not call it holding the lock.
/// Returns true when the buffer was rebuilt.
bool set_target_span(double seconds);
/// The memory limit the planner honours, in bytes. Starts as `Config::memory_limit_mb` and is the
/// one planner input that changes while the application runs (the preferences). Atomic so the
/// status and the probes can read it without the lock.
std::uint64_t memory_limit_bytes() const noexcept {
return memory_limit_bytes_.load(std::memory_order_relaxed);
}
/// Changes the memory limit and re-plans. The buffer is rebuilt -- and the history dropped -- only
/// when the new limit changes the *storage* (`Partition::same_storage_as()`): a limit that still
/// leaves room for the plan updates `Partition::memory_limited` in place and costs nothing.
/// Returns true when the buffer was rebuilt. Takes `mutex()` itself; do not call it holding the
/// lock. A limit too small for even 128 slots per channel is refused (returns false, keeps the old
/// limit) and said on stderr.
/// With `replan` false the limit is only stored, for the next plan: a file or a replayed event
/// lives in the buffer, and re-planning it would clear what the user is looking at.
bool set_memory_limit_bytes(std::uint64_t bytes, bool replan = true);
double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); }
/// Trigger state for the UI, published by the acquisition thread. Atomics
@@ -632,6 +755,20 @@ class ScopeModel {
/// `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
/// 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_; }
private:
@@ -653,7 +790,14 @@ class ScopeModel {
/// so a window change that moves the decimation re-prepares the filters exactly as an edit does.
void prepare_math_locked();
/// Plans the partition for the current channels at `seconds` of window with the current sample
/// budget and memory limit. The one place the planner's inputs are assembled, so the re-plan on a
/// window change, a structural change and a memory-limit change cannot disagree. Throws what
/// `plan_partition()` throws. Caller holds `mutex()`.
Partition plan_locked(double seconds) const;
Config cfg_;
std::atomic<std::uint64_t> memory_limit_bytes_{0};
std::vector<SignalInfo> signals_;
std::vector<ChannelInfo> channels_;
// --- math channels --------------------------------------------------------
@@ -719,9 +863,25 @@ class ScopeModel {
std::atomic<double> capture_edge_{0.0};
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
std::atomic<std::shared_ptr<const EventSnapshot>> pause_view_; ///< the view frozen at pause
/// The retention behind `persist_event()`: one slot per *previous* acquisition, newest first, so
/// slot 0 is the event one latch back. `kMaxPersistDepth - 1` of them, because the acquisition on
/// screen is `event_` itself and is not retained a second time. Written by the producer in
/// `publish_event()` and by `set_persist_depth()`, both under `mutex()`; read without it.
std::array<std::atomic<std::shared_ptr<const EventSnapshot>>, kMaxPersistDepth - 1> persist_{};
/// The readings `persist_count()`/`persist_bytes()`/`persist_dropped()` answer with, maintained
/// where the slots are written.
std::atomic<std::size_t> persist_count_{0};
std::atomic<std::size_t> persist_bytes_{0};
std::atomic<std::uint64_t> persist_dropped_{0};
/// Drops the retained snapshots past `keep` (0 empties the set) and recounts the readings.
/// Caller holds `mutex()`.
void prune_persist_locked(std::size_t keep);
std::atomic<bool> paused_{false};
/// Per-signal-name calibration (see `set_calibration`).
std::unordered_map<std::string, std::pair<double, double>> calibration_;
/// Per-signal-name unit renames (see `set_signal_unit`) and the units the stream declared.
std::unordered_map<std::string, std::string> unit_override_;
std::unordered_map<std::string, std::string> declared_unit_;
std::atomic<std::uint64_t> event_seq_{0};
std::atomic<double> display_epoch_{0.0};
std::atomic<bool> trigger_armed_{false};
+429 -22
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cmath>
#include <span>
namespace udpscope {
namespace {
@@ -67,9 +68,32 @@ constexpr float kMeasureDashOff = 4.0f;
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
/// stacked frames read as one tall plot with a line through it.
constexpr plot::f32 kRowGap = 6.0f;
/// The gap between two cells of a split row, device pixels: the same as the rows', so the
/// plots read as a grid.
constexpr plot::f32 kColGap = 6.0f;
/// The suffix that names a persistence layer, in the accumulate path's own vocabulary --
/// ` (A)`, ` (B)`, ` #4` -- so that `layer_base_name()` takes it back off, and the signal list, the
/// colour resolver, the measurements and the layout writer all keep seeing one signal where the
/// plot draws several traces of it.
std::string layer_suffix(int age) {
if (age == 1) return " (A)";
if (age == 2) return " (B)";
return " #" + std::to_string(age + 1);
}
} // namespace
float RowSet::persist_alpha(int age) noexcept {
// Half per acquisition, so the seventh is 1/128 -- and halves, not a slope, because that is what
// "exponential decay" means and because it is the one schedule that can be read off a line of
// numbers: 1, 0.5, 0.25, 0.125. It does not depend on the depth, so raising N adds dimmer
// acquisitions instead of restating the ones already on screen.
float a = 1.0f;
for (int i = 0; i < age; ++i) a *= 0.5f;
return a;
}
std::string layer_base_name(const std::string& name) {
const std::size_t a = name.rfind(" (");
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
@@ -133,11 +157,56 @@ void RowSet::clear_signal_color(const std::string& signal) {
RowSet::RowSet() { rebuild_all(); }
int RowSet::cells_in_row(int fr) const {
return fr >= 0 && fr < figure_row_count() ? cells_per_row_[static_cast<std::size_t>(fr)] : 0;
}
int RowSet::first_plot_of_row(int fr) const {
int p = 0;
for (int r = 0; r < fr && r < figure_row_count(); ++r) p += cells_per_row_[static_cast<std::size_t>(r)];
return p;
}
int RowSet::figure_row_of(int plot) const {
if (plot < 0 || plot >= rows_) return -1;
int p = 0;
for (int r = 0; r < figure_row_count(); ++r) {
p += cells_per_row_[static_cast<std::size_t>(r)];
if (plot < p) return r;
}
return -1;
}
int RowSet::cell_of(int plot) const {
const int fr = figure_row_of(plot);
return fr < 0 ? -1 : plot - first_plot_of_row(fr);
}
const std::vector<float>& RowSet::cell_weights(int fr) const {
static const std::vector<float> none;
return fr >= 0 && fr < static_cast<int>(cell_weights_.size())
? cell_weights_[static_cast<std::size_t>(fr)]
: none;
}
std::string RowSet::plot_label(int plot) const {
const int fr = figure_row_of(plot);
if (fr < 0) return std::to_string(plot + 1);
if (cells_per_row_[static_cast<std::size_t>(fr)] == 1) return std::to_string(fr + 1);
return std::to_string(fr + 1) + static_cast<char>('a' + cell_of(plot));
}
void RowSet::set_row_count(int rows) {
const int want = std::clamp(rows, 1, kMaxRows);
if (want == rows_) return;
const bool all_single =
std::all_of(cells_per_row_.begin(), cells_per_row_.end(), [](int c) { return c == 1; });
if (want == rows_ && all_single) return;
// `n` single-cell rows. The plots keep their flattened slots (and so their contents), which is
// what the old "n rows" meant.
rows_ = want;
// A `Figure`'s row count is fixed at construction, so a new count is a new figure.
cells_per_row_.assign(static_cast<std::size_t>(want), 1);
cell_weights_.assign(static_cast<std::size_t>(want), {});
// A `Figure`'s shape is fixed at construction, so a new count is a new figure.
rebuild_all();
}
@@ -146,6 +215,11 @@ void RowSet::carry_row(Row& dst, const Row& src) {
dst.name = src.name;
dst.channel = src.channel;
dst.color_index = src.color_index;
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
// moved under a plan made for the old one.
dst.layer = src.layer;
dst.slot_alpha = src.slot_alpha;
dst.ruler_y = src.ruler_y;
dst.ruler_b_y = src.ruler_b_y;
@@ -161,39 +235,152 @@ void RowSet::clear_row(Row& r) {
r.name = {};
r.channel = {-1, -1, -1, -1};
r.color_index = {0, 1, 2, 3};
r.layer = {0, 0, 0, 0};
r.slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
r.ruler_y = 0.0;
for (SlotSeries& s : r.slots) s.bind(nullptr);
}
bool RowSet::insert_row(int at) {
if (rows_ >= kMaxRows) return false;
const int pos = std::clamp(at, 0, rows_);
const int fr = std::clamp(at, 0, figure_row_count());
const int pos = first_plot_of_row(fr);
for (int i = rows_; i > pos; --i) carry_row(row(i), row(i - 1));
clear_row(row(pos));
++rows_;
cells_per_row_.insert(cells_per_row_.begin() + fr, 1);
cell_weights_.insert(cell_weights_.begin() + fr, std::vector<float>{});
// Keep row_weights_ in step with the figure row count: the new row gets the default
// weight (1.0) at its position so the existing rows keep theirs.
if (!row_weights_.empty()) {
row_weights_.resize(static_cast<std::size_t>(figure_row_count() - 1), 1.0f);
row_weights_.insert(row_weights_.begin() + fr, 1.0f);
}
rebuild_all();
return true;
}
bool RowSet::remove_row(int at) {
if (rows_ <= 1 || at < 0 || at >= rows_) return false;
for (int i = at; i + 1 < rows_; ++i) carry_row(row(i), row(i + 1));
--rows_;
clear_row(row(rows_));
if (figure_row_count() <= 1 || at < 0 || at >= figure_row_count()) return false;
const int pos = first_plot_of_row(at);
const int n = cells_per_row_[static_cast<std::size_t>(at)];
// The rows below move up by the removed row's cell count; the `n` slots freed at the end go back
// to empty.
for (int i = pos; i + n < rows_; ++i) carry_row(row(i), row(i + n));
for (int i = rows_ - n; i < rows_; ++i) clear_row(row(i));
rows_ -= n;
cells_per_row_.erase(cells_per_row_.begin() + at);
cell_weights_.erase(cell_weights_.begin() + at);
// Keep row_weights_ in step: erase the removed row's weight so the surviving rows keep theirs.
if (static_cast<std::size_t>(at) < row_weights_.size()) {
row_weights_.erase(row_weights_.begin() + at);
}
rebuild_all();
return true;
}
bool RowSet::insert_cell(int fr, int at) {
if (fr < 0 || fr >= figure_row_count()) return false;
if (cells_per_row_[static_cast<std::size_t>(fr)] >= kMaxCellsPerRow || rows_ >= kMaxRows) {
return false;
}
const int n = cells_per_row_[static_cast<std::size_t>(fr)];
const int k = std::clamp(at, 0, n);
const int p = first_plot_of_row(fr) + k;
for (int i = rows_; i > p; --i) carry_row(row(i), row(i - 1));
clear_row(row(p));
// The row's rulers belong to the new cell as well (they are one pair for the whole row): taken from
// a neighbour that is not the new cell, which sits at `p` and so cannot be its own source.
{
const Row& src = row(k == 0 ? p + 1 : first_plot_of_row(fr));
row(p).ruler_y = src.ruler_y;
row(p).ruler_b_y = src.ruler_b_y;
}
++rows_;
++cells_per_row_[static_cast<std::size_t>(fr)];
auto& w = cell_weights_[static_cast<std::size_t>(fr)];
if (!w.empty()) {
// The new cell takes the mean share, so it neither dwarfs nor vanishes beside its neighbours.
float mean = 0.0f;
for (const float v : w) mean += v;
mean /= static_cast<float>(w.size());
w.insert(w.begin() + std::min<int>(k, static_cast<int>(w.size())), mean);
}
rebuild_all();
return true;
}
bool RowSet::remove_cell(int plot) {
const int fr = figure_row_of(plot);
if (fr < 0) return false;
if (cells_per_row_[static_cast<std::size_t>(fr)] == 1) {
return remove_row(fr); // false when it is the last row
}
for (int i = plot; i + 1 < rows_; ++i) carry_row(row(i), row(i + 1));
--rows_;
clear_row(row(rows_));
const int k = plot - first_plot_of_row(fr);
--cells_per_row_[static_cast<std::size_t>(fr)];
auto& w = cell_weights_[static_cast<std::size_t>(fr)];
if (cells_per_row_[static_cast<std::size_t>(fr)] < 2) {
w.clear();
} else if (k < static_cast<int>(w.size())) {
w.erase(w.begin() + k);
}
rebuild_all();
return true;
}
void RowSet::set_cell_weights(int fr, std::vector<float> w) {
if (fr < 0 || fr >= figure_row_count()) return;
cell_weights_[static_cast<std::size_t>(fr)] = std::move(w);
if (figure_ != nullptr) apply_cell_weights();
}
void RowSet::set_cells_per_row(std::vector<int> cells) {
std::vector<int> next;
int total = 0;
for (int c : cells) {
c = std::clamp(c, 1, kMaxCellsPerRow);
if (total + c > kMaxRows) c = kMaxRows - total;
if (c < 1) break;
next.push_back(c);
total += c;
}
if (next.empty()) {
next.push_back(1);
total = 1;
}
cells_per_row_ = std::move(next);
cell_weights_.assign(cells_per_row_.size(), {});
rows_ = total;
rebuild_all();
}
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
r.plot = &p;
r.axis_x = r.plot->add_x_axis(
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
r.axis_y = r.plot->add_y_axis(
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
for (int s = 0; s < kSlotsPerRow; ++s) {
// Traces are drawn in the order they were created, and a `Plot` cannot reorder them: the
// *dimmest* traces are created first and a signal's own trace last, so the newest acquisition is
// the ink on top. A layer drawn over the event would take its colour out of the event's own trace
// wherever the two agree -- the opposite of what a persistence display shows.
std::array<int, kSlotsPerRow> order{};
for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast<std::size_t>(s)] = s;
std::stable_sort(order.begin(), order.end(), [&r](int a, int b) {
return r.slot_alpha[static_cast<std::size_t>(a)] < r.slot_alpha[static_cast<std::size_t>(b)];
});
for (const int s : order) {
const std::size_t i = static_cast<std::size_t>(s);
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
r.axis_x, r.axis_y, r.name[i]});
plot::Color color = signal_color(r.signal_name[i], r.color_index[i]);
// The slot's own brightness times the figure-wide one the group views set: a persistence
// layer's decay and an accumulation's translucency are the same knob, and `set_trace_alpha()`
// is what reads it here. It was set and never read before, which is why an accumulation's
// traces were opaque.
color.a = r.slot_alpha[i] * trace_alpha_;
r.plot->add_trace({&r.slots[i], color, 1.2f, r.axis_x, r.axis_y, r.name[i]});
}
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
@@ -261,14 +448,40 @@ void RowSet::set_row_weights(std::vector<float> weights) {
void RowSet::apply_row_weights() {
if (row_weights_.empty() || figure_ == nullptr) return;
row_weights_.resize(static_cast<std::size_t>(rows_), 1.0f);
row_weights_.resize(static_cast<std::size_t>(figure_row_count()), 1.0f);
(void)figure_->set_row_weights(row_weights_);
}
void RowSet::apply_cell_weights() {
if (figure_ == nullptr) return;
cell_weights_.resize(cells_per_row_.size());
for (int fr = 0; fr < figure_row_count(); ++fr) {
auto& w = cell_weights_[static_cast<std::size_t>(fr)];
const int n = cells_per_row_[static_cast<std::size_t>(fr)];
if (n < 2) {
w.clear();
continue;
}
if (w.empty()) {
figure_->clear_cell_weights(static_cast<plot::u32>(fr));
continue;
}
w.resize(static_cast<std::size_t>(n), 1.0f);
(void)figure_->set_cell_weights(static_cast<plot::u32>(fr), w);
}
}
void RowSet::rebuild_all() {
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
const std::vector<plot::u32> cells(cells_per_row_.begin(), cells_per_row_.end());
figure_ = std::make_unique<plot::Figure>(std::span<const plot::u32>(cells));
figure_->set_row_gap(kRowGap);
figure_->set_col_gap(kColGap);
// The cells of a split row are one plot with a vertical divider, not unrelated plots: they share
// one y range and auto state, and only the first draws the y axis. Before `install()`, which adds
// the axes the figure enrols; a row of one cell is never a group, so nothing changes for it.
figure_->set_share_row_y(true);
apply_row_weights();
apply_cell_weights();
x_group_ = figure_->add_x_link();
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
@@ -278,6 +491,13 @@ void RowSet::rebuild_all() {
continue;
}
install(r, i, figure_->plot(static_cast<plot::u32>(i)));
// A ruler is the row's (its y axis is shared), so every cell of a split row holds the first
// cell's values -- including a cell a restructure put next to one it had nothing to do with.
if (const int fr = figure_row_of(i); fr >= 0 && cell_of(i) > 0) {
const Row& first = row(first_plot_of_row(fr));
r.ruler_y = first.ruler_y;
r.ruler_b_y = first.ruler_b_y;
}
place_cursors(r);
if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
@@ -320,9 +540,132 @@ void RowSet::assign(int row_index, int slot, std::string signal_name,
r.channel[s] = -1; // re-resolved by the next bind()
r.name[s] = std::move(display_name);
r.color_index[s] = color_index;
// Whatever the slot held, it is a *signal's* trace now: a drop onto a slot a persistence layer
// was using is the user's arrangement, and the tick's plan moves the layers around it next frame.
r.layer[s] = 0;
r.slot_alpha[s] = 1.0f;
row_dirty_[static_cast<std::size_t>(row_index)] = true;
}
void RowSet::assign_layer(int row_index, int slot, std::string signal_name,
std::string display_name, int color_index, int age, float alpha) {
assign(row_index, slot, std::move(signal_name), std::move(display_name), color_index);
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
Row& r = row(row_index);
const std::size_t s = static_cast<std::size_t>(slot);
r.layer[s] = age;
r.slot_alpha[s] = alpha;
row_dirty_[static_cast<std::size_t>(row_index)] = true;
}
void RowSet::clear_layers() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.layer[si] == 0) continue;
assign(i, s, "", "", r.color_index[si]);
}
}
}
int RowSet::sync_layers(int depth, int* want) {
const int ages = std::clamp(depth, 1, kMaxPersistDepth) - 1;
// What each slot should hold: 0 a signal's own trace (kept as it is), -1 free, >0 a layer age.
std::array<std::array<int, kSlotsPerRow>, kMaxRows> plan{};
std::array<std::array<std::string, kSlotsPerRow>, kMaxRows> names{};
int wanted = 0;
for (int r = 0; r < rows_; ++r) {
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
// A layer slot is free for re-planning; a signal's own trace is not.
plan[static_cast<std::size_t>(r)][si] =
row_state.layer[si] > 0 ? -1 : (row_state.signal_name[si].empty() ? -1 : 0);
}
}
// Layer by layer, as the accumulate path fills its own: age 1 for every signal first, then age 2,
// so a row with room for two layers draws the *sharper* one for each of its signals rather than
// four ages of the first one.
for (int age = 1; age <= ages; ++age) {
for (int r = 0; r < rows_; ++r) {
const std::size_t ri = static_cast<std::size_t>(r);
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (plan[ri][si] != 0) continue;
++wanted;
int target = -1;
for (int t = 0; t < kSlotsPerRow; ++t) {
if (plan[ri][static_cast<std::size_t>(t)] == -1) {
target = t;
break;
}
}
if (target < 0) continue; // this row is full: it persists as far as it can
const std::size_t ti = static_cast<std::size_t>(target);
plan[ri][ti] = age;
names[ri][ti] = row_state.signal_name[si] + layer_suffix(age);
}
}
}
int placed = 0;
for (int r = 0; r < rows_; ++r) {
const std::size_t ri = static_cast<std::size_t>(r);
Row& row_state = row(r);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
const int now = plan[ri][si];
const int had = row_state.layer[si];
if (now > 0) {
++placed;
if (had == now && row_state.signal_name[si] == names[ri][si]) continue; // already right
assign_layer(r, s, names[ri][si], names[ri][si], row_state.color_index[si], now,
persist_alpha(now));
} else if (had > 0) {
assign(r, s, "", "", row_state.color_index[si]); // nothing wants this slot any more
}
}
}
if (want != nullptr) *want = wanted;
return placed;
}
int RowSet::layer_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 0;
return row(row_index).layer[static_cast<std::size_t>(slot)];
}
float RowSet::slot_alpha_of(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 1.0f;
return row(row_index).slot_alpha[static_cast<std::size_t>(slot)];
}
int RowSet::layer_slots() const {
int n = 0;
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
for (int s = 0; s < kSlotsPerRow; ++s) {
if (r.layer[static_cast<std::size_t>(s)] > 0) ++n;
}
}
return n;
}
std::vector<std::pair<int, float>> RowSet::row_layers(int row_index) const {
std::vector<std::pair<int, float>> out;
if (row_index < 0 || row_index >= kMaxRows) return out;
const Row& r = row(row_index);
for (int s = 0; s < kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
if (r.layer[si] > 0) out.push_back({r.layer[si], r.slot_alpha[si]});
}
// Oldest first: the order the decay has to read in.
std::sort(out.begin(), out.end(),
[](const auto& a, const auto& b) { return a.first > b.first; });
return out;
}
bool RowSet::slot_bound(int row_index, int slot) const {
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
@@ -348,9 +691,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
// Resolve the stable identity every frame: the channel list is rebuilt (and
// renumbered) whenever a signal is enabled, disabled or re-described, and a
// stored index would then point at a different signal's channel.
//
// A persistence layer resolves the name *under its suffix*: `sine (A)` is `sine`, one
// acquisition back, and it is the same signal's trace -- so it is acquired (or not) with its
// signal, drawn in its colour, and counted by nothing that counts signals.
const std::string base_name =
r.layer[si] > 0 ? layer_base_name(r.signal_name[si]) : r.signal_name[si];
int signal_index = -1;
for (std::size_t k = 0; k < signals.size(); ++k) {
if (signals[k].name == r.signal_name[si]) {
if (signals[k].name == base_name) {
signal_index = static_cast<int>(k);
break;
}
@@ -364,6 +713,36 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
}
}
}
if (r.layer[si] > 0) {
// A layer holds no channel: every reader that asks "is this slot a trace of a signal"
// -- the trace counts, the cursors pane's `mix` mode, the measurement marks, the layout
// writer -- tests `channel >= 0`, so a layer is invisible to all of them by construction.
r.channel[si] = -1;
// It draws only while a *snapshot* is on screen, and only if its signal is still acquired
// (switching a signal off takes its traces off every plot, layers included), and it
// resolves its channel **by name inside the snapshot**: the channel list may have been
// rebuilt and renumbered many times since that acquisition was cut, which is exactly why
// the trigger engine names its channel too.
std::shared_ptr<const EventSnapshot> snap;
if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) {
snap = model.persist_event(static_cast<std::size_t>(r.layer[si]));
}
std::size_t layer_channel = snap != nullptr ? snap->channel_names.size() : 0;
if (snap != nullptr) {
for (std::size_t c = 0; c < snap->channel_names.size(); ++c) {
if (snap->channel_names[c] == base_name) {
layer_channel = c;
break;
}
}
}
if (snap != nullptr && layer_channel < snap->values.size()) {
r.slots[si].bind_event(std::move(snap), layer_channel);
} else {
r.slots[si].bind(nullptr);
}
continue;
}
r.channel[si] = channel;
if (channel < 0) {
// Its signal is present but not acquired: keep the identity (so enabling it
@@ -715,10 +1094,11 @@ bool RowSet::update_time_labels() {
// millisecond-scale window shows. `x_origin_` is read back below for the caption.
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
const bool bottom = i == rows_ - 1;
if (!relative_x_ || !bottom) {
// The caption belongs with the labels, so it goes on every plot whose x strip draws them: the
// bottom row's, and any row above it that carries a strip of its own (a split row whose columns
// differ from the row below's; see `Figure`'s x-axis rule). The plots without labels get an empty
// one, which draws nothing.
if (!relative_x_ || !plot::x_strip_has_labels(r.plot->x_strip_mode())) {
r.plot->set_x_note("");
continue;
}
@@ -745,7 +1125,7 @@ void RowSet::hold_pinned_window() {
// and a following group would re-derive the window from a live edge on the next layout --
// ignoring the pin altogether.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
const plot::Range pin{-pinned_pre_, pinned_post_};
const plot::Range pin = pinned_range();
// The *request*: what the user's gesture left on the row's own axis. A member's `set_x_range`
// -- what the controller writes -- is not adopted into the group while the group is not
// following, so reading the group here only ever saw the previous clamp's answer and the wheel
@@ -753,11 +1133,21 @@ void RowSet::hold_pinned_window() {
// statement, which is what keeps a fresh pin (`pin_event_window()`, `pin_pause_window()`, both
// of which write the group in this same tick) from being reverted to the member's stale window.
plot::Range now = figure_->x_link_range(x_group_);
if (rows_ > 0 && row(0).plot != nullptr) {
const plot::Range member = row(0).plot->x_range(row(0).axis_x);
// Any plot's member may carry the gesture: the controller writes the plot under the pointer, and an x
// label strip is a plot's own (the bottom row's, or a row above it whose columns differ), so reading
// plot 0 alone made a zoom or pan on every other plot's axis vanish when the next statement of the pin
// went over it. Every member was last written with `pinned_stated_` (the group push), so the first
// one that differs is the gesture; the same clamp below applies whichever plot it came from.
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (r.plot == nullptr) continue;
const plot::Range member = r.plot->x_range(r.axis_x);
const bool member_is_ours = std::abs(member.min - pinned_stated_.min) < 1e-12 &&
std::abs(member.max - pinned_stated_.max) < 1e-12;
if (!member_is_ours) now = member;
if (!member_is_ours) {
now = member;
break;
}
}
const double pin_span = pin.span();
// **A zoom *in* is allowed.** The rule is that the view may not leave the time window, and a
@@ -818,6 +1208,11 @@ bool RowSet::event_data_window(double& lo, double& hi) const {
const Row& r = row(i);
if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) {
// A persistence layer is bound to an event series too, and it is the *past*: letting one
// widen this window would move the pin -- and with it the framing and the numbers a pinned
// measurement reads -- every time an older acquisition happened to be wider than the one on
// screen. What is pinned is the current event's window, and only that.
if (r.layer[static_cast<std::size_t>(s)] > 0) continue;
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
if (ev == nullptr || ev->size() < 2) continue;
const plot::Range b = ev->x_bounds();
@@ -857,7 +1252,7 @@ void RowSet::fit_x() {
// *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
// member that has moved since our own last statement -- that is how it tells a gesture from its
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
const plot::Range pin{-pinned_pre_, pinned_post_};
const plot::Range pin = pinned_range();
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
@@ -884,6 +1279,18 @@ void RowSet::sync_cursors(int drag_row) {
const double yb = r.plot->cursor(r.ruler_b).y;
if (y > kParked * 0.5) r.ruler_y = y;
if (yb > kParked * 0.5) r.ruler_b_y = yb;
// The row's cells share one y axis, so the dragged ruler is the row's: every other cell of the
// figure row takes the values and draws them (`Free`: the dragged cell's own value just came
// out of its plot and must not be snapped).
const int fr = figure_row_of(drag_row);
const int first = first_plot_of_row(fr);
for (int i = first; i < first + cells_in_row(fr); ++i) {
if (i == drag_row) continue;
Row& mate = row(i);
mate.ruler_y = r.ruler_y;
mate.ruler_b_y = r.ruler_b_y;
place_cursors(mate, plot::CursorPlacement::Free);
}
}
bool push = false;
if (cursor_mode_ != CursorMode::Horizontal) {
+103 -8
View File
@@ -112,6 +112,16 @@ class RowSet {
/// `add_trace()`, which is why this is stored rather than resolved per frame --
/// changing it needs a rebuild, and `assign()` already forces one.
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
/// The *persistence layer* this slot draws: 0 for a signal's own trace (whatever the frame's
/// display mode binds it to), `a` > 0 for the acquisition `a` latches back, drawn at
/// `slot_alpha`. A layer slot holds a suffixed name (`sine (A)`) and no channel at all -- it is
/// not a signal, and `channel[]` stays -1 for it (see `assign_layer()`).
std::array<int, kSlotsPerRow> layer = {0, 0, 0, 0};
/// How bright the slot's trace is built: 1 for a signal's own trace, `persist_alpha(layer)` for
/// a persistence layer, multiplied into `trace_alpha_`. It lives on the slot because a `Plot`
/// fixes a trace's colour -- alpha included -- at `add_trace()`, so a per-layer brightness is a
/// property of the trace's *creation*, which is what a rebuild is for.
std::array<float, kSlotsPerRow> slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
plot::u32 axis_x = 0;
plot::u32 axis_y = 0;
plot::u32 cursor_a = 0;
@@ -145,8 +155,43 @@ class RowSet {
double ruler_b_y = 0.0;
};
/// The number of **plots** (cells over all figure rows), which is what `row(i)` indexes. With no
/// row split it is also the number of figure rows; `figure_row_count()` is the latter.
int row_count() const noexcept { return rows_; }
/// A figure row holds one to `kMaxCellsPerRow` plots side by side, sharing the row's height and
/// the figure's one x window. The flattened plot index (`row(i)`, `Row::plot`, the figure's
/// `plot(i)`) runs row-major: row 0's cells left to right, then row 1's.
static constexpr int kMaxCellsPerRow = 4;
int figure_row_count() const noexcept { return static_cast<int>(cells_per_row_.size()); }
/// How many cells figure row `fr` holds; 0 for a row that does not exist.
int cells_in_row(int fr) const;
/// The flattened index of figure row `fr`'s first cell; `row_count()` for `fr ==
/// figure_row_count()`.
int first_plot_of_row(int fr) const;
/// The figure row plot `plot` is in, or -1 for an index that is not a plot.
int figure_row_of(int plot) const;
/// Plot `plot`'s 0-based position inside its row, or -1.
int cell_of(int plot) const;
/// Figure row `fr`'s cell width shares (empty means equal; always empty for a single-cell row).
const std::vector<float>& cell_weights(int fr) const;
/// What the UI calls a plot: the figure row's number (`"2"`) for a single-cell row, the number
/// and a letter (`"2a"`..`"2d"`) for a split one.
std::string plot_label(int plot) const;
/// Adds an empty cell to figure row `fr` at position `at` (clamped to the row), pushing the
/// cells at and after it right. False at `kMaxCellsPerRow` cells or `kMaxRows` plots.
bool insert_cell(int fr, int at);
/// Removes plot `plot`. A single-cell row removes the row itself (`remove_row()`); false when
/// that would leave no plot.
bool remove_cell(int plot);
/// Figure row `fr`'s cell width shares, normalised by the plotter; empty means equal.
void set_cell_weights(int fr, std::vector<float> w);
/// Replaces the whole arrangement: `cells[r]` plots in figure row `r`, each clamped to
/// `1..kMaxCellsPerRow` and the total to `kMaxRows`. Weights are reset; plot contents stay in
/// their flattened slots.
void set_cells_per_row(std::vector<int> cells);
/// The alpha every trace is drawn with. The *group* views use it: accumulating events wants their
/// traces translucent so that agreement between them reads as brightness, which is the whole point
/// of a persistence display. It is mixed into the palette colour where the trace is created, so it
@@ -159,22 +204,24 @@ class RowSet {
/// trace set or the row count changes, so weights that lived only in the figure would be lost on the
/// first drop or row change after a user had sized their layout.
void set_row_weights(std::vector<float> weights);
/// Sizes the stored weights to the rows that exist and hands them to the figure.
/// Sizes the stored weights to the figure rows that exist and hands them to the figure. Weights
/// are per **figure row**, not per plot.
void apply_row_weights();
const std::vector<float>& row_weights() const noexcept { return row_weights_; }
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
/// count is fixed at construction.
/// Shows `rows` figure rows of one cell each (so `rows` plots, any split undone). A changed
/// arrangement rebuilds the figure, since a `Figure`'s shape is fixed at construction.
void set_row_count(int rows);
/// Inserts an empty row at `at` (0 = above the first row, `row_count()` = below the
/// last), pushing the rows at and below it down. Returns false at `kMaxRows`.
/// Inserts an empty single-cell figure row at `at` (0 = above the first row,
/// `figure_row_count()` = below the last), pushing the rows at and below it down with their
/// cells. Returns false at `kMaxRows` plots.
///
/// The rows below move as *rows*: their trace assignments, colours and cursors follow
/// them, so inserting above row 1 does not renumber what row 1 was plotting. The
/// figure's `Plot`s are not what moves -- they are rebuilt and re-dealt -- which is
/// the same mechanism a row-count change already used.
bool insert_row(int at);
/// Removes row `at` and pulls the rows below it up. Returns false when only one row
/// is left: a figure with no rows has nothing to draw and no way back.
/// Removes figure row `at` (with all its cells) and pulls the rows below it up. Returns false
/// when only one row is left: a figure with no rows has nothing to draw and no way back.
bool remove_row(int at);
Row& row(int i) { return storage_[static_cast<std::size_t>(i)]; }
const Row& row(int i) const { return storage_[static_cast<std::size_t>(i)]; }
@@ -200,6 +247,42 @@ class RowSet {
/// The channel index a row/slot holds, or -1.
int channel_of(int row, int slot) const;
// --- persistence layers ---------------------------------------------------
/// The brightness a persistence layer is drawn at, `a` acquisitions back: the acquisition on
/// screen is the full-bright one and each older one is **half** the previous, so the schedule is
/// exponential in age -- 1, 0.5, 0.25, 0.125, ... and by the seventh (0.008, `kMaxPersistDepth`)
/// the layer is gone. Halving is per acquisition and not per *depth*, so changing N re-cuts how
/// many are kept without rescaling the ones already on screen.
static float persist_alpha(int age) noexcept;
/// Binds a row's slot to a persistence *layer*: the acquisition `age` latches back, at `alpha`
/// (see `Row::layer`). The name carries the layer's suffix (`sine (A)`, `sine #4`), which is what
/// keeps a layer out of the signal list and out of the measurements -- it is not a signal -- while
/// `signal_color()` still resolves it to the base signal's own colour.
void assign_layer(int row, int slot, std::string signal_name, std::string display_name,
int color_index, int age, float alpha);
/// Frees every slot bound to a layer. What turning persistence off -- or the trigger -- is.
void clear_layers();
/// Lays the persistence layers out over the rows' free slots, one per (signal, age): the signal's
/// own trace stays where it is and its layers take the free slots of the *same* row, layer by
/// layer (age 1 for every signal first, then age 2, as the accumulate path does), so a row that
/// runs out of room draws as many layers as it has room for and no more. Every slot the plan does
/// not fill is freed, which is what makes the display follow a layout change, a depth change and a
/// signal's removal without any caller having to reset anything.
///
/// Returns the number of layers placed; `want` is how many the depth asked for, so a caller can
/// say when a layout could not show them all.
int sync_layers(int depth, int* want);
/// The layer a slot is drawing, 0 for a signal's own trace.
int layer_of(int row, int slot) const;
/// The slot's own brightness (1 unless it is a layer).
float slot_alpha_of(int row, int slot) const;
/// How many slots are layers, over every row: the reading that says the layers exist.
int layer_slots() const;
/// The layers of one row, oldest first, as `(age, alpha)` pairs -- the ordering a reader has to
/// see to know the decay is the right way round.
std::vector<std::pair<int, float>> row_layers(int row) const;
/// Rebinds every slot to what `mode` says it should show this frame. Called every
/// frame, because the channel list is renumbered by any structural change.
///
@@ -385,6 +468,12 @@ class RowSet {
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
/// `fit_x()` branches on, which is why the fit probe reports it.
bool window_pinned() const noexcept { return pinned_; }
/// The pin's own extent, in the x the rows' axes read -- what a pinned view is a view *of*.
/// The one expression of it, shared by the framing (`hold_pinned_window()`, `fit_x()`) and by
/// the measurement pane, which measures *this* while the view is pinned so that a gesture
/// changes no number (see `update_measures()`); meaningless when `window_pinned()` is false.
plot::Range pinned_range() const noexcept { return {-pinned_pre_, pinned_post_}; }
/// True when the pin already *is* this window. A caller that re-states a pin every frame -- the file
/// view does, from the ring's own extent -- uses this to update the pin's numbers with
/// `set_data_pin()` instead of writing the group's range again with `pin_event_window()`, which would
@@ -511,9 +600,15 @@ class RowSet {
std::unique_ptr<plot::Figure> figure_;
/// The one x link group every visible row belongs to. Groups are numbered from 1.
plot::u32 x_group_ = 0;
/// The number of plots; `sum(cells_per_row_) == rows_` always.
int rows_ = 1;
/// Cells per figure row, and each row's cell width shares (empty = equal; kept empty for a
/// single-cell row). Both are state of the `RowSet`, restated to every new figure.
std::vector<int> cells_per_row_{1};
std::vector<std::vector<float>> cell_weights_ = std::vector<std::vector<float>>(1);
void apply_cell_weights();
float trace_alpha_ = 1.0f;
std::vector<float> row_weights_;
std::vector<float> row_weights_; ///< per figure row
double window_s_ = 2.0;
double trigger_marker_x_ = 0.0;
bool trigger_marker_on_ = false;
+15
View File
@@ -3,6 +3,7 @@
#include "settings.hpp"
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <fstream>
@@ -64,8 +65,15 @@ Settings load_settings() {
s.event_cache_max = std::max(0, std::atoi(value.c_str()));
} else if (key == "relative_time") {
s.relative_time = parse_bool(value, s.relative_time);
} else if (key == "memory_limit_mb") {
const long mb = std::strtol(value.c_str(), nullptr, 10);
if (mb >= 16) s.memory_limit_mb = static_cast<int>(std::min<long>(mb, 1000000000L));
} else if (key == "theme") {
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;
@@ -79,7 +87,14 @@ bool save_settings(const Settings& settings) {
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
out << "event_cache_max = " << settings.event_cache_max << "\n";
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
out << "memory_limit_mb = " << settings.memory_limit_mb << "\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();
return out.good();
}
+15
View File
@@ -30,9 +30,24 @@ struct Settings {
/// buffers are planned before any stream is known, so it takes effect on the next start (the
/// preferences dialog says so, like the configurations dialog does).
bool relative_time = false;
/// The hard ceiling on the sample rings' memory, in MiB, for the whole application rather than per
/// source (`--memory-limit-mb` overrides it for one run). The planner halves a source's sample
/// budget until the rings fit under it. Unlike the cache directory and relative time it applies as
/// it is changed: a running scope re-plans its buffer (and so drops its history) when the new limit
/// changes the rings.
int memory_limit_mb = 8192;
/// "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.
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
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more