docs: horizontal plot split

This commit is contained in:
Martino Ferrari committed 2026-11-03 19:14:57 +01:00
1 parent 7730b35fe3
commit 2183317953
13 files changed
+319 -39

No files matched your search

+6 -4
View File
@@ -259,10 +259,12 @@ 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.
+16
View File
@@ -269,6 +269,22 @@ adapter `PlotArea`'s **figure mode** —
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
A figure row can hold **several plots side by side**, which the host's horizontal split needs and
the library had no concept of: `Figure(std::span<const u32> cells_per_row)` builds one, with
`plot_count()`, `cells_in_row()`, `first_plot_of_row()` and `row_of_plot()` to move between rows and the
flattened plot index, `set_cell_weights()` and `set_col_gap()` to size the cells, and `FigureLayout::cells`
(one rect per plot, flattened) and `FigureLayout::col_gap` to read the result back. `Figure(u32 rows)`
still means one cell per row, so every row-only caller and test is unchanged, and `row_count()` stays the
number of *figure rows* (the host's `RowSet::row_count()` is the plot count, `figure_row_count()` the
rows). `plot(i)`, `link_x()` and the crosshair's hovered index all take the **flattened** plot index.
Cell widths use the height scheme: a floor of **64 px logical** per cell, the weights share the rest,
the last cell closes the row exactly, and a figure too narrow for the floors shrinks the cells
proportionally and drops the column gap. Only the bottom row's cells carry x labels and the x title
(every other plot gets `XStrip::None`), and the left y gutter stays one maximum over the whole figure.
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
+57 -1
View File
@@ -52,7 +52,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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_`/
@@ -105,6 +105,60 @@ 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)).
- **Per-cell y.** Each cell has its own y axis (auto or fixed), its own horizontal rulers and its own
fit; a wheel or axis drag acts on the cell under the pointer, an x gesture on any bottom-row cell
moves the shared window. The cursors pane's ruler label is `plot 2a` for a plot of a split row and
`row N` (the plot index) for one of an unsplit row, so the pane reads the same as before until a row
is split.
- **Bottom-row x labels only.** The plotter gives every plot that is not in the last figure row
`XStrip::None`; the cells of the bottom row each keep their strip, so a middle-drag on the axis
works in any of them. The `t0` caption goes on those cells too.
- **One left gutter.** The y gutter is one maximum for the whole figure with the same reserved floor
as before (`Figure::set_min_y_gutter()`), **not a per-column one**: a per-column gutter would make a
cell's width depend on its labels, which "layout must not depend on content" forbids. A cell whose y
labels are narrower than the gutter simply has a wider margin.
- **Widths are weights with a floor.** The plotter keeps 64 px logical per cell and a column gap
(`kColGap`, 6 px); the editor's drag is stricter (below). A weight is a share of what is
left *after* the floors, so cells of `{3, 1}` read 533:220 in a 756 px figure, not 3:1, and the
editor's 96 px drag floor is a floor on the *share it asks for*: a cell dragged to the limit realises
`64 + share * remainder` and reads wider (144 px in the split probe's `pre-insert0` line), so the
boundary does not follow the pointer one for one near either extreme.
- **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
arrow (`kLayoutColKey`, `go-next-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; and 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
@@ -189,6 +243,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
@@ -51,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
+74 -5
View File
@@ -424,13 +424,23 @@ Two lines of the self-check output are assertions in disguise:
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
back as the zoomed range one frame later.
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..] 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
@@ -661,7 +671,7 @@ Two lines of the self-check output are assertions in disguise:
than by the first-run timer.
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
with a drop and a window change, then again with no interaction: the second run's dump
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...] ... cells=[..] colweights=[..]` with the
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise). A **persistence
@@ -2838,3 +2848,62 @@ any is a badge that does not say its panel is open, or one that stays marked aft
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=[376,376] 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=[533,220] 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=[plot 1a,plot 1b,plot 2a,plot 2b] mixed=[row 0,plot 2a,plot 2b]
```
`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). Nineteen steps, 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, `|` between rows), `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=[376,376]` (equal halves of 756) 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 cells |
| `weights 3:1` | `widths=[533,220]`, **not** 3:1, and `tile=1` | not a defect: the plotter's 64 px floor per cell is 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\|533,220] 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=[376,376]`, 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` | `left_gt=1 both_above_min=1 left_gt_right=1 tile=1`; `pair_sum` constant (753 at the 756 px window) | the column drag not moving the boundary (`left_gt=0`), a cell dragged below `kMinCellPx` (`both_above_min=0`), or a drag that changes the pair's total instead of redistributing it. The probe applies +80 px, then +/-10000 px through `layout_col_apply()` |
| `pre-insert0` (state line) | after the extreme drags `widths=[144,609]` | not a defect: the drag floors the left share at 96 px of the pair, but the plotter realises `64 + share * remainder`, so a cell dragged to the limit reads wider than 96 (see [invariants-plot-and-layout.md](invariants-plot-and-layout.md)) |
| `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=[plot 1a,plot 1b,plot 2a,plot 2b]` and, with cells `{1,2}`, `mixed=[row 0,plot 2a,plot 2b]` | the cursors pane's per-plot ruler line (a label read through the pane's own widgets): a split row's plots are `plot <label>`, an unsplit row keeps `row <plot index>` (**0-based**, as it always was -- `row 0` is the first plot, not a defect) |
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.
+22
View File
@@ -26,6 +26,15 @@ shortens `plot=` by the 36 px bar; and the `tour started: steps=` count. A self-
`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 **9248** against C99's 4095-byte string-literal limit with
the five action icons and the two app icons that remain (a note about a generated file, which GCC
@@ -196,6 +205,19 @@ precedent, not an exception.
with, so a synchronous sequence would read every position one action stale. It clicks by
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
- `UDPSCOPE_PROBE_SPLIT_MS` — drive the horizontal plot split, 19 steps 250 ms apart (so a run needs
`UDPSCOPE_PROBE_SPLIT_MS=1500 UDPSCOPE_EXIT_AFTER_MS=7500` 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()` and the cursors pane's ruler labels. 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