107 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 39f82ade31 udpsclient: size the TCP receive buffer from max_packet_bytes so large MARTe frames are not dropped
A TCP frame is never fragmented; a real TCPStreamer cycle (240 KB) exceeded the UDP datagram
limit and made the client reconnect forever, seeing CONFIG but never DATA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 14:07:54 +01:00
Martino Ferrari a65f953f17 docs: the TCPStreamer source kind, the client's TCP mode, and its probes 2026-11-06 13:58:48 +01:00
Martino FerrariandClaude Opus 4.6 3dad65c487 probes: the TCPStreamer kind end to end (standalone, service, reconnect)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 13:54:44 +01:00
Martino FerrariandClaude Opus 4.6 f27bb17669 sources: a TCPStreamer kind (standalone or TCPStreamService) on the UDPS client's TCP mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 13:47:23 +01:00
Martino FerrariandClaude Opus 4.6 7b8c932ab7 udpsclient: a TCP transport mode (SUBSCRIBE, no CONNECT) and a TCP mock server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 13:44:26 +01:00
Martino Ferrari 21b1945769 docs: TCPStreamer source kind spec and plan 2026-11-06 13:41:31 +01:00
martino d47704c6ec Merge pull request 'Gated measurements' (#2) from gated-measurements into main
Reviewed-on: #2
2026-11-06 12:30:43 +01:00
Martino Ferrari 91ca29d181 scope windows: the same instruments as the main window
A scope or detached window drew its traces and nothing else: no cursors, no gate band, no trigger
marker, no measurement marks. They are not per-window state to be recreated -- the cursors are one
pair of times, the gate one band, the trigger one instant -- so the main window's row set stays the
**owner** and every window is written from it.

`sync_scope_instruments()` runs in the main tick after the main's own `sync_cursors()`/
`sync_gate()`: a gesture inside a scope window is read back from *that* window's controllers first
(those two calls only adopt while their own plot holds the grab, which is exactly "the drag
happened here"), adopted by the owner, and then every window -- the dragged one included -- is
written from the owner with the same cursor pair, mode and visibility, gate bounds and visibility,
and trigger marker. `RowSet::set_cursors()` is the new pair setter; `trigger_marker_x()`/`_visible()`
are the getters the mirror reads. The measurement marks travel by signal name
(`RowSet::MeasureMark::signal`, filled by the measures pass), so each window finds the row that
plots the signal and draws its ruler or mark there -- or none, when it does not plot it. The search
marks are times on the window and are placed on every window by the same per-frame path, and the
crosshair-axis-labels preference is restated to open windows when it changes.

Probe: `[scope-probe] instruments windows=N cursors=N gate=N trigger=N marks=N (main trigger
on=..)`, after adding a real gated `Top` measurement on `sine` -- the gate is restated visible each
frame by `update_measures()` and marks come only from `show_on_plot` measurements, so the leg uses
the app's own path. Docs: README, the cursors invariant, and the probe readings.
2026-11-06 12:21:38 +01:00
Martino Ferrari a29f38eaa9 session: remember extra windows, and reopen them from the menu
A scope or detached window was never written to the session, and a detached figure vanished from
the layout at the next start -- the figure had been removed from the main rows, and nothing kept
the window that held it.

Each window is now a `[[window]]` block in the session -- `name`, `detached`, and its own
`[[window.plot]]` rows with the same keys the main `[[plot]]` rows carry (signals, row weight,
cell width). `collect_layout()` writes the open windows' present arrangement plus the ones already
closed; loading puts **all** of them in `App::closed_windows` and never opens one: a window is a
view the user closed, and a launch that tiled windows onto the desktop would take that decision
for them (a tiling WM would tile them straight away). The overflow menu gains **Reopen closed
windows (N)**, which opens every remembered window through `open_scope_window_from_state()` -- the
one window constructor, then its rows, weights and signals dealt into its row set.

Closing a window records it, by name (a close/reopen cycle replaces its entry rather than
multiplying them), and schedules the save; **attaching** records nothing, because that window's
figure is on the main window again. A detached figure therefore survives a quit through its
window's entry.

Probe: `[scope-probe] loaded closed=N scopes=0` (remembered, none opened) and `reopen
scopes=..->.. remembered=.. left=0` (close, remember, the menu item's own click reopens).
Docs: README, the Mallard layout page, the probe readings and the session invariant.
2026-11-06 12:06:21 +01:00
Martino Ferrari 0c9333858c scope windows: their own layout editor, and the same shortcuts
A detached window is a view with an arrangement of its own -- and there was no way to change it:
the layout editor was the main window's, and a second window's rows could not be added or removed.

Every scope window now carries its own editor: `L` (or the grid toggle in its corner, revealed by
the same proximity rule as the attach button) shows the main editor's `+` / `×` / Done controls,
and they act on **that window's** row set (`set_scope_layout_editing()`, every button tagged with
its window), so a window holds as many figures as the user gives it and the two editors cannot
move each other's rows. Nothing a scope window arranges is written to the main window's session:
the windows themselves are not persisted.

The keyboard: each scope window gets a `GtkEventControllerKey` running the application's one
accelerator table -- `L` acts on the window the key arrived in, every other action reaches the
application through `run_app_action()`. The plot's own keys ('f', the arrows, Escape) already
worked there, through its plot controller.

The window struct now always builds its overlay (the corner controls' host); the attach button
stays detached-only, and both corner toggles step aside while the editor is up (Done is the way
out).

Probe: `[scope-probe] layout buttons=3 rows=1->2` -- the toggle builds 2*rows+1 buttons and a
boundary `+` adds a row. `L` in a real scope window verified by OCR ("Done" appears, the row's
`×` at its left). Docs: README, the Mallard layout page, the probe readings, and the layout
invariant (a view's arrangement is the view's).
2026-11-06 10:54:50 +01:00
Martino Ferrari ecdbf4f0e1 scope windows: mirror a pin as a range, and restore the tick rebuild
**The blank scope window in trigger mode.** A held event pins the main window to
`[-pre, +post]`, and the tick mirrors that pin onto every scope window. It passed
`pinned_range().min/max` to `pin_event_window()`, which takes **spans** (`pre, post`): `min` is
signed, so `pre` came out negative and the group range became `[+pre, +post]` — an inverted x
window. The plotter then had nothing to draw: no trace, no x-axis labels, an empty plot that
looked like a broken window. `RowSet::pin_display_range(x0, x1)` states the pin as the range a
mirroring view actually has, and the tick uses it; live mode was never affected, which is why the
defect only showed under the trigger.

Also restores the scope-window `rebuild_if_dirty()` in the tick, which a reproduction experiment
had left commented out (`REPRO-TEMP`) and a later commit carried: a window opened empty and filled
by a drop kept the figure it was built with.

Probe: `[scope-probe] pin_mirror=1 main=[..] scope=[..] x=[..]` pins the main window, reads the
scope's pin and plot x range a frame later, and reads `0` for an inverted or mismatched pin.
Docs: probe readings and the trigger/pinning invariant (range vs spans).
2026-11-06 10:39:10 +01:00
Martino Ferrari 2279e2140a scope windows: their own frame pump, and a diagnosis that names the GSK renderer
A second toplevel's frame clock is not the main window's: the tick binds, queues a render and
mirrors the span, but with nothing asking GTK for frames on *that* window the area can sit with
its queue full and present nothing -- a blank plot. Every scope window now carries a tick
callback on its own area (`gtk_gl_area_queue_render` per frame), which is the request that keeps
its frame clock running.

The "cannot paint" diagnosis now names the window's GSK renderer (`[GSK renderer: GskVulkanRenderer]`)
and says to restart with `GSK_RENDERER=gl` when it is the Vulkan one -- the interaction between
the Vulkan GSK renderer and an embedded `GtkGLArea` in a second toplevel is a known trouble spot,
and a tiling WM has no title bar to carry a title, so the sentence goes to stderr and the status
bar instead.
2026-11-06 10:09:28 +01:00
Martino Ferrari fa2d0f12fc scope windows: Fit X axis / Fit all in the menu, and a window that cannot paint says so
The scope menu carried a y fit only: with one shared time span, the x fit *is* the main window's
fit, and the next frame mirrors the result back -- so `Fit X axis` and `Fit all` now sit in the
menu beside the y entries and reset the shared range.

And a blank plot becomes a sentence: a scope window whose GL context failed, or whose area has
painted no frame two seconds after opening, writes the reason into its own title and to stderr
(`[scope] window 'plot 1' is not drawing: GL context failed: ...`). A blank rectangle is
indistinguishable from a failed context or an area that never got a frame, and the report "the
plot is not there, no x axis" is exactly what this answers: if the title stays unchanged, the
area is painting and the question moves to the compositor presenting the second window.
2026-11-06 09:03:54 +01:00
Martino Ferrari 2924f7627d scope windows: a right-click menu of their own
A scope window is a view with its own arrangement, but its plot had no menu: every right-click
went nowhere, because the main window's row menu acts on the main window's rows.

`on_scope_right_click()` wires each scope window's GL area (beside its drop target) to a menu
that acts on **that window's** row set: y axis auto scale, y axis fixed range (the main menu's
dialog, aimed at the window's rows through `FixedRangeContext::rows`), fit, and one
*Remove '<signal>'* per trace on the plot. A detached window's menu also carries **Attach back
to the main window**. What it deliberately does not carry is an x fit: the window follows the
main window's time span, so an x fit here would be undone by the next frame's mirror.

`remove_trace()` and `FixedRangeContext` are generalized to take the row set they act on (the
main window passes its own). Probe: `[scope-probe] rclick=1` asserts the click gesture is wired
per window; the menu build is exercised by the real right-clicks the smoke makes.
2026-11-06 08:55:29 +01:00
Martino Ferrari b90a99ec39 scope probe: read the windows' own GL account, and drop into the empty window
`[scope-probe]` grows the two readings a "blank scope window" report needs before it can be
blamed: `empty_drop=`/`empty_slot0=` (the same drop onto the empty window, the user's own
path) and `gl copy=/gl empty=/gl detached ok= frames=` -- each window's adapter context
status and staged-frame count. A scope whose context failed draws nothing at all: no axes,
no traces, exactly the "blank plot widget, no x axis" report -- and that is a driver/context
finding, not a layout one. The detached window is left open by the probe, so a run's
screenshots can answer "does the detached figure draw" with pixels.
2026-11-06 08:19:50 +01:00
Martino Ferrari 5ed75eb470 layout: detach a figure to its own window, and attach it back
Beside each figure row's full-screen toggle -- same corner, same show-on-approach reveal, same
one-figure/never-maximized rules -- a **detach** button moves the figure into a window of its
own: copied into a new scope window (titled "detached") and removed from the main window's rows,
which then save the change. The detached window is the one scope window with an **attach**
control: an overlay wrapping its GL area, a button in the top-right corner revealed by the same
pointer-proximity rule from the overlay's own motion controller, and one click appends the
figure back to the main window's rows (cells, weights, every slot's signal in its colour) and
closes the window.

`detach_figure()` / `attach_scope_window()`; the reveal and placement share the maximize
buttons' geometry and pointer state, per row. A detached window's rows participate everywhere a
scope window's do (drops, the signal page's *Plot on* groups), which the previous commit gave
them.

Probe: `[maximize-probe]` now counts the detach buttons (`detach_single=0` for one figure,
`detach=2` for two, absent while maximized) and reads their corner reveal
(`detach_near=1 detach_other=0`, the pointer parked on the detach button's own centre -- the
strictest zone of the pair). Real-pointer smoke: hover reveals the pair, clicking detach opens
the window, clicking attach closes it with the figure back. Docs: README, the Mallard layout
page, the probe readings, the layout invariant, and the tour's plot stop.
2026-11-05 23:35:26 +01:00
Martino Ferrari 51b4fad386 scope windows take drops, and the signal page lists them
The second windows were read-only by accident: nothing accepted a drop on them, and the
signal page's *Plot on* buttons knew only the main window's rows.

- **Drop target per scope window**: the same string payload a badge drags (the signal index),
  handled by `on_scope_drop_channel()` -- the target carries its own window, the row is that
  window's cell under the pointer, and `assign_signal_to_scope()` lands the signal in the
  window's own row set (enabling an unacquired signal first, exactly as the main drop does).
  The rebuild and the area's re-point are the window's next frame in the tick: a drop arrives
  from inside the very widget it rearranges, and a synchronous rebuild would reset the drag
  controller under it.
- **Plot on, per window**: the signal page carries one named group per scope window ("In
  \"plot 2\""), one button per plot of that window, sensitive on that window's own rows. A
  window closing refreshes the pages in its destroy handler, before the deferred delete could
  dangle a button's scope pointer.
- Probe: `[scope-probe]` drops through the wired handler (`drop=1 slot1='square'`) and counts
  the page's groups (`page_groups=2`); the section documents that it must not run with
  `UDPSCOPE_ENABLE_ALL_MS` -- the enable-all probe's second leg clears the front plot, which
  is the slot this probe copies. README, help and the layout invariant follow.
2026-11-05 19:37:42 +01:00
Martino Ferrari 706d247b58 signal page: the header names it, numbered; no duplicate title or back button
The slide-over's header already prints the open page's title beside its Close button, so the
signal page's body carried the name twice and a "Back to signals" button that only repeated
navigation the header owns. Both are gone.

The header's title is now the **numbered** name -- `<n>. <name>`, `measure_channel_number()`'s
number, the same one the signal badges and the measurements pane use -- so the panel says
*which acquired channel* it is and not only what it is called. `slideover_set_title()`
retitles a page in place (the open one included): the number follows the stream's channel
numbering, and the page is refilled whenever the model's signal list changes.

Probe: `[signalpage]` reads `title='1. sine' title_ok=1` (the header text against the numbered
name) and `no_dup=1` (no "Back to signals" button and no `title-3` label in the body); the old
`back_to_list` reading is gone with the button. Help, README and the probe doc follow.
2026-11-05 18:08:50 +01:00
Martino Ferrari bdf0ff7ddc scope: full-screen figures, second windows, gated measurements, per-configuration events
A batch of view and instrument work, with the probes and docs for each.

- **Full screen per figure row**: a toggle on each row's top-right corner, revealed by
  pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an
  overlay-level motion controller so a revealed button under the pointer cannot flicker);
  the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The
  arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout
  of one figure carries no toggle.
- **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in
  their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main
  window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries:
  the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*.
- **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing
  the layout*, flipping the app's one toggle.
- **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause
  snapshot (live falls back to the live ring), persisted with the session and shown in the
  measure dialog.
- **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the
  plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu
  and the probe read them.
- **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`,
  `retarget_event_store()` follows every configuration switch, and a pre-configuration cache
  (loose files) is adopted into the configuration's folder on first start.
- **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"`
  signal to `close-request`; scope windows are closed with it and the application quits
  explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`.
- Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`,
  `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key,
  `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 09:49:55 +01:00
