Files
udpscope/docs/superpowers/plans/2026-11-03-split-shared-y.md
T
2026-11-03 23:57:27 +01:00

27 KiB

Split rows share one y axis -- Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: The cells of a split row share one y range and one y axis (labels on the leftmost cell only), as the figure's rows share one x window and one x axis.

Architecture: The vendored plotter's Figure gets set_share_row_y(bool): when on, every figure row with more than one cell keeps its cells' y axis 0 in step (range and auto flag) from Figure::layout(), and every cell but the first loses its y gutter (YStrip::None). The host turns it on in RowSet::rebuild_all(), mirrors the ruler pair across a row, and maps the layout editor's divider drag through plot widths.

Tech Stack: C++20, GTK4/libadwaita, the vendored deps/plotter (git submodule; commit inside it first, then the pin in the main repo), headless probes under xvfb-run.

Spec: docs/superpowers/specs/2026-11-03-split-shared-y-design.md (amends 2026-11-03-horizontal-split-design.md).

Global Constraints

  • Shared y is always on for split rows, no option; an unsplit row behaves exactly as before. (spec Decisions 1)
  • Only the leftmost cell of a row has a y gutter and y labels; the choice is structural (column index), never from content. (Decisions 2; "layout must not depend on content", docs/claude/invariants-plot-and-layout.md)
  • Figure::set_share_row_y defaults to off so existing figures and every existing test in deps/plotter/tests are unchanged.
  • Cell rects still tile the row exactly (last cell closes the row); no rect is ever negative; Figure(u32 rows) and {1} rows lay out as today.
  • The row's y gutter is taken off the row width first; the cells then share the rest by the existing floor + extra*w/total rule (floor kMinCellWidthLogical = 64 x ui_scale), so equal weights give equal plot areas. The first cell's rect is gutter + its plot width.
  • Rulers: one pair per figure row, drawn at the same y in every cell; a drag in any cell moves them. Measure level marks, readouts, measurements stay per trace / per plot index.
  • Session file unchanged (y ranges were never persisted).
  • Every source file carries the SPDX MIT header (existing files already do).
  • Verification is by the plotter suite and the app's headless probes only. Build: exactly 1 warning (baseline). Probes: gtk warnings: 0, no Gtk-CRITICAL. Run recipe in docs/claude/verification.md; use a fresh throwaway XDG_CONFIG_HOME per run (never write ~/.config/udpscope); run probes short and serial; kill the mock afterwards. The plotter test staging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_build may hang: run the suite under ulimit -c 0; timeout 300.

File map

File Change
deps/plotter/include/plot/plot/plot.hpp, src/plot/plot.cpp YStrip, set_y_strip_mode, set_y_link_member, y_data_bounds, fit_y_bounds; skip autoscale of a linked y axis; zero y chrome for YStrip::None
deps/plotter/include/plot/plot/figure.hpp, src/plot/figure.cpp set_share_row_y, row_y_auto, sync_row_y(), strip modes, gutter maximum over leftmost cells, plot-width cell placement
deps/plotter/src/render/renderer.cpp (+ adapters if needed) no y labels for a None cell
deps/plotter/tests/test_figure.cpp new cases
src/plot_host.cpp, src/ui.cpp, src/ui_cursors.cpp, src/ui_probe.cpp host wiring, rulers, pane, drag, probes
docs see Task 5

Files:

  • Modify: deps/plotter/include/plot/plot/plot.hpp, deps/plotter/src/plot/plot.cpp
  • Modify: deps/plotter/include/plot/plot/figure.hpp, deps/plotter/src/plot/figure.cpp
  • Test: deps/plotter/tests/test_figure.cpp

