From 18b91c80167839cca18f485b651e4987831b0883 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 3 Nov 2026 15:13:45 +0100 Subject: [PATCH] docs: implementation plan for the horizontal plot split --- .../plans/2026-11-03-horizontal-split.md | 836 ++++++++++++++++++ 1 file changed, 836 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-03-horizontal-split.md diff --git a/docs/superpowers/plans/2026-11-03-horizontal-split.md b/docs/superpowers/plans/2026-11-03-horizontal-split.md new file mode 100644 index 0000000..7193f91 --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-horizontal-split.md @@ -0,0 +1,836 @@ +# Horizontal plot split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let each row of the stacked plots be split into 1..4 plots side by side, all sharing one time window, with draggable cell widths saved in the session. + +**Architecture:** Extend the vendored plotter's `plot::Figure` so a figure row holds several plots (flattened plot index, `FigureLayout.cells`). Adapters hit-test by cell rect. The host `RowSet` keeps its flattened plot index (`row_count()` = plot count) and gains `cells_per_row_` plus per-row cell weights. The layout editor, session file, signals page and a new headless probe follow. + +**Tech Stack:** C++20, GTK4/libadwaita, vendored `deps/plotter` (own `PLOT_TEST` suite, GLFW + GTK4 adapters), CMake, headless probes under `xvfb-run` against `tools/mock_udpstreamer.py`. + +**Spec:** `docs/superpowers/specs/2026-11-03-horizontal-split-design.md` + +## Global Constraints + +- All plots (cells in a row, rows in the figure) are members of the one x link group. No new time semantics; a split is not an independent time window and not an XY plot. +- Each figure row holds 1..4 cells, independently of the other rows. At most 4 cells per row and at most 8 plots in total (`RowSet::kMaxRows` keeps its value and now bounds plots, not figure rows). +- `Figure(u32 rows)` stays and means one cell per row; existing behaviour and `tests/test_figure.cpp` are unchanged. +- `plot(i)` indexes the flattened cells, top to bottom, left to right. Everything keyed by a plot index in the host keeps meaning "flattened plot index". +- Widths use the height scheme: a floor per cell, the weights share the rest, the last cell closes the row exactly (no seam). A figure too narrow for the floors shrinks the cells proportionally and drops the column gap; no rect is ever negative. +- Only the bottom figure row's cells carry x labels and the x title; other cells get `XStrip::None`. +- The left y gutter stays one common maximum for the whole figure with the same reserved floor (layout must not depend on content). +- `FigureLayout` gains `cells` (one `Rect` per plot, flattened). `rows` stays the full-width rect per figure row. +- Cursors: the vertical pair is one shared time drawn in every cell; horizontal rulers are per cell. The crosshair is one x on every cell; the hovered cell shows its y. Y axes are per cell. An x gesture anywhere moves the shared window. +- Layout editor: cell `+` insensitive at 4 cells in the row or 8 plots in total; row `+` insensitive at 8 plots in total; each cell keeps its top-left `×` (removes the cell in a multi-cell row, the row in a one-cell row; insensitive when it would leave no plot). Divider drag floors each neighbour at about 96 px logical and is saved on drag end. +- Session file: `[[plot]]` tables gain `row` (the figure row) and `width` (the cell's weight). A session without `row` loads as one cell per row. Row `weight` stays per figure row (carried on the row's first cell). +- Every source file carries an SPDX MIT header; match it in new files. +- No unit-test suite for the app: extend the headless probes in `src/ui.cpp` / `src/ui_probe.cpp`. The plotter has its own `PLOT_TEST` suite. +- Baselines for every run: exactly one build warning on a clean build; `gtk warnings: 0`; no `Gtk-CRITICAL`. +- Keep verification runs short and serial; kill the mock servers you start (never the foreign mock on port 8114). +- Plotter changes are committed inside `deps/plotter` (not pushed), then the new pin is committed in the main repo with `git add deps/plotter`. +- Branch is `feature/source-plugins`. Do not push or merge to `main`. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `deps/plotter/include/plot/plot/figure.hpp`, `src/plot/figure.cpp` (paths as found in the submodule) | `Figure` cell grid, `place_cells`, `FigureLayout.cells` | +| `deps/plotter/src/.../renderer.cpp` | draw every plot (`plot_count()`) | +| `deps/plotter/tests/test_figure.cpp`, `tests/test_glfw_host.cpp` | plotter tests | +| `deps/plotter/adapters/gtk4/gtk_plot_area.*`, `adapters/glfw/glfw_host.*` | x-aware pointer routing over cell rects | +| `src/plot_host.hpp/.cpp` | `RowSet` cell state and operations | +| `src/layout_store.hpp/.cpp` | `row`/`width` in the session file | +| `src/ui_layout.cpp` | `collect_layout` / `apply_layout` | +| `src/ui.cpp`, `src/ui_app.hpp` | layout editor controls, notices, probe scheduling | +| `src/ui_signal_page.cpp`, `src/ui_signals.cpp` | "Plot on" labels, drop target notices | +| `src/ui_probe.cpp` | `UDPSCOPE_PROBE_SPLIT_MS` probe, layout probe re-baseline | +| docs (see Task 8) | user and developer docs | + +Locate the exact plotter paths first with `git -C deps/plotter ls-files | grep -E 'figure|renderer|gtk_plot_area|glfw_host'`. + +--- + +### Task 1: Plotter `Figure` cell grid + +**Files:** +- Modify: `deps/plotter/.../figure.hpp`, `deps/plotter/.../figure.cpp`, `deps/plotter/.../renderer.cpp` (~line 474) +- Test: `deps/plotter/tests/test_figure.cpp` + +**Interfaces:** +- Produces (all in `plot::Figure`): + - `explicit Figure(std::span cells_per_row);` (a count of 0 is clamped to 1). `explicit Figure(u32 rows)` stays and means one cell per row. No `initializer_list` constructor (`Figure{3u}` must keep its meaning). + - `u32 row_count() const` = figure rows; `u32 plot_count() const`; `u32 cells_in_row(u32 row) const`; `u32 first_plot_of_row(u32 row) const` (returns `plot_count()` for `row == row_count()`); `u32 row_of_plot(u32 plot) const`. + - `Status set_cell_weights(u32 row, std::span weights)`; `void clear_cell_weights(u32 row)`; `bool has_cell_weights(u32 row) const`; `void set_col_gap(f32 px)`; `f32 col_gap() const`. + - `FigureLayout::cells` (`std::vector`, flattened, one per plot) and `FigureLayout::col_gap` (`f32`, 0 when any row dropped the gap). + - `Plot& plot(u32 i)` indexes flattened cells; `set_row_weights` validates against the figure row count. + +- [ ] **Step 1: Read the current code** + +Read `figure.hpp` fully and `figure.cpp` `layout()`, the constructor, `set_row_weights`, `set_crosshair`. Note member names `plots_`, `row_weights_`, `row_gap_`, `min_y_gutter_`, `right_gutter_`, `labels_bottom_only_`, `links_`, and `kMinRowHeightLogical = 24`. + +- [ ] **Step 2: Write the failing tests** + +Append to `tests/test_figure.cpp` (add `#include ` and `#include ` if missing). Use the file's existing helpers `figure_with_axes`, `give_magnitude_row`, `whole_figure()` if present; if the viewport helper has a different name, use the one the neighbouring tests use for a 640x480 viewport. + +```cpp +namespace { + +plot::Figure split_figure(const std::vector& cells) +{ + plot::Figure figure{std::span(cells)}; // braces: parentheses would be a most-vexing parse + figure.set_viewport(plot::Rect{0, 0, 640, 480}); + for (plot::u32 i = 0; i < figure.plot_count(); ++i) { + figure.plot(i).add_x_axis(); + figure.plot(i).add_y_axis(); + } + return figure; +} + +} // namespace + +PLOT_TEST(figure_cells_all_ones_equals_rows) +{ + auto split = split_figure({1, 1, 1}); + auto plain = figure_with_axes(3); + split.layout(); + plain.layout(); + PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u); + for (plot::u32 i = 0; i < 3; ++i) { + PLOT_CHECK_NEAR(split.layout_info().cells[i].y, plain.layout_info().rows[i].y, 0.01); + PLOT_CHECK_NEAR(split.layout_info().cells[i].h, plain.layout_info().rows[i].h, 0.01); + PLOT_CHECK_NEAR(split.layout_info().cells[i].w, plain.layout_info().rows[i].w, 0.01); + } +} + +PLOT_TEST(figure_cells_tile_exactly) +{ + auto figure = split_figure({1, 2, 1}); + figure.set_col_gap(10.0f); + figure.layout(); + const auto& fl = figure.layout_info(); + PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u); + PLOT_CHECK(figure.cells_in_row(1) == 2u); + PLOT_CHECK(figure.first_plot_of_row(1) == 1u && figure.first_plot_of_row(3) == 4u); + PLOT_CHECK(figure.row_of_plot(2) == 1u); + PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01); + PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001); + PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001); + PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001); + PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01); + PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001); +} + +PLOT_TEST(figure_cell_weights_and_floor) +{ + auto figure = split_figure({2}); + figure.set_col_gap(0.0f); + const std::vector w{3.0f, 1.0f}; + PLOT_CHECK(figure.set_cell_weights(0, w)); + figure.layout(); + { + const auto& c = figure.layout_info().cells; + PLOT_CHECK_NEAR((c[0].w - 64.0) / (c[1].w - 64.0), 3.0, 0.01); + } + const std::vector tiny{1000.0f, 0.001f}; + PLOT_CHECK(figure.set_cell_weights(0, tiny)); + figure.layout(); + PLOT_CHECK(figure.layout_info().cells[1].w >= 64.0f - 0.001f); + const std::vector three{1.0f, 1.0f, 1.0f}; + const std::vector zero{1.0f, 0.0f}; + PLOT_CHECK(!figure.set_cell_weights(0, three)); + PLOT_CHECK(!figure.set_cell_weights(0, zero)); + PLOT_CHECK(!figure.set_cell_weights(5, w)); + figure.clear_cell_weights(0); + PLOT_CHECK(!figure.has_cell_weights(0)); + figure.layout(); + PLOT_CHECK_NEAR(figure.layout_info().cells[0].w, figure.layout_info().cells[1].w, 0.01); +} + +PLOT_TEST(figure_cells_narrow_degrades) +{ + std::vector cells{4}; + plot::Figure figure{std::span(cells)}; + figure.set_viewport(plot::Rect{0, 0, 20, 100}); + for (plot::u32 i = 0; i < 4; ++i) { figure.plot(i).add_x_axis(); figure.plot(i).add_y_axis(); } + figure.set_col_gap(10.0f); + figure.layout(); + const auto& fl = figure.layout_info(); + double sum = 0; + for (const auto& c : fl.cells) { + PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f); + PLOT_CHECK_NEAR(c.h, 100.0, 0.01); + sum += c.w; + } + PLOT_CHECK_NEAR(sum, 20.0, 0.01); + PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001); +} + +PLOT_TEST(figure_cells_share_x_link_and_crosshair) +{ + auto figure = split_figure({2, 1}); + const auto group = figure.add_x_link(); + for (plot::u32 i = 0; i < 3; ++i) figure.link_x(group, i, 0); + figure.plot(1).set_x_range(0, {3.0, 9.0}); + figure.layout(); + for (plot::u32 i = 0; i < 3; ++i) { + PLOT_CHECK_NEAR(figure.plot(i).x_range(0).lo, 3.0, 1e-9); + PLOT_CHECK_NEAR(figure.plot(i).x_range(0).hi, 9.0, 1e-9); + } + figure.set_crosshair(4.0, true); + for (plot::u32 i = 0; i < 3; ++i) { + PLOT_CHECK(figure.plot(i).crosshair().active); + PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9); + } + figure.clear_crosshair(); + for (plot::u32 i = 0; i < 3; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active); +} + +PLOT_TEST(figure_cells_x_labels_bottom_row_only) +{ + auto figure = split_figure({1, 2}); + figure.layout(); + PLOT_CHECK(figure.plot(0).layout().x_label_area.h == 0.0f); + PLOT_CHECK(figure.plot(1).layout().x_label_area.h > 0.0f); + PLOT_CHECK(figure.plot(2).layout().x_label_area.h > 0.0f); + PLOT_CHECK_NEAR(figure.layout_info().x_axis_strip.h, figure.plot(2).layout().x_label_area.h, 0.001); +} + +PLOT_TEST(figure_cells_common_y_gutter) +{ + auto figure = split_figure({2}); + give_magnitude_row(figure.plot(0), 0); + give_magnitude_row(figure.plot(1), 1); + figure.layout(); + const auto& fl = figure.layout_info(); + const double g0 = figure.plot(0).layout().axes.x - fl.cells[0].x; + const double g1 = figure.plot(1).layout().axes.x - fl.cells[1].x; + PLOT_CHECK(g0 > 0.0); + PLOT_CHECK_NEAR(g0, g1, 0.01); +} +``` + +Adjust accessor names (`layout_info()`, `set_viewport`, `link_x`, `x_range`, `add_x_axis`) to the ones the existing tests in the same file use; the intent of each test is fixed. + +- [ ] **Step 3: Run to verify it fails** + +Run: `cmake --build deps/plotter/build --target plot_tests 2>&1 | tail -20` (use the plotter build dir the repo already has; if none, configure with the plotter's own `CMakeLists.txt` and `-DPLOT_BUILD_TESTS=ON`). +Expected: compile failure, `plot_count` / `set_cell_weights` not members of `Figure`. + +- [ ] **Step 4: Implement the header** + +In `figure.hpp`: + +```cpp +explicit Figure(u32 rows); +explicit Figure(std::span cells_per_row); + +u32 row_count() const; // figure rows +u32 plot_count() const; // flattened cells +u32 cells_in_row(u32 row) const; +u32 first_plot_of_row(u32 row) const; // plot_count() for row == row_count() +u32 row_of_plot(u32 plot) const; + +Status set_cell_weights(u32 row, std::span weights); +void clear_cell_weights(u32 row); +bool has_cell_weights(u32 row) const; +void set_col_gap(f32 px); +f32 col_gap() const; +``` + +Members: `std::vector cells_per_row_; std::vector row_first_; // prefix sums, rows+1 entries; std::vector> cell_weights_; f32 col_gap_ = 0.0f; void build_cells(std::span);`. In `FigureLayout` add `std::vector cells; f32 col_gap = 0.0f;`. + +- [ ] **Step 5: Implement the constructors and accessors** + +```cpp +Figure::Figure(u32 rows) +{ + const std::vector ones(rows, 1u); + build_cells(std::span(ones)); +} + +Figure::Figure(std::span cells_per_row) { build_cells(cells_per_row); } + +void Figure::build_cells(std::span cells) +{ + cells_per_row_.assign(cells.begin(), cells.end()); + for (u32& c : cells_per_row_) c = std::max(c, 1u); + row_first_.assign(cells_per_row_.size() + 1, 0u); + for (std::size_t r = 0; r < cells_per_row_.size(); ++r) + row_first_[r + 1] = row_first_[r] + cells_per_row_[r]; + plots_ = std::vector(row_first_.back()); + cell_weights_.assign(cells_per_row_.size(), {}); + // keep whatever the old Figure(u32) constructor initialised besides plots_ (row_weights_ etc.) +} + +u32 Figure::row_count() const { return static_cast(cells_per_row_.size()); } +u32 Figure::plot_count() const { return static_cast(plots_.size()); } +u32 Figure::cells_in_row(u32 row) const { return row < row_count() ? cells_per_row_[row] : 0u; } +u32 Figure::first_plot_of_row(u32 row) const { return row <= row_count() ? row_first_[row] : plot_count(); } +u32 Figure::row_of_plot(u32 plot) const +{ + u32 r = 0; + while (r + 1 < row_count() && row_first_[r + 1] <= plot) ++r; + return r; +} + +Status Figure::set_cell_weights(u32 row, std::span w) +{ + if (row >= row_count() || w.size() != cells_per_row_[row]) return Status::invalid_argument(); + for (f32 v : w) if (!(v > 0.0f)) return Status::invalid_argument(); + cell_weights_[row].assign(w.begin(), w.end()); + return Status::ok(); +} +void Figure::clear_cell_weights(u32 row) { if (row < row_count()) cell_weights_[row].clear(); } +bool Figure::has_cell_weights(u32 row) const { return row < row_count() && !cell_weights_[row].empty(); } +void Figure::set_col_gap(f32 px) { col_gap_ = std::max(0.0f, px); } +f32 Figure::col_gap() const { return col_gap_; } +``` + +Use the `Status` constructors the file already uses for `set_row_weights` failures (copy that spelling). Remove any other `plots_` initialisation from the old constructor and make `set_row_weights` validate against `row_count()` (it already did if it used `row_count()`; verify it did not use `plots_.size()`). + +- [ ] **Step 6: Implement `place_cells` and wire it into `layout()`** + +In an anonymous namespace of `figure.cpp`: + +```cpp +constexpr f32 kMinCellWidthLogical = 64.0f; + +// Splits the row rect `r` into n cells. Returns the gap actually used. +f32 place_cells(const Rect& r, u32 n, const std::vector& weights, f32 col_gap, f32 ui_scale, + Rect* out) +{ + if (n == 1) { out[0] = r; return col_gap; } + f32 gap = col_gap; + f32 usable = r.w - gap * static_cast(n - 1); + if (usable <= 0.0f) { gap = 0.0f; usable = r.w; } + usable = std::max(0.0f, usable); + const f32 floor_px = std::min(kMinCellWidthLogical * ui_scale, usable / static_cast(n)); + const f32 extra = usable - floor_px * static_cast(n); + f32 total = 0.0f; + if (weights.size() == n) for (f32 w : weights) total += w; + f32 x = r.x; + f32 consumed = 0.0f; + for (u32 i = 0; i < n; ++i) { + const f32 share = (weights.size() == n && total > 0.0f) ? weights[i] / total + : 1.0f / static_cast(n); + const f32 end = (i + 1 == n) ? usable : consumed + floor_px + extra * share; + out[i] = Rect{x, r.y, end - consumed, r.h}; + consumed = end; + x = out[i].right() + gap; + } + return gap; +} +``` + +In `Figure::layout()`, after the row rects (`layout_.rows`) are computed and before viewports are set: size `layout_.cells` to `plot_count()`, call `place_cells` per figure row (pass `&layout_.cells[row_first_[r]]`), track `layout_.col_gap = (any row with n>1 and returned gap < col_gap_) ? 0 : col_gap_` (set `col_gap` to `col_gap_` otherwise), and set each plot's viewport to `layout_.cells[i]`. The strip rule becomes: per plot `i`, `XStrip::None` when `labels_bottom_only_` and `row_of_plot(i) + 1 != row_count()`, else the existing strip. Every loop that ran over rows for `set_right_gutter`, `sync_x_links()`, the two gutter passes and crosshair now runs over `plots_` (flattened). Keep `layout_.x_axis_strip = plots_.back().layout().x_label_area`. The ui_scale reference stays `plots_[0].ui_scale()`. + +In `renderer.cpp` ~474 replace `const u32 rows = figure.row_count();` with `figure.plot_count()`. + +- [ ] **Step 7: Run tests** + +Run: `cmake --build deps/plotter/build --target plot_tests && deps/plotter/build/plot_tests 2>&1 | tail -15` +Expected: all tests pass, including the pre-existing `figure_*` tests. + +- [ ] **Step 8: Commit in the submodule** + +```bash +git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: rows can hold several plots (cells), cell weights and column gap" +``` + +--- + +### Task 2: Adapters route pointers by cell + +**Files:** +- Modify: `deps/plotter/adapters/gtk4/gtk_plot_area.cpp/.hpp`, `deps/plotter/adapters/glfw/glfw_host.cpp/.hpp` +- Test: `deps/plotter/tests/test_glfw_host.cpp` +- Modify (main repo): `deps/plotter` pin + +**Interfaces:** +- Consumes: `Figure::plot_count()`, `FigureLayout::cells`. +- Produces: `GtkPlotArea::row_at(x, y)` returns the flattened plot index under (x, y) (nearest cell when outside all); GLFW `Host::controller_at(x, y)` is x-aware. `row_controller(i)` indexes flattened plots. + +- [ ] **Step 1: Write the failing GLFW routing test** + +Add `#include ` and, in `tests/test_glfw_host.cpp`, next to the existing `Routed(rows, gap)` fixture test: + +```cpp +PLOT_TEST(glfw_host_routes_by_cell) +{ + std::vector cells{1, 2}; + plot::Figure figure{std::span(cells)}; + figure.set_viewport(plot::Rect{0, 0, 300, 600}); + figure.set_col_gap(6.0f); + for (plot::u32 i = 0; i < 3; ++i) { figure.plot(i).add_x_axis(); figure.plot(i).add_y_axis(); } + figure.layout(); + plot::glfw::Host host; + host.set_figure(&figure); + const auto& c = figure.layout_info().cells; + for (plot::u32 i = 0; i < 3; ++i) { + const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5; + PLOT_CHECK(&host.controller_at(cx, cy) == host.row_controller(i)); + } + // in the gap: nearest cell wins + PLOT_CHECK(&host.controller_at(c[1].right() + 1.0, c[1].y + 5.0) == host.row_controller(1)); + PLOT_CHECK(&host.controller_at(c[2].x - 1.0, c[2].y + 5.0) == host.row_controller(2)); +} +``` + +Mirror the existing fixture for how `controller_at` / `row_controller` are called (names may differ slightly; keep the three assertions). + +- [ ] **Step 2: Run to verify it fails** + +Run: `cmake --build deps/plotter/build --target test_glfw_host 2>&1 | tail` then run the binary. +Expected: FAIL (the cell-2 centre routes to cell 1, or build error). + +- [ ] **Step 3: GLFW host** + +In `glfw_host.cpp`: `set_figure` uses `plot_count()` for the controllers; `refresh_row_rects` copies `layout().cells`; replace `row_in(rows, y)` with + +```cpp +static int row_in(const std::vector& rects, double x, double y) +{ + int best = -1; + double best_d = 0.0; + for (std::size_t i = 0; i < rects.size(); ++i) { + const Rect& r = rects[i]; + const double dx = x < r.x ? r.x - x : (x > r.right() ? x - r.right() : 0.0); + const double dy = y < r.y ? r.y - y : (y > r.bottom() ? y - r.bottom() : 0.0); + const double d = dx * dx + dy * dy; + if (best < 0 || d < best_d) { best = static_cast(i); best_d = d; } + if (d == 0.0) break; + } + return best; +} +``` + +(Use `r.y + r.h` / `r.x + r.w` if `Rect` has no `bottom()`.) For unsplit figures this is equivalent to the old y-only nearest-row rule. Make `controller_at(x, y)`, `pointer_target(x, y, ...)` and `pointer_row(rows)` pass `x` (use `last_pointer_x_` in `pointer_row`) and drop the `(void)x`. `sync_figure_crosshair` loops over the plot count. Update the header comments. + +- [ ] **Step 4: GTK adapter** + +In `gtk_plot_area.cpp`: `set_figure` uses `plot_count()` (line ~248); `refresh_row_rects` caches `.layout().cells`; add the same x-aware `row_in(rows, x, y)`; change `controller_at`, `row_at(x, y)` (use `to_device(x, y)`), `target_controller(f32 x, f32 y)`, `pointer_row()` (`pointer_.x/.y`) and `press_row_` (set with `row_in(row_rects_, p.x, p.y)` at ~619). Update every call site of `target_controller` (~637 `p`, ~646 `position`, ~751 `sample.position`, ~798 `g`, ~837 `at`, ~861 `p`) to pass both coordinates. Update the header declarations. + +- [ ] **Step 5: Build and run plotter tests** + +Run: `cmake --build deps/plotter/build 2>&1 | tail -5 && for t in deps/plotter/build/test_*; do $t >/dev/null || echo FAIL $t; done` +Expected: no FAIL lines. + +- [ ] **Step 6: Commit the submodule, then the pin; check the app still builds** + +```bash +git -C deps/plotter add -u && git -C deps/plotter commit -m "adapters: route pointer events by cell rect" +git add deps/plotter && git commit -m "deps/plotter: figure rows can hold several plots" +cmake --build build 2>&1 | grep -c warning +``` +Expected: the build succeeds with the single baseline warning (count as before the change; compare with `git stash`-free run of an untouched clean build if unsure). + +--- + +### Task 3: `RowSet` cell state and the split probe scaffold + +**Files:** +- Modify: `src/plot_host.hpp`, `src/plot_host.cpp` (`rebuild_all` ~309, `set_row_count` ~156, `carry_row` ~164, `clear_row` ~184, `insert_row` ~195, `remove_row` ~205, `apply_row_weights` ~303, `x_note` caption ~920) +- Modify: `src/ui_app.hpp` (`dbg_split_step`, declarations near ~2093), `src/ui_probe.cpp` (new `split_probe_*`), `src/ui.cpp` (scheduling next to `UDPSCOPE_PROBE_LAYOUT_MS`, ~6003) + +**Interfaces:** +- Produces in `RowSet`: + - `static constexpr int kMaxCellsPerRow = 4;` + - `int figure_row_count() const; int cells_in_row(int fr) const; int first_plot_of_row(int fr) const; int figure_row_of(int plot) const /* -1 if invalid */; int cell_of(int plot) const; const std::vector& cell_weights(int fr) const;` + - `std::string plot_label(int plot) const` ("2" for a single-cell row, "2a".."2d" for a split row; the figure row is `fr + 1`) + - `bool insert_cell(int fr, int at)`, `bool remove_cell(int plot)`, `void set_cell_weights(int fr, std::vector w)`, `void set_cells_per_row(std::vector cells)` + - `insert_row(at)` / `remove_row(at)` act on figure rows; `set_row_count(n)` means n single-cell rows; `row_count()` stays the plot count. +- Invariant: `sum(cells_per_row_) == rows_` (rows_ ≤ kMaxRows = 8). + +- [ ] **Step 1: Read** `plot_host.hpp` 35–215, 280–320 and `plot_host.cpp` 135–345, 905–950 to see `carry_row`, `clear_row`, `storage_`, `rebuild_all`. + +- [ ] **Step 2: Header additions** + +```cpp +static constexpr int kMaxCellsPerRow = 4; + +int figure_row_count() const { return static_cast(cells_per_row_.size()); } +int cells_in_row(int fr) const; +int first_plot_of_row(int fr) const; // rows_ for fr == figure_row_count() +int figure_row_of(int plot) const; // -1 if plot is out of range +int cell_of(int plot) const; // 0-based position inside its row, -1 if invalid +const std::vector& cell_weights(int fr) const; +std::string plot_label(int plot) const; + +bool insert_cell(int fr, int at); // false at kMaxCellsPerRow cells or kMaxRows plots +bool remove_cell(int plot); // a single-cell row removes the row; false if it would leave no plot +void set_cell_weights(int fr, std::vector w); +void set_cells_per_row(std::vector cells); + +private: + std::vector cells_per_row_{1}; + std::vector> cell_weights_ = std::vector>(1); + void apply_cell_weights(); +``` + +- [ ] **Step 3: Implement queries and labels** in `plot_host.cpp` + +```cpp +int RowSet::cells_in_row(int fr) const +{ + return fr >= 0 && fr < figure_row_count() ? cells_per_row_[fr] : 0; +} +int RowSet::first_plot_of_row(int fr) const +{ + int p = 0; + for (int r = 0; r < fr && r < figure_row_count(); ++r) p += cells_per_row_[r]; + return p; +} +int RowSet::figure_row_of(int plot) const +{ + if (plot < 0 || plot >= rows_) return -1; + int p = 0; + for (int r = 0; r < figure_row_count(); ++r) { + p += cells_per_row_[r]; + if (plot < p) return r; + } + return -1; +} +int RowSet::cell_of(int plot) const +{ + const int fr = figure_row_of(plot); + return fr < 0 ? -1 : plot - first_plot_of_row(fr); +} +const std::vector& RowSet::cell_weights(int fr) const +{ + static const std::vector none; + return fr >= 0 && fr < static_cast(cell_weights_.size()) ? cell_weights_[fr] : none; +} +std::string RowSet::plot_label(int plot) const +{ + const int fr = figure_row_of(plot); + if (fr < 0) return std::to_string(plot + 1); + if (cells_per_row_[fr] == 1) return std::to_string(fr + 1); + return std::to_string(fr + 1) + static_cast('a' + cell_of(plot)); +} +``` + +- [ ] **Step 4: Figure construction in `rebuild_all`** + +Replace the `std::make_unique(rows_)` with + +```cpp +std::vector cells(cells_per_row_.begin(), cells_per_row_.end()); +figure_ = std::make_unique(std::span(cells)); +``` + +and after `set_row_gap(kRowGap)` add `figure_->set_col_gap(kColGap);` (`constexpr float kColGap = 6.0f;` beside `kRowGap`), then call `apply_row_weights(); apply_cell_weights();`. The per-plot loop stays per plot index (it already iterates `rows_`). + +```cpp +void RowSet::apply_cell_weights() +{ + if (!figure_) return; + cell_weights_.resize(cells_per_row_.size()); + for (int fr = 0; fr < figure_row_count(); ++fr) { + auto& w = cell_weights_[fr]; + if (cells_per_row_[fr] < 2) { w.clear(); continue; } + if (w.empty()) { figure_->clear_cell_weights(fr); continue; } + w.resize(cells_per_row_[fr], 1.0f); + figure_->set_cell_weights(fr, std::span(w)); + } +} +``` + +`apply_row_weights` resizes to `figure_row_count()` instead of `rows_`. + +- [ ] **Step 5: Mutators** + +```cpp +bool RowSet::insert_cell(int fr, int at) +{ + if (fr < 0 || fr >= figure_row_count()) return false; + if (cells_per_row_[fr] >= kMaxCellsPerRow || rows_ >= kMaxRows) return false; + const int n = cells_per_row_[fr]; + const int p = first_plot_of_row(fr) + std::clamp(at, 0, n); + for (int i = rows_; i > p; --i) carry_row(i, i - 1); // carry_row(dst, src), as insert_row uses it + clear_row(p); + ++rows_; + ++cells_per_row_[fr]; + auto& w = cell_weights_[fr]; + if (!w.empty()) { + float mean = 0.0f; + for (float v : w) mean += v; + mean /= static_cast(w.size()); + w.insert(w.begin() + std::clamp(at, 0, n), mean); + } + rebuild_all(); + return true; +} + +bool RowSet::remove_cell(int plot) +{ + const int fr = figure_row_of(plot); + if (fr < 0) return false; + if (cells_per_row_[fr] == 1) { + if (figure_row_count() == 1) return false; + remove_row(fr); + return true; + } + for (int i = plot; i + 1 < rows_; ++i) carry_row(i, i + 1); + --rows_; + clear_row(rows_); + const int k = plot - first_plot_of_row(fr); + --cells_per_row_[fr]; + auto& w = cell_weights_[fr]; + if (cells_per_row_[fr] < 2) w.clear(); + else if (k < static_cast(w.size())) w.erase(w.begin() + k); + rebuild_all(); + return true; +} + +void RowSet::set_cell_weights(int fr, std::vector w) +{ + if (fr < 0 || fr >= figure_row_count()) return; + cell_weights_[fr] = std::move(w); + if (figure_) apply_cell_weights(); +} + +void RowSet::set_cells_per_row(std::vector cells) +{ + std::vector next; + int total = 0; + for (int c : cells) { + c = std::clamp(c, 1, kMaxCellsPerRow); + if (total + c > kMaxRows) c = kMaxRows - total; + if (c < 1) break; + next.push_back(c); + total += c; + } + if (next.empty()) next.push_back(1), total = 1; + cells_per_row_ = std::move(next); + cell_weights_.assign(cells_per_row_.size(), {}); + rows_ = total; + rebuild_all(); +} +``` + +Check against the real `carry_row` signature (read in Step 1) and keep its argument order; if `insert_row` uses a different shifting idiom, copy it. `insert_row(at)` / `remove_row(at)`: `at` is a figure row; the plot position is `first_plot_of_row(at)`; insert/erase the matching `cells_per_row_` entry (one cell for an inserted row) and `cell_weights_` entry; `remove_row` shifts storage down by the removed row's cell count `n` and decrements `rows_` by `n`. `insert_row` refuses when `rows_ >= kMaxRows`. `set_row_count(n)`: sets all cells to one per row (`cells_per_row_.assign(n, 1)`, `cell_weights_.assign(n, {})`) and early-returns only when `n == rows_` and every row already has one cell. + +- [ ] **Step 6: The `x_note` caption** (~line 920): replace `const bool bottom = i == rows_ - 1;` with `const bool bottom = i >= first_plot_of_row(figure_row_count() - 1);`. + +- [ ] **Step 7: Probe scaffold** + +In `ui_app.hpp` add `int dbg_split_step = 0;` beside `dbg_layout_step` and declarations beside the layout probe's: `void split_probe_report(App& app, const char* tag); void split_probe_next(App& app); gboolean split_probe_step(gpointer data);`. In `ui_probe.cpp` (SPDX header already in the file) add, modelled on `layout_probe_*` (read `ui_probe.cpp` 3386–3471 and 3751–3890 first, reuse `enable_all_probe`'s channel assignment loop): + +```cpp +void split_probe_report(App& app, const char* tag) +{ + RowSet& rs = *app.rows; + const auto& fl = rs.figure().layout(); + int tile = 1; + for (int fr = 0; fr < rs.figure_row_count(); ++fr) { + const int first = rs.first_plot_of_row(fr), n = rs.cells_in_row(fr); + const auto& row = fl.rows[fr]; + if (std::fabs(fl.cells[first].x - row.x) > 0.5f) tile = 0; + if (std::fabs(fl.cells[first + n - 1].right() - row.right()) > 0.5f) tile = 0; + for (int k = 0; k < n; ++k) { + const auto& c = fl.cells[first + k]; + if (std::fabs(c.y - row.y) > 0.5f || std::fabs(c.h - row.h) > 0.5f) tile = 0; + if (k > 0 && std::fabs((c.x - fl.cells[first + k - 1].right()) - fl.col_gap) > 0.5f) tile = 0; + } + } + // x_equal: every plot's x range equals plot 0's; cursors_aligned: every plot's cursor a/b x equal + int x_equal = 1, aligned = 1; + const auto r0 = rs.plot(0)->x_range(rs.axis_x(0)); + for (int p = 1; p < rs.row_count(); ++p) { + const auto r = rs.plot(p)->x_range(rs.axis_x(p)); + const double span = std::max(1e-12, std::fabs(r0.hi - r0.lo)); + if (std::fabs(r.lo - r0.lo) > 1e-9 * span || std::fabs(r.hi - r0.hi) > 1e-9 * span) x_equal = 0; + if (std::fabs(rs.cursor_x(p, 0) - rs.cursor_x(0, 0)) > 1e-9 * span) aligned = 0; + if (std::fabs(rs.cursor_x(p, 1) - rs.cursor_x(0, 1)) > 1e-9 * span) aligned = 0; + } + // print: [split-probe] %-14s plots=%d frows=%d cells=[a,b] widths=[logical px, | between rows] tile=%d x_equal=%d cursors_aligned=%d weights=[...] traces=[P:name,...|...] +} +``` + +The accessor names `plot(p)`, `axis_x(p)` and `cursor_x(p, which)` are placeholders for the host's real accessors for the plot object, its x axis index and cursor A/B x (the layout probe and `RowSet::cursor_a_x()` show them: `r.plot->cursor(r.cursor_a).x`, `r.plot->x_range(r.axis_x)`); use those, adding a tiny `RowSet::cursor_x(int plot, int which)` if none exists. Fill in the print line with `g_print`/`std::printf` in the style of `layout_probe_report`, with widths in logical px (`cell.w / scale`), weights from `rs.cell_weights(fr)` and traces as `P:name` from the slot names the layout probe already prints. + +`split_probe_step` follows `layout_probe_step` (200 ms) but with 250 ms steps; after any structural change call `app.area->set_figure(&app.rows->figure()); app.relayout_pending = true;` and let a frame run before the report. Steps: + 0. `rows->set_cells_per_row({1}); ` free the slots; enable all signals and assign channels via the `enable_all_probe` loop; report `"start"` (expect `plots=1 frows=1 cells=[1]`). + 1. `insert_cell(0, 1)`; assign channel 0 to plot 0 and channel 1 to plot 1; `set_cursors_visible(true)`; report `"split"` (expect `cells=[2] tile=1 x_equal=1 cursors_aligned=1`). + 2. `set_cell_weights(0, {3, 1})`; report `"weights 3:1"` (widths ratio about 3:1 after the floors; `tile=1`). + 3. `insert_row(1)` then `insert_cell(1, 1)`; report `"row+cell"` (expect `cells=[2,2] plots=4`). + 4. Call `insert_cell` until it returns false and then `insert_row` until the plot total is 8; report `"caps"` printing the refusals (`insert_cell(0, 2)` true, `…` false at 4 cells; `insert_row` false at 8 plots). Keep these as separate printed `refused=` fields. + 5. `remove_cell` on the last plot of the widest row, report `"remove"`; then finish by returning `G_SOURCE_REMOVE`. + +In `ui.cpp` next to `UDPSCOPE_PROBE_LAYOUT_MS` (~6003) add: + +```cpp +if (const char* ms = g_getenv("UDPSCOPE_PROBE_SPLIT_MS")) + g_timeout_add(static_cast(std::atoi(ms)), split_probe_step, app); +``` + +(Use the exact `g_timeout_add` argument shape the layout probe uses.) + +- [ ] **Step 8: Build and run the probe** + +```bash +cmake --build build 2>&1 | grep -E 'warning|error' | head +python3 tools/mock_udpstreamer.py --port 8171 --rate 1000 --batch 20 & MOCK=$!; sleep 2 +xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_SPLIT_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8171 2>&1 | grep -E 'split-probe|gtk warnings|Gtk-CRITICAL' +kill $MOCK +``` +Expected: the six `[split-probe]` lines above with `tile=1 x_equal=1 cursors_aligned=1`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Also confirm that the first build output still has exactly one warning. + +- [ ] **Step 9: Commit** + +```bash +git add src/plot_host.hpp src/plot_host.cpp src/ui_app.hpp src/ui_probe.cpp src/ui.cpp +git commit -m "plot host: rows hold 1..4 cells; split probe scaffold" +``` + +--- + +### Task 4: Session file and probe round-trip + +**Files:** +- Modify: `src/layout_store.hpp` (`LayoutState::Row`), `src/layout_store.cpp` (writer ~786, `plots = N` ~729, parser `[[plot]]` ~457 and keys ~605–614, fallback ~718), `src/ui_layout.cpp` (`collect_layout` 17–36, `apply_layout` ~445–520) +- Modify: `src/ui_probe.cpp` (new probe steps) + +**Interfaces:** +- Consumes: `RowSet::cells_in_row`, `figure_row_of`, `cell_weights`, `set_cells_per_row`, `set_cell_weights`, `set_row_weights`. +- Produces: `LayoutState::Row { …; int row = -1; double width = 1.0; }`. + +- [ ] **Step 1: Format** + +`LayoutState::Row` gains `int row = -1; // figure row; -1 = file had none` and `double width = 1.0; // cell weight`. The writer emits, per `[[plot]]`: `row = `, `width = `, then the existing `weight` (the figure row's weight, written on every cell) and `signals`. Parser: keys `row` (int ≥ 0) and `width` (double > 0) next to `weight`. A file where no `[[plot]]` has `row` leaves every `row == -1`. + +- [ ] **Step 2: `collect_layout`** — for each plot `p`: `fr = rs.figure_row_of(p)`, `row.row = fr`, `row.weight = row_weights()[fr]`, `row.width = cell_weights(fr)` entry `cell_of(p)` or 1.0 when the row has no weights. + +- [ ] **Step 3: `apply_layout`** — before `set_row_count`: if every `Row.row >= 0`, validate that rows are non-decreasing, contiguous from 0 (each step 0 or +1), at most `kMaxCellsPerRow` cells per row and at most `kMaxRows` plots, otherwise fall back to the one-cell-per-row path. Valid: build `cells_per_row` counts and call `rows->set_cells_per_row(counts)`; row weights come from the first cell of each figure row (`set_row_weights`); for rows with ≥ 2 cells call `set_cell_weights(fr, {widths…})`. Fallback (no `row`, or invalid): `set_row_count(min(n, kMaxRows))` as today. The per-plot signal assignment loop is unchanged (it is per flattened plot index). + +- [ ] **Step 4: Probe steps** — append to `split_probe_step`: + 6. Build `{1,2}` with weights `{3,1}` and signals in each cell, `collect_layout` → `save_layout(tmp)` (use a path under `g_get_tmp_dir()` and delete it afterwards) → set to `{1}` → `load_layout` → `apply_layout`; report `"roundtrip"` and add `rt=1/0` comparing cells, widths (tolerance 1e-6) and traces. + 7. Write a copy of that file with all `row = …` lines stripped, load/apply it, report `"legacy"` (expect `cells=[1,1,1]`, or the plot count of the file, one cell per row). + +- [ ] **Step 5: Run the probe** (same recipe as Task 3 Step 8). Expected: `roundtrip rt=1`, `legacy` with one cell per row, `gtk warnings: 0`. + +- [ ] **Step 6: Commit** + +```bash +git add src/layout_store.hpp src/layout_store.cpp src/ui_layout.cpp src/ui_probe.cpp +git commit -m "session: plots carry row and width; files without row load one cell per row" +``` + +--- + +### Task 5: Layout editor cell controls and column drag + +**Files:** +- Modify: `src/ui.cpp` (layout editor 568–807), `src/ui_layout.cpp` (534–598 handlers), `src/ui_app.hpp` (constants ~1600–1607, new `App` fields), `src/ui_probe.cpp` (`layout_probe_*` re-baseline, new split-probe click steps) + +**Interfaces:** +- Consumes: Task 3 `RowSet` API, Task 2 `FigureLayout.cells`. +- Produces: `void layout_col_apply(App&, int plot, double offset_px)`, `GtkWidget* layout_cell_button(App&, int fr, int pos)`, `GtkWidget* layout_col_handle(App&, int plot)`; `App::layout_col_drag_plot`, `App::layout_col_drag_left0` / `layout_col_drag_right0` (start widths, logical px); constants `kLayoutCellKey = "udpscope-layout-cell"`, `kLayoutColKey = "udpscope-layout-col"`, `kMinCellPx = 96`. + +- [ ] **Step 1: Read** `ui.cpp` 568–807 (overlay build, `sync_layout_editor`, `layout_overlay_button`), `ui_layout.cpp` 534–598 and the drag handlers, `ui_app.hpp` ~1600–1607. + +- [ ] **Step 2: Row-keyed controls by figure row.** The row boundary `+` buttons and the row resize handles are keyed by **figure row** (`figure_row_count() + 1` boundaries, positioned from `fl.rows`). `on_layout_row_insert(boundary)` calls `rows->insert_row(boundary)`. The per-cell `×` stays keyed by plot index (existing `set_row_key`), positioned from `fl.cells[idx]` top-left, and its handler calls `rows->remove_cell(idx)`; it is insensitive when `remove_cell` would fail (`row_count() == 1`). After each handler: `app->area->set_figure(&app->rows->figure())`, `rebuild_layout_editor`, `rebuild_signal_panels`, `schedule_layout_save` (same tail as the existing handlers). The row `+` is insensitive at `row_count() >= RowSet::kMaxRows`. + +- [ ] **Step 3: Cell `+` buttons.** For each figure row `fr` with `n = cells_in_row(fr)`, create `n + 1` buttons via `layout_overlay_button(app, "list-add-symbolic", tooltip)` with `g_object_set_data(btn, kLayoutCellKey, GINT_TO_POINTER(fr * 8 + pos + 1))` (do **not** call `set_row_key`). Positions: `pos 0` at `cells[first].x/scale + kLayoutButtonPad`; internal `pos k` centred on the gap between cells `k-1` and `k`; `pos n` at `cells[last].right()/scale - kLayoutButtonPx - kLayoutButtonPad`; vertically centred in `fl.rows[fr]`. Clicking calls `rows->insert_cell(fr, pos)` then the same tail as Step 2. Sensitivity: `cells_in_row(fr) < kMaxCellsPerRow && row_count() < kMaxRows`. Convert with `/scale` as the existing code does in `sync_layout_editor`, and re-place them in `sync_layout_editor` every frame. + +- [ ] **Step 4: Column resize handles.** At each internal boundary `k` (1..n-1) of a split row create a drag handle with `kLayoutColKey` = plot index of the right-hand cell (`first + k`), placed just right of the cell `+` there, with a left-right arrow icon (`go-next-symbolic` is acceptable, use whatever the row handle's icon pair provides for the horizontal direction), copying the row handle's `GtkGestureDrag` ("udpscope-layout-drag" pattern with its own begin/update/end handlers). Handlers: + +```cpp +void layout_col_apply(App& app, int plot, double offset_px) +{ + RowSet& rs = *app.rows; + const int fr = rs.figure_row_of(plot); + const int k = rs.cell_of(plot); + if (fr < 0 || k < 1) return; + const double scale = gtk_widget_get_scale_factor(app.window) > 0 ? ui_scale_of(app) : 1.0; // use the helper sync_layout_editor uses + const double pair = app.layout_col_drag_left0 + app.layout_col_drag_right0; + const double want_left = std::clamp(app.layout_col_drag_left0 + offset_px / scale, double(kMinCellPx), pair - kMinCellPx); + std::vector w = rs.cell_weights(fr); + w.resize(rs.cells_in_row(fr), 1.0f); + const float sum = w[k - 1] + w[k]; + w[k - 1] = static_cast(sum * want_left / pair); + w[k] = sum - w[k - 1]; + rs.set_cell_weights(fr, w); + app.area->queue_draw(); // same redraw call the row drag uses +} +``` + +`begin` records `layout_col_drag_plot = plot` and the logical widths `cells[plot-1].w/scale` and `cells[plot].w/scale`, and also snapshots `rs.cell_weights(fr)` (resized with 1.0) so repeated `update` calls are relative to drag start, as the row drag is. If the pair is narrower than `2 * kMinCellPx`, make the clamp degenerate to `pair / 2` rather than inverted bounds. `end` calls `schedule_layout_save(app)`. + +- [ ] **Step 5: Tooltips and sensitivity wording.** Cell `+`: "Split here"; column handle: "Drag to resize the plots"; `×` in a multi-cell row: "Remove this plot"; one-cell row: unchanged ("Remove this row"). + +- [ ] **Step 6: Helpers for the probe.** `layout_cell_button(App&, fr, pos)` and `layout_col_handle(App&, plot)` find the widget by walking the overlay children and matching `kLayoutCellKey` / `kLayoutColKey` data (as `layout_button` does for `row_key`). + +- [ ] **Step 7: Probe steps.** Append to `split_probe_step`: with the layout editor open (reuse how `layout_probe_step` opens it), 8. click `layout_cell_button(app, 0, 1)` via `layout_click` → expect `cells=[2]`; 9. call `layout_col_apply` through the real handle's begin/update/end (or `layout_col_apply(app, 1, 80.0)` after setting the start widths as the begin handler does) → widths shift by 80 logical px and neither cell is below 96; a drag of ±10000 px clamps both to ≥ 96; 10. click the `×` of the new cell → `cells=[1]`; 11. report the sensitivity of the cell `+` at 4 cells in the row (`cell_plus_sensitive=0`) and of the row `+` at 8 plots. + +- [ ] **Step 8: Re-baseline the layout probe.** Run the existing `UDPSCOPE_PROBE_LAYOUT_MS` probe on the previous commit and on this one (fresh `XDG_CONFIG_HOME` per side) and diff number-normalised (`sed 's/[0-9.]\+/N/g'`). The only expected difference is the button count: `+2` per figure row (the two edge `+`s). Update the expected count in `docs/claude/probe-readings.md` in Task 8; do not change anything else in the baseline. + +- [ ] **Step 9: Build, run both probes** (`UDPSCOPE_PROBE_LAYOUT_MS` and `UDPSCOPE_PROBE_SPLIT_MS`, serially). Expected: split-probe lines as designed, `gtk warnings: 0`, no `Gtk-CRITICAL`. + +- [ ] **Step 10: Commit** + +```bash +git add src/ui.cpp src/ui_layout.cpp src/ui_app.hpp src/ui_probe.cpp +git commit -m "layout editor: split a row into cells, resize cells by drag" +``` + +--- + +### Task 6: Signals page, notices and drop target + +**Files:** +- Modify: `src/ui_signal_page.cpp` (~260–285), `src/ui_signals.cpp` (~557, ~570, ~579), `src/ui.cpp` (~325, ~1680, ~2762, ~2766, ~2942) + +**Interfaces:** +- Consumes: `RowSet::plot_label(int)`, x-aware `area->row_at(x, y)` from Task 2. + +- [ ] **Step 1:** In `ui_signal_page.cpp` "Plot on" buttons keep their `udpscope-row` data (plot index) and the `assign_signal_to_row` callback but their label becomes `rows->plot_label(r)` and, for split rows, the tooltip "row 2, cell 1 of 2" (`"row %d, cell %d of %d"` with `figure_row_of + 1`, `cell_of + 1`, `cells_in_row`). This amends the spec's "row 2, left" wording to a unified numeric form; a single-cell row keeps its existing tooltip. +- [ ] **Step 2:** Replace `"row %d"` / `r + 1` notices at `ui_signals.cpp:557`, `:570`, `ui.cpp:1680`, `:2762`, `:2766` with `"row %s"` and `plot_label(i).c_str()`. +- [ ] **Step 3:** Confirm `row_at(app, x, y)` (`ui.cpp:325`) forwards `x` to `area->row_at` (it did not need it before) and that the two callers (`ui_signals.cpp:579`, `ui.cpp:2942`) pass the pointer position unchanged. Persistence layers (`sync_layers`) fill free slots of the same plot, with no change. +- [ ] **Step 4:** Extend the split probe: after step 3 of Task 3, call `assign_signal_to_row` for a signal onto plot 1 of a split row and report the notice text / `traces=` showing the signal on `1b`. Expected probe line shows `traces=[…|1b:name…]`. +- [ ] **Step 5:** Build, run the split probe and `gtk warnings: 0`; commit: + +```bash +git add src/ui_signal_page.cpp src/ui_signals.cpp src/ui.cpp src/ui_probe.cpp +git commit -m "signals: label plots by row and cell, drop onto a cell" +``` + +--- + +### Task 7: Regression runs + +**Files:** none (verification); `docs/claude/probe-readings.md` is updated in Task 8 for any feature-explained difference. + +- [ ] **Step 1:** Clean build from scratch in a temporary build dir; expect exactly one warning. +- [ ] **Step 2:** Serially run the probes listed in `docs/claude/verification.md` that touch layout, cursors, measurements, readouts and the E-1608 source (one mock per run, killed afterwards). For each, run once on the pre-feature commit (`git worktree` or checkout of `HEAD` before Task 1, built separately) and once now with a fresh `XDG_CONFIG_HOME`; diff number-normalised. The only acceptable differences are those the feature explains (layout probe's button count, new `cells`/column-weights lines in layout dumps). +- [ ] **Step 3:** Layout dumps and probes that print per-row layout gain a `cells=[…]` list and a `colweights=[…]` line: add those to `layout_probe_report` and to the layout dump in `ui.cpp`, then re-run to confirm the numbers. +- [ ] **Step 4:** Run the plotter suite once more (`deps/plotter/build`, all `test_*`). +- [ ] **Step 5:** Commit any report-line additions: `git add src/ui_probe.cpp src/ui.cpp && git commit -m "probes: print cells and column weights"`. + +--- + +### Task 8: Docs + +**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`, `docs/claude/verification.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `src/ui_context_help.cpp`, `CLAUDE.md` (only if the "figure of up to eight stacked rows" sentence needs "plots"). + +- [ ] **Step 1: Write each doc paragraph** (real text, in the style of the neighbouring sections): + - `build-and-run.md` (vendored plotter changes list): "`plot::Figure` rows can hold several plots: `Figure(std::span cells_per_row)`, `plot_count()`, `cells_in_row()`, `first_plot_of_row()`, `row_of_plot()`, `set_cell_weights()`, `set_col_gap()`, `FigureLayout::cells`/`col_gap`. `Figure(u32 rows)` still means one cell per row. Cell widths use the height scheme with a 64 px (logical) floor; the GTK4 and GLFW adapters route pointer events by cell rect." + - `invariants-plot-and-layout.md` (cell rules): plots are indexed flat; `row_count()` is the plot count and `figure_row_count()` the rows; the x link, the crosshair and the vertical cursor pair are shared by all cells; y axes, horizontal rulers and fit are per cell; only the bottom row's cells carry x labels; the left gutter is one maximum for the whole figure; caps 4 cells per row / 8 plots; the editor's `+` and drag rules. + - `verification.md` and `probe-readings.md`: `UDPSCOPE_PROBE_SPLIT_MS`, each `[split-probe]` line, the reading it must give (`tile=1 x_equal=1 cursors_aligned=1`, `rt=1`, legacy file → one cell per row), and the new layout-probe button count / the `cells` and `colweights` fields. + - `README.md` and `docs/USER-GUIDE.md`: a "Splitting a row" subsection: Layout editor → `+` at a row's left/right edge or between cells; drag the arrow to resize; `×` removes a cell; all cells share one time window; limits; session `row`/`width` keys. + - Mallard layout page and `ui_context_help.cpp` layout-editor text: the same user-facing paragraph, in the page's format. +- [ ] **Step 2:** Build (the help page is validated at build/install, if the project has a `yelp-check` target run it) and run the context-help probe if `verification.md` lists one. +- [ ] **Step 3: Commit** + +```bash +git add README.md docs/USER-GUIDE.md help docs/claude src/ui_context_help.cpp CLAUDE.md +git commit -m "docs: horizontal plot split" +``` + +--- + +## Self-Review + +- **Spec coverage:** shared time window (Task 1 x link test, Task 3 probe `x_equal`); per-row 1..4 cells and caps (Tasks 3, 5); draggable widths saved (Tasks 4, 5); plotter API and layout rules including narrow degradation, bottom-only labels, common gutter (Task 1); hit-testing by cell (Task 2); host model, flattened indices, caps, cursors/crosshair/y axes per cell (Tasks 1, 3); drop onto a cell and "place on row" listing (Task 6, tooltip wording amended to "row 2, cell 1 of 2"); layout editor controls (Task 5); session format and legacy loading (Task 4); verification probes and regression (Tasks 3–5, 7); docs list (Task 8). +- **Placeholders:** accessor names that the plan could not confirm without re-reading code (`layout_info()`, `rs.plot(p)`, `axis_x`, `cursor_x`, `carry_row` argument order, `Status` constructors) are flagged where used with how to resolve them from the neighbouring code; no behaviour is left unspecified. +- **Type consistency:** `insert_cell(fr, at)`, `remove_cell(plot)`, `set_cell_weights(fr, vector)`, `set_cells_per_row(vector)`, `plot_label(plot)`, `figure_row_count()`, `cells_in_row(fr)`, `first_plot_of_row(fr)`, `figure_row_of(plot)`, `cell_of(plot)` are used with the same signatures in Tasks 3–6; plotter names (`plot_count`, `cells_in_row`, `set_cell_weights(u32, span)`, `set_col_gap`, `FigureLayout::cells/col_gap`) match across Tasks 1–3.