Martino Ferrari b6822cd32d docs: tidy the scan spec 2026-11-02 09:42:54 +01:00
117 changed files with 18048 additions and 2258 deletions

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
# AGENT.md
Working notes for an AI agent (or a new contributor) in this repository. **Read
[CLAUDE.md](CLAUDE.md) first, then the `docs/claude/` file that covers the area you are touching** —
this page is the fast path into it, not a replacement. `SPECS.md` is what was asked for; `README.md`
is what was built, deviations and limitations included; when the two disagree, README explains why.
## Build
```sh
git submodule update --init --recursive # deps/plotter is a submodule
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build # the app
cmake --build build --target test-ui # round-trips every .ui through blueprint-compiler
cmake --build build --target test-help # yelp-check over help/C/org.udpscope.app/
./build/udpscope --server 127.0.0.1 --port 8000
```
A clean build emits **exactly one warning** (`-Woverlength-strings` on the generated GResource
`.c`). Two is a regression.
## Verify
There is **no unit test suite**. The check is the app's own headless self-check against a mock
source; add a probe rather than a harness:
```sh
python3 tools/mock_udpstreamer.py --port 8000 --duration 120 &
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 UDPSCOPE_ENABLE_ALL_MS=1000 \
UDPSCOPE_PROBE_<NAME>_MS=3000 ./build/udpscope --server 127.0.0.1 --port 8000
```
Two baselines every run is read against: **`gtk warnings: 0`** in the exit dump (a `Gtk-CRITICAL` is
a defect, not noise) and the single build warning above. The probes, their env vars and the value
each line must read are in [docs/claude/probe-readings.md](docs/claude/probe-readings.md); how a run
is compared with the previous one is in [docs/claude/verification.md](docs/claude/verification.md).
For UI work, drive the real surface: the app under `xvfb-run` plus `xdotool`/`import` for pointer and
pixel checks (see `tmp/`-free examples in the probe sources), not only internal state reads.
## Hard rules
- **SPDX MIT header on every source file**, in the format the existing files use.
- **Two threads only**: a producer owning the data source and the GTK main thread owning every
widget and the GL context. They share `ScopeModel` under **one mutex**; samples cross through
lock-free rings, never through the lock.
- **A view never acquires.** Scope windows, full-screen and the layout editor are arrangements of
views over the one acquisition; they must not plan buffers, epochs or sources of their own.
- **Layout must not depend on content** (docs/claude/invariants-plot-and-layout.md).
- **Documentation moves with the code.** A behaviour change updates the matching
`docs/claude/invariants-*.md`, `README.md`, the Mallard page under `help/C/org.udpscope.app/`, and
the probe reading in `docs/claude/probe-readings.md` — the probe baselines are read as tests.
- **`deps/plotter` is a submodule and has its own history**: commit there first, then commit the pin
in this repository. Never edit its files without committing them both sides.
- No new dependencies, no generated-file edits, no silent changes to `SPECS.md` semantics: a
deliberate deviation is written into `README.md` under "Deviations from the spec".
## Commits
Subject: `<area>: <what changed>`, lowercase, imperative, no trailing period (`layout editor: …`,
`docs: …`, `plot: …`). The body explains *why* (the defect it fixes, the trade-off it takes), not a
restatement of the diff. Keep each commit one coherent change; probes, docs and the code they
describe travel together.
+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 six bundled icons and their authoring rules, and how the Mallard manual is opened. |
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the bundled icons and their authoring rules, how the slide-over's click-away coexists with the plot's gestures, how the guided tour opens a page, and how the Mallard manual is opened. |
+6 -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")
@@ -241,6 +241,7 @@ add_executable(udpscope
src/source/registry.cpp
src/source/udps_source.cpp
src/source/udps_kind.cpp
src/source/tcps_kind.cpp
src/source/probe_source.cpp
src/source/e1608/e1608_link.cpp
src/source/e1608/e1608_device.cpp
@@ -269,11 +270,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
)
+459 -120
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,124 @@ 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>`)
```
**A row full screen, and a second window.** Each figure row carries a full-screen toggle in its
top-right corner: it comes out of hiding while the pointer is near that corner (the button's own
rect plus a `kMaximizeRevealPx` of slack, per row) and is invisible otherwise, so the plot is not
decorated by controls nobody is reaching for; one click makes that row fill
the plot area and the same button restores the whole arrangement; the plot's right-click menu
offers the same as **Full screen this figure** / **Restore the layout**, and a layout of a single
figure carries no button (there is nothing to enlarge *to*). Beside the toggle, and with the
same show-on-approach, a **detach** button moves the figure into a window of its own (removed
from the main window's rows, which save the change); the detached window carries an **attach**
button in the same corner — its figure returns to the main window's rows and the window closes.
It is a *view* — the row set
saves every row's state on the way in and re-applies it on the way out (`RowState` in
`plot_host.*`), so nothing plotted moves and nothing is saved. A plot's right-click menu has
**Open this plot in a new window**, which copies that figure — its cells, their width shares and
every slot's signal in its colour — into a second top-level window with its own `RowSet`, figure
and `PlotArea`; the overflow menu's **New scope window** opens one with an empty figure. A scope
window is another *view* of the same acquisition: the main window's tick binds and acquires its
rowset under the same model mutex, mirrors the main window's time window (or its pin) onto it
every frame, and pumps its render — so the two windows never disagree about the data, the display
mode or the span. **A signal badge dragged onto a scope window's plot lands there** — the same
drop the main plot takes, into that window's own row set, on the cell the pointer is over — and
the signal page's *Plot on* buttons list **every window**: one group per window (named, `In
"plot 2"`), one button per plot of it. **The session remembers every extra window**, but a launch never opens one: a window is a view
you closed on purpose, and a start that tiled them onto the desktop would take that decision for
you. The overflow menu's **Reopen closed windows (N)** brings them back, arrangement and all; a
window that was *attached* is not remembered (its figure is on the main window again), and a
detached figure is preserved by its window's entry, so quitting with one open no longer loses it.
A scope window draws the **same instruments as the main window** — the two cursors (draggable
there, and shared: moving one in any window moves it everywhere), the gate band, the trigger
marker and the measurements' marks on the traces each window actually plots. They have one owner
(the main window's plot set) and are written into every window each frame, so no window can drift.
A scope window also **carries its own layout editor**: `L` (or the grid button in its corner,
revealed like the others) shows the same `+`/`×`/Done controls the main window's editor has, and
they change *that* window's rows — so a second window can hold several figures as easily as the
main one. A right-click on a scope window's plot opens **its own menu** — y axis (auto or a fixed
range), *Fit X axis*, *Fit all*, remove a trace — acting on that window's rows (the x fit resets the **shared** time span, so every view follows); a detached
window's menu carries **Attach back to the main window** beside them. A window that cannot paint
says so on stderr and in the status bar after two seconds, naming its **GSK renderer**: a blank
plot is indistinguishable from a context that failed, and the GTK **Vulkan** renderer is known to
interact badly with an embedded `GtkGLArea` in a second toplevel — the sentence then says to
restart with `GSK_RENDERER=gl`. Each scope window also carries a **tick callback of its own**,
which keeps its toplevel's frame clock advancing: the main window's tick binds and queues the
render, but a second window whose clock never ticks presents nothing.
mode or the span.
**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 -- its title is the **numbered name**
(`2. sine`, the same number the badge and the measurements pane use) and the body carries the unit,
gain and offset and the CONFIG's details directly under it, with no second copy of the name and no
"back to the list" button (the header's Close is the way out) (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 one chip per measurement (the
signal's colour chip, the type, the value; a click opens its dialog) and a **`[+]`** after the last chip that
adds a new measurement -- the strip is where the readings are watched, so it is where adding one belongs.
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
@@ -117,22 +253,38 @@ only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-che
The second built-in kind is the **MCC E-1608** Ethernet DAQ (8 single-ended or 4 differential 16-bit inputs, two
analog outputs, eight digital lines and a 32-bit counter). Its connection form takes an address, or a MAC address
to look up (optionally with the subnet to sweep), and 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
to look up (optionally with the subnet to sweep). A **Scan network** row at the top of the form probes every
network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a
click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not
saved: only the filled field is. 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, 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.
**This kind has not been run against a board.** It is verified against `tools/mock_e1608.py`, a protocol mock written
from two independent implementations of the wire protocol. The following rest on a single reference or the two
disagree, and are the first thing to check when a board is attached (the mock's header lists the same items):
the counter reset command (0x31, from one reference only); the pacer value (the source sends the *aggregate*
**This kind has been run against a real board once, on 2026-11-02, with one channel.** The board was at
169.254.88.75 on `net0`, `ai0=BIP10V` single-ended with the input grounded, `scan_rate=1000`, for 7 s: the connection
and handshake succeeded, the source configured one channel and streamed it at the declared 1000 S/s (about 1250
samples in the first 1.25 s, no gaps, no overflows, no `[e1608]` errors), and the readings sat within a few LSB of
0 V (about 0.9 to 1.8 mV on a +-10 V range). Nothing else has been run on hardware; everything else is verified only
against `tools/mock_e1608.py`, a protocol mock written from two independent implementations of the wire protocol.
The following rest on a single reference or the two disagree, and are the first thing to check with more of the board
exercised (the mock's header lists the same items):
the counter reset command (0x31, from one reference only; not run on the board); the pacer value (the source sends the *aggregate*
rate, `round(80e6 / (scan_rate * channels) - 1)`, as one reference does; the other sends the per-channel
frequency); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only);
frequency; the two differ only with two or more channels, so the one-channel run cannot settle it); the scan-start trigger option bits (none 0, rising 4, falling 8, high 12, low 16, one reference only);
whether the handshake may answer status 2 ("code ignored"; one reference accepts it, the other refuses it, and the
source accepts it); the first frame id; and the maximum frame size. Two further things
are worth a look on a board even though the mock's header does not flag them: the calibration memory layout and
@@ -175,6 +327,27 @@ delivers one frame per datagram. The source is connected on the first valid data
was verified on a loopback group (`239.255.42.99` joined on `127.0.0.1`, although this machine's `lo` does not
carry the `MULTICAST` flag); a real network, its switches and IGMP were not exercised.
The fourth built-in kind is **TCPStreamer** (`--source tcps`), a receiver for the MARTe2 `TCPStreamer`
datasource, standalone or through a shared `TCPStreamService`. Both speak the UDPStreamer framing over a TCP
connection, so it is the UDPS client in a TCP mode: the same CONFIG and DATA, the same channels, calibration and
timing. The form takes the server (default 127.0.0.1), the port (44500) and an optional **stream name**: leave
it empty for a standalone `TCPStreamer`, which starts sending on connect; name the stream for a
`TCPStreamService`, which wants a `SUBSCRIBE` first (the client sends one only when a name is set). On the
command line these are `--source tcps --source-opt server=... --source-opt port=... --source-opt stream=...`.
The source is disconnected after 2 s of silence or a closed connection and reconnects every 0.5 s; a stream
whose frames are more than 2 s apart therefore reconnects continually, as UDPS does. It is verified against
`tools/mock_tcpstreamer.py`, not against a MARTe2 application. Its limits:
- **One stream per connection.** A multiplexed `SUBSCRIBE` list (`a,b,c`) is refused by the form, because a
source is single-timebase; merge streams on the server with `TCPStreamJoin` and name the result.
- **No packet capture.** The capture is of the UDP socket; the button stays disabled.
- **A gap is reported at once.** TCP is ordered, so there is no reorder window: a counter gap is a server-side
drop and is counted as lost immediately.
- **A frame is at most `UDPS_MAX_DATAGRAM_BYTES` (about 65 KB)**, which the server's `MaxPayloadSize` default of
60000 satisfies.
- **After a reconnect to a server whose time base restarted** the app prints `the clock stalled at ...`
warnings, exactly as for a restarted UDPS stream.
A session file written before kinds existed has no `source_kind`, and is read as UDPS; a UDPS file is
written exactly as before. A kind's capability panels (for instance a source's own controls) appear as
pages of the side stack, and are insensitive rather than hidden while no source is connected.
@@ -296,7 +469,9 @@ edge the ticks were generated for; the caption always names the anchor the label
with, so the addition stays exact.
**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement:
`[badge] <type> <value> [edit]`. `[+]` opens a dialog with the signal, the type, the options
`[chips] <N>. <signal> · <type> <value> [edit]` — the number is the signal's among the
active channels, the same one the signal's badge in the bar and the dialog's signal dropdown
carry. `[+]` opens a dialog with the signal, the type, the options
that type actually uses, and "show on plot"; `edit` opens the same dialog on that
measurement, with a `Delete` beside `Save`.
@@ -407,6 +582,21 @@ Both cost: a filtered window disables the two bucket fast paths, because a bucke
samples and cannot be mixed into a filtered walk. A filtered window is still walked once and shared by every
measurement that asks for it, so a pane of five measurements at two corners costs two walks, not five.
**The gate measures a slice of the window, and it is a per-measurement choice.** A
measurement's dialog carries `Gate enabled`; every gated measurement then describes
only the samples inside one shared violet band, carried by its own pair of draggable edges on
the plots — *not* the cursors, which keep meaning what the cursors pane reads. The band is
shown exactly while some measurement is gated: gate one and it appears, gate or delete the
last one and it goes. Ungated measurements on the same signal are untouched — they share the
window walks they always shared, and a gated one costs one more walk, not one per pane. The
band lives in the same relative time the axis labels do, so it stays exactly where it was put
in both modes: on a live view "300 ms ago to 100 ms ago" stays put on screen while the data
flows under it — the gated read of the newest data a bench scope gives — and on a held event
it names a fixed slice of the frozen frame. Drag an edge to resize, grab the middle to move; a
band with no samples in it — dragged outside the view, or over a gap — reads `--` like any
window with nothing in it. The band's position and each measurement's gate flag are session
state, so a reopened scope measures what the closed one measured.
### Why a crossing is counted with hysteresis
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
@@ -492,7 +682,15 @@ results must still narrow with the axis (`follows=1`).
**Show on plot** marks the result on the row that plots the signal: a ruler at the level
(`top`/`bottom`), both rulers at the extremes (`min`/`max`), the pulse's or the cycle's two
edge times as vertical markers. The marks are **readouts, not instruments**: a fixed pool of
edge times as vertical markers. Each mark carries its **badge**: the ruler's own value written
in a small opaque panel straddling the ruler — in the y axis' own tick format, so it reads
exactly like the tick beside it — and the time mark's time in a box centred on the marker (in
the pane's own formatter, so a live view's badge is an age). The boxes exist for the trace
underneath: a number drawn bare over a noisy signal is a number nobody can read, and an
opaque one is a number nothing prints through. And when marks land close
together — two levels a few pixels apart, a pulse's two edges — their badges **merge into one
panel with one line per mark**, because two texts over one column is a smear and one boxed
readout is a reading. The marks are **readouts, not instruments**: a fixed pool of
cursors per row, created **unselectable** (`Plot::set_cursor_selectable()`, added for this), so
the controller's hit test never offers the pointer one — it can neither hover nor drag a
measurement's ruler. They are also re-placed every frame, because a mark is only correct for
@@ -693,8 +891,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
@@ -746,29 +948,51 @@ Three things it deliberately is not, and the reasons are in
## Captured events
Trigger mode keeps the last **32 triggered acquisitions** in a cache, so an event you did not have
time to look at is still there:
time to look at is still there. The folder is the source **and its active configuration** -- an
event's trigger, channel set and units are the configuration's, so two configurations of one source
do not list each other's events:
```
~/.cache/udpscope/events/192.168.1.40-8000/
~/.cache/udpscope/events/192.168.1.40-8000/default/
20261009-123250-0001.npy # the event, in the export format
20261009-123250-0001.txt # when, how long, how many signals, the rate, the edge
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.
Switching configuration re-points the cache with it, and a cache written before configurations
existed (loose files directly in `<source>/`) is adopted into the configuration's folder on the
first start.
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
@@ -779,7 +1003,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
@@ -797,8 +1021,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
@@ -829,7 +1053,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.
@@ -842,12 +1066,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
@@ -876,15 +1100,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
@@ -906,18 +1130,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
@@ -947,9 +1171,13 @@ named `default`: copied into the directory, remembered as active, and the old fi
`~/.config/udpscope/settings.toml`, written by **Preferences…** -- in the overflow menu or the source
chooser -- and read at start-up. It holds the event cache's directory, how many events to keep (`0`
keeps every one), whether relative time is on, and the interface's theme. Whether anything is cached at
keeps every one), whether relative time is on, the interface's theme, and the crosshair's **axis
labels** — the two small boxes where the crosshair's lines meet the axes: the y value among the y
ticks, the time among the x ticks, each in its axis' own format. On by default; the switch applies at
once. Whether anything is cached at
all is the trigger pane's own switch, and there is deliberately no second one here; the directory is
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache
the one place every source gets a folder inside (and every configuration a folder within that),
`<cache>/udpscope/events` by default. The cache
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory
is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
chosen. A change
@@ -957,7 +1185,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
@@ -981,6 +1213,9 @@ plots = 1
measure_stats = false
[[plot]]
row = 0
width = 1
weight = 1
signals = ["Ch1", "", "", ""]
[[calibration]]
@@ -989,8 +1224,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
@@ -1135,18 +1380,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.
@@ -1157,7 +1413,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.
@@ -1193,11 +1449,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
@@ -1383,8 +1639,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
@@ -1548,9 +1804,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
@@ -1600,7 +1857,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.
@@ -1804,7 +2061,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:
@@ -1947,9 +2204,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.
@@ -2143,10 +2400,17 @@ the line marks the edge, so the pre/post split can be read straight off the plot
not one of the user's cursors: it has its own colour, ignores the cursor show/hide
toggle, and is parked (invisible) when there is no event.
**The status line.** It starts with an activity badge and then the mode, the
**The status line.** It starts with the **trigger circle**, then an activity badge and then
the mode, the
connection, the measured rate, the packet/frame/gap counters, what plot 1 holds and
the buffer's size and span. Three of those are worth calling out:
the buffer's size and span. Four of those are worth calling out:
- **The trigger circle is the trigger's own light**, in the same glyph-and-colour alphabet as
the badge: solid green while the trigger has fired (an event is held, or a capture is
filling), **flashing** green while armed and waiting for an event, red while the trigger
mode is on but disarmed — and *hidden* while the trigger mode is off, because a light for a
mode the user is not in is noise, and the mode text beside it is not showing trigger words
then either. Its tooltip names the state in words.
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
mode the trigger is armed. Anything else leaves it steady, with the reason on its
@@ -2214,7 +2478,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),
@@ -2223,7 +2487,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
@@ -2430,7 +2694,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
@@ -2592,7 +2858,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.
@@ -2623,8 +2889,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*
@@ -2638,8 +2905,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
@@ -2648,13 +2915,14 @@ edge.
**Rows are edited in place, not chosen from a count.** The header bar used to carry a
`plots` dropdown of 1 to 4, and a count is the wrong handle: it says how many rows you
want and leaves which row your traces land on to the renumbering. **Layout editor**
(overflow menu) instead puts the controls on the rows — a `×` at the top-left of each row
(overflow menu, a plot's right-click menu, or `l`) instead puts the controls on the rows — a `×` at the top-left of each row
removes it, a `+` on each boundary inserts one there — so the row an action names is the
row you are pointing at. `--plots` is now only the count to start with, and `RowSet::kMaxRows`
(8) is a ceiling rather than a menu: past about that a row is chrome and no plot.
The editor also carries its own way out: a **`Done`** button in the plot area's top-right
corner while it is up, because the toggle that opened it lives in the overflow menu — a
corner while it is up, because the toggle that opened it lives in the overflow menu (and in a
plot's right-click menu, as *Edit the layout...* / *Done editing the layout*) — a
long way from the plot being edited. It turns that toggle off, so there is still exactly
one piece of state, and the buttons (its own included) go with it.
@@ -2670,6 +2938,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
@@ -2764,12 +3070,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 |
@@ -2857,7 +3164,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
@@ -2881,34 +3188,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 pill with its own glyph and no text** — the
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list
for Signals (the same picture the toolbar's trigger button carries), drawn as a
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
accessible name. Both panes also have a **locked** mode, which is what a returning user gets: the
trigger pane shows one line saying what it is set to, the signal list shows only the signals that are
on, and neither offers a control that changes the configuration — each heading has an `[edit]` button
to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling
plots is what the list is for, not configuration — and a locked row can be dragged from anywhere on it,
the colour chip included. In a window too narrow to show both, where the sidebar is *overlaid on* the
plot, the sidebar steps aside for the length of the drag so there is something under the pointer to drop
onto. A **first run** (no session file for that source) comes up
- **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
design spec had the switcher showing *titles* and neither page an
`icon-name`; `narrow` alone does not do it (libadwaita still draws a label beside the icon,
measured), so the pages carry an empty title and the pills are named in code. With the math
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
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
@@ -2989,6 +3320,10 @@ silence timeout and then resumes, which is the EMI case end to end.
exactly faithful in `strict` mode, and the only reading of the wire that does
not invent samples in `accumulate` mode. The info popup and the CONFIG dump on
stderr report the element counts.
- **The TCPStreamer source takes one stream per connection and has no packet capture.** A multiplexed
`SUBSCRIBE` list is not supported (merge on the server with `TCPStreamJoin`), the capture button stays
disabled, a gap is reported at once (no reorder window) and a frame is limited to about 65 KB; see "Data
sources".
- **The event window can be shorter than the buffer.** The trigger latches a window
of the event-span selection; the ring holds far more, so with trigger mode off the
rest is scrollable history.
@@ -3002,6 +3337,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,
@@ -3039,7 +3378,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
+68 -5
View File
@@ -174,6 +174,7 @@ struct udps_client {
char server_addr[128];
char mcast_group[128];
char iface_addr[128];
char stream_name[256];
struct sockaddr_in server_sa;
int udp_fd; /**< Unicast receive socket, or joined multicast socket. */
@@ -197,6 +198,7 @@ struct udps_client {
size_t vals_cap;
uint8_t *rxbuf;
size_t rxbuf_cap; /**< bytes allocated for rxbuf */
udps_held_t held[UDPS_REORDER_SLOTS];
size_t held_bytes; /**< payload bytes currently held */
@@ -1149,9 +1151,50 @@ static int connect_multicast(udps_client_t *c) {
return 0;
}
/** TCP transport: connect, then SUBSCRIBE when a stream name is set. */
static int connect_tcp(udps_client_t *c) {
struct timeval tv;
size_t name_len = strlen(c->stream_name);
c->tcp_fd = socket(AF_INET, SOCK_STREAM, 0);
if (c->tcp_fd < 0) {
return fail(c, "TCP socket() failed: %s", strerror(errno));
}
set_recv_buffer(c->tcp_fd, c->cfg.recv_buffer_bytes);
/* Linux bounds a blocking connect() with SO_SNDTIMEO, so an unreachable host
* cannot hold the polling thread for minutes. */
tv.tv_sec = 3;
tv.tv_usec = 0;
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, (socklen_t)sizeof tv);
if (connect(c->tcp_fd, (struct sockaddr *)&c->server_sa,
(socklen_t)sizeof c->server_sa) != 0) {
int e = errno;
close_sockets(c);
return fail(c, "TCP connect to %s:%u failed: %s", c->server_addr,
(unsigned)c->cfg.server_port, strerror(e));
}
/* A half-received frame must not wedge the poll loop (read_exact_tcp blocks). */
tv.tv_sec = 1;
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, (socklen_t)sizeof tv);
if (name_len > 0u) {
uint8_t pkt[UDPS_HEADER_SIZE + 255u];
build_header(pkt, UDPS_PKT_SUBSCRIBE, 0u, 0u, 1u, (uint32_t)name_len);
memcpy(pkt + UDPS_HEADER_SIZE, c->stream_name, name_len);
if (send(c->tcp_fd, pkt, UDPS_HEADER_SIZE + name_len, MSG_NOSIGNAL) !=
(ssize_t)(UDPS_HEADER_SIZE + name_len)) {
int e = errno;
close_sockets(c);
return fail(c, "SUBSCRIBE failed: %s", strerror(e));
}
}
return 0;
}
static int do_connect(udps_client_t *c) {
int rc = (c->mcast_group[0] != '\0') ? connect_multicast(c)
: connect_unicast(c);
int rc = c->cfg.use_tcp ? connect_tcp(c)
: (c->mcast_group[0] != '\0') ? connect_multicast(c)
: connect_unicast(c);
if (rc != 0) {
c->disconnect_t = now_mono();
return rc;
@@ -1174,7 +1217,10 @@ static void do_disconnect(udps_client_t *c, const char *why) {
if (!c->connected) {
return;
}
(void)send_control(c, UDPS_PKT_DISCONNECT);
if (!c->cfg.use_tcp) {
/* TCP: a write to a peer that closed would raise SIGPIPE; the server sees the close itself. */
(void)send_control(c, UDPS_PKT_DISCONNECT);
}
close_sockets(c);
c->connected = 0;
c->disconnect_t = now_mono();
@@ -1211,7 +1257,7 @@ static int read_tcp_frame(udps_client_t *c) {
if (udps_parse_header(c->rxbuf, UDPS_HEADER_SIZE, &h) != 0) {
return -1; /* desynchronised: the stream cannot be resynced */
}
if (h.payload_bytes > (uint32_t)(UDPS_RX_BUF_BYTES - UDPS_HEADER_SIZE)) {
if (h.payload_bytes > c->rxbuf_cap - UDPS_HEADER_SIZE) {
return -1;
}
if (h.payload_bytes > 0u &&
@@ -1267,17 +1313,28 @@ udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
if (c == NULL) {
return NULL;
}
if (cfg->use_tcp && cfg->stream_name != NULL && strlen(cfg->stream_name) > 255u) {
free(c);
return NULL;
}
c->cfg = *cfg;
c->udp_fd = -1;
c->tcp_fd = -1;
copy_str(c->server_addr, sizeof c->server_addr, cfg->server_addr);
copy_str(c->mcast_group, sizeof c->mcast_group, cfg->multicast_group);
copy_str(c->iface_addr, sizeof c->iface_addr, cfg->interface_addr);
copy_str(c->stream_name, sizeof c->stream_name, cfg->stream_name);
/* The config's string pointers must not be used again: they belong to the
* caller and the copies above are what the client works from. */
c->cfg.server_addr = c->server_addr;
c->cfg.multicast_group = (c->mcast_group[0] != '\0') ? c->mcast_group : NULL;
c->cfg.interface_addr = (c->iface_addr[0] != '\0') ? c->iface_addr : NULL;
c->cfg.stream_name = c->stream_name;
if (c->cfg.use_tcp) {
c->cfg.multicast_group = NULL;
c->mcast_group[0] = '\0';
c->cfg.reorder_window_s = 0.0;
}
if (c->cfg.max_packet_bytes == 0u) {
c->cfg.max_packet_bytes = 1u * 1024u * 1024u;
@@ -1294,7 +1351,13 @@ udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
return NULL;
}
c->rxbuf = (uint8_t *)malloc(UDPS_RX_BUF_BYTES);
/* A UDP datagram is at most UDPS_MAX_DATAGRAM_BYTES; a TCP frame is not fragmented
* and may be as large as max_packet_bytes (MARTe's TCPStreamer allows 1 MiB). */
c->rxbuf_cap = UDPS_RX_BUF_BYTES;
if (c->cfg.use_tcp && (size_t)c->cfg.max_packet_bytes + UDPS_HEADER_SIZE > c->rxbuf_cap) {
c->rxbuf_cap = (size_t)c->cfg.max_packet_bytes + UDPS_HEADER_SIZE;
}
c->rxbuf = (uint8_t *)malloc(c->rxbuf_cap);
if (c->rxbuf == NULL) {
free(c);
return NULL;
+16 -1
View File
@@ -52,7 +52,8 @@ enum {
UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */
UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */
UDPS_PKT_CONNECT = 3, /**< Client -> server: open a session. */
UDPS_PKT_DISCONNECT = 4 /**< Either direction: close a session. */
UDPS_PKT_DISCONNECT = 4, /**< Either direction: close a session. */
UDPS_PKT_SUBSCRIBE = 5 /**< Client -> server (TCP stream service): payload = stream name. */
};
/** Sample type codes (udps_signal_t::type_code). */
@@ -279,6 +280,20 @@ typedef struct {
* the drop-on-late behaviour. Default 0.020.
*/
double reorder_window_s;
/**
* Non-zero selects the TCP transport (a MARTe2 TCPStreamer / TCPStreamService):
* @c server_port is the TCP port, no CONNECT or DISCONNECT frame is sent, the
* server streams CONFIG then DATA on connect, and the reorder window is forced
* to 0 (TCP is ordered, so a counter gap is a server-side drop). @c multicast_group,
* @c interface_addr and @c data_port are ignored.
*/
int use_tcp;
/**
* TCP mode only: when non-empty (1..255 bytes) a SUBSCRIBE frame naming this stream is
* sent right after every connect, as a TCPStreamService requires. A plain
* TCPStreamer ignores it. NULL or "" sends nothing.
*/
const char *stream_name;
} udps_client_config_t;
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
+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
+112 -11
View File
@@ -32,11 +32,19 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept.
- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained
at the top of `poll()`. A panel never calls a source directly.
- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in
- `UdpsSource` wraps `deps/udpsclient` and takes a `Transport` (UDP or TCP); `udps_*` names appear only in
`src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling).
- **The TCPStreamer kind (`tcps`) is `UdpsSource` with `Transport::Tcp`**, not a new `DataSource`: the
transport argument sets `use_tcp` / `stream_name` in the client config, defaults the port to 44500 and
ignores multicast and interface. `src/source/tcps_kind.cpp` is only the form and the identity (`host:port` or
`host:port/stream`). One stream per connection (no multiplexed `SUBSCRIBE` list: a source is
single-timebase; merge on the server with `TCPStreamJoin`), and the form refuses a name with a comma.
`has_datagrams()` is false, so the capture button stays disabled. `Disconnected` follows the client's 2 s
silence timeout or a closed connection, and it reconnects every 0.5 s; a stream whose frames are further
apart than 2 s therefore reconnects continually, as UDPS does.
- 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
@@ -44,12 +52,14 @@ What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
has neither key, so a legacy session is read as UDPS and nothing existing moves.
- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link`
(discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands,
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types;
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is
`Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main
thread, the source's lookup goes through `discover_broadcast`);
`e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
never reaches the UI files.
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital
input/counter poll and the 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.
@@ -89,11 +99,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.
@@ -169,12 +267,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
+81 -8
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,16 +120,17 @@ 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
required; a MAC with no `address` is looked up by broadcast, or by a sweep of `subnet` (`a.b.c`).
required; a MAC with no `address` is looked up by a broadcast sent on every interface (`Scan`), or by a sweep of
`subnet` (`a.b.c`).
The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol
mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine
per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S`
switches, `--code N`); there is no real board in the loop, and the mock's header lists the items whose
layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message.
switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see
the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message.
```sh
python3 tools/mock_e1608.py --port 54711 &
@@ -161,6 +164,16 @@ python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals 'Trigger:uint8,
(Quote the `signals` value: zsh globs the `[4]`. The mock takes the same text with or without the trailing `w`;
the datagram is the same, `w` only says how the app shows it.)
`--source tcps` receives a MARTe2 `TCPStreamer` (standalone) or `TCPStreamService` (named stream) over
TCP: `server` (default 127.0.0.1), `port` 44500, and `stream` (optional; a `SUBSCRIBE` frame is sent only when it is
set). `tools/mock_tcpstreamer.py` serves one client with the `mock_udpstreamer.py` signals (`--port`, `--stream`
to require a `SUBSCRIBE`, `--rate`, `--batch`, `--freq`, `--close-after S`, `--drop-every N`).
```sh
python3 tools/mock_tcpstreamer.py --port 44500 --stream fast &
./build/udpscope --source tcps --source-opt port=44500 --source-opt stream=fast
```
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
- `--square-harmonics N` shapes `square`: `N` odd harmonics of the ideal step's Fourier
@@ -198,7 +211,8 @@ form for a new host. Every probe below passes `--server`, so none of them sees i
`deps/plotter` is built from source with only the GTK4 adapter enabled
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
vendored C99 source pair compiled into a static lib. `deps/udpsclient` gained one
vendored C99 source pair compiled into a static lib. `deps/udpsclient` gained a TCP
transport mode (item 6 below) and one
additive entry point for the packet capture — `udps_client_set_datagram_cb()`, firing
inside `udps_client_poll()` immediately after the UDP `recv()` returns, before the
magic check or reassembly — plus the public `UDPS_MAX_DATAGRAM_BYTES` the capture
@@ -224,7 +238,7 @@ handler clears either pointer without knowing which dialog it is. Presenting eit
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
`deps/udpsclient` is a vendored C99 source pair and its diff from the upstream source
is intentional. Five things were changed:
is intentional. Six things were changed:
1. **A reorder ring of reassembled payloads.** When a packet arrives with a higher counter
than expected, the raw datagram is held in a fixed-size ring rather than decoded immediately.
@@ -255,6 +269,17 @@ is intentional. Five things were changed:
would be the same silent loss by another route. `stats.reorder_overflows` counts the event,
so an undersized ring is a reading rather than a mystery; it is surfaced in the exit dump and
the `[reseq-probe]` line as `overflows=`.
6. **A TCP transport mode.** `udps_client_config_t::use_tcp` / `stream_name` select a TCP
connection to a MARTe2 `TCPStreamer` or `TCPStreamService`: `connect_tcp()` (a 3 s connect
bound through `SO_SNDTIMEO`, a 1 s receive bound, an optional `SUBSCRIBE` frame,
`UDPS_PKT_SUBSCRIBE = 5`, sent only when `stream_name` is set), the existing
`read_tcp_frame()` for the frames, the reorder window forced to 0 (TCP is ordered, so a
counter gap is a server-side drop and is reported at once), multicast forced off, no
DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), no keepalive ACK
and no datagram tap. The receive buffer is `max_packet_bytes + header` (1 MiB by default,
matching the server's frame ceiling) in TCP mode, since a TCP frame is never fragmented and
a MARTe cycle of large arrays is one frame; it is `UDPS_MAX_DATAGRAM_BYTES` only for UDP. A
stream name over 255 bytes is refused at create.
`deps/plotter` is upstream's own repository and this app's figure support was added
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
@@ -266,6 +291,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
+159 -86
View File
@@ -9,6 +9,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
tick as the channel dump; the status line only formats what is already there. The
badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames.
- **The trigger circle is the badge's sibling, in its alphabet and under its rules.** It sits
before the badge on the status row (`main-window.ui` `trigger_status`) and says what the
badge cannot: the *trigger's* state -- solid green while an event is held or a capture is
filling, flashing green (the lit and dim halves) while armed and waiting, red while the
trigger mode is on but disarmed, and **hidden while the trigger mode is off**, because a
light for a mode the user is not in is noise and the mode text beside it shows no trigger
words then either. Same width-locked glyph in every state, same half-second square for the
flash, markup rewritten on a change of the state key only (`trigger_circle_state`), and the
exit dump reads the tick's cached answer (`dbg_trigger_circle`) because it cannot ask the
widget.
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
@@ -50,39 +60,25 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
how the first version of that line earned them. What stays in the menu is what acts on the *data*
(Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list
is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
- **Seven of this app's icons are its own, and they are a `GResource` compiled into the
is the `signals` slide-over page, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
- **An overlay handle that follows what it drags reports gesture offsets in its own moving
coordinates; add its displacement back.** A `GtkGestureDrag` measures its offset from where the
press began *in the widget's own coordinates*. The layout editor's column handle sits on the
divider and `sync_layout_editor()` moves it every frame, so after a few motion events the
offset is the pointer's travel **minus** the handle's travel, and the divider converges on half
the pointer's travel (`D = P - D`). Record the handle's x in the overlay's coordinates at
begin (`gtk_widget_compute_point`) and add `handle_now - handle_begin` to the offset on every update
(`layout_col_total_offset()`). The same applies to any future handle that is repositioned
from the state its own drag changes.
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
measurement ruler, and the stock names those buttons used to carry said something else
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,tab-measure,tab-signals}-symbolic.svg`
are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,resize-cols}-symbolic.svg`
are the six action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
the Preferences window's icon, not the tab's.
The two **tabs** got glyphs of their own, and each is a *pair* rather than one of the icons above.
`udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two
bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed
frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and
`udpscope-tab-signals-symbolic` is a sine crossing its dashed level, beside two bold channel rows. They
are not reuses, because a glyph the toolbar already carries means two things the moment a tab shows it
too; instead the *trigger's* own icon (`udpscope-trigger-symbolic`) was redrawn to the Signals pill's
picture, so the pill and the button that turns the trigger on are recognisably about the same thing.
The wave is a **full period and not a hump**: a crest and a trough are what make it read as a wave
rather than as a hill, and the level's dashes survive between the crossings. (A hump was tried and
reverted — it named the "above the level" half of a trigger and lost the crossings, which is the half
that matters.)
Both are **32x16** composites drawn **bold** — one thick band, two thick dashes, few features — because
at this size a thin wave or a 1px tick reads as *texture* rather than as a shape, which is what "not
clear" looks like. Wider than tall on purpose: a 16px square cell carries one motif, not a pair. And
**GDK paints an icon into a square box whatever the document's aspect** (measured:
`gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg fits it to `32x16`), so the pair is
letterboxed rather than stretched — and the pill's image is given `pixel_size` 32 in `name_side_tabs()`,
which draws the artwork at the size it is written, so its bars stay crisp. Every glyph here is
**inset two pixels**, because art flush with the cell edge is what a button's padding or a crop takes a
pixel off, and a fractional scale is what would blur it, which is why nothing is drawn at 24 or 48. The
two tab glyphs measure 29% and 30% ink, inside the range the icons above them set.
the Preferences window's icon and the **Dock toggle's** (the ruler: the dock is the readouts).
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
@@ -233,9 +229,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.
@@ -243,46 +240,49 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a
310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel
window. Every vertical revealer in the side panel hides this, because there the leftover goes down
window. (That pane was a revealer beside the plot; it is a slide-over page now, so no revealer sits in
a horizontal box any more.) Every vertical revealer in the side panel hid this, because there the leftover goes down
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
**The sidebar is a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher`
above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different
heights, so one shared scroller would leave a switch scrolled where the new page never asked to
be). The reason is the one this trap teaches: five sections competing for one 320 px column left
every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the
**cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the
**The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow`
(`dock_scroller`) holding only the compact **readouts** (`src/ui_dock.cpp`). It
replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source
can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one
this trap teaches: full panels competing for one narrow column each left every one of them short of room.
The full cursors and measurements panels are now the `cursors` and `measure` **slide-over pages**, reached
by the readouts' **Edit** buttons, and `m` toggles `measure` (it no longer scrolls the dock;
`dock_show_section()` is gone); the signal list is the `signals` slide-over page, which `g` and the `+` badge
open. The header's Cursors toggle no longer opens anything: it only enables the cursors. A *feature's* on/off is no longer a visibility at all -- the
**cursors'** toggle sets its pane's **body** insensitive (and the dock's cursor grid beside it) -- the readout has nothing to show with the
instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch
(see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured
*before* it is switched on, so switching it off to change it, or to read what it is set to, is the
ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable
exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a
`0` there means the configuration has been locked out again. The
trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still
given the leftover width, so **ask about state, not about size** carries over unchanged. Every
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
current -- a change in what the reading means, not a defect, and no functional code may depend on
those numbers.
- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.**
Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a
`GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab
bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to
`select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick
calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and
`AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an
`AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
the sidebar on `drag-begin` and restores it on `drag-end` — only when collapsed, because side by side the
plot is already visible and hiding the list the drag started in would be startling. `pane drag: sidebar
hidden for the drag=1 restored after it=1` is the reading; the state is forced collapsed the way the
sidebar probe forces it.
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two
panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
trap itself does not go away with the widget: a section scrolled out of view is still laid out, but
**ask about state, not about size** still holds for anything a tick reads.
- **The dock is shown but insensitive until the stream has signals ("disable, do not hide").**
`update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish
alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays
sensitive throughout. The `cursors` and `measure` **pages follow the same condition** through
`update_signal_availability()` (called from `update_side_visibility()` and the per-frame block), so a
page opened by `m` before the stream has signals is insensitive, not refused. `[readouts] ... pages_sensitive=`
reads it. The wish is persisted (`dock = true|false` in the session file) and a change of it
schedules a save. `dock:` in the dump reads the result.
- **The signal badge is the drag handle, and a drop handler only moves widgets.** Dragging a signal onto a plot
starts on its badge in the badge bar (a `GtkDragSource` on the badge's button, payload the signal index as a
decimal string, taken by the plot area's existing drop target); the list's rows are not drag sources. A drag
starting with the slide-over open would have part of the plot hidden under the panel, so the source's
`drag-begin` closes it (which only restyles the badges: nothing is destroyed mid-drag). A click without movement
never reaches the drag threshold and still opens the page; past it the source claims the sequence and the
button's click is cancelled. Each badge is also a `GtkDropTarget` for reordering: `on_badge_drop` calls
`gtk_box_reorder_child_after` and rewrites `App::badge_order` from the widgets, and **never rebuilds the badges** --
the dragged badge and the target whose `drop` emission is running must both survive it. A foreign drag whose string is not a
signal index is refused. `[sigbadges]` reads the controllers, the payload and the reorder; `pane drag:` reads the close.
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The
trigger pane's `[edit]` button is `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
(the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** —
so the button that leaves configuration mode was blank, which no other reading could see: the button
worked, the layout was unchanged, and only `pane edits:` (the two names and
@@ -294,13 +294,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
@@ -317,22 +318,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
@@ -610,12 +615,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,
@@ -624,15 +656,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
@@ -641,11 +684,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
@@ -679,8 +726,34 @@ 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.
- **GTK4 has no `GtkWidget::destroy` signal; the lifecycle hook is `close-request`, and
`gtk_window_destroy()` never emits it.** A `g_signal_connect(window, "destroy", ...)` on a GTK4
widget is a connect to a signal that does not exist: it never fires (and its "signal not found"
warning is in the GObject domain, not GTK's, so the dump's `gtk warnings:` counter cannot see
it). That is why the dialogs use `gtk_window_close()` when their *lifetime* matters (a close
clears `App::measure_dialog`, which a destroy would not), and why the main window's own handler
-- the scope windows' teardown, the debounced layout-save flush and the application quit -- is
connected to **`close-request`** and returns `FALSE` so the default destroy still runs.
Everything that ends the main window programmatically (the exit probe's `exit_probe_step3b`)
closes it with `gtk_window_close()` for the same reason. The **scope windows go with it**:
each is a `GtkWindow` added to the application, and one left behind keeps the GApplication's
main loop alive around a window that is gone -- the hang where the exit run never reached its
dump.
- **A page's name lives in the slide-over header, once.** The header prints the open page's title
(from the `GtkStackPage`) beside its **Close** button; a page body that repeats the name is the
same word twice, and a body-level "back to the list" button duplicates a navigation the header
already owns. Signal pages therefore carry **no** name label and **no** back button
(`[signalpage] no_dup=1`), and their title is the **numbered** name -- `<channel number>. <name>`,
the same `measure_channel_number()` the badges and the measurements pane use -- so the panel says
*which acquired channel* this is rather than only what it is called. `slideover_set_title()`
retitles a page in place (the open one included) because that number changes when the stream
renumbers its channels; `fill()` sets it on every fill, and a page added by
`slideover_add_page()` gets its final title from the `fill()` that follows.
+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.
+42 -1
View File
@@ -70,6 +70,47 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp.
- **A row names its channel: number and signal, from one resolver.** The pane's row reads
`N. signal · type`, the dock's and the strip's lines prefix the same number to the type,
the signal's badge carries it, and the measurement dialog's signal dropdown numbers its
entries with it -- all through `measure_channel_number()`, which is the position of the
signal's channel in the model's channel list (the acquired channels in stream order), or 0
when the signal is not acquired. The readouts rule cuts both ways here: every view shows the
same number because every view asks the one function, and a view that derived a number by
its own walk of the channel list would be the second spelling that drifts. 0 is a state, not
a failure -- the unacquired signal's row is already grey -- so the readers *omit* the number
rather than printing a 0. Renumbering on every enable, disable and CONFIG is inherent (the
number answers "which active channel is this", which is what a rebuild renumbers), so a
generation change rebuilds the pane as well as the signal list.
- **The gate is a per-measurement option, and one band shared by everyone who uses it.**
`MeasureOptions::gate` is set per measurement in its dialog; every gated measurement reads
only inside the one shaded band -- a plotter `Region`, violet, its two bounds the gate's own
pair of instruments, not the cursors, which stay what the cursors pane reads -- and the band
is *shown exactly while some measurement is gated*, derived at `rebuild_measures_pane()`,
because every change of the measure set (an add, an edit, a delete, a session restore)
passes through there. Ungated measurements are keyed away from gated ones
(`MeasureWindow`'s key is `(channel, fc, gated)`), so a gated measurement costs one more
walk and changes no ungated number.
The bounds live on the `RowSet` in **display x** (`gate_x0()`/`gate_x1()`), the cursors' own
space, and that is the point: a live axis is newest-relative, so a band at `[-0.3, -0.1]`
keeps meaning "300 ms to 100 ms ago" while the data flows under it, and a held event's
stamps are edge-relative, so there the band names a fixed slice of the frozen frame. Either
way it stays where it was put, which is what makes it a gate and not a second window.
`update_measures()` clips the pane's window (viewport live, pin pinned) into the band once,
and each gated window walks the clip: every statistic, mark and "it moved" booking below
describes the gated slice through that one expression. A band entirely off the view clips to
a zero span and the pane reads the same `--` any empty window gives. Because a band that
moves can change what the *gated* measurements describe without the viewport or pin moving,
the "it moved" test asks the band as well (`measure_gate_window`, booked every pass). While
a region drag runs, the tick adopts the bounds out of the dragged row's plot
(`sync_gate(drag_row)` -- the `sync_cursors()` contract: read only while a gesture holds the
instrument, because outside one the read is our own echo) and mirrors them to every other
row; the drag's *end* saves the session, because the bounds are session state
(`gate_x0`/`gate_x1`, and each measure's `gate` flag in its `[[measure]]` section) and a
drag that is not saved is a session that lies about where the gate is. `[gate-probe]` is the
reading: the gated average differs from the ungated one on the same signal, the booked gate
window is the band moved onto the stream's clock, the band is still where it was put a
second later while `s0=` has moved under it, and a restored session brings both back.
- **A measurement must not read its window raw.** Both passes go through
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
pane inherits the summary of the new samples rather than computing one, which is what the
@@ -121,7 +162,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`:
+214 -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,156 @@ 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, and the **vertical cursor
pair is one shared time drawn in every cell** (`cursors_aligned`). The horizontal crosshair line is
the hovered cell's y **drawn in every cell of its figure row** -- the cells share that y, so the
line is one value spanning the row rather than stopping at the divider; no other row gets one
(`Figure::set_crosshair` spreads it, `[split-probe] cell-xhair` reads it).
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 +277,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
@@ -182,3 +343,49 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
leaving them off.
- **The full-screen toggle and a scope window are *views*: neither edits the arrangement nor takes a second
acquisition.** The per-row toggle (`RowSet::toggle_maximize_figure_row()`) saves every row's persistent
state (`RowState` -- the fields `carry_row()` copies, because a `Row` is not copy-assignable: its
`SlotSeries` owns a `unique_ptr`) plus the cells, the weights and the plot count, rebuilds the figure with
the asked-for row's plots moved to the front, and puts every field back on the second click; it saves
nothing, so a reopened session has the arrangement, not the view. Its overlay button is on screen
*always* (`App::maximize_buttons`, a set of its own -- `layout_buttons` is the editor's and is cleared with
it), placed from the same `FigureLayout` rects as the editor's buttons. A scope window
(`App::ScopeWindow`, `open_scope_window()`) owns a `RowSet`, a `PlotArea` and the area's placeholder
`Plot`, and is driven by the **main window's tick**: the same model mutex binds and acquires its rows
(same display mode, same epoch), and its span follows the main window's window or pin every frame. It is
therefore a second *view* -- the settle rule is that a scope must never acquire, plan or epoch anything of
its own. Closing the main window destroys its scopes first: a leftover toplevel keeps the GApplication's
loop alive around a window that is gone (the exit run's hang). What a user *arranges* in a scope is the
scope's own arrangement, and it takes the same two doors the main window's rows take: a badge **dropped on
a scope window's plot** lands in that window's row set on the cell under the pointer (its own drop target,
its own `assign_signal_to_scope()`), and the signal page's *Plot on* section carries **one named group per
window** -- rebuilt out of `App::scopes` on every fill, and again when a window closes (the destroy handler
refreshes the pages before the deferred delete can dangle a button's scope pointer). The change itself is
never applied synchronously: a drop arrives from inside the very widget it rearranges, so the rebuild and
the area's re-point are the window's next frame in the tick.
- **A figure row's full-screen is one click from two places, and never from one figure.** The
toggle sits on the row's top-right corner, **out of hiding only while the pointer is near that
corner**: its own placed rect inflated by `kMaximizeRevealPx`, per row, so approaching one
row's corner reveals that row's toggle and no other. The pointer is read from a
`GtkEventControllerMotion` on the **overlay**, not on the GL area -- a revealed toggle sits
under the pointer, and a leave on the GL widget would fire the moment the button appeared and
hide it again (the flicker the ancestor avoids); a leave on the overlay means the pointer left
the plot, which is when the toggles go away. The plot menu offers the same action as *Full
screen this figure* / *Restore the layout*;
both end in the same `toggle_fullscreen()` -- the `RowState` save/restore, the area's figure,
the toggles and the signal panels -- so the two cannot drift apart. A layout of a single
figure row carries **no** toggle (and no menu entry): there is nothing to enlarge *to*, and a
visible no-op is worse than none. The button reappears while a row *is* maximized, because
there it is the way back.
- **A view's arrangement is the view's, editor included.** A scope window holds its own figure
rows and edits them with its own `+`/`×`/Done set (`set_scope_layout_editing()`,
`rebuild_scope_layout_editor()`, `sync_scope_layout_editor()`): the buttons are tagged with the
window and the handlers act on *that* window's row set, so the two editors cannot move each
other's figures. `L` acts on the window the key arrived in (a `GtkEventControllerKey` per
window; every other accelerator reaches the application through `run_app_action()`), and a
pinned layout change is **not** written to the main window's session — the windows themselves
are not persisted.
+56 -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
@@ -86,3 +118,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
marker says where the scope put the edge and a measurement mark says what a measurement measured;
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
- **The crosshair's axis badges are painted *over* the tick labels, and that costs a flush.**
A badge is a fill in the crosshair's colour framing the value where the line meets the axis
it names — and it sits *in the label strip*, on top of the tick label at that row. The tick
labels are staged into `glyphs_over_`, the over-text batch the frame flushes **after every
line batch**, so a panel fill drawn at the ordinary moment lands *under* them: the label
prints through the badge and the backplate reads as transparent (the defect this rule was
learned from). `draw_crosshair_axis_badges()` therefore flushes the accumulated over-text
*first* — those labels are already placed, so it is one batch — then draws both fills, then
stages the badge texts, which leave with the rest of the over batch over everything. An
assertion on badge pixels inside a label's rows is the regression test: with the fill under
the text, the label's glyph pixels are still lit inside the badge's rect.
- **The instruments have one owner: the main window's row set.** The two cursors, the gate band,
the trigger marker and the measurement marks are *shared*, not copied per window. The main tick's
`sync_scope_instruments()` writes the owner's state into every scope window each frame (the
setters early-out, so a steady frame is a handful of comparisons), and a gesture inside a scope
window is read back from **that** window's controllers first -- `sync_cursors()`/`sync_gate()`
only adopt while their own plot holds the grab, which is exactly "the drag happened here" -- and
adopted by the owner before anything is written out. The measurement marks travel by **signal
name** (`RowSet::MeasureMark::signal`), so a window finds the row that plots it; a window that
does not plot the signal draws none. The search marks are times on the window and are placed on
every window by the same per-frame path.
+66 -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
@@ -162,3 +192,26 @@ of.
Nothing is lost by switching: the dialog saves the current session *before* loading the picked
one, which is the only ordering in which a switch cannot lose what it switches from.
- **The event cache is keyed on the source *and its active configuration*, and the key moves
with the switch.** An event's trigger, its channel set and its units are the configuration's,
so the directory is `<base>/<host>-<port>/<configuration>/` (`EventStore`'s third constructor
argument) and `retarget_event_store()` -- called next to every `App::config_name` write and
resolving the name from `active_configuration()` when it is empty -- re-derives it, refreshing
both views of the list only when the directory changed. A store built without a configuration
keeps the legacy `<base>/<source>/` path, and the **first construction under a configuration
adopts the loose event files it finds there** (`*.npy`/`*.txt`/`*.bmp`, moved into the
configuration's folder): an upgrade keeps the events it had. The key is a *sanitized* path
component, the same rule the configuration files use, so a name typed with a slash cannot
leave the directory.
- **The session remembers extra windows; a launch never opens them.** Each scope or detached
window is a `[[window]]` block (`name`, `detached`, and its own `[[window.plot]]` rows -- the
same keys the main `[[plot]]` rows carry), written from the open windows' present arrangement
plus the ones already closed (`collect_layout()`). Loading puts **all** of them in
`App::closed_windows`, never into `app.scopes`: the overflow menu's *Reopen closed windows (N)*
is the only path that opens them (`open_scope_window_from_state()`, the one constructor every
window uses, then its rows). Closing a window records it (by name, replaced, so close/reopen
cycles do not multiply entries); **attaching** records nothing -- its figure is on the main
window again -- and a detached figure survives a quit through its window's entry instead of
being lost from the main layout.
@@ -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.
+27 -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
@@ -620,3 +629,11 @@ samples. Three rules keep that honest:
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
class GTK-free, which is what makes the cache testable without a window.
- **A pin is mirrored as a *range*, never as a pair of numbers that might be spans.** `Range`
(`pinned_range()`: `{-pre, +post}`) and the pin's *spans* (`pin_event_window(pre, post)`) are two
different things that both look like a pair, and mixing them inverts the window: a second view
that handed `pinned_range().min` to `pin_event_window()` pinned to `[+pre, +post]`, and the plot
drew nothing — no trace, no axis labels — because the x range was degenerate. Views that mirror a
pin use `pin_display_range(x0, x1)`, the range's own form. `[scope-probe] pin_mirror=1` reads the
two windows' pins and the scope's x range, and an inverted pin reads `0`.
File diff suppressed because it is too large. Load diff
+269 -69
View File
@@ -15,34 +15,53 @@ 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 **12352** against C99's 4095-byte string-literal limit with
the seven action icons and the two app icons that remain (a note about a generated file, which GCC
itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is
now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that
bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by
touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers`
or any other warning is a finding. (The fifth warning that used to appear here was an unused
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
`output_after_add` already printed on the same line — which dropped the baseline from five to
four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's
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`,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
runs **after `g_application_run` returns**, so the window and every widget in it are gone -- the pointer
is **freed memory**, and a critical is only
the lucky reading of it. A run that had built and destroyed other windows first —
`UDPSCOPE_PROBE_HELP_MS`, which opens the About and shortcut dialogs and then closes them — has had
that memory reused, and the same two calls
@@ -56,7 +75,26 @@ precedent, not an exception.
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_ARM_TRIGGER_MS` clicks the
trigger toggle and then samples the **trigger status circle** once a second for four
seconds: an armed, waiting trigger reads `circle=flash-lit` / `circle=flash-dim` (the two
halves of the 1 Hz flash, in the phase the sample lands in), a latched event `circle=green`,
a disarm `circle=red`; the exit dump carries the last tick's state as `trigger circle:`
(`off` under `UDPSCOPE_EXIT_AFTER_MS`, whose own probe goes live before it dumps).
`UDPSCOPE_PROBE_CROSSHAIR_MS` hovers the pointer over row 0 through the adapter's own
controller and asserts the crosshair's two **axis badges** landed in their label strips
(`[crosshair-probe] active=1 ... y_ok=1 x_ok=1`), then flips the flag off and reads the
plot's back; the pixel-level contract is the plotter suite's. The preference behind the
flag is `[prefs-probe]`'s `xhair=` field. `UDPSCOPE_PROBE_MAXIMIZE_MS` arranges two
single-cell rows (sine, square) and exercises the one-click **full-screen** toggle through
the same `RowSet` call the button's handler makes — `[maximize-probe] before: rows=2 frows=2
row0=sine row1=square buttons=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2
row0=sine row1=square flag=0` — where `buttons=` is the overlay's one toggle per figure row
and the `back` leg is the proof that a maximize moves no signal. `UDPSCOPE_PROBE_SCOPE_MS`
opens the **second window** twice, once copying figure 0 and once empty, and reads it back
a few frames later: `[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1` —
`bound=1` is the main tick having bound the copy's rows to the same model, and `empty_ok=1`
the empty scope staying empty. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present
@@ -187,6 +225,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
@@ -203,13 +254,16 @@ precedent, not an exception.
window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a
recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at
the same window).
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
- `[measure-probe] N measurements on 'X' (channel C)` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
measurements that all read `--` *and* makes a performance run look free (each bails before
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
The values are in the exit dump as `measurements: N.signal <type>=<value>`, each numbered
with the signal's active-channel number (the same one the pane's rows carry, and unnumbered
when the signal is not acquired -- `enable_all` switches one off before the dump, so its
measurements read unnumbered there); on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
`peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
says: **`top=0.957988`** and `amplitude=1.915983`. `top` is no longer 2/pi -- it is the *flat*
@@ -315,6 +369,42 @@ precedent, not an exception.
[probe-readings.md](probe-readings.md); the window rule itself is in
[invariants-measurements.md](invariants-measurements.md).
- `[gate-probe]` (stderr, `UDPSCOPE_PROBE_GATE_MS`) — the **per-measurement measurement gate**:
a gated measurement must read the shared band and an ungated one the whole window, and the
band must stay put while the data moves under it. It enables the sine, puts it on a plot
(the way `enable_all_probe` does — an unbound trace leaves the display epoch at 0, and a
still window is the state the `still=` leg asserts moves), adds **two** averages — index 0
ungated, index 1 gated, which is what makes the pane rebuild *derive* the band from the
measure set — and re-states the band's bounds to a fixed relative interval, 300 ms ago to
100 ms ago, where samples exist whatever the run's age. It first waits for the ring to be
older than the window, because the `plain=` assertion below is exact zero only while a
whole-seconds window of the mock's 1 Hz sine holds whole periods; a young ring clips the
window to a fraction of a cycle and the reading is merely small. Two lines:
```sh
python3 tools/mock_udpstreamer.py --port 8140 --rate 100000 --batch 100 --freq 1 &
sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_GATE_MS=3000 \
./build/udpscope --server 127.0.0.1 --port 8140 2>&1 | grep -E 'gate-probe'
```
The first line must read **`band=1 placed=1 plain=1 split=1 gwin=1`**: one gated
measurement showed the band, the plot's region holds exactly the bounds the `RowSet` does,
the ungated average is zero (it read the whole window — `-1.36e-17 V` on the reference run),
the gated average differs (it read the band — `355.31 mV`, wherever in its period the
stream happened to be), and the pass booked `measure_gate_window` = the band moved onto the
stream's clock (`s0=`), which is the phase-free statement of *which* slice the gated walk
read. The second line, a second later, must read **`still=1 s0_moved=1`**: the band exactly
where it was put while the stream's clock moved under it — the *relative* behaviour the
gate exists for. The probe saves the bounds the way a drag end does; the exit dump's
**`gate:` line** is the round trip — a second run on the same config, with the probe unset,
must restore both measurements (`measurements: 2 average≈0 average≠0`) and read
`gate: on=1 bounds=[-0.300000..-0.100000]`. **Not in one run with the other measurement
probes** (`UDPSCOPE_PROBE_MEASURE_MS`, `_MEASURES_MS`, `_BANDAVG_MS`, `_LEVELS_MS`,
`_MEASUREPIN_MS`): they add measurements to one pane and these lines quote indexes 0 and 1.
The lines are in [probe-readings.md](probe-readings.md); the rule is in
[invariants-measurements.md](invariants-measurements.md).
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
@@ -333,7 +423,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,16 +504,79 @@ precedent, not an exception.
pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three
phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs
`UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first.
- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements on the
Measure tab, a forced pass, then the tab switched away and read again two seconds later. **`advanced=4`
is the assertion** (every accumulator gained passes while its page was off screen); `advanced=0` is the
defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for
exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other
probe ahead of it. The reading's own entry is in `probe-readings.md`.
- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements while the
`measure` page is **closed**: it adds four measurements, opens the page for a forced pass (the baseline),
closes it, and reads the accumulators again two seconds later. **`advanced=<rows>` is the assertion**
(every accumulator gained passes while the page was off screen); `advanced=0` is the defect where the
accumulation is gated on what is visible, which would leave a gap in min/max/avg for exactly as long as the
page was shut. It enables *and plots* its own signal, so it needs no other probe ahead of it, and it
re-arms once after 400 ms (a buffer rebuild between baseline and report reads as a gated pass). It needs a
config that has seen the first-run tour, which would close the page it opens (see the next-but-one bullet).
The reading's own entry is in `probe-readings.md`. (Probe name `dock_section_probe`; the variable and the
`[side-tab-probe]` tag keep their old names so existing scripts keep working. The measurements used to be a
dock section the probe scrolled out of view, with padding; the dock now holds the readouts and the Events block, so there is
no scroll and no padding.)
- `UDPSCOPE_PROBE_CURSORS_MS` and `UDPSCOPE_PROBE_MEASURES_MS` also print the dock's copy of what they read:
a `dock cursors:` line after every `[cursors-probe]` report and a `dock measures:` line at the
measures probe's added / settled / final stops, each with a `strip:` line beside it (the readouts strip,
shown when `(!dock_shown || collapsed)` and the cursors are on or there is a measurement; `expected=` is that, `visible_ok=1` that the strip agrees, `groups=`/`groups_ok=1` the same for its Cursors / Measures groups and separator; the measures probe's `start` line, before it adds anything, is the hidden "neither" state -- run it from a fresh config); each probe
ends by hiding the dock for 0.6 s and reading the strip (`dock hidden`, `dock restored`: under xvfb the
window is collapsed, so `visible=1 expected=1 h=26 same_text=1` on both, given something to read). **`same_text=1` on every one** is the assertion that the
dock, the strip and the page show one string; `fits=1` that the Readouts box is no wider than the dock. Entries in
`probe-readings.md`. The exit chain also prints one `[readouts]` line (pages registered, the dock page's three
children, the dock's width, the pages' sensitivity, whether the stream has signals) on every exit-dump run.
- `UDPSCOPE_PROBE_DOCK_SESSION_MS=2500` with `UDPSCOPE_EXIT_AFTER_MS=8000` -- the session file's `dock` key,
end to end: the dock hidden, saved and reloaded; shown again; and a file from the previous version
(`side_tab = "measure"`, no `dock`) loaded without an error. Three `[dock-session]` lines, each `ok=1`;
entries in `probe-readings.md`.
- `UDPSCOPE_PROBE_SLIDEOVER_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` (and `UDPSCOPE_ENABLE_ALL_MS=500`) -- the
slide-over panel over the plot's right edge, opened on a throw-away `probe` page, read 0.5 s later. Two
`[slideover]` lines. Line 1 must read `panel=360x<overlay height> ... edge_flush=1 full_height=1
outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`: a 360 px opaque panel flush to the overlay's
right edge and as tall as it, a pick outside it landing in the plot and one inside it in the panel, and the
plot's width unchanged by the panel being open (`plot_w before=` equal to `open=`).
`outside_hits_plot=0` means the panel has an allocation wider than 360 or a transparent parent that
swallows the plot's pan, drop and hover; `plot_unchanged=0` means the panel resized the plot. Line 2 must
read `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`. For a screenshot
add `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which leaves the page open after line 1 (run with `GSK_RENDERER=cairo`
and `import -window root`): the panel must be drawn over the traces, opaque.
- **Slide-over and page probes (`SLIDEOVER`, `BADGES`, `SIGNAL_PAGES`, `SIGNAL_BADGES`, `SIDE_TAB`, `KEYS`) need a config that has already seen
the first-run tour**: a fresh `XDG_CONFIG_HOME` auto-starts the tour at about 1.5 s, and a tour stop with no page closes the
slide-over under the probe. Seed it with `mkdir -p $D/udpscope && printf 'tour_seen = true\n' > $D/udpscope/settings.toml`
(or one short throw-away run, which writes the key when the tour starts) and run with `XDG_CONFIG_HOME=$D`. Delete the
per-source subdirectory (`$D/udpscope/<host>_<port>`) between runs that must start from a first-run layout.
- `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the
per-signal `signal:<name>` slide-over pages. **Against a config that has already seen the tour** (a first run's
auto-tour closes the slide-over at about 1.5 s, which is when this probe opens its page). One `[signalpage]` line;
see [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the
signal badges in the badge bar. **Against a config that has already seen the tour**, as above (the probe clicks
the badges, and the auto-tour would close the page). One `[sigbadges]` line; see
[probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_BADGES_MS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000` and `UDPSCOPE_ENABLE_ALL_MS=500` -- the badge
bar under the plot. One `[badges]` line, which must read `table_bad=0 fixed=1 open_marked=1 page_open=1
closed_unmarked=1`: `table_bad` counts the rows of the 16-input `trigger_badge_state()` table the probe
gets wrong; `fixed=1` is the bar's height being the same (and at least 36 px) before and after the
`trigger` page was opened and closed -- `0` means the bar's height depends on content and the plot above it
moves; `open_marked`/`closed_unmarked` are the `udpscope-badge-open` class following the slide-over's page.
`trigger_state=` is the state word as read then (`off` with the trigger toggle off). **The trigger form is
a slide-over page**, so a probe that reads one of its widgets' *allocation* (a width, a height) must
`slideover_open(*a, "trigger")` and read after the 120 ms reveal, as `exit_probe_step1` and `force_probe` do;
a sensitivity, a visibility or a value needs no open page.
**The source kind's panels are slide-over pages too** (`source-<id>`: the E-1608's `source-ao` and
`source-dio`, Flat UDP's, the probe kind's), so the same rule holds for them. The probe appends a second
`[badges]` line for the Source badge: `[badges] source name=<kind> pages=<n>
visible=<0|1> switch_visible=<0|1> can_target=<0|1> second_page=<id|-> second_ok=<0|1> open_marked=<0|1|-1>
closed_unmarked=1`. `pages` is the number of source pages, `switch_visible` is the segmented switch
being on screen after the first source page was opened (`1` for the E-1608's two pages, `0` for one page
or none), `second_page` is what `slideover_page()` read after the switch selected the second page
(`second_ok=1` when it is that page), `visible=0` with `pages=0` is the UDPS badge being **hidden** (a kind with no source page
has nothing for it to open; `can_target=0` goes with it) and `open_marked=-1` is that no-page case (there is
nothing to open); `visible=1` with `pages>0`.
- `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500`
-- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a
synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms.
Nine `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `flatudp`, `probe`); the
Ten `[source-kinds]` lines (one `form` line per registered kind: `udps`, `tcps`, `e1608`, `flatudp`, `probe`); the
reading's own entry is in `probe-readings.md`.
- `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against
`tools/mock_e1608.py` -- the MCC E-1608 end to end: panels present and sensitive, a DAC write, a
@@ -439,8 +592,27 @@ 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
address=127.0.0.1`. Start `tools/mock_e1608.py` first on its default port 54211 (the form scans that port; do
not pass `--port`) and kill it afterwards. The value is the delay of phase 1; phase 2 follows 2 s after it and
phase 3 (the line) 2 s after that, so phase 3 runs 4 s after phase 1 and the delay must stay below about
2500 ms for the line to print before the exit at 6500 ms. It builds
the connection form, presses **Scan network**, expects the mock to be listed, clicks the result (address
filled, MAC and subnet cleared), presses **Use MAC** (MAC filled, the others cleared), scans again and checks
the results were replaced. `UDPSCOPE_PROBE_E1608_SCAN_MAC` names another board (a real one) instead of the
mock's `00:80:2f:00:16:08`. One `[e1608] scan:` line. It needs a non-loopback up interface with a broadcast
address; when the board did not answer a second line says so, and the run proves nothing about the scan.
- `UDPSCOPE_PROBE_FLATUDP=2500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source flatudp` against
`tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both
need no source; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the
@@ -465,6 +637,22 @@ precedent, not an exception.
- **no sender:** `connected=0` throughout, a reading and not a failure.
- `UDPSCOPE_PROBE_FLATUDP_GAP=1500` -- starts the silence poll described above (2 s after the source's last
datagram it must be disconnected; then it must reconnect and the frame count move on).
- `UDPSCOPE_PROBE_TCPS=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source tcps --source-opt port=44500`
against `tools/mock_tcpstreamer.py --port 44500` -- the TCPStreamer source end to end: the connection, the
five channel names the mock sends (`time,sine,square,ramp,noise`), frames flowing and the capture button
present but disabled. It enables the signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS`. Runs, each
short and serial, with the mock killed afterwards:
- **standalone:** the mock with no `--stream`, `UDPSCOPE_PROBE_TCPS=1500 UDPSCOPE_EXIT_AFTER_MS=4000`;
- **service:** mock `--stream fast`, app `--source-opt stream=fast`, the same environment; the mock logs
`SUBSCRIBE 'fast'`;
- **reconnect:** mock `--close-after 3`, `UDPSCOPE_PROBE_TCPS=1500 UDPSCOPE_PROBE_TCPS_RECONNECT=2000
UDPSCOPE_EXIT_AFTER_MS=8000`; stderr also carries `the clock stalled at ...` warnings after the
reconnect (see `probe-readings.md`);
- **no mock:** `connected=0` throughout, a reading and not a failure, with no retry flood on stderr.
- `UDPSCOPE_PROBE_TCPS_RECONNECT=2000` -- records the frame and reconnect counts, then polls every 100 ms
(up to 6 s) until connected, frames and reconnects have all advanced, and prints. **It must fall before the
mock's `--close-after`** (the baseline is read before the close); the poll, not a fixed delay, absorbs
when the close lands.
- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live
trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to
hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.
@@ -538,7 +726,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).
@@ -546,7 +734,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=…`).
@@ -584,12 +772,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
@@ -600,9 +790,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*:
@@ -631,41 +821,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
@@ -683,7 +882,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**.
@@ -716,21 +915,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.
@@ -955,8 +1154,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,681 @@
# E-1608 Network Scan Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The E-1608 connection form gets a "Scan network" row that finds boards on every local interface and fills the address or the MAC from a click; the MAC lookup in `E1608Source` stops depending on the default route.
**Architecture:** A non-blocking `Scan` class in `e1608_link` opens one UDP socket per up IPv4 broadcast interface (bound to the device, or to its address), sends `D` on each and drains replies on `poll()`. The form drives it with a 50 ms GLib timer on the GTK main thread (the project has exactly two threads, so no worker). `discover_broadcast()` is `Scan` plus a blocking wait and replaces the limited-broadcast `discover()` call in the source's MAC lookup.
**Tech Stack:** C++20, GTK4/libadwaita (`AdwExpanderRow`, `AdwActionRow`), POSIX sockets (`getifaddrs`, `SO_BINDTODEVICE`), `tools/mock_e1608.py`, the in-app headless probe under `xvfb-run`.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md` (approved 2026-11-02).
- Exactly two threads: no worker thread for the scan; the form polls with `g_timeout_add*`.
- Every source file carries the SPDX MIT header (no new source files in this plan).
- Text buttons only (no theme icons) on the new controls.
- A `subnet` that is given still uses `sweep_targets` + `discover` unchanged.
- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty result.
- Scan results are never persisted: the session reads and writes only the filled fields.
- Baselines: a clean build has exactly 3 warnings (an incremental build shows the 2 `ui_probe.cpp` MathNode ones); `gtk warnings: 0`; no `Gtk-CRITICAL`.
- GTK traps (`docs/claude/invariants-gtk-and-chrome.md`): handler signatures are notify `(GObject*, GParamSpec*, gpointer)`, clicked `(GtkButton*, gpointer)`, changed `(GtkEditable*, gpointer)`; handlers behind `Form::alive`; probes use `g_object_ref_sink` + `g_object_unref` on never-parented rows.
- Probe code must avoid `x({...}).empty() || y({...}).empty()` (GCC `-Wdangling-pointer`).
- Verification runs are short and serial; kill every mock afterwards. Do not push or merge.
## File Structure
- Modify `src/source/e1608/e1608_link.hpp` / `.cpp`: `class Scan`, `discover_broadcast()`, a shared reply parser used by `discover()` too.
- Modify `src/source/e1608/e1608_source.cpp`: the MAC lookup with an empty subnet uses `discover_broadcast()`.
- Modify `src/source/e1608/e1608_kind.cpp`: the scan row, result rows, fills, timer.
- Modify `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp`: the `UDPSCOPE_PROBE_E1608_SCAN` probe.
- Modify docs: `README.md`, `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`.
One design note the spec leaves open: `AdwExpanderRow` can only append rows, so "results right after the scan row" is done by making the scan row itself an `AdwExpanderRow` ("Scan network", a text "Scan" suffix button) at the top of the Connection expander, with the results as its child rows. It reads the same: results sit directly under the scan row.
---
### Task 1: The `Scan` class, `discover_broadcast()`, and the MAC lookup
**Files:**
- Modify: `src/source/e1608/e1608_link.hpp` (after `discover`, line ~77)
- Modify: `src/source/e1608/e1608_link.cpp` (includes; anonymous namespace; `discover`; new definitions)
- Modify: `src/source/e1608/e1608_source.cpp:270-293`
**Interfaces:**
- Produces (used by Task 2):
```cpp
class Scan {
public:
explicit Scan(int port);
Scan(const Scan&) = delete;
Scan& operator=(const Scan&) = delete;
const std::vector<DeviceInfo>& poll(); // drains non-blocking; cumulative, one entry per MAC
std::size_t interfaces() const noexcept; // sockets opened
};
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
```
- Consumes: existing `Fd`, `DeviceInfo`, `kProductId`, `Clock`, `ms_left`.
- [ ] **Step 1: Declare the API**
In `e1608_link.hpp`, after the `discover` declaration add:
```cpp
/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default
/// route only, so on a host with several interfaces it can miss a board on another one; this opens
/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or
/// to the interface address when binding to the device is refused), sends `D` to
/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the
/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan.
class Scan {
public:
explicit Scan(int port);
Scan(const Scan&) = delete;
Scan& operator=(const Scan&) = delete;
/// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC.
const std::vector<DeviceInfo>& poll();
/// How many interfaces a probe was sent on.
std::size_t interfaces() const noexcept { return socks_.size(); }
private:
std::vector<Fd> socks_;
std::vector<DeviceInfo> found_;
};
/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses.
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
```
- [ ] **Step 2: Share the reply parser**
In `e1608_link.cpp` add includes `#include <ifaddrs.h>`, `#include <net/if.h>`, `#include <thread>` (keep the list alphabetical with the existing ones). Inside the existing anonymous namespace, just before its closing `} // namespace` (after `tcp_connect`), add:
```cpp
/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board
/// that `found` did not already hold (one entry per MAC).
bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from,
std::vector<DeviceInfo>& found) {
if (n < 29 || buf[0] != 'D') return false;
DeviceInfo info;
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
if (info.product_id != kProductId) return false;
std::copy(buf + 1, buf + 7, info.mac.begin());
for (const DeviceInfo& d : found) {
if (d.mac == info.mac) return false;
}
char ip[INET_ADDRSTRLEN] = {};
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
info.ip = ip;
found.push_back(info);
return true;
}
```
In `discover()`, replace the block from `if (n < 29 || buf[0] != 'D') continue;` through `found.push_back(info);` (the lines that build `info`, test the MAC for a duplicate and push) with the single call:
```cpp
take_reply(buf, n, from, found);
```
- [ ] **Step 3: Implement `Scan` and `discover_broadcast`**
After `discover()` (before the `// --- Link` banner) add:
```cpp
Scan::Scan(int port) {
ifaddrs* list = nullptr;
if (::getifaddrs(&list) != 0) return;
sockaddr_in to{};
to.sin_family = AF_INET;
to.sin_port = htons(static_cast<std::uint16_t>(port));
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
const std::uint8_t probe = 'D';
std::vector<std::string> done; // an interface with several addresses needs one probe, not several
for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) {
if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue;
const unsigned fl = a->ifa_flags;
if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue;
const std::string name = a->ifa_name;
if (std::find(done.begin(), done.end(), name) != done.end()) continue;
Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
if (!s.valid()) continue;
const int one = 1;
::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(),
static_cast<socklen_t>(name.size())) != 0) {
sockaddr_in me = *reinterpret_cast<const sockaddr_in*>(a->ifa_addr);
me.sin_port = 0;
if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&me), sizeof me) != 0) continue;
}
if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&to),
sizeof to) != 1)
continue;
done.push_back(name);
socks_.push_back(std::move(s));
}
::freeifaddrs(list);
}
const std::vector<DeviceInfo>& Scan::poll() {
for (const Fd& s : socks_) {
for (;;) {
std::uint8_t buf[128];
sockaddr_in from{};
socklen_t flen = sizeof from;
const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT,
reinterpret_cast<sockaddr*>(&from), &flen);
if (n < 0) break; // nothing more on this socket for now
take_reply(buf, n, from, found_);
}
}
return found_;
}
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms) {
Scan scan(port);
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
while (ms_left(deadline) > 0) {
scan.poll();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return scan.poll();
}
```
- [ ] **Step 4: Use it in the source's lookup**
In `e1608_source.cpp` `connect_and_start()`, replace
```cpp
const std::vector<std::string> targets =
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
const std::vector<DeviceInfo> found =
discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500));
```
with
```cpp
const std::vector<std::string> targets =
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
const int wait_ms = std::min(cfg_.timeout_ms, 1500);
// No address and no subnet: the limited broadcast, sent on every interface rather than only
// the default route's.
const bool broadcast =
host.empty() && targets.size() == 1 && targets[0] == "255.255.255.255";
const std::vector<DeviceInfo> found =
broadcast ? discover_broadcast(cfg_.port, wait_ms) : discover(targets, cfg_.port, wait_ms);
```
- [ ] **Step 5: Build**
Run: `cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done`
Expected: no `error:`; only the 2 baseline `ui_probe.cpp` MathNode warnings (incremental) -- nothing new from `e1608_link.cpp` or `e1608_source.cpp`.
- [ ] **Step 6: Verify the MAC lookup end to end against the mock**
The mock answers a broadcast `D` on UDP 54211. A run with only a `mac` option (no address, no subnet) must find it and stream:
```sh
python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 &
MOCK=$!
UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a ./build/udpscope --source e1608 \
--source-opt mac=00:80:2f:00:16:08 --source-opt scan_rate=1000 2>&1 | grep -E "^\[e1608\] (stream|state)"
kill $MOCK
```
Expected: `[e1608] stream: connected=1 ... ok=1` and `state: connects=1 message=''`.
If it reads `connected=0` with `no E-1608 with MAC ... answered`: the sandbox did not loop the broadcast back to the mock. Check with `ip -4 -o addr` that an up non-loopback interface with a broadcast address exists. If one does and it still fails, report that to the user as a finding; do not paper over it.
- [ ] **Step 7: Commit**
```bash
git add src/source/e1608/e1608_link.hpp src/source/e1608/e1608_link.cpp src/source/e1608/e1608_source.cpp
git commit -m "e1608: discover boards on every interface, not only the default route's"
```
---
### Task 2: The form's "Scan network" row, results and fills
**Files:**
- Modify: `src/source/e1608/e1608_kind.cpp`
**Interfaces:**
- Consumes: `Scan`, `DeviceInfo`, `format_mac` (Task 1 / existing, via `e1608_source.hpp`'s includes of `e1608_link.hpp`; add the include if the build says otherwise).
- Produces (used by Task 3's probe), by widget name: the scan row `AdwExpanderRow` named `e1608-scan-row`; its suffix `GtkButton` named `e1608-scan`; each result an `AdwActionRow` named `e1608-result` (title IP, subtitle MAC, `activated` fills the address) holding a suffix button named `e1608-use-mac`. Subtitle texts: `Scanning...`, `1 board found`, `N boards found`, `No board found`.
- [ ] **Step 1: Add the state and `fill()` to `Form`**
In `struct Form`, after `bool alive = true;...` add:
```cpp
GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows
std::vector<GtkWidget*> results; ///< the result rows of the current scan
std::unique_ptr<Scan> scan; ///< non-null while a scan is running
guint timer = 0; ///< the GLib source polling `scan`, 0 when none
int scan_ms = 0; ///< time spent in the running scan
```
and, after `write(...)`:
```cpp
/// A result click: puts one way of naming the board in and the other two fields out, under the
/// `updating` guard so the dialog is told once.
void fill(const std::string& addr, const std::string& mac_text) {
if (!alive) return;
updating = true;
gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str());
gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str());
gtk_editable_set_text(GTK_EDITABLE(subnet), "");
updating = false;
if (changed && *changed) (*changed)();
}
void stop_scan() {
if (timer != 0) g_source_remove(timer);
timer = 0;
scan.reset();
}
```
Add `#include <vector>` to the includes if absent.
- [ ] **Step 2: Destroy hook stops the timer**
Replace `on_form_destroy` with:
```cpp
void on_form_destroy(GtkWidget*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
f.alive = false;
f.stop_scan(); // the poll must not outlive the rows it writes to
}
```
- [ ] **Step 3: The scan functions**
After `hook_entry(...)` and before `class E1608Kind`, add:
```cpp
constexpr int kScanMs = 1500; ///< how long a scan listens
constexpr int kTickMs = 50; ///< how often it drains the sockets
/// A result row's two ways to name the board, with the form they fill.
struct Pick {
FormPtr form;
std::string ip, mac;
};
void free_pick(gpointer d, GClosure*) { delete static_cast<Pick*>(d); }
void on_pick_address(AdwActionRow*, gpointer user) {
Pick& p = *static_cast<Pick*>(user);
p.form->fill(p.ip, "");
}
void on_pick_mac(GtkButton*, gpointer user) {
Pick& p = *static_cast<Pick*>(user);
p.form->fill("", p.mac);
}
void add_result(const FormPtr& f, const DeviceInfo& d) {
const std::string mac = format_mac(d.mac);
GtkWidget* row = adw_action_row_new();
gtk_widget_set_name(row, "e1608-result");
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str());
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str());
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE);
GtkWidget* use_mac = gtk_button_new_with_label("Use MAC");
gtk_widget_set_name(use_mac, "e1608-use-mac");
gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER);
adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac);
g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address),
new Pick{f, d.ip, mac}, free_pick, static_cast<GConnectFlags>(0));
g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac},
free_pick, static_cast<GConnectFlags>(0));
adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row);
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE);
f->results.push_back(row);
}
void set_scan_status(Form& f, const std::string& text) {
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str());
}
/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is
/// settled and the scan (its sockets) is released.
gboolean on_scan_tick(gpointer user) {
const FormPtr& fp = *static_cast<FormPtr*>(user);
Form& f = *fp;
if (!f.alive || !f.scan) {
f.timer = 0;
return G_SOURCE_REMOVE;
}
const std::vector<DeviceInfo> found = f.scan->poll();
while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]);
f.scan_ms += kTickMs;
if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE;
if (found.empty())
set_scan_status(f, "No board found");
else if (found.size() == 1)
set_scan_status(f, "1 board found");
else
set_scan_status(f, std::to_string(found.size()) + " boards found");
f.scan.reset();
f.timer = 0;
return G_SOURCE_REMOVE;
}
void free_form_ptr(gpointer d) { delete static_cast<FormPtr*>(d); }
/// A new scan on the Port field's value: the previous results go, a running scan is replaced.
void start_scan(const FormPtr& fp) {
Form& f = *fp;
if (!f.alive) return;
f.stop_scan();
for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r);
f.results.clear();
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE);
f.scan = std::make_unique<Scan>(static_cast<int>(value_of(f.port)));
f.scan_ms = 0;
set_scan_status(f, "Scanning...");
f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp),
free_form_ptr);
}
void on_scan_clicked(GtkButton*, gpointer user) {
start_scan(*static_cast<FormPtr*>(user));
}
```
(`f.stop_scan()` removes the timer; `g_source_remove` fires `free_form_ptr`, which only drops one `FormPtr` copy, never the last. `on_scan_tick` never calls `g_source_remove` on itself: it zeroes `timer` and returns `G_SOURCE_REMOVE`.)
- [ ] **Step 4: Build the row in `make_config_form()`**
After the `f->ao_init[1] = ...` line, add:
```cpp
f->scan_row = adw_expander_row_new();
gtk_widget_set_name(f->scan_row, "e1608-scan-row");
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network");
GtkWidget* scan_button = gtk_button_new_with_label("Scan");
gtk_widget_set_name(scan_button, "e1608-scan");
gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER);
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button);
g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f),
free_form, static_cast<GConnectFlags>(0));
```
Change the Connection group to start with the scan row:
```cpp
GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
f->code, f->timeout});
```
Update the description string to:
```cpp
out.description =
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), "
"or scan the network for boards";
```
- [ ] **Step 5: Build, then check the existing form round trip and warnings**
```sh
cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done
UDPSCOPE_PROBE_SOURCE_KINDS=1000 UDPSCOPE_EXIT_AFTER_MS=2500 xvfb-run -a ./build/udpscope \
--source probe --source-opt rate=500 2>&1 | grep -E "form e1608|gtk warnings|Gtk-CRITICAL"
```
Expected: no `error:`, only the 2 baseline warnings; `[source-kinds] form e1608: round_trip=1 valid=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`.
- [ ] **Step 6: Commit**
```bash
git add src/source/e1608/e1608_kind.cpp
git commit -m "e1608: a Scan network row in the connection form, with click-to-fill results"
```
---
### Task 3: The scan probe, docs, and final verification
**Files:**
- Modify: `src/ui_probe.cpp` (after `e1608_probe_reconnect`, ~line 2459)
- Modify: `src/ui_app.hpp:1874-1875`
- Modify: `src/ui.cpp:6031-6033`
- Modify: `README.md` (E-1608 paragraph, lines ~118-146), `docs/claude/build-and-run.md:118-130`, `docs/claude/verification.md` (after the `UDPSCOPE_PROBE_E1608_RECONNECT` bullet, ~line 443), `docs/claude/probe-readings.md` (`## [e1608]` section, ~line 1848-1890), `docs/claude/architecture.md:45-48`
**Interfaces:**
- Consumes: the widget names from Task 2; `find_named()`, `find_kind()` in `ui_probe.cpp`.
- Produces: env var `UDPSCOPE_PROBE_E1608_SCAN=<ms>` and optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` (default `00:80:2f:00:16:08`, the mock's); one stderr line `[e1608] scan: ...`.
- [ ] **Step 1: Declare and register the probe**
In `ui_app.hpp`, after `gboolean e1608_probe_resumed(gpointer user);` add:
```cpp
/// The E-1608 form's "Scan network" row: presses Scan against the mock, then (phases `_fill`,
/// `_rescan`) clicks a result to fill the address, "Use MAC" to fill the MAC, scans again and checks
/// the results were replaced, not added to.
gboolean e1608_scan_probe(gpointer user);
gboolean e1608_scan_probe_fill(gpointer user);
gboolean e1608_scan_probe_rescan(gpointer user);
```
In `ui.cpp`, after the `UDPSCOPE_PROBE_E1608_RECONNECT` block add:
```cpp
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_scan_probe, &app);
}
```
- [ ] **Step 2: Write the probe**
In `ui_probe.cpp`, after `e1608_probe_reconnect`, add (this is outside the file's anonymous namespace, like its neighbours; the helpers it uses, `find_named` and `find_kind`, are visible):
```cpp
namespace {
/// The scan probe's state, alive from phase 1 to phase 3.
struct E1608ScanProbe {
ConfigForm form;
int changes = 0; ///< how many times the form told the dialog it changed
std::string want_mac; ///< the board the probe expects (the mock's by default)
std::string status; ///< the scan row's subtitle, as read at the end of the first scan
std::string ip; ///< the expected board's address, as listed
std::size_t first = 0; ///< result rows after the first scan
bool listed = false; ///< the expected board was among them
bool fill_addr = false;
bool fill_mac = false;
};
E1608ScanProbe* g_scan_probe = nullptr;
void collect_named(GtkWidget* root, const char* name, std::vector<GtkWidget*>& out) {
if (root == nullptr) return;
if (std::strcmp(gtk_widget_get_name(root), name) == 0) out.push_back(root);
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
c = gtk_widget_get_next_sibling(c))
collect_named(c, name, out);
}
std::string scan_status(E1608ScanProbe& p) {
GtkWidget* row = find_named(p.form.rows[0], "e1608-scan-row");
const char* t = row != nullptr ? adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(row)) : nullptr;
return t != nullptr ? t : "";
}
/// A form holding stale values in all three naming fields, so a fill is seen to clear the others.
void scan_probe_stale(E1608ScanProbe& p) {
SourceConfig c;
c.set("address", "1.2.3.4");
c.set("mac", "aa:bb:cc:dd:ee:ff");
c.set("subnet", "10.0.0");
p.form.write(c);
p.changes = 0;
}
void scan_probe_click(E1608ScanProbe& p) {
GtkWidget* button = find_named(p.form.rows[0], "e1608-scan");
if (button != nullptr) g_signal_emit_by_name(button, "clicked");
}
void scan_probe_cleanup() {
if (g_scan_probe == nullptr) return;
for (GtkWidget* row : g_scan_probe->form.rows) {
g_object_ref_sink(row);
g_object_unref(row);
}
delete g_scan_probe;
g_scan_probe = nullptr;
}
} // namespace
/// Phase 3, 2 s after the second Scan: the results were replaced (same count), and the one line.
gboolean e1608_scan_probe_rescan(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
E1608ScanProbe& p = *g_scan_probe;
std::vector<GtkWidget*> rows;
collect_named(p.form.rows[0], "e1608-result", rows);
const bool replaced = rows.size() == p.first;
const std::string status = scan_status(p);
const bool status_ok = status.find("found") != std::string::npos && status.rfind("No", 0) != 0;
const bool ok = p.listed && p.fill_addr && p.fill_mac && replaced && status_ok;
std::fprintf(stderr,
"[e1608] scan: found=%zu ip=%s mac=%s status='%s' fill_addr=%d fill_mac=%d "
"rescan_replaced=%d ok=%d\n",
p.first, p.ip.c_str(), p.want_mac.c_str(), p.status.c_str(), p.fill_addr ? 1 : 0,
p.fill_mac ? 1 : 0, replaced ? 1 : 0, ok ? 1 : 0);
if (!p.listed)
std::fprintf(stderr,
"[e1608] scan: %s did not answer; no up broadcast interface reaches it, or the "
"broadcast was refused (this run proves nothing about the scan)\n",
p.want_mac.c_str());
std::fflush(stderr);
scan_probe_cleanup();
return G_SOURCE_REMOVE;
}
/// Phase 2, 2 s after the first Scan: the expected board is listed, a result click fills the address
/// only, "Use MAC" fills the MAC only (each telling the dialog exactly once), then Scan again.
gboolean e1608_scan_probe_fill(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
E1608ScanProbe& p = *g_scan_probe;
p.status = scan_status(p);
std::vector<GtkWidget*> rows;
collect_named(p.form.rows[0], "e1608-result", rows);
p.first = rows.size();
GtkWidget* mine = nullptr;
for (GtkWidget* r : rows) {
const char* sub = adw_action_row_get_subtitle(ADW_ACTION_ROW(r));
if (sub != nullptr && p.want_mac == sub) mine = r;
}
p.listed = mine != nullptr;
if (mine != nullptr) {
const char* title = adw_preferences_row_get_title(ADW_PREFERENCES_ROW(mine));
p.ip = title != nullptr ? title : "";
scan_probe_stale(p);
g_signal_emit_by_name(mine, "activated");
const SourceConfig by_addr = p.form.read();
p.fill_addr = by_addr.get("address") == p.ip && by_addr.get("mac").empty() &&
by_addr.get("subnet").empty() && p.changes == 1;
scan_probe_stale(p);
GtkWidget* use = find_named(mine, "e1608-use-mac");
if (use != nullptr) g_signal_emit_by_name(use, "clicked");
const SourceConfig by_mac = p.form.read();
p.fill_mac = by_mac.get("mac") == p.want_mac && by_mac.get("address").empty() &&
by_mac.get("subnet").empty() && p.changes == 1;
}
scan_probe_click(p);
g_timeout_add(2000, e1608_scan_probe_rescan, a);
return G_SOURCE_REMOVE;
}
/// Phase 1: the form, Scan pressed (the row must read `Scanning...` at once), phase 2 in 2 s.
gboolean e1608_scan_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
SourceKind* k = find_kind("e1608");
if (k == nullptr) {
std::fprintf(stderr, "[e1608] scan: no e1608 kind\n");
return G_SOURCE_REMOVE;
}
g_scan_probe = new E1608ScanProbe;
E1608ScanProbe& p = *g_scan_probe;
p.form = k->make_config_form();
const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN_MAC");
p.want_mac = env != nullptr ? env : "00:80:2f:00:16:08";
p.form.on_change([]() {
if (g_scan_probe != nullptr) ++g_scan_probe->changes;
});
scan_probe_click(p);
const std::string now = scan_status(p);
if (now != "Scanning...")
std::fprintf(stderr, "[e1608] scan: status right after Scan reads '%s', expected 'Scanning...'\n",
now.c_str());
g_timeout_add(2000, e1608_scan_probe_fill, a);
return G_SOURCE_REMOVE;
}
```
Notes for the implementer: `SourceConfig::get` returns `std::string` (the existing probe calls `f.read().get("signals") == "..."`); `find_kind`, `SourceConfig` and `ConfigForm` are already used in this file. Do not write `x({...}).empty() || y({...}).empty()`.
- [ ] **Step 3: Build and run against the mock**
```sh
cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done
python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 &
MOCK=$!
UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_EXIT_AFTER_MS=6500 xvfb-run -a ./build/udpscope \
--source e1608 --source-opt address=127.0.0.1 --source-opt scan_rate=1000 2>&1 \
| grep -E "^\[e1608\] scan|gtk warnings|Gtk-CRITICAL"
kill $MOCK
```
Expected: `[e1608] scan: found=1 ip=<a local address> mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`. `found=2` or more is fine if a real board is on the network (status then reads `N boards found`; the line still needs `ok=1`).
If `ok=0` and the second line says the board did not answer, check Task 1 Step 6's loopback note, and tell the user honestly; do not mark this step passed.
If a flag other than `listed` reads 0, debug that flag (the fill reads the form back; `changes` must be exactly 1 per fill).
- [ ] **Step 4: Clean-build warning count**
```sh
rm -rf /tmp/scan-clean && cmake -S . -B /tmp/scan-clean >/dev/null && cmake --build /tmp/scan-clean -j8 2>&1 | grep -c "warning:"; rm -rf /tmp/scan-clean
```
Expected: `3`.
- [ ] **Step 5: Docs**
- `README.md`, E-1608 paragraph (line ~119): after "...(optionally with the subnet to sweep)," add the scan: the sentence "A **Scan network** row at the top of the form probes every network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not saved: only the filled field is." Also add one sentence that a MAC with no subnet is now looked up on every interface, not only the default route's, because a single limited broadcast leaves through the default route alone and missed a board on another interface (found on a real board 2026-11-02, where the board was on `net0` and the default route was `wlan0`).
- `docs/claude/build-and-run.md` line ~125: change "a MAC with no `address` is looked up by broadcast" to "...looked up by a broadcast sent on every interface (`Scan`), or by a sweep of `subnet`".
- `docs/claude/verification.md`: after the `UDPSCOPE_PROBE_E1608_RECONNECT=8000` bullet add a bullet for `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt address=127.0.0.1` against the mock on its default port 54211 (the form scans that port), optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` for a real board; one `[e1608] scan:` line; note it needs a non-loopback up interface with a broadcast address, and that the line says so when the board did not answer.
- `docs/claude/probe-readings.md`, `## [e1608]`: add the sample line `[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1` to the block, and one bullet per field: `found` (result rows after the first scan; 0 with the "did not answer" second line is an environment without a reachable broadcast interface, not a pass), `status` (the row subtitle; "No board found" with a mock running is the probe sockets not reaching it), `fill_addr=0` (a result click did not set only the address, or told the dialog other than once), `fill_mac=0` (same for Use MAC), `rescan_replaced=0` (the results of the first scan were kept or doubled: `start_scan` did not remove them).
- `docs/claude/architecture.md` line ~45: in the E-1608 three-layers bullet, add that discovery is `Scan` (one socket per interface, polled by the caller; the form polls it from a GLib timer on the main thread, the source's lookup through `discover_broadcast`).
- [ ] **Step 6: Commit**
```bash
git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp README.md docs/claude
git commit -m "e1608: probe and docs for the network scan"
```
- [ ] **Step 7: Manual run on the real board (only if it is attached)**
Check `ip -4 -o addr` for the board's interface and `ping`-free: simply run the probe without the mock, naming the board's MAC (the user knows it; the MAC printed by the earlier hardware run was on `net0`, address 169.254.88.75):
```sh
UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_PROBE_E1608_SCAN_MAC=<board mac> UDPSCOPE_EXIT_AFTER_MS=6500 \
xvfb-run -a ./build/udpscope --source e1608 --source-opt address=127.0.0.1 2>&1 | grep -E "^\[e1608\] scan"
```
If it finds the board (`listed`, `ok=1`), add one sentence with the date to the README's E-1608 paragraph ("the network scan was checked against a real board on <date>") and commit `docs: record the scan check on a real board`. If the board is not attached or the MAC is unknown, say so; claim nothing.
@@ -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,592 @@
# TCPStreamer Source Kind 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:** Add a `tcps` source kind that receives a MARTe2 `TCPStreamer` DataSource, standalone or through a `TCPStreamService`.
**Architecture:** `deps/udpsclient` gets an additive TCP transport mode (connect, optional SUBSCRIBE, framed reads reusing `read_tcp_frame`). `UdpsSource` takes a transport argument so the TCP kind reuses its callbacks. A new `tcps_kind.cpp` supplies the form and registration. The headless self-check gets a `[tcps]` probe against a new Python mock server.
**Tech Stack:** C99 (`deps/udpsclient`), C++17 + GTK4/libadwaita (`src/`), Python 3 + numpy (mock), CMake.
Spec: `docs/superpowers/specs/2026-11-06-tcp-streamer-source-design.md`.
## Global Constraints
- There is no unit test suite; verification is the app's own headless self-check under `xvfb-run` against a mock. Read `docs/claude/verification.md` (baselines) before Task 3 and run probes **short and serial**, killing the mock afterwards.
- Baselines for every run: exactly **one** build warning (the pre-existing one; record the count before starting), and no `Gtk-CRITICAL`.
- Every new source file starts with the two-line SPDX header: `// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari` (`#` comments in Python, after the shebang).
- The GTK thread reaches a source only through `DataSource::post()`; `udps_*` names stay in `src/source/udps_*.cpp` and the new `tcps` files may include `udps_source.hpp` only.
- One connection carries one stream; multiplexed `SUBSCRIBE` lists are out of scope.
- Session naming for a non-UDPS kind is automatic (`session_host` -> `tcps-<identity>`, port 1, `source_kind=tcps`); do not touch `registry.cpp`'s session functions.
- A kind id is `[a-z0-9]+`: `tcps`. Display name: `TCPStreamer`. Default port 44500.
- Do not commit unless the task step says so.
## File Structure
| File | Change | Responsibility |
| --- | --- | --- |
| `deps/udpsclient/udps_client.h` | modify | `use_tcp`, `stream_name` config fields |
| `deps/udpsclient/udps_client.c` | modify | `connect_tcp()`, dispatch, no DISCONNECT frame in TCP mode, reorder window 0 |
| `tools/mock_tcpstreamer.py` | create | Mock TCPStreamer / TCPStreamService server, reuses `mock_udpstreamer.py` frame builders |
| `src/source/udps_source.hpp/.cpp` | modify | `Transport` enum, constructor argument, accessor |
| `src/source/tcps_kind.cpp` | create | `tcps` `SourceKind` and its form |
| `src/source/registry.hpp/.cpp` | modify | `make_tcps_kind()`, registration |
| `CMakeLists.txt` | modify | add `tcps_kind.cpp` |
| `src/ui_probe.cpp`, `src/ui.cpp` | modify | `[source-kinds]` sample, `[tcps]` probes and their env scheduling |
| `README.md`, `docs/claude/*.md` | modify | document the kind, the upstream change, the probes |
---
### Task 1: TCP transport in `deps/udpsclient`, and the mock server
**Files:**
- Modify: `deps/udpsclient/udps_client.h` (struct `udps_client_config_t`, around line 263-282)
- Modify: `deps/udpsclient/udps_client.c` (struct `udps_client` ~line 166, `send_control` ~1021, `do_connect` ~1076, `do_disconnect` ~1093, `udps_client_create` ~1260)
- Create: `tools/mock_tcpstreamer.py`
**Interfaces:**
- Produces: `udps_client_config_t::use_tcp` (`int`, 0 = UDP as before) and `::stream_name` (`const char *`, NULL or empty = no SUBSCRIBE). Both default to zero/NULL through `udps_client_config_init()` (it memsets). `server_port` is the TCP port in TCP mode.
- Produces: `tools/mock_tcpstreamer.py --port P [--stream NAME] [--rate R] [--batch B] [--close-after S] [--drop-every N]`.
- [ ] **Step 1: Add the config fields** at the end of `udps_client_config_t` in `udps_client.h`, after `reorder_window_s`:
```c
/**
* Non-zero selects the TCP transport (a MARTe2 TCPStreamer / TCPStreamService):
* @c server_port is the TCP port, no CONNECT or DISCONNECT frame is sent, the
* server streams CONFIG then DATA on connect, and the reorder window is forced
* to 0 (TCP is ordered, so a counter gap is a server-side drop). @c multicast_group,
* @c interface_addr and @c data_port are ignored.
*/
int use_tcp;
/**
* TCP mode only: when non-empty (1..255 bytes) a SUBSCRIBE frame naming this stream is
* sent right after every connect, as a TCPStreamService requires. A plain
* TCPStreamer ignores it. NULL or "" sends nothing.
*/
const char *stream_name;
```
- [ ] **Step 2: Own a copy of the name.** In `struct udps_client`, after `char iface_addr[128];` add `char stream_name[256];`. In `udps_client_create`, next to the other `copy_str` calls add `copy_str(c->stream_name, sizeof c->stream_name, cfg->stream_name);` and after the `c->cfg.interface_addr = ...` line add:
```c
c->cfg.stream_name = c->stream_name;
if (c->cfg.use_tcp) {
c->cfg.multicast_group = NULL;
c->mcast_group[0] = '\0';
c->cfg.reorder_window_s = 0.0;
}
```
Also reject a name over 255 bytes: before `copy_str`, `if (cfg->use_tcp && cfg->stream_name != NULL && strlen(cfg->stream_name) > 255u) { free(c); return NULL; }` (place right after the `calloc` succeeds).
- [ ] **Step 3: Add `connect_tcp()`** after `connect_multicast` (before `do_connect`):
```c
/** TCP transport: connect, then SUBSCRIBE when a stream name is set. */
static int connect_tcp(udps_client_t *c) {
struct timeval tv;
size_t name_len = strlen(c->stream_name);
c->tcp_fd = socket(AF_INET, SOCK_STREAM, 0);
if (c->tcp_fd < 0) {
return fail(c, "TCP socket() failed: %s", strerror(errno));
}
set_recv_buffer(c->tcp_fd, c->cfg.recv_buffer_bytes);
/* Linux bounds a blocking connect() with SO_SNDTIMEO, so an unreachable host
* cannot hold the polling thread for minutes. */
tv.tv_sec = 3;
tv.tv_usec = 0;
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, (socklen_t)sizeof tv);
if (connect(c->tcp_fd, (struct sockaddr *)&c->server_sa,
(socklen_t)sizeof c->server_sa) != 0) {
int e = errno;
close_sockets(c);
return fail(c, "TCP connect to %s:%u failed: %s", c->server_addr,
(unsigned)c->cfg.server_port, strerror(e));
}
/* A half-received frame must not wedge the poll loop (read_exact_tcp blocks). */
tv.tv_sec = 1;
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, (socklen_t)sizeof tv);
if (name_len > 0u) {
uint8_t pkt[UDPS_HEADER_SIZE + 255u];
build_header(pkt, UDPS_PKT_SUBSCRIBE, 0u, 0u, 1u, (uint32_t)name_len);
memcpy(pkt + UDPS_HEADER_SIZE, c->stream_name, name_len);
if (send(c->tcp_fd, pkt, UDPS_HEADER_SIZE + name_len, MSG_NOSIGNAL) !=
(ssize_t)(UDPS_HEADER_SIZE + name_len)) {
int e = errno;
close_sockets(c);
return fail(c, "SUBSCRIBE failed: %s", strerror(e));
}
}
return 0;
}
```
Add `UDPS_PKT_SUBSCRIBE = 5 /**< Client -> server (TCP stream service): payload = stream name. */` to the packet-type enum in `udps_client.h` (add a comma after the `UDPS_PKT_DISCONNECT = 4` line).
- [ ] **Step 4: Dispatch and disconnect.** Replace the first lines of `do_connect`:
```c
int rc = c->cfg.use_tcp ? connect_tcp(c)
: (c->mcast_group[0] != '\0') ? connect_multicast(c)
: connect_unicast(c);
```
In `do_disconnect`, replace `(void)send_control(c, UDPS_PKT_DISCONNECT);` with `if (!c->cfg.use_tcp) { (void)send_control(c, UDPS_PKT_DISCONNECT); }` (a write to a peer that closed would raise SIGPIPE and kill the application; the server detects the close itself).
- [ ] **Step 5: Confirm the other TCP-path assumptions by reading, not guessing.** Read `deliver_payload` (~line 848) and confirm that with `reorder_window_s == 0` a counter jump delivers the frame with `lost > 0` and `stats.counter_gaps` incremented, and that `read_tcp_frame` -> `handle_packet` needs no change. If either is false, stop and report; do not patch around it.
- [ ] **Step 6: Write the mock** `tools/mock_tcpstreamer.py` (`chmod +x`):
```python
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
"""A minimal MARTe2 TCPStreamer / TCPStreamService producer, for exercising udpscope.
Listens on `--port`, serves one client at a time with the same frames as
mock_udpstreamer.py (CONFIG, then Accumulate DATA packets of sine/square/ramp/noise), each
framed UDPS-over-TCP. Without `--stream` it behaves like a standalone TCPStreamer: the
stream starts at once and anything the client sends is ignored. With `--stream NAME` it
behaves like a TCPStreamService: the client must open with a SUBSCRIBE frame naming NAME
within 5 s, otherwise the connection is closed.
./tools/mock_tcpstreamer.py --port 44500 --stream fast --close-after 6
"""
import argparse
import os
import select
import socket
import struct
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mock_udpstreamer as m # noqa: E402
PKT_SUBSCRIBE = 5
HEADER = 17
def read_exact(conn, n, deadline):
buf = b""
while len(buf) < n:
left = deadline - time.monotonic()
if left <= 0:
return None
r, _, _ = select.select([conn], [], [], left)
if not r:
return None
chunk = conn.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def subscribed_to(conn, want):
"""True once a SUBSCRIBE frame naming `want` arrived within 5 s."""
deadline = time.monotonic() + 5.0
head = read_exact(conn, HEADER, deadline)
if head is None:
return False
magic, ptype, _, _, _, plen = struct.unpack("<IBIHHI", head)
if magic != m.MAGIC or ptype != PKT_SUBSCRIBE or not 0 < plen <= 4096:
return False
name = read_exact(conn, plen, deadline)
return name is not None and name.decode(errors="replace") == want
def serve(conn, args):
dt = 1.0 / args.rate
period = args.batch / args.rate
conn.sendall(m.build_config(args.rate, m.TIME_FULL_ARRAY, "V"))
counter, t_stream = 1, 0.0
started = time.monotonic()
next_send = started
while True:
now = time.monotonic()
if args.close_after > 0 and now - started >= args.close_after:
print("mock tcpstreamer: closing the connection (--close-after)", flush=True)
return
if now < next_send:
time.sleep(min(next_send - now, 0.002))
continue
pkt = m.build_data(counter, t_stream, dt, args.batch, args.freq)
if not (args.drop_every > 0 and counter % args.drop_every == 0):
conn.sendall(pkt)
counter += 1
t_stream += args.batch * dt
next_send += period
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=44500)
ap.add_argument("--stream", default="", help="TCPStreamService mode: require SUBSCRIBE of this name")
ap.add_argument("--rate", type=float, default=10000.0, help="samples per second")
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
ap.add_argument("--close-after", type=float, default=0.0, metavar="S",
help="close each connection after S seconds (the client must reconnect); 0 = never")
ap.add_argument("--drop-every", type=int, default=0, metavar="N",
help="skip sending every Nth packet but advance the counter (a server-side drop); 0 = never")
args = ap.parse_args()
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", args.port))
srv.listen(4)
print("mock tcpstreamer: listening on :%d (%s)" % (
args.port, "service stream '%s'" % args.stream if args.stream else "standalone"), flush=True)
while True:
conn, addr = srv.accept()
print("mock tcpstreamer: client %s:%d" % addr, flush=True)
try:
if args.stream and not subscribed_to(conn, args.stream):
print("mock tcpstreamer: no valid SUBSCRIBE for '%s'; closing" % args.stream, flush=True)
continue
if args.stream:
print("mock tcpstreamer: SUBSCRIBE '%s'" % args.stream, flush=True)
serve(conn, args)
except (BrokenPipeError, ConnectionResetError):
print("mock tcpstreamer: client went away", flush=True)
finally:
conn.close()
if __name__ == "__main__":
main()
```
- [ ] **Step 7: Build the dependency and the app** with the project's usual command (see `docs/claude/build-and-run.md`; normally `cmake --build build -j`). Expected: it builds; the warning count equals the count recorded before the task. Run `python3 tools/mock_tcpstreamer.py --help` and `python3 -c "import ast,sys;ast.parse(open('tools/mock_tcpstreamer.py').read())"`: both succeed.
- [ ] **Step 8: Commit**
```bash
git add deps/udpsclient/udps_client.h deps/udpsclient/udps_client.c tools/mock_tcpstreamer.py
git commit -m "udpsclient: a TCP transport mode (SUBSCRIBE, no CONNECT) and a TCP mock server"
```
---
### Task 2: The `tcps` source kind
**Files:**
- Modify: `src/source/udps_source.hpp`, `src/source/udps_source.cpp`
- Create: `src/source/tcps_kind.cpp`
- Modify: `src/source/registry.hpp`, `src/source/registry.cpp`, `CMakeLists.txt` (source list near line 242-243)
**Interfaces:**
- Consumes: `udps_client_config_t::use_tcp` / `::stream_name` from Task 1.
- Produces: `enum class UdpsSource::Transport { Udp, Tcp }`; `UdpsSource(const SourceConfig&, Transport = Transport::Udp)`; `Transport UdpsSource::transport() const`; `std::unique_ptr<SourceKind> make_tcps_kind()`; kind id `tcps` with config keys `server` (default `127.0.0.1`), `port` (default `44500`), `stream` (optional).
- [ ] **Step 1: `udps_source.hpp`.** Inside `class UdpsSource`, add before the constructor:
```cpp
enum class Transport { Udp, Tcp };
```
change the constructor to `explicit UdpsSource(const SourceConfig& cfg, Transport transport = Transport::Udp);`, add `Transport transport() const { return transport_; }` in the public section, and members `Transport transport_ = Transport::Udp;` and `std::string stream_;`.
- [ ] **Step 2: `udps_source.cpp`.** Replace the constructor:
```cpp
UdpsSource::UdpsSource(const SourceConfig& cfg, Transport transport)
: transport_(transport),
server_(cfg.get("server", "127.0.0.1")),
port_(static_cast<std::uint16_t>(
cfg.get_int("port", transport == Transport::Tcp ? 44500 : 8000))),
multicast_(transport == Transport::Tcp ? std::string() : cfg.get("multicast")),
interface_(transport == Transport::Tcp ? std::string() : cfg.get("interface")),
stream_(transport == Transport::Tcp ? cfg.get("stream") : std::string()),
recv_buffer_(static_cast<int>(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {}
```
Member declaration order must match the initialiser order: in the header declare `transport_` before `server_`, and `stream_` after `interface_` and before `recv_buffer_` (an out-of-order initialiser is a `-Wreorder` warning, which breaks the one-warning baseline). In `open()`, after `c.keepalive_interval_s = 1.0;` add:
```cpp
if (transport_ == Transport::Tcp) {
c.use_tcp = 1;
c.stream_name = stream_.empty() ? nullptr : stream_.c_str();
}
```
`datagram_cb` registration stays; in TCP mode the client never fires it. Leave `set_datagram_tap` as is.
- [ ] **Step 3: Create `src/source/tcps_kind.cpp`.** Copy the helper block of `src/source/udps_kind.cpp` (`ChangeCb`, `entry_row`, `on_edited`, `on_value`, `hook_value`, `hook`, `text_of`; they are in an anonymous namespace, so the copy is local, as three kinds already do) and add the kind:
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/registry.hpp"
#include "source/udps_source.hpp"
#include <adwaita.h>
#include <memory>
namespace udpscope {
namespace {
/* ChangeCb, entry_row, on_edited, on_value, hook_value, hook, text_of:
the same six helpers as udps_kind.cpp, verbatim. */
class TcpsKind final : public SourceKind {
public:
std::string id() const override { return "tcps"; }
std::string display_name() const override { return "TCPStreamer"; }
std::string identity(const SourceConfig& cfg) const override {
std::string s = cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 44500));
if (!cfg.get("stream").empty()) s += "/" + cfg.get("stream");
return s;
}
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<UdpsSource>(cfg, UdpsSource::Transport::Tcp);
}
ConfigForm make_config_form() const override {
GtkWidget* server = entry_row("_Server");
GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1));
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port");
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE);
adw_spin_row_set_value(ADW_SPIN_ROW(port), 44500);
GtkWidget* stream = entry_row("S_tream name");
ChangeCb cb = std::make_shared<std::function<void()>>();
hook(server, cb);
hook_value(port, cb);
hook(stream, cb);
ConfigForm f;
f.rows = {server, port, stream};
f.description =
"Leave the stream name empty for a standalone TCPStreamer; name the stream for a TCPStreamService";
f.focus = server;
f.write = [=](const SourceConfig& c) {
gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str());
adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast<double>(c.get_int("port", 44500)));
gtk_editable_set_text(GTK_EDITABLE(stream), c.get("stream").c_str());
};
f.read = [=]() {
SourceConfig c;
c.set("server", text_of(server));
c.set("port", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(port)))));
if (!text_of(stream).empty()) c.set("stream", text_of(stream));
return c;
};
// A stream name is 1..255 bytes and, being the SUBSCRIBE payload, may not hold a comma
// (that would select a multiplexed list, which this kind does not support).
f.valid = [=]() {
const std::string s = text_of(stream);
return !text_of(server).empty() && s.size() <= 255 && s.find(',') == std::string::npos;
};
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return f;
}
};
} // namespace
std::unique_ptr<SourceKind> make_tcps_kind() { return std::make_unique<TcpsKind>(); }
} // namespace udpscope
```
When writing the file, paste the six real helper definitions in place of the comment block. Check `udps_kind.cpp` includes `<functional>` transitively (it does through `source_kind.hpp`).
- [ ] **Step 4: Register.** `registry.hpp`: add `std::unique_ptr<SourceKind> make_tcps_kind();` after `make_udps_kind()` and add `tcps_kind.cpp` to the comment's file list. `registry.cpp`: in `register_builtin_sources()` add `register_kind(make_tcps_kind());` right after `make_udps_kind()` (so the chooser lists the two network kinds together). `CMakeLists.txt`: add `src/source/tcps_kind.cpp` after `src/source/udps_kind.cpp`.
- [ ] **Step 5: Build.** `cmake --build build -j`. Expected: builds, warning count unchanged from the baseline.
- [ ] **Step 6: Smoke-run against the mock** (serial, kill the mock after):
```bash
python3 tools/mock_tcpstreamer.py --port 44500 &
MOCK=$!
UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a build/udpscope --source tcps --source-opt server=127.0.0.1 --source-opt port=44500 2>&1 | tail -20
kill $MOCK
```
Expected: no `Gtk-CRITICAL`; the mock prints `client 127.0.0.1:...`. (If the app's CLI spells the options differently, take the spelling from `docs/claude/build-and-run.md`.) Then the same with `--stream fast` on the mock and `--source-opt stream=fast` on the app: the mock prints `SUBSCRIBE 'fast'`. Then once with the wrong name (`stream=slow`): the mock prints `no valid SUBSCRIBE`, and the app keeps retrying without a stderr flood.
- [ ] **Step 7: Commit**
```bash
git add src/source/udps_source.hpp src/source/udps_source.cpp src/source/tcps_kind.cpp src/source/registry.hpp src/source/registry.cpp CMakeLists.txt
git commit -m "sources: a TCPStreamer kind (standalone or TCPStreamService) on the UDPS client's TCP mode"
```
---
### Task 3: Probes
**Files:**
- Modify: `src/ui_probe.cpp` (sample at ~line 2683 `const auto sample`; new `[tcps]` functions after `flatudp_probe`, ~line 3703), `src/ui.cpp` (env scheduling after the `UDPSCOPE_PROBE_FLATUDP_GAP` block, ~line 7657)
- Declarations: wherever `flatudp_probe` / `flatudp_probe_gap` are declared (grep `flatudp_probe_gap` in `src/*.hpp`; add the two new functions beside them)
**Interfaces:**
- Consumes: `UdpsSource::transport()`, kind `tcps`, `a->model->stats()` fields `connected`, `frames`, `reconnects`, `a->model->signals()`, `a->capture_button`.
- Produces: env vars `UDPSCOPE_PROBE_TCPS=<ms>` and `UDPSCOPE_PROBE_TCPS_RECONNECT=<ms>`; stderr lines `[tcps] stream:`, `[tcps] capture button:`, `[tcps] reconnect:`; a `[source-kinds] form tcps:` line.
- [ ] **Step 1: Form round trip.** In `source_kinds_probe`'s `sample` lambda add a branch:
```cpp
} else if (id == "tcps") {
c.set("server", "10.0.0.7");
c.set("port", "44501");
c.set("stream", "fast");
```
Build and run `UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep source-kinds`. Expected: `kinds=udps,tcps,e1608,flatudp,probe` and `form tcps: round_trip=1 valid=1`. The `kinds=` line changes for every run from now on, so update `docs/claude/probe-readings.md` (~line 2120-2123, 2135) and `docs/claude/verification.md` (~line 579, "Nine `[source-kinds]` lines" -> ten, "one `form` line per registered kind: `udps`, `tcps`, ...") in Task 4.
- [ ] **Step 2: Add the source accessor and stream probe** in `ui_probe.cpp`, after `flatudp_probe`:
```cpp
namespace {
UdpsSource* tcps_source(App* a) {
UdpsSource* s = a->acq != nullptr ? dynamic_cast<UdpsSource*>(a->acq->source()) : nullptr;
return s != nullptr && s->transport() == UdpsSource::Transport::Tcp ? s : nullptr;
}
std::uint64_t g_tcps_frames_before = 0;
std::uint64_t g_tcps_reconnects_before = 0;
} // namespace
/// Phase 2, 1 s after the signals were enabled: connected, the mock's four channels, frames
/// flowing, and the capture button present but disabled (the capture is of the UDP socket).
gboolean tcps_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
std::string names;
for (const SignalInfo& s : a->model->signals()) names += (names.empty() ? "" : ",") + s.name;
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
const bool ok = up && names == "sine,square,ramp,noise" && frames > 0;
std::fprintf(stderr,
"[tcps] stream: connected=%d signals=%zu names=%s frames=%" G_GUINT64_FORMAT " ok=%d\n",
up ? 1 : 0, a->model->signals().size(), names.c_str(), frames, ok ? 1 : 0);
std::fprintf(stderr, "[tcps] capture button: present=%d sensitive=%d\n",
a->capture_button != nullptr ? 1 : 0,
a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase 1: enable every signal in one structural change, report a second later.
gboolean tcps_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
if (tcps_source(a) == nullptr) {
std::fprintf(stderr, "[tcps] no tcps source (run with --source tcps)\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
std::vector<std::size_t> all(a->model->signals().size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
if (!all.empty()) a->model->set_signals_enabled(all, true);
g_timeout_add(1000, tcps_probe_report, a);
return G_SOURCE_REMOVE;
}
/// After the mock closed the connection (`--close-after`): the client must have reconnected and
/// the frame count moved on. Phase 2 reads 1.5 s after phase 1 recorded the baseline.
gboolean tcps_probe_reconnect_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
const std::uint64_t rc = a->model->stats().reconnects.load(std::memory_order_relaxed);
const bool ok = up && frames > g_tcps_frames_before && rc > g_tcps_reconnects_before;
std::fprintf(stderr,
"[tcps] reconnect: connected=%d frames_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT
" reconnects_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT " ok=%d\n",
up ? 1 : 0, g_tcps_frames_before, frames, g_tcps_reconnects_before, rc, ok ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean tcps_probe_reconnect(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
g_tcps_frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
g_tcps_reconnects_before = a->model->stats().reconnects.load(std::memory_order_relaxed);
g_timeout_add(1500, tcps_probe_reconnect_report, a);
return G_SOURCE_REMOVE;
}
```
Make sure `ui_probe.cpp` includes `source/udps_source.hpp` (add it beside the flatudp includes at line 11-12 if absent). The `reconnects` stat is fed from `SourceStats::reconnects` by `Acquisition`; if after Task 3 Step 4 it reads 0 while the client demonstrably reconnected, `Acquisition` does not map it for this path: report that rather than weakening the check.
- [ ] **Step 3: Schedule and declare.** Add the two declarations next to `flatudp_probe_gap`'s, and in `ui.cpp` after the `UDPSCOPE_PROBE_FLATUDP_GAP` block:
```cpp
if (const char* env = g_getenv("UDPSCOPE_PROBE_TCPS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), tcps_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_TCPS_RECONNECT")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), tcps_probe_reconnect, &app);
}
```
- [ ] **Step 4: Run the three scenarios**, each short and serial, mock killed after each, stderr filtered with `grep -E '^\[tcps\]|Gtk-CRITICAL|warning'`:
1. Standalone: mock `--port 44500`; app `--source tcps --source-opt port=44500`, `UDPSCOPE_PROBE_TCPS=2500 UDPSCOPE_EXIT_AFTER_MS=5000`. Expected `[tcps] stream: connected=1 signals=4 names=sine,square,ramp,noise frames=<n> ok=1` (n > 0) and `[tcps] capture button: present=1 sensitive=0`.
2. Service: mock `--port 44500 --stream fast`; app additionally `--source-opt stream=fast`. Same expectations.
3. Reconnect: mock `--port 44500 --close-after 3`; app `UDPSCOPE_PROBE_TCPS=2500 UDPSCOPE_PROBE_TCPS_RECONNECT=4500 UDPSCOPE_EXIT_AFTER_MS=8000`. Expected `[tcps] reconnect: connected=1 ... ok=1`.
Also check the no-server case: no mock, `UDPSCOPE_PROBE_TCPS=2500`: `connected=0 ... ok=0` is a reading, not a crash; no stderr flood (a retry every 0.5 s is acceptable, more than a few lines per second is not).
- [ ] **Step 5: Baselines.** A full rebuild shows the same warning count as before the plan, and none of the runs printed `Gtk-CRITICAL`. Then re-run the source-kinds probe (Step 1) and confirm the existing `udps`/`e1608`/`flatudp`/`probe` form lines still read `round_trip=1 valid=1`.
- [ ] **Step 6: Commit**
```bash
git add src/ui_probe.cpp src/ui.cpp
git commit -m "probes: the TCPStreamer kind end to end (standalone, service, reconnect)"
```
(Include the header that carries the probe declarations in `git add` if Step 3 touched one.)
---
### Task 4: Documentation
**Files:**
- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `CLAUDE.md` (only its intro sentence if it enumerates the source kinds)
- [ ] **Step 1: `docs/claude/build-and-run.md`.** The `deps/udpsclient` section says "Five things were changed". Make it six and add item 6: "**A TCP transport mode.** `udps_client_config_t::use_tcp` / `stream_name` select a TCP connection to a MARTe2 `TCPStreamer` or `TCPStreamService`: `connect_tcp()` (3 s connect bound via `SO_SNDTIMEO`, 1 s receive bound, an optional `SUBSCRIBE` frame), the existing `read_tcp_frame()` for the frames, the reorder window forced to 0, no DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), and no datagram tap. A frame must fit `UDPS_MAX_DATAGRAM_BYTES` (the server's `MaxPayloadSize` default of 60000 does)." Also update the earlier sentence "`deps/udpsclient` gained one additive entry point" to mention the TCP mode.
- [ ] **Step 2: `docs/claude/architecture.md` (Sources section).** Add a bullet: "**The TCPStreamer kind (`tcps`) is `UdpsSource` with `Transport::Tcp`** ... `src/source/tcps_kind.cpp` is only the form and identity (`host:port` or `host:port/stream`); there is no new `DataSource`. One stream per connection (no multiplexed `SUBSCRIBE` list: a source is single-timebase; merge on the server with `TCPStreamJoin`). `has_datagrams()` is false, so the capture button stays disabled. `Disconnected` follows the client's 2 s silence timeout or a closed connection, and it reconnects every 0.5 s. A stream whose frames are further apart than 2 s therefore reconnects continually, as UDPS does." Fix the bullet that says `UdpsSource` "wraps `deps/udpsclient` unchanged".
- [ ] **Step 3: `docs/claude/verification.md` and `probe-readings.md`.** Verification: update "Nine `[source-kinds]` lines (... `udps`, `e1608`, `flatudp`, `probe`)" to include `tcps`; add `UDPSCOPE_PROBE_TCPS` and `UDPSCOPE_PROBE_TCPS_RECONNECT` entries beside the FLATUDP ones, with the three scenarios from Task 3 Step 4 (commands, mock options, exit times). Probe readings: update `kinds=` (line ~2120 and ~2135) to `udps,tcps,e1608,flatudp,probe`, add `[source-kinds] form tcps: round_trip=1 valid=1` (and the form sample's keys: server, port, stream), and add a `## [tcps]` section after `[flatudp]` with the sample output of the three lines and what a wrong reading means (`connected=0` with a mock running: SUBSCRIBE or connect failing; `names` wrong: CONFIG not decoded; `sensitive=1`: the capture button was enabled for a kind with no datagrams; `reconnect ... ok=0`: the client did not come back after the close, or `Acquisition` does not map `reconnects`).
- [ ] **Step 4: `README.md`.** Wherever it lists the source kinds or the command-line `--source` values, add `tcps` with its options (`server`, `port` default 44500, `stream` optional), one line saying it receives a MARTe2 `TCPStreamer`/`TCPStreamService`, and under known limitations: single stream per connection, no packet capture over TCP, frames up to 65 KB (the server's `MaxPayloadSize`). If `CLAUDE.md`'s intro sentence enumerates the kinds, add `tcps` there too.
- [ ] **Step 5: Read-through.** `grep -rn "Nine \`\[source-kinds\]\|Five things\|unchanged" README.md CLAUDE.md docs/claude` and fix every statement the change made false.
- [ ] **Step 6: Commit**
```bash
git add README.md CLAUDE.md docs/claude
git commit -m "docs: the TCPStreamer source kind, the client's TCP mode, and its probes"
```
---
## Self-review (against the spec)
- Wire/scope (single stream, SUBSCRIBE only with a name): Task 1 Steps 1-4, Task 2 Step 3 (`valid` rejects a comma).
- `udpsclient` TCP mode (use_tcp, stream_name, connect_tcp, reorder 0, no tap, additive, recorded as a sixth change): Task 1; recording: Task 4 Step 1.
- Source adapter reuse via transport argument; form rows, description, identity, session naming (automatic), registration and CMake: Task 2.
- Verification (fake server, probe lines for signals/frames/reconnect, baselines, real smoke test): Task 3 uses a Python mock rather than the in-process fake server the spec named, because the existing probes all use external mocks under `tools/`. A smoke test against a real MARTe `TCPStreamer` is not in the plan; do it only if one is runnable.
- Docs: Task 4.
- Types: `UdpsSource::Transport`, `transport()`, `make_tcps_kind()`, `tcps_source()`, env names are used identically across tasks.
@@ -22,7 +22,7 @@ host with several interfaces; the existing "find by MAC" lookup has the same fla
(`SO_BINDTODEVICE`, falling back to binding the interface address if the call is refused), enables
`SO_BROADCAST`, and sends `D` to 255.255.255.255 on each. `poll()` drains every socket without
blocking and returns the devices found so far (E-1608 product id only, one entry per MAC, the same
reply parsing as `discover`). `done(ms)` is not part of the class: the caller owns the clock.
reply parsing as `discover`). The caller owns the clock.
- `discover()` keeps its signature. A new overload or helper `discover_broadcast(port, wait_ms)` is
`Scan` plus a blocking wait. `E1608Source`'s lookup uses it when the subnet is empty; a given
subnet still uses `sweep_targets` + `discover` as today.
@@ -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.
@@ -0,0 +1,51 @@
# TCPStreamer source kind
A new source kind `tcps` ("TCPStreamer") that receives a MARTe2 `TCPStreamer` DataSource, either
standalone or through a shared `TCPStreamService`.
## Wire and scope
- Both transports are plain UDPS framing over TCP. A standalone `TCPStreamer` (TCPSServer) sends
CONFIG then DATA on connect. A `TCPStreamService` additionally requires a first client frame,
`SUBSCRIBE` (type 5, payload = stream name, 1..255 bytes).
- A plain server ignores `SUBSCRIBE`, so the client sends it only when a stream name is set.
- One connection carries one stream. Multiplexed lists (`"a,b,c"`, stream index in `fragmentIdx`)
are out of scope: a udpscope source is single-timebase. Several streams are merged server-side
with `TCPStreamJoin`, which publishes an ordinary named stream.
## `deps/udpsclient`: a TCP transport mode
- `udps_client_config_t` gains `use_tcp` and `stream_name`.
- New `connect_tcp()`: open the TCP socket, send `SUBSCRIBE` if a stream name is set, send no
`CONNECT`. No UDP socket, no keepalive ACK.
- `udps_client_poll` reuses `read_tcp_frame` / `handle_packet`.
- `reorder_window_s` is forced to 0 in TCP mode: TCP is ordered, so a counter gap is a server-side
drop and is reported at once.
- Silence timeout and reconnect delay behave as in UDP (dead server, or a stalled client the server
evicted, reconnects).
- The datagram tap does not fire; the packet capture stays UDP-only (`has_datagrams()` false).
- Additive. Recorded in `docs/claude/build-and-run.md` as a sixth intentional upstream change.
## Source adapter and kind
- `UdpsSource` takes a transport argument (`UdpsSource(cfg, Transport::Tcp)`) instead of being
copied: its callbacks are identical. The TCP variant reads `server`, `port` (default 44500) and
`stream`.
- `src/source/tcps_kind.cpp`: kind `tcps`, display name "TCPStreamer". Form rows: Server, Port,
Stream name (optional). Description: "Leave empty for a standalone TCPStreamer; name the stream
for a TCPStreamService".
- Identity: `host:port` or `host:port/stream`. Session naming follows the existing non-UDPS rule
(`tcps-<identity>`, port 1, `source_kind=tcps`, `source_opt_*`).
- Registered in `register_builtin_sources()` and `CMakeLists.txt`.
## Verification
No unit suite. Extend the self-check in `src/ui_probe.cpp` with an in-process fake TCP server that
sends CONFIG then DATA, and requires `SUBSCRIBE` in one mode. Probe lines assert the signals, the
frame count, and the reconnect after the server closes. Baselines: exactly one build warning, no
`Gtk-CRITICAL`. A smoke test against a real MARTe `TCPStreamer` if one is runnable.
## Docs
README and `docs/claude/architecture.md` (Sources) describe the kind; `docs/claude/probe-readings.md`
gets the new probe lines.
+17 -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>
@@ -60,6 +63,16 @@
<item><p>A marker past the newest sample reads <code>--</code>. With only one marker
over data the row reads <code>partial</code>: a difference between a number and nothing
is not a number.</p></item>
<item><p>The hover crosshair also writes its two coordinates where the eye already
looks: the value where its horizontal line meets the y axis, and the time where its
vertical line meets the x axis — each in a small box among that axis' own tick labels,
in the axis' own format. Each box is centred on the line it names, and its background
covers what is behind it, so a tick label underneath never prints through the number.
The preference's <gui>Crosshair axis labels</gui> turns the pair off.</p></item>
<item><p>In a split row the horizontal line runs across <em>every</em> plot of the row,
not just the one under the pointer: the plots share one y range, so it is one value
along the whole row. It stops at the row's edges — another row has a scale of its
own.</p></item>
<item><p>In <gui>mix</gui> each marker also places a crossing annotation per trace,
which is what lets you see on the plot which sample a number came from. It is <em>on
that sample</em>, so when the marker falls between two it sits a little off its own
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

+73 -17
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,13 @@
<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>
<p>Press <key>l</key>, pick <gui>Layout editor</gui> from the overflow menu, or pick
<gui>Edit the layout…</gui> from a plot's right-click menu, to add
and remove rows: a <gui>×</gui> appears at the top left of each plot and a <gui>+</gui>
on each boundary between rows, including above the first and below the last.</p>
</item>
<item>
@@ -38,19 +40,72 @@
</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, the
hover crosshair's horizontal line spans all of them, 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="fullscreen">
<title>A row full screen, and a second window</title>
<list>
<item><p>Every row carries a <gui>full-screen</gui> button in its top-right corner: it comes
out of hiding when the pointer moves near that corner and hides again when it leaves, so the
plot is not decorated by a control you are not reaching for. One click makes that row fill the plot area — the
other rows step aside with everything they were plotting — and the same button (now the
restore icon) brings the whole layout back exactly as it was. It is a view, not an edit:
nothing is saved, nothing is dropped, and the rows' traces, colours and cursors come back
where they were. The plot's right-click menu offers the same as <gui>Full screen this
figure</gui> (and <gui>Restore the layout</gui> while one is enlarged). A layout of a single
figure has nothing to enlarge *to*, so it carries no button.</p></item> Beside the toggle, shown the same way, a <gui>detach</gui> button moves the figure into a
window of its own — it leaves the main window's rows; the detached window carries an
<gui>attach</gui> button in the same corner, which returns the figure to the main window and
closes the window.
<item><p>A plot's right-click menu has <gui>Open this plot in a new window</gui>: a second
window showing the same stream through a copy of that figure (its cells, their widths and
every trace), with its own axes following the main window's time window. The overflow menu's
<gui>New scope window</gui> opens the same kind of window with an empty figure. A signal
badge dragged onto a window's plot lands in that window (on the plot under the pointer), and
the signal page's <gui>Plot on</gui> section lists every window — one named group per
window, one button per plot of it. Closing one closes that view only; the main window keeps
acquiring.</p></item>
</list>
</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 +117,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
+35 -3
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>
@@ -78,8 +80,19 @@
shorter than a level's own resolution, or one read through <gui>hf</gui> — where
<gui>top</gui>/<gui>bottom</gui> fall back to the mean of each side of the split and
<gui>amplitude</gui> to the window's peak to peak.</p></item>
<item><p>Each row names the <em>channel</em> it measures: its number among the active
channels (1., 2., … the same number the signal's badge in the bar carries), its name, and
the type — <code>1. sine · average</code>. The measurement dialog's signal dropdown
numbers its entries the same way. A channel list that changes (a signal switched off, a
new one enabled) renumbers, because the number is the answer to "which active channel is
this".</p></item>
<item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em>
mark is a ruler at a value, a <em>time</em> mark is a marker at a position. An average
mark is a ruler at a value — its value written in a small box on the ruler, the way the
axis writes its own ticks — and a <em>time</em> mark is a marker at a position, with the
position's own badge beside it. A level ruler's badge carries its number; a time
marker's carries its time. Marks whose badges meet share one panel with one line each,
so a column of close readings is one boxed readout rather than a smear of texts; and a
box over a noisy trace is what keeps the number readable. An average
draws one level mark; a max draws a level mark <em>and</em> a time mark at the first
sample that reached it; <gui>peak to peak</gui> draws a ruler at each extreme and
<gui>amplitude</gui> a ruler at each of its two flat levels — the pair is the span, so
@@ -134,6 +147,25 @@
</list>
</section>
<section id="gate">
<title>The gate</title>
<p>A measurement's dialog carries <gui>Gate enabled</gui>. Every measurement
with it set reads only the samples inside one shared band — violet, labelled <em>gate</em>,
carried by its own two draggable edges on the plots, which are not the cursors (those keep
meaning what the cursors pane reads). Measurements without it are untouched: they read the
whole window and share the walks they always shared.</p>
<p>The band is shown exactly while some measurement is gated — gate one and it appears,
gate or delete the last one and it goes — because it is one area that all of the gated
measurements share, not a property of any one of them. Drag an edge to resize it, grab the
middle to move it.</p>
<p>The band lives in the same relative time the axis labels use, so it stays where it was
put in both modes: live, a band at 300 ms to 100 ms ago stays put on screen while the data
streams beneath it — the gated read of the newest data a bench scope gives — and on a held
event it names a fixed slice of the frozen frame. A band with no samples in it — dragged
outside the view, or over a gap — reads <code>--</code> like any empty window. The band's
position and each measurement's gate flag are saved with the session.</p>
</section>
<section id="example">
<title>Example</title>
<p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.957988</code>,
+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
+22 -12
View File
@@ -23,11 +23,18 @@
<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
title carries the signal's name and its channel number (<code>2. sine</code>), and its
details follow — <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 +45,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 +61,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>
+6 -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">
@@ -29,6 +29,11 @@
not a working badge. Of the two steady colours, <em>grey</em> is no stream at all or a
pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that
answered but has sent no configuration.</p></item>
<item><p>Left of the badge is the <em>trigger circle</em>, and it is the trigger's own
light: solid green while the trigger has fired (an event is held, or a capture is
filling), <em>flashing</em> green while armed and waiting for an event, red while the
trigger mode is on but disarmed, and hidden entirely while the trigger mode is off — a
light for a mode you are not in is noise. Its tooltip names the state in words.</p></item>
<item><p>The state field is one word from a fixed set: <code>connecting</code>,
<code>live</code>, <code>paused</code>, <code>armed</code>, <code>capturing</code>,
<code>event held</code>, <code>disarmed</code>. Two states add one field each: in trigger
+26 -20
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">
@@ -168,31 +169,36 @@
<section id="cache">
<title>The event cache, replay and compare</title>
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
(<file>~/.cache/udpscope/events/&lt;source&gt;/</file>) as a <file>.npy</file> of the
(<file>~/.cache/udpscope/events/&lt;source&gt;/&lt;configuration&gt;/</file> -- one folder per
source and configuration, so two configurations of one source keep their events apart) 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).
+64 -4
View File
@@ -239,15 +239,75 @@ bool event_meta_parse(const std::string& text, EventRecord* out) {
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
EventStore::EventStore(std::string source, std::string base_dir) {
if (source.empty()) return;
namespace {
/// A path component from a user-visible name: alphanumerics, `.`, `_` and `-` kept, everything else
/// replaced. The same rule the layout store applies to a configuration name, and for the same
/// reason -- a name typed with a slash in it must not be able to leave the directory.
std::string sanitize_component(const std::string& in) {
std::string safe;
for (char c : source) {
safe.reserve(in.size());
for (char c : in) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
safe.push_back(ok ? c : '_');
}
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
return safe;
}
/// The directory a source's configuration writes into: `<root>/<source>/<configuration>/`, or the
/// source's own directory when it has no configuration name (a store built without one).
std::string event_dir(const std::string& root, const std::string& source,
const std::string& configuration) {
std::string dir = root + "/" + sanitize_component(source);
if (!configuration.empty()) dir += "/" + sanitize_component(configuration);
return dir;
}
} // namespace
EventStore::EventStore(std::string source, std::string base_dir, std::string configuration)
: root_(base_dir.empty() ? default_events_dir() : std::move(base_dir)),
source_(std::move(source)),
configuration_(std::move(configuration)) {
if (source_.empty()) {
root_.clear();
configuration_.clear();
return;
}
dir_ = event_dir(root_, source_, configuration_);
// **An upgrade keeps its events.** Before configurations came in, a source's events sat directly
// in `<root>/<source>/`; the first store opened under a configuration adopts every loose event
// file it finds there, so the cache a user already has is the cache the new folder lists.
if (!configuration_.empty()) {
const std::string from = root_ + "/" + sanitize_component(source_);
DIR* d = ::opendir(from.c_str());
if (d != nullptr) {
bool moved = false;
while (dirent* e = ::readdir(d)) {
const std::string name = e->d_name;
if (name == "." || name == "..") continue;
const bool event_file = name.size() > 4 &&
(name.compare(name.size() - 4, 4, ".npy") == 0 ||
name.compare(name.size() - 4, 4, ".txt") == 0 ||
name.compare(name.size() - 4, 4, ".bmp") == 0);
if (!event_file) continue;
if (!moved) {
mkdirs(dir_);
moved = true;
}
std::rename((from + "/" + name).c_str(), (dir_ + "/" + name).c_str());
}
::closedir(d);
}
}
}
bool EventStore::set_configuration(const std::string& configuration) {
if (source_.empty() || configuration == configuration_) return false;
configuration_ = configuration;
dir_ = event_dir(root_, source_, configuration_);
return true;
}
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
+25 -2
View File
@@ -45,11 +45,28 @@ std::string default_events_dir();
class EventStore {
public:
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses.
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses. `configuration`
/// is the source's **named configuration** -- the same name the session files and the packet
/// capture use, `default` for a source that never saved one: an event's trigger, its channel set
/// and its units belong to the configuration it was latched under, so two configurations of one
/// source must not list each other's events. The directory is
/// `<base>/<source>/<configuration>/`; an empty configuration is the legacy layout
/// (`<base>/<source>/`), and the first construction under a configuration **adopts** the loose
/// event files it finds there, so an upgrade keeps the events it had.
///
/// `base_dir` is the directory every source's own folder is created under; empty means
/// `<cache>/udpscope/events`. It comes from the preferences (item 9), because a user with a small
/// home partition and a second disk should not have to choose between events and free space.
explicit EventStore(std::string source = {}, std::string base_dir = {});
explicit EventStore(std::string source = {}, std::string base_dir = {},
std::string configuration = {});
/// Points the store at another configuration of the same source, for the configuration switch
/// the session has: only the directory changes, and `true` means it did -- the caller then
/// refreshes whatever lists the cache. A store with no source (`empty_dir()`) never changes.
bool set_configuration(const std::string& configuration);
/// The configuration the store currently writes to (the constructor's, or `set_configuration`'s).
const std::string& configuration() const noexcept { return configuration_; }
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
/// preferences are where this becomes the user's number.
@@ -89,6 +106,12 @@ class EventStore {
void prune(std::size_t keep) const;
private:
/// The base directory every source folder lives under (an absolute path, or empty for a store
/// with no source), and the source's own sanitized key: kept so a configuration switch can
/// re-derive `dir_` without asking the caller for either again.
std::string root_;
std::string source_;
std::string configuration_;
std::string dir_;
};
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A boundary that can be dragged sideways: a horizontal double-headed arrow, the turned twin of
udpscope-resize-rows-symbolic. It says *which* edge the handle moves, which a single chevron (a
"go next") does not. Every coordinate is a whole pixel of the 16px cell, every subpath is a filled
`path` wound the same way (a stroke would keep this colour on a dark theme), and the two heads
meet the shaft exactly, at y 5..11 and y 7..9. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M4 7 H12 V9 H4 Z
M1 8 L5 5 V11 Z
M15 8 L11 11 V5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Measure tab: two cursors beside the ruler they read against. Each cursor is a cap over two
bold dashes — the mark the toolbar's cursors button carries, at a weight that survives this size.
The ruler is two bands with three marks between them and *no end caps*: a closed frame reads as a
rectangle, and it costs a fifth of the ink on two lines that say nothing. Both motifs are centred
on the same line and share one weight, and everything is inset two pixels. -->
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M2 2 H7 V5 H2 Z
M3 6 H6 V10 H3 Z
M3 11 H6 V14 H3 Z
M9 2 H14 V5 H9 Z
M10 6 H13 V10 H10 Z
M10 11 H13 V14 H10 Z
M17 4 H30 V6 H17 Z
M17 10 H30 V12 H17 Z
M18 6 H20 V10 H18 Z
M22 6 H24 V10 H22 Z
M26 6 H28 V10 H26 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

+1 -2
View File
@@ -20,10 +20,9 @@
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-tab-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-tab-signals-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-cols-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
</gresource>
+101 -12
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,8 @@ 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, Window, WindowPlot, Calibration, Unit, Color, Math, Measure } section =
Section::Top;
int plot_count = 0;
bool plot_section_seen = false;
while (std::getline(in, line)) {
@@ -457,9 +460,20 @@ bool load_layout(const std::string& path, LayoutState& out) {
section = Section::Plot;
plot_section_seen = true;
st.rows.emplace_back();
} else if (t == "[[window]]") {
section = Section::Window;
st.windows.emplace_back();
} else if (t == "[[window.plot]]") {
// A window's own plot rows: the same shape as the main layout's `[[plot]]`, one level in.
if (st.windows.empty()) st.windows.emplace_back();
section = Section::WindowPlot;
st.windows.back().rows.emplace_back();
} 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 +582,13 @@ 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 == "gate_x0" || key == "gate_x1") {
try {
(key == "gate_x0" ? st.gate_x0 : st.gate_x1) = std::stod(value);
} catch (...) {
}
} 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 +602,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") {
@@ -596,6 +617,35 @@ bool load_layout(const std::string& path, LayoutState& out) {
} catch (...) {
}
}
} else if (section == Section::Window && !st.windows.empty()) {
if (key == "name") {
st.windows.back().name = strip_quote(value);
} else if (key == "detached") {
st.windows.back().detached = value == "true";
}
} else if ((section == Section::WindowPlot && !st.windows.empty() &&
!st.windows.back().rows.empty())) {
// The window's rows take exactly the main layout's keys; `rows` is reached through the
// window record so the parser stays one shape.
LayoutState::Row& row = st.windows.back().rows.back();
if (key == "signals") {
std::vector<std::string> names;
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
parse_string_array(value.substr(1, value.size() - 2), names);
}
row.signals = names;
} else if (key == "weight") {
row.weight = std::strtod(value.c_str(), nullptr);
} else if (key == "row") {
try {
const int r = std::stoi(value);
if (r >= 0) row.row = r;
} catch (...) {
}
} else if (key == "width") {
const double w = std::strtod(value.c_str(), nullptr);
if (std::isfinite(w) && w > 0.0) row.width = w;
}
} else if (section == Section::Plot && !st.rows.empty()) {
if (key == "signals") {
std::vector<std::string> names;
@@ -605,6 +655,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();
@@ -669,6 +728,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
m.options.above = value == "true";
} else if (key == "show") {
m.options.show_on_plot = value == "true";
} else if (key == "gate") {
m.options.gate = value == "true";
}
} else if (section == Section::Calibration && !st.calibration.empty()) {
if (key == "name") {
@@ -684,6 +745,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 +807,9 @@ 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 << "gate_x0 = " << state.gate_x0 << "\n";
o << "gate_x1 = " << state.gate_x1 << "\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,21 +837,40 @@ 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 = [";
const auto write_row = [&](const LayoutState::Row& row) {
// 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]);
}
o << "]\n";
};
for (const auto& row : state.rows) {
o << "\n[[plot]]\n";
write_row(row);
}
// The extra windows: written so a closed one can be reopened, never opened by a launch.
for (const auto& window : state.windows) {
o << "\n[[window]]\nname = " << toml_escape(window.name)
<< "\ndetached = " << (window.detached ? "true" : "false") << "\n";
for (const auto& row : window.rows) {
o << "\n[[window.plot]]\n";
write_row(row);
}
}
for (const auto& cal : state.calibration) {
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);
@@ -812,7 +900,8 @@ bool save_layout(const std::string& path, const LayoutState& state) {
<< "\nthreshold2 = " << m.options.threshold_pct2
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
<< "\nhf = " << m.options.hf_fc << "\n";
<< "\nhf = " << m.options.hf_fc
<< "\ngate = " << (m.options.gate ? "true" : "false") << "\n";
}
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
+46 -7
View File
@@ -24,11 +24,31 @@ 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
std::vector<Row> rows;
/// An **extra window**: a scope or detached window, remembered with the session but never opened
/// by a launch. A window is a view a user closed, and bringing one back is a decision -- the
/// *Reopen closed windows* entry asks for it, so a start never conjures windows onto a desktop
/// that does not want them (a tiling WM would tile them straight away). `name` is the window's own
/// short name (`plot 2`, `scope 3`), `detached` whether its figure came from a detach, and `rows`
/// the same plot rows the main layout carries: signals, row weights and cell widths.
struct Window {
std::string name;
bool detached = false;
std::vector<Row> rows;
};
std::vector<Window> windows;
struct Calibration {
std::string name;
double gain = 1.0;
@@ -36,6 +56,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 +100,18 @@ 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 measurement gate's **bounds** -- where the one shared band sits, in the display x the
/// axes read, the cursors' coordinate space, so they restore as the same relative offsets
/// ("half a second ago to a tenth ago") whatever the stream is doing. Whether the gate is on
/// is *not* here: it is a per-measurement option (`MeasureOptions::gate`, in `measures`
/// below), and the band is shown exactly while some measurement is gated.
double gate_x0 = 0.0;
double gate_x1 = 0.0;
/// 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 +174,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 +200,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;
+7
View File
@@ -93,6 +93,13 @@ struct MeasureOptions {
/// -- a filtered window cannot use the LOD pyramid, because a bucket aggregates unfiltered samples --
/// so a non-zero corner costs a full raw walk.
double hf_fc = 0.0;
/// Every type: measure only inside the **gate** -- the one shaded band shared by every gated
/// measurement, clipped to the window this measurement would otherwise describe. The band is
/// carried by its own pair of draggable bounds on the plots (see `RowSet::gate_x0()`), and it
/// is shown while *any* measurement is gated: it is one area, and its bounds are one pair of
/// hands. Off by default; a measurement with it off reads the whole window and shares the
/// walks it always shared.
bool gate = false;
};
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
+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};
+390 -33
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cmath>
#include <span>
namespace udpscope {
namespace {
@@ -39,6 +40,12 @@ constexpr plot::u32 kSearchColor = 0x7FB2FFu;
/// around. Sixteen identical lines with no way to tell which of them the readout is quoting is a
/// picture that cannot be read, so the current hit is brighter than its neighbours.
constexpr plot::u32 kSearchCurrentColor = 0xE8D37Fu;
/// The measurement gate's colour: violet, a hue no instrument on screen uses -- the cursors'
/// yellow and cyan, the measurements' green, the trigger's red and a hit's blue all name
/// something else, and the gate's shaded band is the one area that must not read as any of
/// them. The fill is the same hue at the low alpha a region reads as "area of interest" through.
constexpr plot::u32 kGateColor = 0xB080FFu;
constexpr float kGateFillAlpha = 0.15f;
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
@@ -67,6 +74,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,32 +163,117 @@ 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();
}
void RowSet::toggle_maximize_figure_row(int fr) {
if (maximized_) {
// Back exactly as it was: every row's state first, then the arrangement it was indexed
// by, then the rebuild that deals the saved rows back into fresh plots.
for (int i = 0; i < saved_rows_ && i < kMaxRows; ++i) {
apply_row_state(storage_[static_cast<std::size_t>(i)],
saved_row_states_[static_cast<std::size_t>(i)]);
}
cells_per_row_ = saved_cells_;
cell_weights_ = saved_cell_weights_;
row_weights_ = saved_row_weights_;
rows_ = saved_rows_;
maximized_ = false;
saved_cells_.clear();
saved_cell_weights_.clear();
saved_row_weights_.clear();
saved_rows_ = 0;
rebuild_all();
return;
}
if (fr < 0 || fr >= figure_row_count()) return;
const int n = cells_in_row(fr);
const int first = first_plot_of_row(fr);
if (!(n > 0)) return;
// The arrangement and every row's persistent state, so the restore is the inverse and
// nothing the user plotted is lost to a view change.
for (int i = 0; i < kMaxRows; ++i) {
saved_row_states_[static_cast<std::size_t>(i)] =
row_state_of(storage_[static_cast<std::size_t>(i)]);
}
saved_cells_ = cells_per_row_;
saved_cell_weights_ = cell_weights_;
saved_row_weights_ = row_weights_;
saved_rows_ = rows_;
// The row's plots move to the front, in order: `rebuild_all()` re-deals `storage_[i]` to
// figure plot `i`, so a maximized figure row *is* those rows and nothing else. The states
// are copied from the saved snapshot, so a source row further down is not overwritten
// before it is read.
for (int k = 0; k < n; ++k) {
apply_row_state(storage_[static_cast<std::size_t>(k)],
saved_row_states_[static_cast<std::size_t>(first + k)]);
}
cells_per_row_.assign(1, n);
cell_weights_.assign(1, fr < static_cast<int>(saved_cell_weights_.size())
? saved_cell_weights_[static_cast<std::size_t>(fr)]
: std::vector<float>{});
// One figure row: equal weights of one. `apply_row_weights()` sizes the vector to the rows
// that exist, so this is the stated "one row takes the whole figure".
row_weights_.assign(1, 1.0f);
rows_ = std::min(n, static_cast<int>(kMaxRows));
maximized_ = true;
rebuild_all();
}
void RowSet::carry_row(Row& dst, const Row& src) {
dst.signal_name = src.signal_name;
dst.name = src.name;
dst.channel = src.channel;
dst.color_index = src.color_index;
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
// moved under a plan made for the old one.
dst.layer = src.layer;
dst.slot_alpha = src.slot_alpha;
dst.ruler_y = src.ruler_y;
dst.ruler_b_y = src.ruler_b_y;
// The slot's series stays where it is, but what it is bound to belonged to the row
// this state came *from*. `bind()` re-resolves every slot next frame; clearing it here
// is what stops the frame in between from drawing the moved row's data under the new
// row's legend.
for (SlotSeries& s : dst.slots) s.bind(nullptr);
// One spelling of "a row's persistent state": the save/restore the maximize toggle uses
// is the same copy, and a field added there is a field carried here.
apply_row_state(dst, row_state_of(src));
}
void RowSet::clear_row(Row& r) {
@@ -194,23 +289,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(
@@ -289,6 +481,21 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
// does not stop a drag -- the controller holds the marker for the gesture's life and follows the
// pointer between frames -- so excluding it from the hit test is the only place that can say no.
r.plot->set_cursor_selectable(r.trigger_marker, false);
// The measurement gate: one region per plot, parked until the gate is switched on (see
// `place_gate()`). Its bounds are the gate's own pair of instruments -- grabbable, the
// way a cursor is -- and its shaded fill is the area the user sees: the measurements
// describe what is inside it. The label names it, because a shaded band with no caption
// next to a plot full of readout marks is a band the user has to guess at.
plot::Region gate;
gate.x0 = kParked;
gate.x1 = kParked + 1.0;
gate.fill = plot::Color::hex(kGateColor, kGateFillAlpha);
gate.edge = plot::Color::hex(kGateColor);
gate.label = "gate";
r.gate_region = r.plot->add_region(gate);
// The crosshair's axis badges carry the set's flag onto the new plot: a rebuild keeps the
// setting, the way it keeps the cursor mode.
p.set_crosshair_axis_labels(crosshair_axis_labels_);
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
@@ -302,14 +509,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,7 +552,18 @@ 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);
// The gate too: a rebuild builds new plots, whose regions are parked until this
// restates the shared bounds on them -- a row added while the gate was on would
// otherwise be the one row without the band.
place_gate(r);
if (trigger_marker_on_) {
r.plot->set_cursor_x(r.trigger_marker, trigger_marker_x_, plot::CursorPlacement::Free);
}
@@ -701,6 +945,16 @@ void RowSet::place_cursors(Row& r, plot::CursorPlacement placement) {
}
void RowSet::set_cursors(double a, double b) {
if (a == cursor_a_x_ && b == cursor_b_x_) return;
cursor_a_x_ = a;
cursor_b_x_ = b;
// `Free`: the pair is the owner's already-committed position (a gesture in another window, or
// this window's own last sync); snapping it here would fight the hand still moving it.
for (int i = 0; i < rows_; ++i) place_cursors(row(i), plot::CursorPlacement::Free);
place_crossings();
}
void RowSet::set_cursor_mode(CursorMode mode) {
if (mode == cursor_mode_) return;
cursor_mode_ = mode;
@@ -813,6 +1067,7 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
const std::size_t mi = static_cast<std::size_t>(v++);
r.plot->set_cursor_x(r.measure_v[mi], m.value, plot::CursorPlacement::Free);
r.plot->set_cursor_color(r.measure_v[mi], plot::Color::hex(m.color));
r.plot->set_cursor_label(r.measure_v[mi], m.label);
}
}
// What the measurements left over is parked rather than kept: a stale readout at the old
@@ -822,6 +1077,7 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
}
for (; v < kMeasureMarks; ++v) {
r.plot->set_cursor_x(r.measure_v[static_cast<std::size_t>(v)], kParked);
r.plot->set_cursor_label(r.measure_v[static_cast<std::size_t>(v)], "");
}
}
}
@@ -915,10 +1171,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 +1210,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 +1356,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) {
@@ -1113,6 +1392,63 @@ void RowSet::sync_cursors(int drag_row) {
place_crossings();
}
void RowSet::place_gate(Row& r) {
if (!r.plot) return;
// Off is parked, the cursors' own convention for taking an instrument off screen: bounds
// the window cannot reach contribute no clipped rect, so nothing draws and -- because the
// controller's hit test reads the same clipped geometry -- nothing is grabbable either.
const double a = gate_on_ ? gate_x0_ : kParked;
const double b = gate_on_ ? gate_x1_ : kParked + 1.0;
r.plot->set_region(r.gate_region, a, b);
}
void RowSet::set_gate_visible(bool on) {
if (on && !gate_seeded_) reset_gate();
gate_on_ = on;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::set_gate(double x0, double x1) {
if (!std::isfinite(x0) || !std::isfinite(x1)) return;
gate_x0_ = std::min(x0, x1);
gate_x1_ = std::max(x0, x1);
gate_seeded_ = true;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::reset_gate() {
if (figure_ == nullptr) return;
const plot::Range x = figure_->x_link_range(x_group_);
if (!(x.span() > 0.0) || !std::isfinite(x.min) || !std::isfinite(x.max)) return;
// The middle half of whatever is on screen -- the same quarter/three-quarter answer
// `reset_cursors()` gives the vertical pair, so both instruments start in the same place
// a user looking for them knows to look.
gate_x0_ = x.min + 0.25 * x.span();
gate_x1_ = x.min + 0.75 * x.span();
gate_seeded_ = true;
for (int i = 0; i < kMaxRows; ++i) place_gate(row(i));
}
void RowSet::sync_gate(int drag_row) {
if (!gate_on_ || drag_row < 0 || drag_row >= rows_) return;
// With the gate off the plots hold the parked pair, whose bounds read back finite --
// adopting them would be the same echo `sync_cursors()` guards against.
Row& r = row(drag_row);
if (!r.plot) return;
const plot::Region& reg = r.plot->region(r.gate_region);
if (!std::isfinite(reg.x0) || !std::isfinite(reg.x1)) return;
if (reg.x0 == gate_x0_ && reg.x1 == gate_x1_) return;
gate_x0_ = reg.x0;
gate_x1_ = reg.x1;
gate_seeded_ = true;
// Every row's region is written from the shared bounds; the dragged row already holds
// them, and `set_region()` orders them, so a bound dragged across the other lands here
// as the swap the controller already made in its own plot.
for (int i = 0; i < rows_; ++i) {
if (i != drag_row) place_gate(row(i));
}
}
void RowSet::clamp_to_data() {
if (!figure_) return;
// The rows share one window, so they share one bound: the union of what they hold.
@@ -1183,6 +1519,19 @@ void RowSet::pin_event_window(double pre_s, double post_s) {
figure_->set_x_link_range(x_group_, {-pre_s, post_s});
}
void RowSet::pin_display_range(double x0, double x1) {
if (!(x1 > x0)) return;
window_s_ = std::max(1.0e-3, x1 - x0);
pinned_ = true;
pinned_pre_ = -x0;
pinned_post_ = x1;
if (!figure_) return;
// The same two statements `pin_event_window()` makes, from the range instead of the spans: the
// group stops following the live edge and is placed on the event's own window.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
figure_->set_x_link_range(x_group_, {x0, x1});
}
void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
@@ -1259,6 +1608,14 @@ void RowSet::set_cursors_visible(bool on) {
for (int i = 0; i < kMaxRows; ++i) place_cursors(row(i));
}
void RowSet::set_crosshair_axis_labels(bool on) {
crosshair_axis_labels_ = on;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (r.plot != nullptr) r.plot->set_crosshair_axis_labels(on);
}
}
void RowSet::reset_cursors() {
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
+189 -9
View File
@@ -133,6 +133,13 @@ class RowSet {
/// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0;
/// The measurement gate: one shaded interval per plot, whose bounds are the gate's own
/// pair of instruments -- not the cursors above, which stay what the cursors pane reads.
/// Every row carries its own `Region` because each plot shades its own axes rect, but
/// they all draw the one shared interval (`gate_x0_`/`gate_x1_` below), the way the
/// vertical cursors are one time on every row.
plot::u32 gate_region = 0;
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
std::array<plot::u32, kMeasureMarks> measure_h{};
@@ -155,8 +162,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 +211,36 @@ 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`.
/// One figure row filling the plot area, and back -- the "full screen this row" toggle.
///
/// The arrangement, the weights and **every row's state** are saved on the way in and
/// restored on the way out, so a maximize is a *view*: nothing a user plotted moves, and
/// nothing survives a restore that was not there before it. Volatile like a zoom -- the
/// session keeps the arrangement, not the view. Calling it while maximized restores, so a
/// click on the eye-catching button is its own way back; a figure row outside the current
/// arrangement (the maximized one, by definition a single row) is ignored when not
/// maximized.
void toggle_maximize_figure_row(int fr);
bool figures_maximized() const noexcept { return maximized_; }
/// 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)]; }
@@ -285,6 +341,11 @@ class RowSet {
void set_y_range(int row, double lo, double hi);
void set_cursors_visible(bool on);
/// The crosshair's axis badges (the y and x values the crosshair's lines meet the axes at,
/// drawn in the label strips): one flag for the whole set, restated to every new plot by
/// `install()` -- a rebuilt row keeps the setting, the way it keeps the cursor mode.
void set_crosshair_axis_labels(bool on);
bool crosshair_axis_labels() const noexcept { return crosshair_axis_labels_; }
/// What the cursors are (see `CursorMode`). Switching parks the instruments the mode
/// does not use rather than removing them: a `Plot` cannot drop a cursor.
void set_cursor_mode(CursorMode mode);
@@ -294,6 +355,16 @@ class RowSet {
void place_crossings();
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
/// Writes both vertical cursors at once: the two-time pair is one instrument with one owner
/// (the main window's row set), and a scope window is written from it each frame -- reading it
/// back from that window's own plots is what lets a drag *there* move the same pair.
void set_cursors(double a, double b);
/// The trigger marker's position and whether it is up: what a scope window is written from, the
/// same way it is written from the cursor pair.
double trigger_marker_x() const noexcept { return trigger_marker_x_; }
bool trigger_marker_visible() const noexcept { return trigger_marker_on_; }
/// The two vertical cursors' positions, in the **display** x the axis reads -- not stream
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
@@ -332,12 +403,20 @@ class RowSet {
/// One measurement's readout mark on a row: a *level* (drawn as a horizontal ruler) or a
/// *time* (a vertical marker). Which one a measurement needs is the measurement's own
/// business; the row is the one that plots its signal.
/// business; the row is the one that plots its signal. A time mark's `label` is the badge
/// text the plot draws beside it (its own time, the pane's formatter), so a column of
/// marks reads as numbers and not as bare lines; a level ruler writes its own value, so
/// its label stays empty.
struct MeasureMark {
int row = 0;
/// The signal the mark belongs to, so a *scope window* can find the row that plots it (its
/// row numbering is its own): a mark that never carried the name could only be placed in a
/// copy by index, which a window's own layout editor invalidates.
std::string signal;
bool level = false;
double value = 0.0;
plot::u32 color = 0;
std::string label;
};
/// Places the measurements' marks and parks every pool slot they do not use. The marks are
@@ -457,6 +536,33 @@ class RowSet {
/// Called once a frame from the tick, with `drag_row` < 0 when no cursor is held.
void sync_cursors(int drag_row);
// --- the measurement gate -------------------------------------------------
/// Shows (or hides) the gate on every row. Bounds that were never seeded are seeded
/// first (`reset_gate()`), so the first enable puts the band under the data instead of
/// parking it wherever zero is.
void set_gate_visible(bool on);
/// Whether the gate is on. One flag for the whole set, like the cursors' own: a per-row
/// copy is something a rebuild can clear behind the user's back.
bool gate_visible() const noexcept { return gate_on_; }
/// The gate's bounds, in the **display** x the axes read -- the cursors' own coordinate
/// space. A live view is newest-relative, so a gate left at `[-0.5, -0.1]` keeps meaning
/// "half a second ago to a tenth of a second ago" and the data flows under it; a held
/// event's stamps are edge-relative, so there the band names a fixed slice of the frozen
/// frame. Either way it stays where it was put, which is what a gate has to do.
double gate_x0() const noexcept { return gate_x0_; }
double gate_x1() const noexcept { return gate_x1_; }
/// Restates the gate's bounds (ordered, both finite) and redraws every row.
void set_gate(double x0, double x1);
/// Puts the gate over the middle half of the current x window -- where
/// `reset_cursors()` puts the vertical pair -- and remembers it as seeded.
void reset_gate();
/// Adopts the gate bounds a gesture is moving, on `drag_row` (the row whose controller
/// holds a region), and mirrors them to every other row. Same contract as
/// `sync_cursors()`: reading outside a drag reads our own echo. Called once a frame from
/// the tick, with `drag_row` < 0 when no region drag is open.
void sync_gate(int drag_row);
/// Puts both vertical cursors at a quarter and three quarters of the x window, and
/// the two rulers at a third and two thirds of each row's y range. Also switches the
/// cursors on, so the button cannot look dead.
@@ -478,6 +584,13 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s);
/// The same pin, stated as the **range** a second view mirrors: the caller has the display
/// range (`x0 < x1`) and not the spans -- a scope window takes `pinned_range()` from the main
/// window and hands it on. `pin_event_window()` takes *spans*, and passing it a range's signed
/// `min` turned the pin inside out (a scope in Held mode pinned to `[+pre, +post]`: no trace
/// and no x labels). A degenerate or inverted range pins nothing.
void pin_display_range(double x0, double x1);
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
@@ -548,6 +661,46 @@ class RowSet {
/// objects the traces point at, and those belong to the row's *position*, not to its
/// contents. `rebuild_all()` re-deals them straight after.
static void carry_row(Row& dst, const Row& src);
/// One row's persistent state, without the plot-bound `slots`: a `SlotSeries` owns a
/// `unique_ptr`, so a `Row` is not copy-assignable, and the state a save needs is exactly
/// what `carry_row()` copies. The maximize toggle saves it for every row on the way in and
/// re-applies it on the way out, so a view change moves no signal.
struct RowState {
std::array<std::string, kSlotsPerRow> signal_name;
std::array<std::string, kSlotsPerRow> name;
std::array<int, kSlotsPerRow> channel;
std::array<int, kSlotsPerRow> color_index;
std::array<int, kSlotsPerRow> layer;
std::array<float, kSlotsPerRow> slot_alpha;
double ruler_y = 0.0;
double ruler_b_y = 0.0;
};
static RowState row_state_of(const Row& r) {
RowState s;
s.signal_name = r.signal_name;
s.name = r.name;
s.channel = r.channel;
s.color_index = r.color_index;
s.layer = r.layer;
s.slot_alpha = r.slot_alpha;
s.ruler_y = r.ruler_y;
s.ruler_b_y = r.ruler_b_y;
return s;
}
static void apply_row_state(Row& r, const RowState& s) {
r.signal_name = s.signal_name;
r.name = s.name;
r.channel = s.channel;
r.color_index = s.color_index;
r.layer = s.layer;
r.slot_alpha = s.slot_alpha;
r.ruler_y = s.ruler_y;
r.ruler_b_y = s.ruler_b_y;
// What it was bound to belonged to the row this state came *from*; the tick's `bind()`
// re-resolves every slot next frame, and clearing stops the frame in between from
// drawing the moved row's data under the new row's legend (the `carry_row()` rule).
for (SlotSeries& slot : r.slots) slot.bind(nullptr);
}
/// Back to an empty row: nothing plotted, the default palette entries, no cursors.
static void clear_row(Row& r);
@@ -559,13 +712,22 @@ class RowSet {
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
/// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
/// Writes a row's gate region from the shared bounds -- parked outside the axis when the
/// gate is off, the cursors' own convention for taking an instrument off screen.
void place_gate(Row& r);
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;
@@ -592,6 +754,17 @@ class RowSet {
std::vector<plot::InvalidSpan> spans_;
CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false;
bool crosshair_axis_labels_ = true;
/// The maximize toggle's saved arrangement (see `toggle_maximize_figure_row()`): every
/// row's persistent state, the cells, the weights and the plot count, kept only while
/// `maximized_`. States, not `Row`s: a `Row` is not copyable (its slots own a
/// `unique_ptr`) and the figure handles belong to the position anyway.
bool maximized_ = false;
std::array<RowState, static_cast<std::size_t>(kMaxRows)> saved_row_states_{};
std::vector<int> saved_cells_;
std::vector<std::vector<float>> saved_cell_weights_;
std::vector<float> saved_row_weights_;
int saved_rows_ = 0;
bool relative_x_ = false;
double x_origin_ = 0.0;
/// What was last *written* to each row's own vertical cursors. A row is only evidence of a
@@ -605,6 +778,13 @@ class RowSet {
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
double cursor_a_x_ = 0.0;
double cursor_b_x_ = 0.0;
/// The gate's shared interval and its on/off flag (`gate_visible()`). `gate_seeded_`
/// distinguishes "never placed" from "placed at the window's own origin", which the
/// first enable needs: seeding must put the band over the data, not over x = 0.
bool gate_on_ = false;
bool gate_seeded_ = false;
double gate_x0_ = 0.0;
double gate_x1_ = 0.0;
std::array<bool, static_cast<std::size_t>(kMaxRows)> row_dirty_{};
};
+8
View File
@@ -3,6 +3,7 @@
#include "settings.hpp"
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <fstream>
@@ -64,8 +65,13 @@ 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 == "crosshair_axis_labels") {
s.crosshair_axis_labels = parse_bool(value, s.crosshair_axis_labels);
} else if (key == "contextual_help") {
s.contextual_help = parse_bool(value, s.contextual_help);
} else if (key == "tour_seen") {
@@ -83,12 +89,14 @@ bool save_settings(const Settings& settings) {
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
out << "event_cache_max = " << settings.event_cache_max << "\n";
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
out << "memory_limit_mb = " << settings.memory_limit_mb << "\n";
out << "theme = \"" << settings.theme << "\"\n";
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows --
// the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the
// second. `read_rows()` in the preferences starts from `load_settings()`, so a dialog save carries
// them through untouched.
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n";
out << "crosshair_axis_labels = " << (settings.crosshair_axis_labels ? "true" : "false") << "\n";
out << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
out.flush();
return out.good();
+11
View File
@@ -30,9 +30,20 @@ struct Settings {
/// buffers are planned before any stream is known, so it takes effect on the next start (the
/// preferences dialog says so, like the configurations dialog does).
bool relative_time = false;
/// The hard ceiling on the sample rings' memory, in MiB, for the whole application rather than per
/// source (`--memory-limit-mb` overrides it for one run). The planner halves a source's sample
/// budget until the rings fit under it. Unlike the cache directory and relative time it applies as
/// it is changed: a running scope re-plans its buffer (and so drops its history) when the new limit
/// changes the rings.
int memory_limit_mb = 8192;
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto";
/// The two small badges the crosshair writes in the label strips -- the y value where its
/// horizontal line meets the y axis, the x time where its vertical line meets the x axis.
/// **On by default**: it is the axis reading an oscilloscope puts where the eye already is,
/// and off is the decision that needs a reason. Applies as it changes, like the theme.
bool crosshair_axis_labels = true;
/// The contextual-help layer: a small "?" beside each surface it can explain. **Off by default** --
/// a first run shows the interface as a user expects it and the layer is asked for, from the Help
/// menu or from the chooser's own help menu, by whoever wants it; on is then a decision that
+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,
+139 -7
View File
@@ -11,6 +11,7 @@
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
namespace {
@@ -76,12 +77,17 @@ 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()>>();
bool updating = false;
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing
GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows
std::vector<GtkWidget*> results; ///< the result rows of the current scan
std::unique_ptr<Scan> scan; ///< non-null while a scan is running
guint timer = 0; ///< the GLib source polling `scan`, 0 when none
int scan_ms = 0; ///< time spent in the running scan
bool diff() const { return sel_of(mode) == 1; }
int enabled_ai() const {
@@ -115,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) {
@@ -150,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(
@@ -164,6 +172,24 @@ struct Form {
refresh();
}
/// A result click: puts one way of naming the board in and the other two fields out, under the
/// `updating` guard so the dialog is told once.
void fill(const std::string& addr, const std::string& mac_text) {
if (!alive) return;
updating = true;
gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str());
gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str());
gtk_editable_set_text(GTK_EDITABLE(subnet), "");
updating = false;
if (changed && *changed) (*changed)();
}
void stop_scan() {
if (timer != 0) g_source_remove(timer);
timer = 0;
scan.reset();
}
bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); }
};
@@ -185,7 +211,11 @@ void on_edited(GtkEditable*, gpointer user) {
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
/// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone.
void on_form_destroy(GtkWidget*, gpointer user) { (**static_cast<FormPtr*>(user)).alive = false; }
void on_form_destroy(GtkWidget*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
f.alive = false;
f.stop_scan(); // the poll must not outlive the rows it writes to
}
void hook_destroy(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form,
@@ -202,6 +232,96 @@ void hook_entry(GtkWidget* w, const FormPtr& f) {
static_cast<GConnectFlags>(0));
}
constexpr int kScanMs = 1500; ///< how long a scan listens
constexpr int kTickMs = 50; ///< how often it drains the sockets
/// A result row's two ways to name the board: what each fills into the form (the other is cleared).
struct Pick {
FormPtr form;
std::string value;
};
void free_pick(gpointer d, GClosure*) { delete static_cast<Pick*>(d); }
void on_pick_address(AdwActionRow*, gpointer user) {
Pick& p = *static_cast<Pick*>(user);
p.form->fill(p.value, "");
}
void on_pick_mac(GtkButton*, gpointer user) {
Pick& p = *static_cast<Pick*>(user);
p.form->fill("", p.value);
}
void add_result(const FormPtr& f, const DeviceInfo& d) {
const std::string mac = format_mac(d.mac);
GtkWidget* row = adw_action_row_new();
gtk_widget_set_name(row, "e1608-result");
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str());
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str());
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE);
GtkWidget* use_mac = gtk_button_new_with_label("Use MAC");
gtk_widget_set_name(use_mac, "e1608-use-mac");
gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER);
adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac);
g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address),
new Pick{f, d.ip}, free_pick, static_cast<GConnectFlags>(0));
g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, mac},
free_pick, static_cast<GConnectFlags>(0));
adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row);
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE);
f->results.push_back(row);
}
void set_scan_status(Form& f, const std::string& text) {
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str());
}
/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is
/// settled and the scan (its sockets) is released.
gboolean on_scan_tick(gpointer user) {
const FormPtr& fp = *static_cast<FormPtr*>(user);
Form& f = *fp;
if (!f.alive || !f.scan) {
f.timer = 0;
return G_SOURCE_REMOVE;
}
const auto& found = f.scan->poll(); // valid until f.scan.reset() below, after its last use
while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]);
f.scan_ms += kTickMs;
if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE;
if (found.empty())
set_scan_status(f, "No board found");
else if (found.size() == 1)
set_scan_status(f, "1 board found");
else
set_scan_status(f, std::to_string(found.size()) + " boards found");
f.scan.reset();
f.timer = 0;
return G_SOURCE_REMOVE;
}
void free_form_ptr(gpointer d) { delete static_cast<FormPtr*>(d); }
/// A new scan on the Port field's value: the previous results go, a running scan is replaced.
void start_scan(const FormPtr& fp) {
Form& f = *fp;
if (!f.alive) return;
f.stop_scan();
for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r);
f.results.clear();
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE);
f.scan = std::make_unique<Scan>(static_cast<int>(value_of(f.port)));
f.scan_ms = 0;
set_scan_status(f, "Scanning...");
f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp),
free_form_ptr);
}
void on_scan_clicked(GtkButton*, gpointer user) {
start_scan(*static_cast<FormPtr*>(user));
}
class E1608Kind final : public SourceKind {
public:
std::string id() const override { return "e1608"; }
@@ -238,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);
@@ -253,10 +374,20 @@ class E1608Kind final : public SourceKind {
f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3);
f->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3);
GtkWidget* conn = expander("Connection", {f->address, f->mac, f->subnet, f->port, f->code,
f->timeout});
f->scan_row = adw_expander_row_new();
gtk_widget_set_name(f->scan_row, "e1608-scan-row");
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network");
GtkWidget* scan_button = gtk_button_new_with_label("Scan");
gtk_widget_set_name(scan_button, "e1608-scan");
gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER);
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button);
g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f),
free_form, static_cast<GConnectFlags>(0));
GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
f->code, f->timeout});
GtkWidget* acq = expander("Acquisition", {f->rate, f->ext_clock, f->trigger, f->auto_restart,
f->calibrate, f->poll_rate});
f->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],
@@ -267,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})
@@ -283,7 +414,8 @@ class E1608Kind final : public SourceKind {
ConfigForm out;
out.rows = {conn, acq, ai, dio};
out.description =
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1)";
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), "
"or scan the network for boards";
out.focus = f->address;
out.write = [f](const SourceConfig& c) { f->write(c); };
out.read = [f]() { return f->read(); };
+85 -12
View File
@@ -9,11 +9,14 @@
#include <chrono>
#include <cstdio>
#include <cstring>
#include <ifaddrs.h>
#include <net/if.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
namespace udpscope::e1608 {
@@ -120,6 +123,25 @@ Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) {
return fd;
}
/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board
/// that `found` did not already hold (one entry per MAC).
bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from,
std::vector<DeviceInfo>& found) {
if (n < 29 || buf[0] != 'D') return false;
DeviceInfo info;
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
if (info.product_id != kProductId) return false;
std::copy(buf + 1, buf + 7, info.mac.begin());
for (const DeviceInfo& d : found) {
if (d.mac == info.mac) return false;
}
char ip[INET_ADDRSTRLEN] = {};
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
info.ip = ip;
found.push_back(info);
return true;
}
} // namespace
// --- addresses ------------------------------------------------------------------------------
@@ -222,22 +244,73 @@ std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int po
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
break;
}
if (n < 29 || buf[0] != 'D') continue;
DeviceInfo info;
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
if (info.product_id != kProductId) continue;
std::copy(buf + 1, buf + 7, info.mac.begin());
bool dup = false;
for (const DeviceInfo& d : found) dup = dup || d.mac == info.mac;
if (dup) continue;
char ip[INET_ADDRSTRLEN] = {};
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
info.ip = ip;
found.push_back(info);
take_reply(buf, n, from, found);
}
return found;
}
Scan::Scan(int port) {
ifaddrs* list = nullptr;
if (::getifaddrs(&list) != 0) return;
sockaddr_in to{};
to.sin_family = AF_INET;
to.sin_port = htons(static_cast<std::uint16_t>(port));
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
const std::uint8_t probe = 'D';
std::vector<std::string> done; // an interface with several addresses needs one probe, not several
for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) {
if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue;
const unsigned fl = a->ifa_flags;
if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue;
const std::string name = a->ifa_name;
if (std::find(done.begin(), done.end(), name) != done.end()) continue;
Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
if (!s.valid()) continue;
const int one = 1;
::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(),
static_cast<socklen_t>(name.size())) != 0) {
sockaddr_in me = *reinterpret_cast<const sockaddr_in*>(a->ifa_addr);
me.sin_port = 0;
if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&me), sizeof me) != 0) continue;
}
if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&to),
sizeof to) != 1)
continue;
done.push_back(name);
socks_.push_back(std::move(s));
}
::freeifaddrs(list);
}
const std::vector<DeviceInfo>& Scan::poll() {
for (const Fd& s : socks_) {
for (;;) {
std::uint8_t buf[128];
sockaddr_in from{};
socklen_t flen = sizeof from;
const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT,
reinterpret_cast<sockaddr*>(&from), &flen);
if (n < 0) {
if (errno == EINTR) continue;
break; // nothing more on this socket for now
}
take_reply(buf, n, from, found_);
}
}
return found_;
}
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms) {
Scan scan(port);
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
while (ms_left(deadline) > 0) {
scan.poll();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return scan.poll();
}
// --- Link -----------------------------------------------------------------------------------
void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) {
+24
View File
@@ -76,6 +76,30 @@ std::vector<std::string> sweep_targets(const std::string& subnet);
/// `kProductId`) and one entry per MAC.
std::vector<DeviceInfo> discover(const std::vector<std::string>& targets, int port, int wait_ms);
/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default
/// route only, so on a host with several interfaces it can miss a board on another one; this opens
/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or
/// to the interface address when binding to the device is refused), sends `D` to
/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the
/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan.
class Scan {
public:
explicit Scan(int port);
Scan(const Scan&) = delete;
Scan& operator=(const Scan&) = delete;
/// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC.
const std::vector<DeviceInfo>& poll();
/// How many interfaces a probe was sent on.
std::size_t interfaces() const noexcept { return socks_.size(); }
private:
std::vector<Fd> socks_;
std::vector<DeviceInfo> found_;
};
/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses.
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
/// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`).
/// Not thread safe; the source's producer thread owns it.
class Link {
+28 -4
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);
@@ -332,12 +358,10 @@ std::vector<SourcePanel> make_e1608_panels(DataSource& source) {
SourcePanel ao;
ao.id = "ao";
ao.title = "Analog out";
ao.icon = "audio-volume-high-symbolic";
ao.widget = build_ao(src);
SourcePanel dio;
dio.id = "dio";
dio.title = "Digital I/O";
dio.icon = "input-dialpad-symbolic";
dio.widget = build_dio(src);
return {ao, dio};
}
+37 -5
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 {
@@ -272,10 +275,15 @@ void E1608Source::connect_and_start() {
Mac want{};
const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want);
if (host.empty() || have_mac) {
const std::vector<std::string> targets =
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
const int wait_ms = std::min(cfg_.timeout_ms, 1500);
// No address and no subnet: the limited broadcast, sent on every interface rather than only
// the default route's.
const bool broadcast = host.empty() && cfg_.subnet.empty();
const std::vector<DeviceInfo> found =
discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500));
broadcast ? discover_broadcast(cfg_.port, wait_ms)
: discover(host.empty() ? sweep_targets(cfg_.subnet)
: std::vector<std::string>{host},
cfg_.port, wait_ms);
if (host.empty()) {
for (const DeviceInfo& d : found) {
if (d.mac == want) host = d.ip;
@@ -322,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;
@@ -404,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);
}
}
@@ -470,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_;
+1
View File
@@ -50,6 +50,7 @@ void register_builtin_sources() {
if (done) return;
done = true;
register_kind(make_udps_kind());
register_kind(make_tcps_kind());
register_kind(make_e1608_kind());
register_kind(make_flatudp_kind());
if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());
+2 -1
View File
@@ -21,8 +21,9 @@ const std::vector<SourceKind*>& kinds();
/// may drop. A later `dlopen` loader would only call `register_kind()`.
void register_builtin_sources();
/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`, `e1608/e1608_kind.cpp`, `flatudp/flatudp_kind.cpp`).
/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `tcps_kind.cpp`, `probe_source.cpp`, `e1608/e1608_kind.cpp`, `flatudp/flatudp_kind.cpp`).
std::unique_ptr<SourceKind> make_udps_kind();
std::unique_ptr<SourceKind> make_tcps_kind();
std::unique_ptr<SourceKind> make_probe_kind();
std::unique_ptr<SourceKind> make_e1608_kind();
std::unique_ptr<SourceKind> make_flatudp_kind();
-1
View File
@@ -38,7 +38,6 @@ struct ConfigForm {
struct SourcePanel {
std::string id; ///< stack page name
std::string title;
std::string icon; ///< icon name for the tab, may be empty
GtkWidget* widget = nullptr;
};
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/registry.hpp"
#include "source/udps_source.hpp"
#include <adwaita.h>
#include <memory>
namespace udpscope {
namespace {
using ChangeCb = std::shared_ptr<std::function<void()>>;
GtkWidget* entry_row(const char* title) {
GtkWidget* r = adw_entry_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
return r;
}
void on_edited(GtkEditable*, gpointer user) {
const auto& cb = *static_cast<ChangeCb*>(user);
if (cb && *cb) (*cb)();
}
void on_value(GObject*, GParamSpec*, gpointer user) {
const auto& cb = *static_cast<ChangeCb*>(user);
if (cb && *cb) (*cb)();
}
void hook_value(GtkWidget* spin, const ChangeCb& cb) {
g_signal_connect_data(spin, "notify::value", G_CALLBACK(on_value), new ChangeCb(cb),
+[](gpointer d, GClosure*) { delete static_cast<ChangeCb*>(d); },
static_cast<GConnectFlags>(0));
}
void hook(GtkWidget* entry, const ChangeCb& cb) {
g_signal_connect_data(entry, "changed", G_CALLBACK(on_edited), new ChangeCb(cb),
+[](gpointer d, GClosure*) { delete static_cast<ChangeCb*>(d); },
static_cast<GConnectFlags>(0));
}
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
class TcpsKind final : public SourceKind {
public:
std::string id() const override { return "tcps"; }
std::string display_name() const override { return "TCPStreamer"; }
std::string identity(const SourceConfig& cfg) const override {
std::string s = cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 44500));
if (!cfg.get("stream").empty()) s += "/" + cfg.get("stream");
return s;
}
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<UdpsSource>(cfg, UdpsSource::Transport::Tcp);
}
ConfigForm make_config_form() const override {
GtkWidget* server = entry_row("_Server");
GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1));
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port");
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE);
adw_spin_row_set_value(ADW_SPIN_ROW(port), 44500);
GtkWidget* stream = entry_row("S_tream name");
ChangeCb cb = std::make_shared<std::function<void()>>();
hook(server, cb);
hook_value(port, cb);
hook(stream, cb);
ConfigForm f;
f.rows = {server, port, stream};
f.description =
"Leave the stream name empty for a standalone TCPStreamer; name the stream for a TCPStreamService";
f.focus = server;
f.write = [=](const SourceConfig& c) {
gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str());
adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast<double>(c.get_int("port", 44500)));
gtk_editable_set_text(GTK_EDITABLE(stream), c.get("stream").c_str());
};
f.read = [=]() {
SourceConfig c;
c.set("server", text_of(server));
c.set("port", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(port)))));
if (!text_of(stream).empty()) c.set("stream", text_of(stream));
return c;
};
// A stream name is 1..255 bytes and, being the SUBSCRIBE payload, may not hold a comma
// (that would select a multiplexed list, which this kind does not support).
f.valid = [=]() {
const std::string s = text_of(stream);
return !text_of(server).empty() && s.size() <= 255 && s.find(',') == std::string::npos;
};
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return f;
}
};
} // namespace
std::unique_ptr<SourceKind> make_tcps_kind() { return std::make_unique<TcpsKind>(); }
} // namespace udpscope
+12 -5
View File
@@ -40,11 +40,14 @@ SignalInfo to_info(const udps_signal_t& s) {
} // namespace
UdpsSource::UdpsSource(const SourceConfig& cfg)
: server_(cfg.get("server", "127.0.0.1")),
port_(static_cast<std::uint16_t>(cfg.get_int("port", 8000))),
multicast_(cfg.get("multicast")),
interface_(cfg.get("interface")),
UdpsSource::UdpsSource(const SourceConfig& cfg, Transport transport)
: transport_(transport),
server_(cfg.get("server", "127.0.0.1")),
port_(static_cast<std::uint16_t>(
cfg.get_int("port", transport == Transport::Tcp ? 44500 : 8000))),
multicast_(transport == Transport::Tcp ? std::string() : cfg.get("multicast")),
interface_(transport == Transport::Tcp ? std::string() : cfg.get("interface")),
stream_(transport == Transport::Tcp ? cfg.get("stream") : std::string()),
recv_buffer_(static_cast<int>(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {}
UdpsSource::~UdpsSource() { close(); }
@@ -62,6 +65,10 @@ void UdpsSource::open(SourceSink& sink) {
c.silence_timeout_s = 2.0;
c.reconnect_delay_s = 0.5;
c.keepalive_interval_s = 1.0;
if (transport_ == Transport::Tcp) {
c.use_tcp = 1;
c.stream_name = stream_.empty() ? nullptr : stream_.c_str();
}
client_ = udps_client_create(&c);
if (client_ == nullptr) {
+6 -1
View File
@@ -18,7 +18,9 @@ namespace udpscope {
/// all protocol work stays in `deps/udpsclient`.
class UdpsSource final : public DataSource {
public:
explicit UdpsSource(const SourceConfig& cfg);
enum class Transport { Udp, Tcp };
explicit UdpsSource(const SourceConfig& cfg, Transport transport = Transport::Udp);
~UdpsSource() override;
void open(SourceSink& sink) override;
@@ -27,6 +29,7 @@ class UdpsSource final : public DataSource {
SourceStats stats() const override;
bool connected() const override;
void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); }
Transport transport() const { return transport_; }
private:
static void config_cb(const udps_signal_t* signals, std::uint32_t count,
@@ -36,10 +39,12 @@ class UdpsSource final : public DataSource {
static void datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time,
void* user);
Transport transport_ = Transport::Udp;
std::string server_;
std::uint16_t port_ = 8000;
std::string multicast_;
std::string interface_;
std::string stream_;
int recv_buffer_ = 8 * 1024 * 1024;
udps_client_t* client_ = nullptr;
+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);
+2006 -410
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>
+112 -112
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,103 +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 policy is `narrow` and the **titles are empty**: `narrow` still draws a label
beside an icon (measured: a 48px "Measure" inside a 54px pill), so an empty title is
what makes a pill an icon. The pages' *names* are what identify them to
`select_side_tab()` and the session file, and the words are re-attached to the pills in
code — tooltip and accessible name — by `name_side_tabs()` on the first tick, so an
icon-only pill is still a named thing rather than an anonymous button.
Each page's icon is a glyph of its own, and each one is a *pair*: the Measure tab is
`udpscope-tab-measure-symbolic` (the cursors and the ruler they read against), and the
Signals tab is `udpscope-tab-signals-symbolic` (the trigger's edge and the channel list
below it). They are drawn for the tabs rather than reusing the icons the toolbar and the
Preferences window already carry: a glyph the toolbar uses for one thing means two things
the moment a tab shows it too. -->
<!-- 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">narrow</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"></property>
<!-- The cursors and the ruler: what the pane brackets, and what it
reads the bracketed window with. -->
<property name="icon-name">udpscope-tab-measure-symbolic</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"></property>
<!-- The trigger and the channel list it acts on. -->
<property name="icon-name">udpscope-tab-signals-symbolic</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>
@@ -151,34 +95,90 @@
</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>
<!-- The trigger's own circle: green while an event is on screen (or a capture
is filling), flashing green while armed and waiting, red while disarmed,
and hidden while the trigger is off: a light for a mode the user is not
in is noise. Markup is written by the tick on a change of state only. -->
<object class="GtkLabel" id="trigger_status"/>
</child>
<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>
+4 -3
View File
@@ -9,9 +9,10 @@
readout row — so the rows are built and rebuilt in code, and this file holds the frame they land in
(`measures_list`) plus the three widgets whose state outlives them.
One statistics switch for the panel rather than one per reading: statistics are either what a user is
looking at or they are not, and a per-row switch is a column of chrome paid for on every row to answer a
question asked once.
One statistics switch for the panel rather than one per reading: statistics are either what a user
is looking at or they are not, and a per-row switch is a column of chrome paid for on every row to
answer a question asked once. The gate is the opposite case — a per-measurement choice — so it
lives in the measurement dialog, not here.
-->
<interface>
<object class="GtkBox" id="measures_panel">
+29 -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.
@@ -68,6 +68,13 @@
<property name="subtitle" translatable="yes">Take the plot's time from the sample rate and the packet counter rather than from the stream's own clock, so arrival and clock jitter cannot move a sample; absolute time is still kept for export. Read at start-up, because the buffers are planned before any source is known.</property>
</object>
</child>
<child>
<object class="AdwSwitchRow" id="crosshair_axis_labels_row">
<property name="title" translatable="yes">Crosshair _axis labels</property>
<property name="use-underline">True</property>
<property name="subtitle" translatable="yes">Show the value and the time where the crosshair's lines meet the axes, each in its axis' own format, in the strips the tick labels are written in. Applies at once.</property>
</object>
</child>
<child>
<object class="AdwComboRow" id="theme_row">
<property name="title" translatable="yes">_Theme</property>
@@ -77,6 +84,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>
+535 -90
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,10 +176,28 @@ 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.
GtkWidget* area_widget = nullptr;
/// The pointer's last position over the plot area, in the area's coordinates (the overlay's,
/// for the toggles' corner), and whether one is over it at all: the full-screen toggles are
/// revealed by proximity, so the frame tick reads this. The overlay's own motion controller
/// keeps it current, which is why a pointer *on* a revealed toggle still counts as over the
/// area -- the toggle is an overlay child, and the event still travels the ancestor.
double area_pointer_x = 0.0;
double area_pointer_y = 0.0;
bool area_pointer_valid = false;
plot::gtk4::PlotArea* area = nullptr;
/// The GL area wrapped in a `GtkOverlay`, which is what the plot box actually holds.
/// The layout editor's buttons are overlay children: each is a small widget placed by
@@ -135,6 +205,66 @@ struct App {
/// is -- a single transparent child covering the area would swallow every pan, drop and
/// hover the editor is not interested in.
GtkWidget* area_overlay = nullptr;
/// One extra top-level scope window: its own figure and `RowSet`, bound to the same
/// `ScopeModel` by the main window's tick -- which is also its repaint pump -- so a scope
/// is a second *view* of one acquisition, never a second acquisition. The main window's
/// x window (or its pin) is mirrored onto it every frame, so the two look at the same
/// span; its own controllers can still pan and zoom, and the mirror reclaims the span on
/// the next frame.
struct ScopeWindow {
GtkWindow* window = nullptr;
plot::gtk4::PlotArea* area = nullptr;
GtkWidget* widget = nullptr;
/// The window's short name, the one the signal page's *Plot on* groups carry ("plot 2" for a
/// copy, "scope 3" for an empty one): a button must say which window it plots into.
std::string name;
/// The drop target wired onto `widget`: the handler reads the *window* off it (each window's
/// target names its own scope), and the probe calls the handler with it directly.
GtkDropTarget* drop = nullptr;
/// This window came from a **detach** -- the figure left the main window to live here -- so it
/// carries the attach control and can be put back. A copied or empty window is a plain second
/// view, and has no attach control.
bool detached = false;
/// False while attaching: the window is closing *because* its figure went back to the main
/// window, so there is nothing to remember (remembering it would offer to reopen a window
/// whose figure is on screen again).
bool remember_on_close = true;
/// The overlay wrapping the GL widget: the corner controls (attach, the layout toggle, the
/// layout editor's own buttons) all live on it. Its motion controller keeps the pointer state
/// they are revealed by.
GtkWidget* overlay = nullptr;
/// Layout editing in this window: its own editor and toggle. A scope is a view with its own
/// arrangement, so it holds as many figure rows as it is given and edits them with the same
/// `+` / `×` / Done controls the main window's editor has -- its own, never the main window's.
bool layout_editing = false;
GtkWidget* layout_toggle = nullptr;
std::vector<GtkWidget*> layout_buttons;
GtkWidget* layout_done = nullptr;
int layout_buttons_rows = -1;
std::vector<GtkWidget*> attach_buttons;
double pointer_x = 0.0;
double pointer_y = 0.0;
bool pointer_valid = false;
/// Frames this window's area was watched for its first paint: a window whose GL context failed
/// or that never paints says so in its own title (and on stderr) instead of staying a blank
/// rectangle nobody can diagnose.
int paint_checks = 0;
/// The area's initial plot, owned here: `PlotArea::create()` wants a `Plot&` that
/// outlives the widget, and sharing the main window's placeholder would hand two areas
/// one plot the moment the figure mode is ever left.
plot::Plot placeholder;
std::unique_ptr<RowSet> rows;
};
std::vector<std::unique_ptr<ScopeWindow>> scopes;
/// The **closed** extra windows, as the session remembers them: a window's arrangement is kept
/// when it is closed (and across a restart), and the *Reopen closed windows* entry opens them --
/// a launch never does, because a window is a view the user closed on purpose.
std::vector<LayoutState::Window> closed_windows;
/// The menu entry that reopens them, so its label can carry the count.
GtkWidget* reopen_scopes_item = nullptr;
/// Every empty scope window is numbered from this, so two of them never share a name.
int scope_seq = 0;
/// On while the layout editor is up. Nothing else about the plot changes: the editor is
/// a set of overlay buttons, not a mode the figure knows about.
bool layout_editing = false;
@@ -142,10 +272,32 @@ struct App {
/// The editor's buttons, in the order they were added, so they can be taken off the
/// overlay again. Rebuilt when the row count changes, repositioned when the rows move.
std::vector<GtkWidget*> layout_buttons;
/// The full-screen toggles: one per figure row, on the overlay **always** (not only while
/// the editor is up), placed in each row's top-right corner. Rebuilt when the figure row
/// count changes (`maximize_buttons_rows` is the count they were built for).
std::vector<GtkWidget*> maximize_buttons;
int maximize_buttons_rows = -1;
/// The **detach** buttons: one per figure row beside its full-screen toggle, same reveal,
/// same rebuild cadence -- but only while the layout holds two or more figures (detaching the
/// only figure would leave the main window with nothing to draw) and never while maximized.
std::vector<GtkWidget*> detach_buttons;
/// The boundary a resize drag is on, and the weights it started from: a drag is *relative* to where it
/// 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 +311,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`).
@@ -181,6 +338,15 @@ struct App {
/// What the badge was last set to (`BadgePhase`), so the markup is rewritten on a
/// change of phase rather than on every one of the 60 frames a second.
int badge_phase = -1;
/// The trigger status circle beside it (`main-window.ui` `trigger_status`): green while an
/// event is held or a capture is filling, flashing green while armed and waiting, red while
/// disarmed, hidden while the trigger is off. Same width-locked logic as the badge: one
/// "●" glyph in every state, markup rewritten on a change of the state key only, and the
/// flash runs off the same half-second square the badge blinks on.
GtkWidget* trigger_status = nullptr;
int trigger_circle_state = -1;
/// The circle's state as of the last tick, for the exit dump (which cannot read widgets).
int dbg_trigger_circle = -1;
/// The display-window control: a header-bar button whose label is the current window
/// and whose popover holds the number, its unit and the two decade steppers. The label
/// is width-locked (see `kWindowLabelChars`) because the header bar sits above the plot
@@ -204,13 +370,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,23 +604,6 @@ struct App {
/// works, and every other line of the dump still passes.
std::string dbg_toolbar_icons;
std::string dbg_app_icons;
/// The two sidebar pages' own icons and whether the theme resolved them, read on the first tick
/// like the two lines above.
std::string dbg_page_icons;
/// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The label
/// count is the one that says the pages are icons rather than words: `narrow` hides them, and the
/// titles stay set because they are the pages' names for a screen reader and for the tooltip.
int dbg_switcher_pills = -1;
int dbg_switcher_labels = -1;
int dbg_switcher_names = -1;
/// The pills' icon as it is *rendered*: a 32x16 glyph in a 32px box is drawn 1:1, which is what
/// keeps its 2px bars crisp.
int dbg_switcher_icon_w = -1;
int dbg_switcher_icon_h = -1;
/// The switcher's own size, beside it: the pills carry 32px icon boxes now, so the tab bar's height
/// is what it costs.
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;
@@ -472,15 +619,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;
/// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped.
bool side_tabs_named = false;
/// 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
@@ -491,20 +637,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;
@@ -516,14 +658,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.
@@ -554,6 +696,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
@@ -592,6 +767,14 @@ struct App {
plot::Range measure_window{};
bool measure_window_valid = false;
bool measure_was_held = false;
/// The **gate band** the last pass saw, in the same stream seconds, clipped to the window the
/// pane was about. The gate is per measurement, but the band is one: when any measurement is
/// gated, a band that moves -- a drag, a seed, a mode change -- changes what *those*
/// measurements describe without the viewport or the pin moving, so the "it moved" question
/// is asked of this as well. Booked even when nothing is gated (then it is the pane's own
/// window, and the comparison below repeats one the viewport has already answered).
plot::Range measure_gate_window{};
bool measure_gate_window_valid = false;
/// The window **on screen**, in the same stream seconds, as of the last *tick* -- which is the
/// only thing `measure_window` is comparable with. Captured here rather than read back in the
/// dump for the reason every other dump field is (see verification.md): the dump runs from the
@@ -615,12 +798,15 @@ struct App {
/// `MeasureChunk`).
/// One channel's window, at one HF corner, with every level accumulator the pane asked for.
///
/// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement setting, so two
/// measurements on one signal at different corners are reading two different signals and cannot share a
/// walk.
/// Keyed by `(channel, fc, gated)` and not by channel alone: the corner and the gate are
/// per-measurement settings, so two measurements on one signal at different corners -- or one
/// gated and one not -- are reading two different windows and cannot share a walk. A gated
/// window is walked over the gate band clipped to the pane's window; an ungated one over the
/// pane's window whole.
struct MeasureWindow {
int channel = -1;
double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered
bool gated = false; ///< this window is the gate band, not the pane's window whole
MeasureStats stats;
std::vector<double> pct; ///< the level shares asked for, deduplicated
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct`
@@ -709,6 +895,30 @@ struct App {
/// probes add measurements, so they must not be scheduled in one run (see verification.md), and a
/// shared counter would make that a silent mis-step rather than a documented prohibition.
int dbg_bandavg_step = 0;
/// The gate probe's (`UDPSCOPE_PROBE_GATE_MS`) step counter and bookkeeping: the bounds it
/// set and the `s0` it read when it set them, so the second leg can say whether the band
/// stayed still while the stream's clock moved under it.
int dbg_gate_step = 0;
double dbg_gate_x0 = 0.0;
double dbg_gate_x1 = 0.0;
double dbg_gate_s0 = 0.0;
/// The gate probe's settle polls (`dbg_gate_step == 1` re-enters while the ring is younger
/// than the window, which is what its whole-periods arithmetic needs), bounded so a stalled
/// stream still measures and prints.
int dbg_gate_wait = 0;
/// The row a gate-region drag was on last frame, or -1. The fall to -1 is the drag's end,
/// and the tick saves the session there: the bounds are session state, and a drag that is
/// not saved is a session that lies about where the gate is.
int gate_drag_last = -1;
/// Which step of the crosshair probe runs next (`UDPSCOPE_PROBE_CROSSHAIR_MS`).
int dbg_crosshair_step = 0;
/// Which step of the maximize probe runs next (`UDPSCOPE_PROBE_MAXIMIZE_MS`).
int dbg_maximize_step = 0;
std::size_t dbg_maximize_single = 0;
int dbg_maximize_hidden = -1;
std::size_t dbg_detach_single = 0;
/// Which step of the scope-window probe runs next (`UDPSCOPE_PROBE_SCOPE_MS`).
int dbg_scope_step = 0;
int cursors_pane_mode = -1;
int cursors_pane_rows = -1;
unsigned cursors_pane_generation = ~0u;
@@ -730,29 +940,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.
@@ -851,12 +1058,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
@@ -1109,20 +1318,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;
@@ -1131,7 +1354,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,
@@ -1152,6 +1374,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
@@ -1336,6 +1561,10 @@ struct MeasureDialog {
GtkWidget* band = nullptr;
GtkWidget* above = nullptr;
GtkWidget* show = nullptr;
/// The per-measurement gate: measure only inside the one shared band. Type-independent --
/// the gate is about *which samples* answer, and every type reads samples -- so unlike the
/// rows above it is shown for every type and never retitled.
GtkWidget* gate = nullptr;
GtkWidget* window = nullptr;
/// The option rows' boxes and labels, so the dialog can *retitle* them per type: the same
/// switch means "the pulse is above the level" for a pulse and nothing at all for a period,
@@ -1445,6 +1674,9 @@ struct ExportContext {
};
struct FixedRangeContext {
App* app;
/// The row set the range applies to: `nullptr` is the main window's. A scope window's own
/// dialog writes its window's rows.
RowSet* rows = nullptr;
int row;
GtkWidget* lo;
GtkWidget* hi;
@@ -1514,11 +1746,31 @@ constexpr int kWindowLabelChars = 9;
constexpr int kRowKeyBias = 1;
constexpr int kLayoutButtonPx = 24;
constexpr int kLayoutButtonPad = 4;
/// How far from its corner a full-screen toggle comes out of hiding. The toggles are on the
/// screen only while the pointer is near them; this is the slack around the button's own rect
/// (a square corner zone a few button-widths wide), large enough that a pointer arrives without
/// having to find the exact pixel and small enough that crossing a plot's top edge leaves it
/// hidden.
constexpr int kMaximizeRevealPx = 48;
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 full-screen toggle's key: the figure row it acts on, +1 (0 is not a valid row). Its
/// own key because the button is on the overlay *always*, not only while the editor is up.
constexpr const char* kMaximizeKey = "udpscope-maximize-row";
/// 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"};
@@ -1542,9 +1794,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;
@@ -1571,8 +1823,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);
@@ -1590,6 +1842,9 @@ int window_unit_for(double s);
std::string format_window_value(double s, int unit);
std::string format_window(double s);
const char* badge_markup(BadgePhase phase);
/// The trigger status circle's markup, by state (see `trigger_circle_markup` in
/// `ui_status.cpp`): 0 solid green, 1/2 the flash's halves, 3 red.
const char* trigger_circle_markup(int state);
std::string format_measure(double v, const std::string& unit);
void set_notice(App& app, std::string what, int seconds = 4);
void set_row_key(GtkWidget* widget, int row);
@@ -1625,7 +1880,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);
@@ -1635,8 +1894,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);
@@ -1655,8 +1936,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);
@@ -1685,6 +1964,47 @@ 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);
/// The 1-based number of an active channel (its position among the acquired channels), or 0
/// when the signal is not acquired. The number the badge bar and the measurements pane show.
int measure_channel_number(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);
@@ -1768,8 +2088,41 @@ 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);
/// The same drop, onto a **scope window's** plot: the target carries the window, the row is the
/// window's own cell under the pointer, and the assignment lands in that window's row set.
gboolean on_scope_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
/// Plots `signal` on `row` of a scope window's row set (the signal page's *Plot on* buttons and the
/// drop target both end here). False, with a notice, when the signal cannot be acquired or the plot
/// holds four signals already.
bool assign_signal_to_scope(App& app, App::ScopeWindow& scope, std::size_t signal, int row);
/// Runs one keyboard accelerator. Scope windows carry the same shortcuts as the main window: the
/// view-level ones (`L` -- the layout editor) act on the window the key arrived in; the rest reach
/// the application through `run_app_action()`.
gboolean on_scope_key(GtkEventControllerKey* controller, guint keyval, guint keycode,
GdkModifierType state, gpointer user);
/// The session record of a window: its name, whether it was detached, and its plot rows.
LayoutState::Window scope_window_state(const App::ScopeWindow& scope);
/// Remembers a window as closed (`closed_windows`, replaced by name) -- what its destroy handler
/// does, and what keeps a closed window reopenable.
void remember_closed_window(App& app, const App::ScopeWindow& scope);
/// Opens a window from a remembered state: an empty scope window, then its rows, weights and
/// signals. Returns the opened window (nullptr when it could not be made).
App::ScopeWindow* open_scope_window_from_state(App& app, const LayoutState::Window& state);
/// Writes the main window's instruments -- the two cursors, the gate band, the trigger marker --
/// into every scope window, and adopts one dragged *there* back into the main window first. Called
/// once per frame from the main tick, after the main's own `sync_cursors()`/`sync_gate()`.
void sync_scope_instruments(App& app);
/// The main window's measurement marks, translated to each scope window's own rows (by signal
/// name) and placed there: a window that plots a measured signal draws its mark.
void mirror_measure_marks(App& app);
/// Refreshes the *Reopen closed windows* entry: its count and whether it is sensitive.
void sync_reopen_scopes_item(App& app);
/// Turns a scope window's own layout editor on or off, building or taking down its buttons.
void set_scope_layout_editing(App& app, App::ScopeWindow& scope, bool on);
/// The scope window's own right-click menu: the same row menu's actions, aimed at **that
/// window's** row set.
void on_scope_right_click(GtkGestureClick* gesture, int n_press, double x, double y,
gpointer user);
/// 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);
@@ -1785,13 +2138,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
@@ -1832,6 +2180,17 @@ 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);
/// Points the event cache at the configuration the session is on -- the active one when
/// `App::config_name` is empty -- and refreshes the two views of it when the directory changed.
/// Every configuration switch calls it next to setting `App::config_name`, so the list on screen
/// is always the configuration's own.
void retarget_event_store(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
@@ -1865,6 +2224,23 @@ gboolean mathwrap_probe_wait(gpointer user);
gboolean mathwrap_probe_report(gpointer user);
gboolean measure_probe(gpointer user);
gboolean levels_probe(gpointer user);
gboolean gate_probe_step(gpointer user);
gboolean crosshair_probe(gpointer user);
gboolean maximize_probe_step(gpointer user);
gboolean scope_probe_step(gpointer user);
/// A second top-level window showing the same stream through its own figure: `figure_row` >= 0
/// copies that figure of the main window (cells, widths, every slot's signal), -1 opens an
/// empty one-row scope. The main window's tick binds, acquires and repaints it.
void open_scope_window(App& app, int figure_row, bool detached = false);
/// Detaches figure row `fr` into a window of its own: the figure is copied into a new scope
/// window -- which is what makes the window *detached*, the one kind with an attach control --
/// and removed from the main window's rows. Refused, changing nothing, for a one-figure layout
/// or a maximized one: detaching then would leave the main window with nothing to draw.
void detach_figure(App& app, int fr);
/// Puts a detached window's figure back: a figure row is appended to the main window carrying
/// the window's arrangement (cells, weights, every slot's signal in its colour) and the window
/// closes. The window must be a detached one; anything else returns without changing.
void attach_scope_window(App& app, App::ScopeWindow& scope);
gboolean measurepin_probe(gpointer user);
gboolean measurepin_held_wait(gpointer user);
gboolean measurepin_pause_wait(gpointer user);
@@ -1872,8 +2248,36 @@ 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);
/// Retitles a page in place (`slideover_add_page`'s title, for a page whose subject changed: a signal
/// page carries its channel's number, and the stream can renumber). False when the id is unknown.
bool slideover_set_title(App& app, const std::string& id, const std::string& title);
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
@@ -1883,6 +2287,18 @@ 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.
gboolean e1608_scan_probe(gpointer user);
gboolean e1608_scan_probe_fill(gpointer user);
gboolean e1608_scan_probe_rescan(gpointer user);
/// The flat UDP (MARTe2 UDPSender) source: the signal-list codec and a hand-computed decode, then,
/// against `tools/mock_flatudp.py`, the stream, the value continuity, the wrong-size accounting and
/// the capture button (`_report`, a second later). `_gap` starts a 100 ms poll that waits for the
@@ -1892,6 +2308,13 @@ gboolean flatudp_probe(gpointer user);
gboolean flatudp_probe_report(gpointer user);
gboolean flatudp_probe_gap(gpointer user);
gboolean flatudp_probe_resumed(gpointer user);
/// The TCPStreamer kind: `tcps_probe` enables every signal, `tcps_probe_report` reads the stream
/// and the capture button a second later; `tcps_probe_reconnect` records a baseline and
/// `tcps_probe_reconnect_report` checks a reconnect moved the counters on.
gboolean tcps_probe(gpointer user);
gboolean tcps_probe_report(gpointer user);
gboolean tcps_probe_reconnect(gpointer user);
gboolean tcps_probe_reconnect_report(gpointer user);
gboolean pan_probe(gpointer user);
gboolean arm_probe(gpointer user);
gboolean trigrenum_probe_step3(gpointer user);
@@ -1907,10 +2330,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);
@@ -2060,6 +2500,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);
+561
View File
@@ -0,0 +1,561 @@
// 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, prefixed with
/// the channel's number -- the same number the measurements pane shows, so a badge and a
/// measurement row answer "which channel" with one word. 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());
// The channel's number, the same one the measurements pane carries: an active channel is
// "the k-th acquired channel", and the badge and the rows name it alike. A signal the
// stream does not carry yet (a badge can outlive its channel for a tick) shows unnumbered.
const int number = measure_channel_number(app, b.signal_name);
const std::string who = number > 0 ? std::to_string(number) + ". " + b.signal_name
: b.signal_name;
b.name = gtk_label_new(who.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();
+609
View File
@@ -0,0 +1,609 @@
// 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, one chip per
// measurement with its value and a `[+]` to add one), 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);
}
}
/// The strip's `[+]`: a new measurement, through the same dialog the pane's own `+` opens.
void on_add_measure(GtkButton*, gpointer user) {
open_measure_dialog(*static_cast<App*>(user), -1);
}
/// Fills the strip `main-window.ui` declares: the two groups 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);
// The chips, then the `[+]` after the last one: the compact strip is where the readings are
// watched, so that is where adding one belongs -- and it replaces the old "Measures" button,
// which only opened the panel any chip already opens its own measurement from (the dock and
// the page keep their Edit).
gtk_box_append(GTK_BOX(app.strip_measure_group), app.strip_measure_box);
gtk_box_append(GTK_BOX(app.strip_measure_group),
strip_button("+", G_CALLBACK(on_add_measure), &app, "add a measurement"));
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];
// The row's channel number, the same one the pane's row carries: the dock line says which
// channel it is about with one word, and the chip beside it says which colour.
const int number = measure_channel_number(app, m.signal);
const std::string who =
number > 0 ? std::to_string(number) + ". " + measure_type_name(m.type)
: std::string(measure_type_name(m.type));
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(who.c_str(), 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);
const std::string swho =
number > 0 ? std::to_string(number) + ". " + measure_type_name(m.type)
: std::string(measure_type_name(m.type));
GtkWidget* skind = fixed_label(swho.c_str(), 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
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more