Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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_ydefaults to off so existing figures and every existing test indeps/plotter/testsare 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/totalrule (floorkMinCellWidthLogical = 64x 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, noGtk-CRITICAL. Run recipe indocs/claude/verification.md; use a fresh throwawayXDG_CONFIG_HOMEper run (never write~/.config/udpscope); run probes short and serial; kill the mock afterwards. The plotter teststaging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_buildmay hang: run the suite underulimit -c 0; timeout 300.
File map
| File | Change |
|---|---|
deps/plotter/include/plot/plot/plot.hpp, src/plot/plot.cpp |
YStrip, set_y_strip_mode, set_y_link_member, y_data_bounds, fit_y_bounds; skip autoscale of a linked y axis; zero y chrome for YStrip::None |
deps/plotter/include/plot/plot/figure.hpp, src/plot/figure.cpp |
set_share_row_y, row_y_auto, sync_row_y(), strip modes, gutter maximum over leftmost cells, plot-width cell placement |
deps/plotter/src/render/renderer.cpp (+ adapters if needed) |
no y labels for a None cell |
deps/plotter/tests/test_figure.cpp |
new cases |
src/plot_host.cpp, src/ui.cpp, src/ui_cursors.cpp, src/ui_probe.cpp |
host wiring, rulers, pane, drag, probes |
| docs | see Task 5 |
Task 1: Plotter -- y link of a row's cells (range and auto)
Files:
- Modify:
deps/plotter/include/plot/plot/plot.hpp,deps/plotter/src/plot/plot.cpp - Modify:
deps/plotter/include/plot/plot/figure.hpp,deps/plotter/src/plot/figure.cpp - Test:
deps/plotter/tests/test_figure.cpp
Interfaces:
- Produces (Plot):
void set_y_link_member(u32 axis, bool linked) noexcept;bool y_data_bounds(u32 axis, Range& out) const;(union of the visible traces' y bounds on that axis, false if none)bool fit_y_bounds(u32 axis, Range bounds);(the axis' own padded fit ofbounds, asfit_y_axisdoes; leavesauto_scalealone). - 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 > 1cells, 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 isfit_axis_range's, viafit_y_boundson 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 existingsplit_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_yundeclared). Build and run the plotter tests perdocs/claude/build-and-run.md(the plotter's own CMake test target; run the binary withulimit -c 0; timeout 300). - Step 3: Implement the Plot additions,
Figuremembers (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_yas above (declare it private next tosync_x_links), and callsync_row_y();inFigure::layout()immediately aftersync_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 toXStripinplot.hpp;Plot::set_y_strip_mode(YStrip)/y_strip_mode(). WithNone,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 ignoresmin_y_gutter_; the grid lines are still built. With sharing on,Figure::layout()setsNoneon every cell but the first of its row; the common-gutter maximum is taken overBothcells only;Nonecells getset_min_y_gutter(0). -
Cell widths with sharing on, for a row with
n > 1cells: after the gutterGis known (end of the measurement pass),G_eff = min(G, row.w / 2); callplace_cellson the rect{row.x + G_eff, row.y, row.w - G_eff, row.h}, then grow cell 0 to the left byG_eff(x -= G_eff; w += G_eff). Updatelayout_.cells, callset_viewporton each cell, then run the finalp.layout()pass. With sharing off nothing changes. -
Step 1: Write the failing tests (append to
test_figure.cpp;give_magnitude_rowandsplit_figureexist):
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;
YStripundeclared). - Step 3: Implement.
Plot::layout:const bool y_none = y_strip_ == YStrip::None;seedy_width = y_none ? 0 : ...,y_title_w = y_none ? 0 : ...,y_floor = y_none ? 0 : min(min_y_gutter_, y_budget), andneed_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 theaxesmust start atviewport_.x.Figure::layout: before the viewport loop computeconst bool shared = share_row_y_ && cells_per_row_[row] > 1u;and setp.set_y_strip_mode(shared && i != row_first_[row] ? YStrip::None : YStrip::Both)(reset toBothwhen not shared). In the gutter maximum loop skipNoneplots and give themset_min_y_gutter(0.0f). After the measurement pass and before the finalp.layout()loop, re-place the cells of each shared row as specified in Interfaces and re-applyset_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 atrenderer.cpp:~906-917first. Checksrc/input/controller.cpp:~524still cannot start a y axis-drag in a zero-width label area (it hit-testsy_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_gutterstill 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_cursorscall sites),src/plot_host.hppif 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.hppif 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_allneed 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.cppnear the[split-probe] ...lines; note the next free step index) that, on a figure with a split row holding a trace in each cell:y_shared: set the y range on cell A viars.set_y_range(a, -3, 7), pump a frame, assert cell B'splot->y_range(axis_y)equals it.y_gutter: cell B (non-first)layout().y_axis_width == 0andy_label_area.w == 0; cell A> 0; andaxes.wof A and B equal within 1 px with default weights.y_fit:rs.fit_all()then one frame: both cells' ranges equal and cover both traces' y bounds.ruler_mirror: with cursor mode showing rulers, set cell A's ruler (viaset_cursor_yon its plot) then callrs.sync_cursors(a);ruler_y(a)==ruler_y(b)andruler_b_ylikewise. 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; expecty_shared=0 y_gutter=0(sharing not wired) -- RED.
-
Step 2: Wire it.
RowSet::rebuild_all(): callfigure_->set_share_row_y(true);right after theFigureis created (beforeinstall, so the axes added byinstallare picked up -- Task 1 re-applies membership insync_row_y).RowSet::sync_cursors(int drag_row): afterr.ruler_y/ruler_b_yare 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) andplace_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: wheninsert_cellcreates the new cell, copy the row's first cell'sruler_y/ruler_b_yinto it (readinsert_cellandcopy/reset helpers nearplot_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 numberingplot_labeluses for the row (without the cell letter). Checkui_cursors.cpp:~60and every other reader ofcursors_lines/slot == -1for assumptions of one ruler line per plot, and update the probe lineruler-labelsaccordingly (its expected value changes by design -- say so in the report). row_unit(app, row)(ui.cpp:~1759): join the units over every cell ofrow's figure row (loopfirst_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:~4237gutter reading takes a max over all plots and~4361reads 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 perdocs/claude/verification.mdand diff against the baseline commit18b91c8-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 liney_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, noGtk-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(thecol-dragstep ~4190-4250),src/ui_app.hpp(layout_col_drag_left0/right0docs) - Test: split probe
col-dragline
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_beginstores the left/right plot widths in logical px (rect width minusy_axis_width + y_title_widthof that cell's plot; the right cell's is 0).layout_col_applyis unchanged otherwise: the 96 px floor and the 64 px plotter floor both refer to plot widths now (kMinCellPx,kPlotterMinCellPx), the formulafrac = (want_left - 64)/(pair - 128)stays, and the pair's weight sum stays. -
Step 1: Probe first (RED). Extend the
col-dragstep so the cells compared are plot widths (useaxes.wof 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; expectok=0before the fix. Keep the other split-probe lines as they are. -
Step 2: Fix
layout_col_drag_beginto subtract the gutter from the left cell:gutter = plot.layout().y_axis_width + plot.layout().y_title_widthof the left plot (0 for a non-first left cell, which hasNone), converted by the samescale. Update the comment abovelayout_col_applyto say "plot width". -
Step 3: Verify. Build; run the split probe; expect
col-drag ok=1and 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.mdto 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, aNonecell contributes 0, never content-dependent).probe-readings.md/verification.md: document the[split-probe] shared-yline and the changedruler-labels/col-dragreadings 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".