Interfaces:

  • Produces (Plot): void set_y_link_member(u32 axis, bool linked) noexcept; bool y_data_bounds(u32 axis, Range& out) const; (union of the visible traces' y bounds on that axis, false if none) bool fit_y_bounds(u32 axis, Range bounds); (the axis' own padded fit of bounds, as fit_y_axis does; leaves auto_scale alone).
  • Produces (Figure): void set_share_row_y(bool on); bool share_row_y() const noexcept; bool row_y_auto(u32 row) const noexcept; (figure row; true for a row with fewer than two cells or when sharing is off, i.e. "no group").
  • Behaviour: with sharing on, for each figure row with n > 1 cells, y axis 0 of its cells is one group. A range set on any member (wheel, drag, set_y_range) becomes the group's and is pushed to all; the group is auto or manual as one; auto fits the union of all members' visible traces (cells without a trace ignored; padding/log handling is fit_axis_range's, via fit_y_bounds on the first member). Members' own autoscale is suppressed.

Group state per row (Figure::RowY): Range pushed; bool have = false; bool auto_scale = true;. Sync, run from Figure::layout() right after sync_x_links() (before any Plot::layout() call, because that is where a plot would otherwise autoscale):

void Figure::sync_row_y() {
  if (!share_row_y_) return;
  row_y_.resize(cells_per_row_.size());
  for (u32 row = 0; row < cells_per_row_.size(); ++row) {
    const u32 n = cells_per_row_[row];
    if (n < 2u) continue;
    RowY& g = row_y_[row];
    const u32 first = row_first_[row];
    if (!g.have) {
      g.pushed = plots_[first].y_axis(0).range;
      g.auto_scale = plots_[first].y_axis(0).auto_scale;
    }
    // What happened since the last push. A member's auto flag that differs from the group's is a
    // request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it); otherwise a
    // member whose range differs from the pushed one was moved by a gesture.
    bool want_auto = g.auto_scale;
    Range target = g.pushed;
    bool flag_request = false;
    for (u32 i = first; i < first + n && !flag_request; ++i) {
      const Axis& a = plots_[i].y_axis(0);
      if (a.auto_scale != g.auto_scale) {
        want_auto = a.auto_scale;
        if (!want_auto) target = a.range;  // auto -> manual: the gesture's range
        flag_request = true;
      }
    }
    if (!flag_request && !want_auto) {
      for (u32 i = first; i < first + n; ++i) {
        const Range r = plots_[i].y_axis(0).range;
        if (r.min != g.pushed.min || r.max != g.pushed.max) { target = r; break; }
      }
    }
    if (want_auto) {
      Range bounds{};
      bool any = false;
      for (u32 i = first; i < first + n; ++i) {
        Range b{};
        if (!plots_[i].y_data_bounds(0, b)) continue;
        bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
        any = true;
      }
      if (any && plots_[first].fit_y_bounds(0, bounds)) target = plots_[first].y_axis(0).range;
      // else: nothing to fit -- keep the range the group had
    }
    for (u32 i = first; i < first + n; ++i) {
      plots_[i].set_y_range(0, target);            // clears the flag ...
      plots_[i].set_y_auto_scale(0, want_auto);    // ... so restate it: every member mirrors the group
    }
    g.pushed = target;
    g.auto_scale = want_auto;
    g.have = true;
  }
}

set_share_row_y(on) stores the flag, clears row_y_, and calls set_y_link_member(0, on) on every cell of every row with n > 1 (cells whose plot has no y axis yet are fine: set_y_link_member ignores a missing axis, so also re-apply in sync_row_y() before the loop so axes added after the call are covered). Plot::autoscale_axes() must skip a y axis whose link_member is set (reuse Axis::link_member, currently x-only in meaning; update its comment). y_data_bounds is trace_bounds_union(false, axis, out). fit_y_bounds is return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds);.

  • Step 1: Write the failing tests -- append to deps/plotter/tests/test_figure.cpp (they use the existing split_figure, whole_figure, plot::data::ArraySeries):
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
  plot::Figure figure = split_figure({2, 1});
  figure.set_share_row_y(true);
  figure.layout();
  figure.plot(1u).set_y_range(0u, {-3.0, 7.0});  // what a wheel / drag / pan ends in
  figure.layout();
  PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
  PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
  PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
  PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
  // The unsplit row is its own thing.
  PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
  PLOT_CHECK(!figure.row_y_auto(0u));
}

PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
  plot::Figure figure = split_figure({3});
  for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
  plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
  plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
  figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
  figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
  figure.set_share_row_y(true);  // cell 2 has no trace
  figure.layout();
  PLOT_CHECK(figure.row_y_auto(0u));
  const plot::Range r0 = figure.plot(0u).y_range(0u);
  PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
  PLOT_CHECK(r0.max - r0.min < 40.0);  // padded, not blown up
  for (plot::u32 i = 1; i < 3u; ++i) {
    PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
    PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
  }
}

PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
  plot::Figure figure = split_figure({2});
  for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
  plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
  plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
  figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
  figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
  figure.set_share_row_y(true);
  figure.layout();
  figure.plot(1u).set_y_range(0u, {1.0, 2.0});
  figure.layout();
  PLOT_CHECK(!figure.row_y_auto(0u));
  PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
  PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
  figure.layout();  // manual holds: no fight with the plots' own autoscale
  PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
  figure.plot(0u).auto_fit_y(0u);  // what fit-all / double-click does to one cell
  figure.layout();
  PLOT_CHECK(figure.row_y_auto(0u));
  PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
  PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
}

PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
  plot::Figure figure = split_figure({2});
  figure.layout();
  figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
  figure.layout();
  PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
  PLOT_CHECK(!figure.share_row_y());
}
  • Step 2: Run -- expect compile failure (set_share_row_y undeclared). Build and run the plotter tests per docs/claude/build-and-run.md (the plotter's own CMake test target; run the binary with ulimit -c 0; timeout 300).
  • Step 3: Implement the Plot additions, Figure members (bool share_row_y_ = false; struct RowY {...}; std::vector<RowY> row_y_;), set_share_row_y, share_row_y, row_y_auto (row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true), sync_row_y as above (declare it private next to sync_x_links), and call sync_row_y(); in Figure::layout() immediately after sync_x_links();. Doc comments in the file's style (what, and why the group, not the plot, owns auto scale).
  • Step 4: Run the whole plotter suite. Expect all new cases pass and 0 failed overall (was 407 cases).
  • Step 5: Commit inside the submodule: git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: cells of a row can share one y range". Do not touch the pin yet.

Task 2: Plotter -- one y axis per row (strip mode, gutter, cell widths)

Files:

  • Modify: deps/plotter/include/plot/plot/plot.hpp, deps/plotter/src/plot/plot.cpp (Plot::layout, ~lines 1286-1390)
  • Modify: deps/plotter/src/plot/figure.cpp (Figure::layout, ~lines 550-625)
  • Modify: deps/plotter/src/render/renderer.cpp (~lines 900-920, y label staging)
  • Test: deps/plotter/tests/test_figure.cpp

Interfaces:

  • Consumes: Task 1's set_share_row_y.

  • Produces: enum class YStrip { Both, None }; next to XStrip in plot.hpp; Plot::set_y_strip_mode(YStrip) / y_strip_mode(). With None, Plot::layout() reserves no y label width and no y title width (y_axis_width == 0, y_title_width == 0, y_label_area.w == 0) and ignores min_y_gutter_; the grid lines are still built. With sharing on, Figure::layout() sets None on every cell but the first of its row; the common-gutter maximum is taken over Both cells only; None cells get set_min_y_gutter(0).

  • Cell widths with sharing on, for a row with n > 1 cells: after the gutter G is known (end of the measurement pass), G_eff = min(G, row.w / 2); call place_cells on the rect {row.x + G_eff, row.y, row.w - G_eff, row.h}, then grow cell 0 to the left by G_eff (x -= G_eff; w += G_eff). Update layout_.cells, call set_viewport on each cell, then run the final p.layout() pass. With sharing off nothing changes.

  • Step 1: Write the failing tests (append to test_figure.cpp; give_magnitude_row and split_figure exist):

PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
  plot::Figure figure = split_figure({1, 3}, false);
  for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
  figure.set_share_row_y(true);
  figure.layout();
  PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f);   // unsplit row keeps its axis
  PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);   // first cell of the split row
  for (plot::u32 i = 2; i < 4u; ++i) {
    const plot::PlotLayout& l = figure.plot(i).layout();
    PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
    PLOT_CHECK(l.axes.w > 0.0f);
  }
  // The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
  PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
                  figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
}

PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
  plot::Figure figure = split_figure({3}, false);
  for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
  figure.set_col_gap(8.0f);
  figure.set_share_row_y(true);
  const plot::FigureLayout& fl = figure.layout();
  const double w0 = figure.plot(0u).layout().axes.w;
  PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
  PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
  PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
  PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
  PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
  PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
}

PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
  plot::Figure figure = split_figure({2}, false);
  for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
  figure.set_col_gap(0.0f);
  figure.set_share_row_y(true);
  const std::vector<plot::f32> w{3.0f, 1.0f};
  PLOT_CHECK(figure.set_cell_weights(0u, w));
  figure.layout();
  const double a0 = figure.plot(0u).layout().axes.w;
  const double a1 = figure.plot(1u).layout().axes.w;
  PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
}

PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
  plot::Figure figure = split_figure({4}, false);
  figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
  for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
  figure.set_col_gap(10.0f);
  figure.set_share_row_y(true);
  double sum = 0.0;
  for (const plot::Rect& c : figure.layout().cells) {
    PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
    sum += c.w;
  }
  PLOT_CHECK_NEAR(sum, 30.0, 0.05);
}

PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
  plot::Figure figure = split_figure({2}, false);
  give_magnitude_row(figure.plot(0u), 0u);
  give_magnitude_row(figure.plot(1u), 1u);
  figure.layout();
  PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
}

