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
142 changed files with 33424 additions and 2654 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 three 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 how the Mallard manual is opened. |
| [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. |
+20 -1
View File
@@ -151,7 +151,7 @@ add_custom_command(
# 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 bundle
# is small enough per file never to need it), so the build stays at its three warnings. The manual is
# 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")
@@ -236,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
@@ -255,10 +269,15 @@ 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
)
+658 -116
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
+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
+122 -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
@@ -96,13 +99,70 @@ prefix-wide target *is* that rule, reached through the `Help` component
(`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every
component, so its behaviour is unchanged.
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
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:
@@ -209,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
+65 -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
@@ -169,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
@@ -177,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
@@ -265,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
+135 -88
View File
@@ -50,39 +50,25 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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, Configurations) and the one editor that is not a pane (the layout editor): the signal list
is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. 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
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,events,resize-rows,tab-measure,tab-signals}-symbolic.svg`
are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
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, not the tab's.
The two **tabs** got glyphs of their own, and each is a *pair* rather than one of the icons above.
`udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two
bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed
frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and
`udpscope-tab-signals-symbolic` is a sine crossing its dashed level, beside two bold channel rows. They
are not reuses, because a glyph the toolbar already carries means two things the moment a tab shows it
too; instead the *trigger's* own icon (`udpscope-trigger-symbolic`) was redrawn to the Signals pill's
picture, so the pill and the button that turns the trigger on are recognisably about the same thing.
The wave is a **full period and not a hump**: a crest and a trough are what make it read as a wave
rather than as a hill, and the level's dashes survive between the crossings. (A hump was tried and
reverted — it named the "above the level" half of a trigger and lost the crossings, which is the half
that matters.)
Both are **32x16** composites drawn **bold** — one thick band, two thick dashes, few features — because
at this size a thin wave or a 1px tick reads as *texture* rather than as a shape, which is what "not
clear" looks like. Wider than tall on purpose: a 16px square cell carries one motif, not a pair. And
**GDK paints an icon into a square box whatever the document's aspect** (measured:
`gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg fits it to `32x16`), so the pair is
letterboxed rather than stretched — and the pill's image is given `pixel_size` 32 in `name_side_tabs()`,
which draws the artwork at the size it is written, so its bars stay crisp. Every glyph here is
**inset two pixels**, because art flush with the cell edge is what a button's padding or a crop takes a
pixel off, and a fractional scale is what would blur it, which is why nothing is drawn at 24 or 48. The
two tab glyphs measure 29% and 30% ink, inside the range the icons above them set.
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"`,
@@ -116,11 +102,20 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the `help:<document>` URI to the desktop help viewer (`GtkUriLauncher`, i.e. Yelp) — a process of
its own that cannot be closed from here — because the manual is a Mallard set installed for the
freedesktop `help:` URI, not a GResource in this binary (see the help bullet below). So there is
exactly **one `GtkWindow`** among what the app opens: the shortcut overview. It closes with
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 About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
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
@@ -224,9 +219,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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.
@@ -234,46 +230,49 @@ 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 a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher`
above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different
heights, so one shared scroller would leave a switch scrolled where the new page never asked to
be). The reason is the one this trap teaches: five sections competing for one 320 px column left
every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the
**cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the
**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 stack's hidden page measures 0x0 too and is still
given the leftover width, so **ask about state, not about size** carries over unchanged. Every
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
current -- a change in what the reading means, not a defect, and no functional code may depend on
those numbers.
- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.**
Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a
`GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab
bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to
`select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick
calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and
`AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an
`AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
the sidebar on `drag-begin` and restores it on `drag-end` — only when collapsed, because side by side the
plot is already visible and hiding the list the drag started in would be startling. `pane drag: sidebar
hidden for the drag=1 restored after it=1` is the reading; the state is forced collapsed the way the
sidebar probe forces it.
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two
panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
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
@@ -285,13 +284,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`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 switcher must
(`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
@@ -308,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
@@ -601,12 +605,39 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
`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,
@@ -615,15 +646,26 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
compiled once and the probe reads either window's state by the same fields.
- **A stop declares the page it stands on, and the tour selects that page as the stop is entered.** A
stop whose target lives on another page of a stack -- the window's `signals` (and `trigger`) stops
on the sidebar's `signals` page, the chooser's "Two kinds of input" stop on its `waveforms` page --
names that page in `TourStop::page`, and `show_step()` selects it from `TourView::page_stack`
before anything is measured. The page switch is **not** followed by a one-shot measure: `show_step()`
selects the page and `tour_update()`, which already runs every frame, fills the spotlight in on the
frame *after*, when the new page has been laid out. A stack allocates the new page's widgets on the
- **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)) -- and reading the
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
@@ -632,11 +674,15 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
`gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both
read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to
drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when
its target is **mapped**, or when it names a page and the stack that page belongs to is mapped: a
wrong page leaves the stack mapped, so the stop is kept and the page switch reveals it; a collapsed
pane leaves the stack unmapped too, so no page can reveal it and the stop is dropped. The filter
lives in `tour_begin()`, not in each window's `start_tour()`, both so the two windows cannot drift
into two rules and because the rule needs the view's own `page_stack`.
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
@@ -670,8 +716,9 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names
ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after
the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
dump" rule again. `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger
summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0, and a crop
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.
+23 -1
View File
@@ -307,7 +307,7 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b
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 Calibrate, because calibration is applied at
*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
@@ -323,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.
+145 -12
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.
@@ -20,6 +20,38 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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
@@ -66,9 +98,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
exactly `2` on a unit sine, and it and `average` still come straight out of the one shared
`measure_stats()` pass. A bucket's sum is a sum. (`top` has no *exact* reading to protect any
more: it is a density estimate and not a mean, and the two places that used to assert
`top == 0.636620` on a sine now assert what the flat level actually reads -- see the flat-level
invariant below. Everything above is about the *bucket* path being a fast path and not an
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
@@ -85,6 +118,47 @@ 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
@@ -139,6 +213,60 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`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, which is the *fallback* `Top`/`Bottom` read on a window with no
@@ -170,12 +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`.** Two things read it and neither may be
* **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), and `Top`/`Bottom` fall back to the mean of
each side on a window with no bucket histogram. The band is about *deciding a crossing*, not
about where a sample lives, so folding it in would change a filtered window's `top` for no gain
to any crossing count.
(`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
@@ -201,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
+33 -1
View File
@@ -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
@@ -55,7 +87,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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* rather than hiding it -- a panel you can still see is a panel you can read, and 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
+51 -16
View File
@@ -13,24 +13,33 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
(`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()`
resolves it itself when they are already there) — a name that resolved to nothing would silently move
the trigger to channel 0.
- **A session that loaded is what locks the two panes.** Locked is the returning user's mode: the
trigger pane shows one compact line, the signal list only the signals that are on, and neither offers
a control that changes the configuration. A *first run* — no session file for that source — never
- **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 row onto a plot is not configuration and survives
the lock**: filling plots is what the list is *for*, so a locked row keeps its `GtkDragSource` — from
anywhere on it, the colour chip included, which in configuration mode has a click gesture that claims
the sequence precisely so a drag cannot start there. `drag_sources` on the `signals panel:` line is the
reading, and it must be `rows - 1`.
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
@@ -42,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
@@ -134,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
@@ -145,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.
+262 -13
View File
@@ -27,6 +27,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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
@@ -36,7 +46,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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
@@ -52,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
@@ -74,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
@@ -141,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]`
@@ -175,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
@@ -197,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
File diff suppressed because it is too large. Load diff
+486 -77
View File
@@ -13,29 +13,49 @@ 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 **three** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons'
generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with
the seven action icons and the two app icons that remain (a note about a generated file, which GCC
itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is
now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that
bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by
touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers`
or any other warning is a finding. (The fifth warning that used to appear here was an unused
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
`output_after_add` already printed on the same line — which dropped the baseline from five to
four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's
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 **one** of those three: the generated GResource `.c` is not recompiled
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`,
@@ -57,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
@@ -76,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
@@ -96,11 +254,25 @@ precedent, not an exception.
overshoot, kept by the extremes and excluded by the levels. The per-type values, the reason for
each, and the cost of the histogram are in
[invariants-measurements.md](invariants-measurements.md).
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
(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.
`[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
@@ -120,13 +292,60 @@ precedent, not an exception.
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).
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
@@ -145,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.
@@ -226,12 +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 on the
Measure tab, a forced pass, then the tab switched away and read again two seconds later. **`advanced=4`
is the assertion** (every accumulator gained passes while its page was off screen); `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. It enables *and plots* its own signal, so it needs no other
probe ahead of it. The reading's own entry is in `probe-readings.md`.
- `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=`.
@@ -251,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 |
| --- | --- | --- |
@@ -296,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=…`).
@@ -338,12 +697,14 @@ as distinct from the fault injection below, which exercises ingest:
bullet) — so a probe run against a fresh config directory will have it on screen for the first
second and a half, and that start *writes* `tour_seen` to whichever config directory the run is
using, which is why the second run against the same one reads `auto=0`.
- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it puts the sidebar on **Measure**,
starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's
page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the
tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch
could have revealed). `[toursurface] signals stop=4/5 … side_tab=signals … contains=1` and
`[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0` are the two assertions; its
- `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
@@ -354,9 +715,9 @@ 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
`[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*:
@@ -385,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
@@ -437,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**.
@@ -470,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.
@@ -558,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.
@@ -583,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
@@ -593,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`
@@ -674,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
@@ -691,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
@@ -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,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.
+7 -4
View File
@@ -22,11 +22,13 @@
<title>How to reach it</title>
<steps>
<item>
<p>Press the cursors button in the toolbar, or <key>c</key>.</p>
<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 pane below the plot shows the readings and owns a <gui>Reset</gui> button.
Drag a marker to move it.</p>
<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),
@@ -42,7 +44,8 @@
any row moves it on all of them. That is what makes <code>dt</code> meaningful across
rows.</p></item>
<item><p>A ruler belongs to its row and nothing shares it, so two rows' rulers are
independent.</p></item>
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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 129 KiB

+44 -16
View File
@@ -5,8 +5,8 @@
<info>
<link type="guide" xref="index#main"/>
<link type="seealso" xref="signals"/>
<desc>How the figure's rows and traces are arranged, how to drop a signal onto a row, and
how the layout editor adds and removes rows.</desc>
<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>
@@ -14,9 +14,10 @@
<title>Rows, layout and drag &amp; drop</title>
<p>The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share
one time axis and one pair of side margins, so their y labels line up, their data areas
start and end on the same two columns, and their windows cannot drift apart. The left
<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>
@@ -24,12 +25,12 @@
<title>How to reach it</title>
<steps>
<item>
<p>Drag a signal's chip out of the side panel (<key>s</key>) and drop it on a row to
<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 row and a <gui>+</gui>
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>
@@ -38,19 +39,45 @@
</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, the two fits, and <gui>Remove signal</gui>, which swaps
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 row the drop is refused.</p></item>
<item><p>Inserting a row moves the rows below it <em>with their contents</em>. Inserting
above row 1 puts what row 1 plotted onto row 2 and leaves the new row empty — the rows
are not renumbered under you.</p></item>
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>
@@ -62,10 +89,11 @@
<section id="example">
<title>Example</title>
<p>With three traces on row 1, pressing <key>l</key> shows 3 buttons (one <gui>×</gui>,
two <gui>+</gui>). Clicking the <gui>+</gui> above row 1 gives 2 rows and 5 buttons, with
the three traces now on row 2 — the layout probe reports exactly this as
<code>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</code>.</p>
<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>
+5 -3
View File
@@ -22,10 +22,12 @@
<title>How to reach it</title>
<steps>
<item>
<p>Press the math button in the toolbar, or <key>g</key>.</p>
<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 pane's <gui>+</gui> adds a channel, and opening one shows the graph editor:
<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>
@@ -53,7 +55,7 @@
<em>above</em> it. A wire drawn right to left re-sorts the list silently so the graph
stays valid. A cycle is unrepresentable: the connection is refused rather than created,
and the line beneath the form says why — a loop, or a node asked to read itself.</p></item>
<item><p>A derived channel <em>is</em> a channel. It appears in the signal panel, can be
<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
+47 -10
View File
@@ -5,7 +5,8 @@
<info>
<link type="guide" xref="index#analysis"/>
<link type="seealso" xref="cursors"/>
<desc>A measurement is a statistic of the visible window of one signal, with optional
<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>
@@ -14,20 +15,27 @@
<title>Measurements</title>
<p>A measurement is a statistic of the <em>visible window</em> of one signal, recomputed as
<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 the measurements button in the toolbar, or <key>m</key>.</p>
<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 pane's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
the type, the threshold share and the on-plot marks are set, with <gui>Save</gui> and
<gui>Delete</gui>.</p>
<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>
@@ -35,14 +43,32 @@
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, pulse
duration, period and frequency. <gui>top</gui> and <gui>bottom</gui> are the waveform's
<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
@@ -95,14 +121,18 @@
not the raw window: a bucket's sum is a sum, so the mean, the extremes and a level's own
mean are exact. <gui>top</gui>, <gui>bottom</gui> and <gui>amplitude</gui> read the
<em>density</em> of the window out of that same summary — which is what tells a flat level
from a ripple — so those three cost a second read of the summary and never of a sample.</p></item>
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.</p></item>
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>
@@ -117,6 +147,13 @@
<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>
+61 -26
View File
@@ -4,7 +4,7 @@
<info>
<link type="guide" xref="index#main"/>
<desc>What udpscope plots, the four parts of the window, and one pass through the
<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>
@@ -18,7 +18,7 @@
<p>udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots,
each with up to four traces, and every row shares one time axis, so the whole figure
scrolls together. Acquisition runs on its own thread and writes a ring buffer sized from
<cmd>--buffer-mb</cmd>; the drawing thread reads whatever that ring holds when a frame
<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
@@ -34,11 +34,12 @@
<figure>
<title>The udpscope window</title>
<desc>The header bar above the plots, the side panel on the right, and the status bar
along the bottom.</desc>
<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 four signals enabled, the Measure tab open in the
side panel, and the status bar reading <gui>live</gui>.</p>
<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>
@@ -55,7 +56,20 @@
<p>The signals arrive <em>disabled</em>. Nothing is acquired until you turn one on in the
signal list, and enabling is what starts acquisition — so a freshly opened scope with an
idle side panel is working correctly, not failing to connect.</p>
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
@@ -68,30 +82,49 @@
<terms>
<item>
<title>The header bar</title>
<p>Left to right: the time window (<gui>2 s</gui>), the trigger and the pause, the
events, cursors and side-panel switches, and the menu, which holds
<gui>Fit to data</gui>, the <gui>Layout editor</gui>, <gui>Export</gui>,
<gui>Keyboard shortcuts</gui>, <gui>Help</gui> and <gui>About</gui>.</p>
</item>
<item>
<title>The side panel</title>
<p>Two tabs. <gui>Measure</gui> holds the cursors readout and the measurements
derived from it; <gui>Signals</gui> holds the trigger and the channel list, where a
math channel is a row of that one list under a <gui>MATH</gui> heading whose
<gui>+</gui> creates one. One row per channel: its colour, an enable switch, its name
and its unit. Channels are dragged from here onto a plot row.</p>
<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>Directly above the plot's bottom edge — the state word, the stored sample rate and
<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 held capture looks like: <gui>event held</gui>, the
axis pinned to the event, and the trigger marker at the edge.</p>
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>
@@ -114,12 +147,12 @@
and port.</p>
</item>
<item>
<p><em>Enable a signal</em> in the signal list. The plot stays empty for the first
<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 it onto a row.</em> Drop a second signal on another row, or the same
<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>
@@ -139,8 +172,10 @@
got.</p>
</item>
<item>
<p><em>Measure it</em> — the measurements pane computes a statistic of the visible
window, and <gui>show on plot</gui> marks it as a ruler or a pair of markers.</p>
<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
+4 -2
View File
@@ -22,7 +22,7 @@
<title>How to reach it</title>
<steps>
<item>
<p>Press the pause button in the toolbar, or <key>Space</key>.</p>
<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
@@ -41,7 +41,9 @@
snapshot, and the pinned axis is that shorter span — so you see a narrower view rather
than an empty margin.</p></item>
<item><p>A paused view is pinned exactly as a held trigger event is: zoom in freely, but
a pan or a zoom out is clamped to the snapshot's data.</p></item>
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
+3 -3
View File
@@ -41,7 +41,7 @@
they open.</p>
<p>A configuration also carries the settings that can only be decided when the buffers are
allocated: the <gui>buffer size</gui> (<cmd>--buffer-mb</cmd>), relative time, and the
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>
@@ -57,8 +57,8 @@
<list>
<item><p>What a configuration persists: the time window, the rows and each row's slot
signals, the calibration, the trace colours, which signals were enabled, the math
channels with their graphs, the measurements pane — and the startup settings: buffer
size, relative time and the packet capture's ring size.</p></item>
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
+21 -12
View File
@@ -23,11 +23,17 @@
<title>How to reach it</title>
<steps>
<item>
<p>Open the side panel (<key>s</key>), which lists every signal with a switch.</p>
<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>The <gui>Signal list</gui> item in the overflow menu opens the editor where the
unit and the calibration live.</p>
<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>
@@ -38,10 +44,13 @@
<item><p>Signals start disabled, and enabling one is what starts acquiring it. A trigger
on a signal nobody acquires can never fire, so arming a trigger enables its signal for
you.</p></item>
<item><p><gui>Calibrate</gui> in a signal's row menu opens the gain and offset it is
<em>currently</em> drawn through, so a trace already scaled by a gain of 2 shows 2 —
<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.</p></item>
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
@@ -51,12 +60,12 @@
refuses to put it in a row rather than binding a trace that would read nothing for
ever.</p></item>
<item><p>A signal's colour follows the <em>signal</em>, not the slot, so the same signal
on two rows is one colour and the side panel's chip identifies it unambiguously. A
signal on no row gets a grey chip rather than none, so the panel's rows keep their
height.</p></item>
<item><p><em>Click a coloured chip to change that signal's colour.</em> A grey chip
names no trace, so it is not clickable. The colour is saved with the session;
<gui>Default colour</gui> in the signal's ⋯ menu gives the palette entry back.</p></item>
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>
+1 -1
View File
@@ -28,7 +28,7 @@
stored <em>configuration</em> (<code>host:port · name</code>, its note, and the
settings it carries), newest first, plus a <gui>New host…</gui> form that takes the
address <em>and</em> the configuration it belongs to — its name and note, the
<gui>buffer size</gui>, and whether time is relative or absolute. A row's pencil
<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>
+1 -1
View File
@@ -18,7 +18,7 @@
<section id="reach">
<title>How to reach it</title>
<p>The bar is always visible under the plot. Click <gui>ⓘ</gui> for the detail popover.</p>
<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">
+110 -20
View File
@@ -5,7 +5,7 @@
<info>
<link type="guide" xref="index#viewing"/>
<link type="seealso" xref="pause"/>
<desc>The trigger's four shapes, arming and hold-off, the event cache with its replay,
<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>
@@ -23,18 +23,30 @@
<title>How to reach it</title>
<steps>
<item>
<p>The trigger button in the toolbar (<key>t</key>) reveals the trigger panel: the
signal, the level, the shape, the edge (rising, falling, either), the mode (Normal or
Single) and the pre-trigger share.</p>
<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 pane is locked with its <gui>[edit]</gui> button — so that it states what the
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 panel. It is
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>
@@ -82,34 +94,109 @@
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> pane on the left — opened from the toolbar's
event-history button, which appears while recording is enabled, and collapsed until you
ask for it — lists the last 32 newest first (time, duration, signals, rate), each row with
its own picture beside the numbers.</p>
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 <em>replays</em> it: the stored event is loaded exactly like an exported
<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, and the pane stays
open. Drag the pane's edge to resize it. <gui>Live</gui> goes back to the stream; the
status bar names the file until you do.</p>
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>Select two or more events in the list and the two buttons under the header act on them:
<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. A single click
selects, a <em>double</em> click replays.</p>
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>, in the trigger panel with the trigger's other options, is
<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 the history button goes with it.</p>
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">
@@ -178,7 +265,10 @@
by live data.</p></item>
<item><p>The first edge after arming is only accepted once the history reaches back a
full pre-trigger window, so a capture may legitimately be shorter than you asked for.
The axis is pinned to what the capture actually got.</p></item>
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>
+4 -5
View File
@@ -56,11 +56,10 @@
<section id="example">
<title>Example</title>
<p>1 MS/s with 200 MB of buffer. A 2 s window stores every sample
(<code>time x1</code>). Widen it to 600 s and the stride becomes <code>x16</code>: 625 S/s
stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads
<code>y=[-0.059, 0.079]</code> — attenuated to about 6%, not folded down to a
full-amplitude 125 Hz one.</p>
<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>
+279 -158
View File
@@ -5,9 +5,8 @@
#include <limits>
#include <udps_client.h>
#include "layout_store.hpp"
#include "source/registry.hpp"
#include <glib/gstdio.h>
@@ -29,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) {}
@@ -52,31 +35,21 @@ Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model),
Acquisition::~Acquisition() { stop(); }
void Acquisition::start() {
// A started -- or *restarted* -- client is a new stream. Going live from a file view is the restart that
// 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();
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));
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.
@@ -90,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);
}
}
@@ -127,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);
@@ -171,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());
}
@@ -203,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
@@ -243,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.
@@ -285,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];
@@ -347,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.
@@ -381,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;
@@ -485,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;
@@ -541,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;
}
@@ -561,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;
@@ -569,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
@@ -588,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;
}
@@ -608,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();
@@ -648,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_) {
@@ -695,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;
@@ -809,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
@@ -868,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.
@@ -907,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
@@ -915,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
@@ -1020,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
@@ -1037,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;
@@ -1048,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;
@@ -1072,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));
@@ -1098,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;
+44 -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).
@@ -60,6 +87,15 @@ struct Config {
/// 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'
+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.
@@ -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,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Measure tab: two cursors beside the ruler they read against. Each cursor is a cap over two
bold dashes — the mark the toolbar's cursors button carries, at a weight that survives this size.
The ruler is two bands with three marks between them and *no end caps*: a closed frame reads as a
rectangle, and it costs a fifth of the ink on two lines that say nothing. Both motifs are centred
on the same line and share one weight, and everything is inset two pixels. -->
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M2 2 H7 V5 H2 Z
M3 6 H6 V10 H3 Z
M3 11 H6 V14 H3 Z
M9 2 H14 V5 H9 Z
M10 6 H13 V10 H10 Z
M10 11 H13 V14 H10 Z
M17 4 H30 V6 H17 Z
M17 10 H30 V12 H17 Z
M18 6 H20 V10 H18 Z
M22 6 H24 V10 H22 Z
M26 6 H28 V10 H26 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Signals tab: the toolbar's trigger picture — a sine crossing its dashed level — beside the
channel list. Sharing that picture is the point: the pill and the button that turns the trigger on
should be recognisably about the same thing. A full period and not a hump: the crest and the trough
are what make it read as a wave rather than as a hill, and the level's dashes survive between the
crossings. Two channel rows on the right, drawn bold like the wave, and everything inset two
pixels, because art flush with the cell edge is what a button or a crop clips. -->
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M2 6 H3 V10 H2 Z
M3 4 H4 V8 H3 Z
M4 3 H5 V7 H4 Z
M5 2 H6 V6 H5 Z
M6 3 H7 V7 H6 Z
M7 4 H8 V8 H7 Z
M8 6 H9 V10 H8 Z
M9 8 H10 V12 H9 Z
M10 9 H11 V13 H10 Z
M11 10 H12 V14 H11 Z
M12 9 H13 V13 H12 Z
M13 8 H14 V12 H13 Z
M14 6 H15 V10 H14 Z
M2 7 H5 V9 H2 Z
M7 7 H10 V9 H7 Z
M12 7 H15 V9 H12 Z
M18 3 H23 V8 H18 Z
M24 4 H30 V7 H24 Z
M18 10 H23 V15 H18 Z
M24 11 H30 V14 H24 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

+1 -2
View File
@@ -20,10 +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-tab-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-tab-signals-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>
+103 -16
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,8 +488,11 @@ 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;
}
@@ -516,6 +539,18 @@ bool load_layout(const std::string& path, LayoutState& out) {
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) {
@@ -538,19 +573,26 @@ bool load_layout(const std::string& path, LayoutState& out) {
}
} else if (key == "measure_stats") {
st.measure_stats = value == "true";
} else if (key == "side_tab") {
st.side_tab = strip_quote(value);
} 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") {
@@ -570,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();
@@ -612,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);
@@ -641,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);
@@ -679,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.
@@ -691,7 +762,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "]\n";
}
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
o << "side_tab = \"" << state.side_tab << "\"\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()) {
@@ -710,12 +781,24 @@ bool save_layout(const std::string& path, const LayoutState& state) {
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]);
@@ -726,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);
@@ -752,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";
+54 -7
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,10 @@ struct LayoutState {
/// stream and mean nothing restored against another one.
bool measure_stats = false;
/// Which sidebar tab was showing: "measure" or "signals". The one thing the panel persists --
/// no revealer states remain, and the 320 px width is the `.ui`'s.
std::string side_tab = "signals";
/// 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;
@@ -97,6 +119,26 @@ struct LayoutState {
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;
@@ -111,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;
};
@@ -124,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
@@ -132,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
+92 -89
View File
@@ -14,20 +14,6 @@ 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;
@@ -240,51 +226,6 @@ 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 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;
}
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
MeasureFlatLevels out;
@@ -342,25 +283,39 @@ MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
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";
@@ -373,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) {
@@ -390,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) {
@@ -408,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;
@@ -475,10 +442,10 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
// 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 = 0.0;
if (!measure_level_pct(type, options, pct)) return out;
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);
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});
@@ -487,11 +454,47 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
}
// No histogram: the mean of one side of the level, straight out of the second pass' running
// sums. A window the pyramid cannot summarize is one bucket or two of a zoomed-in view, or
// an HF-rejected walk, and on those the split's own means are the honest reading.
if (level == nullptr) return out;
// 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 (level->count[s] == 0) return out;
out.primary = level->sum[s] / static_cast<double>(level->count[s]);
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;
@@ -499,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
@@ -518,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;
}
@@ -534,5 +538,4 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
return out;
}
} // namespace udpscope
+111 -18
View File
@@ -38,16 +38,37 @@ enum class MeasureType {
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;
@@ -268,11 +289,13 @@ struct MeasureLevel {
/// 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;
/// The samples either side of `level`, exactly: `Top`/`Bottom`'s reading 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.
/// 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};
@@ -326,13 +349,78 @@ 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()`. `Amplitude` reads no level either: its value is the delta between
/// the two flat levels, which the histogram answers, and the split's two means are only a
/// fallback it takes from the window's own extremes -- see `compute_measure()`.
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
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;
}
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
@@ -352,10 +440,11 @@ struct MeasureFlatLevels {
};
MeasureFlatLevels measure_flat_levels(const MeasureStats& stats, double pct);
/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the
/// 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,
/// 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.
@@ -368,6 +457,10 @@ 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);
+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;
}
+155 -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
@@ -667,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 --------------------------------------------------------
@@ -733,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;
+5
View File
@@ -3,6 +3,7 @@
#include "settings.hpp"
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <fstream>
@@ -64,6 +65,9 @@ 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") {
@@ -83,6 +87,7 @@ 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
+6
View File
@@ -30,6 +30,12 @@ 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";
+5 -3
View File
@@ -14,11 +14,13 @@ constexpr Accel kAccels[] = {
{GDK_KEY_a, 0, AppAction::TriggerArm, "a", "Arm or disarm the trigger", "Acquisition"},
{GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Enable or disable trigger mode", "Acquisition"},
{GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Enable or disable the cursors", "Panes and editors"},
{GDK_KEY_m, 0, AppAction::SideTabMeasure, "m", "Show the Measure tab", "Panes and editors"},
{GDK_KEY_g, 0, AppAction::SideTabSignals, "g", "Show the Signals tab", "Panes and editors"},
{GDK_KEY_m, 0, AppAction::ShowMeasure, "m", "Open or close the Measurements page",
"Panes and editors"},
{GDK_KEY_g, 0, AppAction::ShowSignals, "g", "Open or close the Signals page",
"Panes and editors"},
{GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor",
"Panes and editors"},
{GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the side panel",
{GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the dock",
"Panes and editors"},
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "<Control>e",
"Export the visible range", "Files and windows"},
+2 -2
View File
@@ -10,8 +10,8 @@ enum class AppAction {
TriggerArm,
TriggerPanel,
CursorsPane,
SideTabMeasure,
SideTabSignals,
ShowMeasure,
ShowSignals,
LayoutEditor,
SidePanel,
Export,
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/data_source.hpp"
#include <utility>
namespace udpscope {
void DataSource::post(Command cmd) {
std::lock_guard<std::mutex> lk(cmd_mu_);
cmds_.push_back(std::move(cmd));
}
void DataSource::drain_commands() {
std::deque<Command> local;
{
std::lock_guard<std::mutex> lk(cmd_mu_);
local.swap(cmds_);
}
for (auto& c : local) c();
}
} // namespace udpscope
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/frame.hpp"
#include <cstddef>
#include <cstdint>
#include <deque>
#include <functional>
#include <mutex>
#include <vector>
namespace udpscope {
/// Work for the producer thread. UI code posts these; `poll()` drains and runs them, so a
/// source's state is only ever touched by the thread that owns it.
using Command = std::function<void()>;
/// The counters `StreamStats` shows, in source-neutral form. Producer thread only.
struct SourceStats {
std::uint64_t packets = 0;
std::uint64_t frames = 0;
std::uint64_t gaps = 0;
std::uint64_t late = 0;
std::uint64_t resequenced = 0;
std::uint64_t stale = 0;
std::uint64_t overflows = 0;
std::uint64_t reconnects = 0;
bool connected = false;
};
enum class SourceEvent { Connected, Disconnected, Error };
/// Where a source delivers, from inside `poll()`.
class SourceSink {
public:
virtual ~SourceSink() = default;
/// The signal set (re)defined. `publish_mode` is the stream's own publish mode.
virtual void on_config(const std::vector<SignalInfo>& signals, std::uint8_t publish_mode) = 0;
virtual void on_data(const Frame& frame) = 0;
virtual void on_event(SourceEvent event, const char* message) = 0;
};
/// Receives each raw datagram of a UDP-based source: bytes, length, receive time.
using DatagramTap = std::function<void(const std::uint8_t*, std::size_t, double)>;
/// The per-connection object. `Acquisition` owns one and drives it from the producer thread.
class DataSource {
public:
virtual ~DataSource() = default;
/// Allocates and connects. Throws `std::runtime_error` with a message fit for the user.
/// Does not deliver: CONFIG and `Connected` arrive from `poll()`.
virtual void open(SourceSink& sink) = 0;
/// Runs queued commands, then waits up to `timeout_ms` for work and delivers it.
virtual void poll(int timeout_ms) = 0;
virtual void close() = 0;
/// Producer thread only.
virtual SourceStats stats() const = 0;
virtual bool connected() const = 0;
/// Optional: UDP-based sources feed the raw-packet capture through this.
virtual void set_datagram_tap(DatagramTap) {}
/// Thread-safe. The command runs inside a later `poll()`.
void post(Command cmd);
protected:
/// For `poll()` implementations: runs and clears the queue.
void drain_commands();
private:
std::mutex cmd_mu_;
std::deque<Command> cmds_;
};
} // namespace udpscope
+200
View File
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_device.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
namespace udpscope::e1608 {
namespace {
constexpr double kClockHz = 80e6;
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
double clamp_code(double v) { return std::min(65535.0, std::max(0.0, v)); }
float le_f32(const std::uint8_t* p) {
const std::uint32_t u = static_cast<std::uint32_t>(p[0]) | (static_cast<std::uint32_t>(p[1]) << 8) |
(static_cast<std::uint32_t>(p[2]) << 16) |
(static_cast<std::uint32_t>(p[3]) << 24);
float f;
std::memcpy(&f, &u, sizeof f);
return f;
}
void put_le32(std::vector<std::uint8_t>& v, std::uint32_t x) {
for (int i = 0; i < 4; ++i) v.push_back(static_cast<std::uint8_t>((x >> (8 * i)) & 0xFF));
}
/// Reads `count` (slope, intercept) pairs at `addr`.
void read_table(Link& link, std::uint16_t addr, int count, Cal* out) {
const std::uint16_t bytes = static_cast<std::uint16_t>(count * 8);
const std::vector<std::uint8_t> req = {static_cast<std::uint8_t>(addr & 0xFF),
static_cast<std::uint8_t>(addr >> 8),
static_cast<std::uint8_t>(bytes & 0xFF),
static_cast<std::uint8_t>(bytes >> 8)};
const std::vector<std::uint8_t> r = link.command(0x40, req, bytes);
for (int i = 0; i < count; ++i) {
out[i].slope = le_f32(&r[static_cast<std::size_t>(i) * 8]);
out[i].intercept = le_f32(&r[static_cast<std::size_t>(i) * 8 + 4]);
}
}
} // namespace
double full_scale(Range r) {
static const double fs[] = {10.0, 5.0, 2.0, 1.0};
return fs[static_cast<int>(r)];
}
const char* range_name(Range r) {
static const char* const n[] = {"BIP10V", "BIP5V", "BIP2V", "BIP1V"};
return n[static_cast<int>(r)];
}
bool parse_range(const std::string& text, Range& out) {
const std::string t = lower(text);
for (int i = 0; i < 4; ++i) {
if (t == lower(range_name(static_cast<Range>(i)))) {
out = static_cast<Range>(i);
return true;
}
}
return false;
}
const char* trigger_name(Trigger t) {
switch (t) {
case Trigger::None: return "none";
case Trigger::Rising: return "rising";
case Trigger::Falling: return "falling";
case Trigger::High: return "high";
case Trigger::Low: return "low";
}
return "none";
}
bool parse_trigger(const std::string& text, Trigger& out) {
const std::string t = lower(text);
for (Trigger c : {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High, Trigger::Low}) {
if (t == trigger_name(c)) {
out = c;
return true;
}
}
return false;
}
double Calibration::volts(std::uint16_t raw, Range range, bool diff) const {
const Cal& c = (diff ? df : se)[static_cast<int>(range)];
const double code = clamp_code(std::round(static_cast<double>(raw) * c.slope + c.intercept));
return (code - 32768.0) * full_scale(range) / 32768.0;
}
std::uint16_t Calibration::dac_code(int ch, double v) const {
v = std::min(10.0, std::max(-10.0, v));
const double nominal = clamp_code(std::round(v * 32768.0 / 10.0 + 32768.0));
const Cal& c = ao[ch & 1];
return static_cast<std::uint16_t>(clamp_code(std::round(nominal * c.slope + c.intercept)));
}
void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector<QueueEntry>& queue,
const Calibration& cal, std::vector<std::vector<double>>& out) {
const std::size_t nq = queue.size();
out.resize(nq);
for (std::size_t q = 0; q < nq; ++q) {
out[q].resize(scans);
for (std::size_t s = 0; s < scans; ++s)
out[q][s] = cal.volts(raw[s * nq + q], queue[q].range, queue[q].diff());
}
}
bool Device::read_calibration(Calibration& cal) {
Calibration c;
try {
read_table(link_, 0x00, 4, c.df);
read_table(link_, 0x20, 4, c.se);
read_table(link_, 0x40, 2, c.ao);
} catch (const LinkError& e) {
if (e.kind() != LinkError::Kind::Command) throw;
cal = Calibration{};
return false;
}
c.loaded = true;
cal = c;
return true;
}
std::uint8_t Device::din() { return link_.command(0x00, {}, 1)[0]; }
void Device::dout(std::uint8_t value) { link_.command(0x03, {value}); }
void Device::dconf(std::uint8_t inputs_mask) { link_.command(0x05, {inputs_mask}); }
double Device::aout(int ch, double volts, const Calibration& cal) {
const double v = std::min(10.0, std::max(-10.0, volts));
const std::uint16_t code = cal.dac_code(ch, v);
link_.command(0x21, {static_cast<std::uint8_t>(ch & 1), static_cast<std::uint8_t>(code & 0xFF),
static_cast<std::uint8_t>(code >> 8)});
return v;
}
std::uint32_t Device::counter() {
const std::vector<std::uint8_t> r = link_.command(0x30, {}, 4);
return static_cast<std::uint32_t>(r[0]) | (static_cast<std::uint32_t>(r[1]) << 8) |
(static_cast<std::uint32_t>(r[2]) << 16) | (static_cast<std::uint32_t>(r[3]) << 24);
}
void Device::counter_reset() { link_.command(0x31); }
std::uint16_t Device::status() {
const std::vector<std::uint8_t> r = link_.command(0x52, {}, 2);
return static_cast<std::uint16_t>(r[0] | (r[1] << 8));
}
void Device::scan_start(const std::vector<QueueEntry>& queue, double aggregate_hz, bool ext_clock,
Trigger trigger) {
link_.open_scan();
try {
std::vector<std::uint8_t> q;
q.push_back(static_cast<std::uint8_t>(queue.size()));
for (const QueueEntry& e : queue) {
q.push_back(e.wire_channel);
q.push_back(static_cast<std::uint8_t>(e.range));
}
link_.command(0x15, q);
std::uint32_t pacer = 0;
if (!ext_clock) {
const double p = std::round(kClockHz / aggregate_hz - 1.0);
pacer = static_cast<std::uint32_t>(std::min(4294967295.0, std::max(1.0, p)));
}
std::vector<std::uint8_t> s;
put_le32(s, 0); // count 0: scan until stopped
put_le32(s, pacer);
s.push_back(static_cast<std::uint8_t>(trigger));
link_.command(0x11, s);
link_.send_filler();
} catch (...) {
link_.close_scan();
throw;
}
}
void Device::scan_stop() {
link_.close_scan();
try {
link_.command(0x13, {1});
} catch (const LinkError&) {
}
}
} // namespace udpscope::e1608
+91
View File
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/e1608/e1608_link.hpp"
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace udpscope::e1608 {
enum class Range : std::uint8_t { Bip10V = 0, Bip5V = 1, Bip2V = 2, Bip1V = 3 };
double full_scale(Range r); ///< 10, 5, 2, 1
const char* range_name(Range r); ///< "BIP10V" ...
/// Case-insensitive: "BIP10V", "bip5v", ...
bool parse_range(const std::string& text, Range& out);
enum class Trigger : std::uint8_t { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 };
const char* trigger_name(Trigger t); ///< "none", "rising", "falling", "high", "low"
bool parse_trigger(const std::string& text, Trigger& out); ///< case-insensitive
struct Cal {
double slope = 1.0;
double intercept = 0.0;
};
/// The factory calibration: one (slope, intercept) per range for single-ended and differential
/// inputs, one per DAC. Defaults are the nominal (uncalibrated) values.
struct Calibration {
Cal se[4];
Cal df[4];
Cal ao[2];
bool loaded = false;
/// A raw ADC count as calibrated volts.
double volts(std::uint16_t raw, Range range, bool diff) const;
/// The DAC code that makes `ch` output `volts` (clamped to +-10 V).
std::uint16_t dac_code(int ch, double volts) const;
};
/// One entry of the scan queue. DIFF channels are `channel + 8` on the wire.
struct QueueEntry {
std::uint8_t wire_channel;
Range range;
bool diff() const noexcept { return wire_channel >= 8; }
};
/// Turns `scans` interleaved scans of `queue.size()` raw counts into calibrated volts:
/// `out[queue index][scan]`.
void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector<QueueEntry>& queue,
const Calibration& cal, std::vector<std::vector<double>>& out);
/// Typed commands over a `Link`. Holds a reference: the link must outlive it.
class Device {
public:
explicit Device(Link& link) : link_(link) {}
/// Fills `cal` from the board's calibration memory and sets `cal.loaded`. A refusal from the
/// board (`LinkError::Kind::Command`) leaves the nominal values and returns false; any other
/// error propagates.
bool read_calibration(Calibration& cal);
std::uint8_t din();
void dout(std::uint8_t value);
/// `inputs_mask`: a set bit makes that line an input.
void dconf(std::uint8_t inputs_mask);
/// Writes `volts` (clamped to +-10 V, calibrated) to DAC `ch`; returns the clamped volts.
double aout(int ch, double volts, const Calibration& cal);
std::uint32_t counter();
void counter_reset();
/// Bit 0 scan socket open, bit 1 scan running, bit 2 overrun.
std::uint16_t status();
/// Opens the scan socket, writes the queue, starts the scan and sends the filler byte.
/// `aggregate_hz` is the scan rate times the number of channels. On failure the scan socket
/// is closed again.
void scan_start(const std::vector<QueueEntry>& queue, double aggregate_hz, bool ext_clock,
Trigger trigger);
/// Closes the scan socket and tells the board to stop; board errors are ignored.
void scan_stop();
private:
Link& link_;
};
} // namespace udpscope::e1608
+437
View File
@@ -0,0 +1,437 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_source.hpp"
#include "source/registry.hpp"
#include "source/e1608/e1608_panels.hpp"
#include <adwaita.h>
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
namespace {
using namespace e1608;
using ChangeCb = std::shared_ptr<std::function<void()>>;
constexpr const char* kTriggerNames[] = {"none", "rising", "falling", "high", "low"};
constexpr Trigger kTriggers[] = {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High,
Trigger::Low};
std::string num(double v) {
char b[48];
std::snprintf(b, sizeof b, "%.10g", v);
return b;
}
GtkWidget* entry_row(const char* title) {
GtkWidget* r = adw_entry_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
return r;
}
GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) {
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast<guint>(digits));
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
return r;
}
GtkWidget* switch_row(const char* title, bool def) {
GtkWidget* r = adw_switch_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_switch_row_set_active(ADW_SWITCH_ROW(r), def);
return r;
}
GtkWidget* combo_row(const char* title, const char* const* items) {
GtkWidget* r = adw_combo_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items)));
return r;
}
GtkWidget* expander(const char* title, std::initializer_list<GtkWidget*> rows) {
GtkWidget* e = adw_expander_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title);
for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r);
return e;
}
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); }
guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); }
bool on_of(GtkWidget* w) { return adw_switch_row_get_active(ADW_SWITCH_ROW(w)); }
/// The form's widgets. Held by a `shared_ptr` that every signal closure and every `ConfigForm`
/// closure shares; the raw widget pointers are valid while the chooser keeps the rows.
struct Form {
GtkWidget *address, *mac, *subnet, *port, *code, *timeout;
GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate, *gen_rate;
GtkWidget *mode, *ai[8], *ai_group;
GtkWidget *dir[8], *do_init, *plot_counter, *ao_init[2];
ChangeCb changed = std::make_shared<std::function<void()>>();
bool updating = false;
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing
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
bool diff() const { return sel_of(mode) == 1; }
int enabled_ai() const {
int n = 0;
for (int i = 0; i < (diff() ? 4 : 8); ++i) n += sel_of(ai[i]) > 0 ? 1 : 0;
return n;
}
/// In DIFF mode AI rows 4 to 7 are insensitive, not hidden; the group's subtitle shows the load.
void refresh() {
const bool d = diff();
for (int i = 0; i < 8; ++i) gtk_widget_set_sensitive(ai[i], !(d && i >= 4));
const int n = enabled_ai();
char b[96];
std::snprintf(b, sizeof b, "%d channel%s, %.3g of 250 kS/s", n, n == 1 ? "" : "s",
n * value_of(rate) / 1000.0);
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(ai_group), b);
}
SourceConfig read() const {
SourceConfig c;
if (!text_of(address).empty()) c.set("address", text_of(address));
if (!text_of(mac).empty()) c.set("mac", text_of(mac));
if (!text_of(subnet).empty()) c.set("subnet", text_of(subnet));
c.set("port", num(value_of(port)));
c.set("code", num(value_of(code)));
c.set("timeout_ms", num(value_of(timeout)));
c.set("scan_rate", num(value_of(rate)));
c.set("ext_clock", on_of(ext_clock) ? "1" : "0");
c.set("trigger", kTriggerNames[std::min<guint>(sel_of(trigger), 4)]);
c.set("auto_restart", on_of(auto_restart) ? "1" : "0");
c.set("calibrate", on_of(calibrate) ? "1" : "0");
c.set("poll_rate", num(value_of(poll_rate)));
c.set("gen_rate", num(value_of(gen_rate)));
c.set("mode", diff() ? "diff" : "se");
static const char* const kRange[] = {"", "BIP10V", "BIP5V", "BIP2V", "BIP1V"};
for (int i = 0; i < 8; ++i) {
const guint s = sel_of(ai[i]);
if (s > 0 && s <= 4 && !(diff() && i >= 4)) c.set("ai" + std::to_string(i), kRange[s]);
}
for (int i = 0; i < 8; ++i) {
const guint s = sel_of(dir[i]); // 0 input, 1 input plotted, 2 output
c.set("dir" + std::to_string(i), s == 2 ? "o" : "i");
c.set("plot" + std::to_string(i), s == 1 ? "1" : "0");
}
c.set("plot_counter", on_of(plot_counter) ? "1" : "0");
c.set("do_init", num(value_of(do_init)));
c.set("ao0_init", num(value_of(ao_init[0])));
c.set("ao1_init", num(value_of(ao_init[1])));
return c;
}
void write(const SourceConfig& cfg) {
const E1608Settings s = E1608Settings::from(cfg);
updating = true;
gtk_editable_set_text(GTK_EDITABLE(address), s.address.c_str());
gtk_editable_set_text(GTK_EDITABLE(mac), s.mac_text.c_str());
gtk_editable_set_text(GTK_EDITABLE(subnet), s.subnet.c_str());
adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port);
adw_spin_row_set_value(ADW_SPIN_ROW(code), static_cast<double>(s.code));
adw_spin_row_set_value(ADW_SPIN_ROW(timeout), s.timeout_ms);
adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.scan_rate);
adw_switch_row_set_active(ADW_SWITCH_ROW(ext_clock), s.ext_clock);
for (guint i = 0; i < 5; ++i) {
if (kTriggers[i] == s.trigger) adw_combo_row_set_selected(ADW_COMBO_ROW(trigger), i);
}
adw_switch_row_set_active(ADW_SWITCH_ROW(auto_restart), s.auto_restart);
adw_switch_row_set_active(ADW_SWITCH_ROW(calibrate), s.calibrate);
adw_spin_row_set_value(ADW_SPIN_ROW(poll_rate), s.poll_rate);
adw_spin_row_set_value(ADW_SPIN_ROW(gen_rate), s.gen_rate);
adw_combo_row_set_selected(ADW_COMBO_ROW(mode), s.diff ? 1 : 0);
for (int i = 0; i < 8; ++i) {
adw_combo_row_set_selected(
ADW_COMBO_ROW(ai[i]), s.ai_on[i] ? static_cast<guint>(s.ai_range[i]) + 1 : 0);
adw_combo_row_set_selected(ADW_COMBO_ROW(dir[i]), s.dir_out[i] ? 2 : (s.plot[i] ? 1 : 0));
}
adw_switch_row_set_active(ADW_SWITCH_ROW(plot_counter), s.plot_counter);
adw_spin_row_set_value(ADW_SPIN_ROW(do_init), s.do_init);
adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[0]), s.ao_init[0]);
adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[1]), s.ao_init[1]);
updating = false;
refresh();
}
/// 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();
}
bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); }
};
using FormPtr = std::shared_ptr<Form>;
void on_notify(GObject*, GParamSpec*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.alive) return;
f.refresh();
if (!f.updating && f.changed && *f.changed) (*f.changed)();
}
void on_edited(GtkEditable*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.alive) return;
if (!f.updating && f.changed && *f.changed) (*f.changed)();
}
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
/// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone.
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
}
void hook_destroy(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) {
g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
void hook_entry(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
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: what each fills into the form (the other is cleared).
struct Pick {
FormPtr form;
std::string value;
};
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.value, "");
}
void on_pick_mac(GtkButton*, gpointer user) {
Pick& p = *static_cast<Pick*>(user);
p.form->fill("", p.value);
}
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}, free_pick, static_cast<GConnectFlags>(0));
g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, 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 auto& found = f.scan->poll(); // valid until f.scan.reset() below, after its last use
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));
}
class E1608Kind final : public SourceKind {
public:
std::string id() const override { return "e1608"; }
std::string display_name() const override { return "MCC E-1608"; }
bool has_datagrams() const override { return false; }
std::string identity(const SourceConfig& cfg) const override {
return E1608Settings::from(cfg).identity();
}
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<E1608Source>(cfg);
}
ConfigForm make_config_form() const override {
auto f = std::make_shared<Form>();
static const char* const kTrig[] = {"None", "Rising edge", "Falling edge", "High level",
"Low level", nullptr};
static const char* const kMode[] = {"Single-ended (8 channels)", "Differential (4 channels)",
nullptr};
static const char* const kAi[] = {"Off", "\u00b110 V", "\u00b15 V", "\u00b12 V", "\u00b11 V",
nullptr};
static const char* const kDir[] = {"Input", "Input, plotted", "Output", nullptr};
f->address = entry_row("_Address");
f->mac = entry_row("_MAC address");
f->subnet = entry_row("_Discovery subnet");
f->port = spin_row("_Port", 1, 65534, 1, kDefaultPort, 0);
f->code = spin_row("Connection _code", 0, 4294967295.0, 1, 0, 0);
f->timeout = spin_row("_Timeout (ms)", 100, 60000, 100, 2000, 0);
f->rate = spin_row("Scan _rate (S/s per channel)", 0.02, 250000, 1, 1000, 2);
f->ext_clock = switch_row("_External clock", false);
f->trigger = combo_row("T_rigger", kTrig);
f->auto_restart = switch_row("_Restart after an overrun", true);
f->calibrate = switch_row("Apply _factory calibration", true);
f->poll_rate = spin_row("_Input poll rate (Hz)", 0.1, 100, 1, 10, 1);
f->gen_rate = spin_row("_Generator update rate (Hz)", 1, 200, 1, 50, 0);
f->mode = combo_row("Input _mode", kMode);
for (int i = 0; i < 8; ++i) {
const std::string title = "Channel " + std::to_string(i);
f->ai[i] = combo_row(title.c_str(), kAi);
adw_combo_row_set_selected(ADW_COMBO_ROW(f->ai[i]), i == 0 ? 1 : 0);
}
for (int i = 0; i < 8; ++i) {
const std::string title = "Bit " + std::to_string(i);
f->dir[i] = combo_row(title.c_str(), kDir);
}
f->plot_counter = switch_row("Plot the _counter", false);
f->do_init = spin_row("Initial output value", 0, 255, 1, 0, 0);
f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3);
f->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3);
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));
GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
f->code, f->timeout});
GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart,
f->calibrate, f->poll_rate, f->gen_rate});
GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3],
f->ai[4], f->ai[5], f->ai[6], f->ai[7]});
GtkWidget* dio = expander("Digital I/O", {f->dir[0], f->dir[1], f->dir[2], f->dir[3], f->dir[4],
f->dir[5], f->dir[6], f->dir[7], f->plot_counter,
f->do_init, f->ao_init[0], f->ao_init[1]});
f->ai_group = ai;
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(conn), TRUE);
for (GtkWidget* w : {conn, acq, ai, dio}) hook_destroy(w, f);
for (GtkWidget* w : {f->address, f->mac, f->subnet}) hook_entry(w, f);
for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->gen_rate, f->do_init,
f->ao_init[0], f->ao_init[1]})
hook_notify(w, "notify::value", f);
for (GtkWidget* w : {f->ext_clock, f->auto_restart, f->calibrate, f->plot_counter})
hook_notify(w, "notify::active", f);
hook_notify(f->trigger, "notify::selected", f);
hook_notify(f->mode, "notify::selected", f);
for (int i = 0; i < 8; ++i) {
hook_notify(f->ai[i], "notify::selected", f);
hook_notify(f->dir[i], "notify::selected", f);
}
f->refresh();
ConfigForm out;
out.rows = {conn, acq, ai, dio};
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";
out.focus = f->address;
out.write = [f](const SourceConfig& c) { f->write(c); };
out.read = [f]() { return f->read(); };
out.valid = [f]() { return f->valid(); };
ChangeCb cb = f->changed;
out.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return out;
}
std::vector<SourcePanel> make_panels(DataSource& source) const override {
return make_e1608_panels(source);
}
};
} // namespace
std::unique_ptr<SourceKind> make_e1608_kind() { return std::make_unique<E1608Kind>(); }
} // namespace udpscope
+442
View File
@@ -0,0 +1,442 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_link.hpp"
#include <algorithm>
#include <arpa/inet.h>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <ifaddrs.h>
#include <net/if.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
namespace udpscope::e1608 {
void Fd::reset(int fd) noexcept {
if (fd_ >= 0) ::close(fd_);
fd_ = fd;
}
namespace {
using Clock = std::chrono::steady_clock;
using Kind = LinkError::Kind;
constexpr std::size_t kMaxFrame = 1024;
[[noreturn]] void sys_fail(const std::string& what) {
throw LinkError(Kind::Io, what + ": " + std::strerror(errno));
}
int ms_left(Clock::time_point deadline) {
const auto d = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - Clock::now());
return d.count() < 0 ? 0 : static_cast<int>(d.count());
}
/// True when `fd` is ready for `events` (or has an error/hang-up to report); false on timeout.
bool wait_fd(int fd, short events, int timeout_ms) {
pollfd p{fd, events, 0};
for (;;) {
const int r = ::poll(&p, 1, timeout_ms);
if (r > 0) return true;
if (r == 0) return false;
if (errno == EINTR) continue;
sys_fail("poll");
}
}
sockaddr_in make_addr(const std::string& host, int port) {
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_port = htons(static_cast<std::uint16_t>(port));
if (::inet_pton(AF_INET, host.c_str(), &a.sin_addr) == 1) return a;
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* res = nullptr;
if (::getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || res == nullptr)
throw LinkError(Kind::Io, "cannot resolve '" + host + "'");
a.sin_addr = reinterpret_cast<const sockaddr_in*>(res->ai_addr)->sin_addr;
::freeaddrinfo(res);
return a;
}
void send_all(int fd, const std::uint8_t* p, std::size_t n, Clock::time_point deadline) {
while (n > 0) {
if (!wait_fd(fd, POLLOUT, ms_left(deadline)))
throw LinkError(Kind::Timeout, "timed out sending to the board");
const ssize_t w = ::send(fd, p, n, MSG_NOSIGNAL | MSG_DONTWAIT);
if (w < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
sys_fail("send");
}
p += w;
n -= static_cast<std::size_t>(w);
}
}
void read_exact(int fd, std::uint8_t* p, std::size_t n, Clock::time_point deadline) {
while (n > 0) {
if (!wait_fd(fd, POLLIN, ms_left(deadline)))
throw LinkError(Kind::Timeout, "timed out waiting for the board");
const ssize_t r = ::recv(fd, p, n, MSG_DONTWAIT);
if (r == 0) throw LinkError(Kind::Io, "the board closed the connection");
if (r < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
sys_fail("recv");
}
p += r;
n -= static_cast<std::size_t>(r);
}
}
/// A connected, non-blocking TCP socket with TCP_NODELAY. `rcvbuf > 0` sets SO_RCVBUF first.
Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) {
const sockaddr_in a = make_addr(host, port);
Fd fd(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0));
if (!fd.valid()) sys_fail("socket");
const int one = 1;
::setsockopt(fd.get(), IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
if (rcvbuf > 0) ::setsockopt(fd.get(), SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
const std::string where = host + ":" + std::to_string(port);
if (::connect(fd.get(), reinterpret_cast<const sockaddr*>(&a), sizeof a) < 0 &&
errno != EINPROGRESS)
sys_fail("cannot connect to " + where);
if (!wait_fd(fd.get(), POLLOUT, timeout_ms))
throw LinkError(Kind::Timeout, "timed out connecting to " + where);
int err = 0;
socklen_t len = sizeof err;
::getsockopt(fd.get(), SOL_SOCKET, SO_ERROR, &err, &len);
if (err != 0) {
errno = err;
sys_fail("cannot connect to " + where);
}
return fd;
}
/// 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;
}
} // namespace
// --- addresses ------------------------------------------------------------------------------
bool parse_mac(const std::string& text, Mac& out) {
Mac m{};
std::size_t pos = 0;
for (std::size_t i = 0; i < 6; ++i) {
if (pos + 2 > text.size()) return false;
unsigned v = 0;
for (std::size_t k = 0; k < 2; ++k) {
const char c = text[pos + k];
v <<= 4;
if (c >= '0' && c <= '9') v |= static_cast<unsigned>(c - '0');
else if (c >= 'a' && c <= 'f') v |= static_cast<unsigned>(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') v |= static_cast<unsigned>(c - 'A' + 10);
else return false;
}
m[i] = static_cast<std::uint8_t>(v);
pos += 2;
if (i < 5) {
if (pos >= text.size() || (text[pos] != ':' && text[pos] != '-')) return false;
++pos;
}
}
if (pos != text.size()) return false;
out = m;
return true;
}
std::string format_mac(const Mac& mac) {
char buf[24];
std::snprintf(buf, sizeof buf, "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3],
mac[4], mac[5]);
return buf;
}
std::vector<std::string> sweep_targets(const std::string& subnet) {
std::string s = subnet;
while (!s.empty() && s.front() == ' ') s.erase(s.begin());
while (!s.empty() && (s.back() == ' ' || s.back() == '.')) s.pop_back();
if (s.empty()) return {"255.255.255.255"};
int parts = 0;
std::size_t i = 0;
while (i <= s.size()) {
const std::size_t dot = s.find('.', i);
const std::string part = s.substr(i, dot == std::string::npos ? std::string::npos : dot - i);
if (part.empty() || part.size() > 3) return {};
int v = 0;
for (char c : part) {
if (c < '0' || c > '9') return {};
v = v * 10 + (c - '0');
}
if (v > 255) return {};
++parts;
if (dot == std::string::npos) break;
i = dot + 1;
}
if (parts != 3) return {};
std::vector<std::string> out;
out.reserve(254);
for (int h = 1; h <= 254; ++h) out.push_back(s + "." + std::to_string(h));
return out;
}
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms) {
std::vector<DeviceInfo> found;
Fd u(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
if (!u.valid()) sys_fail("socket");
const int one = 1;
::setsockopt(u.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
const std::uint8_t probe = 'D';
for (const std::string& t : targets) {
sockaddr_in a{};
try {
a = make_addr(t, port);
} catch (const LinkError&) {
continue;
}
// A sweep may hit unreachable hosts; those sends fail and are simply skipped. A full send
// buffer is not that: wait for room once and retry, so a large sweep does not lose probes.
const auto send_probe = [&] {
return ::sendto(u.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&a),
sizeof a);
};
if (send_probe() < 0 && (errno == EAGAIN || errno == ENOBUFS)) {
wait_fd(u.get(), POLLOUT, 20);
(void)send_probe();
}
}
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
for (;;) {
if (!wait_fd(u.get(), POLLIN, ms_left(deadline))) break;
std::uint8_t buf[128];
sockaddr_in from{};
socklen_t flen = sizeof from;
const ssize_t n = ::recvfrom(u.get(), buf, sizeof buf, MSG_DONTWAIT,
reinterpret_cast<sockaddr*>(&from), &flen);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
break;
}
take_reply(buf, n, from, found);
}
return found;
}
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) {
if (errno == EINTR) continue;
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();
}
// --- Link -----------------------------------------------------------------------------------
void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) {
close();
host_ = host;
port_ = port;
timeout_ms_ = timeout_ms;
next_id_ = 0;
// The connection-code handshake: a connected UDP socket, so an ICMP refusal surfaces.
{
const sockaddr_in a = make_addr(host, port);
Fd u(::socket(AF_INET, SOCK_DGRAM, 0));
if (!u.valid()) sys_fail("socket");
if (::connect(u.get(), reinterpret_cast<const sockaddr*>(&a), sizeof a) < 0)
sys_fail("handshake with " + host);
const std::uint8_t msg[5] = {'C', static_cast<std::uint8_t>(code & 0xFF),
static_cast<std::uint8_t>((code >> 8) & 0xFF),
static_cast<std::uint8_t>((code >> 16) & 0xFF),
static_cast<std::uint8_t>((code >> 24) & 0xFF)};
if (::send(u.get(), msg, sizeof msg, MSG_NOSIGNAL) < 0) sys_fail("handshake with " + host);
if (!wait_fd(u.get(), POLLIN, timeout_ms))
throw LinkError(Kind::Timeout, "no answer from the board at " + host);
std::uint8_t rep[16];
const ssize_t n = ::recv(u.get(), rep, sizeof rep, 0);
if (n < 0) sys_fail("handshake with " + host);
if (n < 2 || rep[0] != 'C') throw LinkError(Kind::Protocol, "unexpected handshake reply");
switch (rep[1]) {
case 0:
case 2:
break;
case 1:
throw LinkError(Kind::Rejected, "the board rejected the connection code");
case 3:
throw LinkError(Kind::Busy, "the board is busy (another client holds it)");
default:
throw LinkError(Kind::Protocol, "unexpected handshake status " + std::to_string(rep[1]));
}
}
cmd_ = tcp_connect(host, port, timeout_ms, 0);
}
void Link::close() {
scan_.reset();
cmd_.reset();
}
std::vector<std::uint8_t> Link::command(std::uint8_t cmd, const std::vector<std::uint8_t>& payload,
int expect) {
if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected");
if (payload.size() + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "command too long");
// Bytes left by an earlier exchange that timed out must not be read as this reply.
{
std::uint8_t junk[256];
while (::recv(cmd_.get(), junk, sizeof junk, MSG_DONTWAIT) > 0) {
}
}
if (++next_id_ == 0) next_id_ = 1; // id 0 is never used, in case a board treats it specially
const std::uint8_t id = next_id_;
std::vector<std::uint8_t> f;
f.reserve(7 + payload.size());
f.push_back(0xDB);
f.push_back(cmd);
f.push_back(id);
f.push_back(0);
f.push_back(static_cast<std::uint8_t>(payload.size() & 0xFF));
f.push_back(static_cast<std::uint8_t>((payload.size() >> 8) & 0xFF));
f.insert(f.end(), payload.begin(), payload.end());
unsigned sum = 0;
for (std::uint8_t b : f) sum += b;
f.push_back(static_cast<std::uint8_t>(0xFF - (sum & 0xFF)));
const auto deadline = Clock::now() + std::chrono::milliseconds(timeout_ms_);
send_all(cmd_.get(), f.data(), f.size(), deadline);
std::uint8_t h[6];
read_exact(cmd_.get(), h, sizeof h, deadline);
if (h[0] != 0xDB) throw LinkError(Kind::Protocol, "bad sync byte in a reply");
const std::size_t n = static_cast<std::size_t>(h[4] | (h[5] << 8));
if (n + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "oversized reply");
std::vector<std::uint8_t> body(n + 1);
read_exact(cmd_.get(), body.data(), body.size(), deadline);
unsigned rsum = 0;
for (std::uint8_t b : h) rsum += b;
for (std::uint8_t b : body) rsum += b;
if ((rsum & 0xFF) != 0xFF) throw LinkError(Kind::Protocol, "bad checksum in a reply");
if (h[1] != (cmd | 0x80) || h[2] != id)
throw LinkError(Kind::Protocol, "reply does not match the command");
if (h[3] != 0) {
static const char* const kText[] = {"ok", "protocol error", "parameter error",
"busy", "not ready", "timeout",
"other error"};
const char* t = h[3] < 7 ? kText[h[3]] : "unknown error";
char hex[8];
std::snprintf(hex, sizeof hex, "%02x", cmd);
throw LinkError(Kind::Command, std::string("the board refused command 0x") + hex + ": " + t);
}
body.pop_back();
if (expect >= 0 && body.size() != static_cast<std::size_t>(expect))
throw LinkError(Kind::Command, "reply of " + std::to_string(body.size()) +
" bytes, expected " + std::to_string(expect));
return body;
}
void Link::open_scan() {
close_scan();
scan_ = tcp_connect(host_, port_ + 1, timeout_ms_, 1 << 20);
}
void Link::close_scan() { scan_.reset(); }
void Link::send_filler() {
if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected");
const std::uint8_t z = 0;
send_all(cmd_.get(), &z, 1, Clock::now() + std::chrono::milliseconds(timeout_ms_));
}
std::size_t Link::read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms) {
if (!scan_.valid()) throw LinkError(Kind::Io, "the scan socket is not open");
if (cap == 0) return 0; // recv would return 0 and read as a close
if (!wait_fd(scan_.get(), POLLIN, timeout_ms)) return 0;
const ssize_t n = ::recv(scan_.get(), buf, cap, MSG_DONTWAIT);
if (n > 0) return static_cast<std::size_t>(n);
if (n == 0) throw LinkError(Kind::Io, "scan socket closed by the board");
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0;
sys_fail("scan recv");
}
} // namespace udpscope::e1608
+137
View File
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
namespace udpscope::e1608 {
constexpr int kDefaultPort = 54211;
constexpr std::uint16_t kProductId = 0x012F;
/// Every failure of the link. `Rejected` (a bad connection code) is the only one a retry cannot
/// fix. `Command` is a reply the board answered with a nonzero status, or a reply of the wrong
/// length: the link itself is fine. `Protocol` is a framing error (sync, checksum, id).
class LinkError : public std::runtime_error {
public:
enum class Kind { Io, Timeout, Rejected, Busy, Protocol, Command };
LinkError(Kind kind, const std::string& what) : std::runtime_error(what), kind_(kind) {}
Kind kind() const noexcept { return kind_; }
private:
Kind kind_;
};
/// An owned file descriptor.
class Fd {
public:
Fd() = default;
explicit Fd(int fd) : fd_(fd) {}
Fd(const Fd&) = delete;
Fd& operator=(const Fd&) = delete;
Fd(Fd&& o) noexcept : fd_(o.release()) {}
Fd& operator=(Fd&& o) noexcept {
if (this != &o) reset(o.release());
return *this;
}
~Fd() { reset(); }
int get() const noexcept { return fd_; }
bool valid() const noexcept { return fd_ >= 0; }
int release() noexcept {
const int f = fd_;
fd_ = -1;
return f;
}
/// Closes the current descriptor (if any) and takes `fd`.
void reset(int fd = -1) noexcept;
private:
int fd_ = -1;
};
using Mac = std::array<std::uint8_t, 6>;
/// Six hex bytes separated by `:` or `-`. False (and `out` untouched) on anything else.
bool parse_mac(const std::string& text, Mac& out);
/// Lower-case `aa:bb:cc:dd:ee:ff`.
std::string format_mac(const Mac& mac);
struct DeviceInfo {
std::string ip;
Mac mac{};
std::uint16_t product_id = 0;
};
/// Where to send a discovery `D`: an empty subnet is the limited broadcast; `a.b.c` (an optional
/// trailing dot) is a unicast to hosts .1 to .254; anything else is empty.
std::vector<std::string> sweep_targets(const std::string& subnet);
/// Sends `D` to every target, then collects replies for `wait_ms`. Keeps E-1608s only (product id
/// `kProductId`) and one entry per MAC.
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms);
/// 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);
/// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`).
/// Not thread safe; the source's producer thread owns it.
class Link {
public:
/// Handshake, then the command connection. Throws `LinkError` (`Rejected` for a bad code).
void connect(const std::string& host, int port, std::uint32_t code, int timeout_ms);
/// Closes the command and scan sockets. Safe to call at any time.
void close();
bool is_open() const noexcept { return cmd_.valid(); }
/// One framed exchange. `expect >= 0` demands a reply payload of exactly that length. Throws
/// `Command` for a nonzero status or a length mismatch, `Protocol` for a bad frame.
std::vector<std::uint8_t> command(std::uint8_t cmd, const std::vector<std::uint8_t>& payload = {},
int expect = -1);
/// Opens the scan stream socket (a 1 MiB receive buffer, set before connecting).
void open_scan();
void close_scan();
bool scan_open() const noexcept { return scan_.valid(); }
/// The single `0x00` byte on the command socket that starts the stream after scan start.
void send_filler();
/// Waits up to `timeout_ms` for scan bytes and reads what is there (at most `cap`). Returns 0 on
/// a timeout. Throws `Io` when the board closed the socket.
std::size_t read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms);
private:
Fd cmd_;
Fd scan_;
std::string host_;
int port_ = kDefaultPort;
int timeout_ms_ = 2000;
std::uint8_t next_id_ = 0;
};
} // namespace udpscope::e1608
+369
View File
@@ -0,0 +1,369 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_panels.hpp"
#include "source/e1608/e1608_source.hpp"
#include <adwaita.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
namespace udpscope {
namespace {
using namespace e1608;
constexpr gint64 kRefreshUs = 100000; // the panels redraw their readouts at 10 Hz
/// A signal closure's argument: the panel's context and the index (DAC or bit) it concerns.
struct Hook {
void* ctx = nullptr;
int idx = 0;
};
GtkWidget* spin(const std::string& name, const char* title, double lo, double hi, double step,
double def, guint digits) {
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
gtk_widget_set_name(r, name.c_str());
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_spin_row_set_digits(ADW_SPIN_ROW(r), digits);
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
return r;
}
GtkWidget* switch_row(const std::string& name, const char* title, const char* subtitle) {
GtkWidget* r = adw_switch_row_new();
gtk_widget_set_name(r, name.c_str());
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
if (subtitle != nullptr) adw_action_row_set_subtitle(ADW_ACTION_ROW(r), subtitle);
return r;
}
bool due(gint64& last_us) {
const gint64 now = g_get_monotonic_time();
if (now - last_us < kRefreshUs) return false;
last_us = now;
return true;
}
void set_text(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);
}
GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr, GtkWidget* c = nullptr) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 18);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
gtk_box_append(GTK_BOX(box), a);
if (b != nullptr) gtk_box_append(GTK_BOX(box), b);
if (c != nullptr) gtk_box_append(GTK_BOX(box), c);
return box;
}
// --- Analog out -------------------------------------------------------------------------------
struct AoWidgets {
GtkWidget *mode = nullptr, *level = nullptr, *amp = nullptr, *offset = nullptr, *freq = nullptr,
*run = nullptr, *last = nullptr;
};
struct AoCtx {
E1608Source* src = nullptr;
std::shared_ptr<E1608Readouts> ro;
AoWidgets w[2];
GtkWidget* gen_rate = nullptr; ///< the "Update rate" row: the achieved rate against the requested one
Hook hooks[2];
bool updating = false; ///< set while the panel writes its own widgets: those writes post nothing
unsigned epoch_seen = 0;
gint64 last_us = 0;
};
void ao_sensitivity(const AoWidgets& w) {
const bool level = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode)) == 0;
gtk_widget_set_sensitive(w.level, level);
for (GtkWidget* x : {w.amp, w.offset, w.freq, w.run}) gtk_widget_set_sensitive(x, !level);
}
/// Back to what the source holds after a (re)connect.
void ao_reset(AoCtx* c) {
c->updating = true;
for (int ch = 0; ch < 2; ++ch) {
const AoWidgets& w = c->w[ch];
adw_combo_row_set_selected(ADW_COMBO_ROW(w.mode), 0);
adw_spin_row_set_value(ADW_SPIN_ROW(w.level), c->src->settings().ao_init[ch]);
adw_spin_row_set_value(ADW_SPIN_ROW(w.amp), 1.0);
adw_spin_row_set_value(ADW_SPIN_ROW(w.offset), 0.0);
adw_spin_row_set_value(ADW_SPIN_ROW(w.freq), 1.0);
adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE);
ao_sensitivity(w);
}
c->updating = false;
}
void ao_push(AoCtx* c, int ch) {
if (c->updating) return;
const AoWidgets& w = c->w[ch];
const guint mode = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode));
if (mode == 0 && adw_switch_row_get_active(ADW_SWITCH_ROW(w.run))) { // Level has no generator
c->updating = true;
adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE);
c->updating = false;
}
ao_sensitivity(w);
AoSettings s;
s.mode = static_cast<AoMode>(mode);
s.level = adw_spin_row_get_value(ADW_SPIN_ROW(w.level));
s.amplitude = adw_spin_row_get_value(ADW_SPIN_ROW(w.amp));
s.offset = adw_spin_row_get_value(ADW_SPIN_ROW(w.offset));
s.freq_hz = adw_spin_row_get_value(ADW_SPIN_ROW(w.freq));
s.run = adw_switch_row_get_active(ADW_SWITCH_ROW(w.run));
E1608Source* src = c->src;
src->post([src, ch, s] { src->set_analog_out(ch, s); });
}
void on_ao_changed(GObject*, GParamSpec*, gpointer user) {
const Hook* h = static_cast<const Hook*>(user);
ao_push(static_cast<AoCtx*>(h->ctx), h->idx);
}
void ao_refresh(AoCtx* c) {
const unsigned ep = c->ro->epoch.load();
if (ep != c->epoch_seen) {
c->epoch_seen = ep;
ao_reset(c);
}
for (int ch = 0; ch < 2; ++ch) {
const double v = c->ro->ao[ch].load();
char b[48];
if (std::isnan(v))
std::snprintf(b, sizeof b, "not written");
else
std::snprintf(b, sizeof b, "%+.4f V", v);
const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->w[ch].last));
if (cur == nullptr || std::strcmp(cur, b) != 0)
adw_action_row_set_subtitle(ADW_ACTION_ROW(c->w[ch].last), b);
}
// The achieved generator rate: ticks completed per second, measured by the producer thread. Below
// the requested rate, the board's command latency is what limits it.
char r[64];
const double req = c->src->settings().gen_rate;
const bool running = c->ro->gen_on[0].load() || c->ro->gen_on[1].load();
const double hz = c->ro->gen_hz.load();
if (!running)
std::snprintf(r, sizeof r, "off (requested %.0f Hz)", req);
else if (hz <= 0.0)
std::snprintf(r, sizeof r, "measuring... (requested %.0f Hz)", req);
else
std::snprintf(r, sizeof r, "%.0f Hz (requested %.0f Hz)", hz, req);
const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->gen_rate));
if (cur == nullptr || std::strcmp(cur, r) != 0)
adw_action_row_set_subtitle(ADW_ACTION_ROW(c->gen_rate), r);
}
gboolean ao_tick(GtkWidget*, GdkFrameClock*, gpointer user) {
AoCtx* c = static_cast<AoCtx*>(user);
if (due(c->last_us)) ao_refresh(c);
return G_SOURCE_CONTINUE;
}
GtkWidget* build_ao(E1608Source* src) {
auto* c = new AoCtx;
c->src = src;
c->ro = src->readouts();
GtkWidget* groups[2];
static const char* const kModes[] = {"Level", "Sine", "Square", "Triangle", "Ramp", nullptr};
for (int ch = 0; ch < 2; ++ch) {
const std::string p = "e1608-ao" + std::to_string(ch);
AoWidgets& w = c->w[ch];
w.mode = adw_combo_row_new();
gtk_widget_set_name(w.mode, (p + "-mode").c_str());
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.mode), "Mode");
adw_combo_row_set_model(ADW_COMBO_ROW(w.mode), G_LIST_MODEL(gtk_string_list_new(kModes)));
w.level = spin(p + "-level", "Level (V)", -10, 10, 0.1, src->settings().ao_init[ch], 3);
w.amp = spin(p + "-amp", "Amplitude (V)", 0, 10, 0.1, 1.0, 3);
w.offset = spin(p + "-offset", "Offset (V)", -10, 10, 0.1, 0.0, 3);
w.freq = spin(p + "-freq", "Frequency (Hz)", 0.1, 10, 0.1, 1.0, 2);
w.run = switch_row(p + "-run", "Run", nullptr);
w.last = adw_action_row_new();
gtk_widget_set_name(w.last, (p + "-last").c_str());
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.last), "Last written");
adw_action_row_set_subtitle(ADW_ACTION_ROW(w.last), "not written");
GtkWidget* g = adw_preferences_group_new();
const std::string title = "DAC " + std::to_string(ch);
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(g), title.c_str());
for (GtkWidget* r : {w.mode, w.level, w.amp, w.offset, w.freq, w.run, w.last})
adw_preferences_group_add(ADW_PREFERENCES_GROUP(g), r);
groups[ch] = g;
c->hooks[ch] = {c, ch};
g_signal_connect_data(w.mode, "notify::selected", G_CALLBACK(on_ao_changed), &c->hooks[ch],
nullptr, static_cast<GConnectFlags>(0));
for (GtkWidget* x : {w.level, w.amp, w.offset, w.freq})
g_signal_connect_data(x, "notify::value", G_CALLBACK(on_ao_changed), &c->hooks[ch], nullptr,
static_cast<GConnectFlags>(0));
g_signal_connect_data(w.run, "notify::active", G_CALLBACK(on_ao_changed), &c->hooks[ch],
nullptr, static_cast<GConnectFlags>(0));
ao_sensitivity(w);
}
c->gen_rate = adw_action_row_new();
gtk_widget_set_name(c->gen_rate, "e1608-gen-rate");
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(c->gen_rate), "Update rate");
adw_action_row_set_subtitle(ADW_ACTION_ROW(c->gen_rate), "off");
GtkWidget* gen_group = adw_preferences_group_new();
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(gen_group), "Generator");
adw_preferences_group_add(ADW_PREFERENCES_GROUP(gen_group), c->gen_rate);
GtkWidget* root = panel_root(groups[0], groups[1], gen_group);
g_object_set_data_full(G_OBJECT(root), "e1608-ao-ctx", c,
+[](gpointer p) { delete static_cast<AoCtx*>(p); });
gtk_widget_add_tick_callback(root, ao_tick, c, nullptr);
return root;
}
// --- Digital I/O ------------------------------------------------------------------------------
struct DioCtx {
E1608Source* src = nullptr;
std::shared_ptr<E1608Readouts> ro;
GtkWidget* led[8] = {}; ///< input bits
GtkWidget* out[8] = {}; ///< output bits (switch rows)
GtkWidget* counter = nullptr;
Hook hooks[8];
bool updating = false;
unsigned epoch_seen = 0;
gint64 last_us = 0;
};
void set_led(GtkWidget* l, bool on) {
if (gtk_widget_has_css_class(l, "success") == on) return;
if (on) {
gtk_widget_remove_css_class(l, "dim-label");
gtk_widget_add_css_class(l, "success");
} else {
gtk_widget_remove_css_class(l, "success");
gtk_widget_add_css_class(l, "dim-label");
}
}
void dio_refresh(DioCtx* c) {
const unsigned ep = c->ro->epoch.load();
if (ep != c->epoch_seen) { // a (re)connect re-latched the outputs from the form
c->epoch_seen = ep;
const unsigned latch = c->ro->do_latch.load();
c->updating = true;
for (int b = 0; b < 8; ++b) {
if (c->out[b] != nullptr)
adw_switch_row_set_active(ADW_SWITCH_ROW(c->out[b]), ((latch >> b) & 1u) != 0);
}
c->updating = false;
}
const unsigned di = c->ro->di.load();
for (int b = 0; b < 8; ++b) {
if (c->led[b] != nullptr) set_led(c->led[b], ((di >> b) & 1u) != 0);
}
char buf[24];
std::snprintf(buf, sizeof buf, "%u", static_cast<unsigned>(c->ro->counter.load()));
set_text(c->counter, buf);
}
gboolean dio_tick(GtkWidget*, GdkFrameClock*, gpointer user) {
DioCtx* c = static_cast<DioCtx*>(user);
if (due(c->last_us)) dio_refresh(c);
return G_SOURCE_CONTINUE;
}
void on_do_changed(GObject* obj, GParamSpec*, gpointer user) {
const Hook* h = static_cast<const Hook*>(user);
DioCtx* c = static_cast<DioCtx*>(h->ctx);
if (c->updating) return;
const bool on = adw_switch_row_get_active(ADW_SWITCH_ROW(obj));
E1608Source* src = c->src;
const int bit = h->idx;
src->post([src, bit, on] { src->set_output_bit(bit, on); });
}
void on_counter_reset(GtkButton*, gpointer user) {
DioCtx* c = static_cast<DioCtx*>(user);
E1608Source* src = c->src;
src->post([src] { src->reset_counter(); });
}
GtkWidget* build_dio(E1608Source* src) {
auto* c = new DioCtx;
c->src = src;
c->ro = src->readouts();
GtkWidget* lines = adw_preferences_group_new();
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(lines), "Digital lines");
for (int b = 0; b < 8; ++b) {
const std::string title = "Bit " + std::to_string(b);
if (src->settings().dir_out[b]) {
GtkWidget* row = switch_row("e1608-do" + std::to_string(b), title.c_str(), "Output");
c->out[b] = row;
c->hooks[b] = {c, b};
g_signal_connect_data(row, "notify::active", G_CALLBACK(on_do_changed), &c->hooks[b], nullptr,
static_cast<GConnectFlags>(0));
adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row);
} else {
GtkWidget* row = adw_action_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), title.c_str());
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), "Input");
GtkWidget* led = gtk_label_new("\u25cf");
gtk_widget_set_name(led, ("e1608-di" + std::to_string(b)).c_str());
gtk_widget_add_css_class(led, "dim-label");
adw_action_row_add_suffix(ADW_ACTION_ROW(row), led);
c->led[b] = led;
adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row);
}
}
GtkWidget* counter_group = adw_preferences_group_new();
adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(counter_group), "Counter");
GtkWidget* crow = adw_action_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(crow), "Counts");
c->counter = gtk_label_new("0");
gtk_widget_set_name(c->counter, "e1608-counter");
gtk_widget_add_css_class(c->counter, "numeric");
adw_action_row_add_suffix(ADW_ACTION_ROW(crow), c->counter);
GtkWidget* reset = gtk_button_new_with_label("Reset");
gtk_widget_set_name(reset, "e1608-counter-reset");
gtk_widget_set_valign(reset, GTK_ALIGN_CENTER);
g_signal_connect_data(reset, "clicked", G_CALLBACK(on_counter_reset), c, nullptr,
static_cast<GConnectFlags>(0));
adw_action_row_add_suffix(ADW_ACTION_ROW(crow), reset);
adw_preferences_group_add(ADW_PREFERENCES_GROUP(counter_group), crow);
GtkWidget* root = panel_root(lines, counter_group);
g_object_set_data_full(G_OBJECT(root), "e1608-dio-ctx", c,
+[](gpointer p) { delete static_cast<DioCtx*>(p); });
gtk_widget_add_tick_callback(root, dio_tick, c, nullptr);
return root;
}
} // namespace
std::vector<SourcePanel> make_e1608_panels(DataSource& source) {
E1608Source* src = static_cast<E1608Source*>(&source);
SourcePanel ao;
ao.id = "ao";
ao.title = "Analog out";
ao.widget = build_ao(src);
SourcePanel dio;
dio.id = "dio";
dio.title = "Digital I/O";
dio.widget = build_dio(src);
return {ao, dio};
}
} // namespace udpscope
+16
View File
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/source_kind.hpp"
#include <vector>
namespace udpscope {
/// The E-1608's two capability panels, "Analog out" and "Digital I/O". `source` must be an
/// `e1608::E1608Source`. The widgets reach it only through `source.post()`.
std::vector<SourcePanel> make_e1608_panels(DataSource& source);
} // namespace udpscope
+553
View File
@@ -0,0 +1,553 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/e1608/e1608_source.hpp"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <stdexcept>
#include <thread>
namespace udpscope::e1608 {
namespace {
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
std::string trim(const std::string& s) {
std::size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
double clamp(double v, double lo, double hi) { return std::min(hi, std::max(lo, v)); }
double wave(AoMode m, double f) {
switch (m) {
case AoMode::Sine: return std::sin(2.0 * M_PI * f);
case AoMode::Square: return f < 0.5 ? 1.0 : -1.0;
case AoMode::Triangle: return f < 0.5 ? 4.0 * f - 1.0 : 3.0 - 4.0 * f;
case AoMode::Ramp: return 2.0 * f - 1.0;
case AoMode::Level: break;
}
return 0.0;
}
double epoch_seconds() {
return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
}
} // namespace
// --- E1608Settings --------------------------------------------------------------------------
E1608Settings E1608Settings::from(const SourceConfig& c) {
E1608Settings s;
s.address = trim(c.get("address"));
s.mac_text = trim(c.get("mac"));
s.subnet = trim(c.get("subnet"));
s.port = static_cast<int>(c.get_int("port", kDefaultPort));
const long code = c.get_int("code", 0);
s.code = static_cast<std::uint32_t>(std::min(4294967295L, std::max(0L, code)));
s.timeout_ms = static_cast<int>(c.get_int("timeout_ms", 2000));
s.scan_rate = c.get_double("scan_rate", 1000.0);
s.ext_clock = c.get_bool("ext_clock", false);
parse_trigger(c.get("trigger", "none"), s.trigger);
s.auto_restart = c.get_bool("auto_restart", true);
s.calibrate = c.get_bool("calibrate", true);
s.poll_rate = c.get_double("poll_rate", 10.0);
s.gen_rate = c.get_double("gen_rate", 50.0);
s.diff = lower(c.get("mode", "se")) == "diff";
bool any_key = false;
for (int i = 0; i < 8; ++i) {
const std::string key = "ai" + std::to_string(i);
if (!c.has(key)) continue;
any_key = true;
Range r;
if (parse_range(c.get(key), r)) {
s.ai_on[i] = true;
s.ai_range[i] = r;
}
}
if (!any_key) { // a bare `--source-opt address=...` is connectable
s.ai_on[0] = true;
s.ai_range[0] = Range::Bip10V;
}
if (s.diff) {
for (int i = 4; i < 8; ++i) s.ai_on[i] = false;
}
for (int i = 0; i < 8; ++i) {
const std::string d = lower(c.get("dir" + std::to_string(i), "i"));
s.dir_out[i] = d == "o" || d == "out" || d == "output";
s.plot[i] = c.get_bool("plot" + std::to_string(i), false) && !s.dir_out[i];
}
s.plot_counter = c.get_bool("plot_counter", false);
s.do_init = static_cast<int>(c.get_int("do_init", 0) & 0xFF);
for (int i = 0; i < 2; ++i)
s.ao_init[i] = clamp(c.get_double("ao" + std::to_string(i) + "_init", 0.0), -10.0, 10.0);
return s;
}
int E1608Settings::active_channels() const {
int n = 0;
for (int i = 0; i < 8; ++i) n += ai_on[i] ? 1 : 0;
return n;
}
std::vector<QueueEntry> E1608Settings::queue() const {
std::vector<QueueEntry> q;
for (int i = 0; i < 8; ++i) {
if (ai_on[i]) q.push_back({static_cast<std::uint8_t>(diff ? i + 8 : i), ai_range[i]});
}
return q;
}
std::string E1608Settings::identity() const {
if (!address.empty()) return address;
Mac m;
return parse_mac(mac_text, m) ? format_mac(m) : mac_text;
}
std::string E1608Settings::problem() const {
Mac m;
if (address.empty() && mac_text.empty()) return "Enter an address, or a MAC address to look up";
if (!mac_text.empty() && !parse_mac(mac_text, m))
return "The MAC address must be six hex bytes, such as 00:80:2f:00:16:08";
if (address.empty() && !subnet.empty() && sweep_targets(subnet).empty())
return "The subnet must look like 192.168.1";
if (port < 1 || port > 65534) return "The port must be between 1 and 65534";
if (timeout_ms < 100 || timeout_ms > 60000) return "The timeout must be 100 to 60000 ms";
if (!(scan_rate > 0.0)) return "The scan rate must be positive";
if (active_channels() < 1) return "Enable at least one analog input";
if (aggregate() > kMaxAggregateHz) {
char b[96];
std::snprintf(b, sizeof b, "%d channels at %.6g S/s is %.6g kS/s; the board takes 250 kS/s",
active_channels(), scan_rate, aggregate() / 1000.0);
return b;
}
if (aggregate() < kMinAggregateHz) return "The aggregate rate must be at least 0.02 S/s";
if (!(poll_rate >= 0.1 && poll_rate <= 100.0)) return "The input poll rate must be 0.1 to 100 Hz";
if (!(gen_rate >= 1.0 && gen_rate <= 200.0)) return "The generator update rate must be 1 to 200 Hz";
return {};
}
// --- E1608Source ----------------------------------------------------------------------------
E1608Source::E1608Source(const SourceConfig& cfg) : cfg_(E1608Settings::from(cfg)) {
queue_ = cfg_.queue();
block_ = static_cast<std::size_t>(clamp(std::round(cfg_.scan_rate / 100.0), 1.0, 4096.0));
rx_.resize(65536);
ready_.resize(queue_.size());
auto base = [&](const std::string& name, const char* unit, double lo, double hi) {
SignalInfo s;
s.name = name;
s.unit = unit;
s.type_code = 9; // float64
s.rows = 1;
s.cols = 1;
s.range_min = lo;
s.range_max = hi;
s.time_mode = kTimePacket;
s.sampling_rate = cfg_.scan_rate;
return s;
};
for (int i = 0; i < 8; ++i) {
if (!cfg_.ai_on[i]) continue;
const double fs = full_scale(cfg_.ai_range[i]);
signals_.push_back(base("AI" + std::to_string(i), "V", -fs, fs));
}
for (int b = 0; b < 8; ++b) {
if (!cfg_.plot[b]) continue;
plotted_bits_.push_back(b);
signals_.push_back(base("DI" + std::to_string(b), "", 0.0, 1.0));
}
if (cfg_.plot_counter) signals_.push_back(base("Counter", "counts", 0.0, 1000.0));
held_di_.assign(plotted_bits_.size(), 0.0);
}
std::string E1608Source::last_message() const {
std::lock_guard<std::mutex> lk(msg_mu_);
return last_message_;
}
void E1608Source::open(SourceSink& sink) {
const std::string p = cfg_.problem();
if (!p.empty()) throw std::runtime_error(p);
sink_ = &sink;
state_ = State::Down;
retry_at_ = Clock::now();
}
void E1608Source::close() {
if (state_ == State::Scanning) dev_.scan_stop();
scan_started_ = false;
link_.close();
connected_.store(false, std::memory_order_relaxed);
}
SourceStats E1608Source::stats() const {
SourceStats s;
s.packets = packets_;
s.frames = frames_;
s.reconnects = reconnects_;
s.connected = connected_.load(std::memory_order_relaxed);
return s;
}
void E1608Source::note(const std::string& msg) {
{
std::lock_guard<std::mutex> lk(msg_mu_);
last_message_ = msg;
}
if (msg != last_logged_) { // a retry every 2 s must not flood stderr
last_logged_ = msg;
std::fprintf(stderr, "[e1608] %s\n", msg.c_str());
std::fflush(stderr);
}
}
void E1608Source::fail(const std::string& msg, bool fatal) {
if (scan_started_) {
// The scan may have started before a later connect step failed: tell the board to stop, so it
// is not left scanning with nobody reading. Best effort; it must never throw out of here.
try {
dev_.scan_stop();
} catch (...) {
}
scan_started_ = false;
}
link_.close();
const bool was_up = connected_.exchange(false, std::memory_order_relaxed);
note(msg);
if (was_up) sink_->on_event(SourceEvent::Disconnected, msg.c_str());
sink_->on_event(SourceEvent::Error, msg.c_str());
any_gen_ = false;
readouts_->gen_on[0].store(false);
readouts_->gen_on[1].store(false);
gen_rate_reset();
if (fatal) {
state_ = State::Halted;
} else {
state_ = State::Down;
retry_at_ = Clock::now() + std::chrono::seconds(2);
}
}
void E1608Source::poll(int timeout_ms) {
if (sink_ == nullptr) return;
try {
drain_commands();
switch (state_) {
case State::Down: {
const auto now = Clock::now();
if (now < retry_at_) {
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(retry_at_ - now);
std::this_thread::sleep_for(std::min(left, std::chrono::milliseconds(timeout_ms)));
return;
}
connect_and_start();
break;
}
case State::Scanning:
scan_step(timeout_ms);
break;
case State::Halted:
std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms));
break;
}
} catch (const LinkError& e) {
fail(e.what(), e.kind() == LinkError::Kind::Rejected);
} catch (const std::exception& e) {
fail(e.what(), false);
}
}
void E1608Source::connect_and_start() {
std::string host = cfg_.address;
Mac want{};
const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want);
if (host.empty() || have_mac) {
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() && cfg_.subnet.empty();
const std::vector<DeviceInfo> found =
broadcast ? discover_broadcast(cfg_.port, wait_ms)
: discover(host.empty() ? sweep_targets(cfg_.subnet)
: std::vector<std::string>{host},
cfg_.port, wait_ms);
if (host.empty()) {
for (const DeviceInfo& d : found) {
if (d.mac == want) host = d.ip;
}
if (host.empty())
throw LinkError(LinkError::Kind::Io,
"no E-1608 with MAC " + format_mac(want) + " answered");
} else {
if (found.empty()) throw LinkError(LinkError::Kind::Io, "no E-1608 answered at " + host);
if (found[0].mac != want)
throw LinkError(LinkError::Kind::Protocol, "the board at " + host + " has MAC " +
format_mac(found[0].mac) + ", not " +
format_mac(want));
}
}
link_.connect(host, cfg_.port, cfg_.code, cfg_.timeout_ms);
scan_started_ = false;
cal_ = Calibration{};
if (cfg_.calibrate && !dev_.read_calibration(cal_))
note("calibration memory refused; using nominal values");
std::uint8_t inputs = 0;
for (int b = 0; b < 8; ++b) {
if (!cfg_.dir_out[b]) inputs = static_cast<std::uint8_t>(inputs | (1u << b));
}
dev_.dconf(inputs);
do_latch_ = static_cast<std::uint8_t>(cfg_.do_init);
dev_.dout(do_latch_);
readouts_->do_latch.store(do_latch_);
for (int ch = 0; ch < 2; ++ch) {
ao_[ch] = AoSettings{};
ao_[ch].level = cfg_.ao_init[ch];
write_ao(ch, cfg_.ao_init[ch]);
}
any_gen_ = false;
update_gen_flags();
start_scan();
pending_.clear();
for (auto& r : ready_) r.clear();
pending_lost_ = 0;
const auto now = Clock::now();
next_slow_ = now;
next_status_ = now + std::chrono::milliseconds(500);
next_gen_ = now + gen_period();
slow_read();
state_ = State::Scanning;
connected_.store(true, std::memory_order_relaxed);
if (connects_.fetch_add(1) > 0) ++reconnects_;
readouts_->epoch.fetch_add(1);
last_logged_.clear();
sink_->on_config(signals_, 1);
sink_->on_event(SourceEvent::Connected, "");
}
int E1608Source::wait_ms(int timeout_ms, Clock::time_point now) const {
Clock::time_point next = std::min(next_slow_, next_status_);
if (any_gen_) next = std::min(next, next_gen_);
const long long d = std::chrono::duration_cast<std::chrono::milliseconds>(next - now).count();
return static_cast<int>(std::max(0LL, std::min<long long>(d, timeout_ms)));
}
void E1608Source::scan_step(int timeout_ms) {
const std::size_t n = link_.read_scan(rx_.data(), rx_.size(), wait_ms(timeout_ms, Clock::now()));
if (n > 0) {
++packets_;
pending_.insert(pending_.end(), rx_.begin(), rx_.begin() + static_cast<std::ptrdiff_t>(n));
const std::size_t nq = queue_.size();
const std::size_t scans = pending_.size() / (2 * nq);
if (scans > 0) {
std::vector<std::uint16_t> raw(scans * nq);
for (std::size_t i = 0; i < raw.size(); ++i)
raw[i] = static_cast<std::uint16_t>(pending_[2 * i] | (pending_[2 * i + 1] << 8));
pending_.erase(pending_.begin(),
pending_.begin() + static_cast<std::ptrdiff_t>(scans * nq * 2));
decode_scans(raw.data(), scans, queue_, cal_, dec_);
for (std::size_t q = 0; q < nq; ++q)
ready_[q].insert(ready_[q].end(), dec_[q].begin(), dec_[q].end());
}
}
while (!ready_.empty() && ready_[0].size() >= block_) emit_frame(block_);
run_timers();
}
void E1608Source::emit_frame(std::size_t count) {
const std::size_t nq = queue_.size();
const std::size_t total = signals_.size();
vals_.resize(total);
views_.resize(total);
for (std::size_t q = 0; q < nq; ++q) {
vals_[q].assign(ready_[q].begin(), ready_[q].begin() + static_cast<std::ptrdiff_t>(count));
ready_[q].erase(ready_[q].begin(), ready_[q].begin() + static_cast<std::ptrdiff_t>(count));
}
for (std::size_t k = 0; k < plotted_bits_.size(); ++k) vals_[nq + k].assign(count, held_di_[k]);
if (cfg_.plot_counter) vals_[total - 1].assign(count, held_counter_);
for (std::size_t i = 0; i < total; ++i)
views_[i] = SignalValues{vals_[i].data(), static_cast<std::uint32_t>(count)};
Frame f;
f.counter = frame_counter_++;
f.lost = pending_lost_;
pending_lost_ = 0;
f.late = false;
f.recv_time = epoch_seconds();
f.publish_mode = 1;
f.num_samples = static_cast<std::uint32_t>(count);
f.num_signals = static_cast<std::uint32_t>(total);
f.signals = signals_.data();
f.values = views_.data();
++frames_;
sink_->on_data(f);
}
void E1608Source::run_timers() {
const auto now = Clock::now();
if (now >= next_slow_) {
next_slow_ = now + std::chrono::duration_cast<Clock::duration>(
std::chrono::duration<double>(1.0 / cfg_.poll_rate));
slow_read();
}
if (now >= next_status_) {
next_status_ = now + std::chrono::milliseconds(500);
check_status();
}
if (any_gen_ && now >= next_gen_) {
next_gen_ = now + gen_period(); // from now, not from the old deadline: a slow board gets no catch-up burst
generator_tick(now);
}
}
void E1608Source::slow_read() {
const std::uint8_t d = dev_.din();
const std::uint32_t c = dev_.counter();
readouts_->di.store(d);
readouts_->counter.store(c);
for (std::size_t k = 0; k < plotted_bits_.size(); ++k)
held_di_[k] = (d >> plotted_bits_[k]) & 1 ? 1.0 : 0.0;
held_counter_ = static_cast<double>(c);
}
void E1608Source::check_status() {
const std::uint16_t st = dev_.status();
if (st & 4) {
if (!cfg_.auto_restart) {
fail("scan overrun; the scan is stopped (auto-restart is off)", true);
return;
}
note("scan overrun; restarting");
sink_->on_event(SourceEvent::Error, "scan overrun; restarting");
restart_scan();
pending_lost_ = 1;
last_logged_.clear(); // a later overrun on this connection is news again, not a retry echo
} else if (!(st & 2) && cfg_.auto_restart && cfg_.trigger == Trigger::None) {
// With a hardware trigger the board may report "not running" while it waits for the edge;
// restarting then would reset the wait forever, so only the overrun bit restarts the scan.
restart_scan();
pending_lost_ = 1; // the samples between the stop and the restart are gone either way
}
}
void E1608Source::restart_scan() {
dev_.scan_stop();
scan_started_ = false;
pending_.clear();
for (auto& r : ready_) r.clear();
start_scan();
}
void E1608Source::start_scan() {
// The device layer does not validate the rate; the board's cap is enforced here.
if (cfg_.aggregate() > kMaxAggregateHz)
throw LinkError(LinkError::Kind::Command, "the aggregate rate exceeds 250 kS/s");
try {
dev_.scan_start(queue_, cfg_.aggregate(), cfg_.ext_clock, cfg_.trigger);
scan_started_ = true;
} catch (...) {
// The board may have accepted the start (0x11) before a later step failed (the filler byte):
// tell it to stop, so it is not left scanning with nobody reading.
try {
dev_.scan_stop();
} catch (...) {
}
scan_started_ = false;
throw;
}
}
void E1608Source::write_ao(int ch, double volts) {
readouts_->ao[ch].store(dev_.aout(ch, volts, cal_));
}
E1608Source::Clock::duration E1608Source::gen_period() const {
return std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(1.0 / cfg_.gen_rate));
}
void E1608Source::gen_rate_reset() {
gen_win_ticks_ = 0;
gen_win_t0_ = Clock::now();
readouts_->gen_hz.store(0.0);
}
void E1608Source::update_gen_flags() {
const bool was_gen = any_gen_;
any_gen_ = false;
for (int ch = 0; ch < 2; ++ch) {
const bool on = ao_[ch].mode != AoMode::Level && ao_[ch].run;
readouts_->gen_on[ch].store(on);
any_gen_ = any_gen_ || on;
}
if (any_gen_ != was_gen) gen_rate_reset(); // a generator started or the last one stopped
}
void E1608Source::generator_tick(Clock::time_point now) {
bool wrote = false;
for (int ch = 0; ch < 2; ++ch) {
const AoSettings& s = ao_[ch];
if (s.mode == AoMode::Level || !s.run) continue;
const double t = std::chrono::duration<double>(now - gen_t0_[ch]).count();
const double phase = std::fmod(t * s.freq_hz, 1.0);
write_ao(ch, clamp(s.offset + s.amplitude * wave(s.mode, phase), -10.0, 10.0));
wrote = true;
}
if (!wrote) return;
// The achieved rate: ticks that wrote, per second of wall time, published once a window closes.
// The DAC writes are synchronous, so a slow board shows up here as a rate below the requested one.
++gen_win_ticks_;
const double elapsed = std::chrono::duration<double>(Clock::now() - gen_win_t0_).count();
if (elapsed >= 1.0) {
readouts_->gen_hz.store(gen_win_ticks_ / elapsed);
gen_win_ticks_ = 0;
gen_win_t0_ = Clock::now();
}
}
void E1608Source::set_output_bit(int bit, bool on) {
if (state_ != State::Scanning || bit < 0 || bit > 7 || !cfg_.dir_out[bit]) return;
do_latch_ = static_cast<std::uint8_t>(on ? (do_latch_ | (1u << bit)) : (do_latch_ & ~(1u << bit)));
dev_.dout(do_latch_);
readouts_->do_latch.store(do_latch_);
}
void E1608Source::reset_counter() {
if (state_ != State::Scanning) return;
dev_.counter_reset();
held_counter_ = 0.0;
readouts_->counter.store(0);
}
void E1608Source::set_analog_out(int ch, const AoSettings& s) {
if (state_ != State::Scanning || ch < 0 || ch > 1) return;
ao_[ch] = s;
ao_[ch].freq_hz = clamp(s.freq_hz, 0.1, 10.0);
ao_[ch].amplitude = clamp(s.amplitude, 0.0, 10.0);
ao_[ch].offset = clamp(s.offset, -10.0, 10.0);
if (s.mode == AoMode::Level) {
ao_[ch].run = false;
write_ao(ch, clamp(s.level, -10.0, 10.0));
} else if (s.run) {
gen_t0_[ch] = Clock::now();
}
update_gen_flags();
}
} // namespace udpscope::e1608
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/data_source.hpp"
#include "source/e1608/e1608_device.hpp"
#include "source/e1608/e1608_link.hpp"
#include "source/source_config.hpp"
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
namespace udpscope::e1608 {
constexpr double kMaxAggregateHz = 250000.0;
constexpr double kMinAggregateHz = 0.02;
/// Everything a configured E-1608 source needs, parsed once from the flat `SourceConfig`.
struct E1608Settings {
std::string address; ///< host or IP; empty means "find it by MAC"
std::string mac_text; ///< as typed; empty means "any board at `address`"
std::string subnet; ///< `a.b.c` to sweep when looking up a MAC; empty means broadcast
int port = kDefaultPort;
std::uint32_t code = 0;
int timeout_ms = 2000;
double scan_rate = 1000.0; ///< per channel; with an external clock, that clock's rate
bool ext_clock = false;
Trigger trigger = Trigger::None;
bool auto_restart = true;
bool calibrate = true;
double poll_rate = 10.0; ///< Hz, for the digital inputs and the counter
double gen_rate = 50.0; ///< Hz, the waveform generator's DAC update rate
bool diff = false;
bool ai_on[8] = {};
Range ai_range[8] = {};
bool dir_out[8] = {};
bool plot[8] = {};
bool plot_counter = false;
int do_init = 0;
double ao_init[2] = {0.0, 0.0};
static E1608Settings from(const SourceConfig& cfg);
/// The first reason this cannot connect, in words for the user; empty when it can.
std::string problem() const;
int active_channels() const;
std::vector<QueueEntry> queue() const;
double aggregate() const { return scan_rate * active_channels(); }
/// The address, else the MAC; names the session file.
std::string identity() const;
};
/// What the panels read. Written by the producer thread, read by the GTK thread's timer.
struct E1608Readouts {
std::atomic<unsigned> di{0}; ///< the DIN byte as last read
std::atomic<unsigned> do_latch{0}; ///< the DOUT byte as last written
std::atomic<std::uint32_t> counter{0};
std::atomic<double> ao[2]; ///< the last DAC voltage written; NaN before any
std::atomic<bool> gen_on[2];
std::atomic<double> gen_hz{0.0}; ///< generator ticks completed per second, over ~1 s; 0 when off
std::atomic<unsigned> epoch{0}; ///< bumped at each (re)connect: panels re-sync from it
E1608Readouts() {
for (int i = 0; i < 2; ++i) {
ao[i].store(std::nan(""));
gen_on[i].store(false);
}
}
};
enum class AoMode { Level, Sine, Square, Triangle, Ramp };
struct AoSettings {
AoMode mode = AoMode::Level;
double level = 0.0; ///< Level mode, volts
double amplitude = 1.0; ///< waveform modes, volts
double offset = 0.0;
double freq_hz = 1.0; ///< clamped to 0.1 .. 10
bool run = false; ///< waveform modes only
};
class E1608Source final : public DataSource {
public:
explicit E1608Source(const SourceConfig& cfg);
void open(SourceSink& sink) override;
void poll(int timeout_ms) override;
void close() override;
SourceStats stats() const override;
bool connected() const override { return connected_.load(std::memory_order_relaxed); }
// Producer thread only: reach these through `post()`. Each is ignored unless scanning.
void set_output_bit(int bit, bool on);
void reset_counter();
void set_analog_out(int ch, const AoSettings& s);
std::shared_ptr<E1608Readouts> readouts() const { return readouts_; }
const E1608Settings& settings() const { return cfg_; }
unsigned connects() const { return connects_.load(std::memory_order_relaxed); }
std::string last_message() const;
private:
using Clock = std::chrono::steady_clock;
enum class State { Down, Scanning, Halted };
void connect_and_start();
void scan_step(int timeout_ms);
void emit_frame(std::size_t count);
void run_timers();
void slow_read();
void check_status();
void restart_scan();
void start_scan();
void generator_tick(Clock::time_point now);
void write_ao(int ch, double volts);
void update_gen_flags();
void gen_rate_reset();
Clock::duration gen_period() const;
void fail(const std::string& msg, bool fatal);
void note(const std::string& msg);
int wait_ms(int timeout_ms, Clock::time_point now) const;
E1608Settings cfg_;
SourceSink* sink_ = nullptr;
State state_ = State::Down;
bool scan_started_ = false; ///< a scan start succeeded and no stop has followed
Link link_;
Device dev_{link_};
Calibration cal_;
std::vector<QueueEntry> queue_;
std::vector<SignalInfo> signals_;
std::vector<int> plotted_bits_; ///< the DI bits that are plot channels, ascending
std::size_t block_ = 10;
std::shared_ptr<E1608Readouts> readouts_ = std::make_shared<E1608Readouts>();
std::atomic<bool> connected_{false};
std::atomic<unsigned> connects_{0};
mutable std::mutex msg_mu_;
std::string last_message_;
std::string last_logged_;
std::vector<std::uint8_t> rx_; ///< the read buffer
std::vector<std::uint8_t> pending_; ///< bytes of an incomplete scan
std::vector<std::vector<double>> ready_; ///< decoded, not yet framed: [queue index][scan]
std::vector<std::vector<double>> dec_;
std::vector<std::vector<double>> vals_; ///< the frame being built: [signal][sample]
std::vector<SignalValues> views_;
std::vector<double> held_di_;
double held_counter_ = 0.0;
std::uint8_t do_latch_ = 0;
AoSettings ao_[2];
Clock::time_point gen_t0_[2];
bool any_gen_ = false;
Clock::time_point gen_win_t0_; ///< start of the current rate-measurement window
unsigned gen_win_ticks_ = 0; ///< ticks that wrote since `gen_win_t0_`
Clock::time_point retry_at_;
Clock::time_point next_slow_, next_status_, next_gen_;
std::uint32_t frame_counter_ = 0;
std::uint32_t pending_lost_ = 0;
std::uint64_t packets_ = 0, frames_ = 0, reconnects_ = 0;
};
} // namespace udpscope::e1608
+380
View File
@@ -0,0 +1,380 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/flatudp/flatudp_source.hpp"
#include "source/registry.hpp"
#include <adwaita.h>
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
namespace {
using namespace flatudp;
using ChangeCb = std::shared_ptr<std::function<void()>>;
std::string num(double v) {
char b[48];
std::snprintf(b, sizeof b, "%.10g", v);
return b;
}
std::string trim(const std::string& s) {
std::size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
GtkWidget* entry_row(const char* title) {
GtkWidget* r = adw_entry_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
return r;
}
GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) {
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast<guint>(digits));
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
return r;
}
GtkWidget* combo_row(const char* title, const char* const* items) {
GtkWidget* r = adw_combo_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items)));
return r;
}
GtkWidget* expander(const char* title, std::initializer_list<GtkWidget*> rows) {
GtkWidget* e = adw_expander_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title);
for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r);
return e;
}
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); }
guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); }
void mark(GtkWidget* w, bool bad) {
if (bad) {
gtk_widget_add_css_class(w, "error");
} else {
gtk_widget_remove_css_class(w, "error");
}
}
/// One signal's editing widgets; `row` is the `AdwEntryRow` that holds the others. `vec` ("Vector as":
/// Channels or Waveform) is shown only while `count` is above 1.
struct SigRow {
GtkWidget *row, *type, *count, *vec;
};
/// The form's widgets, shared by every signal closure and every `ConfigForm` closure. The raw
/// widget pointers are valid while the chooser keeps the rows.
struct Form : std::enable_shared_from_this<Form> {
GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *group;
std::vector<SigRow> sigs;
ChangeCb changed = std::make_shared<std::function<void()>>();
bool updating = false;
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing
std::vector<Row> rows_now() const {
std::vector<Row> rows;
for (const SigRow& s : sigs) {
Row r;
r.name = trim(text_of(s.row));
r.type = static_cast<Type>(std::min<guint>(gtk_drop_down_get_selected(GTK_DROP_DOWN(s.type)), 9));
r.count = static_cast<std::uint32_t>(gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(s.count)));
r.wave = r.count > 1 && gtk_drop_down_get_selected(GTK_DROP_DOWN(s.vec)) == 1;
rows.push_back(r);
}
return rows;
}
/// The first `S<n>` that no row uses.
std::string next_name() const {
const std::vector<Row> rows = rows_now();
for (int n = 1;; ++n) {
const std::string name = "S" + std::to_string(n);
bool used = false;
for (const Row& r : rows) used = used || r.name == name;
if (!used) return name;
}
}
/// Bad addresses are marked with the `error` style; the group's subtitle carries the signal list's
/// own state, the first rule it breaks or the size it implies.
void refresh() {
mark(bind, !text_of(bind).empty() && !valid_ipv4(text_of(bind)));
mark(multicast, !text_of(multicast).empty() && !valid_multicast(text_of(multicast)));
mark(iface, !text_of(iface).empty() && !valid_ipv4(text_of(iface)));
// "Vector as" belongs to a vector row only; a row back at one element returns to Channels.
for (const SigRow& s : sigs) {
const bool vec = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(s.count)) > 1;
gtk_widget_set_visible(s.vec, vec);
if (!vec && gtk_drop_down_get_selected(GTK_DROP_DOWN(s.vec)) != 0)
gtk_drop_down_set_selected(GTK_DROP_DOWN(s.vec), 0);
}
const std::vector<Row> rows = rows_now();
const std::string p = flatudp::problem(rows);
std::string sub = p;
if (p.empty()) {
sub = std::to_string(channel_count(rows)) + (channel_count(rows) == 1 ? " channel, " : " channels, ") +
std::to_string(datagram_size(rows)) + " bytes per datagram";
}
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(group), sub.c_str());
}
void fire() {
if (!updating && changed && *changed) (*changed)();
}
SourceConfig read() const {
SourceConfig c;
c.set("port", num(value_of(port)));
if (!text_of(bind).empty()) c.set("bind", text_of(bind));
if (!text_of(multicast).empty()) c.set("multicast", text_of(multicast));
if (!text_of(iface).empty()) c.set("iface", text_of(iface));
c.set("rate", num(value_of(rate)));
c.set("endian", sel_of(endian) == 1 ? "big" : "little");
c.set("signals", format(rows_now()));
return c;
}
void add_row(const Row& r);
void remove_row(GtkWidget* row);
void write(const SourceConfig& cfg) {
const FlatUdpSettings s = FlatUdpSettings::from(cfg);
updating = true;
adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port);
gtk_editable_set_text(GTK_EDITABLE(bind), s.bind.c_str());
gtk_editable_set_text(GTK_EDITABLE(multicast), s.multicast.c_str());
gtk_editable_set_text(GTK_EDITABLE(iface), s.iface.c_str());
if (s.rate > 0.0) adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.rate);
adw_combo_row_set_selected(ADW_COMBO_ROW(endian), s.big_endian ? 1 : 0);
while (!sigs.empty()) {
GtkWidget* row = sigs.back().row;
sigs.pop_back();
adw_expander_row_remove(ADW_EXPANDER_ROW(group), row);
}
for (const Row& r : s.rows) add_row(r);
updating = false;
refresh();
}
bool valid() const { return FlatUdpSettings::from(read()).problem().empty(); }
};
using FormPtr = std::shared_ptr<Form>;
/// A remove button's context: which form, which row.
struct RowCtx {
FormPtr form;
GtkWidget* row;
};
void on_notify(GObject*, GParamSpec*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.alive) return;
f.refresh();
f.fire();
}
void on_edited(GtkEditable*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.alive) return;
f.refresh();
f.fire();
}
void on_spin(GtkSpinButton*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.alive) return;
f.refresh();
f.fire();
}
void on_add(GtkButton*, gpointer user) {
FormPtr f = *static_cast<FormPtr*>(user);
if (!f->alive) return;
f->add_row(Row{f->next_name(), Type::F32, 1});
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE);
f->refresh();
f->fire();
}
void on_remove(GtkButton*, gpointer user) {
// Copy first: removing the row finalizes this button, and with it this context.
RowCtx* c = static_cast<RowCtx*>(user);
FormPtr f = c->form;
GtkWidget* row = c->row;
if (!f->alive) return;
f->remove_row(row);
}
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
void free_ctx(gpointer d, GClosure*) { delete static_cast<RowCtx*>(d); }
/// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone.
void on_form_destroy(GtkWidget*, gpointer user) { (**static_cast<FormPtr*>(user)).alive = false; }
void hook_destroy(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) {
g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
void hook_entry(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
void Form::add_row(const Row& r) {
FormPtr self = shared_from_this();
GtkWidget* row = adw_entry_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), "Name");
gtk_editable_set_text(GTK_EDITABLE(row), r.name.c_str());
const char* names[11];
for (std::size_t i = 0; i < 10; ++i) names[i] = type_name(static_cast<Type>(i));
names[10] = nullptr;
GtkWidget* type = gtk_drop_down_new_from_strings(names);
gtk_drop_down_set_selected(GTK_DROP_DOWN(type), static_cast<guint>(r.type));
gtk_widget_set_valign(type, GTK_ALIGN_CENTER);
gtk_widget_set_name(type, "flatudp-type");
// The spin clamps to 1..4096, so a hand-edited session with a count outside it is repaired here.
GtkWidget* count = gtk_spin_button_new_with_range(1, kMaxCount, 1);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(count), r.count);
gtk_widget_set_valign(count, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(count, "Elements: 1 is a scalar signal, more make a vector signal");
gtk_widget_set_name(count, "flatudp-count");
static const char* const kVec[] = {"Channels", "Waveform", nullptr};
GtkWidget* vec = gtk_drop_down_new_from_strings(kVec);
gtk_drop_down_set_selected(GTK_DROP_DOWN(vec), r.wave && r.count > 1 ? 1 : 0);
gtk_widget_set_valign(vec, GTK_ALIGN_CENTER);
gtk_widget_set_visible(vec, r.count > 1);
gtk_widget_set_tooltip_text(vec,
"Vector as: Channels makes one channel per element, one sample per cycle; "
"Waveform makes one channel of all the elements per cycle");
gtk_widget_set_name(vec, "flatudp-vector");
GtkWidget* rm = gtk_button_new_with_label("Remove");
gtk_widget_set_valign(rm, GTK_ALIGN_CENTER);
gtk_widget_set_name(rm, "flatudp-remove");
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), type);
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), count);
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), vec);
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), rm);
hook_entry(row, self);
hook_notify(type, "notify::selected", self);
hook_notify(vec, "notify::selected", self);
g_signal_connect_data(count, "value-changed", G_CALLBACK(on_spin), new FormPtr(self), free_form,
static_cast<GConnectFlags>(0));
g_signal_connect_data(rm, "clicked", G_CALLBACK(on_remove), new RowCtx{self, row}, free_ctx,
static_cast<GConnectFlags>(0));
adw_expander_row_add_row(ADW_EXPANDER_ROW(group), row);
sigs.push_back(SigRow{row, type, count, vec});
}
void Form::remove_row(GtkWidget* row) {
for (auto it = sigs.begin(); it != sigs.end(); ++it) {
if (it->row == row) {
sigs.erase(it);
adw_expander_row_remove(ADW_EXPANDER_ROW(group), row);
break;
}
}
refresh();
fire();
}
class FlatUdpKind final : public SourceKind {
public:
std::string id() const override { return "flatudp"; }
std::string display_name() const override { return "Flat UDP (MARTe2 UDPSender)"; }
bool has_datagrams() const override { return true; }
std::string identity(const SourceConfig& cfg) const override {
return FlatUdpSettings::from(cfg).identity();
}
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<FlatUdpSource>(cfg);
}
ConfigForm make_config_form() const override {
auto f = std::make_shared<Form>();
static const char* const kEndian[] = {"Little endian", "Big endian", nullptr};
f->port = spin_row("_Port", 1, 65535, 1, kDefaultPort, 0);
f->bind = entry_row("_Bind address");
f->multicast = entry_row("_Multicast group");
f->iface = entry_row("Multicast _interface");
f->rate = spin_row("Cycle _rate (Hz)", 0.001, 1000000, 1, 1000, 3);
f->endian = combo_row("_Byte order", kEndian);
GtkWidget* net = expander("Network", {f->port, f->bind, f->multicast, f->iface});
GtkWidget* timing = expander("Timing and format", {f->rate, f->endian});
f->group = adw_expander_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->group), "Signals");
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(net), TRUE);
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE);
GtkWidget* add = gtk_button_new_with_label("Add signal");
gtk_widget_set_valign(add, GTK_ALIGN_CENTER);
gtk_widget_set_name(add, "flatudp-add");
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->group), add);
g_signal_connect_data(add, "clicked", G_CALLBACK(on_add), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
for (GtkWidget* w : {net, timing, f->group}) hook_destroy(w, f);
for (GtkWidget* w : {f->bind, f->multicast, f->iface}) hook_entry(w, f);
for (GtkWidget* w : {f->port, f->rate}) hook_notify(w, "notify::value", f);
hook_notify(f->endian, "notify::selected", f);
f->refresh();
ConfigForm out;
out.rows = {net, timing, f->group};
out.description =
"The signal list must match the sender's exactly, in order: the datagrams carry no "
"description, and a datagram of any other size is dropped. A vector signal is either "
"one channel per element or, as a waveform, one channel of all its elements";
out.focus = f->rate;
out.write = [f](const SourceConfig& c) { f->write(c); };
out.read = [f]() { return f->read(); };
out.valid = [f]() { return f->valid(); };
ChangeCb cb = f->changed;
out.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return out;
}
};
} // namespace
std::unique_ptr<SourceKind> make_flatudp_kind() { return std::make_unique<FlatUdpKind>(); }
} // namespace udpscope
+238
View File
@@ -0,0 +1,238 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/flatudp/flatudp_signals.hpp"
#include <algorithm>
#include <bit>
#include <cctype>
#include <cstring>
#include <set>
namespace udpscope::flatudp {
namespace {
constexpr const char* kNames[] = {"uint8", "int8", "uint16", "int16", "uint32",
"int32", "uint64", "int64", "float32", "float64"};
constexpr std::size_t kSizes[] = {1, 1, 2, 2, 4, 4, 8, 8, 4, 8};
std::string trim(const std::string& s) {
std::size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
/// A name must survive `format` and `parse`: none of the separators, no edge whitespace.
bool valid_name(const std::string& n) {
if (n.empty()) return false;
if (std::isspace(static_cast<unsigned char>(n.front())) ||
std::isspace(static_cast<unsigned char>(n.back())))
return false;
for (char c : n) {
if (c == ',' || c == ':' || c == '[' || c == ']' || static_cast<unsigned char>(c) < 0x20)
return false;
}
return true;
}
template <class T>
double load(const std::uint8_t* p, bool swap) {
unsigned char b[sizeof(T)];
std::memcpy(b, p, sizeof(T));
if (swap) std::reverse(b, b + sizeof(T));
T v;
std::memcpy(&v, b, sizeof(T));
return static_cast<double>(v);
}
double load_typed(Type t, const std::uint8_t* p, bool swap) {
switch (t) {
case Type::U8: return load<std::uint8_t>(p, swap);
case Type::I8: return load<std::int8_t>(p, swap);
case Type::U16: return load<std::uint16_t>(p, swap);
case Type::I16: return load<std::int16_t>(p, swap);
case Type::U32: return load<std::uint32_t>(p, swap);
case Type::I32: return load<std::int32_t>(p, swap);
case Type::U64: return load<std::uint64_t>(p, swap);
case Type::I64: return load<std::int64_t>(p, swap);
case Type::F32: return load<float>(p, swap);
case Type::F64: return load<double>(p, swap);
}
return 0.0;
}
} // namespace
std::size_t type_size(Type t) { return kSizes[static_cast<std::size_t>(t)]; }
const char* type_name(Type t) { return kNames[static_cast<std::size_t>(t)]; }
bool parse_type(const std::string& s, Type& out) {
for (std::size_t i = 0; i < 10; ++i) {
if (s == kNames[i]) {
out = static_cast<Type>(i);
return true;
}
}
return false;
}
std::size_t datagram_size(const std::vector<Row>& rows) {
std::size_t n = 0;
for (const Row& r : rows) n += type_size(r.type) * static_cast<std::size_t>(r.count);
return n;
}
bool all_scalar(const std::vector<Row>& rows) {
return std::all_of(rows.begin(), rows.end(), [](const Row& r) { return r.count == 1; });
}
bool any_wave(const std::vector<Row>& rows) {
return std::any_of(rows.begin(), rows.end(), [](const Row& r) { return r.wave; });
}
int channel_count(const std::vector<Row>& rows) {
long n = 0;
for (const Row& r : rows) n += r.wave ? 1 : static_cast<long>(r.count);
return static_cast<int>(std::min<long>(n, 1L << 30));
}
std::string format(const std::vector<Row>& rows) {
std::string out;
for (const Row& r : rows) {
if (!out.empty()) out += ',';
out += r.name + ':' + type_name(r.type);
if (r.count != 1 || r.wave) out += '[' + std::to_string(r.count) + ']';
if (r.wave) out += 'w';
}
return out;
}
std::string parse(const std::string& text, std::vector<Row>& rows) {
rows.clear();
if (trim(text).empty()) return {};
std::size_t pos = 0;
int index = 0;
for (;;) {
std::size_t end = text.find(',', pos);
if (end == std::string::npos) end = text.size();
++index;
const std::string where = "signal " + std::to_string(index) + ": ";
const std::string tok = trim(text.substr(pos, end - pos));
const std::size_t colon = tok.find(':');
if (colon == std::string::npos) {
rows.clear();
return where + "expected name:type";
}
Row r;
r.name = trim(tok.substr(0, colon));
std::string ty = trim(tok.substr(colon + 1));
const std::size_t br = ty.find('[');
if (br != std::string::npos) {
const std::size_t cl = ty.rfind(']');
if (cl == std::string::npos || cl < br) {
rows.clear();
return where + "missing ']' after the count";
}
const std::string tail = trim(ty.substr(cl + 1));
if (tail == "w") {
r.wave = true;
} else if (!tail.empty()) {
rows.clear();
return where + "unexpected '" + tail + "' after the count (only w, for a waveform)";
}
const std::string n = trim(ty.substr(br + 1, cl - br - 1));
const bool digits = !n.empty() && n.size() <= 6 &&
std::all_of(n.begin(), n.end(), [](char c) { return c >= '0' && c <= '9'; });
if (!digits) {
rows.clear();
return where + "the count must be a whole number";
}
r.count = static_cast<std::uint32_t>(std::stoul(n));
ty = trim(ty.substr(0, br));
}
if (!parse_type(ty, r.type)) {
rows.clear();
return where + "unknown type '" + ty + "'";
}
rows.push_back(r);
if (end >= text.size()) break;
pos = end + 1;
}
return {};
}
std::string problem(const std::vector<Row>& rows) {
if (rows.empty()) return "add at least one signal";
std::set<std::string> seen;
for (std::size_t i = 0; i < rows.size(); ++i) {
const Row& r = rows[i];
if (r.name.empty()) return "signal " + std::to_string(i + 1) + " has no name";
if (!valid_name(r.name))
return "'" + r.name + "': a name cannot hold , : [ ] or edge spaces";
if (!seen.insert(r.name).second) return "the name '" + r.name + "' is used twice";
if (r.count < 1 || r.count > kMaxCount)
return "'" + r.name + "': the count must be 1 to " + std::to_string(kMaxCount);
}
const std::size_t n = datagram_size(rows);
if (n > kMaxDatagram)
return std::to_string(n) + " bytes per datagram exceed the " + std::to_string(kMaxDatagram) +
" a UDP datagram can carry";
// A waveform is one channel of `count` samples per datagram; it cannot share a frame with a
// channel of another length, so a list of waveforms is all waveforms of one length.
const Row* first_wave = nullptr;
for (const Row& r : rows) {
if (!r.wave) continue;
if (r.count < 2) return "signal '" + r.name + "' is a waveform and needs a count above 1";
if (first_wave == nullptr) first_wave = &r;
}
if (first_wave != nullptr) {
for (const Row& r : rows) {
if (!r.wave)
return "signal '" + r.name + "' is not a waveform but '" + first_wave->name +
"' is; every row must be a waveform of the same length";
if (r.count != first_wave->count)
return "signal '" + r.name + "' is a waveform of " + std::to_string(r.count) +
" values but '" + first_wave->name + "' has " + std::to_string(first_wave->count) +
"; every row must be a waveform of the same length";
}
}
const int channels = channel_count(rows);
if (channels > kMaxChannels)
return std::to_string(channels) + " channels exceed the " + std::to_string(kMaxChannels) +
" one list may make (a vector row makes one per element unless it is a waveform)";
return {};
}
void decode_datagram(const std::vector<Row>& rows, const std::uint8_t* data, bool big_endian,
std::vector<std::vector<double>>& cols) {
if (cols.size() < rows.size()) cols.resize(rows.size());
const bool swap = big_endian != (std::endian::native == std::endian::big);
std::size_t off = 0;
for (std::size_t s = 0; s < rows.size(); ++s) {
const std::size_t w = type_size(rows[s].type);
for (std::uint32_t k = 0; k < rows[s].count; ++k) {
cols[s].push_back(load_typed(rows[s].type, data + off, swap));
off += w;
}
}
}
void decode_channels(const std::vector<Row>& rows, const std::uint8_t* data, bool big_endian,
std::vector<std::vector<double>>& cols) {
const std::size_t channels = static_cast<std::size_t>(channel_count(rows));
if (cols.size() < channels) cols.resize(channels);
const bool swap = big_endian != (std::endian::native == std::endian::big);
std::size_t off = 0, ch = 0;
for (const Row& r : rows) {
const std::size_t w = type_size(r.type);
for (std::uint32_t k = 0; k < r.count; ++k) {
cols[r.wave ? ch : ch + k].push_back(load_typed(r.type, data + off, swap));
off += w;
}
ch += r.wave ? 1 : r.count;
}
}
} // namespace udpscope::flatudp
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace udpscope::flatudp {
/// The numeric value is the `SignalInfo::type_code` (the order of the client library's `UDPS_T_*`).
enum class Type : std::uint8_t { U8 = 0, I8, U16, I16, U32, I32, U64, I64, F32, F64 };
constexpr std::uint32_t kMaxCount = 4096;
constexpr std::size_t kMaxDatagram = 65507; ///< the largest UDP payload over IPv4
constexpr int kMaxChannels = 256; ///< the most channels one list may make
/// One signal of the sender's datagram: `count` consecutive values of `type`.
struct Row {
std::string name;
Type type = Type::F32;
std::uint32_t count = 1;
/// A vector row (count > 1) only: true is ONE channel of `count` consecutive samples per
/// datagram (a waveform); false is `count` channels `name[0]`.. of one sample each.
bool wave = false;
bool operator==(const Row&) const = default;
};
std::size_t type_size(Type t);
const char* type_name(Type t); ///< "uint8" ... "float64"
bool parse_type(const std::string& s, Type& out);
/// The bytes one datagram must have: the sum of `type_size * count`.
std::size_t datagram_size(const std::vector<Row>& rows);
/// True when no row is a vector. (Batching depends on `any_wave`, not on this.)
bool all_scalar(const std::vector<Row>& rows);
/// True when some row is a waveform (`wave`).
bool any_wave(const std::vector<Row>& rows);
/// The channels the list makes: a scalar 1, a non-wave vector `count`, a waveform 1.
int channel_count(const std::vector<Row>& rows);
/// `name:type[,name:type[count][w]...]`; a count of 1 is written without brackets, and the `w`
/// of a waveform row follows its count.
std::string format(const std::vector<Row>& rows);
/// The inverse of `format`. Whitespace around names, types and counts is ignored. An empty text
/// is an empty list (and not an error: `problem` reports it). Returns an error in words, empty
/// on success; `rows` is empty after a failure.
std::string parse(const std::string& text, std::vector<Row>& rows);
/// The first reason this list cannot describe a datagram, in words for the user; empty when valid.
std::string problem(const std::vector<Row>& rows);
/// Decodes one datagram of `datagram_size(rows)` bytes to one column per *channel* (see
/// `channel_count`): a non-wave vector row feeds `count` columns of one value each, a waveform row
/// one column of `count` values. Appends; the caller clears the columns between uses.
void decode_channels(const std::vector<Row>& rows, const std::uint8_t* data, bool big_endian,
std::vector<std::vector<double>>& cols);
/// Decodes one datagram of `datagram_size(rows)` bytes to doubles, row by row, byte-swapping when
/// `big_endian` differs from the host. Appends to `cols[s]`; the caller clears them between uses.
void decode_datagram(const std::vector<Row>& rows, const std::uint8_t* data, bool big_endian,
std::vector<std::vector<double>>& cols);
} // namespace udpscope::flatudp
+315
View File
@@ -0,0 +1,315 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/flatudp/flatudp_source.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <cctype>
#include <cerrno>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <thread>
namespace udpscope::flatudp {
namespace {
using namespace std::chrono_literals;
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
std::string trim(const std::string& s) {
std::size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
double epoch_seconds() {
return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
}
constexpr std::size_t kMaxReportedSizes = 8; ///< a sender of random sizes must not flood stderr
} // namespace
bool valid_ipv4(const std::string& s) {
in_addr a{};
return inet_pton(AF_INET, s.c_str(), &a) == 1;
}
bool valid_multicast(const std::string& s) {
in_addr a{};
if (inet_pton(AF_INET, s.c_str(), &a) != 1) return false;
return (ntohl(a.s_addr) >> 28) == 0xE;
}
// --- FlatUdpSettings ------------------------------------------------------------------------
FlatUdpSettings FlatUdpSettings::from(const SourceConfig& c) {
FlatUdpSettings s;
s.port = static_cast<int>(c.get_int("port", kDefaultPort));
s.bind = trim(c.get("bind"));
s.multicast = trim(c.get("multicast"));
s.iface = trim(c.get("iface"));
s.rate = c.get_double("rate", 0.0);
const std::string e = lower(trim(c.get("endian", "little")));
s.endian_ok = e == "little" || e == "big";
s.big_endian = e == "big";
s.signals_error = parse(c.get("signals"), s.rows);
return s;
}
std::string FlatUdpSettings::problem() const {
if (port < 1 || port > 65535) return "the port must be 1 to 65535";
if (!signals_error.empty()) return signals_error;
const std::string p = flatudp::problem(rows);
if (!p.empty()) return p;
if (!(rate > 0.0) || !std::isfinite(rate)) return "give the sender's cycle rate in Hz (rate)";
if (!endian_ok) return "endian must be little or big";
if (!bind.empty() && !valid_ipv4(bind)) return "the bind address '" + bind + "' is not an IPv4 address";
if (!multicast.empty() && !valid_multicast(multicast))
return "'" + multicast + "' is not a multicast group (224.0.0.0 to 239.255.255.255)";
if (!iface.empty() && !valid_ipv4(iface))
return "the interface address '" + iface + "' is not an IPv4 address";
return {};
}
std::string FlatUdpSettings::identity() const {
return (multicast.empty() ? std::string() : multicast + ":") + std::to_string(port);
}
std::size_t FlatUdpSettings::block() const {
if (any_wave(rows) || !(rate > 0.0) || !std::isfinite(rate)) return 1;
const long b = std::lround(rate / 100.0);
return static_cast<std::size_t>(std::min(1024L, std::max(1L, b)));
}
// --- FlatUdpSource --------------------------------------------------------------------------
FlatUdpSource::FlatUdpSource(const SourceConfig& cfg) : cfg_(FlatUdpSettings::from(cfg)) {}
FlatUdpSource::~FlatUdpSource() { close_fd(); }
std::string FlatUdpSource::last_message() const {
std::lock_guard<std::mutex> lk(msg_mu_);
return last_message_;
}
void FlatUdpSource::note(const std::string& msg) {
{
std::lock_guard<std::mutex> lk(msg_mu_);
last_message_ = msg;
}
std::fprintf(stderr, "[flatudp] %s\n", msg.c_str());
std::fflush(stderr);
}
void FlatUdpSource::close_fd() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
void FlatUdpSource::open(SourceSink& sink) {
const std::string p = cfg_.problem();
if (!p.empty()) throw std::runtime_error(p);
sink_ = &sink;
// A fresh session: Stop then Go live opens the same object again, and the model needs its CONFIG.
configured_ = false;
connected_.store(false, std::memory_order_relaxed);
ever_connected_ = false;
recv_error_noted_ = false;
reported_sizes_.clear();
frame_counter_ = 0;
frames_ = 0;
reconnects_ = 0;
valid_.store(0, std::memory_order_relaxed);
wrong_.store(0, std::memory_order_relaxed);
// One SignalInfo per channel: a scalar, one element of a `channels` vector (`Name[k]`), or a whole
// waveform (rows = count consecutive samples at rate * count).
signals_.clear();
const auto add = [&](const std::string& name, const Row& r, std::uint32_t rows, double rate) {
SignalInfo s;
s.name = name;
s.type_code = static_cast<std::uint8_t>(r.type);
s.rows = rows;
s.cols = 1;
s.time_mode = kTimePacket;
s.sampling_rate = rate;
signals_.push_back(s);
};
for (const Row& r : cfg_.rows) {
if (r.wave) {
add(r.name, r, r.count, cfg_.rate * static_cast<double>(r.count));
} else if (r.count == 1) {
add(r.name, r, 1, cfg_.rate);
} else {
for (std::uint32_t k = 0; k < r.count; ++k)
add(r.name + "[" + std::to_string(k) + "]", r, 1, cfg_.rate);
}
}
expected_ = datagram_size(cfg_.rows);
block_ = cfg_.block();
pending_.assign(signals_.size(), {});
pending_n_ = 0;
rx_.assign(65536, 0);
const auto fail = [this](const std::string& what) {
const std::string msg = what + ": " + std::strerror(errno);
close_fd();
throw std::runtime_error(msg);
};
fd_ = ::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (fd_ < 0) fail("cannot create a UDP socket");
// Receivers of one multicast group share its port; a unicast port is the one app's.
if (!cfg_.multicast.empty()) {
const int one = 1;
::setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
}
const int rcvbuf = 4 << 20; // best effort: a 1 kHz sender at 64 KiB would not fit a default one
::setsockopt(fd_, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<std::uint16_t>(cfg_.port));
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (!cfg_.bind.empty()) inet_pton(AF_INET, cfg_.bind.c_str(), &addr.sin_addr);
if (::bind(fd_, reinterpret_cast<const sockaddr*>(&addr), sizeof addr) < 0)
fail("cannot bind UDP port " + std::to_string(cfg_.port));
if (!cfg_.multicast.empty()) {
ip_mreq mreq{};
inet_pton(AF_INET, cfg_.multicast.c_str(), &mreq.imr_multiaddr);
if (cfg_.iface.empty()) {
mreq.imr_interface.s_addr = htonl(INADDR_ANY);
} else {
inet_pton(AF_INET, cfg_.iface.c_str(), &mreq.imr_interface);
}
if (::setsockopt(fd_, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof mreq) < 0)
fail("cannot join multicast group " + cfg_.multicast);
}
}
void FlatUdpSource::close() {
close_fd();
connected_.store(false, std::memory_order_relaxed);
}
SourceStats FlatUdpSource::stats() const {
SourceStats s;
s.packets = valid_.load(std::memory_order_relaxed) + wrong_.load(std::memory_order_relaxed);
s.frames = frames_;
s.stale = wrong_.load(std::memory_order_relaxed);
s.reconnects = reconnects_;
s.connected = connected_.load(std::memory_order_relaxed);
return s;
}
void FlatUdpSource::poll(int timeout_ms) {
drain_commands();
// Never wait long: the partial-block flush and the silence timeout are checked on this cadence.
const int wait = std::clamp(timeout_ms, 0, 20);
if (fd_ < 0 || sink_ == nullptr) {
std::this_thread::sleep_for(std::chrono::milliseconds(wait));
return;
}
if (!configured_) {
configured_ = true;
sink_->on_config(signals_, 1);
}
pollfd p{};
p.fd = fd_;
p.events = POLLIN;
if (::poll(&p, 1, wait) > 0 && (p.revents & POLLIN)) drain_socket();
const auto now = Clock::now();
if (pending_n_ > 0 && now - batch_start_ >= 100ms) flush(); // a stopped sender holds nothing back
if (connected_.load(std::memory_order_relaxed) && now - last_valid_ >= 2s) {
connected_.store(false, std::memory_order_relaxed);
sink_->on_event(SourceEvent::Disconnected, "no datagram for 2 s");
}
}
void FlatUdpSource::drain_socket() {
for (int i = 0; i < 8192; ++i) { // bounded, so a flood cannot starve the timers
// MSG_TRUNC: the return value is the datagram's real length even if it did not fit `rx_`.
const ssize_t n = ::recv(fd_, rx_.data(), rx_.size(), MSG_TRUNC);
if (n < 0) {
if (errno == EINTR) continue;
if (errno != EAGAIN && errno != EWOULDBLOCK && !recv_error_noted_) {
recv_error_noted_ = true;
note(std::string("receive failed: ") + std::strerror(errno));
}
break; // EAGAIN: drained
}
const double t = epoch_seconds();
const std::size_t len = static_cast<std::size_t>(n);
if (tap_) tap_(rx_.data(), std::min(len, rx_.size()), t);
if (len == expected_) {
accept(rx_.data());
} else {
reject(len);
}
}
}
void FlatUdpSource::accept(const std::uint8_t* data) {
valid_.fetch_add(1, std::memory_order_relaxed);
last_valid_ = Clock::now();
if (!connected_.load(std::memory_order_relaxed)) {
if (ever_connected_) ++reconnects_;
ever_connected_ = true;
connected_.store(true, std::memory_order_relaxed);
sink_->on_event(SourceEvent::Connected, "");
}
decode_channels(cfg_.rows, data, cfg_.big_endian, pending_);
if (pending_n_ == 0) batch_start_ = last_valid_;
if (++pending_n_ >= block_) flush();
}
void FlatUdpSource::reject(std::size_t len) {
wrong_.fetch_add(1, std::memory_order_relaxed);
if (reported_sizes_.size() >= kMaxReportedSizes || !reported_sizes_.insert(len).second) return;
const std::string msg = "datagram of " + std::to_string(len) + " bytes, expected " +
std::to_string(expected_) + ", check the signal list";
note(msg);
sink_->on_event(SourceEvent::Error, msg.c_str());
}
void FlatUdpSource::flush() {
const std::size_t n = signals_.size();
views_.resize(n);
for (std::size_t i = 0; i < n; ++i)
views_[i] = SignalValues{pending_[i].data(), static_cast<std::uint32_t>(pending_[i].size())};
Frame f;
f.counter = frame_counter_++;
f.lost = 0;
f.late = false;
f.recv_time = epoch_seconds();
f.publish_mode = 1;
f.num_samples = static_cast<std::uint32_t>(pending_n_);
f.num_signals = static_cast<std::uint32_t>(n);
f.signals = signals_.data();
f.values = views_.data();
++frames_;
sink_->on_data(f);
for (auto& c : pending_) c.clear();
pending_n_ = 0;
}
} // namespace udpscope::flatudp
+105
View File
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/data_source.hpp"
#include "source/flatudp/flatudp_signals.hpp"
#include "source/source_config.hpp"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <mutex>
#include <set>
#include <string>
#include <vector>
namespace udpscope::flatudp {
constexpr int kDefaultPort = 44488; ///< `UDPSender`'s own default
bool valid_ipv4(const std::string& s);
/// An IPv4 address in 224.0.0.0/4.
bool valid_multicast(const std::string& s);
/// Everything a configured flat UDP source needs, parsed once from the flat `SourceConfig`.
struct FlatUdpSettings {
int port = kDefaultPort;
std::string bind; ///< local address; empty is any
std::string multicast; ///< group to join; empty is plain unicast
std::string iface; ///< local interface address for the join; empty is the system's choice
double rate = 0.0; ///< the sender's cycle rate in Hz; 0 means not given
bool big_endian = false;
bool endian_ok = true; ///< false when `endian` is neither "little" nor "big"
std::vector<Row> rows;
std::string signals_error; ///< why the `signals` text did not parse; `rows` is empty then
static FlatUdpSettings from(const SourceConfig& cfg);
/// The first reason this cannot connect, in words for the user; empty when it can.
std::string problem() const;
/// `[multicast:]port`; names the session file.
std::string identity() const;
/// Datagrams per frame: 1 for a list with a waveform row, else `clamp(round(rate / 100), 1, 1024)`.
std::size_t block() const;
};
class FlatUdpSource final : public DataSource {
public:
explicit FlatUdpSource(const SourceConfig& cfg);
~FlatUdpSource() override;
void open(SourceSink& sink) override;
void poll(int timeout_ms) override;
void close() override;
SourceStats stats() const override;
bool connected() const override { return connected_.load(std::memory_order_relaxed); }
void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); }
const FlatUdpSettings& settings() const { return cfg_; }
/// Datagrams of the expected size / of any other size; for the probe, readable from any thread.
std::uint64_t valid_datagrams() const { return valid_.load(std::memory_order_relaxed); }
std::uint64_t wrong_size_datagrams() const { return wrong_.load(std::memory_order_relaxed); }
std::string last_message() const;
private:
using Clock = std::chrono::steady_clock;
void close_fd();
void drain_socket();
void accept(const std::uint8_t* data);
void reject(std::size_t len);
void flush();
void note(const std::string& msg);
FlatUdpSettings cfg_;
SourceSink* sink_ = nullptr;
DatagramTap tap_;
int fd_ = -1;
bool configured_ = false;
bool ever_connected_ = false;
bool recv_error_noted_ = false; ///< a failing `recv` is logged once per open
std::size_t expected_ = 0; ///< bytes of a valid datagram
std::size_t block_ = 1;
std::vector<SignalInfo> signals_;
std::vector<std::uint8_t> rx_;
std::vector<std::vector<double>> pending_; ///< decoded, not yet framed: [signal][value]
std::vector<SignalValues> views_;
std::size_t pending_n_ = 0; ///< datagrams in `pending_`
Clock::time_point batch_start_;
Clock::time_point last_valid_;
std::set<std::size_t> reported_sizes_;
std::atomic<bool> connected_{false};
std::atomic<std::uint64_t> valid_{0};
std::atomic<std::uint64_t> wrong_{0};
mutable std::mutex msg_mu_;
std::string last_message_;
std::uint32_t frame_counter_ = 0;
std::uint64_t frames_ = 0;
std::uint64_t reconnects_ = 0;
};
} // namespace udpscope::flatudp
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "model.hpp"
#include <cstdint>
namespace udpscope {
/// One signal's values in a frame. `values` points into the source's own buffer and is valid
/// only while the `on_data` call that received the frame is running.
struct SignalValues {
const double* values = nullptr;
std::uint32_t count = 0;
};
/// A data frame in source-neutral form; it mirrors `udps_frame_t`. A source with no packet
/// counter supplies a running one and `lost = 0`: the hole, backfill and time-base logic only
/// needs `counter`, `lost` and `late`.
struct Frame {
std::uint32_t counter = 0;
std::uint32_t lost = 0;
bool late = false;
double recv_time = 0.0;
std::uint8_t publish_mode = 0;
std::uint32_t num_samples = 0;
std::uint32_t num_signals = 0;
const SignalInfo* signals = nullptr;
const SignalValues* values = nullptr;
/// Element `elem` of signal `sig` at sample `sample`. Mirrors `udps_frame_value`: a scalar
/// signal carried once per sample is indexed by `sample`, a vector/matrix signal by `elem`.
/// Out of range reads 0.0.
double value(std::uint32_t sig, std::uint32_t sample, std::uint32_t elem) const noexcept {
if (sig >= num_signals) return 0.0;
const SignalValues& v = values[sig];
const std::uint32_t idx =
(v.count == num_samples && signals[sig].elements() == 1u) ? sample : elem;
return idx < v.count ? v.values[idx] : 0.0;
}
};
} // namespace udpscope
+165
View File
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/probe_source.hpp"
#include "source/registry.hpp"
#include <adwaita.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <functional>
#include <memory>
#include <thread>
namespace udpscope {
ProbeSource::ProbeSource(const SourceConfig& cfg)
: rate_hz_(std::max(1.0, cfg.get_double("rate", 1000.0))), batch_(kBatch, 0.0) {
SignalInfo s;
s.name = "probe";
s.unit = "V";
s.type_code = 9; // float64
s.rows = 1;
s.cols = 1;
s.range_min = -1.0;
s.range_max = 1.0;
s.time_mode = kTimePacket;
s.sampling_rate = rate_hz_;
signals_.push_back(s);
}
void ProbeSource::open(SourceSink& sink) {
sink_ = &sink;
configured_ = false;
open_.store(true, std::memory_order_relaxed);
}
void ProbeSource::close() { open_.store(false, std::memory_order_relaxed); }
SourceStats ProbeSource::stats() const {
SourceStats s;
s.packets = frames_;
s.frames = frames_;
s.connected = open_.load(std::memory_order_relaxed);
return s;
}
void ProbeSource::poll(int timeout_ms) {
drain_commands();
if (!open_.load(std::memory_order_relaxed) || sink_ == nullptr) return;
if (!configured_) {
configured_ = true;
sink_->on_config(signals_, /*publish_mode=*/1);
sink_->on_event(SourceEvent::Connected, "");
}
// One batch takes kBatch / rate seconds to "arrive".
const int period_ms = std::max(1, static_cast<int>(1000.0 * kBatch / rate_hz_));
std::this_thread::sleep_for(std::chrono::milliseconds(std::min(period_ms, timeout_ms)));
const double step = 2.0 * M_PI * 5.0 / rate_hz_; // a 5 Hz sine
for (std::uint32_t k = 0; k < kBatch; ++k) {
batch_[k] = std::sin(phase_);
phase_ += step;
}
values_.values = batch_.data();
values_.count = kBatch;
Frame f;
f.counter = counter_++;
f.lost = 0;
f.late = false;
f.recv_time = std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch())
.count();
f.publish_mode = 1;
f.num_samples = kBatch;
f.num_signals = 1;
f.signals = signals_.data();
f.values = &values_;
++frames_;
sink_->on_data(f);
}
namespace {
void on_rate_changed(GObject*, GParamSpec*, gpointer user) {
const auto& cb = *static_cast<std::shared_ptr<std::function<void()>>*>(user);
if (cb && *cb) (*cb)();
}
class ProbeKind final : public SourceKind {
public:
std::string id() const override { return "probe"; }
std::string display_name() const override { return "Probe (test)"; }
std::string identity(const SourceConfig& cfg) const override {
return "rate" + std::to_string(cfg.get_int("rate", 1000));
}
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<ProbeSource>(cfg);
}
ConfigForm make_config_form() const override {
GtkWidget* rate = GTK_WIDGET(adw_spin_row_new_with_range(1, 100000, 1));
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(rate), "_Rate (Hz)");
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(rate), TRUE);
adw_spin_row_set_value(ADW_SPIN_ROW(rate), 1000);
auto cb = std::make_shared<std::function<void()>>();
g_signal_connect_data(rate, "notify::value", G_CALLBACK(on_rate_changed),
new std::shared_ptr<std::function<void()>>(cb),
+[](gpointer d, GClosure*) {
delete static_cast<std::shared_ptr<std::function<void()>>*>(d);
},
static_cast<GConnectFlags>(0));
ConfigForm f;
f.rows = {rate};
f.description = "A synthetic 5 Hz sine, for the self-check";
f.focus = rate;
f.write = [=](const SourceConfig& c) {
adw_spin_row_set_value(ADW_SPIN_ROW(rate), static_cast<double>(c.get_int("rate", 1000)));
};
f.read = [=]() {
SourceConfig c;
c.set("rate", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(rate)))));
return c;
};
f.valid = []() { return true; };
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return f;
}
std::vector<SourcePanel> make_panels(DataSource& source) const override {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
GtkWidget* label = gtk_label_new("Test panel");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), label);
// The probe finds the button as the box's last child. It reaches the source only through
// `post()`: the command runs inside the next `poll()`, on the producer thread.
GtkWidget* poke = gtk_button_new_with_label("Poke");
gtk_box_append(GTK_BOX(box), poke);
DataSource* src = &source;
g_signal_connect_data(
poke, "clicked",
G_CALLBACK(+[](GtkButton*, gpointer user) {
DataSource* s = static_cast<DataSource*>(user);
s->post([s] { static_cast<ProbeSource*>(s)->note_applied(); });
}),
src, nullptr, static_cast<GConnectFlags>(0));
SourcePanel p;
p.id = "poke";
p.title = "Probe";
p.widget = box;
return {p};
}
};
} // namespace
std::unique_ptr<SourceKind> make_probe_kind() { return std::make_unique<ProbeKind>(); }
} // namespace udpscope
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/data_source.hpp"
#include "source/source_config.hpp"
#include <atomic>
#include <cstdint>
#include <vector>
namespace udpscope {
/// A synthetic source for the headless self-check: one 1 V sine signal, `kBatch` samples per
/// frame at `rate` Hz, no socket. It exists to prove the framework end to end (open, CONFIG,
/// data, commands, panels) and is registered only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set.
class ProbeSource final : public DataSource {
public:
static constexpr std::uint32_t kBatch = 10;
explicit ProbeSource(const SourceConfig& cfg);
void open(SourceSink& sink) override;
void poll(int timeout_ms) override;
void close() override;
SourceStats stats() const override;
bool connected() const override { return open_.load(std::memory_order_relaxed); }
/// What a panel command does, so the probe can see that `post()` reached the producer thread.
void note_applied() noexcept { applied_.fetch_add(1, std::memory_order_relaxed); }
std::uint64_t commands_applied() const noexcept {
return applied_.load(std::memory_order_relaxed);
}
private:
double rate_hz_;
SourceSink* sink_ = nullptr;
std::atomic<bool> open_{false};
bool configured_ = false;
std::uint32_t counter_ = 0;
std::uint64_t frames_ = 0;
double phase_ = 0.0;
std::vector<SignalInfo> signals_;
std::vector<double> batch_;
SignalValues values_;
std::atomic<std::uint64_t> applied_{0};
};
} // namespace udpscope
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/registry.hpp"
#include <algorithm>
#include <cstdlib>
namespace udpscope {
namespace {
std::vector<std::unique_ptr<SourceKind>>& owned() {
static std::vector<std::unique_ptr<SourceKind>> v;
return v;
}
std::vector<SourceKind*>& raw() {
static std::vector<SourceKind*> v;
return v;
}
std::string sanitize(const std::string& s) {
std::string out = s;
for (char& c : out) {
const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
if (!ok) c = '_';
}
return out;
}
} // namespace
void register_kind(std::unique_ptr<SourceKind> kind) {
if (!kind || find_kind(kind->id())) return;
raw().push_back(kind.get());
owned().push_back(std::move(kind));
}
SourceKind* find_kind(const std::string& id) {
for (SourceKind* k : raw()) {
if (k->id() == id) return k;
}
return nullptr;
}
const std::vector<SourceKind*>& kinds() { return raw(); }
void register_builtin_sources() {
static bool done = false;
if (done) return;
done = true;
register_kind(make_udps_kind());
register_kind(make_e1608_kind());
register_kind(make_flatudp_kind());
if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());
}
std::string session_host(const std::string& kind_id, const SourceConfig& cfg) {
if (kind_id == "udps") return cfg.get("server", "127.0.0.1");
SourceKind* k = find_kind(kind_id);
return sanitize(kind_id + "-" + (k ? k->identity(cfg) : std::string("unknown")));
}
std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg) {
if (kind_id == "udps") {
const long p = cfg.get_int("port", 8000);
return static_cast<std::uint16_t>(p > 0 && p <= 65535 ? p : 8000);
}
return 1;
}
} // namespace udpscope
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/source_kind.hpp"
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
/// Takes ownership. A second kind with the same id is ignored.
void register_kind(std::unique_ptr<SourceKind> kind);
SourceKind* find_kind(const std::string& id);
/// In registration order (the chooser's dropdown order).
const std::vector<SourceKind*>& kinds();
/// Registers the built-in kinds; idempotent. No static-init self-registration, which a linker
/// may drop. A later `dlopen` loader would only call `register_kind()`.
void register_builtin_sources();
/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`, `e1608/e1608_kind.cpp`, `flatudp/flatudp_kind.cpp`).
std::unique_ptr<SourceKind> make_udps_kind();
std::unique_ptr<SourceKind> make_probe_kind();
std::unique_ptr<SourceKind> make_e1608_kind();
std::unique_ptr<SourceKind> make_flatudp_kind();
/// The (host, port) pair that names a source's session file. UDPS keeps its legacy
/// `server`/`port`; any other kind is `"<kind>-<identity>"` (characters outside `[A-Za-z0-9._-]`
/// become `_`) on port 1 -- not 0, which the session-file parser rejects and waveform mode uses.
std::string session_host(const std::string& kind_id, const SourceConfig& cfg);
std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg);
} // namespace udpscope
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/source_config.hpp"
#include <algorithm>
#include <cctype>
#include <cerrno>
#include <cmath>
#include <cstdlib>
namespace udpscope {
void SourceConfig::set(const std::string& key, const std::string& value) {
for (auto& kv : items_) {
if (kv.first == key) {
kv.second = value;
return;
}
}
items_.emplace_back(key, value);
}
bool SourceConfig::has(const std::string& key) const {
return std::any_of(items_.begin(), items_.end(),
[&](const auto& kv) { return kv.first == key; });
}
std::string SourceConfig::get(const std::string& key, const std::string& def) const {
for (const auto& kv : items_) {
if (kv.first == key) return kv.second;
}
return def;
}
long SourceConfig::get_int(const std::string& key, long def) const {
if (!has(key)) return def;
const std::string v = get(key);
char* end = nullptr;
errno = 0;
const long n = std::strtol(v.c_str(), &end, 10);
return (end == v.c_str() || *end != '\0' || errno == ERANGE) ? def : n;
}
double SourceConfig::get_double(const std::string& key, double def) const {
if (!has(key)) return def;
const std::string v = get(key);
char* end = nullptr;
errno = 0;
const double d = std::strtod(v.c_str(), &end);
return (end == v.c_str() || *end != '\0' || std::isinf(d)) ? def : d;
}
bool SourceConfig::get_bool(const std::string& key, bool def) const {
if (!has(key)) return def;
std::string v = get(key);
std::transform(v.begin(), v.end(), v.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (v == "1" || v == "true" || v == "yes" || v == "on") return true;
if (v == "0" || v == "false" || v == "no" || v == "off") return false;
return def;
}
bool SourceConfig::valid_key(const std::string& key) {
return !key.empty() && std::all_of(key.begin(), key.end(), [](unsigned char c) {
return std::isalnum(c) || c == '_';
});
}
bool SourceConfig::parse_assignment(const std::string& kv) {
const auto eq = kv.find('=');
if (eq == std::string::npos) return false;
const std::string key = kv.substr(0, eq);
if (!valid_key(key)) return false;
set(key, kv.substr(eq + 1));
return true;
}
} // namespace udpscope
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <string>
#include <utility>
#include <vector>
namespace udpscope {
/// A source's settings: an ordered string key/value map. It is the stored form (top-level
/// `source_opt_<key>` entries of a session), the `--source-opt k=v` form, and what a config form
/// reads and writes. Order is insertion order, so a stored session stays diff-friendly.
class SourceConfig {
public:
using Items = std::vector<std::pair<std::string, std::string>>;
SourceConfig() = default;
explicit SourceConfig(Items items) : items_(std::move(items)) {}
/// Replaces the value in place if the key exists, else appends.
void set(const std::string& key, const std::string& value);
bool has(const std::string& key) const;
std::string get(const std::string& key, const std::string& def = {}) const;
long get_int(const std::string& key, long def) const;
double get_double(const std::string& key, double def) const;
/// 1/true/yes/on and 0/false/no/off (case-insensitive); anything else gives `def`.
bool get_bool(const std::string& key, bool def) const;
/// Parses `k=v`; false (and no change) if there is no `=` or the key is not `valid_key`.
bool parse_assignment(const std::string& kv);
const Items& items() const noexcept { return items_; }
bool empty() const noexcept { return items_.empty(); }
/// `[A-Za-z0-9_]+` -- the characters that survive as a TOML bare key suffix.
static bool valid_key(const std::string& key);
private:
Items items_;
};
} // namespace udpscope
+63
View File
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/data_source.hpp"
#include "source/source_config.hpp"
#include <gtk/gtk.h>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
/// A kind's configuration UI, as pieces the chooser places in its own group. The widgets are
/// floating `AdwPreferencesRow`s; the closures capture them, so a form must not outlive its rows
/// (the chooser drops the form when it removes the rows).
struct ConfigForm {
std::vector<GtkWidget*> rows;
/// Shown under the group's title; empty for none.
std::string description;
/// The widget that takes focus when the form is shown; may be null.
GtkWidget* focus = nullptr;
/// Puts `cfg` into the widgets (absent keys leave the kind's defaults).
std::function<void(const SourceConfig&)> write;
/// The widgets as a `SourceConfig`.
std::function<SourceConfig()> read;
/// Whether the form is complete enough to connect.
std::function<bool()> valid;
/// Calls `fn` whenever any widget changes.
std::function<void(std::function<void()>)> on_change;
};
/// A capability panel (signal generator, digital I/O, ...) for the side area.
struct SourcePanel {
std::string id; ///< stack page name
std::string title;
GtkWidget* widget = nullptr;
};
/// One plugin type: its identity, factory and UI.
class SourceKind {
public:
virtual ~SourceKind() = default;
virtual std::string id() const = 0; ///< short, `[a-z0-9]+`; the CLI and session name
virtual std::string display_name() const = 0;
/// A stable, human-readable name for one configured source (`host:port`, a serial number).
virtual std::string identity(const SourceConfig& cfg) const = 0;
virtual std::unique_ptr<DataSource> make(const SourceConfig& cfg) const = 0;
virtual ConfigForm make_config_form() const = 0;
/// Panels for a live source. The widgets must reach the source only through `post()`.
virtual std::vector<SourcePanel> make_panels(DataSource& source) const {
(void)source;
return {};
}
/// Whether the source reports raw datagrams (enables the packet-capture UI).
virtual bool has_datagrams() const { return false; }
};
} // namespace udpscope
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more