diff --git a/CLAUDE.md b/CLAUDE.md index 312d49e..f0dc4e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this project is udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one -UDP stream with `deps/udpsclient`, buffers it in a large ring (default 200 MB), and draws it -with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of +data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered +source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 10 M samples per signal, under an 8 GiB memory limit), and draws it +with `deps/plotter` — a figure of up to eight plots in stacked rows (a row can be split into up to four side by side) sharing one x window. On top of the live view it provides the things a bench scope provides: a trigger with a pre/post event window, a pause that freezes the visible window, cursors, statistical measurements, user-defined math channels built in a node editor, and an export of the visible window to @@ -18,9 +19,14 @@ Two documents bound the work and are **not** interchangeable with this one: - **`README.md`** documents what was actually *built*, including deliberate deviations from the spec and known limitations. **Read it before concluding something is a bug.** +**Deliberate deviation from `SPECS.md` line 1:** the sample buffer is budgeted in *samples per signal* +(`--buffer-samples`, rounded down to a power of two), not in megabytes, under a separate hard +`--memory-limit-mb` (also a Preferences row). There is no `--buffer-mb`; see README and `SPECS.md`'s +"Deviations from the spec". + Three things shape almost every change here, and the linked files expand on each: -- **There are exactly two threads** — a producer that owns the UDP client and the GTK main +- **There are exactly two threads** — a producer that owns the data source and the GTK main thread that owns every widget and the GL context — and they share `ScopeModel` under one mutex. Samples cross through lock-free rings, never through the lock. - **There is no unit test suite.** The only automated check is the application's own headless @@ -38,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`). | +| [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. | @@ -60,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. | diff --git a/CMakeLists.txt b/CMakeLists.txt index e13e79f..042baa2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -151,7 +151,7 @@ add_custom_command( # The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the # 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on # generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle -# is small enough per file never to need it), so the build stays at its three warnings. The manual is +# is small enough per file never to need it), so the build stays at its one warning. The manual is # no longer compiled in, so the help bundle's own warning is gone with it. set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings") @@ -236,6 +236,20 @@ add_executable(udpscope src/waveform.cpp src/event_store.cpp src/acquisition.cpp + src/source/source_config.cpp + src/source/data_source.cpp + src/source/registry.cpp + src/source/udps_source.cpp + src/source/udps_kind.cpp + src/source/probe_source.cpp + src/source/e1608/e1608_link.cpp + src/source/e1608/e1608_device.cpp + src/source/e1608/e1608_source.cpp + src/source/e1608/e1608_kind.cpp + src/source/e1608/e1608_panels.cpp + src/source/flatudp/flatudp_signals.cpp + src/source/flatudp/flatudp_source.cpp + src/source/flatudp/flatudp_kind.cpp src/plot_host.cpp src/export.cpp src/search.cpp @@ -255,10 +269,15 @@ add_executable(udpscope src/ui_status.cpp src/ui_measure.cpp src/ui_cursors.cpp + src/ui_dock.cpp src/ui_math.cpp src/ui_layout.cpp + src/ui_source.cpp + src/ui_slideover.cpp + src/ui_badges.cpp src/ui_trigger.cpp src/ui_signals.cpp + src/ui_signal_page.cpp src/ui_context_help.cpp src/ui_tour.cpp ) diff --git a/README.md b/README.md index 1e3465d..e06ee7c 100644 --- a/README.md +++ b/README.md @@ -22,23 +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), -`--capture-packets N` (see "Packet capture" 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 @@ -69,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 @@ -92,16 +117,172 @@ 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:`) ``` -Threading: one producer thread owns the `udps_client_t` and calls -`udps_client_poll()`; the GTK main thread owns every widget and the GL contexts. +**The window.** The plot is the window. Under it sits a fixed-height **badge bar** (36 px). Its left +part is the **signals**: one badge per *enabled* signal (a dot in the signal's colour, filled while the +signal is plotted, and its name), then a `+` badge that opens the **Add a signal** page (`g` does the same). That page proposes only what can be +*added*: the streamed signals that are not acquired and the math channels that are not computed, each row with +the tick that acquires it (the row then leaves the page and the signal's badge appears), plus the `MATH` +divider's `+` for a new math channel; when every signal is acquired a dim "every signal is acquired" line +stands in for the list. While the bar has overflowed into the "N signals" badge the page lists the acquired +signals too, since their rows are then the only way to each signal's page. There is no signal lock: a +signal's page and the list always allow acquire, colour, calibration, the math editor and delete. +A signal badge opens that signal's own page in the slide-over -- the unit, gain and offset and the CONFIG's details directly under the name (aligned rows; a pencil beside the unit, gain and offset edits just that one -- a unit rename is stored per signal name, saved as `[[unit]]` and re-applied to every CONFIG), the colour and the *Plot on N* +buttons -- and is also the drag handle: drag it onto a plot to draw the signal there, or onto another +badge to reorder them. Past **six** signal badges the bar shows a single **"N signals"** badge in their +place (it opens the list, and `+` is hidden): the E-1608's 8 analog inputs and counter would not fit +otherwise. The signal badges sit in a scroller, so the bar's minimum width, and with it the window's, +never depends on how many signals there are. The badge order is kept **for the session only**; it is not +written to the session file. The right-hand end carries two words-only badges: **Trigger** (`off`, `armed`, `capturing`, `triggered` +or `disarmed`) and **Source** (the kind's name only -- the link state is the status bar's). **Source is shown only when the source +has panels of its own** (the E-1608's); for UDPS, Flat UDP and a file there is nothing for it to open, so it is hidden +(the bar keeps its height; the kind's name is in the status bar). A badge opens the **slide-over**: a 360 px +panel laid over the plot's right edge, opaque, with its own title, a *Close* button and one scroller per +page. The Trigger badge opens the trigger form; the Source badge (when shown) opens the source kind's own panels +(the E-1608's *Analog out* and *Digital I/O*, behind a segmented switch at the top of the panel). It +closes with a second click on its badge, with `Esc`, with *Close*, or with a click on the plot; it never +resizes the plot. The right-hand **dock** (the sidebar, **280 px**, fixed) holds the **readouts** and, under +them, the **Events** block (below): the readouts are a compact, read-only copy of the cursors table (a header row `A B delta`, then one line per cursor +line) and of the measurements (one line each: the signal's colour chip, the type, the value). It is +shown from the start and insensitive until the stream has signals. Each of the two blocks has an +**Edit** button, which opens the full panel as a slide-over page -- `cursors` or `measure` -- over the +plot; `m` toggles the `measure` page, and a click on a measurement's line in the dock opens that +measurement's dialog. The header's **Cursors** toggle only switches the cursors on and off (it +mirrors the switch inside the `cursors` page); with the cursors off the dock's cursor lines are +insensitive, not hidden. Both pages are insensitive until the stream has signals, like the dock. The dock's +cells are a fixed number of characters wide and ellipsized, so the dock's width and height do not +depend on what a value reads: the **who** cell of a cursor line is 5 characters (the full text is its +tooltip), a value cell 9 (its tooltip is the full text). The dock and the pages show **one string**: +`update_cursors_pane()` and `update_measures()` format each value once and hand that variable to the +page's label and to the dock's, so the two cannot disagree. **When the dock is not beside the plot** -- hidden +with the dock button, or the window narrow enough (< 900 px, "compact mode") for the dock to be a collapsed +overlay on the plot, open or not -- the same readouts appear on **one line under the plot**, above the badge bar (the +*readouts strip*): a **Cursors** button (opens the `cursors` page) and one chip per cursor line (its name and +its *delta* value), then a **Measures** button (opens the `measure` page) and one chip per measurement (the +signal's colour chip, the type, the value; a click opens its dialog). Each of the two groups is there only +with something to read -- the Cursors group with the cursors switched on, the Measures group with at least one +measurement, the separator with both -- and **with neither, the strip is not shown at all**. +It is a **fixed 26 px** high and its chips are as wide as the dock's cells (name 6 characters, type 10, value +9, the full text in the tooltip); what does not fit scrolls sideways with no bar taking height. With the +cursors off their chips are insensitive and read `--`, as the dock's cells do (the group is hidden then, so this is +not seen). It is the **third reader of the +same strings** (the setters that write the dock's labels write the strip's), and it is shown exactly when +`(!show-sidebar || collapsed)` **and** (the cursors are on **or** there is a measurement) -- a deliberate +exception to "disable, do not hide": an empty line under the plot is 26 px of nothing, and the dock, which keeps +both blocks on screen, is where the empty state is shown. In a wide window it is hidden while the dock is shown beside the plot (so the +plot never loses its 26 px to both), and in a collapsed window it stays on screen even with the dock overlaid, +so the measurements and cursors remain visible in compact mode. The **Events** block is a heading with a +**Manage** button over a short list of the cached events, newest first -- one compact row each, never a +picture -- and one click on a row replays that event (see "Captured events"). The full list, with the +pictures and the comparison actions, is the `events` slide-over page that **Manage** opens. There is no +events pane beside the plot and no Events button in the header: events never take any of the plot's width. +The rest of the redesign is listed under "Review findings not (yet) implemented". + +Threading: one producer thread owns the data source (the UDPStreamer client is the built-in +one) and calls `poll()`; the GTK main thread owns every widget and the GL contexts. They share `ScopeModel` under one mutex, which the producer takes once per DATA frame and the GUI thread takes once per frame around binding the traces. Samples cross the boundary through `plot::data::RingBuffer` (single producer, single consumer), never through the lock. +## Data sources + +What udpscope shows is fed by a *source kind*; the connection chooser's **Source type** row picks one and +swaps in that kind's own connection form. The built-in kind is **UDPS**, the UDPStreamer client this +document describes everywhere else, and it is the default: `--server`, `--port`, `--multicast` and +`--interface` populate its entries, and the same thing is spelled `--source udps --source-opt server=...`. +`--help` lists the registered kinds. A second kind, `probe` (a synthetic sine with one panel), is registered +only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set, and exists only for the self-check. + +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). 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 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 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 +its coefficient type, and the status word's bits, in particular whether the "scan running" bit stays set while the +board waits for a trigger (with auto-restart on, the source restarts a scan that reads as stopped only when no +trigger is configured, so as not to reset the wait for the edge; this is unverified on hardware). The DIFF input +numbering (channel + 8) and the filler byte after scan start are agreed by both references. The scan overrun count +is approximate: the board does not say how many scans it dropped, so the frame after a restart reports one lost. +Errors (no device, a rejected connection code, a timeout, the scan socket closing) are written to stderr as +`[e1608] ...` once per distinct message (an overrun is logged each time it happens); the window shows only that +the source is disconnected (the status popover reads `waiting`, and the panels go insensitive), not why. + +The third built-in kind is **Flat UDP**, a receiver for the MARTe2 `UDPSender` datasource: one datagram per +cycle, the signals concatenated in declaration order with no header, no padding and no timestamp, in the +sender's byte order. The datagram describes nothing, so **the signal list in the connection form is the +whole contract and is your responsibility**: one row per signal (a name, a type from `uint8` to `float64`, +and an element count), in the sender's order, including the `Trigger` byte an IndependentThread `UDPSender` +puts first. A row with a count above 1 is a vector, and a "Vector as" drop-down on that row says how to +show it: as **Channels** (the default), where `Wave:float32[16]` becomes 16 scalar channels `Wave[0]` .. +`Wave[15]` of one sample per cycle each, or as a **Waveform**, one channel that receives a block of 16 +consecutive samples per cycle (its declared rate is the cycle rate times the count). The waveform is written +`Wave:float32[16]w` in the `signals` text, and the same text is what a session file and `--source-opt +signals=` carry. A list with a waveform row may hold **only waveform rows, all of the same count** (a +scalar or a channels row beside one, or two lengths, would need zero padding or held values, so the form +refuses it and says so), and a list may make **at most 256 channels** (a scalar is one, a channels vector is +`count`, a waveform is one). The form's subtitle shows the channel total and the size the list implies +("6 channels, 13 bytes per datagram"), or the first rule the list breaks; a datagram of any other size is +dropped, counted as stale, and reported once per distinct size on stderr as `[flatudp] ...`. The form also +takes the port (44488, the sender's own default), an optional bind address, an optional multicast group with +the interface to join it on, the byte order, and the sender's **cycle rate in Hz, which is required**: it +sizes the ring and the ingest decimation and spaces the samples within a frame. Time is stamped by the +existing pipeline from arrival and that declared rate (and `--relative-time` gives a counted clock); there is +no timestamp in the stream to prefer. **Loss is never reported** -- with no counter in the datagram, a missed +cycle is indistinguishable from a slow one -- and neither is a changed sender: a list that does not match +reads as "everything dropped". Source-address filtering, detecting the list from the traffic and importing a +MARTe2 `.cfg` are not provided. A list with no waveform row is delivered in frames of about 10 ms +(`round(rate / 100)` cycles, at most 1024; a partial frame is flushed after 100 ms); a list of waveforms +delivers one frame per datagram. The source is connected on the first valid datagram and disconnected after +2 s without one. It is verified against `tools/mock_flatudp.py`, not against a MARTe2 application. Multicast +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. + +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. + +Planned, as its own sub-project: ABScope. It has more than one +timebase, so it will change the frame and the data path together rather than fit the present single-timebase +frame. + ## The x axis is relative to now **A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench @@ -612,8 +793,12 @@ The history cost of relative mode is measured rather than computed: | stream time (default) | **83.886 s** | | `--relative-time` | **41.943 s** | -Conditions: the mock at 100 kS/s, 4 channels at stride 1, the default `--buffer-mb 200`. -Relative mode retains **exactly half** the history — not the ~22% the original spec claimed, and +Conditions: the mock at 100 kS/s, 4 channels at stride 1, the old byte budget (`--buffer-mb 200`, since removed). **That +no longer happens**: the buffer is budgeted in samples per signal, so both modes retain the same span +(8 388 608 ticks at the default) and relative mode instead costs a third more *memory* (32 against 24 +bytes a tick with four channels; the `planner:` dump line in `docs/claude/probe-readings.md` reads it); only a memory limit that +bites clips it first. The paragraph below is the measurement that motivated it. +Under the old byte budget relative mode retained **exactly half** the history — not the ~22% the original spec claimed, and not the 75% a per-byte calculation suggests. The reason is the floor: relative mode keeps a second f64 ring for absolute time beside the stream clock's, so a tick's cost goes from `8 + 4×4 = 24` bytes to `16 + 4×4 = 32` bytes — a third more. But `Partition::time_capacity` is @@ -674,20 +859,36 @@ time to look at is still there: 20261009-123250-0001.bmp # a picture of it, drawn from the samples ``` -The **left pane** is opened on demand from the toolbar's event-history button, is collapsed by -default, and its **divider can be dragged**: it starts at 240 pixels and remembers the width you last -chose, and while it is closed it takes no room at all. Both the button and the pane exist while **recording is on**, or while an event is on -screen: with the cache disabled and nothing being examined there is nothing to show, and a visible -empty column beside the plot is what *off* must not look like. Recording is switched in the **trigger -panel**, with the trigger's other options rather than in the pane whose contents it decides, and it is -**off by default** -- writing every latch to disk is the user's decision about their disk. +Events are read in two places. The **dock's Events block** lists them compactly -- one row per event, newest +first, at most **24** with a dim "... N more, see Manage" row after them: a marker (a filled circle on the +event on screen, a hollow one on each member of the comparison or accumulation on screen), the time and +duration, and the event's sequence number (`#7`). **One click on a row replays it.** The block's **Manage** +button opens the **`events` page**, a slide-over page over the plot with the full list (below), the *Live* +button and the two group actions. The page replaces the left pane that used to sit beside the plot behind a +header button and a draggable divider; both are gone, and so is the toolbar's Events button -- opening the +list no longer resizes the plot at all. + +The block and the page exist all the time and are **insensitive, not hidden**, when there is nothing to show: +they are available while **recording is on**, while an event is on screen (a replay or a group), or while +the cache already holds events. **Deviation from the redesign spec,** which has the block insensitive +"outside trigger mode": a replay or a group *leaves* trigger mode (a file has no edge to wait for), so a block +that went grey outside trigger mode would grey itself under the very click that started the replay. Trigger +mode is therefore not one of the conditions. Recording is switched in the **trigger panel**, with the +trigger's other options rather than in the list whose contents it decides, and it is **off by default** -- +writing every latch to disk is the user's decision about their disk. (The session file keeps nothing about +this list: there is no remembered width, because there is no pane to size.) + +The dock follows every latch; the page's list is refreshed per latch only while it is open, and rebuilt when +**Manage** opens it. **The dock list has no thumbnails** -- its rows are fixed-width, ellipsized cells, so the +dock's size does not depend on what an event is called -- the page has them, and its rows are replayed with a +**double** click, because on the page a single click builds the selection that *Compare* and *Accumulate* read. Each row carries **its own picture** beside the numbers -- time, duration, signal count, rate -- 138 -pixels wide whatever the pane's width, and as tall as that event's own proportions make it: the +pixels wide whatever the page's width, and as tall as that event's own proportions make it: the preview is one 44-pixel strip per signal, so a one-signal event is a short row and a six-signal one a tall one (capped, so that a row stays a row). **Hovering it shows the whole preview at full size**, through a tooltip -- the small picture says *which* event this is, the zoom says what is in it. -Clicking a row **replays** it: the same loader `--open` uses, on the event's file. The click **leaves +Double-clicking a row on the page (or clicking one in the dock) **replays** it: the same loader `--open` uses, on the event's file. The click **leaves the list alone** -- your scroll position and the pair you had selected for a comparison both survive it, because marking which event is on screen rewrites the rows' titles in place instead of rebuilding the list. **The layout does @@ -698,7 +899,7 @@ rearrange the user's work under a click. What changes is the data, and the trigg file has no edge to wait for. Cursors, measurements, math channels and another export all work on a stored event. **Live** takes you back to the stream. -Two buttons under the pane's header read the **selection**: +Two buttons under the page's header read the **selection**: - *Compare* puts the two selected events in **two plots side by side**, each with **the layout you already have** -- the same rows, the same signals in the same slots, the same colours -- one showing @@ -716,8 +917,8 @@ model whose channel count no longer matches the stream), the trigger is left, an A replay leaves trigger mode, because a file has no edge to wait for -- an armed trigger keeps a *frozen capture* on screen (`display_mode()` stays `Held` while the model holds a snapshot), and a -held display draws that snapshot rather than the file that was just loaded. The events pane stays -open through it: it is available while a stored event is on screen. +held display draws that snapshot rather than the file that was just loaded. The dock's Events block and +the `events` page stay available through it: they are available while a stored event is on screen. **Going back to *Live* puts the live view back as it was.** Opening a file changes two things that are not the user's choice -- the time window becomes the event's own span, because that is what @@ -748,7 +949,7 @@ Three things worth knowing: - **The picture is decoded synchronously** (`gdk_texture_new_from_filename()` into `gtk_picture_set_paintable()`), not through `gtk_picture_set_filename()`: that path uses the image loader's sandboxed, asynchronous decoder, which here fails silently -- the picture keeps a - paint-able and an allocation, so a probe reports success while the pane shows an empty rectangle. + paint-able and an allocation, so a probe reports success while the page shows an empty rectangle. It is then **scaled once to the row's size** and cached by file: a `GtkPicture`'s natural size is its content, so the stored 480-pixel preview given straight to a row claims the row's whole width and squeezes the text out of it -- a size request on the picture is a floor, not a cap. @@ -761,12 +962,12 @@ Three things worth knowing: replayed samples age out of the ring as the stream refills it. - **Opening an event, a comparison or going back to *Live* is one re-plan of the buffer**, not one per channel. Each of those switches rebuilds the signal set, and a rebuild that moves the plan - allocates --- and so touches --- the whole of `--buffer-mb`; enabling a comparison's eight channels + allocates --- and so touches --- the whole of the planned rings; enabling a comparison's eight channels one at a time was eight of those, which is most of what made the switch feel slow. They are now enabled in a single call, and a rebuild whose plan is unchanged clears the buffer instead of allocating a new one. -- The switch in the pane's header stops the recording; the cache is only useful if it was already - on when the event you wanted happened, which is why it defaults to on. +- The switch in the trigger panel starts and stops the recording; the cache is only useful if it was + already on when the event you wanted happened, so switch it on before the event you are waiting for. ## Opening an exported waveform @@ -795,15 +996,15 @@ Only `.npy` is read: it is the format the item asked for and the one that carrie export is for other tools. **A recording longer than the buffer can hold at its own rate is resampled, never cut short.** The -file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-mb`: 20 s of ten -channels at 500 kS/s is 480 MB of rings, which a 200 MB buffer cannot hold. The loader therefore reads +file is pushed into the same ring a stream fills, and that ring is sized by `--buffer-samples`: 20 s of ten +channels at 500 kS/s is 10 M samples a channel, which the default 8.39 M cannot hold. The loader therefore reads the decimation the plan chose and pushes one stored sample per group of file rows, carrying the group's **mean** — the same anti-aliased filter the live ingest applies, so a long event opens as the whole recording at a lower sample rate instead of as a fraction of itself. It used to push every row regardless, which overran the ring and left the file as **its own tail**: a 20 s event opened showing only its last 4 s, with a correct-looking 20 s header line above it. The load line now names both numbers, `samples=31250 rows=1000000 decim=32`, so what is on screen is never in doubt; give the run a -larger `--buffer-mb` (the source's configuration carries one) to see such a file sample for sample. +larger `--buffer-samples` (the source's configuration carries one) to see such a file sample for sample. ## Named configurations @@ -825,18 +1026,18 @@ measurements — **and the settings that can only be decided at startup**, which here rather than only on the command line: ```toml -comment = "5 kHz sine, trigger on Ch1, 8 MB is plenty" -buffer_mb = 8 +comment = "5 kHz sine, trigger on Ch1, 256 k samples is plenty" +buffer_samples = 262144 relative_time = false capture_packets = 0 ``` -The **buffer size** is the one that matters most: two sources on one machine rarely want the same +The **buffer size** (samples per signal) is the one that matters most: two sources on one machine rarely want the same ring, and the value is applied before the model exists, which is the only moment it can be — by whichever of the chooser's rows opened the session, **and by `apply_stored_settings()` when the source was named on the command line**. A saved host's settings are read either way: opening `udpscope --server 10.0.0.5 --port 8000` gets that source's stored buffer size, clock and capture -ring, and a flag the user actually typed wins over the file (`--buffer-mb`, `--relative-time`, +ring, and a flag the user actually typed wins over the file (`--buffer-samples`, `--relative-time`, `--capture-packets`) because it is the more recent statement of intent. Switching configurations *inside* a running app applies everything that can change live (layout, plots, math, measurements, enables) and says so in the status line about the rest (buffer, clock, packet capture): they take effect on the next @@ -876,7 +1077,11 @@ applies as it is made — there is no Apply button, and a **Reset to defaults** dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a subtitle under it where the setting needs explaining, and the control on the right. A key that is absent means its default, so the file only mentions what changed, and a missing file is a first run -rather than an error. The directory field shows the directory in use: the default +rather than an error. The **Sample buffer** group holds the *Memory limit (MB)* row (`memory_limit_mb`, +default 8192): the hard ceiling on the ring storage for the whole application, which the planner halves the +sample budget to fit. It is the one planner input that changes while the app runs, so changing it +re-plans the live buffer --- dropping history only if the storage actually changes --- except while a +recording or a replay is on screen, where it is stored for the next plan. The directory field shows the directory in use: the default is filled in rather than described, and typing it back leaves the setting unset, so it goes on following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder button beside it, which writes what it found into the same field -- the field stays the one value, and @@ -900,6 +1105,9 @@ plots = 1 measure_stats = false [[plot]] +row = 0 +width = 1 +weight = 1 signals = ["Ch1", "", "", ""] [[calibration]] @@ -908,8 +1116,18 @@ gain = 2 offset = 0.5 ``` +Each `[[plot]]` table is one **plot** (one cell of the layout), in left-to-right, top-to-bottom order. +`row` is the figure row the plot sits in (0-based) and `width` its share of that row's width; `weight` is +the *row's* share of the figure's height, written on every plot of the row and read from the row's first +one. A session with no `row` keys -- every file written before a row could be split -- loads as one plot per +row, and so does one whose `row` numbers cannot be a layout (a number that skips or goes back, more than 4 +plots in a row or 8 in all): the file is never refused for it, and the signals stay where they were. See +"Splitting a row". + It also stores `measure_stats` (the measurements statistics toggle, a boolean, absent means -`false`). It is written (400 ms after the last change, and flushed on close) whenever the +`false`) and `dock` (whether the dock is shown, a boolean, absent means `true`; it replaced the +sidebar's `side_tab`, and a file that still carries `side_tab` loads, the key being ignored). The +open slide-over page is not stored: a panel that opens by itself at start-up would be a surprise. It is written (400 ms after the last change, and flushed on close) whenever the window, the layout, a drop or a calibration changes, and applied once, when the first CONFIG names the signals -- signal *names* are the identity, because a CONFIG renumbers and an enable/disable rebuilds the channel list. Signals the @@ -1054,18 +1272,29 @@ up, the same delay the first-run auto-start uses, the pending request starts the chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds them, and this dialog binds none. -**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot, -the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and +**The guided tour is six steps on a `GtkOverlay`** inserted above the window's content: the plot, +the badge bar, the trigger form, the dock, the signals and the status line. There is no popover anywhere in it and therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below and either side of the target -- that swallow the clicks that land on them. A stop declares the -**page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named -in `TourStop::page`), and the tour selects that page as the step is entered -- on the frame *after* -the switch, since a stack allocates a new page's widgets a frame late -- so the spotlight lands on a -widget the user had left behind on the other tab instead of on nothing. `gtk_widget_get_mapped()` -decides which steps exist at all: a stop on the *wrong page* is kept, because selecting its page -reveals it, while one whose *pane is collapsed* is dropped, because the stack is unmapped with it -(a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all -`is_visible()` walks -- so the old test walked over surfaces that were not on screen). The chooser's +**slide-over page it stands on** (the trigger step names `trigger`, in `TourStop::page`): the tour opens +that page as the step is entered, and a stop with no page *closes* the panel, so the card never points +at the status line over an open panel. It does not spotlight the form until the panel has finished +sliding in (about 120 ms, during which the surface is dimmed and the card waits in the corner), and the +hole is the target's bounds clipped by every scroller above it -- on the documented 756x470 window the +trigger form is taller than the panel, and the spotlight covers the part that is on screen, not the +badge bar under it. Skip, Escape and the last step close a panel the tour opened (an idle dismissal +leaves one the user opened alone). `gtk_widget_get_mapped()` +decides which steps exist at all: a stop on a slide-over page is always kept, because opening the page +reveals it, while one whose *pane is collapsed* (the dock, in a window too narrow to put it beside the +plot) is dropped, because it is unmapped with its pane (a hidden page keeps its own *visible* flag +true -- the widget's own flag, which is all `is_visible()` walks -- so the old test walked over +surfaces that were not on screen); a narrow window therefore walks five steps. The dock's stop +describes the readouts and their **Edit** buttons and the Events block under them (one click on a row +replays it, **Manage** opens the list); it names no slide-over page, so it closes the panel like the plot's. +There is no stop for the `events` page, so nothing in the tour opens it; the contextual help's +event-history hint is attached to the page's heading the way the cursors' and measurements' are. The cursors and measurements pages have no stop of their own, and their contextual +help "(i)" hints are attached to the pages' headings the way the trigger's and the signals' are: they +are drawn while the page is open, and the dock has none. The chooser's tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and stepping off it -- in either direction -- brings the *Sources* page back. @@ -1076,7 +1305,7 @@ same reason: a frame can arrive between two layout passes, and a hole that empti the spotlight and the card jump. The card goes under the target, over it, or in the column beside it -- whichever has room -- and only takes the bottom corner when the target *is* the whole content, which is what happens to the plot step in the documented headless run (a 640x480 Xvfb screen gives -the window its 756x894 minimum, and at that width the split view overlays the side panel instead of +the window its 756x894 minimum, and at that width the split view overlays the dock instead of putting it beside the plot). The probe prints the two rectangles (`spot=`, `card=`) rather than asserting a placement, so that case reads as the geometry it is. @@ -1112,11 +1341,11 @@ allocates one `BufferStore` per CONFIG: one shared time-base ring plus one value ring per channel (N enabled signals -- see the limitation on multi-element signals below). The spec's second buffer is the latched `EventSnapshot`, which is already an immutable copy of the captured window (see "Trigger" below); a second live ring -bought nothing and cost half the memory budget, so `--buffer-mb 200` really is -200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see +bought nothing and cost half the memory, so the planned bytes the status line quotes are +the whole of it. What it *would* buy is priced in the dump's `publish cost:` line — see "There is one live buffer, not two" under the review findings. -`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows -them with the rest of the budget. When a channel's declared sample rate is lower +`plan_partition()` sizes the rings to the sample budget (rounded down to a power of two, halved +until it fits the memory limit) and then decimates the shared clock until `--max-window` seconds fit. When a channel's declared sample rate is lower than the fastest channel's, it is decimated by the integer ratio (`Partition::stride`); when the requested window does not fit the budget, the shared clock is resampled too (`Partition::time_stride`, an averaging decimation — see @@ -1214,6 +1443,30 @@ back to the *previous* event, or to blank if this was the first. No event fired, latch counter does not move; a separate `ScopeModel::event_seq()` counts published snapshots, and that is what the frame tick keys its framing on. +**Force takes one acquisition now.** The trigger panel's **Force** button, beside +Arm/Disarm, is the scope's "capture this instant": for the condition that is not coming, +it opens a capture at the present moment with the configured pre/post window rather than +waiting for an edge. It is deliberately *not* a second publisher — `TriggerEngine::force()` +only puts the engine into the pending state an edge leaves it in, with the newest stored +stamp as the event's own time, and the ordinary latch does everything else: the cut, the +publish, the latch count, the marker at x = 0 and the framing. A forced acquisition is +therefore framed, pinned and recorded exactly like a latched one, down to the axis being +pinned from the snapshot's own stamps rather than from what was asked for (a request the +ring cannot reach back to — a 100 s window at 99 % pre — comes out seconds wide, which is +what the probe's second leg reads). + +Two choices are worth naming. The engine's pre-window gate is **skipped**: the history +behind "now" is whatever the ring holds, and refusing to capture it because the ring is +young would refuse the one thing the button is for. And a capture already filling is **not** +restarted — it is the acquisition being asked for, and restarting it would throw away the +post window it has collected; the click is ignored (and said so on stderr) in that state +only. What the trigger *is* afterwards is still the mode's decision, made where it always +was: Normal leaves it armed for the next edge, Single disarms, because one acquisition is +what the mode means whether an edge produced it or the button. The button lives in the body +of the pane rather than in the locked pane's heading beside Arm/Disarm: the heading's one +control is the runtime *state* a locked pane exists to change, while a force is an action +whose subject — the condition, the level, the pre/post split and the mode — is in the body. + The frame that **frames** a snapshot is the one that consumes its sequence number, not merely the first frame to see it. The tick reads `display_mode()` before it takes the model's mutex, while the producer publishes the snapshot inside that mutex and only lowers @@ -1233,6 +1486,62 @@ snapshot's own stamps. That re-basing machinery already existed (it was written frozen view and then never needed, since snapshots carry edge-relative stamps); this is what finally uses it. +**Multiple events: the last N acquisitions, dimmer with age.** Trigger mode can keep the last +`N` acquisitions on screen at once, each older one drawn at half the brightness of the one before +it (1, 0.5, 0.25, 0.125, …), which is what a phosphor display does with successive sweeps: they +are aligned on their own trigger edge, so the shape of the event repeats and whatever varies +between acquisitions — jitter, ringing, an occasional glitch — reads as a faint ghost behind the +current trace. Persistence in the trigger panel is the knob; `1`, the default, is +exactly the display without it, and the range stops at 8, where the eighth layer would be 0.8 % +ink that nothing can see. The older acquisitions are drawn *behind* the current one and are never +pinned: the axis, the framing and every measurement still describe the event on screen. +`N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained +snapshots there and then, because a trigger that has stopped firing never reaches another publish. + +**Persistence: one image per signal, every acquisition.** The trigger panel's `Advanced` block +carries the *other* display of the same acquisitions, and it is not a count at all: `persistence` +turns on a **phosphor density** — the plotter's intensity image (`Plot::set_density()`, the axes rect +in device pixels **per trace, in that trace's own signal colour**, bounded by the plotter's +`kDensityCellCap`: past **262 144 cells** — a mebibyte of counts — it scales the rect down uniformly +and magnifies the image back over it with a linear filter) +into which every triggered acquisition is deposited, the newest included, and which is drawn *in +place of* the traces. The current acquisition is therefore a sweep *inside* the image and fades with +the rest; no solid polyline is drawn over it, which is what a report of "the persistent image plus +the last acquisition, and the last one never fades in time mode" turned out to be. A cell's +brightness is how many acquisitions passed through it — one sweep's ink is the trace's own colour, +and where several agree it clamps there rather than going white — so the display brightens where the +signal is steady and dims where it only passed, **with no depth to choose and no memory that grows +with the acquisitions**: the cost is bounded, and the bound is the grid's — at most a mebibyte of +counts a signal, so the app's four signals hold 4 MiB between them however wide the window, and the +probe's four one-signal rows read `cells=1044714` (4 178 856 bytes, 1 044 714 of them uploaded on a +moved frame) — and a thousand acquisitions cost the same. Each signal is a separate image, so two +signals agreeing in a cell read as two colours and not one blended grey. `fades by` picks how it +decays: `acquisitions` (each acquisition keeps the factor of what was before it, 0.5 by default — +the phosphor afterglow schedule) or `time` (a time constant in stream seconds, so the display fades +while you watch whatever the trigger rate). It applies in **trigger mode only** — while a captured +acquisition is on screen — and it goes away with the trigger, on a pause and on an opened file, +because the image is an image *of* a stream of acquisitions. Like the invalid-span bands it reserves +no layout space and nothing on it can be picked. The `[density-probe]` run that measured it is in +`docs/claude/probe-readings.md`; what it costs is there and in the plotter's +`docs/data-and-performance.md`, where the density's own pass is priced headless at a fixed geometry — +the app's in-run A/B of it is a mean against a mean and the "before" side includes the run's own +startup frames. A 3040x1700 framebuffer with four rows of one trace and a 2 s window (the frozen +configuration: at 2x device scale that is a 1520x850 window) read **4.5 M cells a trace, 12.9 ms of +tick and 12.8 ms of draw a frame and 4.9 MB uploaded** on llvmpipe, and reads **1.0 M cells, 3.9 / +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 **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 +seven. A depth that the room or the budget cuts short is counted and said in a notice, rather than +quietly drawing fewer layers than were asked for. The layers are *not* signals: they hold no +channel, so the signal list, the colour chips, the calibration table, the layout file and every +measurement see one signal where the plot draws several traces of it — the same rule the event +*pane*'s accumulate view follows. + **The whole trigger path measures the window in stream seconds, and never in samples.** `TriggerEngine` is fed each sample's timestamp and latches once that clock has advanced past the post window; `publish_event()` then finds the slice covering @@ -1387,9 +1696,10 @@ it holds more than the window the bound became the whole ring, and a zoom-out wa view past the window into data the window was never set to show. A partly filled window simply has an empty left-hand side. -**The panel switches are on the toolbar, and the panes own their own controls.** Cursors, -Measurements and Math channels are icon toggles in the header bar rather than rows in the overflow -menu, and **Reset cursors** sits in the cursors pane beside the mode it resets (it acts on that +**The panel switches are on the toolbar, and the panes own their own controls.** (Since the +slide-over redesign the toolbar keeps only the **Cursors** toggle of the three icons named here, and it +only enables the cursors; the measurements are reached by `m` or the dock's Edit button, and the math +editor from the signal list.) **Reset cursors** sits in the cursors page beside the mode it resets (it acts on that pane's instruments and is only visible while they exist). The rows read, left to right: **window control, trigger, pause** — the trigger and the pause are read against the span on screen, so they sit with the control that sets it — then, on the right, **cursors, measurements, math, sidebar** and **the menu last**. With `HeaderBar::pack_end()` the @@ -1439,7 +1749,7 @@ enabled only to feed a math channel. several, and there was no way to see which sources the app already knew about. The chooser lists one row per stored session: the server and port it was named after, how many plots and signals it held, and its multicast group when it had one, newest first. `New host…` is a four-field form — -server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning +server, port, multicast group, interface — and nothing more: `--buffer-samples` and the rest are tuning rather than "which source", and a form that asks for them is a form you have to read before using the app. Cancel exits before anything is allocated, because declining to choose a source is not a failure. @@ -1638,6 +1948,35 @@ Measured on the same stream and window: tick mean **1.39 ms** with the pane hold measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every 400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this. +**...and the same pane costs what the *window* costs, which a big time scale buys.** The three +fixes above bound *how often* and *how much per sample*; what is left is the arithmetic of the +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-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: + +| pane | `each` | `raw` | tick mean | +|---|---|---|---| +| empty | — | — | 1.34 ms | +| 1 measurement, 1 level, 1 channel | 6.58 ms | 0.1 % | 1.99 ms | +| 4 measurements, 4 shares, 1 channel | 10.28 ms | 0.4 % | 2.38 ms | +| 16 measurements, 16 shares, 1 channel | 24.55 ms | 1.2 % | 3.86 ms | +| 4 measurements, 1 share each, **4 channels** | 35.69 ms | 2.6 % | 4.58 ms | + +So a machine's ten measured signals at a 15 s window is tens of milliseconds a pass, several +times a second, on the **tick** — the pane is a *frame* cost and not an ingest one (`gaps` and +`packets` stay clean even though each reader holds the model mutex, which is what "the plot is +slow with measurements on" actually is). One of those terms was a pure call-overhead defect: +pass two folds each bucket into each level through `measure_accumulate_bucket()`, the innermost +loop of a walk whose iteration count is the window's buckets *times* the pane's levels, and as an +out-of-line function in its own translation unit it cost **30 % of the pass** — sixteen shares on +one channel read `24.55 ms` a recompute with every value in `measurements:` unchanged at +**17.25 ms** once it was moved into `measure.hpp` as `inline`. The fetch's three out-of-line +pyramid accessors per bucket (3.3 ms of an 11.8 M-sample window's 11.6 ms pass) are the next +lever, and `raw` is the last one and belongs to the signal rather than to the pane. + **"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an `h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four measurements can put six lines over the data — that is the feature, and the lines move with the @@ -1757,9 +2096,9 @@ a sample and runs out on a clock. Widening only the clock keeps the cost where i the rings are the memory budget, and the values are the overwhelming majority of them. The one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count slots, because a time slot and a value sample are now different sizes — it weighs the -budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per -channel) and the power-of-two rounding absorbs the rest: a three-channel stream at -`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is +budget in **bytes** against the memory limit (`sizeof(f64)` for the shared clock plus `sizeof(f32)` per +channel); the sample budget itself is a count of ticks, so a three-channel stream at +`--buffer-samples 10M` retains the same 8.39 M ticks it did before. `EventSnapshot::stamps` is `f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto its edge and so are small, but a *pause* view is deliberately not rebased and carries full stream time, which quantised at exactly the rate the live ring did. @@ -2024,7 +2363,7 @@ exists; it is there rather than on the chip because a second gesture on a one-gl not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what made this row twice as tall once (below), and the gesture claims its sequence so a click on the -chip cannot become the start of a row drag. +chip cannot also activate the row (and open its page). A `GtkSwitch` and two always-visible buttons made a row about twice as tall (46 px against 26 px now), @@ -2033,7 +2372,7 @@ button and list-row minimums, not the widgets themselves, set that height, so th row carries a `.udpscope-compact` style class that relaxes them. **Signals start disabled**, because enabling one is what starts acquiring it. -Enable a signal with its checkbox, or drag it onto a plot: the drop enables the +Enable a signal with its checkbox, or drag its badge from the badge bar onto a plot: the drop enables the signal if needed, binds it to that row's first free trace slot, and rebuilds the figure so the legend name is right (there is one widget, so the row a drop lands on is `PlotArea::row_at()`). The status line reports the outcome of a drop @@ -2240,7 +2579,9 @@ out of the plot **while a gesture is on that row**, because a drag writes the *p the only place it is visible. Outside a gesture the plot is not read at all: `cursor.x` is the snapped value then, which is what the app wrote itself a frame earlier, and adopting that back is what made a dragged cursor spring to where it had been. A ruler is per row: each row has its own y axis, so two rows' rulers are -two different values, and the pane names the row. +two different values, and the pane names the row. In a split row (see "Splitting a row") the vertical pair is drawn in +every plot, and so is the horizontal pair: the plots of a split row share one y axis, so the pane lists one ruler +line for the whole row, and dragging a ruler in any plot moves it in all of them. `mix` needs no rulers: it reports the value the trace actually has at each marker, read through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing @@ -2402,7 +2743,7 @@ This reverses the earlier "the app does no decimation at all" decision, and keep reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data. The headless check is the same aliasing case with the filter in place: at -`--buffer-mb 1 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s +`--buffer-samples 65536 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost. @@ -2433,8 +2774,9 @@ full range — the snapshot's own span, which is legitimately *narrower* than th when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it back onto the stream. -**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one -`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group. +**Shared x axis.** The plots are the plots of **one `plot::Figure`** (one per row, or several side by +side in a split row, see "Splitting a row"), drawn by one `plot::gtk4::PlotArea` in figure mode, and they +all join a single x link group. A gesture changes the row it landed on; `Figure::layout()` adopts that change into the group and pushes it to the others, so panning or zooming any row moves them all with nothing to propagate by hand. The live follow is the *group's* @@ -2448,8 +2790,8 @@ The self-check's layout line carries `x-desync N`, the number of frames in which any row's x range differed from row 0's by more than a pixel of data. **It must read 0.** -`Figure`'s row count is fixed at construction, so there is one structural path: -a changed row count, or a changed legend name (the one thing a `Plot` cannot +`Figure`'s shape (its cells per row) is fixed at construction, so there is one structural path: +a changed row or cell count, or a changed legend name (the one thing a `Plot` cannot express), builds a new figure and the area is handed it. That costs no GL state — the widget keeps its context. A displayed event survives the rebuild because the pinned `[-pre, +post]` window is restated rather than re-derived from the live @@ -2480,6 +2822,44 @@ above row 2" are the same insertion, and two buttons there would sit one on top other. The `rows + 1` boundary buttons — above the first row and below the last included — reach every position the two-per-row reading offers. +### Splitting a row + +A row can be split into **two to four plots side by side**, up to **eight plots in all**. A split is +only an arrangement of more plots on the screen: **every plot shares the one time window** -- one x +link group, so a pan or zoom in any plot moves them all; one crosshair, drawn at the same time in every +plot; and one pair of vertical cursors, at the same two times in every plot. The plots of a row +also share **one y range**, the way the rows share one x window: the row has one y axis (auto or fixed), +drawn with its labels at the left of the first plot only, one pair of horizontal rulers drawn at the same +value in every plot, and one fit. A wheel, a drag, a box zoom, **Fit all** and the Auto/Fixed dialog act on +the whole row whichever plot they land in, and an auto-scaled row fits the union of its plots' traces (a plot +with none is ignored). The **bottom** row's plots carry the time labels, and so does any row above it whose +columns differ from the row below's (the `t0 = ...` caption goes with the labels, on each plot that draws +them); a row whose columns match the next one's leaves its labels to it. The left margin +holding the y labels is one width for the whole figure, so the plots of a row line up with the rows +above and below it; a plot whose labels are narrower simply has more margin. Only the first plot has a left +margin and only the last a right one, so the plots of a row get equal plot areas at equal weights and +neighbouring plots are exactly one column gap apart. + +In the **Layout editor** (`l`): + +- A **`+` at a row's left and right edge, and one between each two plots**, splits the row there: a new, + empty plot is added at that position and the plots after it move right *with their traces*. A one-cell + row can be split from either side. The `+` is dim at 4 plots in the row or 8 plots in all (the row `+`, + which adds a whole row, dims at 8 plots). +- A **double-headed (left-right) arrow between two plots** resizes the two neighbours against each other; neither can be + dragged below about 96 px. The plotter keeps 64 px as an absolute floor, and when the window is too + narrow for that it shrinks every plot proportionally and drops the gap, so nothing has a negative size. + The widths are saved when the drag ends. +- The **`×`** at a plot's top left removes that plot (and its traces) from a split row, and removes the + *row* from a one-plot row, as it always did. It is dim when that would leave no plot. + +Plots are named by their row and, in a split row, a letter: **`1`**, **`2a`**..**`2d`**. The names are +what the signals page's *Plot on* buttons (tooltip "row 2, cell 1 of 2"), the status notices ("plotting +'sine' on plot 2b") and the cursors pane (one ruler line per figure row, `row 2` counted from 1 for a split row; a plot of an +unsplit row stays `row N`, counted from 0 as before) show. A signal dragged from its badge lands on the plot under the +pointer, with the same free-slot rule as a row. The session file keeps the arrangement (`row` and `width` +on each `[[plot]]`, see "The session layout file"). + The buttons are `GtkOverlay` children placed by their margins over the figure's own row rects (`FigureLayout::rows`, divided by the area's device scale), deliberately **not** one transparent widget covering the plot: an overlay child is picked wherever its allocation @@ -2574,12 +2954,13 @@ reaches every widget). The full key table: |---|---| | `a` | arm / disarm the trigger | | `Space` | pause / resume | -| `t` | show / hide the trigger panel | -| `c` | show / hide cursors | -| `m` | show / hide measurements | -| `g` | show / hide math channels | +| `t` | enable / disable trigger mode | +| `c` | enable / disable the cursors | +| `m` | open or close the Measurements page (the `measure` slide-over page; the dock's Edit button for the cursors opens the `cursors` page) | +| `g` | go to Signals (open or close the signal list, the `signals` slide-over page, also reached by the `+` badge) | | `l` | show / hide the layout editor | -| `s` | show / hide the side panel | +| `s` | show / hide the dock | +| `Esc` | close the slide-over panel (the guided tour takes it first) | | `f` | fit to data (the plot's own key, not an application accelerator) | | `Ctrl+E` | export visible range | | `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer | @@ -2667,7 +3048,7 @@ now — they are listed so they are not mistaken for anything else: smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so what a second ring would buy is a smoother frame at a latch, not recovered packets. - **The buffer is allocated at the first CONFIG, not at initialisation.** - The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the + The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-samples` is the *budget*, and the power-of-two ring rounding means the final allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG, enable/disable and calibration re-partitions (and drops the history) because the @@ -2691,34 +3072,58 @@ now — they are listed so they are not mistaken for anything else: progress. What the spec was protecting still holds — a *held* event is bound to its snapshot alone, and the axis is the event's own window from the edge onwards rather than a sliding live view. -- **The sidebar is two tabs, and a math channel is a row of the signal list.** `SPECS.md` - names the panes as separate collapsible sections — trigger, cursors, measurements, math, - signal list — in one scrolling column; with four of them open in a 320 px sidebar every - section was short of room, and finding one meant scrolling past the others. The built - sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals** - tab (the trigger and the channels), **each a 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 @@ -2781,6 +3186,16 @@ silence timeout and then resumes, which is the EMI case end to end. ## Limitations +- **A remote display needs the software renderer, and the plot is empty until a signal is + enabled.** Over SSH with X11 forwarding there is no usable Vulkan device: GTK cannot create a + swapchain (`vkCreateSwapchainKHR … VK_ERROR_INITIALIZATION_FAILED`) and the window never appears + even though the stream connects and its CONFIG parses. `--renderer cairo` selects the software + preset for **both** halves — GTK's renderer and the plot's own GL context, which is a + `GtkGLArea` and asks for one regardless of what GSK draws with — and + `tools/udpscope-remote.sh` runs the app against a virtual X server with a VNC view carried over + an SSH tunnel, avoiding X11 forwarding altogether. If a window does appear but stays empty, check + that a **signal is enabled**: signals start disabled, so a session with none switched on plots + nothing and prints no `buffer allocated:` line, by design rather than as a fault. - **Multi-element (vector/matrix) signals are acquired with a zero-order hold.** `udps_client` decodes exactly one *sample* of `num_rows x num_cols` values per DATA packet for such a signal, regardless of the publish mode, so it cannot be @@ -2802,6 +3217,10 @@ silence timeout and then resumes, which is the EMI case end to end. rather than rebuilds, and why the frame tick reads the markers back: a parked cursor's snapped position is a real number, so nothing may be inferred from it. The trigger marker is separate: its own cursor, independent of the cursor toggle. +- **The slide-over shows a form taller than the plot by scrolling it.** The panel is as tall as the + plot and never taller, and each page has a scroller of its own, so on the documented 756x470 window + the trigger form (about 474 px) shows 253 px of itself. The tour's spotlight is clipped to that, and at + that size the card of the trigger step overlaps the panel's edge by about 18 px (`clear=0`). - **Verified headless only.** Acquisition, partitioning, the trigger state machine and its integration with the buffers, and rendering under Xvfb (llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs, @@ -2813,10 +3232,33 @@ silence timeout and then resumes, which is the EMI case end to end. inner popover takes the grab, the menu closes under the click, and the bubble is left parented to a row in a hidden popover. Export is described in the Documentation instead. +- **Over SSH with X11 forwarding the window may never paint or allocate, and the cure is + `--renderer cairo`.** There are two walls, in order. + GTK4 defaults to Vulkan and a forwarded display has no usable Vulkan device, so the + log shows `vkCreateSwapchainKHR(): Initialization of an object could not be completed + ... (VK_ERROR_INITIALIZATION_FAILED)` and nothing is drawn. `GSK_RENDERER` alone gets + past that, but the plot is a `GtkGLArea` and asks GDK for a GL context of its own + whatever GSK draws with; over forwarding there is no hardware one, and the window then + stays unallocated — `header: window_button_w=0` with a zero `bar_w`, and no + `[width-probe]` line (it prints only once the plot has a viewport wider than 100 px) — + while the stream side is fine. Absence of `buffer allocated:` is *not* part of that + reading: signals arrive disabled, so a start with nothing enabled plans no buffer and + prints no line even on a healthy display, and the plot stays empty until a signal's + switch is turned on. + `--renderer cairo` is the preset for exactly this display: it exports + `GSK_RENDERER=cairo` **and** `LIBGL_ALWAYS_SOFTWARE=1` (llvmpipe answers the GL area), + the same pair `tools/make-help-shots.sh` uses. `--renderer gl` is the faster + alternative when indirect GL works. The variables are exported in `main()` **before** + `gtk_init()`/`adw_init()`, which is the whole trap: they are read when the renderer and + the GL context are created, so setting them later is a silent no-op. Without the option + nothing is set and GTK's own choice stands. No `GDK_BACKEND` companion exists on + purpose: an X11-forwarded session carries `DISPLAY` and no `WAYLAND_DISPLAY`, so GDK + already selects X11; forcing it would override a working local Wayland session for no + gain (if both are set and Wayland is broken, export `GDK_BACKEND=x11` yourself). - **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. diff --git a/SPECS.md b/SPECS.md index 8609ae2..a4b5272 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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 diff --git a/deps/plotter b/deps/plotter index 3ad02e5..2cb31cb 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit 3ad02e5120b5e2b8fb0a6d1a3ff4661b1d07325c +Subproject commit 2cb31cbb91eefcac0e9479b8b3fb1da487d8288b diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 81451dd..f589b91 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 `● · · `, and the ⓘ beside it opens the +At the foot of the window, under the badge bar, it reads `● · · `, 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 diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index c97252b..6cddb85 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -10,8 +10,8 @@ concluding something is a bug. ### Threads -Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls -`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share +Exactly two: a producer thread in `Acquisition` owns a `DataSource` and calls +`source->poll(50)`; the GTK main thread owns every widget and GL context. They share `ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`. Samples themselves cross via `plot::data::RingBuffer` (single producer, single @@ -21,11 +21,169 @@ consumer) and never through the lock. Cross-thread scalars are atomics on `Scope before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a capture open sees which edge it belongs to. +### Sources + +What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*. + +- The kinds live in `src/source/`. `register_builtin_sources()` fills the registry explicitly -- + there is no static-initialiser registration, which a linker may drop -- and a future `dlopen` + loader would only call `register_kind()`. +- A `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`. `Frame` is + source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept. +- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained + at the top of `poll()`. A panel never calls a source directly. +- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in + `src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling). +- The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and + the data path together; do not bend one frame to carry two clocks. +- A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-`. 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 `-` (characters outside + `[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is + stored as `source_kind` and its settings as `source_opt_`. A UDPS file keeps `_` and + has neither key, so a legacy session is read as UDPS and nothing existing moves. +- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link` + (discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands, + factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is + `Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main + thread, the source's lookup goes through `discover_broadcast`); + `e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire + never reaches the UI files. +- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the + connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital + input/counter poll and the waveform tick (every `1 / gen_rate` s, 20 ms by default; one synchronous DAC write per running channel, so a slow board lowers the rate it achieves, which the Analog out panel reports). There is no second thread. A connect attempt (discovery, + handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each + step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end + the producer thread, and `drain_commands()` runs inside the same `try` for that reason. +- Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not + whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the + first one's. Digital lines and the counter are held across a block (a step trace). +- The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes + and a tick callback throttled to 100 ms reads; a panel never touches the source's internals, and writes + only through `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`, + so the source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per + retry); that line is where a user sees why a connection failed. +- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec, + the validity rules and the decoders, with no GTK and no sockets (the probe round-trips it); + `flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form, a signal-row + editor (a name, a type, a count and, while the count is above 1, a "Vector as" drop-down per row). The + datagram has no description, so a list that is wrong is invisible except as dropped datagrams: the size + check is the only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per + size. +- A vector row is shown one of two ways, chosen per row (`Row::wave`, the `w` suffix in the text). As + **channels** it is `count` scalar channels of one sample per cycle, so the list batches like an + all-scalar one. As a **waveform** it is one channel with `rows = count` and + `sampling_rate = rate * count`, one datagram per `Frame` (`num_samples = 1`). The two cannot share a + list because the model spans a frame by its largest element block and `Frame::value` is 0 past a scalar's + one value: a scalar beside a vector block would be zero-padded, so `problem()` demands that a list with a + waveform row be waveform rows of one count, and the form shows that message in the subtitle. The channel + total is capped at 256. +- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the + sample count varies): `round(rate / 100)` datagrams for a list with no waveform row, a partial block + flushed after 100 ms. `rate` is required because `sampling_rate = 0` makes the model size the ring and + the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping + path that ignores the declared cadence. +- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets + `Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the + history survives (`CONFIG` is sent once, from the first `poll()`). +- The form's row editor removes a row from inside its own button's `clicked` emission (GTK keeps the emitter + alive to the end of the emission, and the handler copies the form and the row out of its context first); + every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have + destroyed the sibling widgets the handlers reach by raw pointer. + +### The window's chrome: plot, slide-over, badge bar, dock + +The main window is a plot with three things around it, each a unit of `src/ui_*.cpp` and none of them +touching the data path. + +- **`SlideOver`** (`src/ui_slideover.cpp`) is one `GtkRevealer` holding a `GtkStack`, added as the second + child placed *over* the plot (after the layout editor's buttons) in `GtkOverlay#plot_overlay`. A page is + a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its + own and registered under its id (`trigger`, `source-`, `signals` (the signal list) and + `signal:` (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 `#`; **one click replays** it + (`replay_event()`, the body of the page's double-click handler). Both views are rebuilt from one + `EventStore::list()` by `refresh_events_pane()`, but on different cadences: **the dock follows every + latch** (`record_event()` refreshes it unconditionally), while the page's list is refreshed per latch only + while the page is open and by `open_events_page()` otherwise. `mark_events_on_screen()` updates titles and + markers in place and never rebuilds either list. The block, and the page, are insensitive (never hidden) + unless `sync_events_availability()` says `event_recording || replaying || events_cached` -- a different + rule from the spec's (see [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). + There is no events pane, divider or header toggle any more: the page is reached from Manage (or by a + caller of `open_events_page()`), and the plot is never squeezed by events. + +**When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour +overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over +too); the trigger form is registered as +a page by `build_trigger_panel`; `build_source_panels()` registers the source pages; and +`build_badge_bar()` runs *after* all of them, because the Source badge needs to know the first page. +Per-tick state (the Trigger word, the Source name, the open marks) is written by +`sync_badge_bar()`, called from `tick()` and from `slideover_open`/`slideover_close`; it writes a label +only when its text changed. Nothing in the badge bar or the slide-over may be read from the producer +thread. + ### Data path `ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one -value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds -within the byte budget. The producer always writes it, whatever the trigger is doing — +value ring per channel, sized by `plan_partition()`: `--buffer-samples` ticks (rounded down to a power of two) per ring, halved +until the real bytes fit `--memory-limit-mb` (`Partition::memory_limited` records a clip), and the shared +clock decimated until `--max-window` seconds fit. The producer always writes it, whatever the trigger is doing — that is what guarantees a full pre-trigger history on the *first* latch after arming. The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an immutable copy of the captured window; see README for why two live rings were dropped. @@ -101,12 +259,15 @@ Layers to keep straight: `ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair. - `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame to either a `ChannelBuffer` or an `EventSeries`. -- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added - and removed one at a time by the layout editor), each a - borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns - everything the rows have to agree on: the x window (one x link group every row joins), +- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` **plots** (rows are added + and removed one at a time by the layout editor, and a row can be split into up to + `kMaxCellsPerRow` cells side by side), each plot a + borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots; `row(i)` indexes the plots row-major, + `figure_row_count()` counts the rows. The figure owns + everything the plots have to agree on: the x window (one x link group every plot joins), the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since - `Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair. + `Figure::link_x()` refuses a `Live` member), the y range of each figure row's cells (`set_share_row_y`: a split + row has one y axis, one ruler pair and one fit), the left gutter and the crosshair. - `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index f52482d..2db2f58 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -13,8 +13,11 @@ cmake --build build ./build/udpscope --server 127.0.0.1 --port 8000 ./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9 +./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port ``` +`--help` lists the registered source kinds. + The app can be launched from the shell rather than only from a terminal, but only on request: ```sh @@ -96,13 +99,70 @@ prefix-wide target *is* that rule, reached through the `Help` component (`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every component, so its behaviour is unchanged. -Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per -buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row +Options: `--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; +`--server`, `--port`, `--multicast` and `--interface` populate the UDPS entries of the same options), +`--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples` (samples +per acquired signal, default 10M, k/M/G suffixes, rounded down to a power of two), +`--memory-limit-mb` (hard ceiling on the ring bytes, default 8192; also a Preferences row), +`--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards), `--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000, the value is required, and the popover button uses 1000 when the flag was absent). The flag is **not** persisted in the session file: persistence of the enable state belongs with `todo.md` item 3, alongside the relative-time toggle. +`--renderer cairo|gl|vulkan` exports `GSK_RENDERER` **at the top of `main()`** — +before `choose_source()`'s `adw_init()` and `run_ui()`'s, because GSK reads the variable when +it creates its renderer and setting it later is a silent no-op. `cairo` is the software preset +and also exports `LIBGL_ALWAYS_SOFTWARE=1`: the plot is a `GtkGLArea`, so a display with no +usable GPU fails twice, and this is the pair `tools/make-help-shots.sh` already sets. Nothing +is exported when the option is absent, so GTK's own choice stands. + +`--source e1608` selects the MCC E-1608 Ethernet DAQ; its settings are `--source-opt` keys, all optional +(`address` or `mac`+`subnet`, `port` 54211, `code` 0, `timeout_ms` 2000, `scan_rate` 1000 per channel, +`ext_clock`, `trigger` `none|rising|falling|high|low`, `auto_restart` 1, `calibrate` 1, `poll_rate` 10, +`gen_rate` 50 (Hz, 1 to 200: the waveform generator's DAC update rate), `mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all +drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1 +alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`, +`plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is +required; a MAC with no `address` is looked up by a broadcast sent on every interface (`Scan`), or by a sweep of +`subnet` (`a.b.c`). +The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol +mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine +per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S` +switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see +the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message. + +```sh +python3 tools/mock_e1608.py --port 54711 & +./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \ + --source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1 +``` + +`--source flatudp` receives the MARTe2 `UDPSender` datasource (one datagram per cycle: the signals +concatenated in declaration order, no header, no padding). The datagram carries **no description**, so the +settings are the whole contract: `port` 44488, `bind`, `multicast` and `iface` (a group to join and the local +interface for it), `rate` (the sender's cycle rate in Hz; **required**, it sizes the ring and the ingest +decimation and spaces the samples within a frame), `endian` `little|big`, and `signals` +`name:type[count][w],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`, e.g. +`Trigger:uint8,Ia:float32,Wave:float32[16]`. A count above 1 makes a vector signal, shown one of two ways: by +default as `count` **channels** `Wave[0]`..`Wave[15]` of one sample per cycle each (batched like any scalar +list); with a trailing `w` (`Wave:float32[16]w`) as ONE **waveform** channel receiving `count` consecutive +samples per cycle at `rate * count`, as a UDPStreamer block signal. A waveform row needs a count above 1, a +list with one holds only waveform rows of one count (no zero-padding of scalars), and a list may make at most +256 channels (a scalar 1, a `channels` row `count`, a waveform 1). A datagram of any other size than +the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as +`[flatudp] ...`. Loss is never reported: the stream has no counter. `tools/mock_flatudp.py` is a sender for +a signal spec (`--rate`, `--signals`, `--big-endian`, `--gap S,D`, `--bad-every S`, `--multicast G --mcast-if A`, +`--log FILE`). + +```sh +python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals 'Trigger:uint8,Ia:float32,Wave:int16[4]' & +./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \ + --source-opt 'signals=Trigger:uint8,Ia:float32,Wave:int16[4]' +``` + +(Quote the `signals` value: zsh globs the `[4]`. The mock takes the same text with or without the trailing `w`; +the datagram is the same, `w` only says how the app shows it.) `tools/mock_udpstreamer.py` has waveform-injection options used by the probes: @@ -209,6 +269,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 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 diff --git a/docs/claude/invariants-data-path.md b/docs/claude/invariants-data-path.md index aa1e1c1..1e439e3 100644 --- a/docs/claude/invariants-data-path.md +++ b/docs/claude/invariants-data-path.md @@ -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` holds a `std::vector>`, 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 diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 552231b..2214ac6 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -50,39 +50,25 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear runs after the window is destroyed and asking GTK about a widget then is four criticals, which is how the first version of that line earned them. What stays in the menu is what acts on the *data* (Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list - is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. -- **Seven of this app's icons are its own, and they are a `GResource` compiled into the + is the `signals` slide-over page, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. +- **An overlay handle that follows what it drags reports gesture offsets in its own moving + coordinates; add its displacement back.** A `GtkGestureDrag` measures its offset from where the + press began *in the widget's own coordinates*. The layout editor's column handle sits on the + divider and `sync_layout_editor()` moves it every frame, so after a few motion events the + offset is the pointer's travel **minus** the handle's travel, and the divider converges on half + the pointer's travel (`D = P - D`). Record the handle's x in the overlay's coordinates at + begin (`gtk_widget_compute_point`) and add `handle_now - handle_begin` to the offset on every update + (`layout_col_total_offset()`). The same applies to any future handle that is repositioned + from the state its own drag changes. +- **Six of this app's icons are its own, and they are a `GResource` compiled into the binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a measurement ruler, and the stock names those buttons used to carry said something else entirely: a *record dot* for the trigger and three different **magnifiers** for cursors, - measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,tab-measure,tab-signals}-symbolic.svg` - are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last + measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,resize-cols}-symbolic.svg` + are the six action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it -- an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is - the Preferences window's icon, not the tab's. - - The two **tabs** got glyphs of their own, and each is a *pair* rather than one of the icons above. - `udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two - bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed - frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and - `udpscope-tab-signals-symbolic` is a sine crossing its dashed level, beside two bold channel rows. They - are not reuses, because a glyph the toolbar already carries means two things the moment a tab shows it - too; instead the *trigger's* own icon (`udpscope-trigger-symbolic`) was redrawn to the Signals pill's - picture, so the pill and the button that turns the trigger on are recognisably about the same thing. - The wave is a **full period and not a hump**: a crest and a trough are what make it read as a wave - rather than as a hill, and the level's dashes survive between the crossings. (A hump was tried and - reverted — it named the "above the level" half of a trigger and lost the crossings, which is the half - that matters.) - Both are **32x16** composites drawn **bold** — one thick band, two thick dashes, few features — because - at this size a thin wave or a 1px tick reads as *texture* rather than as a shape, which is what "not - clear" looks like. Wider than tall on purpose: a 16px square cell carries one motif, not a pair. And - **GDK paints an icon into a square box whatever the document's aspect** (measured: - `gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg fits it to `32x16`), so the pair is - letterboxed rather than stretched — and the pill's image is given `pixel_size` 32 in `name_side_tabs()`, - which draws the artwork at the size it is written, so its bars stay crisp. Every glyph here is - **inset two pixels**, because art flush with the cell edge is what a button's padding or a crop takes a - pixel off, and a fractional scale is what would blur it, which is why nothing is drawn at 24 or 48. The - two tab glyphs measure 29% and 30% ink, inside the range the icons above them set. + the Preferences window's icon and the **Dock toggle's** (the ruler: the dock is the readouts). - **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them with `gtk_accelerator_parse()` — so they are `"space"`, `"e"`, `"slash"`, @@ -233,9 +219,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear entry points in `source_dialog.cpp` (`chooser_tour_begin`, `chooser_tour_autostart`), and *that* walk is what a first run meets first -- see the tour bullets in the help-affordance section below. - **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.** - Putting the events pane in a paned is what makes its width the user's to drag -- but with the - position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column - while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that + (The events pane that taught this is gone -- its list is a slide-over page now, with no divider -- but + the rule holds for any paned.) With the + position left alone, `shrink-start-child` or not, a closed side child's minimum is reserved as an empty + column, and the plot pays for a pane nobody opened. The position is the only knob that says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before* zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is still needed, or the minimum would clamp even a zero position. @@ -243,46 +230,49 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear = FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a 310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel - window. Every vertical revealer in the side panel hides this, because there the leftover goes down + window. (That pane was a revealer beside the plot; it is a slide-over page now, so no revealer sits in + a horizontal box any more.) Every vertical revealer in the side panel hid this, because there the leftover goes down instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane *is* open. The allocated width is how this was found -- the revealer's own measure said 0. - **The sidebar is a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher` - above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different - heights, so one shared scroller would leave a switch scrolled where the new page never asked to - be). The reason is the one this trap teaches: five sections competing for one 320 px column left - every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the - **cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the + **The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow` + (`dock_scroller`) holding only the compact **readouts** (`src/ui_dock.cpp`). It + replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source + can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one + this trap teaches: full panels competing for one narrow column each left every one of them short of room. + The full cursors and measurements panels are now the `cursors` and `measure` **slide-over pages**, reached + by the readouts' **Edit** buttons, and `m` toggles `measure` (it no longer scrolls the dock; + `dock_show_section()` is gone); the signal list is the `signals` slide-over page, which `g` and the `+` badge + open. The header's Cursors toggle no longer opens anything: it only enables the cursors. A *feature's* on/off is no longer a visibility at all -- the + **cursors'** toggle sets its pane's **body** insensitive (and the dock's cursor grid beside it) -- the readout has nothing to show with the instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch (see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured *before* it is switched on, so switching it off to change it, or to read what it is set to, is the ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a `0` there means the configuration has been locked out again. The - trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still - given the leftover width, so **ask about state, not about size** carries over unchanged. Every - allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not* - current -- a change in what the reading means, not a defect, and no functional code may depend on - those numbers. -- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.** - Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a - `GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab - bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to - `select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick - calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and - `AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's - **tooltip and accessible name** from the page's name, then hides the empty label that would otherwise - leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and - `switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once. -- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an - `AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the - list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides - the sidebar on `drag-begin` and restores it on `drag-end` — only when collapsed, because side by side the - plot is already visible and hiding the list the drag started in would be startling. `pane drag: sidebar - hidden for the drag=1 restored after it=1` is the reading; the state is forced collapsed the way the - sidebar probe forces it. -- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two - panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` + trap itself does not go away with the widget: a section scrolled out of view is still laid out, but + **ask about state, not about size** still holds for anything a tick reads. +- **The dock is shown but insensitive until the stream has signals ("disable, do not hide").** + `update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish + alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays + sensitive throughout. The `cursors` and `measure` **pages follow the same condition** through + `update_signal_availability()` (called from `update_side_visibility()` and the per-frame block), so a + page opened by `m` before the stream has signals is insensitive, not refused. `[readouts] ... pages_sensitive=` + reads it. The wish is persisted (`dock = true|false` in the session file) and a change of it + schedules a save. `dock:` in the dump reads the result. +- **The signal badge is the drag handle, and a drop handler only moves widgets.** Dragging a signal onto a plot + starts on its badge in the badge bar (a `GtkDragSource` on the badge's button, payload the signal index as a + decimal string, taken by the plot area's existing drop target); the list's rows are not drag sources. A drag + starting with the slide-over open would have part of the plot hidden under the panel, so the source's + `drag-begin` closes it (which only restyles the badges: nothing is destroyed mid-drag). A click without movement + never reaches the drag threshold and still opens the page; past it the source claims the sequence and the + button's click is cancelled. Each badge is also a `GtkDropTarget` for reordering: `on_badge_drop` calls + `gtk_box_reorder_child_after` and rewrites `App::badge_order` from the widgets, and **never rebuilds the badges** -- + the dragged badge and the target whose `drop` emission is running must both survive it. A foreign drag whose string is not a + signal index is refused. `[sigbadges]` reads the controllers, the payload and the reorder; `pane drag:` reads the close. +- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The + trigger pane's `[edit]` button is `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** — so the button that leaves configuration mode was blank, which no other reading could see: the button worked, the layout was unchanged, and only `pane edits:` (the two names and @@ -294,13 +284,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear `sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps: a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard - (`App::panel_switch_sync`) or the program's own write comes back as a click; and the switcher must + (`App::panel_switch_sync`) or the program's own write comes back as a click; and the switch must stay *out* of what the pane disables, because a control inside an insensitive parent cannot be clicked — a switch that greyed out with its own pane could never turn it back on. That is why the sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane. - **A list box outlives the children of the pane it sits in, and the teardown order is not yours to - choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the - list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four + choose.** Tearing the window down finalizes the events page's buttons, then its list box, and the + list's own `selected-rows-changed` then reaches a handler holding the freed pointers (`update_group_buttons()` + therefore keeps its `app.window == nullptr` guard): four `Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is *not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the @@ -317,22 +308,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear - **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's picture across `refresh_events_pane()`, which removes every row first: the next reader of that pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals - read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it. + read as "the page is empty"). A rebuild has to null what it is about to destroy, not only rebuild it + (`rebuild_events_pane_list()`, the page half of `refresh_events_pane()`, nulls `events_thumb` before it removes the page list's children). A handler on a + row that rebuilds the list destroys the widget it was emitted from: `outlive_the_rebuild(widget)`. The dock's + rows are a second list built from the same records; the dock row's click handler is safe only because + `replay_event()` never rebuilds the dock list. - **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the picture keeps a *paint-able* and takes an allocation, so a probe that checks - `gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event - pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this + `gtk_picture_get_paintable()` reads success while the page shows an empty rectangle (the event + page's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says so. A picture that must be on screen is also a picture with a **height request**: `can_shrink` alone asks for no height at all in a box. - **A cache that is the *only* source of a widget's content must be created by the thing that reads - it.** The event pane's thumbnails come from an `EventThumbCache` hung off the list box with + it.** The events page's thumbnails (the dock's rows have none) come from an `EventThumbCache` hung off the page's list box with `g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all. - Nothing ever created it — so for as long as the pane existed, **every** row came up without a + Nothing ever created it — so for as long as the list existed, **every** row came up without a picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache there is not a slow path, it is a blank one; `event_thumb_cache()` therefore creates one on first - use. Owned by the **list**, because nothing else has the pane's lifetime: textures of a list that + use. Owned by the **list**, because nothing else has the page's lifetime: textures of a list that no longer exists are so much held memory, and the destroy notify is where they are unref'd. - **Hover magnification is a tooltip, not a popover of ours.** A row's thumbnail is 138 px wide and the stored preview is 480: the small one says *which* event this is, and the zoom says what is in @@ -610,12 +605,39 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps `ui_impl::hint_button()` reached either by the table's `context_help_sync()` or by `context_help_button()` for a window with no `App` (the source chooser) -- one button, two callers, so the two windows cannot drift into two affordances. +- **A hint on a slide-over page's heading is attached all the time and drawn only while the page is open.** + The trigger's, signals', cursors' and measurements' headings (`trigger_head`, `signals_head`, + `cursors_head`, `measures_head`) live on pages that exist, unmapped, whether or not they are open, so + `context_help_sync()` attaches their "(i)" like any other and the count (`hints=12`) does not depend on + which page is up. There is **no second code path** and the dock's readouts carry no hint of their own: the + pages' hints say what the dock shows too (the cursors' and measurements' bodies mention the read-only copy + and the Edit button). A probe that must *see* one drawn needs its page open, like every other reading off a page. +- **The tour has one stop for the readouts and none for the pages behind them.** The dock's stop + (`side`, target `side_box`, no `page`, so it closes the panel) describes the readouts, the **Edit** + buttons and the click on a measurement; the `cursors` and `measure` pages are reached from there rather than + walked. A stop for either would be one more `TourStop::page` (see the next bullet), and the plan's + length is asserted (`steps=6`, `steps=5` with the dock put away) -- a new stop changes both. - **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim rectangles around the hole are ordinary widgets that swallow the clicks that land on them and nothing else; `Skip`, `Finish` and `Escape` hide them, and there is no popover, no gesture and no grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a later change can add back without noticing. +- **Click-away coexists with the plot's gestures because the observer never claims the sequence.** The + slide-over closes on a press anywhere outside it, and the plot underneath already owns pan, zoom, drop and + right-click. A `GtkGestureClick` added to `plot_overlay` in `GTK_PHASE_CAPTURE` sees the press before the + plot's own controllers and **does not call `gtk_gesture_set_state()`**, so the sequence stays unclaimed and + the plot's gestures run exactly as before; claiming it (or putting a full-area target over the plot) would + swallow the very pan the click was part of. The observer decides inside/outside by + `gtk_widget_compute_point()` into the revealer plus `gtk_widget_contains()`, never by pick. The press + that closes the panel still reaches the plot, which is accepted: it is one click and not a drag the user + started on purpose. `[slideover]` reads `click_inside_closes=0 click_outside_closes=1`. +- **`AdwToggleGroup` is the segmented switch** (libadwaita >= 1.7; the build asks for 1.8). It replaces the + `AdwViewSwitcher` for a source with several panels (the E-1608: Analog out | Digital I/O). Its toggles are + named by the slide-over page ids, and the group's `notify::active-name` is the *input*; writing it + ourselves (`slideover_open` on a source page) goes through `SlideOver::syncing`, or the program's own + write comes back as a click and re-opens the page it just opened. It is hidden unless a source page is + open and there are two or more, which `[badges] switch_visible=` reads. - **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves, @@ -624,15 +646,26 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is compiled once and the probe reads either window's state by the same fields. -- **A stop declares the page it stands on, and the tour selects that page as the stop is entered.** A - stop whose target lives on another page of a stack -- the window's `signals` (and `trigger`) stops - on the sidebar's `signals` page, the chooser's "Two kinds of input" stop on its `waveforms` page -- - names that page in `TourStop::page`, and `show_step()` selects it from `TourView::page_stack` - before anything is measured. The page switch is **not** followed by a one-shot measure: `show_step()` - selects the page and `tour_update()`, which already runs every frame, fills the spotlight in on the - frame *after*, when the new page has been laid out. A stack allocates the new page's widgets on the +- **A stop declares the page it stands on, and the tour selects that page as the stop is entered -- + by a hook in the main window and by a stack in the chooser.** The main window has no page stack any + more: its pages are the slide-over's. A `TourStop::page` there is a slide-over page id (`trigger`), and + `TourView::on_page(page_user, page)` opens it -- or, for a stop whose `page` is null, **closes** the + panel, so the card never points at the status line over an open panel. `tour_end()` calls + `on_page(nullptr)` *only if a walk was running* (so Skip, Esc and the last Next close a panel the tour + opened, while dismissing an idle tour does not close one the user opened). The chooser has no hook and + keeps `TourView::page_stack` (an `AdwViewStack`, selected by name). `select_view_page()` is the single + entry for both, and `show_step()` calls it before anything is measured. + Two rules follow from a panel that *slides*. The page switch is **not** followed by a one-shot measure: + `tour_update()`, which already runs every frame, fills the spotlight in on a later one, and for a stop + with a page it waits for `TourView::page_settled` (the slide-over's `gtk_revealer_get_child_revealed()`) + -- until then the target is moving, so the surface is dimmed with an empty hole. And **the hole is the + target's bounds clipped by every `GtkScrolledWindow` above it** (`tour_target_bounds()`): a form taller + than the panel's scroller overruns it, and an unclipped hole spotlights the badge bar and the status + line under the panel. A target scrolled out entirely is a *measured, empty* rectangle (the function + returns true), not a failure -- `tour_update()` zeroes the hole on it, because taking "false" for "not + measured yet" keeps the previous frame's hole standing. A stack allocates a new page's widgets on the frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation - bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- and reading the + bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- reading the page *name* back is fine immediately, because that is state and not geometry. - **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own @@ -641,11 +674,15 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps `gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when - its target is **mapped**, or when it names a page and the stack that page belongs to is mapped: a - wrong page leaves the stack mapped, so the stop is kept and the page switch reveals it; a collapsed - pane leaves the stack unmapped too, so no page can reveal it and the stop is dropped. The filter - lives in `tour_begin()`, not in each window's `start_tour()`, both so the two windows cannot drift - into two rules and because the rule needs the view's own `page_stack`. + its target is **mapped**; a stop that names a page of a view with an `on_page` hook is **always** + kept (the hook opens the page whatever the dock is doing, since the slide-over does not depend on it); + and in a view with a `page_stack` a stop that names a page is kept when the stack is mapped -- a wrong + page leaves the stack mapped, so the page switch reveals it, while a collapsed pane leaves it unmapped + too and the stop is dropped. So the dock's stop is dropped in a window narrow enough to overlay it, and + the main window's tour is five steps there (`[toursurface] collapsed: ... steps=5`: the signals stop names + the `signals` slide-over page, which does not depend on the dock either). The filter + lives in `tour_begin()`, not in each window's `start_tour()`, so the two windows cannot drift + into two rules. - **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top @@ -679,8 +716,9 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps - **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the - dump" rule again. `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger - summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0, and a crop + dump" rule again. `is_visible()` *and* `get_mapped()`, not `get_visible()`: a widget under a hidden parent -- the trigger + summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0 (and a widget on a closed slide-over page, `trigger_*` + while the panel is shut, would read a bogus `630,46,0,0` without the mapped test), and a crop box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a larger screen gives different rectangles for the same panels. diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md index 4540b0f..815d009 100644 --- a/docs/claude/invariants-math-channels.md +++ b/docs/claude/invariants-math-channels.md @@ -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 '': 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. diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md index 6dc1947..03c0c9b 100644 --- a/docs/claude/invariants-measurements.md +++ b/docs/claude/invariants-measurements.md @@ -118,6 +118,47 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear the pyramid for nothing. `measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say anything. +- **A pass costs the window's buckets times the pane's *measured channels* and its *distinct + levels*, and the window's own size multiplies all of it.** The cadence above bounds *how + often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at + the user's scale -- a **15 s** window, `--buffer-samples 100M` (so `time_stride` stays 1 and the + window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with + `[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading + `recomputes`/`each`/`covered`/`buckets`/`raw`: + + | pane | `each` | `raw` | tick mean | + |---|---|---|---| + | empty | -- | -- | 1.34 ms | + | 1 measurement, 1 level | 6.58 ms | 0.1 % | 1.99 ms | + | 4 measurements, 4 shares on **one** channel | 10.28 ms | 0.4 % | 2.38 ms | + | 16 measurements, 16 shares on one channel | 24.55 ms | 1.2 % | 3.86 ms | + | 4 measurements, 1 share each on **four** channels | 35.69 ms | 2.6 % | 4.58 ms | + + So a pane is priced per measured **channel** (one `MeasureWindowReader`, one fetch, one scan) + and per **distinct share** (one accumulator), and the window's samples are the multiplier on + both: the same pane over a 2 M-sample window is the 1.2--1.6 ms a recompute this pane's + budget was fixed at, and a machine's ten noisy channels at a 15 s window is **tens of ms a + pass**, several times a second. That lands on the **tick** -- `update_measures()` runs inside + it -- so `frame cost:`'s tick mean is where a pane's cost shows (1.34 -> 4.58 ms above), and + it is *not* an ingest problem: `gaps=`/`packets=` stay exactly clean even though each + `MeasureWindowReader` holds `model->mutex()` for its whole life, because a socket buffer + absorbs a few tens of ms of producer. Read the tick mean and `each` before blaming the + producer. + Inside a pass the terms are, instrumented per window at that scale: the **fetch** 3.3 ms + (three out-of-line `MinMaxPyramid` accessors and a 48-byte `MeasureBucket` per bucket), + `measure_scan_buckets()` 0.8 ms, and **pass two** 7.5 ms -- dominated by the per-(bucket, + level) fold. That fold is why `measure_accumulate_bucket()` is `inline` in `measure.hpp`: it + is the innermost loop of a walk whose iteration count is the window's buckets *times* the + pane's levels, and as an out-of-line call in its own translation unit it cost **30 % of the + pass** (sixteen shares on one channel: `24.55 -> 17.25 ms` a recompute). A caller adding a + per-bucket type keeps it in the header, and a caller adding a bulk one may not let it grow a + call per bucket either. + The third term is `raw`, and it is the **signal's**: a level that falls inside a bucket's + `[lo, hi]` has to be walked sample by sample (the fold cannot split a bucket), so a signal + whose local range covers the level everywhere -- white noise, a noisy machine channel -- + degrades the pass to O(window) *per level*: measured `raw=4.6 %` on the mock's `noise` channel + against `0.1 %` on its `sine` at the same window. It is the field to read before blaming the + pane for a slow run, and no cheaper walk fixes it. - **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets', not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30 harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index a82221b..903e658 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -1,6 +1,6 @@ # Invariants: the plot, the figure and the layout -How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content. +How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's and the slide-over's overlay children, and the rule that layout must not depend on content. Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. @@ -12,9 +12,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear from the paned's own width, re-parenting the column, the property form of its children, size requests): the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` = - `max-sidebar-width` = 320 in **pixels**, so the width is its own at any window size, and `collapsed` (set + `max-sidebar-width` = 280 in **pixels**, so the width is its own at any window size, and `collapsed` (set from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than - being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=320` under a 2x scale is exactly + being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=280` under a 2x scale is exactly the case that used to be zero. - **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading @@ -23,12 +23,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted` holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own - `set_active`), `update_side_visibility()` is the single place that turns that wish plus "are there signals" - into the panel's *and* the button's state together, and `tick()` re-asserts them when the widget disagrees — + `set_active`), `update_side_visibility()` is the single place that turns that wish into the panel's visibility + and "are there signals" into the dock's *sensitivity* (the button stays sensitive), and `tick()` re-asserts both when the widget disagrees — which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed. + The wish is the session's `dock` key: a change of it (not a program's own write, and not a restore) schedules a save. - **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever - `sidebar-width-unit` says.** Setting a pixel count there (320) is a `GObject` critical, and it silently + `sidebar-width-unit` says.** Setting a pixel count there (280) is a `GObject` critical, and it silently falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`. - **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the @@ -39,11 +40,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a taller figure. +- **Events never take the plot's width.** The events list is a slide-over page (an overlay child over the plot) and + the dock's Events block lives in the fixed 280 px column, so there is no events divider, revealer or pane beside the + plot to squeeze it; the first latch must not move the plot area (`[events] ... plot_w_stable=1`, which compares the + allocated width before and after). A dock row is a fixed-width ellipsized label (title at 18 characters, `#seq`), the + list is a fixed 112 px scroller capped at `kDockEventsMax` rows plus one "N more" row, and it has no thumbnails: the + dock's width and height do not depend on how many events there are or what they say ("layout must not depend on + content"). The thumbnails are the *page's* (138 px wide). + - **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one thing that forces a rebuild. Cursors are parked outside the axis range instead of removed. -- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds +- **A `Figure`'s shape (its cells per row) is fixed at construction**, so `RowSet::rebuild_all()` builds a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL state. That makes one path for "a row's traces changed" and "the row count changed", and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/ @@ -96,6 +105,153 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear an equal division. `sync_layout_editor()` runs every frame on purpose: `gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs the comparisons and nothing else, and a resize needs no notification. +- **A figure row can be split into cells, and every plot index in the host is the flattened one.** + `RowSet::row_count()` is the number of **plots** and `row(i)`, `Row::plot`, the cursors, the + measurements, `bind()`, the readouts and the persistence layers all index plots row-major (row 0's cells + left to right, then row 1's); `figure_row_count()` is the number of figure rows, and + `cells_in_row()`/`first_plot_of_row()`/`figure_row_of()`/`cell_of()` translate. **Code that means "the + rows of the figure" -- the editor's row `+`, the row drag, the row weights, `fl.rows` -- must say + `figure_row_count()`**: with no split the two are equal and a slip passes every unsplit probe, which + is how a row weight once drifted off its row (see the `row-weights` reading). The caps are + `kMaxCellsPerRow` = 4 per row and `kMaxRows` = 8 **plots** in all (the constant bounds plots now, not + figure rows), so every per-plot structure sized for 8 is unchanged. `plot_label()` is what the UI + prints for a plot: the row's number (`2`) for a one-cell row, the number and a letter (`2a`..`2d`) for + a split one -- never a flat index, which is 0-based and says nothing about where the plot is. + What the cells share and what each keeps: + - **One time window.** Every cell of every row is a member of the one x link group, so a pan or zoom + in any cell moves them all, the crosshair is one x drawn in every cell (the hovered cell shows its + y), and the **vertical cursor pair is one shared time drawn in every cell** (`cursors_aligned`). + A split is an arrangement of more plots, not a second time scale: nothing about the time axis changes + (see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)). + - **One y per figure row.** The cells of a figure row share one y range (the plotter's + `Figure::set_share_row_y(true)`, which `rebuild_all()` sets before the cells are populated): one y + axis (auto or fixed), one pair of horizontal rulers, one fit. A wheel or axis drag in any cell moves + the row, an x gesture on any cell that carries an x strip moves the shared window. The host therefore acts on the + **figure row** of a plot it is given (`set_y_auto`, `set_y_range`, `fit_all`, the Auto/Fixed dialog, + `row_unit`, which joins the units of every cell and reads "" if they differ). The rulers are stored on + the row's first plot index, copied to the other cells by `sync_cursors()` / `insert_cell()` / + `rebuild_all()` and placed in every cell by `place_cursors()`. The cursors pane lists **one ruler line + per figure row**: `row N` with N the 1-based figure row for a split row, and the 0-based plot index + for an unsplit row (the pane read that before splitting existed). Readouts and measurements stay per + trace and per plot index; only the axis is shared. + - **The x axis is the bottom row's for every run of rows with the same columns.** A figure row gets + `XStrip::None` only if the row directly below has the same columns (same cell count and same + normalised cell weights); a row whose columns differ keeps its own strip under each of its cells + (`[1][2]/[3]` has axes on 1, 2 and 3), because the axis below is at another width and scale. The + rule is structural and transitive, never a function of content. Every cell that carries a strip is + a place a middle-drag on the axis works. The `t0` caption belongs on every cell that carries a strip, + not only the last figure row's. + - **The right gutter is the last cell's.** In a split row with a shared y the plotter gives + `right_gutter` to the last cell alone and takes it off the row like the y gutter, so the visible gap + between two plots is `col_gap` and equal weights give equal axes widths. + - **One left gutter, on the first cell only.** The y gutter is one maximum for the whole figure with the + same reserved floor as before (`Figure::set_min_y_gutter()`). It is carried by the first cell of each + row alone: `Figure::layout` gives every other cell `YStrip::None` from its column index (structurally, + never from what it holds), which forces its y label width, title width and reserved floor to 0. A + `None` cell contributes 0 to the common maximum, so a split row's non-first cells cannot widen the + gutter and a row's gutter does not depend on how many cells it has. A per-column gutter sized by the + labels would make a cell's width depend on its content, which "layout must not depend on content" + forbids. A row whose labels are narrower than the gutter simply has a wider margin. The weights apply + to **plot widths**, not cell rects (below). + - **Widths are weights with a floor.** The plotter keeps 64 px logical per plot and a column gap + (`kColGap`, 6 px); the editor's drag is stricter (below). The floors and the weights act on **plot widths**: the row's y gutter (carried by the first + cell) is taken off the row width first, then the cells share the rest, so equal weights give equal plot + areas and the first cell's rect is its gutter plus its plot width. A weight is a share of what is + left *after* the gutter and the floors, so cells of `{3, 1}` read 524:229 (rects) in a 756 px + figure, not 3:1, and the editor's 96 px drag floor is a floor on the plot *width*, not on the share. Because a cell's width is + `64 + extra * weight / total` and not proportional to its weight, `layout_col_apply()` does not scale + the pair's weights by the width ratio: it keeps the pair's weight sum `S` from the drag's *start* + (`layout_col_drag_start`) and gives the left cell `S * (want_left - 64) / (pair - 128)`, clamped to + `[0.001, 0.999]` (the plotter refuses non-positive weights; `0.5` if the pair is not wider than 128). + `want_left` and the pair are plot widths (`layout_col_drag_begin` reads each plot's `layout().axes.w`, after a `layout()` on the current figure, so the gutter and the inter-axes gap are never counted as weight); `want_left` is the pointer's width clamped to `[96, pair - 96]` logical px. The divider therefore + follows the pointer, the shrinking cell reads 96 px at the extreme and the pair keeps its total width + (the split probe's `col-drag` line asserts all three). `kPlotterMinCellPx` (64) mirrors the plotter's + `kMinCellWidthLogical`; both are logical px (the plotter scales it by `ui_scale`, which is + `PlotArea::device_scale()`, the divisor used to turn layout rects into logical px), so the two + must change together. + - **A cell is removed with its traces, and a new cell is empty.** `insert_cell(fr, at)` pushes the + cells at and after `at` right *with their contents* (the same rule as `insert_row()`), and + `remove_cell(plot)` on a one-cell row removes the row. Both return false at a cap or when they would + leave no plot, and never grow past 4 / 8. + **The editor's controls** (`rebuild_layout_editor()`): a cell `+` (`kLayoutCellKey`, tooltip "split + here") at a row's left edge, at its right edge and at every internal boundary -- `n + 1` of them for a + row of `n` cells, so a one-cell row can be split from either side -- inserting at that position; a drag + double-headed arrow (`kLayoutColKey`, `udpscope-resize-cols-symbolic`) at each internal boundary that moves the boundary between + its two neighbours, **each floored at `kMinCellPx` = 96 px logical** (or half the pair when the pair is + narrower) and measured from the two widths at drag start, so the drag does not compound. **The handle follows the divider it + drags, so the gesture's offset shrinks by the handle's own displacement**: `on_layout_col_update` adds it back + (`layout_col_total_offset(offset, handle_x_now, handle_x_at_begin)`, the handle's x in overlay coordinates, stored by + `on_layout_col_begin` in `layout_col_drag_handle_x0`) before multiplying by the scale; without it the divider settles at half the pointer's travel. The per-plot + `×`, which removes the cell in a split row ("remove this plot") and the row in a one-cell row ("remove + this row"), insensitive when it would leave no plot. Cell `+` is insensitive at 4 cells in its row or 8 + plots in all; the row `+` is insensitive at 8 plots. **The cell `+` is keyed by `kLayoutCellKey` (figure row * 8 + position + 1) and + never by `set_row_key()`**: `row_key()` is a plot or boundary index, `layout_button()` finds a button by it, + and a cell `+` that also carried one would be found as a row button; `sync_layout_editor()` tells the kinds + apart by which key it finds. Like every editor control the + cell buttons are placed from `FigureLayout::cells`, divided by `device_scale()`, and the editor does + not draw while the figure's rects are not yet one per plot. +- **The slide-over is the second thing placed over the plot, and it is one opaque widget, not a + transparent area.** The same pick rule as the editor's buttons applies, harder: the panel is a + `GtkRevealer` aligned to the end edge (`halign=END`, `valign=FILL`) at `kSlideOverWidth` = 360 px, so + its allocation *is* the part of the plot it covers and everything to its left is still the plot's -- + pan, zoom, drop and hover are untouched. A full-area child with a panel drawn in it would swallow all + of them. Closed, the revealer is `can_target(FALSE)` (and `gtk_overlay_set_clip_overlay()` keeps its + slide-out inside the plot), so the frames of a slide-out do not eat input either. `[slideover]` reads + it back: `outside_hits_plot=1 inside_hits_panel=1`. Three consequences: + the panel **never resizes the plot** (it is an overlay child; `plot_unchanged=1`), its height is the + plot's and never the page's (each page has a `GtkScrolledWindow` with a `NEVER` horizontal policy that + does not propagate its child's height), and content that is taller than the plot -- the trigger form + on a 470 px window -- scrolls inside the panel instead of growing it. The widgets that go in a page + are reparented into it; their widths must fit 360 minus the border (the trigger form measures 267 + minimum, the E-1608's panels 197). **One page wider than that widens the panel + for all of them** (the stack measures every page): the signals page's hint line once did, unwrapped, and + `[slideover] panel=` read 376 instead of 360; the label now wraps. +- **The badge bar has a fixed height and sits outside the overlay.** `badge_bar` is 36 px + (`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a + badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on + content" for the new row: the Trigger badge's state is a word from a short fixed set and the Source badge + (shown only when the kind has source pages; hidden otherwise) is a kind's display name only (no connection state: that is the status bar's), so what changes with the stream is the row's *width* (the bar has room to + spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to + need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear + inside the slide-over's scroller, never in the bar. +- **The signal badges live in a scroller, so the bar's minimum width never depends on the signal count.** + The bar's signals slot holds a `GtkScrolledWindow` (horizontal `EXTERNAL`, vertical `NEVER`) around one + row of badges and the `+`, because a button row's minimum width is the sum of its children: nine badges + (the E-1608: eight inputs and the counter) would push the window's minimum width past a laptop screen. + Past `kMaxSignalBadges` (6) the row is replaced by one "N signals" badge, which also stands for the `+`. + The bar's height stays 36 px in every one of these states (`bar_h_fixed` in `[sigbadges]`), and + `bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 124 on UDPS: + the Source badge is on screen for the E-1608 and hidden for UDPS, and its text differs by kind). A change that makes it grow with the signal count has + taken the scroller out of the measurement. +- **The dock's width is fixed too, and it is the readouts' width that holds it there** (`side_column` + `width-request` 260, the split view's 280 px floor and cap). The dock holds only the compact readouts and the Events block (a fixed-height list, below), so a + section's visibility is never what makes it taller; the scroller absorbs the rest. **The readouts' minimum + width is the sum of their cells' character widths, and it must stay under 280** (the Readouts box is 268: + 280 less its 6 px margins): a cursor line is the `who` cell (5 characters) plus three value cells (9 each), + every one ellipsized at that width (`width-chars` = `max-width-chars`), so a long value is cut and its + tooltip carries the text and the layout never depends on what a value reads. The `who` cell was 8 + characters, which made the readouts' minimum width 304 px; that pushed the sidebar to 304 px + (`dock_w=304`) and defeated the 280 pin. Widening any cell is the same + trap -- read `[readouts] ... dock_w=` (must be 280) and the `dock cursors:` / `dock measures:` `fits=`, and for the Events block `dock events: ... dock_w=` (268). +- **The readouts strip is a fixed 26 px, scrolls sideways, and is shown only when the dock is not beside the plot and there is something to read.** + `readouts_strip` (a `GtkScrolledWindow`, `height-request` 26, horizontal `external`, vertical `never`) sits + in `left_column` between `plot_overlay` and `badge_bar`. Its height is a request and not a measurement of + its chips (the buttons are trimmed by `button.udpscope-strip-button` in `ui_widgets.cpp`, or Adwaita's + 34 px flat button would push it past 26), the chips' cells are fixed-width ellipsized labels like the + dock's, and an overflow scrolls with no bar taking height: "layout must not depend on content" for the + strip, the same as for the badge bar. It is *visible* exactly when (`show-sidebar` is false **or** the split view is `collapsed`) + **and** (the cursors are on **or** there is a measurement) + (`sync_readouts_strip()`, from the tick after the wish is re-asserted and before `set_collapsed()` -- so + `show-sidebar` is the wish and not the split view's collapse-time write, and `collapsed` is the previous + frame's value). Hidden with a wide window and the dock beside the plot, so the plot's 26 px go to one + pane of readouts and never both, and hidden with nothing to read (cursors off, no measurement). That last + is a deliberate, user-requested exception to "disable, do not hide": an empty strip is 26 px of nothing, and + the dock keeps both blocks on screen so the empty state is never invisible; the strip's two groups and the + separator follow the same rule (`strip_cursor_group` with the cursors, `strip_measure_group` with + measurements, `strip_sep` with both). In a collapsed window the dock is an overlay on the plot and the strip + stays, overlay open or not. The plot is 26 px shorter while it shows; that is the dock's and the window's + state moving the layout, not content. Reading `h=26` with `visible=1` (and `h=0` with `visible=0`) in + `strip:` is the assertion, `visible_ok=1` that `visible` equals `expected` and `groups_ok=1` that the groups agree. - **Trace slots are identified by signal name, not channel index.** Enabling a signal or a new CONFIG renumbers the channel list, so an index would silently re-point a trace at another signal. @@ -118,6 +274,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear per-channel list, and why `format_elapsed()` steps to a coarser unit instead of letting the counter grow: each is a status field whose *width* would otherwise depend on the stream, and the status row is directly above the plot. + A split row keeps this rule: cell widths come from the weights and the 64 px floor and never from what + a cell's labels need, and the gutter stays one figure-wide maximum. The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the plotter for this), not on the rows: `Figure::layout()` clears every row's `set_min_y_gutter()` to measure what its own labels need, so a floor written onto the diff --git a/docs/claude/invariants-readouts-and-cursors.md b/docs/claude/invariants-readouts-and-cursors.md index 45a9e9c..31133d8 100644 --- a/docs/claude/invariants-readouts-and-cursors.md +++ b/docs/claude/invariants-readouts-and-cursors.md @@ -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 diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 550e0ea..db2f0d4 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -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 diff --git a/docs/claude/invariants-time-axis-and-window.md b/docs/claude/invariants-time-axis-and-window.md index ec681cf..de1e362 100644 --- a/docs/claude/invariants-time-axis-and-window.md +++ b/docs/claude/invariants-time-axis-and-window.md @@ -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. diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 8a22ec4..53dd376 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -27,6 +27,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's button is the one on screen — **two arm buttons at once would be two controls for one state**, which is why the probe reads the body's allocation as `-1` while the compact one is up. + **The same function states the Force button beside the arm one, and the Force button is in the body + only.** Its sensitivity is the same `enabled` (a trigger that is off has no engine to force) and it + is stated in `sync_arm_buttons()` for the same reason the pair above is stated there — one place, so + nothing can drift — but it is *not* armed-gated: in Single mode the previous acquisition leaves the + engine disarmed, which is exactly when a user reaches for Force again, so a button that greyed out + with the engine's armed state would be unavailable for the one thing it is for. It stays in the body + because the heading's single control is the runtime *state* a locked pane exists to change, while a + force is an action whose subject — the condition, the level, the pre/post split and the mode that + decides whether one acquisition is all of it — is in the body; a second Force in the heading would + be a second control for one action whose settings are off screen. - **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale @@ -36,7 +46,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear has a `compact_arm_step()` of its own; and the control to compare against must be measured with `is_visible()` or not at all. - **Signals start disabled** — enabling is what starts acquisition. A trigger on a - signal nobody acquires can never fire, so selecting/arming enables it. + signal nobody acquires can never fire, so selecting/arming enables it. **And the signal is resolved + *through* the channel, never taken as one.** `TriggerConfig::channel` indexes `channels()` (the + acquired ones) while `ScopeModel::set_signal_enabled()` indexes the stream's `signals()`, and the + two are not the same index: with one signal acquired, channel 0 *is* that signal while signal 0 is + the stream's own clock. Passing one for the other switched on the time base, left the signal the + trigger watches exactly as it was — and, because a signal the panel was not showing *is* a change, + rebuilt the buffer under the click and dropped its history, which is precisely the pre-trigger + window an arming, and now a force, was about to capture. `trigger_signal_index()` is the one + resolution and both buttons go through it. - **The trigger's event window is separate from the display window**, and `pretrigger_pct` is a share of the *event* span, not of the ring. - **The event window is counted in stream seconds end to end; nothing converts it to @@ -201,6 +219,42 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear export.** Both need the same absolute index range over the time ring and every value ring, or an export and a capture of the same window would hold different samples. Call it under `mutex()`. +- **Force is a synthetic edge, and the latch is still the only publisher.** The trigger panel's + Force button opens one capture at the *present* moment through `TriggerEngine::force()`: the + newest stored stamp becomes the event's own time and the engine enters the same pending state an + edge leaves it in, so the ordinary latch cuts, publishes, counts and frames the acquisition. A + forced acquisition therefore obeys every rule a latched one does — the axis pinned from the + snapshot's own stamps, a `pre_s` shorter than the request when the ring does not reach back that + far, and the frame that frames it being the one that consumes its `event_seq()` — and the button + owns no window, no publish and no framing of its own: **the event it publishes is cut by the same + code as a latched one.** Two things it deliberately does *not* do, both of them choices: + - **It skips the pre-window gate.** `force()` does not test `event_t - oldest_s_ >= pre_seconds_`: + "capture now" is a request for the history that *exists*, and refusing it because the ring is + young would refuse the one thing the button is for. The snapshot reports its own `pre_s`, and the + pin follows it — which is also how the pin gets *read*: with a request the ring cannot satisfy (a + 100 s window at 99 % pre) the axis must come out a few seconds wide, where a pin stated from the + request would come out a hundred. + - **It does not restart a capture already filling.** That capture *is* the acquisition being asked + for: the display is already showing it fill (`live(capturing)`), and restarting it would throw + away the post window it has collected and move the marker to the press. The click is refused, and + named on stderr, rather than silently reinterpreted. + What the trigger *is* afterwards is still the mode's decision, and it is made where it always was — + in the latch: Normal leaves the engine armed with the latch's hold-off, Single disarms it. One + acquisition, whether an edge produced it or the button. +- **The Force request is written under the model lock and consumed at the end of a packet.** It is + one atomic flag on `Acquisition` (`request_force()`), read with `exchange` after the channel loop + and before the `trigger_pending()`/`capture_edge()` publish — after the loop because the cut + `publish_event()` is about to make clamps to every value ring's `produced()`, so the newest sample + of every channel has to be in by then, and before the publish so the capture the click opened is + reported by the same packet that opened it. The *write* belongs under `ScopeModel::mutex()` and in + the same critical section as the `armed` flag the click also sets: the producer holds that lock for + a whole packet, reading the panel's `armed` at the top of one and the request at the bottom, so a + request written in between would be read by a packet whose engine was still disarmed and then + dropped — and dropped is right, because a momentary request must not be replayed later on a trigger + the user has stopped watching. With **no channel resolved** (no signal acquired) the click is + refused in the handler and not left to the producer: `on_data()` returns at once for a model with + no *streamed* channel, so a request parked behind that return would fire on the first signal + switched on afterwards. - **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway, so the engine would not latch; but the real reason the samples are withheld is the other one: feeding them would advance the hold-off clock across time nothing was sampled in. The probe @@ -242,17 +296,168 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear moved under the stream; a frame accepted into a model with no streamed channel) and for why the relative clock is accumulated in seconds. +- **Persistence is the older acquisitions drawn *over* the current one, and "a layer is not a + signal" is what keeps them from becoming one.** Trigger mode may keep the last `N` + acquisitions -- `TriggerConfig::persist_depth`, the panel's *persistence* spin, 1 = the event + on screen alone -- and draw the older ones dimmer, which is the whole feature. The machinery is + the layer machinery the accumulate path already has: one **layer** per (signal, age), in the + same trace slot the accumulate path puts its own extra events in, named with the same suffix + vocabulary (` (A)`, ` (B)`, ` #4`), so `layer_base_name()` gives the base signal back and the + signal list, the colour resolver, the measurements and the layout writer all keep seeing **one** + signal where the plot draws several traces of it. Four consequences, each of which is a rule: + * **A layer slot holds no channel.** `RowSet::bind()` resolves a layer's *base* signal (it must + still be acquired -- switching a signal off takes its traces off every plot, layers included) + and then finds the channel **by name inside the snapshot** (`EventSnapshot::channel_names`), + because the channel list is renumbered by any structural change since that acquisition was + cut -- the rule `TriggerEngine` already follows, for the same reason. It leaves + `Row::channel[]` at `-1`, which is what makes every reader that asks "is this slot a trace of + a signal" skip it: the trace counts, the cursors pane's `mix` mask, the measurement marks, the + dump's per-slot lines, `collect_layout()`. A persistence layer is therefore invisible to the + signal list, the calibration table and the measurements by construction and not by a check; + the *measurements* go on describing the current frame because they resolve a channel from the + signal name the user configured, and `sine (A)` is not a signal anybody can configure. + * **Its brightness is a property of the slot, and it is applied where the trace is created.** + `Row::slot_alpha` is `RowSet::persist_alpha(age)` -- **half per acquisition**, so the schedule + is 1, 0.5, 0.25, 0.125, ... and the seventh layer is 0.8 %, i.e. gone; it does *not* depend on + the depth, so raising N adds dimmer acquisitions instead of restating the ones already on + screen. `install()` mixes `slot_alpha * trace_alpha_` into the trace's colour, because a + `Plot` fixes a trace's colour **at `add_trace()`** and a brightness nobody can set later is a + brightness that has to be a rebuild. Two things follow: a per-layer alpha is impossible + without a per-slot one, and **`set_trace_alpha()` is finally read** -- it was set by the + accumulate path and read by nothing, so an accumulation's "translucent, so agreement is + brightness" was opaque in fact while the docs claimed otherwise. + * **The dimmest trace is created first.** A `Plot` draws its traces in the order they were + created and cannot reorder them, so `install()` creates them sorted by brightness and the + *newest* acquisition is the ink on top. A layer drawn over the event would take its colour out + of the event's own trace wherever the two agree -- the opposite of what a persistence display + shows. + * **Every slot the plan does not fill is freed, and the plan does not depend on the data.** + `RowSet::sync_layers()` (called from the tick, before the frame's `bind()`) re-plans one layer + per (signal, age) into the row's free slots, *layer by layer* -- age 1 for every signal first, + then age 2, as the accumulate path does -- so a row that runs out of room draws the sharper + layer for each of its signals and stops. The plan is a function of the rows, the depth and + nothing else, so a **latch costs no rebuild**: the slot names and ages stay put and only the + snapshot behind them changes. +- **An older acquisition is *drawn*, and it may not move anything.** The current event is the one + the axis is pinned to, and persistence must not disturb that, so `RowSet::event_data_window()` + -- the union the pin is re-stated from every frame while an event is held -- **skips layer + slots**: a wider older acquisition would otherwise re-pin the axis, and with it the window a + pinned measurement reads, which is a *measurement* moving because a display option was turned + up. The layers are bound only in `Held` and `Capturing` (a live view has no current event to be + the newest of them, a `Waiting` display is blank by design, and a pause view's stamps are on the + live window's clock, not on an edge): in `Waiting` and `Live` the layer slots are planned but + bound to nothing, and a trace with no series is not drawn and not listed in the legend. The one + thing they do share with the signal is the **row's y axis**, so the y auto-fit sees them -- a + y axis that ignored the traces it draws would clip the excursion the display exists to show. +- **A retained acquisition is a whole window copy, so the depth is capped by bytes and by what a + row can draw.** `publish_event()` moves the outgoing `event()` into the retention before it + publishes the new one: `ScopeModel::persist_event(age)` is the event `age` latches back, one + atomic `shared_ptr` slot per age, read by the tick without a lock exactly as `event()` is. + `snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples + and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of + clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and + `persist_budget_bytes()`, which is the planned ring bytes (`Partition::bytes()`), because persistence is a copy + *of* what the rings hold and holding as much again is the honest ceiling for a display of it. + `kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already + invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only + thing that limits what is *drawn* -- a row of `S` signals with 8 slots shows + `floor((8 - S) / S)` ages of each, so the default four-signal row shows one older acquisition + and a row per signal shows all seven. The two shortfalls are **separate readings**, and a caller + that conflated them would report a shrunken display as complete: a *slot* shortfall is + `placed < want` (what `RowSet::sync_layers()` returns against what the depth asked for), while a + *budget* shortfall is a `kept` below the depth plus the count `persist_dropped()` carries -- and in + that case the layer slots **exist and draw nothing at all**, so `layers=`/`placed=` alone say + nothing is wrong. The panel's notice therefore names both numbers -- `D of W layers fit, K of A + older acquisitions retained` -- because a user who asked for eight layers and sees two has no other + way to tell "the display is deliberately shallower" from "the feature is broken". +- **Depth 1 is not a mode, it is the *absence* of one.** `persist_depth = 1` retains nothing at + all -- no copy, no layer, no rebuild -- and `set_persist_depth()` prunes the set **at once** + rather than at the next latch, because lowering the depth is a user asking to stop paying, and a + trigger that has stopped firing never reaches another publish. A depth change is therefore also + how the memory comes back. + +- **The phosphor density is the *trace*, and that is a different display from the layers above, not a + mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple + events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence* + is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, an intensity grid + **per trace, drawn in that trace's own colour**, every acquisition deposited into it, which fades + either by a time constant or by a per-acquisition factor. There is **no depth** on the density and + no per-acquisition memory: the image is `traces * columns * rows * 4` bytes, and both factors are + bounded -- the grid is the row's axes rect in device pixels **up to the plotter's + `kDensityCellCap`**, and there is one grid a trace the row draws. Four traces in a row + therefore hold at most 4 MiB between them, whoever dragged the window where (the probe's + one-signal-per-row run reads `cells=1044714`, 4 178 856 bytes), so a thousand acquisitions cost + what one costs and *neither* a wide window nor a long run can make the image grow. A cell's value + is a number of *sweeps*: the library + deposits a cell once per trace per sweep however many of that trace's own samples crossed it + (`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit deposit read 18) -- dwell is + `Style::intensity_grading`'s question, not this one's. The colour is the trace's own + (`RowSet::signal_color()`, the same source the trace was built with and never a second one), so a + saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two + quads of two colours rather than a blend. + Five things hold it together, each of which is a rule: + * **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as + the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops + the grids **at once** rather than at an acquisition a quiet trigger never sends (the rule + `set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an + empty image rather than a stale one at another scale. + * **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()` + deposits what the plot currently holds through the transforms of its **last** layout, because the + frame's own layout is computed inside the paint that follows the tick: `density_pending` is set + where a snapshot is *framed* -- beside `record_pending` -- and consumed by the next tick, so the + deposit is of the new acquisition drawn at the axis that acquisition is pinned to. Doing it in the + same tick was right on the common Held-to-Held transition and wrong on the first latch, whose axis + was still the live ring's. + * **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending + flag, so a user who turns persistence on while an event is held sees it instead of waiting for a + latch that `single` mode may never send. + * **No trace is drawn over it, and the newest acquisition is inside it.** `Renderer::draw_traces()` + returns at once while a density is on: the trace ink *is* the image, so the acquisition just + deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid, + unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition", + where the last acquisition never faded in time mode -- and the assertion is a **framebuffer** one: + `a_phosphor_density_is_the_trace_in_its_own_colour` reads the same pixel before and after one + time constant and requires it dimmer (`full=224 -> faded=115`, the trace's own `0xE0` green), + which a stroke over the density could not do. + * **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** The + app leaves `DensityConfig::full_scale` at the library's default of **one sweep** and `gamma` at + 1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing + the same place every acquisition settles *at* the trace's colour (the ceiling clamps), rather than + a fraction of a brighter steady state. The old ceiling -- the fade's steady state, `1/(1-factor)` + for the acquisition fade -- is the gain offset a user reported between the latest acquisition and + the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed + here. + * **It is the plot's, so a figure rebuild resets it** -- the grids live on each `Plot`, and + `rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal + switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else, + and the alternative -- keeping the grids in the `RowSet` -- is what would make it not a plot + feature. The layout is unaffected either way: the density reserves no space and nothing on it can + be picked, exactly as `set_invalid_spans()` does. + * **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and + `trigger_density_factor` in the layout file, written whether or not it is on -- a display set up + once and switched off is still the display you come back to. The decay is the *word* the panel's + dropdown shows ("acquisitions"/"time"), for the reason the condition's shapes are text. + ## A latch is also a file -The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are -collapsed until it is asked for, because a column of stale events beside a live plot is what a user -who has stopped looking at events does not want. What makes it *available* -- (recording is on) or -(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and -the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and* -collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is -deliberately **not** one of the conditions: a stored event being examined is a file, which has no -trigger, and tying the pane to the trigger would close the list under the click that opened an event. -The recording switch lives in the trigger panel, not in the pane it empties. +The cache is listed twice, from one `EventStore::list()`: the dock's compact **Events block** and the full list on the +`events` slide-over page (the left pane, its divider and the header toggle are gone, so events no longer take +any of the plot's width). What makes it *available* -- recording is on, **or** a replay is on screen, **or** the +cache already holds something (`event_recording || replaying || events_cached`) -- is decided in one place, +`sync_events_availability()`, and it sets the block's sensitivity (`dock_events_set_sensitive()`) and the page's +(`events_box`) from the one `available`, so they cannot disagree. Unavailable means **insensitive, never hidden** +("disable, do not hide"): the block stays on screen greyed, and turning recording off with the page open leaves it up +and greyed rather than stale and live. + +**This deliberately differs from the redesign spec**, which has the block insensitive "outside trigger mode". Trigger +mode is *not* one of the conditions: a stored event being examined is a file, which has no trigger, and a replay or a +group *leaves* trigger mode, so a block that went grey outside trigger mode would grey itself under the very click +that started the replay -- the one-click replay in the dock would disable the dock it was clicked in. Do not "fix" this +to match the spec. The recording switch lives in the trigger panel, not in the block it empties. + +The two views follow the latch on different cadences: the dock is rebuilt on **every** latch (`record_event()`), the +page's list only while the page is open, and `open_events_page()` refreshes it when it was closed; a caller that +opens the page any other way shows a list that is as stale as the last time it was open. ## Opening a file is a detour, so *Go live* is a restore diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 79fef9b..254abcf 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -29,7 +29,7 @@ Two lines of the self-check output are assertions in disguise: window slides over a round number. The companion `axes.w changes: N (last at frame F, side panel up=S)` line separates the two readings: `0`, or a handful near the start, is a settled layout; a count near the frame count is the twitch. Runs far longer than - the documented probes have shown one ~320 px step — the side panel's width — that + the documented probes have shown one ~320 px step — the side panel's width, when it was 320 px — that reverts; it is intermittent and unexplained, so read `changes` before calling a range the twitch bug. - `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr, @@ -75,7 +75,7 @@ Two lines of the self-check output are assertions in disguise: a live axis narrower than the requested window is the same symptom from the other cause (see the `clamp_to_data()` invariant in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to - show up, so run them under `--buffer-mb 1`, which wraps in a few seconds. + show up, so run them under `--buffer-samples 65536`, which wraps in a few seconds. - `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal `want` and `N` must be exactly the window times the stream's real rate, plus one (2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left* @@ -94,6 +94,50 @@ Two lines of the self-check output are assertions in disguise: `events` does not move**: no event fired, so the partial publish deliberately leaves `trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at the disarm` line in the dump meaning what it says. +- `[force-probe] button off: sensitive=0 visible=1 w=X | on: sensitive=1 w=X | arm w=Y | + label_found=1` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — the Force button's stated state, read on both + sides of the trigger toggle. **`sensitive=0` with the trigger off and `1` with it on** is the whole + rule (`sync_arm_buttons()` states it from the toggle, and a trigger that is off has no engine to + force); `visible=1` in both says it is a *body* control and not a compact-heading one, which is why + the off reading must not be `visible=0`; `w` is its allocation a frame after the state it belongs + to, and `arm w=Y` the button beside it, so a `w=0` next to a non-zero `Y` is the "width read in the + tick the control was stated" trap rather than a collapsed button. `label_found=1` is the button the + window shows under the word **Force** *being* the panel's own widget. +- `[force-probe] leg A|B want=[-a..b] during=[-c..d] snap=[-e..f] n=N axis=[..] latches=P->Q + display=.. matches_request=D pin_is_snapshot=P` (stderr, `UDPSCOPE_PROBE_FORCE_MS`) — one line per + leg, taken after the latch the press produced. **`latches` must move by exactly one and `display` + must read `event(frozen)`**: the probe sets the condition to a level the mock never reaches (9 V + against ±1 V signals), so the engine cannot latch on its own and a forced acquisition is the only + thing that *can* move the count — that is also why this probe must run alone, since any other probe + that arms a reachable condition would add latches of its own. `want` is the pre/post split the + panel was set to at the press, taken from the producer's own `post_span_s()`; `snap` is what the + snapshot holds (`[-pre_s..+post_s]`); `during` is the axis while the forced capture was *filling*, + which is the *requested* window — a capture has no snapshot to frame from yet. The two legs differ + in which of those can agree: + - **Leg A (2 s window at 50 % pre, on a ring that has filled)**: `matches_request=1`, i.e. + `snap ≈ want` to within a stored interval, and `n` the span times the stream's real rate plus + one (`20001` at 10 kS/s for `[-1..+1]`, exactly as the latch line demands). + - **Leg B (100 s window at 99 % pre, which no run this short can reach back to)**: the request is + 99 s of history and the ring holds seconds, so `matches_request=0` **on purpose** and + `snap ≈ [-history..+1]`. That is the leg that makes **`pin_is_snapshot=1`** a reading rather than + a coincidence: `axis` must equal `[-snap.pre..+snap.post]` (a few seconds wide) while `during` + still reads the requested `[-99..+1]`. A pin stated from the *request* — the + `hold_pinned_window()`/`pin_event_window()` mistake this probe exists for — reads + `axis=[-99.0000..1.0000]` here, i.e. `pin_is_snapshot=0` with the frozen picture a fraction of + its own axis. + A forced acquisition is an ordinary latch in every other respect, so the `trigger: edge at X ... + want [-a..b] got [-c..d]` line appears beside each leg with the *force's* edge as its `X`, and that + line's own rule (`got == want`, `n` exact) applies to it unchanged. The line + `[udpscope] trigger: forced acquisition, edge at E -- want [-a..b] s, H s of history behind it, + mode=M` is the producer accepting the request: `H` is the ring's retained span at the press, which + is what caps the pre side in leg B, and `mode=single (disarms at the latch)` says the one + acquisition the mode means, so an armed state that reads `0` afterwards is right and not a lost + click. `force ignored (enabled=.. armed=.. ch=.. pending=..)` is the refusal — a trigger that is + off, a channel that is still `-1`, or **a capture already filling**, which is deliberately *not* + restarted (it is the acquisition being asked for); in the `ch=-1` case the click is refused in the + handler instead (`force refused -- no signal is being acquired to trigger on`), because a request + left to the producer would sit behind `on_data()`'s no-streamed-channel early return and fire on + the first signal switched on afterwards. - `[trigrenum-probe] window=W event_span=S pre_pct=P post=O holdoff=H level=L edge=E mode=M enabled=1 armed=1 ch=C channels=N`, then `capture open: t0=T ch=C -- enabling signal 1 now`, `after enable: trigger ch=C' channels=N'`, and `latched t0=T' opened on T t0_held=D` (stderr, @@ -162,10 +206,10 @@ Two lines of the self-check output are assertions in disguise: decimation is for, and a `buffered` short of the window is the old "the control is capped to what the ring holds" behaviour coming back. `decim` must be a power of two, `stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it - under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s - decimate; at 200 MB nothing inside `--max-window` needs to. + under `--buffer-samples 65536`, which saturates in a few seconds and makes windows past ~6 s + decimate; at the default 10M nothing inside `--max-window` needs to. - **The anti-alias check** is the same probe run against a waveform above the stored - Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives + Nyquist: `--buffer-samples 65536 --freq 2000` with the probe ending on a 60 s window gives `decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's `ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes 2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has @@ -380,13 +424,23 @@ Two lines of the self-check output are assertions in disguise: the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_` — that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads back as the zoomed range one frame later. -- `[layout-probe] rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]` - `side_w` is the sidebar column's width and **must be its own request (320), never 0**: it read 0 while - the divider sat at the window's right edge, which is a panel that is not there. It was 320 during the +- `[layout-probe] rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] resize=[..] traces=[..] side_w=S cells=[..] colweights=[..]` + `side_w` is the sidebar column's width and **must be its own request (280), never 0**: it read 0 while + the divider sat at the window's right edge, which is a panel that is not there. It was 280 during the first frames and 0 once the window had settled, so read it from a `placed` line rather than the first. - (stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is - up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the - buttons exist only while the editor does. `done` follows the same rule for the editor's + (stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `3P + 2F + 2`** while the editor is + up, for `P` plots in `F` figure rows: one `×` per plot (`P`), one row `+` and one row resize arrow + per row boundary (`2(F+1)`, the arrows of the figure's two outer edges counted although hidden), and + per row of `n` cells `n + 1` cell `+` buttons and `n - 1` column arrows (`3P` in all, since + `sum(n+1) + sum(n-1) = 2P`, plus the `P` of the `×`). With no split that is **7 for one row and 12 for + two** (`5` and `8` before the split, the old `2N+1` having long stopped counting the arrows), and a + split row adds three per extra cell. **0 on the `editor off` line**: the + buttons exist only while the editor does. `rows=` is the plot count and `cells=[..]` the cells of each + figure row, comma-separated (`[1,1]` for two unsplit rows, `[1,2]` for a split second row); + `colweights=[..]` is each figure row's cell weights, `|` between rows and **empty for a row with none** + (every unsplit row, and a split row nobody dragged), so two unsplit rows read `[|]` and a dragged + `{3,1}` second row `[|3,1]`. An unsplit run must read `cells=[1,..,1]` and a `colweights` of + only separators; anything else is a split the run did not ask for. `done` follows the same rule for the editor's own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the area's top-right corner: `x + W` within a few pixels of the area width and `y` within a few of 0 (the on-switch line reports a box from before the first frame, so read the @@ -406,11 +460,25 @@ Two lines of the self-check output are assertions in disguise: floor are the two refusals, and because the probe emits `::clicked` straight at the widget it reaches the handler even when the button is insensitive, so `add above 0` there must leave `rows=` and `traces=` untouched rather than growing a ninth row. -- `header: window_button_w=W bar_w=B` (stderr, once a second) — **`W` must be +- `[udpscope] header: window_button_w=W bar_w=B` (stderr, once a second, **only when + `UDPSCOPE_PROBE_HEADER` is set**) — **`W` must be a small constant and must not track `B`**: the window control is a header-bar child, and an expanding one is measured into the title's centre box, so it stretched from the label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that - grows with the window width is that bug back. + grows with the window width is that bug back. The widths are still sampled every second + in every run, because the exit dump's `header:` line reads them; only the line is behind + the variable, since a reading printed on every run is a log flood (the `[notrace]` rule). + An unallocated window reads `0` for both, which is the reading for "the toplevel never + got a size", not for a layout drift. +- `[renderer] option=NAME GSK_RENDERER=V LIBGL_ALWAYS_SOFTWARE=V` (stderr, at startup, + before GTK initialises; only when `UDPSCOPE_PROBE_RENDERER` is set) — what `--renderer` + exported, printed from `main()` so the reading is of the environment a pre-init GTK will + see. `option=(unset)` with both variables `(unset)` is the default and the assertion that + the flag changes nothing unless asked. +- `[renderer] realized=TYPE` (stderr, once, from the first tick that finds a window renderer; + same probe) — the class GTK actually built (`GskCairoRenderer` for `--renderer cairo`), + which is the half of the claim the export alone cannot make. It is null until the window is + realized, so a tick that finds none tries again rather than printing a false reading. - `toolbar icons: trigger=NAME(ok) events=... pause=... cursors=... sidebar=... menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Three @@ -420,30 +488,36 @@ Two lines of the self-check output are assertions in disguise: those means the resource path was never registered. That failure is otherwise *silent*: GTK substitutes a missing-image placeholder, the button still works, and every other line of the dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this - is the only check that it names something that exists. -- `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36` (in the exit dump, beside - `panels:`) — the two - sidebar tabs as the switcher actually draws them. **`pills=2 labels=0`** is the claim: two pills are on - screen and **no text is drawn** (`labels` counts *mapped labels with text*, because `narrow` keeps an - empty label in the pill's layout and a plain mapped count would read 2 either way). `names=2` is the - other half — both pills carry a tooltip and an accessible name set by `name_side_tabs()`, so an - icon-only pill is not an anonymous button — and `pills=0` means nothing is mapped, which makes the - rest of the line meaningless rather than a pass. `policy=narrow` is read rather than assumed. `icon=` - is the box the pills' `GtkImage` is *rendered* in, which proves the 32px override stuck: the tab - glyphs are 32x16 documents, and GDK paints an icon into a square box whatever the document's aspect, - so the artwork is drawn 32x16 at 1:1 inside that 32x32 box — letterboxed, not stretched, which is what - keeps its 2px bars crisp. It reads `0x0` on the first tick, where nothing has been allocated yet, and - is therefore read by the exit probe with the rest of the sidebar's geometry. `size=` is the bar's own - size, and it is the answer to "what did the wider glyphs cost": `36` is what it was with 16px icons, - because the pill's fixed height takes a 32px box without growing — the glyph gets twice the width for - no vertical room at all. -- `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)` (in the exit - dump, beside `toolbar icons:`) — each tab's own glyph and `gtk_icon_theme_has_icon()`'s verdict, read - on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are - empty on purpose, so an icon that does not resolve leaves a *blank* pill, and a blank pill looks - exactly like a page with nothing in it. -- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit - dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal + is the only check that it names something that exists. The line ends with `resize_rows=udpscope-resize-rows-symbolic(ok) + resize_cols=udpscope-resize-cols-symbolic(ok)`: the two layout-editor handle icons, which no toolbar button carries but + which are the same resource and fail the same silent way. +- `dock: shown=1 sensitive=1 w=280 scroller_pages=1` (in the exit dump, beside `panels:`) -- the sidebar's dock + as the last tick found it. There is no tab switcher: the sidebar is one scroller (`dock_scroller`) holding the + **readouts box only** -- a compact, read-only copy of the cursors and of the measurements, each with an Edit + button (`build_readouts_dock()` in `src/ui_dock.cpp`). The signal list, the full cursors table, the measurements + and the trigger are slide-over pages. **`shown=1` + always** (the dock is on screen from the start; `shown=0` only when the user hid it with the Dock button, + which a probe does deliberately); **`sensitive=0` only before the stream has signals** ("disable, do not + hide") -- a run against a port with no sender reads `shown=1 sensitive=0`, a normal run `sensitive=1`; + `w=` is the sidebar's own width (**280**); **`scroller_pages=1`** counts the scrollers directly under the dock + column and says there is one scroller and no stack. These are captured on the tick or at the exit probe: + the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels + are slide-over pages (`source-`), so the line reads the same on the E-1608 and the probe kind. +- `[readouts] pages cursors=1 measure=1 dock_children=3 dock_w=280 pages_sensitive=1 signals=1` (in the exit + dump; `dock_children` counts the direct children of the dock page) -- the two pages and the dock against the + stream. **`cursors=1 measure=1`**: both slide-over pages are registered, always and from the start (not when + the stream has signals); `0` is a page that was never added, and then the dock's Edit buttons and the `m` key + open nothing. **`dock_w=280`** is the sidebar's width, as for `dock:`. **`pages_sensitive` must equal + `signals`**: both pages are insensitive until the stream has signals ("disable, do not hide"; + `update_signal_availability()` writes them with the dock), so a run against a port with no sender reads + `pages_sensitive=0 signals=0`, a normal run `pages_sensitive=1 signals=1`. `pages_sensitive=1` with `signals=0` + is a page that can be opened and read with nothing to read; `pages_sensitive=0` with `signals=1` is a + stream whose pages never came back after the signal list arrived. **`dock_children=3`**: the dock + page holds the readouts box, a separator and the Events block (`build_events_dock()`) and nothing else (a fourth + child is a section that crept back into the dock; 1 is a dock without its Events block, 0 a dock that was built + empty). +- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=6 step=0 auto=1 requested=0` (in the exit + dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt on every opening, so it is not one of the window's own). Four of the eleven are not a pane's *heading* @@ -487,14 +561,18 @@ Two lines of the self-check output are assertions in disguise: [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). The writer chains to `g_log_writer_default()`, so every message still reaches stderr exactly as before and the documented baselines are read the same way. -- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit +- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… badge_bar=… dock=… window_button=… status_label=… header_bar=… plot=…` (in the exit dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see - [verification.md](verification.md)) and with `gtk_widget_is_visible()`, not `get_visible()`: a widget - under a hidden parent — `trigger_summary` while the pane is in *configuration* mode, the Measure page - while the Signals tab is showing — keeps its own flag true and reads `0x0`, and a crop box drawn from - that is a box over the wrong thing. The figures are produced by cropping a real screenshot, so these + [verification.md](verification.md)) and with `gtk_widget_is_visible()` **and** `gtk_widget_get_mapped()`, + not `get_visible()`: a widget under a hidden parent — `trigger_summary` while the pane is in + *configuration* mode, and **every `trigger_*` surface while the slide-over is closed** (a revealer + unmaps its child without touching its visible flag) — keeps its own flag true and reads `0x0`, and a crop + box drawn from that is a box over the wrong thing. So the three `trigger_*` rectangles read real numbers + only while the `trigger` page is open (the exit probe has it open; `UDPSCOPE_PROBE_HELPUI_MS` opens it for + its figures legs), and `-1,-1,0,0` otherwise. `badge_bar` is the badge row under the plot and `dock` the + right-hand column (these replaced `side_stack`, the old sidebar stack). The figures are produced by cropping a real screenshot, so these numbers **are** the crop boxes; their values depend on the window's size, which under the documented `xvfb-run` is the window's 756x894 minimum (the 640x480 screen is smaller than the content), so a run on a larger screen reads different numbers for the same panels. `trigger_summary` reading @@ -595,10 +673,15 @@ Two lines of the self-check output are assertions in disguise: than by the first-run timer. - **The session layout round trip** (`~/.config/udpscope/_.toml`) — run once with a drop and a window change, then again with no interaction: the second run's dump - must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the + must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...] ... cells=[..] colweights=[..]` with the first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0 -> channel ... samples=N` line). Delete the file before a run whose layout you did not - mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). + mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). A **persistence + layer** would appear in that line as a suffixed name (`sine (A)`, `sine #4`) *while trigger mode + is on* — it is not the user's arrangement and is never saved (`collect_layout()` writes a layer + slot as empty) — and it is absent from the dump, which is always taken after the exit probe's + go-live has cleared the layers. `[persist-probe]` is therefore the only run-in-progress reading of + them. - `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last second ...` (stderr, once per second while the state holds, plus a status-line notice) — "connected but nothing is arriving after a reconnect", which over unicast is the second @@ -707,13 +790,40 @@ Two lines of the self-check output are assertions in disguise: it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a `ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at the price of a second budget's worth of memory (README records that one live ring is why - `--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line + the planned bytes are the whole memory cost) and a second per-sample write on the producer. What the line says about that trade is that the stall is real but **costs no packets**: an A/B of the same run with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring; it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.) +- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level + walk(s)` — then the exit dump's `measure cost:` and `frame cost:` beside it. This is how a + pane's cost is read, and the three fields are the three factors: **`W` is the pane's price + for the window**, one fetch and one scan per *measured channel*; **`L` is its price for the + pane's shares**, one accumulator per distinct one, walked over the window's buckets; and the + window's samples are the multiplier on both, so a sweep at one scale says nothing about + another. Measured at the user's scale (15 s window, `--buffer-samples 100M` -> `stride=1`, 11.8 M + samples in the window = 184 k buckets, four signals, mock at ~0.5 MS/s per signal, llvmpipe), + with `UDPSCOPE_PROBE_LEVELS_CHANNELS` set: + * `50`, 1 channel -> `1 window, 1 level`, `measure cost: 226 recomputes, 6.58 ms each`, `raw=0.1%`, + `tick 1.99 ms` against an empty pane's **1.34**; + * `5,10,15,20`, 1 channel -> `4 levels`, **10.28 ms** each, `raw=0.4%`, `tick 2.38 ms`; + * the sixteen shares `5..80`, 1 channel -> `16 levels`, **24.55 ms** each, `raw=1.2%`, `tick 3.86 ms`; + * `5,10,15,20` over **4 channels** -> 4 windows and 4 levels, **35.69 ms** each, `raw=2.6%`, + `tick 4.58 ms`, and still `gaps=0 packets=30580` — the pane is a *tick* cost and not an + ingest one, which is the thing to say before blaming the producer. + `measure cost:`'s `each` is the pass and `covered`/`recomputes` is the mean window it ran + over (11.8 M samples here), so `each` is only comparable between runs at the same window and + the same pane; `raw` is the *signal's* share (the mock's `noise` channel reads `4.6%` against + its `sine`'s `0.1%` at the same window, because a level inside a bucket's own range has to be + walked sample by sample). A pass that reads **tens of ms** on a 15 s window with a handful of + measurements is the curve in + [invariants-measurements.md](invariants-measurements.md) and not a defect, and the same pane + at a 2 s window reads the 1.2--1.6 ms the pane's budget was fixed at. The guard for the walk + itself is the exit dump's `frame cost:`: sixteen shares on one channel read `24.55 ms` a pass + and `3.86 ms` of tick mean before `measure_accumulate_bucket()` was made `inline`, and + **17.25 ms** a pass after it, with every value in `measurements:` unchanged. - `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst drawn-vs-quoted D px over R raw paints` plus a `[ink] paint N mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=.. @@ -799,7 +909,7 @@ two also catch a row whose accelerator went missing. `settings=` must be a real every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores it afterwards. -- `[helpui] auto_started=1 visible=1 steps=5 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) — +- `[helpui] auto_started=1 visible=1 steps=6 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) — the help affordance layer end to end, in ten steps 300-400 ms apart (every reading is an *allocation*, and a step that read its own predecessor's geometry would read a rectangle from before the frame that placed it). The line above is the **first-run auto-start**, read before the probe @@ -831,21 +941,33 @@ two also catch a row whose accelerator went missing. `settings=` must be a real affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of removing them. The count comes back to the same twelve, which is the other half — a toggle that removed and could not restore is a one-way switch. - - `[helpui] tour started from the probe: visible=1 steps=5 step=0` — the tour started the way - `Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose surface is on screen or - selectable into it (`gtk_widget_get_mapped()` -- a wrong *page* is kept, because selecting its - page reveals it, while a *collapsed pane* is still dropped; see the surface bullets in - [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). - - `[helpui] step=1/5 visible=1 side_tab=signals spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` - — **in window coordinates**, the frame a screenshot is cropped in, with the sidebar's page beside - it (a stop the walk had to change the page for is readable as such; see `[toursurface]` below). - **`contains` must be 1** on every step: the spotlight is the target's own rectangle grown three - pixels. `clear` is the card missing the target, and it is 1 wherever the window has room for the + - `[helpui] tour started from the probe: visible=1 steps=6 step=0` — the tour started the way + `Ctrl+T` starts it. `steps=6` is the plan: the table's six stops (plot, badges, trigger, dock, + signals, status) whose surface is on screen or can be opened into view. A stop that names a + slide-over page is always kept (the `on_page` hook opens it whatever the dock is doing), a target + that is mapped is kept, and a stop in a *collapsed dock* is dropped (`gtk_widget_get_mapped()`; see + the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). + - `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C` + — **in window coordinates**, the frame a screenshot is cropped in, with the slide-over's open page + (`none`, or a page id) beside it; see `[toursurface]` below. `raw=` is the target's own rectangle, + unclipped; `target=` is the part of it that is *on screen*, which the probe computes **itself** by + intersecting `raw` with the bounds of every scrolled window above the target (not through + `tour_target_bounds()`, which the spotlight is drawn from -- reading the spotlight against its own helper + would only ever agree with itself). A form taller than the panel's scroller on a short window reads + `raw` taller than `target`, and is spotlit down to the scroller's edge and no further; the trigger stop at + the documented window reads `raw=[279,143 343x474] target=[279,143 343x253]`. + **`contains` must be 1** on every step: the spotlight covers the visible part (the part grown three + pixels), or is empty when none of the target is visible (a target scrolled out of its scroller gets no + hole at all, and not the hole of the frame before). **`inside` must not be 0**: it is the spotlight + staying within the nearest scrolled window above the target (grown by the same three pixels); `-1` is a + target with no scroller above it (the plot, the badge bar, the dock, the status line), `1` the trigger + form and the signal list. `inside=0` is a spotlight that spills out of its scroller over whatever is + under it. `clear` is the card missing the target, and it is 1 wherever the window has room for the card beside or above/below the hole — at the documented run's window (see `help figures:` below) - the plot's step is *the whole content*, because at 756 px the split view overlays the side panel + the plot's step is *the whole content*, because at 756 px the split view overlays the dock rather than putting it beside the plot, so `clear=0` there is the geometry and not a placement - bug. The side panel's step reads `clear=1`. - - `[helpui] after Next: step=2/5 visible=1`, `[helpui] after Back: step=1/5 visible=1`, + bug. The badges step reads `clear=1`. + - `[helpui] after Next: step=2/6 visible=1`, `[helpui] after Back: step=1/6 visible=1`, `[helpui] after Skip: visible=0 dims_hidden=1 spot_hidden=1 card_hidden=1 controllers=12 (was 12) click_gestures=2` — the three controls. **`controllers=` equal to `(was N)` is the "no lingering input grab" reading**: a tour of ordinary overlay widgets takes no grab at all, and a count that @@ -856,7 +978,9 @@ two also catch a row whose accelerator went missing. `settings=` must be a real trigger pane's modes, by clicking its [edit] button. This exists because one of the help's figures is the *locked* pane's summary grid (`trigger_summary`), which in configuration mode is not on screen at all and reads `-1,-1,0,0`; the two lines give the geometry agent both states' numbers - rather than leaving them to find the mode. `trigger_panel`'s height differs between them (508 px in + rather than leaving them to find the mode. The probe opens the slide-over's `trigger` page for these + legs (and for the collapsed-search leg after them) and closes it at the end: with the panel shut + every `trigger_*` reads `-1,-1,0,0`. `trigger_panel`'s height differs between them (532 px in configuration mode against 72 locked — the body is replaced by the summary), which is the check that the mode really moved. - `[helpui] collapsed search: expanded=0 mapped=1 mapped_header=1 hint=12x12` (the last step, in @@ -866,34 +990,44 @@ two also catch a row whose accelerator went missing. `settings=` must be a real read `mapped=0` (and `0x0`), which is exactly why the header box exists (`search_head` in `src/ui/trigger-panel.ui`) and why the hint joins it as its last child. `mapped_header=` is the header box beside it, and `hint=WxH` is the same 12x12 the layer's own line reads. -- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) — a stop that stands on a page it is - not on, entered from the page it is not on. It puts the sidebar on **Measure**, starts the window's - tour, and walks it a stop per tick (400 ms), because a stack allocates the new page's widgets on - the frame *after* the switch — so each reading is of the page the step left standing, not of the - one the switch has just left behind. The lines: +- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that opens a surface it is not + on, entered from the state it is not in. The probe opens the `trigger` slide-over page, starts the + window's tour from there and walks the whole table a stop per tick (400 ms, past the panel's 120 ms + slide); the `on_page` hook is what opens the page for the trigger stop and closes the panel for every + stop that names none. The lines: - - `[toursurface] before: side_tab=signals signal_list_mapped=1 running=1` — the state a first run - starts in (a fresh config's sidebar is on Signals), then the auto-started walk is dismissed. - - `[toursurface] on measure: side_tab=measure signal_list_mapped=0 side_stack_mapped=1` — a frame - after the switch: **`signal_list_mapped=0` beside `side_stack_mapped=1`** is the whole premise, - the list is genuinely off screen while the stack that holds it is on screen. - - `[toursurface] tour: visible=1 steps=5 step=0 signals_stop=1` — **`signals_stop=1` is the filter's - reading**: a stop whose target is *not mapped* is still in the plan because it names a page the - stack can select. `steps=5` is the full table. - - `[toursurface] step=N/5 side_tab= target_mapped=D[ target=signal_list]` — one per stop. The - page moves from `measure` to `signals` at the `trigger` stop (the first stop whose target is on - the Signals page) and stays there; **the `signals` stop reads `target_mapped=1`**. - - `[toursurface] signals stop=4/5 visible=1 side_tab=signals spot=[…] target=[…] contains=1 - card=[…] clear=1` — the `tour_geometry_line()` the helpui probe uses, read on the frame *after* - the switch: **`side_tab=signals` with `contains=1` is the assertion** that the spotlight and the - card are on the list, not on the page the walk came from. - - `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0 side_stop=0` — the other half - of the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this - is how a probe puts the sidebar away for good) the split view unmaps the column, the stack with - it, and the two stops that need the sidebar are dropped while the page-less ones stay. This is - what says a *wrong page* and a *collapsed pane* are not the same case. -- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. - The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). + - `[toursurface] before: slideover=none signal_list_mapped=0 running=1` -- the state a first run starts + in (panel closed, dock on screen; the signal list is a slide-over page, so unmapped); the auto-started walk is dismissed. + - `[toursurface] on trigger: slideover=trigger trigger_body_mapped=1 side_box_mapped=1` -- a frame after + the page was opened: the dock is still on screen under the panel (they are not alternatives). + - `[toursurface] tour: visible=1 steps=6 step=0 trigger_stop=1 signals_stop=1` -- the plan: **six + stops, the trigger stop and the signals stop both in it**. + - `[toursurface] step=N/6 title='…' slideover= target_mapped=D` -- one per stop, in order: *The + plot*, *The badges*, *The trigger*, *The dock*, *The signals*, *The status line*. **Step 1 reads + `slideover=none` although the probe started with the panel open** (a stop with no page closes it), + **step 3 reads `slideover=trigger`, step 4 reads `slideover=none` again, and step 5 (the signals) + reads `slideover=signals`**; every `target_mapped` is 1. + - `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] raw=[…] target=[…] contains=1 inside=I card=[…] clear=C` -- the + `tour_geometry_line()` the helpui probe uses, one per stop, read a frame after the stop was entered + (the spotlight is withheld until the panel has finished sliding in, `page_settled`): **`contains=1` on + every stop is the assertion** that the spotlight is on the target, and `inside` must not be 0 (both as + in the `[helpui]` entry above, where `raw`, `target` and `inside` are explained). `clear=0` is read as geometry, not + as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the + documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush + left over the plot, overlapping the panel's edge by a few pixels), and on the `signals` stop for the same + reason (the dock beside it is 280 px and the card has no side wide enough). + - `[toursurface] finished: running=0 slideover=none` -- the last Next ended the walk and **the panel is + closed with it**. + - `[toursurface] restarted at trigger: running=1 slideover=trigger` then + `[toursurface] skipped: running=0 slideover=none` -- the walk parked on the trigger stop and dismissed + (the Skip/Esc route): **`slideover=none` after the dismissal** is the tour not leaving a panel behind. + - `[toursurface] collapsed: side_box_mapped=0 steps=5 trigger_stop=1 signals_stop=1 side_stop=0` -- the + filter with the dock put away (`sidebar_wanted` off; `update_side_visibility()` re-asserts it, so this + is how a probe puts the dock away for good): the dock's own stop is dropped, **and the trigger and + signals stops are kept** because the slide-over does not depend on the dock -- five stops remain (plot, + badges, trigger, signals, status). +- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: page=1 dock_still=1 closed=1 | g: page=1 dock_still=1 closed=1 | … | esc: opened=1 handled=1 closed=1 idle_handled=0 | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`m` and `g` are not pairs: each opens a slide-over page, `measure` and `signals`, and a second press closes it), **`refused` must be 1**, and the `esc:` reading must be `opened=1 handled=1 closed=1 idle_handled=0`. + The `m`/`g` readings are `page=P dock_still=D closed=C`: **all three must be 1 for both keys** -- `P` the page the key opens (`measure` for `m`, `signals` for `g`) is the slide-over's open page after the first press, `D` the dock scroller's vertical adjustment did not move (the dock holds only the readouts, so a key must not scroll it), and `C` a second press closes the page. This is what says `m` opens the measurements' page rather than scrolling the dock to a section (the old `m: dock 0->N (cursors_y=… at_cursors=1)` reading is gone with the dock sections), and that neither key toggles a pane. `m` does *not* turn the measurements on or off: the header Cursors toggle only enables cursors, and `m` only opens the page. `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. **The `esc:` reading goes through `on_app_key_pressed()` with `GDK_KEY_Escape`**, the handler the window's key controller calls, and not through `slideover_escape()`: the probe opens the `trigger` slide-over page (`opened=1`), sends the key (`handled=1`: it was consumed, and `closed=1`: the page is gone), then sends it again with nothing open (`idle_handled=0`: Escape is left alone when there is nothing for it to close, so a dialog or popover under it still gets it). `handled=0` or `closed=0` is the slide-over's Escape never reaching the panel from the window's key path -- a handler that returns before the slide-over test, or a controller in the wrong phase -- which the `[slideover] esc_closes=1` reading (a direct call to `slideover_escape()`) cannot see. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `: : ->` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values). - `[color] '' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit. - `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` @@ -1296,11 +1430,11 @@ popover row reads `failed: ` — one stderr line, no retry storm again ## `[defaults]` and `[configs]` ``` -[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 +[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 0 0 1 [defaults] calibrated: 2.5 -1.25 model=2.5 -1.25 [configs] before=default active=default [configs] save-as=1 names=probe,default active=probe -[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1 +[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1 [configs] after forget names=default ``` `noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe @@ -1312,10 +1446,10 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file | --- | --- | --- | | `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it | | `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number | -| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01` | the same, per trigger field; a level of `-1e12` is a number nobody chose | +| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01 0 0 1` | the same, per trigger field — the ten spins in tree order: width, width2, level2, timeout, level, pre-trigger, hold-off, noise band, HF reject, persistence; a level of `-1e12` is a number nobody chose, and the trailing `1` is the persistence depth, whose default *is* the feature off | | `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file | | `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration | -| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with | +| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with | ## `[rowmenu]` @@ -1417,8 +1551,8 @@ actually use. ``` Printed by `apply_stored_settings()` when a source is named on the command line: the stored -configuration's startup settings, filled in where the command line was silent. A `buffer=` here -that disagrees with `--buffer-mb` is a flag being overruled by the file; a *missing* line, with a +configuration's startup settings, filled in where the command line was silent. A `samples=` here +that disagrees with `--buffer-samples` is a flag being overruled by the file; a *missing* line, with a configuration that carries settings, is the defect this exists for — a saved host opened with `--server` ran on the defaults, so an edit to its buffer or clock never took effect. @@ -1480,24 +1614,33 @@ run they report: ## `[events]` ``` -[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0 -[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1 -[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1 +[events] live, recording off: available=0 dock_sensitive=0 page_sensitive=0 page=none +[events] recording on, no trigger: available=1 dock_sensitive=1 page_sensitive=1 page=none +[events] after trigger on: available=1 dock_sensitive=1 page_sensitive=1 page=none +[events] after events page opened: available=1 dock_sensitive=1 page_sensitive=1 page=events +[events] open alone: page=events page_box=347 [events] preview redrawn: signals=3 stored=480x132 row_drawn=1 -[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 pane=1 -[events] activated row0: replaying=1 pane=1 pane_w=240 pane_box=228 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2 +[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756 +[events] activated row0: replaying=1 page=events page_box=347 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2 +[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=0 members=0 max_ok=1 expected_marked=1 at=after pane replay +[events] dock replay: clicked=1 replaying=1 waveform_changed=1 +[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=1 members=0 max_ok=1 expected_marked=1 at=after dock click [events] replayed: channels=3 samples=20001 replaying=1 waveform=1 [events] live again: replaying=0 waveform=0 [events] live restored (after replay): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=10/5 -[events] recording off, trigger off: button_visible=1 pane=1 pane_w=240 tick=1 recording=0 -[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0 +[events] recording off, trigger off: available=1 dock_sensitive=1 page_sensitive=1 page=events +[events] recording off, trigger on: available=1 dock_sensitive=1 page_sensitive=1 page=events [events] compare buttons: cached=2 selected=2 compare=1 accumulate=1 -[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1 +[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=0 marked=-1 members=0 max_ok=1 expected_marked=-1 at=two events latched +[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 page=events +[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=0 marked=-1 members=2 max_ok=1 expected_marked=-1 at=comparison up [events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1 [events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1 [events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000 [events] group left: replaying=0 kind=0 waveform=0 [events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=22/16 +[events] dock replay: clicked=1 replaying=1 (second click, live before) +[events] dock events: rows=2 pane_rows=2 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=1 members=0 max_ok=1 expected_marked=1 at=second dock click ``` `thumb_size` is the built size of the row's picture: `kEventThumbW` wide, and as tall as the *stored* @@ -1507,8 +1650,18 @@ reads `138x75`) as well as with the CSS border and the display's scale factor. W `0x0`, and a height pinned to the floor for *every* event says the aspect fit was dropped and tall previews are being squashed again. +**The E-1608 never latches an event** (its stream carries no trigger edge to record): on `--source e1608` the probe reads +`n=0`, the `recording on` lines read `1 1 1` (recording is on, so the block is available), and the two +`recording off` lines read `available=0 dock_sensitive=0 page_sensitive=0 page=events` -- the page is open and +greyed, the "disabled, not hidden" path -- while `activated row0:` reads `replaying=0 ... marker=-1000000000000.000 +thumbnail=0 rows_kept=0` and the `dock events:` / `dock replay:` lines read `rows=0`, `clicked=0 replaying=0`. The +`page=` of the first availability lines is whichever `source-*` page the kind opened at start-up, not `none`. +A UDPS run against a **fresh cache** reads the same `n=0` and `rows=0` on the opening `dir=` / `dock` lines (nothing +has latched when they are printed) and fills in from the group leg on; a UDPS run with a populated cache is the one +that proves `same_titles` and the dock click on real rows. + `[events] preview redrawn:` is the repair path, and the probe forces it: step 1 deletes the newest -`.bmp`, drops the thumbnail cache and refreshes the pane, so this line is `EventStore::redraw_preview()` +`.bmp`, drops the thumbnail cache and refreshes the page's list, so this line is `EventStore::redraw_preview()` drawing the picture again from the `.npy`. `stored` is the file it wrote, read back out of the BMP header — `480` wide and `44 * signals` tall — and `row_drawn=1` says the row got a paintable from it. The probe therefore needs a cache a **previous** run populated: it fires ~4.5 s in, before this run's @@ -1547,23 +1700,25 @@ session that then disagrees with it takes the pair apart. | `signals`, `samples` | the event's channels and rows | a table built from the *rows* instead of the snapshot: only what happened to be plotted | | `thumb_bytes` | a real 24-bit BMP, `54 + 480 * (44 * signals) * 3` rounded up per row to a multiple of four -- so it *grows with the signal count* (190134 at three signals, 380214 at six) | a preview that is empty or missing -- the widget snapshot of a `GtkGLArea`, which is NULL -- or a picture back on a fixed total height, which gave a six-signal event 26 px a signal and an eight-signal one 20: a smear, not a waveform | | `recorded` | 1 while the trigger panel's switch is on | the switch not wired, or recording defaulting off (it must be on: the cache is for the event you did not expect) | -| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane | -| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot | -| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode | -| `recording off, trigger off` and `recording off, trigger on` | `button_visible=1 pane=1 recording=0` in both **once the cache holds anything** (the probe's own run does by this point) | the button going away with the switch when events are already written -- an event on disk outlives the switch that wrote it -- or `pane_w` not being 240 with the pane open. With an *empty* cache the same two lines read `button_visible=0 pane=0`, which is the empty column the item exists to prevent | +| `page` (on the `dir=` line) | 1 once the `events` page is open (the probe opens it with `open_events_page()`) | a page that did not open, or one that opened before its list was refreshed | +| `plot_w_stable`, `plot_w=a/b` | 1, with the plot area's width before and after the first latch equal and both > 0 (756/756 on the documented window) | events squeezing the plot again (the old pane did), or two unrealised `-1` widths read as equal (the probe requires > 0) | +| `available`, `dock_sensitive`, `page_sensitive` (the lines headed `live, recording off`, `recording on, no trigger`, `after trigger on`, `after events page opened`, `recording off, trigger off/on`) | the three equal on every line, because both views follow `sync_events_availability()`: `0 0 0` live with recording off and an empty cache; `1 1 1` once recording is on, **and it stays `1 1 1` in trigger mode and with the page open** (a replay or a group leaves trigger mode, and a block that greyed itself under that click is the trap) | the dock block and the page disagreeing (one of the two not wired to the sync), or the block going `0` under trigger mode. After recording is switched off the lines read `1 1 1` **once the cache holds anything** (the probe's own run does by this point): an event on disk outlives the switch that wrote it. With an *empty* cache they read `0 0 0` -- insensitive, **never hidden** ("disable, do not hide") | +| `page` (on the availability lines) | `none` until the probe opens the page, then `events` | the page not registered (`open_events_page()` returning false), or a page left open by an earlier leg | +| `open alone` | `page=events page_box=` a width near the slide-over's (347 on the documented window) | the page's box measuring 0 (an unrevealed `GtkRevealer` as the content instead of `events_box`), or a page not opened at all | +| `dock events` | `same_titles=1`, `rows` equal `pane_rows` (both capped at 24: `pane_rows` is cut to the dock's `kDockEventsMax`), `sensitive=1`, **`dock_w` below 280** (268: the block fits the fixed dock; more means a row that no longer ellipsizes), `max_ok=1` (dock children <= 24 + the "N more" row). `on_screen` counts filled markers: **1 after a replay, 0 while live or in a group**; `marked` is the index of the first filled row (-1 when none), `expected_marked=1` when the filled row is the replayed record's file (-1 where no replay is named); `members` counts hollow markers: **2 with a comparison up**, 0 otherwise. The `at=` text names the moment | `same_titles=0` is the two lists rebuilt from different data or one of them stale (the dock follows every latch, the page only while open or on Manage -- a latch landing between the page's refresh and the read can show `rows=9 pane_rows=8` once, which is a probe-time race, not a defect, if it does not persist); `on_screen` above 1 is two filled markers (`event_on_screen` not shared with the title); `members=0` in a comparison is the group not marking its members; `max_ok=0` is the cap lost | +| `dock replay` | `clicked=1 replaying=1 waveform_changed=1`; the second line, from live, reads `clicked=1 replaying=1` | the dock row's click not reaching `replay_event()` (`clicked=0` with a non-empty list), or the click rebuilding the dock list under its own handler. With an empty cache (`rows=0`) it reads `clicked=0 replaying=0`, which is not a defect | | `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both. It also catches the cache never being *created*: `event_preview_texture()` answers from the cache or not at all, so a reader that does not create one leaves **every** row blank, which is what `thumbnail=0` meant for as long as it read 0 | | `preview redrawn` | `stored=480x<44*signals>` and `row_drawn=1` | a preview that cannot be repaired. The `.npy` is the event and the `.bmp` is only a picture of it, so a write that failed, a truncated file or a cache from an older build must be redrawn rather than shown as a blank row -- `row_drawn=0` is a row the user reported as "sometimes missing" | -| `axis`, `marker`, `pane_box` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000`, and a `pane_box` close to `pane_w` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker; or a pane whose *rendered* width is 0 while the divider says 240 -- the position is what was asked for, `pane_box` is what the pane actually got, and a divider at 240 with a zero-width pane is still no pane | +| `axis`, `marker` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker. `page=events` and `page_box` (the page's width, as in `open alone`) say the page is still up and has a width: a replay from the page must not close it | | `rows_kept`, `selected` (in `activated row0`) | `rows_kept=1` and `selected=N/N` -- the counts either side of the activation equal | the list **rebuilt** by the activation. It is the row *object* that is compared, not its index: a rebuilt list has a row 0 too, and only identity tells a list that was left alone from one that was torn down under the user's scroll position and their comparison selection. `mark_events_on_screen()` must rewrite the titles in place | | `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread | | `window`, `trigger`, `channel`, `level` (in `live restored`) | each equal to its `(want …)` | the way back out of a file view discarding what the live view was set to. One wrong half names which writer won: a `window` that reads the **session file's** value is the deferred `apply_layout()` beating the restore; `trigger=0` is the toggle the file view switched off and nobody switched back; an empty or shifted `channel` is an index restored instead of a name, after a rebuild renumbered (or a group renamed) the channels; and a `level` that moved on its own is `rebuild_trigger_signals()` re-defaulting it because `trigger_level_touched` was not restored with the configuration | | `pending`, `gen` (in `live restored`) | `pending=0`, and the two generations different | the restore still waiting. It is gated on the stream's first CONFIG, so `pending=1` says the channel list the stored trigger name needs never arrived -- with `channels=0` behind it, a session file that enabled nothing | | `pinned`, `held`, `mode` (in `live restored`) | `pinned=0`, **or** `pinned=1` with `held=1` | a file's pin surviving the way back, which is a live view that cannot be navigated. `apply_time_window()` drops it; the second reading is the legitimate case -- a restored trigger that was *on* can latch again before the probe reads, and a held capture is pinned, correctly | -| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered | | `tick` | 1 in live mode, **0** once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored | | `window` (in `activated row0`) | the **event's** span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once | | `compare buttons` | `selected=2` and both buttons sensitive | the buttons reading something other than the selection, or staying insensitive with a valid pair selected | -| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `pane=1` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced | +| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `page=events` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced | | `compare plots` | `area_b=1 rows_a=1 rows_b=1 list=4 same_x=1` | no second plot on screen, a second plot with a different row count, two plots not on one x window, or `list` above the signal count -- the panel listing each event's copy of a signal as a signal of its own | | `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees | | `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds | @@ -1597,31 +1752,34 @@ library measures and draws the rect in tests of its own. ## `panes:` — the locked and configuration modes ``` -[udpscope] pane drag: sidebar hidden for the drag=1 restored after it=1 +[udpscope] pane drag: slide-over closed for the drag=1 [udpscope] pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok) -[udpscope] panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms' -[udpscope] panes: trigger=config signals=config body_visible=1 summary='' +[udpscope] panes: trigger=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms' +[udpscope] panes: trigger=config body_visible=1 summary='' [udpscope] panes: the same line as the probe found it: 'mode: stream (trigger off)' ``` -**`pane drag:`** is a drag begun through the *source* with the split view forced into its **overlaid** -state, which is what a window with no room beside the plot produces. Both must read `1`: with the sidebar -covering the plot there is no drop target under the pointer, so a drag out of the signal list is -*impossible* rather than merely awkward, and `row_drag_sidebar()` steps the sidebar aside for the length of -the drag and puts it back after. `hidden=0` is an overlaid drag that can never land anywhere. +**`pane drag:`** is a drag begun through the *source* of the first signal badge (`drag-begin` emitted on its +`GtkDragSource`, so the handler is the one a real drag reaches) with the `signals` slide-over page open. It +must read `1`: the slide-over covers the plot's right edge, so a drag that started with it open would have part +of the plot that is not a place to drop, and the badge's `drag-begin` closes it. (The page that was open is put +back afterwards.) `0` is a drag that leaves the panel over the plot; `-1` is no badge to drag (no signal enabled, +or more than six, where the badges are replaced by the "N signals" overflow). It replaced the old +`sidebar hidden for the drag=1 restored after it=1` pair, which tested `row_drag_sidebar()` -- the list rows were +the drag handle and the overlaid sidebar had to step aside; both are gone. -**`pane edits:`** is the two `[edit]` buttons in each mode, by name and verdict, and it exists because of +**`pane edits:`** is the trigger pane's `[edit]` button in each mode, by name and verdict, and it exists because of a real defect: the "done" icon was `emblem-ok-symbolic`, which **Adwaita does not have** (breeze does), so the button that leaves configuration mode was blank — and a blank icon button is invisible in every other reading, exactly as `toolbar icons:` describes for the toolbar. The probe clicks into configuration mode and back, so one line carries both names. **Both must read `(ok)`**, and the pair is `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done). -Both panes have two modes. **Locked** is what a returning user gets — the trigger pane shows the one line -in `summary`, the signal list shows only the signals that are on, and neither offers a control that -changes the configuration — and **config** is the full pane, which is what a *first run* gets, because a -session file that loaded is what "we have been here before" means. Each heading's `[edit]` button flips -its pane. +The trigger pane has two modes. **Locked** is what a returning user gets — the pane shows the one line +in `summary` and offers no control that changes the configuration — and **config** is the full pane, which +is what a *first run* gets, because a session file that loaded is what "we have been here before" means. +The heading's `[edit]` button flips it. (The signals have no lock: the badges and the signal pages are +direct manipulation, and `panes:` no longer carries a `signals=` field.) - **With trigger mode off the summary is one line**: `mode: stream (trigger off)`. The four lines below describe a trigger that is not running, so quoting them would describe nothing. The dump carries the line @@ -1655,9 +1813,8 @@ its pane. `body_visible=0` is a locked pane showing nothing at all, which is the defect this reads for. - **`body_visible=0`** is the settings being off screen, which is what makes the pane a couple of hundred pixels instead of 580 in `panels:`. -- **The locked signal list drops the rows of the signals that are off**, so `rows` counts what is on: - `signals panel: … rows=3 signals=2 divider=1` is two on signals and the `MATH` divider, not four - signals. Its `+` goes with them — a `+` on a locked list opens the editor the lock exists to keep shut. +- **The signals list has no lock.** It lists what can be *added* (see `signals panel:`), and the signal + pages always allow acquire, colour, gain/offset, math edit and delete. ## `panel switches:` and the trigger panel's default sensitivity @@ -1682,11 +1839,49 @@ disagree. The two lines are the claim, in both directions: - `trigger panel sensitive …=0` is the *panel body*, not the panel: the heading row, with its switch, stays live. `1` here while trigger mode is off is the pane not being disabled at all. +## `dock cursors:` and `dock measures:` + +``` +[cursors-probe] dock cursors: lines=1 page_lines=1 same_text=1 dock_w=268 side_w=280 fits=1 dock_sensitive=1 +[measures-probe] dock measures: lines=13 page_rows=13 same_text=1 dock_w=268 side_w=280 fits=1 +``` + +These ride on the `[cursors-probe]` readings (every state line gets a `dock cursors:` beside it) and on the `[measures-probe]` readings (`added`, `settled`, `final`), and read the dock's compact copy against the page it copies. The dock never formats a value: `update_cursors_pane()` and `update_measures()` hand the same string to the page label and the dock label. + +- **`same_text=1` is the assertion**: every dock label (the cursors' who, A, B, delta cells; the measurements' values) reads the same text as the page's label in the same position. `same_text=0` is a second formatter, or a value written to one view only; the two panes must never disagree. The probe compares `gtk_label_get_text()`, so the who cell compares as the text a user reads, markup stripped. +- **`lines` must equal `page_lines` / `page_rows`**: one dock line per page line. A shorter dock is a rebuild that was not mirrored (`dock_rebuild_cursors()` / `dock_rebuild_measures()` run from the pages' rebuilds); `same_text` reads 0 with it, so this names the cause. +- **`fits=1`** (`dock_w <= side_w`, `side_w > 0`): the readouts box fits inside the sidebar scroller. `dock_w` reads **268** inside the 280 px sidebar (the box's own margins and the fixed label widths: who 5 characters, each value 9, a measurement's type 12). **A who cell at 8 characters made the box 304 px and `fits=0`**, which is the defeat of the pin: the fixed-width ellipsis is what makes the dock's layout independent of what a value reads, so a cell given more characters widens the dock for every run, not only for a long name. `side_w=-1` or `dock_w=-1` is a probe that ran before the dock was built. +- **`dock_sensitive=1`** (cursors only): the dock's cursor grid is live whenever the stream has signals; `0` before them ("disable, do not hide"), `0` with signals is the dock never coming back. +- On a session with no cursors line, or with no measurements, `lines=0 page_lines=0 same_text=1` is correct: the dock then shows `no measurements` for the measurements (one non-value label, not counted in `lines`). + +## `strip:` (the readouts strip) + +``` +[cursors-probe] strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=4 measures=0 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=100 groups_ok=1 +[measures-probe] strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=0 measures=12 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=010 groups_ok=1 +[measures-probe] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1 +[cursors-probe] dock hidden strip: visible=1 expected=1 visible_ok=1 dock_shown=0 cursors=4 measures=0 same_text=1 h=26 sens_same=1 ... +[cursors-probe] dock restored strip: visible=1 expected=1 visible_ok=1 dock_shown=1 ... collapsed=1 window=756 +``` + +One `strip:` line follows every `dock cursors:` / `dock measures:` line, and each probe ends with a drive that puts the dock away through the wish (`sidebar_wanted = false`), reads the strip 600 ms later (`dock hidden`), restores the wish and reads again (`dock restored`). + +- **`visible` equals `expected`, and `expected = (!dock_shown || collapsed) && (cursors on || measures non-empty)`** (`dock_shown` is `show-sidebar`, `collapsed` the split view's, both read in the same frame as `visible`; "cursors on" is the cursors toggle, not the `cursors=` chip count, which is a rebuild's and is 1 even with them off). `visible_ok=1` is that equality; `visible_ok=0` is `sync_readouts_strip()` not run, or run before the reconciliation. A single such line right after the probe's own `update_side_visibility()` or on the very first line (the tick has not set `collapsed` yet) is the sub-frame before the next tick and not a defect. +- **What each leg reads.** `[measures-probe] start` (before the probe adds anything, from a **fresh** config: a stored session brings its measurements back) is the "neither" state: `visible=0 expected=0 groups=000 h=0`. The measures probe's later lines have measurements and the cursors off: `groups=010`. The cursors probe alone has the cursors on and no measurements: `groups=100`; run after the measures probe in the same session it has both: `groups=111`. A line with the strip visible and `groups=000`, or hidden with a group on, is `sync_readouts_strip()` not called from that path. +- **`groups=`** is which of `strip_cursor_group`, `strip_measure_group` and `strip_sep` are visible; **`groups_ok=1`** is that they equal (cursors on, a measurement, both). `0` is a group left over from the previous state. +- **`h=0` with `visible=1` on the `cursors-probe ... on` and `measures-probe ... added` lines** is the strip shown in the same frame the probe reads it, before its first allocation; the next line reads 26. It is not a `visible_ok` miss: the toggle handler and `rebuild_measures_pane()` call `sync_readouts_strip()` themselves, so `visible` is already right. +- **`visible=1 expected=1` on every line that has something to read, under `xvfb-run`**, the `dock restored` line and the lines with `dock_shown=1` included: the window is 756 px, below the 900 px `kSidebarRoom`, so the split view is collapsed, the dock is an overlay on the plot, and the strip stays. `visible=0` there is the old rule (hidden whenever the dock is shown) or `collapsed` not being read. The wide case (`collapsed=0`, `dock_shown=1`, `visible=0 expected=0`) is **not reachable from a probe**, because a window with no manager cannot be resized by `gtk_window_set_default_size()`. +- **`h` is 26 with `visible=1`, 0 with `visible=0`.** Any other visible height is the chips setting the strip's height (the button trim in `ui_widgets.cpp` or the `height-request` lost). +- **`same_text=1` is the assertion**: every strip label (the cursors' `who` and delta, the measurements' values) has the dock's text, and the counts of chips match the dock's lines. `0` is a value written to one view only -- a dock cell set without `dock_cursor_cell()` / `dock_measure_value()`, or a rebuild that did not mirror. +- **`cursors` / `measures`** are the chip counts and equal the dock's `lines`. +- **`sens_same=1`**: the strip's cursor chips and the dock's cursor grid have the same sensitivity (`dock_set_cursors_sensitive()`). +- **`collapsed=1 window=756`** on every line under `xvfb-run`; the very first line can read `collapsed=0` (the tick has not set it yet), and then `expected` follows it. + ## `[sidebar-probe]` ``` -[sidebar-probe] beside window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1) -[sidebar-probe] regained window=756 sidebar=320 collapsed=1 shown=1 button=1(sens=1) +[sidebar-probe] beside window=756 sidebar=280 collapsed=1 shown=1 button=1(sens=1) +[sidebar-probe] regained window=756 sidebar=280 collapsed=1 shown=1 button=1(sens=1) ``` `UDPSCOPE_PROBE_SIDEBAR_MS` reads the sidebar in the two states a window can put it in, and against a hide @@ -1697,7 +1892,7 @@ and `regained` reads 400 ms later. - **`sidebar` must never be 0.** It was, for every window narrower than the 940 px the divider used to sit at, and the pane could only be found by dragging the divider back — the report this reading exists for. - It must read its own width (320) in *both* states: `collapsed` changes where the sidebar is, never how + It must read its own width (280) in *both* states: `collapsed` changes where the sidebar is, never how wide it is. - **`shown` and `button` must agree, always.** They are the panel and the toolbar button, and a button that says "shown" over a hidden panel is the report this reading exists for. `shown=1 button=1` on `regained` @@ -1713,27 +1908,213 @@ and `regained` reads 400 ms later. ## `[side-tab-probe]` ``` -[side-tab-probe] page=signals accums=4 baseline recorded -[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=18 +[side-tab-probe] section=none accums=4 baseline recorded (measure page was_open=1, now closed=1) +[side-tab-probe] section=none rows=4 accums=4 advanced=4 min_n=18 ``` -`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the rule that **the statistics accumulator keeps running -on the hidden page**. It switches *to* the Measure tab, forces one pass so there is a baseline, then switches -away to Signals and reads again two seconds later -- with the measurements' page not current, so its widgets -are unrealized and measure 0x0. +`UDPSCOPE_PROBE_SIDE_TAB_MS` (the variable and the tag keep their pre-dock names; the function is `dock_section_probe`) is the one reading for the rule that **the statistics accumulator keeps running while the measurements' page is closed**. The measurements are a slide-over page (`measure`), not a dock section, so the old scroll-the-dock set-up is gone. The probe opens the page, forces one pass so there is a baseline, *closes* the page (its widgets stay in the tree, unmapped) and reads again two seconds later. It re-arms once after 400 ms so the first reading has the layout. **Run it against a config that has seen the first-run tour**, which would close the page it opens (`tour_seen = true`). -- **`advanced=4` is the assertion**: every accumulator gained passes while its page was off screen, and +- **`was_open=1 closed=1` is the precondition**: the page really was open for the baseline and really is closed for the report, so `advanced=4` says something. `was_open=0` is the page never opening (the tour closed it, or the page is insensitive because the stream has no signals) and `closed=0` is `slideover_close()` not taking effect; either way the run proves nothing. `section=` is the slide-over's open page and **reads `none` on both lines**, because the page is closed. +- **`advanced=4` is the assertion**: every accumulator gained passes while the page was closed, and `min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the - accumulation on the visible tab, so the readout would gain a gap in its min/max/avg for exactly as long as - the trigger was on screen. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an - allocation read on the unrealized page. + accumulation on the page being open, so the readout would gain a gap in its min/max/avg for exactly as long as + the page was shut. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an + allocation read on the unmapped page. - The probe enables a signal **and assigns it to a plot** itself, both deliberately. A signal starts disabled (enabling is what starts acquisition), and a channel with no trace leaves the live window *still* -- and a still window is the state `update_measures()` is *supposed* to skip, so a probe that left it that way would - read `advanced=0` for a reason that has nothing to do with the tab. + read `advanced=0` for a reason that has nothing to do with the page. - `rows` is the measurement count and `accums` the accumulator count; the two must agree, because one accumulator per measurement row is what makes `advanced` a statement about every row. +## `[source-kinds]` + +``` +[source-kinds] kinds=udps,e1608,flatudp,probe +[source-kinds] form udps: round_trip=1 valid=1 +[source-kinds] form e1608: round_trip=1 valid=1 +[source-kinds] form flatudp: round_trip=1 valid=1 +[source-kinds] form probe: round_trip=1 valid=1 +[source-kinds] panel disconnected: panels=1 present=1 sensitive=0 +[source-kinds] panel connected: panels=1 present=1 sensitive=1 +[source-kinds] capture button: present=1 sensitive=0 +[source-kinds] command: before=0 after=1 drained=1 +``` + +`UDPSCOPE_PROBE_SOURCE_KINDS=2000`, run as `--source probe --source-opt rate=500`, is the one reading for the +source-plugin path from the registry to the producer thread. The variable also registers the test-only `probe` +kind, so a run without it never shows that kind. The `form` line appears once per registered kind. + +- `kinds=udps,e1608,flatudp,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`, + or the env var gate is wrong. +- `round_trip=0`: a form's `write` and `read` disagree about a key -- a renamed `SourceConfig` key, or an + absent key that was not defaulted. `valid=0` is a default configuration the form refuses. The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1, plot0 and gen_rate; a mismatch names one of those. The flatudp sample sets port, bind, multicast, iface, rate, endian and signals. +- `panel disconnected: sensitive=0 present=1`: **disable, don't hide.** `present=0` is a panel that was hidden + or never added; `sensitive=1` is a panel that stays live with no source behind it. +- `panel connected: sensitive=1`: anything else means `apply_source_panels` acted on the wrong widget (it must + act on the kind's own widget, not the scroller around it). +- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI **disabled, not + hidden.** `sensitive=1` is the button live for a source that has nothing to capture, `present=0` a hidden one. +- `command: before=0 after=1 drained=1`: the command reached the producer thread through `post()`. + `drained=0` is a queue that `poll()` does not drain on some path; `after` greater than `before + 1` is a + command run twice. + +The UDPS run has no `source-` slide-over page, because the UDPS kind has no panels. + +## `[e1608]` + +``` +[e1608] panel ao: present=1 sensitive=1 connected=1 +[e1608] panel dio: present=1 sensitive=1 connected=1 +[e1608] actions: ao0_level=1 do1=1 counter_reset=1 +[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames=274 rate=1000.000 ok=1 +[e1608] calibration: ai0_mean=0.5000 expect=0.5000 ok=1 +[e1608] capture button: present=1 sensitive=0 +[e1608] state: connects=1 message='' +[e1608] decode: ok=1 +[e1608] scan socket closed by the board +[e1608] reconnect: connects=2 connected=1 ok=1 +[e1608] resumed: frames_before=612 frames_after=662 ok=1 +[e1608] generator: requested=100.0 achieved=99.7 panel='100 Hz (requested 100 Hz)' ok=1 +[e1608] generator stopped: achieved=0.0 panel='off (requested 100 Hz)' ok=1 +[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1 +``` + +`UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and +`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the next two) run as `--source e1608` against `tools/mock_e1608.py` +with the options in `verification.md`; `UDPSCOPE_PROBE_E1608_SCAN=1000` (the `scan` line) is a run of its own. The line between `decode` and `reconnect` is the source's own stderr message +about the drop, not a probe line (its text is the error the source met, once per distinct message). `connected=0` throughout, with +`actions: skipped (not connected)`, is the no-board run; it is a reading and not a failure, and it also prints +the one `[e1608] handshake with ...: Connection refused` line, `stream: ... signals=0 expect=1 ... ok=0`, +`calibration: ai0_mean=nan ... ok=0` and `state: connects=0 message='handshake with ...'`. + +- `panel ...: present=1 sensitive=`: **disable, don't hide.** `present=0` is a panel that was never + added; `sensitive` that differs from `connected` is `apply_source_panels` acting on the wrong widget. +- `actions: ao0_level=1 do1=1 counter_reset=1`: each control was found by its widget name (`e1608-ao0-level`, + `e1608-do1`, `e1608-counter-reset`). A `0` is a renamed widget, or (`do1`) `dir1=o` missing from the run. The + effects are read off the **mock's log**, not here: a DAC write near 2.5 V (within 0.01), `DOUT 0x02`, `COUNTER_RESET`. + A control that was found but whose effect is missing is a command that `post()` did not deliver or `poll()` + did not run. +- `stream: signals=4 expect=4 rate=1000.000 ok=1`: the enabled channels plus the plotted digital lines plus the + counter, at the scan rate. `signals` below `expect` is a signal the source did not declare; `rate` off by a + factor is the pacer computed from the wrong aggregate. `ok` also requires `names=` to be the enabled analog + inputs, then the plotted digital lines, then `Counter`, in that order: swapped channels read `ok=0`. +- `calibration: ai0_mean=0.50xx ok=1`: the mock's AI0 is 0.5 V under a 5 Hz sine of 0.1 V, read through + non-trivial factory coefficients; the mean over two periods lands on 0.5 V only when `decode()` applies them. + About `0.2043` is the **uncalibrated** reading (the calibration read failed, or `calibrate=0`); `nan` is a + run that never got AI0 (not connected, or AI0 not enabled). +- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI disabled, not hidden. +- `state: connects=1 message=''`: a non-empty message at this point is a refusal that happened (for example + `calibration memory refused; using nominal values`), which the stream then ran through. +- `decode: ok=1`: a fixed raw buffer against hand-computed volts (intercept 100, BIP10V and BIP5V, the code + clamp at both ends). It needs no board; `ok=0` is a change to the calibration arithmetic. +- `reconnect: connects=2 connected=1`, `resumed: ok=1`: the mock dropped both links at about 3 s; the source + reported it, retried after 2 s and the frame count moved on. `connects=1` is a source that never noticed the + drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart + the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped + proves nothing). +- `generator: requested=100.0 achieved=99.7 ... ok=1`, `generator stopped: ... ok=1` + (`UDPSCOPE_PROBE_E1608_GEN=1500`, a run of its own with `gen_rate=100`): `requested` is `gen_rate`, `achieved` + the source's own measurement (generator ticks that wrote, per second of wall time, over a window of about 1 s), + `panel` the subtitle of the Analog out panel's "Update rate" row. `ok` needs `achieved` within 70% to 110% of + `requested` **and** the panel to quote the requested rate; the mock answers at once, so it reads within a + percent of `requested`. Far below is a `run_timers()` that re-arms with a constant (the 20 ms the setting + replaced reads 50) or a tick that runs late; `panel='off'` while `achieved` is non-zero is a panel that + did not refresh (it ticks only while its page is on screen) or one that reads the wrong readout. This line says + nothing about a real board, whose DAC writes are slower; there the same row is the measurement. The stopped + line must read `achieved=0.0` and a panel that starts with `off`; a non-zero value is a rate that + `update_gen_flags()` did not reset when the last generator stopped. +- `scan: found=1 ... ok=1`: the form's **Scan network** row against the mock. `found` is the result rows after + the first scan; `0` with the second line `did not answer` is an environment with no up, non-loopback + broadcast interface that reaches the mock (or a refused broadcast), **not a pass**. `ip` is the listed address + of the expected board and `mac` the MAC the probe looked for. +- `status='1 board found'`: the row's subtitle at the end of the first scan (`N boards found` with more + than one). `No board found` with a mock running is the probe's sockets not reaching it. +- `fill_addr=0`: a click on a result did not set only the address (the MAC or subnet kept a stale value), or it + told the dialog something other than once. `fill_mac=0`: the same for **Use MAC**. +- `rescan_replaced=0`: the results of the first scan were kept or doubled by the second, so `start_scan` did not + remove them. + +## `[flatudp]` + +``` +[flatudp] codec: ok=1 +[flatudp] form: initial_valid=0 good_valid=1 size_ok=1 dup_valid=0 dup_msg=1 oversize_valid=0 add=1 remove=1 wave_valid=1 wave_sub=1 wave_roundtrip=1 mixed_valid=0 mixed_msg=1 length_valid=0 length_msg=1 cap_valid=0 cap_msg=1 cap_wave_valid=1 vec_shown=1 vec_drive=1 vec_reset=1 ok=1 +[flatudp] datagram of 22 bytes, expected 21, check the signal list +[flatudp] stream: connected=1 signals=7 names=Trigger,Ia,Cnt,Wave[0],Wave[1],Wave[2],Wave[3] expect=Trigger,Ia,Cnt,Wave[0],Wave[1],Wave[2],Wave[3] frames=368 rate=1000.000 ok=1 +[flatudp] channels: count=7 expect=7 ok=1 +[flatudp] continuity: Trigger_breaks=0 Ia_breaks=0 Cnt_breaks=0 Wave[0]_breaks=0 Wave[1]-[0]_breaks=0 ok=1 +[flatudp] wrong size: count=4 message='datagram of 22 bytes, expected 21, check the signal list' ok=1 +[flatudp] capture button: present=1 sensitive=1 +[flatudp] state: valid=3683 wrong=4 +[flatudp] silence: connected=0 model_connected=0 after_ms=4300 ok=1 +[flatudp] resumed: connected=1 frames_before=401 frames_after=410 ok=1 +``` + +and, for the waveform-only list `Wave:int16[4]w`: + +``` +[flatudp] stream: connected=1 signals=1 names=Wave expect=Wave frames=3737 rate=4000.000 ok=1 +[flatudp] channels: count=1 expect=1 ok=1 +[flatudp] continuity: wave_breaks=0 ok=1 +``` + +`UDPSCOPE_PROBE_FLATUDP=2500` (the first lines; counts vary) and `UDPSCOPE_PROBE_FLATUDP_GAP=1500` (the last two) +run as `--source flatudp` against `tools/mock_flatudp.py` with the options in `verification.md`. The second line is +the source's own stderr message, printed once per distinct wrong size, not a probe line. With no sender the stream +lines read `connected=0 ... frames=0 ... ok=0`, `continuity` is skipped and the capture button reads +`sensitive=0`: a reading, not a failure. A run without `--bad-every` reads `wrong size: count=0 ... ok=0` for the +same reason. + +- `codec: ok=1`: the `signals` text round-trips (including a `w` row and a bracketed count of 1 on a waveform), a + hand-built 24-byte datagram decodes to the hand-computed values in both byte orders (`uint8` 254, `int16` -2, + `float32` 1.5, `uint64[2]` 1 and 4294967298, `int8` -1), so do a `float64`, `uint16`, `int32`, `uint32` and + `int64`; `decode_channels` puts a `channels` vector in `count` columns and a waveform in one; and the validity + rules refuse what they should (a waveform of count 1, a waveform mixed with another row or of another length, + 257 channels). It needs no source, so `--source probe` shows it too. `ok=0` is a codec defect: a swapped byte + order, a wrong width or a rule that does not fire. +- `form: ... ok=1`: driven through the widgets, not the settings. `initial_valid=0` -- an empty list is not + connectable (`1` is a form that lets a source with no signals through). `good_valid=1 size_ok=1` -- a + 13-byte list is valid and its subtitle says `13 bytes` and `6 channels` (`W:int16[4]` as channels). + `dup_valid=0 dup_msg=1` -- a duplicate name is refused *and the subtitle says why* ("used twice"). + `oversize_valid=0` -- two `float64[4096]` rows (65536 bytes) exceed a datagram. `add=1` -- two clicks on + "Add signal" append `S1` and `S2`; `remove=1` -- the first row's "Remove" button takes that row out. A `0` + on `add`/`remove` with a `Gtk-CRITICAL` is a row handled after it was removed. The vector fields: + `wave_valid=1 wave_sub=1 wave_roundtrip=1` -- two waveform rows of one length are valid, the subtitle says + `2 channels`, and `signals` reads back with its `w`; `mixed_valid=0 mixed_msg=1` -- a waveform beside a + scalar is refused and the subtitle names the waveform rule; `length_valid=0 length_msg=1` -- waveforms of + two lengths are refused ("same length"); `cap_valid=0 cap_msg=1 cap_wave_valid=1` -- 300 channels are over + the 256 cap and the subtitle says `256`, while two `[200]w` waveforms (2 channels) are fine; `vec_shown=1` + -- the "Vector as" drop-down (`flatudp-vector`) is shown on a count above 1 and hidden on a scalar row; + `vec_drive=1` -- setting it to Waveform writes `Row::wave` (one waveform beside a channels row, then beside + a scalar, are both still invalid, and the text gains its `w`); `vec_reset=1` -- a row whose count returns + to 1 hides its drop-down and returns to Channels. Like `codec`, it needs no flatudp source. +- `stream: ... ok=1`: the model's signals are the list's channels in order, each at its declared rate: `rate` + for a scalar or a `channels` element (named `Name[k]`), `rate * count` for a waveform. `names` out of order is a + decode that mixed rows; a rate that is not the expected one is a `sampling_rate` that did not reach the model + (the ring and the decimation are then sized for the wrong cadence; a waveform at `rate` instead of `rate * 4` + has a time axis 4 times too long). +- `channels: count=7`: one channel per `SignalInfo`, which is `channel_count(rows)`: a scalar 1, a `channels` + vector `count`, a waveform 1 (its `count` values are consecutive samples of that one channel). +- `continuity: _breaks=0 ...`: the mock's row k carries `(i * (k + 1) + e) % 100`, so consecutive samples + of a channel from row k step by k + 1 modulo 100 (`Trigger` 1, `Ia` 2, `Cnt` 3, `Wave[0]` 4), and `Wave[1]-[0]` + is the same-sample difference of two elements, which must be 1. A break is a lost or reordered datagram, a + batch flushed with the wrong number of samples, an element mapped to the wrong channel, a wrong byte order + or ingest decimation that is not 1:1. A waveform list reads `wave_breaks`: samples step by 1 inside a + datagram and by `2 - count` into the next, at a phase the probe finds from the first such step and then holds; + a non-zero count is a block of the wrong length or one that dropped samples. (Before the `channels` form, a + mixed list showed ~400 breaks here: a scalar next to a vector was zero-padded to the vector's length.) +- `wrong size: ok=1`: wrong-size datagrams were counted and the message names the received and expected size. + The message must also be the only stderr line of its kind: one per distinct size, however many arrive. +- `capture button: present=1 sensitive=1`: the kind reports datagrams, so the capture UI is live (contrast + `[source-kinds]`, where the probe kind leaves it disabled). With no stream it reads `sensitive=0`. +- `silence: connected=0 model_connected=0 after_ms=...`: 2 s without a valid datagram raised `Disconnected` + (`after_ms` is the wait from the stream coming up to the drop; it only reads the gap's start in the app's + clock). `connected=1` is the timeout not firing or wrong-size datagrams refreshing the silence clock (they + must not); `never connected` is a stream that never came up before the probe gave up after 20 s. +- `resumed: ok=1`: `Connected` was raised again and frames resumed. `connected=0` here means the source never + recovered after the sender came back. + ## `[prefs-probe]` ``` @@ -1811,55 +2192,222 @@ copy and never writes is exactly the failure this has to catch. ``` [prefs-probe] mnemonics=4/4 -[sources] form mnemonics=8/8 +[sources] form mnemonics=9/9 ``` How many of the dialog's rows have `use-underline` set -- the property that makes a `_` in a row's title a mnemonic. It is FALSE on `AdwPreferencesRow` by default, which is why these titles were drawn with a literal underscore; anything below the full count is that bug back. Read by property name rather than through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form's line appears only when -`UDPSCOPE_PROBE_NEWHOST` drives the form. +`UDPSCOPE_PROBE_NEWHOST` drives the form. It reads 9/9 since the chooser gained its Source type row, which has a +mnemonic of its own (a non-UDPS kind's form has fewer rows: the `probe` kind reads 6/6). ## `trigger panel:` ``` -[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4 +[udpscope] trigger panel: boxes=16 controls_inside=15/15 search_widgets=4/4 search_inside=4/4 advanced_widgets=5/5 advanced_inside=5/5 ``` The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box this app wraps it in — the heading row and the locked summary are the code's arrangement, and reading the *wrapper* made this line report `boxes=2 … search_inside=0/4` the moment the heading moved out of the file. That is the failure this line is for, so it is worth knowing it can happen to itself. `boxes` is the row -boxes the `.ui` holds, `controls_inside` -counts the fifteen controls the code built and then appended into those rows -- the last two being the -noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four -search buttons having resolved by id, and `search_inside` is those four being inside the block the expander -opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as -`controls_inside` below 15 while every other line still looks right, which is the panel that has a field -missing. +boxes the `.ui` holds and `controls_inside` +counts the sixteen controls the code built and then appended into those rows -- the last three being the +noise band and the HF reject, whose rows arrived with the noise-rejection work, and the persistence depth +counts the fifteen controls the code built and then appended into the panel's own rows -- the noise band and +the HF reject, the persistence-depth spin and the record switch among them, and the Arm/Force *pair* not, +because that row holds buttons and a `boxes` count reads the rows either way. -The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the -widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see -`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's seven spins in tree order, so -the two lines together read what the file gave and what the code put in it. +`boxes` is **16** since the trigger's display settings moved into the `Advanced` expander: the multiple-event +depth and the four density controls left the panel's own child list, and the expander that holds them is not a +box. Those five are what `advanced_widgets`/`advanced_inside` count, for the same two reasons the search pair +exists -- the ids resolving, and the controls being *in* the block -- and an expander's child is **not** in the +widget-parent chain, so a control there is invisible to `controls_inside` by construction (see +`invariants-gtk-and-chrome.md`). The walk for a block therefore starts at **its own** expander, +`gtk_expander_get_child(GTK_EXPANDER(app.search_exp))`: the first version of this line took "the first +expander in the panel", which stopped naming the search block the moment a second expander was added *before* +it -- and would have read `search_inside=0/4` on a panel that was entirely correct. `[defaults] trigger:` +prints the same panel's spins in tree order, so the two lines together read what the file gave and what the +code put in it. + +## `[persist-probe]` and `persistence:` + +``` +[persist-probe] held depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 signals=5 channels=4 layers=12 want=12 placed=12 alphas=[0.1250,0.2500,0.5000] ages=[3,2,1] row0=[1:'sine (A)'@0.5000 2:'sine (B)'@0.2500 3:'sine #4'@0.1250] drawn=12 +[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1 +[udpscope] persistence: depth=4 kept=3 bytes=20998152 budget=209715200 dropped=0 layers=0 want=0 placed=0 schedule=[0.1250,0.2500,0.5000] alphas=[] +``` + +The persistence display: the last `depth` triggered acquisitions drawn behind the current one, each older one +at half the previous one's brightness. Run with one signal per row and a 0.5 s window against the mock at +500 kS/s, which is what the two lines above are. + +**`alphas=` is the assertion, oldest first**: `0.5000,0.2500,0.1250` — halving, with `ages=[3,2,1]` matching +one for one. A flat, ascending, or age-gapped list is the decay not being applied or applied backwards (the +rule is in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). `row0=` is the same +schedule read per slot, with each layer's name — `sine (A)` one acquisition back, `sine (B)` two, `sine #4` +three, the accumulate path's own suffix vocabulary. `layers=` is the layer slots that exist, +`want=` the depth's whole demand across the rows, `placed=` what fitted: **the three agreeing is a display +that shows every age it was asked for**, and `placed < want` is a layout with no free trace slot left (a row +of `S` signals shows `floor((8 - S) / S)` ages of each, so the default four-signal row shows one). + +**`kept`/`bytes`/`dropped` are the *retention*, and they bound what those slots can draw.** `kept=3` of the +3 asked at `bytes=20998152` (21 MB: three acquisitions of a 0.5 s window at 500 kS/s over four channels, +~7 MB each) against `budget=` the planned ring bytes (`persist_budget_bytes()`, `Partition::bytes()`). `drawn=` is how many layer slots are +actually *bound* to a retained acquisition, and it is a different number from `placed=` exactly when the +budget bit — which is the run to take next, on the fast mock with a long window: + +``` +[persist-probe] settled: kept=1 of 7 after 270 polls +[persist-probe] held depth=8 kept=1 bytes=72798888 budget=209715200 dropped=0 signals=5 channels=4 layers=28 want=28 placed=28 alphas=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] ages=[7,6,5,4,3,2,1] row0=[1:'sine (A)'@0.5000 … 7:'sine #8'@0.0078] drawn=4 +[persist-probe] held axis=[-1.0000..1.0000] pin=[-1.0000..1.0000] snap=[-1.0000..1.0000] n=2600000 ev=[-1.0000..1.0000] ev_is_snap=1 pin_is_snap=1 +[udpscope] persistence: depth=8 kept=2 bytes=145598128 budget=209715200 dropped=2 layers=0 want=0 placed=0 schedule=[0.0078,0.0156,0.0312,0.0625,0.1250,0.2500,0.5000] alphas=[] +``` + +`UDPSCOPE_PROBE_PERSIST_DEPTH=8 UDPSCOPE_PROBE_PERSIST_WINDOW=2` against the mock at 1.3 MS/s: one retained +acquisition is 72.8 MB, so the budget (the planned rings, ~201 MB for four channels at the default 10M) keeps two and drops two — `kept` below the depth, `dropped` +non-zero, and **`drawn=4` against `placed=28`**: the layer slots exist and the oldest seven ages per row draw +nothing at all. That is the memory bound as a reading, and `settled:` is the wait saying so (the retention +can never reach the depth, so the probe reports what stopped growing instead of waiting out its cap — its +`kept` can therefore be mid-fill, which is why the *dump's* line below is the settled one). The +in-doubling of `alphas=` there is the same rule at full depth: 1/128 at the eighth, which is why the depth +stops at 8. Do not read this run's `frame cost:` as persistence's: a 2 s window at 1.3 MS/s costs ~90 ms a +latch in `publish cost:` on its own, with or without persistence. + +**`ev_is_snap` and `pin_is_snap` must be 1**, and that is the rule about the past: the pin the held event's +axis is re-stated from (`event_data_window()`) is the *snapshot's* own window, so the older acquisitions — +which are bound event series on the same rows — moved neither the axis nor the framing; both runs above read +`axis = pin = snap = [-0.2500..0.2500]` with `n=250001`. They are read on a frame that is *bound* (the probe +waits for one), because a latch landing mid-frame leaves the mode `Held` while the rows still hold the live +ring's bindings, and a reading taken there says nothing about either. + +## `[density-probe]` and `density:` + +``` +[density-probe] arming on 'square', window 0.5 s, display off +[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 traces=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000 +[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=99403.0 peak=1.0000 ratio=1.000000 +[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=149104.5 peak=1.5000 ratio=1.500000 +[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=121738.0 peak=1.2247 ratio=1.000000 +[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 traces=4 cells=1044714 ink=44784.9 peak=0.4505 ratio=0.367879 +[density-probe] frame cost: tick before=3.75 ms (145 frames) after=3.59 ms (24 frames) draw before=3.93 ms (125) after=3.23 ms (6) +[udpscope] density: frames=918 rows=4 traces=4 cells=1044714 grid_bytes=4178856 ink=279073.5 peak=2.2 upload_bytes=1044714 +``` + +The **phosphor density** (`Plot::set_density()`, the trigger pane's `persistence` switch): the axes +rect in device pixels **up to `kDensityCellCap`** (262 144 cells, 1 MiB of counts) as an intensity +image **per trace**, in that trace's own signal colour, every acquisition accumulated into it (the +newest included), drawn *in place of* the traces and fading by a count or by the clock. Past the cap +the plotter scales the rect down uniformly and magnifies the image back over it with a linear +filter, so the number this probe reports is bounded whatever the window is. The numbers are the +plotter's own -- `DensityGrid::ink()`/`peak()` summed and maxed over the traces the display is on, +which is the same source the library's tests read -- and the probe drives the panel's **own +widgets** for the switch and the fade, so what it measures is this app's wiring. + +- **`off` is the display without it**: `rows=0 traces=0`, so there is no grid at all and both numbers + are zero. That is also the run the frame cost is read against, and it is a plain run -- the density + is off by default and nothing draws it. +- **`on` is one sweep**: `traces=4` -- one grid per signal, four rows of one signal each -- and + `peak=1.0000` exactly, with `ink=99403` the cells that sweep covered. A cell takes **1.0 once per + trace per sweep** however many of the trace's own samples crossed it, which is what makes a sweep's + peak *one* rather than the eighteen a per-hit deposit read on the same run -- and what makes the + sweeps behind it visible at all. +- **`step-acquisitions`** deposits one more sweep: `peak=1.0000 -> 1.5000`, which is `1 + 0.5 x 1` -- the + panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. The ratio is + **not** a constant to expect -- the acquisition on screen can move between the two reads, so what it says + is "the old image halved and a pass landed on it"; the exact relation is the *library's* case + (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest`), where the same frame is deposited + twice. +- **`step-time` is `1/e` by construction**: one step of `dt = tau` through the plot's own `decay_density()`, + so `ratio=0.367879` is `exp(-1)`. The leg before it (`time-before`) reads the same image under the time + fade (a peak between 1 and 2, `1.2247` here), and the pair is the reading that says the *panel's* constant + reaches the grid. +- **`frame cost:` is the A/B inside one run**: the accumulators as of the tick the display was switched on + against the frames after it, so both means are the same stream, the same layout and the same mock. The + "before" side is a mean that **includes the run's startup frames** (the first paints are the expensive + ones), so it is a high-water mark and not a steady-state mean: at four rows of ~261 000 cells each (the + cap) the pair above reads `tick 3.75 -> 3.59 ms`, i.e. the switch is *inside* the startup noise, and a + quiet machine reading the same line before the cap read `tick 0.62 -> 9.53 ms` at 2.2 M cells. **The + reading that prices a display at a given window is the library's**, taken headless and against a fixed + geometry, in the plotter's `docs/data-and-performance.md`: a 3040x1700 framebuffer with four rows of one + trace and a 2 s window, where the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame and + 4.9 MB uploaded** before the cap and **3.9 / 5.6 ms and 1.0 MB** after it, over the same frames. +- **`cells`/`grid_bytes` are the per-signal memory figure**: at most **262 144 cells and 1 048 576 bytes + *per signal***, and the four signals here read 1 044 714 cells / 4 178 856 bytes because a 1349x424 device + axes rect is already past the cap. It is the cap and the trace count, and neither the trigger nor the + window beyond the cap has anything to say about it -- the app's default layout, four signals in one row, + is four grids for that one row and 4 MiB whoever dragged the window where. +- **The dump's `density:` line** is captured on the last tick the display was **on**, not read at exit: the + exit probe switches the trigger off before it dumps, which takes the display with it, so a dump that asked + the plots would say `rows=0` about a run whose tail had the image up. `upload_bytes` is the **most** any + one frame uploaded -- one byte a cell **per trace**, over the grids that moved -- and not the last frame's, + for the same reason. +- **A frame the display is drawing is not a `[notrace]` frame.** `Renderer::draw_traces()` returns while a + density is on, so every frame of a persistence display stages no trace; the paint probe skips its + traceless accounting for those (they are the display working, not "a frame asked to draw something it had + nothing for") and the `[notrace]` line -- one per frame, and so behind `UDPSCOPE_PROBE_NOTRACE` -- is not + printed for them. A density run's `traceless paints:` therefore no longer counts them: it read `653 (637 + after the first bind)` for a run whose density was up six hundred frames. + +**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink +(the trace's own colour), so a steady display under the acquisition fade sits between 1 and 2, and a `peak` +well above that (the per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now +catches. + +**And `N = 1` is the display without the feature**, which is the same command at +`UDPSCOPE_PROBE_PERSIST_DEPTH=1`: + +``` +[persist-probe] held depth=1 kept=0 bytes=0 budget=209715200 dropped=0 signals=5 channels=4 layers=0 want=0 placed=0 alphas=[] ages=[] row0=[] drawn=0 +[persist-probe] held axis=[-0.2500..0.2500] pin=[-0.2500..0.2500] snap=[-0.2500..0.2500] n=250001 ev=[-0.2500..0.2500] ev_is_snap=1 pin_is_snap=1 +``` + +Nothing retained, nothing planned, nothing bound, no bytes — and the framing line identical field for field, +which is what "the same display as before the feature existed" means as a reading. **That run is also the +baseline for the exit dump's `frame cost:` line**, because the tick mean is a mean over the whole run and the +depth is raised at `UDPSCOPE_PROBE_PERSIST_MS` (2.5 s of 16 s), so the difference between the pair is a +*lower bound* on what the layers cost. At the scale above (0.5 s window, 500 kS/s, four rows, one signal +each): + +| run | tick mean | draw mean | paint mean | frames | +|---|---|---|---|---| +| `..._PERSIST_DEPTH=1` | `0.82 ms` | `11.10 ms` | `15.96 ms` | 936 | +| `..._PERSIST_DEPTH=4` | `1.18 ms` | `28.92 ms` | `34.60 ms` | 470 | + +Thirteen extra traces across the four rows (four signals plus nine layers, the twelfth layer's signal having +no free slot in its row) for **+17.8 ms of draw a frame**, i.e. ~2.6× the ink of the same plot without +persistence; `tick` moves with it by a few tenths, mostly because there are half as many frames to average. + +`[udpscope] persistence:` is the same state at the end of the run, and the one line the *standard* self-check +prints without a probe: `depth`, the retention (`kept`/`bytes`/`budget`/`dropped`), the plan +(`layers`/`want`/`placed`, 0 here because the exit probe's go-live has already cleared the layers — +`layers=0` in that line is correct, not a defect), `schedule=` the alphas the depth asks for oldest first +(the rule; the probe's `alphas=` is what the rows were built with), and `alphas=` for row 0. ## `signals panel:` ``` -[udpscope] signals panel: labels=2 list=1 rows=5 signals=4 math=0 divider=1 -[udpscope] signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1 +[udpscope] signals panel: labels=3 list=1 rows=5 signals=4 math=0 divider=1 drag_sources=0 addable=4 addable_ok=1 empty_label=0 +[udpscope] signals panel: labels=3 list=1 rows=2 signals=1 math=0 divider=1 drag_sources=0 addable=1 addable_ok=1 empty_label=0 +[udpscope] signals panel: labels=3 list=1 rows=1 signals=0 math=0 divider=1 drag_sources=0 addable=0 addable_ok=1 empty_label=1 ``` -The signals panel's frame read from the window, and in the same line the *shape* of the one channel list: -the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds -(`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`), -and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. **`drag_sources` must be -`rows - 1`** — one per signal row, the `MATH` divider not being draggable. Dragging a signal onto a plot is -how a plot is filled, so it has to survive both pane modes: the locked list drops the tick, the menu and the -`+`, and this is the reading that says the drag did not go with them. (`0` is a list that has become a thing -you can read but not use.) `labels=1` is a `.ui` that lost -one of the two labels — counted over the panel's subtree *above* the list, because the rows' own name -and unit labels are the code's and the heading sits in a heading row beside the pane's `[edit]` button — `list=0` one that lost the list -- both of which otherwise show as a panel quietly +The signals panel's frame read from the window, and in the same line the *shape* of the one list. The page +is "Add a signal": it proposes only what can be added, so the list holds the streamed signals that are not +acquired (`signals`), the math channels that are not computed (`math`), and the `MATH` divider with its `+` +(`divider`, 1 when `rows == signals + 1 + math`, 0 otherwise). An acquired signal has a badge in the bar and +is not listed -- **unless the badge bar has overflowed** (more than 6 enabled signals, the "N signals" badge), +when the acquired ones are listed too, since the bar no longer shows them. `addable` is the same count +worked out from the model (unacquired listed signals, or all of them when overflowed) and `addable_ok=1` is +`signals + math == addable`; `0` is a list filter that drifted from the rule. `empty_label=1` is the dim +"every signal is acquired" label shown, which must happen exactly when `addable=0` and the stream declares +signals (the divider row stays, so a math channel can still be created). **`drag_sources` must be +`0`**: dragging a signal onto a plot is the signal *badge's* job now (see `[sigbadges]`, which counts the +badges' own sources), and the list's rows only open the signal's page; a non-zero reading is a second, competing +drag handle on the list. `labels=2` is a `.ui` that lost +one of the three labels (heading, hint, the empty-state label) — counted over the panel's subtree *above* the list, because the rows' own name +and unit labels are the code's — `list=0` one that lost the list -- both of which otherwise show as a panel quietly missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create a math channel. The first line above is a run with no math channels at all: the divider is there anyway, because a `+` that only appeared once a channel existed could never create the first one. `panels: … @@ -1869,14 +2417,15 @@ is what the compact-row CSS rule is for. ## `events pane:` ``` -[udpscope] events pane: compare=1 accumulate=1 list=1 revealer=1 nested=1 +[udpscope] events pane: compare=1 accumulate=1 list=1 nested=1 ``` -The events pane's frame read from the window: the two group actions, the list, and the revealer the `.ui` -builds, plus `nested` — the list being *inside* that revealer, which is the one thing a lost id cannot show -(a list parented elsewhere reads 1 everywhere else). `[events]` is the other half, and it drives the pane end -to end: recording off, recording on, the trigger, the history button that reveals it, a row click and the -replay, then Compare and Accumulate — so the file's buttons are read by being used. +The `events` page's frame read from the window (the page's content is `events_box` of `events-pane.ui`; there is +no revealer, divider or header toggle any more, so none is read): the two group actions, the list, plus `nested` +— the list being *inside* `events_box`, which is the one thing a lost id cannot show (a list parented elsewhere +reads 1 everywhere else). **All four must read 1.** `[events]` is the other half, and it drives the page end +to end: recording off, recording on, the trigger, the page opened with `open_events_page()`, a row activation +and the replay, then Compare and Accumulate — so the file's buttons are read by being used. ## `[windowpop]` @@ -1910,7 +2459,7 @@ refusal it may still hold the previous text. ## `[enable-all-probe]` and switching a signal off ``` -[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the 200 MB buffer' +[enable-all-probe] 4 channel(s) enabled and assigned to plots, notice='connecting to 127.0.0.1:21023 -- planning the buffer: 8.39 M samples per signal' [enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots' ``` @@ -1919,7 +2468,7 @@ earliest thing that reads the status bar. It is set as the window is presented - CONFIG can take the model's mutex, so before anything can block -- and it names the host and the buffer being planned, because that is the window's only answer to "why is nothing happening yet". An empty `notice` here is the window that used to come up silent while a large buffer was allocated: -at `--buffer-mb 4000` that is the dialog disappearing and then over a second of a frozen, blank +at `--buffer-samples 100M` that is the dialog disappearing and then over a second of a frozen, blank window. (The buffer is allocated on the producer thread under `ScopeModel::mutex()`, which the frame tick also takes, so the freeze is real and cannot be papered over in the UI -- see the `[udpscope] buffer allocated:` line below.) @@ -1988,6 +2537,34 @@ rate whenever data arrives at all: Neither can be read off the engine's own fields, which is why the store's pair is here. +## `[udpscope] buffer:` and `[udpscope] planner:` + +``` +[udpscope] buffer: requested=10000000 used=8388608 bytes=167772160 limit=8589934592 limited=0 +[udpscope] planner: unclipped got=67108864 pow2=1 limited=0 | clipped(1GiB) got=33554432 bytes=671088640 fits=1 pow2=1 limited=1 | clipped_rel got=33554432 bytes=939524096 fits=1 | too_small_throws=1 +``` + +Printed by the exit dump (`src/ui.cpp`), next to the per-channel `produced` lines. The first is the live +plan: `requested` is `Config::buffer_samples`, `used` is `Partition::time_capacity` (the request rounded +down to a power of two, then halved under the memory limit), `bytes` is `Partition::bytes()` (the rings' +real size -- 8 388 608 ticks x (8 + 3 x 4) = 167 772 160 for the mock's three channels) and `limit` is +`memory_limit_bytes()`. **`used` must be a power of two no larger than `requested`'s floor, `bytes` must be +<= `limit`, and `limited=1` only when `used` is below that floor.** A run with `--buffer-samples 100M +--memory-limit-mb 256` reads `used=8388608 limited=1` and prints the `buffer limited to ...` stderr line; +`used=0 bytes=0` means no plan was made yet (no channel enabled -- add `UDPSCOPE_ENABLE_ALL_MS`). + +The second line is not about the run: it feeds `plan_partition()` three channels and a 100 M request on +fixed inputs, so a change to the rounding, the halving or the byte formula reads here even when the run's +own plan is not clipped. `unclipped` (16 GiB limit) must give the pow2 floor of 100 M, **67108864**, with +`pow2=1 limited=0`. `clipped(1GiB)` must give a smaller power of two whose `bytes` are <= 1 GiB +(**33554432**, 671 088 640 bytes: 67 108 864 would need 1 342 177 280), `fits=1 pow2=1 limited=1`. +`clipped_rel` is the same in relative time, where the clock ring is doubled, so a tick costs 8 more bytes: +the same 33 554 432 ticks but 939 524 096 bytes, still `fits=1`. `too_small_throws=1` is a 1000-byte limit +refused (it cannot hold 128 ticks). A `fits=0` is the halving loop stopping early; `limited=0` on the clipped +case is the clip not being reported to the UI; `too_small_throws=0` is a planner that allocates past the limit. +The Preferences *Memory limit* row is not probed: change it by hand and read the new `buffer allocated:` line +and the status popover. + ## `[udpscope] buffer allocated:` ``` @@ -1996,7 +2573,7 @@ Neither can be read off the engine's own fields, which is why the store's pair i Not a probe — it is printed by `ScopeModel::rebuild_locked()` in every build, because this is the one operation in the application whose duration a user can see with no instrument: the store's rings are -`std::vector>` and so **value-initialised**, which touches the whole `--buffer-mb` +`std::vector>` and so **value-initialised**, which touches the whole planned ring storage (roughly a second per GB, mostly first-touch page faults), and it runs on the producer thread holding `ScopeModel::mutex()` — which the frame tick also takes. So the line's `ms` is a frozen window, not bookkeeping. @@ -2005,7 +2582,7 @@ bookkeeping. rebuild whose plan describes the storage already allocated clears the store instead (`Partition::same_storage_as()` → `BufferStore::reset()`) and prints nothing, so a startup or a mode change that reads more than one or two of these is a re-plan that should have been coalesced. Startup -asked for **fifteen**; it now asks for two or three. A run at `--buffer-mb 4000` measured those +asked for **fifteen**; it now asks for two or three. A run at `--buffer-samples 100M` measured those coalesced allocations at 1148 ms each and a max frame tick of 1186 ms — the same number, which is the evidence that the freeze *is* this call and not something downstream of it. @@ -2031,8 +2608,8 @@ while the edit runs) and never falls to 0, which is what a rebuild would do. Eve [mathwrap-probe] after edit: produced=72400 slots=72400 (want equal) y=[-0.5000,0.5000] (want +-0.5, was +-2.0000) ``` -`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-mb`** and given time to -saturate it: `--buffer-mb 1` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with +`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-samples`** and given time to +saturate it: `--buffer-samples 65536` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with `UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`, reads the lines above. It installs one `Gain` graph of 2 over the mock's unit sine, polls (100 ms) until the ring has **wrapped**, and only then edits the gain to 0.5 — the same channel list with one node changed, which is the in-place recompute and @@ -2159,3 +2736,180 @@ The same probe then puts five measurements on the same spiked `sine` as **two wi - **`measure windows=2`** -- the five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two distinct windows. **`windows=1` means the corner is not part of the key**: the five collapsed onto one walk and two of them read a signal they did not ask for. + +## `[dock-session]` + +``` +[dock-session] hidden: wish=0 save_scheduled=1 collected=0 saved=1 loaded=1 read=0 applied=1 split_follows=1 ok=1 +[dock-session] shown: wish=1 save_scheduled=1 collected=1 saved=1 loaded=1 read=1 applied=1 split_follows=1 ok=1 +[dock-session] old file (side_tab only): loaded=1 dock_shown=1 default=1 ok=1 +``` + +`UDPSCOPE_PROBE_DOCK_SESSION_MS` reads the one thing the dock persists, the Dock button's state, as `dock = true|false` in the +session file (it replaced `side_tab`). Each of the first two lines drives the toggle, then follows the wish through: `save_scheduled=1` is +`on_sidebar_toggled` having scheduled a save (a user's wish that was not saved is lost at the next start), `collected` is +`collect_layout()`'s `dock_shown`, `saved`/`loaded` the file round trip on a temp path, `read` the loaded value, `applied` +the wish after `apply_layout()`, and `split_follows=1` the split view following it. **`ok=1` on all three lines is the assertion.** Each leg first cancels any save timer still pending +(`schedule_layout_save()` is an idempotent 400 ms debounce, so a leg would otherwise inherit the previous one's), which is what makes `save_scheduled` a reading +of *that* leg's toggle; with no source port (a recording opened from a file) nothing can be scheduled and the leg expects `save_scheduled=0`. The third line +loads a file as the previous version wrote it (`side_tab = "measure"`) into a `LayoutState` the probe has set to `dock_shown=false`, the opposite of the default: it must load with no error and +leave `dock_shown` at the default (`true`), so `ok=1` requires `dock_shown == default` -- the parser ignores unknown keys, and this is what says the old key still does not break a session. A load that +left the caller's `false` alone would read `dock_shown=0 default=1 ok=0`. The probe writes only to a temp path. + +## `[signalpage]` + +``` +[signalpage] name=sine open=1 pages=1 enable_visible=1 plot_buttons=1 plot_on_row0=1 plot_on_row0_disabled=1 removed=1 btn_row0=1 back_to_list=1 row_opens=1 pruned=1 other_page_kept=1 rows=1 +``` + +`UDPSCOPE_PROBE_SIGNAL_PAGES_MS` builds and drives the page of the first streamed signal. `open=1` (the slide-over shows `signal:`), `pages=1` +(one page in `App::signal_pages`), `enable_visible=1` (the acquire tick is shown in live mode; `0` in a file view), `plot_buttons=` equal to +`rows=` (one "Plot on" button per figure row), `plot_on_row0=1` (`assign_signal_to_row()` returned true and the signal is plotted, from a state where the probe first took it off every plot), `plot_on_row0_disabled=1` +(after the page refilled with the signal on plot 1, that plot's "Plot on" button is insensitive, "already on this plot"; `0` is a button that would put the signal in a second slot of +the same plot), `removed=1` +(the page's "Remove from plots" took it off), `btn_row0=1` (the "Plot on 1" button, the user's path, put it back), `back_to_list=1` ("Back to signals" +target), `row_opens=1` (the probe switches the signal off so it is addable, activates its list row and the page opens; the signal is then switched back on), and `pruned=1`: a math channel's page was opened, its **Delete** button clicked, and +the slide-over closed and the page was dropped by `signal_pages_sync()`. `pruned=-1` means no math channel could be added; `0` is a stale page. `other_page_kept=1`: with the `trigger` page open, a signal page that is not on show was pruned (a math channel added, its page built, the +channel removed) and the slide-over was still on `trigger`, both by `slideover_page()` and by the stack's visible child; `0` is `signal_pages_sync()` parking the stack on the +signals list while the title and the badge marker still name `trigger`; `-1` when there is no `trigger` page or no math channel could be added. + +A second line, `[signalpage] calibration identity=1/0 set=2.5/-1.25 pencils=3 pencil_edits_one=1 unit_edit=1 unit='V'`, reads the page's details grid (unit, gain, offset, then the CONFIG's read-only details) rows +(`udpscope-gain` / `udpscope-offset` labels, one string rule: `format_calibration()`, up to six decimals with trailing zeros dropped): `identity` is what an +uncalibrated signal shows, `set` what the page shows after `set_calibration(2.5, -1.25)` and `signal_pages_sync()` (the Apply path), `pencils=3` the unit, gain and offset edit +buttons, and `pencil_edits_one=1` that the gain pencil opens a dialog titled "Gain" with exactly one spin button and that Apply changed the gain only +(offset stayed -1.25) and refilled the label. `unit_edit=1`: the unit pencil opens a dialog titled "Unit" with one entry, "kV" reached the model's signal and the page's `udpscope-unit` label, and an empty entry restored the declared unit (`unit=` prints it; it must be the mock's own). A rename bumps `generation()`, so the panels re-read the list and the measurement statistics restart. `-1` is no pencil / no dialog; `0` is a dialog that edits both or a label that did not follow the model. + +## `[sigbadges]` + +``` +[sigbadges] enabled=4 badges=4 overflow=0 overflow_text=- plus_visible=1 dot_plotted=4 bar_h=36 bar_h_fixed=1 bar_min_w=124 opens=1 plus_opens=1 +``` + +`UDPSCOPE_PROBE_SIGNAL_BADGES_MS` reads the signal badges. `badges` must equal `enabled` (the enabled, listed signals) while `enabled` is at most 6 +(`kMaxSignalBadges`); beyond that `badges=0 overflow=1 overflow_text= signals plus_visible=0` (the "N signals" badge replaces the badges and the "+"). `dot_plotted` is the number of +badges whose signal is on a plot (its dot is then the trace colour, not the unplotted grey). `bar_h` is the bar's allocated height and `bar_h_fixed=1` that neither it nor its natural +height moved while one signal was switched off and on again (`rebuild_signal_panels()` each time), and that the badge count followed; `0` is a bar whose height depends on its content. +`bar_min_w` is only a record: the scroller must keep it the same however many signals there are (it reads 124 with the UDPS mock's 3 (the Source badge being hidden there) and with the overflow forced on, and 244 against the E-1608 mock with one signal and with nine -- the overflow, `enabled=9 badges=0 overflow=1 overflow_text=9 signals plus_visible=0 bar_h=36 bar_h_fixed=1`; a value that grows with the signal count is a scroller that has stopped constraining the bar). `opens=1`: clicking the +first signal badge opens `signal:` with the badge marked open and a second click closes it (`-1` when overflowed, there being no signal badges); `plus_opens=1` the same for the "+" +(the overflow badge when overflowed) and the `signals` page. + +``` +[sigbadges] drag_sources=4 drop_targets=4 prepare_ok=1 reorder_before=sine,square,ramp,noise reorder_after=noise,sine,square,ramp reorder_ok=1 payload_drop=1 list_drag=0 +``` + +The second line is the drag handles. `drag_sources` and `drop_targets` count the `GtkDragSource` / `GtkDropTarget` controllers over all the +signal badges (each badge carries one of each, so both must equal `badges`; beyond six signals the badges are gone and both read 0). `prepare_ok=1`: the first badge's +`prepare` returns a provider whose string is its signal index (the payload the plot area's drop target reads; `0` is a drag that carries nothing or the wrong +signal, `-1` no badge). `reorder_before` / `reorder_after` are the badge names in widget order around a call to the badges' own `drop` handler with the last badge dropped on +the first; `reorder_ok=1` means the last badge is now first, `badge_order` matches the widgets, and the original order was restored afterwards (so later probes see it; +`-1` with fewer than two badges). `payload_drop=1`: the signal is taken off the plots and `assign_signal_to_row(signal, 0)`, the call the plot's drop makes, leaves it plotted +(`-1` no badge). `list_drag` is the number of drag sources on the signal list's first row, and **must be 0**. **A real pointer drag cannot be simulated headless**: the +gesture's threshold, the slide-over closing under it and the pointer-driven drop are untested here; the line reads the controllers, the payload, the reorder and the assignment. + +## `[badges]` + +``` +[badges] table_bad=0 trigger_state=off bar_h=36 bar_h_after=36 fixed=1 open_marked=1 page_open=1 closed_unmarked=1 +[badges] source name=UDPStreamer pages=0 visible=0 switch_visible=0 can_target=0 second_page=- second_ok=-1 open_marked=-1 closed_unmarked=1 panel_min_w=0 +``` + +`UDPSCOPE_PROBE_BADGES_MS` reads the badge bar under the plot. **Line 1** is the Trigger badge: `table_bad=0` (the rows of the 16-input +`trigger_badge_state()` table the probe gets wrong -- anything else is a state word that misreports the engine), `fixed=1` (the bar's height is +the same, and at least 36 px, before and after the `trigger` page was opened and closed; `0` means the bar's height depends on content and the plot above it +moves), `open_marked=1` / `page_open=1` / `closed_unmarked=1` (the `udpscope-badge-open` class follows the slide-over's page, both ways; `0` on +any is a badge that does not say its panel is open, or one that stays marked after it closed). + +**Line 2** is the Source badge, which is **hidden when the source has no pages** (UDPS, Flat UDP, a file), and every field has a value it must read: + +- `name=` the source kind's name, as the badge shows it. The badge carries no link state (the status bar says it); there is no `state=` field. +- `pages=` the number of slide-over pages the source kind contributes (`source-`): `0` for UDPS, `2` for the E-1608 (`source-ao`, `source-dio`). + `can_target=0` with `pages=0` is the UDPS badge being untargetable, which goes with it being hidden; `can_target=1` with `pages>0` is the button being live. + `can_target=1` with `pages=0` is a dead button that opens nothing. +- `visible=` the badge being on screen: **`0` with `pages=0`** (hidden, by design: nothing to open), `1` with `pages>0`. `visible=1` with `pages=0` is a dead pill that was supposed to be hidden; `visible=0` with pages is a source panel nothing can open. +- `switch_visible=` the segmented switch being on screen after the first source page was opened: `1` with two or more pages, `0` with one or none. + A switch shown for a single page is a control that cannot change anything; hidden with two, the second panel is unreachable. +- `second_page=` / `second_ok=` what `slideover_page()` read after the switch selected the second page, and whether it is that page. **`second_ok=1` with + two or more pages, `-1` with fewer** (there is no second page to select, so the reading is "not applicable" and not a pass). `second_ok=0` is + the switch changing its own state without the slide-over following it. +- `open_marked=` the Source badge carrying the open mark while its first page is open: `1`; **`-1` with `pages=0`** (nothing to open); + `0` with pages is a badge that does not follow the panel. `closed_unmarked=1` is the mark gone once the panel is closed. +- `panel_min_w=` the widest minimum width of any source panel, measured in the slide-over's page. The page is 360 px less the scroller's own chrome and the + scroller never scrolls horizontally, so a value above that is a panel clipped at its right edge. `0` is no panel. + +## `[slideover]` + +``` +[slideover] open page=probe panel=360x350 at x=270 overlay=630x350 edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 plot_w before=630 open=630 plot_unchanged=1 +[slideover] click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1 +``` + +`UDPSCOPE_PROBE_SLIDEOVER_MS` opens a throw-away `probe` page and reads the panel 0.5 s later (add `UDPSCOPE_ENABLE_ALL_MS` so there are traces to hit). +**Line 1:** `panel=360x` (a 360 px panel as tall as the overlay), `edge_flush=1` (its right edge on the overlay's) and `full_height=1`; +`outside_hits_plot=1` / `inside_hits_panel=1` (a pick outside the panel lands in the plot, one inside it in the panel); `plot_unchanged=1` (`before=` equal to +`open=`: the panel is an overlay and does not resize the plot). `outside_hits_plot=0` is a panel allocation wider than 360 or a transparent parent that swallows +the plot's pan, drop and hover; `plot_unchanged=0` is the panel taking room from the plot. **Line 2:** `click_inside_closes=0` with `still_open=1` (a click +inside the panel does not close it), `click_outside_closes=1` (a click on the plot does), `esc_closes=1` (`slideover_escape()` closes it) and `closed=1`. +`esc_closes=1` is the function called directly; the key *through the window's handler* is the `esc:` reading of the `[keys]` line. + +## `[split-probe]` + +``` +[split-probe] start plots=1 frows=1 cells=[1] widths=[756] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1:sine,square,ramp,noise] +[split-probe] split plots=2 frows=1 cells=[2] widths=[388,365] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1a:sine|1b:square] +[split-probe] weights 3:1 plots=2 frows=1 cells=[2] widths=[524,229] tile=1 x_equal=1 cursors_aligned=1 weights=[3,1] traces=[1a:sine|1b:square] +[split-probe] caps-calls insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8 +[split-probe] roundtrip rt=1 expect=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] got=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] +[split-probe] ruler-labels split=[row 1,row 2] mixed=[row 0,row 2] +[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[671.599976,671.599976] auto=1 +[split-probe] x-axes row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] gap_px=[3.0/3.0,3.0/3.0,3.0/3.0] col_gap_px=3.0 caption_ok=[1,1,1] last_plot_zoom=1 +``` + +`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 28 steps (0..27), 250 ms apart; +each restructures and the *next* step reports the frame that was laid out with it, so a line's label +names the arrangement the line **reads**, not the call that made it. The state lines share one format: +`plots` (`row_count()`), `frows` (`figure_row_count()`), `cells=[..]` (per figure row), `widths=[..]` +(logical pixels of each cell rect, `|` between rows; the first cell's rect includes the row's y gutter, ~52 px here, and the others have none, so equal weights read `[388,365]` -- equal *plot* widths, not equal rects; the 15 px by which this moved from `[403,350]` is the plotter giving the row's right gutter to the last cell only and spacing adjacent axes by one `col_gap`, both of which widen the plots), `weights=[..]` (the stored cell weights, `-` for a row with +none), `traces=[label:signals|..]` with the labels `plot_label()` gives (`1`, `1a`, `2b`). Every state line +carries the same three assertions: + +| field | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `tile` | **1** on every state line | a row whose cells do not fill its rect edge to edge, are not at its y and height, or are not one `col_gap` apart, or a `FigureLayout` with `cells.size() != plots` / `rows.size() != frows`: the plotter's placement or a host that did not rebuild the figure | +| `x_equal` | **1** | a plot whose x range differs from plot 0's: a cell that is not in the x link group (a rebuild that linked rows and forgot cells) | +| `cursors_aligned` | **1** | a plot whose two vertical cursors sit elsewhere than plot 0's: a per-cell cursor time instead of the one shared time; the probe turns the cursors on and resets them first so the comparison is of real positions | + +The other lines, in order: + +| line | value it must read | the defect a wrong reading names | +| --- | --- | --- | +| `start` / `split` | `cells=[1]` then `[2]` with `widths=[388,365]` (equal plot widths; the first rect is 52 px wider by the y gutter it alone carries) and the traces on `1a`/`1b`; a second cell starts **empty**, the probe assigns it | a split that moved or dropped the first cell's traces, or unequal default plot widths (`[376,376]` is the pre-sharing reading: a second cell that still has its own gutter) | +| `weights 3:1` | `widths=[524,229]`, **not** 3:1, and `tile=1` | not a defect: the row's y gutter and the plotter's 64 px floor per plot are taken off first and the weights share the remainder, so the shares read compressed; a result of exactly 3:1 would be the floor missing | +| `row+cell` | `cells=[2,2]`, `weights=[3,1\|-]` | row 0's cell weights lost or moved when a row and a cell were inserted below it (they belong to the figure row) | +| `caps-calls` | `insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8` | a cap that is not reached or not held: row 0 starts with two cells and takes two more, refusing the third call at 4; two rows then fit before the plot total reaches 8 and the third is refused; `plots` must stop at **8** | +| `caps` | `plots=8 frows=4 cells=[4,2,1,1]`, `tile=1` | a ninth plot, or a fifth cell in a row | +| `remove` | `plots=7 cells=[3,2,1,1]` | `remove_cell()` on a multi-cell row that removed the row, or that dropped a neighbour's traces | +| `roundtrip-io` | `wrote=1 read=1 plots_in_file=3` | a save that was refused, or a file that lost a `[[plot]]` table | +| `roundtrip` (state line) | `cells=[1,2] widths=[756\|524,229] weights=[-\|3,1]` and the three traces on `1`, `2a`, `2b` | the arrangement did not survive `collect_layout()` -> file -> `apply_layout()` | +| `roundtrip rt=` | **`rt=1`** with `got=` equal to `expect=` (row weights `h=`, cell weights `w=` and each cell's trace, read back at the file's `%.4f`) | **`rt=0`** names the difference: a weight written per plot index instead of per figure row, widths applied before the arrangement (which resets them), or a trace that moved to another cell | +| `legacy-io` | `read=1 plots_in_file=3 row_unset=1` | the stripped file still carrying a `row`, which would make the next line test nothing | +| `legacy` | `cells=[1,1,1]`, `frows=3`, `widths=[756\|756\|756]`, `traces=[1:sine\|2:square\|3:ramp]` | a session from before the split that did not open as one cell per row, or lost its signals | +| `invalid-rows` | `ok=1 frows/plots=[5/5 3/3 3/3 3/3]` | one of four unusable `row` sequences (five cells in a row; a skipped row; a row that goes back; a first row that is not 0) that was refused, or kept as a split instead of loading one cell per row | +| `editor-on` -> `cell-split` | `cells=[1]` then `cell-split-ck cells=2` and `cell-split` with `widths=[388,365]`, the new `1b` empty | the editor's cell `+` not reaching `insert_cell()`, or the figure not re-pointed after it | +| `cell-assigned` | `1a:sine\|1b:square` | an assignment into the new cell that landed on another plot | +| `col-drag` | `ok=1 left_gt_right=1 follows_80=1 relative=1 sum_80=1 right_floor=1 left_floor=1 sum_ext=1 both_above_min=1 handle_comp=1 tile=1`, then `d80=[335.8,335.8]->[415.8,255.8]`, `ext=[575.6,96.0\|96.0,575.6]` and `gutter0=52.2` (all logical **plot** widths, each plot's `layout().axes.w`, measured after a `layout()` on the new weights; 671.6 px pair) | `ok` is the AND of the checks. The compared widths are plot widths and not cell rects, because only the first cell carries the y gutter (`gutter0=` is its rect width minus its plot width, printed so a reader can tell the two apart): a drag that equates the rect widths moves the divider more than the pointer (the gutter's share is treated as weight) and reaches the floor short of 96 plot px, which reads `follows_80=0 relative=0 left_floor=0 right_floor=0 both_above_min=0`. The widths are `axes.w` and not `rect.w - y_axis_width - y_title_width`: the latter also counts the right gutter and the gap between axes, so once the plotter spaced adjacent axes by one `col_gap` and gave the right gutter to the last cell only, the old measure read `ok=0 d80=[335.8,365.0]->[397.9,302.9]` (the pair came out 700.8 instead of 671.6 and the divider followed ~62 for +80). `gutter0=` itself should read the y gutter (about 52 px; 0 means the first cell lost its gutter, a value near the cell width means the plot area collapsed). `follows_80=0`: the drag of +80 did not put the left cell within 1 px of `start + 80`; `relative=0`: three updates (+30, +60, +80) from one begin did not land at +80, i.e. the drag read the live weights instead of the begin snapshot `layout_col_drag_start`; `sum_80=0` / `sum_ext=0`: the pair's total changed instead of being redistributed; `right_floor=0` / `left_floor=0`: after +10000 / -10000 the shrinking cell does not read `kMinCellPx` (96) within 1 px; `both_above_min=0`: a cell below 96 px; `tile=1` is `split_probe_tile()`, the same check the state lines print. `left_gt_right` is `d80.l > d80.r` (after +80 from equal halves the left is the wider). `handle_comp=1`: the pure `layout_col_total_offset(offset, handle_now, handle_begin)` adds the handle's displacement back (`(40,140,100)` and `(80,100,100)` read 80, `(-25,70,100)` reads -55); `0` is a gesture that would leave the divider lagging behind the pointer (it converges on half the travel). The probe goes through `layout_col_drag_begin()` + `layout_col_apply()`, the pair the gesture calls; **a real pointer drag is not exercised headlessly**, so the handle's position read in `on_layout_col_update` is covered only by this helper | +| `pre-insert0` (state line) | after the extreme drags `widths=[148,605]`, `weights=[0.117734,1.88227]` | the last extreme drag was -10000: the left *plot* sits on the 96 px floor (the rect reads 148 = 96 plus the 52 px y gutter) and the right takes the rest of the pair. The weights follow from the same 96 px floor over the 671.6 px pair: `S * (96 - 64) / (671.6 - 128)` with `S = 2` (it read `0.111732` while the pair was 700.8 px). A left rect reading 146 (`widths=[146,607]`) is a 93.3 px plot floor plus the 52.2 px gutter: the drag was measuring cell rects (the gutter's share treated as weight) and not plot widths, so the left plot stopped short of the 96 px floor | +| `insert0-move` | `move_ok=1 before=[sine,square] after=[,sine,square]` | an insert at the left edge that renumbered the cells instead of moving their traces right with them | +| `remove-mid` | `rm_ok=1 after=[,square]` | removing a middle cell that dropped or shifted the wrong neighbour | +| `row-weights` | `wt_ok=1 before=[3,1,1] after=[1,1]` | the row weights not following an insert or removal of a *figure row* (the vector had the plot-count length once and drifted off the rows) | +| `sensitivity` | `cell_plus_sensitive=0 row_plus_sensitive=0 plots=8` | `-1` is a button the probe could not find; `1` is a `+` left clickable at the 4-cell cap (cell) or at 8 plots (row) | +| `two-split-rows` | `cells=[2,2]`, labels `1a 1b 2a 2b`, all empty | `set_cells_per_row()` leaving the old plots' contents or labels behind | +| `drop-rowat` | `ok=1 rowat=[1a=0,1b=1,2a=2,2b=3]` | `PlotArea::row_at(x, y)` choosing by y alone, which would put every cell of a row on its first plot: a drop or a right click on `1b` landing in `1a` | +| `plot-on-buttons` | `[1a:row 1, cell 1 of 2\|1b:row 1, cell 2 of 2\|2a:row 2, cell 1 of 2\|2b:row 2, cell 2 of 2]` | a signals-page button labelled with the flat index, or without the cell tooltip; a one-cell row keeps its plain label and the tooltip "draw this signal on this plot" | +| `assign-1b` / `drop-2b` | `assigned=1 notice='plotting 'sine' on plot 1b'`; `dropped=1 notice='plotting 'square' on plot 2b'` | a notice that names the plot by number alone | +| `after-assign` (state line) | `traces=[1a:-\|1b:sine\|2a:-\|2b:square]` | the signal went to a neighbouring cell | +| `ruler-labels` | `split=[row 1,row 2]` and, with cells `{1,2}`, `mixed=[row 0,row 2]` | the cursors pane's ruler lines (a label read through the pane's own widgets): **one line per figure row**, because the cells of a split row share their y axis and so their rulers. A split row is `row
` and sits on its first cell; an unsplit row keeps `row ` (**0-based**, as it always was -- `row 0` is the first plot, not a defect). One line per cell (`plot 1a,plot 1b,...`) is the pre-sharing reading | +| `shared-y` | `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, `ranges=[A=-3..7 B=-3..7 \| fit A=B]`, `axes_w` equal to within a pixel (both `671.6`; `642.4` was the reading before the plotter gave the right gutter to the last cell only), `auto=1` | a row of two cells with a trace each. `y_shared`: a `set_y_range(-3, 7)` on cell A is B's after one frame. `y_gutter`: only cell A has a y gutter (B's `y_axis_width` and `y_label_area.w` are 0) and the two plots are equally wide. `y_fit`: `fit_all()` leaves both cells on one range that covers both traces (not the manual one). `ruler_mirror`: a ruler written on A and read back by `sync_cursors(0)` is B's too. A 0 in the first two is `set_share_row_y(true)` not reaching the figure; in `y_fit`, no frame between `fit_all()` and the read (the probe restructures to pump one) or a group that did not follow the flag | +| `x-axes` | `row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] caption_ok=[1,1,1] last_plot_zoom=1`, `gap_px` equal on both sides of each `/` and equal to `col_gap_px` | three arrangements, read in order: `{2}/{1}`, `{2}/{2}` and `{2}/{2}` with cell weights 1:3 over 1:1, every plot on channel 0 and the view pinned to an absolute span (`pin_pause_window(100.0, 100.05)`), because the live axis is `[-window, 0]` and never relative. `row0_labelled`: `x_strip_has_labels(x_strip_mode())` of row 0's plots, a row draws labels only if its columns differ from the row below's, so `[1,0,1]` (differs, equal columns, differs by weight); `bottom_labelled`: the bottom row always labels. `gap_ok` / `gap_px`: `axes[k+1].x - axes[k].right()` against `col_gap` within 0.5 px, in the logical px the line prints; a different gap is the plotter's inter-axes spacing changed or a cell that still carries its own right gutter. `caption_ok`: a plot has a non-empty `x_note` (the `t0 = ...` caption) **exactly when** its strip draws labels (`-1` means the labels were not relative, so the caption could not be checked: the pin did not take); `0` is `update_time_labels()` putting the caption on the bottom row only, so a labelled row above reads no `t0`. `last_plot_zoom`: after a narrower x range is written onto the last plot's member (a gesture on a labelled strip that is not row 0) every plot adopts it, `0` is `hold_pinned_window()` looking at row 0 only and reverting the gesture. The step restores the window with `set_time_window()` afterwards | + +The run ends with the usual `gtk warnings: 0` and no `Gtk-CRITICAL`. `x_equal=1` and `cursors_aligned=1` on every state line are +the only readings of the shared time window across cells. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index c179a81..6314ba5 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -13,29 +13,49 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ ./build/udpscope --server 127.0.0.1 --port 8000 ``` -Two baselines every run is read against. A clean build emits **three** warnings and no more: two -`-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons' -generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with -the seven action icons and the two app icons that remain (a note about a generated file, which GCC -itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is -now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that -bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by -touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers` -or any other warning is a finding. (The fifth warning that used to appear here was an unused -`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the -`output_after_add` already printed on the same line — which dropped the baseline from five to -four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's +Two baselines every run is read against. The UDPS runs must be **unchanged by the source-plugin work**: +a UDPS self-check, number-normalised (`sed 's/[0-9.]\+/N/g'`), diffs empty against one taken before it. +For the badge bar / slide-over / dock redesign the same diff is taken **against the commit before that +series (`c572ec2`)**, from a fresh `XDG_CONFIG_HOME` on each side (a stored mock session changes the +trigger lines), and it is "unchanged except" exactly these lines: `panels:` loses `switcher=` (the +trigger form is read through the slide-over, so its width and height change, and the `compact arm +probe` width with them); `switcher:` becomes `dock: shown= sensitive= w= scroller_pages=`; +`panel controls:` prints `dock=` for `switcher=`; `help figures:` loses `side_switcher=`, replaces +`side_stack=` with `badge_bar=` and `dock=`, moves the `trigger_*` rectangles into the slide-over and +shortens `plot=` by the 36 px bar; and the `tour started: steps=` count. A self-check taken **before** +`c572ec2` is no longer the baseline for those lines. The rest is the mock's own jitter (the sign of a +`square y=[...]` or a `trig engine:` figure, the position of one `trigger: edge` line), which the +normalisation does not remove -- anything else is a finding. +For the horizontal plot split the same diff is taken against the commit before it (`18b91c8`, with +`deps/plotter` at its pre-split pin `f79a97b`), and it is "unchanged except" exactly two things on the +layout lines: **`buttons=`** grows (the editor now carries the cell `+` buttons and the column arrows: +`3P + 2F + 2` for `P` plots in `F` figure rows, so `5 -> 7` for one row and `8 -> 12` for two), and the +layout probe line and the exit dump's layout line gain the **`cells=[..] colweights=[..]`** fields. +Everything else -- cursors, measurements, readouts, signal pages, math, trigger, help figures, the +E-1608 -- reads as before; a difference there is a finding, not the feature. Take a second baseline run +as a jitter control before believing one (a few mock readings, such as the sine's membership of the +cursors mix list or a V/mV flip, move between two runs of the *same* build). +A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons' +generated GResource `.c`, whose blob is **10146** against C99's 4095-byte string-literal limit with +the six action icons and the two app icons that remain (a note about a generated file, which GCC +itself supports). History: the help pages' generated `.c` (**516044** bytes) went when the manual +became a Mallard set installed for Yelp; an unused `output_built` in `math_probe()` was deleted as +dead code; and the last two, `-Wmissing-field-initializers` on `MathNode::source` in `math_probe()`, +went when the probe's `MathNode{MathOp::Gain, {}}` / `MathNode{MathOp::LowPass, {}}` named the +field (a partial brace-initialiser on `MathNode` re-adds one: `source` has no default member +initialiser). Re-measure by touching `src/icons/udpscope.gresource.xml` and rebuilding; any +second warning is a finding. And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's `gtk warnings:` line counts GTK's own log (through a writer function — GTK is built with structured logging, so the handler API never sees its messages) and must read `0`; see [probe-readings.md](probe-readings.md). -An *incremental* build reads **one** of those three: the generated GResource `.c` is not recompiled +An *incremental* build reads **zero**: the generated GResource `.c` is not recompiled when only `src/` changed, so its `-Woverlength-strings` does not reappear. A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it printed.** The line is not behind a probe — it is in every build — and one line is one *real* change of buffer plan, each of which blocks the main thread for the time it takes to touch the whole -`--buffer-mb`. A normal run prints two or three. Fifteen at startup is the defect it was added for; +planned buffer. A normal run prints two or three. Fifteen at startup is the defect it was added for; see [probe-readings.md](probe-readings.md). That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`, @@ -57,6 +77,115 @@ precedent, not an exception. `UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only *starts* its sequence: it switches the trigger on and then polls for a capture to open, so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall. +- `UDPSCOPE_PROBE_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present + moment, with the pre/post window the panel is set to, without waiting for the condition. It is a + chain, not an instant — `MS` is when it *starts*: it forces trigger mode **off** through the toggle + (a session file remembers the trigger, so the off state is stated rather than assumed) and reads the + button's stated state 300 ms later; it then sets a level no mock signal reaches (9 V), a 2 s window + at 50 % pre, acquires the mock's `sine` and turns the trigger on; it presses Force 2.5 s after that + (the window write re-plans the buffer and clears the history, so the ring needs the time to fill + past the 1 s leg A asks for); it presses a second time on a 100 s window at 99 % pre — a request no + run this short can satisfy — 400 ms after the first latch. **Run it alone, against the plain mock**, + with time for both legs: + + ```sh + python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & + sleep 1.5 + xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_FORCE_MS=3000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | \ + grep -E 'force-probe|trigger:' + ``` + + Being alone is load-bearing twice: the level is chosen so the engine *cannot* latch on its own, so + a latch count that moves is the force's and nothing else's — any other probe that arms a reachable + condition adds latches of its own — and `UDPSCOPE_ENABLE_ALL_MS` before it would acquire four + signals, which changes the level's default range and the plan. Two lines per pass are in + [probe-readings.md](probe-readings.md); **`latches=P->Q` with `Q == P + 1`, `display=event(frozen)`, + `matches_request=1` in leg A, `pin_is_snapshot=1` in both** are the assertions, and leg B's + `matches_request=0` is *expected* — a ring that cannot reach back 99 s is the only state where + "pinned to the snapshot, not to the request" is a reading rather than a coincidence. +- `UDPSCOPE_PROBE_PERSIST_MS` (+ `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, 1..8) — the **persistence + display**: trigger mode keeps the last N acquisitions on screen, each older one drawn at half the + previous one's brightness. It enables the signals itself (one call, as a restored session does) and + arranges one signal per row — the arrangement the app gives an opened file, and the one with room + for every age — sets the depth **through the panel's own spin**, arms the trigger on the mock's + `square` and polls until the retention is full: an event on screen and as many older ones kept as + the depth asks for. The depth is *left* where it put it, so the run's own tail is the display at + that depth: + + ```sh + python3 tools/mock_udpstreamer.py --port 9541 --rate 500000 --batch 1000 & + PY=$!; sleep 0.5 + xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 \ + UDPSCOPE_PROBE_PERSIST_MS=2500 UDPSCOPE_PROBE_PERSIST_DEPTH=4 \ + ./build/udpscope --server 127.0.0.1 --port 9541 2>&1 | grep -E 'persist-probe|persistence:|layout:' + kill $PY + ``` + + **Not with `UDPSCOPE_ENABLE_ALL_MS`**: that probe ends by switching a signal off two seconds later, + which takes the row that plotted it — and its layers with it — out of the middle of the measurement. + + **Read it as a pair of runs**: the same command at `..._PERSIST_DEPTH=1` is the display *without* + the feature, so its `kept=0`, `layers=0`, `bytes=0` and its `layout:` line holding only the signals + is the "N = 1 is exactly the old behaviour" reading, and it is also the run the exit dump's + `frame cost:` line is compared against — the tick/draw means are means over the whole run, and the + depth is raised at `MS`, so the difference is a *lower bound* on what the layers cost. **`ev_is_snap` + and `pin_is_snap` must be 1 in both runs**: the older acquisitions are drawn and never pinned, so + the axis, the framing and the measurements still describe the current event. `alphas=` is the decay + schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump + lines are in [probe-readings.md](probe-readings.md). +- `UDPSCOPE_PROBE_DENSITY_MS` (+ `UDPSCOPE_PROBE_DENSITY_TAU`, default 1.0 s) — the **phosphor + density**: an intensity image **per trace**, in that trace's own colour, the axes rect in device + pixels up to the plotter's `kDensityCellCap` (past which it scales the rect down uniformly and + magnifies the image back over it with a linear filter), every + triggered acquisition accumulated into it (the newest included), drawn *in place of* the traces, + fading by a per-acquisition factor or by a time constant. It states the display + **off** through the panel's own switch first, so the `off` leg is the run without it, then arms the + trigger on the mock's `square` with one signal per row — the arrangement the persistence probe uses + — and only then switches it on, so the acquisition already on screen is the first image. Four legs, + each a separate `g_timeout`, so whole frames happen between them: + + ```sh + python3 tools/mock_udpstreamer.py --port 9551 --rate 500000 --batch 1000 & + PY=$!; sleep 0.5 + xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 \ + UDPSCOPE_PROBE_DENSITY_MS=2500 ./build/udpscope --server 127.0.0.1 --port 9551 2>&1 | \ + grep -E 'density-probe|density:' + kill $PY + ``` + + **The assertions, in order:** `off` reads `rows=0 traces=0 cells=0 ink=0.0 peak=0.0000` (there is no + grid at all); `on` reads `rows=4` — every row has its own image, one per trace in its own colour, + so `traces=4` with one signal a row — and **`peak=1.0000`**, one sweep's ink + being one per cell it covers; `step-acquisitions` reads **`peak=1.5000`**, which is the panel's + 0.5 applied to the old image plus the new sweep's own pass; and `step-time` reads + **`ratio=0.367879`**, one step of `dt = tau` through the plot's own `decay_density()`. A `peak` in + the tens for the `on` leg is the *per-hit* deposit -- one sweep counted once per sample that + crossed a cell -- which saturates a single acquisition and hides the sweeps behind it. The line + also carries the app's own A/B of the frame cost (`tick`/`draw` before and after the switch, in + one run), and the exit dump's `density:` line says what the last frame it was on held. Both lines + and the dump line are in [probe-readings.md](probe-readings.md). + +- `UDPSCOPE_PROBE_NOTRACE` (no value; **not** a scheduled probe, and off unless it is set) — the + per-paint detail behind the exit dump's `traceless paints:` counter: one `[notrace]` line for each + of the first 40 paints after the first bind that staged no trace, naming the mode, the axis the + frame was asked for and every slot's bounds. It is behind a variable because a state that draws + nothing lasts as long as it lasts, so an ungated line is one per frame for the whole time -- and it + is *not* printed for a frame a phosphor density is drawing, which stages no trace by construction + (the frame's ink is the image) and would otherwise be counted as well. The counter itself is in + every build; see [probe-readings.md](probe-readings.md). + +- `UDPSCOPE_PROBE_HEADER` (no value; **not** a scheduled probe) — the once-a-second + `[udpscope] header: window_button_w=… bar_w=…` line. The widths are sampled either way (the + exit dump reads them); the line is behind this variable because it is detail behind that + reading, not a line every run should print. An unallocated window reads `0` for both. +- `UDPSCOPE_PROBE_RENDERER` (no value; **not** a scheduled probe) — the renderer pair + `--renderer` should have exported, printed from `main()` before GTK initialises + (`[renderer] option=… GSK_RENDERER=… LIBGL_ALWAYS_SOFTWARE=…`), then the renderer GTK + built (`[renderer] realized=…`) once the window has one. Run it with `--renderer cairo` to + check the option reaches the environment before `gtk_init()`/`adw_init()`; run it without + the option to check the default is untouched. + - `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share, hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then @@ -76,6 +205,35 @@ precedent, not an exception. with, so a synchronous sequence would read every position one action stale. It clicks by emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its 250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed. +- `UDPSCOPE_PROBE_SPLIT_MS` — drive the horizontal plot split, 28 steps 250 ms apart (so a run needs + `UDPSCOPE_PROBE_SPLIT_MS=1500 UDPSCOPE_EXIT_AFTER_MS=12000` at least; it enables and assigns the + signals itself, so `UDPSCOPE_ENABLE_ALL_MS` is not needed). Each step restructures the cells, + re-points the area at the new figure and lets the *next* step read a frame that was actually laid out + with them, which is why every report line describes the previous step's change. It splits and + weights a row, fills the caps, round-trips a session through `collect_layout()` / `save_layout()` / + `load_layout()` / `apply_layout()` (and a copy with its `row`/`width` lines stripped), clicks the layout + editor's real cell `+` button, drives the column drag handler, and checks the signals page, the drop's + `row_at()`, the cursors pane's ruler labels and the shared y axis of a split row (`shared-y`). Run it once, serially, on its own mock port, from a + fresh `XDG_CONFIG_HOME` (the probe deletes the temp files `udpscope-split-probe.toml` and `.legacy` it + writes under `g_get_tmp_dir()`, but the editor's clicks schedule a real session save). Read it against + the `[split-probe]` section of [probe-readings.md](probe-readings.md); the run must still end with + `gtk warnings: 0`. +- `[levels-probe] N measurement(s) over K channel(s), shares='...' -> W window(s), L level + walk(s)` (stderr, `UDPSCOPE_PROBE_LEVELS_MS`) — **prices a measurement pane**, which is what a + report of "it is slow with many measurements" has to become before it is a number. It adds one + `above` per share in `UDPSCOPE_PROBE_LEVELS` (comma separated, default `50`), spread in turn + over the first `UDPSCOPE_PROBE_LEVELS_CHANNELS` channels (default 1), and reports the two + things a count does not name: how many **windows** (one `MeasureWindowReader`, one fetch, one + scan per measured channel) and how many **level walks** (one accumulator per *distinct* share) + the pass then had to take. `50,50,50,50` on one channel is four pane rows and **one** walk; + `5,10,15,20` is four rows and **four**; the same four shares over four channels is four + windows *and* four walks. Read the `measure cost:` line it leaves in the exit dump and the + sweep in [invariants-measurements.md](invariants-measurements.md) — and read it against a run + with `UDPSCOPE_PROBE_LEVELS_MS` unset, because the pane's cost is a *difference* between two + tick means. **Run it at the scale being asked about**: the cost is the window's, so a 2 s + window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a + recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at + the same window). - `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one measurement of each statistical type (average, top, peak to peak, amplitude) on the first *channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the @@ -206,7 +364,7 @@ precedent, not an exception. *tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with - a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) — + a ring that holds *more* than the window (`--buffer-samples 2M` past the window's own seconds) — that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as `span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix. @@ -287,12 +445,139 @@ precedent, not an exception. pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first. -- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements on the - Measure tab, a forced pass, then the tab switched away and read again two seconds later. **`advanced=4` - is the assertion** (every accumulator gained passes while its page was off screen); `advanced=0` is the - defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for - exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other - probe ahead of it. The reading's own entry is in `probe-readings.md`. +- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements while the + `measure` page is **closed**: it adds four measurements, opens the page for a forced pass (the baseline), + closes it, and reads the accumulators again two seconds later. **`advanced=` 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 ... 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/_`) 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:` 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-`: 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= pages= + visible=<0|1> switch_visible=<0|1> can_target=<0|1> second_page= second_ok=<0|1> open_marked=<0|1|-1> + closed_unmarked=1`. `pages` is the number of source pages, `switch_visible` is the segmented switch + being on screen after the first source page was opened (`1` for the E-1608's two pages, `0` for one page + or none), `second_page` is what `slideover_page()` read after the switch selected the second page + (`second_ok=1` when it is that page), `visible=0` with `pages=0` is the UDPS badge being **hidden** (a kind with no source page + has nothing for it to open; `can_target=0` goes with it) and `open_marked=-1` is that no-page case (there is + nothing to open); `visible=1` with `pages>0`. +- `UDPSCOPE_PROBE_SOURCE_KINDS=2000` with `UDPSCOPE_EXIT_AFTER_MS=5000`, run as `--source probe --source-opt rate=500` + -- the source-plugin path end to end. The variable's presence also registers the test-only `probe` kind (a + synthetic sine with one panel), so it is the only way a run sees that kind; its value is the delay in ms. + Nine `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `flatudp`, `probe`); the + reading's own entry is in `probe-readings.md`. +- `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against + `tools/mock_e1608.py` -- the MCC E-1608 end to end: panels present and sensitive, a DAC write, a + digital-output toggle and a counter reset each reaching the mock, the stream's channels and rate, the + factory calibration applied, the capture button disabled, and a hand-computed decode. It enables the + signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS` (that probe switches the first signal off + after two seconds). Three runs, each short and serial, with the mock killed afterwards: + - **no board:** panels `sensitive=0`, `connected=0`, no retry flood on stderr (the one + `[e1608] handshake with ...: Connection refused` line is printed once, not per retry); + - **the mock:** `--drop-after 3 --log /tmp/e1608-mock.log`, options `scan_rate=1000 ai0=BIP10V + ai1=BIP5V plot0=1 plot_counter=1 dir1=o ao0_init=1.5`, with `UDPSCOPE_PROBE_E1608=1500 + UDPSCOPE_PROBE_E1608_RECONNECT=8000 UDPSCOPE_EXIT_AFTER_MS=10500`; then read the mock's log: the AO + levels `1.5, 2.5, 1.5` (the log prints the quantised voltage, so `1.4999, 2.4999, 1.4999` is the + expected text), the DOUT bytes `0x00, 0x02, 0x00`, one `COUNTER_RESET`, one `DROP`; + - **a rejected code:** mock `--code 1234`, app `code=7`: one error line, the retries stop. +- `UDPSCOPE_PROBE_E1608_GEN=1500` with `UDPSCOPE_EXIT_AFTER_MS=5500`, run as `--source e1608 --source-opt + address=127.0.0.1 --source-opt port=54711 --source-opt ao0_init=0 --source-opt gen_rate=100` against + `tools/mock_e1608.py --port 54711 --log /tmp/e1608-gen100.log` -- the generator's update rate: it starts a Sine + on DAC 0 through the Analog out panel (and opens the `source-ao` slide-over page, since the panel refreshes on + its frame clock, which does not tick for a page that is off screen; the page is closed again at the end), + waits 2.5 s, reads the achieved rate and the panel's "Update rate" row, stops the generator and reads both + again 400 ms later. Two lines, in `probe-readings.md`. Then count the mock log's `AOUT` lines: about 250 with + `gen_rate=100` (the generator ran for about 2.5 s) and about 125 for a run without the option (default + 50 Hz). Short and serial, the mock killed afterwards. It enables nothing else, so it can run alone. +- `UDPSCOPE_PROBE_E1608_RECONNECT=8000` -- reads the source after the mock dropped the link and 500 ms + later checks the frame count moved on. The reading's own entry is in `probe-readings.md`. +- `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt + address=127.0.0.1`. Start `tools/mock_e1608.py` first on its default port 54211 (the form scans that port; do + not pass `--port`) and kill it afterwards. The value is the delay of phase 1; phase 2 follows 2 s after it and + phase 3 (the line) 2 s after that, so phase 3 runs 4 s after phase 1 and the delay must stay below about + 2500 ms for the line to print before the exit at 6500 ms. It builds + the connection form, presses **Scan network**, expects the mock to be listed, clicks the result (address + filled, MAC and subnet cleared), presses **Use MAC** (MAC filled, the others cleared), scans again and checks + the results were replaced. `UDPSCOPE_PROBE_E1608_SCAN_MAC` names another board (a real one) instead of the + mock's `00:80:2f:00:16:08`. One `[e1608] scan:` line. It needs a non-loopback up interface with a broadcast + address; when the board did not answer a second line says so, and the run proves nothing about the scan. +- `UDPSCOPE_PROBE_FLATUDP=2500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source flatudp` against + `tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both + need no source; it covers the `w` suffix, the waveform and 256-channel rules, the channel decode and the + float64/uint16/int32/uint32/int64 types in both byte orders), the stream's channel names (`Wave[0]`.. + for a `channels` vector) and declared rates (`rate * count` for a waveform), the channel count, value + continuity per row (and the offset between a vector's elements, or a waveform's block phase), the + wrong-size accounting and the capture button. It enables the signals itself, so do **not** add + `UDPSCOPE_ENABLE_ALL_MS`. Runs, each short and serial, with the mock killed afterwards (quote the `signals` + value: zsh globs the `[4]`): + - **mixed list, gap and bad datagrams:** mock `--rate 1000 --signals 'Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4]' + --gap 8,4 --bad-every 1` started 1 s before the app, the same `signals` as a `--source-opt`, with + `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_PROBE_FLATUDP_GAP=1500 UDPSCOPE_EXIT_AFTER_MS=14500`; stderr carries + exactly one `datagram of 22 bytes, expected 21` line. The silence probe is a 100 ms poll that waits for the + stream, then for the 2 s of silence to take the source down, then for it to come back, so it does not + depend on when `xvfb-run` finished starting; the only requirement is that the mock's gap begins after + the app connected (the mock's clock starts about 4 s before the app's) and ends in time to read the return + (`--gap 8,4` with a 14.5 s run does both with several seconds of margin); + - **waveform-only list:** mock `--signals 'Wave:int16[4]w'`, app `--source-opt 'signals=Wave:int16[4]w'`, + `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`: one channel at `rate=4000`; + - **big endian (decode of every width through the stream):** mock `--big-endian` on the mixed list, app + `--source-opt endian=big`, `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`; + - **no sender:** `connected=0` throughout, a reading and not a failure. +- `UDPSCOPE_PROBE_FLATUDP_GAP=1500` -- starts the silence poll described above (2 s after the source's last + datagram it must be disconnected; then it must reconnect and the frame count move on). - `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`. @@ -366,7 +651,7 @@ as distinct from the fault injection below, which exercises ingest: `Gain` graph over the mock's sine, waits for the ring to **saturate**, and only then edits the gain, which is the one state `UDPSCOPE_PROBE_MATH_MS` cannot reach (each of its edits follows a structural change, and a replan restarts every ring at absolute 0). It therefore needs a **small buffer and a long - run**: `--buffer-mb 1` against `--rate 20000 --batch 200`, with + run**: `--buffer-samples 65536` against `--rate 20000 --batch 200`, with `UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`. `base > 0` on its second line is the precondition — **`base=0` means the run tested nothing** — and the third line's `produced == slots` and `y=±0.5` are the two assertions; both are in [probe-readings.md](probe-readings.md). @@ -374,7 +659,7 @@ as distinct from the fault injection below, which exercises ingest: note, through `save_configuration_as()` (the same entry point the dialog's button uses), then reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the round trip must carry the note **and** the startup settings — - `comment='written by the configs probe' buffer_mb=8 relative=0 capture=0`. A `buffer_mb=0` there + `comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0`. A `buffer_samples=0` there is the per-source buffer size not being written, which is the one setting a user notices only after a capture has been sized wrong. Run it with a throwaway `XDG_CONFIG_HOME`; the probe deletes its own configuration at the end (`after forget names=…`). @@ -412,12 +697,14 @@ as distinct from the fault injection below, which exercises ingest: bullet) — so a probe run against a fresh config directory will have it on screen for the first second and a half, and that start *writes* `tour_seen` to whichever config directory the run is using, which is why the second run against the same one reads `auto=0`. -- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it puts the sidebar on **Measure**, - starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's - page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the - tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch - could have revealed). `[toursurface] signals stop=4/5 … side_tab=signals … contains=1` and - `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0` are the two assertions; its +- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it opens a slide-over page (`trigger`), + starts the window's tour from that state and walks all six stops a stop per tick, reading the + slide-over's open page and the spotlight/card at each. The trigger stop must read `slideover=trigger` and + the next `slideover=none`; the first stop closes the panel the probe left open, and the end of the walk + (and a dismissal on the trigger stop) leave none. It then puts the dock *away* and starts the tour again: + the dock's stops are dropped while the trigger stop, which does not depend on the dock, stays. + `[toursurface] stop=3/6 … slideover=trigger … contains=1` and + `[toursurface] collapsed: side_box_mapped=0 steps=5 trigger_stop=1 signals_stop=1` are the assertions; its lines are in [probe-readings.md](probe-readings.md). Run it alone with `UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source. - `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the @@ -428,9 +715,9 @@ as distinct from the fault injection below, which exercises ingest: `[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored calibration**, and the two halves must match. - The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one - entry per configuration (`host:port/name … buffer= relative= note='…'`), and + entry per configuration (`host:port/name … samples= relative= note='…'`), and `UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints - `[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the + `[sources] picked host:port … samples=2097152 relative=0 capture=0 config=… note='…'` — the settings the configuration carried, into the `Config` the model is built from. **With no `--server` and no probe the chooser blocks in its own nested loop** (no frame to end and no dump), so a chooser run is always this probe. It also prints the dialog's frame *and its own help layer*: @@ -459,41 +746,50 @@ as distinct from the fault injection below, which exercises ingest: leg of it (`title='Opening ' 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 @@ -511,7 +807,7 @@ as distinct from the fault injection below, which exercises ingest: python3 -c "import numpy as np; n=1000000; t=-4.0+np.arange(n)/50000.0; \ a=np.zeros(n, dtype=[('time','` — 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. @@ -783,8 +1079,9 @@ stages into one is the failure mode**: a probe that rebuilt the buffer at 11.5 s immediately would read `bare=0` on every search leg, which looks exactly like a broken comparator. **Why 4 s and not 3.** `enable_all_probe`'s last act is a timeout of its own, `ENABLE_ALL_MS + 2000` -- 3 s -under this recipe -- which walks `signal_list` for the front channel's acquire tick and clicks it. A probe -that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then +under this recipe -- which opens the front channel's signal page and clicks its "Acquire this signal" tick (an +acquired signal is not in the Add-a-signal list, so the list cannot be walked). A probe +that installs a math graph at that same instant rebuilds the signal panels under it, and the off-step then emits `active` on a freed `GtkCheckButton`: `GLib-GObject-CRITICAL: instance with invalid (NULL) class pointer`, with the backtrace in `enable_all_probe()::{lambda}`. It is a real fragility of that probe's lookup and not of the feature, and it is why every documented probe time is offset from `ENABLE_ALL_MS + 2000`, not diff --git a/docs/img/main-window.png b/docs/img/main-window.png index c8fa754..f626603 100644 Binary files a/docs/img/main-window.png and b/docs/img/main-window.png differ diff --git a/docs/img/trigger-capture.png b/docs/img/trigger-capture.png index 5159e8f..cf2c563 100644 Binary files a/docs/img/trigger-capture.png and b/docs/img/trigger-capture.png differ diff --git a/docs/superpowers/plans/2026-10-31-e1608-source.md b/docs/superpowers/plans/2026-10-31-e1608-source.md new file mode 100644 index 0000000..3db59b7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-e1608-source.md @@ -0,0 +1,3592 @@ +# MCC E-1608 Source Plugin 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 an `e1608` source kind to udpscope: the Measurement Computing E-1608 Ethernet DAQ as a plot source, with its own connect form and two side-stack panels (Analog out, Digital I/O), verified against a protocol mock because no board is available. + +**Architecture:** Three layers under `src/source/e1608/`: a *link* (POSIX UDP discovery and handshake, TCP command framing, the scan socket), a *device* (typed commands, calibration, `decode_scans`), and a *source* (`DataSource` with a `poll()` state machine, timers, `Frame` building). Neither of the bottom two layers knows GTK or `DataSource`. The kind (`e1608_kind.cpp`) owns the connect form and `e1608_panels.cpp` the panels; panels reach the source only through `post(Command)` and read live values from a shared atomic `E1608Readouts`. The wire protocol is ported from the user's MARTe2 `E1608DataSource` (primary) and cross-checked against the Go `uopi/internal/datasource/mcc`. + +**Tech Stack:** C++20, plain POSIX sockets, GTK4 + libadwaita 1.10 (`AdwExpanderRow`, `AdwSpinRow`, `AdwComboRow`, `AdwSwitchRow`), CMake, Python 3 + numpy (the mock). + +**Spec:** `docs/superpowers/specs/2026-10-31-e1608-source-design.md` (committed `69585a9`). **Branch:** `feature/source-e1608` (from `feature/source-plugins`). + +## Global Constraints + +These are the spec's project-wide requirements. Every task's requirements include them. + +- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`; `#` for Python). +- C++20; the build uses `-Wall -Wextra -Wpedantic`. **Baselines unchanged:** the UDPS self-check reads as on `main`, a clean build emits **exactly three** warnings (one on an incremental build), and there is **no `Gtk-CRITICAL`** (`gtk warnings: 0`). +- "Everything runs inside `poll()` on the producer thread. The GTK thread reaches the board only through `post(Command)`." +- "The aggregate rate (enabled channels times scan rate) must stay at or below 250 kS/s. The form refuses a configuration above it." +- "A connect attempt can block `poll()` for up to the command timeout; that delays only the command queue." A link error fires `on_event(Disconnected)` and **retries every 2 s**. +- "A rejected connection code stops the retries: the source stays in Error with that message until the user reconnects." +- Hardware-paced acquisition only (scan rate or external clock). Software-paced mode (`ScanRate = 0`) is not ported. +- The generator is a static level plus a software waveform (sine, square, triangle, ramp), 10 Hz at most, run as a timer inside `poll()`; no extra thread. DACs clamp to ±10 V. +- Both side-stack panels are insensitive while disconnected: **disabled, not hidden** (project rule). In DIFF mode AI rows 4 to 7 are insensitive, not hidden. +- DI lines and the counter are plot channels the user chooses, **off by default**. `has_datagrams()` is false, so the capture button is disabled. +- `SourceConfig` stays flat; a per-channel setting is a key such as `ai0=BIP10V` (absent means off). A session file is named `e1608-`, port 1. +- One global SE/DIFF mode (no per-channel mixing); DIO directions are fixed at connect (no runtime change). +- No board exists: `tools/mock_e1608.py` is the contract, and its header **lists every unverified command layout**. +- Out of scope: software-paced acquisition, a generator faster than 10 Hz or a dedicated I/O thread, runtime DIO direction changes, per-channel SE/DIFF mixing, verification against a real board. +- Docs to update: `docs/claude/architecture.md`, `build-and-run.md`, `verification.md`, `probe-readings.md`, and `README.md`. +- There is **no unit-test suite** in this repo (CLAUDE.md). The checks are the headless self-check (extend the probe in `src/ui_probe.cpp`) and throwaway drivers kept in `/tmp`; **do not commit a test harness**. +- Verification runs are short and serial; clean up every mock process you start (`kill $MOCK`); do not re-run to chase cosmetics. + +## Deviations from the spec, to report in the final summary + +1. The spec says the generator tick is "at most 10 Hz". A 10 Hz tick would sample a 10 Hz wave once per period, so this plan reads "10 Hz" as the **waveform frequency** bound (0.1 to 10 Hz) and uses a **20 ms tick**. +2. The spec's `valid()` says "an address, or a MAC plus a subnet". The plan accepts a MAC with an **empty subnet** (a broadcast discovery). +3. `valid()` also requires **at least one enabled analog input**: the scan drives the frames, so a configuration with none can never produce data. When a config has no `ai0`..`ai7` key at all, `ai0=BIP10V` is the default (so `--source-opt address=...` alone is connectable). +4. A reply status other than 0 is `LinkError::Kind::Command`, distinct from framing errors (`Protocol`); only `Command` is swallowed by the calibration read. +5. `Acquisition::on_event` ignores the detail string, so `Error` messages are also written to stderr as `[e1608] ...`. +6. The two side-stack panels live in their own `e1608_panels.{hpp,cpp}` rather than inside `e1608_kind.cpp`, for one responsibility per file. + +## File structure + +| File | Responsibility | +| --- | --- | +| `tools/mock_e1608.py` (new) | The protocol mock; header lists the unverified commands. | +| `src/source/e1608/e1608_link.{hpp,cpp}` (new) | Sockets: discovery, handshake, framed commands, scan socket. No GTK, no `DataSource`. | +| `src/source/e1608/e1608_device.{hpp,cpp}` (new) | Typed commands, calibration, `decode_scans`. No GTK, no `DataSource`. | +| `src/source/e1608/e1608_source.{hpp,cpp}` (new) | `E1608Settings`, `E1608Readouts`, `AoSettings`, `E1608Source` (the `DataSource`). | +| `src/source/e1608/e1608_kind.cpp` (new) | `SourceKind`: the connect form; `make_panels` delegates to `e1608_panels`. | +| `src/source/e1608/e1608_panels.{hpp,cpp}` (new) | `make_e1608_panels(DataSource&)`: the Analog out and Digital I/O panels. | +| `src/source/registry.{hpp,cpp}` | Declare and register `make_e1608_kind()` after udps. | +| `CMakeLists.txt` | Add the five `.cpp` files (link, device, source, kind, panels) after `probe_source.cpp`. | +| `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp` | `UDPSCOPE_PROBE_E1608`, `UDPSCOPE_PROBE_E1608_RECONNECT`; the `e1608` sample in `source_kinds_probe`. | +| the five docs | See Task 6. | + +Session ledger: `.superpowers/sdd/e1608/progress.md` (git-ignored scratch). Models: sonnet for implementers and task reviewers; opus for the final whole-branch review. + +--- + +### Task 0: Baseline and branch check + +**Files:** none modified. + +- [ ] **Step 1: Confirm the branch and a clean tree** + +Run: `git status -sb | head -3 && git log --oneline -3` +Expected: `## feature/source-e1608`, the top commit is the plan or `69585a9`. + +- [ ] **Step 2: Build and take the UDPS baseline** + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | tee /tmp/e1608-build0.log | grep -c "warning:" +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/udps-baseline.raw 2>&1 +kill $MOCK +sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.raw > /tmp/udps-baseline.norm +grep -c "gtk warnings: 0" /tmp/udps-baseline.raw +``` +Expected: the warning count is `3` on a clean build (`1` if the tree was already built; touch nothing to chase it), and the last line prints `1`. `/tmp/udps-baseline.norm` is the file Task 5 diffs against. If the first build is incremental, `grep -c warning:` prints 1; that is the baseline for incremental builds. + +- [ ] **Step 3: Record the ledger** + +```sh +mkdir -p .superpowers/sdd/e1608 && printf '# E-1608 source: progress ledger\n\nBase before Task 1: %s\nPlan: docs/superpowers/plans/2026-10-31-e1608-source.md\n' "$(git rev-parse --short HEAD)" > .superpowers/sdd/e1608/progress.md +``` + +No commit. + +--- + +### Task 1: The protocol mock + +**Files:** +- Create: `tools/mock_e1608.py` +- Throwaway (not committed): `/tmp/e1608_smoke.py` + +**Interfaces:** +- Produces: a mock board on `--port` (default 54211): UDP discovery/handshake, TCP command channel, TCP scan stream on `port+1`. Log lines (to `--log FILE` and stdout), one per event, **exactly these formats** (Task 5 greps them): + - `HANDSHAKE code= status=` + - `DOUT 0x<2 hex>`, `DCONF 0x<2 hex>` + - `AOUT ch= code= volts=<%.4f>` (volts = the code through the *inverse* of the served DAC calibration) + - `COUNTER_RESET` + - `QUEUE : ...` + - `SCAN_START pacer= options= channels=`, `STREAM rate=<%.1f> channels=`, `SCAN_STOP` + - `DROP`, `OVERRUN` +- The synthetic signal: for the queue entry (wire channel `w`, range `r`): `ch = w - 8` if `w >= 8` else `w`; `dc = min(0.5*(ch+1), 0.8*fs)`; value `dc + 0.1*sin(2*pi*5*t)` volts; converted to a raw code through the **inverse** of the served calibration, so that applying the calibration recovers `dc`. A decoder that skips the calibration is off by about 0.3 V on `ch=0`, `BIP10V`. +- Served calibration (float32-rounded): DIFF `(1.01 - 0.004 r, 150 - 30 r)`, SE `(1.02 - 0.005 r, 300 - 40 r)` for `r` in 0..3, DAC `(0.99 + 0.01 c, 200 - 100 c)` for `c` in 0..1, in a single 80-byte memory: DIFF at 0x00, SE at 0x20, DAC at 0x40. +- DIN: `(pins & dconf) | (dout & ~dconf)` with `pins = int(t*4) & 0xFF` (bit 0 toggles at 2 Hz). Counter: `int((now - counter_t0) * 5)`; reset rebases `counter_t0`. + +- [ ] **Step 1: Write the mock** + +Create `tools/mock_e1608.py` (mode 755): + +```python +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Martino Ferrari +"""A protocol mock of the Measurement Computing E-1608 Ethernet DAQ, for exercising udpscope +without a board. It is the *contract* the `e1608` source is verified against until a board is +attached, so its layouts are only as good as the two references they were taken from: + + * MARTe2 `E1608DataSource` (~/Projects/marte2_projects/MCC_E1608_DataSource), the primary, and + * the Go `uopi/internal/datasource/mcc` implementation, the cross-check. + + ./tools/mock_e1608.py --port 54211 [--code N] [--drop-after S] [--overrun-after S] [--log F] + +Serves: UDP `D` discovery and `C` connection-code handshake on --port; the TCP command channel on +--port; the TCP scan stream on --port+1 (it starts when the one filler byte follows the scan-start +command). Frame: 0xDB, cmd, frame id, status, count LE16, payload, checksum (the whole frame sums +to 0xFF mod 256). Reply: cmd | 0x80, same id. + +UNVERIFIED -- every item below rests on a single reference, or the references disagree. They are +the first places to look when a real board misbehaves: + + * 0x31 counter reset: Go only (no payload, no reply payload). + * Pacer: MARTe2 sends round(80e6 / (scan_rate * channels) - 1), the *aggregate* rate; Go sends the + passed frequency. This mock follows MARTe2. + * The scan-start options byte (trigger): none 0, rising 4, falling 8, high 12, low 16 -- MARTe2 only. + * Handshake status 2 ("code ignored") is accepted by MARTe2 and refused by Go. The mock never sends it. + * The first frame id: MARTe2 pre-increments (first frame is 1), Go starts at 0. The mock echoes it. + * The maximum frame size (MARTe2: 1024 in total; Go: 1024 of payload). The mock enforces neither. + * Agreed by both references (not in doubt): the filler byte after scan start, the scan port being + command port + 1, DIFF wire channel = channel + 8. + +Switches: --drop-after S closes both connections once, S seconds after the first command connection +(logs DROP); --overrun-after S raises the overrun bit and stops the stream S seconds after a scan +starts, once (logs OVERRUN); --code N is the connection code the board accepts (anything else gets +status 1). +""" + +import argparse +import socket +import struct +import sys +import threading +import time + +import numpy as np + +CLOCK = 80e6 +PRODUCT_ID = 0x012F +FS = [10.0, 5.0, 2.0, 1.0] # full scale per range code BIP10V, BIP5V, BIP2V, BIP1V + + +def f32(x): + return struct.unpack("= 8 + ch = wire - 8 if diff else wire + fs = FS[rng] + dc = min(0.5 * (ch + 1), 0.8 * fs) + v = np.clip(dc + 0.1 * np.sin(2.0 * np.pi * 5.0 * t), -0.98 * fs, 0.98 * fs) + code = v * 32768.0 / fs + 32768.0 + slope, icpt = (DF_CAL if diff else SE_CAL)[rng] + return np.clip(np.rint((code - icpt) / slope), 0, 65535) + + # --- commands --------------------------------------------------------------------------- + def handle(self, cmd, p): + """Returns (status, reply payload). Status 1 protocol, 2 parameter.""" + if cmd == 0x00: + return 0, bytes([self.din()]) + if cmd == 0x02: + return 0, bytes([self.dout]) + if cmd == 0x03: + if len(p) < 1: + return 2, b"" + self.dout = p[0] + self.log("DOUT 0x%02x" % p[0]) + return 0, b"" + if cmd == 0x05: + if len(p) < 1: + return 2, b"" + self.dconf = p[0] + self.log("DCONF 0x%02x" % p[0]) + return 0, b"" + if cmd == 0x10: + return 0, struct.pack(" 8 or len(p) != 1 + 2 * n: + return 2, b"" + q = [(p[1 + 2 * i], p[2 + 2 * i]) for i in range(n)] + if any(r > 3 for _, r in q): + return 2, b"" + self.queue = q + self.log("QUEUE " + " ".join("%d:%d" % e for e in q)) + return 0, b"" + if cmd == 0x20: + return 0, struct.pack(" 1: + return 2, b"" + ch, code = struct.unpack(" 0 else 1000.0 * nq # external clock: 1 kS/s each + rate = agg / nq + t0 = time.monotonic() + sent = 0 + ov_at = None + if self.args.overrun_after > 0 and not self.overrun_done: + ov_at = t0 + self.args.overrun_after + self.log("STREAM rate=%.1f channels=%d" % (rate, nq)) + try: + while self.stream_gen == gen: + now = time.monotonic() + if ov_at is not None and now >= ov_at: + with self.lock: + if self.stream_gen == gen: + self.overrun = True + self.overrun_done = True + self.streaming = False + self.log("OVERRUN") + return + due = min(int((now - t0) * rate) - sent, 65536) + if due > 0: + t = np.arange(sent, sent + due) / rate + cols = [self.raw(w, r, t) for (w, r) in queue] + conn.sendall(np.stack(cols, axis=1).astype("= 5: + (code,) = struct.unpack(" 0: + threading.Timer(self.args.drop_after, self.drop).start() + try: + while True: + b = conn.recv(1) + if not b: + break + if b[0] == 0x00: + self.start_stream() # the filler byte after scan start + continue + if b[0] != 0xDB: + continue + rest = recv_exact(conn, 5) + if rest is None: + break + cmd, fid, _status, count = struct.unpack("`; `bool parse_mac(const std::string&, Mac&)`; `std::string format_mac(const Mac&)`; `struct DeviceInfo { std::string ip; Mac mac; std::uint16_t product_id; }`; `std::vector sweep_targets(const std::string& subnet)`; `std::vector discover(const std::vector& targets, int port, int wait_ms)`; `class Link` with `connect(host, port, code, timeout_ms)`, `close()`, `is_open()`, `command(cmd, payload = {}, expect = -1) -> std::vector`, `open_scan()`, `close_scan()`, `scan_open()`, `send_filler()`, `read_scan(buf, cap, timeout_ms) -> size_t` (0 on timeout; throws `Io` when the board closed the scan socket). + - `device.hpp`: `enum class Range`, `full_scale(Range)`, `range_name(Range)`, `parse_range(str, Range&)`; `enum class Trigger { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 }`, `trigger_name`, `parse_trigger`; `struct Cal { double slope = 1.0, intercept = 0.0; }`; `struct Calibration { Cal se[4], df[4], ao[2]; bool loaded; double volts(uint16_t raw, Range, bool diff) const; uint16_t dac_code(int ch, double volts) const; }`; `struct QueueEntry { uint8_t wire_channel; Range range; bool diff() const; }`; `void decode_scans(const uint16_t* raw, size_t scans, const std::vector&, const Calibration&, std::vector>& out)`; `class Device` (`read_calibration`, `din`, `dout`, `dconf`, `aout`, `counter`, `counter_reset`, `status`, `scan_start`, `scan_stop`). + +Protocol facts (from the two references; the mock implements the same): +- UDP on the command port: `D` -> a 64-byte reply (`D`, MAC[1..6], product id LE16 at [7..8], firmware [9..10], name [11..26], port [27..28]); `C` + code LE32 -> `C` + status (0 ok, 2 ok "code ignored", 1 rejected, 3 busy). +- TCP command frame: `0xDB, cmd, frame id, status 0, count LE16, payload, checksum` with `checksum = 0xFF - (sum of the previous bytes mod 256)`. A reply is `cmd | 0x80`, the id echoed, status 0 on success, and the whole frame sums to `0xFF`. The first frame id is 1. +- Commands: `0x00` DIN (1 byte), `0x03` DOUT write, `0x05` DCONF (bit set = input), `0x11` scan start (`count LE32 = 0, pacer LE32, options`), `0x13` scan stop (payload `1` = close), `0x15` queue write (`n`, then `(wire channel, range)` pairs), `0x21` AOUT write (`ch`, code LE16), `0x30` counter read (LE32), `0x31` counter reset, `0x40` calibration read (`addr LE16, count LE16`), `0x52` status LE16 (bit 0 scan socket open, bit 1 running, bit 2 overrun). +- After scan start, send one `0x00` filler byte on the **command** socket; the stream is TCP on `port + 1`, little-endian `uint16`, interleaved in queue order. +- Pacer = `round(80e6 / aggregate - 1)` (aggregate = scan rate times channels); `0` for an external clock. Options byte = the trigger value. +- Calibration memory: float32 LE `(slope, intercept)` pairs: DIFF 4 pairs at `0x00`, SE 4 pairs at `0x20`, DAC 2 pairs at `0x40`; indexed by range (AI) or channel (DAC). +- ADC: `code = clamp(round(raw*slope + intercept), 0, 65535)`; `volts = (code - 32768) * fs / 32768`. DAC: `nominal = clamp(round(v*32768/10 + 32768))`, then `clamp(round(nominal*slope + intercept))`; clamp the volts to +-10 first. DIFF wire channel = channel + 8. + +- [ ] **Step 1: Write `e1608_link.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace udpscope::e1608 { + +constexpr int kDefaultPort = 54211; +constexpr std::uint16_t kProductId = 0x012F; + +/// Every failure of the link. `Rejected` (a bad connection code) is the only one a retry cannot +/// fix. `Command` is a reply the board answered with a nonzero status, or a reply of the wrong +/// length: the link itself is fine. `Protocol` is a framing error (sync, checksum, id). +class LinkError : public std::runtime_error { + public: + enum class Kind { Io, Timeout, Rejected, Busy, Protocol, Command }; + LinkError(Kind kind, const std::string& what) : std::runtime_error(what), kind_(kind) {} + Kind kind() const noexcept { return kind_; } + + private: + Kind kind_; +}; + +/// An owned file descriptor. +class Fd { + public: + Fd() = default; + explicit Fd(int fd) : fd_(fd) {} + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + Fd(Fd&& o) noexcept : fd_(o.release()) {} + Fd& operator=(Fd&& o) noexcept { + if (this != &o) reset(o.release()); + return *this; + } + ~Fd() { reset(); } + int get() const noexcept { return fd_; } + bool valid() const noexcept { return fd_ >= 0; } + int release() noexcept { + const int f = fd_; + fd_ = -1; + return f; + } + /// Closes the current descriptor (if any) and takes `fd`. + void reset(int fd = -1) noexcept; + + private: + int fd_ = -1; +}; + +using Mac = std::array; + +/// Six hex bytes separated by `:` or `-`. False (and `out` untouched) on anything else. +bool parse_mac(const std::string& text, Mac& out); +/// Lower-case `aa:bb:cc:dd:ee:ff`. +std::string format_mac(const Mac& mac); + +struct DeviceInfo { + std::string ip; + Mac mac{}; + std::uint16_t product_id = 0; +}; + +/// Where to send a discovery `D`: an empty subnet is the limited broadcast; `a.b.c` (an optional +/// trailing dot) is a unicast to hosts .1 to .254; anything else is empty. +std::vector sweep_targets(const std::string& subnet); + +/// Sends `D` to every target, then collects replies for `wait_ms`. Keeps E-1608s only (product id +/// `kProductId`) and one entry per MAC. +std::vector discover(const std::vector& targets, int port, int wait_ms); + +/// One board: the UDP handshake, the TCP command channel, and the scan socket (`port + 1`). +/// Not thread safe; the source's producer thread owns it. +class Link { + public: + /// Handshake, then the command connection. Throws `LinkError` (`Rejected` for a bad code). + void connect(const std::string& host, int port, std::uint32_t code, int timeout_ms); + /// Closes the command and scan sockets. Safe to call at any time. + void close(); + bool is_open() const noexcept { return cmd_.valid(); } + + /// One framed exchange. `expect >= 0` demands a reply payload of exactly that length. Throws + /// `Command` for a nonzero status or a length mismatch, `Protocol` for a bad frame. + std::vector command(std::uint8_t cmd, const std::vector& payload = {}, + int expect = -1); + + /// Opens the scan stream socket (a 1 MiB receive buffer, set before connecting). + void open_scan(); + void close_scan(); + bool scan_open() const noexcept { return scan_.valid(); } + /// The single `0x00` byte on the command socket that starts the stream after scan start. + void send_filler(); + /// Waits up to `timeout_ms` for scan bytes and reads what is there (at most `cap`). Returns 0 on + /// a timeout. Throws `Io` when the board closed the socket. + std::size_t read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms); + + private: + Fd cmd_; + Fd scan_; + std::string host_; + int port_ = kDefaultPort; + int timeout_ms_ = 2000; + std::uint8_t next_id_ = 0; +}; + +} // namespace udpscope::e1608 +``` + +- [ ] **Step 2: Write `e1608_link.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/e1608/e1608_link.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope::e1608 { + +void Fd::reset(int fd) noexcept { + if (fd_ >= 0) ::close(fd_); + fd_ = fd; +} + +namespace { + +using Clock = std::chrono::steady_clock; +using Kind = LinkError::Kind; + +constexpr std::size_t kMaxFrame = 1024; + +[[noreturn]] void sys_fail(const std::string& what) { + throw LinkError(Kind::Io, what + ": " + std::strerror(errno)); +} + +int ms_left(Clock::time_point deadline) { + const auto d = std::chrono::duration_cast(deadline - Clock::now()); + return d.count() < 0 ? 0 : static_cast(d.count()); +} + +/// True when `fd` is ready for `events` (or has an error/hang-up to report); false on timeout. +bool wait_fd(int fd, short events, int timeout_ms) { + pollfd p{fd, events, 0}; + for (;;) { + const int r = ::poll(&p, 1, timeout_ms); + if (r > 0) return true; + if (r == 0) return false; + if (errno == EINTR) continue; + sys_fail("poll"); + } +} + +sockaddr_in make_addr(const std::string& host, int port) { + sockaddr_in a{}; + a.sin_family = AF_INET; + a.sin_port = htons(static_cast(port)); + if (::inet_pton(AF_INET, host.c_str(), &a.sin_addr) == 1) return a; + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + if (::getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || res == nullptr) + throw LinkError(Kind::Io, "cannot resolve '" + host + "'"); + a.sin_addr = reinterpret_cast(res->ai_addr)->sin_addr; + ::freeaddrinfo(res); + return a; +} + +void send_all(int fd, const std::uint8_t* p, std::size_t n, Clock::time_point deadline) { + while (n > 0) { + if (!wait_fd(fd, POLLOUT, ms_left(deadline))) + throw LinkError(Kind::Timeout, "timed out sending to the board"); + const ssize_t w = ::send(fd, p, n, MSG_NOSIGNAL | MSG_DONTWAIT); + if (w < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; + sys_fail("send"); + } + p += w; + n -= static_cast(w); + } +} + +void read_exact(int fd, std::uint8_t* p, std::size_t n, Clock::time_point deadline) { + while (n > 0) { + if (!wait_fd(fd, POLLIN, ms_left(deadline))) + throw LinkError(Kind::Timeout, "timed out waiting for the board"); + const ssize_t r = ::recv(fd, p, n, MSG_DONTWAIT); + if (r == 0) throw LinkError(Kind::Io, "the board closed the connection"); + if (r < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; + sys_fail("recv"); + } + p += r; + n -= static_cast(r); + } +} + +/// A connected, non-blocking TCP socket with TCP_NODELAY. `rcvbuf > 0` sets SO_RCVBUF first. +Fd tcp_connect(const std::string& host, int port, int timeout_ms, int rcvbuf) { + const sockaddr_in a = make_addr(host, port); + Fd fd(::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)); + if (!fd.valid()) sys_fail("socket"); + const int one = 1; + ::setsockopt(fd.get(), IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + if (rcvbuf > 0) ::setsockopt(fd.get(), SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf); + const std::string where = host + ":" + std::to_string(port); + if (::connect(fd.get(), reinterpret_cast(&a), sizeof a) < 0 && + errno != EINPROGRESS) + sys_fail("cannot connect to " + where); + if (!wait_fd(fd.get(), POLLOUT, timeout_ms)) + throw LinkError(Kind::Timeout, "timed out connecting to " + where); + int err = 0; + socklen_t len = sizeof err; + ::getsockopt(fd.get(), SOL_SOCKET, SO_ERROR, &err, &len); + if (err != 0) { + errno = err; + sys_fail("cannot connect to " + where); + } + return fd; +} + +} // namespace + +// --- addresses ------------------------------------------------------------------------------ + +bool parse_mac(const std::string& text, Mac& out) { + Mac m{}; + std::size_t pos = 0; + for (std::size_t i = 0; i < 6; ++i) { + if (pos + 2 > text.size()) return false; + unsigned v = 0; + for (std::size_t k = 0; k < 2; ++k) { + const char c = text[pos + k]; + v <<= 4; + if (c >= '0' && c <= '9') v |= static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') v |= static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= static_cast(c - 'A' + 10); + else return false; + } + m[i] = static_cast(v); + pos += 2; + if (i < 5) { + if (pos >= text.size() || (text[pos] != ':' && text[pos] != '-')) return false; + ++pos; + } + } + if (pos != text.size()) return false; + out = m; + return true; +} + +std::string format_mac(const Mac& mac) { + char buf[24]; + std::snprintf(buf, sizeof buf, "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], + mac[4], mac[5]); + return buf; +} + +std::vector sweep_targets(const std::string& subnet) { + std::string s = subnet; + while (!s.empty() && s.front() == ' ') s.erase(s.begin()); + while (!s.empty() && (s.back() == ' ' || s.back() == '.')) s.pop_back(); + if (s.empty()) return {"255.255.255.255"}; + int parts = 0; + std::size_t i = 0; + while (i <= s.size()) { + const std::size_t dot = s.find('.', i); + const std::string part = s.substr(i, dot == std::string::npos ? std::string::npos : dot - i); + if (part.empty() || part.size() > 3) return {}; + int v = 0; + for (char c : part) { + if (c < '0' || c > '9') return {}; + v = v * 10 + (c - '0'); + } + if (v > 255) return {}; + ++parts; + if (dot == std::string::npos) break; + i = dot + 1; + } + if (parts != 3) return {}; + std::vector out; + out.reserve(254); + for (int h = 1; h <= 254; ++h) out.push_back(s + "." + std::to_string(h)); + return out; +} + +std::vector discover(const std::vector& targets, int port, int wait_ms) { + std::vector found; + Fd u(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0)); + if (!u.valid()) sys_fail("socket"); + const int one = 1; + ::setsockopt(u.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one); + const std::uint8_t probe = 'D'; + for (const std::string& t : targets) { + sockaddr_in a{}; + try { + a = make_addr(t, port); + } catch (const LinkError&) { + continue; + } + // A sweep may hit unreachable hosts; those sends fail and are simply skipped. + (void)::sendto(u.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&a), + sizeof a); + } + const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms); + for (;;) { + if (!wait_fd(u.get(), POLLIN, ms_left(deadline))) break; + std::uint8_t buf[128]; + sockaddr_in from{}; + socklen_t flen = sizeof from; + const ssize_t n = ::recvfrom(u.get(), buf, sizeof buf, MSG_DONTWAIT, + reinterpret_cast(&from), &flen); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; + break; + } + if (n < 29 || buf[0] != 'D') continue; + DeviceInfo info; + info.product_id = static_cast(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); + } + return found; +} + +// --- Link ----------------------------------------------------------------------------------- + +void Link::connect(const std::string& host, int port, std::uint32_t code, int timeout_ms) { + close(); + host_ = host; + port_ = port; + timeout_ms_ = timeout_ms; + next_id_ = 0; + + // The connection-code handshake: a connected UDP socket, so an ICMP refusal surfaces. + { + const sockaddr_in a = make_addr(host, port); + Fd u(::socket(AF_INET, SOCK_DGRAM, 0)); + if (!u.valid()) sys_fail("socket"); + if (::connect(u.get(), reinterpret_cast(&a), sizeof a) < 0) + sys_fail("handshake with " + host); + const std::uint8_t msg[5] = {'C', static_cast(code & 0xFF), + static_cast((code >> 8) & 0xFF), + static_cast((code >> 16) & 0xFF), + static_cast((code >> 24) & 0xFF)}; + if (::send(u.get(), msg, sizeof msg, MSG_NOSIGNAL) < 0) sys_fail("handshake with " + host); + if (!wait_fd(u.get(), POLLIN, timeout_ms)) + throw LinkError(Kind::Timeout, "no answer from the board at " + host); + std::uint8_t rep[16]; + const ssize_t n = ::recv(u.get(), rep, sizeof rep, 0); + if (n < 0) sys_fail("handshake with " + host); + if (n < 2 || rep[0] != 'C') throw LinkError(Kind::Protocol, "unexpected handshake reply"); + switch (rep[1]) { + case 0: + case 2: + break; + case 1: + throw LinkError(Kind::Rejected, "the board rejected the connection code"); + case 3: + throw LinkError(Kind::Busy, "the board is busy (another client holds it)"); + default: + throw LinkError(Kind::Protocol, "unexpected handshake status " + std::to_string(rep[1])); + } + } + cmd_ = tcp_connect(host, port, timeout_ms, 0); +} + +void Link::close() { + scan_.reset(); + cmd_.reset(); +} + +std::vector Link::command(std::uint8_t cmd, const std::vector& payload, + int expect) { + if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected"); + if (payload.size() + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "command too long"); + // Bytes left by an earlier exchange that timed out must not be read as this reply. + { + std::uint8_t junk[256]; + while (::recv(cmd_.get(), junk, sizeof junk, MSG_DONTWAIT) > 0) { + } + } + const std::uint8_t id = ++next_id_; + std::vector f; + f.reserve(7 + payload.size()); + f.push_back(0xDB); + f.push_back(cmd); + f.push_back(id); + f.push_back(0); + f.push_back(static_cast(payload.size() & 0xFF)); + f.push_back(static_cast((payload.size() >> 8) & 0xFF)); + f.insert(f.end(), payload.begin(), payload.end()); + unsigned sum = 0; + for (std::uint8_t b : f) sum += b; + f.push_back(static_cast(0xFF - (sum & 0xFF))); + + const auto deadline = Clock::now() + std::chrono::milliseconds(timeout_ms_); + send_all(cmd_.get(), f.data(), f.size(), deadline); + + std::uint8_t h[6]; + read_exact(cmd_.get(), h, sizeof h, deadline); + if (h[0] != 0xDB) throw LinkError(Kind::Protocol, "bad sync byte in a reply"); + const std::size_t n = static_cast(h[4] | (h[5] << 8)); + if (n + 7 > kMaxFrame) throw LinkError(Kind::Protocol, "oversized reply"); + std::vector body(n + 1); + read_exact(cmd_.get(), body.data(), body.size(), deadline); + unsigned rsum = 0; + for (std::uint8_t b : h) rsum += b; + for (std::uint8_t b : body) rsum += b; + if ((rsum & 0xFF) != 0xFF) throw LinkError(Kind::Protocol, "bad checksum in a reply"); + if (h[1] != (cmd | 0x80) || h[2] != id) + throw LinkError(Kind::Protocol, "reply does not match the command"); + if (h[3] != 0) { + static const char* const kText[] = {"ok", "protocol error", "parameter error", + "busy", "not ready", "timeout", + "other error"}; + const char* t = h[3] < 7 ? kText[h[3]] : "unknown error"; + char hex[8]; + std::snprintf(hex, sizeof hex, "%02x", cmd); + throw LinkError(Kind::Command, std::string("the board refused command 0x") + hex + ": " + t); + } + body.pop_back(); + if (expect >= 0 && body.size() != static_cast(expect)) + throw LinkError(Kind::Command, "reply of " + std::to_string(body.size()) + + " bytes, expected " + std::to_string(expect)); + return body; +} + +void Link::open_scan() { + close_scan(); + scan_ = tcp_connect(host_, port_ + 1, timeout_ms_, 1 << 20); +} + +void Link::close_scan() { scan_.reset(); } + +void Link::send_filler() { + if (!cmd_.valid()) throw LinkError(Kind::Io, "not connected"); + const std::uint8_t z = 0; + send_all(cmd_.get(), &z, 1, Clock::now() + std::chrono::milliseconds(timeout_ms_)); +} + +std::size_t Link::read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms) { + if (!scan_.valid()) throw LinkError(Kind::Io, "the scan socket is not open"); + if (!wait_fd(scan_.get(), POLLIN, timeout_ms)) return 0; + const ssize_t n = ::recv(scan_.get(), buf, cap, MSG_DONTWAIT); + if (n > 0) return static_cast(n); + if (n == 0) throw LinkError(Kind::Io, "scan socket closed by the board"); + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0; + sys_fail("scan recv"); +} + +} // namespace udpscope::e1608 +``` + +- [ ] **Step 3: Write `e1608_device.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/e1608/e1608_link.hpp" + +#include +#include +#include +#include + +namespace udpscope::e1608 { + +enum class Range : std::uint8_t { Bip10V = 0, Bip5V = 1, Bip2V = 2, Bip1V = 3 }; + +double full_scale(Range r); ///< 10, 5, 2, 1 +const char* range_name(Range r); ///< "BIP10V" ... +/// Case-insensitive: "BIP10V", "bip5v", ... +bool parse_range(const std::string& text, Range& out); + +enum class Trigger : std::uint8_t { None = 0, Rising = 4, Falling = 8, High = 12, Low = 16 }; + +const char* trigger_name(Trigger t); ///< "none", "rising", "falling", "high", "low" +bool parse_trigger(const std::string& text, Trigger& out); ///< case-insensitive + +struct Cal { + double slope = 1.0; + double intercept = 0.0; +}; + +/// The factory calibration: one (slope, intercept) per range for single-ended and differential +/// inputs, one per DAC. Defaults are the nominal (uncalibrated) values. +struct Calibration { + Cal se[4]; + Cal df[4]; + Cal ao[2]; + bool loaded = false; + + /// A raw ADC count as calibrated volts. + double volts(std::uint16_t raw, Range range, bool diff) const; + /// The DAC code that makes `ch` output `volts` (clamped to +-10 V). + std::uint16_t dac_code(int ch, double volts) const; +}; + +/// One entry of the scan queue. DIFF channels are `channel + 8` on the wire. +struct QueueEntry { + std::uint8_t wire_channel; + Range range; + bool diff() const noexcept { return wire_channel >= 8; } +}; + +/// Turns `scans` interleaved scans of `queue.size()` raw counts into calibrated volts: +/// `out[queue index][scan]`. +void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector& queue, + const Calibration& cal, std::vector>& out); + +/// Typed commands over a `Link`. Holds a reference: the link must outlive it. +class Device { + public: + explicit Device(Link& link) : link_(link) {} + + /// Fills `cal` from the board's calibration memory and sets `cal.loaded`. A refusal from the + /// board (`LinkError::Kind::Command`) leaves the nominal values and returns false; any other + /// error propagates. + bool read_calibration(Calibration& cal); + + std::uint8_t din(); + void dout(std::uint8_t value); + /// `inputs_mask`: a set bit makes that line an input. + void dconf(std::uint8_t inputs_mask); + /// Writes `volts` (clamped to +-10 V, calibrated) to DAC `ch`; returns the clamped volts. + double aout(int ch, double volts, const Calibration& cal); + std::uint32_t counter(); + void counter_reset(); + /// Bit 0 scan socket open, bit 1 scan running, bit 2 overrun. + std::uint16_t status(); + + /// Opens the scan socket, writes the queue, starts the scan and sends the filler byte. + /// `aggregate_hz` is the scan rate times the number of channels. On failure the scan socket + /// is closed again. + void scan_start(const std::vector& queue, double aggregate_hz, bool ext_clock, + Trigger trigger); + /// Closes the scan socket and tells the board to stop; board errors are ignored. + void scan_stop(); + + private: + Link& link_; +}; + +} // namespace udpscope::e1608 +``` + +- [ ] **Step 4: Write `e1608_device.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/e1608/e1608_device.hpp" + +#include +#include +#include +#include + +namespace udpscope::e1608 { + +namespace { + +constexpr double kClockHz = 80e6; + +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +double clamp_code(double v) { return std::min(65535.0, std::max(0.0, v)); } + +float le_f32(const std::uint8_t* p) { + const std::uint32_t u = static_cast(p[0]) | (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | + (static_cast(p[3]) << 24); + float f; + std::memcpy(&f, &u, sizeof f); + return f; +} + +void put_le32(std::vector& v, std::uint32_t x) { + for (int i = 0; i < 4; ++i) v.push_back(static_cast((x >> (8 * i)) & 0xFF)); +} + +/// Reads `count` (slope, intercept) pairs at `addr`. +void read_table(Link& link, std::uint16_t addr, int count, Cal* out) { + const std::uint16_t bytes = static_cast(count * 8); + const std::vector req = {static_cast(addr & 0xFF), + static_cast(addr >> 8), + static_cast(bytes & 0xFF), + static_cast(bytes >> 8)}; + const std::vector r = link.command(0x40, req, bytes); + for (int i = 0; i < count; ++i) { + out[i].slope = le_f32(&r[static_cast(i) * 8]); + out[i].intercept = le_f32(&r[static_cast(i) * 8 + 4]); + } +} + +} // namespace + +double full_scale(Range r) { + static const double fs[] = {10.0, 5.0, 2.0, 1.0}; + return fs[static_cast(r)]; +} + +const char* range_name(Range r) { + static const char* const n[] = {"BIP10V", "BIP5V", "BIP2V", "BIP1V"}; + return n[static_cast(r)]; +} + +bool parse_range(const std::string& text, Range& out) { + const std::string t = lower(text); + for (int i = 0; i < 4; ++i) { + if (t == lower(range_name(static_cast(i)))) { + out = static_cast(i); + return true; + } + } + return false; +} + +const char* trigger_name(Trigger t) { + switch (t) { + case Trigger::None: return "none"; + case Trigger::Rising: return "rising"; + case Trigger::Falling: return "falling"; + case Trigger::High: return "high"; + case Trigger::Low: return "low"; + } + return "none"; +} + +bool parse_trigger(const std::string& text, Trigger& out) { + const std::string t = lower(text); + for (Trigger c : {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High, Trigger::Low}) { + if (t == trigger_name(c)) { + out = c; + return true; + } + } + return false; +} + +double Calibration::volts(std::uint16_t raw, Range range, bool diff) const { + const Cal& c = (diff ? df : se)[static_cast(range)]; + const double code = clamp_code(std::round(static_cast(raw) * c.slope + c.intercept)); + return (code - 32768.0) * full_scale(range) / 32768.0; +} + +std::uint16_t Calibration::dac_code(int ch, double v) const { + v = std::min(10.0, std::max(-10.0, v)); + const double nominal = clamp_code(std::round(v * 32768.0 / 10.0 + 32768.0)); + const Cal& c = ao[ch & 1]; + return static_cast(clamp_code(std::round(nominal * c.slope + c.intercept))); +} + +void decode_scans(const std::uint16_t* raw, std::size_t scans, const std::vector& queue, + const Calibration& cal, std::vector>& out) { + const std::size_t nq = queue.size(); + out.resize(nq); + for (std::size_t q = 0; q < nq; ++q) { + out[q].resize(scans); + for (std::size_t s = 0; s < scans; ++s) + out[q][s] = cal.volts(raw[s * nq + q], queue[q].range, queue[q].diff()); + } +} + +bool Device::read_calibration(Calibration& cal) { + Calibration c; + try { + read_table(link_, 0x00, 4, c.df); + read_table(link_, 0x20, 4, c.se); + read_table(link_, 0x40, 2, c.ao); + } catch (const LinkError& e) { + if (e.kind() != LinkError::Kind::Command) throw; + cal = Calibration{}; + return false; + } + c.loaded = true; + cal = c; + return true; +} + +std::uint8_t Device::din() { return link_.command(0x00, {}, 1)[0]; } + +void Device::dout(std::uint8_t value) { link_.command(0x03, {value}); } + +void Device::dconf(std::uint8_t inputs_mask) { link_.command(0x05, {inputs_mask}); } + +double Device::aout(int ch, double volts, const Calibration& cal) { + const double v = std::min(10.0, std::max(-10.0, volts)); + const std::uint16_t code = cal.dac_code(ch, v); + link_.command(0x21, {static_cast(ch & 1), static_cast(code & 0xFF), + static_cast(code >> 8)}); + return v; +} + +std::uint32_t Device::counter() { + const std::vector r = link_.command(0x30, {}, 4); + return static_cast(r[0]) | (static_cast(r[1]) << 8) | + (static_cast(r[2]) << 16) | (static_cast(r[3]) << 24); +} + +void Device::counter_reset() { link_.command(0x31); } + +std::uint16_t Device::status() { + const std::vector r = link_.command(0x52, {}, 2); + return static_cast(r[0] | (r[1] << 8)); +} + +void Device::scan_start(const std::vector& queue, double aggregate_hz, bool ext_clock, + Trigger trigger) { + link_.open_scan(); + try { + std::vector q; + q.push_back(static_cast(queue.size())); + for (const QueueEntry& e : queue) { + q.push_back(e.wire_channel); + q.push_back(static_cast(e.range)); + } + link_.command(0x15, q); + + std::uint32_t pacer = 0; + if (!ext_clock) { + const double p = std::round(kClockHz / aggregate_hz - 1.0); + pacer = static_cast(std::min(4294967295.0, std::max(1.0, p))); + } + std::vector s; + put_le32(s, 0); // count 0: scan until stopped + put_le32(s, pacer); + s.push_back(static_cast(trigger)); + link_.command(0x11, s); + link_.send_filler(); + } catch (...) { + link_.close_scan(); + throw; + } +} + +void Device::scan_stop() { + link_.close_scan(); + try { + link_.command(0x13, {1}); + } catch (const LinkError&) { + } +} + +} // namespace udpscope::e1608 +``` + +- [ ] **Step 5: Add the sources to CMake** + +In `CMakeLists.txt`, after the line `src/source/probe_source.cpp` add: + +``` + src/source/e1608/e1608_link.cpp + src/source/e1608/e1608_device.cpp +``` + +- [ ] **Step 6: Write the throwaway driver `/tmp/e1608_drv.cpp`** + +It exercises both layers against the mock and checks the decode against hand-computed volts (no mock needed for that part). + +```cpp +#include "source/e1608/e1608_device.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace udpscope::e1608; +using namespace std::chrono_literals; + +static int fails = 0; +#define CHECK(c) \ + do { \ + if (!(c)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #c); \ + ++fails; \ + } \ + } while (0) + +static double mean(const std::vector& v) { + double s = 0; + for (double x : v) s += x; + return s / static_cast(v.size()); +} + +int main(int argc, char** argv) { + const int port = argc > 1 ? std::atoi(argv[1]) : 54711; + + // --- decode against hand-computed volts (needs no board) --- + { + Calibration cal; + cal.se[1] = {2.0, -32768.0}; // BIP5V + cal.df[0] = {1.0, 100.0}; // DIFF BIP10V + const std::vector q = {{0, Range::Bip10V}, {1, Range::Bip5V}}; + const std::uint16_t raw[4] = {0x8000, 0x4000, 0xC000, 0x6000}; + std::vector> out; + decode_scans(raw, 2, q, cal, out); + CHECK(out.size() == 2 && out[0].size() == 2); + CHECK(out[0][0] == 0.0 && out[0][1] == 5.0); + CHECK(out[1][0] == -5.0 && out[1][1] == -2.5); + const std::vector qd = {{8, Range::Bip10V}}; + const std::uint16_t rd[1] = {0x8000}; + decode_scans(rd, 1, qd, cal, out); + CHECK(out[0][0] == 0.030517578125); + CHECK(cal.dac_code(0, 0.0) == 32768); + CHECK(cal.dac_code(0, 99.0) == 65535); + Range r; + CHECK(parse_range("bip5v", r) && r == Range::Bip5V && !parse_range("BIP3V", r)); + Trigger t; + CHECK(parse_trigger("Rising", t) && t == Trigger::Rising && !parse_trigger("x", t)); + Mac m; + CHECK(parse_mac("00:80:2F:00:16:08", m) && format_mac(m) == "00:80:2f:00:16:08"); + CHECK(!parse_mac("zz", m) && !parse_mac("00:80:2f:00:16", m) && !parse_mac("00:80:2f:00:16:08:", m)); + CHECK(sweep_targets("").size() == 1 && sweep_targets("192.168.1").size() == 254 && + sweep_targets("192.168.1.").size() == 254 && sweep_targets("bad").empty() && + sweep_targets("1.2").empty() && sweep_targets("1.2.3.4").empty()); + } + + // --- no board: a refusal, not a hang --- + try { + Link l; + l.connect("127.0.0.1", port + 7, 0, 300); + CHECK(false); + } catch (const LinkError&) { + } + + // --- discovery --- + auto devs = discover({"127.0.0.1"}, port, 500); + CHECK(devs.size() == 1); + if (devs.size() == 1) { + CHECK(format_mac(devs[0].mac) == "00:80:2f:00:16:08"); + CHECK(devs[0].product_id == 0x012F); + } + CHECK(discover(sweep_targets("127.0.0"), port, 800).size() == 1); + + // --- a wrong connection code --- + try { + Link l; + l.connect("127.0.0.1", port, 999, 1000); + CHECK(false); + } catch (const LinkError& e) { + CHECK(e.kind() == LinkError::Kind::Rejected); + } + + // --- a session --- + Link link; + link.connect("127.0.0.1", port, 0, 2000); + CHECK(link.is_open()); + Device dev(link); + Calibration cal; + CHECK(dev.read_calibration(cal) && cal.loaded); + CHECK(std::fabs(cal.se[0].slope - 1.02) < 1e-4 && std::fabs(cal.se[0].intercept - 300.0) < 1e-3); + CHECK(std::fabs(cal.df[1].slope - 1.006) < 1e-4); + CHECK(std::fabs(cal.ao[1].intercept - 100.0) < 1e-3); + + dev.dconf(0x0F); + dev.dout(0xA0); + CHECK((dev.din() & 0xF0) == 0xA0); + CHECK(dev.aout(0, 2.5, cal) == 2.5); + CHECK(dev.aout(1, 99.0, cal) == 10.0); + + const std::uint32_t c1 = dev.counter(); + std::this_thread::sleep_for(450ms); + const std::uint32_t c2 = dev.counter(); + CHECK(c2 > c1); + dev.counter_reset(); + CHECK(dev.counter() <= 1); + + // --- a scan: two channels, 1000 S/s each; 200 scans = exactly one 5 Hz period --- + const std::vector q = {{0, Range::Bip10V}, {1, Range::Bip10V}}; + dev.scan_start(q, 2000.0, false, Trigger::None); + std::vector bytes; + std::uint8_t buf[4096]; + const auto t0 = std::chrono::steady_clock::now(); + while (bytes.size() < 800 && std::chrono::steady_clock::now() - t0 < 5s) { + const std::size_t n = link.read_scan(buf, sizeof buf, 500); + bytes.insert(bytes.end(), buf, buf + n); + } + CHECK(bytes.size() >= 800); + if (bytes.size() >= 800) { + std::vector raw(400); + std::memcpy(raw.data(), bytes.data(), 800); + std::vector> out; + decode_scans(raw.data(), 200, q, cal, out); + std::printf("calibrated means: %.4f %.4f\n", mean(out[0]), mean(out[1])); + CHECK(std::fabs(mean(out[0]) - 0.5) < 0.01); + CHECK(std::fabs(mean(out[1]) - 1.0) < 0.01); + Calibration nominal; + decode_scans(raw.data(), 200, q, nominal, out); + std::printf("uncalibrated mean: %.4f\n", mean(out[0])); + CHECK(std::fabs(mean(out[0]) - 0.5) > 0.05); + } + CHECK((dev.status() & 2) != 0); + dev.scan_stop(); + std::this_thread::sleep_for(100ms); + CHECK((dev.status() & 3) == 0); + + std::printf(fails == 0 ? "DRIVER OK\n" : "DRIVER FAILED (%d)\n", fails); + return fails == 0 ? 0 : 1; +} +``` + +- [ ] **Step 7: Build the driver, run it against the mock** + +```sh +g++ -std=c++20 -Wall -Wextra -Wpedantic -Isrc src/source/e1608/e1608_link.cpp src/source/e1608/e1608_device.cpp /tmp/e1608_drv.cpp -o /tmp/e1608_drv +python3 tools/mock_e1608.py --port 54711 --log /tmp/e1608-drv.log > /dev/null & MOCK=$! +sleep 1; /tmp/e1608_drv 54711; kill $MOCK +cat /tmp/e1608-drv.log +``` +Expected: **zero compiler warnings**; output `calibrated means: 0.5000 1.0000` (to within 0.01), an `uncalibrated mean:` of about `0.2043` (more than 0.05 from 0.5), and `DRIVER OK`. The log holds `HANDSHAKE code=999 status=1`, `HANDSHAKE code=0 status=0`, `DCONF 0x0f`, `DOUT 0xa0`, two `AOUT` lines (`ch=0 code=40750 volts=2.4999`, `ch=1 code=65535 volts=9.9692`; the mock inverts the served DAC calibration and the +10 V code saturates, so these exact values are the reading), `COUNTER_RESET`, `QUEUE 0:0 1:0`, `SCAN_START pacer=39999 options=0 channels=2`, `STREAM rate=1000.0 channels=2`, `SCAN_STOP`. A `FAIL line` names the first thing to fix; a mean outside tolerance is a decode or calibration-index bug (check the table order: DIFF, SE, DAC), not a reason to widen the tolerance. + +- [ ] **Step 8: Build the app (warning baseline) and commit** + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:" +git add CMakeLists.txt src/source/e1608/e1608_link.hpp src/source/e1608/e1608_link.cpp src/source/e1608/e1608_device.hpp src/source/e1608/e1608_device.cpp +git commit -m "e1608: link and device layers (discovery, handshake, framed commands, calibration, decode)" +``` +Expected: the warning count is the Task 0 baseline (`3` clean, `1` incremental): the new files add none. Record the commit in the ledger. + +--- +### Task 3: The settings, the `DataSource`, and the connect form + +**Files:** +- Create: `src/source/e1608/e1608_source.hpp`, `src/source/e1608/e1608_source.cpp`, `src/source/e1608/e1608_kind.cpp` +- Modify: `src/source/registry.hpp` (declare `make_e1608_kind`), `src/source/registry.cpp` (register after udps), `CMakeLists.txt` (the two new `.cpp` files after `e1608_device.cpp`) + +**Interfaces:** +- Consumes (Task 2): everything in `e1608_link.hpp` / `e1608_device.hpp`; framework types `DataSource`, `SourceSink`, `Frame`, `SignalValues`, `SignalInfo`, `SourceStats`, `SourceEvent`, `SourceConfig`, `SourceKind`, `ConfigForm`, `SourcePanel`. +- Produces (namespace `udpscope::e1608`), used by Tasks 4 and 5: + - `struct E1608Settings` with `static E1608Settings from(const SourceConfig&)`, `std::string problem() const` (empty = connectable), `queue()`, `active_channels()`, `aggregate()`, `identity()`; fields `address, mac_text, subnet, port, code, timeout_ms, scan_rate, ext_clock, trigger, auto_restart, calibrate, poll_rate, diff, ai_on[8], ai_range[8], dir_out[8], plot[8], plot_counter, do_init, ao_init[2]`. + - `struct E1608Readouts` (atomics: `di`, `do_latch`, `counter`, `ao[2]`, `gen_on[2]`, `epoch`). + - `enum class AoMode { Level, Sine, Square, Triangle, Ramp }`, `struct AoSettings { AoMode mode; double level, amplitude, offset, freq_hz; bool run; }`. + - `class E1608Source final : public DataSource` with the producer-thread-only methods `set_output_bit(int bit, bool on)`, `reset_counter()`, `set_analog_out(int ch, const AoSettings&)`, and accessors `readouts()` (`std::shared_ptr`), `settings()`, `connects()` (`unsigned`, thread-safe), `last_message()` (`std::string`, thread-safe). + - In `udpscope`: `std::unique_ptr make_e1608_kind()`; the kind's id is `"e1608"`. +- Config keys (all optional): `address`, `mac`, `subnet`, `port` (54211), `code` (0), `timeout_ms` (2000), `scan_rate` (1000), `ext_clock` (0), `trigger` (`none|rising|falling|high|low`), `auto_restart` (1), `calibrate` (1), `poll_rate` (10), `mode` (`se|diff`), `ai0`..`ai7` (`BIP10V|BIP5V|BIP2V|BIP1V|off`; absent = off; if **no** `aiN` key is present at all, `ai0=BIP10V`), `dir0`..`dir7` (`i|o`), `plot0`..`plot7` (0/1, inputs only), `plot_counter`, `do_init` (0-255), `ao0_init`, `ao1_init` (volts). + +Behaviour the code below implements (the spec, made concrete): +- `poll()` is a state machine `Down -> Scanning`, or `Halted` after a rejected connection code. Every exception is caught inside `poll()`; the producer thread never sees one. `drain_commands()` runs inside the same `try`, so a posted DAC write that hits a dead link reconnects instead of terminating the thread. +- Frames are fixed-size blocks of `block = clamp(round(scan_rate / 100), 1, 4096)` scans (about 10 ms), so the producer's batch-size check never warns. Decoded scans that do not fill a block wait for the next read. +- DI lines and the counter are read at `poll_rate`, held across a block (a step trace), and shown in the panels through `E1608Readouts`. +- Every 500 ms the status word is read: an overrun with `auto_restart` fires `Error` ("scan overrun; restarting"), restarts the scan, and marks the next frame `lost = 1`; an overrun without it halts. A scan that is not running with `auto_restart` set is restarted. (Whether bit 1 stays set while the board waits for a trigger is **unverified**.) +- The generator ticks every 20 ms while a waveform is running. Waveform frequency is bounded to 0.1 to 10 Hz. + +- [ ] **Step 1: Write `e1608_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/e1608/e1608_device.hpp" +#include "source/e1608/e1608_link.hpp" +#include "source/source_config.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope::e1608 { + +constexpr double kMaxAggregateHz = 250000.0; +constexpr double kMinAggregateHz = 0.02; + +/// Everything a configured E-1608 source needs, parsed once from the flat `SourceConfig`. +struct E1608Settings { + std::string address; ///< host or IP; empty means "find it by MAC" + std::string mac_text; ///< as typed; empty means "any board at `address`" + std::string subnet; ///< `a.b.c` to sweep when looking up a MAC; empty means broadcast + int port = kDefaultPort; + std::uint32_t code = 0; + int timeout_ms = 2000; + double scan_rate = 1000.0; ///< per channel; with an external clock, that clock's rate + bool ext_clock = false; + Trigger trigger = Trigger::None; + bool auto_restart = true; + bool calibrate = true; + double poll_rate = 10.0; ///< Hz, for the digital inputs and the counter + bool diff = false; + bool ai_on[8] = {}; + Range ai_range[8] = {}; + bool dir_out[8] = {}; + bool plot[8] = {}; + bool plot_counter = false; + int do_init = 0; + double ao_init[2] = {0.0, 0.0}; + + static E1608Settings from(const SourceConfig& cfg); + /// The first reason this cannot connect, in words for the user; empty when it can. + std::string problem() const; + int active_channels() const; + std::vector queue() const; + double aggregate() const { return scan_rate * active_channels(); } + /// The address, else the MAC; names the session file. + std::string identity() const; +}; + +/// What the panels read. Written by the producer thread, read by the GTK thread's timer. +struct E1608Readouts { + std::atomic di{0}; ///< the DIN byte as last read + std::atomic do_latch{0}; ///< the DOUT byte as last written + std::atomic counter{0}; + std::atomic ao[2]; ///< the last DAC voltage written; NaN before any + std::atomic gen_on[2]; + std::atomic epoch{0}; ///< bumped at each (re)connect: panels re-sync from it + E1608Readouts() { + for (int i = 0; i < 2; ++i) { + ao[i].store(std::nan("")); + gen_on[i].store(false); + } + } +}; + +enum class AoMode { Level, Sine, Square, Triangle, Ramp }; + +struct AoSettings { + AoMode mode = AoMode::Level; + double level = 0.0; ///< Level mode, volts + double amplitude = 1.0; ///< waveform modes, volts + double offset = 0.0; + double freq_hz = 1.0; ///< clamped to 0.1 .. 10 + bool run = false; ///< waveform modes only +}; + +class E1608Source final : public DataSource { + public: + explicit E1608Source(const SourceConfig& cfg); + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override { return connected_.load(std::memory_order_relaxed); } + + // Producer thread only: reach these through `post()`. Each is ignored unless scanning. + void set_output_bit(int bit, bool on); + void reset_counter(); + void set_analog_out(int ch, const AoSettings& s); + + std::shared_ptr readouts() const { return readouts_; } + const E1608Settings& settings() const { return cfg_; } + unsigned connects() const { return connects_.load(std::memory_order_relaxed); } + std::string last_message() const; + + private: + using Clock = std::chrono::steady_clock; + enum class State { Down, Scanning, Halted }; + + void connect_and_start(); + void scan_step(int timeout_ms); + void emit_frame(std::size_t count); + void run_timers(); + void slow_read(); + void check_status(); + void restart_scan(); + void generator_tick(Clock::time_point now); + void write_ao(int ch, double volts); + void update_gen_flags(); + void fail(const std::string& msg, bool fatal); + void note(const std::string& msg); + int wait_ms(int timeout_ms, Clock::time_point now) const; + + E1608Settings cfg_; + SourceSink* sink_ = nullptr; + State state_ = State::Down; + Link link_; + Device dev_{link_}; + Calibration cal_; + std::vector queue_; + std::vector signals_; + std::vector plotted_bits_; ///< the DI bits that are plot channels, ascending + std::size_t block_ = 10; + + std::shared_ptr readouts_ = std::make_shared(); + std::atomic connected_{false}; + std::atomic connects_{0}; + mutable std::mutex msg_mu_; + std::string last_message_; + std::string last_logged_; + + std::vector rx_; ///< the read buffer + std::vector pending_; ///< bytes of an incomplete scan + std::vector> ready_; ///< decoded, not yet framed: [queue index][scan] + std::vector> dec_; + std::vector> vals_; ///< the frame being built: [signal][sample] + std::vector views_; + std::vector held_di_; + double held_counter_ = 0.0; + std::uint8_t do_latch_ = 0; + + AoSettings ao_[2]; + Clock::time_point gen_t0_[2]; + bool any_gen_ = false; + + Clock::time_point retry_at_; + Clock::time_point next_slow_, next_status_, next_gen_; + std::uint32_t frame_counter_ = 0; + std::uint32_t pending_lost_ = 0; + std::uint64_t packets_ = 0, frames_ = 0, reconnects_ = 0; +}; + +} // namespace udpscope::e1608 +``` + +- [ ] **Step 2: Write `e1608_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/e1608/e1608_source.hpp" + +#include +#include +#include +#include +#include + +namespace udpscope::e1608 { + +namespace { + +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +std::string trim(const std::string& s) { + std::size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +double clamp(double v, double lo, double hi) { return std::min(hi, std::max(lo, v)); } + +double wave(AoMode m, double f) { + switch (m) { + case AoMode::Sine: return std::sin(2.0 * M_PI * f); + case AoMode::Square: return f < 0.5 ? 1.0 : -1.0; + case AoMode::Triangle: return f < 0.5 ? 4.0 * f - 1.0 : 3.0 - 4.0 * f; + case AoMode::Ramp: return 2.0 * f - 1.0; + case AoMode::Level: break; + } + return 0.0; +} + +double epoch_seconds() { + return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +} // namespace + +// --- E1608Settings -------------------------------------------------------------------------- + +E1608Settings E1608Settings::from(const SourceConfig& c) { + E1608Settings s; + s.address = trim(c.get("address")); + s.mac_text = trim(c.get("mac")); + s.subnet = trim(c.get("subnet")); + s.port = static_cast(c.get_int("port", kDefaultPort)); + const long code = c.get_int("code", 0); + s.code = static_cast(std::min(4294967295L, std::max(0L, code))); + s.timeout_ms = static_cast(c.get_int("timeout_ms", 2000)); + s.scan_rate = c.get_double("scan_rate", 1000.0); + s.ext_clock = c.get_bool("ext_clock", false); + parse_trigger(c.get("trigger", "none"), s.trigger); + s.auto_restart = c.get_bool("auto_restart", true); + s.calibrate = c.get_bool("calibrate", true); + s.poll_rate = c.get_double("poll_rate", 10.0); + s.diff = lower(c.get("mode", "se")) == "diff"; + + bool any_key = false; + for (int i = 0; i < 8; ++i) { + const std::string key = "ai" + std::to_string(i); + if (!c.has(key)) continue; + any_key = true; + Range r; + if (parse_range(c.get(key), r)) { + s.ai_on[i] = true; + s.ai_range[i] = r; + } + } + if (!any_key) { // a bare `--source-opt address=...` is connectable + s.ai_on[0] = true; + s.ai_range[0] = Range::Bip10V; + } + if (s.diff) { + for (int i = 4; i < 8; ++i) s.ai_on[i] = false; + } + + for (int i = 0; i < 8; ++i) { + const std::string d = lower(c.get("dir" + std::to_string(i), "i")); + s.dir_out[i] = d == "o" || d == "out" || d == "output"; + s.plot[i] = c.get_bool("plot" + std::to_string(i), false) && !s.dir_out[i]; + } + s.plot_counter = c.get_bool("plot_counter", false); + s.do_init = static_cast(c.get_int("do_init", 0) & 0xFF); + for (int i = 0; i < 2; ++i) + s.ao_init[i] = clamp(c.get_double("ao" + std::to_string(i) + "_init", 0.0), -10.0, 10.0); + return s; +} + +int E1608Settings::active_channels() const { + int n = 0; + for (int i = 0; i < 8; ++i) n += ai_on[i] ? 1 : 0; + return n; +} + +std::vector E1608Settings::queue() const { + std::vector q; + for (int i = 0; i < 8; ++i) { + if (ai_on[i]) q.push_back({static_cast(diff ? i + 8 : i), ai_range[i]}); + } + return q; +} + +std::string E1608Settings::identity() const { + if (!address.empty()) return address; + Mac m; + return parse_mac(mac_text, m) ? format_mac(m) : mac_text; +} + +std::string E1608Settings::problem() const { + Mac m; + if (address.empty() && mac_text.empty()) return "Enter an address, or a MAC address to look up"; + if (!mac_text.empty() && !parse_mac(mac_text, m)) + return "The MAC address must be six hex bytes, such as 00:80:2f:00:16:08"; + if (address.empty() && !subnet.empty() && sweep_targets(subnet).empty()) + return "The subnet must look like 192.168.1"; + if (port < 1 || port > 65534) return "The port must be between 1 and 65534"; + if (timeout_ms < 100 || timeout_ms > 60000) return "The timeout must be 100 to 60000 ms"; + if (!(scan_rate > 0.0)) return "The scan rate must be positive"; + if (active_channels() < 1) return "Enable at least one analog input"; + if (aggregate() > kMaxAggregateHz) { + char b[96]; + std::snprintf(b, sizeof b, "%d channels at %.6g S/s is %.6g kS/s; the board takes 250 kS/s", + active_channels(), scan_rate, aggregate() / 1000.0); + return b; + } + if (aggregate() < kMinAggregateHz) return "The aggregate rate must be at least 0.02 S/s"; + if (!(poll_rate >= 0.1 && poll_rate <= 100.0)) return "The input poll rate must be 0.1 to 100 Hz"; + return {}; +} + +// --- E1608Source ---------------------------------------------------------------------------- + +E1608Source::E1608Source(const SourceConfig& cfg) : cfg_(E1608Settings::from(cfg)) { + queue_ = cfg_.queue(); + block_ = static_cast(clamp(std::round(cfg_.scan_rate / 100.0), 1.0, 4096.0)); + rx_.resize(65536); + ready_.resize(queue_.size()); + + auto base = [&](const std::string& name, const char* unit, double lo, double hi) { + SignalInfo s; + s.name = name; + s.unit = unit; + s.type_code = 9; // float64 + s.rows = 1; + s.cols = 1; + s.range_min = lo; + s.range_max = hi; + s.time_mode = kTimePacket; + s.sampling_rate = cfg_.scan_rate; + return s; + }; + for (int i = 0; i < 8; ++i) { + if (!cfg_.ai_on[i]) continue; + const double fs = full_scale(cfg_.ai_range[i]); + signals_.push_back(base("AI" + std::to_string(i), "V", -fs, fs)); + } + for (int b = 0; b < 8; ++b) { + if (!cfg_.plot[b]) continue; + plotted_bits_.push_back(b); + signals_.push_back(base("DI" + std::to_string(b), "", 0.0, 1.0)); + } + if (cfg_.plot_counter) signals_.push_back(base("Counter", "counts", 0.0, 1000.0)); + held_di_.assign(plotted_bits_.size(), 0.0); +} + +std::string E1608Source::last_message() const { + std::lock_guard lk(msg_mu_); + return last_message_; +} + +void E1608Source::open(SourceSink& sink) { + const std::string p = cfg_.problem(); + if (!p.empty()) throw std::runtime_error(p); + sink_ = &sink; + state_ = State::Down; + retry_at_ = Clock::now(); +} + +void E1608Source::close() { + if (state_ == State::Scanning) dev_.scan_stop(); + link_.close(); + connected_.store(false, std::memory_order_relaxed); +} + +SourceStats E1608Source::stats() const { + SourceStats s; + s.packets = packets_; + s.frames = frames_; + s.reconnects = reconnects_; + s.connected = connected_.load(std::memory_order_relaxed); + return s; +} + +void E1608Source::note(const std::string& msg) { + { + std::lock_guard lk(msg_mu_); + last_message_ = msg; + } + if (msg != last_logged_) { // a retry every 2 s must not flood stderr + last_logged_ = msg; + std::fprintf(stderr, "[e1608] %s\n", msg.c_str()); + std::fflush(stderr); + } +} + +void E1608Source::fail(const std::string& msg, bool fatal) { + link_.close(); + const bool was_up = connected_.exchange(false, std::memory_order_relaxed); + note(msg); + if (was_up) sink_->on_event(SourceEvent::Disconnected, msg.c_str()); + sink_->on_event(SourceEvent::Error, msg.c_str()); + any_gen_ = false; + readouts_->gen_on[0].store(false); + readouts_->gen_on[1].store(false); + if (fatal) { + state_ = State::Halted; + } else { + state_ = State::Down; + retry_at_ = Clock::now() + std::chrono::seconds(2); + } +} + +void E1608Source::poll(int timeout_ms) { + if (sink_ == nullptr) return; + try { + drain_commands(); + switch (state_) { + case State::Down: { + const auto now = Clock::now(); + if (now < retry_at_) { + const auto left = std::chrono::duration_cast(retry_at_ - now); + std::this_thread::sleep_for(std::min(left, std::chrono::milliseconds(timeout_ms))); + return; + } + connect_and_start(); + break; + } + case State::Scanning: + scan_step(timeout_ms); + break; + case State::Halted: + std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms)); + break; + } + } catch (const LinkError& e) { + fail(e.what(), e.kind() == LinkError::Kind::Rejected); + } catch (const std::exception& e) { + fail(e.what(), false); + } +} + +void E1608Source::connect_and_start() { + std::string host = cfg_.address; + Mac want{}; + const bool have_mac = !cfg_.mac_text.empty() && parse_mac(cfg_.mac_text, want); + if (host.empty() || have_mac) { + const std::vector targets = + host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const std::vector found = + discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500)); + if (host.empty()) { + for (const DeviceInfo& d : found) { + if (d.mac == want) host = d.ip; + } + if (host.empty()) + throw LinkError(LinkError::Kind::Io, + "no E-1608 with MAC " + format_mac(want) + " answered"); + } else { + if (found.empty()) throw LinkError(LinkError::Kind::Io, "no E-1608 answered at " + host); + if (found[0].mac != want) + throw LinkError(LinkError::Kind::Protocol, "the board at " + host + " has MAC " + + format_mac(found[0].mac) + ", not " + + format_mac(want)); + } + } + + link_.connect(host, cfg_.port, cfg_.code, cfg_.timeout_ms); + cal_ = Calibration{}; + if (cfg_.calibrate && !dev_.read_calibration(cal_)) + note("calibration memory refused; using nominal values"); + + std::uint8_t inputs = 0; + for (int b = 0; b < 8; ++b) { + if (!cfg_.dir_out[b]) inputs = static_cast(inputs | (1u << b)); + } + dev_.dconf(inputs); + do_latch_ = static_cast(cfg_.do_init); + dev_.dout(do_latch_); + readouts_->do_latch.store(do_latch_); + for (int ch = 0; ch < 2; ++ch) { + ao_[ch] = AoSettings{}; + ao_[ch].level = cfg_.ao_init[ch]; + write_ao(ch, cfg_.ao_init[ch]); + } + any_gen_ = false; + update_gen_flags(); + + dev_.scan_start(queue_, cfg_.aggregate(), cfg_.ext_clock, cfg_.trigger); + + pending_.clear(); + for (auto& r : ready_) r.clear(); + pending_lost_ = 0; + const auto now = Clock::now(); + next_slow_ = now; + next_status_ = now + std::chrono::milliseconds(500); + next_gen_ = now + std::chrono::milliseconds(20); + slow_read(); + + state_ = State::Scanning; + connected_.store(true, std::memory_order_relaxed); + if (connects_.fetch_add(1) > 0) ++reconnects_; + readouts_->epoch.fetch_add(1); + last_logged_.clear(); + sink_->on_config(signals_, 1); + sink_->on_event(SourceEvent::Connected, ""); +} + +int E1608Source::wait_ms(int timeout_ms, Clock::time_point now) const { + Clock::time_point next = std::min(next_slow_, next_status_); + if (any_gen_) next = std::min(next, next_gen_); + const long long d = std::chrono::duration_cast(next - now).count(); + return static_cast(std::max(0LL, std::min(d, timeout_ms))); +} + +void E1608Source::scan_step(int timeout_ms) { + const std::size_t n = link_.read_scan(rx_.data(), rx_.size(), wait_ms(timeout_ms, Clock::now())); + if (n > 0) { + ++packets_; + pending_.insert(pending_.end(), rx_.begin(), rx_.begin() + static_cast(n)); + const std::size_t nq = queue_.size(); + const std::size_t scans = pending_.size() / (2 * nq); + if (scans > 0) { + std::vector raw(scans * nq); + for (std::size_t i = 0; i < raw.size(); ++i) + raw[i] = static_cast(pending_[2 * i] | (pending_[2 * i + 1] << 8)); + pending_.erase(pending_.begin(), + pending_.begin() + static_cast(scans * nq * 2)); + decode_scans(raw.data(), scans, queue_, cal_, dec_); + for (std::size_t q = 0; q < nq; ++q) + ready_[q].insert(ready_[q].end(), dec_[q].begin(), dec_[q].end()); + } + } + while (!ready_.empty() && ready_[0].size() >= block_) emit_frame(block_); + run_timers(); +} + +void E1608Source::emit_frame(std::size_t count) { + const std::size_t nq = queue_.size(); + const std::size_t total = signals_.size(); + vals_.resize(total); + views_.resize(total); + for (std::size_t q = 0; q < nq; ++q) { + vals_[q].assign(ready_[q].begin(), ready_[q].begin() + static_cast(count)); + ready_[q].erase(ready_[q].begin(), ready_[q].begin() + static_cast(count)); + } + for (std::size_t k = 0; k < plotted_bits_.size(); ++k) vals_[nq + k].assign(count, held_di_[k]); + if (cfg_.plot_counter) vals_[total - 1].assign(count, held_counter_); + for (std::size_t i = 0; i < total; ++i) + views_[i] = SignalValues{vals_[i].data(), static_cast(count)}; + + Frame f; + f.counter = frame_counter_++; + f.lost = pending_lost_; + pending_lost_ = 0; + f.late = false; + f.recv_time = epoch_seconds(); + f.publish_mode = 1; + f.num_samples = static_cast(count); + f.num_signals = static_cast(total); + f.signals = signals_.data(); + f.values = views_.data(); + ++frames_; + sink_->on_data(f); +} + +void E1608Source::run_timers() { + const auto now = Clock::now(); + if (now >= next_slow_) { + next_slow_ = now + std::chrono::duration_cast( + std::chrono::duration(1.0 / cfg_.poll_rate)); + slow_read(); + } + if (now >= next_status_) { + next_status_ = now + std::chrono::milliseconds(500); + check_status(); + } + if (any_gen_ && now >= next_gen_) { + next_gen_ = now + std::chrono::milliseconds(20); + generator_tick(now); + } +} + +void E1608Source::slow_read() { + const std::uint8_t d = dev_.din(); + const std::uint32_t c = dev_.counter(); + readouts_->di.store(d); + readouts_->counter.store(c); + for (std::size_t k = 0; k < plotted_bits_.size(); ++k) + held_di_[k] = (d >> plotted_bits_[k]) & 1 ? 1.0 : 0.0; + held_counter_ = static_cast(c); +} + +void E1608Source::check_status() { + const std::uint16_t st = dev_.status(); + if (st & 4) { + if (!cfg_.auto_restart) { + fail("scan overrun; the scan is stopped (auto-restart is off)", true); + return; + } + note("scan overrun; restarting"); + sink_->on_event(SourceEvent::Error, "scan overrun; restarting"); + restart_scan(); + pending_lost_ = 1; + } else if (!(st & 2) && cfg_.auto_restart) { + restart_scan(); + } +} + +void E1608Source::restart_scan() { + dev_.scan_stop(); + pending_.clear(); + for (auto& r : ready_) r.clear(); + dev_.scan_start(queue_, cfg_.aggregate(), cfg_.ext_clock, cfg_.trigger); +} + +void E1608Source::write_ao(int ch, double volts) { + readouts_->ao[ch].store(dev_.aout(ch, volts, cal_)); +} + +void E1608Source::update_gen_flags() { + 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; + } +} + +void E1608Source::generator_tick(Clock::time_point now) { + 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(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)); + } +} + +void E1608Source::set_output_bit(int bit, bool on) { + if (state_ != State::Scanning || bit < 0 || bit > 7 || !cfg_.dir_out[bit]) return; + do_latch_ = static_cast(on ? (do_latch_ | (1u << bit)) : (do_latch_ & ~(1u << bit))); + dev_.dout(do_latch_); + readouts_->do_latch.store(do_latch_); +} + +void E1608Source::reset_counter() { + if (state_ != State::Scanning) return; + dev_.counter_reset(); + held_counter_ = 0.0; + readouts_->counter.store(0); +} + +void E1608Source::set_analog_out(int ch, const AoSettings& s) { + if (state_ != State::Scanning || ch < 0 || ch > 1) return; + ao_[ch] = s; + ao_[ch].freq_hz = clamp(s.freq_hz, 0.1, 10.0); + ao_[ch].amplitude = clamp(s.amplitude, 0.0, 10.0); + ao_[ch].offset = clamp(s.offset, -10.0, 10.0); + if (s.mode == AoMode::Level) { + ao_[ch].run = false; + write_ao(ch, clamp(s.level, -10.0, 10.0)); + } else if (s.run) { + gen_t0_[ch] = Clock::now(); + } + update_gen_flags(); +} + +} // namespace udpscope::e1608 +``` + +Note: `M_PI` needs ``, already included through the header. + +- [ ] **Step 3: Write `e1608_kind.cpp` (the connect form; the panels are Task 4)** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/e1608/e1608_source.hpp" +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { +namespace { + +using namespace e1608; +using ChangeCb = std::shared_ptr>; + +constexpr const char* kTriggerNames[] = {"none", "rising", "falling", "high", "low"}; +constexpr Trigger kTriggers[] = {Trigger::None, Trigger::Rising, Trigger::Falling, Trigger::High, + Trigger::Low}; + +std::string num(double v) { + char b[48]; + std::snprintf(b, sizeof b, "%.10g", v); + return b; +} + +GtkWidget* entry_row(const char* title) { + GtkWidget* r = adw_entry_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + return r; +} + +GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) { + GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast(digits)); + adw_spin_row_set_value(ADW_SPIN_ROW(r), def); + return r; +} + +GtkWidget* switch_row(const char* title, bool def) { + GtkWidget* r = adw_switch_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_switch_row_set_active(ADW_SWITCH_ROW(r), def); + return r; +} + +GtkWidget* combo_row(const char* title, const char* const* items) { + GtkWidget* r = adw_combo_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items))); + return r; +} + +GtkWidget* expander(const char* title, std::initializer_list rows) { + GtkWidget* e = adw_expander_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title); + for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r); + return e; +} + +std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); } +double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); } +guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); } +bool on_of(GtkWidget* w) { return adw_switch_row_get_active(ADW_SWITCH_ROW(w)); } + +/// The form's widgets. Held by a `shared_ptr` that every signal closure and every `ConfigForm` +/// closure shares; the raw widget pointers are valid while the chooser keeps the rows. +struct Form { + GtkWidget *address, *mac, *subnet, *port, *code, *timeout; + GtkWidget *rate, *ext_clock, *trigger, *auto_restart, *calibrate, *poll_rate; + GtkWidget *mode, *ai[8], *ai_group; + GtkWidget *dir[8], *do_init, *plot_counter, *ao_init[2]; + ChangeCb changed = std::make_shared>(); + bool updating = false; + + bool diff() const { return sel_of(mode) == 1; } + int enabled_ai() const { + int n = 0; + for (int i = 0; i < (diff() ? 4 : 8); ++i) n += sel_of(ai[i]) > 0 ? 1 : 0; + return n; + } + + /// In DIFF mode AI rows 4 to 7 are insensitive, not hidden; the group's subtitle shows the load. + void refresh() { + const bool d = diff(); + for (int i = 0; i < 8; ++i) gtk_widget_set_sensitive(ai[i], !(d && i >= 4)); + const int n = enabled_ai(); + char b[96]; + std::snprintf(b, sizeof b, "%d channel%s, %.3g of 250 kS/s", n, n == 1 ? "" : "s", + n * value_of(rate) / 1000.0); + adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(ai_group), b); + } + + SourceConfig read() const { + SourceConfig c; + if (!text_of(address).empty()) c.set("address", text_of(address)); + if (!text_of(mac).empty()) c.set("mac", text_of(mac)); + if (!text_of(subnet).empty()) c.set("subnet", text_of(subnet)); + c.set("port", num(value_of(port))); + c.set("code", num(value_of(code))); + c.set("timeout_ms", num(value_of(timeout))); + c.set("scan_rate", num(value_of(rate))); + c.set("ext_clock", on_of(ext_clock) ? "1" : "0"); + c.set("trigger", kTriggerNames[std::min(sel_of(trigger), 4)]); + c.set("auto_restart", on_of(auto_restart) ? "1" : "0"); + c.set("calibrate", on_of(calibrate) ? "1" : "0"); + c.set("poll_rate", num(value_of(poll_rate))); + c.set("mode", diff() ? "diff" : "se"); + static const char* const kRange[] = {"", "BIP10V", "BIP5V", "BIP2V", "BIP1V"}; + for (int i = 0; i < 8; ++i) { + const guint s = sel_of(ai[i]); + if (s > 0 && s <= 4 && !(diff() && i >= 4)) c.set("ai" + std::to_string(i), kRange[s]); + } + for (int i = 0; i < 8; ++i) { + const guint s = sel_of(dir[i]); // 0 input, 1 input plotted, 2 output + c.set("dir" + std::to_string(i), s == 2 ? "o" : "i"); + c.set("plot" + std::to_string(i), s == 1 ? "1" : "0"); + } + c.set("plot_counter", on_of(plot_counter) ? "1" : "0"); + c.set("do_init", num(value_of(do_init))); + c.set("ao0_init", num(value_of(ao_init[0]))); + c.set("ao1_init", num(value_of(ao_init[1]))); + return c; + } + + void write(const SourceConfig& cfg) { + const E1608Settings s = E1608Settings::from(cfg); + updating = true; + gtk_editable_set_text(GTK_EDITABLE(address), s.address.c_str()); + gtk_editable_set_text(GTK_EDITABLE(mac), s.mac_text.c_str()); + gtk_editable_set_text(GTK_EDITABLE(subnet), s.subnet.c_str()); + adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port); + adw_spin_row_set_value(ADW_SPIN_ROW(code), static_cast(s.code)); + adw_spin_row_set_value(ADW_SPIN_ROW(timeout), s.timeout_ms); + adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.scan_rate); + adw_switch_row_set_active(ADW_SWITCH_ROW(ext_clock), s.ext_clock); + for (guint i = 0; i < 5; ++i) { + if (kTriggers[i] == s.trigger) adw_combo_row_set_selected(ADW_COMBO_ROW(trigger), i); + } + adw_switch_row_set_active(ADW_SWITCH_ROW(auto_restart), s.auto_restart); + adw_switch_row_set_active(ADW_SWITCH_ROW(calibrate), s.calibrate); + adw_spin_row_set_value(ADW_SPIN_ROW(poll_rate), s.poll_rate); + adw_combo_row_set_selected(ADW_COMBO_ROW(mode), s.diff ? 1 : 0); + for (int i = 0; i < 8; ++i) { + adw_combo_row_set_selected( + ADW_COMBO_ROW(ai[i]), s.ai_on[i] ? static_cast(s.ai_range[i]) + 1 : 0); + adw_combo_row_set_selected(ADW_COMBO_ROW(dir[i]), s.dir_out[i] ? 2 : (s.plot[i] ? 1 : 0)); + } + adw_switch_row_set_active(ADW_SWITCH_ROW(plot_counter), s.plot_counter); + adw_spin_row_set_value(ADW_SPIN_ROW(do_init), s.do_init); + adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[0]), s.ao_init[0]); + adw_spin_row_set_value(ADW_SPIN_ROW(ao_init[1]), s.ao_init[1]); + updating = false; + refresh(); + } + + bool valid() const { return enabled_ai() > 0 && E1608Settings::from(read()).problem().empty(); } +}; + +using FormPtr = std::shared_ptr
; + +void on_notify(GObject*, GParamSpec*, gpointer user) { + Form& f = **static_cast(user); + f.refresh(); + if (!f.updating && f.changed && *f.changed) (*f.changed)(); +} + +void on_edited(GtkEditable*, gpointer user) { + Form& f = **static_cast(user); + if (!f.updating && f.changed && *f.changed) (*f.changed)(); +} + +void free_form(gpointer d, GClosure*) { delete static_cast(d); } + +void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) { + g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form, + static_cast(0)); +} + +void hook_entry(GtkWidget* w, const FormPtr& f) { + g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form, + static_cast(0)); +} + +class E1608Kind final : public SourceKind { + public: + std::string id() const override { return "e1608"; } + std::string display_name() const override { return "MCC E-1608"; } + bool has_datagrams() const override { return false; } + + std::string identity(const SourceConfig& cfg) const override { + return E1608Settings::from(cfg).identity(); + } + + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + auto f = std::make_shared(); + static const char* const kTrig[] = {"None", "Rising edge", "Falling edge", "High level", + "Low level", nullptr}; + static const char* const kMode[] = {"Single-ended (8 channels)", "Differential (4 channels)", + nullptr}; + static const char* const kAi[] = {"Off", "\u00b110 V", "\u00b15 V", "\u00b12 V", "\u00b11 V", + nullptr}; + static const char* const kDir[] = {"Input", "Input, plotted", "Output", nullptr}; + + f->address = entry_row("_Address"); + f->mac = entry_row("_MAC address"); + f->subnet = entry_row("_Discovery subnet"); + f->port = spin_row("_Port", 1, 65534, 1, kDefaultPort, 0); + f->code = spin_row("Connection _code", 0, 4294967295.0, 1, 0, 0); + f->timeout = spin_row("_Timeout (ms)", 100, 60000, 100, 2000, 0); + f->rate = spin_row("Scan _rate (S/s per channel)", 0.02, 250000, 1, 1000, 2); + f->ext_clock = switch_row("_External clock", false); + f->trigger = combo_row("T_rigger", kTrig); + f->auto_restart = switch_row("_Restart after an overrun", true); + f->calibrate = switch_row("Apply _factory calibration", true); + f->poll_rate = spin_row("_Input poll rate (Hz)", 0.1, 100, 1, 10, 1); + f->mode = combo_row("Input _mode", kMode); + for (int i = 0; i < 8; ++i) { + const std::string title = "Channel " + std::to_string(i); + f->ai[i] = combo_row(title.c_str(), kAi); + adw_combo_row_set_selected(ADW_COMBO_ROW(f->ai[i]), i == 0 ? 1 : 0); + } + for (int i = 0; i < 8; ++i) { + const std::string title = "Bit " + std::to_string(i); + f->dir[i] = combo_row(title.c_str(), kDir); + } + f->plot_counter = switch_row("Plot the _counter", false); + f->do_init = spin_row("Initial output value", 0, 255, 1, 0, 0); + f->ao_init[0] = spin_row("Initial DAC 0 (V)", -10, 10, 0.1, 0, 3); + f->ao_init[1] = spin_row("Initial DAC 1 (V)", -10, 10, 0.1, 0, 3); + + GtkWidget* conn = expander("Connection", {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}); + GtkWidget* ai = expander("Analog inputs", {f->mode, f->ai[0], f->ai[1], f->ai[2], f->ai[3], + f->ai[4], f->ai[5], f->ai[6], f->ai[7]}); + GtkWidget* dio = expander("Digital I/O", {f->dir[0], f->dir[1], f->dir[2], f->dir[3], f->dir[4], + f->dir[5], f->dir[6], f->dir[7], f->plot_counter, + f->do_init, f->ao_init[0], f->ao_init[1]}); + f->ai_group = ai; + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(conn), TRUE); + + for (GtkWidget* w : {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, + f->ao_init[0], f->ao_init[1]}) + hook_notify(w, "notify::value", f); + for (GtkWidget* w : {f->ext_clock, f->auto_restart, f->calibrate, f->plot_counter}) + hook_notify(w, "notify::active", f); + hook_notify(f->trigger, "notify::selected", f); + hook_notify(f->mode, "notify::selected", f); + for (int i = 0; i < 8; ++i) { + hook_notify(f->ai[i], "notify::selected", f); + hook_notify(f->dir[i], "notify::selected", f); + } + f->refresh(); + + ConfigForm out; + out.rows = {conn, acq, ai, dio}; + out.description = + "Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1)"; + out.focus = f->address; + out.write = [f](const SourceConfig& c) { f->write(c); }; + out.read = [f]() { return f->read(); }; + out.valid = [f]() { return f->valid(); }; + ChangeCb cb = f->changed; + out.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return out; + } +}; + +} // namespace + +std::unique_ptr make_e1608_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +Note: `out.write/read/valid` capture `f` (a `shared_ptr`) by value, and the signal closures hold their own copies; `Form` holds no `shared_ptr` to itself, so there is no cycle. `Form::changed` is read through `f->changed` (the same `shared_ptr` object the `on_change` lambda captured). + +- [ ] **Step 4: Register the kind and add the sources to CMake** + +`src/source/registry.hpp`, in the declarations block: + +```cpp +std::unique_ptr make_e1608_kind(); +``` +(after `make_probe_kind()`), and update the comment above to `(\`udps_kind.cpp\`, \`probe_source.cpp\`, \`e1608/e1608_kind.cpp\`)`. + +`src/source/registry.cpp`, in `register_builtin_sources()` between the udps line and the probe line: + +```cpp + register_kind(make_e1608_kind()); +``` + +`CMakeLists.txt`, after `src/source/e1608/e1608_device.cpp`: + +``` + src/source/e1608/e1608_source.cpp + src/source/e1608/e1608_kind.cpp +``` + +- [ ] **Step 5: Build, and read the warnings against the baseline** + +```sh +cmake --build build -j 2>&1 | tee /tmp/e1608-build3.log | grep -E "warning:|error" | head -20 +``` +Expected: no errors; **no new warning** (the total stays at the Task 0 baseline: `3` on a clean build, `1` incremental). + +- [ ] **Step 6: Check the form's defaults and the registry with the real app (headless), against the mock** + +```sh +python3 tools/mock_e1608.py --port 54711 --log /tmp/e1608-t3.log > /dev/null & MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 \ + ./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \ + --source-opt scan_rate=1000 --source-opt ai1=BIP5V --source-opt plot0=1 --source-opt plot_counter=1 \ + 2>&1 | tee /tmp/e1608-t3.out | grep -E "e1608|gtk warnings|Gtk-CRITICAL|Gtk-WARNING|signals|connected" | head -20 +kill $MOCK; cat /tmp/e1608-t3.log +``` +Expected: no `Gtk-CRITICAL`, `gtk warnings: 0`, no `[e1608]` error line; the mock log shows `HANDSHAKE code=0 status=0`, `DCONF 0xff`, `DOUT 0x00`, two `AOUT` lines with `volts=0.0000`-ish (the DAC calibration moves the code, not the nominal: `volts` reads about `0.00`), `QUEUE 0:0 1:1`, `SCAN_START pacer=39999 options=0 channels=2`, `STREAM rate=1000.0 channels=2`, and `SCAN_STOP` at exit. (The `[stream]` lines of the self-check show the signals `AI0`, `AI1`, `DI0`, `Counter`; their exact format is documented in `probe-readings.md`.) If `--source e1608` is rejected as an unknown kind, the registration in Step 4 was missed. + +- [ ] **Step 7: Commit** + +```sh +git add CMakeLists.txt src/source/registry.hpp src/source/registry.cpp src/source/e1608/e1608_source.hpp src/source/e1608/e1608_source.cpp src/source/e1608/e1608_kind.cpp +git commit -m "e1608: the settings, the DataSource state machine, and the connect form" +``` +Record the commit in the ledger. + +--- +### Task 4: The two side-stack panels + +**Files:** +- Create: `src/source/e1608/e1608_panels.hpp`, `src/source/e1608/e1608_panels.cpp` +- Modify: `src/source/e1608/e1608_kind.cpp` (override `make_panels`), `CMakeLists.txt` (add `e1608_panels.cpp` after `e1608_kind.cpp`) + +**Interfaces:** +- Consumes (Task 3): `E1608Source::post` (inherited), `set_output_bit`, `reset_counter`, `set_analog_out`, `readouts()`, `settings()`; `E1608Readouts`; `AoMode`, `AoSettings`; framework `SourcePanel`, `DataSource`. +- Produces: `std::vector make_e1608_panels(DataSource& source)` in namespace `udpscope`, two panels: id `ao` (title "Analog out", icon `audio-volume-high-symbolic`) and id `dio` (title "Digital I/O", icon `input-dialpad-symbolic`). The app turns the ids into stack pages `source-ao` and `source-dio`. Task 5's probe finds these **widget names** (set with `gtk_widget_set_name` on the widget itself), and they are a contract: + - `e1608-ao-mode` (`AdwComboRow`), `-level`, `-amp`, `-offset`, `-freq` (`AdwSpinRow`), `-run` (`AdwSwitchRow`), `-last` (`AdwActionRow`), for `n` = 0, 1; + - `e1608-do` (`AdwSwitchRow`, output bits only), `e1608-di` (`GtkLabel`, input bits only), for `b` = 0..7; + - `e1608-counter` (`GtkLabel`), `e1608-counter-reset` (`GtkButton`). + +Behaviour (spec section 3): both panels reach the board **only** through `source.post(...)`; neither is hidden when disconnected (the app makes the panel insensitive; Task 4 adds no code for that). A panel refreshes from `E1608Readouts` in a tick callback throttled to 100 ms. When `readouts->epoch` changes (a (re)connect) the panel resets its widgets to what the source reset to: mode Level, level = the configured initial DAC voltage, amplitude 1, offset 0, frequency 1 Hz, Run off, and each output switch = the DOUT latch bit. + +Why a second file: `e1608_kind.cpp` is already about 280 lines of form code; the panels are a separate responsibility. (Deviation from the spec's file list, which puts both in `e1608_kind.cpp`; noted in the closing report.) + +- [ ] **Step 1: Write `e1608_panels.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/source_kind.hpp" + +#include + +namespace udpscope { + +/// The E-1608's two capability panels, "Analog out" and "Digital I/O". `source` must be an +/// `e1608::E1608Source`. The widgets reach it only through `source.post()`. +std::vector make_e1608_panels(DataSource& source); + +} // namespace udpscope +``` + +- [ ] **Step 2: Write `e1608_panels.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/e1608/e1608_panels.hpp" + +#include "source/e1608/e1608_source.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace udpscope { +namespace { + +using namespace e1608; + +constexpr gint64 kRefreshUs = 100000; // the panels redraw their readouts at 10 Hz + +/// A signal closure's argument: the panel's context and the index (DAC or bit) it concerns. +struct Hook { + void* ctx = nullptr; + int idx = 0; +}; + +GtkWidget* spin(const std::string& name, const char* title, double lo, double hi, double step, + double def, guint digits) { + GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step)); + gtk_widget_set_name(r, name.c_str()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_spin_row_set_digits(ADW_SPIN_ROW(r), digits); + adw_spin_row_set_value(ADW_SPIN_ROW(r), def); + return r; +} + +GtkWidget* switch_row(const std::string& name, const char* title, const char* subtitle) { + GtkWidget* r = adw_switch_row_new(); + gtk_widget_set_name(r, name.c_str()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + if (subtitle != nullptr) adw_action_row_set_subtitle(ADW_ACTION_ROW(r), subtitle); + return r; +} + +bool due(gint64& last_us) { + const gint64 now = g_get_monotonic_time(); + if (now - last_us < kRefreshUs) return false; + last_us = now; + return true; +} + +void set_text(GtkWidget* label, const char* text) { + if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text); +} + +GtkWidget* panel_root(GtkWidget* a, GtkWidget* b = nullptr) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 18); + gtk_widget_set_margin_top(box, 12); + gtk_widget_set_margin_bottom(box, 12); + gtk_widget_set_margin_start(box, 12); + gtk_widget_set_margin_end(box, 12); + gtk_box_append(GTK_BOX(box), a); + if (b != nullptr) gtk_box_append(GTK_BOX(box), b); + return box; +} + +// --- Analog out ------------------------------------------------------------------------------- + +struct AoWidgets { + GtkWidget *mode = nullptr, *level = nullptr, *amp = nullptr, *offset = nullptr, *freq = nullptr, + *run = nullptr, *last = nullptr; +}; + +struct AoCtx { + E1608Source* src = nullptr; + std::shared_ptr ro; + AoWidgets w[2]; + Hook hooks[2]; + bool updating = false; ///< set while the panel writes its own widgets: those writes post nothing + unsigned epoch_seen = 0; + gint64 last_us = 0; +}; + +void ao_sensitivity(const AoWidgets& w) { + const bool level = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode)) == 0; + gtk_widget_set_sensitive(w.level, level); + for (GtkWidget* x : {w.amp, w.offset, w.freq, w.run}) gtk_widget_set_sensitive(x, !level); +} + +/// Back to what the source holds after a (re)connect. +void ao_reset(AoCtx* c) { + c->updating = true; + for (int ch = 0; ch < 2; ++ch) { + const AoWidgets& w = c->w[ch]; + adw_combo_row_set_selected(ADW_COMBO_ROW(w.mode), 0); + adw_spin_row_set_value(ADW_SPIN_ROW(w.level), c->src->settings().ao_init[ch]); + adw_spin_row_set_value(ADW_SPIN_ROW(w.amp), 1.0); + adw_spin_row_set_value(ADW_SPIN_ROW(w.offset), 0.0); + adw_spin_row_set_value(ADW_SPIN_ROW(w.freq), 1.0); + adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE); + ao_sensitivity(w); + } + c->updating = false; +} + +void ao_push(AoCtx* c, int ch) { + if (c->updating) return; + const AoWidgets& w = c->w[ch]; + const guint mode = adw_combo_row_get_selected(ADW_COMBO_ROW(w.mode)); + if (mode == 0 && adw_switch_row_get_active(ADW_SWITCH_ROW(w.run))) { // Level has no generator + c->updating = true; + adw_switch_row_set_active(ADW_SWITCH_ROW(w.run), FALSE); + c->updating = false; + } + ao_sensitivity(w); + AoSettings s; + s.mode = static_cast(mode); + s.level = adw_spin_row_get_value(ADW_SPIN_ROW(w.level)); + s.amplitude = adw_spin_row_get_value(ADW_SPIN_ROW(w.amp)); + s.offset = adw_spin_row_get_value(ADW_SPIN_ROW(w.offset)); + s.freq_hz = adw_spin_row_get_value(ADW_SPIN_ROW(w.freq)); + s.run = adw_switch_row_get_active(ADW_SWITCH_ROW(w.run)); + E1608Source* src = c->src; + src->post([src, ch, s] { src->set_analog_out(ch, s); }); +} + +void on_ao_changed(GObject*, GParamSpec*, gpointer user) { + const Hook* h = static_cast(user); + ao_push(static_cast(h->ctx), h->idx); +} + +void ao_refresh(AoCtx* c) { + const unsigned ep = c->ro->epoch.load(); + if (ep != c->epoch_seen) { + c->epoch_seen = ep; + ao_reset(c); + } + for (int ch = 0; ch < 2; ++ch) { + const double v = c->ro->ao[ch].load(); + char b[48]; + if (std::isnan(v)) + std::snprintf(b, sizeof b, "not written"); + else + std::snprintf(b, sizeof b, "%+.4f V", v); + const char* cur = adw_action_row_get_subtitle(ADW_ACTION_ROW(c->w[ch].last)); + if (cur == nullptr || std::strcmp(cur, b) != 0) + adw_action_row_set_subtitle(ADW_ACTION_ROW(c->w[ch].last), b); + } +} + +gboolean ao_tick(GtkWidget*, GdkFrameClock*, gpointer user) { + AoCtx* c = static_cast(user); + if (due(c->last_us)) ao_refresh(c); + return G_SOURCE_CONTINUE; +} + +GtkWidget* build_ao(E1608Source* src) { + auto* c = new AoCtx; + c->src = src; + c->ro = src->readouts(); + + GtkWidget* groups[2]; + static const char* const kModes[] = {"Level", "Sine", "Square", "Triangle", "Ramp", nullptr}; + for (int ch = 0; ch < 2; ++ch) { + const std::string p = "e1608-ao" + std::to_string(ch); + AoWidgets& w = c->w[ch]; + w.mode = adw_combo_row_new(); + gtk_widget_set_name(w.mode, (p + "-mode").c_str()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.mode), "Mode"); + adw_combo_row_set_model(ADW_COMBO_ROW(w.mode), G_LIST_MODEL(gtk_string_list_new(kModes))); + w.level = spin(p + "-level", "Level (V)", -10, 10, 0.1, src->settings().ao_init[ch], 3); + w.amp = spin(p + "-amp", "Amplitude (V)", 0, 10, 0.1, 1.0, 3); + w.offset = spin(p + "-offset", "Offset (V)", -10, 10, 0.1, 0.0, 3); + w.freq = spin(p + "-freq", "Frequency (Hz)", 0.1, 10, 0.1, 1.0, 1); + w.run = switch_row(p + "-run", "Run", nullptr); + w.last = adw_action_row_new(); + gtk_widget_set_name(w.last, (p + "-last").c_str()); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(w.last), "Last written"); + adw_action_row_set_subtitle(ADW_ACTION_ROW(w.last), "not written"); + + GtkWidget* g = adw_preferences_group_new(); + const std::string title = "DAC " + std::to_string(ch); + adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(g), title.c_str()); + for (GtkWidget* r : {w.mode, w.level, w.amp, w.offset, w.freq, w.run, w.last}) + adw_preferences_group_add(ADW_PREFERENCES_GROUP(g), r); + groups[ch] = g; + + c->hooks[ch] = {c, ch}; + g_signal_connect_data(w.mode, "notify::selected", G_CALLBACK(on_ao_changed), &c->hooks[ch], + nullptr, static_cast(0)); + for (GtkWidget* x : {w.level, w.amp, w.offset, w.freq}) + g_signal_connect_data(x, "notify::value", G_CALLBACK(on_ao_changed), &c->hooks[ch], nullptr, + static_cast(0)); + g_signal_connect_data(w.run, "notify::active", G_CALLBACK(on_ao_changed), &c->hooks[ch], + nullptr, static_cast(0)); + ao_sensitivity(w); + } + + GtkWidget* root = panel_root(groups[0], groups[1]); + g_object_set_data_full(G_OBJECT(root), "e1608-ao-ctx", c, + +[](gpointer p) { delete static_cast(p); }); + gtk_widget_add_tick_callback(root, ao_tick, c, nullptr); + return root; +} + +// --- Digital I/O ------------------------------------------------------------------------------ + +struct DioCtx { + E1608Source* src = nullptr; + std::shared_ptr ro; + GtkWidget* led[8] = {}; ///< input bits + GtkWidget* out[8] = {}; ///< output bits (switch rows) + GtkWidget* counter = nullptr; + Hook hooks[8]; + bool updating = false; + unsigned epoch_seen = 0; + gint64 last_us = 0; +}; + +void set_led(GtkWidget* l, bool on) { + if (gtk_widget_has_css_class(l, "success") == on) return; + if (on) { + gtk_widget_remove_css_class(l, "dim-label"); + gtk_widget_add_css_class(l, "success"); + } else { + gtk_widget_remove_css_class(l, "success"); + gtk_widget_add_css_class(l, "dim-label"); + } +} + +void dio_refresh(DioCtx* c) { + const unsigned ep = c->ro->epoch.load(); + if (ep != c->epoch_seen) { // a (re)connect re-latched the outputs from the form + c->epoch_seen = ep; + const unsigned latch = c->ro->do_latch.load(); + c->updating = true; + for (int b = 0; b < 8; ++b) { + if (c->out[b] != nullptr) + adw_switch_row_set_active(ADW_SWITCH_ROW(c->out[b]), ((latch >> b) & 1u) != 0); + } + c->updating = false; + } + const unsigned di = c->ro->di.load(); + for (int b = 0; b < 8; ++b) { + if (c->led[b] != nullptr) set_led(c->led[b], ((di >> b) & 1u) != 0); + } + char buf[24]; + std::snprintf(buf, sizeof buf, "%u", static_cast(c->ro->counter.load())); + set_text(c->counter, buf); +} + +gboolean dio_tick(GtkWidget*, GdkFrameClock*, gpointer user) { + DioCtx* c = static_cast(user); + if (due(c->last_us)) dio_refresh(c); + return G_SOURCE_CONTINUE; +} + +void on_do_changed(GObject* obj, GParamSpec*, gpointer user) { + const Hook* h = static_cast(user); + DioCtx* c = static_cast(h->ctx); + if (c->updating) return; + const bool on = adw_switch_row_get_active(ADW_SWITCH_ROW(obj)); + E1608Source* src = c->src; + const int bit = h->idx; + src->post([src, bit, on] { src->set_output_bit(bit, on); }); +} + +void on_counter_reset(GtkButton*, gpointer user) { + DioCtx* c = static_cast(user); + E1608Source* src = c->src; + src->post([src] { src->reset_counter(); }); +} + +GtkWidget* build_dio(E1608Source* src) { + auto* c = new DioCtx; + c->src = src; + c->ro = src->readouts(); + + GtkWidget* lines = adw_preferences_group_new(); + adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(lines), "Digital lines"); + for (int b = 0; b < 8; ++b) { + const std::string title = "Bit " + std::to_string(b); + if (src->settings().dir_out[b]) { + GtkWidget* row = switch_row("e1608-do" + std::to_string(b), title.c_str(), "Output"); + c->out[b] = row; + c->hooks[b] = {c, b}; + g_signal_connect_data(row, "notify::active", G_CALLBACK(on_do_changed), &c->hooks[b], nullptr, + static_cast(0)); + adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row); + } else { + GtkWidget* row = adw_action_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), title.c_str()); + adw_action_row_set_subtitle(ADW_ACTION_ROW(row), "Input"); + GtkWidget* led = gtk_label_new("\u25cf"); + gtk_widget_set_name(led, ("e1608-di" + std::to_string(b)).c_str()); + gtk_widget_add_css_class(led, "dim-label"); + adw_action_row_add_suffix(ADW_ACTION_ROW(row), led); + c->led[b] = led; + adw_preferences_group_add(ADW_PREFERENCES_GROUP(lines), row); + } + } + + GtkWidget* counter_group = adw_preferences_group_new(); + adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(counter_group), "Counter"); + GtkWidget* crow = adw_action_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(crow), "Counts"); + c->counter = gtk_label_new("0"); + gtk_widget_set_name(c->counter, "e1608-counter"); + gtk_widget_add_css_class(c->counter, "numeric"); + adw_action_row_add_suffix(ADW_ACTION_ROW(crow), c->counter); + GtkWidget* reset = gtk_button_new_with_label("Reset"); + gtk_widget_set_name(reset, "e1608-counter-reset"); + gtk_widget_set_valign(reset, GTK_ALIGN_CENTER); + g_signal_connect_data(reset, "clicked", G_CALLBACK(on_counter_reset), c, nullptr, + static_cast(0)); + adw_action_row_add_suffix(ADW_ACTION_ROW(crow), reset); + adw_preferences_group_add(ADW_PREFERENCES_GROUP(counter_group), crow); + + GtkWidget* root = panel_root(lines, counter_group); + g_object_set_data_full(G_OBJECT(root), "e1608-dio-ctx", c, + +[](gpointer p) { delete static_cast(p); }); + gtk_widget_add_tick_callback(root, dio_tick, c, nullptr); + return root; +} + +} // namespace + +std::vector make_e1608_panels(DataSource& source) { + E1608Source* src = static_cast(&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}; +} + +} // namespace udpscope +``` + +Two rules this code follows, from `docs/claude/invariants-gtk-and-chrome.md` (read it before editing GTK code): a notify handler is `(GObject*, GParamSpec*, gpointer)` and a button's `clicked` is `(GtkButton*, gpointer)`; every heap context is freed by a destroy function (`g_object_set_data_full` on the panel root), never leaked. The `Hook` arrays live inside the context, so the pointers handed to the signals stay valid for exactly as long as the widgets. + +- [ ] **Step 3: Make `E1608Kind` return the panels** + +In `src/source/e1608/e1608_kind.cpp`, add `#include "source/e1608/e1608_panels.hpp"` below the `registry.hpp` include, and in `class E1608Kind`, after `make_config_form()`: + +```cpp + std::vector make_panels(DataSource& source) const override { + return make_e1608_panels(source); + } +``` + +`CMakeLists.txt`, after `src/source/e1608/e1608_kind.cpp`: + +``` + src/source/e1608/e1608_panels.cpp +``` + +- [ ] **Step 4: Build, and read the warnings against the baseline** + +```sh +cmake --build build -j 2>&1 | tee /tmp/e1608-build4.log | grep -E "warning:|error" | head -20 +``` +Expected: no errors, no new warning (baseline: 3 on a clean build, 1 incremental). If `adw_action_row_get_subtitle` or `adw_switch_row_*` is flagged as undeclared, the header is ``, already included; a typo in a name is the likelier cause. + +- [ ] **Step 5: Run the app against the mock and look for GTK complaints** + +The semantic checks come with Task 5's probe; this step only proves the panels build and run without a GTK complaint. + +```sh +python3 tools/mock_e1608.py --port 54711 --log /tmp/e1608-t4.log > /dev/null & MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=4000 \ + ./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \ + --source-opt dir1=o --source-opt ao0_init=1.5 2>&1 | tee /tmp/e1608-t4.out | grep -E "e1608|gtk warnings|Gtk-|GLib|Adwaita" | head -20 +kill $MOCK; grep -c AOUT /tmp/e1608-t4.log +``` +Expected: no `Gtk-CRITICAL`, no `Gtk-WARNING`, no `GLib-GObject` or `Adwaita-WARNING` line, `gtk warnings: 0`, no `[e1608]` error line; the mock log has `AOUT` twice (the two initial DAC writes: `grep -c` prints `2`). + +- [ ] **Step 6: Commit** + +```sh +git add CMakeLists.txt src/source/e1608/e1608_panels.hpp src/source/e1608/e1608_panels.cpp src/source/e1608/e1608_kind.cpp +git commit -m "e1608: the Analog out and Digital I/O panels" +``` +Record the commit in the ledger. + +--- + +### Task 5: The probe and the verification runs + +**Files:** +- Modify: `src/ui_probe.cpp` (new probes; the `e1608` sample in `source_kinds_probe`; guard the click), `src/ui_app.hpp` (declarations near line 1878), `src/ui.cpp` (schedule near line 6113) + +**Interfaces:** +- Consumes (Tasks 3 and 4): `E1608Source` (`settings()`, `connects()`, `last_message()`), `E1608Settings`, `decode_scans`, `Calibration`, `QueueEntry`, `Range`; the panel pages `source-ao` and `source-dio` and the widget names listed under Task 4; app members `a->acq->source()`, `a->source_panels` (`SourcePanelPage{name, title, content}`), `a->side_stack`, `a->capture_button`, `a->model` (`stats()`, `signals()`, `channels()`, `channel_count()`, `live().channel(i)`, `set_signals_enabled()`). +- Produces: environment variables `UDPSCOPE_PROBE_E1608=` and `UDPSCOPE_PROBE_E1608_RECONNECT=`, and the `[e1608]` output lines Task 6 documents. Four functions in `udpscope::ui_impl`: `gboolean e1608_probe(gpointer)`, `e1608_probe_report`, `e1608_probe_reconnect`, `e1608_probe_resumed`. + +Design: the probe enables the stream's signals itself in one `set_signals_enabled()` call (the way `persist_probe` does) and **not** through `UDPSCOPE_ENABLE_ALL_MS`: that probe switches the first signal off again two seconds later, and a run that depended on it would lose AI0 mid-measurement. Phase 1 (`e1608_probe`, at the variable's delay) enables the signals, reads the panels and drives the three controls; phase 2 (`e1608_probe_report`, 1000 ms later) reads the stream, the calibration, the capture button and the source's state, and runs the decode check. + +The calibration reading: the mock serves AI0 as `0.5 V + 0.1 V * sin(2 pi 5 t)` on BIP10V through non-trivial factory coefficients (single-ended slope 1.02, intercept 300 on that range). Two 5 Hz periods at the scan rate is `2 * rate / 5` samples (400 at 1000 S/s), whose mean is 0.5 V when the calibration is applied. Without it the mean reads about 0.2043 (Task 2 measured that), so the tolerance of 0.02 separates the two. + +- [ ] **Step 1: Declare the probes** + +`src/ui_app.hpp`, after `gboolean source_kinds_probe(gpointer user);` (line ~1878): + +```cpp +/// MCC E-1608 end to end against `tools/mock_e1608.py`: the stream, the calibration, the panels and +/// their three controls, the capture button and a hand-checked decode. `_report` is the second phase. +/// `_reconnect` reads the state after the mock dropped the link; `_resumed` shows the stream moved on. +gboolean e1608_probe(gpointer user); +gboolean e1608_probe_report(gpointer user); +gboolean e1608_probe_reconnect(gpointer user); +gboolean e1608_probe_resumed(gpointer user); +``` + +- [ ] **Step 2: Schedule them** + +`src/ui.cpp`, after the `UDPSCOPE_PROBE_SOURCE_KINDS` block (line ~6112): + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608")) { + g_timeout_add(static_cast(std::atoi(env)), e1608_probe, &app); + } + if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) { + g_timeout_add(static_cast(std::atoi(env)), e1608_probe_reconnect, &app); + } +``` + +- [ ] **Step 3: Write the probes** + +In `src/ui_probe.cpp` add `#include "source/e1608/e1608_source.hpp"` with the other source includes (after `#include "source/probe_source.hpp"`). Put the code below **directly after `source_kinds_probe`** (before `pan_probe`), inside `namespace ui_impl`: + +```cpp +namespace { + +/// The first widget named `name` at or under `root` (the panels name their controls). +GtkWidget* find_named(GtkWidget* root, const char* name) { + if (root == nullptr) return nullptr; + if (std::strcmp(gtk_widget_get_name(root), name) == 0) return root; + for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + if (GtkWidget* f = find_named(c, name)) return f; + } + return nullptr; +} + +GtkWidget* source_panel_content(App* a, const char* page) { + for (const SourcePanelPage& p : a->source_panels) { + if (p.name == page) return p.content; + } + return nullptr; +} + +e1608::E1608Source* e1608_source(App* a) { + return a->acq != nullptr ? dynamic_cast(a->acq->source()) : nullptr; +} + +std::uint64_t g_e1608_frames_before = 0; + +/// A fixed raw buffer decoded against volts worked out by hand: single-ended, ch0 on BIP10V with +/// intercept 100 (slope 1), ch1 on BIP5V uncalibrated. `(code - 32768) * fs / 32768`, the code +/// clamped to 0..65535. +bool e1608_decode_check() { + using namespace e1608; + Calibration cal; + cal.se[0] = {1.0, 100.0}; + const std::vector q = {{0, Range::Bip10V}, {1, Range::Bip5V}}; + const std::uint16_t raw[] = {32668, 49152, 49052, 16384, 65535, 0}; // 3 scans x 2 channels + std::vector> out; + decode_scans(raw, 3, q, cal, out); + const double want0[] = {0.0, 5.0, 32767.0 * 10.0 / 32768.0}; + const double want1[] = {2.5, -2.5, -5.0}; + if (out.size() != 2 || out[0].size() != 3 || out[1].size() != 3) return false; + for (int i = 0; i < 3; ++i) { + if (std::fabs(out[0][static_cast(i)] - want0[i]) > 1e-9) return false; + if (std::fabs(out[1][static_cast(i)] - want1[i]) > 1e-9) return false; + } + return true; +} + +} // namespace + +/// Phase 2, 1 s after the controls were driven: the stream, the calibration, the capture button, +/// the source's state and the decode. +gboolean e1608_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + const e1608::E1608Settings& s = src->settings(); + const bool up = a->model->stats().connected.load(std::memory_order_relaxed); + + const std::vector& sigs = a->model->signals(); + std::size_t plotted = 0; + for (bool p : s.plot) plotted += p ? 1 : 0; + const std::size_t expect = + static_cast(s.active_channels()) + plotted + (s.plot_counter ? 1 : 0); + std::string names; + for (const SignalInfo& si : sigs) names += (names.empty() ? "" : ",") + si.name; + const double rate = sigs.empty() ? 0.0 : sigs.front().sampling_rate; + const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed); + const bool stream_ok = up && sigs.size() == expect && std::fabs(rate - s.scan_rate) < 1e-6 && + frames > 0; + std::fprintf(stderr, + "[e1608] stream: connected=%d signals=%zu expect=%zu names=%s frames=%" G_GUINT64_FORMAT + " rate=%.3f ok=%d\n", + up ? 1 : 0, sigs.size(), expect, names.c_str(), frames, rate, stream_ok ? 1 : 0); + + // The mock's AI0 is 0.5 V DC under a 5 Hz sine: two periods average to the DC. + double mean = std::nan(""); + if (up && a->model->channel_count() > 0 && !a->model->channels().empty() && + a->model->channels().front().name == "AI0") { + ChannelBuffer& ch = a->model->live().channel(0); + const u64 n = ch.size(); + const u64 want = static_cast(std::lround(2.0 * s.scan_rate / 5.0)); + if (want > 0 && n > want + 50) { + std::vector v(static_cast(want)); + ch.copy_raw(n - want - 50, n - 50, v.data()); // stay clear of the head the producer is writing + double sum = 0.0; + for (f32 x : v) sum += double(x); + mean = sum / double(v.size()); + } + } + std::fprintf(stderr, "[e1608] calibration: ai0_mean=%.4f expect=0.5000 ok=%d\n", mean, + std::fabs(mean - 0.5) < 0.02 ? 1 : 0); + + std::fprintf(stderr, "[e1608] 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::fprintf(stderr, "[e1608] state: connects=%u message='%s'\n", src->connects(), + src->last_message().c_str()); + std::fprintf(stderr, "[e1608] decode: ok=%d\n", e1608_decode_check() ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Phase 1: the panels, then the three controls (a DAC level, a digital output, the counter reset). +gboolean e1608_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) { + std::fprintf(stderr, "[e1608] no e1608 source (run with --source e1608)\n"); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + const bool up = a->model->stats().connected.load(std::memory_order_relaxed); + + for (const char* id : {"ao", "dio"}) { + const std::string page = std::string("source-") + id; + GtkWidget* content = source_panel_content(a, page.c_str()); + const bool present = + content != nullptr && a->side_stack != nullptr && + adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page.c_str()) != nullptr; + std::fprintf(stderr, "[e1608] panel %s: present=%d sensitive=%d connected=%d\n", id, + present ? 1 : 0, content != nullptr && gtk_widget_get_sensitive(content) ? 1 : 0, + up ? 1 : 0); + } + + if (up) { + // All the stream's signals, in one structural change; the plots do not matter to these readings. + std::vector 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); + + GtkWidget* ao = source_panel_content(a, "source-ao"); + GtkWidget* dio = source_panel_content(a, "source-dio"); + GtkWidget* level = find_named(ao, "e1608-ao0-level"); + GtkWidget* out1 = find_named(dio, "e1608-do1"); + GtkWidget* reset = find_named(dio, "e1608-counter-reset"); + // 2.5 V must differ from the configured initial level, or the spin row notifies nothing. + if (level != nullptr) adw_spin_row_set_value(ADW_SPIN_ROW(level), 2.5); + if (out1 != nullptr) adw_switch_row_set_active(ADW_SWITCH_ROW(out1), TRUE); + if (reset != nullptr) g_signal_emit_by_name(reset, "clicked"); + std::fprintf(stderr, "[e1608] actions: ao0_level=%d do1=%d counter_reset=%d\n", + level != nullptr ? 1 : 0, out1 != nullptr ? 1 : 0, reset != nullptr ? 1 : 0); + } else { + std::fprintf(stderr, "[e1608] actions: skipped (not connected)\n"); + } + std::fflush(stderr); + g_timeout_add(1000, e1608_probe_report, a); + return G_SOURCE_REMOVE; +} + +/// After the mock dropped the link: has the source reconnected, and does the stream move again? +gboolean e1608_probe_resumed(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed); + std::fprintf(stderr, "[e1608] resumed: frames_before=%" G_GUINT64_FORMAT " frames_after=%" G_GUINT64_FORMAT + " ok=%d\n", + g_e1608_frames_before, after, after > g_e1608_frames_before ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +gboolean e1608_probe_reconnect(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + e1608::E1608Source* src = e1608_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + const bool up = a->model->stats().connected.load(std::memory_order_relaxed); + g_e1608_frames_before = a->model->stats().frames.load(std::memory_order_relaxed); + std::fprintf(stderr, "[e1608] reconnect: connects=%u connected=%d ok=%d\n", src->connects(), + up ? 1 : 0, src->connects() >= 2 && up ? 1 : 0); + std::fflush(stderr); + g_timeout_add(500, e1608_probe_resumed, a); + return G_SOURCE_REMOVE; +} +``` + +If the build reports that `ChannelBuffer`, `u64` or `f32` are not found in `ui_probe.cpp`, they are already used unqualified elsewhere in this file (the `[gather]` probe near line 345 uses `u64` and `f32`); copy that file's own spelling. If `copy_raw` is not accessible on `ChannelBuffer`, call it through `const plot::data::Series&` (it is the base class's public virtual). + +- [ ] **Step 4: Add the `e1608` sample to `source_kinds_probe`, and guard its click** + +In `source_kinds_probe`, in the `sample` lambda, extend the chain after the `probe` branch: + +```cpp + } else if (id == "e1608") { + c.set("address", "10.0.0.5"); + c.set("port", "54300"); + c.set("scan_rate", "2000"); + c.set("mode", "diff"); + c.set("ai1", "BIP5V"); + c.set("trigger", "rising"); + c.set("dir1", "o"); + c.set("plot0", "1"); + } +``` +(the existing `}` that closes the `probe` branch moves to the end of this one). The sample has one analog input on, so `valid=1`. + +Then guard the click, which assumed the probe kind's button is the panel's last child: + +```cpp + if (page != nullptr && src != nullptr) { +``` +in place of `if (page != nullptr) {`. (With `--source e1608` the active source is not a `ProbeSource`, so `src` is null and nothing is clicked.) + +- [ ] **Step 5: Build** + +```sh +cmake --build build -j 2>&1 | tee /tmp/e1608-build5.log | grep -E "warning:|error" | head -20 +``` +Expected: no errors; the warning count stays at the baseline (1 incremental). + +- [ ] **Step 6: Run A, no board: the failure path** + +```sh +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 \ + ./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \ + 2>&1 | tee /tmp/e1608-runA.out | grep -E "\[e1608\]|gtk warnings|Gtk-" +``` +Expected, in this order: +``` +[e1608] +[e1608] panel ao: present=1 sensitive=0 connected=0 +[e1608] panel dio: present=1 sensitive=0 connected=0 +[e1608] actions: skipped (not connected) +[e1608] stream: connected=0 signals=0 expect=1 names= frames=0 rate=0.000 ok=0 +[e1608] calibration: ai0_mean=nan expect=0.5000 ok=0 +[e1608] capture button: present=1 sensitive=0 +[e1608] state: connects=0 message='' +[e1608] decode: ok=1 +gtk warnings: 0 +``` +The point of A: the panels exist and are insensitive with no board (disabled, not hidden), the source retries without flooding stderr, and `decode: ok=1` needs no board. Anything else is a defect; `sensitive=1` here means `apply_source_panels` is not reaching the panel, `present=0` means `make_panels` returned nothing. + +- [ ] **Step 7: Run B, the mock: stream, calibration, controls, reconnect** + +```sh +rm -f /tmp/e1608-mock.log +python3 tools/mock_e1608.py --port 54711 --drop-after 3 --log /tmp/e1608-mock.log > /dev/null & MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_PROBE_E1608_RECONNECT=8000 \ + UDPSCOPE_EXIT_AFTER_MS=10500 \ + ./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \ + --source-opt scan_rate=1000 --source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1 \ + --source-opt plot_counter=1 --source-opt dir1=o --source-opt ao0_init=1.5 \ + 2>&1 | tee /tmp/e1608-runB.out | grep -E "\[e1608\]|gtk warnings|Gtk-" +kill $MOCK +``` +(The mock's `--drop-after 3` counts from the first command connection; the app first connects within a fraction of a second, so the link drops at about 3 s, is seen at the next status read, and comes back after the 2 s retry, at about 5.5 s, well before the 8 s reconnect probe.) + +Expected: +``` +[e1608] panel ao: present=1 sensitive=1 connected=1 +[e1608] panel dio: present=1 sensitive=1 connected=1 +[e1608] actions: ao0_level=1 do1=1 counter_reset=1 +[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames= rate=1000.000 ok=1 +[e1608] calibration: ai0_mean=0.50xx expect=0.5000 ok=1 +[e1608] capture button: present=1 sensitive=0 +[e1608] state: connects=1 message='' +[e1608] decode: ok=1 +[e1608] (once, near the 3 s mark) +[e1608] reconnect: connects=2 connected=1 ok=1 +[e1608] resumed: frames_before= frames_after= ok=1 +gtk warnings: 0 +``` +(`names=` lists the enabled channels in signal order, which is `AI0,AI1,DI0,Counter`. `message=` is empty at 2.5 s unless the calibration read was refused, in which case it reads `calibration memory refused; using nominal values` and the calibration line would be `ok=0`: the mock serves the memory, so that would be a defect.) Then the mock's side: + +```sh +python3 - <<'EOF' +import re +log = open('/tmp/e1608-mock.log').read() +ao0 = [float(m) for m in re.findall(r"AOUT ch=0 code=\d+ volts=(-?[0-9.]+)", log)] +print("ao0 :", ao0) +print("dout:", re.findall(r"DOUT (0x[0-9a-f]+)", log)) +for needle in ("COUNTER_RESET", "DROP", "SCAN_START", "STREAM"): + print(needle, log.count(needle)) +EOF +``` +Expected: `ao0 : [1.5, 2.5, 1.5]` (each within 0.01: the initial level, the probe's 2.5 V from the panel, the initial level again after the reconnect), `dout: ['0x00', '0x02', '0x00']` (initial, the Bit 1 switch, re-latched after the reconnect), `COUNTER_RESET 1`, `DROP 1`, `SCAN_START 2`, `STREAM 2`. If `ao0` has no 2.5 entry, the posted command did not reach the board: check `post()` is drained inside the `try` (`E1608Source::poll`), and that the level spin's `notify::value` is connected. If `dout` lacks `0x02`, the `e1608-do1` row was not found (`actions: do1=0` says so) or `dir1=o` did not reach the settings. + +- [ ] **Step 8: Run C, a rejected connection code** + +```sh +python3 tools/mock_e1608.py --port 54711 --code 1234 > /dev/null & MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 \ + ./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \ + --source-opt code=7 2>&1 | tee /tmp/e1608-runC.out | grep -E "\[e1608\]|gtk warnings|Gtk-" +kill $MOCK +``` +Expected: exactly one `[e1608] the board rejected the connection code` line (no repeats: a rejected code stops the retries), the panels `sensitive=0 connected=0`, `stream: connected=0 ... ok=0`, `state: connects=0 message='the board rejected the connection code'`, `gtk warnings: 0`. Repeated rejection lines mean the retry loop was not halted (`fail(..., true)` for `Kind::Rejected`). + +Then the same run with the right code, `--source-opt code=1234` against `--code 1234`, must connect (`stream: ... ok=1`). + +- [ ] **Step 9: The UDPS regression, and the framework probe** + +The UDPS path must read as it did on `main`. Re-run Task 0's self-check against the UDPS mock and compare the distinct lines (the counts that repeat vary with timing, so the sorted unique lines are what is compared): + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/udps-now.raw 2>&1 +kill $MOCK +sed 's/[0-9.]\+/N/g' /tmp/udps-now.raw > /tmp/udps-now.norm +diff <(sort -u /tmp/udps-baseline.norm) <(sort -u /tmp/udps-now.norm) && echo "UDPS self-check unchanged" +grep -c "gtk warnings: 0" /tmp/udps-now.raw +``` +Expected: `UDPS self-check unchanged`, then `1`; no `Gtk-CRITICAL` in `/tmp/udps-now.raw`. Then the framework probe, which now also exercises the `e1608` form: + +```sh +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_PROBE_SOURCE_KINDS=1000 UDPSCOPE_EXIT_AFTER_MS=3000 \ + ./build/udpscope --source probe --source-opt rate=500 2>&1 | grep -E "source-kinds|gtk warnings|Gtk-" +``` +Expected: `kinds=udps,e1608,probe`, `form udps: round_trip=1 valid=1`, `form e1608: round_trip=1 valid=1`, `form probe: round_trip=1 valid=1`, `panel disconnected: panels=1 present=1 sensitive=0`, `panel connected: panels=1 present=1 sensitive=1`, `capture button: present=1 sensitive=0`, `command: before=0 after=1 drained=1`, `gtk warnings: 0`. `round_trip=0` on the e1608 line names a `SourceConfig` key that `Form::write` and `Form::read` disagree about; the sample's keys are `address port scan_rate mode ai1 trigger dir1 plot0`. + +Finally the clean-build warning count: `cmake --build build --clean-first 2>&1 | grep -c "warning:"` must print `3`. + +- [ ] **Step 10: Commit** + +```sh +git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp +git commit -m "e1608: UDPSCOPE_PROBE_E1608 and the e1608 form sample in the source-kinds probe" +``` +Record the commit in the ledger. + +--- +### Task 6: Documentation + +**Files (all modify):** `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `README.md`. + +**Interfaces:** consumes the behaviour and the `[e1608]` lines of Tasks 3 to 5. Produces nothing code depends on. Edit the text to match what the code and the probe **actually printed** in Task 5's runs; where a line below disagrees with a recorded run, the run wins. + +Read each file around the anchor first; the wording below is the content to add, in the voice and format of the surrounding text (these documents state rules and the defect a violation names, not tutorials). + +- [ ] **Step 1: `docs/claude/architecture.md`, "### Sources"** + +After the bullet that ends `... and nothing existing moves.`, append this bullet: + +```markdown +- **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; + `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, the digital + input/counter poll and the 20 ms waveform tick. There is no second thread. A connect attempt can + block `poll()` for up to the command timeout; that delays only the command queue. Every exception is + caught inside `poll()`: an escape would end the producer thread, and `drain_commands()` runs inside + the same `try` for that reason. +- Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not + whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the + first one's. Digital lines and the counter are held across a block (a step trace). +- The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes + and a 100 ms GTK tick reads; a panel never touches the source's internals, and writes only through + `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`, so the + source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per + retry); that line is where a user sees why a connection failed. +``` + +- [ ] **Step 2: `docs/claude/build-and-run.md`** + +After the paragraph that begins `Options: \`--source NAME\``, add: + +```markdown +`--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` (absent = off; no `aiN` at all means +`ai0=BIP10V`), `dir0`..`dir7` `i|o`, `plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). +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, `--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 commands whose layout is unverified. + +```sh +python3 tools/mock_e1608.py --port 54711 & +./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \ + --source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1 +``` +``` + +- [ ] **Step 3: `docs/claude/verification.md`** + +After the `UDPSCOPE_PROBE_SOURCE_KINDS` entry (it ends `... the reading's own entry is in \`probe-readings.md\`.`), add two entries, and change `kinds=` wherever this file quotes it to `udps,e1608,probe`: + +```markdown +- `UDPSCOPE_PROBE_E1608=1500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source e1608` against + `tools/mock_e1608.py` -- the MCC E-1608 end to end: panels present and sensitive, a DAC write, a + digital-output toggle and a counter reset each reaching the mock, the stream's channels and rate, the + factory calibration applied, the capture button disabled, and a hand-computed decode. It enables the + signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS` (that probe switches the first signal off + after two seconds). Three runs, each short and serial, with the mock killed afterwards: + - **no board:** panels `sensitive=0`, `connected=0`, no retry flood on stderr; + - **the mock:** `--drop-after 3 --log /tmp/e1608-mock.log`, options `scan_rate=1000 ai0=BIP10V + ai1=BIP5V plot0=1 plot_counter=1 dir1=o ao0_init=1.5`, with `UDPSCOPE_PROBE_E1608=1500 + UDPSCOPE_PROBE_E1608_RECONNECT=8000 UDPSCOPE_EXIT_AFTER_MS=10500`; then read the mock's log: the AO + levels `1.5, 2.5, 1.5`, the 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_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`. +``` + +- [ ] **Step 4: `docs/claude/probe-readings.md`** + +(a) In the `## \`[source-kinds]\`` entry, change the first output line to `[source-kinds] kinds=udps,e1608,probe`, add the line `[source-kinds] form e1608: round_trip=1 valid=1` between the `udps` and `probe` form lines, change the bullet `kinds=udps,probe` to `kinds=udps,e1608,probe`, and add to the `round_trip=0` bullet: `The e1608 form's sample sets address, port, scan_rate, mode, ai1, trigger, dir1 and plot0; a mismatch names one of those.` + +(b) After that section (before `## \`[prefs-probe]\``), add a new section: + +````markdown +## `[e1608]` + +``` +[e1608] panel ao: present=1 sensitive=1 connected=1 +[e1608] panel dio: present=1 sensitive=1 connected=1 +[e1608] actions: ao0_level=1 do1=1 counter_reset=1 +[e1608] stream: connected=1 signals=4 expect=4 names=AI0,AI1,DI0,Counter frames=300 rate=1000.000 ok=1 +[e1608] calibration: ai0_mean=0.5002 expect=0.5000 ok=1 +[e1608] capture button: present=1 sensitive=0 +[e1608] state: connects=1 message='' +[e1608] decode: ok=1 +[e1608] reconnect: connects=2 connected=1 ok=1 +[e1608] resumed: frames_before=2900 frames_after=2950 ok=1 +``` + +`UDPSCOPE_PROBE_E1608=1500` (the first eight lines; `frames=` and the exact mean vary) and +`UDPSCOPE_PROBE_E1608_RECONNECT=8000` (the last two), run as `--source e1608` against `tools/mock_e1608.py` +with the options in `verification.md`. `connected=0` throughout, with `actions: skipped (not connected)`, is the +no-board run; it is a reading and not a failure. + +- `panel ...: present=1 sensitive=`: **disable, don't hide.** `present=0` is a panel that was never + added; `sensitive` that differs from `connected` is `apply_source_panels` acting on the wrong widget. +- `actions: ao0_level=1 do1=1 counter_reset=1`: each control was found by its widget name (`e1608-ao0-level`, + `e1608-do1`, `e1608-counter-reset`). A `0` is a renamed widget, or (`do1`) `dir1=o` missing from the run. The + effects are read off the **mock's log**, not here: a DAC write near 2.5 V (within 0.01), `DOUT 0x02`, `COUNTER_RESET`. + A control that was found but whose effect is missing is a command that `post()` did not deliver or `poll()` + did not run. +- `stream: signals=4 expect=4 rate=1000.000 ok=1`: the enabled channels plus the plotted digital lines plus the + counter, at the scan rate. `signals` below `expect` is a signal the source did not declare; `rate` off by a + factor is the pacer computed from the wrong aggregate. +- `calibration: ai0_mean=0.50xx ok=1`: the mock's AI0 is 0.5 V under a 5 Hz sine of 0.1 V, read through + non-trivial factory coefficients; the mean over two periods lands on 0.5 V only when `decode()` applies them. + About `0.2043` is the **uncalibrated** reading (the calibration read failed, or `calibrate=0`); `nan` is a + run that never got AI0 (not connected, or AI0 not enabled). +- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI disabled, not hidden. +- `state: connects=1 message=''`: a non-empty message at this point is a refusal that happened (for example + `calibration memory refused; using nominal values`), which the stream then ran through. +- `decode: ok=1`: a fixed raw buffer against hand-computed volts (intercept 100, BIP10V and BIP5V, the code + clamp at both ends). It needs no board; `ok=0` is a change to the calibration arithmetic. +- `reconnect: connects=2 connected=1`, `resumed: ok=1`: the mock dropped both links at about 3 s; the source + reported it, retried after 2 s and the frame count moved on. `connects=1` is a source that never noticed the + drop (the status check does not run), `resumed: ok=0` is a reconnect that did not restart the scan. +```` + +- [ ] **Step 5: `README.md`, "## Data sources"** + +(a) After the paragraph ending `... only when \`UDPSCOPE_PROBE_SOURCE_KINDS\` is set, and exists only for the self-check.`, add: + +```markdown +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 +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 appear while it runs: **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). 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. + +**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 trigger option bits (none 0, rising 4, +falling 8, high 12, low 16), the counter reset command (0x31), the calibration memory layout and its +coefficient type, the DIFF input numbering (channel + 8) and the status word's bits, in particular whether the +"scan running" bit stays set while the board waits for a trigger (the source restarts a scan that reads as stopped +when auto-restart is on). The scan overrun count is approximate: the board does not say how many scans it +dropped, so the frame after a restart reports one lost. Errors (no device, a rejected connection code, a +timeout, the scan socket closing) are written to stderr as `[e1608] ...` once per distinct message; the window itself shows them as the +connection status. +``` + +Check the last sentence against what the window actually does with an `Error` event before keeping it: `Acquisition::on_event` ignores the detail string, so if the window shows nothing but `Disconnected`, change "the window itself shows them as the connection status" to "the window shows only that the source is disconnected". + +(b) Replace the paragraph `Planned, each as its own sub-project: a flat UDP kind, the MCC E-1608 and ABScope. ABScope has ...` with the same sentence minus the E-1608: `Planned, each as its own sub-project: a flat UDP kind and ABScope. ABScope has more than one timebase, so it will change the frame and the data path together rather than fit the present single-timebase frame.` (Keep the original continuation of the sentence word for word.) + +(c) In README's source-tree listing (the block that lists `src/layout_store.*` and its neighbours), add the line `src/source/e1608/ the MCC E-1608 kind: link, device, source, connect form and panels` beside the existing `src/source/` entry, if one exists; if the listing has no `src/source/` line, skip this item. + +- [ ] **Step 6: Re-read the baselines, then commit** + +```sh +cmake --build build -j 2>&1 | grep -c "warning:" # no source changed in this task: the incremental baseline, 0 or 1 +git diff --stat +git add docs/claude/architecture.md docs/claude/build-and-run.md docs/claude/verification.md docs/claude/probe-readings.md README.md +git commit -m "docs: the E-1608 source kind, its probe readings and its unverified commands" +``` +Record the commit in the ledger. + +--- + +## Closing report (for the final message to the user, not a task) + +After the final whole-branch review passes, report in this order, briefly: + +1. What was built (the three layers, the two panels, the mock, the probe) and the commit range on `feature/source-e1608`. +2. **The deviations from the spec**, each with its reason: + 1. The waveform generator ticks every 20 ms; "10 Hz at most" is applied to the waveform **frequency** (0.1 to 10 Hz), not to the write rate. + 2. `valid()` accepts a MAC with an empty subnet (it means "broadcast discovery"); the spec said a MAC plus a subnet. + 3. `valid()` also requires at least one enabled analog input; with no `aiN` key at all, `ai0=BIP10V` is the default, so a bare `--source-opt address=...` connects. + 4. A nonzero reply status is `LinkError::Kind::Command`, distinct from `Protocol`; the calibration read swallows only `Command`, so a board that refuses the calibration memory runs on nominal values while a broken link still fails. + 5. Errors are also written to stderr as `[e1608] ...`, because `Acquisition::on_event` ignores the detail string and does nothing for `Error`. + 6. The two panels live in `e1608_panels.cpp`, not in `e1608_kind.cpp` as the spec's file list says, to keep each file to one responsibility. +3. **The unverified-command list** from the README paragraph in Task 6 (it is the same list as the mock's header), and that no real board has been run. +4. That nothing was pushed or merged; `feature/source-e1608` is branched from the unmerged `feature/source-plugins`. diff --git a/docs/superpowers/plans/2026-10-31-source-plugins.md b/docs/superpowers/plans/2026-10-31-source-plugins.md new file mode 100644 index 0000000..6d28036 --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-source-plugins.md @@ -0,0 +1,2277 @@ +# Source Plugins: Framework and UDPS Port 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 udpscope read from pluggable data sources behind one `DataSource` interface, with the existing UDPStreamer client ported onto it and no change in UDPS behaviour. + +**Architecture:** A compiled-in `SourceRegistry` of `SourceKind` factories. `Acquisition` owns one `DataSource` and still runs the single producer thread, calling `source->poll(50)`; the GTK thread reaches the source only through `post(Command)`, a mutex-guarded queue drained inside `poll()`. A source-neutral `Frame` replaces `udps_frame_t`; each kind supplies a config form (`ConfigForm`) for the chooser and optional capability panels that become pages of the existing `side_stack`. + +**Tech Stack:** C++17, GTK4 + libadwaita, CMake, `deps/udpsclient` (wrapped, unchanged), `deps/plotter`. Python mock `tools/mock_udpstreamer.py`. + +**Spec:** `docs/superpowers/specs/2026-10-31-source-plugins-design.md` (Task 0 amends it with the implementation details settled here). + +## Global Constraints + +- There is **no unit test suite.** The only automated check is the headless self-check under `xvfb-run` against `tools/mock_udpstreamer.py`; extend the probes in `src/ui_probe.cpp` rather than adding a harness. Read `docs/claude/verification.md` and `docs/claude/probe-readings.md` before running one. +- A clean full build has **exactly three warnings**; an incremental build shows one. The run must show `gtk warnings: 0` and no `Gtk-CRITICAL`. +- Every new source file starts with `// SPDX-License-Identifier: MIT` and `// SPDX-FileCopyrightText: 2026 Martino Ferrari`. +- Exactly two threads: the producer (owns the source) and the GTK main thread. UI code never touches a `DataSource` directly except `post()`. +- UDPS behaviour must not change: `--server/--port/--multicast/--interface` keep meaning UDPS; legacy `host_port.toml` session files are read as `udps`; nothing is migrated destructively. +- Disable, don't hide: a panel for a disconnected source is insensitive, not hidden. +- GTK traps (see `docs/claude/invariants-gtk-and-chrome.md`): `notify::` handlers take `(GObject*, GParamSpec*, gpointer)`; probes click with `g_signal_emit_by_name(w, "clicked")`. +- Verification runs are short and serial; kill the mock afterwards; never point a run at the real `~/.config` (use `XDG_CONFIG_HOME=$(mktemp -d)`). +- Mock: `python3 tools/mock_udpstreamer.py --port 8000 &`. Self-check: `xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 ./build/udpscope --server 127.0.0.1 --port 8000` (dump on stdout, probes on stderr). +- Work happens on branch `feature/source-plugins`. Commit from the repo root. + +## File Structure + +New (all under `src/source/` unless noted): + +| File | Responsibility | +| --- | --- | +| `source_config.hpp/.cpp` | `SourceConfig`: ordered string key/value map with typed getters. | +| `frame.hpp` | Source-neutral `Frame` and `SignalValues` (header-only). | +| `data_source.hpp/.cpp` | `Command`, `SourceStats`, `SourceEvent`, `SourceSink`, `DatagramTap`, `DataSource` (command queue). | +| `source_kind.hpp` | `ConfigForm`, `SourcePanel`, `SourceKind` (includes GTK). | +| `registry.hpp/.cpp` | Registry functions, `register_builtin_sources()`, session-key helpers. | +| `udps_source.hpp/.cpp` | `UdpsSource`: wraps `deps/udpsclient`. | +| `udps_kind.cpp` | `UdpsKind`: id, identity, config form. | +| `probe_source.hpp/.cpp` | Test-only `probe` kind with one panel (registered only when the probe env var is set). | +| `src/ui_source.cpp` | Builds and syncs capability panels in the side stack. | + +Modified: `src/model.hpp`, `src/model.cpp`, `src/acquisition.hpp/.cpp`, `src/config.hpp/.cpp`, `src/main.cpp`, `src/layout_store.hpp/.cpp`, `src/source_dialog.cpp`, `src/ui/source-form.ui`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, and the docs. + +--- + +## Task 0: Baseline and spec amendment + +**Files:** +- Modify: `docs/superpowers/specs/2026-10-31-source-plugins-design.md` +- Create (outside repo): `/tmp/udps-baseline.out`, `/tmp/udps-baseline.err` + +**Interfaces:** Produces the baseline files every later UDPS check is compared against. + +- [ ] **Step 1: Build main's behaviour (the current branch has no code changes yet)** + +Run: `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:"` +Expected: a number (3 on a clean build, 1 if incremental). Note it. + +- [ ] **Step 2: Capture the baseline self-check** + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-baseline.out 2>/tmp/udps-baseline.err +kill $MOCK +grep -c "Gtk-CRITICAL" /tmp/udps-baseline.err; grep "gtk warnings" /tmp/udps-baseline.err +``` +Expected: `0`, then `gtk warnings: 0`. + +- [ ] **Step 2b: Capture the chooser baseline** + +The chooser's form probes (see `docs/claude/probe-readings.md`, "The chooser's form") are the reference for Task 5. Same fresh config dir for all three, mock running: + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +CFGB=$(mktemp -d) +echo "$CFGB" >/tmp/chooser-cfg-dir +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-baseline-new.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_EDITHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-baseline-edit.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGB UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>/tmp/chooser-baseline-list.err >/dev/null +kill $MOCK +grep -h "^\[sources\]" /tmp/chooser-baseline-new.err /tmp/chooser-baseline-edit.err /tmp/chooser-baseline-list.err +``` +Expected: `form mnemonics=8/8`, `form: name='probe-host' ... connect_sensitive=1`, `configuration created: 127.0.0.1:8000/probe-host`, `edit form: ... button='Save'`, `configuration saved`, and one `known:` row. These are the lines Task 5 must reproduce (apart from `8/8` becoming `9/9`, because the new Source type row has a mnemonic too). + +- [ ] **Step 3: Amend the spec** + +Edit the spec's Decisions/Design text to say: +- the new probe is `UDPSCOPE_PROBE_SOURCE_KINDS` (the name `UDPSCOPE_PROBE_SOURCES` is the chooser discovery probe), its value is a delay in ms, and its presence also registers the stub `probe` kind; +- `Command` is `std::function`; +- session keys are top-level TOML keys (`source_kind`, `source_opt_`), written only for non-UDPS kinds, not a `[source]` table; non-UDPS session files are named by a pseudo-host `-` with port 1; +- `Acquisition` creates its source lazily in `start()`; +- `Frame` holds pointers into the source's buffers, valid only during `on_data`; +- `make_config_form()` takes no argument (the chooser calls `form.write(cfg)` to fill it); `ConfigForm` is a list of `AdwPreferencesRow` widgets plus `write`/`read`/`valid`/`on_change` closures (so the chooser can place them in its own group); +- panels become pages of the existing `side_stack`. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/specs/2026-10-31-source-plugins-design.md +git commit -m "docs: settle source-plugin implementation details in the spec" +``` + +--- + +## Task 1: Core types, registry, neutral constants + +Compile-only: nothing uses the new code yet, so behaviour cannot change. + +**Files:** +- Create: `src/source/source_config.hpp`, `src/source/source_config.cpp`, `src/source/frame.hpp`, `src/source/data_source.hpp`, `src/source/data_source.cpp`, `src/source/source_kind.hpp`, `src/source/registry.hpp`, `src/source/registry.cpp` +- Modify: `src/model.hpp` (constants), `src/model.cpp:649`, `CMakeLists.txt` + +**Interfaces:** +- Produces (all in `namespace udpscope`): + - `SourceConfig` with `using Items = std::vector>;`, `explicit SourceConfig(Items)`, `void set(const std::string&, const std::string&)`, `bool has(const std::string&) const`, `std::string get(const std::string&, const std::string& def = {}) const`, `long get_int(key, long def) const`, `double get_double(key, double def) const`, `bool get_bool(key, bool def) const`, `bool parse_assignment(const std::string& kv)`, `const Items& items() const`, `bool empty() const`, `static bool valid_key(const std::string&)`. + - `Frame`, `SignalValues`, `Command`, `SourceStats`, `SourceEvent`, `SourceSink`, `DatagramTap`, `DataSource`, `ConfigForm`, `SourcePanel`, `SourceKind`. + - Registry: `void register_kind(std::unique_ptr)`, `SourceKind* find_kind(const std::string&)`, `const std::vector& kinds()`, `void register_builtin_sources()`, `std::string session_host(const std::string& kind, const SourceConfig&)`, `std::uint16_t session_port(const std::string& kind, const SourceConfig&)`. + - `model.hpp`: `constexpr std::uint8_t kTimePacket = 0, kTimeFullArray = 1, kTimeFirstSample = 2, kTimeLastSample = 3;` + +- [ ] **Step 1: `source_config.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include + +namespace udpscope { + +/// A source's settings: an ordered string key/value map. It is the stored form (top-level +/// `source_opt_` entries of a session), the `--source-opt k=v` form, and what a config form +/// reads and writes. Order is insertion order, so a stored session stays diff-friendly. +class SourceConfig { + public: + using Items = std::vector>; + + SourceConfig() = default; + explicit SourceConfig(Items items) : items_(std::move(items)) {} + + /// Replaces the value in place if the key exists, else appends. + void set(const std::string& key, const std::string& value); + bool has(const std::string& key) const; + std::string get(const std::string& key, const std::string& def = {}) const; + long get_int(const std::string& key, long def) const; + double get_double(const std::string& key, double def) const; + /// 1/true/yes/on and 0/false/no/off (case-insensitive); anything else gives `def`. + bool get_bool(const std::string& key, bool def) const; + + /// Parses `k=v`; false (and no change) if there is no `=` or the key is not `valid_key`. + bool parse_assignment(const std::string& kv); + + const Items& items() const noexcept { return items_; } + bool empty() const noexcept { return items_.empty(); } + + /// `[A-Za-z0-9_]+` -- the characters that survive as a TOML bare key suffix. + static bool valid_key(const std::string& key); + + private: + Items items_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `source_config.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +void SourceConfig::set(const std::string& key, const std::string& value) { + for (auto& kv : items_) { + if (kv.first == key) { + kv.second = value; + return; + } + } + items_.emplace_back(key, value); +} + +bool SourceConfig::has(const std::string& key) const { + return std::any_of(items_.begin(), items_.end(), + [&](const auto& kv) { return kv.first == key; }); +} + +std::string SourceConfig::get(const std::string& key, const std::string& def) const { + for (const auto& kv : items_) { + if (kv.first == key) return kv.second; + } + return def; +} + +long SourceConfig::get_int(const std::string& key, long def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const long n = std::strtol(v.c_str(), &end, 10); + return (end == v.c_str() || *end != '\0') ? def : n; +} + +double SourceConfig::get_double(const std::string& key, double def) const { + if (!has(key)) return def; + const std::string v = get(key); + char* end = nullptr; + const double d = std::strtod(v.c_str(), &end); + return (end == v.c_str() || *end != '\0') ? def : d; +} + +bool SourceConfig::get_bool(const std::string& key, bool def) const { + if (!has(key)) return def; + std::string v = get(key); + std::transform(v.begin(), v.end(), v.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (v == "1" || v == "true" || v == "yes" || v == "on") return true; + if (v == "0" || v == "false" || v == "no" || v == "off") return false; + return def; +} + +bool SourceConfig::valid_key(const std::string& key) { + return !key.empty() && std::all_of(key.begin(), key.end(), [](unsigned char c) { + return std::isalnum(c) || c == '_'; + }); +} + +bool SourceConfig::parse_assignment(const std::string& kv) { + const auto eq = kv.find('='); + if (eq == std::string::npos) return false; + const std::string key = kv.substr(0, eq); + if (!valid_key(key)) return false; + set(key, kv.substr(eq + 1)); + return true; +} + +} // namespace udpscope +``` + +- [ ] **Step 3: `frame.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "model.hpp" + +#include + +namespace udpscope { + +/// One signal's values in a frame. `values` points into the source's own buffer and is valid +/// only while the `on_data` call that received the frame is running. +struct SignalValues { + const double* values = nullptr; + std::uint32_t count = 0; +}; + +/// A data frame in source-neutral form; it mirrors `udps_frame_t`. A source with no packet +/// counter supplies a running one and `lost = 0`: the hole, backfill and time-base logic only +/// needs `counter`, `lost` and `late`. +struct Frame { + std::uint32_t counter = 0; + std::uint32_t lost = 0; + bool late = false; + double recv_time = 0.0; + std::uint8_t publish_mode = 0; + std::uint32_t num_samples = 0; + std::uint32_t num_signals = 0; + const SignalInfo* signals = nullptr; + const SignalValues* values = nullptr; + + /// Element `elem` of signal `sig` at sample `sample`. Mirrors `udps_frame_value`: a scalar + /// signal carried once per sample is indexed by `sample`, a vector/matrix signal by `elem`. + /// Out of range reads 0.0. + double value(std::uint32_t sig, std::uint32_t sample, std::uint32_t elem) const noexcept { + if (sig >= num_signals) return 0.0; + const SignalValues& v = values[sig]; + const std::uint32_t idx = + (v.count == num_samples && signals[sig].elements() == 1u) ? sample : elem; + return idx < v.count ? v.values[idx] : 0.0; + } +}; + +} // namespace udpscope +``` + +(`Frame::value` is `udps_frame_value` from `deps/udpsclient/udps_client.c:346` with the pointer replaced by `this`; keep the two identical.) + +- [ ] **Step 4: `data_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/frame.hpp" + +#include +#include +#include +#include +#include +#include + +namespace udpscope { + +/// Work for the producer thread. UI code posts these; `poll()` drains and runs them, so a +/// source's state is only ever touched by the thread that owns it. +using Command = std::function; + +/// The counters `StreamStats` shows, in source-neutral form. Producer thread only. +struct SourceStats { + std::uint64_t packets = 0; + std::uint64_t frames = 0; + std::uint64_t gaps = 0; + std::uint64_t late = 0; + std::uint64_t resequenced = 0; + std::uint64_t stale = 0; + std::uint64_t overflows = 0; + std::uint64_t reconnects = 0; + bool connected = false; +}; + +enum class SourceEvent { Connected, Disconnected, Error }; + +/// Where a source delivers, from inside `poll()`. +class SourceSink { + public: + virtual ~SourceSink() = default; + /// The signal set (re)defined. `publish_mode` is the stream's own publish mode. + virtual void on_config(const std::vector& signals, std::uint8_t publish_mode) = 0; + virtual void on_data(const Frame& frame) = 0; + virtual void on_event(SourceEvent event, const char* message) = 0; +}; + +/// Receives each raw datagram of a UDP-based source: bytes, length, receive time. +using DatagramTap = std::function; + +/// The per-connection object. `Acquisition` owns one and drives it from the producer thread. +class DataSource { + public: + virtual ~DataSource() = default; + + /// Allocates and connects. Throws `std::runtime_error` with a message fit for the user. + /// Does not deliver: CONFIG and `Connected` arrive from `poll()`. + virtual void open(SourceSink& sink) = 0; + /// Runs queued commands, then waits up to `timeout_ms` for work and delivers it. + virtual void poll(int timeout_ms) = 0; + virtual void close() = 0; + /// Producer thread only. + virtual SourceStats stats() const = 0; + virtual bool connected() const = 0; + /// Optional: UDP-based sources feed the raw-packet capture through this. + virtual void set_datagram_tap(DatagramTap) {} + + /// Thread-safe. The command runs inside a later `poll()`. + void post(Command cmd); + + protected: + /// For `poll()` implementations: runs and clears the queue. + void drain_commands(); + + private: + std::mutex cmd_mu_; + std::deque cmds_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 5: `data_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/data_source.hpp" + +#include + +namespace udpscope { + +void DataSource::post(Command cmd) { + std::lock_guard lk(cmd_mu_); + cmds_.push_back(std::move(cmd)); +} + +void DataSource::drain_commands() { + std::deque local; + { + std::lock_guard lk(cmd_mu_); + local.swap(cmds_); + } + for (auto& c : local) c(); +} + +} // namespace udpscope +``` + +- [ ] **Step 6: `source_kind.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { + +/// A kind's configuration UI, as pieces the chooser places in its own group. The widgets are +/// floating `AdwPreferencesRow`s; the closures capture them, so a form must not outlive its rows +/// (the chooser drops the form when it removes the rows). +struct ConfigForm { + std::vector rows; + /// Shown under the group's title; empty for none. + std::string description; + /// The widget that takes focus when the form is shown; may be null. + GtkWidget* focus = nullptr; + /// Puts `cfg` into the widgets (absent keys leave the kind's defaults). + std::function write; + /// The widgets as a `SourceConfig`. + std::function read; + /// Whether the form is complete enough to connect. + std::function valid; + /// Calls `fn` whenever any widget changes. + std::function)> on_change; +}; + +/// A capability panel (signal generator, digital I/O, ...) for the side area. +struct SourcePanel { + std::string id; ///< stack page name + std::string title; + std::string icon; ///< icon name for the tab, may be empty + GtkWidget* widget = nullptr; +}; + +/// One plugin type: its identity, factory and UI. +class SourceKind { + public: + virtual ~SourceKind() = default; + virtual std::string id() const = 0; ///< short, `[a-z0-9]+`; the CLI and session name + virtual std::string display_name() const = 0; + /// A stable, human-readable name for one configured source (`host:port`, a serial number). + virtual std::string identity(const SourceConfig& cfg) const = 0; + virtual std::unique_ptr make(const SourceConfig& cfg) const = 0; + virtual ConfigForm make_config_form() const = 0; + /// Panels for a live source. The widgets must reach the source only through `post()`. + virtual std::vector make_panels(DataSource& source) const { + (void)source; + return {}; + } + /// Whether the source reports raw datagrams (enables the packet-capture UI). + virtual bool has_datagrams() const { return false; } +}; + +} // namespace udpscope +``` + +- [ ] **Step 7: `registry.hpp` and `registry.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/source_kind.hpp" + +#include +#include +#include +#include + +namespace udpscope { + +/// Takes ownership. A second kind with the same id is ignored. +void register_kind(std::unique_ptr kind); +SourceKind* find_kind(const std::string& id); +/// In registration order (the chooser's dropdown order). +const std::vector& kinds(); +/// Registers the built-in kinds; idempotent. No static-init self-registration, which a linker +/// may drop. A later `dlopen` loader would only call `register_kind()`. +void register_builtin_sources(); + +/// The built-in kinds, each defined in its own file (`udps_kind.cpp`, `probe_source.cpp`). +std::unique_ptr make_udps_kind(); +std::unique_ptr make_probe_kind(); + +/// The (host, port) pair that names a source's session file. UDPS keeps its legacy +/// `server`/`port`; any other kind is `"-"` (characters outside `[A-Za-z0-9._-]` +/// become `_`) on port 1 -- not 0, which the session-file parser rejects and waveform mode uses. +std::string session_host(const std::string& kind_id, const SourceConfig& cfg); +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg); + +} // namespace udpscope +``` + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" + +#include +#include + +namespace udpscope { +namespace { + +std::vector>& owned() { + static std::vector> v; + return v; +} +std::vector& raw() { + static std::vector v; + return v; +} + +std::string sanitize(const std::string& s) { + std::string out = s; + for (char& c : out) { + const bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-'; + if (!ok) c = '_'; + } + return out; +} + +} // namespace + +void register_kind(std::unique_ptr kind) { + if (!kind || find_kind(kind->id())) return; + raw().push_back(kind.get()); + owned().push_back(std::move(kind)); +} + +SourceKind* find_kind(const std::string& id) { + for (SourceKind* k : raw()) { + if (k->id() == id) return k; + } + return nullptr; +} + +const std::vector& kinds() { return raw(); } + +void register_builtin_sources() { + static bool done = false; + if (done) return; + done = true; + register_kind(make_udps_kind()); + // The stub kind exists for the headless source-kinds probe only; a normal run never sees it. + if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind()); +} + +std::string session_host(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") return cfg.get("server", "127.0.0.1"); + SourceKind* k = find_kind(kind_id); + return sanitize(kind_id + "-" + (k ? k->identity(cfg) : std::string("unknown"))); +} + +std::uint16_t session_port(const std::string& kind_id, const SourceConfig& cfg) { + if (kind_id == "udps") { + const long p = cfg.get_int("port", 8000); + return static_cast(p > 0 && p <= 65535 ? p : 8000); + } + return 1; +} + +} // namespace udpscope +``` + +`make_udps_kind()` (Task 2) and `make_probe_kind()` (Task 6) are declared in `registry.hpp` but not defined yet, so in this task `register_builtin_sources()` must not call them: write its body as only `static bool done = false; if (done) return; done = true;` followed by the two comment lines `// Task 2: register_kind(make_udps_kind());` and `// Task 6: register make_probe_kind() when UDPSCOPE_PROBE_SOURCE_KINDS is set`. Task 2 Step 6 and Task 6 Step 3 replace them with the real calls. + +- [ ] **Step 8: Neutral time-mode constants** + +In `src/model.hpp`, inside `namespace udpscope`, above `struct SignalInfo`: + +```cpp +/// `SignalInfo::time_mode` values. They equal the wire protocol's `UDPS_TIME_*` +/// (`udps_source.cpp` static_asserts it), but the model must not name a source's own header. +constexpr std::uint8_t kTimePacket = 0; +constexpr std::uint8_t kTimeFullArray = 1; +constexpr std::uint8_t kTimeFirstSample = 2; +constexpr std::uint8_t kTimeLastSample = 3; +``` + +In `src/model.cpp:649` replace `UDPS_TIME_FULL_ARRAY` with `kTimeFullArray`. Check line 6 of `model.cpp` (`#include ` or similar): remove it only if nothing else in `model.cpp` needs a `udps_*` name (`grep -n "udps_\|UDPS_" src/model.cpp`). + +- [ ] **Step 9: CMake** + +In `CMakeLists.txt`, add after `src/acquisition.cpp`: + +``` + src/source/source_config.cpp + src/source/data_source.cpp + src/source/registry.cpp +``` + +- [ ] **Step 10: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; warning count equal to Step 1 of Task 0. + +- [ ] **Step 11: Commit** + +```bash +git add src/source src/model.hpp src/model.cpp CMakeLists.txt +git commit -m "source: DataSource, Frame, SourceConfig and the registry (unused yet)" +``` + +--- + +## Task 2: The UDPS source and kind + +Wraps `deps/udpsclient` unchanged. Compiled and registered, but `Acquisition` does not use it until Task 3, so the running program is still unchanged. + +**Files:** +- Create: `src/source/udps_source.hpp`, `src/source/udps_source.cpp`, `src/source/udps_kind.cpp` +- Modify: `src/source/registry.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: Task 1's `DataSource`, `SourceSink`, `Frame`, `SourceKind`, `ConfigForm`, `SourceConfig`, `kTime*`. +- Produces: `class UdpsSource final : public DataSource` constructed from a `SourceConfig` with keys `server` (default `127.0.0.1`), `port` (8000), `multicast`, `interface`, `recv_buffer` (8388608); `std::unique_ptr make_udps_kind()` with id `"udps"`, display name `"UDPStreamer"`, identity `":"`, `has_datagrams() == true`. + +- [ ] **Step 1: `udps_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include + +#include +#include + +namespace udpscope { + +/// The UDPStreamer client behind the `DataSource` interface. A thin adapter: it maps +/// `udps_signal_t`/`udps_frame_t` to `SignalInfo`/`Frame` and forwards the datagram observer; +/// all protocol work stays in `deps/udpsclient`. +class UdpsSource final : public DataSource { + public: + explicit UdpsSource(const SourceConfig& cfg); + ~UdpsSource() override; + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override; + void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); } + + private: + static void config_cb(const udps_signal_t* signals, std::uint32_t count, + std::uint8_t publish_mode, void* user); + static void data_cb(const udps_frame_t* frame, void* user); + static void event_cb(udps_event_t event, const char* detail, void* user); + static void datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time, + void* user); + + std::string server_; + std::uint16_t port_ = 8000; + std::string multicast_; + std::string interface_; + int recv_buffer_ = 8 * 1024 * 1024; + + udps_client_t* client_ = nullptr; + SourceSink* sink_ = nullptr; + DatagramTap tap_; + std::vector signals_; + std::vector values_; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `udps_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/udps_source.hpp" + +#include + +namespace udpscope { +namespace { + +static_assert(kTimePacket == UDPS_TIME_PACKET && kTimeFullArray == UDPS_TIME_FULL_ARRAY && + kTimeFirstSample == UDPS_TIME_FIRST_SAMPLE && + kTimeLastSample == UDPS_TIME_LAST_SAMPLE, + "model.hpp's kTime* must equal the wire protocol's UDPS_TIME_*"); + +SignalInfo to_info(const udps_signal_t& s) { + SignalInfo info; + info.name = s.name; + info.unit = s.unit; + info.type_code = s.type_code; + info.quant_type = s.quant_type; + info.rows = s.num_rows; + info.cols = s.num_cols; + info.range_min = s.range_min; + info.range_max = s.range_max; + info.time_mode = s.time_mode; + info.sampling_rate = s.sampling_rate; + info.time_signal_idx = s.time_signal_idx; + return info; +} + +} // namespace + +UdpsSource::UdpsSource(const SourceConfig& cfg) + : server_(cfg.get("server", "127.0.0.1")), + port_(static_cast(cfg.get_int("port", 8000))), + multicast_(cfg.get("multicast")), + interface_(cfg.get("interface")), + recv_buffer_(static_cast(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {} + +UdpsSource::~UdpsSource() { close(); } + +void UdpsSource::open(SourceSink& sink) { + close(); + sink_ = &sink; + udps_client_config_t c; + udps_client_config_init(&c); + c.server_addr = server_.c_str(); + c.server_port = port_; + c.multicast_group = multicast_.empty() ? nullptr : multicast_.c_str(); + c.interface_addr = interface_.empty() ? nullptr : interface_.c_str(); + c.recv_buffer_bytes = static_cast(recv_buffer_); + c.silence_timeout_s = 2.0; + c.reconnect_delay_s = 0.5; + c.keepalive_interval_s = 1.0; + + client_ = udps_client_create(&c); + if (client_ == nullptr) { + throw std::runtime_error("could not create the UDPS client for " + server_ + ":" + + std::to_string(port_)); + } + udps_client_set_callbacks(client_, &UdpsSource::config_cb, &UdpsSource::data_cb, + &UdpsSource::event_cb, this); + udps_client_set_datagram_cb(client_, &UdpsSource::datagram_cb, this); +} + +void UdpsSource::poll(int timeout_ms) { + drain_commands(); + if (client_ != nullptr) udps_client_poll(client_, timeout_ms); +} + +void UdpsSource::close() { + if (client_ != nullptr) { + udps_client_destroy(client_); + client_ = nullptr; + } +} + +SourceStats UdpsSource::stats() const { + SourceStats out; + if (client_ == nullptr) return out; + udps_stats_t s; + udps_client_stats(client_, &s); + out.packets = s.packets_received; + out.frames = s.frames_delivered; + out.gaps = s.counter_gaps; + out.late = s.late_delivered; + out.resequenced = s.late_resequenced; + out.stale = s.stale_packets; + out.overflows = s.reorder_overflows; + out.reconnects = s.reconnects; + out.connected = udps_client_is_connected(client_) != 0; + return out; +} + +bool UdpsSource::connected() const { + return client_ != nullptr && udps_client_is_connected(client_) != 0; +} + +void UdpsSource::config_cb(const udps_signal_t* signals, std::uint32_t count, + std::uint8_t publish_mode, void* user) { + auto* self = static_cast(user); + self->signals_.clear(); + self->signals_.reserve(count); + for (std::uint32_t i = 0; i < count; ++i) self->signals_.push_back(to_info(signals[i])); + self->sink_->on_config(self->signals_, publish_mode); +} + +void UdpsSource::data_cb(const udps_frame_t* f, void* user) { + auto* self = static_cast(user); + // The frame's signals are the CONFIG's; the cache is rebuilt only if a frame ever disagrees. + if (self->signals_.size() != f->num_signals) { + self->signals_.clear(); + for (std::uint32_t i = 0; i < f->num_signals; ++i) { + self->signals_.push_back(to_info(f->signals[i])); + } + } + self->values_.resize(f->num_signals); + for (std::uint32_t i = 0; i < f->num_signals; ++i) { + self->values_[i].values = f->values[i].values; + self->values_[i].count = f->values[i].count; + } + Frame fr; + fr.counter = f->counter; + fr.lost = f->lost; + fr.late = f->late != 0; + fr.recv_time = f->recv_time; + fr.publish_mode = f->publish_mode; + fr.num_samples = f->num_samples; + fr.num_signals = f->num_signals; + fr.signals = self->signals_.data(); + fr.values = self->values_.data(); + self->sink_->on_data(fr); +} + +void UdpsSource::event_cb(udps_event_t event, const char* detail, void* user) { + auto* self = static_cast(user); + SourceEvent e = SourceEvent::Error; + if (event == UDPS_EVENT_CONNECTED) e = SourceEvent::Connected; + else if (event == UDPS_EVENT_DISCONNECTED) e = SourceEvent::Disconnected; + self->sink_->on_event(e, detail); +} + +void UdpsSource::datagram_cb(const std::uint8_t* bytes, std::size_t len, double recv_time, + void* user) { + auto* self = static_cast(user); + if (self->tap_) self->tap_(bytes, len, recv_time); +} + +} // namespace udpscope +``` + +- [ ] **Step 3: `udps_kind.cpp`, the kind and its form** + +The four rows keep the titles, mnemonics and defaults of `src/ui/source-form.ui` (Server, Port 1..65535 default 8000, Multicast group, Interface) so the chooser reads the same. + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" +#include "source/udps_source.hpp" + +#include + +#include + +namespace udpscope { +namespace { + +using ChangeCb = std::shared_ptr>; + +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(user); + if (cb && *cb) (*cb)(); +} + +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(d); }, + static_cast(0)); +} + +std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); } + +class UdpsKind final : public SourceKind { + public: + std::string id() const override { return "udps"; } + std::string display_name() const override { return "UDPStreamer"; } + bool has_datagrams() const override { return true; } + + std::string identity(const SourceConfig& cfg) const override { + return cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 8000)); + } + + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + 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), 8000); + GtkWidget* group = entry_row("Multicast _group"); + GtkWidget* iface = entry_row("_Interface"); + + ChangeCb cb = std::make_shared>(); + hook(server, cb); + hook(group, cb); + hook(iface, cb); + + ConfigForm f; + f.rows = {server, port, group, iface}; + f.description = "Leave the multicast group and the interface empty for unicast on the default route"; + 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(c.get_int("port", 8000))); + gtk_editable_set_text(GTK_EDITABLE(group), c.get("multicast").c_str()); + gtk_editable_set_text(GTK_EDITABLE(iface), c.get("interface").c_str()); + }; + f.read = [=]() { + SourceConfig c; + c.set("server", text_of(server)); + c.set("port", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(port))))); + if (!text_of(group).empty()) c.set("multicast", text_of(group)); + if (!text_of(iface).empty()) c.set("interface", text_of(iface)); + return c; + }; + f.valid = [=]() { return !text_of(server).empty(); }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_udps_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +- [ ] **Step 4: Register it** + +In `src/source/registry.cpp` replace the `// Task 2:` comment with `register_kind(make_udps_kind());`. + +- [ ] **Step 5: CMake** + +Add after the Task 1 entries: + +``` + src/source/udps_source.cpp + src/source/udps_kind.cpp +``` + +- [ ] **Step 6: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; same warning count as Task 0. + +- [ ] **Step 7: Commit** + +```bash +git add src/source CMakeLists.txt +git commit -m "source: the UDPS client behind DataSource, with its config form" +``` + +--- + +## Task 3: Acquisition, Config and CLI on the registry + +The switch-over. After this task the program reads UDPS through `DataSource` and the self-check must read exactly like the Task 0 baseline. + +**Files:** +- Modify: `src/acquisition.hpp`, `src/acquisition.cpp`, `src/config.hpp`, `src/config.cpp`, `src/main.cpp`, `src/ui.cpp` (lines ~2497, ~5398, ~5917), `src/ui_probe.cpp` (~5414), `src/source_dialog.cpp` (lines ~35-38, ~1132-1135, ~1199) + +**Interfaces:** +- Consumes: Task 1 and 2 (`find_kind`, `session_host`, `session_port`, `Frame`, `SourceSink`, `kTime*`). +- Produces: + - `Config::source_kind` (`"udps"`), `Config::source_specified`, `Config::source_opts` (`SourceConfig`), `bool Config::has_source() const noexcept`, `SourceConfig Config::source_config() const`, `std::string Config::session_host() const`, `std::uint16_t Config::session_port() const`. + - `Acquisition::source()` returning `DataSource*` (null before `start()`). + - CLI: `--source NAME`, repeatable `--source-opt k=v`. + +- [ ] **Step 1: `config.hpp`** + +Add `#include "source/source_config.hpp"` after ``. After `server_specified` add: + +```cpp + /// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/ + /// --multicast/--interface` keep meaning UDPS. + std::string source_kind = "udps"; + /// Whether `--source` was given (the other half of "did the user choose a source"). + bool source_specified = false; + /// Repeatable `--source-opt k=v`: the kind's own settings. For `udps` they overlay the fields above. + SourceConfig source_opts; + + /// A source was named on the command line (`--server` or `--source`), so the chooser is skipped. + bool has_source() const noexcept { return server_specified || source_specified; } + /// The settings the kind's `make()` receives: for UDPS the four connection fields plus the receive + /// buffer, then `source_opts` on top. + SourceConfig source_config() const; + /// The (host, port) that name this source's session file; see `session_host()` in the registry. + std::string session_host() const; + std::uint16_t session_port() const; +``` + +- [ ] **Step 2: `config.cpp`** + +Add `#include "source/registry.hpp"` after the `plot_host.hpp` include. Add before `print_usage`: + +```cpp +SourceConfig Config::source_config() const { + SourceConfig out; + if (source_kind == "udps") { + out.set("server", server); + out.set("port", std::to_string(port)); + if (!multicast.empty()) out.set("multicast", multicast); + if (!interface_addr.empty()) out.set("interface", interface_addr); + out.set("recv_buffer", std::to_string(recv_buffer_bytes)); + } + for (const auto& kv : source_opts.items()) out.set(kv.first, kv.second); + return out; +} + +std::string Config::session_host() const { return udpscope::session_host(source_kind, source_config()); } +std::uint16_t Config::session_port() const { return udpscope::session_port(source_kind, source_config()); } +``` + +In `print_usage`, after the `--interface` line add (and extend the usage line with `[--source NAME]`): + +```cpp + std::string names; + for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id(); +``` +placed before the `fprintf`, and inside the format string after `--interface`: + +``` + " --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" +``` +with `names.c_str()` inserted as the second `fprintf` argument (after `argv0`; the first `%s` is `argv0`, so keep argument order matching the format). + +In `parse_args`, before `--buffer-mb`: + +```cpp + } else if (a == "--source" || a.rfind("--source=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--source"); + if (find_kind(v) == nullptr) { + std::string names; + for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id(); + fail("unknown source '" + v + "' (available: " + names + ")"); + } + c.source_kind = v; + c.source_specified = true; + } else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) { + const std::string v = value_of(argc, argv, i, "--source-opt"); + if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v); +``` + +(`--source=` does not match `--source-opt=`: the prefix includes `=`.) + +- [ ] **Step 3: `main.cpp`** + +Add `#include "source/registry.hpp"`. Immediately before `cfg = udpscope::parse_args(argc, argv);` (line ~21) add `udpscope::register_builtin_sources();`. Replace `cfg.server_specified` with `cfg.has_source()` at lines ~60 and ~66. + +- [ ] **Step 4: `acquisition.hpp`** + +- Replace `#include ` with `#include "source/data_source.hpp"` and add `#include `. +- Change `class Acquisition {` to `class Acquisition : private SourceSink {`. +- Doc of the class: "Owns the data source and the producer thread...". +- Add to the public section: + +```cpp + /// The source this acquisition drives, for the UI's capability panels: it may only call + /// `post()` on it. Null until `start()`. + DataSource* source() noexcept { return source_.get(); } +``` +- Replace the three `on_*` declarations and the four trampoline declarations (lines ~71-80) with: + +```cpp + void on_config(const std::vector& signals, std::uint8_t publish_mode) override; + void on_data(const Frame& frame) override; + void on_event(SourceEvent event, const char* detail) override; +``` +- `build_stamps(const Frame& frame, int time_signal, bool counter_gap_refused)` and `backfill_from(const Frame& frame, ...)`. +- Replace `udps_client* client_ = nullptr;` with `std::unique_ptr source_;`. +- In the doc comment of `build_stamps`, `frame->num_samples` becomes `frame.num_samples`. + +- [ ] **Step 5: `acquisition.cpp`, the mechanical part** + +Run, from the repo root: + +```bash +sed -i \ + -e 's/const udps_frame_t\* frame/const Frame\& frame/g' \ + -e 's/frame->/frame./g' \ + -e 's/udps_frame_value(frame, /frame.value(/g' \ + -e 's/udps_signal_num_elements(&\([^()]*\))/\1.elements()/g' \ + -e 's/const udps_signal_t&/const SignalInfo\&/g' \ + -e 's/UDPS_TIME_PACKET/kTimePacket/g' \ + -e 's/UDPS_TIME_FULL_ARRAY/kTimeFullArray/g' \ + -e 's/UDPS_TIME_FIRST_SAMPLE/kTimeFirstSample/g' \ + -e 's/UDPS_TIME_LAST_SAMPLE/kTimeLastSample/g' \ + src/acquisition.cpp +grep -no "frame\.[a-z_]*" src/acquisition.cpp | cut -d: -f2 | sort | uniq -c +grep -n "num_rows\|num_cols\|num_dimensions\|hrt" src/acquisition.cpp +``` +Expected: every `frame.` is one of `counter lost late recv_time publish_mode num_samples num_signals signals values value`; the second grep prints nothing. If either differs, fix by hand (`Frame` has no `hrt`; `SignalInfo` names the dimensions `rows`/`cols`). + +- [ ] **Step 6: `acquisition.cpp`, the structural part** + +1. Delete `to_info` (lines ~32-46), `#include ` (line 8), the four trampolines at the end of the file (`// Static trampolines ...` through `event_trampoline`), and `datagram_trampoline` (lines ~174-177). +2. Add includes `"source/registry.hpp"`. +3. `start()` becomes: + +```cpp +void Acquisition::start() { + // A started -- or *restarted* -- source is a new stream. Going live from a file view is the restart that + // matters: the store still holds the event's samples, and the new stream's clock can sit behind their + // stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing + // was the symptom -- every packet pinned flat at the file view's last stamp. + model_.reset_stream(); + last_stamp_ = -std::numeric_limits::infinity(); + if (!source_) { + SourceKind* kind = find_kind(cfg_.source_kind); + if (kind == nullptr) throw std::runtime_error("unknown source kind: " + cfg_.source_kind); + source_ = kind->make(cfg_.source_config()); + } + source_->set_datagram_tap([this](const std::uint8_t* bytes, std::size_t len, double recv_time) { + on_datagram(bytes, len, recv_time); + }); + source_->open(*this); + // `--capture-packets`: armed before the first datagram, so the first CONNECT and + // its CONFIG -- the hardest strange behaviours to reproduce -- are in the ring. + // The open itself still happens on the producer thread, at the first datagram. + if (cfg_.capture_packets > 0) { + set_capture_want(true, cfg_.capture_packets); + } + + started_ = std::chrono::steady_clock::now(); + running_.store(true, std::memory_order_release); + thread_ = std::thread([this] { run(); }); +} + +void Acquisition::stop() { + if (running_.exchange(false, std::memory_order_acq_rel) && thread_.joinable()) thread_.join(); + if (source_) source_->close(); +} + +void Acquisition::run() { + while (running_.load(std::memory_order_acquire)) { + source_->poll(50); + const SourceStats s = source_->stats(); + StreamStats& out = model_.stats(); + out.packets.store(s.packets, std::memory_order_relaxed); + out.frames.store(s.frames, std::memory_order_relaxed); + out.gaps.store(s.gaps, std::memory_order_relaxed); + out.resequenced.store(s.resequenced, std::memory_order_relaxed); + out.late.store(s.late, std::memory_order_relaxed); + out.stale.store(s.stale, std::memory_order_relaxed); + out.overflows.store(s.overflows, std::memory_order_relaxed); + out.reconnects.store(s.reconnects, std::memory_order_relaxed); + out.connected.store(s.connected, std::memory_order_relaxed); + } +} +``` +4. `set_capture_want`: replace `cfg_.server`/`cfg_.port` with `cfg_.session_host()`/`cfg_.session_port()` for the directory name, and guard the peer address: `if (cfg_.source_kind == "udps" && ::inet_pton(AF_INET, cfg_.server.c_str(), &a) == 1) { ... } capture_.set_peer(addr, cfg_.source_kind == "udps" ? cfg_.port : 0);`. +5. `on_config` signature: `void Acquisition::on_config(const std::vector& info, std::uint8_t publish_mode)`. Delete the `info` vector construction and use `info.size()` for `count` in the log line (`%u` with `static_cast(info.size())`). The mode log chain becomes `kTimePacket`... already done by sed. +6. `on_event`: + +```cpp +void Acquisition::on_event(SourceEvent event, const char* detail) { + (void)detail; + if (event == SourceEvent::Connected) { + model_.stats().connected.store(true, std::memory_order_relaxed); + } else if (event == SourceEvent::Disconnected) { +``` +leaving the body of the old `else if` untouched. + +- [ ] **Step 7: Every other reader of `cfg.server`/`cfg.port`** + +Run: `grep -n "config()\.server\|config()\.port\|cfg\.server\|cfg\.port" src/*.cpp src/*.hpp` and change each that names a *session file or capture directory* to `cfg.session_host()` / `cfg.session_port()`: +- `src/ui.cpp:~2497` `layout_path(app->model->config().server, ...port)` -> `layout_path(config().session_host(), config().session_port())`; +- `src/ui.cpp:~5917-5918` `app.source_host`/`source_port` -> `cfg.session_host()`/`cfg.session_port()` (keep the `open_waveform.empty()` guards); +- `src/ui_probe.cpp:~5414` likewise; +- `src/source_dialog.cpp:~1132-1135` (`apply_stored_settings`) -> `cfg.session_host()`/`cfg.session_port()`. +Leave `ui.cpp:~5398` (a log line), `ui.cpp:5919-5920` (`source_multicast`/`source_interface`) and `source_dialog.cpp:~35-38,1199` (UDPS fields filled from `KnownSource`) alone: they stay correct for UDPS and Task 5 reworks them for other kinds. + +- [ ] **Step 8: CMake and build** + +No new files. Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors, warning count as in Task 0 (a full rebuild of `acquisition.cpp` and others is incremental, so 1 or 3). Fix any leftover `udps_`/`->` compile errors by hand. + +- [ ] **Step 9: Self-check against the baseline** + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-new.out 2>/tmp/udps-new.err +kill $MOCK +grep -c "Gtk-CRITICAL" /tmp/udps-new.err; grep "gtk warnings" /tmp/udps-new.err +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.out) <(sed 's/[0-9.]\+/N/g' /tmp/udps-new.out) | head -30 +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.err) <(sed 's/[0-9.]\+/N/g' /tmp/udps-new.err) | head -30 +``` +Expected: `0`, `gtk warnings: 0`, and both diffs empty or limited to timing noise (a line present in one and not the other by a count that varies with timing); anything structural (a missing probe line, a changed message) is a regression to fix before continuing. Also run `./build/udpscope --help | grep -A3 -- --source` (expect the new options with `udps`) and `./build/udpscope --source nope` (expect `unknown source 'nope' (available: udps)`, exit 2). + +- [ ] **Step 10: Commit** + +```bash +git add -A src +git commit -m "acquisition: drive a DataSource from the registry; --source and --source-opt" +``` + +--- + +## Task 4: The `probe` kind and per-kind session persistence + +Adds the test-only `probe` kind (a synthetic sine, no panel yet) so a non-UDPS source can run end to end, and makes sessions remember which kind they belong to. + +**Files:** +- Create: `src/source/probe_source.hpp`, `src/source/probe_source.cpp` +- Modify: `src/source/registry.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_layout.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: Tasks 1-3. +- Produces: + - `class ProbeSource final : public DataSource` with `explicit ProbeSource(const SourceConfig&)`, `void note_applied()`, `std::uint64_t commands_applied() const`; option `rate` (Hz, default 1000). + - Kind id `"probe"`, display name `"Probe (test)"`, identity `"rate"`. + - `LayoutState::source_kind` (`std::string`, empty = UDPS) and `LayoutState::source_opts` (`SourceConfig`); `KnownSource::kind` (default `"udps"`) and `KnownSource::opts`. + - `App::source_kind`, `App::source_opts`, `App::source_title`. + - Session file keys: top-level `source_kind = "probe"` and `source_opt_ = ""`, written only for kinds other than `udps`. + +- [ ] **Step 1: `probe_source.hpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/source_config.hpp" + +#include +#include +#include + +namespace udpscope { + +/// A synthetic source for the headless self-check: one 1 V sine signal, `kBatch` samples per +/// frame at `rate` Hz, no socket. It exists to prove the framework end to end (open, CONFIG, +/// data, commands, panels) and is registered only when `UDPSCOPE_PROBE_SOURCE_KINDS` is set. +class ProbeSource final : public DataSource { + public: + static constexpr std::uint32_t kBatch = 10; + + explicit ProbeSource(const SourceConfig& cfg); + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override { return open_.load(std::memory_order_relaxed); } + + /// What a panel command does, so the probe can see that `post()` reached the producer thread. + void note_applied() noexcept { applied_.fetch_add(1, std::memory_order_relaxed); } + std::uint64_t commands_applied() const noexcept { + return applied_.load(std::memory_order_relaxed); + } + + private: + double rate_hz_; + SourceSink* sink_ = nullptr; + std::atomic open_{false}; + bool configured_ = false; + std::uint32_t counter_ = 0; + std::uint64_t frames_ = 0; + double phase_ = 0.0; + std::vector signals_; + std::vector batch_; + SignalValues values_; + std::atomic applied_{0}; +}; + +} // namespace udpscope +``` + +- [ ] **Step 2: `probe_source.cpp`** + +The kind's `make_panels` is added in Task 6; the form lives here. + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/probe_source.hpp" + +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { + +ProbeSource::ProbeSource(const SourceConfig& cfg) + : rate_hz_(std::max(1.0, cfg.get_double("rate", 1000.0))), batch_(kBatch, 0.0) { + SignalInfo s; + s.name = "probe"; + s.unit = "V"; + s.type_code = 9; // float64 + s.rows = 1; + s.cols = 1; + s.range_min = -1.0; + s.range_max = 1.0; + s.time_mode = kTimePacket; + s.sampling_rate = rate_hz_; + signals_.push_back(s); +} + +void ProbeSource::open(SourceSink& sink) { + sink_ = &sink; + configured_ = false; + open_.store(true, std::memory_order_relaxed); +} + +void ProbeSource::close() { open_.store(false, std::memory_order_relaxed); } + +SourceStats ProbeSource::stats() const { + SourceStats s; + s.packets = frames_; + s.frames = frames_; + s.connected = open_.load(std::memory_order_relaxed); + return s; +} + +void ProbeSource::poll(int timeout_ms) { + drain_commands(); + if (!open_.load(std::memory_order_relaxed) || sink_ == nullptr) return; + if (!configured_) { + configured_ = true; + sink_->on_config(signals_, /*publish_mode=*/1); + sink_->on_event(SourceEvent::Connected, ""); + } + // One batch takes kBatch / rate seconds to "arrive". + const int period_ms = std::max(1, static_cast(1000.0 * kBatch / rate_hz_)); + std::this_thread::sleep_for(std::chrono::milliseconds(std::min(period_ms, timeout_ms))); + const double step = 2.0 * M_PI * 5.0 / rate_hz_; // a 5 Hz sine + for (std::uint32_t k = 0; k < kBatch; ++k) { + batch_[k] = std::sin(phase_); + phase_ += step; + } + values_.values = batch_.data(); + values_.count = kBatch; + Frame f; + f.counter = counter_++; + f.lost = 0; + f.late = false; + f.recv_time = std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()) + .count(); + f.publish_mode = 1; + f.num_samples = kBatch; + f.num_signals = 1; + f.signals = signals_.data(); + f.values = &values_; + ++frames_; + sink_->on_data(f); +} + +namespace { + +void on_rate_changed(GObject*, GParamSpec*, gpointer user) { + const auto& cb = *static_cast>*>(user); + if (cb && *cb) (*cb)(); +} + +class ProbeKind final : public SourceKind { + public: + std::string id() const override { return "probe"; } + std::string display_name() const override { return "Probe (test)"; } + std::string identity(const SourceConfig& cfg) const override { + return "rate" + std::to_string(cfg.get_int("rate", 1000)); + } + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + GtkWidget* rate = GTK_WIDGET(adw_spin_row_new_with_range(1, 100000, 1)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(rate), "_Rate (Hz)"); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(rate), TRUE); + adw_spin_row_set_value(ADW_SPIN_ROW(rate), 1000); + + auto cb = std::make_shared>(); + g_signal_connect_data(rate, "notify::value", G_CALLBACK(on_rate_changed), + new std::shared_ptr>(cb), + +[](gpointer d, GClosure*) { + delete static_cast>*>(d); + }, + static_cast(0)); + + ConfigForm f; + f.rows = {rate}; + f.description = "A synthetic 5 Hz sine, for the self-check"; + f.focus = rate; + f.write = [=](const SourceConfig& c) { + adw_spin_row_set_value(ADW_SPIN_ROW(rate), static_cast(c.get_int("rate", 1000))); + }; + f.read = [=]() { + SourceConfig c; + c.set("rate", std::to_string(static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(rate))))); + return c; + }; + f.valid = []() { return true; }; + f.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return f; + } +}; + +} // namespace + +std::unique_ptr make_probe_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +(Add `#include ` and `#include ` at the top if the compiler asks.) + +- [ ] **Step 3: Register it** + +In `src/source/registry.cpp`, replace the `// Task 6:` comment with the real call from the listing in Task 1 Step 7: `if (std::getenv("UDPSCOPE_PROBE_SOURCE_KINDS") != nullptr) register_kind(make_probe_kind());`. Add `src/source/probe_source.cpp` to `CMakeLists.txt` after `udps_kind.cpp`. + +- [ ] **Step 4: `layout_store.hpp`** + +Add `#include "source/source_config.hpp"`. In `LayoutState`, after `interface_addr`: + +```cpp + /// Which source kind this session belongs to. Empty means `udps`: every file written before the + /// plugins existed. Only a kind other than `udps` is written, so a UDPS session file is + /// byte-identical to what earlier versions wrote. + std::string source_kind; + /// That kind's settings (`SourceConfig`), stored as top-level `source_opt_` entries. + SourceConfig source_opts; +``` + +In `KnownSource`, after `interface_addr`: + +```cpp + std::string kind = "udps"; ///< the session's source kind + SourceConfig opts; ///< that kind's settings (empty for UDPS, whose fields are above) +``` + +- [ ] **Step 5: `layout_store.cpp`** + +Parser, next to `key == "interface"` (line ~559): + +```cpp + } else if (key == "source_kind") { + st.source_kind = strip_quote(value); + } else if (key.rfind("source_opt_", 0) == 0) { + const std::string k = key.substr(11); + if (SourceConfig::valid_key(k)) st.source_opts.set(k, strip_quote(value)); +``` +Writer, right after the `interface` block (line ~700): + +```cpp + if (!state.source_kind.empty() && state.source_kind != "udps") { + o << "source_kind = " << toml_escape(state.source_kind) << "\n"; + for (const auto& kv : state.source_opts.items()) { + o << "source_opt_" << kv.first << " = " << toml_escape(kv.second) << "\n"; + } + } +``` +`known_sources()`, after `s.interface_addr = st.interface_addr;` (line ~324): + +```cpp + if (!st.source_kind.empty()) { + s.kind = st.source_kind; + s.opts = st.source_opts; + } +``` + +- [ ] **Step 6: App plumbing** + +`src/ui_app.hpp`: add `#include "source/source_config.hpp"` and after `source_interface` (line ~839): + +```cpp + /// The source kind and its settings, so the session can say which kind it belongs to, and the + /// words that name the source in dialogs (`host:port` for UDPS, ` ` otherwise). + std::string source_kind = "udps"; + SourceConfig source_opts; + std::string source_title; +``` +`src/ui.cpp` in `run_ui`, after `app.source_interface = cfg.interface_addr;` (line ~5920): + +```cpp + app.source_kind = cfg.source_kind; + app.source_opts = cfg.source_opts; + if (cfg.source_kind != "udps" && find_kind(cfg.source_kind) != nullptr) { + app.source_title = find_kind(cfg.source_kind)->display_name() + " " + + find_kind(cfg.source_kind)->identity(cfg.source_config()); + } else { + app.source_title = app.source_host + ":" + std::to_string(app.source_port); + } +``` +(add `#include "source/registry.hpp"` to `ui.cpp` if not present). At `ui.cpp:~103` replace `app.source_host + ":" + std::to_string(app.source_port)` with `app.source_title`. +`src/ui_layout.cpp:~99`, after `st.interface_addr = app.source_interface;`: + +```cpp + st.source_kind = app.source_kind; + st.source_opts = app.source_opts; +``` + +- [ ] **Step 7: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c`. Expected: no errors, baseline warning count. + +- [ ] **Step 8: Verify a non-UDPS source runs and its session is tagged** + +```bash +CFG=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFG UDPSCOPE_PROBE_SOURCE_KINDS=1500 UDPSCOPE_EXIT_AFTER_MS=4000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --source probe --source-opt rate=500 >/tmp/probe.out 2>/tmp/probe.err +grep -c "Gtk-CRITICAL" /tmp/probe.err; grep "gtk warnings" /tmp/probe.err +grep "CONFIG:" /tmp/probe.err +grep -rn "source_" $(find $CFG -name '*.toml') +find $CFG -name '*.toml' +``` +Expected: `0`, `gtk warnings: 0`, `[udpscope] CONFIG: 1 signals, publish=accumulate`, a `'probe' 1x1 ... rate=500 Hz` line, and a session file under a directory named `probe-rate500_1` containing `source_kind = "probe"` and `source_opt_rate = "500"`. If no `.toml` appears, find where the exit path saves the session (`save_layout_now` in `src/ui_layout.cpp`) and what condition skips it, before changing anything. + +Then confirm UDPS sessions are untouched: re-run the Task 3 Step 9 self-check, then `grep -rl source_kind $CFG2` over its config dir must print nothing, and both diffs against the baseline must still be empty. + +- [ ] **Step 9: Commit** + +```bash +git add src CMakeLists.txt +git commit -m "source: a synthetic probe kind, and sessions that remember their source kind" +``` + +--- + +## Task 5: The chooser picks a source type + +The connect dialog gets a "Source type" dropdown; choosing a kind swaps in that kind's `ConfigForm`. For UDPS it must behave as before. + +**Files:** +- Modify: `src/ui/source-form.ui`, `src/source_dialog.cpp` + +**Interfaces:** +- Consumes: `ConfigForm`, `SourceKind`, `kinds()`, `find_kind()`, `session_host/port`, `KnownSource::kind/opts`, `LayoutState::source_kind/source_opts`, `Config::source_kind/source_opts`. +- Produces: new chooser probe variable `UDPSCOPE_PROBE_NEWHOST_KIND=[:k=v,...]` (with `UDPSCOPE_PROBE_NEWHOST` still required to open the form; its value is ignored for a non-UDPS kind) and, for a non-UDPS pick, one extra stderr line `[sources] picked kind=K opts=k=v,... session=HOST:PORT`. + +- [ ] **Step 1: `src/ui/source-form.ui`** + +Update the header comment ("eight rows" -> "nine rows, the connection ones supplied by the chosen source kind"). Replace the whole first `AdwPreferencesGroup` (title `Connection`, with the four rows) by these two groups: + +```xml + + + + + Source _type + True + + + + + + + Connection + + +``` +The Configuration group stays as it is. + +- [ ] **Step 2: `source_dialog.cpp`, the Chooser struct and `apply_source`** + +Includes: `#include "source/registry.hpp"`. + +In `struct Chooser` replace `form_server`, `form_port`, `form_group`, `form_iface` (lines ~108-111) with: + +```cpp + /// The source-type dropdown and the group the chosen kind's rows live in. The rows themselves, + /// and the closures that read and write them, are the kind's (`ConfigForm`); the chooser only + /// swaps them when the dropdown moves. + AdwComboRow* form_kind_row = nullptr; + AdwPreferencesGroup* form_source_group = nullptr; + std::vector form_kinds; ///< dropdown order + ConfigForm form_cfg; + std::vector form_cfg_rows; ///< the rows currently in `form_source_group` +``` + +`apply_source` becomes: + +```cpp +void apply_source(Config& cfg, const KnownSource& src) { + cfg.source_kind = src.kind; + if (src.kind == "udps") { + cfg.server = src.host; + cfg.port = src.port; + cfg.multicast = src.multicast; + cfg.interface_addr = src.interface_addr; + cfg.source_opts = SourceConfig(); + } else { + cfg.source_opts = src.opts; + } + // (the rest of the function is unchanged) +``` + +- [ ] **Step 3: `form_rebuild` and the kind handler** + +Replace `on_form_server_changed` (lines ~435-444) with: + +```cpp +/// The kind selected in the dropdown, or null. +SourceKind* form_selected_kind(const Chooser& c) { + if (c.form_kind_row == nullptr) return nullptr; + const guint i = adw_combo_row_get_selected(c.form_kind_row); + return i < c.form_kinds.size() ? c.form_kinds[i] : nullptr; +} + +/// Swaps the Connection group's rows for `kind`'s form and fills them from `initial`. The +/// connect button follows the form's own `valid()`: the one validation here is the kind's. +void form_rebuild(Chooser& c, SourceKind& kind, const SourceConfig& initial) { + for (GtkWidget* r : c.form_cfg_rows) adw_preferences_group_remove(c.form_source_group, r); + c.form_cfg_rows.clear(); + c.form_cfg = kind.make_config_form(); + for (GtkWidget* r : c.form_cfg.rows) { + adw_preferences_group_add(c.form_source_group, r); + c.form_cfg_rows.push_back(r); + } + adw_preferences_group_set_description( + c.form_source_group, + c.form_cfg.description.empty() ? nullptr : c.form_cfg.description.c_str()); + c.form_cfg.write(initial); + c.form_cfg.on_change([&c] { + if (c.form_connect != nullptr) { + gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE); + } + }); + if (c.form_connect != nullptr) { + gtk_widget_set_sensitive(c.form_connect, c.form_cfg.valid() ? TRUE : FALSE); + } + if (c.form_cfg.focus != nullptr) gtk_widget_grab_focus(c.form_cfg.focus); +} + +void on_form_kind_changed(GObject*, GParamSpec*, gpointer user) { + Chooser& c = *static_cast(user); + if (SourceKind* k = form_selected_kind(c)) form_rebuild(c, *k, SourceConfig()); +} +``` +(`form_rebuild` is called with a `Chooser&` that outlives the form, as the existing comment on the struct explains, so `[&c]` is safe.) + +- [ ] **Step 4: `on_form_connect`** + +Replace the head of the function (up to and including the `c.cfg->interface_addr = iface;` line) with: + +```cpp + SourceKind* kind = form_selected_kind(c); + if (kind == nullptr || !c.form_cfg.valid()) return; + const SourceConfig scfg = c.form_cfg.read(); + const bool udps = kind->id() == "udps"; + const std::string host = session_host(kind->id(), scfg); + const std::uint16_t port = session_port(kind->id(), scfg); + const std::string group = udps ? scfg.get("multicast") : std::string(); + 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 bool relative = adw_switch_row_get_active(c.form_relative) != FALSE; + if (name.empty()) name = "default"; + + c.cfg->source_kind = kind->id(); + if (udps) { + c.cfg->server = scfg.get("server"); + c.cfg->port = port; + c.cfg->multicast = group; + c.cfg->interface_addr = iface; + c.cfg->source_opts = SourceConfig(); + } else { + c.cfg->source_opts = scfg; + } +``` +and after `st.interface_addr = iface;` add: + +```cpp + st.source_kind = udps ? std::string() : kind->id(); + st.source_opts = udps ? SourceConfig() : scfg; +``` +Everything else in the function (the save, the `moved` test, the log line) stays: it already works on `host`, `port`, `name`. + +- [ ] **Step 5: `open_form`** + +1. Remove the four `gtk_builder_get_object` lines for `server_row/port_row/group_row/iface_row`, and in the null-check list drop `form_server/form_port/form_group/form_iface`. Add: + +```cpp + c.form_kind_row = ADW_COMBO_ROW(gtk_builder_get_object(c.form_ui, "kind_row")); + c.form_source_group = + ADW_PREFERENCES_GROUP(gtk_builder_get_object(c.form_ui, "source_group")); +``` +and add both to the null check. +2. Delete the four value lines for server/port/group/iface. After the `form_relative` line, add: + +```cpp + // 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 -- + // disabled, not hidden, like every other control that does not apply. + c.form_kinds = kinds(); + GtkStringList* names = gtk_string_list_new(nullptr); + guint selected = 0; + const std::string want_kind = src != nullptr ? src->kind : c.cfg->source_kind; + for (guint i = 0; i < c.form_kinds.size(); ++i) { + gtk_string_list_append(names, c.form_kinds[i]->display_name().c_str()); + if (c.form_kinds[i]->id() == want_kind) selected = i; + } + adw_combo_row_set_model(c.form_kind_row, G_LIST_MODEL(names)); + adw_combo_row_set_selected(c.form_kind_row, selected); + gtk_widget_set_sensitive(GTK_WIDGET(c.form_kind_row), src == nullptr); + + SourceConfig initial; + if (src != nullptr && src->kind == "udps") { + initial.set("server", src->host); + initial.set("port", std::to_string(src->port)); + if (!src->multicast.empty()) initial.set("multicast", src->multicast); + if (!src->interface_addr.empty()) initial.set("interface", src->interface_addr); + } else if (src != nullptr) { + initial = src->opts; + } else if (c.cfg->source_kind != "udps") { + initial = c.cfg->source_opts; + } + if (!c.form_kinds.empty()) form_rebuild(c, *c.form_kinds[selected], initial); + // Connected after the first rebuild: setting `selected` above must not rebuild a second time. + g_signal_connect(c.form_kind_row, "notify::selected", G_CALLBACK(on_form_kind_changed), &c); +``` +3. Delete the `g_signal_connect(c.form_server, "changed", ...)` line (it is replaced by `on_change`) and the final `gtk_widget_grab_focus(GTK_WIDGET(c.form_server));` (the rebuild focused the kind's `focus` widget). Note the rebuild's `grab_focus` happens before the dialog is presented, as the old code did after; if focus does not land (probe: `mnemonics`/`connect_sensitive` lines are unaffected), call `if (c.form_cfg.focus) gtk_widget_grab_focus(c.form_cfg.focus);` again after `adw_dialog_present`. +4. In `form_close`'s caller and the `closed` handler, also clear the form: in the `closed` lambda add `Chooser& ch = *static_cast(user); ch.form = nullptr; ch.form_cfg = ConfigForm(); ch.form_cfg_rows.clear(); ch.form_kind_row = nullptr; ch.form_source_group = nullptr;` (the rows are gone with the dialog; dropping the closures that captured them keeps a stale `read()` from ever being callable). + +- [ ] **Step 6: Row titles, loading window, `report_pick`** + +- `chooser_populate` title (line ~375): + +```cpp + SourceKind* sk = s.kind == "udps" ? nullptr : find_kind(s.kind); + const std::string where = sk != nullptr ? sk->display_name() + " " + sk->identity(s.opts) + : s.host + ":" + std::to_string(s.port); + const std::string title = where + " \u00b7 " + s.config; +``` + (a non-UDPS session whose kind is not registered keeps its pseudo-host as the title.) +- `loading_show` (line ~1199): for `cfg.source_kind != "udps"` and a registered kind, `head = "Connecting to " + kind->display_name() + " " + kind->identity(cfg.source_config());` instead of the host:port form; the UDPS branch is unchanged. +- `report_pick`: after the existing `fprintf`, add + +```cpp + if (c.cfg->source_kind != "udps") { + std::string opts; + for (const auto& kv : c.cfg->source_opts.items()) { + opts += (opts.empty() ? "" : ",") + kv.first + "=" + kv.second; + } + std::fprintf(stderr, "[sources] picked kind=%s opts=%s session=%s:%u\n", + c.cfg->source_kind.c_str(), opts.c_str(), c.cfg->session_host().c_str(), + c.cfg->session_port()); + std::fflush(stderr); + } +``` + The first `picked` line prints `c.cfg->server`; for a non-UDPS pick it prints the default server, which is why the second line exists. Leave it. + +- [ ] **Step 7: The probes** + +In the `UDPSCOPE_PROBE_NEWHOST` block, replace the `gtk_editable_set_text(... form_server ...)` / `adw_spin_row_set_value(c.form_port ...)` lines with: + +```cpp + SourceConfig sc; + if (const char* kspec = g_getenv("UDPSCOPE_PROBE_NEWHOST_KIND")) { + // "kind" or "kind:k=v,k=v": pick the kind in the dropdown (which rebuilds the form), then + // write the options into it. + std::string ks = kspec; + std::string opts; + if (const std::size_t sep = ks.find(':'); sep != std::string::npos) { + opts = ks.substr(sep + 1); + ks = ks.substr(0, sep); + } + for (guint i = 0; i < c.form_kinds.size(); ++i) { + if (c.form_kinds[i]->id() == ks) adw_combo_row_set_selected(c.form_kind_row, i); + } + for (std::size_t pos = 0; pos < opts.size();) { + const std::size_t end = std::min(opts.find(',', pos), opts.size()); + sc.parse_assignment(opts.substr(pos, end - pos)); + pos = end + 1; + } + } else { + sc.set("server", colon == std::string::npos ? s : s.substr(0, colon)); + if (colon != std::string::npos) sc.set("port", s.substr(colon + 1)); + } + c.form_cfg.write(sc); +``` +(Keep the `colon` computation above it.) Change the mnemonic line to count the kind row and the form's rows: + +```cpp + int underlines = row_underlines(G_OBJECT(c.form_kind_row)) + row_underlines(G_OBJECT(c.form_name)) + + row_underlines(G_OBJECT(c.form_note)) + row_underlines(G_OBJECT(c.form_buffer)) + + row_underlines(G_OBJECT(c.form_relative)); + for (GtkWidget* r : c.form_cfg.rows) underlines += row_underlines(G_OBJECT(r)); + std::fprintf(stderr, "[sources] form mnemonics=%d/%zu\n", underlines, 5 + c.form_cfg.rows.size()); +``` +For UDPS this prints `9/9`. (The old `/8` was a hard-coded total; the new total follows the kind's row count.) + +The EDITHOST probe needs no code change: it opens the form with `open_form(c, idx)` and edits the config rows. Its match `known[i].host:port == want` still works for UDPS, and for another kind `want` is the pseudo-host (`probe-rate250:1`). + +- [ ] **Step 8: Build and run the UDPS chooser probes against the baseline** + +```bash +cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +CFGN=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-new-new.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_EDITHOST=127.0.0.1:8000 ./build/udpscope 2>/tmp/chooser-new-edit.err >/dev/null +xvfb-run -a env XDG_CONFIG_HOME=$CFGN UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>/tmp/chooser-new-list.err >/dev/null +kill $MOCK +for k in new edit list; do + diff <(grep -h "^\[sources\]" /tmp/chooser-baseline-$k.err | sed 's/[0-9.]\+/N/g') \ + <(grep -h "^\[sources\]" /tmp/chooser-new-$k.err | sed 's/[0-9.]\+/N/g') +done +grep -c "Gtk-CRITICAL" /tmp/chooser-new-*.err +``` +Expected: no errors; the only diff is `form mnemonics=8/8` -> `form mnemonics=9/9`; `Gtk-CRITICAL` counts all `0`. + +- [ ] **Step 9: Run the non-UDPS path through the form** + +```bash +CFGP=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFGP UDPSCOPE_PROBE_SOURCE_KINDS=9999 UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \ + UDPSCOPE_PROBE_NEWHOST=x UDPSCOPE_PROBE_NEWHOST_KIND=probe:rate=250 ./build/udpscope 2>/tmp/chooser-probe.err >/dev/null +grep "^\[sources\]" /tmp/chooser-probe.err +xvfb-run -a env XDG_CONFIG_HOME=$CFGP UDPSCOPE_PROBE_SOURCE_KINDS=9999 UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>&1 >/dev/null | grep "^\[sources\]" +``` +Expected: `form mnemonics=6/6`, `configuration created: probe-rate250:1/probe-host`, `picked kind=probe opts=rate=250 session=probe-rate250:1`, then `CONFIG: 1 signals` in the app half; and the second run lists one `known:` row for `probe-rate250:1`. + +- [ ] **Step 10: Commit** + +```bash +git add src/ui/source-form.ui src/source_dialog.cpp +git commit -m "chooser: pick the source type; each kind supplies its own connection form" +``` + +--- + +--- + +## Task 6: Capability panels in the side stack, and the source-kinds probe + +A kind's `make_panels()` widgets become pages of the existing `side_stack`, next to Measure and Signals. The `probe` kind carries one panel (a "Poke" button) so the headless run can prove the whole path: page built, insensitive while disconnected, a clicked button's command drained by `poll()`. + +**Files:** +- Create: `src/ui_source.cpp` +- Modify: `src/source/probe_source.cpp` (the panel), `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt` + +**Interfaces:** +- Consumes: `SourceKind::make_panels(DataSource&)`, `SourcePanel{id,title,icon,widget}`, `Acquisition::source()`, `App::source_kind`, `ScopeModel::stats().connected` (an atomic the producer keeps current), `ProbeSource::commands_applied()`. +- Produces: + - `struct SourcePanelPage { std::string name; std::string title; GtkWidget* content = nullptr; };` and `App::source_panels` (`std::vector`). `name` is the stack page name, `"source-" + SourcePanel::id`; `content` is the kind's own widget (the page wraps it in a scroller). + - `void build_source_panels(App&)`, `void apply_source_panels(App&, bool connected)`, `void sync_source_panels(App&)` (declared in `ui_app.hpp`, defined in `ui_source.cpp`). + - `gboolean source_kinds_probe(gpointer)` (declared in `ui_app.hpp`, defined in `ui_probe.cpp`), scheduled by `UDPSCOPE_PROBE_SOURCE_KINDS=`. + +- [ ] **Step 1: The probe kind's panel** + +In `src/source/probe_source.cpp`, inside `class ProbeKind`, add after `make_config_form()`: + +```cpp + std::vector make_panels(DataSource& source) const override { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_margin_top(box, 12); + gtk_widget_set_margin_bottom(box, 12); + gtk_widget_set_margin_start(box, 12); + gtk_widget_set_margin_end(box, 12); + GtkWidget* label = gtk_label_new("Test panel"); + gtk_widget_set_halign(label, GTK_ALIGN_START); + gtk_box_append(GTK_BOX(box), label); + // The probe finds the button as the box's last child. It reaches the source only through + // `post()`: the command runs inside the next `poll()`, on the producer thread. + GtkWidget* poke = gtk_button_new_with_label("Poke"); + gtk_box_append(GTK_BOX(box), poke); + DataSource* src = &source; + g_signal_connect_data( + poke, "clicked", + G_CALLBACK(+[](GtkButton*, gpointer user) { + DataSource* s = static_cast(user); + s->post([s] { static_cast(s)->note_applied(); }); + }), + src, nullptr, static_cast(0)); + SourcePanel p; + p.id = "poke"; + p.title = "Probe"; + p.widget = box; + return {p}; + } +``` + +(The `static_cast` is sound because only `ProbeKind::make` creates the source this kind's panels are built for. `src` outlives the button: `Acquisition` owns the source and outlives the window.) + +- [ ] **Step 2: `ui_app.hpp`** + +Above `struct App` add: + +```cpp +/// One capability panel a source kind contributed to the side stack. `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 + GtkWidget* content = nullptr; +}; +``` + +In `App`, after `source_title` (added in Task 4): + +```cpp + /// The panels the source kind added to `side_stack`, in tab order. Empty for UDPS and in file mode. + std::vector source_panels; +``` + +Next to `void save_layout_now(App& app);` add: + +```cpp +/// 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. +/// `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); +void sync_source_panels(App& app); +``` + +Next to `gboolean side_tab_probe(gpointer user);` add `gboolean source_kinds_probe(gpointer user);`. + +- [ ] **Step 3: `src/ui_source.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/registry.hpp" +#include "ui_app.hpp" + +#include + +namespace udpscope { + +void build_source_panels(App& app) { + if (app.side_stack == nullptr || app.acq == nullptr || app.acq->source() == nullptr) return; + SourceKind* kind = find_kind(app.source_kind); + if (kind == nullptr) return; + for (const SourcePanel& p : kind->make_panels(*app.acq->source())) { + if (p.widget == nullptr || p.id.empty()) continue; + // Each page scrolls on its own, like Measure and Signals: panels differ in height. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), p.widget); + const std::string name = "source-" + p.id; + // An empty title on purpose, as for the built-in tabs: the pill is an icon, and + // `name_side_tabs()` puts `title` on it as tooltip and accessible name. + adw_view_stack_add_titled_with_icon( + ADW_VIEW_STACK(app.side_stack), scroller, name.c_str(), "", + p.icon.empty() ? "applications-engineering-symbolic" : p.icon.c_str()); + app.source_panels.push_back({name, p.title, p.widget}); + } + // Disconnected until the first Connected event says otherwise. + apply_source_panels(app, false); +} + +void apply_source_panels(App& app, bool connected) { + for (const SourcePanelPage& p : app.source_panels) { + if (gtk_widget_get_sensitive(p.content) != (connected ? TRUE : FALSE)) { + gtk_widget_set_sensitive(p.content, connected); + } + } +} + +void sync_source_panels(App& app) { + if (app.source_panels.empty() || app.model == nullptr) return; + apply_source_panels(app, app.model->stats().connected.load(std::memory_order_relaxed)); +} + +} // namespace udpscope +``` + +Add `src/ui_source.cpp` to `CMakeLists.txt` after `src/ui_layout.cpp`. + +- [ ] **Step 4: Hook it into `ui.cpp`** + +1. `side_tab_title` (line ~2981) becomes `static const char* side_tab_title(const App& app, const char* name)`: after the two built-in `strcmp` lines and before `return "";` add + +```cpp + if (name != nullptr) { + for (const SourcePanelPage& p : app.source_panels) { + if (p.name == name) return p.title.c_str(); + } + } +``` +and in `name_side_tabs` (line ~3003) call `side_tab_title(app, adw_view_stack_page_get_name(page))`. + +2. In `on_activate`, immediately after the block that selects the `signals` page and connects `notify::visible-child-name` (line ~5280-5284, ending `}`), add: + +```cpp + // The source kind's own tabs (a generator, digital I/O). Before the first tick: that is when + // `name_side_tabs()` puts the words on the pills, and it must see these pages too. + build_source_panels(*app); +``` + +3. In `tick()`, directly after `note_panel_controls(*app);` add `sync_source_panels(*app);`. + +4. In `run_ui`, next to the other `g_getenv("UDPSCOPE_PROBE_*_MS")` schedules (line ~6090, after `UDPSCOPE_PROBE_DENSITY_MS`): + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_SOURCE_KINDS")) { + g_timeout_add(static_cast(std::atoi(env)), source_kinds_probe, &app); + } +``` + +5. The packet-capture button is for UDP sources only (spec: "left disabled, not hidden"). In `ui_app.hpp`, after `source_title`, add `bool source_has_datagrams = true; ///< the kind reports raw datagrams; false leaves the capture button disabled`. In `run_ui`, after the `source_title` block added in Task 4, add `if (SourceKind* k = find_kind(cfg.source_kind)) app.source_has_datagrams = k->has_datagrams();`. At `ui.cpp:~4554` change `snap.connected ? TRUE : FALSE` to `snap.connected && app->source_has_datagrams ? TRUE : FALSE`. + +- [ ] **Step 5: The probe, in `ui_probe.cpp`** + +Add `#include "source/probe_source.hpp"` and `#include "source/registry.hpp"` to the includes, and, next to `side_tab_probe` (define the report first): + +```cpp +namespace { +std::uint64_t g_source_kinds_before = 0; +} + +/// Phase two: the command the Poke button posted must have been run by `poll()` on the producer. +gboolean source_kinds_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + const ProbeSource* src = a->acq != nullptr ? dynamic_cast(a->acq->source()) + : nullptr; + const std::uint64_t after = src != nullptr ? src->commands_applied() : 0; + std::fprintf(stderr, "[source-kinds] command: before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT + " drained=%d\n", + g_source_kinds_before, after, after == g_source_kinds_before + 1 ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// What the registry offers and what a source kind brings: the kinds, each config form's +/// round trip, the panel page (insensitive while disconnected, still present), and a posted command. +gboolean source_kinds_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + + std::string ids; + for (const SourceKind* k : kinds()) ids += (ids.empty() ? "" : ",") + k->id(); + std::fprintf(stderr, "[source-kinds] kinds=%s\n", ids.c_str()); + + // Each kind's form must hand back what it was given. + const auto sample = [](const std::string& id) { + SourceConfig c; + if (id == "udps") { + c.set("server", "10.0.0.5"); + c.set("port", "9001"); + c.set("multicast", "239.1.2.3"); + c.set("interface", "10.0.0.9"); + } else if (id == "probe") { + c.set("rate", "750"); + } + return c; + }; + for (const SourceKind* k : kinds()) { + ConfigForm f = k->make_config_form(); + const SourceConfig in = sample(k->id()); + f.write(in); + const SourceConfig out = f.read(); + bool same = !in.empty(); + for (const auto& kv : in.items()) same = same && out.get(kv.first) == kv.second; + std::fprintf(stderr, "[source-kinds] form %s: round_trip=%d valid=%d\n", k->id().c_str(), + same ? 1 : 0, f.valid() ? 1 : 0); + // The rows were never parented; sink and release them so the form leaves nothing behind. + for (GtkWidget* row : f.rows) { + g_object_ref_sink(row); + g_object_unref(row); + } + } + + // The panel page: built, insensitive while disconnected, present either way. + const SourcePanelPage* page = a->source_panels.empty() ? nullptr : &a->source_panels.front(); + const auto report = [&](const char* state) { + const bool present = + page != nullptr && a->side_stack != nullptr && + adw_view_stack_get_child_by_name(ADW_VIEW_STACK(a->side_stack), page->name.c_str()) != + nullptr; + std::fprintf(stderr, "[source-kinds] panel %s: panels=%zu present=%d sensitive=%d\n", state, + a->source_panels.size(), present ? 1 : 0, + page != nullptr && gtk_widget_get_sensitive(page->content) ? 1 : 0); + }; + apply_source_panels(*a, false); + report("disconnected"); + apply_source_panels(*a, true); + report("connected"); + + // The probe kind has no datagrams, so the packet-capture button stays disabled, and is still there. + std::fprintf(stderr, "[source-kinds] 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); + + // Click the panel's button: it posts a command, and a later `poll()` runs it. + ProbeSource* src = a->acq != nullptr ? dynamic_cast(a->acq->source()) : nullptr; + g_source_kinds_before = src != nullptr ? src->commands_applied() : 0; + if (page != nullptr) { + if (GtkWidget* poke = gtk_widget_get_last_child(page->content)) { + g_signal_emit_by_name(poke, "clicked"); + } + } + std::fflush(stderr); + g_timeout_add(500, source_kinds_probe_report, a); + return G_SOURCE_REMOVE; +} +``` + +Add the declaration `gboolean source_kinds_probe_report(gpointer user);` beside `source_kinds_probe` in `ui_app.hpp`. If `ProbeSource`/`SourceKind` names need `udpscope::` qualification the compiler will say so (the probe file is inside `namespace udpscope`). + +(The probe forces `apply_source_panels(..., true)` last, and the per-tick `sync_source_panels` also drives it from the model flag. The producer never sets the flag false in this run, so the two agree.) + +- [ ] **Step 6: Build** + +Run: `cmake --build build -j 2>&1 | grep -E "warning:|error:" | sort | uniq -c` +Expected: no errors; baseline warning count. + +- [ ] **Step 7: Verify** + +```bash +CFG=$(mktemp -d) +xvfb-run -a env XDG_CONFIG_HOME=$CFG UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 \ + ./build/udpscope --source probe --source-opt rate=500 >/tmp/sk.out 2>/tmp/sk.err +grep -c "Gtk-CRITICAL" /tmp/sk.err; grep "gtk warnings" /tmp/sk.err; grep "^\[source-kinds\]" /tmp/sk.err +``` +Expected: + +``` +0 +gtk warnings: 0 +[source-kinds] kinds=udps,probe +[source-kinds] form udps: round_trip=1 valid=1 +[source-kinds] form probe: round_trip=1 valid=1 +[source-kinds] panel disconnected: panels=1 present=1 sensitive=0 +[source-kinds] panel connected: panels=1 present=1 sensitive=1 +[source-kinds] capture button: present=1 sensitive=0 +[source-kinds] command: before=0 after=1 drained=1 +``` +(Every line of the form `[source-kinds] form ...` appears once per registered kind.) If `drained=0` the command was posted but `poll()` never ran it: check that `ProbeSource::poll` calls `drain_commands()` before returning on any path. If `sensitive=0` on the connected line, `apply_source_panels` is not reaching `content` (it must act on the kind's widget, not the scroller). + +Then the regression checks, serially: the Task 3 Step 9 UDPS self-check against the mock (both diffs against `/tmp/udps-baseline.*` still empty; no `source-` page appears, because UDPS has no panels), and the Task 5 chooser runs. + +- [ ] **Step 8: Commit** + +```bash +git add src CMakeLists.txt +git commit -m "source: capability panels as side-stack pages, with a probe for the path" +``` + +--- + +## Task 7: Documentation and the final check + +**Files:** +- Modify: `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `README.md`, `CLAUDE.md` + +**Interfaces:** none; documentation only. + +- [ ] **Step 1: `architecture.md`** + +In "Threads" (line ~13) replace "a producer thread in `Acquisition` owns `udps_client_t` and calls `udps_client_poll()`" with "a producer thread in `Acquisition` owns a `DataSource` and calls `source->poll(50)`". Add a "### Sources" section after "### Threads", covering exactly: + +- The kinds live in `src/source/`; `register_builtin_sources()` fills the registry explicitly (no static-init registration); a future `dlopen` loader would only call `register_kind()`. +- `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`; `Frame` is source-neutral and its pointers are valid only during `on_data`. +- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained at the top of `poll()`. A panel never calls a source directly. +- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in `src/source/udps_*.cpp`. +- The frame is single-timebase; a multi-timebase source (ABScope) will change the interface and the data path together. +- Panels are pages of `side_stack` named `source-`; disconnected means insensitive, not hidden. +- Session file naming for non-UDPS kinds (pseudo-host `-`, port 1; `source_kind` / `source_opt_` keys; UDPS files unchanged). + +- [ ] **Step 2: `build-and-run.md`** + +After the two example launches (line ~15) add: + +```sh +./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port +``` +and say that `--help` lists the registered kinds. In the Options line (line ~99) add `--source NAME` and the repeatable `--source-opt k=v`, and say `--server/--port/--multicast/--interface` populate the UDPS entries. + +- [ ] **Step 3: `verification.md`** + +Add under the probe list, after the `UDPSCOPE_PROBE_SIDE_TAB_MS` entry (line ~415): + +``` +- `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. + Seven `[source-kinds]` lines; the reading's own entry is in `probe-readings.md`. +``` +Also state, in the baselines section, that the UDPS runs must be unchanged by the plugin work. + +- [ ] **Step 4: `probe-readings.md`** + +Add a `## [source-kinds]` section after `[side-tab-probe]` containing the seven expected lines from Task 6 Step 7 and, for each, the defect a wrong reading names: +- `kinds=udps,probe`: a missing kind means `register_builtin_sources()` was not called before `parse_args()`, or the env var gate is wrong. +- `round_trip=0`: a form's `write` and `read` disagree about a key (a renamed `SourceConfig` key, or an absent key not defaulted). +- `panel disconnected: sensitive=0 present=1`: **disable, don't hide** -- `present=0` is a panel that was hidden or never added; `sensitive=1` is a panel that stays live with no source. +- `panel connected: sensitive=1`: `apply_source_panels` acted on the wrong widget. +- `capture button: present=1 sensitive=0`: a source with no datagrams leaves the capture UI **disabled, not hidden**; `sensitive=1` is the button live for a source that has nothing to capture, `present=0` a hidden one. +- `drained=1`: the command reached the producer thread through `post()`; `drained=0` is a queue that `poll()` does not drain, `after` greater than `before + 1` is a command run twice. + +Then update every existing `mnemonics=` / `form mnemonics=8/8` mention (`grep -n "8/8" docs/claude/*.md README.md`) to `9/9` with one sentence: the chooser's new Source type row has a mnemonic. + +- [ ] **Step 5: `README.md` and `CLAUDE.md`** + +- `README.md` line ~25 (Options): add `--source`, `--source-opt`. Line ~101 (Threading): "one producer thread owns the data source (the UDPStreamer client is the built-in one) and calls `poll()`". Add a short "Data sources" subsection: the chooser's Source type, the UDPS kind, the stub that exists only for the self-check, that legacy sessions are read as UDPS, and that the flat UDP, MCC E-1608 and ABScope kinds are planned (separate sub-projects). +- `CLAUDE.md`: in "What this project is", change "receives one UDP stream with `deps/udpsclient`" to "receives one data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered source kind (see `docs/claude/architecture.md`)"; change "a producer that owns the UDP client" to "a producer that owns the data source". Add to the Architecture row of the table: "the source plugins". + +- [ ] **Step 6: Final check** + +1. Clean full build: `rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null && cmake --build build -j 2>&1 | grep -c "warning:"`. Expected: `3`. +2. UDPS self-check, then compare (number-normalised) with the baseline: + +```bash +python3 tools/mock_udpstreamer.py --port 8000 & +MOCK=$! +sleep 1 +xvfb-run -a env XDG_CONFIG_HOME=$(mktemp -d) UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8000 >/tmp/udps-final.out 2>/tmp/udps-final.err +kill $MOCK +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.out) <(sed 's/[0-9.]\+/N/g' /tmp/udps-final.out) +diff <(sed 's/[0-9.]\+/N/g' /tmp/udps-baseline.err) <(sed 's/[0-9.]\+/N/g' /tmp/udps-final.err) +grep -c "Gtk-CRITICAL" /tmp/udps-final.err; grep "gtk warnings" /tmp/udps-final.err +``` +Expected: both diffs empty, `0`, `gtk warnings: 0`. +3. The Task 6 Step 7 run and the Task 5 chooser runs, once each. +4. `grep -rn "udps_" src --include=*.cpp --include=*.hpp -l` must list only `src/source/udps_source.cpp`, `src/source/udps_source.hpp`, and `src/source/udps_kind.cpp` (if it names a `udps_*` symbol). Any other file is a leak of the old coupling; fix it. +5. Dev hygiene: `pgrep -f mock_udpstreamer` prints nothing. + +- [ ] **Step 7: Commit** + +```bash +git add docs README.md CLAUDE.md +git commit -m "docs: the source-plugin framework, the --source options and the source-kinds probe" +``` diff --git a/docs/superpowers/plans/2026-11-02-e1608-network-scan.md b/docs/superpowers/plans/2026-11-02-e1608-network-scan.md new file mode 100644 index 0000000..8362d02 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-e1608-network-scan.md @@ -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& poll(); // drains non-blocking; cumulative, one entry per MAC + std::size_t interfaces() const noexcept; // sockets opened + }; + std::vector 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& poll(); + /// How many interfaces a probe was sent on. + std::size_t interfaces() const noexcept { return socks_.size(); } + + private: + std::vector socks_; + std::vector found_; +}; + +/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses. +std::vector discover_broadcast(int port, int wait_ms); +``` + +- [ ] **Step 2: Share the reply parser** + +In `e1608_link.cpp` add includes `#include `, `#include `, `#include ` (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& found) { + if (n < 29 || buf[0] != 'D') return false; + DeviceInfo info; + info.product_id = static_cast(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(port)); + to.sin_addr.s_addr = htonl(INADDR_BROADCAST); + const std::uint8_t probe = 'D'; + std::vector 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(name.size())) != 0) { + sockaddr_in me = *reinterpret_cast(a->ifa_addr); + me.sin_port = 0; + if (::bind(s.get(), reinterpret_cast(&me), sizeof me) != 0) continue; + } + if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&to), + sizeof to) != 1) + continue; + done.push_back(name); + socks_.push_back(std::move(s)); + } + ::freeifaddrs(list); +} + +const std::vector& 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(&from), &flen); + if (n < 0) break; // nothing more on this socket for now + take_reply(buf, n, from, found_); + } + } + return found_; +} + +std::vector 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 targets = + host.empty() ? sweep_targets(cfg_.subnet) : std::vector{host}; + const std::vector found = + discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500)); +``` + +with + +```cpp + const std::vector targets = + host.empty() ? sweep_targets(cfg_.subnet) : std::vector{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 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 results; ///< the result rows of the current scan + std::unique_ptr 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 ` 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(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(d); } + +void on_pick_address(AdwActionRow*, gpointer user) { + Pick& p = *static_cast(user); + p.form->fill(p.ip, ""); +} + +void on_pick_mac(GtkButton*, gpointer user) { + Pick& p = *static_cast(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(0)); + g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac}, + free_pick, static_cast(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(user); + Form& f = *fp; + if (!f.alive || !f.scan) { + f.timer = 0; + return G_SOURCE_REMOVE; + } + const std::vector 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(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(static_cast(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(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(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=` 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(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& 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(user); + if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE; + E1608ScanProbe& p = *g_scan_probe; + std::vector 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(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 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(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= 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= 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 ") 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. diff --git a/docs/superpowers/plans/2026-11-02-flat-udp-source.md b/docs/superpowers/plans/2026-11-02-flat-udp-source.md new file mode 100644 index 0000000..99c89a2 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-flat-udp-source.md @@ -0,0 +1,2213 @@ +# Flat UDP Source 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 `flatudp` source kind: a receiver for the MARTe2 `UDPSender` datagram (signals concatenated in declaration order, no header), configured by a hand-entered signal list. + +**Architecture:** Three new units under `src/source/flatudp/`: a GTK-free signal-list codec, a `DataSource` that owns a non-blocking UDP socket and does all its work inside `poll()` on the producer thread, and a `SourceKind` with a row-editor connect form. The existing pipeline stamps the frames from the declared `rate`; the source never reports loss. + +**Tech Stack:** C++20, GTK4/libadwaita (>= 1.8), POSIX sockets, Python 3 for the mock sender. No unit-test suite: the contract is the application's own headless self-check (`xvfb-run`, env-scheduled probes in `src/ui_probe.cpp`). + +Spec: `docs/superpowers/specs/2026-11-02-flat-udp-source-design.md`. Branch: `feature/source-flatudp`, cut from `feature/source-plugins` (do not push or merge). + +## Global Constraints + +- Kind id `flatudp`; new files `src/source/flatudp/flatudp_{signals,source,kind}.{hpp,cpp}`, `tools/mock_flatudp.py`. +- Every source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`; `#` form in Python). +- Config keys: `port` (default 44488), `bind` (empty), `multicast` (empty), `iface` (empty), `rate` (none, required, Hz), `endian` (`little` default or `big`), `signals` (`name:type[count],...`). +- Types: `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`. Count 1 is a scalar signal, N a vector signal. +- Validity (form and `--source-opt` alike): at least one signal; names non-empty and unique; count 1-4096; total at most 65507 bytes; `rate` finite and positive. +- One datagram is one cycle. A datagram of any other size than the expected one is dropped, never decoded. +- Batching: all rows scalar means `block = clamp(round(rate / 100), 1, 1024)` datagrams per `Frame`, a partial block flushed after 100 ms. Any vector row means one datagram per `Frame` (`num_samples = 1`). +- `Frame`: `counter` a running frame count, `lost = 0`, `late = false`, `recv_time` the wall-clock epoch seconds at delivery, `publish_mode = 1`. +- `on_config` is sent from the first `poll()`: one `SignalInfo` per row (`rows = count`, `cols = 1`, `time_mode = kTimePacket`, `sampling_rate = rate`). +- `Connected` on the first valid datagram, `Disconnected` after 2 s of silence, `Connected` again on the next valid datagram. Each distinct wrong size: one `Error` ("datagram of N bytes, expected M, check the signal list"), one stderr line, `stale` incremented per wrong datagram. +- `has_datagrams()` is true and `set_datagram_tap` is honoured for every datagram, valid or not. +- Threading unchanged: the source lives on the producer thread; the probe reads only atomics. +- GTK traps: notify handlers are `(GObject*, GParamSpec*, gpointer)`, `clicked` is `(GtkButton*, gpointer)`, `changed` is `(GtkEditable*, gpointer)`, spin `value-changed` is `(GtkSpinButton*, gpointer)`; contexts are freed by the destroy notify of `g_signal_connect_data`; rows never parented are `g_object_ref_sink` + `g_object_unref` in probes; no theme icons (text buttons only). +- Baselines of every run: a clean build has exactly 3 warnings (an incremental one shows the 2 pre-existing `ui_probe.cpp` `MathNode::source` ones; none from the new files), the exit dump reads `gtk warnings: 0`, no `Gtk-CRITICAL`. +- Keep verification runs short and serial; kill every mock afterwards. +- Never push, never merge. Commit per task. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `src/source/flatudp/flatudp_signals.{hpp,cpp}` (new) | `Type`/`Row`, the `signals` string codec (`parse`, `format`), `problem`, size arithmetic, `decode_datagram`. No GTK, no sockets. | +| `src/source/flatudp/flatudp_source.{hpp,cpp}` (new) | `FlatUdpSettings` (config parsing and validity, `valid_ipv4`, `valid_multicast`) and `FlatUdpSource` (socket, batching, events). | +| `src/source/flatudp/flatudp_kind.cpp` (new) | `FlatUdpKind`, the connect form, `make_flatudp_kind()`. | +| `src/source/registry.{hpp,cpp}` | Declare and register `make_flatudp_kind()` after e1608. | +| `CMakeLists.txt` | The five new `.cpp` files. | +| `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp` | The `UDPSCOPE_PROBE_FLATUDP` probe family and the `[source-kinds]` sample. | +| `tools/mock_flatudp.py` (new) | A UDPSender-shaped packet source for a signal spec. | +| `README.md`, `docs/claude/{build-and-run,verification,probe-readings,architecture}.md` | The kind, its limits, the run recipes and the probe readings. | + +## Task ordering + +1. **Codec** (pure, checked by a hand-computed probe line). +2. **Settings, source, kind with an interim form, mock, stream probes, docs** (end to end against the mock). +3. **Row-editor form**, its probe lines, README and architecture docs. + +--- + +### Task 1: The signal-list codec + +**Files:** +- Create: `src/source/flatudp/flatudp_signals.hpp`, `src/source/flatudp/flatudp_signals.cpp` +- Modify: `CMakeLists.txt` (source list, after `src/source/e1608/e1608_panels.cpp`) +- Modify: `src/ui_probe.cpp` (include, `flatudp_codec_check()` and `flatudp_probe()` after `e1608_probe_reconnect`) +- Modify: `src/ui_app.hpp` (declaration after `e1608_probe_resumed`), `src/ui.cpp` (env hook after `UDPSCOPE_PROBE_E1608_RECONNECT`) + +**Interfaces:** +- Consumes: nothing. +- Produces (namespace `udpscope::flatudp`, header `source/flatudp/flatudp_signals.hpp`): + - `enum class Type : std::uint8_t { U8 = 0, I8, U16, I16, U32, I32, U64, I64, F32, F64 }` -- the numeric value **is** the `SignalInfo::type_code` (`UDPS_T_*` order). + - `constexpr std::uint32_t kMaxCount = 4096; constexpr std::size_t kMaxDatagram = 65507;` + - `struct Row { std::string name; Type type = Type::F32; std::uint32_t count = 1; bool operator==(const Row&) const = default; };` + - `std::size_t type_size(Type)`, `const char* type_name(Type)`, `bool parse_type(const std::string&, Type&)` + - `std::size_t datagram_size(const std::vector&)`, `bool all_scalar(const std::vector&)` + - `std::string format(const std::vector&)`; `std::string parse(const std::string& text, std::vector& rows)` (returns an error text, empty on success; `rows` is cleared on failure) + - `std::string problem(const std::vector&)` (first validity failure, empty when valid) + - `void decode_datagram(const std::vector&, const std::uint8_t* data, bool big_endian, std::vector>& cols)` -- appends row `s`'s `count` values to `cols[s]` (growing `cols` to `rows.size()`); the caller guarantees `datagram_size(rows)` bytes at `data`. + - In `ui_app.hpp`: `gboolean flatudp_probe(gpointer user);` + +- [ ] **Step 1: Create the branch** + +```bash +git checkout feature/source-plugins && git checkout -b feature/source-flatudp +``` + +- [ ] **Step 2: Write the header** + +Create `src/source/flatudp/flatudp_signals.hpp`: + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include +#include + +namespace udpscope::flatudp { + +/// The numeric value is the `SignalInfo::type_code` (the order of the client library's `UDPS_T_*`). +enum class Type : std::uint8_t { U8 = 0, I8, U16, I16, U32, I32, U64, I64, F32, F64 }; + +constexpr std::uint32_t kMaxCount = 4096; +constexpr std::size_t kMaxDatagram = 65507; ///< the largest UDP payload over IPv4 + +/// One signal of the sender's datagram: `count` consecutive values of `type`. +struct Row { + std::string name; + Type type = Type::F32; + std::uint32_t count = 1; + bool operator==(const Row&) const = default; +}; + +std::size_t type_size(Type t); +const char* type_name(Type t); ///< "uint8" ... "float64" +bool parse_type(const std::string& s, Type& out); + +/// The bytes one datagram must have: the sum of `type_size * count`. +std::size_t datagram_size(const std::vector& rows); +/// True when no row is a vector. Such a list can be batched, several datagrams to a frame. +bool all_scalar(const std::vector& rows); + +/// `name:type[,name:type[count]...]`; a count of 1 is written without brackets. +std::string format(const std::vector& rows); +/// The inverse of `format`. Whitespace around names, types and counts is ignored. An empty text +/// is an empty list (and not an error: `problem` reports it). Returns an error in words, empty +/// on success; `rows` is empty after a failure. +std::string parse(const std::string& text, std::vector& rows); + +/// The first reason this list cannot describe a datagram, in words for the user; empty when valid. +std::string problem(const std::vector& rows); + +/// Decodes one datagram of `datagram_size(rows)` bytes to doubles, row by row, byte-swapping when +/// `big_endian` differs from the host. Appends to `cols[s]`; the caller clears them between uses. +void decode_datagram(const std::vector& rows, const std::uint8_t* data, bool big_endian, + std::vector>& cols); + +} // namespace udpscope::flatudp +``` + +- [ ] **Step 3: Write the implementation** + +Create `src/source/flatudp/flatudp_signals.cpp`: + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/flatudp/flatudp_signals.hpp" + +#include +#include +#include +#include +#include + +namespace udpscope::flatudp { +namespace { + +constexpr const char* kNames[] = {"uint8", "int8", "uint16", "int16", "uint32", + "int32", "uint64", "int64", "float32", "float64"}; +constexpr std::size_t kSizes[] = {1, 1, 2, 2, 4, 4, 8, 8, 4, 8}; + +std::string trim(const std::string& s) { + std::size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +/// A name must survive `format` and `parse`: none of the separators, no edge whitespace. +bool valid_name(const std::string& n) { + if (n.empty()) return false; + if (std::isspace(static_cast(n.front())) || + std::isspace(static_cast(n.back()))) + return false; + for (char c : n) { + if (c == ',' || c == ':' || c == '[' || c == ']' || static_cast(c) < 0x20) + return false; + } + return true; +} + +template +double load(const std::uint8_t* p, bool swap) { + unsigned char b[sizeof(T)]; + std::memcpy(b, p, sizeof(T)); + if (swap) std::reverse(b, b + sizeof(T)); + T v; + std::memcpy(&v, b, sizeof(T)); + return static_cast(v); +} + +double load_typed(Type t, const std::uint8_t* p, bool swap) { + switch (t) { + case Type::U8: return load(p, swap); + case Type::I8: return load(p, swap); + case Type::U16: return load(p, swap); + case Type::I16: return load(p, swap); + case Type::U32: return load(p, swap); + case Type::I32: return load(p, swap); + case Type::U64: return load(p, swap); + case Type::I64: return load(p, swap); + case Type::F32: return load(p, swap); + case Type::F64: return load(p, swap); + } + return 0.0; +} + +} // namespace + +std::size_t type_size(Type t) { return kSizes[static_cast(t)]; } +const char* type_name(Type t) { return kNames[static_cast(t)]; } + +bool parse_type(const std::string& s, Type& out) { + for (std::size_t i = 0; i < 10; ++i) { + if (s == kNames[i]) { + out = static_cast(i); + return true; + } + } + return false; +} + +std::size_t datagram_size(const std::vector& rows) { + std::size_t n = 0; + for (const Row& r : rows) n += type_size(r.type) * static_cast(r.count); + return n; +} + +bool all_scalar(const std::vector& rows) { + return std::all_of(rows.begin(), rows.end(), [](const Row& r) { return r.count == 1; }); +} + +std::string format(const std::vector& rows) { + std::string out; + for (const Row& r : rows) { + if (!out.empty()) out += ','; + out += r.name + ':' + type_name(r.type); + if (r.count != 1) out += '[' + std::to_string(r.count) + ']'; + } + return out; +} + +std::string parse(const std::string& text, std::vector& rows) { + rows.clear(); + if (trim(text).empty()) return {}; + std::size_t pos = 0; + int index = 0; + for (;;) { + std::size_t end = text.find(',', pos); + if (end == std::string::npos) end = text.size(); + ++index; + const std::string where = "signal " + std::to_string(index) + ": "; + const std::string tok = trim(text.substr(pos, end - pos)); + const std::size_t colon = tok.find(':'); + if (colon == std::string::npos) { + rows.clear(); + return where + "expected name:type"; + } + Row r; + r.name = trim(tok.substr(0, colon)); + std::string ty = trim(tok.substr(colon + 1)); + const std::size_t br = ty.find('['); + if (br != std::string::npos) { + if (ty.back() != ']') { + rows.clear(); + return where + "missing ']' after the count"; + } + const std::string n = trim(ty.substr(br + 1, ty.size() - br - 2)); + const bool digits = !n.empty() && n.size() <= 6 && + std::all_of(n.begin(), n.end(), [](char c) { return c >= '0' && c <= '9'; }); + if (!digits) { + rows.clear(); + return where + "the count must be a whole number"; + } + r.count = static_cast(std::stoul(n)); + ty = trim(ty.substr(0, br)); + } + if (!parse_type(ty, r.type)) { + rows.clear(); + return where + "unknown type '" + ty + "'"; + } + rows.push_back(r); + if (end >= text.size()) break; + pos = end + 1; + } + return {}; +} + +std::string problem(const std::vector& rows) { + if (rows.empty()) return "add at least one signal"; + std::set seen; + for (std::size_t i = 0; i < rows.size(); ++i) { + const Row& r = rows[i]; + if (r.name.empty()) return "signal " + std::to_string(i + 1) + " has no name"; + if (!valid_name(r.name)) + return "'" + r.name + "': a name cannot hold , : [ ] or edge spaces"; + if (!seen.insert(r.name).second) return "the name '" + r.name + "' is used twice"; + if (r.count < 1 || r.count > kMaxCount) + return "'" + r.name + "': the count must be 1 to " + std::to_string(kMaxCount); + } + const std::size_t n = datagram_size(rows); + if (n > kMaxDatagram) + return std::to_string(n) + " bytes per datagram exceed the " + std::to_string(kMaxDatagram) + + " a UDP datagram can carry"; + return {}; +} + +void decode_datagram(const std::vector& rows, const std::uint8_t* data, bool big_endian, + std::vector>& cols) { + if (cols.size() < rows.size()) cols.resize(rows.size()); + const bool swap = big_endian != (std::endian::native == std::endian::big); + std::size_t off = 0; + for (std::size_t s = 0; s < rows.size(); ++s) { + const std::size_t w = type_size(rows[s].type); + for (std::uint32_t k = 0; k < rows[s].count; ++k) { + cols[s].push_back(load_typed(rows[s].type, data + off, swap)); + off += w; + } + } +} + +} // namespace udpscope::flatudp +``` + +- [ ] **Step 4: Register in CMake** + +In `CMakeLists.txt`, after the line ` src/source/e1608/e1608_panels.cpp` add: + +```cmake + src/source/flatudp/flatudp_signals.cpp +``` + +- [ ] **Step 5: Write the failing probe (codec check)** + +In `src/ui_probe.cpp` add the include after `#include "source/e1608/e1608_source.hpp"`: + +```cpp +#include "source/flatudp/flatudp_signals.hpp" +``` + +In the anonymous namespace, directly after `e1608_decode_check()`'s closing brace and before the namespace's closing `} // namespace`, add: + +```cpp +/// The `signals` codec and the datagram decode against values worked out by hand. The row set +/// mixes every width and a vector: A:uint8, B:int16, C:float32, D:uint64[2], E:int8, 24 bytes. +/// A = 254, B = -2, C = 1.5, D = {1, 4294967298}, E = -1. +bool flatudp_codec_check() { + using namespace flatudp; + std::vector rows; + if (!parse(" A:uint8, B:int16,C : float32 ,D:uint64[2],E:int8", rows).empty()) return false; + const std::vector want = {{"A", Type::U8, 1}, {"B", Type::I16, 1}, {"C", Type::F32, 1}, + {"D", Type::U64, 2}, {"E", Type::I8, 1}}; + if (rows != want) return false; + if (format(rows) != "A:uint8,B:int16,C:float32,D:uint64[2],E:int8") return false; + if (datagram_size(rows) != 24 || all_scalar(rows) || !problem(rows).empty()) return false; + // The enum value is the type code the model keeps. + if (static_cast(Type::F64) != 9 || static_cast(Type::U64) != 6) return false; + + const std::uint8_t le[24] = {0xFE, // A + 0xFE, 0xFF, // B + 0x00, 0x00, 0xC0, 0x3F, // C + 0x01, 0, 0, 0, 0, 0, 0, 0, // D[0] + 0x02, 0, 0, 0, 0x01, 0, 0, 0, // D[1] + 0xFF}; // E + const std::uint8_t be[24] = {0xFE, + 0xFF, 0xFE, + 0x3F, 0xC0, 0x00, 0x00, + 0, 0, 0, 0, 0, 0, 0, 0x01, + 0, 0, 0, 0x01, 0, 0, 0, 0x02, + 0xFF}; + const double expect[5][2] = {{254, 0}, {-2, 0}, {1.5, 0}, {1, 4294967298.0}, {-1, 0}}; + const std::size_t counts[5] = {1, 1, 1, 2, 1}; + for (int pass = 0; pass < 2; ++pass) { + std::vector> cols; + decode_datagram(rows, pass == 0 ? le : be, pass == 1, cols); + if (cols.size() != 5) return false; + for (std::size_t s = 0; s < 5; ++s) { + if (cols[s].size() != counts[s]) return false; + for (std::size_t k = 0; k < counts[s]; ++k) + if (cols[s][k] != expect[s][k]) return false; + } + } + + // Parse failures name the signal; a bad list never half-parses. + std::vector bad; + if (parse("A:uint8,B", bad).find("signal 2") == std::string::npos || !bad.empty()) return false; + if (parse("A:uint7", bad).find("uint7") == std::string::npos) return false; + if (parse("A:uint8[x]", bad).empty() || parse("A:uint8[3", bad).empty()) return false; + if (!parse("", bad).empty() || !bad.empty() || problem(bad).empty()) return false; + + // The validity rules. + const auto prob = [](std::vector r) { return problem(r); }; + if (prob({{"A", Type::U8, 1}, {"A", Type::U8, 1}}).find("twice") == std::string::npos) return false; + if (prob({{"", Type::U8, 1}}).empty() || prob({{"a,b", Type::U8, 1}}).empty()) return false; + if (prob({{"A", Type::U8, 0}}).empty() || prob({{"A", Type::U8, 4097}}).empty()) return false; + if (!prob({{"A", Type::U8, 4096}}).empty()) return false; + // 2 x 32768 = 65536 bytes is over the datagram limit; 32768 + 16384 is not. + if (prob({{"A", Type::F64, 4096}, {"B", Type::F64, 4096}}).find("65536") == std::string::npos) + return false; + if (!prob({{"A", Type::F64, 4096}, {"B", Type::F32, 4096}}).empty()) return false; + return true; +} +``` + +Then, after the function `e1608_probe_reconnect` (outside the anonymous namespace, directly before `gboolean pan_probe`), add: + +```cpp +/// The flat UDP source's probe. For now only the pure codec check; it needs no flatudp source. +gboolean flatudp_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + std::fprintf(stderr, "[flatudp] codec: ok=%d\n", flatudp_codec_check() ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} +``` + +In `src/ui_app.hpp`, after `gboolean e1608_probe_resumed(gpointer user);` add: + +```cpp +/// The flat UDP (MARTe2 UDPSender) source: the signal-list codec and a hand-computed decode, then, +/// against `tools/mock_flatudp.py`, the stream, the wrong-size accounting and the silence timeout. +gboolean flatudp_probe(gpointer user); +``` + +In `src/ui.cpp`, after the `UDPSCOPE_PROBE_E1608_RECONNECT` block add: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) { + g_timeout_add(static_cast(std::atoi(env)), flatudp_probe, &app); + } +``` + +- [ ] **Step 6: Build and run the check** + +This is the first run of the check (the implementation is written in step 3). Reconfigure (new source file) and build: + +```bash +cmake -S . -B build >/dev/null && cmake --build build -j8 2>&1 | grep -E "warning|error|flatudp" | head -20 +``` + +Expected: no `error`; no warning mentioning `flatudp`. + +```bash +UDPSCOPE_PROBE_SOURCE_KINDS=60000 UDPSCOPE_PROBE_FLATUDP=1500 UDPSCOPE_EXIT_AFTER_MS=3000 \ + timeout 30 xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings" +``` + +Expected: `[flatudp] codec: ok=1` and `gtk warnings: 0`, no `Gtk-CRITICAL`. If `ok=0`, bisect the check by returning early at each stage; every expected value above is hand-computed, so a mismatch is a codec defect, not a test defect (re-derive the byte arrays before touching the codec). + +- [ ] **Step 7: Commit** + +```bash +git add CMakeLists.txt src/source/flatudp/flatudp_signals.hpp src/source/flatudp/flatudp_signals.cpp \ + src/ui_probe.cpp src/ui_app.hpp src/ui.cpp +git commit -m "flatudp: the signal-list codec, with a hand-computed decode check" +``` + +--- +### Task 2: The settings, the source, the kind, the mock and the stream probes + +**Files:** +- Create: `src/source/flatudp/flatudp_source.hpp`, `src/source/flatudp/flatudp_source.cpp`, `src/source/flatudp/flatudp_kind.cpp`, `tools/mock_flatudp.py` +- Modify: `CMakeLists.txt`, `src/source/registry.hpp`, `src/source/registry.cpp` +- Modify: `src/ui_probe.cpp` (include, anonymous-namespace helpers, `flatudp_probe` rewritten, three new phase functions, the `[source-kinds]` sample), `src/ui_app.hpp`, `src/ui.cpp` +- Modify: `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md` + +**Interfaces:** +- Consumes (Task 1): everything in `flatudp_signals.hpp`. +- Produces (namespace `udpscope::flatudp`, header `source/flatudp/flatudp_source.hpp`): + - `constexpr int kDefaultPort = 44488;` + - `bool valid_ipv4(const std::string&)`, `bool valid_multicast(const std::string&)` (224.0.0.0/4) + - `struct FlatUdpSettings { int port; std::string bind, multicast, iface; double rate; bool big_endian; bool endian_ok; std::vector rows; std::string signals_error; static FlatUdpSettings from(const SourceConfig&); std::string problem() const; std::string identity() const; std::size_t block() const; }` + - `class FlatUdpSource final : public DataSource` with `explicit FlatUdpSource(const SourceConfig&)`, `const FlatUdpSettings& settings() const`, `std::uint64_t valid_datagrams() const`, `std::uint64_t wrong_size_datagrams() const`, `std::string last_message() const`. + - `std::unique_ptr make_flatudp_kind();` (namespace `udpscope`, declared in `registry.hpp`). + - In `ui_app.hpp`: `flatudp_probe`, `flatudp_probe_report`, `flatudp_probe_gap`, `flatudp_probe_resumed`. + +- [ ] **Step 1: Write the settings and source header** + +Create `src/source/flatudp/flatudp_source.hpp`: + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include "source/data_source.hpp" +#include "source/flatudp/flatudp_signals.hpp" +#include "source/source_config.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope::flatudp { + +constexpr int kDefaultPort = 44488; ///< `UDPSender`'s own default + +bool valid_ipv4(const std::string& s); +/// An IPv4 address in 224.0.0.0/4. +bool valid_multicast(const std::string& s); + +/// Everything a configured flat UDP source needs, parsed once from the flat `SourceConfig`. +struct FlatUdpSettings { + int port = kDefaultPort; + std::string bind; ///< local address; empty is any + std::string multicast; ///< group to join; empty is plain unicast + std::string iface; ///< local interface address for the join; empty is the system's choice + double rate = 0.0; ///< the sender's cycle rate in Hz; 0 means not given + bool big_endian = false; + bool endian_ok = true; ///< false when `endian` is neither "little" nor "big" + std::vector rows; + std::string signals_error; ///< why the `signals` text did not parse; `rows` is empty then + + static FlatUdpSettings from(const SourceConfig& cfg); + /// The first reason this cannot connect, in words for the user; empty when it can. + std::string problem() const; + /// `[multicast:]port`; names the session file. + std::string identity() const; + /// Datagrams per frame: 1 for a list with a vector, else `clamp(round(rate / 100), 1, 1024)`. + std::size_t block() const; +}; + +class FlatUdpSource final : public DataSource { + public: + explicit FlatUdpSource(const SourceConfig& cfg); + ~FlatUdpSource() override; + + void open(SourceSink& sink) override; + void poll(int timeout_ms) override; + void close() override; + SourceStats stats() const override; + bool connected() const override { return connected_.load(std::memory_order_relaxed); } + void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); } + + const FlatUdpSettings& settings() const { return cfg_; } + /// Datagrams of the expected size / of any other size; for the probe, readable from any thread. + std::uint64_t valid_datagrams() const { return valid_.load(std::memory_order_relaxed); } + std::uint64_t wrong_size_datagrams() const { return wrong_.load(std::memory_order_relaxed); } + std::string last_message() const; + + private: + using Clock = std::chrono::steady_clock; + + void close_fd(); + void drain_socket(); + void accept(const std::uint8_t* data); + void reject(std::size_t len); + void flush(); + void note(const std::string& msg); + + FlatUdpSettings cfg_; + SourceSink* sink_ = nullptr; + DatagramTap tap_; + int fd_ = -1; + bool configured_ = false; + bool ever_connected_ = false; + std::size_t expected_ = 0; ///< bytes of a valid datagram + std::size_t block_ = 1; + + std::vector signals_; + std::vector rx_; + std::vector> pending_; ///< decoded, not yet framed: [signal][value] + std::vector views_; + std::size_t pending_n_ = 0; ///< datagrams in `pending_` + Clock::time_point batch_start_; + Clock::time_point last_valid_; + std::set reported_sizes_; + + std::atomic connected_{false}; + std::atomic valid_{0}; + std::atomic wrong_{0}; + mutable std::mutex msg_mu_; + std::string last_message_; + + std::uint32_t frame_counter_ = 0; + std::uint64_t frames_ = 0; + std::uint64_t reconnects_ = 0; +}; + +} // namespace udpscope::flatudp +``` + +- [ ] **Step 2: Write the settings and source implementation** + +Create `src/source/flatudp/flatudp_source.cpp`: + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/flatudp/flatudp_source.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace udpscope::flatudp { +namespace { + +using namespace std::chrono_literals; + +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +std::string trim(const std::string& s) { + std::size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +double epoch_seconds() { + return std::chrono::duration(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +constexpr std::size_t kMaxReportedSizes = 8; ///< a sender of random sizes must not flood stderr + +} // namespace + +bool valid_ipv4(const std::string& s) { + in_addr a{}; + return inet_pton(AF_INET, s.c_str(), &a) == 1; +} + +bool valid_multicast(const std::string& s) { + in_addr a{}; + if (inet_pton(AF_INET, s.c_str(), &a) != 1) return false; + return (ntohl(a.s_addr) >> 28) == 0xE; +} + +// --- FlatUdpSettings ------------------------------------------------------------------------ + +FlatUdpSettings FlatUdpSettings::from(const SourceConfig& c) { + FlatUdpSettings s; + s.port = static_cast(c.get_int("port", kDefaultPort)); + s.bind = trim(c.get("bind")); + s.multicast = trim(c.get("multicast")); + s.iface = trim(c.get("iface")); + s.rate = c.get_double("rate", 0.0); + const std::string e = lower(trim(c.get("endian", "little"))); + s.endian_ok = e == "little" || e == "big"; + s.big_endian = e == "big"; + s.signals_error = parse(c.get("signals"), s.rows); + return s; +} + +std::string FlatUdpSettings::problem() const { + if (port < 1 || port > 65535) return "the port must be 1 to 65535"; + if (!signals_error.empty()) return signals_error; + const std::string p = flatudp::problem(rows); + if (!p.empty()) return p; + if (!(rate > 0.0) || !std::isfinite(rate)) return "give the sender's cycle rate in Hz (rate)"; + if (!endian_ok) return "endian must be little or big"; + if (!bind.empty() && !valid_ipv4(bind)) return "the bind address '" + bind + "' is not an IPv4 address"; + if (!multicast.empty() && !valid_multicast(multicast)) + return "'" + multicast + "' is not a multicast group (224.0.0.0 to 239.255.255.255)"; + if (!iface.empty() && !valid_ipv4(iface)) + return "the interface address '" + iface + "' is not an IPv4 address"; + return {}; +} + +std::string FlatUdpSettings::identity() const { + return (multicast.empty() ? std::string() : multicast + ":") + std::to_string(port); +} + +std::size_t FlatUdpSettings::block() const { + if (!all_scalar(rows) || !(rate > 0.0) || !std::isfinite(rate)) return 1; + const long b = std::lround(rate / 100.0); + return static_cast(std::min(1024L, std::max(1L, b))); +} + +// --- FlatUdpSource -------------------------------------------------------------------------- + +FlatUdpSource::FlatUdpSource(const SourceConfig& cfg) : cfg_(FlatUdpSettings::from(cfg)) {} + +FlatUdpSource::~FlatUdpSource() { close_fd(); } + +std::string FlatUdpSource::last_message() const { + std::lock_guard lk(msg_mu_); + return last_message_; +} + +void FlatUdpSource::note(const std::string& msg) { + { + std::lock_guard lk(msg_mu_); + last_message_ = msg; + } + std::fprintf(stderr, "[flatudp] %s\n", msg.c_str()); + std::fflush(stderr); +} + +void FlatUdpSource::close_fd() { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } +} + +void FlatUdpSource::open(SourceSink& sink) { + const std::string p = cfg_.problem(); + if (!p.empty()) throw std::runtime_error(p); + sink_ = &sink; + + signals_.clear(); + for (const Row& r : cfg_.rows) { + SignalInfo s; + s.name = r.name; + s.type_code = static_cast(r.type); + s.rows = r.count; + s.cols = 1; + s.time_mode = kTimePacket; + s.sampling_rate = cfg_.rate; + signals_.push_back(s); + } + expected_ = datagram_size(cfg_.rows); + block_ = cfg_.block(); + pending_.assign(cfg_.rows.size(), {}); + pending_n_ = 0; + rx_.assign(65536, 0); + + const auto fail = [this](const std::string& what) { + const std::string msg = what + ": " + std::strerror(errno); + close_fd(); + throw std::runtime_error(msg); + }; + + fd_ = ::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (fd_ < 0) fail("cannot create a UDP socket"); + // Receivers of one multicast group share its port; a unicast port is the one app's. + if (!cfg_.multicast.empty()) { + const int one = 1; + ::setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + } + const int rcvbuf = 4 << 20; // best effort: a 1 kHz sender at 64 KiB would not fit a default one + ::setsockopt(fd_, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(cfg_.port)); + addr.sin_addr.s_addr = htonl(INADDR_ANY); + if (!cfg_.bind.empty()) inet_pton(AF_INET, cfg_.bind.c_str(), &addr.sin_addr); + if (::bind(fd_, reinterpret_cast(&addr), sizeof addr) < 0) + fail("cannot bind UDP port " + std::to_string(cfg_.port)); + + if (!cfg_.multicast.empty()) { + ip_mreq mreq{}; + inet_pton(AF_INET, cfg_.multicast.c_str(), &mreq.imr_multiaddr); + if (cfg_.iface.empty()) { + mreq.imr_interface.s_addr = htonl(INADDR_ANY); + } else { + inet_pton(AF_INET, cfg_.iface.c_str(), &mreq.imr_interface); + } + if (::setsockopt(fd_, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof mreq) < 0) + fail("cannot join multicast group " + cfg_.multicast); + } +} + +void FlatUdpSource::close() { + close_fd(); + connected_.store(false, std::memory_order_relaxed); +} + +SourceStats FlatUdpSource::stats() const { + SourceStats s; + s.packets = valid_.load(std::memory_order_relaxed) + wrong_.load(std::memory_order_relaxed); + s.frames = frames_; + s.stale = wrong_.load(std::memory_order_relaxed); + s.reconnects = reconnects_; + s.connected = connected_.load(std::memory_order_relaxed); + return s; +} + +void FlatUdpSource::poll(int timeout_ms) { + drain_commands(); + // Never wait long: the partial-block flush and the silence timeout are checked on this cadence. + const int wait = std::clamp(timeout_ms, 0, 20); + if (fd_ < 0 || sink_ == nullptr) { + std::this_thread::sleep_for(std::chrono::milliseconds(wait)); + return; + } + if (!configured_) { + configured_ = true; + sink_->on_config(signals_, 1); + } + pollfd p{}; + p.fd = fd_; + p.events = POLLIN; + if (::poll(&p, 1, wait) > 0 && (p.revents & POLLIN)) drain_socket(); + + const auto now = Clock::now(); + if (pending_n_ > 0 && now - batch_start_ >= 100ms) flush(); // a stopped sender holds nothing back + if (connected_.load(std::memory_order_relaxed) && now - last_valid_ >= 2s) { + connected_.store(false, std::memory_order_relaxed); + sink_->on_event(SourceEvent::Disconnected, "no datagram for 2 s"); + } +} + +void FlatUdpSource::drain_socket() { + for (int i = 0; i < 8192; ++i) { // bounded, so a flood cannot starve the timers + const ssize_t n = ::recv(fd_, rx_.data(), rx_.size(), 0); + if (n < 0) { + if (errno == EINTR) continue; + break; // EAGAIN: drained + } + const double t = epoch_seconds(); + const std::size_t len = static_cast(n); + if (tap_) tap_(rx_.data(), len, t); + if (len == expected_) { + accept(rx_.data()); + } else { + reject(len); + } + } +} + +void FlatUdpSource::accept(const std::uint8_t* data) { + valid_.fetch_add(1, std::memory_order_relaxed); + last_valid_ = Clock::now(); + if (!connected_.load(std::memory_order_relaxed)) { + if (ever_connected_) ++reconnects_; + ever_connected_ = true; + connected_.store(true, std::memory_order_relaxed); + sink_->on_event(SourceEvent::Connected, ""); + } + decode_datagram(cfg_.rows, data, cfg_.big_endian, pending_); + if (pending_n_ == 0) batch_start_ = last_valid_; + if (++pending_n_ >= block_) flush(); +} + +void FlatUdpSource::reject(std::size_t len) { + wrong_.fetch_add(1, std::memory_order_relaxed); + if (reported_sizes_.size() >= kMaxReportedSizes || !reported_sizes_.insert(len).second) return; + const std::string msg = "datagram of " + std::to_string(len) + " bytes, expected " + + std::to_string(expected_) + ", check the signal list"; + note(msg); + sink_->on_event(SourceEvent::Error, msg.c_str()); +} + +void FlatUdpSource::flush() { + const std::size_t n = signals_.size(); + views_.resize(n); + for (std::size_t i = 0; i < n; ++i) + views_[i] = SignalValues{pending_[i].data(), static_cast(pending_[i].size())}; + Frame f; + f.counter = frame_counter_++; + f.lost = 0; + f.late = false; + f.recv_time = epoch_seconds(); + f.publish_mode = 1; + f.num_samples = static_cast(pending_n_); + f.num_signals = static_cast(n); + f.signals = signals_.data(); + f.values = views_.data(); + ++frames_; + sink_->on_data(f); + for (auto& c : pending_) c.clear(); + pending_n_ = 0; +} + +} // namespace udpscope::flatudp +``` + +- [ ] **Step 3: Write the kind with an interim form** + +The `signals` row is a plain text entry here; Task 3 replaces it with the row editor. Create `src/source/flatudp/flatudp_kind.cpp`: + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/flatudp/flatudp_source.hpp" +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include + +namespace udpscope { +namespace { + +using namespace flatudp; +using ChangeCb = std::shared_ptr>; + +std::string num(double v) { + char b[48]; + std::snprintf(b, sizeof b, "%.10g", v); + return b; +} + +GtkWidget* entry_row(const char* title) { + GtkWidget* r = adw_entry_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + return r; +} + +GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) { + GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast(digits)); + adw_spin_row_set_value(ADW_SPIN_ROW(r), def); + return r; +} + +GtkWidget* combo_row(const char* title, const char* const* items) { + GtkWidget* r = adw_combo_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items))); + return r; +} + +GtkWidget* expander(const char* title, std::initializer_list rows) { + GtkWidget* e = adw_expander_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title); + for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r); + return e; +} + +std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); } +double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); } +guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); } + +void mark(GtkWidget* w, bool bad) { + if (bad) { + gtk_widget_add_css_class(w, "error"); + } else { + gtk_widget_remove_css_class(w, "error"); + } +} + +/// The form's widgets, shared by every signal closure and every `ConfigForm` closure. +struct Form { + GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *signals; + ChangeCb changed = std::make_shared>(); + bool updating = false; + bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing + + /// A bad address is marked with the `error` style; the rest of the problem is `valid()`'s. + void refresh() { + mark(bind, !text_of(bind).empty() && !valid_ipv4(text_of(bind))); + mark(multicast, !text_of(multicast).empty() && !valid_multicast(text_of(multicast))); + mark(iface, !text_of(iface).empty() && !valid_ipv4(text_of(iface))); + } + + SourceConfig read() const { + SourceConfig c; + c.set("port", num(value_of(port))); + if (!text_of(bind).empty()) c.set("bind", text_of(bind)); + if (!text_of(multicast).empty()) c.set("multicast", text_of(multicast)); + if (!text_of(iface).empty()) c.set("iface", text_of(iface)); + c.set("rate", num(value_of(rate))); + c.set("endian", sel_of(endian) == 1 ? "big" : "little"); + c.set("signals", text_of(signals)); + return c; + } + + void write(const SourceConfig& cfg) { + const FlatUdpSettings s = FlatUdpSettings::from(cfg); + updating = true; + adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port); + gtk_editable_set_text(GTK_EDITABLE(bind), s.bind.c_str()); + gtk_editable_set_text(GTK_EDITABLE(multicast), s.multicast.c_str()); + gtk_editable_set_text(GTK_EDITABLE(iface), s.iface.c_str()); + if (s.rate > 0.0) adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.rate); + adw_combo_row_set_selected(ADW_COMBO_ROW(endian), s.big_endian ? 1 : 0); + gtk_editable_set_text(GTK_EDITABLE(signals), cfg.get("signals").c_str()); + updating = false; + refresh(); + } + + bool valid() const { return FlatUdpSettings::from(read()).problem().empty(); } +}; + +using FormPtr = std::shared_ptr; + +void on_notify(GObject*, GParamSpec*, gpointer user) { + Form& f = **static_cast(user); + if (!f.alive) return; + f.refresh(); + if (!f.updating && f.changed && *f.changed) (*f.changed)(); +} + +void on_edited(GtkEditable*, gpointer user) { + Form& f = **static_cast(user); + if (!f.alive) return; + f.refresh(); + if (!f.updating && f.changed && *f.changed) (*f.changed)(); +} + +void free_form(gpointer d, GClosure*) { delete static_cast(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(user)).alive = false; } + +void hook_destroy(GtkWidget* w, const FormPtr& f) { + g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form, + static_cast(0)); +} + +void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) { + g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form, + static_cast(0)); +} + +void hook_entry(GtkWidget* w, const FormPtr& f) { + g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form, + static_cast(0)); +} + +class FlatUdpKind final : public SourceKind { + public: + std::string id() const override { return "flatudp"; } + std::string display_name() const override { return "Flat UDP (MARTe2 UDPSender)"; } + bool has_datagrams() const override { return true; } + + std::string identity(const SourceConfig& cfg) const override { + return FlatUdpSettings::from(cfg).identity(); + } + + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + auto f = std::make_shared(); + static const char* const kEndian[] = {"Little endian", "Big endian", nullptr}; + + f->port = spin_row("_Port", 1, 65535, 1, kDefaultPort, 0); + f->bind = entry_row("_Bind address"); + f->multicast = entry_row("_Multicast group"); + f->iface = entry_row("Multicast _interface"); + f->rate = spin_row("Cycle _rate (Hz)", 0.001, 1000000, 1, 1000, 3); + f->endian = combo_row("_Byte order", kEndian); + f->signals = entry_row("_Signals"); + + GtkWidget* net = expander("Network", {f->port, f->bind, f->multicast, f->iface}); + GtkWidget* timing = expander("Timing and format", {f->rate, f->endian}); + GtkWidget* sig = expander("Signals", {f->signals}); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(net), TRUE); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(sig), TRUE); + + for (GtkWidget* w : {net, timing, sig}) hook_destroy(w, f); + for (GtkWidget* w : {f->bind, f->multicast, f->iface, f->signals}) hook_entry(w, f); + for (GtkWidget* w : {f->port, f->rate}) hook_notify(w, "notify::value", f); + hook_notify(f->endian, "notify::selected", f); + f->refresh(); + + ConfigForm out; + out.rows = {net, timing, sig}; + out.description = + "The signal list must match the sender's exactly, in order: the datagrams carry no " + "description. Write name:type[count], comma separated, e.g. Trigger:uint8,Ia:float32," + "Wave:float32[16]"; + out.focus = f->signals; + out.write = [f](const SourceConfig& c) { f->write(c); }; + out.read = [f]() { return f->read(); }; + out.valid = [f]() { return f->valid(); }; + ChangeCb cb = f->changed; + out.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return out; + } +}; + +} // namespace + +std::unique_ptr make_flatudp_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +- [ ] **Step 4: Register the kind** + +`CMakeLists.txt`, after the `flatudp_signals.cpp` line add: + +```cmake + src/source/flatudp/flatudp_source.cpp + src/source/flatudp/flatudp_kind.cpp +``` + +`src/source/registry.hpp`: change the comment above the `make_*_kind` declarations to name `flatudp/flatudp_kind.cpp` too, and add after `make_e1608_kind`: + +```cpp +std::unique_ptr make_flatudp_kind(); +``` + +`src/source/registry.cpp`, in `register_builtin_sources()`, after `register_kind(make_e1608_kind());`: + +```cpp + register_kind(make_flatudp_kind()); +``` + +- [ ] **Step 5: Write the mock sender** + +Create `tools/mock_flatudp.py` (then `chmod +x`): + +```python +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Martino Ferrari +"""A stand-in for the MARTe2 UDPSender datasource, for the flatudp source's self-check. + +One datagram is one cycle: the signals concatenated in declaration order, no padding, in the +chosen byte order. Packet i carries, for the signal of index k (from 0) and its element e, +the value (i * (k + 1) + e) % 100, so consecutive datagrams are predictable. + + --signals name:type[count],... default Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] + --rate cycles per second + --big-endian pack big endian (default little) + --gap S,D send nothing from S to S+D seconds after the start (a sender that stops) + --bad-every S one datagram one byte too long every S seconds (the wrong-size path) + --multicast G --mcast-if A send to group G out of interface A instead of --host + --log FILE one line per event +""" +import argparse +import socket +import struct +import sys +import time + +CODES = {"uint8": "B", "int8": "b", "uint16": "H", "int16": "h", "uint32": "I", "int32": "i", + "uint64": "Q", "int64": "q", "float32": "f", "float64": "d"} + + +def parse_signals(text): + out = [] + for tok in text.split(","): + name, _, ty = tok.strip().partition(":") + count = 1 + if "[" in ty: + ty, _, n = ty.partition("[") + count = int(n.rstrip("]")) + out.append((name.strip(), CODES[ty.strip()], count)) + return out + + +def make_packer(sigs, big): + st = struct.Struct((">" if big else "<") + "".join(code * count for _, code, count in sigs)) + + def pack(i): + vals = [] + for k, (_, code, count) in enumerate(sigs): + for e in range(count): + v = (i * (k + 1) + e) % 100 + vals.append(float(v) if code in "fd" else v) + return st.pack(*vals) + + return pack, st.size + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter) + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=44488) + ap.add_argument("--rate", type=float, default=1000.0) + ap.add_argument("--signals", default="Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4]") + ap.add_argument("--big-endian", action="store_true") + ap.add_argument("--gap", default="") + ap.add_argument("--bad-every", type=float, default=0.0) + ap.add_argument("--multicast", default="") + ap.add_argument("--mcast-if", default="127.0.0.1") + ap.add_argument("--log", default="") + args = ap.parse_args() + + logf = open(args.log, "w") if args.log else None + + def log(msg): + if logf: + logf.write("%.3f %s\n" % (time.monotonic() - t0, msg)) + logf.flush() + + sigs = parse_signals(args.signals) + pack, size = make_packer(sigs, args.big_endian) + gap = tuple(float(x) for x in args.gap.split(",")) if args.gap else None + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + dest = (args.host, args.port) + if args.multicast: + dest = (args.multicast, args.port) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(args.mcast_if)) + + period = 1.0 / args.rate + t0 = time.monotonic() + log("start %s rate=%g size=%d %s" % (args.signals, args.rate, size, "big" if args.big_endian else "little")) + next_t = t0 + next_bad = t0 + args.bad_every + in_gap = False + i = 0 + try: + while True: + now = time.monotonic() + if now < next_t: + time.sleep(min(next_t - now, 0.001)) + continue + elapsed = now - t0 + if gap and gap[0] <= elapsed < gap[0] + gap[1]: + if not in_gap: + in_gap = True + log("gap begins") + next_t = now + period + time.sleep(0.01) + continue + if in_gap: + in_gap = False + log("gap ends") + if args.bad_every > 0 and now >= next_bad: + next_bad = now + args.bad_every + sock.sendto(pack(i) + b"\x00", dest) + log("bad datagram of %d bytes" % (size + 1)) + burst = 0 + while next_t <= now and burst < 64: # catch up a late timer, but bounded + sock.sendto(pack(i), dest) + i += 1 + next_t += period + burst += 1 + if next_t < now - 0.5: + next_t = now + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 6: Replace the probe** + +In `src/ui_probe.cpp`: + +1. Add the include `#include "source/flatudp/flatudp_source.hpp"` after the signals include. +2. In the anonymous namespace, directly after `flatudp_codec_check()` and before its closing `} // namespace`, add: + +```cpp +flatudp::FlatUdpSource* flatudp_source(App* a) { + return a->acq != nullptr ? dynamic_cast(a->acq->source()) : nullptr; +} + +std::uint64_t g_flatudp_frames_before = 0; + +/// The mock's signal k carries (i * (k + 1) + element) % 100 in packet i, so two consecutive +/// samples of a scalar signal differ by k + 1 modulo 100. Returns the number of steps that do not, +/// over a window clear of the head the producer is writing; -1 when it cannot be read. +long flatudp_breaks(App* a, std::size_t channel, long step) { + if (channel >= a->model->channels().size()) return -1; + const ChannelBuffer& ch = a->model->live().channel(channel); + const u64 n = ch.size(); + const u64 want = 400; + if (n <= want + 50) return -1; + std::vector v(static_cast(want)); + ch.raw(n - want - 50, n - 50, v.data()); + long breaks = 0; + for (std::size_t i = 1; i < v.size(); ++i) { + const long d = std::lround(double(v[i])) - std::lround(double(v[i - 1])); + if (((d % 100) + 100) % 100 != step) ++breaks; + } + return breaks; +} +``` + +3. Replace the whole `flatudp_probe` function (the T1 one) with: + +```cpp +/// Phase 2, 1 s after the signals were enabled: the stream, its channels, the value continuity, the +/// wrong-size accounting and the capture button. +gboolean flatudp_probe_report(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + flatudp::FlatUdpSource* src = flatudp_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + const flatudp::FlatUdpSettings& s = src->settings(); + const bool up = a->model->stats().connected.load(std::memory_order_relaxed); + + const std::vector& sigs = a->model->signals(); + std::string names, expect_names; + std::size_t expect_channels = 0; + for (const SignalInfo& si : sigs) names += (names.empty() ? "" : ",") + si.name; + for (const flatudp::Row& r : s.rows) { + expect_names += (expect_names.empty() ? "" : ",") + r.name; + expect_channels += r.count; + } + const double rate = sigs.empty() ? 0.0 : sigs.front().sampling_rate; + const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed); + const bool stream_ok = up && names == expect_names && std::fabs(rate - s.rate) < 1e-6 && frames > 0; + std::fprintf(stderr, + "[flatudp] stream: connected=%d signals=%zu names=%s expect=%s frames=%" G_GUINT64_FORMAT + " rate=%.3f ok=%d\n", + up ? 1 : 0, sigs.size(), names.c_str(), expect_names.c_str(), frames, rate, + stream_ok ? 1 : 0); + + const std::size_t channels = a->model->channels().size(); + std::fprintf(stderr, "[flatudp] channels: count=%zu expect=%zu ok=%d\n", channels, expect_channels, + channels == expect_channels ? 1 : 0); + + // Only the first two signals are checked, and only when they are scalars (the mock's default). + long b0 = -1, b1 = -1; + if (up && s.rows.size() >= 2 && s.rows[0].count == 1 && s.rows[1].count == 1) { + b0 = flatudp_breaks(a, 0, 1); + b1 = flatudp_breaks(a, 1, 2); + } + std::fprintf(stderr, "[flatudp] continuity: ch0_breaks=%ld ch1_breaks=%ld ok=%d\n", b0, b1, + b0 == 0 && b1 == 0 ? 1 : 0); + + const std::uint64_t wrong = src->wrong_size_datagrams(); + const std::string msg = src->last_message(); + std::fprintf(stderr, "[flatudp] wrong size: count=%" G_GUINT64_FORMAT " message='%s' ok=%d\n", wrong, + msg.c_str(), wrong >= 1 && msg.find("expected") != std::string::npos ? 1 : 0); + + std::fprintf(stderr, "[flatudp] 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::fprintf(stderr, "[flatudp] state: valid=%" G_GUINT64_FORMAT " wrong=%" G_GUINT64_FORMAT "\n", + src->valid_datagrams(), wrong); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Phase 1: the codec check (it needs no flatudp source), then every signal enabled in one structural +/// change, and the report a second later. +gboolean flatudp_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + std::fprintf(stderr, "[flatudp] codec: ok=%d\n", flatudp_codec_check() ? 1 : 0); + if (flatudp_source(a) == nullptr) { + std::fprintf(stderr, "[flatudp] no flatudp source (run with --source flatudp)\n"); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + std::vector 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); + std::fflush(stderr); + g_timeout_add(1000, flatudp_probe_report, a); + return G_SOURCE_REMOVE; +} + +/// After the mock's gap: the stream's frame count moved on and the source is connected again. +gboolean flatudp_probe_resumed(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + flatudp::FlatUdpSource* src = flatudp_source(a); + const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed); + const bool up = src != nullptr && src->connected() && + a->model->stats().connected.load(std::memory_order_relaxed); + std::fprintf(stderr, "[flatudp] resumed: connected=%d frames_before=%" G_GUINT64_FORMAT + " frames_after=%" G_GUINT64_FORMAT " ok=%d\n", + up ? 1 : 0, g_flatudp_frames_before, after, + up && after > g_flatudp_frames_before ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + +/// Inside the mock's gap: 2 s of silence must have raised `Disconnected`. +gboolean flatudp_probe_gap(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + flatudp::FlatUdpSource* src = flatudp_source(a); + if (src == nullptr) return G_SOURCE_REMOVE; + const bool src_up = src->connected(); + const bool model_up = a->model->stats().connected.load(std::memory_order_relaxed); + g_flatudp_frames_before = a->model->stats().frames.load(std::memory_order_relaxed); + std::fprintf(stderr, "[flatudp] silence: connected=%d model_connected=%d ok=%d\n", src_up ? 1 : 0, + model_up ? 1 : 0, !src_up && !model_up ? 1 : 0); + std::fflush(stderr); + g_timeout_add(4000, flatudp_probe_resumed, a); + return G_SOURCE_REMOVE; +} +``` + +The phase functions reference each other, so define them in this order (resumed, gap, report, probe) or forward-declare: they are all declared in `ui_app.hpp` (next step), so any order compiles. + +4. In `source_kinds_probe`'s `sample` lambda, after the `e1608` branch, add: + +```cpp + } else if (id == "flatudp") { + c.set("port", "44600"); + c.set("bind", "10.0.0.8"); + c.set("multicast", "239.1.2.3"); + c.set("iface", "10.0.0.9"); + c.set("rate", "2500"); + c.set("endian", "big"); + c.set("signals", "Trigger:uint8,Ia:float32,Wave:int16[4]"); + } +``` +(the existing `e1608` branch's closing `}` moves after this one: the chain is `if (udps) {...} else if (probe) {...} else if (e1608) {...} else if (flatudp) {...}`). + +5. `src/ui_app.hpp`: replace the T1 `flatudp_probe` declaration with: + +```cpp +/// 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` reads the source inside the mock's silence; +/// `_resumed` shows the stream came back. +gboolean flatudp_probe(gpointer user); +gboolean flatudp_probe_report(gpointer user); +gboolean flatudp_probe_gap(gpointer user); +gboolean flatudp_probe_resumed(gpointer user); +``` + +6. `src/ui.cpp`, after the `UDPSCOPE_PROBE_FLATUDP` block add: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP_GAP")) { + g_timeout_add(static_cast(std::atoi(env)), flatudp_probe_gap, &app); + } +``` + +- [ ] **Step 7: Build** + +```bash +cmake -S . -B build >/dev/null && cmake --build build -j8 2>&1 | grep -E "warning|error" | head -20 +``` + +Expected: no errors; at most the 2 pre-existing `MathNode::source` warnings of `ui_probe.cpp`, nothing from `flatudp`. If `ChannelBuffer::raw` or `model->channels()` differ from what the e1608 probe calls (see `e1608_probe_report`), mirror that code. + +- [ ] **Step 8: Run against the mock (scalar and vector lists)** + +First the run with a vector signal in the list and the mock's gap and bad datagrams; the probe schedule is: phase 1 at 2.5 s, `silence` at 8 s, `resumed` at 12 s. + +```bash +python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] \ + --gap 6,5 --bad-every 1 --log /tmp/flatudp-mock.log & +MOCK=$! +sleep 1 +UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_PROBE_FLATUDP_GAP=8000 UDPSCOPE_EXIT_AFTER_MS=14500 timeout 40 \ + xvfb-run -a build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \ + --source-opt signals=Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] 2>&1 \ + | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings|buffer allocated" +kill $MOCK 2>/dev/null; wait $MOCK 2>/dev/null; head -5 /tmp/flatudp-mock.log +``` + +Expected, in order: + +``` +[flatudp] codec: ok=1 +[flatudp] datagram of N bytes, expected M, check the signal list <- exactly ONE such line (N = M + 1) +[flatudp] stream: connected=1 signals=4 names=Trigger,Ia,Cnt,Wave expect=Trigger,Ia,Cnt,Wave frames=(any N > 0) rate=1000.000 ok=1 +[flatudp] channels: count=7 expect=7 ok=1 +[flatudp] continuity: ch0_breaks=0 ch1_breaks=0 ok=1 +[flatudp] wrong size: count=(any N >= 1) message='datagram of ... expected ...' ok=1 +[flatudp] capture button: present=1 sensitive=1 +[flatudp] state: valid=(any N > 0) wrong=(any N >= 1) +[flatudp] silence: connected=0 model_connected=0 ok=1 +[flatudp] resumed: connected=1 frames_before=(N) frames_after=(a number larger than N) ok=1 +gtk warnings: 0 +``` + +Rough sizes: M = 1+4+8+8 = 21 bytes, N = 22. Notes for reading failures: +- `stream rate` is the declared rate (1000); `names` is the signal order, so swapped signals read `ok=0`. +- `continuity` nonzero: first check the source did not drop or reorder datagrams (`valid` against the mock's count), then whether the model's ingest decimation is 1:1 at 1 kHz (a decimated channel steps by more than `k + 1`). Do not weaken the check before one of those is shown. +- `capture button sensitive=0`: find where the button's sensitivity is derived from the active kind's `has_datagrams()` (grep `capture_button`); do not special-case the kind. +- Any `Gtk-CRITICAL` or a count other than one wrong-size stderr line is a defect. + +- [ ] **Step 9: Run the scalar list (batching) and the big-endian run** + +```bash +python3 tools/mock_flatudp.py --port 44589 --rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64 --big-endian & +MOCK=$! +sleep 1 +UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 \ + xvfb-run -a build/udpscope --source flatudp --source-opt port=44589 --source-opt rate=1000 \ + --source-opt endian=big --source-opt signals=Trigger:uint8,Ia:float32,Cnt:uint64 2>&1 \ + | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings" +kill $MOCK 2>/dev/null; wait $MOCK 2>/dev/null +``` + +Expected: `stream ... names=Trigger,Ia,Cnt ... ok=1`, `channels: count=3 expect=3 ok=1`, `continuity: ch0_breaks=0 ch1_breaks=0 ok=1` (a byte-order or batching defect shows here), `wrong size: count=0 ... ok=0` (no `--bad-every` in this run: a reading, not a failure), `gtk warnings: 0`. + +Then the no-sender run (nothing listening on the mock side), 3 s: + +```bash +UDPSCOPE_PROBE_FLATUDP=1500 UDPSCOPE_EXIT_AFTER_MS=3000 timeout 20 \ + xvfb-run -a build/udpscope --source flatudp --source-opt port=44590 --source-opt rate=1000 \ + --source-opt signals=Trigger:uint8,Ia:float32 2>&1 | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings" +``` + +Expected: `stream: connected=0 ... frames=0 ... ok=0`, `continuity: ... -1 ... ok=0`, no crash, `gtk warnings: 0`. + +A `--source flatudp` run with no `rate` must fail to start with the message "give the sender's cycle rate in Hz (rate)" rather than silently using 1 kHz: + +```bash +UDPSCOPE_EXIT_AFTER_MS=2000 timeout 20 xvfb-run -a build/udpscope --source flatudp \ + --source-opt signals=A:uint8 2>&1 | grep -iE "rate|error" | head -3 +``` + +If the app starts a window with no error, look at how `--source` failures are reported for the e1608 (`--source e1608` with no address) and match that behaviour rather than inventing one. + +- [ ] **Step 10: The `[source-kinds]` run** + +```bash +UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 \ + xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep -E "\[source-kinds\]|gtk warnings|Gtk-CRITICAL" +``` + +Expected: `kinds=udps,e1608,flatudp,probe`, `form flatudp: round_trip=1 valid=1`, nine `[source-kinds]` lines in all, `gtk warnings: 0`. (`valid=1` holds because the sample has a rate and a signal list.) + +- [ ] **Step 11: Documentation** + +1. `docs/claude/build-and-run.md`: after the e1608 `sh` block (before "`tools/mock_udpstreamer.py` has waveform-injection options"), add: + +````markdown +`--source flatudp` receives the MARTe2 `UDPSender` datasource (one datagram per cycle: the signals +concatenated in declaration order, no header, no padding). The datagram carries **no description**, so the +settings are the whole contract: `port` 44488, `bind`, `multicast` and `iface` (a group to join and the local +interface for it), `rate` (the sender's cycle rate in Hz; **required**, it sizes the ring and the ingest +decimation and spaces the samples within a frame), `endian` `little|big`, and `signals` +`name:type[count],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64` (a count above 1 +makes a vector signal), e.g. `Trigger:uint8,Ia:float32,Wave:float32[16]`. A datagram of any other size than +the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as +`[flatudp] ...`. Loss is never reported: the stream has no counter. `tools/mock_flatudp.py` is a sender for +a signal spec (`--rate`, `--signals`, `--big-endian`, `--gap S,D`, `--bad-every S`, `--multicast G --mcast-if A`, +`--log FILE`). + +```sh +python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals Trigger:uint8,Ia:float32,Wave:int16[4] & +./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \ + --source-opt signals=Trigger:uint8,Ia:float32,Wave:int16[4] +``` +```` + +2. `docs/claude/verification.md`: in the `UDPSCOPE_PROBE_SOURCE_KINDS` entry change "Eight `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `probe`)" to "Nine `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `flatudp`, `probe`)". After the `UDPSCOPE_PROBE_E1608_RECONNECT=8000` entry add: + +````markdown +- `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 (it + needs no source), the stream's signal names and declared rate, the channel count (vectors count per + element), value continuity across the first two signals, the wrong-size accounting and the capture button. + It enables the signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS`. Runs, each short and serial, + with the mock killed afterwards: + - **mixed list, gap and bad datagrams:** mock `--rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] + --gap 6,5 --bad-every 1 --log /tmp/flatudp-mock.log`, the same `signals` as a `--source-opt`, with + `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_PROBE_FLATUDP_GAP=8000 UDPSCOPE_EXIT_AFTER_MS=14500`; stderr carries + exactly one `datagram of 22 bytes, expected 21` line; + - **scalar list, big endian (batching):** mock `--signals Trigger:uint8,Ia:float32,Cnt:uint64 --big-endian`, + app `--source-opt endian=big`, `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`; + - **no sender:** `connected=0` throughout, a reading and not a failure. +- `UDPSCOPE_PROBE_FLATUDP_GAP=8000` -- reads the source inside the mock's silence (2 s after its last datagram + the source is disconnected) and 4 s later checks it reconnected and the stream moved on. +```` + +3. `docs/claude/probe-readings.md`: in the `[source-kinds]` block change `kinds=udps,e1608,probe` to `kinds=udps,e1608,flatudp,probe` and insert `[source-kinds] form flatudp: round_trip=1 valid=1` after the e1608 form line; in the bullet about the e1608 sample add "The flatudp sample sets port, bind, multicast, iface, rate, endian and signals." Add a new section after `## [e1608]`'s last bullet (before the next `##`): + +````markdown +## `[flatudp]` + +``` +[flatudp] codec: ok=1 +[flatudp] datagram of 22 bytes, expected 21, check the signal list +[flatudp] stream: connected=1 signals=4 names=Trigger,Ia,Cnt,Wave expect=Trigger,Ia,Cnt,Wave frames=2410 rate=1000.000 ok=1 +[flatudp] channels: count=7 expect=7 ok=1 +[flatudp] continuity: ch0_breaks=0 ch1_breaks=0 ok=1 +[flatudp] wrong size: count=3 message='datagram of 22 bytes, expected 21, check the signal list' ok=1 +[flatudp] capture button: present=1 sensitive=1 +[flatudp] state: valid=3511 wrong=3 +[flatudp] silence: connected=0 model_connected=0 ok=1 +[flatudp] resumed: connected=1 frames_before=3522 frames_after=4310 ok=1 +``` + +`UDPSCOPE_PROBE_FLATUDP=2500` (the first lines; counts vary) and `UDPSCOPE_PROBE_FLATUDP_GAP=8000` (the last two) +run as `--source flatudp` against `tools/mock_flatudp.py` with the options in `verification.md`. The second line is +the source's own stderr message, printed once per distinct wrong size, not a probe line. With no sender the +stream lines read `connected=0 ... frames=0 ... ok=0` and `continuity` reads `-1`: a reading, not a failure. A +run without `--bad-every` reads `wrong size: count=0 ... ok=0` for the same reason. + +- `codec: ok=1`: the `signals` text round-trips, a hand-built 24-byte datagram decodes to the hand-computed + values in both byte orders (`uint8` 254, `int16` -2, `float32` 1.5, `uint64[2]` 1 and 4294967298, `int8` -1) + and the validity rules refuse what they should. It needs no source, so `--source probe` shows it too. + `ok=0` is a codec defect: a swapped byte order, a wrong width or a rule that does not fire. +- `stream: ... ok=1`: the model's signals are the list's rows in order, at the declared rate. `names` out of + order is a decode that mixed rows; `rate` of 1000 against a different declared rate is a `sampling_rate` + that did not reach the model (the ring and the decimation are then sized for the wrong cadence). +- `channels: count=7`: the sum of the rows' counts -- a vector signal is one channel per element. +- `continuity: ch0_breaks=0 ch1_breaks=0`: consecutive samples of the mock's first two signals step by 1 and + by 2 modulo 100. A break is a lost or reordered datagram, a batch flushed with the wrong number of samples, + or ingest decimation that is not 1:1. +- `wrong size: ok=1`: wrong-size datagrams were counted and the message names the received and expected size. + The message must also be the only stderr line of its kind: one per distinct size, however many arrive. +- `capture button: present=1 sensitive=1`: the kind reports datagrams, so the capture UI is live (contrast + `[source-kinds]`, where the probe kind leaves it disabled). +- `silence: connected=0 model_connected=0`: 2 s without a valid datagram raised `Disconnected`. `connected=1` + is the timeout not firing, or wrong-size datagrams refreshing the silence clock (they must not). +- `resumed: ok=1`: `Connected` was raised again and frames resumed. `connected=0` here means the source never + recovered after the sender came back. +```` + +- [ ] **Step 12: Commit** + +```bash +git add CMakeLists.txt src/source/flatudp src/source/registry.hpp src/source/registry.cpp \ + src/ui_probe.cpp src/ui_app.hpp src/ui.cpp tools/mock_flatudp.py \ + docs/claude/build-and-run.md docs/claude/verification.md docs/claude/probe-readings.md +git commit -m "flatudp: the source, kind, mock sender and stream probes" +``` + +--- +### Task 3: The row-editor form, its probe lines, README and architecture + +> **Amendment (read first).** After Task 2 the vector semantics changed: see the "Amendment 2026-11-02" section +> of `docs/superpowers/specs/2026-11-02-flat-udp-source-design.md`. `flatudp::Row` now has a `bool wave` +> (default false) and the codec/source already implement `channels` vs `waveform` rows and the channel cap. This +> task's form must therefore: add a "Vector as" `GtkDropDown` (`Channels` / `Waveform`, widget name `flatudp-vector`) +> to each row, shown only while that row's count is above 1 (reset to Channels when the count returns to 1); write +> it into `Row::wave`; show the channel total in the subtitle (the first `problem()` text still wins); and extend the +> form probe with a waveform case (a waveform row beside a scalar row is invalid; two waveform rows of the same count +> are valid). Where the code below predates this, follow the amendment. README and the docs must describe both +> modes, the `w` suffix, the mixed-list rule and the 256 channel cap. + +**Files:** +- Modify (full replacement): `src/source/flatudp/flatudp_kind.cpp` +- Modify: `src/ui_probe.cpp` (a form check, called from `flatudp_probe`) +- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md` + +**Interfaces:** +- Consumes: everything from Tasks 1 and 2 (`flatudp::Row`, `Type`, `type_name`, `problem`, `format`, `datagram_size`, `FlatUdpSettings::from/problem`, `valid_ipv4`, `valid_multicast`, `kDefaultPort`). +- Produces: the same `make_flatudp_kind()`; the widget names `flatudp-add` (the "Add signal" button), `flatudp-remove` (each row's button), which the probe finds with `find_named`. + +- [ ] **Step 1: Replace the kind with the row-editor form** + +Overwrite `src/source/flatudp/flatudp_kind.cpp` with: + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/flatudp/flatudp_source.hpp" +#include "source/registry.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace udpscope { +namespace { + +using namespace flatudp; +using ChangeCb = std::shared_ptr>; + +std::string num(double v) { + char b[48]; + std::snprintf(b, sizeof b, "%.10g", v); + return b; +} + +std::string trim(const std::string& s) { + std::size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +GtkWidget* entry_row(const char* title) { + GtkWidget* r = adw_entry_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + return r; +} + +GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) { + GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step)); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast(digits)); + adw_spin_row_set_value(ADW_SPIN_ROW(r), def); + return r; +} + +GtkWidget* combo_row(const char* title, const char* const* items) { + GtkWidget* r = adw_combo_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title); + adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE); + adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items))); + return r; +} + +GtkWidget* expander(const char* title, std::initializer_list rows) { + GtkWidget* e = adw_expander_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title); + for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r); + return e; +} + +std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); } +double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); } +guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); } + +void mark(GtkWidget* w, bool bad) { + if (bad) { + gtk_widget_add_css_class(w, "error"); + } else { + gtk_widget_remove_css_class(w, "error"); + } +} + +/// One signal's three editing widgets; `row` is the `AdwEntryRow` that holds the other two. +struct SigRow { + GtkWidget *row, *type, *count; +}; + +/// The form's widgets, shared by every signal closure and every `ConfigForm` closure. The raw +/// widget pointers are valid while the chooser keeps the rows. +struct Form : std::enable_shared_from_this { + GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *group; + std::vector sigs; + ChangeCb changed = std::make_shared>(); + bool updating = false; + bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing + + std::vector rows_now() const { + std::vector rows; + for (const SigRow& s : sigs) { + Row r; + r.name = trim(text_of(s.row)); + r.type = static_cast(std::min(gtk_drop_down_get_selected(GTK_DROP_DOWN(s.type)), 9)); + r.count = static_cast(gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(s.count))); + rows.push_back(r); + } + return rows; + } + + /// The first `S` that no row uses. + std::string next_name() const { + const std::vector rows = rows_now(); + for (int n = 1;; ++n) { + const std::string name = "S" + std::to_string(n); + bool used = false; + for (const Row& r : rows) used = used || r.name == name; + if (!used) return name; + } + } + + /// Bad addresses are marked with the `error` style; the group's subtitle carries the signal list's + /// own state, the first rule it breaks or the size it implies. + void refresh() { + mark(bind, !text_of(bind).empty() && !valid_ipv4(text_of(bind))); + mark(multicast, !text_of(multicast).empty() && !valid_multicast(text_of(multicast))); + mark(iface, !text_of(iface).empty() && !valid_ipv4(text_of(iface))); + const std::vector rows = rows_now(); + const std::string p = flatudp::problem(rows); + std::string sub = p; + if (p.empty()) { + sub = std::to_string(datagram_size(rows)) + " bytes per datagram"; + } + adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(group), sub.c_str()); + } + + void fire() { + if (!updating && changed && *changed) (*changed)(); + } + + SourceConfig read() const { + SourceConfig c; + c.set("port", num(value_of(port))); + if (!text_of(bind).empty()) c.set("bind", text_of(bind)); + if (!text_of(multicast).empty()) c.set("multicast", text_of(multicast)); + if (!text_of(iface).empty()) c.set("iface", text_of(iface)); + c.set("rate", num(value_of(rate))); + c.set("endian", sel_of(endian) == 1 ? "big" : "little"); + c.set("signals", format(rows_now())); + return c; + } + + void add_row(const Row& r); + void remove_row(GtkWidget* row); + + void write(const SourceConfig& cfg) { + const FlatUdpSettings s = FlatUdpSettings::from(cfg); + updating = true; + adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port); + gtk_editable_set_text(GTK_EDITABLE(bind), s.bind.c_str()); + gtk_editable_set_text(GTK_EDITABLE(multicast), s.multicast.c_str()); + gtk_editable_set_text(GTK_EDITABLE(iface), s.iface.c_str()); + if (s.rate > 0.0) adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.rate); + adw_combo_row_set_selected(ADW_COMBO_ROW(endian), s.big_endian ? 1 : 0); + while (!sigs.empty()) { + GtkWidget* row = sigs.back().row; + sigs.pop_back(); + adw_expander_row_remove(ADW_EXPANDER_ROW(group), row); + } + for (const Row& r : s.rows) add_row(r); + updating = false; + refresh(); + } + + bool valid() const { return FlatUdpSettings::from(read()).problem().empty(); } +}; + +using FormPtr = std::shared_ptr; + +/// A remove button's context: which form, which row. +struct RowCtx { + FormPtr form; + GtkWidget* row; +}; + +void on_notify(GObject*, GParamSpec*, gpointer user) { + Form& f = **static_cast(user); + if (!f.alive) return; + f.refresh(); + f.fire(); +} + +void on_edited(GtkEditable*, gpointer user) { + Form& f = **static_cast(user); + if (!f.alive) return; + f.refresh(); + f.fire(); +} + +void on_spin(GtkSpinButton*, gpointer user) { + Form& f = **static_cast(user); + if (!f.alive) return; + f.refresh(); + f.fire(); +} + +void on_add(GtkButton*, gpointer user) { + FormPtr f = *static_cast(user); + if (!f->alive) return; + f->add_row(Row{f->next_name(), Type::F32, 1}); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE); + f->refresh(); + f->fire(); +} + +void on_remove(GtkButton*, gpointer user) { + // Copy first: removing the row finalizes this button, and with it this context. + RowCtx* c = static_cast(user); + FormPtr f = c->form; + GtkWidget* row = c->row; + if (!f->alive) return; + f->remove_row(row); +} + +void free_form(gpointer d, GClosure*) { delete static_cast(d); } +void free_ctx(gpointer d, GClosure*) { delete static_cast(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(user)).alive = false; } + +void hook_destroy(GtkWidget* w, const FormPtr& f) { + g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form, + static_cast(0)); +} + +void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) { + g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form, + static_cast(0)); +} + +void hook_entry(GtkWidget* w, const FormPtr& f) { + g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form, + static_cast(0)); +} + +void Form::add_row(const Row& r) { + FormPtr self = shared_from_this(); + GtkWidget* row = adw_entry_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), "Name"); + gtk_editable_set_text(GTK_EDITABLE(row), r.name.c_str()); + + const char* names[11]; + for (std::size_t i = 0; i < 10; ++i) names[i] = type_name(static_cast(i)); + names[10] = nullptr; + GtkWidget* type = gtk_drop_down_new_from_strings(names); + gtk_drop_down_set_selected(GTK_DROP_DOWN(type), static_cast(r.type)); + gtk_widget_set_valign(type, GTK_ALIGN_CENTER); + gtk_widget_set_name(type, "flatudp-type"); + + // The spin clamps to 1..4096, so a hand-edited session with a count outside it is repaired here. + GtkWidget* count = gtk_spin_button_new_with_range(1, kMaxCount, 1); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(count), r.count); + gtk_widget_set_valign(count, GTK_ALIGN_CENTER); + gtk_widget_set_tooltip_text(count, "Elements: 1 is a scalar signal, more make a vector signal"); + gtk_widget_set_name(count, "flatudp-count"); + + GtkWidget* rm = gtk_button_new_with_label("Remove"); + gtk_widget_set_valign(rm, GTK_ALIGN_CENTER); + gtk_widget_set_name(rm, "flatudp-remove"); + + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), type); + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), count); + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), rm); + + hook_entry(row, self); + hook_notify(type, "notify::selected", self); + g_signal_connect_data(count, "value-changed", G_CALLBACK(on_spin), new FormPtr(self), free_form, + static_cast(0)); + g_signal_connect_data(rm, "clicked", G_CALLBACK(on_remove), new RowCtx{self, row}, free_ctx, + static_cast(0)); + + adw_expander_row_add_row(ADW_EXPANDER_ROW(group), row); + sigs.push_back(SigRow{row, type, count}); +} + +void Form::remove_row(GtkWidget* row) { + for (auto it = sigs.begin(); it != sigs.end(); ++it) { + if (it->row == row) { + sigs.erase(it); + adw_expander_row_remove(ADW_EXPANDER_ROW(group), row); + break; + } + } + refresh(); + fire(); +} + +class FlatUdpKind final : public SourceKind { + public: + std::string id() const override { return "flatudp"; } + std::string display_name() const override { return "Flat UDP (MARTe2 UDPSender)"; } + bool has_datagrams() const override { return true; } + + std::string identity(const SourceConfig& cfg) const override { + return FlatUdpSettings::from(cfg).identity(); + } + + std::unique_ptr make(const SourceConfig& cfg) const override { + return std::make_unique(cfg); + } + + ConfigForm make_config_form() const override { + auto f = std::make_shared(); + static const char* const kEndian[] = {"Little endian", "Big endian", nullptr}; + + f->port = spin_row("_Port", 1, 65535, 1, kDefaultPort, 0); + f->bind = entry_row("_Bind address"); + f->multicast = entry_row("_Multicast group"); + f->iface = entry_row("Multicast _interface"); + f->rate = spin_row("Cycle _rate (Hz)", 0.001, 1000000, 1, 1000, 3); + f->endian = combo_row("_Byte order", kEndian); + + GtkWidget* net = expander("Network", {f->port, f->bind, f->multicast, f->iface}); + GtkWidget* timing = expander("Timing and format", {f->rate, f->endian}); + f->group = adw_expander_row_new(); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->group), "Signals"); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(net), TRUE); + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE); + + GtkWidget* add = gtk_button_new_with_label("Add signal"); + gtk_widget_set_valign(add, GTK_ALIGN_CENTER); + gtk_widget_set_name(add, "flatudp-add"); + adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->group), add); + g_signal_connect_data(add, "clicked", G_CALLBACK(on_add), new FormPtr(f), free_form, + static_cast(0)); + + for (GtkWidget* w : {net, timing, f->group}) hook_destroy(w, f); + for (GtkWidget* w : {f->bind, f->multicast, f->iface}) hook_entry(w, f); + for (GtkWidget* w : {f->port, f->rate}) hook_notify(w, "notify::value", f); + hook_notify(f->endian, "notify::selected", f); + f->refresh(); + + ConfigForm out; + out.rows = {net, timing, f->group}; + out.description = + "The signal list must match the sender's exactly, in order: the datagrams carry no " + "description, and a datagram of any other size is dropped"; + out.focus = f->rate; + out.write = [f](const SourceConfig& c) { f->write(c); }; + out.read = [f]() { return f->read(); }; + out.valid = [f]() { return f->valid(); }; + ChangeCb cb = f->changed; + out.on_change = [cb](std::function fn) { *cb = std::move(fn); }; + return out; + } +}; + +} // namespace + +std::unique_ptr make_flatudp_kind() { return std::make_unique(); } + +} // namespace udpscope +``` + +Notes for the implementer: `adw_expander_row_remove` and `adw_entry_row_add_suffix` are in libadwaita 1.8. `std::min` needs ``; add it to the includes if the compiler asks. The remove handler removes the row from inside its own button's `clicked` emission: GTK keeps the emitting instance alive until the emission ends, which is why `on_remove` copies the form and the row out of its context first. If the probe in step 3 shows a `Gtk-CRITICAL` or a crash on remove, defer the removal with `g_idle_add` (carrying the same two values) instead of changing the ownership scheme. + +- [ ] **Step 2: Add the form check to the probe** + +In `src/ui_probe.cpp`, in the anonymous namespace after `flatudp_breaks`, add: + +```cpp +/// The connect form's validity rules and its signal-row editor, driven through the widgets: the +/// empty form is not connectable; a good list is, and its subtitle names the implied size; a +/// duplicate name and an over-size list are not; "Add signal" appends `S1`, `S2`; a row's +/// "Remove" button takes that row out. +void flatudp_form_probe() { + SourceKind* k = find_kind("flatudp"); + if (k == nullptr) { + std::fprintf(stderr, "[flatudp] form: no flatudp kind\n"); + return; + } + ConfigForm f = k->make_config_form(); + const bool initial = f.valid(); + const auto with = [&](const char* signals) { + SourceConfig c; + c.set("rate", "1000"); + c.set("signals", signals); + f.write(c); + return f.valid(); + }; + const auto subtitle = [&]() { + const char* t = adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(f.rows[2])); + return std::string(t != nullptr ? t : ""); + }; + const bool good = with("A:uint8,B:float32,W:int16[4]"); + const bool size_ok = subtitle().find("13 bytes") != std::string::npos; + const bool dup = with("A:uint8,A:float32"); + const bool dup_ok = subtitle().find("twice") != std::string::npos; + const bool big = with("A:float64[4096],B:float64[4096]"); + with("A:uint8,B:float32"); + + GtkWidget* add = find_named(f.rows[2], "flatudp-add"); + if (add != nullptr) { + g_signal_emit_by_name(add, "clicked"); + g_signal_emit_by_name(add, "clicked"); + } + const bool added = f.read().get("signals") == "A:uint8,B:float32,S1:float32,S2:float32"; + GtkWidget* rm = find_named(f.rows[2], "flatudp-remove"); // the first row's + if (rm != nullptr) g_signal_emit_by_name(rm, "clicked"); + const bool removed = f.read().get("signals") == "B:float32,S1:float32,S2:float32"; + const bool ok = !initial && good && size_ok && !dup && dup_ok && !big && add != nullptr && added && + rm != nullptr && removed && f.valid(); + std::fprintf(stderr, + "[flatudp] form: initial_valid=%d good_valid=%d size_ok=%d dup_valid=%d dup_msg=%d " + "oversize_valid=%d add=%d remove=%d ok=%d\n", + initial ? 1 : 0, good ? 1 : 0, size_ok ? 1 : 0, dup ? 1 : 0, dup_ok ? 1 : 0, + big ? 1 : 0, added ? 1 : 0, removed ? 1 : 0, ok ? 1 : 0); + // The rows were never parented; sink and release them so the form leaves nothing behind. + for (GtkWidget* row : f.rows) { + g_object_ref_sink(row); + g_object_unref(row); + } +} +``` + +In `flatudp_probe`, directly after the `[flatudp] codec:` `fprintf`, add the call: + +```cpp + flatudp_form_probe(); +``` + +The anonymous-namespace helper `find_named` is defined earlier in the file (before `source_panel_content`), so it is in scope. + +- [ ] **Step 3: Build and run the form check (no sender needed)** + +```bash +cmake --build build -j8 2>&1 | grep -E "warning|error" | head +UDPSCOPE_PROBE_SOURCE_KINDS=60000 UDPSCOPE_PROBE_FLATUDP=1500 UDPSCOPE_EXIT_AFTER_MS=3000 \ + timeout 30 xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 \ + | grep -E "\[flatudp\]|Gtk-CRITICAL|Gtk-WARNING|gtk warnings" +``` + +Expected: no build warning from `flatudp`; + +``` +[flatudp] codec: ok=1 +[flatudp] form: initial_valid=0 good_valid=1 size_ok=1 dup_valid=0 dup_msg=1 oversize_valid=0 add=1 remove=1 ok=1 +[flatudp] no flatudp source (run with --source flatudp) +gtk warnings: 0 +``` + +and no `Gtk-CRITICAL`. A `Gtk-CRITICAL` here is a widget used after removal or a row removed from the wrong container: fix it, do not filter it out. + +Then the `[source-kinds]` round trip once more (the row editor changed how `signals` is written) and one full mock run (the vector list of Task 2, step 8, shortened is enough: `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`, no gap) to confirm the stream lines are unchanged: + +```bash +UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 \ + xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep -E "form flatudp|kinds=|gtk warnings|Gtk-CRITICAL" +python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] --bad-every 1 & +MOCK=$! +sleep 1 +UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 xvfb-run -a build/udpscope --source flatudp \ + --source-opt port=44588 --source-opt rate=1000 --source-opt signals=Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] 2>&1 \ + | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings" +kill $MOCK 2>/dev/null; wait $MOCK 2>/dev/null +``` + +Expected: `form flatudp: round_trip=1 valid=1`, `kinds=udps,e1608,flatudp,probe`; in the mock run every line `ok=1` (the `form:` line included) and `gtk warnings: 0`. + +- [ ] **Step 4: Multicast loopback (if the sandbox allows it)** + +```bash +python3 tools/mock_flatudp.py --port 44591 --rate 1000 --signals Trigger:uint8,Ia:float32 \ + --multicast 239.255.42.99 --mcast-if 127.0.0.1 & +MOCK=$! +sleep 1 +UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 xvfb-run -a build/udpscope --source flatudp \ + --source-opt port=44591 --source-opt rate=1000 --source-opt multicast=239.255.42.99 --source-opt iface=127.0.0.1 \ + --source-opt signals=Trigger:uint8,Ia:float32 2>&1 | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings" +kill $MOCK 2>/dev/null; wait $MOCK 2>/dev/null +``` + +Expected: `stream: connected=1 ... ok=1` and `continuity ... ok=1`. If the sandbox or the `lo` interface refuses multicast (`ip link show lo` lacks `MULTICAST`, the join fails with "cannot join multicast group ..." or `stream` stays `connected=0`), record **"multicast not verified here"** in the commit message and in the README paragraph (step 5); do not claim it works. + +- [ ] **Step 5: README** + +In `README.md`'s "Data sources" section, change the sentence "`--help` lists the registered kinds." paragraph's neighbours as follows. After the E-1608 paragraph that ends "...not why." (before "A session file written before kinds existed"), insert: + +````markdown +The third built-in kind is **Flat UDP**, a receiver for the MARTe2 `UDPSender` datasource: one datagram per +cycle, the signals concatenated in declaration order with no header, no padding and no timestamp, in the +sender's byte order. The datagram describes nothing, so **the signal list in the connection form is the +whole contract and is your responsibility**: one row per signal (a name, a type from `uint8` to `float64`, +and an element count; a count above 1 is a vector signal), in the sender's order, including the `Trigger` +byte an IndependentThread `UDPSender` puts first. The form shows the size the list implies ("41 bytes per +datagram"); a datagram of any other size is dropped, counted as stale, and reported once per distinct size +on stderr as `[flatudp] ...`. The form also takes the port (44488, the sender's own default), an optional bind +address, an optional multicast group with the interface to join it on, the byte order, and the sender's +**cycle rate in Hz, which is required**: it sizes the ring and the ingest decimation and spaces the samples +within a frame. Time is stamped by the existing pipeline from arrival and that declared rate (and +`--relative-time` gives a counted clock); there is no timestamp in the stream to prefer. **Loss is never +reported** -- with no counter in the datagram, a missed cycle is indistinguishable from a slow one -- and +neither is a changed sender: a list that does not match reads as "everything dropped". Source-address +filtering, detecting the list from the traffic and importing a MARTe2 `.cfg` are not provided. A list of +scalars is delivered in frames of about 10 ms (`round(rate / 100)` cycles, at most 1024; a partial frame is +flushed after 100 ms); a list with any vector signal delivers one frame per datagram. The source is +connected on the first valid datagram and disconnected after 2 s without one. It is verified against +`tools/mock_flatudp.py`, not against a MARTe2 application. *(Multicast: state here whether the loopback run of +the plan's step 4 passed or was not verifiable.)* +```` + +Replace the italic parenthetical in the inserted text with the real outcome of step 4 (either "Multicast was verified on a loopback group." or "Multicast joins were not verified here: the sandbox's loopback has no multicast route."). Then change the paragraph "Planned, each as its own sub-project: a flat UDP kind and ABScope. ABScope has more than one timebase, ..." to start "Planned, as its own sub-project: ABScope. It has more than one timebase, ...", keeping the rest of the sentence. + +- [ ] **Step 6: Architecture notes** + +In `docs/claude/architecture.md`, after the E-1608 bullet about `E1608Readouts` / stderr (the last E-1608 bullet in the source-plugins list), add: + +````markdown +- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec, + the validity rules and `decode_datagram`, with no GTK and no sockets (the probe round-trips it); + `flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form. The datagram has + no description, so a list that is wrong is invisible except as dropped datagrams: the size check is the + only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per size. +- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the + sample count varies): `round(rate / 100)` datagrams for an all-scalar list, a partial block flushed after + 100 ms. A vector signal carries one sample per `Frame` (`Frame::value` indexes by sample only when the + value count equals `num_samples` and the signal has one element), so **one vector row forces + one datagram per frame**. `rate` is required because `sampling_rate = 0` makes the model size the ring and + the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping + path that ignores the declared cadence. +- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets + `Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the + history survives (`CONFIG` is sent once, from the first `poll()`). +```` + +- [ ] **Step 7: Verification and probe-reading docs** + +`docs/claude/verification.md`: in the `UDPSCOPE_PROBE_FLATUDP` entry added in Task 2, change "a hand-computed codec and decode check (it needs no source)" to "a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both need no source)". + +`docs/claude/probe-readings.md`: in the `## [flatudp]` block add, after the `codec` line: + +``` +[flatudp] form: initial_valid=0 good_valid=1 size_ok=1 dup_valid=0 dup_msg=1 oversize_valid=0 add=1 remove=1 ok=1 +``` + +and, after the `codec: ok=1` bullet, this bullet: + +````markdown +- `form: ... ok=1`: driven through the widgets, not the settings. `initial_valid=0` -- an empty list is not + connectable (`1` is a form that lets a source with no signals through). `good_valid=1 size_ok=1` -- a + 13-byte list is valid and its subtitle says `13 bytes per datagram`. `dup_valid=0 dup_msg=1` -- a duplicate + name is refused *and the subtitle says why* ("used twice"). `oversize_valid=0` -- two `float64[4096]` rows + (65536 bytes) exceed a datagram. `add=1` -- two clicks on "Add signal" append `S1` and `S2`; `remove=1` -- + the first row's "Remove" button takes that row out. A `0` on `add`/`remove` with a `Gtk-CRITICAL` is a row + handled after it was removed. Like `codec`, it needs no flatudp source. +```` + +- [ ] **Step 8: Final baselines and commit** + +A clean build must show exactly the 3 baseline warnings. Build from scratch in a scratch directory so the main build tree is untouched: + +```bash +cmake -S . -B /tmp/flatudp-clean >/dev/null && cmake --build /tmp/flatudp-clean -j8 2>&1 | grep -c "warning:" +rm -rf /tmp/flatudp-clean +``` + +Expected: `3`. Anything else, read the new warning: it comes from a flatudp file. + +```bash +git add src/source/flatudp/flatudp_kind.cpp src/ui_probe.cpp README.md \ + docs/claude/architecture.md docs/claude/verification.md docs/claude/probe-readings.md +git commit -m "flatudp: the signal-row editor form, its probe lines, README and architecture notes" +``` + +Then ask the user whether to merge `feature/source-flatudp` into `feature/source-plugins`; do not merge or push on your own. diff --git a/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md b/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md new file mode 100644 index 0000000..168ce8d --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md @@ -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:`, `signals`, `measure`, `cursors`, `events`, `source-`. (Step 1 implements `trigger` and `source-` 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 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 `` (the bare `plot_box`) with: + +```xml + + + + 1 + 1 + + + ... (the existing plot_box, unchanged) ... + + + + +``` +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 + +#include +#include + +namespace udpscope { +namespace ui_impl { + +namespace { + +void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast(user)); } + +void on_source_switch(GObject* o, GParamSpec*, gpointer user) { + App* app = static_cast(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(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(x), static_cast(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(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(r.size.width + 0.5f) : -1; + const int rx = have ? static_cast(r.origin.x + 0.5f) : -1; + const int rh = have ? static_cast(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(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(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 ... 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 + + + + horizontal + 6 + 36 + 6 + 6 + + + horizontal + 6 + 1 + + + + + horizontal + 6 + end + + + + +``` +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 +#include + +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(user); + // The App is reached through the badge's button: the badges live inside App, so the pointer is stable. + App* app = static_cast(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(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-` 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= switch_visible=<0|1>` line: after `slideover_open(*a, )`, `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 + + + vertical + 300 + + + 1 + never + automatic + + + vertical + + + + + +``` +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= 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(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=` 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. diff --git a/docs/superpowers/plans/2026-11-03-horizontal-split.md b/docs/superpowers/plans/2026-11-03-horizontal-split.md new file mode 100644 index 0000000..7193f91 --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-horizontal-split.md @@ -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 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 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`, 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 ` and `#include ` 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& cells) +{ + plot::Figure figure{std::span(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 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 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 three{1.0f, 1.0f, 1.0f}; + const std::vector 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 cells{4}; + plot::Figure figure{std::span(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 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 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 cells_per_row_; std::vector row_first_; // prefix sums, rows+1 entries; std::vector> cell_weights_; f32 col_gap_ = 0.0f; void build_cells(std::span);`. In `FigureLayout` add `std::vector cells; f32 col_gap = 0.0f;`. + +- [ ] **Step 5: Implement the constructors and accessors** + +```cpp +Figure::Figure(u32 rows) +{ + const std::vector ones(rows, 1u); + build_cells(std::span(ones)); +} + +Figure::Figure(std::span cells_per_row) { build_cells(cells_per_row); } + +void Figure::build_cells(std::span cells) +{ + cells_per_row_.assign(cells.begin(), cells.end()); + for (u32& c : cells_per_row_) c = std::max(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(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(cells_per_row_.size()); } +u32 Figure::plot_count() const { return static_cast(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 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& 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(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(n)); + const f32 extra = usable - floor_px * static_cast(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(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 ` 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 cells{1, 2}; + plot::Figure figure{std::span(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& 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(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& 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 w)`, `void set_cells_per_row(std::vector 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(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& 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 w); +void set_cells_per_row(std::vector cells); + +private: + std::vector cells_per_row_{1}; + std::vector> cell_weights_ = std::vector>(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& RowSet::cell_weights(int fr) const +{ + static const std::vector none; + return fr >= 0 && fr < static_cast(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('a' + cell_of(plot)); +} +``` + +- [ ] **Step 4: Figure construction in `rebuild_all`** + +Replace the `std::make_unique(rows_)` with + +```cpp +std::vector cells(cells_per_row_.begin(), cells_per_row_.end()); +figure_ = std::make_unique(std::span(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(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(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(w.size())) w.erase(w.begin() + k); + rebuild_all(); + return true; +} + +void RowSet::set_cell_weights(int fr, std::vector 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 cells) +{ + std::vector 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(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 = `, `width = `, 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 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(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 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)`, `set_cells_per_row(vector)`, `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)`, `set_col_gap`, `FigureLayout::cells/col_gap`) match across Tasks 1–3. diff --git a/docs/superpowers/plans/2026-11-03-split-shared-y.md b/docs/superpowers/plans/2026-11-03-split-shared-y.md new file mode 100644 index 0000000..4f5caad --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-split-shared-y.md @@ -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{0.0f, 10.0f, 5.0f}, 0.0, 1.0); + plot::data::ArraySeries b(std::vector{-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{0.0f, 10.0f}, 0.0, 1.0); + plot::data::ArraySeries b(std::vector{-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 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 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 && git commit -m "docs: split rows share one y axis"`. diff --git a/docs/superpowers/plans/2026-11-03-ui-badges-signals.md b/docs/superpowers/plans/2026-11-03-ui-badges-signals.md new file mode 100644 index 0000000..eed708f --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-ui-badges-signals.md @@ -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:` 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:`, `signals`, `measure`, `cursors`, `events`, `source-`. This step adds `signals` and `signal:`. +- 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:` 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=` 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:` 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 listed_signals(const std::vector& 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:` 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 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 listed_signals(const std::vector& signals) { + std::vector 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 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(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::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 lock(app.model->mutex()); + const std::vector& 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"`, ``, ``, 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 lock(app.model->mutex()); + const std::vector& 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(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 all; + { + std::lock_guard 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(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(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(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(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(user); + outlive_the_rebuild(GTK_WIDGET(b)); + std::string n; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& 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(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(user); + const std::size_t index = static_cast( + GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal"))); + std::string name; + { + std::lock_guard lock(a->model->mutex()); + const std::vector& 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:")` and prints: + +``` +[signalpage] name= open= pages= enable_visible=<0|1> plot_buttons= plot_on_row0=<1 if assign_signal_to_row(...,0) returned true and signal_plotted> removed= back_to_list= 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> 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 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 `` 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& 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 signals; + { + std::lock_guard 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 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(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()); + 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 badge_order;` and `#include ` / `` 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= badges= overflow=<0|1> overflow_text= plus_visible=<0|1> dot_plotted= bar_h= bar_h_fixed=<1 if bar_h before == after toggling one signal off and on> bar_min_w= opens=<1 if emitting "clicked" on badges[0] makes slideover_page == signal:, 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(data); }, + G_CONNECT_DEFAULT); + g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) { + slideover_close(*static_cast(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(user); + App* app = static_cast(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::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= drop_targets=<... GtkDropTarget> prepare_ok=<1 if on_drag_prepare for badges[0]'s DragContext returns a non-null provider> reorder_before= 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= +``` +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= 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:`), `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)" +``` diff --git a/docs/superpowers/plans/2026-11-03-ui-events-dock.md b/docs/superpowers/plans/2026-11-03-ui-events-dock.md new file mode 100644 index 0000000..ca7616b --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-ui-events-dock.md @@ -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= pane_rows= same_titles=<0|1> sensitive=<0|1> dock_w=` 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=` 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:, 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`). diff --git a/docs/superpowers/plans/2026-11-03-ui-readouts-dock.md b/docs/superpowers/plans/2026-11-03-ui-readouts-dock.md new file mode 100644 index 0000000..661f930 --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-ui-readouts-dock.md @@ -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:`, `signals`, `measure`, `cursors`, `events`, `source-`. 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= page_lines= same_text=<0|1>` and `dock measures: lines= page_rows= same_text=<0|1>` where `same_text` compares every dock label's text to the page label's text. Also `dock_w=` 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= dock_w=` (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`). diff --git a/docs/superpowers/specs/2026-10-31-e1608-source-design.md b/docs/superpowers/specs/2026-10-31-e1608-source-design.md new file mode 100644 index 0000000..cdac441 --- /dev/null +++ b/docs/superpowers/specs/2026-10-31-e1608-source-design.md @@ -0,0 +1,153 @@ +# MCC E-1608 source plugin (sub-project 3 of 4) + +Date: 2026-10-31 +Branch: `feature/source-e1608` (from `feature/source-plugins`, sub-project 1) + +## Context + +Sub-project 1 added the `DataSource` / `SourceKind` / `SourceRegistry` framework and ported +UDPS onto it. This sub-project adds a second kind, `e1608`: the Measurement Computing E-1608 +Ethernet DAQ (8 SE / 4 DIFF 16-bit analog inputs, 2 analog outputs, 8 DIO bits, one 32-bit +counter), with its own connect form and two side-stack panels. + +References for the wire protocol, both written by the user: + +- Primary: `~/Projects/marte2_projects/MCC_E1608_DataSource`, a MARTe2 DataSource + (`E1608DataSource.{h,cpp}`, `E1608ScanFifo`). Its class comment in the header is the + authoritative list of parameters and behaviour. It is bound to MARTe2 (`BasicTCPSocket`, + `StreamString`, `SingleThreadService`), so it is **ported** to plain C++, not linked. +- Cross-check: `~/Projects/uopi/internal/datasource/mcc/{protocol,e1608}.go`, an independent + Go implementation of the same protocol. + +No board is available. The mock is the only verification until one is attached. + +## Decisions + +| Question | Decision | +| --- | --- | +| DAC / generator | Static level plus a software waveform (sine, square, triangle, ramp), 10 Hz at most. Runs as a timer inside `poll()`; no extra thread. | +| Hardware | None available. A protocol mock (`tools/mock_e1608.py`) is the contract. Commands whose layout rests on a single reference (or where the two disagree) are listed as unverified. | +| Analog input config | Full: per-channel enable and range, global SE/DIFF, MAC discovery, external clock, trigger, DI/counter poll rate. | +| Digital lines and counter | Plot channels the user chooses, off by default, enabled per line in the connect form. | +| Acquisition modes | Hardware-paced only (scan rate or external clock). Software-paced mode (`ScanRate = 0`) is not ported. | +| Structure | Three layers: link, device, source. The bottom two have no GTK or `DataSource` types. | + +## Design + +### 1. Files (new, `src/source/e1608/`, plus the mock) + +- `e1608_link.{hpp,cpp}`: UDP discovery (broadcast, or a unicast sweep of a given subnet + matched by MAC and the E-1608 product id) and connection-code handshake; TCP command + framing with a timeout; the scan socket on `port + 1`. Plain POSIX sockets. +- `e1608_device.{hpp,cpp}`: typed commands (AI, AO, DIO read/write/direction, counter + read/reset, scan queue, scan start/stop, status), the factory calibration read from the board's + calibration memory, and `decode()`, which turns raw interleaved `uint16` scans into calibrated + volts (`double`). +- `e1608_source.{hpp,cpp}`: the `DataSource`: the `poll()` state machine, `Frame` building, the + DI/counter and generator timers, the atomic readouts for the panels. +- `e1608_kind.cpp`: the `SourceKind`: the connect form and the two panels. +- `tools/mock_e1608.py`: the protocol mock. +- `register_builtin_sources()` registers `e1608` after `udps`. + +### 2. Data path and threading + +Everything runs inside `poll()` on the producer thread. The GTK thread reaches the board only +through `post(Command)`. + +`poll(timeout)`: + +1. Drains the posted commands (DAC writes, DIO toggles, counter reset, generator edits). +2. Runs the connection state machine: optional MAC lookup, handshake, calibration read, DIO + direction and initial outputs, scan queue and scan start take Disconnected to Scanning. A + link error fires `on_event(Disconnected)` and retries every 2 s. A connect attempt can block + `poll()` for up to the command timeout; that delays only the command queue. +3. Waits on the scan socket for at most the time to the next timer, reads what arrived, keeps a + partial scan for the next call, decodes the complete scans with the calibration and + delivers a `Frame`. +4. Runs the due timers: the DI/counter poll (`DiPollRate`) is a command round trip; the + generator tick (at most 10 Hz) is a DAC write. + +The frame: + +- Signals: the enabled AI channels (unit `V`, `range_min`/`range_max` from the channel's range), + the DI lines the user chose (0/1), and the counter if chosen. +- All signals are scalar; `num_samples` is the number of scans decoded in that read. +- DI and counter values are held across the block (a step trace). +- `sampling_rate` is the scan rate on every signal. `counter` is a running frame count. +- The aggregate rate (enabled channels times scan rate) must stay at or below 250 kS/s. The + form refuses a configuration above it. + +Overrun: if the board reports a scan overrun the source fires `on_event(Error)` and restarts +the scan when `AutoRestart` is set; the next frame carries `lost = 1`. This is approximate, +because the board does not report how many scans it dropped. + +Panel readouts: the source keeps the live DIO inputs, the counter, the DAC levels and the +generator state in atomics. The panels read them from a GTK timer, so no widget touches the +source's internals. + +### 3. Connect form and panels + +Connect form: grouped `AdwExpanderRow`s in the chooser's group. `SourceConfig` stays flat; a +per-channel setting is a key such as `ai0=BIP10V` (absent means off). + +- Connection: address, MAC, discovery subnet, port, connection code, timeout. `valid()` needs + an address, or a MAC plus a subnet. +- Acquisition: scan rate, external clock, trigger mode, auto-restart, calibrate (default on), + DI/counter poll rate. +- Analog inputs: global SE or DIFF mode and 8 channel rows (enable, range). In DIFF mode rows + 4 to 7 are insensitive, not hidden. `valid()` also enforces the 250 kS/s aggregate. +- Digital I/O: direction per bit (I or O), a "plot" switch per input bit, a "plot counter" + switch, the initial DO value and the initial DAC levels. + +`identity()` is the address, or the MAC when there is no address. A session file is named +`e1608-`, port 1, as sub-project 1 defines for non-UDPS kinds. `has_datagrams()` is +false, so the capture button is disabled. + +Two side-stack panels, both insensitive while disconnected (disabled, not hidden): + +- Analog out: per DAC, a mode (Level, Sine, Square, Triangle, Ramp), a level or amplitude plus + offset, a frequency of 0.1 to 10 Hz, and an on/off switch. It shows the last voltage written + and clamps to ±10 V. The generator stops on disconnect and the DACs return to their initial + levels on reconnect. +- Digital I/O: eight bit rows; an input bit shows a live LED, an output bit has a toggle that + posts a write. The counter shows its value and a Reset button. Directions come from the form + and are not changed at runtime. + +Tab icons are chosen in the plan under the project's bundled-icon rules. + +### 4. Verification + +Mock (`tools/mock_e1608.py`): UDP discovery with a MAC and the connection-code handshake; TCP +commands for AI, AO, DIO, the counter, scan queue, scan start/stop, calibration memory and +status; a scan stream on port+1 at the requested rate with a sine per channel; switches to drop +the link after N seconds and to report an overrun. Its header lists every command whose layout +is unverified (single reference, or the references disagree). + +Probe `UDPSCOPE_PROBE_E1608=`, run against the mock with `--source e1608`, extends the +probe/dump in `src/ui.cpp`. It checks: + +- Frames arrive with the expected channel count and sample rate. +- The panels are present and insensitive while disconnected. +- A DAC write, a DIO toggle and a counter reset each reach the mock. +- A forced link drop leads to a reconnect and a resumed stream. +- The calibration is applied (the mock serves non-trivial coefficients). + +A decode check needs no mock: a fixed raw buffer decoded against hand-computed volts. + +Baselines are unchanged: the UDPS self-check reads as on `main`, exactly three build warnings, +no `Gtk-CRITICAL`. + +Errors: a rejected handshake (bad connection code), no device found, timeouts and scan-socket +loss surface through `on_event(Error)` with a readable message and follow the reconnect cycle. +A rejected connection code stops the retries: the source stays in Error with that message until the user reconnects. + +Docs: update `architecture.md`, `build-and-run.md`, `verification.md`, `probe-readings.md` and +`README.md`. + +## Out of scope + +- Software-paced acquisition (`ScanRate = 0`). +- A generator faster than 10 Hz, and a dedicated I/O thread. +- Changing DIO directions at runtime. +- Per-channel SE/DIFF mixing (one global mode). +- Verification against a real board. diff --git a/docs/superpowers/specs/2026-10-31-source-plugins-design.md b/docs/superpowers/specs/2026-10-31-source-plugins-design.md new file mode 100644 index 0000000..21ed3f1 --- /dev/null +++ b/docs/superpowers/specs/2026-10-31-source-plugins-design.md @@ -0,0 +1,127 @@ +# Source plugins: framework and UDPS port (sub-project 1 of 4) + +Date: 2026-10-31 +Branch: `feature/source-plugins` + +## Context + +udpscope reads exactly one kind of source, a UDPStreamer stream through `deps/udpsclient`. +The goal is to accept several kinds of data source as plugins, each with its own config +dialog and, where the hardware offers it, its own UI (signal generator, digital I/O, +multiple timebases). + +The work is split into four sub-projects, each with its own spec, plan and implementation: + +1. **Plugin framework + UDPS client ported onto it** (this document). +2. Flat UDP plugin (standard MARTe2 UDPWriter stream; the wire format must be supplied or + located before that spec is written -- none was found in `~/workspace` or `~/Projects`). +3. MCC E-1608 plugin. The protocol code in `MCC_E1608_DataSource` is bound to MARTe2 + (`BasicTCPSocket`, `StreamString`, `SingleThreadService`); it is ported to plain C++, not + linked against MARTe2. AI streaming, DIO and DAC/generator panels. +4. ABScope plugin. Needs a multi-timebase data path and a wire format: the ABScope design + only sketches the UDP header and `streamerd` is unwritten. + +Sub-project 1 changes no UDPS behaviour. It is a refactor plus the hosting machinery the +later plugins need. + +## Decisions + +| Question | Decision | +| --- | --- | +| What "plugin" means | Compiled-in registry; the interface is kept clean enough for a `dlopen` loader to be added later (not built now). | +| Threading | `Acquisition` keeps the single producer thread and calls `source->poll()`. UI actions reach the source through a thread-safe command queue drained inside `poll()`. CLAUDE.md's two-thread rule holds. (A per-source I/O thread was considered and deferred.) | +| Source UI | Widget factory: a source returns `GtkWidget*` for its config form and each capability panel. | +| Timebases in the frame | The neutral frame is single-timebase. Sub-project 4 extends interface and data path together; this may break the interface once, accepted. | +| CLI / sessions | `--server/--port/...` keep meaning UDPS; `--source NAME` and `--source-opt k=v` added. Sessions keyed by kind + identity; legacy `host_port.toml` read as `udps`. | + +## Design + +### 1. The source interface (new `src/source/`) + +- **`SourceConfig`**: ordered string key/value map with typed getters. It is the stored form + (a TOML table), the `--source-opt` form, and what a config widget reads and writes. +- **`Frame`**: source-neutral, mirrors `udps_frame_t`: `counter`, `lost`, `late`, + `recv_time`, `publish_mode`, `num_samples`, `signals[]` (the existing `SignalInfo`) and + `values[]` (doubles). `signals` and `values` are pointers into the source's own buffers, + valid only for the duration of `on_data`; `Acquisition` copies what it keeps. The hole, backfill and time-base logic needs `counter/lost/late`; a + source with no counter supplies a running counter and `lost = 0`. `Frame::value(sig, k, + col)` replaces `udps_frame_value`. +- **`DataSource`**: the per-connection object. + - `open()`, `poll(timeout_ms)`, `close()`. + - `stats()` returns the counters now in `StreamStats`; `connected()` reports link state. + - `post(Command)`: thread-safe queue, drained inside `poll()`. `Command` is + `std::function`. + - `set_datagram_tap(fn)`: optional; lets UDP-based sources feed the raw-packet capture. + - Output goes to a `SourceSink` (`on_config`, `on_data`, `on_event`) called inside + `poll()`, as `udps_client` does today. +- **`SourceKind`**: factory and UI description of one plugin type: `id`, `display_name`, + `identity(cfg)`, `make(cfg)`, `make_config_form()` and `make_panels(source)` (each + `{id, title, GtkWidget*}`). `make_config_form()` takes no argument: the chooser calls + `form.write(cfg)` to fill the form. A `ConfigForm` is a list of `AdwPreferencesRow` widgets + plus `write` / `read` / `valid` / `on_change` closures, so the chooser can place the rows in + its own group and gate its Connect button on `valid()`. +- **`SourceRegistry`**: filled by an explicit `register_builtin_sources()` call. No + static-init self-registration (linkers can drop it). A future `dlopen` loader would only + call `register_kind()`. + +### 2. Acquisition and the UDPS plugin + +- `Acquisition` takes a `std::unique_ptr` instead of building a `udps_client_t` + and implements `SourceSink`; it creates the source lazily in `start()`, from the kind and + `SourceConfig` it was given; the four trampolines go away. +- `on_config` / `on_data` change only in the type they read. Time base, holes, backfill, + decimation, trigger and math code are untouched. +- `run()` calls `source_->poll(50)` and copies `source_->stats()` into + `ScopeModel::stats()`. `PacketCapture` stays in `Acquisition`, fed through the datagram + tap. Sources without datagrams (E-1608) leave the capture UI disabled, not hidden. +- `src/source/udps_source.cpp` wraps `deps/udpsclient` unchanged: it maps `udps_signal_t` + to `SignalInfo` and `udps_frame_t` to `Frame`, and carries the existing knobs (server, + port, multicast, interface, recv buffer). Identity is `host:port`. +- The `udps_*` types are used only in `acquisition.cpp` today, so no other file changes for + this step beyond the `Frame` type. + +### 3. UI hosting, config and persistence + +- **Connect dialog** (`source_dialog.cpp`): gains a "Source type" dropdown; choosing a kind + swaps in that kind's `make_config_form()`. The UDPS form (server, port, multicast, + interface) moves into the UDPS plugin. The dialog keeps the known-sources list, buffer + size, relative time and capture ring size. +- **Capability panels**: after the source is created the app calls `make_panels()` and adds + each panel as a page of the existing `side_stack` (the side area's page stack, owned by + the tab switcher). A panel is insensitive when + disconnected (disabled, not hidden; the tab switcher owns visibility). A source with no + panels adds no tab; UDPS has none. Panels call `source->post(Command)`, never the source + directly, so cross-thread safety lives in one place. A helper kit for common panels (DIO + bank, generator form) lives in `src/source/widgets/` but is only added when a plugin uses + it, so it arrives with sub-project 3. +- **Config / CLI**: `Config` gains `source_kind` (default `udps`) and a `SourceConfig`. + `--server/--port/--multicast/--interface` populate the UDPS entries. `--source NAME` and + repeatable `--source-opt k=v` work for any kind. `--help` lists the registered kinds. +- **Sessions**: the source's config is stored as top-level TOML keys, `source_kind` and + `source_opt_`, written only for non-UDPS kinds (a UDPS session file is unchanged, so + legacy `host_port.toml` files read as `udps` with nothing migrated). It is not a `[source]` + table. A non-UDPS session file is named by a pseudo-host `-` with port 1. +- **Mocks**: `tools/mock_udpstreamer.py` remains the UDPS mock. Each later plugin ships its + own mock under `tools/` so the headless self-check keeps working. + +## Verification + +- This is a pure refactor for UDPS: the existing headless self-check must read exactly as on + `main` -- exactly three build warnings, no `Gtk-CRITICAL` (see `docs/claude/verification.md`). +- New probe `UDPSCOPE_PROBE_SOURCE_KINDS` (`UDPSCOPE_PROBE_SOURCES` is already the chooser + discovery probe). Its value is a delay in ms, and its presence also registers the stub + `probe` kind. It extends the probe/dump in `src/ui.cpp` rather than + adding a harness. It checks that the registry lists its kinds, that a config form + round-trips its `SourceConfig`, and -- with a stub test-only kind carrying one panel -- + that the panel's tab is built, insensitive while disconnected, and that a posted command is + drained by `poll()`. +- Document the new probe lines in `docs/claude/probe-readings.md`; update `architecture.md` + (a `DataSource` replaces the client in the thread description), `build-and-run.md` + (new CLI options) and `README.md`. + +## Out of scope + +- Runtime `.so` loading, and a per-source I/O thread. +- Multi-timebase data path (sub-project 4). +- The flat UDP, E-1608 and ABScope plugins (sub-projects 2-4). +- The DIO / generator helper widgets (arrive with sub-project 3). diff --git a/docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md b/docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md new file mode 100644 index 0000000..1dbfd19 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md @@ -0,0 +1,63 @@ +# E-1608 network scan -- design + +Date: 2026-11-02. Status: approved in conversation, awaiting written-spec review. + +## Goal + +The E-1608 connection form gets a "Scan network" row that finds boards and fills the address or the +MAC from a click. Today a user must already know the address, or give a MAC and rely on a lookup. + +## Finding (tested on a real board, 2026-11-02) + +The board was at 169.254.88.75 on `net0`; the default route is `wlan0`. A `D` probe sent to +255.255.255.255 with no binding found nothing (it left through `wlan0`). The same probe sent with +`SO_BINDTODEVICE net0`, or bound to the interface address, or sent to a directed broadcast, or by +unicast, was answered (64 bytes, first byte `D`). So a single limited broadcast is not enough on a +host with several interfaces; the existing "find by MAC" lookup has the same flaw. + +## Discovery layer (`e1608_link.{hpp,cpp}`) + +- `class Scan` (non-blocking, one instance per scan): `Scan(port)` enumerates up, non-loopback IPv4 + interfaces with `IFF_BROADCAST` (`getifaddrs`), opens one UDP socket per interface bound to it + (`SO_BINDTODEVICE`, falling back to binding the interface address if the call is refused), enables + `SO_BROADCAST`, and sends `D` to 255.255.255.255 on each. `poll()` drains every socket without + blocking and returns the devices found so far (E-1608 product id only, one entry per MAC, the same + reply parsing as `discover`). The caller owns the clock. +- `discover()` keeps its signature. A new overload or helper `discover_broadcast(port, wait_ms)` is + `Scan` plus a blocking wait. `E1608Source`'s lookup uses it when the subnet is empty; a given + subnet still uses `sweep_targets` + `discover` as today. +- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty + result. + +## Form (`e1608_kind.cpp`) + +- At the top of the Connection expander, an `AdwActionRow` "Scan network" with a text suffix button + "Scan" (widget name `e1608-scan`). The row subtitle reads "Scanning...", "N boards found" or + "No board found". No theme icons. +- A scan starts a `Scan` on the port in the Port field and a 50 ms `g_timeout_add` poll for about + 1.5 s. There is no thread: the project has exactly two. The timer is removed when the form is + destroyed (`Form::alive` guard plus the destroy hook) and when a new scan starts. +- Each device found becomes an `AdwActionRow` in the Connection expander right after the scan row, + replacing the previous results. Title: IP. Subtitle: MAC. Activating the row fills Address and + clears MAC and Subnet. A suffix text button "Use MAC" (`e1608-use-mac`) fills MAC and clears + Address and Subnet. Fill is done under the form's `updating` guard, then `changed` fires once. +- Result rows are not read or written by the session: only the filled fields persist. +- The description line mentions the scan. + +## Verification + +- A scan probe (env var in the existing pattern): starts against `tools/mock_e1608.py` on a + loopback-reachable port, presses the scan button, and prints `[e1608] scan: found=N ip=... mac=... + fill_addr=1 fill_mac=1 ok=1`, covering both fills and that the results are replaced on a rescan. + If the sandbox refuses the broadcast, the line says so and the limitation is documented, not claimed. +- A manual run of the scan against the real board, recorded in the README. +- Baselines unchanged: 3 warnings clean, `gtk warnings: 0`, no `Gtk-CRITICAL`. + +## Docs + +`README.md` (E-1608 paragraph), `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`. + +## Out of scope + +Remembering scan results across runs; scanning other subnets than the host's interfaces (the +`subnet` sweep field remains for that); a worker thread. diff --git a/docs/superpowers/specs/2026-11-02-flat-udp-source-design.md b/docs/superpowers/specs/2026-11-02-flat-udp-source-design.md new file mode 100644 index 0000000..1f243d8 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-flat-udp-source-design.md @@ -0,0 +1,147 @@ +# Flat UDP source plugin (sub-project 2 of 4) + +Date: 2026-11-02 +Branch: to be cut from `feature/source-plugins` + +## Context + +Sub-project 1 added the `DataSource` / `SourceKind` / `SourceRegistry` framework. Sub-project 3 +added the E-1608. This sub-project adds `flatudp`: a receiver for the MARTe2 `UDPSender` +datasource (`MARTe2-components/Source/Components/DataSources/UDP/UDPSender.{h,cpp}`). + +## The wire format + +Read from `UDPSender::Synchronise()` and `MemoryDataSourceI`: + +- One `Write` of the datasource memory per `Synchronise()`: **one datagram is one cycle**. +- The memory is the signals concatenated in declaration order, **no padding**, in the sender's + native byte order. `signalOffsets[s]` is the running sum of `size * elements`. +- There is no header, counter, timestamp or signal description. In IndependentThread mode the + first signal is a `uint8` `Trigger`; it is sent like any other signal. +- The user must therefore enter the exact signal list by hand. + +## Decisions + +| Question | Decision | +| --- | --- | +| Time base | The user declares the nominal cycle rate in Hz (`rate`, required). The existing pipeline stamps the frames: anchored at arrival, samples spaced by the declared cadence, corrected to the measured cadence when the arrivals are more than 25% off it; `--relative-time` gives a purely counted clock. There is no `time_mode` switch: `acquisition.cpp` has no stamping path that ignores the declared cadence, and `sampling_rate = 0` would size the ring and the ingest decimation for 1 kHz (`model.cpp:60`). Loss is never reported (`lost = 0`). | +| Signal list entry | A row editor: name entry, type dropdown, element count and a remove button per row, plus an "Add signal" row. | +| Network | Port and optional bind address, plus an optional multicast group and interface. No source-address filter. | +| Byte order | `little` (default) or `big`. | +| Out of scope | Auto-detecting the list, importing a MARTe2 `.cfg`, a source filter, loss detection. | + +## Config keys + +Persisted in the session, settable with `--source-opt`. Kind id: `flatudp`. + +| Key | Default | Meaning | +| --- | --- | --- | +| `port` | 44488 | UDP port to listen on (the sender's own default). | +| `bind` | empty | Local address to bind; empty is any. | +| `multicast` | empty | Group to join; empty is plain unicast. | +| `iface` | empty | Local interface address for the join. | +| `rate` | none | Nominal cycle rate in Hz. Required; sizes the ring and the decimation and spaces samples within a frame. | +| `endian` | `little` | `little` or `big`. | +| `signals` | none | `name:type[count],...`, e.g. `Trigger:uint8,Ia:float32,Wave:float32[16]`. A persistence format only: the form builds and parses it. | + +Types: `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`. Count 1 is a scalar signal; N +is a vector signal. Expected datagram size is the sum of `size * count` over the rows. + +Validity (form and `--source-opt` alike): at least one signal; names non-empty and unique; +count 1-4096; total size at most 65507 bytes; `rate` finite and positive. + +## Architecture + +New files under `src/source/flatudp/`: + +- `flatudp_signals.{hpp,cpp}` -- the `signals` string codec: parse, format, validate, size + arithmetic. No GTK, so a probe can round-trip it. +- `flatudp_source.{hpp,cpp}` -- the `DataSource`. +- `flatudp_kind.{hpp,cpp}` -- the `SourceKind`, the form, and the registration in + `register_builtin_sources()`. + +Threading is unchanged: the source lives on the producer thread and `poll()` does all the work. + +### Data flow + +`poll(timeout_ms)` waits on a non-blocking UDP socket and drains it. + +- A datagram of exactly the expected size is decoded row by row to doubles (byte-swapped when + `endian=big`). +- **Batching.** When every row is scalar, decoded datagrams are queued and delivered + `block` at a time in one `Frame` (`num_samples = block`), where + `block = clamp(round(rate / 100), 1, 1024)`, about 100 frames per second as the E-1608 does. A + partial block is flushed after 100 ms so a slow or stopped sender does not hold samples back. + When any row is a vector, a vector signal carries one sample per `Frame`, so each datagram is + its own frame (`num_samples = 1`). +- `Frame`: `counter` a running frame count, `lost = 0`, `late = false`, `recv_time` the arrival + clock at delivery. +- `set_datagram_tap` is honoured for every datagram, valid or not, and `has_datagrams()` is + true, so packet capture works. +- `on_config` is called from the first `poll()` with one `SignalInfo` per row (`rows = count`, + `cols = 1`, `time_mode = kTimePacket`, `sampling_rate = rate`). + +### Errors and state + +- A wrong-size datagram is dropped, never decoded. Each distinct size raises one `Error` + event ("datagram of N bytes, expected M, check the signal list") and increments `stale`. +- `Connected` is raised on the first valid datagram and `Disconnected` after 2 s of silence; a + later datagram raises `Connected` again. +- A bind or multicast-join failure raises an `Error` and the source stays disconnected. + +### Connect form + +Built from `AdwPreferencesRow`s, with the destroy-notify guard used by the E-1608 form. + +- Network group: port, bind, multicast, interface. +- Timing: a rate spin. +- Byte order combo. +- Signals group: one row per signal, "Add signal" at the bottom, and a live subtitle with the + implied size ("41 bytes per datagram"). `valid()` is false while the list breaks a validity + rule. +- The `signals` string is rebuilt from the rows on every change; `write()` parses it back. + +## Verification + +No unit suite; the self-check is the contract. + +- `tools/mock_flatudp.py`: emits packed structs for a given signal spec and rate; options for + big-endian and for one wrong-size datagram. +- `UDPSCOPE_PROBE_FLATUDP` in `src/ui_probe.cpp`: stream names and count; a hand-computed + decode of known values including a vector signal and a `uint64`; the declared rate reaching the model; the + wrong-size error reported once and counted in `stale`; disconnect after silence; the form's + validity rules; the `signals` codec round trip. A multicast loopback check runs if the + sandbox allows it. +- Baselines unchanged: exactly 3 build warnings, `gtk warnings: 0`, no `Gtk-CRITICAL`. + +## Documentation + +README (the kind and its limits: no loss detection, the signal list is the user's +responsibility), `docs/claude/build-and-run.md` (options and mock usage), +`docs/claude/architecture.md` (source plugins), `docs/claude/probe-readings.md` and +`docs/claude/verification.md` (the new probe lines). + +## Amendment 2026-11-02: how a vector signal is shown (decided with the user during Task 2) + +Task 2 found that treating every vector row as one channel of `count` consecutive samples zero-pads +the scalar channels of a mixed list (the model spans a frame by its largest element block, and +`Frame::value` is 0 past a scalar's one value) and declares the wrong rate for the vector channel. +Both readings of a vector are useful depending on the sender, so each vector row picks one: + +- **`channels` (default).** `Wave:float32[16]` becomes 16 scalar channels named `Wave[0]` .. `Wave[15]`, + one sample per cycle each. All such lists are batched like an all-scalar list. +- **`waveform`.** `Wave:float32[16]w` is ONE channel receiving a block of `count` consecutive samples per + cycle: `SignalInfo{rows=count, cols=1, sampling_rate=rate*count}`, one datagram per `Frame` + (`num_samples = 1`). + +Rules: +- The `signals` string takes an optional trailing `w` after the count; `format` emits it only for a waveform row. +- A waveform row needs `count > 1`. +- A list with a waveform row must contain only waveform rows, all with the same `count`; otherwise it is + invalid, with a message the form shows. (No zero-padding, no holding scalars across a block.) +- The channel total is capped at 256: scalars count 1, a `channels` row counts `count`, a waveform row counts 1. + Over the cap is invalid. +- `block` batching applies whenever the list has no waveform row; with a waveform row it is 1. +- The form shows a "Vector as" dropdown (`Channels` / `Waveform`) on rows whose count is above 1, and + the channel total in the subtitle. +- The earlier "Data flow" text saying every vector row forces `num_samples = 1` applies to waveform rows only. diff --git a/docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md b/docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md new file mode 100644 index 0000000..c089c63 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md @@ -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:`, `signals`, `measure`, `cursors`, `events`, + `source-`. 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-` 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. diff --git a/docs/superpowers/specs/2026-11-03-horizontal-split-design.md b/docs/superpowers/specs/2026-11-03-horizontal-split-design.md new file mode 100644 index 0000000..91be061 --- /dev/null +++ b/docs/superpowers/specs/2026-11-03-horizontal-split-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-11-03-split-shared-y-design.md b/docs/superpowers/specs/2026-11-03-split-shared-y-design.md new file mode 100644 index 0000000..06ba826 --- /dev/null +++ b/docs/superpowers/specs/2026-11-03-split-shared-y-design.md @@ -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. diff --git a/help/C/org.udpscope.app/cursors.page b/help/C/org.udpscope.app/cursors.page index 6c9eda2..44e2b41 100644 --- a/help/C/org.udpscope.app/cursors.page +++ b/help/C/org.udpscope.app/cursors.page @@ -22,11 +22,13 @@ How to reach it -

Press the cursors button in the toolbar, or c.

+

Press the cursors button in the header bar, or c. It switches the cursors on + and off.

-

The pane below the plot shows the readings and owns a Reset button. - Drag a marker to move it.

+

The readings are in the dock (a read-only copy) and on the Cursors page, which + Edit beside Cursors opens over the plot and which owns the mode + and a Reset button. Drag a marker to move it.

The mode dropdown offers four modes: vertical (the shared time pair), @@ -42,7 +44,8 @@ any row moves it on all of them. That is what makes dt meaningful across rows.

A ruler belongs to its row and nothing shares it, so two rows' rulers are - independent.

+ 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.

mix is the reading mode: one line per bound 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.

diff --git a/help/C/org.udpscope.app/figures/main-window.png b/help/C/org.udpscope.app/figures/main-window.png index 449791c..f626603 100644 Binary files a/help/C/org.udpscope.app/figures/main-window.png and b/help/C/org.udpscope.app/figures/main-window.png differ diff --git a/help/C/org.udpscope.app/figures/trigger-capture.png b/help/C/org.udpscope.app/figures/trigger-capture.png index 299392e..cf2c563 100644 Binary files a/help/C/org.udpscope.app/figures/trigger-capture.png and b/help/C/org.udpscope.app/figures/trigger-capture.png differ diff --git a/help/C/org.udpscope.app/layout.page b/help/C/org.udpscope.app/layout.page index f5183c3..6ad40ae 100644 --- a/help/C/org.udpscope.app/layout.page +++ b/help/C/org.udpscope.app/layout.page @@ -5,8 +5,8 @@ - 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. + 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. Martino Ferrari @@ -14,9 +14,10 @@ Rows, layout and drag & drop -

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 +

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.

@@ -24,12 +25,12 @@ How to reach it -

Drag a signal's chip out of the side panel (s) and drop it on a row to +

Drag a signal's badge from the badge bar onto a row to add a trace.

Press l, or pick Layout editor from the overflow menu, to add - and remove rows: a × appears at the top left of each row and a + + and remove rows: a × appears at the top left of each plot and a + on each boundary between rows, including above the first and below the last.

@@ -38,19 +39,45 @@
+
+ Splitting a row + + +

In the layout editor, click the + at a row's left or right edge, or the + + between two plots, to add a plot to that row. The new plot starts empty.

+
+ +

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.

+
+ +

Click the × of a plot in a split row to remove that plot and its traces. + In a row with a single plot the × removes the row instead.

+
+
+

All the plots of a row, and of the whole figure, share one time window: zooming or + panning along time in any of them moves all of them, and the vertical cursors are drawn in + every plot. The plots of a split row also share one y range: the y labels are drawn at the + left of the first plot only, the horizontal cursors sit at the same values in every plot, and a + zoom, pan, fit or fixed range in any of them applies to the whole row. The time labels + sit under the bottom row, and under any row above it whose columns differ from the row below. A row holds at most 4 plots and the figure at most 8 + in total; the + buttons go grey at the limit.

+
+
Worth knowing

A plot's own right-click menu (the same compact rows as the app's - menu) holds the y-axis scale, the two fits, and Remove signal, which swaps + menu) holds the y-axis scale (for the whole row), the two fits, and Remove signal, which swaps the menu to that plot's traces plus All traces on this plot — with a Back row to return. That is how a trace comes off a row without going through the layout editor.

A drop adds a trace to the row's next free slot; it does not replace - one. With four traces already on a row the drop is refused.

-

Inserting a row moves the rows below it with their contents. 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.

+ one. With four traces already on a plot the drop is refused.

+

Inserting a row moves the rows below it with their contents, 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 + 2a, 2b and so on.

A trace is remembered by the signal's name, not by its channel number, because a new CONFIG or an enable renumbers the channels and an index would silently re-point the trace.

@@ -62,10 +89,11 @@
Example -

With three traces on row 1, pressing l shows 3 buttons (one ×, - two +). Clicking the + above row 1 gives 2 rows and 5 buttons, with - the three traces now on row 2 — the layout probe reports exactly this as - rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise].

+

With three traces on row 1, pressing l shows 7 buttons: the ×, + the two + above and below the row with their two resize arrows, and the two + + at the row's left and right edges. Clicking the + above row 1 gives 2 rows and 12 buttons, + with the three traces now on row 2 — the layout probe reports exactly this as + rows=2 buttons=12 traces=[0:-|1:sine,ramp,noise].

diff --git a/help/C/org.udpscope.app/math.page b/help/C/org.udpscope.app/math.page index 0e207ec..8f7cbd5 100644 --- a/help/C/org.udpscope.app/math.page +++ b/help/C/org.udpscope.app/math.page @@ -22,10 +22,12 @@ How to reach it -

Press the math button in the toolbar, or g.

+

Open the Signals page (the + badge, or g). Math channels + are listed under a MATH heading.

-

The pane's + adds a channel, and opening one shows the graph editor: +

The heading's + adds a channel. Edit graph, 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.

@@ -53,7 +55,7 @@ above it. A wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is unrepresentable: the connection is refused rather than created, and the line beneath the form says why — a loop, or a node asked to read itself.

-

A derived channel is a channel. It appears in the signal panel, can be +

A derived channel is 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.

The math runs on the stored samples — decimated and averaged — so a cutoff you type in Hz means the rate the filter will actually run at. Change the window diff --git a/help/C/org.udpscope.app/measurements.page b/help/C/org.udpscope.app/measurements.page index 9293d8d..c6f19e0 100644 --- a/help/C/org.udpscope.app/measurements.page +++ b/help/C/org.udpscope.app/measurements.page @@ -28,10 +28,12 @@ How to reach it -

Press the measurements button in the toolbar, or m.

+

Press m, or Edit beside Measurements in the dock. Either opens the + Measurements page over the plot; the dock itself shows each measurement's value, + and a click on one opens its dialog.

-

The pane's + adds one; clicking a row opens the dialog where the signal, +

The page's + 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 Save and Delete.

diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page index 6ea5685..ab0401c 100644 --- a/help/C/org.udpscope.app/overview.page +++ b/help/C/org.udpscope.app/overview.page @@ -4,7 +4,7 @@ - What udpscope plots, the four parts of the window, and one pass through the + What udpscope plots, the parts of the window, and one pass through the application from connecting to saving. Martino Ferrari @@ -18,7 +18,7 @@

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 - --buffer-mb; the drawing thread reads whatever that ring holds when a frame + --buffer-samples; the drawing thread reads whatever that ring holds when a frame starts, which is why a trace never makes the window stutter.

The stream is the clock. Every interval you read on screen is measured between @@ -34,11 +34,12 @@

The udpscope window - The header bar above the plots, the side panel on the right, and the status bar - along the bottom. + The header bar above the plots, the badge bar and the status bar under them, and + the dock on the right. -

One figure of three rows with four signals enabled, the Measure tab open in the - side panel, and the status bar reading live.

+

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 + live.

@@ -55,7 +56,20 @@

The signals arrive disabled. 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.

+ empty badge bar is working correctly, not failing to connect. For the same reason a start + that has enabled nothing prints no buffer allocated: line on stderr.

+ + +

Over SSH with X11 forwarding the window may never appear, and the cure is + udpscope --renderer cairo. Two symptoms tell the two halves apart: a + vkCreateSwapchainKHR() ... VK_ERROR_INITIALIZATION_FAILED warning is + GTK's default Vulkan renderer failing to make a swapchain and is what the option's + GSK_RENDERER=cairo answers; a window whose allocation stays zero + (window_button_w=0) means the plot's GL area has no context either, and + that is what its LIBGL_ALWAYS_SOFTWARE=1 answers. Both variables are + exported before GTK initialises, because they are read when the renderer and the GL + context are created. --renderer gl is faster when indirect GL works.

+

See Choosing a source for the chooser and the multicast @@ -68,30 +82,49 @@ The header bar -

Left to right: the time window (2 s), the trigger and the pause, the - events, cursors and side-panel switches, and the menu, which holds - Fit to data, the Layout editor, Export, - Keyboard shortcuts, Help and About.

- - - The side panel -

Two tabs. Measure holds the cursors readout and the measurements - derived from it; Signals holds the trigger and the channel list, where a - math channel is a row of that one list under a MATH heading whose - + 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.

+

Left to right: the time window (2 s), the trigger and the pause; then, on + the right, the cursors switch, the dock switch and the menu, which holds + Fit to data, the Layout editor, Export visible range, + Configurations, Help, Preferences and + About. The trigger button turns trigger mode on and off; the trigger's + settings are in the panel its badge opens.

The plot

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).

+ + The badge bar +

The strip under the plot. On the left, one badge for each signal you have switched + on, then a +; past six signals they fold into a single N signals + badge. On the right, Trigger, which names the trigger's state, and Source, 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.

+
+ + The side panel over the plot +

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 Esc, with + Close, or with a click on the plot, and it never resizes the plot.

+
+ + The dock +

The column on the right, which the dock button in the header bar (s) + shows and hides. Its Readouts are a read-only copy of the cursors and of + every measurement; Edit beside each heading opens that instrument's full + page. Under them, Events lists the captured events, newest first; a click + replays one and Manage 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.

+
The status bar -

Directly above the plot's bottom edge — the state word, the stored sample rate and +

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: event held, the - axis pinned to the event, and the trigger marker at the edge.

+ second screenshot below is what a capture looks like: the trigger marker at the edge + and the axis pinned to the event.

@@ -114,12 +147,12 @@ and port.

-

Enable a signal in the signal list. The plot stays empty for the first +

Enable a signal in the Signals page (the + badge, or g). The plot stays empty for the first fraction of a second while the first window fills — the buffer holds only what has arrived.

-

Drag it onto a row. Drop a second signal on another row, or the same +

Drag its badge onto a row. 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.

@@ -139,8 +172,10 @@ got.

-

Measure it — the measurements pane computes a statistic of the visible - window, and show on plot marks it as a ruler or a pair of markers.

+

Measure it — press m, or Edit beside + Measurements in the dock. A measurement is a statistic of the visible + window, its value is shown in the dock, and show on plot marks it as a + ruler or a pair of markers.

Save the data with Export visible range, which writes the diff --git a/help/C/org.udpscope.app/pause.page b/help/C/org.udpscope.app/pause.page index 9f8dbe3..5659e70 100644 --- a/help/C/org.udpscope.app/pause.page +++ b/help/C/org.udpscope.app/pause.page @@ -22,7 +22,7 @@ How to reach it -

Press the pause button in the toolbar, or Space.

+

Press the pause button in the header bar, or Space.

Press it again to resume, which rebinds the live ring and puts the window back on diff --git a/help/C/org.udpscope.app/sessions.page b/help/C/org.udpscope.app/sessions.page index f677cac..615b52a 100644 --- a/help/C/org.udpscope.app/sessions.page +++ b/help/C/org.udpscope.app/sessions.page @@ -41,7 +41,7 @@ they open.

A configuration also carries the settings that can only be decided when the buffers are - allocated: the buffer size (--buffer-mb), relative time, and the + allocated: the samples per signal (--buffer-samples), 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.

@@ -57,8 +57,8 @@

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.

+ channels with their graphs, the measurements — and the startup settings: samples + per signal, relative time and the packet capture's ring size.

Signal identity is the name throughout, because a new CONFIG renumbers the channels.

A file written by a run against a source whose streamer was down would name no diff --git a/help/C/org.udpscope.app/signals.page b/help/C/org.udpscope.app/signals.page index 5ca7b67..a642169 100644 --- a/help/C/org.udpscope.app/signals.page +++ b/help/C/org.udpscope.app/signals.page @@ -23,11 +23,17 @@ How to reach it -

Open the side panel (s), which lists every signal with a switch.

+

Click the + badge in the badge bar, or press g. The + Signals 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 N signals badge, and the page then lists every signal.

-

The Signal list item in the overflow menu opens the editor where the - unit and the calibration live.

+

Once a signal is acquired its badge appears. Click it to open that signal's page: its + details directly under its name — Unit, Gain and Offset, + 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 Plot on buttons for the + rows.

@@ -38,10 +44,13 @@

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.

-

Calibrate in a signal's row menu opens the gain and offset it is - currently drawn through, so a trace already scaled by a gain of 2 shows 2 — +

A signal's page shows the gain and offset it is currently 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 Apply confirms it rather than resetting it. A signal nobody - calibrated shows the identity, 1 and 0.

+ 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.

Calibration is applied at ingest, so changing it re-plans the buffer and drops the history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot for a second after one of those is the ring refilling, not a @@ -51,12 +60,12 @@ refuses to put it in a row rather than binding a trace that would read nothing for ever.

A signal's colour follows the signal, 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.

-

Click a coloured chip to change that signal's colour. A grey chip - names no trace, so it is not clickable. The colour is saved with the session; - Default colour in the signal's ⋯ menu gives the palette entry back.

+ 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.

+

Change a signal's colour with Change colour 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; Default colour on the same page gives the palette entry + back.

diff --git a/help/C/org.udpscope.app/source.page b/help/C/org.udpscope.app/source.page index 956bdc4..7ed3bf8 100644 --- a/help/C/org.udpscope.app/source.page +++ b/help/C/org.udpscope.app/source.page @@ -28,7 +28,7 @@ stored configuration (host:port · name, its note, and the settings it carries), newest first, plus a New host… form that takes the address and the configuration it belongs to — its name and note, the - buffer size, and whether time is relative or absolute. A row's pencil + samples per signal, 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).

diff --git a/help/C/org.udpscope.app/status.page b/help/C/org.udpscope.app/status.page index 5a0ad46..3360273 100644 --- a/help/C/org.udpscope.app/status.page +++ b/help/C/org.udpscope.app/status.page @@ -18,7 +18,7 @@
How to reach it -

The bar is always visible under the plot. Click ⓘ for the detail popover.

+

The bar is always visible at the foot of the window, under the badge bar. Click ⓘ for the detail popover.

diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page index 1bcf3cd..60bf4e6 100644 --- a/help/C/org.udpscope.app/trigger.page +++ b/help/C/org.udpscope.app/trigger.page @@ -5,7 +5,7 @@ - The trigger's four shapes, arming and hold-off, the event cache with its replay, + The trigger's four shapes, arming, forcing an acquisition and hold-off, the event cache with its replay, compare and accumulate, and searching the buffer for a condition. Martino Ferrari @@ -23,18 +23,30 @@ How to reach it -

The trigger button in the toolbar (t) 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.

+

Click the Trigger 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 + (t) turns trigger mode on and off; it does not open the page.

Arm starts watching; a arms and disarms from the keyboard. - When the pane is locked with its [edit] 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 — Arm/Disarm 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.

+ +

Force takes one acquisition now, with the event window above, + without waiting for the condition to hold — for when the event you are waiting for is not + coming and you want to see what the signal is doing this instant. The capture runs out its + post-trigger share and then freezes, exactly like a latch: the marker sits at the instant + you pressed. In Normal mode the trigger goes back to waiting afterwards; in + Single mode it disarms, because one acquisition is the whole of what that mode + means either way. It needs a signal to be acquired — the trigger cannot watch one that is + switched off — and pressing it while a capture is already filling leaves that capture + alone, since it is the acquisition you asked for.

+

Search the buffer is at the foot of the same panel.

@@ -82,34 +94,109 @@ offers a pulse width to a window trigger.

+
+ Multiple events: keeping the last few acquisitions +

multiple events decides how many of the most recent triggered acquisitions stay on + screen at once. It is 1 by default, which is the event alone — the display you get + when you ask for no persistence — and it can be raised to 8. Each older acquisition is drawn + at half the brightness of the one before it (1, 0.5, 0.25, 0.125, …), and they are all aligned + on their own trigger edge, so the pre-trigger part of the event lines up from acquisition to + acquisition and whatever changes between them — jitter, ringing, the occasional glitch — shows + as a faint ghost behind the current trace. That is what a phosphor display does with + successive sweeps.

+ +

The older acquisitions are drawn behind the current one and nothing else changes + because of them: the axis still frames the event on screen, the trigger marker still marks + its edge, and the measurements still describe the current frame. Lowering + multiple events back to 1 remembers nothing and frees the retained acquisitions + immediately.

+ +

Two things bound how many are drawn, and the panel tells you when one of them bites.

+ +

Room in the plot. Each older acquisition is an extra trace in the row that + plots its signal, so it needs a free trace slot there. A row holding four signals — the + layout the app starts with — has room for one older acquisition of each of them; a row per + signal has room for all seven.

+

Memory. A retained acquisition is a whole window copied out of the ring, + so persistence may hold at most as much again as the buffer takes (see --buffer-samples and --memory-limit-mb). + On a long window at a high sample rate that bound is reached before the eighth layer.

+
+
+ Persistence: how often the trace has been here +

persistence — the switch in the trigger panel's Advanced block — keeps + an image of the last acquisitions instead of their traces. Every acquisition is + accumulated into it, the one on screen included, so a cell's brightness means how many of them + passed through it: the sweep on screen is the brightest, the ones behind it add on top, and + where several agree the ink is brighter still. There is no stroke drawn over the image — the + acquisition on screen is its newest ink — so it fades with the rest in both modes. Each + signal is kept as its own image in that signal's own colour, so where two signals cross the same + place you see both, not one blended line. It is off by default.

+ +

Two things make it different from multiple events beside it, and the panel offers + both because they answer different questions. It never runs out of room: each signal's + image is sized to the plot on screen and capped, so a display of a thousand acquisitions costs + what a display of one costs — one image per signal, whatever the plot's trace slots, and a very + wide window does not make it grow — and there is no count to choose. And it fades, in + one of two ways — fades by picks them:

+ +

acquisitions: every acquisition keeps + per acquisition of what was there before it (0.5, the default, is the schedule a + phosphor's afterglow has: 1, 0.5, 0.25, …). The display then shows the last few sweeps + whatever the trigger rate.

+

time: the image fades by time constant s as the display runs — + one constant is the time for it to fall to a third of its ink — so a slow trigger leaves + faint sweeps and a fast one leaves bright ones, and the picture is the same whichever the + stream is doing.

+
+ +

Persistence is a trigger mode display, like the trigger itself: it is drawn while a + captured acquisition is on screen and goes away with the trigger, on a pause, and when you open + a stored waveform. Nothing about it moves the framing — the axis is still the current event's, + the older sweeps only add ink — and it reserves no space in the plot and cannot be clicked, so + it is never in the way of a cursor or a drop.

+ +

Both displays live in the trigger panel's Advanced block, together with the + trigger's other settings that are set up once and left alone.

+
+ + +

If a depth cannot be shown in full, a notice says how many layers fit, and the panel's + tooltip carries the same rule. Persistence is a display of what has already been captured: it + changes neither what is acquired nor what is measured, and it does nothing while the trigger + is off.

+
+
The event cache, replay and compare

Every triggered acquisition is kept. Latches are written to the event cache (~/.cache/udpscope/events/<source>/) as a .npy of the event, a .txt of what it was, and a .bmp picture drawn from the - samples. The Events 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.

+ samples. The Events 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. Manage + beside its heading opens the Events page over the plot, which keeps the last 32 + (time, duration, signals, rate), each row with its own picture beside the numbers.

-

Clicking a row replays it: the stored event is loaded exactly like an exported +

Clicking a row in the dock replays it: the stored event is loaded exactly like an exported file, without changing the layout — 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. Live goes back to the stream; the - status bar names the file until you do.

+ replay leaves trigger mode, because a file has no edge to wait for. Live, on the + Events page, goes back to the stream; the status bar names the file until + you do.

-

Select two or more events in the list and the two buttons under the header act on them: +

On the Events page, select two or more events in the list and the two buttons + under its header act on them: Compare 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 Accumulate 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 double click replays.

+ are in both. They need events with the same signals and the same length. On this page a + single click selects and a double click replays.

-

Record events, in the trigger panel with the trigger's other options, is +

Record events, on the trigger page with the trigger's other options, is what decides whether anything is kept. It is off by default — nothing is written - to disk until you ask — and with it off the history button goes with it.

+ to disk until you ask — and with it off, and nothing stored, the dock's events block and + the page stay dimmed.