diff --git a/CLAUDE.md b/CLAUDE.md
index 4eabd46..f0dc4e3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
udpscope is a **GTK4 oscilloscope-style viewer for a UDPStreamer source**. It receives one
data source -- a UDPStreamer stream through `deps/udpsclient` by default, or another registered
source kind (see `docs/claude/architecture.md`) -- buffers it in a large ring (default 10 M samples per signal, under an 8 GiB memory limit), and draws it
-with `deps/plotter` — a figure of up to eight stacked rows sharing one x window. On top of
+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
diff --git a/README.md b/README.md
index 4756915..af6ee87 100644
--- a/README.md
+++ b/README.md
@@ -26,8 +26,8 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples`
(samples kept per acquired signal, default 10M; see "The sample buffer is budgeted in samples" below),
`--memory-limit-mb` (hard ceiling on the ring storage, default 8192), `--max-window` (largest time
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
-`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are
-added and removed from the **Layout editor** afterwards), `--relative-time` (see below),
+`--recv-buffer`, `--plots` (the number of plots to start with, one per row, 1..8; rows are
+added and removed and a row can be split into side-by-side plots from the **Layout editor** afterwards), `--relative-time` (see below),
`--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings; see "Data sources" below),
`--capture-packets N` (see "Packet capture" below), `--renderer NAME` (GTK's renderer:
`cairo`, `gl` or `vulkan`; `cairo` is the software preset that exports both
@@ -1105,6 +1105,9 @@ plots = 1
measure_stats = false
[[plot]]
+row = 0
+width = 1
+weight = 1
signals = ["Ch1", "", "", ""]
[[calibration]]
@@ -1113,6 +1116,14 @@ 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`) 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
@@ -2568,7 +2579,8 @@ 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 a ruler belongs to one plot, which the pane names `plot 2a`.
`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
@@ -2761,8 +2773,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*
@@ -2776,8 +2789,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
@@ -2808,6 +2821,37 @@ 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. What each plot keeps is its
+own **y axis** (auto or fixed), its own horizontal rulers and its own fit; a wheel or an axis drag acts on
+the plot under the pointer. Only the **bottom** row's plots carry the time labels, and 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.
+
+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 **drag 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 for a split row (`plot 2a`; 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
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 9981515..6ec5201 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -71,6 +71,29 @@ it (type, size, range, sample rate, timing). Below come the colour and the **Plo
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 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. Each plot has
+its **own y axis**, its own horizontal rulers and its own fit, and only the bottom row shows the time
+labels. 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, the status line and the cursors readout 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
The header's window control (`2 s`) sets the time span on screen; the popover takes a number and a
@@ -129,8 +152,8 @@ does not walk it off its snapshot.
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 plot;
- **both** — the two together;
- **mix** — the two times, read off each *trace* where they cross it.
diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md
index ee24973..cd1832b 100644
--- a/docs/claude/architecture.md
+++ b/docs/claude/architecture.md
@@ -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.
diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md
index d915040..cdf00da 100644
--- a/docs/claude/build-and-run.md
+++ b/docs/claude/build-and-run.md
@@ -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 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
diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md
index ca9562f..017a35e 100644
--- a/docs/claude/invariants-plot-and-layout.md
+++ b/docs/claude/invariants-plot-and-layout.md
@@ -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
diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md
index beee2b7..db2f0d4 100644
--- a/docs/claude/invariants-session-and-export.md
+++ b/docs/claude/invariants-session-and-export.md
@@ -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
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 8b51516..3c79b36 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -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] rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
+- `[layout-probe] 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/_.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
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 +87,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/src/ui_context_help.cpp b/src/ui_context_help.cpp
index 23d0658..79791ca 100644
--- a/src/ui_context_help.cpp
+++ b/src/ui_context_help.cpp
@@ -69,9 +69,10 @@ struct HelpHint {
/// the page is on screen, so the dock needs no hint of its own and the layer has one code path.
constexpr HelpHint kHelpHints[] = {
{"plot", "The plot",
- "Every signal you have switched on is drawn here, one row per group of traces, with all rows "
- "sharing this one time axis. The wheel zooms about the pointer, a drag pans, and a right-click "
- "on a row opens that plot's own menu.",
+ "Every signal you have switched on is drawn here, one plot per group of traces, with all plots "
+ "sharing this one time axis. The layout editor (L) adds rows and splits a row into plots side "
+ "by side. The wheel zooms about the pointer, a drag pans, and a right-click on a plot opens "
+ "its own menu.",
"", &App::area_overlay},
{"trigger", "The trigger",
"Freezes the view on an event you describe: a signal, a condition (an edge, a pulse width, a "
diff --git a/src/ui_dock.cpp b/src/ui_dock.cpp
index ae1bbb4..d372830 100644
--- a/src/ui_dock.cpp
+++ b/src/ui_dock.cpp
@@ -104,7 +104,7 @@ void clear_box(GtkWidget* box) {
/// The strip's cell widths, in characters. Fixed like the dock's, so the strip's chips are as wide with
/// `--` as with `-123.456 mV` (what does not fit scrolls).
-constexpr int kStripWhoChars = 6;
+constexpr int kStripWhoChars = 7;
constexpr int kStripTypeChars = 10;
/// A flat text button that opens a slide-over page. It only opens a page, so a rebuild can never free the
diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp
index b32b4fc..0df0302 100644
--- a/src/ui_tour.cpp
+++ b/src/ui_tour.cpp
@@ -441,7 +441,7 @@ struct TourStep {
constexpr TourStep kTourSteps[] = {
{"plot", nullptr, "The plot",
- "Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
+ "Every signal you have switched on is drawn here, all plots sharing one time axis. The wheel "
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
&App::area_overlay},
{"badges", nullptr, "The badges",