837 lines
50 KiB
Markdown
837 lines
50 KiB
Markdown
# 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.
|
||
|
||
```cpp
|
||
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`:
|
||
|
||
```cpp
|
||
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**
|
||
|
||
```cpp
|
||
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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: rows can hold several plots (cells), cell weights and column gap"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Adapters route pointers by cell
|
||
|
||
**Files:**
|
||
- Modify: `deps/plotter/adapters/gtk4/gtk_plot_area.cpp/.hpp`, `deps/plotter/adapters/glfw/glfw_host.cpp/.hpp`
|
||
- Test: `deps/plotter/tests/test_glfw_host.cpp`
|
||
- Modify (main repo): `deps/plotter` pin
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Figure::plot_count()`, `FigureLayout::cells`.
|
||
- Produces: `GtkPlotArea::row_at(x, y)` returns the flattened plot index under (x, y) (nearest cell when outside all); GLFW `Host::controller_at(x, y)` is x-aware. `row_controller(i)` indexes flattened plots.
|
||
|
||
- [ ] **Step 1: Write the failing GLFW routing test**
|
||
|
||
Add `#include <span>` and, in `tests/test_glfw_host.cpp`, next to the existing `Routed(rows, gap)` fixture test:
|
||
|
||
```cpp
|
||
PLOT_TEST(glfw_host_routes_by_cell)
|
||
{
|
||
std::vector<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
|
||
|
||
```cpp
|
||
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**
|
||
|
||
```bash
|
||
git -C deps/plotter add -u && git -C deps/plotter commit -m "adapters: route pointer events by cell rect"
|
||
git add deps/plotter && git commit -m "deps/plotter: figure rows can hold several plots"
|
||
cmake --build build 2>&1 | grep -c warning
|
||
```
|
||
Expected: the build succeeds with the single baseline warning (count as before the change; compare with `git stash`-free run of an untouched clean build if unsure).
|
||
|
||
---
|
||
|
||
### Task 3: `RowSet` cell state and the split probe scaffold
|
||
|
||
**Files:**
|
||
- Modify: `src/plot_host.hpp`, `src/plot_host.cpp` (`rebuild_all` ~309, `set_row_count` ~156, `carry_row` ~164, `clear_row` ~184, `insert_row` ~195, `remove_row` ~205, `apply_row_weights` ~303, `x_note` caption ~920)
|
||
- Modify: `src/ui_app.hpp` (`dbg_split_step`, declarations near ~2093), `src/ui_probe.cpp` (new `split_probe_*`), `src/ui.cpp` (scheduling next to `UDPSCOPE_PROBE_LAYOUT_MS`, ~6003)
|
||
|
||
**Interfaces:**
|
||
- Produces in `RowSet`:
|
||
- `static constexpr int kMaxCellsPerRow = 4;`
|
||
- `int figure_row_count() const; int cells_in_row(int fr) const; int first_plot_of_row(int fr) const; int figure_row_of(int plot) const /* -1 if invalid */; int cell_of(int plot) const; const std::vector<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**
|
||
|
||
```cpp
|
||
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`
|
||
|
||
```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
|
||
|
||
```cpp
|
||
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_`).
|
||
|
||
```cpp
|
||
void RowSet::apply_cell_weights()
|
||
{
|
||
if (!figure_) return;
|
||
cell_weights_.resize(cells_per_row_.size());
|
||
for (int fr = 0; fr < figure_row_count(); ++fr) {
|
||
auto& w = cell_weights_[fr];
|
||
if (cells_per_row_[fr] < 2) { w.clear(); continue; }
|
||
if (w.empty()) { figure_->clear_cell_weights(fr); continue; }
|
||
w.resize(cells_per_row_[fr], 1.0f);
|
||
figure_->set_cell_weights(fr, std::span<const float>(w));
|
||
}
|
||
}
|
||
```
|
||
|
||
`apply_row_weights` resizes to `figure_row_count()` instead of `rows_`.
|
||
|
||
- [ ] **Step 5: Mutators**
|
||
|
||
```cpp
|
||
bool RowSet::insert_cell(int fr, int at)
|
||
{
|
||
if (fr < 0 || fr >= figure_row_count()) return false;
|
||
if (cells_per_row_[fr] >= kMaxCellsPerRow || rows_ >= kMaxRows) return false;
|
||
const int n = cells_per_row_[fr];
|
||
const int p = first_plot_of_row(fr) + std::clamp(at, 0, n);
|
||
for (int i = rows_; i > p; --i) carry_row(i, i - 1); // carry_row(dst, src), as insert_row uses it
|
||
clear_row(p);
|
||
++rows_;
|
||
++cells_per_row_[fr];
|
||
auto& w = cell_weights_[fr];
|
||
if (!w.empty()) {
|
||
float mean = 0.0f;
|
||
for (float v : w) mean += v;
|
||
mean /= static_cast<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):
|
||
|
||
```cpp
|
||
void split_probe_report(App& app, const char* tag)
|
||
{
|
||
RowSet& rs = *app.rows;
|
||
const auto& fl = rs.figure().layout();
|
||
int tile = 1;
|
||
for (int fr = 0; fr < rs.figure_row_count(); ++fr) {
|
||
const int first = rs.first_plot_of_row(fr), n = rs.cells_in_row(fr);
|
||
const auto& row = fl.rows[fr];
|
||
if (std::fabs(fl.cells[first].x - row.x) > 0.5f) tile = 0;
|
||
if (std::fabs(fl.cells[first + n - 1].right() - row.right()) > 0.5f) tile = 0;
|
||
for (int k = 0; k < n; ++k) {
|
||
const auto& c = fl.cells[first + k];
|
||
if (std::fabs(c.y - row.y) > 0.5f || std::fabs(c.h - row.h) > 0.5f) tile = 0;
|
||
if (k > 0 && std::fabs((c.x - fl.cells[first + k - 1].right()) - fl.col_gap) > 0.5f) tile = 0;
|
||
}
|
||
}
|
||
// x_equal: every plot's x range equals plot 0's; cursors_aligned: every plot's cursor a/b x equal
|
||
int x_equal = 1, aligned = 1;
|
||
const auto r0 = rs.plot(0)->x_range(rs.axis_x(0));
|
||
for (int p = 1; p < rs.row_count(); ++p) {
|
||
const auto r = rs.plot(p)->x_range(rs.axis_x(p));
|
||
const double span = std::max(1e-12, std::fabs(r0.hi - r0.lo));
|
||
if (std::fabs(r.lo - r0.lo) > 1e-9 * span || std::fabs(r.hi - r0.hi) > 1e-9 * span) x_equal = 0;
|
||
if (std::fabs(rs.cursor_x(p, 0) - rs.cursor_x(0, 0)) > 1e-9 * span) aligned = 0;
|
||
if (std::fabs(rs.cursor_x(p, 1) - rs.cursor_x(0, 1)) > 1e-9 * span) aligned = 0;
|
||
}
|
||
// print: [split-probe] %-14s plots=%d frows=%d cells=[a,b] widths=[logical px, | between rows] tile=%d x_equal=%d cursors_aligned=%d weights=[...] traces=[P:name,...|...]
|
||
}
|
||
```
|
||
|
||
The accessor names `plot(p)`, `axis_x(p)` and `cursor_x(p, which)` are placeholders for the host's real accessors for the plot object, its x axis index and cursor A/B x (the layout probe and `RowSet::cursor_a_x()` show them: `r.plot->cursor(r.cursor_a).x`, `r.plot->x_range(r.axis_x)`); use those, adding a tiny `RowSet::cursor_x(int plot, int which)` if none exists. Fill in the print line with `g_print`/`std::printf` in the style of `layout_probe_report`, with widths in logical px (`cell.w / scale`), weights from `rs.cell_weights(fr)` and traces as `P:name` from the slot names the layout probe already prints.
|
||
|
||
`split_probe_step` follows `layout_probe_step` (200 ms) but with 250 ms steps; after any structural change call `app.area->set_figure(&app.rows->figure()); app.relayout_pending = true;` and let a frame run before the report. Steps:
|
||
0. `rows->set_cells_per_row({1}); ` free the slots; enable all signals and assign channels via the `enable_all_probe` loop; report `"start"` (expect `plots=1 frows=1 cells=[1]`).
|
||
1. `insert_cell(0, 1)`; assign channel 0 to plot 0 and channel 1 to plot 1; `set_cursors_visible(true)`; report `"split"` (expect `cells=[2] tile=1 x_equal=1 cursors_aligned=1`).
|
||
2. `set_cell_weights(0, {3, 1})`; report `"weights 3:1"` (widths ratio about 3:1 after the floors; `tile=1`).
|
||
3. `insert_row(1)` then `insert_cell(1, 1)`; report `"row+cell"` (expect `cells=[2,2] plots=4`).
|
||
4. Call `insert_cell` until it returns false and then `insert_row` until the plot total is 8; report `"caps"` printing the refusals (`insert_cell(0, 2)` true, `…` false at 4 cells; `insert_row` false at 8 plots). Keep these as separate printed `refused=` fields.
|
||
5. `remove_cell` on the last plot of the widest row, report `"remove"`; then finish by returning `G_SOURCE_REMOVE`.
|
||
|
||
In `ui.cpp` next to `UDPSCOPE_PROBE_LAYOUT_MS` (~6003) add:
|
||
|
||
```cpp
|
||
if (const char* ms = g_getenv("UDPSCOPE_PROBE_SPLIT_MS"))
|
||
g_timeout_add(static_cast<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**
|
||
|
||
```bash
|
||
cmake --build build 2>&1 | grep -E 'warning|error' | head
|
||
python3 tools/mock_udpstreamer.py --port 8171 --rate 1000 --batch 20 & MOCK=$!; sleep 2
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_SPLIT_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8171 2>&1 | grep -E 'split-probe|gtk warnings|Gtk-CRITICAL'
|
||
kill $MOCK
|
||
```
|
||
Expected: the six `[split-probe]` lines above with `tile=1 x_equal=1 cursors_aligned=1`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Also confirm that the first build output still has exactly one warning.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/plot_host.hpp src/plot_host.cpp src/ui_app.hpp src/ui_probe.cpp src/ui.cpp
|
||
git commit -m "plot host: rows hold 1..4 cells; split probe scaffold"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Session file and probe round-trip
|
||
|
||
**Files:**
|
||
- Modify: `src/layout_store.hpp` (`LayoutState::Row`), `src/layout_store.cpp` (writer ~786, `plots = N` ~729, parser `[[plot]]` ~457 and keys ~605–614, fallback ~718), `src/ui_layout.cpp` (`collect_layout` 17–36, `apply_layout` ~445–520)
|
||
- Modify: `src/ui_probe.cpp` (new probe steps)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `RowSet::cells_in_row`, `figure_row_of`, `cell_weights`, `set_cells_per_row`, `set_cell_weights`, `set_row_weights`.
|
||
- Produces: `LayoutState::Row { …; int row = -1; double width = 1.0; }`.
|
||
|
||
- [ ] **Step 1: Format**
|
||
|
||
`LayoutState::Row` gains `int row = -1; // figure row; -1 = file had none` and `double width = 1.0; // cell weight`. The writer emits, per `[[plot]]`: `row = <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**
|
||
|
||
```bash
|
||
git add src/layout_store.hpp src/layout_store.cpp src/ui_layout.cpp src/ui_probe.cpp
|
||
git commit -m "session: plots carry row and width; files without row load one cell per row"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Layout editor cell controls and column drag
|
||
|
||
**Files:**
|
||
- Modify: `src/ui.cpp` (layout editor 568–807), `src/ui_layout.cpp` (534–598 handlers), `src/ui_app.hpp` (constants ~1600–1607, new `App` fields), `src/ui_probe.cpp` (`layout_probe_*` re-baseline, new split-probe click steps)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 3 `RowSet` API, Task 2 `FigureLayout.cells`.
|
||
- Produces: `void layout_col_apply(App&, int plot, double offset_px)`, `GtkWidget* layout_cell_button(App&, int fr, int pos)`, `GtkWidget* layout_col_handle(App&, int plot)`; `App::layout_col_drag_plot`, `App::layout_col_drag_left0` / `layout_col_drag_right0` (start widths, logical px); constants `kLayoutCellKey = "udpscope-layout-cell"`, `kLayoutColKey = "udpscope-layout-col"`, `kMinCellPx = 96`.
|
||
|
||
- [ ] **Step 1: Read** `ui.cpp` 568–807 (overlay build, `sync_layout_editor`, `layout_overlay_button`), `ui_layout.cpp` 534–598 and the drag handlers, `ui_app.hpp` ~1600–1607.
|
||
|
||
- [ ] **Step 2: Row-keyed controls by figure row.** The row boundary `+` buttons and the row resize handles are keyed by **figure row** (`figure_row_count() + 1` boundaries, positioned from `fl.rows`). `on_layout_row_insert(boundary)` calls `rows->insert_row(boundary)`. The per-cell `×` stays keyed by plot index (existing `set_row_key`), positioned from `fl.cells[idx]` top-left, and its handler calls `rows->remove_cell(idx)`; it is insensitive when `remove_cell` would fail (`row_count() == 1`). After each handler: `app->area->set_figure(&app->rows->figure())`, `rebuild_layout_editor`, `rebuild_signal_panels`, `schedule_layout_save` (same tail as the existing handlers). The row `+` is insensitive at `row_count() >= RowSet::kMaxRows`.
|
||
|
||
- [ ] **Step 3: Cell `+` buttons.** For each figure row `fr` with `n = cells_in_row(fr)`, create `n + 1` buttons via `layout_overlay_button(app, "list-add-symbolic", tooltip)` with `g_object_set_data(btn, kLayoutCellKey, GINT_TO_POINTER(fr * 8 + pos + 1))` (do **not** call `set_row_key`). Positions: `pos 0` at `cells[first].x/scale + kLayoutButtonPad`; internal `pos k` centred on the gap between cells `k-1` and `k`; `pos n` at `cells[last].right()/scale - kLayoutButtonPx - kLayoutButtonPad`; vertically centred in `fl.rows[fr]`. Clicking calls `rows->insert_cell(fr, pos)` then the same tail as Step 2. Sensitivity: `cells_in_row(fr) < kMaxCellsPerRow && row_count() < kMaxRows`. Convert with `/scale` as the existing code does in `sync_layout_editor`, and re-place them in `sync_layout_editor` every frame.
|
||
|
||
- [ ] **Step 4: Column resize handles.** At each internal boundary `k` (1..n-1) of a split row create a drag handle with `kLayoutColKey` = plot index of the right-hand cell (`first + k`), placed just right of the cell `+` there, with a left-right arrow icon (`go-next-symbolic` is acceptable, use whatever the row handle's icon pair provides for the horizontal direction), copying the row handle's `GtkGestureDrag` ("udpscope-layout-drag" pattern with its own begin/update/end handlers). Handlers:
|
||
|
||
```cpp
|
||
void layout_col_apply(App& app, int plot, double offset_px)
|
||
{
|
||
RowSet& rs = *app.rows;
|
||
const int fr = rs.figure_row_of(plot);
|
||
const int k = rs.cell_of(plot);
|
||
if (fr < 0 || k < 1) return;
|
||
const double scale = gtk_widget_get_scale_factor(app.window) > 0 ? ui_scale_of(app) : 1.0; // use the helper sync_layout_editor uses
|
||
const double pair = app.layout_col_drag_left0 + app.layout_col_drag_right0;
|
||
const double want_left = std::clamp(app.layout_col_drag_left0 + offset_px / scale, double(kMinCellPx), pair - kMinCellPx);
|
||
std::vector<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**
|
||
|
||
```bash
|
||
git add src/ui.cpp src/ui_layout.cpp src/ui_app.hpp src/ui_probe.cpp
|
||
git commit -m "layout editor: split a row into cells, resize cells by drag"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Signals page, notices and drop target
|
||
|
||
**Files:**
|
||
- Modify: `src/ui_signal_page.cpp` (~260–285), `src/ui_signals.cpp` (~557, ~570, ~579), `src/ui.cpp` (~325, ~1680, ~2762, ~2766, ~2942)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `RowSet::plot_label(int)`, x-aware `area->row_at(x, y)` from Task 2.
|
||
|
||
- [ ] **Step 1:** In `ui_signal_page.cpp` "Plot on" buttons keep their `udpscope-row` data (plot index) and the `assign_signal_to_row` callback but their label becomes `rows->plot_label(r)` and, for split rows, the tooltip "row 2, cell 1 of 2" (`"row %d, cell %d of %d"` with `figure_row_of + 1`, `cell_of + 1`, `cells_in_row`). This amends the spec's "row 2, left" wording to a unified numeric form; a single-cell row keeps its existing tooltip.
|
||
- [ ] **Step 2:** Replace `"row %d"` / `r + 1` notices at `ui_signals.cpp:557`, `:570`, `ui.cpp:1680`, `:2762`, `:2766` with `"row %s"` and `plot_label(i).c_str()`.
|
||
- [ ] **Step 3:** Confirm `row_at(app, x, y)` (`ui.cpp:325`) forwards `x` to `area->row_at` (it did not need it before) and that the two callers (`ui_signals.cpp:579`, `ui.cpp:2942`) pass the pointer position unchanged. Persistence layers (`sync_layers`) fill free slots of the same plot, with no change.
|
||
- [ ] **Step 4:** Extend the split probe: after step 3 of Task 3, call `assign_signal_to_row` for a signal onto plot 1 of a split row and report the notice text / `traces=` showing the signal on `1b`. Expected probe line shows `traces=[…|1b:name…]`.
|
||
- [ ] **Step 5:** Build, run the split probe and `gtk warnings: 0`; commit:
|
||
|
||
```bash
|
||
git add src/ui_signal_page.cpp src/ui_signals.cpp src/ui.cpp src/ui_probe.cpp
|
||
git commit -m "signals: label plots by row and cell, drop onto a cell"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Regression runs
|
||
|
||
**Files:** none (verification); `docs/claude/probe-readings.md` is updated in Task 8 for any feature-explained difference.
|
||
|
||
- [ ] **Step 1:** Clean build from scratch in a temporary build dir; expect exactly one warning.
|
||
- [ ] **Step 2:** Serially run the probes listed in `docs/claude/verification.md` that touch layout, cursors, measurements, readouts and the E-1608 source (one mock per run, killed afterwards). For each, run once on the pre-feature commit (`git worktree` or checkout of `HEAD` before Task 1, built separately) and once now with a fresh `XDG_CONFIG_HOME`; diff number-normalised. The only acceptable differences are those the feature explains (layout probe's button count, new `cells`/column-weights lines in layout dumps).
|
||
- [ ] **Step 3:** Layout dumps and probes that print per-row layout gain a `cells=[…]` list and a `colweights=[…]` line: add those to `layout_probe_report` and to the layout dump in `ui.cpp`, then re-run to confirm the numbers.
|
||
- [ ] **Step 4:** Run the plotter suite once more (`deps/plotter/build`, all `test_*`).
|
||
- [ ] **Step 5:** Commit any report-line additions: `git add src/ui_probe.cpp src/ui.cpp && git commit -m "probes: print cells and column weights"`.
|
||
|
||
---
|
||
|
||
### Task 8: Docs
|
||
|
||
**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`, `docs/claude/verification.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `src/ui_context_help.cpp`, `CLAUDE.md` (only if the "figure of up to eight stacked rows" sentence needs "plots").
|
||
|
||
- [ ] **Step 1: Write each doc paragraph** (real text, in the style of the neighbouring sections):
|
||
- `build-and-run.md` (vendored plotter changes list): "`plot::Figure` rows can hold several plots: `Figure(std::span<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**
|
||
|
||
```bash
|
||
git add README.md docs/USER-GUIDE.md help docs/claude src/ui_context_help.cpp CLAUDE.md
|
||
git commit -m "docs: horizontal plot split"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
- **Spec coverage:** shared time window (Task 1 x link test, Task 3 probe `x_equal`); per-row 1..4 cells and caps (Tasks 3, 5); draggable widths saved (Tasks 4, 5); plotter API and layout rules including narrow degradation, bottom-only labels, common gutter (Task 1); hit-testing by cell (Task 2); host model, flattened indices, caps, cursors/crosshair/y axes per cell (Tasks 1, 3); drop onto a cell and "place on row" listing (Task 6, tooltip wording amended to "row 2, cell 1 of 2"); layout editor controls (Task 5); session format and legacy loading (Task 4); verification probes and regression (Tasks 3–5, 7); docs list (Task 8).
|
||
- **Placeholders:** accessor names that the plan could not confirm without re-reading code (`layout_info()`, `rs.plot(p)`, `axis_x`, `cursor_x`, `carry_row` argument order, `Status` constructors) are flagged where used with how to resolve them from the neighbouring code; no behaviour is left unspecified.
|
||
- **Type consistency:** `insert_cell(fr, at)`, `remove_cell(plot)`, `set_cell_weights(fr, vector<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.
|