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:
1 parent
62ceb69278
commit
645cb6f4fa
5 files changed
+389
-57
No files matched your search
+198
-49
@@ -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
|
||||
|
||||
Reference in new issue
Block a user