Plus a renderer check: find how existing tests count or inspect staged text (see tests/test_render_smoke.cpp / the staging_a_glyph_... neighbourhood). Add one case that a None cell stages no y tick labels (the staged label count of a two-cell shared row equals that of a one-cell row of the same height, plus nothing for the second cell's y side). If the harness cannot observe staged text, instead assert in the renderer change below with a comment and report that no automated check was possible.

  • Step 2: Run -- expect failures (the gutter/width cases; YStrip undeclared).
  • Step 3: Implement.
    • Plot::layout: const bool y_none = y_strip_ == YStrip::None; seed y_width = y_none ? 0 : ..., y_title_w = y_none ? 0 : ..., y_floor = y_none ? 0 : min(min_y_gutter_, y_budget), and need_y = y_none ? 0 : ... in the fixed-point test so the loop still settles in two passes. Read the loop first; every use of the y strip widths must collapse to 0 and the axes must start at viewport_.x.
    • Figure::layout: before the viewport loop compute const bool shared = share_row_y_ && cells_per_row_[row] > 1u; and set p.set_y_strip_mode(shared && i != row_first_[row] ? YStrip::None : YStrip::Both) (reset to Both when not shared). In the gutter maximum loop skip None plots and give them set_min_y_gutter(0.0f). After the measurement pass and before the final p.layout() loop, re-place the cells of each shared row as specified in Interfaces and re-apply set_viewport.
    • Renderer: do not stage y tick labels (and y title) when the plot's layout().y_label_area.w <= 0.0f; read the staging code at renderer.cpp:~906-917 first. Check src/input/controller.cpp:~524 still cannot start a y axis-drag in a zero-width label area (it hit-tests y_label_area); if it can, guard it.
    • Check both adapters (adapters/gtk4, adapters/glfw) route by cell rect only, so nothing else changes.
  • Step 4: Run the whole plotter suite. Expect 0 failed, including all pre-existing figure cases (figure_cells_common_y_gutter still reads the old behaviour because sharing defaults off).
  • Step 5: Commit in the submodule: git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: shared-y rows draw one y axis, on the first cell". Then in the main repo: git add deps/plotter && git commit -m "pin deps/plotter: shared y axis for split rows".

Task 3: Host -- turn sharing on; rulers per row; pane and units

Files:

  • Modify: src/plot_host.cpp (rebuild_all, sync_cursors, place_cursors call sites), src/plot_host.hpp if a helper is declared
  • Modify: src/ui.cpp (row_unit ~1759, cursors-pane line construction ~1840-1855), src/ui_cursors.cpp (~55-70)
  • Modify: src/ui_probe.cpp (split probe: new steps), src/ui_app.hpp if a probe state is needed
  • Test: the split probe (UDPSCOPE_PROBE_SPLIT_MS), layout probe

Interfaces:

  • Consumes: Task 2's Figure::set_share_row_y(bool) and its pin.

  • Produces: no new public host API required. Behaviour: after rebuild_all() every split figure row shares y; RowSet::set_y_auto/set_y_range/fit_all need no change (they call per-plot setters that the figure's sync turns into row-wide effects).

  • Step 1: Probe first (RED). Add a new split-probe step after the existing steps (follow the existing step style in ui_probe.cpp near the [split-probe] ... lines; note the next free step index) that, on a figure with a split row holding a trace in each cell:

    1. y_shared: set the y range on cell A via rs.set_y_range(a, -3, 7), pump a frame, assert cell B's plot->y_range(axis_y) equals it.
    2. y_gutter: cell B (non-first) layout().y_axis_width == 0 and y_label_area.w == 0; cell A > 0; and axes.w of A and B equal within 1 px with default weights.
    3. y_fit: rs.fit_all() then one frame: both cells' ranges equal and cover both traces' y bounds.
    4. ruler_mirror: with cursor mode showing rulers, set cell A's ruler (via set_cursor_y on its plot) then call rs.sync_cursors(a); ruler_y(a)==ruler_y(b) and ruler_b_y likewise. Print one line: [split-probe] shared-y y_shared=%d y_gutter=%d y_fit=%d ruler_mirror=%d ranges=[...] axes_w=[..]. Build and run it; expect y_shared=0 y_gutter=0 (sharing not wired) -- RED.
  • Step 2: Wire it.

    • RowSet::rebuild_all(): call figure_->set_share_row_y(true); right after the Figure is created (before install, so the axes added by install are picked up -- Task 1 re-applies membership in sync_row_y).
    • RowSet::sync_cursors(int drag_row): after r.ruler_y/ruler_b_y are read back from the dragged cell, copy both values to every other cell of the same figure row (first_plot_of_row(figure_row_of(drag_row)) .. + cells_in_row) and place_cursors(that, plot::CursorPlacement::Free) on them.
    • reset_cursors() already writes each plot's rulers from its y range; confirm both cells of a row get identical values (the range is shared). Rulers that exist before the row was split: when insert_cell creates the new cell, copy the row's first cell's ruler_y/ruler_b_y into it (read insert_cell and copy/reset helpers near plot_host.cpp:224-240).
    • Cursors pane (ui.cpp:~1840): the ruler line (slot = -1) is created once per figure row, for the row's first plot only (app.rows->cell_of(r) == 0). Label: unsplit rows keep today's wording; a split row uses "row N" with the same numbering plot_label uses for the row (without the cell letter). Check ui_cursors.cpp:~60 and every other reader of cursors_lines / slot == -1 for assumptions of one ruler line per plot, and update the probe line ruler-labels accordingly (its expected value changes by design -- say so in the report).
    • row_unit(app, row) (ui.cpp:~1759): join the units over every cell of row's figure row (loop first_plot_of_row .. + cells_in_row, same lock), "" if they differ.
    • sync_compare_axes (ui_events.cpp:~555): leave as is; the probe in Step 3 includes a comparison-mode check only if the existing compare probe covers split rows, otherwise note it in the report as unverified.
    • ui.cpp:~4237 gutter reading takes a max over all plots and ~4361 reads plot 0: both stay correct (None cells read 0). Confirm by reading, change nothing.
  • Step 3: Verify. Incremental cmake --build build (1 warning baseline). Run the split probe and the layout probe per docs/claude/verification.md and diff against the baseline commit 18b91c8-era numbers as in the earlier tasks (number-normalised): only lines this feature explains may differ (ruler-labels, buttons= unchanged, cells= unchanged; widths of non-first cells grow by the gutter). Expect the new line y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1, all earlier split-probe steps still reading as before (col-drag may change -- that is Task 4), gtk warnings: 0, no Gtk-CRITICAL.

  • Step 4: Commit git add src/plot_host.cpp src/plot_host.hpp src/ui.cpp src/ui_cursors.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "plot host: split rows share y, rulers per row" (add only files actually changed).


Task 4: Layout editor -- divider drag through plot widths

Files:

  • Modify: src/ui.cpp (layout_col_drag_begin ~786, layout_col_apply ~755)
  • Modify: src/ui_probe.cpp (the col-drag step ~4190-4250), src/ui_app.hpp (layout_col_drag_left0/right0 docs)
  • Test: split probe col-drag line

Interfaces:

  • Consumes: Tasks 2-3. A cell's rect width is no longer its weight's width: weights apply to plot widths (plot->layout().axes.w + right_gutter, i.e. rect width minus the y gutter, which only cell 0 has).

  • Produces: layout_col_drag_begin stores the left/right plot widths in logical px (rect width minus y_axis_width + y_title_width of that cell's plot; the right cell's is 0). layout_col_apply is unchanged otherwise: the 96 px floor and the 64 px plotter floor both refer to plot widths now (kMinCellPx, kPlotterMinCellPx), the formula frac = (want_left - 64)/(pair - 128) stays, and the pair's weight sum stays.

  • Step 1: Probe first (RED). Extend the col-drag step so the cells compared are plot widths (use axes.w of the two plots, in logical px) on a row whose first cell is the left one, in addition to the existing checks (+80 px tracking within 1 px, both extremes 96 +/- 1, pair sum preserved). With the old begin code the tracking is off by the gutter; expect ok=0 before the fix. Keep the other split-probe lines as they are.

  • Step 2: Fix layout_col_drag_begin to subtract the gutter from the left cell: gutter = plot.layout().y_axis_width + plot.layout().y_title_width of the left plot (0 for a non-first left cell, which has None), converted by the same scale. Update the comment above layout_col_apply to say "plot width".

  • Step 3: Verify. Build; run the split probe; expect col-drag ok=1 and the previous steps unchanged; layout probe unchanged; gtk warnings: 0.

  • Step 4: Commit git add src/ui.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "layout editor: column drag measures plot widths, not cell rects".


Task 5: Docs

Files: README.md, docs/USER-GUIDE.md, help/C/org.udpscope.app/layout.page, docs/claude/probe-readings.md, docs/claude/verification.md (new probe line), docs/claude/invariants-plot-and-layout.md, docs/claude/build-and-run.md, docs/claude/architecture.md if it describes per-cell y, CLAUDE.md if it mentions per-cell y, src/ui_context_help.cpp, src/ui_tour.cpp if they say cells have their own y axis.

  • Step 1: grep -rn -i "per cell\|own y\|each cell\|y axis" README.md docs help src/ui_context_help.cpp src/ui_tour.cpp CLAUDE.md to find every statement that the cells of a split row have their own y axis or one common gutter "not per column"; rewrite each to the new rule: one y range and one y axis per figure row, labels on the left cell, y zoom/pan/fit/Auto-Fixed act on the whole row, one ruler pair per row, cells share the y gutter's width only through the first cell.
  • Step 2: build-and-run.md: add the plotter change (set_share_row_y, y link semantics, YStrip, plot-width cell placement) beside the other vendored changes. invariants-plot-and-layout.md: replace the "one gutter, not a per-column one" wording with the structural rule (first cell only, a None cell contributes 0, never content-dependent). probe-readings.md / verification.md: document the [split-probe] shared-y line and the changed ruler-labels / col-drag readings with the defect each wrong value names.
  • Step 3: Build (help/manual and context help compile), run the split probe once more for gtk warnings: 0.
  • Step 4: Commit git add <the files changed> && git commit -m "docs: split rows share one y axis".