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
95 changed files with 11715 additions and 1857 deletions

No files matched your search

+11 -6
View File
@@ -6,8 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one
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 200 MB), and draws it
with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of
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
@@ -19,6 +19,11 @@ 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 data source and the GTK main
@@ -39,20 +44,20 @@ 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`); the source plugins. |
| [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. |
@@ -61,4 +66,4 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, 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 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. |
+5 -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")
@@ -269,11 +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
)
+313 -100
View File
@@ -22,27 +22,48 @@ It is used for exactly one widget — the shortcut overview is an `AdwShortcutsD
GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and `AdwShortcutsDialog` is its
replacement.
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
(size of the sample buffer, default 200), `--max-window` (largest time
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples`
(samples kept per acquired signal, default 10M; see "The sample buffer is budgeted in samples" below),
`--memory-limit-mb` (hard ceiling on the ring storage, default 8192), `--max-window` (largest time
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are
added and removed from the **Layout editor** afterwards), `--relative-time` (see below),
`--recv-buffer`, `--plots` (the number of plots to start with, one per row, 1..8; rows are
added and removed and a row can be split into side-by-side plots from the **Layout editor** afterwards), `--relative-time` (see below),
`--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; see "Data sources" below),
`--capture-packets N` (see "Packet capture" below), `--renderer NAME` (GTK's renderer:
`cairo`, `gl` or `vulkan`; `cairo` is the software preset that exports both
`GSK_RENDERER` and `LIBGL_ALWAYS_SOFTWARE` before GTK initialises — see the SSH
limitation below).
**What a large `--buffer-mb` costs at startup, and what the window says while it happens.** The
buffer's rings are value-initialised, so allocating one touches every byte of `--buffer-mb` --- about
**The sample buffer is budgeted in samples per signal, under a memory limit.** `--buffer-samples N`
(`250000`, `64k`, `100M`, `1.5G`; k, M and G are decimal, a *sample* count and not a byte size) is how
many samples to keep for **every acquired signal**, and for the shared time ring. A ring is a power of
two, so the request is rounded **down** to one --- "up to" --- and the UI shows both figures: the
default 10 M asks for, and gets, 8 388 608 per signal, and `--buffer-samples 100M` gets 67 108 864. What that
costs in bytes follows from the channel count (`ticks x 8` for the clock, doubled in relative mode, plus
`ticks x 4` per channel), and the status popover and the badge text quote the *planned* bytes rather than a
figure the user typed. The ceiling on those bytes is a separate application setting,
**`--memory-limit-mb`** (default 8192 MiB; also a *Memory limit* row in **Preferences**, which re-plans the
live buffer when changed). `plan_partition()` starts at the power-of-two floor of the request and
**halves** it until the rings fit under the limit, and throws only when 128 samples do not fit. A clipped
plan is never silent: stderr gets one line (`[udpscope] buffer limited to 8.39 M samples per signal (100 M
requested) by the 256 MiB memory limit; the rings take 192 MiB`), the status popover's buffer row names the
limit, and the source form's note says so, assuming one signal there because the channel count is not
known before a stream is. `--buffer-mb` and the stored `buffer_mb` key are gone, with no compatibility
shim: the flag is an unknown argument, and an old `buffer_mb` in a saved configuration is ignored (the
source falls back to the default until it is saved again as `buffer_samples`). **This is a deliberate
change of the budget's unit** (it was megabytes of ring, which made the retained history depend on the
channel count and on relative time); it is recorded under "Deviations from the spec" in `SPECS.md`.
**What a large buffer costs at startup, and what the window says while it happens.** The
buffer's rings are value-initialised, so allocating one touches every byte of the planned rings --- about
a second per gigabyte, mostly first-touch page faults --- and it happens on the producer thread
holding the lock the frame tick also takes. It is therefore a real freeze and not a slow paint. Two
things follow. First, a rebuild whose plan describes the storage already allocated **clears** the
store instead of allocating a new one, which is what most structural changes are: startup used to ask
for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at `--buffer-mb 1000`), and
for fifteen allocations and now asks for two or three (3.36 s → 1.18 s at a gigabyte of rings), and
every enable of a signal is applied in one batch rather than re-planning per signal. Second, the
window is **presented before** the buffer is planned and carries a notice naming what it is waiting
for --- `connecting to 127.0.0.1:8000 — planning the 4000 MB buffer` --- because the remaining second
for --- `connecting to 127.0.0.1:8000 — planning the buffer: 134 M samples per signal` --- because the remaining second
or so at a multi-gigabyte buffer is unavoidable, and a blank window that says nothing is the part of
it that reads as a hang. Each allocation also names itself on stderr
(`[udpscope] buffer allocated: 4 channels, 839 s span, 74 ms`), so a run that prints a column of them
@@ -73,7 +94,7 @@ switches trigger mode on, waits for an event, then leaves trigger mode the way t
"Go live" button does, closes the window, and prints on stdout the status line in
both states, the frame and per-channel sample counts, each plot's painted-frame
count, which channel each trace slot holds, and the allocated size and reveal
state of both side panels.
state of the dock, and the events page's controls (present and nested in the page).
Every size it prints is read from a value the frame tick captured while the window was alive, not
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
@@ -96,9 +117,72 @@ src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x lin
src/export.* the visible range as a table, and the .npy / .csv / .mf3 writers
src/layout_store.* the per-source session layout file (TOML), written and read back
src/event_series.hpp an immutable captured event as a plot::data::Series
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
src/ui.* the GTK4 window: toolbar, trigger form, signal list, plot box, the dock
src/ui_slideover.cpp the slide-over panel over the plot's right edge, one page at a time
src/ui_badges.cpp the badge bar under the plot: the signal badges, the + badge, Trigger and Source
src/ui_signal_page.cpp the per-signal slide-over pages (`signal:<name>`)
```
**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left
part is the **signals**: one badge per *enabled* signal (a dot in the signal's colour, filled while the
signal is plotted, and its name), then a `+` badge that opens the **Add a signal** page (`g` does the same). That page proposes only what can be
*added*: the streamed signals that are not acquired and the math channels that are not computed, each row with
the tick that acquires it (the row then leaves the page and the signal's badge appears), plus the `MATH`
divider's `+` for a new math channel; when every signal is acquired a dim "every signal is acquired" line
stands in for the list. While the bar has overflowed into the "N signals" badge the page lists the acquired
signals too, since their rows are then the only way to each signal's page. There is no signal lock: a
signal's page and the list always allow acquire, colour, calibration, the math editor and delete.
A signal badge opens that signal's own page in the slide-over -- the unit, gain and offset and the CONFIG's details directly under the name (aligned rows; a pencil beside the unit, gain and offset edits just that one -- a unit rename is stored per signal name, saved as `[[unit]]` and re-applied to every CONFIG), the colour and the *Plot on N*
buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another
badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their
place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and counter would not fit
otherwise. The signal badges sit in a scroller, so the bar's minimum width, and with it the window's,
never depends on how many signals there are. The badge order is kept **for the session only**; it is not
written to the session file. The right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered`
or `disarmed`) and **Source** (the kind's name only -- the link state is the status bar's). **Source is shown only when the source
has panels of its own** (the E-1608's); for UDPS, Flat UDP and a file there is nothing for it to open, so it is hidden
(the bar keeps its height; the kind's name is in the status bar). A badge opens the **slide-over**: a 360 px
panel laid over the plot's right edge, opaque, with its own title, a *Close* button and one scroller per
page. The Trigger badge opens the trigger form; the Source badge (when shown) opens the source kind's own panels
(the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It
closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never
resizes the plot. The right-hand **dock** (the sidebar, **280 px**, fixed) holds the **readouts** and, under
them, the **Events** block (below): the readouts are a compact, read-only copy of the cursors table (a header row `A B delta`, then one line per cursor
line) and of the measurements (one line each: the signal's colour chip, the type, the value). It is
shown from the start and insensitive until the stream has signals. Each of the two blocks has an
**Edit** button, which opens the full panel as a slide-over page -- `cursors` or `measure` -- over the
plot; `m` toggles the `measure` page, and a click on a measurement's line in the dock opens that
measurement's dialog. The header's **Cursors** toggle only switches the cursors on and off (it
mirrors the switch inside the `cursors` page); with the cursors off the dock's cursor lines are
insensitive, not hidden. Both pages are insensitive until the stream has signals, like the dock. The dock's
cells are a fixed number of characters wide and ellipsized, so the dock's width and height do not
depend on what a value reads: the **who** cell of a cursor line is 5 characters (the full text is its
tooltip), a value cell 9 (its tooltip is the full text). The dock and the pages show **one string**:
`update_cursors_pane()` and `update_measures()` format each value once and hand that variable to the
page's label and to the dock's, so the two cannot disagree. **When the dock is not beside the plot** -- hidden
with the dock button, or the window narrow enough (< 900 px, "compact mode") for the dock to be a collapsed
overlay on the plot, open or not -- the same readouts appear on **one line under the plot**, above the badge bar (the
*readouts strip*): a **Cursors** button (opens the `cursors` page) and one chip per cursor line (its name and
its *delta* value), then a **Measures** button (opens the `measure` page) and one chip per measurement (the
signal's colour chip, the type, the value; a click opens its dialog). Each of the two groups is there only
with something to read -- the Cursors group with the cursors switched on, the Measures group with at least one
measurement, the separator with both -- and **with neither, the strip is not shown at all**.
It is a **fixed 26 px** high and its chips are as wide as the dock's cells (name 6 characters, type 10, value
9, the full text in the tooltip); what does not fit scrolls sideways with no bar taking height. With the
cursors off their chips are insensitive and read `--`, as the dock's cells do (the group is hidden then, so this is
not seen). It is the **third reader of the
same strings** (the setters that write the dock's labels write the strip's), and it is shown exactly when
`(!show-sidebar || collapsed)` **and** (the cursors are on **or** there is a measurement) -- a deliberate
exception to "disable, do not hide": an empty line under the plot is 26 px of nothing, and the dock, which keeps
both blocks on screen, is where the empty state is shown. In a wide window it is hidden while the dock is shown beside the plot (so the
plot never loses its 26 px to both), and in a collapsed window it stays on screen even with the dock overlaid,
so the measurements and cursors remain visible in compact mode. The **Events** block is a heading with a
**Manage** button over a short list of the cached events, newest first -- one compact row each, never a
picture -- and one click on a row replays that event (see "Captured events"). The full list, with the
pictures and the comparison actions, is the `events` slide-over page that **Manage** opens. There is no
events pane beside the plot and no Events button in the header: events never take any of the plot's width.
The rest of the redesign is listed under "Review findings not (yet) implemented".
Threading: one producer thread owns the data source (the UDPStreamer client is the built-in
one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts.
They share `ScopeModel` under one mutex, which the producer takes once per DATA
@@ -123,11 +207,17 @@ click on a result fills the address, or **Use MAC** fills the MAC and leaves the
saved: only the filled field is. A MAC with no subnet is looked up by a broadcast sent on every up interface: a
single limited broadcast leaves through the default route only, and on 2026-11-02 it missed a real board on
`net0` while the default route was `wlan0`. The form also groups the acquisition settings (scan rate, external clock,
trigger, restart after an overrun, factory calibration, the digital poll rate), a range per analog input, and the
trigger, restart after an overrun, factory calibration, the digital poll rate, the generator update rate), a range per analog input, and the
direction and initial value of each digital line. The analog inputs are the plotted channels; each digital input
and the counter is an extra channel only if you ask for it. Two side-stack tabs belong to it: **Analog out**
(per DAC: a static level, or a sine, square, triangle or ramp from 0.1 to 10 Hz) and **Digital I/O** (a live LED per
input bit, a switch per output bit, the counter with a reset); they are insensitive while no board is connected.
The waveform generator writes the DACs at the **generator update rate** (`gen_rate`, 1 to 200 Hz, default 50), which
is separate from the input poll rate (`poll_rate`, which only reads the digital inputs and the counter). Each update
is a synchronous command to the board, so a board that answers slowly achieves less than was asked: the **Analog out**
tab ends with an **Update rate** row that shows the rate the generator actually achieved (measured over about a
second) next to the requested one, or `off` when no generator runs. The waveform frequency itself is still limited
to 0.1 to 10 Hz.
Acquisition is hardware-paced only (the scan rate or an external clock), the channels times the rate must stay at
or below 250 kS/s, and the form refuses more. With the command-line options, a run that names no `aiN` key gets
`ai0=BIP10V`; naming any `aiN` key drops that default, so `ai1=BIP5V` alone is channel 1 alone.
@@ -703,8 +793,12 @@ The history cost of relative mode is measured rather than computed:
| stream time (default) | **83.886 s** |
| `--relative-time` | **41.943 s** |
Conditions: the mock at 100 kS/s, 4 channels at stride 1, the default `--buffer-mb 200`.
Relative mode retains **exactly half** the history — not the ~22% the original spec claimed, and
Conditions: the mock at 100 kS/s, 4 channels at stride 1, the old byte budget (`--buffer-mb 200`, since removed). **That
no longer happens**: the buffer is budgeted in samples per signal, so both modes retain the same span
(8 388 608 ticks at the default) and relative mode instead costs a third more *memory* (32 against 24
bytes a tick with four channels; the `planner:` dump line in `docs/claude/probe-readings.md` reads it); only a memory limit that
bites clips it first. The paragraph below is the measurement that motivated it.
Under the old byte budget relative mode retained **exactly half** the history — not the ~22% the original spec claimed, and
not the 75% a per-byte calculation suggests. The reason is the floor: relative mode keeps a second
f64 ring for absolute time beside the stream clock's, so a tick's cost goes from
`8 + 4×4 = 24` bytes to `16 + 4×4 = 32` bytes — a third more. But `Partition::time_capacity` is
@@ -765,20 +859,36 @@ time to look at is still there:
20261009-123250-0001.bmp # a picture of it, drawn from the samples
```
The **left pane** is opened on demand from the toolbar's event-history button, is collapsed by
default, and its **divider can be dragged**: it starts at 240 pixels and remembers the width you last
chose, and while it is closed it takes no room at all. Both the button and the pane exist while **recording is on**, or while an event is on
screen: with the cache disabled and nothing being examined there is nothing to show, and a visible
empty column beside the plot is what *off* must not look like. Recording is switched in the **trigger
panel**, with the trigger's other options rather than in the pane whose contents it decides, and it is
**off by default** -- writing every latch to disk is the user's decision about their disk.
Events are read in two places. The **dock's Events block** lists them compactly -- one row per event, newest
first, at most **24** with a dim "... N more, see Manage" row after them: a marker (a filled circle on the
event on screen, a hollow one on each member of the comparison or accumulation on screen), the time and
duration, and the event's sequence number (`#7`). **One click on a row replays it.** The block's **Manage**
button opens the **`events` page**, a slide-over page over the plot with the full list (below), the *Live*
button and the two group actions. The page replaces the left pane that used to sit beside the plot behind a
header button and a draggable divider; both are gone, and so is the toolbar's Events button -- opening the
list no longer resizes the plot at all.
The block and the page exist all the time and are **insensitive, not hidden**, when there is nothing to show:
they are available while **recording is on**, while an event is on screen (a replay or a group), or while
the cache already holds events. **Deviation from the redesign spec,** which has the block insensitive
"outside trigger mode": a replay or a group *leaves* trigger mode (a file has no edge to wait for), so a block
that went grey outside trigger mode would grey itself under the very click that started the replay. Trigger
mode is therefore not one of the conditions. Recording is switched in the **trigger panel**, with the
trigger's other options rather than in the list whose contents it decides, and it is **off by default** --
writing every latch to disk is the user's decision about their disk. (The session file keeps nothing about
this list: there is no remembered width, because there is no pane to size.)
The dock follows every latch; the page's list is refreshed per latch only while it is open, and rebuilt when
**Manage** opens it. **The dock list has no thumbnails** -- its rows are fixed-width, ellipsized cells, so the
dock's size does not depend on what an event is called -- the page has them, and its rows are replayed with a
**double** click, because on the page a single click builds the selection that *Compare* and *Accumulate* read.
Each row carries **its own picture** beside the numbers -- time, duration, signal count, rate -- 138
pixels wide whatever the pane's width, and as tall as that event's own proportions make it: the
pixels wide whatever the page's width, and as tall as that event's own proportions make it: the
preview is one 44-pixel strip per signal, so a one-signal event is a short row and a six-signal one a
tall one (capped, so that a row stays a row). **Hovering it shows the whole preview at full size**,
through a tooltip -- the small picture says *which* event this is, the zoom says what is in it.
Clicking a row **replays** it: the same loader `--open` uses, on the event's file. The click **leaves
Double-clicking a row on the page (or clicking one in the dock) **replays** it: the same loader `--open` uses, on the event's file. The click **leaves
the list alone** -- your scroll position and the pair you had selected for a comparison both survive
it, because marking which event is on screen rewrites the rows' titles in place instead of rebuilding
the list. **The layout does
@@ -789,7 +899,7 @@ rearrange the user's work under a click. What changes is the data, and the trigg
file has no edge to wait for. Cursors, measurements, math channels and another export all work on a
stored event. **Live** takes you back to the stream.
Two buttons under the pane's header read the **selection**:
Two buttons under the page's header read the **selection**:
- *Compare* puts the two selected events in **two plots side by side**, each with **the layout you
already have** -- the same rows, the same signals in the same slots, the same colours -- one showing
@@ -807,8 +917,8 @@ model whose channel count no longer matches the stream), the trigger is left, an
A replay leaves trigger mode, because a file has no edge to wait for -- an armed trigger keeps a
*frozen capture* on screen (`display_mode()` stays `Held` while the model holds a snapshot), and a
held display draws that snapshot rather than the file that was just loaded. The events pane stays
open through it: it is available while a stored event is on screen.
held display draws that snapshot rather than the file that was just loaded. The dock's Events block and
the `events` page stay available through it: they are available while a stored event is on screen.
**Going back to *Live* puts the live view back as it was.** Opening a file changes two things that
are not the user's choice -- the time window becomes the event's own span, because that is what
@@ -839,7 +949,7 @@ Three things worth knowing:
- **The picture is decoded synchronously** (`gdk_texture_new_from_filename()` into
`gtk_picture_set_paintable()`), not through `gtk_picture_set_filename()`: that path uses the image
loader's sandboxed, asynchronous decoder, which here fails silently -- the picture keeps a
paint-able and an allocation, so a probe reports success while the pane shows an empty rectangle.
paint-able and an allocation, so a probe reports success while the page shows an empty rectangle.
It is then **scaled once to the row's size** and cached by file: a `GtkPicture`'s natural size is
its content, so the stored 480-pixel preview given straight to a row claims the row's whole width
and squeezes the text out of it -- a size request on the picture is a floor, not a cap.
@@ -852,12 +962,12 @@ Three things worth knowing:
replayed samples age out of the ring as the stream refills it.
- **Opening an event, a comparison or going back to *Live* is one re-plan of the buffer**, not one
per channel. Each of those switches rebuilds the signal set, and a rebuild that moves the plan
allocates --- and so touches --- the whole of `--buffer-mb`; enabling a comparison's eight channels
allocates --- and so touches --- the whole of the planned rings; enabling a comparison's eight channels
one at a time was eight of those, which is most of what made the switch feel slow. They are now
enabled in a single call, and a rebuild whose plan is unchanged clears the buffer instead of
allocating a new one.
- The switch in the pane's header stops the recording; the cache is only useful if it was already
on when the event you wanted happened, which is why it defaults to on.
- The switch in the trigger panel starts and stops the recording; the cache is only useful if it was
already on when the event you wanted happened, so switch it on before the event you are waiting for.
## Opening an exported waveform
@@ -886,15 +996,15 @@ Only `.npy` is read: it is the format the item asked for and the one that carrie
export is for other tools.
**A recording longer than the buffer can hold at its own rate is resampled, never cut short.** The
file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-mb`: 20 s of ten
channels at 500 kS/s is 480 MB of rings, which a 200 MB buffer cannot hold. The loader therefore reads
file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-samples`: 20 s of ten
channels at 500 kS/s is 10 M samples a channel, which the default 8.39 M cannot hold. The loader therefore reads
the decimation the plan chose and pushes one stored sample per group of file rows, carrying the
group's **mean** — the same anti-aliased filter the live ingest applies, so a long event opens as the
whole recording at a lower sample rate instead of as a fraction of itself. It used to push every row
regardless, which overran the ring and left the file as **its own tail**: a 20 s event opened showing
only its last 4 s, with a correct-looking 20 s header line above it. The load line now names both
numbers, `samples=31250 rows=1000000 decim=32`, so what is on screen is never in doubt; give the run a
larger `--buffer-mb` (the source's configuration carries one) to see such a file sample for sample.
larger `--buffer-samples` (the source's configuration carries one) to see such a file sample for sample.
## Named configurations
@@ -916,18 +1026,18 @@ measurements — **and the settings that can only be decided at startup**, which
here rather than only on the command line:
```toml
comment = "5 kHz sine, trigger on Ch1, 8 MB is plenty"
buffer_mb = 8
comment = "5 kHz sine, trigger on Ch1, 256 k samples is plenty"
buffer_samples = 262144
relative_time = false
capture_packets = 0
```
The **buffer size** is the one that matters most: two sources on one machine rarely want the same
The **buffer size** (samples per signal) is the one that matters most: two sources on one machine rarely want the same
ring, and the value is applied before the model exists, which is the only moment it can be — by
whichever of the chooser's rows opened the session, **and by `apply_stored_settings()` when the
source was named on the command line**. A saved host's settings are read either way: opening
`udpscope --server 10.0.0.5 --port 8000` gets that source's stored buffer size, clock and capture
ring, and a flag the user actually typed wins over the file (`--buffer-mb`, `--relative-time`,
ring, and a flag the user actually typed wins over the file (`--buffer-samples`, `--relative-time`,
`--capture-packets`) because it is the more recent statement of intent. Switching configurations *inside* a running
app applies everything that can change live (layout, plots, math, measurements, enables) and says
so in the status line about the rest (buffer, clock, packet capture): they take effect on the next
@@ -967,7 +1077,11 @@ applies as it is made — there is no Apply button, and a **Reset to defaults**
dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a
subtitle under it where the setting needs explaining, and the control on the right. A key that is
absent means its default, so the file only mentions what changed, and a missing file is a first run
rather than an error. The directory field shows the directory in use: the default
rather than an error. The **Sample buffer** group holds the *Memory limit (MB)* row (`memory_limit_mb`,
default 8192): the hard ceiling on the ring storage for the whole application, which the planner halves the
sample budget to fit. It is the one planner input that changes while the app runs, so changing it
re-plans the live buffer --- dropping history only if the storage actually changes --- except while a
recording or a replay is on screen, where it is stored for the next plan. The directory field shows the directory in use: the default
is filled in rather than described, and typing it back leaves the setting unset, so it goes on
following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder
button beside it, which writes what it found into the same field -- the field stays the one value, and
@@ -991,6 +1105,9 @@ plots = 1
measure_stats = false
[[plot]]
row = 0
width = 1
weight = 1
signals = ["Ch1", "", "", ""]
[[calibration]]
@@ -999,8 +1116,18 @@ gain = 2
offset = 0.5
```
Each `[[plot]]` table is one **plot** (one cell of the layout), in left-to-right, top-to-bottom order.
`row` is the figure row the plot sits in (0-based) and `width` its share of that row's width; `weight` is
the *row's* share of the figure's height, written on every plot of the row and read from the row's first
one. A session with no `row` keys -- every file written before a row could be split -- loads as one plot per
row, and so does one whose `row` numbers cannot be a layout (a number that skips or goes back, more than 4
plots in a row or 8 in all): the file is never refused for it, and the signals stay where they were. See
"Splitting a row".
It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means
`false`). It is written (400 ms after the last change, and flushed on close) whenever the
`false`) and `dock` (whether the dock is shown, a boolean, absent means `true`; it replaced the
sidebar's `side_tab`, and a file that still carries `side_tab` loads, the key being ignored). The
open slide-over page is not stored: a panel that opens by itself at start-up would be a surprise. It is written (400 ms after the last change, and flushed on close) whenever the
window, the layout, a drop or a calibration changes, and applied once, when the
first CONFIG names the signals -- signal *names* are the identity, because a
CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the
@@ -1145,18 +1272,29 @@ up, the same delay the first-run auto-start uses, the pending request starts the
chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds
them, and this dialog binds none.
**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot,
the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and
**The guided tour is six steps on a `GtkOverlay`** inserted above the window's content: the plot,
the badge bar, the trigger form, the dock, the signals and the status line. There is no popover anywhere in it and
therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below
and either side of the target -- that swallow the clicks that land on them. A stop declares the
**page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named
in `TourStop::page`), and the tour selects that page as the step is entered -- on the frame *after*
the switch, since a stack allocates a new page's widgets a frame late -- so the spotlight lands on a
widget the user had left behind on the other tab instead of on nothing. `gtk_widget_get_mapped()`
decides which steps exist at all: a stop on the *wrong page* is kept, because selecting its page
reveals it, while one whose *pane is collapsed* is dropped, because the stack is unmapped with it
(a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all
`is_visible()` walks -- so the old test walked over surfaces that were not on screen). The chooser's
**slide-over page it stands on** (the trigger step names `trigger`, in `TourStop::page`): the tour opens
that page as the step is entered, and a stop with no page *closes* the panel, so the card never points
at the status line over an open panel. It does not spotlight the form until the panel has finished
sliding in (about 120 ms, during which the surface is dimmed and the card waits in the corner), and the
hole is the target's bounds clipped by every scroller above it -- on the documented 756x470 window the
trigger form is taller than the panel, and the spotlight covers the part that is on screen, not the
badge bar under it. Skip, Escape and the last step close a panel the tour opened (an idle dismissal
leaves one the user opened alone). `gtk_widget_get_mapped()`
decides which steps exist at all: a stop on a slide-over page is always kept, because opening the page
reveals it, while one whose *pane is collapsed* (the dock, in a window too narrow to put it beside the
plot) is dropped, because it is unmapped with its pane (a hidden page keeps its own *visible* flag
true -- the widget's own flag, which is all `is_visible()` walks -- so the old test walked over
surfaces that were not on screen); a narrow window therefore walks five steps. The dock's stop
describes the readouts and their **Edit** buttons and the Events block under them (one click on a row
replays it, **Manage** opens the list); it names no slide-over page, so it closes the panel like the plot's.
There is no stop for the `events` page, so nothing in the tour opens it; the contextual help's
event-history hint is attached to the page's heading the way the cursors' and measurements' are. The cursors and measurements pages have no stop of their own, and their contextual
help "(i)" hints are attached to the pages' headings the way the trigger's and the signals' are: they
are drawn while the page is open, and the dock has none. The chooser's
tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and
stepping off it -- in either direction -- brings the *Sources* page back.
@@ -1167,7 +1305,7 @@ same reason: a frame can arrive between two layout passes, and a hole that empti
the spotlight and the card jump. The card goes under the target, over it, or in the column beside it
-- whichever has room -- and only takes the bottom corner when the target *is* the whole content,
which is what happens to the plot step in the documented headless run (a 640x480 Xvfb screen gives
the window its 756x894 minimum, and at that width the split view overlays the side panel instead of
the window its 756x894 minimum, and at that width the split view overlays the dock instead of
putting it beside the plot). The probe prints the two rectangles (`spot=`, `card=`) rather than
asserting a placement, so that case reads as the geometry it is.
@@ -1203,11 +1341,11 @@ allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value
ring per channel (N enabled signals -- see the limitation on multi-element signals
below). The spec's second buffer is the latched `EventSnapshot`, which is already
an immutable copy of the captured window (see "Trigger" below); a second live ring
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see
bought nothing and cost half the memory, so the planned bytes the status line quotes are
the whole of it. What it *would* buy is priced in the dump's `publish cost:` line — see
"There is one live buffer, not two" under the review findings.
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
them with the rest of the budget. When a channel's declared sample rate is lower
`plan_partition()` sizes the rings to the sample budget (rounded down to a power of two, halved
until it fits the memory limit) and then decimates the shared clock until `--max-window` seconds fit. When a channel's declared sample rate is lower
than the fastest channel's, it is decimated by the integer ratio
(`Partition::stride`); when the requested window does not fit the budget, the shared
clock is resampled too (`Partition::time_stride`, an averaging decimation — see
@@ -1393,8 +1531,8 @@ tick and 12.8 ms of draw a frame and 4.9 MB uploaded** on llvmpipe, and reads **
5.6 ms and 1.0 MB** at the cap.
**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so
the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at
most as much again as the buffer does, which is the bound a long window on a fast stream reaches
the retention is bounded by two things: the depth, and **the planned buffer** — persistence may hold at
most as many bytes again as the rings take (`persist_budget_bytes()`), which is the bound a long window on a fast stream reaches
long before the eighth layer. The other bound is the plot's own: a layer takes a *free trace slot
in the row that plots its signal*, layer by layer, so a row of four signals — the layout the app
starts with — has room for one older acquisition of each, while a row per signal has room for all
@@ -1558,9 +1696,10 @@ it holds more than the window the bound became the whole ring, and a zoom-out wa
view past the window into data the window was never set to show. A partly filled window
simply has an empty left-hand side.
**The panel switches are on the toolbar, and the panes own their own controls.** Cursors,
Measurements and Math channels are icon toggles in the header bar rather than rows in the overflow
menu, and **Reset cursors** sits in the cursors pane beside the mode it resets (it acts on that
**The panel switches are on the toolbar, and the panes own their own controls.** (Since the
slide-over redesign the toolbar keeps only the **Cursors** toggle of the three icons named here, and it
only enables the cursors; the measurements are reached by `m` or the dock's Edit button, and the math
editor from the signal list.) **Reset cursors** sits in the cursors page beside the mode it resets (it acts on that
pane's instruments and is only visible while they exist). The rows read, left to right: **window control, trigger, pause** — the trigger and the pause are
read against the span on screen, so they sit with the control that sets it — then, on the right,
**cursors, measurements, math, sidebar** and **the menu last**. With `HeaderBar::pack_end()` the
@@ -1610,7 +1749,7 @@ enabled only to feed a math channel.
several, and there was no way to see which sources the app already knew about. The chooser lists
one row per stored session: the server and port it was named after, how many plots and signals it
held, and its multicast group when it had one, newest first. `New host…` is a four-field form —
server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning
server, port, multicast group, interface — and nothing more: `--buffer-samples` and the rest are tuning
rather than "which source", and a form that asks for them is a form you have to read before using
the app. Cancel exits before anything is allocated, because declining to choose a source is not a
failure.
@@ -1814,7 +1953,7 @@ fixes above bound *how often* and *how much per sample*; what is left is the ari
scale: a pass is priced per measured **channel** (one fetch of the window's level-0 buckets, one
statistics scan) and per **distinct share** in the pane (one level accumulator, walked over those
buckets), and the window's own sample count multiplies both. Measured with the new
`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-mb 4000` (so `time_stride`
`[levels-probe]` sweep on the mock at a **15 s** window and `--buffer-samples 100M` (so `time_stride`
stays 1 and the window holds 11.8 M samples, 184 k buckets), four signals, one row, on llvmpipe
— `measure cost:`'s `each` per recompute, and the tick's mean:
@@ -1957,9 +2096,9 @@ a sample and runs out on a clock. Widening only the clock keeps the cost where i
the rings are the memory budget, and the values are the overwhelming majority of them. The
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
slots, because a time slot and a value sample are now different sizes — it weighs the
budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
budget in **bytes** against the memory limit (`sizeof(f64)` for the shared clock plus `sizeof(f32)` per
channel); the sample budget itself is a count of ticks, so a three-channel stream at
`--buffer-samples 10M` retains the same 8.39 M ticks it did before. `EventSnapshot::stamps` is
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
stream time, which quantised at exactly the rate the live ring did.
@@ -2224,7 +2363,7 @@ exists; it is there rather than on the chip because a second gesture on a one-gl
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
chip cannot become the start of a row drag.
chip cannot also activate the row (and open its page).
A `GtkSwitch` and two
always-visible buttons made a row about twice as tall (46 px against 26 px now),
@@ -2233,7 +2372,7 @@ button and list-row minimums, not the widgets themselves, set that height, so th
row carries a `.udpscope-compact` style class that relaxes them.
**Signals start disabled**, because enabling one is what starts acquiring it.
Enable a signal with its checkbox, or drag it onto a plot: the drop enables the
Enable a signal with its checkbox, or drag its badge from the badge bar onto a plot: the drop enables the
signal if needed, binds it to that row's first free trace slot, and rebuilds the
figure so the legend name is right (there is one widget, so the row a drop lands
on is `PlotArea::row_at()`). The status line reports the outcome of a drop
@@ -2440,7 +2579,9 @@ out of the plot **while a gesture is on that row**, because a drag writes the *p
the only place it is visible. Outside a gesture the plot is not read at all: `cursor.x` is the
snapped value then, which is what the app wrote itself a frame earlier, and adopting that back
is what made a dragged cursor spring to where it had been. A ruler is per row: each row has its own y axis, so two rows' rulers are
two different values, and the pane names the row.
two different values, and the pane names the row. In a split row (see "Splitting a row") the vertical pair is drawn in
every plot, and so is the horizontal pair: the plots of a split row share one y axis, so the pane lists one ruler
line for the whole row, and dragging a ruler in any plot moves it in all of them.
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing
@@ -2602,7 +2743,7 @@ This reverses the earlier "the app does no decimation at all" decision, and keep
reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a
full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data.
The headless check is the same aliasing case with the filter in place: at
`--buffer-mb 1 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s
`--buffer-samples 65536 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s
stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly
the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost.
@@ -2633,8 +2774,9 @@ full range — the snapshot's own span, which is legitimately *narrower* than th
when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it
back onto the stream.
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
**Shared x axis.** The plots are the plots of **one `plot::Figure`** (one per row, or several side by
side in a split row, see "Splitting a row"), drawn by one `plot::gtk4::PlotArea` in figure mode, and they
all join a single x link group.
A gesture changes the row it landed on; `Figure::layout()` adopts that change
into the group and pushes it to the others, so panning or zooming any row moves
them all with nothing to propagate by hand. The live follow is the *group's*
@@ -2648,8 +2790,8 @@ The self-check's layout line carries `x-desync N`, the number of frames in which
any row's x range differed from row 0's by more than a pixel of data. **It must
read 0.**
`Figure`'s row count is fixed at construction, so there is one structural path:
a changed row count, or a changed legend name (the one thing a `Plot` cannot
`Figure`'s shape (its cells per row) is fixed at construction, so there is one structural path:
a changed row or cell count, or a changed legend name (the one thing a `Plot` cannot
express), builds a new figure and the area is handed it. That costs no GL state —
the widget keeps its context. A displayed event survives the rebuild because the
pinned `[-pre, +post]` window is restated rather than re-derived from the live
@@ -2680,6 +2822,44 @@ above row 2" are the same insertion, and two buttons there would sit one on top
other. The `rows + 1` boundary buttons — above the first row and below the last included —
reach every position the two-per-row reading offers.
### Splitting a row
A row can be split into **two to four plots side by side**, up to **eight plots in all**. A split is
only an arrangement of more plots on the screen: **every plot shares the one time window** -- one x
link group, so a pan or zoom in any plot moves them all; one crosshair, drawn at the same time in every
plot; and one pair of vertical cursors, at the same two times in every plot. The plots of a row
also share **one y range**, the way the rows share one x window: the row has one y axis (auto or fixed),
drawn with its labels at the left of the first plot only, one pair of horizontal rulers drawn at the same
value in every plot, and one fit. A wheel, a drag, a box zoom, **Fit all** and the Auto/Fixed dialog act on
the whole row whichever plot they land in, and an auto-scaled row fits the union of its plots' traces (a plot
with none is ignored). The **bottom** row's plots carry the time labels, and so does any row above it whose
columns differ from the row below's (the `t0 = ...` caption goes with the labels, on each plot that draws
them); a row whose columns match the next one's leaves its labels to it. The left margin
holding the y labels is one width for the whole figure, so the plots of a row line up with the rows
above and below it; a plot whose labels are narrower simply has more margin. Only the first plot has a left
margin and only the last a right one, so the plots of a row get equal plot areas at equal weights and
neighbouring plots are exactly one column gap apart.
In the **Layout editor** (`l`):
- A **`+` at a row's left and right edge, and one between each two plots**, splits the row there: a new,
empty plot is added at that position and the plots after it move right *with their traces*. A one-cell
row can be split from either side. The `+` is dim at 4 plots in the row or 8 plots in all (the row `+`,
which adds a whole row, dims at 8 plots).
- A **double-headed (left-right) arrow between two plots** resizes the two neighbours against each other; neither can be
dragged below about 96 px. The plotter keeps 64 px as an absolute floor, and when the window is too
narrow for that it shrinks every plot proportionally and drops the gap, so nothing has a negative size.
The widths are saved when the drag ends.
- The **`×`** at a plot's top left removes that plot (and its traces) from a split row, and removes the
*row* from a one-plot row, as it always did. It is dim when that would leave no plot.
Plots are named by their row and, in a split row, a letter: **`1`**, **`2a`**..**`2d`**. The names are
what the signals page's *Plot on* buttons (tooltip "row 2, cell 1 of 2"), the status notices ("plotting
'sine' on plot 2b") and the cursors pane (one ruler line per figure row, `row 2` counted from 1 for a split row; a plot of an
unsplit row stays `row N`, counted from 0 as before) show. A signal dragged from its badge lands on the plot under the
pointer, with the same free-slot rule as a row. The session file keeps the arrangement (`row` and `width`
on each `[[plot]]`, see "The session layout file").
The buttons are `GtkOverlay` children placed by their margins over the figure's own row
rects (`FigureLayout::rows`, divided by the area's device scale), deliberately **not** one
transparent widget covering the plot: an overlay child is picked wherever its allocation
@@ -2774,12 +2954,13 @@ reaches every widget). The full key table:
|---|---|
| `a` | arm / disarm the trigger |
| `Space` | pause / resume |
| `t` | show / hide the trigger panel |
| `c` | show / hide cursors |
| `m` | show / hide measurements |
| `g` | show / hide math channels |
| `t` | enable / disable trigger mode |
| `c` | enable / disable the cursors |
| `m` | open or close the Measurements page (the `measure` slide-over page; the dock's Edit button for the cursors opens the `cursors` page) |
| `g` | go to Signals (open or close the signal list, the `signals` slide-over page, also reached by the `+` badge) |
| `l` | show / hide the layout editor |
| `s` | show / hide the side panel |
| `s` | show / hide the dock |
| `Esc` | close the slide-over panel (the guided tour takes it first) |
| `f` | fit to data (the plot's own key, not an application accelerator) |
| `Ctrl+E` | export visible range |
| `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer |
@@ -2867,7 +3048,7 @@ now — they are listed so they are not mistaken for anything else:
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
what a second ring would buy is a smoother frame at a latch, not recovered packets.
- **The buffer is allocated at the first CONFIG, not at initialisation.**
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-samples` is the
*budget*, and the power-of-two ring rounding means the final
allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG,
enable/disable and calibration re-partitions (and drops the history) because the
@@ -2891,30 +3072,58 @@ now — they are listed so they are not mistaken for anything else:
progress. What the spec was protecting still holds — a *held* event is bound to its
snapshot alone, and the axis is the event's own window from the edge onwards rather
than a sliding live view.
- **The sidebar is two tabs, and a math channel is a row of the signal list.** `SPECS.md`
names the panes as separate collapsible sections — trigger, cursors, measurements, math,
signal list — in one scrolling column; with four of them open in a 320 px sidebar every
section was short of room, and finding one meant scrolling past the others. The built
sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals**
tab (the trigger and the channels), **each a text tab** (the sidebar was icon-only for a while; glyphs for "cursors and ruler" and
"trigger and channel list" were not understandable enough, so the words won). A source kind's own tabs
(the E-1608's **Analog out** and **Digital I/O**) sit beside them as text too. Both panes also have a **locked** mode, which is what a returning user gets: the
trigger pane shows one line saying what it is set to, the signal list shows only the signals that are
on, and neither offers a control that changes the configuration — each heading has an `[edit]` button
to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling
plots is what the list is for, not configuration — and a locked row can be dragged from anywhere on it,
the colour chip included. In a window too narrow to show both, where the sidebar is *overlaid on* the
plot, the sidebar steps aside for the length of the drag so there is something under the pointer to drop
onto. A **first run** (no session file for that source) comes up
- **The sidebar is one dock, setup is a slide-over, and a math channel is a row of the signal list.**
`SPECS.md` names the panes as separate collapsible sections -- trigger, cursors, measurements, math,
signal list -- in one scrolling column; with four of them open in a 320 px sidebar every
section was short of room, and finding one meant scrolling past the others. The sidebar was then two
text tabs (a **Measure** tab and a **Signals** tab, with a source kind's own tabs beside them), and four
tabs did not fit 320 px. It is **one dock** now, with no tab switcher: the **readouts** (a compact,
read-only copy of the cursors and of the measurements, each block with an **Edit** button), shown from
the start and insensitive until the stream has signals; the full cursors and measurements panels are the
`cursors` and `measure` slide-over pages (`m` toggles `measure`); the **signal list** is the `signals` slide-over page (`g`, or the `+` badge). The **trigger form** and a **source's own
controls** are pages of the slide-over, reached from the badge bar. The trigger pane also has a **locked** mode, which is what a returning user gets: it
shows one line saying what it is set to and offers no control that changes the configuration -- its
heading has an `[edit]` button to switch to the full pane, and back. The signals have no lock: the
signals page only proposes what can be added, and the signal pages always allow editing. A signal's badge is dragged onto a plot, or onto another badge to
reorder the badges (the order is kept for the session only); the slide-over closes for the length of the
drag so there is something under the pointer to drop onto. A **first run** (no session file for that source) comes up
unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the
header bar's button — clicking either moves both — because a pane that is *disabled* while its
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The
switcher's policy is `wide` and each page has a title. With the math
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no
header bar's button -- clicking either moves both -- because a pane that is *disabled* while its
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. With the math
list folded into the signal list under a `MATH` divider whose `+` creates a channel -- a derived channel already *is* a channel in
the model, so the two lists were the same list drawn twice. No
toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and
trigger-off leaves the trigger panel fully editable — a trigger is configured *before* it is
switched on, and the locked view says `stream (trigger off)` instead of the settings — and the measurements' and math panes' toolbar toggles are gone with the panes.
trigger-off leaves the trigger form fully editable -- a trigger is configured *before* it is
switched on, and the locked view says `stream (trigger off)` instead of the settings -- and the measurements' and math panes' toolbar toggles are gone with the panes.
- **The badge bar / slide-over / dock redesign is in progress; steps 1-3 are done, and they deviate
from `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` in these known ways.**
(1) The session file gains the dock's *shown* state only, not a width -- the dock's width is
fixed (**280 px**, as the spec says), so there is nothing to persist -- and does not store the open
slide-over page. (2) The **Trigger badge** shows `off`, `armed` and `triggered` as the spec says, and
two more words, `capturing` (a post-trigger window is filling) and `disarmed` (trigger on, not
armed), so the label never claims a state the engine is not in. (3) The **`cursors` and `measure`
pages hold the existing panels unchanged** (the cursors table with its mode, its Reset and its
switch; the measurement list with its `+`, statistics and per-row edit), so the pages carry the
panels' own controls, not a new form. (4) The **measurement editor stays a dialog**: the spec has the
measurement add/edit/remove inside the `measure` page, but the dialog (`open_measure_dialog()`) is
what the `+`, a row's edit button and a click on a dock line all open; the page lists the measurements
and hosts the dialog's entry points. (5) A **dock cursor line carries the full who / A / B / delta
cells** (not one value per line as the spec sketches), in a 5 + 3 x 9 character fixed-width grid: the
**who** cell ellipsizes at **5 characters** -- the width the 280 px pin allows; with 8 the readouts'
minimum width was 304 px and widened the sidebar -- and its tooltip carries the full text. (6) The dock and
the pages **share one string** for every value (see `docs/claude/invariants-readouts-and-cursors.md`).
(7) The **Events block and the `events` page are available** while recording is on, a replay is on screen, or
the cache holds events -- **not** "outside trigger mode" as the spec has it, because a replay or a group leaves
trigger mode and the block would grey itself under the click that started it. (8) The **dock's event rows carry no
thumbnails** (the page's do) and show a **hollow marker on the members of the group on screen**, which the page's
rows do not. (9) The dock lists at most 24 events; the page lists them all.
The signal badges, the signal pages and the drag (step 2) are done, and so are the events block in the
dock and the `events` page (the left events pane, its divider and the header's Events toggle are
deleted), the Mallard manual, `USER-GUIDE.md` and the two figures that show the window
(`main-window.png`, `trigger-capture.png`, regenerated with `tools/make-help-shots.sh`). The
manual's `math-editor.png` is the editor's own window and was not regenerated.
The main-window figure shows `'sine' is off: 1 trace left the plots` in the status bar and no sine
badge: the headless enable-all probe that drives the scene ends that way, and the figure records it.
- **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot`
cannot give up a trace and the slots are therefore created up front.
- **One toggle drives both cursor orientations.** The vertical markers and the
@@ -3008,6 +3217,10 @@ silence timeout and then resumes, which is the EMI case end to end.
rather than rebuilds, and why the frame tick reads the markers back: a parked
cursor's snapped position is a real number, so nothing may be inferred from it. The
trigger marker is separate: its own cursor, independent of the cursor toggle.
- **The slide-over shows a form taller than the plot by scrolling it.** The panel is as tall as the
plot and never taller, and each page has a scroller of its own, so on the documented 756x470 window
the trigger form (about 474 px) shows 253 px of itself. The tour's spotlight is clipped to that, and at
that size the card of the trigger step overlaps the panel's edge by about 18 px (`clear=0`).
- **Verified headless only.** Acquisition, partitioning, the trigger state
machine and its integration with the buffers, and rendering under Xvfb
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
@@ -3045,7 +3258,7 @@ silence timeout and then resumes, which is the EMI case end to end.
- **The tour card can cover its own target, and in the documented headless run it
does.** The card is placed under, over or beside the hole whenever one of those has
room; when the target covers the whole content -- which is what the plot does at the
window's 756 px minimum, because the split view overlays the side panel rather than
window's 756 px minimum, because the split view overlays the dock rather than
putting it beside the plot -- no placement can avoid it, and the card takes the
bottom corner. The probe prints both rectangles instead of asserting a placement,
so the case reads as a measurement.
+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
+100 -9
View File
@@ -36,7 +36,7 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
`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 `side_stack`, named `source-<id>`. A disconnected source
- 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
@@ -51,7 +51,7 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
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 20 ms waveform tick. There is no second thread. A connect attempt (discovery,
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.
@@ -91,11 +91,99 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
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.
@@ -171,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
+53 -3
View File
@@ -101,8 +101,10 @@ component, so its behaviour is unchanged.
Options: `--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings;
`--server`, `--port`, `--multicast` and `--interface` populate the UDPS entries of the same options),
`--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
`--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).
@@ -118,7 +120,7 @@ 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,
`mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all
`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
@@ -267,6 +269,54 @@ 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
+3 -3
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
+124 -65
View File
@@ -50,21 +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.
- **Five 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}-symbolic.svg`
are the five 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.
The sidebar's **tabs have no icons any more**: two glyphs (`udpscope-tab-measure-symbolic`,
`udpscope-tab-signals-symbolic`, each a 32x16 pair) were drawn for them and judged not understandable
enough, so the tabs are text and both SVGs were deleted. The *trigger's* own icon was redrawn to the
Signals glyph's picture at that time and kept it.
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"`,
@@ -215,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.
@@ -225,43 +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.
- **The sidebar's tabs are text: `AdwViewSwitcherPolicy.wide`, a title on every page, no icon.** The
built-in pages get their titles in the `.ui` and a source kind's pages from `SourcePanel::title`
(`adw_view_stack_add_titled`); the page's *name* still identifies it to `select_side_tab()` and the
session file. An icon-only version existed (policy `narrow`, empty titles, the pills named in code by
`name_side_tabs()`, because `narrow` still draws a label beside an icon); it was reversed because the
glyphs were not understandable. A page added with an empty title is a blank pill, and
`switcher: … pills=N labels=N pages=N` reads it as `labels` below `pills`.
- **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
@@ -273,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
@@ -296,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
@@ -589,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,
@@ -603,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
@@ -620,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
@@ -658,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.
+2 -2
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
@@ -334,7 +334,7 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b
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-mb`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
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.
+1 -1
View File
@@ -121,7 +121,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
- **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-mb 4000` (so `time_stride` stays 1 and the
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`:
+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
+43 -13
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,6 +51,27 @@ 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".
- **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
@@ -139,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
@@ -150,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.
+19 -10
View File
@@ -356,7 +356,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`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 ring's own `--buffer-mb`, because persistence is a copy
`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
@@ -440,15 +440,24 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
## 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
+492 -151
View File
@@ -29,7 +29,7 @@ Two lines of the self-check output are assertions in disguise:
window slides over a round number. The companion `axes.w changes: N (last at frame F,
side panel up=S)` line separates the two readings: `0`, or a handful near the start,
is a settled layout; a count near the frame count is the twitch. Runs far longer than
the documented probes have shown one ~320 px step — the side panel's width — that
the documented probes have shown one ~320 px step — the side panel's width, when it was 320 px — that
reverts; it is intermittent and unexplained, so read `changes` before calling a range
the twitch bug.
- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr,
@@ -75,7 +75,7 @@ Two lines of the self-check output are assertions in disguise:
a live axis narrower than the requested window is the same symptom from the other
cause (see the `clamp_to_data()` invariant in
[invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
show up, so run them under `--buffer-samples 65536`, which wraps in a few seconds.
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
`want` and `N` must be exactly the window times the stream's real rate, plus one
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
@@ -206,10 +206,10 @@ Two lines of the self-check output are assertions in disguise:
decimation is for, and a `buffered` short of the window is the old "the control is capped
to what the ring holds" behaviour coming back. `decim` must be a power of two,
`stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it
under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s
decimate; at 200 MB nothing inside `--max-window` needs to.
under `--buffer-samples 65536`, which saturates in a few seconds and makes windows past ~6 s
decimate; at the default 10M nothing inside `--max-window` needs to.
- **The anti-alias check** is the same probe run against a waveform above the stored
Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
Nyquist: `--buffer-samples 65536 --freq 2000` with the probe ending on a 60 s window gives
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
@@ -424,13 +424,23 @@ Two lines of the self-check output are assertions in disguise:
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
back as the zoomed range one frame later.
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
`side_w` is the sidebar column's width and **must be its own request (320), never 0**: it read 0 while
the divider sat at the window's right edge, which is a panel that is not there. It was 320 during the
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] resize=[..] traces=[..] side_w=S cells=[..] colweights=[..]`
`side_w` is the sidebar column's width and **must be its own request (280), never 0**: it read 0 while
the divider sat at the window's right edge, which is a panel that is not there. It was 280 during the
first frames and 0 once the window had settled, so read it from a `placed` line rather than the first.
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
buttons exist only while the editor does. `done` follows the same rule for the editor's
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `3P + 2F + 2`** while the editor is
up, for `P` plots in `F` figure rows: one `×` per plot (`P`), one row `+` and one row resize arrow
per row boundary (`2(F+1)`, the arrows of the figure's two outer edges counted although hidden), and
per row of `n` cells `n + 1` cell `+` buttons and `n - 1` column arrows (`3P` in all, since
`sum(n+1) + sum(n-1) = 2P`, plus the `P` of the `×`). With no split that is **7 for one row and 12 for
two** (`5` and `8` before the split, the old `2N+1` having long stopped counting the arrows), and a
split row adds three per extra cell. **0 on the `editor off` line**: the
buttons exist only while the editor does. `rows=` is the plot count and `cells=[..]` the cells of each
figure row, comma-separated (`[1,1]` for two unsplit rows, `[1,2]` for a split second row);
`colweights=[..]` is each figure row's cell weights, `|` between rows and **empty for a row with none**
(every unsplit row, and a split row nobody dragged), so two unsplit rows read `[|]` and a dragged
`{3,1}` second row `[|3,1]`. An unsplit run must read `cells=[1,..,1]` and a `colweights` of
only separators; anything else is a split the run did not ask for. `done` follows the same rule for the editor's
own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the
area's top-right corner: `x + W` within a few pixels of the area width and `y` within a
few of 0 (the on-switch line reports a box from before the first frame, so read the
@@ -478,17 +488,36 @@ Two lines of the self-check output are assertions in disguise:
those means the resource path was never registered. That failure is otherwise *silent*: GTK
substitutes a missing-image placeholder, the button still works, and every other line of the
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
is the only check that it names something that exists.
- `switcher: policy=wide pills=4 labels=4 pages=4 size=308x34` (in the exit dump, beside `panels:`) — the
sidebar tabs as the switcher actually draws them, and they are **words**: `labels` counts *mapped labels
with text*, so `pills=labels=pages` says every page has a visible title. `pages` is read from the stack
(2 for UDPS, 3 for the probe source, 4 for the E-1608 with its Analog out and Digital I/O tabs). A
`labels` below `pills` is a page added with an empty title; `pills=0` means nothing is mapped, which
makes the rest of the line meaningless rather than a pass. `policy=wide` is read rather than assumed.
`size=` is the bar's own size: a text tab costs about 34 px of height, and a width of 308 shows the
four tabs fit the 320 px column.
- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit
dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal
is the only check that it names something that exists. The line ends with `resize_rows=udpscope-resize-rows-symbolic(ok)
resize_cols=udpscope-resize-cols-symbolic(ok)`: the two layout-editor handle icons, which no toolbar button carries but
which are the same resource and fail the same silent way.
- `dock: shown=1 sensitive=1 w=280 scroller_pages=1` (in the exit dump, beside `panels:`) -- the sidebar's dock
as the last tick found it. There is no tab switcher: the sidebar is one scroller (`dock_scroller`) holding the
**readouts box only** -- a compact, read-only copy of the cursors and of the measurements, each with an Edit
button (`build_readouts_dock()` in `src/ui_dock.cpp`). The signal list, the full cursors table, the measurements
and the trigger are slide-over pages. **`shown=1`
always** (the dock is on screen from the start; `shown=0` only when the user hid it with the Dock button,
which a probe does deliberately); **`sensitive=0` only before the stream has signals** ("disable, do not
hide") -- a run against a port with no sender reads `shown=1 sensitive=0`, a normal run `sensitive=1`;
`w=` is the sidebar's own width (**280**); **`scroller_pages=1`** counts the scrollers directly under the dock
column and says there is one scroller and no stack. These are captured on the tick or at the exit probe:
the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels
are slide-over pages (`source-<id>`), so the line reads the same on the E-1608 and the probe kind.
- `[readouts] pages cursors=1 measure=1 dock_children=3 dock_w=280 pages_sensitive=1 signals=1` (in the exit
dump; `dock_children` counts the direct children of the dock page) -- the two pages and the dock against the
stream. **`cursors=1 measure=1`**: both slide-over pages are registered, always and from the start (not when
the stream has signals); `0` is a page that was never added, and then the dock's Edit buttons and the `m` key
open nothing. **`dock_w=280`** is the sidebar's width, as for `dock:`. **`pages_sensitive` must equal
`signals`**: both pages are insensitive until the stream has signals ("disable, do not hide";
`update_signal_availability()` writes them with the dock), so a run against a port with no sender reads
`pages_sensitive=0 signals=0`, a normal run `pages_sensitive=1 signals=1`. `pages_sensitive=1` with `signals=0`
is a page that can be opened and read with nothing to read; `pages_sensitive=0` with `signals=1` is a
stream whose pages never came back after the signal list arrived. **`dock_children=3`**: the dock
page holds the readouts box, a separator and the Events block (`build_events_dock()`) and nothing else (a fourth
child is a section that crept back into the dock; 1 is a dock without its Events block, 0 a dock that was built
empty).
- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=6 step=0 auto=1 requested=0` (in the exit
dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal
the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window
itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt
on every opening, so it is not one of the window's own). Four of the eleven are not a pane's *heading*
@@ -532,14 +561,18 @@ Two lines of the self-check output are assertions in disguise:
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). The writer chains to
`g_log_writer_default()`, so every message still reaches stderr exactly as before and the documented
baselines are read the same way.
- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit
- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… badge_bar=… dock=… window_button=… status_label=… header_bar=… plot=…` (in the exit
dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated
figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is
measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see
[verification.md](verification.md)) and with `gtk_widget_is_visible()`, not `get_visible()`: a widget
under a hidden parent — `trigger_summary` while the pane is in *configuration* mode, the Measure page
while the Signals tab is showing — keeps its own flag true and reads `0x0`, and a crop box drawn from
that is a box over the wrong thing. The figures are produced by cropping a real screenshot, so these
[verification.md](verification.md)) and with `gtk_widget_is_visible()` **and** `gtk_widget_get_mapped()`,
not `get_visible()`: a widget under a hidden parent — `trigger_summary` while the pane is in
*configuration* mode, and **every `trigger_*` surface while the slide-over is closed** (a revealer
unmaps its child without touching its visible flag) — keeps its own flag true and reads `0x0`, and a crop
box drawn from that is a box over the wrong thing. So the three `trigger_*` rectangles read real numbers
only while the `trigger` page is open (the exit probe has it open; `UDPSCOPE_PROBE_HELPUI_MS` opens it for
its figures legs), and `-1,-1,0,0` otherwise. `badge_bar` is the badge row under the plot and `dock` the
right-hand column (these replaced `side_stack`, the old sidebar stack). The figures are produced by cropping a real screenshot, so these
numbers **are** the crop boxes; their values depend on the window's size, which under the documented
`xvfb-run` is the window's 756x894 minimum (the 640x480 screen is smaller than the content), so a run
on a larger screen reads different numbers for the same panels. `trigger_summary` reading
@@ -640,7 +673,7 @@ Two lines of the self-check output are assertions in disguise:
than by the first-run timer.
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
with a drop and a window change, then again with no interaction: the second run's dump
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...] ... cells=[..] colweights=[..]` with the
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). A **persistence
@@ -757,7 +790,7 @@ Two lines of the self-check output are assertions in disguise:
it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a
`ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at
the price of a second budget's worth of memory (README records that one live ring is why
`--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line
the planned bytes are the whole memory cost) and a second per-sample write on the producer. What the line
says about that trade is that the stall is real but **costs no packets**: an A/B of the same run
with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a
second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run
@@ -770,7 +803,7 @@ Two lines of the self-check output are assertions in disguise:
for the window**, one fetch and one scan per *measured channel*; **`L` is its price for the
pane's shares**, one accumulator per distinct one, walked over the window's buckets; and the
window's samples are the multiplier on both, so a sweep at one scale says nothing about
another. Measured at the user's scale (15 s window, `--buffer-mb 4000` -> `stride=1`, 11.8 M
another. Measured at the user's scale (15 s window, `--buffer-samples 100M` -> `stride=1`, 11.8 M
samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe),
with `UDPSCOPE_PROBE_LEVELS_CHANNELS` set:
* `50`, 1 channel -> `1 window, 1 level`, `measure cost: 226 recomputes, 6.58 ms each`, `raw=0.1%`,
@@ -876,7 +909,7 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
it afterwards.
- `[helpui] auto_started=1 visible=1 steps=5 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) —
- `[helpui] auto_started=1 visible=1 steps=6 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) —
the help affordance layer end to end, in ten steps 300-400 ms apart (every reading is an
*allocation*, and a step that read its own predecessor's geometry would read a rectangle from before
the frame that placed it). The line above is the **first-run auto-start**, read before the probe
@@ -908,21 +941,33 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of
removing them. The count comes back to the same twelve, which is the other half — a toggle that
removed and could not restore is a one-way switch.
- `[helpui] tour started from the probe: visible=1 steps=5 step=0` — the tour started the way
`Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose surface is on screen or
selectable into it (`gtk_widget_get_mapped()` -- a wrong *page* is kept, because selecting its
page reveals it, while a *collapsed pane* is still dropped; see the surface bullets in
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)).
- `[helpui] step=1/5 visible=1 side_tab=signals spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C`
— **in window coordinates**, the frame a screenshot is cropped in, with the sidebar's page beside
it (a stop the walk had to change the page for is readable as such; see `[toursurface]` below).
**`contains` must be 1** on every step: the spotlight is the target's own rectangle grown three
pixels. `clear` is the card missing the target, and it is 1 wherever the window has room for the
- `[helpui] tour started from the probe: visible=1 steps=6 step=0` — the tour started the way
`Ctrl+T` starts it. `steps=6` is the plan: the table's six stops (plot, badges, trigger, dock,
signals, status) whose surface is on screen or can be opened into view. A stop that names a
slide-over page is always kept (the `on_page` hook opens it whatever the dock is doing), a target
that is mapped is kept, and a stop in a *collapsed dock* is dropped (`gtk_widget_get_mapped()`; see
the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)).
- `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C`
— **in window coordinates**, the frame a screenshot is cropped in, with the slide-over's open page
(`none`, or a page id) beside it; see `[toursurface]` below. `raw=` is the target's own rectangle,
unclipped; `target=` is the part of it that is *on screen*, which the probe computes **itself** by
intersecting `raw` with the bounds of every scrolled window above the target (not through
`tour_target_bounds()`, which the spotlight is drawn from -- reading the spotlight against its own helper
would only ever agree with itself). A form taller than the panel's scroller on a short window reads
`raw` taller than `target`, and is spotlit down to the scroller's edge and no further; the trigger stop at
the documented window reads `raw=[279,143 343x474] target=[279,143 343x253]`.
**`contains` must be 1** on every step: the spotlight covers the visible part (the part grown three
pixels), or is empty when none of the target is visible (a target scrolled out of its scroller gets no
hole at all, and not the hole of the frame before). **`inside` must not be 0**: it is the spotlight
staying within the nearest scrolled window above the target (grown by the same three pixels); `-1` is a
target with no scroller above it (the plot, the badge bar, the dock, the status line), `1` the trigger
form and the signal list. `inside=0` is a spotlight that spills out of its scroller over whatever is
under it. `clear` is the card missing the target, and it is 1 wherever the window has room for the
card beside or above/below the hole — at the documented run's window (see `help figures:` below)
the plot's step is *the whole content*, because at 756 px the split view overlays the side panel
the plot's step is *the whole content*, because at 756 px the split view overlays the dock
rather than putting it beside the plot, so `clear=0` there is the geometry and not a placement
bug. The side panel's step reads `clear=1`.
- `[helpui] after Next: step=2/5 visible=1`, `[helpui] after Back: step=1/5 visible=1`,
bug. The badges step reads `clear=1`.
- `[helpui] after Next: step=2/6 visible=1`, `[helpui] after Back: step=1/6 visible=1`,
`[helpui] after Skip: visible=0 dims_hidden=1 spot_hidden=1 card_hidden=1 controllers=12 (was 12)
click_gestures=2` — the three controls. **`controllers=` equal to `(was N)` is the "no lingering
input grab" reading**: a tour of ordinary overlay widgets takes no grab at all, and a count that
@@ -933,7 +978,9 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
trigger pane's modes, by clicking its [edit] button. This exists because one of the help's figures
is the *locked* pane's summary grid (`trigger_summary`), which in configuration mode is not on
screen at all and reads `-1,-1,0,0`; the two lines give the geometry agent both states' numbers
rather than leaving them to find the mode. `trigger_panel`'s height differs between them (508 px in
rather than leaving them to find the mode. The probe opens the slide-over's `trigger` page for these
legs (and for the collapsed-search leg after them) and closes it at the end: with the panel shut
every `trigger_*` reads `-1,-1,0,0`. `trigger_panel`'s height differs between them (532 px in
configuration mode against 72 locked — the body is replaced by the summary), which is the check
that the mode really moved.
- `[helpui] collapsed search: expanded=0 mapped=1 mapped_header=1 hint=12x12` (the last step, in
@@ -943,34 +990,44 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
read `mapped=0` (and `0x0`), which is exactly why the header box exists (`search_head` in
`src/ui/trigger-panel.ui`) and why the hint joins it as its last child. `mapped_header=` is the
header box beside it, and `hint=WxH` is the same 12x12 the layer's own line reads.
- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) — a stop that stands on a page it is
not on, entered from the page it is not on. It puts the sidebar on **Measure**, starts the window's
tour, and walks it a stop per tick (400 ms), because a stack allocates the new page's widgets on
the frame *after* the switch — so each reading is of the page the step left standing, not of the
one the switch has just left behind. The lines:
- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that opens a surface it is not
on, entered from the state it is not in. The probe opens the `trigger` slide-over page, starts the
window's tour from there and walks the whole table a stop per tick (400 ms, past the panel's 120 ms
slide); the `on_page` hook is what opens the page for the trigger stop and closes the panel for every
stop that names none. The lines:
- `[toursurface] before: side_tab=signals signal_list_mapped=1 running=1` — the state a first run
starts in (a fresh config's sidebar is on Signals), then the auto-started walk is dismissed.
- `[toursurface] on measure: side_tab=measure signal_list_mapped=0 side_stack_mapped=1` — a frame
after the switch: **`signal_list_mapped=0` beside `side_stack_mapped=1`** is the whole premise,
the list is genuinely off screen while the stack that holds it is on screen.
- `[toursurface] tour: visible=1 steps=5 step=0 signals_stop=1` — **`signals_stop=1` is the filter's
reading**: a stop whose target is *not mapped* is still in the plan because it names a page the
stack can select. `steps=5` is the full table.
- `[toursurface] step=N/5 side_tab=<page> target_mapped=D[ target=signal_list]` — one per stop. The
page moves from `measure` to `signals` at the `trigger` stop (the first stop whose target is on
the Signals page) and stays there; **the `signals` stop reads `target_mapped=1`**.
- `[toursurface] signals stop=4/5 visible=1 side_tab=signals spot=[…] target=[…] contains=1
card=[…] clear=1` — the `tour_geometry_line()` the helpui probe uses, read on the frame *after*
the switch: **`side_tab=signals` with `contains=1` is the assertion** that the spotlight and the
card are on the list, not on the page the walk came from.
- `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0 side_stop=0` — the other half
of the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this
is how a probe puts the sidebar away for good) the split view unmaps the column, the stack with
it, and the two stops that need the sidebar are dropped while the page-less ones stay. This is
what says a *wrong page* and a *collapsed pane* are not the same case.
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**.
The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
- `[toursurface] before: slideover=none signal_list_mapped=0 running=1` -- the state a first run starts
in (panel closed, dock on screen; the signal list is a slide-over page, so unmapped); the auto-started walk is dismissed.
- `[toursurface] on trigger: slideover=trigger trigger_body_mapped=1 side_box_mapped=1` -- a frame after
the page was opened: the dock is still on screen under the panel (they are not alternatives).
- `[toursurface] tour: visible=1 steps=6 step=0 trigger_stop=1 signals_stop=1` -- the plan: **six
stops, the trigger stop and the signals stop both in it**.
- `[toursurface] step=N/6 title='…' slideover=<page> target_mapped=D` -- one per stop, in order: *The
plot*, *The badges*, *The trigger*, *The dock*, *The signals*, *The status line*. **Step 1 reads
`slideover=none` although the probe started with the panel open** (a stop with no page closes it),
**step 3 reads `slideover=trigger`, step 4 reads `slideover=none` again, and step 5 (the signals)
reads `slideover=signals`**; every `target_mapped` is 1.
- `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C` -- the
`tour_geometry_line()` the helpui probe uses, one per stop, read a frame after the stop was entered
(the spotlight is withheld until the panel has finished sliding in, `page_settled`): **`contains=1` on
every stop is the assertion** that the spotlight is on the target, and `inside` must not be 0 (both as
in the `[helpui]` entry above, where `raw`, `target` and `inside` are explained). `clear=0` is read as geometry, not
as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the
documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush
left over the plot, overlapping the panel's edge by a few pixels), and on the `signals` stop for the same
reason (the dock beside it is 280 px and the card has no side wide enough).
- `[toursurface] finished: running=0 slideover=none` -- the last Next ended the walk and **the panel is
closed with it**.
- `[toursurface] restarted at trigger: running=1 slideover=trigger` then
`[toursurface] skipped: running=0 slideover=none` -- the walk parked on the trigger stop and dismissed
(the Skip/Esc route): **`slideover=none` after the dismissal** is the tour not leaving a panel behind.
- `[toursurface] collapsed: side_box_mapped=0 steps=5 trigger_stop=1 signals_stop=1 side_stop=0` -- the
filter with the dock put away (`sidebar_wanted` off; `update_side_visibility()` re-asserts it, so this
is how a probe puts the dock away for good): the dock's own stop is dropped, **and the trigger and
signals stops are kept** because the slide-over does not depend on the dock -- five stops remain (plot,
badges, trigger, signals, status).
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: page=1 dock_still=1 closed=1 | g: page=1 dock_still=1 closed=1 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`m` and `g` are not pairs: each opens a slide-over page, `measure` and `signals`, and a second press closes it), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`.
The `m`/`g` readings are `page=P dock_still=D closed=C`: **all three must be 1 for both keys** -- `P` the page the key opens (`measure` for `m`, `signals` for `g`) is the slide-over's open page after the first press, `D` the dock scroller's vertical adjustment did not move (the dock holds only the readouts, so a key must not scroll it), and `C` a second press closes the page. This is what says `m` opens the measurements' page rather than scrolling the dock to a section (the old `m: dock 0->N (cursors_y=… at_cursors=1)` reading is gone with the dock sections), and that neither key toggles a pane. `m` does *not* turn the measurements on or off: the header Cursors toggle only enables cursors, and `m` only opens the page. `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. **The `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D`
@@ -1377,7 +1434,7 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] after forget names=default
```
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
@@ -1392,7 +1449,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off |
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
## `[rowmenu]`
@@ -1494,8 +1551,8 @@ actually use.
```
Printed by `apply_stored_settings()` when a source is named on the command line: the stored
configuration's startup settings, filled in where the command line was silent. A `buffer=` here
that disagrees with `--buffer-mb` is a flag being overruled by the file; a *missing* line, with a
configuration's startup settings, filled in where the command line was silent. A `samples=` here
that disagrees with `--buffer-samples` is a flag being overruled by the file; a *missing* line, with a
configuration that carries settings, is the defect this exists for — a saved host opened with
`--server` ran on the defaults, so an edit to its buffer or clock never took effect.
@@ -1557,24 +1614,33 @@ run they report:
## `[events]`
```
[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
[events] live, recording off: available=0 dock_sensitive=0 page_sensitive=0 page=none
[events] recording on, no trigger: available=1 dock_sensitive=1 page_sensitive=1 page=none
[events] after trigger on: available=1 dock_sensitive=1 page_sensitive=1 page=none
[events] after events page opened: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] open alone: page=events page_box=347
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 pane=1
[events] activated row0: replaying=1 pane=1 pane_w=240 pane_box=228 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756
[events] activated row0: replaying=1 page=events page_box=347 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=0 members=0 max_ok=1 expected_marked=1 at=after pane replay
[events] dock replay: clicked=1 replaying=1 waveform_changed=1
[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=1 members=0 max_ok=1 expected_marked=1 at=after dock click
[events] replayed: channels=3 samples=20001 replaying=1 waveform=1
[events] live again: replaying=0 waveform=0
[events] live restored (after replay): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=10/5
[events] recording off, trigger off: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
[events] recording off, trigger off: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] recording off, trigger on: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1
[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=0 marked=-1 members=0 max_ok=1 expected_marked=-1 at=two events latched
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 page=events
[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=0 marked=-1 members=2 max_ok=1 expected_marked=-1 at=comparison up
[events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1
[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1
[events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000
[events] group left: replaying=0 kind=0 waveform=0
[events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=22/16
[events] dock replay: clicked=1 replaying=1 (second click, live before)
[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=1 members=0 max_ok=1 expected_marked=1 at=second dock click
```
`thumb_size` is the built size of the row's picture: `kEventThumbW` wide, and as tall as the *stored*
@@ -1584,8 +1650,18 @@ reads `138x75`) as well as with the CSS border and the display's scale factor. W
`0x0`, and a height pinned to the floor for *every* event says the aspect fit was dropped and tall
previews are being squashed again.
**The E-1608 never latches an event** (its stream carries no trigger edge to record): on `--source e1608` the probe reads
`n=0`, the `recording on` lines read `1 1 1` (recording is on, so the block is available), and the two
`recording off` lines read `available=0 dock_sensitive=0 page_sensitive=0 page=events` -- the page is open and
greyed, the "disabled, not hidden" path -- while `activated row0:` reads `replaying=0 ... marker=-1000000000000.000
thumbnail=0 rows_kept=0` and the `dock events:` / `dock replay:` lines read `rows=0`, `clicked=0 replaying=0`. The
`page=` of the first availability lines is whichever `source-*` page the kind opened at start-up, not `none`.
A UDPS run against a **fresh cache** reads the same `n=0` and `rows=0` on the opening `dir=` / `dock` lines (nothing
has latched when they are printed) and fills in from the group leg on; a UDPS run with a populated cache is the one
that proves `same_titles` and the dock click on real rows.
`[events] preview redrawn:` is the repair path, and the probe forces it: step 1 deletes the newest
`.bmp`, drops the thumbnail cache and refreshes the pane, so this line is `EventStore::redraw_preview()`
`.bmp`, drops the thumbnail cache and refreshes the page's list, so this line is `EventStore::redraw_preview()`
drawing the picture again from the `.npy`. `stored` is the file it wrote, read back out of the BMP
header — `480` wide and `44 * signals` tall — and `row_drawn=1` says the row got a paintable from it.
The probe therefore needs a cache a **previous** run populated: it fires ~4.5 s in, before this run's
@@ -1624,23 +1700,25 @@ session that then disagrees with it takes the pair apart.
| `signals`, `samples` | the event's channels and rows | a table built from the *rows* instead of the snapshot: only what happened to be plotted |
| `thumb_bytes` | a real 24-bit BMP, `54 + 480 * (44 * signals) * 3` rounded up per row to a multiple of four -- so it *grows with the signal count* (190134 at three signals, 380214 at six) | a preview that is empty or missing -- the widget snapshot of a `GtkGLArea`, which is NULL -- or a picture back on a fixed total height, which gave a six-signal event 26 px a signal and an eight-signal one 20: a smear, not a waveform |
| `recorded` | 1 while the trigger panel's switch is on | the switch not wired, or recording defaulting off (it must be on: the cache is for the event you did not expect) |
| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane |
| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot |
| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode |
| `recording off, trigger off` and `recording off, trigger on` | `button_visible=1 pane=1 recording=0` in both **once the cache holds anything** (the probe's own run does by this point) | the button going away with the switch when events are already written -- an event on disk outlives the switch that wrote it -- or `pane_w` not being 240 with the pane open. With an *empty* cache the same two lines read `button_visible=0 pane=0`, which is the empty column the item exists to prevent |
| `page` (on the `dir=` line) | 1 once the `events` page is open (the probe opens it with `open_events_page()`) | a page that did not open, or one that opened before its list was refreshed |
| `plot_w_stable`, `plot_w=a/b` | 1, with the plot area's width before and after the first latch equal and both > 0 (756/756 on the documented window) | events squeezing the plot again (the old pane did), or two unrealised `-1` widths read as equal (the probe requires > 0) |
| `available`, `dock_sensitive`, `page_sensitive` (the lines headed `live, recording off`, `recording on, no trigger`, `after trigger on`, `after events page opened`, `recording off, trigger off/on`) | the three equal on every line, because both views follow `sync_events_availability()`: `0 0 0` live with recording off and an empty cache; `1 1 1` once recording is on, **and it stays `1 1 1` in trigger mode and with the page open** (a replay or a group leaves trigger mode, and a block that greyed itself under that click is the trap) | the dock block and the page disagreeing (one of the two not wired to the sync), or the block going `0` under trigger mode. After recording is switched off the lines read `1 1 1` **once the cache holds anything** (the probe's own run does by this point): an event on disk outlives the switch that wrote it. With an *empty* cache they read `0 0 0` -- insensitive, **never hidden** ("disable, do not hide") |
| `page` (on the availability lines) | `none` until the probe opens the page, then `events` | the page not registered (`open_events_page()` returning false), or a page left open by an earlier leg |
| `open alone` | `page=events page_box=` a width near the slide-over's (347 on the documented window) | the page's box measuring 0 (an unrevealed `GtkRevealer` as the content instead of `events_box`), or a page not opened at all |
| `dock events` | `same_titles=1`, `rows` equal `pane_rows` (both capped at 24: `pane_rows` is cut to the dock's `kDockEventsMax`), `sensitive=1`, **`dock_w` below 280** (268: the block fits the fixed dock; more means a row that no longer ellipsizes), `max_ok=1` (dock children <= 24 + the "N more" row). `on_screen` counts filled markers: **1 after a replay, 0 while live or in a group**; `marked` is the index of the first filled row (-1 when none), `expected_marked=1` when the filled row is the replayed record's file (-1 where no replay is named); `members` counts hollow markers: **2 with a comparison up**, 0 otherwise. The `at=` text names the moment | `same_titles=0` is the two lists rebuilt from different data or one of them stale (the dock follows every latch, the page only while open or on Manage -- a latch landing between the page's refresh and the read can show `rows=9 pane_rows=8` once, which is a probe-time race, not a defect, if it does not persist); `on_screen` above 1 is two filled markers (`event_on_screen` not shared with the title); `members=0` in a comparison is the group not marking its members; `max_ok=0` is the cap lost |
| `dock replay` | `clicked=1 replaying=1 waveform_changed=1`; the second line, from live, reads `clicked=1 replaying=1` | the dock row's click not reaching `replay_event()` (`clicked=0` with a non-empty list), or the click rebuilding the dock list under its own handler. With an empty cache (`rows=0`) it reads `clicked=0 replaying=0`, which is not a defect |
| `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both. It also catches the cache never being *created*: `event_preview_texture()` answers from the cache or not at all, so a reader that does not create one leaves **every** row blank, which is what `thumbnail=0` meant for as long as it read 0 |
| `preview redrawn` | `stored=480x<44*signals>` and `row_drawn=1` | a preview that cannot be repaired. The `.npy` is the event and the `.bmp` is only a picture of it, so a write that failed, a truncated file or a cache from an older build must be redrawn rather than shown as a blank row -- `row_drawn=0` is a row the user reported as "sometimes missing" |
| `axis`, `marker`, `pane_box` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000`, and a `pane_box` close to `pane_w` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker; or a pane whose *rendered* width is 0 while the divider says 240 -- the position is what was asked for, `pane_box` is what the pane actually got, and a divider at 240 with a zero-width pane is still no pane |
| `axis`, `marker` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker. `page=events` and `page_box` (the page's width, as in `open alone`) say the page is still up and has a width: a replay from the page must not close it |
| `rows_kept`, `selected` (in `activated row0`) | `rows_kept=1` and `selected=N/N` -- the counts either side of the activation equal | the list **rebuilt** by the activation. It is the row *object* that is compared, not its index: a rebuilt list has a row 0 too, and only identity tells a list that was left alone from one that was torn down under the user's scroll position and their comparison selection. `mark_events_on_screen()` must rewrite the titles in place |
| `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread |
| `window`, `trigger`, `channel`, `level` (in `live restored`) | each equal to its `(want …)` | the way back out of a file view discarding what the live view was set to. One wrong half names which writer won: a `window` that reads the **session file's** value is the deferred `apply_layout()` beating the restore; `trigger=0` is the toggle the file view switched off and nobody switched back; an empty or shifted `channel` is an index restored instead of a name, after a rebuild renumbered (or a group renamed) the channels; and a `level` that moved on its own is `rebuild_trigger_signals()` re-defaulting it because `trigger_level_touched` was not restored with the configuration |
| `pending`, `gen` (in `live restored`) | `pending=0`, and the two generations different | the restore still waiting. It is gated on the stream's first CONFIG, so `pending=1` says the channel list the stored trigger name needs never arrived -- with `channels=0` behind it, a session file that enabled nothing |
| `pinned`, `held`, `mode` (in `live restored`) | `pinned=0`, **or** `pinned=1` with `held=1` | a file's pin surviving the way back, which is a live view that cannot be navigated. `apply_time_window()` drops it; the second reading is the legitimate case -- a restored trigger that was *on* can latch again before the probe reads, and a held capture is pinned, correctly |
| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered |
| `tick` | 1 in live mode, **0** once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored |
| `window` (in `activated row0`) | the **event's** span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once |
| `compare buttons` | `selected=2` and both buttons sensitive | the buttons reading something other than the selection, or staying insensitive with a valid pair selected |
| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `pane=1` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced |
| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `page=events` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced |
| `compare plots` | `area_b=1 rows_a=1 rows_b=1 list=4 same_x=1` | no second plot on screen, a second plot with a different row count, two plots not on one x window, or `list` above the signal count -- the panel listing each event's copy of a signal as a signal of its own |
| `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees |
| `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds |
@@ -1674,31 +1752,34 @@ library measures and draws the rect in tests of its own.
## `panes:` — the locked and configuration modes
```
[udpscope] pane drag: sidebar hidden for the drag=1 restored after it=1
[udpscope] pane drag: slide-over closed for the drag=1
[udpscope] pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)
[udpscope] panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms'
[udpscope] panes: trigger=config signals=config body_visible=1 summary=''
[udpscope] panes: trigger=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms'
[udpscope] panes: trigger=config body_visible=1 summary=''
[udpscope] panes: the same line as the probe found it: 'mode: stream (trigger off)'
```
**`pane drag:`** is a drag begun through the *source* with the split view forced into its **overlaid**
state, which is what a window with no room beside the plot produces. Both must read `1`: with the sidebar
covering the plot there is no drop target under the pointer, so a drag out of the signal list is
*impossible* rather than merely awkward, and `row_drag_sidebar()` steps the sidebar aside for the length of
the drag and puts it back after. `hidden=0` is an overlaid drag that can never land anywhere.
**`pane drag:`** is a drag begun through the *source* of the first signal badge (`drag-begin` emitted on its
`GtkDragSource`, so the handler is the one a real drag reaches) with the `signals` slide-over page open. It
must read `1`: the slide-over covers the plot's right edge, so a drag that started with it open would have part
of the plot that is not a place to drop, and the badge's `drag-begin` closes it. (The page that was open is put
back afterwards.) `0` is a drag that leaves the panel over the plot; `-1` is no badge to drag (no signal enabled,
or more than six, where the badges are replaced by the "N signals" overflow). It replaced the old
`sidebar hidden for the drag=1 restored after it=1` pair, which tested `row_drag_sidebar()` -- the list rows were
the drag handle and the overlaid sidebar had to step aside; both are gone.
**`pane edits:`** is the two `[edit]` buttons in each mode, by name and verdict, and it exists because of
**`pane edits:`** is the trigger pane's `[edit]` button in each mode, by name and verdict, and it exists because of
a real defect: the "done" icon was `emblem-ok-symbolic`, which **Adwaita does not have** (breeze does), so
the button that leaves configuration mode was blank — and a blank icon button is invisible in every other
reading, exactly as `toolbar icons:` describes for the toolbar. The probe clicks into configuration mode
and back, so one line carries both names. **Both must read `(ok)`**, and the pair is
`document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done).
Both panes have two modes. **Locked** is what a returning user gets — the trigger pane shows the one line
in `summary`, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — and **config** is the full pane, which is what a *first run* gets, because a
session file that loaded is what "we have been here before" means. Each heading's `[edit]` button flips
its pane.
The trigger pane has two modes. **Locked** is what a returning user gets — the pane shows the one line
in `summary` and offers no control that changes the configuration — and **config** is the full pane, which
is what a *first run* gets, because a session file that loaded is what "we have been here before" means.
The heading's `[edit]` button flips it. (The signals have no lock: the badges and the signal pages are
direct manipulation, and `panes:` no longer carries a `signals=` field.)
- **With trigger mode off the summary is one line**: `mode: stream (trigger off)`. The four lines below
describe a trigger that is not running, so quoting them would describe nothing. The dump carries the line
@@ -1732,9 +1813,8 @@ its pane.
`body_visible=0` is a locked pane showing nothing at all, which is the defect this reads for.
- **`body_visible=0`** is the settings being off screen, which is what makes the pane a couple of hundred
pixels instead of 580 in `panels:`.
- **The locked signal list drops the rows of the signals that are off**, so `rows` counts what is on:
`signals panel: … rows=3 signals=2 divider=1` is two on signals and the `MATH` divider, not four
signals. Its `+` goes with them — a `+` on a locked list opens the editor the lock exists to keep shut.
- **The signals list has no lock.** It lists what can be *added* (see `signals panel:`), and the signal
pages always allow acquire, colour, gain/offset, math edit and delete.
## `panel switches:` and the trigger panel's default sensitivity
@@ -1759,11 +1839,49 @@ disagree. The two lines are the claim, in both directions:
- `trigger panel sensitive …=0` is the *panel body*, not the panel: the heading row, with its switch,
stays live. `1` here while trigger mode is off is the pane not being disabled at all.
## `dock cursors:` and `dock measures:`
```
[cursors-probe] <what> dock cursors: lines=1 page_lines=1 same_text=1 dock_w=268 side_w=280 fits=1 dock_sensitive=1
[measures-probe] <what> dock measures: lines=13 page_rows=13 same_text=1 dock_w=268 side_w=280 fits=1
```
These ride on the `[cursors-probe]` readings (every state line gets a `dock cursors:` beside it) and on the `[measures-probe]` readings (`added`, `settled`, `final`), and read the dock's compact copy against the page it copies. The dock never formats a value: `update_cursors_pane()` and `update_measures()` hand the same string to the page label and the dock label.
- **`same_text=1` is the assertion**: every dock label (the cursors' who, A, B, delta cells; the measurements' values) reads the same text as the page's label in the same position. `same_text=0` is a second formatter, or a value written to one view only; the two panes must never disagree. The probe compares `gtk_label_get_text()`, so the who cell compares as the text a user reads, markup stripped.
- **`lines` must equal `page_lines` / `page_rows`**: one dock line per page line. A shorter dock is a rebuild that was not mirrored (`dock_rebuild_cursors()` / `dock_rebuild_measures()` run from the pages' rebuilds); `same_text` reads 0 with it, so this names the cause.
- **`fits=1`** (`dock_w <= side_w`, `side_w > 0`): the readouts box fits inside the sidebar scroller. `dock_w` reads **268** inside the 280 px sidebar (the box's own margins and the fixed label widths: who 5 characters, each value 9, a measurement's type 12). **A who cell at 8 characters made the box 304 px and `fits=0`**, which is the defeat of the pin: the fixed-width ellipsis is what makes the dock's layout independent of what a value reads, so a cell given more characters widens the dock for every run, not only for a long name. `side_w=-1` or `dock_w=-1` is a probe that ran before the dock was built.
- **`dock_sensitive=1`** (cursors only): the dock's cursor grid is live whenever the stream has signals; `0` before them ("disable, do not hide"), `0` with signals is the dock never coming back.
- On a session with no cursors line, or with no measurements, `lines=0 page_lines=0 same_text=1` is correct: the dock then shows `no measurements` for the measurements (one non-value label, not counted in `lines`).
## `strip:` (the readouts strip)
```
[cursors-probe] <what> strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=4 measures=0 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=100 groups_ok=1
[measures-probe] <what> strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=0 measures=12 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=010 groups_ok=1
[measures-probe] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1
[cursors-probe] dock hidden strip: visible=1 expected=1 visible_ok=1 dock_shown=0 cursors=4 measures=0 same_text=1 h=26 sens_same=1 ...
[cursors-probe] dock restored strip: visible=1 expected=1 visible_ok=1 dock_shown=1 ... collapsed=1 window=756
```
One `strip:` line follows every `dock cursors:` / `dock measures:` line, and each probe ends with a drive that puts the dock away through the wish (`sidebar_wanted = false`), reads the strip 600 ms later (`dock hidden`), restores the wish and reads again (`dock restored`).
- **`visible` equals `expected`, and `expected = (!dock_shown || collapsed) && (cursors on || measures non-empty)`** (`dock_shown` is `show-sidebar`, `collapsed` the split view's, both read in the same frame as `visible`; "cursors on" is the cursors toggle, not the `cursors=` chip count, which is a rebuild's and is 1 even with them off). `visible_ok=1` is that equality; `visible_ok=0` is `sync_readouts_strip()` not run, or run before the reconciliation. A single such line right after the probe's own `update_side_visibility()` or on the very first line (the tick has not set `collapsed` yet) is the sub-frame before the next tick and not a defect.
- **What each leg reads.** `[measures-probe] start` (before the probe adds anything, from a **fresh** config: a stored session brings its measurements back) is the "neither" state: `visible=0 expected=0 groups=000 h=0`. The measures probe's later lines have measurements and the cursors off: `groups=010`. The cursors probe alone has the cursors on and no measurements: `groups=100`; run after the measures probe in the same session it has both: `groups=111`. A line with the strip visible and `groups=000`, or hidden with a group on, is `sync_readouts_strip()` not called from that path.
- **`groups=<cursors><measures><separator>`** is which of `strip_cursor_group`, `strip_measure_group` and `strip_sep` are visible; **`groups_ok=1`** is that they equal (cursors on, a measurement, both). `0` is a group left over from the previous state.
- **`h=0` with `visible=1` on the `cursors-probe ... on` and `measures-probe ... added` lines** is the strip shown in the same frame the probe reads it, before its first allocation; the next line reads 26. It is not a `visible_ok` miss: the toggle handler and `rebuild_measures_pane()` call `sync_readouts_strip()` themselves, so `visible` is already right.
- **`visible=1 expected=1` on every line that has something to read, under `xvfb-run`**, the `dock restored` line and the lines with `dock_shown=1` included: the window is 756 px, below the 900 px `kSidebarRoom`, so the split view is collapsed, the dock is an overlay on the plot, and the strip stays. `visible=0` there is the old rule (hidden whenever the dock is shown) or `collapsed` not being read. The wide case (`collapsed=0`, `dock_shown=1`, `visible=0 expected=0`) is **not reachable from a probe**, because a window with no manager cannot be resized by `gtk_window_set_default_size()`.
- **`h` is 26 with `visible=1`, 0 with `visible=0`.** Any other visible height is the chips setting the strip's height (the button trim in `ui_widgets.cpp` or the `height-request` lost).
- **`same_text=1` is the assertion**: every strip label (the cursors' `who` and delta, the measurements' values) has the dock's text, and the counts of chips match the dock's lines. `0` is a value written to one view only -- a dock cell set without `dock_cursor_cell()` / `dock_measure_value()`, or a rebuild that did not mirror.
- **`cursors` / `measures`** are the chip counts and equal the dock's `lines`.
- **`sens_same=1`**: the strip's cursor chips and the dock's cursor grid have the same sensitivity (`dock_set_cursors_sensitive()`).
- **`collapsed=1 window=756`** on every line under `xvfb-run`; the very first line can read `collapsed=0` (the tick has not set it yet), and then `expected` follows it.
## `[sidebar-probe]`
```
[sidebar-probe] beside window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1)
[sidebar-probe] regained window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1)
[sidebar-probe] beside window=756 sidebar=280 collapsed=1 shown=1 button=1(sens=1)
[sidebar-probe] regained window=756 sidebar=280 collapsed=1 shown=1 button=1(sens=1)
```
`UDPSCOPE_PROBE_SIDEBAR_MS` reads the sidebar in the two states a window can put it in, and against a hide
@@ -1774,7 +1892,7 @@ and `regained` reads 400 ms later.
- **`sidebar` must never be 0.** It was, for every window narrower than the 940 px the divider used to sit
at, and the pane could only be found by dragging the divider back — the report this reading exists for.
It must read its own width (320) in *both* states: `collapsed` changes where the sidebar is, never how
It must read its own width (280) in *both* states: `collapsed` changes where the sidebar is, never how
wide it is.
- **`shown` and `button` must agree, always.** They are the panel and the toolbar button, and a button that
says "shown" over a hidden panel is the report this reading exists for. `shown=1 button=1` on `regained`
@@ -1790,24 +1908,22 @@ and `regained` reads 400 ms later.
## `[side-tab-probe]`
```
[side-tab-probe] page=signals accums=4 baseline recorded
[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=18
[side-tab-probe] section=none accums=4 baseline recorded (measure page was_open=1, now closed=1)
[side-tab-probe] section=none rows=4 accums=4 advanced=4 min_n=18
```
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the rule that **the statistics accumulator keeps running
on the hidden page**. It switches *to* the Measure tab, forces one pass so there is a baseline, then switches
away to Signals and reads again two seconds later -- with the measurements' page not current, so its widgets
are unrealized and measure 0x0.
`UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running while the measurements' page is closed**. The measurements are a slide-over page (`measure`), not a dock section, so the old scroll-the-dock set-up is gone. The probe opens the page, forces one pass so there is a baseline, *closes* the page (its widgets stay in the tree, unmapped) and reads again two seconds later. It re-arms once after 400 ms so the first reading has the layout. **Run it against a config that has seen the first-run tour**, which would close the page it opens (`tour_seen = true`).
- **`advanced=4` is the assertion**: every accumulator gained passes while its page was off screen, and
- **`was_open=1 closed=1` is the precondition**: the page really was open for the baseline and really is closed for the report, so `advanced=4` says something. `was_open=0` is the page never opening (the tour closed it, or the page is insensitive because the stream has no signals) and `closed=0` is `slideover_close()` not taking effect; either way the run proves nothing. `section=` is the slide-over's open page and **reads `none` on both lines**, because the page is closed.
- **`advanced=4` is the assertion**: every accumulator gained passes while the page was closed, and
`min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the
accumulation on the visible tab, so the readout would gain a gap in its min/max/avg for exactly as long as
the trigger was on screen. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an
allocation read on the unrealized page.
accumulation on the page being open, so the readout would gain a gap in its min/max/avg for exactly as long as
the page was shut. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an
allocation read on the unmapped page.
- The probe enables a signal **and assigns it to a plot** itself, both deliberately. A signal starts disabled
(enabling is what starts acquisition), and a channel with no trace leaves the live window *still* -- and a
still window is the state `update_measures()` is *supposed* to skip, so a probe that left it that way would
read `advanced=0` for a reason that has nothing to do with the tab.
read `advanced=0` for a reason that has nothing to do with the page.
- `rows` is the measurement count and `accums` the accumulator count; the two must agree, because one
accumulator per measurement row is what makes `advanced` a statement about every row.
@@ -1832,7 +1948,7 @@ kind, so a run without it never shows that kind. The `form` line appears once pe
- `kinds=udps,e1608,flatudp,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`,
or the env var gate is wrong.
- `round_trip=0`: a form's `write` and `read` disagree about a key -- a renamed `SourceConfig` key, or an
absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1 and plot0; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals.
absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1, plot0 and gen_rate; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals.
- `panel disconnected: sensitive=0 present=1`: **disable, don't hide.** `present=0` is a panel that was hidden
or never added; `sensitive=1` is a panel that stays live with no source behind it.
- `panel connected: sensitive=1`: anything else means `apply_source_panels` acted on the wrong widget (it must
@@ -1843,7 +1959,7 @@ kind, so a run without it never shows that kind. The `form` line appears once pe
`drained=0` is a queue that `poll()` does not drain on some path; `after` greater than `before + 1` is a
command run twice.
The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no panels.
The UDPS run has no `source-` slide-over page, because the UDPS kind has no panels.
## `[e1608]`
@@ -1859,6 +1975,8 @@ The UDPS run has no `source-` page in `side_stack`, because the UDPS kind has no
[e1608] scan socket closed by the board
[e1608] reconnect: connects=2 connected=1 ok=1
[e1608] resumed: frames_before=612 frames_after=662 ok=1
[e1608] generator: requested=100.0 achieved=99.7 panel='100 Hz (requested 100 Hz)' ok=1
[e1608] generator stopped: achieved=0.0 panel='off (requested 100 Hz)' ok=1
[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
```
@@ -1895,6 +2013,17 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign
drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart
the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped
proves nothing).
- `generator: requested=100.0 achieved=99.7 ... ok=1`, `generator stopped: ... ok=1`
(`UDPSCOPE_PROBE_E1608_GEN=1500`, a run of its own with `gen_rate=100`): `requested` is `gen_rate`, `achieved`
the source's own measurement (generator ticks that wrote, per second of wall time, over a window of about 1 s),
`panel` the subtitle of the Analog out panel's "Update rate" row. `ok` needs `achieved` within 70% to 110% of
`requested` **and** the panel to quote the requested rate; the mock answers at once, so it reads within a
percent of `requested`. Far below is a `run_timers()` that re-arms with a constant (the 20 ms the setting
replaced reads 50) or a tick that runs late; `panel='off'` while `achieved` is non-zero is a panel that
did not refresh (it ticks only while its page is on screen) or one that reads the wrong readout. This line says
nothing about a real board, whose DAC writes are slower; there the same row is the measurement. The stopped
line must read `achieved=0.0` and a panel that starts with `off`; a non-zero value is a rate that
`update_gen_flags()` did not reset when the last generator stopped.
- `scan: found=1 ... ok=1`: the form's **Scan network** row against the mock. `found` is the result rows after
the first scan; `0` with the second line `did not answer` is an environment with no up, non-loopback
broadcast interface that reaches the mock (or a refused broadcast), **not a pass**. `ip` is the listed address
@@ -2125,7 +2254,7 @@ of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-sign
**`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the
3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels,
~7 MB each) against `budget=209715200`, the ring's own `--buffer-mb`. `drawn=` is how many layer slots are
~7 MB each) against `budget=` the planned ring bytes (`persist_budget_bytes()`, `Partition::bytes()`). `drawn=` is how many layer slots are
actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the
budget bit — which is the run to take next, on the fast mock with a long window:
@@ -2137,7 +2266,7 @@ budget bit — which is the run to take next, on the fast mock with a long windo
```
`UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained
acquisition is 72.8 MB, so the 200 MB budget keeps two and drops two — `kept` below the depth, `dropped`
acquisition is 72.8 MB, so the budget (the planned rings, ~201 MB for four channels at the default 10M) keeps two and drops two — `kept` below the depth, `dropped`
non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw
nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention
can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its
@@ -2259,20 +2388,26 @@ prints without a probe: `depth`, the retention (`kept`/`bytes`/`budget`/`dropped
## `signals panel:`
```
[udpscope] signals panel: labels=2 list=1 rows=5 signals=4 math=0 divider=1
[udpscope] signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1
[udpscope] signals panel: labels=3 list=1 rows=5 signals=4 math=0 divider=1 drag_sources=0 addable=4 addable_ok=1 empty_label=0
[udpscope] signals panel: labels=3 list=1 rows=2 signals=1 math=0 divider=1 drag_sources=0 addable=1 addable_ok=1 empty_label=0
[udpscope] signals panel: labels=3 list=1 rows=1 signals=0 math=0 divider=1 drag_sources=0 addable=0 addable_ok=1 empty_label=1
```
The signals panel's frame read from the window, and in the same line the *shape* of the one channel list:
the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds
(`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`),
and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. **`drag_sources` must be
`rows - 1`** — one per signal row, the `MATH` divider not being draggable. Dragging a signal onto a plot is
how a plot is filled, so it has to survive both pane modes: the locked list drops the tick, the menu and the
`+`, and this is the reading that says the drag did not go with them. (`0` is a list that has become a thing
you can read but not use.) `labels=1` is a `.ui` that lost
one of the two labels — counted over the panel's subtree *above* the list, because the rows' own name
and unit labels are the code's and the heading sits in a heading row beside the pane's `[edit]` button — `list=0` one that lost the list -- both of which otherwise show as a panel quietly
The signals panel's frame read from the window, and in the same line the *shape* of the one list. The page
is "Add a signal": it proposes only what can be added, so the list holds the streamed signals that are not
acquired (`signals`), the math channels that are not computed (`math`), and the `MATH` divider with its `+`
(`divider`, 1 when `rows == signals + 1 + math`, 0 otherwise). An acquired signal has a badge in the bar and
is not listed -- **unless the badge bar has overflowed** (more than 6 enabled signals, the "N signals" badge),
when the acquired ones are listed too, since the bar no longer shows them. `addable` is the same count
worked out from the model (unacquired listed signals, or all of them when overflowed) and `addable_ok=1` is
`signals + math == addable`; `0` is a list filter that drifted from the rule. `empty_label=1` is the dim
"every signal is acquired" label shown, which must happen exactly when `addable=0` and the stream declares
signals (the divider row stays, so a math channel can still be created). **`drag_sources` must be
`0`**: dragging a signal onto a plot is the signal *badge's* job now (see `[sigbadges]`, which counts the
badges' own sources), and the list's rows only open the signal's page; a non-zero reading is a second, competing
drag handle on the list. `labels=2` is a `.ui` that lost
one of the three labels (heading, hint, the empty-state label) — counted over the panel's subtree *above* the list, because the rows' own name
and unit labels are the code's — `list=0` one that lost the list -- both of which otherwise show as a panel quietly
missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create
a math channel. The first line above is a run with no math channels at all: the divider is there anyway,
because a `+` that only appeared once a channel existed could never create the first one. `panels: …
@@ -2282,14 +2417,15 @@ is what the compact-row CSS rule is for.
## `events pane:`
```
[udpscope] events pane: compare=1 accumulate=1 list=1 revealer=1 nested=1
[udpscope] events pane: compare=1 accumulate=1 list=1 nested=1
```
The events pane's frame read from the window: the two group actions, the list, and the revealer the `.ui`
builds, plus `nested` — the list being *inside* that revealer, which is the one thing a lost id cannot show
(a list parented elsewhere reads 1 everywhere else). `[events]` is the other half, and it drives the pane end
to end: recording off, recording on, the trigger, the history button that reveals it, a row click and the
replay, then Compare and Accumulate — so the file's buttons are read by being used.
The `events` page's frame read from the window (the page's content is `events_box` of `events-pane.ui`; there is
no revealer, divider or header toggle any more, so none is read): the two group actions, the list, plus `nested`
— the list being *inside* `events_box`, which is the one thing a lost id cannot show (a list parented elsewhere
reads 1 everywhere else). **All four must read 1.** `[events]` is the other half, and it drives the page end
to end: recording off, recording on, the trigger, the page opened with `open_events_page()`, a row activation
and the replay, then Compare and Accumulate — so the file's buttons are read by being used.
## `[windowpop]`
@@ -2323,7 +2459,7 @@ refusal it may still hold the previous text.
## `[enable-all-probe]` and switching a signal off
```
[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the 200 MB buffer'
[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the buffer: 8.39 M samples per signal'
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'
```
@@ -2332,7 +2468,7 @@ earliest thing that reads the status bar. It is set as the window is presented -
CONFIG can take the model's mutex, so before anything can block -- and it names the host and the
buffer being planned, because that is the window's only answer to "why is nothing happening yet".
An empty `notice` here is the window that used to come up silent while a large buffer was allocated:
at `--buffer-mb 4000` that is the dialog disappearing and then over a second of a frozen, blank
at `--buffer-samples 100M` that is the dialog disappearing and then over a second of a frozen, blank
window. (The buffer is allocated on the producer thread under `ScopeModel::mutex()`, which the frame
tick also takes, so the freeze is real and cannot be papered over in the UI -- see the
`[udpscope] buffer allocated:` line below.)
@@ -2401,6 +2537,34 @@ rate whenever data arrives at all:
Neither can be read off the engine's own fields, which is why the store's pair is here.
## `[udpscope] buffer:` and `[udpscope] planner:`
```
[udpscope] buffer: requested=10000000 used=8388608 bytes=167772160 limit=8589934592 limited=0
[udpscope] planner: unclipped got=67108864 pow2=1 limited=0 | clipped(1GiB) got=33554432 bytes=671088640 fits=1 pow2=1 limited=1 | clipped_rel got=33554432 bytes=939524096 fits=1 | too_small_throws=1
```
Printed by the exit dump (`src/ui.cpp`), next to the per-channel `produced` lines. The first is the live
plan: `requested` is `Config::buffer_samples`, `used` is `Partition::time_capacity` (the request rounded
down to a power of two, then halved under the memory limit), `bytes` is `Partition::bytes()` (the rings'
real size -- 8 388 608 ticks x (8 + 3 x 4) = 167 772 160 for the mock's three channels) and `limit` is
`memory_limit_bytes()`. **`used` must be a power of two no larger than `requested`'s floor, `bytes` must be
<= `limit`, and `limited=1` only when `used` is below that floor.** A run with `--buffer-samples 100M
--memory-limit-mb 256` reads `used=8388608 limited=1` and prints the `buffer limited to ...` stderr line;
`used=0 bytes=0` means no plan was made yet (no channel enabled -- add `UDPSCOPE_ENABLE_ALL_MS`).
The second line is not about the run: it feeds `plan_partition()` three channels and a 100 M request on
fixed inputs, so a change to the rounding, the halving or the byte formula reads here even when the run's
own plan is not clipped. `unclipped` (16 GiB limit) must give the pow2 floor of 100 M, **67108864**, with
`pow2=1 limited=0`. `clipped(1GiB)` must give a smaller power of two whose `bytes` are <= 1 GiB
(**33554432**, 671 088 640 bytes: 67 108 864 would need 1 342 177 280), `fits=1 pow2=1 limited=1`.
`clipped_rel` is the same in relative time, where the clock ring is doubled, so a tick costs 8 more bytes:
the same 33 554 432 ticks but 939 524 096 bytes, still `fits=1`. `too_small_throws=1` is a 1000-byte limit
refused (it cannot hold 128 ticks). A `fits=0` is the halving loop stopping early; `limited=0` on the clipped
case is the clip not being reported to the UI; `too_small_throws=0` is a planner that allocates past the limit.
The Preferences *Memory limit* row is not probed: change it by hand and read the new `buffer allocated:` line
and the status popover.
## `[udpscope] buffer allocated:`
```
@@ -2409,7 +2573,7 @@ Neither can be read off the engine's own fields, which is why the store's pair i
Not a probe — it is printed by `ScopeModel::rebuild_locked()` in every build, because this is the one
operation in the application whose duration a user can see with no instrument: the store's rings are
`std::vector<std::atomic<T>>` and so **value-initialised**, which touches the whole `--buffer-mb`
`std::vector<std::atomic<T>>` and so **value-initialised**, which touches the whole planned ring storage
(roughly a second per GB, mostly first-touch page faults), and it runs on the producer thread holding
`ScopeModel::mutex()` — which the frame tick also takes. So the line's `ms` is a frozen window, not
bookkeeping.
@@ -2418,7 +2582,7 @@ bookkeeping.
rebuild whose plan describes the storage already allocated clears the store instead
(`Partition::same_storage_as()` → `BufferStore::reset()`) and prints nothing, so a startup or a mode
change that reads more than one or two of these is a re-plan that should have been coalesced. Startup
asked for **fifteen**; it now asks for two or three. A run at `--buffer-mb 4000` measured those
asked for **fifteen**; it now asks for two or three. A run at `--buffer-samples 100M` measured those
coalesced allocations at 1148 ms each and a max frame tick of 1186 ms — the same number, which is the
evidence that the freeze *is* this call and not something downstream of it.
@@ -2444,8 +2608,8 @@ while the edit runs) and never falls to 0, which is what a rebuild would do. Eve
[mathwrap-probe] after edit: produced=72400 slots=72400 (want equal) y=[-0.5000,0.5000] (want +-0.5, was +-2.0000)
```
`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-mb`** and given time to
saturate it: `--buffer-mb 1` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-samples`** and given time to
saturate it: `--buffer-samples 65536` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`, reads the lines above. It installs one
`Gain` graph of 2 over the mock's unit sine, polls (100 ms) until the ring has **wrapped**, and only then
edits the gain to 0.5 — the same channel list with one node changed, which is the in-place recompute and
@@ -2572,3 +2736,180 @@ The same probe then puts five measurements on the same spiked `sine` as **two wi
- **`measure windows=2`** -- the five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two
distinct windows. **`windows=1` means the corner is not part of the key**: the five collapsed onto one walk
and two of them read a signal they did not ask for.
## `[dock-session]`
```
[dock-session] hidden: wish=0 save_scheduled=1 collected=0 saved=1 loaded=1 read=0 applied=1 split_follows=1 ok=1
[dock-session] shown: wish=1 save_scheduled=1 collected=1 saved=1 loaded=1 read=1 applied=1 split_follows=1 ok=1
[dock-session] old file (side_tab only): loaded=1 dock_shown=1 default=1 ok=1
```
`UDPSCOPE_PROBE_DOCK_SESSION_MS` reads the one thing the dock persists, the Dock button's state, as `dock = true|false` in the
session file (it replaced `side_tab`). Each of the first two lines drives the toggle, then follows the wish through: `save_scheduled=1` is
`on_sidebar_toggled` having scheduled a save (a user's wish that was not saved is lost at the next start), `collected` is
`collect_layout()`'s `dock_shown`, `saved`/`loaded` the file round trip on a temp path, `read` the loaded value, `applied`
the wish after `apply_layout()`, and `split_follows=1` the split view following it. **`ok=1` on all three lines is the assertion.** Each leg first cancels any save timer still pending
(`schedule_layout_save()` is an idempotent 400 ms debounce, so a leg would otherwise inherit the previous one's), which is what makes `save_scheduled` a reading
of *that* leg's toggle; with no source port (a recording opened from a file) nothing can be scheduled and the leg expects `save_scheduled=0`. The third line
loads a file as the previous version wrote it (`side_tab = "measure"`) into a `LayoutState` the probe has set to `dock_shown=false`, the opposite of the default: it must load with no error and
leave `dock_shown` at the default (`true`), so `ok=1` requires `dock_shown == default` -- the parser ignores unknown keys, and this is what says the old key still does not break a session. A load that
left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The probe writes only to a temp path.
## `[signalpage]`
```
[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 other_page_kept=1 rows=1
```
`UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:<name>`), `pages=1`
(one page in `App::signal_pages`), `enable_visible=1` (the acquire tick is shown in live mode; `0` in a file view), `plot_buttons=` equal to
`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted, from a state where the probe first took it off every plot), `plot_on_row0_disabled=1`
(after the page refilled with the signal on plot 1, that plot's "Plot on" button is insensitive, "already on this plot"; `0` is a button that would put the signal in a second slot of
the same plot), `removed=1`
(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("Back to signals"
target), `row_opens=1` (the probe switches the signal off so it is addable, activates its list row and the page opens; the signal is then switched back on), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and
the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. `other_page_kept=1`: with the `trigger` page open, a signal page that is not on show was pruned (a math channel added, its page built, the
channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the
signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added.
A second line, `[signalpage] calibration identity=1/0 set=2.5/-1.25 pencils=3 pencil_edits_one=1 unit_edit=1 unit='V'`, reads the page's details grid (unit, gain, offset, then the CONFIG's read-only details) rows
(`udpscope-gain` / `udpscope-offset` labels, one string rule: `format_calibration()`, up to six decimals with trailing zeros dropped): `identity` is what an
uncalibrated signal shows, `set` what the page shows after `set_calibration(2.5, -1.25)` and `signal_pages_sync()` (the Apply path), `pencils=3` the unit, gain and offset edit
buttons, and `pencil_edits_one=1` that the gain pencil opens a dialog titled "Gain" with exactly one spin button and that Apply changed the gain only
(offset stayed -1.25) and refilled the label. `unit_edit=1`: the unit pencil opens a dialog titled "Unit" with one entry, "kV" reached the model's signal and the page's `udpscope-unit` label, and an empty entry restored the declared unit (`unit=` prints it; it must be the mock's own). A rename bumps `generation()`, so the panels re-read the list and the measurement statistics restart. `-1` is no pencil / no dialog; `0` is a dialog that edits both or a label that did not follow the model.
## `[sigbadges]`
```
[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=124 opens=1 plus_opens=1
```
`UDPSCOPE_PROBE_SIGNAL_BADGES_MS` reads the signal badges. `badges` must equal `enabled` (the enabled, listed signals) while `enabled` is at most 6
(`kMaxSignalBadges`); beyond that `badges=0 overflow=1 overflow_text=<N> signals plus_visible=0` (the "N signals" badge replaces the badges and the "+"). `dot_plotted` is the number of
badges whose signal is on a plot (its dot is then the trace colour, not the unplotted grey). `bar_h` is the bar's allocated height and `bar_h_fixed=1` that neither it nor its natural
height moved while one signal was switched off and on again (`rebuild_signal_panels()` each time), and that the badge count followed; `0` is a bar whose height depends on its content.
`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 124 with the UDPS mock's 3 (the Source badge being hidden there) and with the overflow forced on, and 244 against the E-1608 mock with one signal and with nine -- the overflow, `enabled=9 badges=0 overflow=1 overflow_text=9 signals plus_visible=0 bar_h=36 bar_h_fixed=1`; a value that grows with the signal count is a scroller that has stopped constraining the bar). `opens=1`: clicking the
first signal badge opens `signal:<name>` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+"
(the overflow badge when overflowed) and the `signals` page.
```
[sigbadges] drag_sources=4 drop_targets=4 prepare_ok=1 reorder_before=sine,square,ramp,noise reorder_after=noise,sine,square,ramp reorder_ok=1 payload_drop=1 list_drag=0
```
The second line is the drag handles. `drag_sources` and `drop_targets` count the `GtkDragSource` / `GtkDropTarget` controllers over all the
signal badges (each badge carries one of each, so both must equal `badges`; beyond six signals the badges are gone and both read 0). `prepare_ok=1`: the first badge's
`prepare` returns a provider whose string is its signal index (the payload the plot area's drop target reads; `0` is a drag that carries nothing or the wrong
signal, `-1` no badge). `reorder_before` / `reorder_after` are the badge names in widget order around a call to the badges' own `drop` handler with the last badge dropped on
the first; `reorder_ok=1` means the last badge is now first, `badge_order` matches the widgets, and the original order was restored afterwards (so later probes see it;
`-1` with fewer than two badges). `payload_drop=1`: the signal is taken off the plots and `assign_signal_to_row(signal, 0)`, the call the plot's drop makes, leaves it plotted
(`-1` no badge). `list_drag` is the number of drag sources on the signal list's first row, and **must be 0**. **A real pointer drag cannot be simulated headless**: the
gesture's threshold, the slide-over closing under it and the pointer-driven drop are untested here; the line reads the controllers, the payload, the reorder and the assignment.
## `[badges]`
```
[badges] table_bad=0 trigger_state=off bar_h=36 bar_h_after=36 fixed=1 open_marked=1 page_open=1 closed_unmarked=1
[badges] source name=UDPStreamer pages=0 visible=0 switch_visible=0 can_target=0 second_page=- second_ok=-1 open_marked=-1 closed_unmarked=1 panel_min_w=0
```
`UDPSCOPE_PROBE_BADGES_MS` reads the badge bar under the plot. **Line 1** is the Trigger badge: `table_bad=0` (the rows of the 16-input
`trigger_badge_state()` table the probe gets wrong -- anything else is a state word that misreports the engine), `fixed=1` (the bar's height is
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=1` / `page_open=1` / `closed_unmarked=1` (the `udpscope-badge-open` class follows the slide-over's page, both ways; `0` on
any is a badge that does not say its panel is open, or one that stays marked after it closed).
**Line 2** is the Source badge, which is **hidden when the source has no pages** (UDPS, Flat UDP, a file), and every field has a value it must read:
- `name=` the source kind's name, as the badge shows it. The badge carries no link state (the status bar says it); there is no `state=` field.
- `pages=` the number of slide-over pages the source kind contributes (`source-<id>`): `0` for UDPS, `2` for the E-1608 (`source-ao`, `source-dio`).
`can_target=0` with `pages=0` is the UDPS badge being untargetable, which goes with it being hidden; `can_target=1` with `pages>0` is the button being live.
`can_target=1` with `pages=0` is a dead button that opens nothing.
- `visible=` the badge being on screen: **`0` with `pages=0`** (hidden, by design: nothing to open), `1` with `pages>0`. `visible=1` with `pages=0` is a dead pill that was supposed to be hidden; `visible=0` with pages is a source panel nothing can open.
- `switch_visible=` the segmented switch being on screen after the first source page was opened: `1` with two or more pages, `0` with one or none.
A switch shown for a single page is a control that cannot change anything; hidden with two, the second panel is unreachable.
- `second_page=` / `second_ok=` what `slideover_page()` read after the switch selected the second page, and whether it is that page. **`second_ok=1` with
two or more pages, `-1` with fewer** (there is no second page to select, so the reading is "not applicable" and not a pass). `second_ok=0` is
the switch changing its own state without the slide-over following it.
- `open_marked=` the Source badge carrying the open mark while its first page is open: `1`; **`-1` with `pages=0`** (nothing to open);
`0` with pages is a badge that does not follow the panel. `closed_unmarked=1` is the mark gone once the panel is closed.
- `panel_min_w=` the widest minimum width of any source panel, measured in the slide-over's page. The page is 360 px less the scroller's own chrome and the
scroller never scrolls horizontally, so a value above that is a panel clipped at its right edge. `0` is no panel.
## `[slideover]`
```
[slideover] open page=probe panel=360x350 at x=270 overlay=630x350 edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 plot_w before=630 open=630 plot_unchanged=1
[slideover] click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1
```
`UDPSCOPE_PROBE_SLIDEOVER_MS` opens a throw-away `probe` page and reads the panel 0.5 s later (add `UDPSCOPE_ENABLE_ALL_MS` so there are traces to hit).
**Line 1:** `panel=360x<overlay height>` (a 360 px panel as tall as the overlay), `edge_flush=1` (its right edge on the overlay's) and `full_height=1`;
`outside_hits_plot=1` / `inside_hits_panel=1` (a pick outside the panel lands in the plot, one inside it in the panel); `plot_unchanged=1` (`before=` equal to
`open=`: the panel is an overlay and does not resize the plot). `outside_hits_plot=0` is a panel allocation wider than 360 or a transparent parent that swallows
the plot's pan, drop and hover; `plot_unchanged=0` is the panel taking room from the plot. **Line 2:** `click_inside_closes=0` with `still_open=1` (a click
inside the panel does not close it), `click_outside_closes=1` (a click on the plot does), `esc_closes=1` (`slideover_escape()` closes it) and `closed=1`.
`esc_closes=1` is the function called directly; the key *through the window's handler* is the `esc:` reading of the `[keys]` line.
## `[split-probe]`
```
[split-probe] start plots=1 frows=1 cells=[1] widths=[756] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1:sine,square,ramp,noise]
[split-probe] split plots=2 frows=1 cells=[2] widths=[388,365] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1a:sine|1b:square]
[split-probe] weights 3:1 plots=2 frows=1 cells=[2] widths=[524,229] tile=1 x_equal=1 cursors_aligned=1 weights=[3,1] traces=[1a:sine|1b:square]
[split-probe] caps-calls insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8
[split-probe] roundtrip rt=1 expect=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] got=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp]
[split-probe] ruler-labels split=[row 1,row 2] mixed=[row 0,row 2]
[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[671.599976,671.599976] auto=1
[split-probe] x-axes row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] gap_px=[3.0/3.0,3.0/3.0,3.0/3.0] col_gap_px=3.0 caption_ok=[1,1,1] last_plot_zoom=1
```
`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 28 steps (0..27), 250 ms apart;
each restructures and the *next* step reports the frame that was laid out with it, so a line's label
names the arrangement the line **reads**, not the call that made it. The state lines share one format:
`plots` (`row_count()`), `frows` (`figure_row_count()`), `cells=[..]` (per figure row), `widths=[..]`
(logical pixels of each cell rect, `|` between rows; the first cell's rect includes the row's y gutter, ~52 px here, and the others have none, so equal weights read `[388,365]` -- equal *plot* widths, not equal rects; the 15 px by which this moved from `[403,350]` is the plotter giving the row's right gutter to the last cell only and spacing adjacent axes by one `col_gap`, both of which widen the plots), `weights=[..]` (the stored cell weights, `-` for a row with
none), `traces=[label:signals|..]` with the labels `plot_label()` gives (`1`, `1a`, `2b`). Every state line
carries the same three assertions:
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `tile` | **1** on every state line | a row whose cells do not fill its rect edge to edge, are not at its y and height, or are not one `col_gap` apart, or a `FigureLayout` with `cells.size() != plots` / `rows.size() != frows`: the plotter's placement or a host that did not rebuild the figure |
| `x_equal` | **1** | a plot whose x range differs from plot 0's: a cell that is not in the x link group (a rebuild that linked rows and forgot cells) |
| `cursors_aligned` | **1** | a plot whose two vertical cursors sit elsewhere than plot 0's: a per-cell cursor time instead of the one shared time; the probe turns the cursors on and resets them first so the comparison is of real positions |
The other lines, in order:
| line | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `start` / `split` | `cells=[1]` then `[2]` with `widths=[388,365]` (equal plot widths; the first rect is 52 px wider by the y gutter it alone carries) and the traces on `1a`/`1b`; a second cell starts **empty**, the probe assigns it | a split that moved or dropped the first cell's traces, or unequal default plot widths (`[376,376]` is the pre-sharing reading: a second cell that still has its own gutter) |
| `weights 3:1` | `widths=[524,229]`, **not** 3:1, and `tile=1` | not a defect: the row's y gutter and the plotter's 64 px floor per plot are taken off first and the weights share the remainder, so the shares read compressed; a result of exactly 3:1 would be the floor missing |
| `row+cell` | `cells=[2,2]`, `weights=[3,1\|-]` | row 0's cell weights lost or moved when a row and a cell were inserted below it (they belong to the figure row) |
| `caps-calls` | `insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8` | a cap that is not reached or not held: row 0 starts with two cells and takes two more, refusing the third call at 4; two rows then fit before the plot total reaches 8 and the third is refused; `plots` must stop at **8** |
| `caps` | `plots=8 frows=4 cells=[4,2,1,1]`, `tile=1` | a ninth plot, or a fifth cell in a row |
| `remove` | `plots=7 cells=[3,2,1,1]` | `remove_cell()` on a multi-cell row that removed the row, or that dropped a neighbour's traces |
| `roundtrip-io` | `wrote=1 read=1 plots_in_file=3` | a save that was refused, or a file that lost a `[[plot]]` table |
| `roundtrip` (state line) | `cells=[1,2] widths=[756\|524,229] weights=[-\|3,1]` and the three traces on `1`, `2a`, `2b` | the arrangement did not survive `collect_layout()` -> file -> `apply_layout()` |
| `roundtrip rt=` | **`rt=1`** with `got=` equal to `expect=` (row weights `h=`, cell weights `w=` and each cell's trace, read back at the file's `%.4f`) | **`rt=0`** names the difference: a weight written per plot index instead of per figure row, widths applied before the arrangement (which resets them), or a trace that moved to another cell |
| `legacy-io` | `read=1 plots_in_file=3 row_unset=1` | the stripped file still carrying a `row`, which would make the next line test nothing |
| `legacy` | `cells=[1,1,1]`, `frows=3`, `widths=[756\|756\|756]`, `traces=[1:sine\|2:square\|3:ramp]` | a session from before the split that did not open as one cell per row, or lost its signals |
| `invalid-rows` | `ok=1 frows/plots=[5/5 3/3 3/3 3/3]` | one of four unusable `row` sequences (five cells in a row; a skipped row; a row that goes back; a first row that is not 0) that was refused, or kept as a split instead of loading one cell per row |
| `editor-on` -> `cell-split` | `cells=[1]` then `cell-split-ck cells=2` and `cell-split` with `widths=[388,365]`, the new `1b` empty | the editor's cell `+` not reaching `insert_cell()`, or the figure not re-pointed after it |
| `cell-assigned` | `1a:sine\|1b:square` | an assignment into the new cell that landed on another plot |
| `col-drag` | `ok=1 left_gt_right=1 follows_80=1 relative=1 sum_80=1 right_floor=1 left_floor=1 sum_ext=1 both_above_min=1 handle_comp=1 tile=1`, then `d80=[335.8,335.8]->[415.8,255.8]`, `ext=[575.6,96.0\|96.0,575.6]` and `gutter0=52.2` (all logical **plot** widths, each plot's `layout().axes.w`, measured after a `layout()` on the new weights; 671.6 px pair) | `ok` is the AND of the checks. The compared widths are plot widths and not cell rects, because only the first cell carries the y gutter (`gutter0=` is its rect width minus its plot width, printed so a reader can tell the two apart): a drag that equates the rect widths moves the divider more than the pointer (the gutter's share is treated as weight) and reaches the floor short of 96 plot px, which reads `follows_80=0 relative=0 left_floor=0 right_floor=0 both_above_min=0`. The widths are `axes.w` and not `rect.w - y_axis_width - y_title_width`: the latter also counts the right gutter and the gap between axes, so once the plotter spaced adjacent axes by one `col_gap` and gave the right gutter to the last cell only, the old measure read `ok=0 d80=[335.8,365.0]->[397.9,302.9]` (the pair came out 700.8 instead of 671.6 and the divider followed ~62 for +80). `gutter0=` itself should read the y gutter (about 52 px; 0 means the first cell lost its gutter, a value near the cell width means the plot area collapsed). `follows_80=0`: the drag of +80 did not put the left cell within 1 px of `start + 80`; `relative=0`: three updates (+30, +60, +80) from one begin did not land at +80, i.e. the drag read the live weights instead of the begin snapshot `layout_col_drag_start`; `sum_80=0` / `sum_ext=0`: the pair's total changed instead of being redistributed; `right_floor=0` / `left_floor=0`: after +10000 / -10000 the shrinking cell does not read `kMinCellPx` (96) within 1 px; `both_above_min=0`: a cell below 96 px; `tile=1` is `split_probe_tile()`, the same check the state lines print. `left_gt_right` is `d80.l > d80.r` (after +80 from equal halves the left is the wider). `handle_comp=1`: the pure `layout_col_total_offset(offset, handle_now, handle_begin)` adds the handle's displacement back (`(40,140,100)` and `(80,100,100)` read 80, `(-25,70,100)` reads -55); `0` is a gesture that would leave the divider lagging behind the pointer (it converges on half the travel). The probe goes through `layout_col_drag_begin()` + `layout_col_apply()`, the pair the gesture calls; **a real pointer drag is not exercised headlessly**, so the handle's position read in `on_layout_col_update` is covered only by this helper |
| `pre-insert0` (state line) | after the extreme drags `widths=[148,605]`, `weights=[0.117734,1.88227]` | the last extreme drag was -10000: the left *plot* sits on the 96 px floor (the rect reads 148 = 96 plus the 52 px y gutter) and the right takes the rest of the pair. The weights follow from the same 96 px floor over the 671.6 px pair: `S * (96 - 64) / (671.6 - 128)` with `S = 2` (it read `0.111732` while the pair was 700.8 px). A left rect reading 146 (`widths=[146,607]`) is a 93.3 px plot floor plus the 52.2 px gutter: the drag was measuring cell rects (the gutter's share treated as weight) and not plot widths, so the left plot stopped short of the 96 px floor |
| `insert0-move` | `move_ok=1 before=[sine,square] after=[,sine,square]` | an insert at the left edge that renumbered the cells instead of moving their traces right with them |
| `remove-mid` | `rm_ok=1 after=[,square]` | removing a middle cell that dropped or shifted the wrong neighbour |
| `row-weights` | `wt_ok=1 before=[3,1,1] after=[1,1]` | the row weights not following an insert or removal of a *figure row* (the vector had the plot-count length once and drifted off the rows) |
| `sensitivity` | `cell_plus_sensitive=0 row_plus_sensitive=0 plots=8` | `-1` is a button the probe could not find; `1` is a `+` left clickable at the 4-cell cap (cell) or at 8 plots (row) |
| `two-split-rows` | `cells=[2,2]`, labels `1a 1b 2a 2b`, all empty | `set_cells_per_row()` leaving the old plots' contents or labels behind |
| `drop-rowat` | `ok=1 rowat=[1a=0,1b=1,2a=2,2b=3]` | `PlotArea::row_at(x, y)` choosing by y alone, which would put every cell of a row on its first plot: a drop or a right click on `1b` landing in `1a` |
| `plot-on-buttons` | `[1a:row 1, cell 1 of 2\|1b:row 1, cell 2 of 2\|2a:row 2, cell 1 of 2\|2b:row 2, cell 2 of 2]` | a signals-page button labelled with the flat index, or without the cell tooltip; a one-cell row keeps its plain label and the tooltip "draw this signal on this plot" |
| `assign-1b` / `drop-2b` | `assigned=1 notice='plotting 'sine' on plot 1b'`; `dropped=1 notice='plotting 'square' on plot 2b'` | a notice that names the plot by number alone |
| `after-assign` (state line) | `traces=[1a:-\|1b:sine\|2a:-\|2b:square]` | the signal went to a neighbouring cell |
| `ruler-labels` | `split=[row 1,row 2]` and, with cells `{1,2}`, `mixed=[row 0,row 2]` | the cursors pane's ruler lines (a label read through the pane's own widgets): **one line per figure row**, because the cells of a split row share their y axis and so their rulers. A split row is `row <figure row, from 1>` and sits on its first cell; an unsplit row keeps `row <plot index>` (**0-based**, as it always was -- `row 0` is the first plot, not a defect). One line per cell (`plot 1a,plot 1b,...`) is the pre-sharing reading |
| `shared-y` | `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, `ranges=[A=-3..7 B=-3..7 \| fit A=B]`, `axes_w` equal to within a pixel (both `671.6`; `642.4` was the reading before the plotter gave the right gutter to the last cell only), `auto=1` | a row of two cells with a trace each. `y_shared`: a `set_y_range(-3, 7)` on cell A is B's after one frame. `y_gutter`: only cell A has a y gutter (B's `y_axis_width` and `y_label_area.w` are 0) and the two plots are equally wide. `y_fit`: `fit_all()` leaves both cells on one range that covers both traces (not the manual one). `ruler_mirror`: a ruler written on A and read back by `sync_cursors(0)` is B's too. A 0 in the first two is `set_share_row_y(true)` not reaching the figure; in `y_fit`, no frame between `fit_all()` and the read (the probe restructures to pump one) or a group that did not follow the flag |
| `x-axes` | `row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] caption_ok=[1,1,1] last_plot_zoom=1`, `gap_px` equal on both sides of each `/` and equal to `col_gap_px` | three arrangements, read in order: `{2}/{1}`, `{2}/{2}` and `{2}/{2}` with cell weights 1:3 over 1:1, every plot on channel 0 and the view pinned to an absolute span (`pin_pause_window(100.0, 100.05)`), because the live axis is `[-window, 0]` and never relative. `row0_labelled`: `x_strip_has_labels(x_strip_mode())` of row 0's plots, a row draws labels only if its columns differ from the row below's, so `[1,0,1]` (differs, equal columns, differs by weight); `bottom_labelled`: the bottom row always labels. `gap_ok` / `gap_px`: `axes[k+1].x - axes[k].right()` against `col_gap` within 0.5 px, in the logical px the line prints; a different gap is the plotter's inter-axes spacing changed or a cell that still carries its own right gutter. `caption_ok`: a plot has a non-empty `x_note` (the `t0 = ...` caption) **exactly when** its strip draws labels (`-1` means the labels were not relative, so the caption could not be checked: the pin did not take); `0` is `update_time_labels()` putting the caption on the bottom row only, so a labelled row above reads no `t0`. `last_plot_zoom`: after a narrower x range is written onto the last plot's member (a gesture on a labelled strip that is not row 0) every plot adopts it, `0` is `hold_pinned_window()` looking at row 0 only and reverting the gesture. The step restores the window with `set_time_window()` afterwards |
The run ends with the usual `gtk warnings: 0` and no `Gtk-CRITICAL`. `x_equal=1` and `cursors_aligned=1` on every state line are
the only readings of the shared time window across cells.
+179 -64
View File
@@ -15,29 +15,47 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
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.
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 **9248** against C99's 4095-byte string-literal limit with
the five 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
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`,
@@ -187,6 +205,19 @@ 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
@@ -333,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.
@@ -414,12 +445,75 @@ 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.
@@ -439,6 +533,15 @@ precedent, not an exception.
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
@@ -548,7 +651,7 @@ as distinct from the fault injection below, which exercises ingest:
`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-mb 1` against `--rate 20000 --batch 200`, with
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).
@@ -556,7 +659,7 @@ as distinct from the fault injection below, which exercises ingest:
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=…`).
@@ -594,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
@@ -610,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*:
@@ -641,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
@@ -693,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**.
@@ -726,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.
@@ -965,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
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

@@ -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,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
+4 -2
View File
@@ -28,10 +28,12 @@
<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,
<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>
+48 -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,7 @@
<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. For the same reason a start
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">
@@ -81,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>
@@ -127,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>
@@ -152,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
+1 -1
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
+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">
+23 -19
View File
@@ -23,15 +23,16 @@
<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>
@@ -117,7 +118,7 @@
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 (<gui>--buffer-mb</gui>) does.
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">
@@ -170,29 +171,32 @@
<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">
+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>
+34 -5
View File
@@ -39,6 +39,25 @@ 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 {
@@ -70,7 +89,11 @@ void print_usage(std::FILE* out, const char* argv0) {
" --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-mb MB size of each of the two sample buffers (default 200)\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"
@@ -125,10 +148,16 @@ Config parse_args(int argc, char** argv) {
} 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-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 == "--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");
+16 -8
View File
@@ -11,7 +11,7 @@
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).
@@ -40,13 +40,21 @@ struct Config {
std::string session_host() const;
std::uint16_t session_port() const;
/// 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;
/// 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).
@@ -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
View File
@@ -22,6 +22,7 @@
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-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>
+39 -10
View File
@@ -4,6 +4,7 @@
#include "layout_store.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
@@ -328,7 +329,7 @@ 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;
@@ -347,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 {};
@@ -439,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();
@@ -446,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)) {
@@ -460,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();
@@ -568,8 +573,8 @@ 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") {
@@ -583,9 +588,11 @@ bool load_layout(const std::string& path, LayoutState& out) {
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") {
@@ -605,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();
@@ -684,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);
@@ -740,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()) {
@@ -768,11 +790,15 @@ bool save_layout(const std::string& path, const LayoutState& state) {
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]);
@@ -783,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);
+25 -7
View File
@@ -24,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
@@ -36,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.
@@ -73,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;
@@ -138,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;
};
@@ -161,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;
};
+5 -1
View File
@@ -24,7 +24,11 @@ int main(int argc, char** 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;
+131 -41
View File
@@ -3,6 +3,8 @@
#include "model.hpp"
#include "status_text.hpp" // `format_samples()` / `format_bytes()`, for the one line a clipped plan prints
#include <algorithm>
#include <chrono>
#include <cmath>
@@ -40,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
@@ -69,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
@@ -103,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
@@ -178,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;
}
@@ -224,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
@@ -567,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
@@ -601,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);
}
@@ -608,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_);
@@ -616,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 "
@@ -719,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;
}
+81 -15
View File
@@ -104,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.
@@ -137,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.
@@ -187,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
@@ -436,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_; }
@@ -546,13 +586,13 @@ class ScopeModel {
std::uint64_t persist_dropped() const noexcept {
return persist_dropped_.load(std::memory_order_relaxed);
}
/// The bytes persistence may retain, which is the ring's own byte budget (`--buffer-mb`): 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.
/// 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 {
const int mb = cfg_.buffer_mb > 0 ? cfg_.buffer_mb : 0;
return static_cast<std::size_t>(mb) * 1024u * 1024u;
return static_cast<std::size_t>(part_.bytes());
}
/// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and
@@ -671,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
@@ -734,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 --------------------------------------------------------
@@ -816,6 +879,9 @@ class ScopeModel {
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};
+218 -16
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cmath>
#include <span>
namespace udpscope {
namespace {
@@ -67,6 +68,9 @@ 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
@@ -153,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();
}
@@ -194,23 +243,120 @@ void RowSet::clear_row(Row& r) {
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(
@@ -302,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);
@@ -319,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);
@@ -915,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;
}
@@ -953,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
@@ -1089,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) {
+51 -8
View File
@@ -155,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
@@ -169,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)]; }
@@ -563,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,
+6 -3
View File
@@ -77,7 +77,7 @@ bool on_of(GtkWidget* w) { return adw_switch_row_get_active(ADW_SWITCH_ROW(w));
/// 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;
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()>>();
@@ -121,6 +121,7 @@ struct Form {
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) {
@@ -156,6 +157,7 @@ struct Form {
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(
@@ -356,6 +358,7 @@ class E1608Kind final : public SourceKind {
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);
@@ -384,7 +387,7 @@ class E1608Kind final : public SourceKind {
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->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],
@@ -395,7 +398,7 @@ class E1608Kind final : public SourceKind {
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->do_init,
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})
+28 -2
View File
@@ -55,7 +55,7 @@ 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* 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);
@@ -63,6 +63,7 @@ GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr) {
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;
}
@@ -77,6 +78,7 @@ 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;
@@ -148,6 +150,22 @@ void ao_refresh(AoCtx* c) {
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) {
@@ -198,7 +216,15 @@ GtkWidget* build_ao(E1608Source* src) {
ao_sensitivity(w);
}
GtkWidget* root = panel_root(groups[0], groups[1]);
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);
+29 -2
View File
@@ -61,6 +61,7 @@ E1608Settings E1608Settings::from(const SourceConfig& c) {
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;
@@ -133,6 +134,7 @@ std::string E1608Settings::problem() const {
}
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 {};
}
@@ -230,6 +232,7 @@ void E1608Source::fail(const std::string& msg, bool fatal) {
any_gen_ = false;
readouts_->gen_on[0].store(false);
readouts_->gen_on[1].store(false);
gen_rate_reset();
if (fatal) {
state_ = State::Halted;
} else {
@@ -327,7 +330,7 @@ void E1608Source::connect_and_start() {
const auto now = Clock::now();
next_slow_ = now;
next_status_ = now + std::chrono::milliseconds(500);
next_gen_ = now + std::chrono::milliseconds(20);
next_gen_ = now + gen_period();
slow_read();
state_ = State::Scanning;
@@ -409,7 +412,7 @@ void E1608Source::run_timers() {
check_status();
}
if (any_gen_ && now >= next_gen_) {
next_gen_ = now + std::chrono::milliseconds(20);
next_gen_ = now + gen_period(); // from now, not from the old deadline: a slow board gets no catch-up burst
generator_tick(now);
}
}
@@ -475,22 +478,46 @@ 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();
}
}
+6
View File
@@ -36,6 +36,7 @@ struct E1608Settings {
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] = {};
@@ -62,6 +63,7 @@ struct E1608Readouts {
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) {
@@ -117,6 +119,8 @@ class E1608Source final : public DataSource {
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;
@@ -153,6 +157,8 @@ class E1608Source final : public DataSource {
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_;
+75 -29
View File
@@ -7,7 +7,9 @@
#include "help_window.hpp"
#include "layout_store.hpp"
#include "model.hpp" // `buffer_ticks()` / `planned_bytes()`: what a request becomes
#include "settings.hpp"
#include "status_text.hpp" // `format_samples()` and friends
#include "source/registry.hpp"
// The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the
// chooser's help menu is the *same* menu the window's Help view is, not a second one that merely
@@ -21,6 +23,7 @@
#include <unistd.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -47,7 +50,7 @@ void apply_source(Config& cfg, const KnownSource& src) {
// The configuration's own startup settings. They are read *before* the model exists, which is the
// only moment a buffer size can be applied at all -- and the reason they are stored per
// configuration rather than only passed on the command line.
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
if (src.buffer_samples > 0) cfg.buffer_samples = src.buffer_samples;
// The list's own copy of the clock, but only where nothing newer has said: with the preferences
// open from this dialog a user can state relative time a minute before clicking the row, and that
// statement must survive the click that follows it.
@@ -59,8 +62,9 @@ void apply_source(Config& cfg, const KnownSource& src) {
void report_sources(const std::vector<KnownSource>& known) {
std::fprintf(stderr, "[sources] %zu known:", known.size());
for (const KnownSource& s : known) {
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d buffer=%.6g relative=%d capture=%u",
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals, s.buffer_mb,
std::fprintf(stderr, " %s:%u/%s plots=%d signals=%d samples=%llu relative=%d capture=%u",
s.host.c_str(), s.port, s.config.c_str(), s.rows, s.signals,
static_cast<unsigned long long>(s.buffer_samples),
s.relative_time ? 1 : 0, s.capture_packets);
if (!s.comment.empty()) std::fprintf(stderr, " note='%s'", s.comment.c_str());
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
@@ -405,9 +409,7 @@ void chooser_populate(Chooser& c) {
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
if (s.buffer_mb > 0.0) {
sub += ", buffer " + std::to_string(static_cast<int>(s.buffer_mb)) + " MB";
}
if (s.buffer_samples > 0) sub += ", buffer " + format_samples(s.buffer_samples) + " samples";
if (s.relative_time) sub += ", relative time";
if (s.capture_packets > 0) {
sub += ", capture " + std::to_string(s.capture_packets) + " packets";
@@ -442,6 +444,32 @@ void chooser_populate(Chooser& c) {
}
}
/// The form's sample budget as a count: the spin is in millions, and a count is what is stored.
std::uint64_t form_buffer_samples(const Chooser& c) {
const double m = adw_spin_row_get_value(c.form_buffer);
return static_cast<std::uint64_t>(std::max(128.0, std::round(m * 1.0e6)));
}
/// Says under the "Samples per signal" row what the number will become: the rings are powers of two,
/// so the request is a ceiling, and the memory limit can lower it further. The limit is judged for
/// *one* signal here -- the channel count is not known until a stream declares it -- so a clip shown
/// is certain, and the status line says what a real stream got. The cost line gives the two figures
/// the user can multiply by their own signal count.
void update_buffer_note(Chooser& c) {
if (c.form_buffer == nullptr) return;
const std::uint64_t requested = form_buffer_samples(c);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
const std::uint64_t limit = c.cfg->memory_limit_mb * 1024u * 1024u;
const std::uint64_t wanted = buffer_ticks(requested);
std::uint64_t used = wanted;
while (used > 128 && planned_bytes(used, 1, relative) > limit) used /= 2;
const std::string note =
describe_buffer(requested, used, used < wanted, limit) + ". The rings take " +
format_bytes(used * sizeof(double) * (relative ? 2 : 1)) + " for the clock and " +
format_bytes(used * sizeof(float)) + " per signal.";
adw_action_row_set_subtitle(ADW_ACTION_ROW(c.form_buffer), note.c_str());
}
void form_close(Chooser& c) {
// The form's own `closed` handler clears the pointer, so this is the only place that has to know how
// an `AdwDialog` closes.
@@ -497,7 +525,7 @@ void on_form_connect(GtkButton*, gpointer user) {
const std::string iface = udps ? scfg.get("interface") : std::string();
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
const double buffer_mb = adw_spin_row_get_value(c.form_buffer);
const std::uint64_t buffer_samples = form_buffer_samples(c);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
if (name.empty()) name = "default";
@@ -513,7 +541,7 @@ void on_form_connect(GtkButton*, gpointer user) {
}
// The startup settings. Applied here because this runs *before* `ScopeModel` exists, which is the
// only moment a buffer size can be applied at all -- and stored, so the next run gets them back.
c.cfg->buffer_mb = buffer_mb;
c.cfg->buffer_samples = buffer_samples;
c.cfg->relative_time = relative;
LayoutState st;
@@ -523,7 +551,7 @@ void on_form_connect(GtkButton*, gpointer user) {
load_layout(configuration_path(c.edit_host, c.edit_port, c.edit_config), st);
}
st.comment = note;
st.buffer_mb = buffer_mb;
st.buffer_samples = buffer_samples;
st.relative_time = relative;
st.multicast = group;
st.interface_addr = iface;
@@ -541,9 +569,9 @@ void on_form_connect(GtkButton*, gpointer user) {
c.saved_config = name;
c.saved_note = note;
std::fprintf(stderr,
"[sources] configuration %s: %s:%u/%s note='%s' buffer=%.6g relative=%d\n",
"[sources] configuration %s: %s:%u/%s note='%s' samples=%llu relative=%d\n",
editing ? "saved" : "created", host.c_str(), port, name.c_str(), note.c_str(),
buffer_mb, relative ? 1 : 0);
static_cast<unsigned long long>(buffer_samples), relative ? 1 : 0);
std::fflush(stderr);
} else {
std::fprintf(stderr, "[sources] configuration write failed: %s:%u/%s\n", host.c_str(), port,
@@ -624,12 +652,24 @@ void open_form(Chooser& c, int edit_index = -1) {
// the rows' subtitles, so an empty field still says what empty means.
gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : "");
adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0
? src->buffer_mb
: std::max(1.0, c.cfg->buffer_mb));
adw_spin_row_set_value(c.form_buffer, double(src != nullptr && src->buffer_samples > 0
? src->buffer_samples
: c.cfg->buffer_samples) /
1.0e6);
update_buffer_note(c);
g_signal_connect(c.form_buffer, "notify::value", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) {
update_buffer_note(*static_cast<Chooser*>(user));
}),
&c);
adw_switch_row_set_active(c.form_relative,
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
: FALSE);
// The relative clock doubles the time ring, so the note under the sample budget follows it.
g_signal_connect(c.form_relative, "notify::active", G_CALLBACK(+[](GObject*, GParamSpec*, gpointer user) {
update_buffer_note(*static_cast<Chooser*>(user));
}),
&c);
update_buffer_note(c);
// The dropdown lists the registered kinds. A new host starts on the kind the command line named
// (UDPS by default); an edit starts on the configuration's own kind and cannot change it --
@@ -701,10 +741,11 @@ void report_pick(const Chooser& c) {
// size would look identical here otherwise, and the buffer is the one of them a user notices
// only after a long capture has been sized wrong.
std::fprintf(stderr,
"[sources] picked %s:%u multicast=%s interface=%s buffer=%.6g relative=%d "
"[sources] picked %s:%u multicast=%s interface=%s samples=%llu relative=%d "
"capture=%u config=%s note='%s'\n",
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
c.cfg->interface_addr.c_str(), c.cfg->buffer_mb, c.cfg->relative_time ? 1 : 0,
c.cfg->interface_addr.c_str(),
static_cast<unsigned long long>(c.cfg->buffer_samples), c.cfg->relative_time ? 1 : 0,
c.cfg->capture_packets,
(c.saved_config.empty() ? (c.known.empty() ? "-" : c.known.front().config)
: c.saved_config)
@@ -748,6 +789,10 @@ void open_chooser_preferences(Chooser& c) {
// stored configuration, and it works in both directions -- turning it on *and* off.
c.cfg->relative_time = s.relative_time;
c.cfg->relative_time_specified = true;
// The memory limit is read by the model when it is built, which is after this returns, so the
// chooser's own change is the newest word on it for the run about to start.
c.cfg->memory_limit_mb = static_cast<std::uint64_t>(s.memory_limit_mb);
c.cfg->memory_limit_specified = true;
if (!written) {
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
std::fflush(stderr);
@@ -922,8 +967,8 @@ void chooser_tour_mark_seen() {
void chooser_tour_begin(Chooser& c) {
if (c.tour.card == nullptr) return;
chooser_tour_mark_seen();
// The chooser's own stack, so a stop can name the page it stands on (the window does the same with
// its sidebar in `start_tour()`).
// The chooser's own stack, so a stop can name the page it stands on (the window has no stack:
// `start_tour()` hooks its slide-over pages in through `on_page`).
c.tour.page_stack = c.page_stack;
// The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen.
std::vector<ui_impl::TourStop> stops;
@@ -1171,7 +1216,7 @@ gboolean pick_probe(gpointer user) {
c.form_cfg.write(sc);
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
adw_spin_row_set_value(c.form_buffer, 32);
adw_spin_row_set_value(c.form_buffer, 0.5);
adw_switch_row_set_active(c.form_relative, TRUE);
// 9/9 for UDPS: the form's row titles are mnemonics (`Source _type`, `_Server`, `_Port`, ...). The
// total follows the kind's row count: five rows are the chooser's, the rest the kind's own.
@@ -1183,7 +1228,7 @@ gboolean pick_probe(gpointer user) {
std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines,
5 + c.form_cfg.rows.size());
std::fprintf(stderr,
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d "
"[sources] form: name='%s' note='%s' samples_m=%.6g relative=%d "
"connect_sensitive=%d\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
@@ -1216,14 +1261,14 @@ gboolean pick_probe(gpointer user) {
// The reading is the *pre-fill*: an edit form that opens empty is the defect this asserts
// against, and the button's label says which mode it is in.
std::fprintf(stderr,
"[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n",
"[sources] edit form: name='%s' note='%s' samples_m=%.6g relative=%d button='%s'\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
adw_spin_row_get_value(c.form_buffer),
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_button_get_label(GTK_BUTTON(c.form_connect)));
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
adw_spin_row_set_value(c.form_buffer, 16);
adw_spin_row_set_value(c.form_buffer, 0.25);
adw_switch_row_set_active(c.form_relative, FALSE);
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
// survive, or the chooser lists the same session twice.
@@ -1263,16 +1308,17 @@ void apply_stored_settings(Config& cfg) {
// Only where nothing newer was said: a flag the user typed, or the preferences they just applied,
// wins -- because it is the more recent statement of intent, and the file is not a place to hide a
// surprise from whoever just made it.
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
if (!cfg.buffer_samples_specified && st.buffer_samples > 0) cfg.buffer_samples = st.buffer_samples;
const bool relative = !cfg.relative_time_specified && st.relative_time;
if (relative) cfg.relative_time = true;
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative ||
const bool applied = (!cfg.buffer_samples_specified && st.buffer_samples > 0) || relative ||
st.capture_packets > 0;
if (applied) {
std::fprintf(stderr,
"[sources] settings from configuration '%s': buffer=%.6g relative=%d capture=%u\n",
want.c_str(), cfg.buffer_mb, cfg.relative_time ? 1 : 0, cfg.capture_packets);
"[sources] settings from configuration '%s': samples=%llu relative=%d capture=%u\n",
want.c_str(), static_cast<unsigned long long>(cfg.buffer_samples),
cfg.relative_time ? 1 : 0, cfg.capture_packets);
std::fflush(stderr);
}
}
@@ -1330,9 +1376,9 @@ void loading_show(const Config& cfg) {
head = "Connecting to " + cfg.server + ":" + std::to_string(cfg.port);
if (!cfg.multicast.empty()) head += " (" + cfg.multicast + ")";
}
char buf[128];
std::snprintf(buf, sizeof(buf), "planning the %.0f MB sample buffer", cfg.buffer_mb);
sub = buf;
sub = "planning the buffer: " + format_samples(buffer_ticks(cfg.buffer_samples)) +
" samples per signal, " + format_bytes(cfg.memory_limit_mb * 1024u * 1024u) +
" memory limit";
} else {
const std::size_t slash = cfg.open_waveform.rfind('/');
head = "Opening " + (slash == std::string::npos ? cfg.open_waveform
+49 -4
View File
@@ -41,6 +41,43 @@ std::string format_rate(double per_s) {
return buf;
}
std::string format_samples(std::uint64_t n) {
char buf[32];
const double d = static_cast<double>(n);
if (n >= 1000000000ull) {
std::snprintf(buf, sizeof(buf), "%.3g G", d / 1.0e9);
} else if (n >= 1000000ull) {
std::snprintf(buf, sizeof(buf), "%.3g M", d / 1.0e6);
} else if (n >= 1000ull) {
std::snprintf(buf, sizeof(buf), "%.3g k", d / 1.0e3);
} else {
std::snprintf(buf, sizeof(buf), "%llu", static_cast<unsigned long long>(n));
}
return buf;
}
std::string format_bytes(std::uint64_t bytes) {
char buf[32];
const double d = static_cast<double>(bytes);
if (bytes >= (1ull << 30)) {
std::snprintf(buf, sizeof(buf), "%.3g GiB", d / double(1ull << 30));
} else if (bytes >= (1ull << 20)) {
std::snprintf(buf, sizeof(buf), "%.3g MiB", d / double(1ull << 20));
} else if (bytes >= (1ull << 10)) {
std::snprintf(buf, sizeof(buf), "%.3g KiB", d / double(1ull << 10));
} else {
std::snprintf(buf, sizeof(buf), "%llu B", static_cast<unsigned long long>(bytes));
}
return buf;
}
std::string describe_buffer(std::uint64_t requested, std::uint64_t used, bool memory_limited,
std::uint64_t memory_limit_bytes) {
std::string out = format_samples(requested) + " requested, " + format_samples(used) + " used";
if (memory_limited) out += " (limited by the " + format_bytes(memory_limit_bytes) + " memory limit)";
return out;
}
std::string format_glance(const StatusSnapshot& s) {
std::string out = s.state_word;
out += " · ";
@@ -68,17 +105,25 @@ std::string format_detail(const StatusSnapshot& s) {
char buf[1024];
std::snprintf(buf, sizeof(buf),
"[%s] %s %s | rate %s | packets %llu frames %llu gaps %llu | %zu signals, "
"%zu channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s "
"span, time x%llu",
"%zu channels | plot 1: %d trace(s), %zu samples | buffer %s samples/signal "
"(%s), %.2f s span, time x%llu",
s.state_detail.c_str(), s.connected ? "connected" : "waiting",
s.have_config ? "configured" : "-",
s.rate_text.empty() ? "--" : s.rate_text.c_str(),
static_cast<unsigned long long>(s.packets),
static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(s.gaps), s.signals, s.channels,
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride));
s.plot1_traces, s.plot1_samples, format_samples(s.buffer_samples).c_str(),
s.buffer_bytes > 0 ? format_bytes(s.buffer_bytes).c_str() : "not planned",
s.span_s, static_cast<unsigned long long>(s.time_stride));
std::string out = buf;
// The request is quoted only where it differs from what was planned, and the limit only where it
// is the reason: a clean run says the figure once, and a clipped one says why in the same breath.
if (s.memory_limited) {
out += " | limited to " + format_samples(s.buffer_samples) + " samples by the " +
format_bytes(s.memory_limit_bytes) + " memory limit (" +
format_samples(s.buffer_requested) + " requested)";
}
// Nothing about resequencing is said at all while all six read zero, which is a stream that
// never lost ordering: the field would otherwise be six zeros on every clean run, and this
// string is read by eye out of the exit dump.
+22 -1
View File
@@ -41,7 +41,15 @@ struct StatusSnapshot {
std::size_t channels = 0;
int plot1_traces = 0;
std::size_t plot1_samples = 0;
double buffer_mb = 0.0;
/// The sample budget as planned: slots per signal the rings hold (the request rounded down to a
/// power of two and clipped by the memory limit), what was asked for, and the bytes those rings
/// occupy -- 0 until a stream has planned a buffer. `memory_limited` says the limit, and not the
/// rounding, made `buffer_samples` smaller than the request.
std::uint64_t buffer_samples = 0;
std::uint64_t buffer_requested = 0;
std::uint64_t buffer_bytes = 0;
std::uint64_t memory_limit_bytes = 0;
bool memory_limited = false;
double span_s = 0.0;
std::uint64_t time_stride = 1;
bool have_event = false;
@@ -82,6 +90,19 @@ std::string format_detail(const StatusSnapshot& s);
/// cannot track the stream. Moved here from ui.cpp, which still calls it.
std::string format_rate(double hz);
/// A sample count as "67.1 M": `%.3g` of the count in the unit it reads shortest in (k, M, G; decimal,
/// like `--buffer-samples`). Under a thousand it is the bare number.
std::string format_samples(std::uint64_t n);
/// A byte count in binary units, "168 MiB" / "8 GiB", `%.3g` so the width does not track the figure.
std::string format_bytes(std::uint64_t bytes);
/// The sentence the source form, the status popover and the exit dump share for a sample budget:
/// "100 M requested, 67.1 M used", and " (limited by the 1 GiB memory limit)" when the limit clipped it.
/// `requested` is the user's number, `used` the planned slots per signal.
std::string describe_buffer(std::uint64_t requested, std::uint64_t used, bool memory_limited,
std::uint64_t memory_limit_bytes);
/// An elapsed time, stepping to a coarser unit rather than growing digits, so its width
/// cannot track the stream. Moved here from ui.cpp.
std::string format_elapsed(double seconds);
+645 -294
View File
File diff suppressed because it is too large. Load diff
+53 -58
View File
@@ -2,8 +2,8 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The events pane: the revealer that slides it in, the header with the way back to the live stream, the two
actions that read the selection, and the list inside its scroller.
The events list, the content of the slide-over's `events` page: the header with the way back to the live
stream, the two actions that read the selection, and the list inside its scroller.
The rows are not here. There is one per latched event in the cache, it carries the time, the duration, the
signals and the rate, and the whole list is rebuilt whenever the cache changes — so the file holds the
@@ -15,73 +15,68 @@
instead. Both actions start insensitive because a selection starts empty, and `update_group_buttons()`
owns them from there.
A width floor, not a width: the divider sets the pane's width, and this is what it needs to stay usable at
its narrowest.
A width floor, not a width: the slide-over sets the page's width, and this is what the list needs to stay
usable at its narrowest.
-->
<interface>
<object class="GtkRevealer" id="events_pane">
<property name="transition-type">slide-right</property>
<object class="GtkBox" id="events_box">
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<property name="width-request">170</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-top">6</property>
<child>
<object class="GtkBox" id="events_box">
<property name="orientation">vertical</property>
<object class="GtkBox" id="events_head">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="width-request">170</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-top">6</property>
<child>
<object class="GtkBox" id="events_head">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="events_title">
<property name="label" translatable="yes">&lt;b&gt;Events&lt;/b&gt;</property>
<property name="use-markup">1</property>
<property name="hexpand">1</property>
<property name="halign">start</property>
</object>
</child>
<child>
<object class="GtkButton" id="events_live">
<property name="label" translatable="yes">Live</property>
<property name="css-classes">flat</property>
<property name="tooltip-text" translatable="yes">go back to the live stream after replaying a stored event</property>
</object>
</child>
<object class="GtkLabel" id="events_title">
<property name="label" translatable="yes">&lt;b&gt;Events&lt;/b&gt;</property>
<property name="use-markup">1</property>
<property name="hexpand">1</property>
<property name="halign">start</property>
</object>
</child>
<child>
<object class="GtkBox" id="events_actions">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child>
<object class="GtkButton" id="events_compare">
<property name="label" translatable="yes">Compare</property>
<property name="tooltip-text" translatable="yes">the two selected events in two plots side by side, with the axes synchronised</property>
<property name="hexpand">1</property>
<property name="sensitive">0</property>
</object>
</child>
<child>
<object class="GtkButton" id="events_accumulate">
<property name="label" translatable="yes">Accumulate</property>
<property name="tooltip-text" translatable="yes">up to four selected events overlaid in the same traces, translucent, so where they agree the trace is brighter</property>
<property name="hexpand">1</property>
<property name="sensitive">0</property>
</object>
</child>
<object class="GtkButton" id="events_live">
<property name="label" translatable="yes">Live</property>
<property name="css-classes">flat</property>
<property name="tooltip-text" translatable="yes">go back to the live stream after replaying a stored event</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="events_actions">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child>
<object class="GtkButton" id="events_compare">
<property name="label" translatable="yes">Compare</property>
<property name="tooltip-text" translatable="yes">the two selected events in two plots side by side, with the axes synchronised</property>
<property name="hexpand">1</property>
<property name="sensitive">0</property>
</object>
</child>
<child>
<object class="GtkScrolledWindow" id="events_scroll">
<property name="vexpand">1</property>
<property name="hscrollbar-policy">never</property>
<child>
<object class="GtkListBox" id="events_list">
<property name="activate-on-single-click">0</property>
<property name="selection-mode">multiple</property>
</object>
</child>
<object class="GtkButton" id="events_accumulate">
<property name="label" translatable="yes">Accumulate</property>
<property name="tooltip-text" translatable="yes">up to four selected events overlaid in the same traces, translucent, so where they agree the trace is brighter</property>
<property name="hexpand">1</property>
<property name="sensitive">0</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow" id="events_scroll">
<property name="vexpand">1</property>
<property name="hscrollbar-policy">never</property>
<child>
<object class="GtkListBox" id="events_list">
<property name="activate-on-single-click">0</property>
<property name="selection-mode">multiple</property>
</object>
</child>
</object>
+5 -18
View File
@@ -2,8 +2,7 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The header bar: the time-window control's slot, the trigger, pause and events group with the separator that
marks where the *view's* controls end, and the three at the far end.
The header bar: the time-window control's slot, the trigger and pause group, and the three at the far end.
The bar carries only the controls you reach for *while watching a signal*; everything you set up once lives
in the overflow menu, which is code — its rows act on the `RowSet` directly and one of them is a toggle
@@ -17,8 +16,8 @@
from the buffer's limits), and `gtk_header_bar_pack_start()` appends *after* whatever a file has already
packed, so a control added by code would land to the right of this whole group instead of at its head.
The events toggle starts hidden: the event history is a *trigger* thing, and asking for it is one click
from the mode that produces it.
There is no events control here: the event list is the `events` page of the slide-over, reached by the dock's
Manage button.
-->
<interface>
<object class="GtkHeaderBar" id="header_bar">
@@ -39,18 +38,6 @@
<property name="tooltip-text" translatable="yes">pause acquisition</property>
</object>
</child>
<child type="start">
<object class="GtkSeparator">
<property name="orientation">vertical</property>
</object>
</child>
<child type="start">
<object class="GtkToggleButton" id="events_toggle">
<property name="icon-name">udpscope-events-symbolic</property>
<property name="tooltip-text" translatable="yes">the event history: the triggered acquisitions kept in the cache, with a picture of each</property>
<property name="visible">0</property>
</object>
</child>
<child type="end">
<object class="GtkMenuButton" id="menu_button">
<property name="icon-name">open-menu-symbolic</property>
@@ -59,8 +46,8 @@
</child>
<child type="end">
<object class="GtkToggleButton" id="sidebar_toggle">
<property name="icon-name">sidebar-show-symbolic</property>
<property name="tooltip-text" translatable="yes">show the side panel</property>
<property name="icon-name">udpscope-measure-symbolic</property>
<property name="tooltip-text" translatable="yes">show the readouts dock (cursors, measurements, events)</property>
<property name="active">1</property>
</object>
</child>
+105 -99
View File
@@ -3,7 +3,9 @@
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The main window's skeleton: the window, the split view that puts the sidebar beside the plot, the
events pane's divider, the plot column with the status line under it, and the sidebar's tab stack.
plot column (`left_column`: `plot_overlay` > `plot_box`, the slide-over panel being an overlay child added
in code) with the readouts strip (only while the dock is not on screen), the badge bar and the status
line under it, and the sidebar, which is the dock.
The sidebar is an `AdwOverlaySplitView`, which is what a utility sidebar is in GNOME (as against
`AdwNavigationSplitView`, which is for navigation lists). Three things follow from that, and they are
@@ -19,14 +21,13 @@
What is *not* here is as deliberate as what is. Everything this file nests is fixed — the shape of
the window does not depend on the stream, the trigger or the layout — while the subtrees that do are
built by code and put in place: the events pane, into `events_divider`'s start side; the two plot
areas, into the split inside `plot_box`; the sidebar's panels, appended to `measure_page` and `signals_page`; and the
status popover, attached to `status_info`.
built by code and put in place: the two plot areas, into the split inside `plot_box`; the dock's
readouts and events, appended to `dock_page`; the slide-over's pages, into the panel over `plot_overlay`;
and the status popover, attached to `status_info`.
The header bar is not here either: it is the window's own titlebar, built by `build_toolbar()`
because every button on it is wired to an action. The status line is not a bottom bar — it belongs to
the plot column, not to the window, so it stays inside `left_column` where the divider can move past
it.
the plot column, not to the window, so it stays inside `left_column` under the plot.
-->
<interface>
<object class="GtkApplicationWindow" id="main_window">
@@ -39,90 +40,46 @@
<child>
<object class="AdwOverlaySplitView" id="side_view">
<property name="sidebar-position">end</property>
<!-- Its width in pixels, not as a fraction of the window: 320 on a first run, and never less
than 240 however small the window gets. -->
<!-- Its width in pixels, not as a fraction of the window: 280, the width of the compact readouts. -->
<!-- Both ends of the range, in pixels: `sidebar-width-fraction` is a 0..1 *fraction* of the
window (setting a pixel count there is a GObject critical and falls back to 0.25), so the
width is pinned by min and max instead. -->
<property name="sidebar-width-unit">px</property>
<property name="min-sidebar-width">320</property>
<property name="max-sidebar-width">320</property>
<property name="min-sidebar-width">280</property>
<property name="max-sidebar-width">280</property>
<property name="sidebar">
<!-- A switcher above a stack, and a scroller *per page*: the two pages are different
heights, so one shared scroller around the stack would leave a tab switch scrolled
to somewhere the new page never asked for. The switcher is the sibling above, not a
child of either scroller: the one control that chooses what you are looking at must
not be the first thing to scroll away.
The tabs are words: the policy is `wide` and every page has a title, no icon. (They
were icon-only glyphs once; the glyphs were not understandable enough.) The pages'
*names* are what identify them to `select_side_tab()` and the session file; a source
kind's own tabs are added in code with their `SourcePanel::title`. -->
<!-- The dock: one scroller holding the compact cursor and measurement readouts. The full
Cursors and Measurements panels are slide-over pages, and the signal list is a page too. -->
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
<property name="width-request">260</property>
<child>
<object class="AdwViewSwitcher" id="side_switcher">
<property name="stack">side_stack</property>
<property name="policy">wide</property>
<property name="margin-top">4</property>
<property name="margin-bottom">4</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
</object>
</child>
<child>
<object class="AdwViewStack" id="side_stack">
<object class="GtkScrolledWindow" id="dock_scroller">
<property name="vexpand">1</property>
<child>
<object class="AdwViewStackPage">
<property name="name">measure</property>
<property name="title">Measure</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="child">
<object class="GtkBox" id="measure_page">
<property name="orientation">vertical</property>
</object>
</property>
</object>
</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>
</child>
<child>
<object class="AdwViewStackPage">
<property name="name">signals</property>
<property name="title">Signals</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="child">
<object class="GtkBox" id="signals_page">
<property name="orientation">vertical</property>
</object>
</property>
</object>
</property>
</object>
</child>
</property>
</object>
</child>
</object>
</property>
<property name="content">
<object class="GtkPaned" id="events_divider">
<property name="orientation">horizontal</property>
<!-- Shrinkable, or the pane's own minimum is reserved as an empty column even while it is
closed and the plot pays for a pane nobody opened. -->
<property name="shrink-start-child">1</property>
<property name="resize-start-child">0</property>
<child type="end">
<object class="GtkBox" id="left_column">
<property name="orientation">vertical</property>
<object class="GtkBox" id="left_column">
<property name="orientation">vertical</property>
<property name="hexpand">1</property>
<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">
<property name="orientation">vertical</property>
@@ -138,34 +95,83 @@
</child>
</object>
</child>
</object>
</child>
<child>
<!-- The readouts strip: the cursors' deltas and the measurements' values on one line, shown
only while the dock is not (hidden with the Dock button, or collapsed and not currently
overlaid; `sync_readouts_strip()` decides, per tick). A fixed height, so the content
never sets it; what does not fit scrolls horizontally with no bar taking height. The
box inside is filled by code (`src/ui_dock.cpp`). Starts hidden: the dock starts shown. -->
<object class="GtkScrolledWindow" id="readouts_strip">
<property name="visible">0</property>
<property name="height-request">26</property>
<property name="vexpand">0</property>
<property name="hscrollbar-policy">external</property>
<property name="vscrollbar-policy">never</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="child">
<object class="GtkBox" id="readouts_strip_box">
<property name="orientation">horizontal</property>
<property name="spacing">8</property>
<property name="valign">center</property>
</object>
</property>
</object>
</child>
<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="status_row">
<object class="GtkBox" id="badge_signals">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="margin-top">2</property>
<property name="margin-bottom">2</property>
<property name="margin-start">6</property>
<child>
<object class="GtkLabel" id="status_badge"/>
</child>
<child>
<!-- The line is rewritten every frame and its counters grow, so it is
ellipsized: a label's minimum width is its text, and an unellipsized one
would push the divider around as the numbers change. -->
<object class="GtkLabel" id="status_label">
<property name="label">starting…</property>
<property name="ellipsize">end</property>
<property name="hexpand">1</property>
<property name="halign">fill</property>
</object>
</child>
<child>
<object class="GtkMenuButton" id="status_info">
<property name="icon-name">help-about-symbolic</property>
<property name="tooltip-text" translatable="yes">Stream and buffer detail</property>
<property name="valign">center</property>
</object>
</child>
<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>
<child>
<object class="GtkBox" id="status_row">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="margin-top">2</property>
<property name="margin-bottom">2</property>
<property name="margin-start">6</property>
<child>
<object class="GtkLabel" id="status_badge"/>
</child>
<child>
<!-- The line is rewritten every frame and its counters grow, so it is
ellipsized: a label's minimum width is its text, and an unellipsized one
would push the divider around as the numbers change. -->
<object class="GtkLabel" id="status_label">
<property name="label">starting…</property>
<property name="ellipsize">end</property>
<property name="hexpand">1</property>
<property name="halign">fill</property>
</object>
</child>
<child>
<object class="GtkMenuButton" id="status_info">
<property name="icon-name">help-about-symbolic</property>
<property name="tooltip-text" translatable="yes">Stream and buffer detail</property>
<property name="valign">center</property>
</object>
</child>
</object>
+22 -1
View File
@@ -13,7 +13,7 @@
show-apply-button is off here: the write is a few bytes to one file, so a keystroke is not worth
batching). The two settings that cannot take effect immediately — the cache directory and relative
time, both of which are planned before any source is known — say so in their subtitles instead of
being held back by a button.
being held back by a button. The memory limit does take effect at once: a running buffer re-plans.
Nothing here carries a value: `ui_prefs.cpp` fills the rows from `Settings` when the dialog opens,
and writes it back on every change. This file is the shape, that is the state.
@@ -77,6 +77,27 @@
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Sample buffer</property>
<child>
<object class="AdwSpinRow" id="memory_row">
<property name="title" translatable="yes">_Memory limit (MB)</property>
<property name="use-underline">True</property>
<property name="subtitle" translatable="yes">Hard ceiling on the memory of the sample rings, all signals together. A source asks for a number of samples per signal; if that does not fit under this limit it is halved until it does, and the status line says so. Applies at once: a running buffer is re-planned, which clears its history.</property>
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">16</property>
<property name="upper">1048576</property>
<property name="step-increment">256</property>
<property name="page-increment">1024</property>
<property name="value">8192</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
+3 -3
View File
@@ -73,14 +73,14 @@
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">m</property>
<property name="title" translatable="yes">Show the Measure tab</property>
<property name="title" translatable="yes">Open or close the Measurements page</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">g</property>
<property name="title" translatable="yes">Show the Signals tab</property>
<property name="title" translatable="yes">Open or close the Signals page</property>
</object>
</child>
<child>
@@ -94,7 +94,7 @@
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">s</property>
<property name="title" translatable="yes">Show or hide the side panel</property>
<property name="title" translatable="yes">Show or hide the dock</property>
</object>
</child>
</object>
+22 -17
View File
@@ -2,11 +2,13 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The signals panel: a heading, the hint that says what a signal is for, and the list they go in.
The signals page: a heading, the hint that says what the page is for, the list, and the dim label that
replaces the list's signals when there is nothing left to add.
The rows are not here. There is one per signal in the stream, it carries the switch that decides whether
that signal is acquired, its colour, its calibration and its drop targets, and the whole list is rebuilt
whenever the stream's signals change — so the file holds the frame and the code holds the rows.
The page proposes what can be *added*: the streamed signals that are not acquired and the math channels
that are not computed (an acquired signal has a badge in the bar). The rows are not here. There is one per
proposed signal, it carries the tick that acquires it and its menu, and the whole list is rebuilt whenever
the stream's signals or their state change — so the file holds the frame and the code holds the rows.
The list's size request is **width only**, deliberately. A height floor here made the list occupy 120 px
whatever it held, which hid the compact rows' height behind it and wasted the space on an empty stream;
@@ -21,27 +23,17 @@
<property name="margin-start">8</property>
<property name="margin-end">8</property>
<child>
<!-- The heading row, with the button that switches this pane between its two modes: locked, where
the list shows the signals that are on and nothing else, and configuration, which is the list
with every control on it. It is the code that swaps the icon and the tooltip, because the mode
is the code's. -->
<!-- The heading row (the contextual help's "?" joins it). -->
<object class="GtkBox" id="signals_head">
<property name="orientation">horizontal</property>
<property name="spacing">8</property>
<child>
<object class="GtkLabel" id="signals_heading">
<property name="label" translatable="yes">Signals</property>
<property name="label" translatable="yes">Add a signal</property>
<property name="halign">start</property>
<property name="hexpand">1</property>
</object>
</child>
<child>
<object class="GtkButton" id="signals_edit">
<property name="icon-name">document-edit-symbolic</property>
<property name="valign">center</property>
<property name="css-classes">flat</property>
</object>
</child>
</object>
</child>
<child>
@@ -53,14 +45,27 @@
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="signals_hint">
<property name="label" translatable="yes">drag a signal onto a plot</property>
<property name="label" translatable="yes">tick a signal to acquire it; click one to open its page</property>
<property name="halign">start</property>
<property name="xalign">0</property>
<property name="wrap">1</property>
<property name="hexpand">1</property>
<property name="css-classes">dim-label</property>
</object>
</child>
</object>
</child>
<child>
<!-- Shown by the code when every signal the stream declares is acquired. -->
<object class="GtkLabel" id="signals_empty">
<property name="label" translatable="yes">every signal is acquired</property>
<property name="halign">start</property>
<property name="xalign">0</property>
<property name="wrap">1</property>
<property name="visible">0</property>
<property name="css-classes">dim-label</property>
</object>
</child>
<child>
<object class="GtkListBox" id="signal_list">
<property name="selection-mode">none</property>
+7 -5
View File
@@ -85,15 +85,17 @@
</child>
<child>
<object class="AdwSpinRow" id="buffer_row">
<property name="title" translatable="yes">_Buffer MB</property>
<property name="title" translatable="yes">_Samples per signal (millions)</property>
<property name="use-underline">True</property>
<property name="subtitle" translatable="yes">sample buffer per source, applied when this source is opened</property>
<property name="subtitle" translatable="yes">samples kept for every signal, rounded down to a power of two; applied when this source is opened</property>
<property name="digits">3</property>
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">1</property>
<property name="upper">65536</property>
<property name="lower">0.001</property>
<property name="upper">4096</property>
<property name="step-increment">1</property>
<property name="value">64</property>
<property name="page-increment">10</property>
<property name="value">10</property>
</object>
</property>
</object>
+316 -77
View File
@@ -44,6 +44,7 @@
#include <array>
#include <cmath>
#include <cstdint>
#include <map>
#include <memory>
#include <mutex>
#include <string>
@@ -73,9 +74,11 @@ enum class EventGroupKind {
/// do not exist yet.
struct TourStop {
GtkWidget* target;
/// The named page of the view's `page_stack` the target lives on, or nullptr when the stop needs no
/// page. The tour selects it as the stop is entered, so a target on a page the user has left is
/// made visible instead of being walked over or spotlighted where it is not.
/// The page the target lives on, or nullptr when the stop needs none. What a page *is* depends on
/// the view: a named page of its `page_stack` (the chooser), or whatever its `on_page` hook opens
/// (the main window: a slide-over page id). The tour selects it as the stop is entered, so a target
/// on a page the user has left is made visible instead of being walked over or spotlighted where it
/// is not. A stop with no page *closes* what an `on_page` hook opened.
const char* page;
const char* title;
const char* body;
@@ -103,20 +106,69 @@ struct TourView {
/// sidebar; the chooser's two pages), so it is the view's and not each stop's. Set where the walk
/// begins, when the widgets exist; null leaves every stop on the page it finds.
GtkWidget* page_stack = nullptr;
/// The alternative to `page_stack` for a surface that is not a stack: when set it *replaces* the
/// stack mechanism for this view, and is called with each stop's `page` as the stop is entered --
/// `nullptr` meaning "no page needed" -- and with `nullptr` as the walk ends. The main window's opens
/// and closes the slide-over through it; the chooser keeps `page_stack`.
void (*on_page)(void* user, const char* page) = nullptr;
void* page_user = nullptr;
/// Optional, with `on_page`: whether the surface `on_page` opened has finished appearing. A stop that
/// names a page is not spotlit while this answers false -- a panel that is still sliding in is a
/// target that moves, and a spotlight that follows it for 120 ms reads as a glitch. Null means "ready".
bool (*page_settled)(void* user) = nullptr;
/// Runs when the walk passes its last stop (Next/Finish, never Skip): the chooser's hands the tour
/// on to the application through `Config::tour_pending`.
void (*on_done)(void* user) = nullptr;
void* done_user = nullptr;
};
/// One capability panel a source kind contributed to the side stack. `content` is the kind's own widget
/// One capability panel a source kind contributed to the slide-over. `content` is the kind's own widget
/// (made insensitive while the source is disconnected); the page wrapping it is named `name`.
struct SourcePanelPage {
std::string name; ///< stack page name: "source-" + the kind's panel id
std::string title; ///< the pill's tooltip and accessible name; the page's own title stays empty
std::string name; ///< slide-over page id: "source-" + the kind's panel id
std::string title; ///< the slide-over page's title, and the segmented switch's label
GtkWidget* content = nullptr;
};
/// 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;
/// 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;
std::string signal_name; ///< a signal badge: the signal it stands for; empty for the others
std::size_t signal = 0; ///< a signal badge: its index in `ScopeModel::signals()` when it was built
};
/// The most signal badges the bar shows; beyond that one "N signals" badge stands for them all.
inline constexpr std::size_t kMaxSignalBadges = 6;
/// The badge bar under the plot (src/ui_badges.cpp).
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; ///< opens the first source page; hidden when the source has none
GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then the "+"
/// One per shown enabled signal; empty when overflowed. `unique_ptr`: each button's "clicked" handler holds
/// a pointer to its `Badge`, which must not move when the vector does.
std::vector<std::unique_ptr<Badge>> signal_badges;
Badge overflow; ///< the "N signals" badge, used only beyond `kMaxSignalBadges`
Badge plus; ///< the "+" in the signals slot: opens the `signals` page
};
struct App {
ScopeModel* model = nullptr;
Acquisition* acq = nullptr;
@@ -124,6 +176,16 @@ struct App {
GtkWidget* window = nullptr;
GtkWidget* plot_box = nullptr;
/// The `GtkOverlay` around `plot_box` (`main-window.ui`), which the slide-over panel is a child of.
GtkWidget* plot_overlay = nullptr;
SlideOver slideover;
/// The badge bar under the plot (`main-window.ui` `badge_bar`, with its two slots).
BadgeBar badges;
GtkWidget* badge_bar = nullptr;
GtkWidget* badge_signals = nullptr;
/// Signal names in the order the user left the badges (memory only; names not in it sort last).
std::vector<std::string> badge_order;
GtkWidget* badge_system = nullptr;
/// The one GL area, drawing the `RowSet`'s figure: every row is a row of that
/// figure, so there is one widget, one context and one `::render` whatever the row
/// count is.
@@ -146,6 +208,19 @@ struct App {
/// began, so the beginning has to be kept -- reading the weights while dragging would compound.
int layout_drag_row = -1;
std::vector<float> layout_drag_start;
/// Column drag state: which plot (right-hand cell), the two starting *plot* widths in logical px
/// (each plot's `layout().axes.w`, the area the plotter places a cell by -- not the cell rect, which
/// adds the y and right gutters and the gap), and the cell weights snapshot at drag begin
/// (`layout_col_apply()` redistributes the pair's weight sum from these, so a drag stays relative to
/// where it started).
int layout_col_drag_plot = -1;
double layout_col_drag_left0 = 0.0;
double layout_col_drag_right0 = 0.0;
/// The drag handle's x in the overlay's coordinates (logical px) when the drag began. The handle is
/// re-placed on the divider every frame, and a gesture reports its offset in the handle's own, moving,
/// coordinates; `layout_col_total_offset()` adds the handle's displacement since this back.
double layout_col_drag_handle_x0 = 0.0;
std::vector<float> layout_col_drag_start;
/// The editor's way out: one `Done` button in the plot area's top-right corner while
/// the editor is up. Not part of `layout_buttons`, which is one button per row and per
/// boundary -- the count the layout probe asserts.
@@ -159,6 +234,11 @@ struct App {
/// a frame apart, because the buttons are placed from the rects the last frame was laid
/// out with, so the sequence cannot be one function.
int dbg_layout_step = 0;
/// Which step of the split probe runs next (`UDPSCOPE_PROBE_SPLIT_MS`).
int dbg_split_step = 0;
/// The split probe's round trip: the arrangement saved, and the legacy copy of its file.
std::string dbg_split_expect;
std::string dbg_split_legacy_path;
/// Which step of the cursors probe runs next (`UDPSCOPE_PROBE_CURSORS_MS`).
int dbg_cursors_step = 0;
/// Which step of the fit probe runs next (`UDPSCOPE_PROBE_FIT_MS`).
@@ -204,13 +284,11 @@ struct App {
GtkWidget* trigger_panel = nullptr;
GtkWidget* signal_panel = nullptr;
GtkWidget* side_box = nullptr;
/// The sidebar's tab stack and its switcher. The stack is what chooses which panels are on
/// screen; a feature's own on/off never does (it sets its panel's sensitivity instead).
GtkWidget* side_stack = nullptr;
GtkWidget* side_switcher = nullptr;
/// The page name the stack is showing, and the one value the session file persists about the
/// sidebar. "signals" on a first run.
std::string side_tab = "signals";
/// The dock: the sidebar's one scroller and the box inside it, which holds only the compact readouts
/// (`build_readouts_dock()`). The full cursors and measurements panels are the slide-over's `cursors`
/// and `measure` pages. A feature's own on/off never removes a panel (it sets its sensitivity instead).
GtkWidget* dock_page = nullptr;
GtkWidget* dock_scroller = nullptr;
GtkWidget* arm_button = nullptr;
/// The Force button, beside the Arm/Disarm one: one acquisition now, with the configured pre/post
/// window. It carries no state of its own -- `sync_arm_buttons()` states the one it has
@@ -440,15 +518,6 @@ struct App {
/// works, and every other line of the dump still passes.
std::string dbg_toolbar_icons;
std::string dbg_app_icons;
/// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The tabs
/// are words (`wide` policy, a title per page), so the label count must equal the page count.
int dbg_switcher_pills = -1;
int dbg_switcher_labels = -1;
/// How many pages the sidebar stack holds: `pills` and `labels` must both equal it.
int dbg_switcher_pages = -1;
/// The switcher's own size, beside it: what the tab bar costs the column.
int dbg_switcher_w = -1;
int dbg_switcher_h = -1;
/// The panes' own switches and the header bar's buttons, read together by the exit probe: the two
/// must agree, and `dbg_panel_drove_toolbar` is the pane's switch having moved the button.
int dbg_panel_switch_trigger = -1;
@@ -464,13 +533,14 @@ struct App {
/// The Arm button, read with the trigger off: it is the pane's one runtime control, so it is the one
/// thing in the body that *is* disabled with the mode.
int dbg_arm_sensitive = -1;
int dbg_switcher_policy = -1;
/// The header bar, so the dump can report the order the controls are in: a header bar is a
/// *titlebar*, not a child of the window, so walking down from the window does not reach it.
GtkWidget* toolbar = nullptr;
GtkWidget* cursors_reset = nullptr;
/// The cursors panel's root, kept because cursors-off disables it rather than hiding it.
GtkWidget* cursors_panel = nullptr;
/// The measurements page's root: like `cursors_panel`, insensitive until the stream has signals.
GtkWidget* measures_panel = nullptr;
/// The panes' *own* switches, in their header rows, and the panel bodies those switches disable.
/// The switch and the header bar's button are one control with two places to click: each runs the
/// same action, and `sync_panel_switches()` re-reads both from the toolbar's state after any
@@ -481,20 +551,16 @@ struct App {
/// True while `sync_panel_switches()` is writing the switches: setting a `GtkSwitch` emits its own
/// `notify::active`, and without this the program's own write would come back as a click.
bool panel_switch_sync = false;
/// True while a drag has the overlaid sidebar hidden: what it was showing before, so `drag-end` puts it
/// back rather than guessing.
bool sidebar_hidden_for_drag = false;
/// The channel a session's trigger names, until `rebuild_trigger_signals()` can resolve it: the
/// file stores a *name* and the trigger needs an index into the acquired channels, which do not
/// exist until the stream has been described. Empty means "nothing pending".
std::string pending_trigger_channel;
/// The two panes' modes. *Locked* is the one a returning user gets: the trigger pane shows a line
/// that says what it is set to, the signal list shows only the signals that are on, and neither
/// offers a control that changes the configuration. A first run -- no session file for this source
/// -- starts in configuration mode, because that is what a first run is for. The flags last for the
/// session: a lock is a mode, not a setting, so it is not persisted.
/// The trigger pane's mode. *Locked* is the one a returning user gets: the pane shows a line that
/// says what it is set to and offers no control that changes the configuration. A first run -- no
/// session file for this source -- starts in configuration mode, because that is what a first run is
/// for. The flag lasts for the session: a lock is a mode, not a setting, so it is not persisted.
/// (The signals have no lock: the badges and the signal pages are direct manipulation.)
bool trigger_locked = false;
bool signals_locked = false;
/// The locked trigger pane's content: a small definition list, because one line cannot hold the
/// signal, the condition, the level and the mode, and those are what a user reads the pane for.
GtkWidget* trigger_summary = nullptr;
@@ -506,14 +572,14 @@ struct App {
/// pane is locked, which is the one combination where the body's own Arm button has gone with the body.
GtkWidget* trigger_summary_arm = nullptr;
GtkWidget* trigger_edit = nullptr;
GtkWidget* signals_edit = nullptr;
/// The dim label of the `signals` page that says nothing is left to add.
GtkWidget* signals_empty = nullptr;
int dbg_trigger_locked = -1;
int dbg_summary_arm_off = -1; ///< the compact line's arm control with the trigger *off*
int dbg_summary_arm_visible = -1; ///< the compact line's arm control, locked and with the trigger on
int dbg_summary_arm_sensitive = -1;
std::string dbg_summary_arm_label; ///< its text, which is the armed state in the user's words
int dbg_summary_arm_width = -1; ///< its allocation a frame later: 0 means "not laid out"
int dbg_signals_locked = -1;
int dbg_trigger_body_visible = -1;
/// The compact line the locked trigger pane shows. It is the pane's whole content in that mode, so it
/// is read rather than assumed: empty here is a locked pane showing nothing at all.
@@ -544,6 +610,39 @@ struct App {
int slot = -1;
};
std::vector<CursorLine> cursors_lines;
/// The dock's compact copy of the cursors table (`src/ui_dock.cpp`): one line per `cursors_lines`
/// entry, same order, written from the same strings by `update_cursors_pane()`.
struct DockCursorLine {
GtkWidget* label = nullptr;
GtkWidget* a = nullptr;
GtkWidget* b = nullptr;
GtkWidget* d = nullptr;
};
std::vector<DockCursorLine> dock_cursor_lines;
/// The Readouts section at the top of the dock, and the parts of it the rebuilds and the Edit buttons
/// reach.
GtkWidget* dock_readouts = nullptr;
GtkWidget* dock_cursor_grid = nullptr;
GtkWidget* dock_measures_box = nullptr;
GtkWidget* dock_cursors_edit = nullptr;
GtkWidget* dock_measures_edit = nullptr;
/// One value label per measurement, in `measures` order (the dock's copy of `measure_labels`).
std::vector<GtkWidget*> dock_measure_labels;
/// The readouts strip under the plot (`main-window.ui` `readouts_strip`, `src/ui_dock.cpp`): shown
/// whenever the dock is not beside the plot (`!show-sidebar || collapsed`) *and* there is something to
/// read (the cursors on, or a measurement; `sync_readouts_strip()`). Its value labels mirror the dock's one for one -- `strip_cursor_deltas` the
/// `d` cell of each `dock_cursor_lines` entry, `strip_measure_labels` each of `dock_measure_labels` --
/// and `strip_cursor_who` the cursor lines' `who` cells, all written from the dock setters' arguments.
GtkWidget* readouts_strip = nullptr;
GtkWidget* strip_box = nullptr;
GtkWidget* strip_cursor_box = nullptr; ///< the cursor chips; insensitive with the cursors off
GtkWidget* strip_measure_box = nullptr; ///< the measurement chips
GtkWidget* strip_cursor_group = nullptr; ///< the Cursors button and its chips; shown with the cursors on
GtkWidget* strip_measure_group = nullptr; ///< the Measures button and its chips; shown with measurements
GtkWidget* strip_sep = nullptr; ///< between the groups; shown when both are
std::vector<GtkWidget*> strip_cursor_who;
std::vector<GtkWidget*> strip_cursor_deltas;
std::vector<GtkWidget*> strip_measure_labels;
/// The measurements the user set up, and what they last computed. The list is the state;
/// the results are recomputed on a throttle (the numbers are window statistics, and the
/// window scrolls every frame -- 60 recomputations a second of every sample is not a
@@ -720,29 +819,26 @@ struct App {
std::string config_comment;
/// The cache of captured events, and whether latches are being written to it. The store is created
/// once the source is known (it is keyed on `<host>-<port>`, like the packet capture);
/// `event_recording` is the trigger panel's switch and defaults on -- the cache is only useful if
/// it was already on when the event you wanted happened.
/// `event_recording` is the trigger panel's switch and starts off; the cache is only useful if
/// it was already on when the event you wanted happened, so switch it on before the events you want.
std::unique_ptr<EventStore> event_store;
/// Whether the cache holds anything, refreshed once a second: what keeps the history button on the bar
/// for a source whose events are already written, even with recording switched off.
/// Whether the cache holds anything, refreshed once a second: what keeps the dock's Events block and the
/// `events` page live for a source whose events are already written, even with recording switched off.
bool events_cached = false;
bool event_recording = false;
/// The events pane: its list, the revealer that holds it, the recording switch (in the trigger
/// panel, where the trigger's options live), and `events_thumb` -- the picture of the newest
/// The `events` page of the slide-over: its content box, its list, the recording switch (in the
/// trigger panel, where the trigger's options live), and `events_thumb` -- the picture of the newest
/// event. Every row carries the picture of *its* event; this is the first one's, which is the one
/// the probe measures and which the pane does not put anywhere special.
GtkWidget* events_revealer = nullptr;
/// the probe measures and which the page does not put anywhere special.
GtkWidget* events_box = nullptr;
GtkWidget* events_list = nullptr;
GtkWidget* events_thumb = nullptr;
GtkWidget* events_record_switch = nullptr;
/// The toolbar's event-history toggle: the pane is opened *on demand* from here (the item's own
/// arrangement), and the toggle only exists while trigger mode does -- there are no events without
/// a trigger, and a button that can never have anything to show is a button spent on nothing.
GtkWidget* events_toggle = nullptr;
/// The paned that holds the events pane and the plot, and the width the divider was last dragged
/// to. 240 by default: enough for the preview and the numbers beside it, and not a plot's worth.
GtkWidget* events_divider = nullptr;
int events_pane_width = 240;
/// The Events block of the dock: the box, its list (compact rows, one per `EventRecord`, the record
/// attached to each row as `udpscope-event`) and the Manage button.
GtkWidget* dock_events_box = nullptr;
GtkWidget* dock_events_list = nullptr;
GtkWidget* dock_events_manage = nullptr;
/// The comparison's second plot: its own placeholder, area and `RowSet`, and the paned that holds
/// the two side by side. `rows_b` exists only once the second area is built, and the area is hidden
/// (so the paned gives it no room and no handle) unless a comparison is up.
@@ -841,12 +937,14 @@ struct App {
std::string source_kind = "udps";
SourceConfig source_opts;
std::string source_title;
/// The panels the source kind added to `side_stack`, in tab order. Empty for UDPS and in file mode.
/// The panels the source kind added to the slide-over, in page order. Empty for UDPS and in file mode.
std::vector<SourcePanelPage> source_panels;
bool source_has_datagrams = true; ///< the kind reports raw datagrams; false leaves the capture button disabled
guint layout_save_source = 0;
bool layout_loaded = false;
unsigned signal_generation = ~0u;
/// Signal name -> the content box of its `signal:<name>` slide-over page (src/ui_signal_page.cpp).
std::map<std::string, GtkWidget*> signal_pages;
bool updating_panels = false; ///< guards signal handlers during a rebuild
bool relayout_pending = false;
/// True once the renderer's `Style` has been stated (see `tick()`): the renderer does not
@@ -1099,20 +1197,34 @@ struct App {
int dbg_signal_row_h = 0;
int dbg_trigger_w = 0;
int dbg_trigger_h = 0;
/// The signal list has rows (the dock has no tab to be "current" on any more).
int dbg_signal_page_current = 0;
/// The dock as the exit probe found it: the split view showing the sidebar, the column sensitive (it is
/// not until the stream has signals), its width, and how many `GtkScrolledWindow`s it holds directly.
int dbg_dock_shown = -1;
int dbg_dock_sensitive = -1;
/// The Cursors and Measurements pages' sensitivity (1 only if both are), read on the tick.
int dbg_pages_sensitive = -1;
int dbg_dock_w = -1;
int dbg_dock_scrollers = -1;
int dbg_trigger_sensitive = 0;
int dbg_trigger_items = 0;
int dbg_trigger_selected = -1;
/// The signal list's shape, captured where the list is built: how many rows it holds, and how many
/// of them are streamed signals. The `MATH` divider and the math channels account for the rest, and
/// of them are streamed signals. The `MATH` divider and the math rows account for the rest, and
/// `rows == signals + 1 + math` is the invariant the dump checks -- a list that lost its `+`.
int dbg_signal_rows = 0;
/// How many rows of the list carry a `GtkDragSource`: `rows - 1`, the divider not being draggable.
/// How many rows of the list carry a `GtkDragSource`: 0 -- the signal badges are the drag handles now.
int dbg_signal_drag_sources = -1;
/// The drag-with-an-overlaid-sidebar pair: the sidebar hid for the drag, and came back after it.
int dbg_drag_hid_sidebar = -1;
int dbg_drag_restored_sidebar = -1;
/// A drag begun through the first signal badge's source closed the open slide-over (1), or did not (0);
/// -1 when there is no badge to drag.
int dbg_drag_closed_slideover = -1;
std::size_t dbg_signal_plain = 0;
/// The math channels among those rows, and the number of signals the page should be proposing (counted from
/// the model, not from the rows built), and whether the "every signal is acquired" label was on screen.
std::size_t dbg_signal_math_rows = 0;
std::size_t dbg_signal_addable = 0;
int dbg_signal_empty_label = -1;
int dbg_side_column_after_hide = 1;
std::string dbg_mode_triggered;
std::string dbg_window_text;
@@ -1121,7 +1233,6 @@ struct App {
double dbg_trigger_time = 0.0;
int dbg_trigger_time_valid = -1;
std::string dbg_mode_live;
int dbg_switcher_visible = 0;
int dbg_trigger_sensitive_default = 0;
/// The detail string the popover and the exit dump read. The dump used to read the label,
@@ -1142,6 +1253,9 @@ struct App {
/// it keeps for a second latch (it is counting retries, not time).
int dbg_events_step = 0;
int dbg_events_wait = 0;
/// The plot area's allocated width before the first event was latched (the events probe), compared with
/// its width after: the events list is a slide-over page and no longer a column beside the plot.
int dbg_events_plot_w = 0;
/// The event row the probe selected before it activated one, and how many rows were selected.
/// Both are read back *after* the activation: a row pointer that is no longer the list's row 0,
/// or a smaller count, says the activation rebuilt the list and so threw away the user's scroll
@@ -1509,6 +1623,17 @@ constexpr const char* kLayoutKindKey = "udpscope-layout-insert";
/// The ↕ handles' own key: what they are is *not* what the `+` is, and `sync_layout_editor()`
/// places them differently -- one boundary, two controls.
constexpr const char* kLayoutResizeKey = "udpscope-layout-resize";
/// Cell `+` buttons carry this key (fr * 8 + pos + 1) so `layout_button()` never matches them.
constexpr const char* kLayoutCellKey = "udpscope-layout-cell";
/// Column resize handles carry the plot index of the right-hand cell.
constexpr const char* kLayoutColKey = "udpscope-layout-col";
/// The minimum width a cell may be dragged to, in logical pixels.
constexpr double kMinCellPx = 96.0;
/// The plotter's own per-cell floor, `kMinCellWidthLogical` in `deps/plotter/src/plot/figure.cpp`
/// (`place_cells()`): a cell is `floor + extra * weight / total`. Both it and `kMinCellPx` are
/// *logical* px -- the plotter scales its floor by the `ui_scale` the renderer installs, which is
/// `PlotArea::device_scale()`, the same divisor that turns a layout rect into logical px here.
constexpr double kPlotterMinCellPx = 64.0;
constexpr gint64 kMeasureRefreshUs = 100 * 1000;
constexpr gint64 kMeasureHeavyUs = 5 * 1000;
constexpr const char* kCursorModeNames[] = {"vertical", "horizontal", "both", "mix"};
@@ -1532,9 +1657,9 @@ constexpr int kEventThumbH = 46;
constexpr int kEventThumbMaxH = 104;
inline int g_stats_phase = 0;
inline std::vector<std::uint64_t> g_stats_n_before;
/// The same for the tab-switch probe: the accumulator counts recorded while the Measure page was
/// still current, so the report can say whether any of them advanced afterwards.
inline std::vector<std::uint64_t> g_side_tab_n_before;
/// The same for the dock-section probe: the accumulator counts recorded while the `measure` page was
/// still open, so the report can say whether any of them advanced after it was closed.
inline std::vector<std::uint64_t> g_dock_section_n_before;
inline double g_trigrenum_edge = 0.0;
inline std::uint64_t g_trigrenum_latches = 0;
inline ConditionProbe g_condition;
@@ -1561,8 +1686,8 @@ void open_configurations(App& app);
void save_layout_now(App& app);
/// Capability panels (`src/ui_source.cpp`). `build_source_panels` adds the source kind's panels as
/// pages of `side_stack`, once, before the first tick. `apply_source_panels` sets every panel's
/// sensitivity from `connected` -- disabled, never hidden: the tab switcher owns visibility.
/// pages of the slide-over (`source-<id>`), once, before the first tick. `apply_source_panels` sets every panel's
/// sensitivity from `connected` -- disabled, never hidden.
/// `sync_source_panels` is the per-tick call: it applies the model's own connection flag.
void build_source_panels(App& app);
void apply_source_panels(App& app, bool connected);
@@ -1615,7 +1740,11 @@ void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user);
void show_info_dialog(GtkWindow* parent, const SignalInfo& sig);
void on_info_clicked(GtkButton* button, gpointer user);
void on_calibrate_apply(GtkButton*, gpointer user);
void open_calibration(App& app, GtkWindow* parent, std::size_t signal);
/// Which of the calibration's two numbers a dialog edits; `Both` is the full dialog.
enum class CalibField { Both, Gain, Offset };
void open_calibration(App& app, GtkWindow* parent, std::size_t signal, CalibField field = CalibField::Both);
/// The one-entry editor for a streamed signal's unit (an empty entry restores the declared one).
void open_unit_editor(App& app, GtkWindow* parent, std::size_t signal);
GdkContentProvider* on_drag_prepare(GtkDragSource*, double, double, gpointer user);
int signal_color_index(const std::vector<SignalInfo>& signals, std::size_t signal);
std::string color_hex(plot::Color c);
@@ -1625,8 +1754,30 @@ std::string signal_chip_markup(App& app, const std::vector<SignalInfo>& signals,
void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user);
void open_signal_color(App& app, std::size_t signal);
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals);
/// 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);
/// Whether more signals are acquired than `kMaxSignalBadges`: the badge bar then shows one "N signals" badge,
/// and the `signals` page lists the acquired signals as well, since their rows are the only way to a page.
/// The one rule the bar and the page share.
bool signal_badges_overflowed(App& app, 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: a handler that rebuilds the list or a page destroys the
/// widget it was emitted from.
void outlive_the_rebuild(GtkWidget* widget);
/// 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);
gboolean signal_page_probe(gpointer user); // UDPSCOPE_PROBE_SIGNAL_PAGES_MS
/// The `MATH` divider row in the one channel list, and the `+` it carries.
GtkWidget* make_math_divider(App& app, bool with_add);
GtkWidget* make_math_divider(App& app);
void on_math_add_clicked(GtkButton* button, gpointer user);
/// The index in `App::math` of the channel a synthetic signal is named after, or `-1`.
int math_index_for_signal(App& app, const std::string& name);
@@ -1645,8 +1796,6 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user);
void search_jump(App& app, double t);
void update_search_panel(App& app);
void sync_events_availability(App& app);
/// Restates the events pane's divider position once a frame; see the definition.
void sync_events_divider(App& app);
/// The preferences dialog: one window, opened from the overflow menu. `apply_theme()` is separate
/// because the theme is the one preference that takes effect without a restart.
void open_preferences(App& app);
@@ -1675,6 +1824,44 @@ void rebuild_measures_pane(App& app);
void on_cursor_mode_changed(GObject* obj, GParamSpec*, gpointer user);
App::CursorLine cursors_line(App& app, const char* who, plot::Color chip, bool has_chip);
GtkWidget* cursors_header(App& app);
/// The compact readouts at the top of the dock (`src/ui_dock.cpp`). The strings are the pages' own: the
/// pages' writers call the `dock_*` setters from the same variable, so a number is formatted once.
GtkWidget* build_readouts_dock(App& app);
void dock_rebuild_cursors(App& app);
void dock_rebuild_measures(App& app);
void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b,
const std::string& d);
void dock_measure_value(App& app, std::size_t i, const std::string& text);
/// The readouts strip under the plot (`src/ui_dock.cpp`): the third reader of the same strings. It is
/// filled by the `dock_*` rebuilds and written by the `dock_*` setters above (so nothing here formats),
/// and shown whenever the dock is not beside the plot (`!show-sidebar || collapsed`, a collapsed dock being
/// an overlay on it) and there is something to read (cursors on or a measurement; each group follows its own
/// feature, and with neither the strip is hidden): `sync_readouts_strip()` is called by the tick after the show-sidebar reconciliation
/// and before `set_collapsed()`, so it reads the previous frame's `collapsed`. `dock_set_cursors_sensitive()` is the one writer of the cursor readouts'
/// sensitivity, for the dock's grid and the strip's chips together.
void sync_readouts_strip(App& app);
void dock_set_cursors_sensitive(App& app, bool on);
/// The Events block under the readouts (`src/ui_dock.cpp`): compact rows (marker, title, time; no
/// thumbnails) from `event_store->list()`, newest first, and a Manage button for the `events` page.
/// `dock_rebuild_events()` follows every change of what is *cached*, `dock_mark_events()` every change
/// of what is *on screen* (in place, no rebuild), `dock_events_set_sensitive()` is the one writer of the
/// block's sensitivity. All three are no-ops before the block is built.
GtkWidget* build_events_dock(App& app);
/// The dock shows at most this many events (the newest), then one dim "... N more, see Manage" row.
inline constexpr std::size_t kDockEventsMax = 24;
/// The first lists the store itself; the second takes a list the caller already holds (so one refresh
/// reads the cache directory once, not twice). Both keep the list's vertical scroll across the rebuild.
void dock_rebuild_events(App& app);
void dock_rebuild_events(App& app, const std::vector<EventRecord>& records);
void dock_mark_events(App& app);
void dock_events_set_sensitive(App& app, bool available);
/// A row's first line, and the two questions its mark is made of (`ui_events.cpp`): the one function
/// for the pane and the dock, so the two cannot disagree about which event is on screen.
std::string event_row_title(App& app, const EventRecord& r);
bool event_on_screen(const App& app, const EventRecord& r);
bool event_in_group(const App& app, const EventRecord& r);
/// The signal chip of a measurement's row, in the colour every other view names that signal with.
std::string measure_chip_markup(App& app, const std::string& signal);
std::string row_unit(App& app, int row);
std::string slot_unit(App& app, int row, int slot);
std::uint32_t bound_slot_mask(App& app);
@@ -1758,8 +1945,6 @@ void row_menu_fill(RowMenuState& st);
GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide = true);
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user);
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
void on_side_tab_changed(GObject*, GParamSpec*, gpointer user);
void select_side_tab(App& app, const char* name);
/// Reads each pane's own switch back off the header bar's button, and the other way round: the two are
/// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
void sync_panel_switches(App& app);
@@ -1775,13 +1960,8 @@ const char* trigger_mode_text(TriggerMode m);
TriggerMode trigger_mode_from_text(const std::string& s);
/// The panel's controls from the model, and the session's trigger into both.
void write_trigger_widgets(App& app);
/// Hides the sidebar for the length of a drag when it is *overlaid*, and puts it back after. With the
/// split view collapsed the sidebar covers the plot, so a drag that starts in the signal list has nowhere
/// to land: the pointer is over the sidebar, not over a drop target.
void row_drag_sidebar(App& app, bool dragging);
/// The compact line the trigger pane shows while it is locked, and the lock itself: the panes'
/// bodies, their [edit] buttons and the signal list's rows all follow from `trigger_locked` and
/// `signals_locked`.
/// The compact line the trigger pane shows while it is locked, and the lock itself: the pane's body and
/// its [edit] button follow from `trigger_locked`.
void sync_trigger_summary(App& app);
/// Both arm buttons -- the panel's and the compact line's -- from the one armed state the producer keeps in
@@ -1822,6 +2002,12 @@ GdkTexture* scale_texture(GdkTexture* source, int width, int height);
EventThumbCache* event_thumb_cache(App& app);
GdkTexture* event_preview_texture(App& app, const std::string& path);
void refresh_events_pane(App& app);
/// Opens the `events` page. **Every route that opens that page must use this** and never `slideover_open()`
/// directly: the page's list is rebuilt only while the page is open (see `record_event()`), and the
/// slide-over has no on-open hook, so the list is refreshed here, once, before the page shows. The refresh
/// is skipped when the page is already open -- it is current then, and a rebuild would drop the
/// Compare/Accumulate selection and the scroll position. Returns what `slideover_open()` returns.
bool open_events_page(App& app);
/// Re-marks which row is the event currently on screen, **without rebuilding the list**.
///
/// The mark is the only thing about a row that a replay changes -- the picture and the numbers
@@ -1862,8 +2048,33 @@ gboolean measurepin_live_wait(gpointer user);
gboolean measure_trace(gpointer user);
gboolean stats_probe_report(gpointer user);
gboolean stats_probe(gpointer user);
gboolean side_tab_probe_report(gpointer user);
gboolean side_tab_probe(gpointer user);
gboolean dock_section_probe_report(gpointer user);
gboolean dock_section_probe(gpointer user);
gboolean dock_session_probe(gpointer user); // UDPSCOPE_PROBE_DOCK_SESSION_MS
/// The slide-over panel (`src/ui_slideover.cpp`): one panel at a time over the plot's right edge, a page
/// per setup surface, each page in a scroller of its own.
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);
/// The inverse of `slideover_add_page`: false when the id is unknown or is the open page.
bool slideover_remove_page(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
const std::string& slideover_page(const App& app); // "" when closed
bool slideover_escape(App& app); // closes if open; true when it did
/// x, y in the overlay's coordinates; closes unless the point is inside the panel; true when it closed.
bool slideover_click_away(App& app, double x, double y);
gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS
/// The badge bar (`src/ui_badges.cpp`): setup is reached by the object it configures.
/// 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); // after build_slideover, the toolbar (it reads trigger_toggle) and build_source_panels (the Source badge's page)
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
/// Rebuilds the signal badges (one per enabled signal, or the "N signals" overflow) from the model.
void sync_signal_badges(App& app);
gboolean signal_badges_probe(gpointer user); // UDPSCOPE_PROBE_SIGNAL_BADGES_MS
gboolean source_kinds_probe_report(gpointer user);
gboolean source_kinds_probe(gpointer user);
/// MCC E-1608 end to end against `tools/mock_e1608.py`: the stream, the calibration, the panels and
@@ -1873,6 +2084,12 @@ gboolean e1608_probe(gpointer user);
gboolean e1608_probe_report(gpointer user);
gboolean e1608_probe_reconnect(gpointer user);
gboolean e1608_probe_resumed(gpointer user);
/// The generator's update rate: starts a Sine on DAC 0 through the panel, reads the achieved rate the
/// source measured and the Analog out panel's "Update rate" row (`_report`, 2.5 s later), then stops
/// it and checks both read "off" (`_stopped`).
gboolean e1608_gen_probe(gpointer user);
gboolean e1608_gen_probe_report(gpointer user);
gboolean e1608_gen_probe_stopped(gpointer user);
/// 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.
@@ -1903,10 +2120,27 @@ gboolean search_probe_run(gpointer user);
gboolean search_probe(gpointer user);
gboolean window_probe(gpointer user);
GtkWidget* layout_button(App& a, bool insert, int index);
GtkWidget* layout_cell_button(App& a, int fr, int pos);
GtkWidget* layout_col_handle(App& a, int plot);
/// Records the starting widths (logical px) and weights of the cell pair a column drag on `plot`
/// (the right-hand cell) begins with. False when `plot` has no left neighbour.
bool layout_col_drag_begin(App& app, int plot);
void layout_col_apply(App& app, int plot, double offset_px);
/// A column drag's pointer travel from what its gesture reports: `offset` is relative to the handle, which
/// has moved from `handle_begin` to `handle_now` (same units, the overlay's) since the drag began.
double layout_col_total_offset(double offset, double handle_now, double handle_begin);
void layout_probe_report(App& a, const char* what);
/// The split in the report lines, from the `RowSet` alone (no widget is asked, so the exit dump can
/// use them): cells per figure row (`1,2,1`) and each figure row's cell weights, rows divided by `|`
/// and empty for an unsplit row (`|0.3,0.7|`).
std::string layout_cells_text(const RowSet& rs);
std::string layout_colweights_text(const RowSet& rs);
void layout_click(GtkWidget* b);
void layout_probe_next(App& a, int step, guint delay_ms);
gboolean layout_probe_step(gpointer user);
void split_probe_report(App& a, const char* tag);
void split_probe_next(App& a);
gboolean split_probe_step(gpointer user);
gboolean enable_all_probe(gpointer user);
gboolean disarm_probe_step2(gpointer user);
gboolean disarm_probe_step3(gpointer user);
@@ -2056,6 +2290,11 @@ void tour_end(TourView& view);
void tour_update(TourView& view);
/// The widget the current stop points at, or nullptr when the walk is not on a stop.
GtkWidget* tour_target(TourView& view);
/// The part of `target` that is on screen, in `overlay`'s coordinates: its bounds intersected with every
/// scrolled window above it. A target scrolled out of sight entirely is measured and *empty*: true with a
/// zero-size rectangle. False only when it cannot be measured yet (no allocation, not in the overlay).
/// The spotlight is drawn from it, and the probe reads the spotlight against it.
bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* out);
/// Builds the *window's* tour inside `app.tour.overlay`. Called once, as the overlay is inserted.
void build_tour(App& app);
+553
View File
@@ -0,0 +1,553 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The badge bar under the plot: setup is reached by the object it configures.
#include "source/registry.hpp"
#include "ui_app.hpp"
#include <adwaita.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <utility>
#include <string>
#include <vector>
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) return;
// The Source badge opens the first source page; with another source page already open, a click on it
// closes the panel rather than jumping back to the first page (the switch inside the panel moves between
// them).
if (b == &app->badges.source && slideover_page(*app).rfind("source-", 0) == 0) {
slideover_close(*app);
return;
}
// A signal's page is made on first use (and kept in step by `signal_pages_sync`). Nothing here rebuilds the
// badges (the page's fill and the slide-over open/close only restyle them), so the badge outlives this call.
if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return;
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);
}
/// 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. The payload is the signal index as a decimal string (the
/// plot area's drop target reads the same one); anything else a foreign drag might carry is refused.
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 || value == nullptr || !G_VALUE_HOLDS_STRING(value)) return FALSE;
const char* text = g_value_get_string(value);
if (text == nullptr || *text == '\0') return FALSE;
char* end = nullptr;
const unsigned long long parsed = std::strtoull(text, &end, 10);
if (end == nullptr || *end != '\0') return FALSE;
const std::size_t signal = static_cast<std::size_t>(parsed);
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; the names that are not shown (signals switched off) keep their old
// relative order after the visible ones, so switching one back on finds it where it was remembered.
std::vector<std::string> order;
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) order.push_back(b->signal_name);
}
}
const std::size_t shown_count = order.size();
for (const std::string& n : app->badge_order) {
if (std::find(order.begin(), order.begin() + static_cast<std::ptrdiff_t>(shown_count), n) ==
order.begin() + static_cast<std::ptrdiff_t>(shown_count)) {
order.push_back(n);
}
}
app->badge_order = std::move(order);
return TRUE;
}
/// 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);
// 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. (A click
// without movement never reaches the drag threshold, so it still opens the page; past the threshold the
// source claims the sequence and the button's click is cancelled.)
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));
}
} // 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");
// The first source page is where the badge goes; a source with none (UDPS, Flat UDP, a file) has nothing to
// open, so its badge is hidden (`sync_badge_bar` keeps it in step with the slide-over's source pages) and, for
// as long as it is, neither a target nor focusable. Hidden, not insensitive: a dead "Source" pill that does
// nothing was only noise. The bar's height is fixed, so the row loses width and never height.
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);
gtk_widget_set_visible(bb.source.button, FALSE);
}
// The link state is the status bar's to say; the badge names the source and opens its pages. The empty
// state label is hidden, as the "+" badge's is, rather than leaving a gap in the pill.
gtk_widget_set_visible(bb.source.state, FALSE);
// 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);
// The full signal list. Insensitive until the stream has declared signals ("disable, do not hide"). It
// follows the signal badges, which are inserted before it. It has no state, so its empty label is hidden
// rather than leaving a dead gap in the pill.
make_badge(app, bb.plus, bb.signals_row, "+", "signals");
gtk_widget_set_visible(bb.plus.state, FALSE);
gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel");
sync_signal_badges(app);
}
void sync_signal_badges(App& app) {
BadgeBar& bb = app.badges;
if (bb.signals_row == nullptr || bb.plus.button == 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. The badges' buttons die
// with the removal (their handlers with them), so the `Badge` records they point to can go too.
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; // == signal_badges_overflowed()
if (overflowed) {
make_badge(app, bb.overflow, bb.signals_row, "", "signals");
gtk_widget_set_visible(bb.overflow.state, FALSE);
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);
}
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);
bool have_signals = false;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
have_signals = !app.model->signals().empty();
}
if (bb.plus.button != nullptr) {
gtk_widget_set_sensitive(bb.plus.button, have_signals);
set_open(bb.plus.button, slideover_page(app) == "signals");
}
if (bb.overflow.button != nullptr) {
gtk_widget_set_sensitive(bb.overflow.button, have_signals);
// The overflow stands for every signal's badge too: a signal page opened from a list row marks it.
set_open(bb.overflow.button,
slideover_page(app) == bb.overflow.page || slideover_page(app).rfind("signal:", 0) == 0);
}
for (const std::unique_ptr<Badge>& sb : bb.signal_badges) {
set_open(sb->button, slideover_page(app) == sb->page);
}
if (bb.source.button == nullptr) return;
// Shown only while there is a source page to open. The slide-over's page list is the truth, so a page that is
// registered later brings the badge back, and its focus and pick with it.
const bool has_source_pages = !app.slideover.source_pages.empty();
if ((gtk_widget_get_visible(bb.source.button) != FALSE) != has_source_pages) {
gtk_widget_set_visible(bb.source.button, has_source_pages);
gtk_widget_set_can_target(bb.source.button, has_source_pages);
gtk_widget_set_focusable(bb.source.button, has_source_pages);
}
// The source's name; the link state is the status bar's. A file has no kind worth naming.
const bool file_mode = app.replaying || !app.model->config().open_waveform.empty();
std::string name = "Source";
if (file_mode) {
name = "File";
} else if (SourceKind* kind = find_kind(app.source_kind)) {
name = kind->display_name();
}
set_text_if_changed(bb.source.name, name.c_str());
set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0);
}
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, false, false, true, "off"},
{false, false, true, false, "off"}, {false, false, true, true, "off"},
{false, true, false, false, "off"}, {false, true, false, true, "off"},
{false, true, true, false, "off"}, {false, true, true, true, "off"},
{true, false, false, false, "disarmed"}, {true, false, false, true, "triggered"},
{true, false, true, false, "capturing"}, {true, false, true, true, "capturing"},
{true, true, false, false, "armed"}, {true, true, false, true, "triggered"},
{true, true, true, false, "capturing"}, {true, true, true, true, "capturing"},
};
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);
// The Source badge: shown only with a source page (`visible=`; hidden, the page-dependent fields read -1 or 0),
// and the segmented switch of a source with several panels.
const std::string first = a->badges.source.page;
bool switch_visible = false;
int second_ok = -1; // -1: there is no second page to select (one source page, or none)
std::string second_page;
if (!first.empty()) {
slideover_open(*a, first);
switch_visible = gtk_widget_get_visible(a->slideover.source_switch) != FALSE;
if (a->slideover.source_pages.size() >= 2) {
adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(a->slideover.source_switch),
a->slideover.source_pages[1].c_str());
second_page = slideover_page(*a);
second_ok = second_page == a->slideover.source_pages[1] ? 1 : 0;
}
}
const bool marked = gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") != FALSE;
// The widest minimum width of any source panel: the slide-over's scroller never scrolls horizontally, so
// a panel whose minimum exceeds the page (360 px less the scroller's own chrome) would be clipped.
int widest = 0;
for (const SourcePanelPage& p : a->source_panels) {
int min_w = 0;
gtk_widget_measure(p.content, GTK_ORIENTATION_HORIZONTAL, -1, &min_w, nullptr, nullptr, nullptr);
widest = std::max(widest, min_w);
}
slideover_close(*a);
std::fprintf(stderr,
"[badges] source name=%s pages=%zu visible=%d switch_visible=%d can_target=%d second_page=%s "
"second_ok=%d open_marked=%d closed_unmarked=%d panel_min_w=%d\n",
gtk_label_get_text(GTK_LABEL(a->badges.source.name)),
a->slideover.source_pages.size(),
gtk_widget_get_visible(a->badges.source.button) ? 1 : 0, switch_visible ? 1 : 0, gtk_widget_get_can_target(a->badges.source.button) ? 1 : 0,
second_page.empty() ? "-" : second_page.c_str(), second_ok,
first.empty() ? -1 : marked ? 1 : 0,
gtk_widget_has_css_class(a->badges.source.button, "udpscope-badge-open") ? 0 : 1, widest);
return G_SOURCE_REMOVE;
}
gboolean signal_badges_probe(gpointer user) {
App* a = static_cast<App*>(user);
BadgeBar& bb = a->badges;
const auto bar_height = [&] {
int min_h = 0, nat_h = 0;
gtk_widget_measure(bb.bar, GTK_ORIENTATION_VERTICAL, -1, &min_h, &nat_h, nullptr, nullptr);
return std::make_pair(gtk_widget_get_height(bb.bar), nat_h);
};
const auto before = bar_height();
int bar_min_w = 0;
gtk_widget_measure(bb.bar, GTK_ORIENTATION_HORIZONTAL, -1, &bar_min_w, nullptr, nullptr, nullptr);
// The enabled, listed signals the bar must stand for.
std::vector<SignalInfo> signals;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
signals = a->model->signals();
}
std::size_t enabled = 0;
std::size_t toggle = signals.size();
for (std::size_t i : listed_signals(signals)) {
if (!a->model->signal_enabled(i)) continue;
++enabled;
if (toggle == signals.size()) toggle = i;
}
const bool overflowed = bb.overflow.button != nullptr;
int dot_plotted = 0;
for (const std::unique_ptr<Badge>& sb : bb.signal_badges) {
if (signal_plotted(*a, sb->signal_name)) ++dot_plotted;
}
// A copy: the toggling below rebuilds the badges, and the label with them.
const std::string overflow_text = overflowed ? gtk_label_get_text(GTK_LABEL(bb.overflow.name)) : "-";
const int plus_visible = gtk_widget_get_visible(bb.plus.button) != FALSE ? 1 : 0;
const std::size_t badges = bb.signal_badges.size();
// One signal off and on again: the bar's height does not move, and the badges come back.
int bar_h_fixed = -1;
if (toggle != signals.size()) {
a->model->set_signal_enabled(toggle, false);
rebuild_signal_panels(*a);
const auto off = bar_height();
const std::size_t badges_off = bb.signal_badges.size();
a->model->set_signal_enabled(toggle, true);
rebuild_signal_panels(*a);
const auto on = bar_height();
bar_h_fixed = before == off && before == on && (overflowed || badges_off + 1 == badges || badges == 0) ? 1 : 0;
}
// A signal badge opens its page and the second click closes it; the "+" (the overflow when overflowed)
// does the same with the list.
int opens = -1;
if (!bb.signal_badges.empty()) {
Badge& first = *bb.signal_badges.front();
const std::string want = "signal:" + first.signal_name;
g_signal_emit_by_name(first.button, "clicked");
const bool open1 = slideover_page(*a) == want &&
gtk_widget_has_css_class(first.button, "udpscope-badge-open") != FALSE;
g_signal_emit_by_name(first.button, "clicked");
const bool closed = slideover_page(*a).empty() &&
gtk_widget_has_css_class(first.button, "udpscope-badge-open") == FALSE;
opens = open1 && closed ? 1 : 0;
}
Badge& list_badge = bb.overflow.button != nullptr ? bb.overflow : bb.plus;
g_signal_emit_by_name(list_badge.button, "clicked");
const bool plus1 = slideover_page(*a) == "signals";
g_signal_emit_by_name(list_badge.button, "clicked");
const int plus_opens = plus1 && slideover_page(*a).empty() ? 1 : 0;
std::fprintf(stderr,
"[sigbadges] enabled=%zu badges=%zu overflow=%d overflow_text=%s plus_visible=%d dot_plotted=%d "
"bar_h=%d bar_h_fixed=%d bar_min_w=%d opens=%d plus_opens=%d\n",
enabled, badges, overflowed ? 1 : 0, overflow_text.c_str(), plus_visible, dot_plotted, before.first,
bar_h_fixed, bar_min_w, opens, plus_opens);
// The drag handles. Controllers are enumerated through the widget's own model.
const auto count_controllers = [](GtkWidget* w, bool (*is)(GObject*)) {
int n = 0;
GListModel* controllers = gtk_widget_observe_controllers(w);
const guint items = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < items; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (is(c)) ++n;
g_object_unref(c);
}
return n;
};
const auto is_source = [](GObject* c) { return GTK_IS_DRAG_SOURCE(c) != FALSE; };
const auto is_target = [](GObject* c) { return GTK_IS_DROP_TARGET(c) != FALSE; };
int drag_sources = 0;
int drop_targets = 0;
for (const std::unique_ptr<Badge>& sb : bb.signal_badges) {
drag_sources += count_controllers(sb->button, is_source);
drop_targets += count_controllers(sb->button, is_target);
}
// The payload: what a drag from the first badge carries, read back from the provider `prepare` returns.
int prepare_ok = -1;
if (!bb.signal_badges.empty()) {
prepare_ok = 0;
GListModel* controllers = gtk_widget_observe_controllers(bb.signal_badges.front()->button);
const guint items = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < items; ++i) {
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
if (GTK_IS_DRAG_SOURCE(c)) {
GdkContentProvider* provider = nullptr;
g_signal_emit_by_name(c, "prepare", 0.0, 0.0, &provider);
if (provider != nullptr) {
GValue v = G_VALUE_INIT;
g_value_init(&v, G_TYPE_STRING);
if (gdk_content_provider_get_value(provider, &v, nullptr) && g_value_get_string(&v) != nullptr &&
std::to_string(bb.signal_badges.front()->signal) == g_value_get_string(&v)) {
prepare_ok = 1;
}
g_value_unset(&v);
g_object_unref(provider);
}
}
g_object_unref(c);
}
}
// The reorder, by the drop handler the targets run: the last badge dropped on the first goes in front of it,
// and the remembered order follows the widgets. Then the order is put back as it was.
const auto widget_names = [&] {
std::string names;
for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr; c = gtk_widget_get_next_sibling(c)) {
for (const std::unique_ptr<Badge>& sb : bb.signal_badges) {
if (sb->button == c) names += (names.empty() ? "" : ",") + sb->signal_name;
}
}
return names;
};
std::string reorder_before = "-";
std::string reorder_after = "-";
int reorder_ok = -1;
if (bb.signal_badges.size() >= 2) {
const std::vector<std::string> saved_order = a->badge_order;
reorder_before = widget_names();
Badge& first = *bb.signal_badges.front();
Badge& last = *bb.signal_badges.back();
GValue v = G_VALUE_INIT;
g_value_init(&v, G_TYPE_STRING);
g_value_set_string(&v, std::to_string(last.signal).c_str());
const gboolean handled = on_badge_drop(nullptr, &v, 0.0, 0.0, &first);
g_value_unset(&v);
reorder_after = widget_names();
std::string order;
for (const std::string& n : a->badge_order) order += (order.empty() ? "" : ",") + n;
GtkWidget* head = gtk_widget_get_first_child(bb.signals_row);
reorder_ok = handled && head == last.button && order == reorder_after && reorder_after != reorder_before ? 1 : 0;
// Back to the original place: the moved badge goes after the one that was last, and the order is restored.
gtk_box_reorder_child_after(GTK_BOX(bb.signals_row), last.button, bb.signal_badges[bb.signal_badges.size() - 2]->button);
a->badge_order = saved_order;
if (widget_names() != reorder_before) reorder_ok = 0;
}
// The drop path itself: a badge's payload lands through `assign_signal_to_row`, as the plot area's drop does.
// (The assignment rebuilds the panels, and the badges with them, so the first one is named beforehand.)
int payload_drop = -1;
int list_drag = -1;
if (!bb.signal_badges.empty()) {
const std::size_t signal = bb.signal_badges.front()->signal;
const std::string name = bb.signal_badges.front()->signal_name;
remove_signal_from_plots(*a, name);
const bool assigned = assign_signal_to_row(*a, signal, 0);
payload_drop = assigned && signal_plotted(*a, name) ? 1 : 0;
}
if (a->signal_list != nullptr) {
GtkWidget* row = gtk_widget_get_first_child(a->signal_list);
list_drag = row != nullptr ? count_controllers(row, is_source) : -1;
}
std::fprintf(stderr,
"[sigbadges] drag_sources=%d drop_targets=%d prepare_ok=%d reorder_before=%s reorder_after=%s "
"reorder_ok=%d payload_drop=%d list_drag=%d\n",
drag_sources, drop_targets, prepare_ok, reorder_before.c_str(), reorder_after.c_str(), reorder_ok,
payload_drop, list_drag);
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope
+24 -16
View File
@@ -63,11 +63,16 @@ struct HelpHint {
/// The table, which is the whole of the curation: a new surface is a row, and the heading row it
/// names must already exist in `App` (that is what `ui_app.hpp`'s help-layer fields are for).
///
/// The trigger, cursors, measurements, events and signals headings live on slide-over pages, which exist
/// (unmapped) whether or not the page is open: their hints are attached all the time and drawn while
/// the page is on screen, so the dock needs no hint of its own and the layer has one code path.
constexpr HelpHint kHelpHints[] = {
{"plot", "The plot",
"Every signal you have switched on is drawn here, one row per group of traces, with all rows "
"sharing this one time axis. The wheel zooms about the pointer, a drag pans, and a right-click "
"on a row opens that plot's own menu.",
"Every signal you have switched on is drawn here, one plot per group of traces, with all plots "
"sharing this one time axis. The layout editor (L) adds rows and splits a row into plots side "
"by side. The wheel zooms about the pointer, a drag pans, and a right-click on a plot opens "
"its own menu.",
"", &App::area_overlay},
{"trigger", "The trigger",
"Freezes the view on an event you describe: a signal, a condition (an edge, a pulse width, a "
@@ -77,32 +82,35 @@ constexpr HelpHint kHelpHints[] = {
{"cursors", "The cursors",
"Two movable instruments read against the window on screen: two verticals for time, two "
"horizontals for the value axis, or the two crossings of each trace. What every plotted row "
"reads at the cursor is listed in this pane.",
"reads at the cursor is listed in this page; the dock keeps a read-only copy.",
"C", &App::cursors_head},
{"measures", "The measurements",
"Readings over the visible window: average, RMS, top, peak to peak, amplitude, frequency, a "
"threshold count and more. Statistics under a reading track how it has varied since the last "
"reset, without the plot having to be still.",
"reset, without the plot having to be still. The dock lists the values; Edit opens this page.",
"", &App::measures_head},
{"math", "Math channels",
"A channel computed from the streamed ones in a small node graph: sources, gains, filters and "
"arithmetic, wired together and named here. It is a channel like any other, so it can be "
"plotted, measured, searched and exported.",
"", &App::math_head},
{"signals", "The signals",
"Every signal the source declares, each with the switch that acquires it. Nothing is buffered "
"until it is switched on, and only acquired signals appear in the drop targets and the "
"measurement editor. A signal that is dragged onto a plot is drawn there.",
{"signals", "Add a signal",
"The signals the source declares that are not acquired yet, and the math channels not computed, "
"each with the tick that acquires it; an acquired signal has a badge in the bar instead. Nothing "
"is buffered until it is ticked, and only acquired signals appear in the drop targets and the "
"measurement editor. A signal's badge, dragged onto a plot, draws it there.",
"G", &App::signals_head},
{"events", "The event history",
"Every triggered acquisition the cache keeps, with a picture of each. Open one to read it as a "
"file, or select two or four and have them compared in two plots or overlaid in one.",
"Every triggered acquisition the cache keeps, with a picture of each. A double click on one "
"replays it, or select two or four and have them compared in two plots or overlaid in one. The "
"dock keeps a short list of the newest, where one click replays; its Manage button opens this "
"page.",
"", &App::events_head},
{"events_record", "Recording events",
"With this on, every acquisition the trigger latches is written to the event cache -- the "
"samples around it and a picture of it -- so an event can be read again long after it has left "
"the live buffer. It is the switch beside it, and the same one the events pane shows the result "
"of.",
"the live buffer. It is the switch beside it, and the same one the dock's Events block and the "
"events page show the result of.",
"", &App::trigger_record_row},
{"trigger_advanced", "The trigger's finer settings",
"Pre-trigger share is how much of the event window is kept *before* the crossing, and hold-off "
@@ -116,9 +124,9 @@ constexpr HelpHint kHelpHints[] = {
"every row.",
"", &App::trigger_search},
{"signals_config", "Configuring signals",
"The list is locked until the pencil above opens the per-signal controls: the calibration that "
"maps raw counts to a unit, the colour a trace is drawn in, and which plot a signal is dragged "
"onto. A signal nothing measures can be switched off to stop acquiring it.",
"A signal's own page, opened by its badge or by its row here, shows its unit, gain and offset (a pencil edits each) "
"above what the stream declared about it, the colour a trace is drawn in, and which plot a signal is drawn on. A "
"signal nothing measures can be switched off there to stop acquiring it.",
"", &App::signals_note},
{"window", "The time window",
"How much time is on screen, counted back from the newest sample. Type a span, change its unit, "
+16 -5
View File
@@ -19,10 +19,14 @@ void update_cursors_pane(App& app) {
// A readout quotes a stored sample; with the instruments off there is no sample it quotes, so
// the cells blank rather than keeping the last ones. `"--"` is the same spelling an
// unavailable cell already uses -- two spellings of one state in one grid would be the defect.
for (const App::CursorLine& line : app.cursors_lines) {
if (line.a != nullptr) gtk_label_set_text(GTK_LABEL(line.a), "--");
if (line.b != nullptr) gtk_label_set_text(GTK_LABEL(line.b), "--");
if (line.d != nullptr) gtk_label_set_text(GTK_LABEL(line.d), "--");
// The dock's copy is written from the same literal, in the same loop.
const std::string blank = "--";
for (std::size_t i = 0; i < app.cursors_lines.size(); ++i) {
const App::CursorLine& line = app.cursors_lines[i];
if (line.a != nullptr) gtk_label_set_text(GTK_LABEL(line.a), blank.c_str());
if (line.b != nullptr) gtk_label_set_text(GTK_LABEL(line.b), blank.c_str());
if (line.d != nullptr) gtk_label_set_text(GTK_LABEL(line.d), blank.c_str());
dock_cursor_cell(app, i, blank, blank, blank);
}
return;
}
@@ -41,7 +45,8 @@ void update_cursors_pane(App& app) {
const RowSet::CursorMode mode = app.rows->cursor_mode();
const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both;
const bool crossings = mode == RowSet::CursorMode::Mix;
for (const App::CursorLine& line : app.cursors_lines) {
for (std::size_t index = 0; index < app.cursors_lines.size(); ++index) {
const App::CursorLine& line = app.cursors_lines[index];
if (line.a == nullptr) continue;
// One measurement per cell, each with the unit its own axis declares: the pane is a
// readout beside a plot, where the values are the point.
@@ -85,6 +90,8 @@ void update_cursors_pane(App& app) {
put(line.a, va);
put(line.b, vb);
put(line.d, vd);
// The dock's compact line takes the same three strings, from these variables.
dock_cursor_cell(app, index, va, vb, vd);
}
}
@@ -144,6 +151,10 @@ void on_cursors_toggled(GtkToggleButton* button, gpointer user) {
// cursors back on -- and a control inside an insensitive parent cannot be clicked.
gtk_widget_set_sensitive(app->cursors_reset, on);
if (app->cursors_body != nullptr) gtk_widget_set_sensitive(app->cursors_body, on);
// The dock's copy and the strip's chips follow: switched off they stay on screen, insensitive, reading
// `--`. One function, so the two cannot disagree.
dock_set_cursors_sensitive(*app, on);
sync_readouts_strip(*app); // the strip's Cursors group (and the strip itself) follows at once
if (!on) update_cursors_pane(*app);
if (on) {
app->rows->reset_cursors();
+591
View File
@@ -0,0 +1,591 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The compact readouts at the top of the dock: a read-only copy of the cursors table and of the
// measurement values, in a fixed width, with an Edit button for each that opens the full panel.
//
// The strings are not formatted here. `update_cursors_pane()` and `update_measures()` format each value
// once and hand the same variable to the page's label and to `dock_cursor_cell()` / `dock_measure_value()`,
// so the two views cannot disagree. Every label is ellipsized at a fixed `width-chars`, so the dock's
// layout does not depend on what a value reads; the full text is the label's tooltip.
//
// The same strings are shown a third time in the readouts strip under the plot (`readouts_strip`, one
// fixed-height line: a "Cursors" button, one chip per cursor line with its delta, a "Measures" button, one
// chip per measurement with its value), which is on screen whenever the dock is not beside the plot (hidden
// with the Dock button, or the window collapsed and the dock an overlay). The setters below
// write the strip's labels in the same call as the dock's, from the same argument, so there is still one
// string and no second formatter.
//
// Below them, the Events block: one compact row per cached event (marker, title, time; no thumbnails),
// a Manage button for the full list, and one click on a row to replay it. Its rows are built from the
// same `event_store->list()` and the same `event_row_title()` as the events page, so the two cannot
// disagree.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <algorithm>
#include <cstring>
#include <ctime>
#include <string>
#include <vector>
namespace udpscope {
namespace ui_impl {
namespace {
/// The width, in characters, of a value cell and of a `who` / type cell. Fixed, so the dock is as wide
/// with `--` as with `-123.456 mV`; their sum (5 + 3 x 9 characters) is what keeps the readouts inside the
/// dock's 280 px.
constexpr int kValueChars = 9;
constexpr int kNameChars = 5;
GtkWidget* fixed_label(const char* text, int chars, GtkAlign halign) {
GtkWidget* l = gtk_label_new(text);
gtk_widget_set_halign(l, halign);
gtk_label_set_ellipsize(GTK_LABEL(l), PANGO_ELLIPSIZE_END);
gtk_label_set_width_chars(GTK_LABEL(l), chars);
gtk_label_set_max_width_chars(GTK_LABEL(l), chars);
gtk_widget_add_css_class(l, "caption");
return l;
}
/// Sets a value label's text and, with it, the tooltip that carries what the ellipsis cut. The text is
/// compared first so the per-frame write of an unchanged value costs nothing.
void set_value_text(GtkWidget* label, const std::string& text) {
if (label == nullptr) return;
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text.c_str()) == 0) return;
gtk_label_set_text(GTK_LABEL(label), text.c_str());
gtk_widget_set_tooltip_text(label, text.c_str());
}
void on_edit_cursors(GtkButton*, gpointer user) {
slideover_open(*static_cast<App*>(user), "cursors");
}
void on_edit_measures(GtkButton*, gpointer user) {
slideover_open(*static_cast<App*>(user), "measure");
}
/// A click on a measurement's line opens that measurement's dialog, as the page row's `edit` button
/// does. The line carries its index (plus one, so that zero is "none") in its data.
void on_measure_line_pressed(GtkGestureClick* gesture, int n_press, double, double, gpointer user) {
if (n_press != 1) return;
GtkWidget* line = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture));
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(line), "udpscope-measure-index")) - 1;
if (idx < 0) return;
open_measure_dialog(*static_cast<App*>(user), idx);
}
/// The page's `who` cell, verbatim, onto a copy: a chip and a name as markup, or plain text, and its
/// tooltip. The dock's cell and the strip's chip are both made by it.
void copy_who(GtkWidget* dst, GtkWidget* src) {
if (src == nullptr) return;
const char* text = gtk_label_get_label(GTK_LABEL(src));
if (gtk_label_get_use_markup(GTK_LABEL(src))) {
gtk_label_set_markup(GTK_LABEL(dst), text);
} else {
gtk_label_set_text(GTK_LABEL(dst), text);
}
const char* tip = gtk_widget_get_tooltip_text(src);
gtk_widget_set_tooltip_text(dst, tip != nullptr ? tip : gtk_label_get_text(GTK_LABEL(src)));
}
void clear_box(GtkWidget* box) {
GtkWidget* child = gtk_widget_get_first_child(box);
while (child != nullptr) {
GtkWidget* next = gtk_widget_get_next_sibling(child);
gtk_box_remove(GTK_BOX(box), child);
child = next;
}
}
/// The strip's cell widths, in characters. Fixed like the dock's, so the strip's chips are as wide with
/// `--` as with `-123.456 mV` (what does not fit scrolls).
constexpr int kStripWhoChars = 7;
constexpr int kStripTypeChars = 10;
/// A flat text button that opens a slide-over page. It only opens a page, so a rebuild can never free the
/// widget its own signal was emitted from.
GtkWidget* strip_button(const char* label, GCallback on_click, App* app, const char* tip) {
GtkWidget* b = gtk_button_new_with_label(label);
gtk_widget_add_css_class(b, "flat");
gtk_widget_add_css_class(b, "udpscope-strip-button");
gtk_widget_set_valign(b, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(b, tip);
g_signal_connect(b, "clicked", on_click, app);
return b;
}
/// A section's heading row: its title at the left, its Edit button at the right.
GtkWidget* section_head(const char* title, GtkWidget** edit_out, GCallback on_edit, App* app,
const char* tip) {
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* t = gtk_label_new(title);
gtk_widget_set_halign(t, GTK_ALIGN_START);
gtk_widget_set_hexpand(t, true);
gtk_widget_add_css_class(t, "caption-heading");
gtk_box_append(GTK_BOX(row), t);
GtkWidget* edit = gtk_button_new_with_label("Edit");
gtk_widget_add_css_class(edit, "flat");
gtk_widget_set_tooltip_text(edit, tip);
g_signal_connect(edit, "clicked", on_edit, app);
gtk_box_append(GTK_BOX(row), edit);
*edit_out = edit;
return row;
}
/// The same header `cursors_header()` writes into the page's table, small.
void dock_cursors_header(GtkGrid* grid) {
const char* texts[4] = {"", "A", "B", "delta"};
for (int c = 0; c < 4; ++c) {
GtkWidget* l = fixed_label(texts[c], c == 0 ? kNameChars : kValueChars,
c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END);
gtk_widget_add_css_class(l, "dim-label");
gtk_grid_attach(grid, l, c, 0, 1, 1);
}
}
/// Fills the strip `main-window.ui` declares: the two buttons and the two boxes the rebuilds fill.
void build_readouts_strip(App& app) {
if (app.strip_box == nullptr) return;
clear_box(app.strip_box);
app.strip_cursor_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
app.strip_measure_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
// Each section is a group of its own so the strip can show only the one that has something to say.
app.strip_cursor_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(app.strip_cursor_group),
strip_button("Cursors", G_CALLBACK(on_edit_cursors), &app, "the cursors' full panel"));
gtk_box_append(GTK_BOX(app.strip_cursor_group), app.strip_cursor_box);
app.strip_sep = gtk_separator_new(GTK_ORIENTATION_VERTICAL);
gtk_widget_set_margin_top(app.strip_sep, 5);
gtk_widget_set_margin_bottom(app.strip_sep, 5);
app.strip_measure_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(app.strip_measure_group),
strip_button("Measures", G_CALLBACK(on_edit_measures), &app,
"the measurements' full panel"));
gtk_box_append(GTK_BOX(app.strip_measure_group), app.strip_measure_box);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_cursor_group);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_sep);
gtk_box_append(GTK_BOX(app.strip_box), app.strip_measure_group);
}
} // namespace
GtkWidget* build_readouts_dock(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
GtkWidget* heading = gtk_label_new("Readouts");
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_widget_add_css_class(heading, "heading");
gtk_box_append(GTK_BOX(box), heading);
gtk_box_append(GTK_BOX(box), section_head("Cursors", &app.dock_cursors_edit,
G_CALLBACK(on_edit_cursors), &app,
"the cursors' full panel"));
app.dock_cursor_grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(app.dock_cursor_grid), 2);
gtk_grid_set_column_spacing(GTK_GRID(app.dock_cursor_grid), 4);
gtk_box_append(GTK_BOX(box), app.dock_cursor_grid);
gtk_box_append(GTK_BOX(box), section_head("Measurements", &app.dock_measures_edit,
G_CALLBACK(on_edit_measures), &app,
"the measurements' full panel"));
app.dock_measures_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_box_append(GTK_BOX(box), app.dock_measures_box);
app.dock_readouts = box;
build_readouts_strip(app);
// The tables are built from the pages' current lines, which exist already when the pages were built
// first; otherwise the pages' own rebuild fills them.
dock_rebuild_cursors(app);
dock_rebuild_measures(app);
return box;
}
void dock_rebuild_cursors(App& app) {
if (app.dock_cursor_grid == nullptr) return;
GtkGrid* grid = GTK_GRID(app.dock_cursor_grid);
GtkWidget* child = gtk_widget_get_first_child(app.dock_cursor_grid);
while (child != nullptr) {
GtkWidget* next = gtk_widget_get_next_sibling(child);
gtk_grid_remove(grid, child);
child = next;
}
app.dock_cursor_lines.clear();
// The strip's chips are rebuilt with the dock's lines (one per line, same order), so the counts agree by
// construction. The box itself keeps its sensitivity across the rebuild.
if (app.strip_cursor_box != nullptr) clear_box(app.strip_cursor_box);
app.strip_cursor_who.clear();
app.strip_cursor_deltas.clear();
dock_cursors_header(grid);
int at = 1;
for (const App::CursorLine& src : app.cursors_lines) {
App::DockCursorLine line;
line.label = fixed_label("", kNameChars, GTK_ALIGN_START);
copy_who(line.label, src.label);
gtk_grid_attach(grid, line.label, 0, at, 1, 1);
GtkWidget** cells[3] = {&line.a, &line.b, &line.d};
for (int c = 0; c < 3; ++c) {
GtkWidget* v = fixed_label("", kValueChars, GTK_ALIGN_END);
gtk_widget_add_css_class(v, "numeric");
gtk_label_set_selectable(GTK_LABEL(v), true);
gtk_grid_attach(grid, v, c + 1, at, 1, 1);
*cells[c] = v;
}
app.dock_cursor_lines.push_back(line);
if (app.strip_cursor_box != nullptr) {
// A chip: the line's `who` and its delta. Not selectable, so a drag on it scrolls the strip.
GtkWidget* chip = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
GtkWidget* who = fixed_label("", kStripWhoChars, GTK_ALIGN_START);
copy_who(who, src.label);
GtkWidget* delta = fixed_label("", kValueChars, GTK_ALIGN_END);
gtk_widget_add_css_class(delta, "numeric");
gtk_box_append(GTK_BOX(chip), who);
gtk_box_append(GTK_BOX(chip), delta);
gtk_box_append(GTK_BOX(app.strip_cursor_box), chip);
app.strip_cursor_who.push_back(who);
app.strip_cursor_deltas.push_back(delta);
}
++at;
}
}
void dock_rebuild_measures(App& app) {
if (app.dock_measures_box == nullptr) return;
clear_box(app.dock_measures_box);
if (app.strip_measure_box != nullptr) clear_box(app.strip_measure_box);
app.dock_measure_labels.clear();
app.strip_measure_labels.clear();
if (app.measures.empty()) {
GtkWidget* none = gtk_label_new("no measurements");
gtk_widget_set_halign(none, GTK_ALIGN_START);
gtk_widget_add_css_class(none, "caption");
gtk_widget_add_css_class(none, "dim-label");
gtk_box_append(GTK_BOX(app.dock_measures_box), none);
// The strip has no placeholder: its Measures group is hidden while there are no measurements.
return;
}
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
g_object_set_data(G_OBJECT(line), "udpscope-measure-index",
GINT_TO_POINTER(static_cast<int>(i) + 1));
GtkWidget* chip = gtk_label_new("");
gtk_label_set_markup(GTK_LABEL(chip), measure_chip_markup(app, m.signal).c_str());
gtk_widget_set_tooltip_text(chip, m.signal.c_str());
gtk_box_append(GTK_BOX(line), chip);
GtkWidget* kind = fixed_label(measure_type_name(m.type), 12, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true);
gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
gtk_box_append(GTK_BOX(line), kind);
GtkWidget* value = fixed_label("--", kValueChars, GTK_ALIGN_END);
gtk_widget_add_css_class(value, "numeric");
gtk_label_set_selectable(GTK_LABEL(value), true);
gtk_box_append(GTK_BOX(line), value);
// On the line and not on the value label, which is selectable and so keeps its own clicks.
GtkGesture* click = gtk_gesture_click_new();
g_signal_connect(click, "pressed", G_CALLBACK(on_measure_line_pressed), &app);
gtk_widget_add_controller(line, GTK_EVENT_CONTROLLER(click));
gtk_box_append(GTK_BOX(app.dock_measures_box), line);
app.dock_measure_labels.push_back(value);
if (app.strip_measure_box != nullptr) {
// The strip's chip: the signal's chip, the type's short name and the value; a click on it opens the
// same dialog as the dock's line.
GtkWidget* sline = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
g_object_set_data(G_OBJECT(sline), "udpscope-measure-index",
GINT_TO_POINTER(static_cast<int>(i) + 1));
GtkWidget* schip = gtk_label_new("");
gtk_label_set_markup(GTK_LABEL(schip), measure_chip_markup(app, m.signal).c_str());
gtk_widget_set_tooltip_text(schip, m.signal.c_str());
gtk_box_append(GTK_BOX(sline), schip);
GtkWidget* skind = fixed_label(measure_type_name(m.type), kStripTypeChars, GTK_ALIGN_START);
gtk_widget_set_tooltip_text(skind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
gtk_box_append(GTK_BOX(sline), skind);
GtkWidget* svalue = fixed_label("--", kValueChars, GTK_ALIGN_END);
gtk_widget_add_css_class(svalue, "numeric");
gtk_box_append(GTK_BOX(sline), svalue);
GtkGesture* sclick = gtk_gesture_click_new();
g_signal_connect(sclick, "pressed", G_CALLBACK(on_measure_line_pressed), &app);
gtk_widget_add_controller(sline, GTK_EVENT_CONTROLLER(sclick));
gtk_box_append(GTK_BOX(app.strip_measure_box), sline);
app.strip_measure_labels.push_back(svalue);
}
}
}
void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b,
const std::string& d) {
if (line >= app.dock_cursor_lines.size()) return; // mid-rebuild
const App::DockCursorLine& l = app.dock_cursor_lines[line];
set_value_text(l.a, a);
set_value_text(l.b, b);
set_value_text(l.d, d);
// The strip's chip takes the delta from the same argument, in the same call.
if (line < app.strip_cursor_deltas.size()) set_value_text(app.strip_cursor_deltas[line], d);
}
void dock_measure_value(App& app, std::size_t i, const std::string& text) {
if (i >= app.dock_measure_labels.size()) return; // mid-rebuild
set_value_text(app.dock_measure_labels[i], text);
if (i < app.strip_measure_labels.size()) set_value_text(app.strip_measure_labels[i], text);
}
void dock_set_cursors_sensitive(App& app, bool on) {
// Disabled, not hidden: the dock's grid and the strip's chips go grey together and read `--`.
if (app.dock_cursor_grid != nullptr) gtk_widget_set_sensitive(app.dock_cursor_grid, on);
if (app.strip_cursor_box != nullptr) gtk_widget_set_sensitive(app.strip_cursor_box, on);
}
void sync_readouts_strip(App& app) {
if (app.readouts_strip == nullptr || app.side_view == nullptr) return;
// The strip is on screen whenever the dock is not *beside* the plot: hidden with the Dock button, or
// the window collapsed (compact mode), where the dock overlays the plot -- even while the overlay is
// open -- and is not a readout you can keep an eye on while looking at the trace. `collapsed` is the
// split view's value from the previous frame (the tick calls this before `set_collapsed()`), so the
// strip follows a collapse or a widening one tick late; `show-sidebar` is the wish just re-asserted.
//
// And only with something to read: a section is there when its feature is (cursors switched on,
// at least one measurement), and with neither the strip is gone -- it is a readout, not a control
// strip, and an empty line under the plot is 26 px of nothing. (The one place "hide" beats "disable":
// the dock keeps both blocks on screen, so the empty state is never invisible.)
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(app.side_view);
const bool cursors = app.cursors_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.cursors_toggle));
const bool measures = !app.measures.empty();
const bool want = (!adw_overlay_split_view_get_show_sidebar(split) ||
adw_overlay_split_view_get_collapsed(split)) &&
(cursors || measures);
auto show = [](GtkWidget* w, bool v) {
if (w != nullptr && (gtk_widget_get_visible(w) != FALSE) != v) gtk_widget_set_visible(w, v);
};
show(app.strip_cursor_group, cursors);
show(app.strip_measure_group, measures);
show(app.strip_sep, cursors && measures);
show(app.readouts_strip, want);
}
namespace {
/// The widths of an event row's three cells, in characters. Fixed, so the Events block is as wide with
/// one event as with thirty (the marker is one cell wide even when it is empty).
constexpr int kEventTitleChars = 18;
constexpr int kEventSeqChars = 8;
/// The one-character mark of a row: filled for the event on screen, hollow for a member of the group on
/// screen, blank for the rest. Both questions are the ones `event_row_title()` asks (`ui_events.cpp`).
const char* dock_event_marker(const App& app, const EventRecord& r) {
if (event_on_screen(app, r)) return "\xE2\x97\x8F"; // U+25CF black circle
if (event_in_group(app, r)) return "\xE2\x97\x8B"; // U+25CB white circle
return " ";
}
/// Opens the `events` page through `open_events_page()` (which refreshes the list only if it is not
/// already open). The button is not a child of either list, so the rebuild cannot free the widget it runs from.
void on_manage_events(GtkButton*, gpointer user) {
open_events_page(*static_cast<App*>(user));
}
/// One click on a row replays it, as the page list's activation does: `replay_event()` and then the in-place
/// re-mark. The record is copied first, because nothing it triggers may be allowed to free the row it
/// was read from.
///
/// Safety depends on `replay_event()` never rebuilding the dock's list (it must go through
/// `dock_mark_events()`, which rewrites labels in place): a rebuild here would free this very row, and
/// `row`, `record` and the list box's own emission of `row-activated` would all be dangling. The copy
/// guards the record only, not the row. If a replay ever has to refresh the cache's view, defer it.
void on_dock_event_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
App& app = *static_cast<App*>(user);
if (row == nullptr) return;
const auto* record =
static_cast<const EventRecord*>(g_object_get_data(G_OBJECT(row), "udpscope-event"));
if (record == nullptr) return;
const EventRecord copy = *record;
replay_event(app, copy);
mark_events_on_screen(app);
}
} // namespace
GtkWidget* build_events_dock(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* title = gtk_label_new("Events");
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_widget_set_hexpand(title, true);
gtk_widget_add_css_class(title, "heading");
gtk_box_append(GTK_BOX(head), title);
GtkWidget* manage = gtk_button_new_with_label("Manage");
gtk_widget_add_css_class(manage, "flat");
gtk_widget_set_tooltip_text(manage, "the events' full list and actions");
g_signal_connect(manage, "clicked", G_CALLBACK(on_manage_events), &app);
gtk_box_append(GTK_BOX(head), manage);
gtk_box_append(GTK_BOX(box), head);
GtkWidget* list = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_NONE);
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(list), true);
gtk_widget_add_css_class(list, "boxed-list");
g_signal_connect(list, "row-activated", G_CALLBACK(on_dock_event_activated), &app);
// A fixed height (about four rows), so the block is as tall with no events as with thirty and the
// readouts above it do not move when one is latched.
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_min_content_height(GTK_SCROLLED_WINDOW(scroller), 112);
gtk_scrolled_window_set_max_content_height(GTK_SCROLLED_WINDOW(scroller), 112);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), list);
gtk_box_append(GTK_BOX(box), scroller);
app.dock_events_box = box;
app.dock_events_list = list;
app.dock_events_manage = manage;
// Insensitive until the first availability sync says otherwise: on screen and greyed, never hidden.
gtk_widget_set_sensitive(box, FALSE);
dock_rebuild_events(app);
return box;
}
void dock_rebuild_events(App& app) {
if (app.dock_events_list == nullptr || app.event_store == nullptr) return;
dock_rebuild_events(app, app.event_store->list());
}
namespace {
/// Puts the list's vertical scroll back after a refill. The adjustment's bounds are only updated at the
/// next allocation, so the value set at once is clamped against the old ones; an idle (which runs after the
/// frame that laid the new rows out) sets it again against the new bounds, where the clamp is exact.
void restore_dock_scroll(GtkWidget* list, double value) {
GtkWidget* scroller = gtk_widget_get_ancestor(list, GTK_TYPE_SCROLLED_WINDOW);
if (scroller == nullptr) return;
GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroller));
gtk_adjustment_set_value(adj, value);
struct Pending {
GtkAdjustment* adj;
double value;
};
auto* p = new Pending{GTK_ADJUSTMENT(g_object_ref(adj)), value};
g_idle_add_full(
G_PRIORITY_DEFAULT_IDLE,
[](gpointer d) -> gboolean {
auto* q = static_cast<Pending*>(d);
gtk_adjustment_set_value(q->adj, q->value);
g_object_unref(q->adj);
delete q;
return G_SOURCE_REMOVE;
},
p, nullptr);
}
} // namespace
void dock_rebuild_events(App& app, const std::vector<EventRecord>& records) {
if (app.dock_events_list == nullptr) return;
GtkListBox* list = GTK_LIST_BOX(app.dock_events_list);
GtkWidget* scroller = gtk_widget_get_ancestor(app.dock_events_list, GTK_TYPE_SCROLLED_WINDOW);
const double scroll =
scroller != nullptr
? gtk_adjustment_get_value(gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroller)))
: 0.0;
gtk_list_box_remove_all(list);
if (records.empty()) {
GtkWidget* none = gtk_label_new("no events yet");
gtk_widget_set_halign(none, GTK_ALIGN_START);
gtk_widget_add_css_class(none, "caption");
gtk_widget_add_css_class(none, "dim-label");
gtk_widget_set_margin_start(none, 6);
gtk_widget_set_margin_top(none, 4);
gtk_widget_set_margin_bottom(none, 4);
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), none);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), false);
gtk_list_box_append(list, row);
return;
}
// The newest `kDockEventsMax` only: the list is newest first, and a block that builds four widgets per
// cached event is a cost that grows with a cache the user may have set to unbounded.
const std::size_t shown = std::min(records.size(), kDockEventsMax);
for (std::size_t i = 0; i < shown; ++i) {
const EventRecord& r = records[i];
GtkWidget* row = gtk_list_box_row_new();
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(line, 6);
gtk_widget_set_margin_end(line, 6);
gtk_widget_set_margin_top(line, 3);
gtk_widget_set_margin_bottom(line, 3);
GtkWidget* marker = fixed_label(dock_event_marker(app, r), 1, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(line), marker);
const std::string text = event_row_title(app, r);
GtkWidget* name = fixed_label(text.c_str(), kEventTitleChars, GTK_ALIGN_START);
gtk_widget_set_hexpand(name, true);
gtk_widget_set_tooltip_text(name, text.c_str());
gtk_box_append(GTK_BOX(line), name);
// The latch's sequence number, as the page row's third line ends with it: the title already carries the
// clock, so this is the part of the row that is not in it.
const std::string seq = "#" + std::to_string(r.seq);
GtkWidget* num = fixed_label(seq.c_str(), kEventSeqChars, GTK_ALIGN_END);
gtk_widget_set_tooltip_text(num, ("latch " + seq).c_str());
gtk_widget_add_css_class(num, "dim-label");
gtk_box_append(GTK_BOX(line), num);
g_object_set_data(G_OBJECT(row), "udpscope-event-marker", marker);
g_object_set_data(G_OBJECT(row), "udpscope-event-title", name);
g_object_set_data_full(G_OBJECT(row), "udpscope-event", new EventRecord(r),
[](gpointer p) { delete static_cast<EventRecord*>(p); });
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
gtk_list_box_append(list, row);
}
if (records.size() > shown) {
// A last row with no record (like the placeholder: `dock_mark_events()` skips it, and it does nothing
// when activated). Fixed text and the same ellipsis as the rest, so it cannot widen the dock.
const std::string more = "\xE2\x80\xA6 " + std::to_string(records.size() - shown) + " more, see Manage";
GtkWidget* label = fixed_label(more.c_str(), kEventTitleChars + kEventSeqChars, GTK_ALIGN_START);
gtk_widget_add_css_class(label, "dim-label");
gtk_widget_set_tooltip_text(label, more.c_str());
gtk_widget_set_margin_start(label, 6);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), false);
gtk_list_box_append(list, row);
}
restore_dock_scroll(app.dock_events_list, scroll);
}
void dock_mark_events(App& app) {
if (app.dock_events_list == nullptr) return;
for (GtkWidget* row = gtk_widget_get_first_child(app.dock_events_list); row != nullptr;
row = gtk_widget_get_next_sibling(row)) {
const auto* record =
static_cast<const EventRecord*>(g_object_get_data(G_OBJECT(row), "udpscope-event"));
GtkWidget* marker = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-marker"));
GtkWidget* name = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-title"));
// The "no events yet" placeholder carries none of the three: skipped.
if (record == nullptr || marker == nullptr || name == nullptr) continue;
gtk_label_set_text(GTK_LABEL(marker), dock_event_marker(app, *record));
set_value_text(name, event_row_title(app, *record));
}
}
void dock_events_set_sensitive(App& app, bool available) {
if (app.dock_events_box == nullptr) return;
gtk_widget_set_sensitive(app.dock_events_box, available);
}
} // namespace ui_impl
} // namespace udpscope
+81 -93
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The event cache, seen from the front end: the pane that lists it, the picture each row carries, the\n// replay, and the two group layouts (side-by-side comparison and accumulation) with the axis mirror\n// that keeps a comparison several plots in step.
// The event cache, seen from the front end: the slide-over page that lists it, the picture each row
// carries, the replay, and the two group layouts (side-by-side comparison and accumulation) with the
// axis mirror that keeps a comparison several plots in step.
#include "ui_app.hpp"
@@ -32,67 +34,19 @@ GtkWidget* make_compare_area(App& app) {
return widget;
}
/// Whether the event history is reachable at all, and the one place that decides it. It has exactly
/// two preconditions: trigger mode (there are no events without a trigger) and recording (a disabled
/// cache is empty, and an empty column beside the plot is what "off" must not look like). Both the
/// toolbar button and the pane follow from it, so they cannot disagree -- and turning recording off
/// while the pane is open takes the button and the pane with it rather than leaving a stale list.
void sync_events_availability(App& app) {
if (app.events_toggle == nullptr) return;
// What the divider was last dragged to. Read here, on every change of state, because the close that
// follows is about to set the position to 0 and lose it.
if (app.events_divider != nullptr) {
const int at = gtk_paned_get_position(GTK_PANED(app.events_divider));
if (at > 1) app.events_pane_width = at;
}
// Available while the cache can still grow (recording on) *or* while it is being read (an event on
// screen). Not trigger mode: a stored event being examined is a file, and it has no trigger -- if the
// pane's life depended on the trigger, opening one would close the list it was opened from.
// ... or whenever the cache *has* something: an event on disk outlives the switch that wrote it, and
// the button that shows it must not go with the switch. `events_cached` is refreshed once a second
// from the tick -- the store's list is a directory read, not a per-frame question.
const bool available = app.event_recording || app.replaying || app.events_cached;
gtk_widget_set_visible(app.events_toggle, available);
if (!available) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE);
gtk_revealer_set_reveal_child(GTK_REVEALER(app.events_revealer), FALSE);
}
const bool open =
available && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.events_toggle));
// Visibility, not only `reveal_child`: an unrevealed `GtkRevealer` *measures* 0x0 and its box still
// hands it the leftover width, so the collapsed pane reserved an empty column beside the plot.
gtk_widget_set_visible(app.events_revealer, open);
// The position is what gives the pane its room (or none): a paned hands its start child at least its
// minimum unless the position says otherwise.
if (app.events_divider != nullptr) {
gtk_paned_set_position(GTK_PANED(app.events_divider), open ? app.events_pane_width : 0);
}
}
/// Restates the divider's position, once a frame.
/// Whether the event history is reachable at all, and the one place that decides it. Recording on (the
/// cache can still grow), an event being read (a replay), or something already in the cache (an event on
/// disk outlives the switch that wrote it) -- not trigger mode: a stored event being examined is a file,
/// and it has no trigger. The dock's Events block and the `events` page follow from it, so they cannot
/// disagree; both stay on screen and go insensitive ("disable, do not hide"), and turning recording off
/// while the page is open leaves the page up, greyed, rather than stale and live.
///
/// `gtk_paned_set_position()` is clamped against the allocation the paned has *now*, and a click on
/// the history button flips the toggle in the same instant the pane becomes visible -- so the position
/// is clamped against a child that still measures zero, the pane opens with no width, and only the
/// next call (a replay's, seconds later) gives it one. Restating it per frame is idempotent, and a
/// position the user dragged is *learned* rather than fought: the width is read back whenever the pane
/// is open and the position is not the one we would set.
void sync_events_divider(App& app) {
if (app.events_divider == nullptr) return;
const bool open =
app.events_revealer != nullptr && gtk_widget_get_visible(app.events_revealer);
const int at = gtk_paned_get_position(GTK_PANED(app.events_divider));
if (!open) {
if (at != 0) gtk_paned_set_position(GTK_PANED(app.events_divider), 0);
return;
}
if (at > 1 && at != app.events_pane_width) {
app.events_pane_width = at; // the user dragged it: that is the width now
return;
}
if (at != app.events_pane_width) {
gtk_paned_set_position(GTK_PANED(app.events_divider), app.events_pane_width);
}
/// `events_cached` is refreshed once a second from the tick -- the store's list is a directory read, not
/// a per-frame question.
void sync_events_availability(App& app) {
const bool available = app.event_recording || app.replaying || app.events_cached;
dock_events_set_sensitive(app, available);
if (app.events_box != nullptr) gtk_widget_set_sensitive(app.events_box, available);
}
/// Writes the held event into the cache: its table, a sidecar of what the list shows, and a
@@ -132,11 +86,13 @@ bool record_event(App& app) {
set_notice(app, "event cached: " + std::to_string(table.columns.size()) + " signals, " +
std::to_string(table.rows()) + " samples",
3);
// Only while the pane is on screen: rebuilding every row per latch for a column nobody opened is
// work the tick owes nothing, and the toggle's own handler refreshes when it is asked for.
if (app.events_revealer != nullptr &&
gtk_revealer_get_child_revealed(GTK_REVEALER(app.events_revealer))) {
// The dock follows every latch; the page's rows (with pictures) only while the page is open:
// rebuilding them per latch for a list nobody opened is work the tick owes nothing, and the Manage
// button refreshes the list when it is asked for.
if (slideover_page(app) == "events") {
refresh_events_pane(app);
} else {
dock_rebuild_events(app);
}
return true;
}
@@ -265,7 +221,7 @@ bool replay_event(App& app, const EventRecord& record) {
// left alone afterwards -- restated here and never per frame.
// The axis is the event's own and the tick pins it there (`pin_event_window`): nothing to frame or
// home here beyond the window control's own reading, which the tick keeps in step.
// The pane is available through a replay, and this is what keeps it open.
// The events block is available through a replay, and this is what keeps it so.
sync_events_availability(app);
set_notice(app, "replaying " + record.stem + " (" +
std::to_string(record.signals) + " signals, " +
@@ -580,6 +536,7 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
// Fit on open, once: the group's own span, framed.
// As above: the tick pins the axis to the group's own span and marks the trigger.
sync_events_availability(app);
mark_events_on_screen(app); // the dock marks the group's members
return true;
}
@@ -665,7 +622,7 @@ EventThumbCache* event_thumb_cache(App& app) {
// Created on first use and owned by the list. Nothing used to create it, and a missing cache is
// not a slow path here but a *blank* one: `event_preview_texture()` answers from the cache or not
// at all, so every row came up without a picture -- which is what the previews were reported for.
// Owned by the list because nothing else has the pane's lifetime: textures of a list that no
// Owned by the list because nothing else has the events page's lifetime: textures of a list that no
// longer exists are so much held memory.
cache = new EventThumbCache();
g_object_set_data_full(G_OBJECT(app.events_list), "udpscope-event-thumbs", cache,
@@ -750,7 +707,7 @@ GdkTexture* event_preview_texture(App& app, const std::string& path) {
///
/// Through GTK's own tooltip rather than a popover of ours, for lifetime: a tooltip is shown, placed,
/// dismissed and destroyed by GTK, and it takes no pointer grab -- a popover per row would be a
/// parented widget to unparent on every rebuild of the pane, and a grab to lose (see the GTK
/// parented widget to unparent on every rebuild of the page, and a grab to lose (see the GTK
/// invariants on both). The texture is decoded in the handler and dropped with the tooltip's picture,
/// so a hover costs nothing once it is over and the row cache keeps holding only the small one.
gboolean on_preview_query_tooltip(GtkWidget*, int, int, gboolean, GtkTooltip* tooltip, gpointer user) {
@@ -777,6 +734,19 @@ void attach_preview_zoom(GtkWidget* frame, const std::string& path) {
GConnectFlags(0));
}
/// Whether the event is the one on screen: the one question the title's "(on screen)" and the dock's
/// filled mark both ask.
bool event_on_screen(const App& app, const EventRecord& r) {
return std::string(app.waveform_name) == r.file;
}
/// Whether the event is one of the members of the comparison or accumulation on screen.
bool event_in_group(const App& app, const EventRecord& r) {
if (app.group_kind == EventGroupKind::None) return false;
return std::any_of(app.group.begin(), app.group.end(),
[&](const EventRecord& g) { return g.file == r.file; });
}
/// A row's first line: when the event was latched, how long it is, and whether it is the one on
/// screen. One function because two callers write it -- the row builder and the in-place
/// re-mark -- and a mark that reads differently depending on which of them last ran would be
@@ -789,11 +759,12 @@ std::string event_row_title(App& app, const EventRecord& r) {
std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm);
char title[160];
std::snprintf(title, sizeof(title), "%s %.3f s%s", clock, r.duration_s,
std::string(app.waveform_name) == r.file ? " (on screen)" : "");
event_on_screen(app, r) ? " (on screen)" : "");
return title;
}
void mark_events_on_screen(App& app) {
dock_mark_events(app);
if (app.events_list == nullptr) return;
for (GtkWidget* row = gtk_widget_get_first_child(app.events_list); row != nullptr;
row = gtk_widget_get_next_sibling(row)) {
@@ -806,12 +777,12 @@ void mark_events_on_screen(App& app) {
}
}
/// The events pane's rows, newest first: each row is the picture of *that* event beside the
/// The events page's rows, newest first: each row is the picture of *that* event beside the
/// sidecar's numbers, because what tells one event from another is its shape, and one picture for
/// the whole pane describes whichever event happens to be newest rather than the one being read. The
/// the whole page describes whichever event happens to be newest rather than the one being read. The
/// numbers are one line each: a list that has to load the samples to describe an event is a list that
/// cannot show thirty of them.
void refresh_events_pane(App& app) {
static void rebuild_events_pane_list(App& app, std::vector<EventRecord>& records) {
if (app.events_list == nullptr) return;
while (GtkWidget* child = gtk_widget_get_first_child(app.events_list)) {
gtk_list_box_remove(GTK_LIST_BOX(app.events_list), child);
@@ -821,8 +792,8 @@ void refresh_events_pane(App& app) {
// the one that finds out.
app.events_thumb = nullptr;
if (app.event_store == nullptr) return;
// Not const: a record whose preview had to be redrawn learns its thumbnail's path here.
std::vector<EventRecord> records = app.event_store->list();
// `records` is the caller's list, not const: a record whose preview had to be redrawn learns its
// thumbnail's path here.
if (records.empty()) {
GtkWidget* empty = gtk_label_new("no events yet -- arm the trigger");
gtk_widget_add_css_class(empty, "dim-label");
@@ -871,7 +842,7 @@ void refresh_events_pane(App& app) {
// to grow and the text keeps the rest of the width.
gtk_widget_set_size_request(frame, kEventThumbW, kEventThumbH);
// `halign` START, not the default FILL: a filling widget is given whatever the row has left over,
// which made the picture as wide as the pane and cropped by the height it was pinned to.
// which made the picture as wide as the page and cropped by the height it was pinned to.
gtk_widget_set_halign(frame, GTK_ALIGN_START);
gtk_widget_set_valign(frame, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(frame, "udpscope-event-thumb");
@@ -899,7 +870,7 @@ void refresh_events_pane(App& app) {
++unreadable;
}
gtk_box_append(GTK_BOX(line), frame);
// The newest event is the first row: what the probe measures, and nothing the pane treats
// The newest event is the first row: what the probe measures, and nothing the page treats
// specially.
if (app.events_thumb == nullptr) app.events_thumb = shot;
@@ -915,7 +886,7 @@ void refresh_events_pane(App& app) {
gtk_widget_set_halign(name, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END);
// A cap, not only an ellipsis: `max-width-chars` bounds a label's *natural* width, and the row's
// natural width is the pane's minimum -- which is the floor the divider cannot go below.
// natural width is the page's minimum -- which is the floor the list cannot go below.
gtk_label_set_max_width_chars(GTK_LABEL(name), 22);
gtk_widget_add_css_class(name, "udpscope-event-title");
gtk_box_append(GTK_BOX(box), name);
@@ -955,9 +926,26 @@ void refresh_events_pane(App& app) {
}
}
/// Both views of the cache, rebuilt from the same list: the events page's rows (with their pictures) and the
/// dock block's compact ones. The dock follows even when the page's list does not exist. To *open* the page
/// use `open_events_page()`, which calls this only when the page is not already open.
void refresh_events_pane(App& app) {
// One read of the cache directory for both views (none without a store, which the page's rebuild
// and the dock's both treat as nothing to list).
std::vector<EventRecord> records;
if (app.event_store != nullptr) records = app.event_store->list();
rebuild_events_pane_list(app, records);
if (app.event_store != nullptr) dock_rebuild_events(app, records);
}
bool open_events_page(App& app) {
if (slideover_page(app) != "events") refresh_events_pane(app);
return slideover_open(app, "events");
}
/// Clicking a row replays it: the same loader `--open` uses, on the event's file. The acquisition is
/// stopped by `replay_event()` -- a live producer would overwrite the samples within a frame -- so the
/// pane's own *Live* button is the way back.
/// page's own *Live* button is the way back.
void on_event_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
App& app = *static_cast<App*>(user);
if (row == nullptr) return;
@@ -971,31 +959,30 @@ void on_event_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
mark_events_on_screen(app);
}
/// The pane itself: a collapsible column on the left, a header and the list of events -- each row
/// carrying its own picture. Built once with the rest of the window, and opened on demand from the
/// toolbar's history button, which is itself only there while trigger mode and recording are both
/// on (`sync_events_availability()`).
/// The events list: a header and the list of events, each row carrying its own picture. Built once with
/// the rest of the window and returned as the content of the slide-over's `events` page, which the
/// dock's Manage button opens.
GtkWidget* build_events_pane(App& app) {
// The pane's frame -- the revealer that slides it in, the header with the way back to the live stream, the
// two actions that read the selection, and the list inside its scroller -- is a UI file. The rows are not:
// The list's frame -- the header with the way back to the live stream, the two actions that read the
// selection, and the list inside its scroller -- is a UI file. The rows are not:
// there is one per latched event, and the whole list is rebuilt whenever the cache changes. The two
// buttons' sensitivity is `update_group_buttons()`'s, which is why the file only starts them insensitive.
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/events-pane.ui");
app.events_revealer = GTK_WIDGET(gtk_builder_get_object(ui, "events_pane"));
app.events_box = GTK_WIDGET(gtk_builder_get_object(ui, "events_box"));
app.events_compare = GTK_WIDGET(gtk_builder_get_object(ui, "events_compare"));
app.events_accumulate = GTK_WIDGET(gtk_builder_get_object(ui, "events_accumulate"));
GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "events_live"));
app.events_list = GTK_WIDGET(gtk_builder_get_object(ui, "events_list"));
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
app.events_head = GTK_WIDGET(gtk_builder_get_object(ui, "events_head"));
if (app.events_revealer == nullptr || app.events_compare == nullptr ||
if (app.events_box == nullptr || app.events_compare == nullptr ||
app.events_accumulate == nullptr || live == nullptr || app.events_list == nullptr) {
// A missing id is a mistake in the `.ui`: the pane comes up empty rather than taking the window with it,
// A missing id is a mistake in the `.ui`: the page comes up empty rather than taking the window with it,
// and `[udpscope] events pane:` reads the difference.
return app.events_revealer != nullptr ? app.events_revealer : gtk_revealer_new();
return app.events_box != nullptr ? app.events_box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
}
// What the pane *does*, which a file cannot hold: the two group actions read the selection, the list
// What the page *does*, which a file cannot hold: the two group actions read the selection, the list
// replays an event on a double click, and the header's button goes back to the live stream.
g_signal_connect(app.events_compare, "clicked", G_CALLBACK(on_group_clicked), &app);
g_signal_connect(app.events_accumulate, "clicked", G_CALLBACK(on_group_clicked), &app);
@@ -1009,12 +996,13 @@ GtkWidget* build_events_pane(App& app) {
update_group_buttons(*static_cast<App*>(user));
}),
&app);
return app.events_revealer;
return app.events_box;
}
/// The recording switch, which lives here because it is a trigger option and not a pane option: it
/// decides whether latches are written at all, and the pane that lists them must not be the only
/// place to reach the control that stops them. Flipping it re-decides the pane's availability.
/// The recording switch, which lives here because it is a trigger option and not an events-page option: it
/// decides whether latches are written at all, and the page that lists them must not be the only
/// place to reach the control that stops them. Flipping it re-decides the availability of the dock's
/// Events block and of the page.
void on_events_record_toggled(GObject* o, GParamSpec*, gpointer user) {
App& app = *static_cast<App*>(user);
app.event_recording = gtk_switch_get_active(GTK_SWITCH(o));
+110 -27
View File
@@ -30,8 +30,16 @@ LayoutState collect_layout(App& app) {
row.signals.push_back(r.layer[si] > 0 ? std::string()
: r.signal_name[si]);
}
// The figure row's own weight and this cell's share of the row's width.
const int fr = app.rows->figure_row_of(i);
const std::vector<float>& weights = app.rows->row_weights();
if (static_cast<std::size_t>(i) < weights.size()) row.weight = weights[static_cast<std::size_t>(i)];
row.row = fr;
if (fr >= 0 && static_cast<std::size_t>(fr) < weights.size()) {
row.weight = weights[static_cast<std::size_t>(fr)];
}
const std::vector<float>& cw = app.rows->cell_weights(fr);
const int cell = app.rows->cell_of(i);
if (cell >= 0 && static_cast<std::size_t>(cell) < cw.size()) row.width = cw[static_cast<std::size_t>(cell)];
st.rows.push_back(std::move(row));
}
for (std::size_t si = 0; si < model.signals().size(); ++si) {
@@ -42,6 +50,7 @@ LayoutState collect_layout(App& app) {
break;
}
}
for (const auto& [name, unit] : model.unit_overrides()) st.units.push_back({name, unit});
// The *enable* state, not just what is plotted: a signal can be enabled only as a math channel's
// source, and a session that came back with it off would come back with a NaN trace. "Enabled" is
// read off the channels the model built -- it has no per-signal accessor and does not need one,
@@ -64,7 +73,7 @@ LayoutState collect_layout(App& app) {
st.math = app.math;
st.measures = app.measures;
st.measure_stats = app.measure_stats_on;
st.side_tab = app.side_tab;
st.dock_shown = app.sidebar_wanted;
// The trigger, its settings *and* whether it was on. The channel goes by name, like every identity
// in this file: every rebuild renumbers the channels.
{
@@ -103,7 +112,7 @@ LayoutState collect_layout(App& app) {
// *startup* parameter -- the rings are allocated before any stream is known -- so a configuration
// is the only place it can be remembered per source, and the chooser applies it on open.
st.comment = app.config_comment;
st.buffer_mb = app.model->config().buffer_mb;
st.buffer_samples = app.model->config().buffer_samples;
st.relative_time = app.model->config().relative_time;
st.capture_packets = app.model->config().capture_packets;
return st;
@@ -135,8 +144,8 @@ void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
// a configuration that appears to half-apply.
const Config& cfg = app.model->config();
std::string later;
if (st.buffer_mb > 0.0 && st.buffer_mb != cfg.buffer_mb) {
later += "buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
if (st.buffer_samples > 0 && st.buffer_samples != cfg.buffer_samples) {
later += "buffer " + format_samples(st.buffer_samples) + " samples";
}
if (st.relative_time != cfg.relative_time) {
later += later.empty() ? "" : ", ";
@@ -237,9 +246,7 @@ void config_dialog_refresh(ConfigDialog& d) {
}
std::string sub = std::to_string(st.rows.size()) + " plots, " + std::to_string(signals) +
" signals";
if (st.buffer_mb > 0.0) {
sub += ", buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
}
if (st.buffer_samples > 0) sub += ", buffer " + format_samples(st.buffer_samples) + " samples";
if (st.relative_time) sub += ", relative time";
GtkWidget* counts = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(counts, "dim-label");
@@ -379,7 +386,7 @@ void apply_trigger_layout(App& app, const LayoutState::Trigger& t) {
}
write_trigger_widgets(app);
// The state is applied by *pressing the same control a user would*: the handler is what arms, tells
// the events button, greys the body and syncs the pane's own switch. Deliberately outside the
// the dock's events block, greys the body and syncs the page's own switch. Deliberately outside the
// `updating_panels` window `apply_layout()` uses for the panels, because a toggle set inside it
// would emit `toggled` into a handler that returns at once and nothing would happen.
if (app.trigger_toggle != nullptr) {
@@ -410,7 +417,7 @@ void apply_layout(App& app, const LayoutState& st) {
// What the session had on, collected and enabled **once**, at the end of this function.
// Each enable re-plans the partition, and a re-plan allocates -- and so touches -- the whole
// of `--buffer-mb`: seven signals one at a time is seven allocations before a single sample
// of the planned rings: seven signals one at a time is seven allocations before a single sample
// is kept, which is the ten seconds of blank window a large buffer used to start with. The
// math channels are not collected here: their enable state is the session's `math` and
// `set_math_channels()` has already restored it, so listing them again could turn on one
@@ -427,7 +434,10 @@ void apply_layout(App& app, const LayoutState& st) {
app.measures = st.measures;
app.measure_stats_on = st.measure_stats;
app.updating_panels = true;
select_side_tab(app, st.side_tab.c_str());
// The dock's shown state is the wish `update_side_visibility()` re-asserts every frame; setting the
// toggle goes through `sidebar_syncing`, so the restore does not schedule a save of what it just read.
app.sidebar_wanted = st.dock_shown;
update_side_visibility(app);
if (app.measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
app.measure_stats_on);
@@ -442,17 +452,67 @@ void apply_layout(App& app, const LayoutState& st) {
int rows = static_cast<int>(st.rows.size());
if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows;
if (rows > 0) app.rows->set_row_count(rows);
// The arrangement. A file that names a figure row for every plot (`row = ...`) carries a horizontal
// split; one that names none -- every session written before the split -- is one cell per row, which
// is what it was. A `row` sequence that cannot be a layout (hand-edited, truncated, from a build
// with other limits) is not a reason to refuse the file: it loads as the old shape, with the signals
// where they were.
std::vector<int> cells_per_row;
{
bool all_rows = !st.rows.empty() && st.rows.size() <= static_cast<std::size_t>(RowSet::kMaxRows);
int prev = -1;
for (const LayoutState::Row& r : st.rows) {
if (!all_rows) break;
if (r.row < 0 || r.row < prev || r.row > prev + 1 || (prev < 0 && r.row != 0)) {
all_rows = false;
} else if (r.row != prev) {
cells_per_row.push_back(1);
} else if (++cells_per_row.back() > RowSet::kMaxCellsPerRow) {
all_rows = false;
}
prev = r.row;
}
if (!all_rows) cells_per_row.clear();
}
const bool split_file = !cells_per_row.empty();
if (split_file) {
app.rows->set_cells_per_row(cells_per_row);
} else if (rows > 0) {
app.rows->set_row_count(rows);
}
// The rows' shares of the height come from the same file as their signals: a sizing a user dragged is
// an arrangement, and the layout is where an arrangement lives. Absent from an older file, the weights
// are all 1 -- equal rows -- which is what such a file was saved with.
// are all 1 -- equal rows -- which is what such a file was saved with. The weight is the *figure row's*
// and is written on every cell of it; the row's first cell is the one read.
{
std::vector<float> weights;
weights.reserve(st.rows.size());
for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast<float>(wr.weight));
if (split_file) {
std::size_t at = 0;
for (const int n : cells_per_row) {
weights.push_back(static_cast<float>(st.rows[at].weight));
at += static_cast<std::size_t>(n);
}
} else {
for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast<float>(wr.weight));
}
app.rows->set_row_weights(std::move(weights));
}
// The cells' shares of a split row's width, after the arrangement (which resets them).
if (split_file) {
std::size_t at = 0;
for (std::size_t fr = 0; fr < cells_per_row.size(); ++fr) {
const std::size_t n = static_cast<std::size_t>(cells_per_row[fr]);
if (n >= 2) {
std::vector<float> widths;
for (std::size_t c = 0; c < n; ++c) widths.push_back(static_cast<float>(st.rows[at + c].width));
app.rows->set_cell_weights(static_cast<int>(fr), std::move(widths));
}
at += n;
}
}
for (int i = 0; i < rows; ++i) {
int slot = 0;
for (const std::string& name : st.rows[static_cast<std::size_t>(i)].signals) {
@@ -491,6 +551,14 @@ void apply_layout(App& app, const LayoutState& st) {
}
}
for (const auto& u : st.units) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != u.name) continue;
model.set_signal_unit(si, u.unit);
break;
}
}
// Skipped for a name the stream no longer carries, like the calibration above: an override the
// signal list cannot show is one `collect_layout()` would write back every session for ever.
for (const auto& color : st.colors) {
@@ -513,30 +581,28 @@ void apply_layout(App& app, const LayoutState& st) {
// above have built the channel list.
apply_trigger_layout(app, st.trigger);
// A session that loaded is a user who has been here before, and that is what the lock means: the two
// panes come up showing what they are set to rather than showing every control. A first run -- no
// file for this source -- never reaches this function and stays in configuration mode, which is what
// a first run is for.
// A session that loaded is a user who has been here before, and that is what the lock means: the trigger
// pane comes up showing what it is set to rather than showing every control. A first run -- no file for
// this source -- never reaches this function and stays in configuration mode, which is what a first run
// is for.
app.trigger_locked = true;
app.signals_locked = true;
apply_pane_lock(app);
}
void on_layout_row_remove(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int row = row_key(GTK_WIDGET(button));
if (!app->rows->remove_row(row)) {
app->notice = "a figure needs at least one row";
const int plot = row_key(GTK_WIDGET(button));
// `remove_cell` delegates to `remove_row` for a one-cell row; for a multi-cell row it
// removes just the cell. Returns false when only one plot would remain.
if (!app->rows->remove_cell(plot)) {
app->notice = "a figure needs at least one plot";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
// `remove_row()` has already built the new figure (a `Figure`'s row count is fixed at
// construction), so the area has to be re-pointed here rather than through
// `relayout_areas()`, which only acts on a *dirty* row.
// The structural change has already built the new figure (a `Figure`'s row count is fixed
// at construction), so the area has to be re-pointed here.
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
// The signal list's chips name the trace a signal is drawn as, and a removed row took
// its traces with it.
rebuild_signal_panels(*app);
schedule_layout_save(*app);
}
@@ -579,6 +645,23 @@ GtkWidget* layout_button(App& a, bool insert, int index) {
return nullptr;
}
/// The cell `+` button for figure row `fr` at position `pos`, or null.
GtkWidget* layout_cell_button(App& a, int fr, int pos) {
const int tag = fr * 8 + pos + 1;
for (GtkWidget* b : a.layout_buttons) {
if (GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kLayoutCellKey)) == tag) return b;
}
return nullptr;
}
/// The column resize handle for plot `plot`, or null.
GtkWidget* layout_col_handle(App& a, int plot) {
for (GtkWidget* b : a.layout_buttons) {
if (GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kLayoutColKey)) == plot) return b;
}
return nullptr;
}
/// Clicks one editor button. `::clicked` directly and not `gtk_widget_activate()`: a
/// `GtkButton` activated that way holds the press for `ACTIVATE_TIMEOUT` (250 ms) and only
/// then emits `::clicked`, so every step of the probe read the *previous* step's result --
+5 -1
View File
@@ -111,6 +111,8 @@ void rebuild_measures_pane(App& app) {
gtk_box_append(GTK_BOX(app.measures_list), measures_row(app, i, app.measures[i]));
app.measure_text.emplace_back();
}
dock_rebuild_measures(app);
sync_readouts_strip(app); // the strip's Measures group (and the strip itself) follows at once, not a tick later
update_measures(app, /*force=*/true, /*accumulate=*/true);
}
@@ -581,7 +583,8 @@ void update_measures(App& app, bool force, bool accumulate) {
// to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks
// was the other half of the rulers blinking off.
app.measure_text[i] = "--";
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), app.measure_text[i].c_str());
dock_measure_value(app, i, app.measure_text[i]);
continue;
}
const App::MeasureWindow& w = *wp;
@@ -615,6 +618,7 @@ void update_measures(App& app, bool force, bool accumulate) {
}
app.measure_text[i] = text;
gtk_label_set_text(GTK_LABEL(label), text.c_str());
dock_measure_value(app, i, text);
}
// One observation per recompute, and only when the pass produced one and the caller
// asked for it. An invalid pass contributes nothing -- the marks' `measure_last_valid`
+29 -1
View File
@@ -4,6 +4,7 @@
#include "ui_prefs.hpp"
#include "event_store.hpp" // `default_events_dir()`, so the row shows the path the cache uses
#include "status_text.hpp" // `format_bytes()`
#include "ui_app.hpp" // the application's half of the contract, at the bottom of this file
#include <adwaita.h>
@@ -36,6 +37,7 @@ Settings read_rows(const PrefsForm& f) {
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_row));
s.event_cache_max = static_cast<int>(adw_spin_row_get_value(f.keep_row));
s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE;
s.memory_limit_mb = static_cast<int>(adw_spin_row_get_value(f.memory_row));
s.theme = theme_name(adw_combo_row_get_selected(f.theme_row));
// The row shows the *effective* directory, so the default is written out in full rather than
// described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the
@@ -63,6 +65,7 @@ void fill_rows(PrefsForm& f, const Settings& s) {
(s.event_cache_dir.empty() ? default_events_dir() : s.event_cache_dir).c_str());
adw_spin_row_set_value(f.keep_row, s.event_cache_max < 0 ? 0.0 : s.event_cache_max);
adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE);
adw_spin_row_set_value(f.memory_row, s.memory_limit_mb);
adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0));
f.filling = false;
}
@@ -141,6 +144,7 @@ void on_closed(AdwDialog*, gpointer user) {
f.dir_row = nullptr;
f.keep_row = nullptr;
f.relative_row = nullptr;
f.memory_row = nullptr;
f.theme_row = nullptr;
f.browse = nullptr;
f.parent = nullptr;
@@ -155,12 +159,14 @@ void resolve_rows(PrefsForm& f) {
f.keep_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "keep_row"));
f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row"));
f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row"));
f.memory_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "memory_row"));
if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr ||
f.theme_row == nullptr) {
f.theme_row == nullptr || f.memory_row == nullptr) {
f.dir_row = nullptr;
f.keep_row = nullptr;
f.relative_row = nullptr;
f.theme_row = nullptr;
f.memory_row = nullptr;
g_clear_object(&f.builder);
}
}
@@ -214,6 +220,7 @@ void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
g_signal_connect(form.dir_row, "changed", G_CALLBACK(on_entry_changed), &form);
g_signal_connect(form.keep_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.memory_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form);
if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) {
g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form);
@@ -239,9 +246,29 @@ void prefs_probe_change(PrefsForm& form) {
gtk_editable_set_text(GTK_EDITABLE(form.dir_row), "/tmp/udpscope-prefs-events");
adw_spin_row_set_value(form.keep_row, 7.0);
adw_switch_row_set_active(form.relative_row, TRUE);
adw_spin_row_set_value(form.memory_row, 4096.0);
adw_combo_row_set_selected(form.theme_row, 2);
}
/// A changed memory limit reaches the running model. Only when it differs from the model's own (every
/// preference arrives through the same callback, and a theme change is not a re-plan), and without
/// re-planning while a file or a replayed event is what the buffer holds -- clearing those would
/// throw away what the user opened, so the limit is stored and the next plan honours it.
static void apply_memory_limit(App& app, int mb) {
if (app.model == nullptr || mb <= 0) return;
const std::uint64_t bytes = static_cast<std::uint64_t>(mb) * 1024u * 1024u;
if (bytes == app.model->memory_limit_bytes()) return;
const bool holds_a_view = !app.waveform_name.empty() || app.replaying;
const bool rebuilt = app.model->set_memory_limit_bytes(bytes, !holds_a_view);
std::fprintf(stderr, "[udpscope] memory limit now %s (rebuilt=%d%s)\n",
format_bytes(bytes).c_str(), rebuilt ? 1 : 0,
holds_a_view ? ", applies to the next plan" : "");
std::fflush(stderr);
if (rebuilt) {
set_notice(app, "memory limit " + format_bytes(bytes) + ": buffer re-planned, history cleared", 5);
}
}
/// The application's entry point: **Preferences…** in the overflow menu. Kept here rather than in
/// `ui.cpp` with the rest of the chrome, because it is the application's half of *this* form's
/// contract -- what a preference changing means to a running scope -- and reading it beside the form is
@@ -249,6 +276,7 @@ void prefs_probe_change(PrefsForm& form) {
void open_preferences(App& app) {
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s;
apply_memory_limit(app, s.memory_limit_mb);
// Only a failure is announced. A change arrives once per keystroke in the directory row, and a
// status line that flashed on each one would be the app shouting about its own bookkeeping.
if (!written) set_notice(app, "could not write the preferences file", 4);
+1
View File
@@ -62,6 +62,7 @@ struct PrefsForm {
AdwEntryRow* dir_row = nullptr;
AdwSpinRow* keep_row = nullptr;
AdwSwitchRow* relative_row = nullptr;
AdwSpinRow* memory_row = nullptr;
AdwComboRow* theme_row = nullptr;
/// The folder chooser beside the directory row. A text field alone asks a user to know a path and
/// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so
+1669 -227
View File
File diff suppressed because it is too large. Load diff
+671
View File
@@ -0,0 +1,671 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The per-signal slide-over pages (`signal:<name>`): one signal's acquire switch, colour, the plot it is on,
// its unit, gain, offset and details under its name (a math channel's editor instead). Opened from a row of the signal list;
// kept in step with the model by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <cmath>
#include <cstdio>
#include <mutex>
#include <string>
#include <vector>
namespace udpscope {
namespace ui_impl {
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;
}
/// A calibration number as written on the page: up to six decimals (what the editor's spin carries),
/// trailing zeros dropped, so the identity reads `1` and `0` and a trim of 0.000125 is not rounded away;
/// out of that range, three-to-six significant digits in exponent form.
std::string format_calibration(double v) {
if (!std::isfinite(v)) return "--";
char buf[64];
const double mag = std::fabs(v);
if (mag >= 1.0e9 || (mag > 0.0 && mag < 1.0e-6)) {
std::snprintf(buf, sizeof(buf), "%.6g", v);
return buf;
}
std::snprintf(buf, sizeof(buf), "%.6f", v);
std::string num = buf;
while (num.size() > 1 && num.back() == '0') num.pop_back();
if (num.back() == '.') num.pop_back();
if (num == "-0") num = "0";
return num;
}
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")));
}
/// The wire types and quantisations the CONFIG names (`UDPS_T_*`, `UDPS_QUANT_*`; every source fills the same
/// codes). A code outside the table is shown as the number, not guessed at.
std::string type_name(unsigned code) {
static const char* const names[] = {"uint8", "int8", "uint16", "int16", "uint32",
"int32", "uint64", "int64", "float32", "float64"};
return code < 10 ? names[code] : "unknown (" + std::to_string(code) + ")";
}
std::string quant_name(unsigned code) {
static const char* const names[] = {"none", "uint8", "int8", "uint16", "int16"};
return code < 5 ? names[code] : "unknown (" + std::to_string(code) + ")";
}
/// One row of the details grid: the name left, the value right-aligned in tabular figures, the value's
/// unit in a column of its own (so the digits line up whatever the unit is), and, for a value the user can
/// change, a pencil that edits just that one.
struct DetailRow {
const char* label;
std::string value;
std::string unit;
const char* id; // widget name of the value label, which the probe reads; null for none
const char* tip;
int edit; // which editor the pencil opens: 0 unit, 1 gain, 2 offset; -1 for none
};
/// Everything the page knows about a streamed signal, directly under its name: the unit, gain and offset
/// (each with a pencil), then what the CONFIG declared about it.
GtkWidget* build_info_grid(App& app, std::size_t index, const SignalInfo& sig,
const std::vector<SignalInfo>& all) {
const std::pair<double, double> cal = app.model->calibration(index);
const char* time_modes[] = {"per packet", "full array", "first sample", "last sample"};
const std::string time_mode =
sig.time_mode < 4 ? time_modes[sig.time_mode] : std::to_string(int(sig.time_mode));
const std::string time_signal =
sig.time_signal_idx < all.size() ? all[sig.time_signal_idx].name : std::string("none");
char size[48];
std::snprintf(size, sizeof(size), "%u x %u", sig.rows, sig.cols);
const std::vector<DetailRow> rows = {
{"Unit:", sig.unit.empty() ? "--" : sig.unit, "", "udpscope-unit",
"edit the unit this signal is shown and exported in",
0},
{"Gain:", format_calibration(cal.first), "", "udpscope-gain", "edit the gain, applied at ingest",
1},
{"Offset:", format_calibration(cal.second), sig.unit, "udpscope-offset",
"edit the offset, applied at ingest",
2},
{"Type:", type_name(sig.type_code), "", nullptr, nullptr, -1},
{"Quantisation:", quant_name(sig.quant_type), "", nullptr, nullptr, -1},
{"Size:", size, "", nullptr, nullptr, -1},
{"Range:", format_calibration(sig.range_min) + " .. " + format_calibration(sig.range_max), sig.unit,
nullptr, nullptr, -1},
{"Sample rate:", format_calibration(sig.sampling_rate), "Hz", nullptr, nullptr, -1},
{"Time mode:", time_mode, "", nullptr, nullptr, -1},
{"Time signal:", time_signal, "", nullptr, nullptr, -1},
};
GtkWidget* grid = gtk_grid_new();
gtk_widget_set_name(grid, "udpscope-calibration"); // the probe reads its labels and pencils
gtk_grid_set_column_spacing(GTK_GRID(grid), 8);
gtk_grid_set_row_spacing(GTK_GRID(grid), 4);
int r = 0;
for (const DetailRow& row : rows) {
GtkWidget* name_label = gtk_label_new(row.label);
gtk_widget_set_halign(name_label, GTK_ALIGN_START);
gtk_grid_attach(GTK_GRID(grid), name_label, 0, r, 1, 1);
// Every value is pushed to the right edge; an editable one carries its pencil right after it.
const bool editable = row.edit >= 0;
GtkWidget* cell = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_hexpand(cell, true);
gtk_widget_set_halign(cell, GTK_ALIGN_END);
GtkWidget* value = gtk_label_new(row.value.c_str());
gtk_widget_add_css_class(value, "numeric");
if (row.id != nullptr) gtk_widget_set_name(value, row.id);
gtk_label_set_xalign(GTK_LABEL(value), 1.0f);
gtk_label_set_wrap(GTK_LABEL(value), !editable);
gtk_box_append(GTK_BOX(cell), value);
if (!row.unit.empty()) {
GtkWidget* unit = gtk_label_new(row.unit.c_str());
gtk_widget_add_css_class(unit, "dim-label");
gtk_label_set_ellipsize(GTK_LABEL(unit), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(cell), unit);
}
gtk_grid_attach(GTK_GRID(grid), cell, 1, r, 1, 1);
if (row.edit >= 0) {
GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic");
gtk_widget_add_css_class(edit, "flat");
gtk_widget_set_tooltip_text(edit, row.tip);
gtk_accessible_update_property(GTK_ACCESSIBLE(edit), GTK_ACCESSIBLE_PROPERTY_LABEL, row.tip, -1);
tag(edit, index);
g_object_set_data(G_OBJECT(edit), "udpscope-edit", GINT_TO_POINTER(row.edit));
g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const int which = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-edit"));
if (which == 0) {
open_unit_editor(*a, GTK_WINDOW(a->window), tagged(b));
} else {
open_calibration(*a, GTK_WINDOW(a->window), tagged(b),
which == 1 ? CalibField::Gain : CalibField::Offset);
}
}),
&app);
gtk_box_append(GTK_BOX(cell), edit);
}
++r;
}
return grid;
}
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);
// Back to the list: a page reached from a list row has no other way home.
GtkWidget* back = text_button("Back to 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.math) {
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);
}
} else {
gtk_box_append(GTK_BOX(page), build_info_grid(app, index, sig, all));
}
// 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);
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, 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);
gtk_widget_set_name(rows_box, "udpscope-plot-on"); // the probe counts its buttons
for (int r = 0; r < app.rows->row_count(); ++r) {
// The plot's label is its row's number, then a letter when the row is split ("2", "2a"); the tooltip
// of a split row's cell says the same thing in words.
GtkWidget* b = text_button(app.rows->plot_label(r).c_str(), rows_box);
tag(b, index);
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r));
// A button for a plot the signal is already on would add it to a second slot there.
bool on_this_row = false;
for (int slot = 0; slot < RowSet::kSlotsPerRow; ++slot) {
if (app.rows->row(r).signal_name[static_cast<std::size_t>(slot)] == sig.name) on_this_row = true;
}
gtk_widget_set_sensitive(b, !on_this_row);
const int figure_row = app.rows->figure_row_of(r);
const int in_row = figure_row >= 0 ? app.rows->cells_in_row(figure_row) : 1;
if (in_row > 1) {
char where[64];
std::snprintf(where, sizeof(where), "row %d, cell %d of %d", figure_row + 1,
app.rows->cell_of(r) + 1, in_row);
gtk_widget_set_tooltip_text(b, on_this_row ? "already on this plot" : where);
} else {
gtk_widget_set_tooltip_text(b, on_this_row ? "already on this plot" : "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);
// A math channel's editor. (A streamed signal's details are the grid under its name.)
if (sig.math) {
GtkWidget* more_head = gtk_label_new("Math channel");
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);
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);
gtk_box_append(GTK_BOX(page), more);
}
}
} // namespace
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);
// The stack keeps showing the dying page through the close animation; park it on the list so that
// removing the page does not let another signal's page take its place for those frames. Only when it
// is the page on show: parking while the slide-over is open on another page would switch the stack
// away from the page that slideover_page(), the title and the badge marker still name.
const char* shown = gtk_stack_get_visible_child_name(GTK_STACK(app.slideover.stack));
if (shown != nullptr && std::string(shown) == page_id(it->first) && slideover_has_page(app, "signals")) {
gtk_stack_set_visible_child_name(GTK_STACK(app.slideover.stack), "signals");
}
slideover_remove_page(app, page_id(it->first));
it = app.signal_pages.erase(it);
} else {
fill(app, it->first, it->second);
++it;
}
}
}
// --- the probe ---------------------------------------------------------------------------------
namespace {
/// The first descendant of `root` for which `want` holds, or null.
template <typename Pred>
GtkWidget* find_descendant(GtkWidget* root, Pred want) {
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; c = gtk_widget_get_next_sibling(c)) {
if (want(c)) return c;
if (GtkWidget* deeper = find_descendant(c, want)) return deeper;
}
return nullptr;
}
bool is_button_labelled(GtkWidget* w, const char* label) {
if (!GTK_IS_BUTTON(w)) return false;
const char* l = gtk_button_get_label(GTK_BUTTON(w));
return l != nullptr && std::string(l) == label;
}
} // namespace
gboolean signal_page_probe(gpointer user) {
App& a = *static_cast<App*>(user);
std::vector<SignalInfo> all;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
all = a.model->signals();
}
std::size_t index = std::string::npos;
for (std::size_t i : listed_signals(all)) {
if (!all[i].math) {
index = i;
break;
}
}
if (index == std::string::npos) {
std::fprintf(stderr, "[signalpage] no listed signal\n");
return G_SOURCE_REMOVE;
}
const std::string name = all[index].name;
const std::string id = page_id(name);
const bool made = signal_page_ensure(a, name);
slideover_open(a, id);
const int open = slideover_page(a) == id ? 1 : 0;
const int pages = static_cast<int>(a.signal_pages.size());
int enable_visible = -1;
int plot_buttons = -1;
if (made) {
GtkWidget* page = a.signal_pages[name];
GtkWidget* enable = find_descendant(page, [](GtkWidget* w) { return GTK_IS_CHECK_BUTTON(w); });
if (enable != nullptr) enable_visible = gtk_widget_get_visible(enable) ? 1 : 0;
GtkWidget* rows_box = find_descendant(page, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on";
});
if (rows_box != nullptr) {
plot_buttons = 0;
for (GtkWidget* c = gtk_widget_get_first_child(rows_box); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
++plot_buttons;
}
}
}
// Start from a signal that is on no plot (the layout may have it somewhere), through the page's own button.
auto page_it = a.signal_pages.find(name);
if (signal_plotted(a, name) && page_it != a.signal_pages.end()) {
GtkWidget* off0 = find_descendant(page_it->second, [](GtkWidget* w) {
return is_button_labelled(w, "Remove from plots");
});
if (off0 != nullptr) g_signal_emit_by_name(off0, "clicked");
}
// Plot on row 0 through the function the "Plot on" buttons call, then take it off through the page's own
// button (outlive_the_rebuild + remove_signal_from_plots + the page's refill).
const bool assigned = assign_signal_to_row(a, index, 0);
const int plot_on_row0 = assigned && signal_plotted(a, name) ? 1 : 0;
// The refilled page disables the button of the plot the signal is now on.
int plot_on_row0_disabled = -1;
page_it = a.signal_pages.find(name);
GtkWidget* rows_box_on = page_it == a.signal_pages.end() ? nullptr : find_descendant(page_it->second, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on";
});
if (rows_box_on != nullptr && gtk_widget_get_first_child(rows_box_on) != nullptr) {
plot_on_row0_disabled = gtk_widget_get_sensitive(gtk_widget_get_first_child(rows_box_on)) ? 0 : 1;
}
int removed = -1;
page_it = a.signal_pages.find(name);
GtkWidget* off = page_it == a.signal_pages.end()
? nullptr
: find_descendant(page_it->second, [](GtkWidget* w) {
return is_button_labelled(w, "Remove from plots");
});
if (off != nullptr) {
g_signal_emit_by_name(off, "clicked");
removed = signal_plotted(a, name) ? 0 : 1;
}
// The page's own "Plot on 1" button (the user's path), then off again.
int btn_row0 = -1;
page_it = a.signal_pages.find(name);
GtkWidget* rows_box_now =
page_it == a.signal_pages.end() ? nullptr : find_descendant(page_it->second, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-plot-on";
});
if (rows_box_now != nullptr && gtk_widget_get_first_child(rows_box_now) != nullptr) {
g_signal_emit_by_name(gtk_widget_get_first_child(rows_box_now), "clicked");
btn_row0 = signal_plotted(a, name) ? 1 : 0;
page_it = a.signal_pages.find(name);
off = page_it == a.signal_pages.end()
? nullptr
: find_descendant(page_it->second, [](GtkWidget* w) { return is_button_labelled(w, "Remove from plots"); });
if (off != nullptr) g_signal_emit_by_name(off, "clicked");
}
slideover_open(a, "signals");
const int back_to_list = slideover_page(a) == "signals" ? 1 : 0;
// Activating the signal's row in the list opens its page (the path a click takes). The list proposes what
// can be added, so the signal has a row only while it is not acquired: switch it off for the leg and back
// on after it. (With more than six acquired signals the rows list the acquired ones too.)
slideover_close(a);
const bool was_enabled = a.model->signal_enabled(index);
if (was_enabled) {
a.model->set_signal_enabled(index, false);
rebuild_signal_panels(a);
}
int row_opens = -1;
for (GtkWidget* r = gtk_widget_get_first_child(a.signal_list); r != nullptr; r = gtk_widget_get_next_sibling(r)) {
if (GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(r), "udpscope-signal")) == index && GTK_IS_LIST_BOX_ROW(r)) {
g_signal_emit_by_name(r, "activate");
row_opens = slideover_page(a) == id ? 1 : 0;
break;
}
}
if (was_enabled) {
slideover_close(a);
a.model->set_signal_enabled(index, true);
rebuild_signal_panels(a);
}
// Pruning: a math channel's page, open, then the channel deleted; the sync must close the panel and drop
// the page. Left at -1 when no channel can be added (nothing acquired to build it from).
int pruned = -1;
const std::size_t math_before = a.math.size();
add_math_channel(a);
if (a.math.size() == math_before + 1) {
const std::string mname = a.math.back().name;
if (signal_page_ensure(a, mname)) {
slideover_open(a, page_id(mname));
// Through the page's own Delete button: the click destroys the page it came from.
GtkWidget* del = find_descendant(a.signal_pages[mname],
[](GtkWidget* w) { return is_button_labelled(w, "Delete"); });
if (del != nullptr) {
g_signal_emit_by_name(del, "clicked");
} else {
a.math.pop_back();
apply_math_channels(a);
}
pruned = a.signal_pages.count(mname) == 0 && !slideover_has_page(a, page_id(mname)) &&
slideover_page(a).empty()
? 1
: 0;
} else {
a.math.pop_back();
apply_math_channels(a);
}
}
slideover_close(a);
// A page that is not on show is pruned while another panel is open: the slide-over stays on that panel.
int other_page_kept = -1;
if (slideover_has_page(a, "trigger")) {
slideover_open(a, "trigger");
const std::size_t before = a.math.size();
add_math_channel(a);
if (a.math.size() == before + 1) {
const std::string mname = a.math.back().name;
if (signal_page_ensure(a, mname)) {
const bool on_trigger = slideover_page(a) == "trigger";
a.math.pop_back();
apply_math_channels(a);
const char* shown = gtk_stack_get_visible_child_name(GTK_STACK(a.slideover.stack));
other_page_kept = on_trigger && a.signal_pages.count(mname) == 0 && slideover_page(a) == "trigger" &&
shown != nullptr && std::string(shown) == "trigger"
? 1
: 0;
} else {
a.math.pop_back();
apply_math_channels(a);
}
}
slideover_close(a);
}
// The details under the name: the labels follow the model, and each pencil edits just its own value.
const std::pair<double, double> cal0 = a.model->calibration(index);
const std::string declared0 = a.model->declared_unit(index);
const auto label_text = [&](const char* id) -> std::string {
auto it = a.signal_pages.find(name);
if (it == a.signal_pages.end()) return "?";
GtkWidget* l = find_descendant(it->second, [&](GtkWidget* w) {
return GTK_IS_LABEL(w) && std::string(gtk_widget_get_name(w)) == id;
});
return l == nullptr ? "?" : gtk_label_get_text(GTK_LABEL(l));
};
const auto pencil_n = [&](int n) -> GtkWidget* {
auto it = a.signal_pages.find(name);
GtkWidget* grid = it == a.signal_pages.end() ? nullptr : find_descendant(it->second, [](GtkWidget* w) {
return std::string(gtk_widget_get_name(w)) == "udpscope-calibration";
});
int seen = 0;
return grid == nullptr ? nullptr : find_descendant(grid, [&](GtkWidget* c) {
return GTK_IS_BUTTON(c) && gtk_button_get_icon_name(GTK_BUTTON(c)) != nullptr && seen++ == n;
});
};
// Click pencil `n`, find the dialog titled `title`, set its `Spin`/`Entry`, press Apply. Returns the number
// of editing widgets the dialog had, or -1 when there was no dialog.
const auto drive = [&](int n, const char* title, double number, const char* text) -> int {
GtkWidget* pencil = pencil_n(n);
if (pencil == nullptr) return -1;
g_signal_emit_by_name(pencil, "clicked");
int fields = -1;
GListModel* windows = gtk_window_get_toplevels();
for (guint i = 0; i < g_list_model_get_n_items(windows); ++i) {
GtkWindow* w = GTK_WINDOW(g_list_model_get_item(windows, i));
const char* t = gtk_window_get_title(w);
if (t != nullptr && std::string(t) == title) {
GtkWidget* body = gtk_window_get_child(w);
fields = 0;
find_descendant(body, [&](GtkWidget* x) {
if (GTK_IS_SPIN_BUTTON(x) || GTK_IS_ENTRY(x)) ++fields;
return false;
});
GtkWidget* spin_w = find_descendant(body, [](GtkWidget* x) { return GTK_IS_SPIN_BUTTON(x); });
GtkWidget* entry_w = find_descendant(body, [](GtkWidget* x) { return GTK_IS_ENTRY(x) && !GTK_IS_SPIN_BUTTON(x); });
GtkWidget* apply = find_descendant(body, [](GtkWidget* x) { return is_button_labelled(x, "Apply"); });
if (apply != nullptr && (spin_w != nullptr || entry_w != nullptr)) {
if (text != nullptr && entry_w != nullptr) gtk_editable_set_text(GTK_EDITABLE(entry_w), text);
if (text == nullptr && spin_w != nullptr) gtk_spin_button_set_value(GTK_SPIN_BUTTON(spin_w), number);
g_signal_emit_by_name(apply, "clicked");
} else {
gtk_window_close(w);
}
}
g_object_unref(w);
}
return fields;
};
const std::string cal_identity = label_text("udpscope-gain") + "/" + label_text("udpscope-offset");
a.model->set_calibration(index, 2.5, -1.25);
signal_pages_sync(a);
const std::string cal_set = label_text("udpscope-gain") + "/" + label_text("udpscope-offset");
int pencils = 0;
while (pencil_n(pencils) != nullptr) ++pencils;
const int gain_fields = drive(1, "Gain", 4.0, nullptr);
const std::pair<double, double> after = a.model->calibration(index);
const int pencil_edits_one =
gain_fields == 1 && after.first == 4.0 && after.second == -1.25 && label_text("udpscope-gain") == "4" ? 1
: 0;
// The unit: a pencil renames it, the rename reaches the model's signal and the page, and an empty entry
// restores what the stream declared.
const int unit_fields = drive(0, "Unit", 0.0, "kV");
std::string model_unit;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
model_unit = a.model->signals()[index].unit;
}
const std::string page_unit = label_text("udpscope-unit");
drive(0, "Unit", 0.0, "");
std::string restored;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
restored = a.model->signals()[index].unit;
}
const int unit_edit = unit_fields == 1 && model_unit == "kV" && page_unit == "kV" && restored == declared0 ? 1 : 0;
a.model->set_calibration(index, cal0.first, cal0.second);
signal_pages_sync(a);
std::fprintf(stderr,
"[signalpage] calibration identity=%s set=%s pencils=%d pencil_edits_one=%d unit_edit=%d "
"unit='%s'\n",
cal_identity.c_str(), cal_set.c_str(), pencils, pencil_edits_one, unit_edit, restored.c_str());
std::fprintf(stderr,
"[signalpage] name=%s open=%d pages=%d enable_visible=%d plot_buttons=%d plot_on_row0=%d "
"plot_on_row0_disabled=%d "
"removed=%d btn_row0=%d back_to_list=%d row_opens=%d pruned=%d other_page_kept=%d rows=%d\n",
name.c_str(), open, pages, enable_visible, plot_buttons, plot_on_row0, plot_on_row0_disabled,
removed, btn_row0, back_to_list, row_opens, pruned,
other_page_kept, a.rows->row_count());
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope
+148 -104
View File
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The signal list: one row per signal with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row.
// 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.
#include "ui_app.hpp"
@@ -166,7 +167,7 @@ int math_index_for_signal(App& app, const std::string& name) {
/// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not
/// selectable and not activatable: it is a heading, and a heading that highlights under the
/// pointer reads as a row you can plot.
GtkWidget* make_math_divider(App& app, bool with_add) {
GtkWidget* make_math_divider(App& app) {
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE);
@@ -180,16 +181,12 @@ GtkWidget* make_math_divider(App& app, bool with_add) {
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_set_hexpand(label, TRUE);
gtk_box_append(GTK_BOX(box), label);
// Locked: the heading stays, because the list it divides is the one being read, but the `+` that
// creates a channel does not. A `+` on a locked list is a button that opens the editor the lock
// exists to keep shut.
if (with_add) {
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_add_css_class(add, "flat");
gtk_widget_set_tooltip_text(add, "add a math channel");
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
gtk_box_append(GTK_BOX(box), add);
}
// The `+` that creates a channel: the "new math channel" entry of a page that proposes what can be added.
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_add_css_class(add, "flat");
gtk_widget_set_tooltip_text(add, "add a math channel");
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
gtk_box_append(GTK_BOX(box), add);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
return row;
}
@@ -199,7 +196,7 @@ GtkWidget* make_math_divider(App& app, bool with_add) {
/// emission, and a button's own code keeps running after the handler returns, so the last reference
/// has to outlive the emission: hold one here and drop it from an idle. This is the same repair
/// `on_signal_switch()` makes, for the same reason.
static void outlive_the_rebuild(GtkWidget* widget) {
void outlive_the_rebuild(GtkWidget* widget) {
g_object_ref(widget);
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+[](gpointer) -> gboolean { return G_SOURCE_REMOVE; }, widget, g_object_unref);
@@ -256,6 +253,42 @@ void on_math_delete_clicked(GtkButton* button, gpointer user) {
apply_math_channels(*a);
}
bool signal_badges_overflowed(App& app, const std::vector<SignalInfo>& signals) {
std::size_t enabled = 0;
for (std::size_t i : listed_signals(signals)) {
if (app.model->signal_enabled(i)) ++enabled;
}
return enabled > kMaxSignalBadges;
}
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;
}
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);
}
/// One line per signal: colour chip, checkbox, name, unit, and a menu for the things
/// you touch once (information, calibration). A `GtkSwitch` and two always-visible
/// buttons made every row about twice this tall, which is what turned a four-signal
@@ -273,7 +306,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
// One line per *signal*, not per channel. A group's model holds each signal once per event; a list
// that showed both would name one "sine (A)" and the other "sine (B)" as if they were different
// things, and give them different colours. What the row toggles, colours and drags is the signal.
// things, and give them different colours. What the row toggles and colours is the signal.
std::vector<std::size_t> rows;
// The `MATH` divider goes in before the first math row, and once only: `append_math_signals_locked()`
// has already put every math channel in `signals()`, so the boundary is the one thing the deleted
@@ -281,18 +314,19 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
bool math_divider_done = false;
// Streamed signals, counted as they are drawn: the dump reads `rows == signals + 1 + math`.
std::size_t plain = 0;
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
// Locked: a row per signal that is *on*, which is the list a user reads rather than the list they
// configure. Turning one on is what the pane's [edit] button is for.
if (app.signals_locked && !app.model->signal_enabled(i)) continue;
const std::string base = layer_base_name(signals[i].name);
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) rows.push_back(i);
std::size_t math_rows = 0;
// The page proposes what can be *added*: an acquired signal already has a badge, so it is not listed --
// unless the badges have overflowed into one "N signals" badge, when the rows are the only way to reach
// each signal's page.
const bool overflowed = signal_badges_overflowed(app, signals);
for (std::size_t i : listed_signals(signals)) {
if (!overflowed && app.model->signal_enabled(i)) continue;
rows.push_back(i);
}
// Nothing addable: say so rather than leave the math entry alone under the hint. Not before the stream
// has declared any signal -- "every signal is acquired" would be untrue of no signals at all.
if (app.signals_empty != nullptr) {
gtk_widget_set_visible(app.signals_empty, rows.empty() && !listed_signals(signals).empty());
}
for (std::size_t i : rows) {
@@ -301,9 +335,13 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
// `+` that creates the first one.
if (sig.math && !math_divider_done) {
math_divider_done = true;
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked));
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
}
if (sig.math) {
++math_rows;
} else {
++plain;
}
if (!sig.math) ++plain;
GtkWidget* row = gtk_list_box_row_new();
gtk_widget_add_css_class(row, "udpscope-compact");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
@@ -317,7 +355,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
chip, !app.model->signal_enabled(i) ? "not acquired"
: plotted ? "the colour this signal is plotted in -- click to change it"
: "on no plot, so there is no trace to colour");
if (plotted && !app.signals_locked) {
if (plotted) {
// A gesture on the label rather than a `GtkButton` around it: a button brings its own
// padding, and padding on this row is what made the list twice as tall once already (see
// this function's note). The chip also has to stay exactly one disc wide, or the names
@@ -328,8 +366,8 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
g_signal_connect(pick, "pressed",
G_CALLBACK(+[](GtkGestureClick* g, int, double, double, gpointer user) {
App* a = static_cast<App*>(user);
// Claimed, so the row's `GtkDragSource` cannot take the same sequence
// and turn a click on the chip into the start of a drag.
// Claimed, so the same click does not also activate the row (which would open
// the signal's page).
gtk_gesture_set_state(GTK_GESTURE(g), GTK_EVENT_SEQUENCE_CLAIMED);
open_signal_color(*a, static_cast<std::size_t>(GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(g),
@@ -351,9 +389,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
// Locked: the switch decides what is acquired, so it is a configuration control and it goes with
// the rest of them.
if (!app.signals_locked) gtk_box_append(GTK_BOX(box), enable);
gtk_box_append(GTK_BOX(box), enable);
GtkWidget* name = gtk_label_new(sig.name.c_str());
gtk_widget_set_hexpand(name, true);
@@ -384,20 +420,12 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
g_object_set_data(G_OBJECT(recolor), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(recolor, "clicked",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& sigs = a->model->signals();
if (idx >= sigs.size()) return;
name = sigs[idx].name;
}
a->rows->clear_signal_color(name);
a->relayout_pending = true;
rebuild_signal_panels(*a);
schedule_layout_save(*a);
// The rebuild destroys this button (it is in the list it rebuilds).
outlive_the_rebuild(GTK_WIDGET(b));
reset_signal_color(
*static_cast<App*>(user),
static_cast<std::size_t>(GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(b), "udpscope-signal"))));
}),
&app);
if (sig.math) {
@@ -445,39 +473,30 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover);
gtk_widget_add_css_class(more, "flat");
gtk_widget_set_tooltip_text(more, "information and calibration");
// Locked: the menu holds calibration, deletion and a rename, which are exactly what this mode is
// not for. The popover is left unattached, so nothing can reach it.
if (!app.signals_locked) gtk_box_append(GTK_BOX(box), more);
gtk_box_append(GTK_BOX(box), more);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
DragContext* ctx = new DragContext{&app, i};
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);
// A drag out of an *overlaid* sidebar would have nowhere to land (see `row_drag_sidebar()`), so the
// sidebar steps out of the way for as long as the drag lasts.
g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) {
row_drag_sidebar(*static_cast<App*>(user), true);
}),
&app);
g_signal_connect(source, "drag-end",
G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gboolean, gpointer user) {
row_drag_sidebar(*static_cast<App*>(user), false);
}),
&app);
gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));
// Activating the row opens the signal's page; the row resolves its identity the way its widgets do.
g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i));
// Dragging a signal onto a plot is the signal badge's job (`make_signal_badge`): the row opens the page.
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
// Appended when there are no math channels at all: the `+` is the only way to create the first
// one, so it cannot be conditional on one already existing.
if (!math_divider_done) {
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked));
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
}
app.dbg_signal_plain = plain;
app.dbg_signal_math_rows = math_rows;
// The proposal as the model states it, to compare with the rows built: the signals not acquired, or every
// listed signal while the badges are overflowed.
app.dbg_signal_addable = 0;
for (std::size_t i : listed_signals(signals)) {
if (overflowed || !app.model->signal_enabled(i)) ++app.dbg_signal_addable;
}
app.dbg_signal_empty_label =
app.signals_empty != nullptr && gtk_widget_get_visible(app.signals_empty) != FALSE ? 1 : 0;
app.dbg_signal_rows = 0;
for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
@@ -486,26 +505,18 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
app.updating_panels = false;
}
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;
// Where the drop landed decides the row: one area covers them all.
const int row = row_at(*app, x, y);
const char* text = g_value_get_string(value);
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
if (signal >= app->model->signals().size()) return FALSE;
bool assign_signal_to_row(App& app, std::size_t signal, int row) {
if (signal >= app.model->signals().size()) return false;
const auto notice = [&](const std::string& what) {
set_notice(*app, what, 4);
set_notice(app, what, 4);
std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str());
std::fflush(stderr);
};
// The dropped signal's channels are the ones the model built for it; take the
// first, or the first element if the signal is a vector.
const std::vector<ChannelInfo>& channels = app->model->channels();
const std::vector<ChannelInfo>& channels = app.model->channels();
int channel = -1;
std::string name;
for (std::size_t i = 0; i < channels.size(); ++i) {
@@ -517,8 +528,8 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y
}
if (channel < 0) {
// The signal is not enabled: enable it, then take the channel that appears.
app->model->set_signal_enabled(signal, true);
const std::vector<ChannelInfo>& again = app->model->channels();
app.model->set_signal_enabled(signal, true);
const std::vector<ChannelInfo>& again = app.model->channels();
for (std::size_t i = 0; i < again.size(); ++i) {
if (again[i].signal == signal) {
channel = static_cast<int>(i);
@@ -528,14 +539,14 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y
}
}
if (channel < 0) {
notice("cannot acquire '" + app->model->signals()[signal].name +
notice("cannot acquire '" + app.model->signals()[signal].name +
"' (multi-element signals are not acquired)");
return FALSE;
return false;
}
int slot = -1;
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
if (!app->rows->slot_bound(row, s)) {
if (!app.rows->slot_bound(row, s)) {
slot = s;
break;
}
@@ -543,21 +554,37 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y
if (slot < 0) {
// Say what to do about it. A refusal a reader cannot act on is only half an answer: the two ways
// out are another plot or another row.
notice("plot " + std::to_string(row + 1) +
notice("plot " + app.rows->plot_label(row) +
" holds four signals already: drop it on another plot, or add a row with the layout "
"editor (L)");
return FALSE;
return false;
}
// The slot remembers the *signal* (and element), never the channel index: the
// channel list is renumbered whenever a signal is enabled or disabled.
const std::vector<SignalInfo>& signals = app->model->signals();
const std::vector<SignalInfo>& signals = app.model->signals();
const SignalInfo& sig = signals[signal];
app->rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
schedule_layout_save(*app);
app->relayout_pending = true;
rebuild_signal_panels(*app); // the row's colour chip now has a trace to name
app.rows->assign(row, slot, sig.name, name, signal_color_index(signals, signal));
schedule_layout_save(app);
app.relayout_pending = true;
rebuild_signal_panels(app); // the row's colour chip now has a trace to name
notice("plotting '" + name + "' on plot " + app.rows->plot_label(row));
return true;
}
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;
// Where the drop landed decides the row: one area covers them all.
const int row = row_at(*app, x, y);
const char* text = g_value_get_string(value);
if (text == nullptr || *text == '\0') return FALSE;
char* end = nullptr;
const unsigned long long parsed = std::strtoull(text, &end, 10);
if (end == nullptr || *end != '\0') return FALSE;
const std::size_t signal = static_cast<std::size_t>(parsed);
if (!assign_signal_to_row(*app, signal, row)) return FALSE;
cancel_drag_on(*app, row);
notice("plotting '" + name + "' on plot " + std::to_string(row + 1));
return TRUE;
}
@@ -567,6 +594,9 @@ void rebuild_signal_panels(App& app) {
// GTK calls (a use-after-free of the name strings otherwise).
std::vector<SignalInfo> signals;
bool have_signals = false;
// The generation this build answers to is read *before* the snapshot: a CONFIG landing between the two
// would otherwise be stamped as seen without having been, and the tick would never rebuild for it.
const unsigned generation = app.model->generation();
{
std::lock_guard<std::mutex> lock(app.model->mutex());
signals = app.model->signals();
@@ -581,7 +611,9 @@ void rebuild_signal_panels(App& app) {
if (!have_signals) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE);
}
app.signal_generation = app.model->generation();
signal_pages_sync(app);
sync_signal_badges(app);
app.signal_generation = generation;
}
GtkWidget* build_signal_panel(App& app) {
@@ -597,21 +629,33 @@ GtkWidget* build_signal_panel(App& app) {
// The hint line under it, which is where the "signals_config" hint joins: the heading already
// carries one, and the rule the layer follows is a hint beside the thing it explains.
app.signals_note = GTK_WIDGET(gtk_builder_get_object(ui, "signals_note"));
app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit"));
if (app.signals_edit != nullptr) {
g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
}
app.signals_empty = GTK_WIDGET(gtk_builder_get_object(ui, "signals_empty"));
if (box == nullptr || app.signal_list == nullptr) {
// A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with
// it, and `[udpscope] signals panel:` reads the difference.
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
}
// Activating a row (a click on the row's own space; its tick, chip and menu consume their own clicks)
// opens that signal's page.
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(app.signal_list), TRUE);
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);
return box;
}
/// The pane's availability as one reading: the history button on the bar, the pane revealed, and the
/// switch that decides half of it. Both the button and the pane follow trigger mode *and* recording,
/// so every step that changes either prints all three.
/// Whether the signal list's first row still carries its acquire tick. A file view has nothing to
/// acquire -- the samples are stored -- so the tick goes; in live mode it is the control that decides
/// what is acquired at all.
+279
View File
@@ -0,0 +1,279 @@
// 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 <algorithm>
#include <cstdio>
#include <cstdlib>
#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_remove_page(App& app, const std::string& id) {
SlideOver& s = app.slideover;
if (!slideover_has_page(app, id) || s.page == id) return false;
GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str());
if (is_source_page(id)) {
s.source_pages.erase(std::remove(s.source_pages.begin(), s.source_pages.end(), id),
s.source_pages.end());
AdwToggle* t = adw_toggle_group_get_toggle_by_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str());
if (t != nullptr) {
s.syncing = true;
adw_toggle_group_remove(ADW_TOGGLE_GROUP(s.source_switch), t);
s.syncing = false;
}
}
gtk_stack_remove(GTK_STACK(s.stack), child);
return true;
}
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);
// For a screenshot only: leave the page open instead of exercising click-away and Esc.
if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD") != nullptr) {
delete ps;
return G_SOURCE_REMOVE;
}
// 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
+4 -9
View File
@@ -12,7 +12,7 @@ namespace udpscope {
namespace ui_impl {
void build_source_panels(App& app) {
if (app.side_stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return;
if (app.slideover.stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return;
SourceKind* kind = find_kind(app.source_kind);
if (kind == nullptr) return;
for (const SourcePanel& p : kind->make_panels(*app.acq->source())) {
@@ -21,15 +21,10 @@ void build_source_panels(App& app) {
p.widget == nullptr ? "no widget" : "an empty id");
continue;
}
// Each page scrolls on its own, like Measure and Signals: panels differ in height.
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), p.widget);
const std::string name = "source-" + p.id;
// The tab is its title: the sidebar's pills are words, not glyphs.
adw_view_stack_add_titled(ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(),
p.title.c_str());
// 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});
}
// Disconnected until the first Connected event says otherwise.
+14 -2
View File
@@ -106,9 +106,21 @@ void update_status_popover(App& app, const udpscope::StatusSnapshot& s) {
std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels);
gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf);
std::snprintf(buf, sizeof(buf), "%.1f MB · %.0f s span · time x%llu", s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride));
std::snprintf(buf, sizeof(buf), "%s samples/signal · %s · %.0f s span · time x%llu",
udpscope::format_samples(s.buffer_samples).c_str(),
s.buffer_bytes > 0 ? udpscope::format_bytes(s.buffer_bytes).c_str() : "not planned",
s.span_s, static_cast<unsigned long long>(s.time_stride));
gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf);
// The reason the figure is not the one asked for, where there is one: a smaller ring than the
// source form promised, with the limit that did it, as the label's tooltip.
gtk_widget_set_tooltip_text(
app.popover_label[7],
s.memory_limited
? ("limited to " + udpscope::format_samples(s.buffer_samples) + " samples by the " +
udpscope::format_bytes(s.memory_limit_bytes) + " memory limit (" +
udpscope::format_samples(s.buffer_requested) + " requested)")
.c_str()
: nullptr);
const std::string stored = udpscope::format_rate(s.stored_rate_hz);
const std::string wire = udpscope::format_rate(s.declared_rate_hz);
+130 -45
View File
@@ -16,12 +16,12 @@
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`), and a window
// resize moves it under a spotlight that a one-shot measurement would leave behind.
//
// There are *two* tours and one implementation. The main window's walks the plot, the side panel,
// the trigger and the like; the source chooser's -- which appears *before* `App` exists -- walks the
// chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The
// machinery below is written against a `TourView` and a list of `TourStop`s, so the difference
// between the two is only which widgets they name: the chooser keeps its table in
// `source_dialog.cpp`, where its widgets live.
// There are *two* tours and one implementation. The main window's walks the plot, the badge bar, the
// trigger (in the slide-over), the dock and the like; the source chooser's -- which appears *before*
// `App` exists -- walks the chooser's own widgets and then hands the tour on to the window through
// `Config::tour_pending`. The machinery below is written against a `TourView` and a list of
// `TourStop`s, so the difference between the two is only which widgets they name: the chooser keeps
// its table in `source_dialog.cpp`, where its widgets live.
#include "ui_app.hpp"
@@ -39,12 +39,12 @@ namespace ui_impl {
namespace {
/// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit
/// beside the plot on the documented window -- at 756 px the side panel is 320 and a card wider than
/// beside the plot on the documented window -- at 756 px the dock is 280 and a card wider than
/// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the
/// paragraph: the wrapped label is capped by `max_width_chars` so the natural width never exceeds it.
constexpr int kCardWidth = 280;
/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the side panel
/// is 320 px, and it has to hold the card and both gaps.
/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the dock
/// is 280 px, and it has to hold the card and both gaps.
constexpr int kGap = 8;
/// Places an overlay child of the tour overlay at an exact rectangle. `GtkOverlay` positions an
@@ -76,12 +76,18 @@ const TourStop* current_stop(const TourView& view) {
return &view.plan[static_cast<std::size_t>(view.index)];
}
/// Selects a stop's page on the view's stack, before anything is measured against the target. Both
/// windows' stacks are `AdwViewStack`s and their page names are the ones the same tables name their
/// `.ui` pages with, so the machinery can do this itself; the `get_child_by_name()` guard keeps a
/// stop that names a page a later `.ui` dropped from blanking the stack. The stack's own
/// `notify::visible-child-name` is what tells the window which page it is now on.
/// Selects a stop's page before anything is measured against the target. Two mechanisms, one per
/// kind of view: a view with an `on_page` hook (the main window: its pages are slide-over pages, and
/// a stop with no page *closes* the panel) hands the page to the hook; a view with a `page_stack` (the
/// source chooser: an `AdwViewStack` whose page names are the ones its `.ui` names its pages with)
/// has the machinery select it. The `get_child_by_name()` guard keeps a stop that names a page a
/// later `.ui` dropped from blanking the stack. The stack's own `notify::visible-child-name` is what
/// tells the window which page it is now on.
void select_view_page(TourView& view, const char* page) {
if (view.on_page != nullptr) {
view.on_page(view.page_user, page);
return;
}
if (view.page_stack == nullptr || page == nullptr) return;
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(view.page_stack), page) == nullptr) return;
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(view.page_stack), page);
@@ -91,13 +97,16 @@ void select_view_page(TourView& view, const char* page) {
/// pages, and the split view its sidebar, with `gtk_widget_set_child_visible()`, which leaves the
/// widget's own visible flag -- the one `is_visible()` walks up the parents -- **true**. So the only
/// honest "is this on screen" test is whether it is mapped, and it is the one that keeps the filter's
/// two cases apart:
/// cases apart:
/// * a stop that names a page of a view with an `on_page` hook is always walkable: the hook opens
/// the page whatever the rest of the window is doing (the slide-over does not depend on the dock);
/// * a target not mapped because it is on *another page* is walked over when the stop names that
/// page and the stack itself is on screen -- selecting the page is what puts the target there;
/// * a target not mapped because its *pane is collapsed* leaves the stack unmapped too, so no page
/// can reveal it and the stop is still dropped.
bool stop_is_walkable(const TourView& view, const TourStop& stop) {
if (stop.target == nullptr) return false;
if (stop.page != nullptr && view.on_page != nullptr) return true;
if (gtk_widget_get_mapped(stop.target)) return true;
return stop.page != nullptr && view.page_stack != nullptr &&
gtk_widget_get_mapped(view.page_stack);
@@ -130,6 +139,30 @@ void show_step(TourView& view) {
} // namespace
bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* out) {
if (target == nullptr || overlay == nullptr || out == nullptr) return false;
if (!gtk_widget_compute_bounds(target, overlay, out)) return false;
// A target taller than the scroller it stands in (a form on a short window) is clipped by that
// scroller: the part below its edge is not on screen, and a hole that kept going would spotlight
// whatever is under the scroller instead -- the badge bar, the status line. So the hole is the
// *visible* part, intersected with every scrolled window above the target.
for (GtkWidget* parent = gtk_widget_get_parent(target); parent != nullptr;
parent = gtk_widget_get_parent(parent)) {
if (!GTK_IS_SCROLLED_WINDOW(parent)) continue;
graphene_rect_t clip;
graphene_rect_t cut;
if (!gtk_widget_compute_bounds(parent, overlay, &clip)) continue;
if (!graphene_rect_intersection(out, &clip, &cut)) {
// Scrolled out of sight entirely: measured, and nothing of it is on screen. An *empty* rectangle,
// not a failure -- a caller that took "false" for "not measured yet" would keep the previous hole.
*out = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
return true;
}
*out = cut;
}
return true;
}
// --- the machinery, shared by both windows ----------------------------------
void tour_build(TourView& view, GtkWidget* overlay) {
@@ -241,6 +274,10 @@ void tour_advance(TourView& view, int delta) {
}
void tour_end(TourView& view) {
// A page the walk opened is the walk's to close: the panel must not be left standing over a window
// whose tour has ended (Skip, Esc, or the last Next). Only when a walk was actually running --
// dismissing an idle tour must not close a panel the user opened.
if (view.running && view.on_page != nullptr) view.on_page(view.page_user, nullptr);
view.running = false;
view.index = 0;
view.plan.clear();
@@ -271,8 +308,20 @@ void tour_update(TourView& view) {
// Mapped, not `is_visible()`: a widget on another page of a stack, or in a collapsed pane, keeps
// its own visible flag -- a stack hides its pages with `set_child_visible()` -- and a widget that
// is not on screen is not one to leave the spotlight standing on.
const bool target_present = target != nullptr && gtk_widget_get_mapped(target);
if (target_present && gtk_widget_compute_bounds(target, view.overlay, &bounds)) {
bool target_present = target != nullptr && gtk_widget_get_mapped(target);
// A stop on a page waits for the page to finish appearing: until then the target is moving, and
// the frame is drawn as a stop with nothing to point at (everything dim, the hole empty).
const TourStop* stop = current_stop(view);
if (target_present && stop != nullptr && stop->page != nullptr && view.page_settled != nullptr &&
!view.page_settled(view.page_user)) {
target_present = false;
}
const bool measured = target_present && tour_target_bounds(target, view.overlay, &bounds);
if (measured && (bounds.size.width <= 0.0f || bounds.size.height <= 0.0f)) {
// Measured, but scrolled out of the scroller it stands in: nothing is on screen to point at, and the
// hole from the last frame (the target before it was scrolled away) must not stay standing.
view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
} else if (measured) {
// The hole: the target, grown by three pixels so the outline sits *around* the widget rather than
// on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar
// dragged wide), and a negative rectangle is not something a margin can express.
@@ -289,7 +338,7 @@ void tour_update(TourView& view) {
// target went away mid-tour is not a step to be skipped silently.
view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
}
// ... and when the target *is* there but could not be measured, the previous hole stands: a frame
// ... and when the target *is* there but could not be measured yet, the previous hole stands: a frame
// can arrive between two layout passes, and emptying the hole there is the spotlight and the card
// jumping to the corner and back.
const int hx = view.hole[0];
@@ -305,8 +354,8 @@ void tour_update(TourView& view) {
// The card, placed clear of the hole whenever the window has room for it. The strip order is the
// order a card is easiest to read in: under the thing it describes, then over it, then in
// whichever column is beside it -- which is, on this window, the side panel for the plot's step and
// the plot for every step in the side panel.
// whichever column is beside it -- which is, on this window, the dock for the plot's stop and the
// plot for the dock's stops.
//
// The card's size is the *rectangle it was allocated*, read the same way the probe reads it and
// the help's figures are drawn from: `gtk_widget_compute_bounds()`. Not `gtk_widget_get_width()`,
@@ -354,9 +403,16 @@ void tour_update(TourView& view) {
} else if (hx >= cw + 2 * kGap) {
cx = hx - cw - kGap;
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
} else if (hx > w - (hx + hw) + kGap) {
// No side wide enough for the card, but the hole is at the right edge (the slide-over's trigger
// form on a narrow window: 276 px to its left against a 289 px card): the card goes flush left,
// overlapping the plot and as little of the target as there is no way to avoid, instead of
// sitting on the target's own corner.
cx = kGap;
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
} else {
// No clear side at all -- the target is nearly the whole window, which is the plot's step in a
// window narrow enough for the split view to *overlay* the side panel rather than put it beside
// window narrow enough for the split view to *overlay* the dock rather than put it beside
// the plot. The card goes to the bottom-right corner, which is where the least of the target is,
// and the two readings the probe prints (`spot=`, `card=`) are what show how little choice there
// was rather than the card having been placed badly.
@@ -374,9 +430,9 @@ namespace {
/// point of re-measuring every frame is that nothing here knows where the widget is.
struct TourStep {
const char* id;
/// The sidebar page the target lives on, or nullptr when the stop is not on either page. The
/// trigger and the channel list are both on the Signals page, so a stop about either has to put
/// the sidebar there before it can spotlight one.
/// The slide-over page the target lives on, or nullptr when the stop needs none. A stop with a page
/// opens it; a stop without one closes the panel, so the card never points at the status line over
/// an open panel. The page ids are the slide-over's (`trigger`, `source-<id>`, ...).
const char* page;
const char* title;
const char* body;
@@ -385,20 +441,30 @@ struct TourStep {
constexpr TourStep kTourSteps[] = {
{"plot", nullptr, "The plot",
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
"Every signal you have switched on is drawn here, all plots sharing one time axis. The wheel "
"zooms, a drag pans, and a right-click on a plot opens that plot's own menu.",
&App::area_overlay},
{"side", nullptr, "The side panel",
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals "
"holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
&App::side_box},
{"trigger", "signals", "The trigger",
{"badges", nullptr, "The badges",
"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 opens its form, and so does Source when "
"the source has controls of its own. 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.",
&App::badge_bar},
{"trigger", "trigger", "The trigger",
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: "
"the view then freezes on the first event that matches, however rare it is.",
&App::trigger_body},
{"signals", "signals", "The signals",
"Every signal the source declares, each with the switch that acquires it. A signal has to be "
"switched on before it can be plotted, measured or searched.",
{"side", nullptr, "The dock",
"The dock beside the plot is a read-only copy of the cursors and of every measurement, in a "
"fixed width, with the recorded events below them: one row per latched event, newest first, and "
"one click on a row replays it. Edit, beside each heading, opens that instrument's full panel "
"over the plot, Manage opens the full list of events; a click on a measurement opens its "
"dialog. The dock button in the bar above shows and hides the dock.",
&App::side_box},
{"signals", "signals", "Adding a signal",
"What can be added: the signals the source declares that are not acquired yet, each with the "
"tick that acquires it. The + badge opens this list; a signal has to be acquired before it can "
"be plotted, measured or searched, and its badge then appears in the bar.",
&App::signal_list},
{"status", nullptr, "The status line",
"The stream's own report, once a second: the display mode, the rate arriving, the window on "
@@ -407,6 +473,23 @@ constexpr TourStep kTourSteps[] = {
};
constexpr int kTourStepCount = static_cast<int>(sizeof(kTourSteps) / sizeof(kTourSteps[0]));
/// The tour's page hook: a page is a slide-over page, and no page closes the panel.
void tour_open_page(void* user, const char* page) {
App& app = *static_cast<App*>(user);
if (page != nullptr) {
slideover_open(app, page);
} else {
slideover_close(app);
}
}
/// Whether the slide-over has finished sliding in. A closed panel has nothing to wait for.
bool tour_page_settled(void* user) {
const App& app = *static_cast<App*>(user);
if (app.slideover.revealer == nullptr || app.slideover.page.empty()) return true;
return gtk_revealer_get_child_revealed(GTK_REVEALER(app.slideover.revealer)) != FALSE;
}
} // namespace
void build_tour(App& app) {
@@ -422,11 +505,12 @@ void start_tour(App& app) {
app.settings.tour_seen = true;
(void)save_settings(app.settings);
}
// The sidebar's own stack, so a stop can name the page it stands on. Set here and not in
// `build_tour()`: the overlay is inserted above the window's content *before* `App::side_stack` is
// assigned, so the member does not exist yet at build time -- and the walk cannot start before the
// window is up anyway.
app.tour.page_stack = app.side_stack;
// The main window has no page stack (the dock holds the readouts and the events block): its pages are the slide-over's, and
// the view's `on_page` hook opens one (or closes the panel for a stop with no page), replacing the
// `page_stack` mechanism the chooser keeps.
app.tour.on_page = tour_open_page;
app.tour.page_settled = tour_page_settled;
app.tour.page_user = &app;
// The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen.
std::vector<TourStop> stops;
stops.reserve(kTourStepCount);
@@ -486,8 +570,8 @@ constexpr FigureSurface kFigureSurfaces[] = {
{"trigger_summary", &App::trigger_summary},
{"trigger_panel", &App::trigger_panel},
{"signal_list", &App::signal_list},
{"side_switcher", &App::side_switcher},
{"side_stack", &App::side_stack},
{"badge_bar", &App::badge_bar},
{"dock", &App::side_box},
{"window_button", &App::window_button},
{"status_label", &App::status_label},
{"header_bar", &App::toolbar},
@@ -529,10 +613,11 @@ void capture_help_state(App& app) {
GtkWidget* widget = app.*(surface.widget);
graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
int x = -1, y = -1, width = 0, height = 0;
// `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger summary
// while the pane is in configuration mode, the Measure page while the Signals tab is showing --
// keeps its own flag true and reads 0x0, which a crop box would take for a real rectangle.
if (widget != nullptr && gtk_widget_is_visible(widget) &&
// `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 so does everything on the slide-over while the panel is closed (a revealer unmaps its
// child without touching its visible flag); a crop box would take either for a real rectangle.
if (widget != nullptr && gtk_widget_is_visible(widget) && gtk_widget_get_mapped(widget) &&
gtk_widget_compute_bounds(widget, app.window, &r)) {
x = static_cast<int>(std::lround(r.origin.x));
y = static_cast<int>(std::lround(r.origin.y));
+5 -5
View File
@@ -594,10 +594,10 @@ void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
// unreachable exactly when a user wanted to change them.) What that costs is the reading: the
// `trigger panel sensitive` line is now a regression guard, and a `0` there means the
// configuration has been locked out again.
// The event-history button is part of trigger mode and the pane it opens is *on demand*; leaving
// trigger mode takes the button away and collapses the pane, so going back to streaming is not a
// column of stale events beside the plot. Recording is the other half of that answer, so this is
// `sync_events_availability()`'s call and not the trigger's alone.
// The dock's Events block and the `events` page follow what is recorded or cached, not trigger mode
// alone: leaving trigger mode does not empty the cache, and a replay is not in trigger mode at all.
// Recording is the other half of that answer, so this is `sync_events_availability()`'s call and not
// the trigger's alone.
sync_events_availability(*app);
// Arming is a *runtime* control: with trigger mode off there is no engine to arm, and the arm buttons
// are the one part of this pane that is not a setting. (The body itself stays live -- see above.) Both
@@ -721,7 +721,7 @@ GtkWidget* build_trigger_panel(App& app) {
gtk_switch_set_active(GTK_SWITCH(app.events_record_switch), app.event_recording);
gtk_widget_set_tooltip_text(app.events_record_switch,
"keep every triggered acquisition in the event cache, with a "
"picture of each; the history button goes with it");
"picture of each; the dock's Events block and page follow it");
g_signal_connect(app.events_record_switch, "notify::active",
G_CALLBACK(on_events_record_toggled), &app);
// Not `row_for()`: that expands its child, which is what a spin button wants and what a switch must not
+14 -2
View File
@@ -55,8 +55,8 @@ void install_css() {
"popover.udpscope-menu button label { min-height: 0; }\n"
"popover.udpscope-menu image { min-width: 12px; min-height: 0; }\n"
"popover.udpscope-menu separator { margin: 4px 0; }\n"
// The events pane: the row's time is what a person scans for, so it is the one thing in the
// column that is not dim; the thumbnail gets an edge, or a dark plot on a dark pane has none.
// The events page: the row's time is what a person scans for, so it is the one thing in the
// list that is not dim; the thumbnail gets an edge, or a dark plot on a dark panel has none.
".udpscope-event-title { font-weight: bold; }\n"
".udpscope-event-thumb { border: 1px solid alpha(currentColor, 0.15); border-radius: 4px; }\n"
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
@@ -69,6 +69,18 @@ void install_css() {
".udpscope-tour-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n"
".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;"
" border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n"
// The 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"
// 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"
// The readouts strip's two text buttons: Adwaita's 34 px button would be taller than the strip.
"button.udpscope-strip-button { min-height: 0; padding: 1px 8px; }\n"
// The contextual-help "(i)": a *mark*, not a button. Adwaita's menu button is a 34x34 control
// (a 24 px floor plus 5 px of padding) with a 16 px glyph, which beside a heading reads as a
// second toolbar button. Measured on libadwaita 1.10: the outer `menubutton` node *and* its
+1 -1
View File
@@ -9,7 +9,7 @@
- user is acquiring a signal with 1MSps and 100_000 samples per udp frame
- last frame as number <N> and previous <N-2> in this case a 100_000 indexes must be skipped
- time is simply: `((<N_frame> - <N_last>) * <n_samples_per_frame> - <sample_index>) / <sample rate>` (or something like that)
- [x] (3) ~~named configurations~~ **done**: one directory per source holding `<name>.toml` per configuration plus `active`; a configuration carries a note and the startup settings (`buffer_mb`, `relative_time`, `capture_packets`); the chooser lists them and the menu's _Configurations…_ opens/saves/deletes them.
- [x] (3) ~~named configurations~~ **done**: one directory per source holding `<name>.toml` per configuration plus `active`; a configuration carries a note and the startup settings (`buffer_samples`, `relative_time`, `capture_packets`); the chooser lists them and the menu's _Configurations…_ opens/saves/deletes them.
- [x] (4) ~~optional udp frame storage for debugging of strange behaviours~~ **done**: `--capture-packets N`; see README and the design note in `docs/superpowers/specs/` for the ring file, the popover toggle and `tools/capture_to_pcap.py`.
- [x] (5) ~~open exported binary waveforms to view/analyse~~ **done**: `--open FILE` and the chooser's **Waveforms** page (the files opened before, newest first, plus _Open…_); the `.npy`'s dtype names the columns, so a load restores the signals, the samples and the axis the export had, and every instrument works on it — see README and `docs/claude/invariants-data-path.md`.
- [x] (6) ~~imporve right click menu on the plots~~ **done**: the plot's context menu uses the main menu's row style (`menu_button` + `.udpscope-menu`, asserted by `UDPSCOPE_PROBE_ROWMENU_MS`: uniform `label_x=26`, `h=18`, the shortcut flush right), and deleting a signal is a **Remove signal** submenu listing that plot's traces plus _All traces on this plot_.
+28 -18
View File
@@ -18,9 +18,9 @@
# * `xdotool search --name udpscope` finds nothing: GTK4 sets no WM_NAME on the X window it
# creates, so windows are found by their geometry, not their name.
#
# Box provenance: the sidebar width is read from the app's own probe dump (`side=NNNx`), so the
# side-panel box tracks the real widget; the header and status bands are the table in
# `window_boxes` below, each named where it is used, because the dump reports neither.
# Box provenance: the dock's width is read from the app's own probe dump (`side=NNNx`), so the
# dock box tracks the real widget; the header, badge-bar and status bands are the table below,
# each named where it is used, because the dump reports none of them.
#
# Usage: tools/make-help-shots.sh [OUTPUT_DIR] (default help/C/<id>/figures)
set -euo pipefail
@@ -41,23 +41,29 @@ done
mkdir -p "$out"
# --- the window's own geometry, and the boxes it is annotated with -------------
# main-window.ui asks for 1280x800, and the sidebar is a fixed 320 px
# (sidebar-width-unit=px, min=max=320). The header bar is the window's titlebar and the status
# row sits at the foot of the plot column; neither reports a pixel height, so their bands are
# the third measurement -- a pixel scan of a grab, 51 px and 43 px on the current theme -- and
# are refreshed by re-measuring if the styling changes.
# main-window.ui asks for 1280x800, and the dock is a fixed 280 px (the split view's sidebar,
# min=max=280), leaving a 1000 px plot column. The header bar is the window's titlebar, and the
# badge bar and the status row sit at the foot of the plot column, the badge bar over the status
# row; none of them reports a pixel height, so their bands are a pixel scan of a grab -- 51 px,
# 36 px and 43 px on the current theme -- and are refreshed by re-measuring if the styling
# changes. The plot is what is left between the header and the badge bar.
WIN_W=1280
WIN_H=800
HEADER_H=51
BADGES_H=36
STATUS_H=43
PLOT_W=952
PLOT_W=1000
PLOT_H=$((WIN_H - HEADER_H - BADGES_H - STATUS_H))
BADGES_Y=$((HEADER_H + PLOT_H))
STATUS_Y=$((BADGES_Y + BADGES_H))
boxes_main="header bar:0:0:$WIN_W:$HEADER_H
side panel:SIDE_X:$HEADER_H:SIDE_W:706
plot:0:$HEADER_H:$PLOT_W:706
status bar:0:757:$PLOT_W:$STATUS_H"
boxes_trigger="event held, axis pinned to the event:0:757:$PLOT_W:$STATUS_H
the trigger marker at x = 0:0:$HEADER_H:$PLOT_W:706"
plot:0:$HEADER_H:$PLOT_W:$PLOT_H
badge bar:0:$BADGES_Y:$PLOT_W:$BADGES_H
dock:SIDE_X:$HEADER_H:SIDE_W:$((WIN_H - HEADER_H))
status bar:0:$STATUS_Y:$PLOT_W:$STATUS_H"
boxes_trigger="event held, axis pinned to the event:0:$STATUS_Y:$PLOT_W:$STATUS_H
the trigger marker at x = 0:0:$HEADER_H:$PLOT_W:$PLOT_H"
boxes_math="the graph editor:8:8:EW-24:EH-24"
display=
@@ -88,6 +94,10 @@ export GDK_BACKEND=x11 GSK_RENDERER=cairo LIBGL_ALWAYS_SOFTWARE=1
tmp=$(mktemp -d)
trap 'cleanup; rm -rf "$tmp"' EXIT
export XDG_CONFIG_HOME="$tmp/config" XDG_CACHE_HOME="$tmp/cache" XDG_DATA_HOME="$tmp/data"
# A first run starts the guided tour 1.5 s in, and a tour card over the plot is not what the figures
# show: say the tour has been offered, the way a second run of the app would find it.
mkdir -p "$XDG_CONFIG_HOME/udpscope"
printf 'tour_seen = true\n' >"$XDG_CONFIG_HOME/udpscope/settings.toml"
python3 "$mock" --port "$port" --rate 10000 --batch 100 >"$tmp/mock.log" 2>&1 &
mock_pid=$!
@@ -126,7 +136,7 @@ pick_other_window() {
start_app() { # LOGFILE ENVS... ; extra args via APP_ARGS
local log=$1; shift
# shellcheck disable=SC2086
env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-mb 16 ${APP_ARGS:-} >"$log" 2>&1 &
env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-samples 512k ${APP_ARGS:-} >"$log" 2>&1 &
app_pid=$!
}
@@ -136,14 +146,14 @@ stop_app() {
app_pid=
}
side_of_dump() { # LOGFILE -> the sidebar width the app reports, else the table's 320
side_of_dump() { # LOGFILE -> the dock width the app reports, else the table's 280
local s
s=$(grep -o 'side=[0-9]*x' "$1" 2>/dev/null | head -1 | grep -o '[0-9]*' || true)
echo "${s:-320}"
echo "${s:-280}"
}
# annotate NAME RAW LOG BOXES W H -> the figure, with a numbered box per line of BOXES.
# A box is `label:x:y:w:h`; `SIDE_X`/`SIDE_W` are the sidebar's own numbers, `EW`/`EH` the
# A box is `label:x:y:w:h`; `SIDE_X`/`SIDE_W` are the dock's own numbers, `EW`/`EH` the
# grabbed window's width and height.
annotate() {
local name=$1 raw=$2 log=$3 boxes=$4 win_w=$5 win_h=$6