Feature/source plugins #1

Merged
martino merged 130 commits from feature/source-plugins into main 2026-11-04 07:35:21 +01:00
Showing only changes of commit 4c54b2dead - Show all commits
@@ -0,0 +1,342 @@
# Split rows share one y axis -- Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The cells of a split row share one y range and one y axis (labels on the leftmost cell only), as the figure's rows share one x window and one x axis.
**Architecture:** The vendored plotter's `Figure` gets `set_share_row_y(bool)`: when on, every figure row with more than one cell keeps its cells' y axis 0 in step (range and auto flag) from `Figure::layout()`, and every cell but the first loses its y gutter (`YStrip::None`). The host turns it on in `RowSet::rebuild_all()`, mirrors the ruler pair across a row, and maps the layout editor's divider drag through plot widths.
**Tech Stack:** C++20, GTK4/libadwaita, the vendored `deps/plotter` (git submodule; commit inside it first, then the pin in the main repo), headless probes under `xvfb-run`.
Spec: `docs/superpowers/specs/2026-11-03-split-shared-y-design.md` (amends `2026-11-03-horizontal-split-design.md`).
## Global Constraints
- Shared y is always on for split rows, no option; an unsplit row behaves exactly as before. (spec Decisions 1)
- Only the leftmost cell of a row has a y gutter and y labels; the choice is structural (column index), never from content. (Decisions 2; "layout must not depend on content", `docs/claude/invariants-plot-and-layout.md`)
- `Figure::set_share_row_y` defaults to off so existing figures and every existing test in `deps/plotter/tests` are unchanged.
- Cell rects still tile the row exactly (last cell closes the row); no rect is ever negative; `Figure(u32 rows)` and `{1}` rows lay out as today.
- The row's y gutter is taken off the row width first; the cells then share the rest by the existing `floor + extra*w/total` rule (floor `kMinCellWidthLogical = 64` x ui_scale), so equal weights give equal plot areas. The first cell's rect is gutter + its plot width.
- Rulers: one pair per figure row, drawn at the same y in every cell; a drag in any cell moves them. Measure level marks, readouts, measurements stay per trace / per plot index.
- Session file unchanged (y ranges were never persisted).
- Every source file carries the SPDX MIT header (existing files already do).
- Verification is by the plotter suite and the app's headless probes only. Build: exactly 1 warning (baseline). Probes: `gtk warnings: 0`, no `Gtk-CRITICAL`. Run recipe in `docs/claude/verification.md`; use a fresh throwaway `XDG_CONFIG_HOME` per run (never write `~/.config/udpscope`); run probes short and serial; kill the mock afterwards. The plotter test `staging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_build` may hang: run the suite under `ulimit -c 0; timeout 300`.
---
## File map
| File | Change |
| --- | --- |
| `deps/plotter/include/plot/plot/plot.hpp`, `src/plot/plot.cpp` | `YStrip`, `set_y_strip_mode`, `set_y_link_member`, `y_data_bounds`, `fit_y_bounds`; skip autoscale of a linked y axis; zero y chrome for `YStrip::None` |
| `deps/plotter/include/plot/plot/figure.hpp`, `src/plot/figure.cpp` | `set_share_row_y`, `row_y_auto`, `sync_row_y()`, strip modes, gutter maximum over leftmost cells, plot-width cell placement |
| `deps/plotter/src/render/renderer.cpp` (+ adapters if needed) | no y labels for a `None` cell |
| `deps/plotter/tests/test_figure.cpp` | new cases |
| `src/plot_host.cpp`, `src/ui.cpp`, `src/ui_cursors.cpp`, `src/ui_probe.cpp` | host wiring, rulers, pane, drag, probes |
| docs | see Task 5 |
---
### Task 1: Plotter -- y link of a row's cells (range and auto)
**Files:**
- Modify: `deps/plotter/include/plot/plot/plot.hpp`, `deps/plotter/src/plot/plot.cpp`
- Modify: `deps/plotter/include/plot/plot/figure.hpp`, `deps/plotter/src/plot/figure.cpp`
- Test: `deps/plotter/tests/test_figure.cpp`
**Interfaces:**
- Produces (Plot): `void set_y_link_member(u32 axis, bool linked) noexcept;` `bool y_data_bounds(u32 axis, Range& out) const;` (union of the visible traces' y bounds on that axis, false if none) `bool fit_y_bounds(u32 axis, Range bounds);` (the axis' own padded fit of `bounds`, as `fit_y_axis` does; leaves `auto_scale` alone).
- Produces (Figure): `void set_share_row_y(bool on);` `bool share_row_y() const noexcept;` `bool row_y_auto(u32 row) const noexcept;` (figure row; true for a row with fewer than two cells or when sharing is off, i.e. "no group").
- Behaviour: with sharing on, for each figure row with `n > 1` cells, y axis 0 of its cells is one group. A range set on any member (wheel, drag, `set_y_range`) becomes the group's and is pushed to all; the group is auto or manual as one; auto fits the union of all members' visible traces (cells without a trace ignored; padding/log handling is `fit_axis_range`'s, via `fit_y_bounds` on the first member). Members' own autoscale is suppressed.
Group state per row (`Figure::RowY`): `Range pushed; bool have = false; bool auto_scale = true;`. Sync, run from `Figure::layout()` right after `sync_x_links()` (before any `Plot::layout()` call, because that is where a plot would otherwise autoscale):
```cpp
void Figure::sync_row_y() {
if (!share_row_y_) return;
row_y_.resize(cells_per_row_.size());
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
const u32 n = cells_per_row_[row];
if (n < 2u) continue;
RowY& g = row_y_[row];
const u32 first = row_first_[row];
if (!g.have) {
g.pushed = plots_[first].y_axis(0).range;
g.auto_scale = plots_[first].y_axis(0).auto_scale;
}
// What happened since the last push. A member's auto flag that differs from the group's is a
// request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it); otherwise a
// member whose range differs from the pushed one was moved by a gesture.
bool want_auto = g.auto_scale;
Range target = g.pushed;
bool flag_request = false;
for (u32 i = first; i < first + n && !flag_request; ++i) {
const Axis& a = plots_[i].y_axis(0);
if (a.auto_scale != g.auto_scale) {
want_auto = a.auto_scale;
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
flag_request = true;
}
}
if (!flag_request && !want_auto) {
for (u32 i = first; i < first + n; ++i) {
const Range r = plots_[i].y_axis(0).range;
if (r.min != g.pushed.min || r.max != g.pushed.max) { target = r; break; }
}
}
if (want_auto) {
Range bounds{};
bool any = false;
for (u32 i = first; i < first + n; ++i) {
Range b{};
if (!plots_[i].y_data_bounds(0, b)) continue;
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
any = true;
}
if (any && plots_[first].fit_y_bounds(0, bounds)) target = plots_[first].y_axis(0).range;
// else: nothing to fit -- keep the range the group had
}
for (u32 i = first; i < first + n; ++i) {
plots_[i].set_y_range(0, target); // clears the flag ...
plots_[i].set_y_auto_scale(0, want_auto); // ... so restate it: every member mirrors the group
}
g.pushed = target;
g.auto_scale = want_auto;
g.have = true;
}
}
```
`set_share_row_y(on)` stores the flag, clears `row_y_`, and calls `set_y_link_member(0, on)` on every cell of every row with `n > 1` (cells whose plot has no y axis yet are fine: `set_y_link_member` ignores a missing axis, so also re-apply in `sync_row_y()` before the loop so axes added after the call are covered). `Plot::autoscale_axes()` must skip a y axis whose `link_member` is set (reuse `Axis::link_member`, currently x-only in meaning; update its comment). `y_data_bounds` is `trace_bounds_union(false, axis, out)`. `fit_y_bounds` is `return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds);`.
- [ ] **Step 1: Write the failing tests** -- append to `deps/plotter/tests/test_figure.cpp` (they use the existing `split_figure`, `whole_figure`, `plot::data::ArraySeries`):
```cpp
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
plot::Figure figure = split_figure({2, 1});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
// The unsplit row is its own thing.
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
PLOT_CHECK(!figure.row_y_auto(0u));
}
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
plot::Figure figure = split_figure({3});
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true); // cell 2 has no trace
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
const plot::Range r0 = figure.plot(0u).y_range(0u);
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
for (plot::u32 i = 1; i < 3u; ++i) {
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
}
}
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
plot::Figure figure = split_figure({2});
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
figure.set_share_row_y(true);
figure.layout();
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
figure.layout();
PLOT_CHECK(!figure.row_y_auto(0u));
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
figure.layout(); // manual holds: no fight with the plots' own autoscale
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
figure.layout();
PLOT_CHECK(figure.row_y_auto(0u));
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
}
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
plot::Figure figure = split_figure({2});
figure.layout();
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
figure.layout();
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
PLOT_CHECK(!figure.share_row_y());
}
```
- [ ] **Step 2: Run -- expect compile failure** (`set_share_row_y` undeclared). Build and run the plotter tests per `docs/claude/build-and-run.md` (the plotter's own CMake test target; run the binary with `ulimit -c 0; timeout 300`).
- [ ] **Step 3: Implement** the Plot additions, `Figure` members (`bool share_row_y_ = false; struct RowY {...}; std::vector<RowY> row_y_;`), `set_share_row_y`, `share_row_y`, `row_y_auto` (`row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true`), `sync_row_y` as above (declare it private next to `sync_x_links`), and call `sync_row_y();` in `Figure::layout()` immediately after `sync_x_links();`. Doc comments in the file's style (what, and why the group, not the plot, owns auto scale).
- [ ] **Step 4: Run the whole plotter suite.** Expect all new cases pass and 0 failed overall (was 407 cases).
- [ ] **Step 5: Commit** inside the submodule: `git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: cells of a row can share one y range"`. Do not touch the pin yet.
---
### Task 2: Plotter -- one y axis per row (strip mode, gutter, cell widths)
**Files:**
- Modify: `deps/plotter/include/plot/plot/plot.hpp`, `deps/plotter/src/plot/plot.cpp` (`Plot::layout`, ~lines 1286-1390)
- Modify: `deps/plotter/src/plot/figure.cpp` (`Figure::layout`, ~lines 550-625)
- Modify: `deps/plotter/src/render/renderer.cpp` (~lines 900-920, y label staging)
- Test: `deps/plotter/tests/test_figure.cpp`
**Interfaces:**
- Consumes: Task 1's `set_share_row_y`.
- Produces: `enum class YStrip { Both, None };` next to `XStrip` in `plot.hpp`; `Plot::set_y_strip_mode(YStrip)` / `y_strip_mode()`. With `None`, `Plot::layout()` reserves no y label width and no y title width (`y_axis_width == 0`, `y_title_width == 0`, `y_label_area.w == 0`) and ignores `min_y_gutter_`; the grid lines are still built. With sharing on, `Figure::layout()` sets `None` on every cell but the first of its row; the common-gutter maximum is taken over `Both` cells only; `None` cells get `set_min_y_gutter(0)`.
- Cell widths with sharing on, for a row with `n > 1` cells: after the gutter `G` is known (end of the measurement pass), `G_eff = min(G, row.w / 2)`; call `place_cells` on the rect `{row.x + G_eff, row.y, row.w - G_eff, row.h}`, then grow cell 0 to the left by `G_eff` (`x -= G_eff; w += G_eff`). Update `layout_.cells`, call `set_viewport` on each cell, then run the final `p.layout()` pass. With sharing off nothing changes.
- [ ] **Step 1: Write the failing tests** (append to `test_figure.cpp`; `give_magnitude_row` and `split_figure` exist):
```cpp
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
plot::Figure figure = split_figure({1, 3}, false);
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_share_row_y(true);
figure.layout();
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
for (plot::u32 i = 2; i < 4u; ++i) {
const plot::PlotLayout& l = figure.plot(i).layout();
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
PLOT_CHECK(l.axes.w > 0.0f);
}
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
}
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
plot::Figure figure = split_figure({3}, false);
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(8.0f);
figure.set_share_row_y(true);
const plot::FigureLayout& fl = figure.layout();
const double w0 = figure.plot(0u).layout().axes.w;
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
}
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
plot::Figure figure = split_figure({2}, false);
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(0.0f);
figure.set_share_row_y(true);
const std::vector<plot::f32> w{3.0f, 1.0f};
PLOT_CHECK(figure.set_cell_weights(0u, w));
figure.layout();
const double a0 = figure.plot(0u).layout().axes.w;
const double a1 = figure.plot(1u).layout().axes.w;
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
}
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
plot::Figure figure = split_figure({4}, false);
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
figure.set_col_gap(10.0f);
figure.set_share_row_y(true);
double sum = 0.0;
for (const plot::Rect& c : figure.layout().cells) {
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
sum += c.w;
}
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
}
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
plot::Figure figure = split_figure({2}, false);
give_magnitude_row(figure.plot(0u), 0u);
give_magnitude_row(figure.plot(1u), 1u);
figure.layout();
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
}
```
Plus a renderer check: find how existing tests count or inspect staged text (see `tests/test_render_smoke.cpp` / the `staging_a_glyph_...` neighbourhood). Add one case that a `None` cell stages no y tick labels (the staged label count of a two-cell shared row equals that of a one-cell row of the same height, plus nothing for the second cell's y side). If the harness cannot observe staged text, instead assert in the renderer change below with a comment and report that no automated check was possible.
- [ ] **Step 2: Run -- expect failures** (the gutter/width cases; `YStrip` undeclared).
- [ ] **Step 3: Implement.**
- `Plot::layout`: `const bool y_none = y_strip_ == YStrip::None;` seed `y_width = y_none ? 0 : ...`, `y_title_w = y_none ? 0 : ...`, `y_floor = y_none ? 0 : min(min_y_gutter_, y_budget)`, and `need_y = y_none ? 0 : ...` in the fixed-point test so the loop still settles in two passes. Read the loop first; every use of the y strip widths must collapse to 0 and the `axes` must start at `viewport_.x`.
- `Figure::layout`: before the viewport loop compute `const bool shared = share_row_y_ && cells_per_row_[row] > 1u;` and set `p.set_y_strip_mode(shared && i != row_first_[row] ? YStrip::None : YStrip::Both)` (reset to `Both` when not shared). In the gutter maximum loop skip `None` plots and give them `set_min_y_gutter(0.0f)`. After the measurement pass and before the final `p.layout()` loop, re-place the cells of each shared row as specified in Interfaces and re-apply `set_viewport`.
- Renderer: do not stage y tick labels (and y title) when the plot's `layout().y_label_area.w <= 0.0f`; read the staging code at `renderer.cpp:~906-917` first. Check `src/input/controller.cpp:~524` still cannot start a y axis-drag in a zero-width label area (it hit-tests `y_label_area`); if it can, guard it.
- Check both adapters (`adapters/gtk4`, `adapters/glfw`) route by cell rect only, so nothing else changes.
- [ ] **Step 4: Run the whole plotter suite.** Expect 0 failed, including all pre-existing figure cases (`figure_cells_common_y_gutter` still reads the old behaviour because sharing defaults off).
- [ ] **Step 5: Commit** in the submodule: `git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: shared-y rows draw one y axis, on the first cell"`. Then in the main repo: `git add deps/plotter && git commit -m "pin deps/plotter: shared y axis for split rows"`.
---
### Task 3: Host -- turn sharing on; rulers per row; pane and units
**Files:**
- Modify: `src/plot_host.cpp` (`rebuild_all`, `sync_cursors`, `place_cursors` call sites), `src/plot_host.hpp` if a helper is declared
- Modify: `src/ui.cpp` (`row_unit` ~1759, cursors-pane line construction ~1840-1855), `src/ui_cursors.cpp` (~55-70)
- Modify: `src/ui_probe.cpp` (split probe: new steps), `src/ui_app.hpp` if a probe state is needed
- Test: the split probe (`UDPSCOPE_PROBE_SPLIT_MS`), layout probe
**Interfaces:**
- Consumes: Task 2's `Figure::set_share_row_y(bool)` and its pin.
- Produces: no new public host API required. Behaviour: after `rebuild_all()` every split figure row shares y; `RowSet::set_y_auto/set_y_range/fit_all` need no change (they call per-plot setters that the figure's sync turns into row-wide effects).
- [ ] **Step 1: Probe first (RED).** Add a new split-probe step after the existing steps (follow the existing step style in `ui_probe.cpp` near the `[split-probe] ...` lines; note the next free step index) that, on a figure with a split row holding a trace in each cell:
1. `y_shared`: set the y range on cell A via `rs.set_y_range(a, -3, 7)`, pump a frame, assert cell B's `plot->y_range(axis_y)` equals it.
2. `y_gutter`: cell B (non-first) `layout().y_axis_width == 0` and `y_label_area.w == 0`; cell A `> 0`; and `axes.w` of A and B equal within 1 px with default weights.
3. `y_fit`: `rs.fit_all()` then one frame: both cells' ranges equal and cover both traces' y bounds.
4. `ruler_mirror`: with cursor mode showing rulers, set cell A's ruler (via `set_cursor_y` on its plot) then call `rs.sync_cursors(a)`; `ruler_y(a)==ruler_y(b)` and `ruler_b_y` likewise.
Print one line: `[split-probe] shared-y y_shared=%d y_gutter=%d y_fit=%d ruler_mirror=%d ranges=[...] axes_w=[..]`. Build and run it; expect `y_shared=0 y_gutter=0` (sharing not wired) -- RED.
- [ ] **Step 2: Wire it.**
- `RowSet::rebuild_all()`: call `figure_->set_share_row_y(true);` right after the `Figure` is created (before `install`, so the axes added by `install` are picked up -- Task 1 re-applies membership in `sync_row_y`).
- `RowSet::sync_cursors(int drag_row)`: after `r.ruler_y/ruler_b_y` are read back from the dragged cell, copy both values to every other cell of the same figure row (`first_plot_of_row(figure_row_of(drag_row))` .. `+ cells_in_row`) and `place_cursors(that, plot::CursorPlacement::Free)` on them.
- `reset_cursors()` already writes each plot's rulers from its y range; confirm both cells of a row get identical values (the range is shared). Rulers that exist before the row was split: when `insert_cell` creates the new cell, copy the row's first cell's `ruler_y/ruler_b_y` into it (read `insert_cell` and `copy`/reset helpers near `plot_host.cpp:224-240`).
- Cursors pane (`ui.cpp:~1840`): the ruler line (`slot = -1`) is created once per figure row, for the row's first plot only (`app.rows->cell_of(r) == 0`). Label: unsplit rows keep today's wording; a split row uses `"row N"` with the same numbering `plot_label` uses for the row (without the cell letter). Check `ui_cursors.cpp:~60` and every other reader of `cursors_lines` / `slot == -1` for assumptions of one ruler line per plot, and update the probe line `ruler-labels` accordingly (its expected value changes by design -- say so in the report).
- `row_unit(app, row)` (`ui.cpp:~1759`): join the units over every cell of `row`'s figure row (loop `first_plot_of_row .. + cells_in_row`, same lock), "" if they differ.
- `sync_compare_axes` (`ui_events.cpp:~555`): leave as is; the probe in Step 3 includes a comparison-mode check only if the existing compare probe covers split rows, otherwise note it in the report as unverified.
- `ui.cpp:~4237` gutter reading takes a max over all plots and `~4361` reads plot 0: both stay correct (None cells read 0). Confirm by reading, change nothing.
- [ ] **Step 3: Verify.** Incremental `cmake --build build` (1 warning baseline). Run the split probe and the layout probe per `docs/claude/verification.md` and diff against the baseline commit `18b91c8`-era numbers as in the earlier tasks (number-normalised): only lines this feature explains may differ (`ruler-labels`, `buttons=` unchanged, `cells=` unchanged; widths of non-first cells grow by the gutter). Expect the new line `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, all earlier split-probe steps still reading as before (col-drag may change -- that is Task 4), `gtk warnings: 0`, no `Gtk-CRITICAL`.
- [ ] **Step 4: Commit** `git add src/plot_host.cpp src/plot_host.hpp src/ui.cpp src/ui_cursors.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "plot host: split rows share y, rulers per row"` (add only files actually changed).
---
### Task 4: Layout editor -- divider drag through plot widths
**Files:**
- Modify: `src/ui.cpp` (`layout_col_drag_begin` ~786, `layout_col_apply` ~755)
- Modify: `src/ui_probe.cpp` (the `col-drag` step ~4190-4250), `src/ui_app.hpp` (`layout_col_drag_left0/right0` docs)
- Test: split probe `col-drag` line
**Interfaces:**
- Consumes: Tasks 2-3. A cell's rect width is no longer its weight's width: weights apply to **plot widths** (`plot->layout().axes.w + right_gutter`, i.e. rect width minus the y gutter, which only cell 0 has).
- Produces: `layout_col_drag_begin` stores the left/right **plot** widths in logical px (rect width minus `y_axis_width + y_title_width` of that cell's plot; the right cell's is 0). `layout_col_apply` is unchanged otherwise: the 96 px floor and the 64 px plotter floor both refer to plot widths now (`kMinCellPx`, `kPlotterMinCellPx`), the formula `frac = (want_left - 64)/(pair - 128)` stays, and the pair's weight sum stays.
- [ ] **Step 1: Probe first (RED).** Extend the `col-drag` step so the cells compared are plot widths (use `axes.w` of the two plots, in logical px) on a row whose first cell is the left one, in addition to the existing checks (+80 px tracking within 1 px, both extremes 96 +/- 1, pair sum preserved). With the old begin code the tracking is off by the gutter; expect `ok=0` before the fix. Keep the other split-probe lines as they are.
- [ ] **Step 2: Fix** `layout_col_drag_begin` to subtract the gutter from the left cell: `gutter = plot.layout().y_axis_width + plot.layout().y_title_width` of the left plot (0 for a non-first left cell, which has `None`), converted by the same `scale`. Update the comment above `layout_col_apply` to say "plot width".
- [ ] **Step 3: Verify.** Build; run the split probe; expect `col-drag ok=1` and the previous steps unchanged; layout probe unchanged; `gtk warnings: 0`.
- [ ] **Step 4: Commit** `git add src/ui.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "layout editor: column drag measures plot widths, not cell rects"`.
---
### Task 5: Docs
**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/layout.page`, `docs/claude/probe-readings.md`, `docs/claude/verification.md` (new probe line), `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `docs/claude/architecture.md` if it describes per-cell y, `CLAUDE.md` if it mentions per-cell y, `src/ui_context_help.cpp`, `src/ui_tour.cpp` if they say cells have their own y axis.
- [ ] **Step 1:** `grep -rn -i "per cell\|own y\|each cell\|y axis" README.md docs help src/ui_context_help.cpp src/ui_tour.cpp CLAUDE.md` to find every statement that the cells of a split row have their own y axis or one common gutter "not per column"; rewrite each to the new rule: one y range and one y axis per figure row, labels on the left cell, y zoom/pan/fit/Auto-Fixed act on the whole row, one ruler pair per row, cells share the y gutter's width only through the first cell.
- [ ] **Step 2:** `build-and-run.md`: add the plotter change (`set_share_row_y`, y link semantics, `YStrip`, plot-width cell placement) beside the other vendored changes. `invariants-plot-and-layout.md`: replace the "one gutter, not a per-column one" wording with the structural rule (first cell only, a `None` cell contributes 0, never content-dependent). `probe-readings.md` / `verification.md`: document the `[split-probe] shared-y` line and the changed `ruler-labels` / `col-drag` readings with the defect each wrong value names.
- [ ] **Step 3:** Build (help/manual and context help compile), run the split probe once more for `gtk warnings: 0`.
- [ ] **Step 4: Commit** `git add <the files changed> && git commit -m "docs: split rows share one y axis"`.