Files
udpscope/docs/superpowers/plans/2026-11-03-horizontal-split.md

50 KiB
Raw Permalink Blame History

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<const u32> 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<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) 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 <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_cells and wire it into layout()

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/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 <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_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<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 is fr + 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.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

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_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):

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:

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_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 = <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 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

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:

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) 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

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:

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<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 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
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<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.