layout editor: split a row into cells, resize cells by drag

Add cell `+` buttons (n+1 per figure row), column resize handles with
drag, and update the `x` handler to call remove_cell instead of
remove_row. Fix row_weights_ misalignment in insert_row/remove_row.
Split probe steps 9-15 exercise content movement, column drag clamp,
row-weight tracking, and sensitivity at caps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-03 17:33:29 +01:00
1 parent 62ceb69278
commit 645cb6f4fa
5 files changed
+389 -57

No files matched your search

+198 -49
View File
@@ -614,25 +614,65 @@ void clear_layout_editor(App& app) {
void sync_layout_editor(App& app) {
if (app.layout_buttons.empty() || app.area == nullptr) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
const int rows = app.rows->row_count();
const int plots = app.rows->row_count();
const int frows = app.rows->figure_row_count();
// Before the first frame the figure has no rects yet; placing against them would put
// every button in the top-left corner and then move it, which is a visible jump.
if (static_cast<int>(fl.rows.size()) != rows) return;
if (static_cast<int>(fl.rows.size()) != frows) return;
if (static_cast<int>(fl.cells.size()) != plots) return;
const double scale = app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
const double height = static_cast<double>(gtk_widget_get_height(app.area_widget));
if (!(height > 0.0)) return;
const double half = kLayoutButtonPx * 0.5;
for (GtkWidget* b : app.layout_buttons) {
const int idx = row_key(b);
const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr;
if (resize) {
// *On* the boundary, beside the `+` -- the same edge, one handle's width to its right. Below it sat
// inside the lower row, which read as a handle for that row: a boundary handle that looks like it
// belongs to the plot under it is a handle whose drag changes the wrong plot, whatever it does.
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
const bool cell_insert = g_object_get_data(G_OBJECT(b), kLayoutCellKey) != nullptr;
const bool col_resize = g_object_get_data(G_OBJECT(b), kLayoutColKey) != nullptr;
if (col_resize) {
// Column handle: placed at the internal boundary between two cells.
const int plot = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kLayoutColKey));
if (plot <= 0 || plot >= plots) continue;
const plot::Rect& cl = fl.cells[static_cast<std::size_t>(plot - 1)];
const plot::Rect& cr = fl.cells[static_cast<std::size_t>(plot)];
const double cx = (static_cast<double>(cl.right()) + static_cast<double>(cr.x)) / (2.0 * scale);
const double cy = static_cast<double>(cr.y) / scale + static_cast<double>(cr.h) / (2.0 * scale);
gtk_widget_set_margin_start(
b, static_cast<int>(std::clamp(cx - half, 0.0,
std::max(0.0, static_cast<double>(gtk_widget_get_width(app.area_widget)) - kLayoutButtonPx)) + 0.5));
gtk_widget_set_margin_top(b, static_cast<int>(std::clamp(cy - half, 0.0, height - kLayoutButtonPx) + 0.5));
} else if (cell_insert) {
// Cell `+`: placed along the figure row's cell boundaries.
const int tag = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), kLayoutCellKey)) - 1;
const int fr = tag / 8;
const int pos = tag % 8;
if (fr < 0 || fr >= frows) continue;
const int first = app.rows->first_plot_of_row(fr);
const int n = app.rows->cells_in_row(fr);
const plot::Rect& row_r = fl.rows[static_cast<std::size_t>(fr)];
const double row_cy = static_cast<double>(row_r.y) / scale +
static_cast<double>(row_r.h) / (2.0 * scale);
double cx;
if (pos == 0) {
cx = static_cast<double>(fl.cells[static_cast<std::size_t>(first)].x) / scale + kLayoutButtonPad;
} else if (pos >= n) {
cx = static_cast<double>(fl.cells[static_cast<std::size_t>(first + n - 1)].right()) / scale -
kLayoutButtonPx - kLayoutButtonPad;
} else {
const double r_edge = static_cast<double>(fl.cells[static_cast<std::size_t>(first + pos - 1)].right()) / scale;
const double l_edge = static_cast<double>(fl.cells[static_cast<std::size_t>(first + pos)].x) / scale;
cx = (r_edge + l_edge) * 0.5 - half;
}
gtk_widget_set_margin_start(
b, static_cast<int>(std::clamp(cx, 0.0,
std::max(0.0, static_cast<double>(gtk_widget_get_width(app.area_widget)) - kLayoutButtonPx)) + 0.5));
gtk_widget_set_margin_top(b, static_cast<int>(std::clamp(row_cy - half, 0.0, height - kLayoutButtonPx) + 0.5));
} else if (resize) {
// Row resize handle: placed on the boundary between figure rows.
const int idx = row_key(b);
const double edge = idx < frows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
@@ -640,23 +680,25 @@ void sync_layout_editor(App& app) {
gtk_widget_set_margin_start(
b, static_cast<int>(std::clamp(left, 0.0, std::max(0.0, width - kLayoutButtonPx)) + 0.5));
} else if (insert) {
// Centred on the boundary: the top edge of row `idx`, or the bottom edge of the
// last row for the one past the end.
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
// Row boundary `+`: placed on the boundary between figure rows.
const int idx = row_key(b);
const double edge = idx < frows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
} else {
const plot::Rect& r = fl.rows[static_cast<std::size_t>(idx)];
const double top = std::clamp(static_cast<double>(r.y) / scale + kLayoutButtonPad,
// Per-plot `×`: placed from `fl.cells[idx]`.
const int idx = row_key(b);
if (idx < 0 || idx >= plots) continue;
const plot::Rect& c = fl.cells[static_cast<std::size_t>(idx)];
const double top = std::clamp(static_cast<double>(c.y) / scale + kLayoutButtonPad,
0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
// Inside the axes, not over the y labels: the gutter is the one piece of row chrome
// whose width is reserved rather than measured per frame, so this does not move.
// Inside the axes, not over the y labels.
const plot::Plot* p = app.rows->row(idx).plot;
const double left = (p != nullptr ? static_cast<double>(p->layout().axes.x) / scale
: static_cast<double>(r.x) / scale) +
: static_cast<double>(c.x) / scale) +
kLayoutButtonPad;
gtk_widget_set_margin_start(b, static_cast<int>(left + 0.5));
}
@@ -674,17 +716,17 @@ void on_layout_drag_begin(GtkGestureDrag* gesture, double, double, gpointer user
// A drag is *relative*: the weights it started from are kept, because reading them back while dragging
// would compound -- each update would build on the last frame's instead of on the grab.
app.layout_drag_start = app.rows->row_weights();
app.layout_drag_start.resize(static_cast<std::size_t>(app.rows->row_count()), 1.0f);
app.layout_drag_start.resize(static_cast<std::size_t>(app.rows->figure_row_count()), 1.0f);
}
void on_layout_drag_update(GtkGestureDrag*, double, double offset_y, gpointer user) {
App& app = *static_cast<App*>(user);
const int at = app.layout_drag_row; // the boundary between rows `at - 1` and `at`
const int rows = app.rows->row_count();
if (at <= 0 || at >= rows) return; // the figure's own edges are not boundaries
if (app.layout_drag_start.size() != static_cast<std::size_t>(rows)) return;
const int at = app.layout_drag_row; // the boundary between figure rows `at - 1` and `at`
const int frows = app.rows->figure_row_count();
if (at <= 0 || at >= frows) return; // the figure's own edges are not boundaries
if (app.layout_drag_start.size() != static_cast<std::size_t>(frows)) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
if (static_cast<int>(fl.rows.size()) != rows) return;
if (static_cast<int>(fl.rows.size()) != frows) return;
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
const double up = static_cast<double>(fl.rows[static_cast<std::size_t>(at - 1)].h) / scale;
@@ -709,6 +751,79 @@ void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) {
schedule_layout_save(app);
}
// --- column (cell) resize drag -----------------------------------------------
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 =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
const double pair = app.layout_col_drag_left0 + app.layout_col_drag_right0;
const double floor = std::min(kMinCellPx, pair * 0.5);
const double want_left = std::clamp(app.layout_col_drag_left0 + offset_px / scale,
floor, pair - floor);
std::vector<float> w = rs.cell_weights(fr);
w.resize(static_cast<std::size_t>(rs.cells_in_row(fr)), 1.0f);
const float sum = w[static_cast<std::size_t>(k - 1)] + w[static_cast<std::size_t>(k)];
w[static_cast<std::size_t>(k - 1)] = static_cast<float>(sum * want_left / pair);
w[static_cast<std::size_t>(k)] = sum - w[static_cast<std::size_t>(k - 1)];
rs.set_cell_weights(fr, w);
if (app.area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app.area_widget));
}
void on_layout_col_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_col_drag_plot =
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-col-drag"));
const int plot = app.layout_col_drag_plot;
if (plot < 0 || plot >= app.rows->row_count()) return;
const int fr = app.rows->figure_row_of(plot);
const int k = app.rows->cell_of(plot);
if (fr < 0 || k < 1) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
app.layout_col_drag_left0 = static_cast<double>(fl.cells[static_cast<std::size_t>(plot - 1)].w) / scale;
app.layout_col_drag_right0 = static_cast<double>(fl.cells[static_cast<std::size_t>(plot)].w) / scale;
app.layout_col_drag_start = app.rows->cell_weights(fr);
app.layout_col_drag_start.resize(static_cast<std::size_t>(app.rows->cells_in_row(fr)), 1.0f);
}
void on_layout_col_update(GtkGestureDrag*, double offset_x, double, gpointer user) {
App& app = *static_cast<App*>(user);
layout_col_apply(app, app.layout_col_drag_plot, offset_x);
}
void on_layout_col_end(GtkGestureDrag*, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_col_drag_plot = -1;
app.layout_col_drag_left0 = 0.0;
app.layout_col_drag_right0 = 0.0;
app.layout_col_drag_start.clear();
schedule_layout_save(app);
}
// --- cell insert handler -----------------------------------------------------
void on_layout_cell_insert(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int tag = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), kLayoutCellKey)) - 1;
const int fr = tag / 8;
const int pos = tag % 8;
if (!app->rows->insert_cell(fr, pos)) {
app->notice = "at most " + std::to_string(RowSet::kMaxCellsPerRow) +
" cells per row and " + std::to_string(RowSet::kMaxRows) + " plots total";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
rebuild_signal_panels(*app);
schedule_layout_save(*app);
}
/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the
/// editor is switched off.
/// One of the layout editor's overlay buttons: the look they all share is *here*, once. It was written out
@@ -736,46 +851,46 @@ GtkWidget* layout_overlay_button(App& app, const char* icon, const char* tooltip
void rebuild_layout_editor(App& app) {
clear_layout_editor(app);
if (!app.layout_editing || app.area_overlay == nullptr) return;
const int rows = app.rows->row_count();
app.layout_buttons_rows = rows;
const int plots = app.rows->row_count();
const int frows = app.rows->figure_row_count();
app.layout_buttons_rows = plots;
for (int i = 0; i < rows; ++i) {
GtkWidget* b = layout_overlay_button(app, "window-close-symbolic", "remove this row");
// One `×` per plot, keyed by plot index. In a one-cell row "remove this row"; in a
// multi-cell row "remove this plot" and it calls `remove_cell`.
for (int i = 0; i < plots; ++i) {
const int fr = app.rows->figure_row_of(i);
const bool multi = fr >= 0 && app.rows->cells_in_row(fr) > 1;
GtkWidget* b = layout_overlay_button(app, "window-close-symbolic",
multi ? "remove this plot" : "remove this row");
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
gtk_widget_set_sensitive(b, rows > 1);
gtk_widget_set_sensitive(b, plots > 1);
set_row_key(b, i);
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_remove), &app);
}
for (int b_i = 0; b_i <= rows; ++b_i) {
// One `+` per figure row boundary (row insert).
for (int b_i = 0; b_i <= frows; ++b_i) {
GtkWidget* b = layout_overlay_button(app, "list-add-symbolic",
b_i == 0 ? "add a row above"
: b_i == rows ? "add a row below"
: "add a row here");
b_i == 0 ? "add a row above"
: b_i == frows ? "add a row below"
: "add a row here");
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
gtk_widget_set_valign(b, GTK_ALIGN_START);
gtk_widget_set_sensitive(b, rows < RowSet::kMaxRows);
gtk_widget_set_sensitive(b, plots < RowSet::kMaxRows);
set_row_key(b, b_i);
g_object_set_data(G_OBJECT(b), kLayoutKindKey, b);
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_insert), &app);
}
// ... and one arrow per boundary whose *only* job is resizing the two rows it sits between. A widget
// of its own rather than a second gesture on the `+`, so each handle does one thing -- and a handle
// that cannot be clicked cannot be clicked by accident. The figure's own edges are not boundaries
// (there is no pair of rows to share between them), so the first and last are inert.
for (int b_i = 0; b_i <= rows; ++b_i) {
// One row resize handle per figure row boundary.
for (int b_i = 0; b_i <= frows; ++b_i) {
GtkWidget* b = layout_overlay_button(app, "udpscope-resize-rows-symbolic",
b_i == 0 ? "drag to resize the first row"
: b_i == rows ? "drag to resize the last row"
: "drag to resize the two rows here");
b_i == 0 ? "drag to resize the first row"
: b_i == frows ? "drag to resize the last row"
: "drag to resize the two rows here");
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
gtk_widget_set_valign(b, GTK_ALIGN_START);
// Hidden, not disabled, where it has no pair to share between: the figure's own edges. A greyed
// handle is a question ("why can I not drag this?"), and the answer is that there is nothing there.
gtk_widget_set_visible(b, b_i > 0 && b_i < rows);
gtk_widget_set_visible(b, b_i > 0 && b_i < frows);
gtk_widget_set_halign(b, GTK_ALIGN_START);
// The *row key* is what `sync_layout_editor()` places by -- without it every arrow was placed at
// index 0, the figure's top edge, whatever boundary it belonged to.
set_row_key(b, b_i);
g_object_set_data(G_OBJECT(b), kLayoutResizeKey, GSIZE_TO_POINTER(b_i));
GtkGesture* drag = gtk_gesture_drag_new();
@@ -787,6 +902,40 @@ void rebuild_layout_editor(App& app) {
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_drag_end), &app);
gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag));
}
// Cell `+` buttons: n+1 per figure row with n cells.
for (int fr = 0; fr < frows; ++fr) {
const int n = app.rows->cells_in_row(fr);
const bool can_split = n < RowSet::kMaxCellsPerRow && plots < RowSet::kMaxRows;
for (int pos = 0; pos <= n; ++pos) {
GtkWidget* b = layout_overlay_button(app, "list-add-symbolic", "split here");
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
gtk_widget_set_sensitive(b, can_split);
// Encode fr and pos as a single int (do NOT call set_row_key).
g_object_set_data(G_OBJECT(b), kLayoutCellKey, GINT_TO_POINTER(fr * 8 + pos + 1));
g_signal_connect(b, "clicked", G_CALLBACK(on_layout_cell_insert), &app);
}
}
// Column resize handles at each internal cell boundary.
for (int fr = 0; fr < frows; ++fr) {
const int n = app.rows->cells_in_row(fr);
const int first = app.rows->first_plot_of_row(fr);
for (int k = 1; k < n; ++k) {
const int plot = first + k;
GtkWidget* b = layout_overlay_button(app, "go-next-symbolic", "drag to resize the plots");
gtk_widget_set_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START);
g_object_set_data(G_OBJECT(b), kLayoutColKey, GINT_TO_POINTER(plot));
GtkGesture* drag = gtk_gesture_drag_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 1);
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE);
g_object_set_data(G_OBJECT(drag), "udpscope-layout-col-drag", GINT_TO_POINTER(plot));
g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_col_begin), &app);
g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_col_update), &app);
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_col_end), &app);
gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag));
}
}
// The editor's way out, in the area's top-right corner: the toolbar's toggle is in the
// overflow menu, which is a long way from the plot you are editing. Aligned by margins