50 KiB
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::kMaxRowskeeps its value and now bounds plots, not figure rows). Figure(u32 rows)stays and means one cell per row; existing behaviour andtests/test_figure.cppare 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).
FigureLayoutgainscells(oneRectper plot, flattened).rowsstays 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 gainrow(the figure row) andwidth(the cell's weight). A session withoutrowloads as one cell per row. Rowweightstays 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 ownPLOT_TESTsuite. - Baselines for every run: exactly one build warning on a clean build;
gtk warnings: 0; noGtk-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 withgit add deps/plotter. - Branch is
feature/source-plugins. Do not push or merge tomain.
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<const u32> cells_per_row);(a count of 0 is clamped to 1).explicit Figure(u32 rows)stays and means one cell per row. Noinitializer_listconstructor (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(returnsplot_count()forrow == row_count());u32 row_of_plot(u32 plot) const.Status set_cell_weights(u32 row, std::span<const f32> 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<Rect>, flattened, one per plot) andFigureLayout::col_gap(f32, 0 when any row dropped the gap).Plot& plot(u32 i)indexes flattened cells;set_row_weightsvalidates 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 <span> and #include <vector> 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.
namespace {
plot::Figure split_figure(const std::vector<plot::u32>& cells)
{
plot::Figure figure{std::span<const plot::u32>(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<plot::f32> 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<plot::f32> 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<plot::f32> three{1.0f, 1.0f, 1.0f};
const std::vector<plot::f32> 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<plot::u32> cells{4};
plot::Figure figure{std::span<const plot::u32>(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:
explicit Figure(u32 rows);
explicit Figure(std::span<const u32> 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<const f32> 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<u32> cells_per_row_; std::vector<u32> row_first_; // prefix sums, rows+1 entries; std::vector<std::vector<f32>> cell_weights_; f32 col_gap_ = 0.0f; void build_cells(std::span<const u32>);. In FigureLayout add std::vector<Rect> cells; f32 col_gap = 0.0f;.
- Step 5: Implement the constructors and accessors
Figure::Figure(u32 rows)
{
const std::vector<u32> ones(rows, 1u);
build_cells(std::span<const u32>(ones));
}
Figure::Figure(std::span<const u32> cells_per_row) { build_cells(cells_per_row); }
void Figure::build_cells(std::span<const u32> cells)
{
cells_per_row_.assign(cells.begin(), cells.end());
for (u32& c : cells_per_row_) c = std::max<u32>(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<Plot>(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<u32>(cells_per_row_.size()); }
u32 Figure::plot_count() const { return static_cast<u32>(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<const f32> 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_cellsand wire it intolayout()
In an anonymous namespace of figure.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<f32>& 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<f32>(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<f32>(n));
const f32 extra = usable - floor_px * static_cast<f32>(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<f32>(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
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/plotterpin
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); GLFWHost::controller_at(x, y)is x-aware.row_controller(i)indexes flattened plots. -
Step 1: Write the failing GLFW routing test
Add #include <span> and, in tests/test_glfw_host.cpp, next to the existing Routed(rows, gap) fixture test:
PLOT_TEST(glfw_host_routes_by_cell)
{
std::vector<plot::u32> cells{1, 2};
plot::Figure figure{std::span<const plot::u32>(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
static int row_in(const std::vector<Rect>& 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<int>(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
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_notecaption ~920) - Modify:
src/ui_app.hpp(dbg_split_step, declarations near ~2093),src/ui_probe.cpp(newsplit_probe_*),src/ui.cpp(scheduling next toUDPSCOPE_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<float>& 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 isfr + 1)bool insert_cell(int fr, int at),bool remove_cell(int plot),void set_cell_weights(int fr, std::vector<float> w),void set_cells_per_row(std::vector<int> 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.hpp35–215, 280–320 andplot_host.cpp135–345, 905–950 to seecarry_row,clear_row,storage_,rebuild_all. -
Step 2: Header additions
static constexpr int kMaxCellsPerRow = 4;
int figure_row_count() const { return static_cast<int>(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<float>& 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<float> w);
void set_cells_per_row(std::vector<int> cells);
private:
std::vector<int> cells_per_row_{1};
std::vector<std::vector<float>> cell_weights_ = std::vector<std::vector<float>>(1);
void apply_cell_weights();
- Step 3: Implement queries and labels in
plot_host.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<float>& RowSet::cell_weights(int fr) const
{
static const std::vector<float> none;
return fr >= 0 && fr < static_cast<int>(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<char>('a' + cell_of(plot));
}
- Step 4: Figure construction in
rebuild_all
Replace the std::make_unique<plot::Figure>(rows_) with
std::vector<plot::u32> cells(cells_per_row_.begin(), cells_per_row_.end());
figure_ = std::make_unique<plot::Figure>(std::span<const plot::u32>(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_).
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<const float>(w));
}
}
apply_row_weights resizes to figure_row_count() instead of rows_.
- Step 5: Mutators
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<float>(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<int>(w.size())) w.erase(w.begin() + k);
rebuild_all();
return true;
}
void RowSet::set_cell_weights(int fr, std::vector<float> 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<int> cells)
{
std::vector<int> 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_notecaption (~line 920): replaceconst bool bottom = i == rows_ - 1;withconst 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):
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]).
insert_cell(0, 1); assign channel 0 to plot 0 and channel 1 to plot 1;set_cursors_visible(true); report"split"(expectcells=[2] tile=1 x_equal=1 cursors_aligned=1).set_cell_weights(0, {3, 1}); report"weights 3:1"(widths ratio about 3:1 after the floors;tile=1).insert_row(1)theninsert_cell(1, 1); report"row+cell"(expectcells=[2,2] plots=4).- Call
insert_celluntil it returns false and theninsert_rowuntil the plot total is 8; report"caps"printing the refusals (insert_cell(0, 2)true,…false at 4 cells;insert_rowfalse at 8 plots). Keep these as separate printedrefused=fields. remove_cellon the last plot of the widest row, report"remove"; then finish by returningG_SOURCE_REMOVE.
In ui.cpp next to UDPSCOPE_PROBE_LAYOUT_MS (~6003) add:
if (const char* ms = g_getenv("UDPSCOPE_PROBE_SPLIT_MS"))
g_timeout_add(static_cast<guint>(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
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
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_layout17–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 = <fr>, width = <w>, 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 plotp:fr = rs.figure_row_of(p),row.row = fr,row.weight = row_weights()[fr],row.width = cell_weights(fr)entrycell_of(p)or 1.0 when the row has no weights. -
Step 3:
apply_layout— beforeset_row_count: if everyRow.row >= 0, validate that rows are non-decreasing, contiguous from 0 (each step 0 or +1), at mostkMaxCellsPerRowcells per row and at mostkMaxRowsplots, otherwise fall back to the one-cell-per-row path. Valid: buildcells_per_rowcounts and callrows->set_cells_per_row(counts); row weights come from the first cell of each figure row (set_row_weights); for rows with ≥ 2 cells callset_cell_weights(fr, {widths…}). Fallback (norow, 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 underg_get_tmp_dir()and delete it afterwards) → set to{1}→load_layout→apply_layout; report"roundtrip"and addrt=1/0comparing cells, widths (tolerance 1e-6) and traces. 7. Write a copy of that file with allrow = …lines stripped, load/apply it, report"legacy"(expectcells=[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,legacywith one cell per row,gtk warnings: 0. -
Step 6: Commit
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, newAppfields),src/ui_probe.cpp(layout_probe_*re-baseline, new split-probe click steps)
Interfaces:
-
Consumes: Task 3
RowSetAPI, Task 2FigureLayout.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); constantskLayoutCellKey = "udpscope-layout-cell",kLayoutColKey = "udpscope-layout-col",kMinCellPx = 96. -
Step 1: Read
ui.cpp568–807 (overlay build,sync_layout_editor,layout_overlay_button),ui_layout.cpp534–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() + 1boundaries, positioned fromfl.rows).on_layout_row_insert(boundary)callsrows->insert_row(boundary). The per-cell×stays keyed by plot index (existingset_row_key), positioned fromfl.cells[idx]top-left, and its handler callsrows->remove_cell(idx); it is insensitive whenremove_cellwould 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 atrow_count() >= RowSet::kMaxRows. -
Step 3: Cell
+buttons. For each figure rowfrwithn = cells_in_row(fr), createn + 1buttons vialayout_overlay_button(app, "list-add-symbolic", tooltip)withg_object_set_data(btn, kLayoutCellKey, GINT_TO_POINTER(fr * 8 + pos + 1))(do not callset_row_key). Positions:pos 0atcells[first].x/scale + kLayoutButtonPad; internalpos kcentred on the gap between cellsk-1andk;pos natcells[last].right()/scale - kLayoutButtonPx - kLayoutButtonPad; vertically centred infl.rows[fr]. Clicking callsrows->insert_cell(fr, pos)then the same tail as Step 2. Sensitivity:cells_in_row(fr) < kMaxCellsPerRow && row_count() < kMaxRows. Convert with/scaleas the existing code does insync_layout_editor, and re-place them insync_layout_editorevery frame. -
Step 4: Column resize handles. At each internal boundary
k(1..n-1) of a split row create a drag handle withkLayoutColKey= plot index of the right-hand cell (first + k), placed just right of the cell+there, with a left-right arrow icon (go-next-symbolicis acceptable, use whatever the row handle's icon pair provides for the horizontal direction), copying the row handle'sGtkGestureDrag("udpscope-layout-drag" pattern with its own begin/update/end handlers). Handlers:
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<float> 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<float>(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)andlayout_col_handle(App&, plot)find the widget by walking the overlay children and matchingkLayoutCellKey/kLayoutColKeydata (aslayout_buttondoes forrow_key). -
Step 7: Probe steps. Append to
split_probe_step: with the layout editor open (reuse howlayout_probe_stepopens it), 8. clicklayout_cell_button(app, 0, 1)vialayout_click→ expectcells=[2]; 9. calllayout_col_applythrough the real handle's begin/update/end (orlayout_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_MSprobe on the previous commit and on this one (freshXDG_CONFIG_HOMEper side) and diff number-normalised (sed 's/[0-9.]\+/N/g'). The only expected difference is the button count:+2per figure row (the two edge+s). Update the expected count indocs/claude/probe-readings.mdin Task 8; do not change anything else in the baseline. -
Step 9: Build, run both probes (
UDPSCOPE_PROBE_LAYOUT_MSandUDPSCOPE_PROBE_SPLIT_MS, serially). Expected: split-probe lines as designed,gtk warnings: 0, noGtk-CRITICAL. -
Step 10: Commit
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-awarearea->row_at(x, y)from Task 2. -
Step 1: In
ui_signal_page.cpp"Plot on" buttons keep theirudpscope-rowdata (plot index) and theassign_signal_to_rowcallback but their label becomesrows->plot_label(r)and, for split rows, the tooltip "row 2, cell 1 of 2" ("row %d, cell %d of %d"withfigure_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 + 1notices atui_signals.cpp:557,:570,ui.cpp:1680,:2762,:2766with"row %s"andplot_label(i).c_str(). -
Step 3: Confirm
row_at(app, x, y)(ui.cpp:325) forwardsxtoarea->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_rowfor a signal onto plot 1 of a split row and report the notice text /traces=showing the signal on1b. Expected probe line showstraces=[…|1b:name…]. -
Step 5: Build, run the split probe and
gtk warnings: 0; commit:
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.mdthat 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 worktreeor checkout ofHEADbefore Task 1, built separately) and once now with a freshXDG_CONFIG_HOME; diff number-normalised. The only acceptable differences are those the feature explains (layout probe's button count, newcells/column-weights lines in layout dumps). - Step 3: Layout dumps and probes that print per-row layout gain a
cells=[…]list and acolweights=[…]line: add those tolayout_probe_reportand to the layout dump inui.cpp, then re-run to confirm the numbers. - Step 4: Run the plotter suite once more (
deps/plotter/build, alltest_*). - 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::Figurerows can hold several plots:Figure(std::span<const u32> 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 andfigure_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.mdandprobe-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 / thecellsandcolweightsfields.README.mdanddocs/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; sessionrow/widthkeys.- Mallard layout page and
ui_context_help.cpplayout-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-checktarget run it) and run the context-help probe ifverification.mdlists one. - Step 3: Commit
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_rowargument order,Statusconstructors) 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<float>),set_cells_per_row(vector<int>),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<const f32>),set_col_gap,FigureLayout::cells/col_gap) match across Tasks 1–3.