layout editor: horizontal-resize icon, divider drag follows the pointer; x caption on every labelled plot; probes and docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-11-04 07:20:03 +01:00
1 parent 0c228c4822
commit 7972646dd2
14 files changed
+312 -65

No files matched your search

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A boundary that can be dragged sideways: a horizontal double-headed arrow, the turned twin of
udpscope-resize-rows-symbolic. It says *which* edge the handle moves, which a single chevron (a
"go next") does not. Every coordinate is a whole pixel of the 16px cell, every subpath is a filled
`path` wound the same way (a stroke would keep this colour on a dark theme), and the two heads
meet the shaft exactly, at y 5..11 and y 7..9. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M4 7 H12 V9 H4 Z
M1 8 L5 5 V11 Z
M15 8 L11 11 V5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

+1
View File
@@ -22,6 +22,7 @@
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-cols-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
</gresource>
+18 -7
View File
@@ -1094,10 +1094,11 @@ bool RowSet::update_time_labels() {
// millisecond-scale window shows. `x_origin_` is read back below for the caption.
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
const bool bottom = i >= first_plot_of_row(figure_row_count() - 1);
if (!relative_x_ || !bottom) {
// The caption belongs with the labels, so it goes on every plot whose x strip draws them: the
// bottom row's, and any row above it that carries a strip of its own (a split row whose columns
// differ from the row below's; see `Figure`'s x-axis rule). The plots without labels get an empty
// one, which draws nothing.
if (!relative_x_ || !plot::x_strip_has_labels(r.plot->x_strip_mode())) {
r.plot->set_x_note("");
continue;
}
@@ -1132,11 +1133,21 @@ void RowSet::hold_pinned_window() {
// statement, which is what keeps a fresh pin (`pin_event_window()`, `pin_pause_window()`, both
// of which write the group in this same tick) from being reverted to the member's stale window.
plot::Range now = figure_->x_link_range(x_group_);
if (rows_ > 0 && row(0).plot != nullptr) {
const plot::Range member = row(0).plot->x_range(row(0).axis_x);
// Any plot's member may carry the gesture: the controller writes the plot under the pointer, and an x
// label strip is a plot's own (the bottom row's, or a row above it whose columns differ), so reading
// plot 0 alone made a zoom or pan on every other plot's axis vanish when the next statement of the pin
// went over it. Every member was last written with `pinned_stated_` (the group push), so the first
// one that differs is the gesture; the same clamp below applies whichever plot it came from.
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (r.plot == nullptr) continue;
const plot::Range member = r.plot->x_range(r.axis_x);
const bool member_is_ours = std::abs(member.min - pinned_stated_.min) < 1e-12 &&
std::abs(member.max - pinned_stated_.max) < 1e-12;
if (!member_is_ours) now = member;
if (!member_is_ours) {
now = member;
break;
}
}
const double pin_span = pin.span();
// **A zoom *in* is allowed.** The rule is that the view may not leave the time window, and a
+45 -13
View File
@@ -753,8 +753,11 @@ void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) {
// --- column (cell) resize drag -----------------------------------------------
// The two cells' widths here are *plot* widths (a cell's rect without the first cell's y gutter): the
// 96 px floor and the plotter's 64 px floor both apply to them, and the weights share them out.
// The two cells' widths here are *plot* widths -- `layout().axes.w`, the framed plotting area, which is
// what the plotter places cells by (cell rects add the first cell's y gutter, the last cell's right gutter
// and the column gap on top): the 96 px floor and the plotter's 64 px floor both apply to them, and the
// weights share them out. `offset_px` is the pointer's travel in *device* px (a gesture's logical offset
// times the area's scale).
void layout_col_apply(App& app, int plot, double offset_px) {
RowSet& rs = *app.rows;
const int fr = rs.figure_row_of(plot);
@@ -791,18 +794,15 @@ bool layout_col_drag_begin(App& app, int plot) {
const int fr = app.rows->figure_row_of(plot);
const int k = app.rows->cell_of(plot);
if (fr < 0 || k < 1) return false;
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;
// The weights share out *plot* widths, so the pair is measured as plots: a cell's rect minus the y
// gutter (axis strip + title column) that only the first cell of a row has. The left cell of the pair
// is the first one when `plot - 1` starts the row; the right cell never has a gutter, and neither does
// a non-first left cell (its plot's strips read 0).
// The weights share out *plot* widths, so the pair is measured as plots: each cell's `axes.w`, the
// plotting area the plotter places a cell by. Not the cell rect: that adds the y gutter of the first
// cell, the right gutter of the last and half a column gap, none of which the weights apply to.
(void)app.rows->figure().layout(); // non-const: re-lays the figure, and every plot, on the current weights
const auto plot_width = [&](int cell) {
const RowSet::Row& r = app.rows->row(cell);
const double gutter =
r.plot != nullptr ? double(r.plot->layout().y_axis_width + r.plot->layout().y_title_width) : 0.0;
return std::max(0.0, double(fl.cells[static_cast<std::size_t>(cell)].w) - gutter);
return r.plot != nullptr ? std::max(0.0, static_cast<double>(r.plot->layout().axes.w)) : 0.0;
};
app.layout_col_drag_left0 = plot_width(plot - 1) / scale;
app.layout_col_drag_right0 = plot_width(plot) / scale;
@@ -811,15 +811,39 @@ bool layout_col_drag_begin(App& app, int plot) {
return true;
}
/// Where the drag handle sits in the overlay's coordinates (logical px), or `fallback` when it cannot be
/// asked. The handle follows the divider it drags (`sync_layout_editor()` re-places it every frame), so it
/// is *moving* while the gesture runs.
static double layout_col_handle_x(App& app, GtkWidget* handle, double fallback) {
if (handle == nullptr || app.area_overlay == nullptr) return fallback;
graphene_point_t out;
const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f);
if (!gtk_widget_compute_point(handle, app.area_overlay, &origin, &out)) return fallback;
return static_cast<double>(out.x);
}
double layout_col_total_offset(double offset, double handle_now, double handle_begin) {
return offset + (handle_now - handle_begin);
}
void on_layout_col_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
layout_col_drag_begin(
app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-col-drag")));
app.layout_col_drag_handle_x0 = layout_col_handle_x(
app, gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)), 0.0);
}
void on_layout_col_update(GtkGestureDrag*, double offset_x, double, gpointer user) {
void on_layout_col_update(GtkGestureDrag* gesture, double offset_x, double, gpointer user) {
App& app = *static_cast<App*>(user);
layout_col_apply(app, app.layout_col_drag_plot, offset_x);
// The gesture reports its offset in the *handle's* coordinates, and the handle follows the divider, so
// the offset shrinks by the handle's own displacement; adding that back gives the pointer's travel.
const double now = layout_col_handle_x(
app, gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)), app.layout_col_drag_handle_x0);
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
layout_col_apply(app, app.layout_col_drag_plot,
layout_col_total_offset(offset_x, now, app.layout_col_drag_handle_x0) * scale);
}
void on_layout_col_end(GtkGestureDrag*, double, double, gpointer user) {
@@ -827,6 +851,7 @@ void on_layout_col_end(GtkGestureDrag*, double, double, gpointer 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_handle_x0 = 0.0;
app.layout_col_drag_start.clear();
schedule_layout_save(app);
}
@@ -948,7 +973,7 @@ void rebuild_layout_editor(App& app) {
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");
GtkWidget* b = layout_overlay_button(app, "udpscope-resize-cols-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));
@@ -3320,6 +3345,13 @@ void note_panel_controls(App& app) {
app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + name + "=" +
icon + (gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)");
}
// The layout editor's two resize handles are not toolbar buttons (they exist only while the editor is
// up), so they are asked of the theme by name: the same silent failure, the same line.
for (const auto& [label, icon] : {std::pair<const char*, const char*>{"resize_rows", "udpscope-resize-rows-symbolic"},
std::pair<const char*, const char*>{"resize_cols", "udpscope-resize-cols-symbolic"}}) {
app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + label + "=" + icon +
(gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)");
}
// The application icon, the same two ways and for the same reason: a resource path that was
// never registered is not an error, it is a missing-image placeholder. This one is read on
// the first tick like the line above, because the dump runs after the window is destroyed.
+11 -3
View File
@@ -209,12 +209,17 @@ struct App {
int layout_drag_row = -1;
std::vector<float> layout_drag_start;
/// Column drag state: which plot (right-hand cell), the two starting *plot* widths in logical px
/// (a cell's rect minus the y gutter only the first cell of a row has), and the cell weights
/// snapshot at drag begin (`layout_col_apply()` redistributes the pair's weight sum from these, so
/// a drag stays relative to where it started).
/// (each plot's `layout().axes.w`, the area the plotter places a cell by -- not the cell rect, which
/// adds the y and right gutters and the gap), and the cell weights snapshot at drag begin
/// (`layout_col_apply()` redistributes the pair's weight sum from these, so a drag stays relative to
/// where it started).
int layout_col_drag_plot = -1;
double layout_col_drag_left0 = 0.0;
double layout_col_drag_right0 = 0.0;
/// The drag handle's x in the overlay's coordinates (logical px) when the drag began. The handle is
/// re-placed on the divider every frame, and a gesture reports its offset in the handle's own, moving,
/// coordinates; `layout_col_total_offset()` adds the handle's displacement since this back.
double layout_col_drag_handle_x0 = 0.0;
std::vector<float> layout_col_drag_start;
/// The editor's way out: one `Done` button in the plot area's top-right corner while
/// the editor is up. Not part of `layout_buttons`, which is one button per row and per
@@ -2121,6 +2126,9 @@ GtkWidget* layout_col_handle(App& a, int plot);
/// (the right-hand cell) begins with. False when `plot` has no left neighbour.
bool layout_col_drag_begin(App& app, int plot);
void layout_col_apply(App& app, int plot, double offset_px);
/// A column drag's pointer travel from what its gesture reports: `offset` is relative to the handle, which
/// has moved from `handle_begin` to `handle_now` (same units, the overlay's) since the drag began.
double layout_col_total_offset(double offset, double handle_now, double handle_begin);
void layout_probe_report(App& a, const char* what);
/// The split in the report lines, from the `RowSet` alone (no widget is asked, so the exit dump can
/// use them): cells per figure row (`1,2,1`) and each figure row's cell weights, rows divided by `|`
+170 -9
View File
@@ -3986,6 +3986,93 @@ struct SharedYProbe {
};
SharedYProbe g_shared_y;
/// What the x-axes steps carry from arrangement to arrangement: per arrangement, whether row 0's plots each
/// reserve an x strip, whether the bottom row's do, whether neighbouring plots' axes are one `col_gap`
/// apart, and whether the `t0` caption sits on exactly the plots that draw labels.
struct XAxesProbe {
std::string row0;
std::string bottom;
std::string gap_ok;
std::string gap;
std::string note;
double window_before = 0.0;
double col_gap = 0.0;
};
XAxesProbe g_x_axes;
/// The absolute 50 ms span the x-axes steps pin the view to, and the zoom inside it written onto the last plot.
constexpr double kXAxesPinLo = 100.0;
constexpr double kXAxesPinHi = 100.05;
constexpr double kXAxesZoomLo = 100.01;
constexpr double kXAxesZoomHi = 100.03;
/// A trace on every plot: with data bound the live axis spans [newest - window, newest], which is what makes
/// the labels relative at a short window (an empty plot frames {-window, 0} and needs no anchor).
void split_probe_x_axes_fill(App& a) {
for (int p = 0; p < a.rows->row_count(); ++p) split_probe_assign(a, p, 0);
}
/// Reads the arrangement the last frame was laid out with into `g_x_axes`.
void split_probe_x_axes_read(App& a) {
RowSet& rs = *a.rows;
const double scale = (a.area != nullptr && a.area->device_scale() > 0.0f)
? static_cast<double>(a.area->device_scale()) : 1.0;
const plot::FigureLayout& fl = rs.figure().layout(); // non-const: lays every plot out on the weights
const int frows = rs.figure_row_count();
// 1 when every plot of the figure row reserves an x strip, 0 when none does, 2 for a mixture (a defect:
// a row's cells share their strip decision).
const auto strips = [&](int fr) {
int with = 0;
const int first = rs.first_plot_of_row(fr);
const int n = rs.cells_in_row(fr);
for (int k = 0; k < n; ++k) {
const RowSet::Row& r = rs.row(first + k);
if (r.plot != nullptr && r.plot->layout().x_axis_height > 0.0f) ++with;
}
return with == n ? 1 : (with == 0 ? 0 : 2);
};
const auto add = [](std::string& out, const std::string& v) { out += (out.empty() ? "" : ",") + v; };
add(g_x_axes.row0, std::to_string(strips(0)));
add(g_x_axes.bottom, std::to_string(strips(frows - 1)));
// The gap between neighbouring plots' axes, over every split row (shared y: only the last cell has a
// right gutter, so the distance is the column gap and nothing else).
int gap_ok = 1;
double gap_min = 1.0e9;
double gap_max = -1.0e9;
int pairs = 0;
for (int fr = 0; fr < frows; ++fr) {
const int first = rs.first_plot_of_row(fr);
for (int k = 0; k + 1 < rs.cells_in_row(fr); ++k) {
const RowSet::Row& l = rs.row(first + k);
const RowSet::Row& r = rs.row(first + k + 1);
if (l.plot == nullptr || r.plot == nullptr) { gap_ok = 0; continue; }
const double d = static_cast<double>(r.plot->layout().axes.x) - l.plot->layout().axes.right();
gap_min = std::min(gap_min, d);
gap_max = std::max(gap_max, d);
++pairs;
if (std::fabs(d - static_cast<double>(fl.col_gap)) > 0.5) gap_ok = 0;
}
}
if (pairs == 0) gap_ok = 0;
g_x_axes.col_gap = static_cast<double>(fl.col_gap) / scale;
add(g_x_axes.gap_ok, std::to_string(gap_ok));
char buf[48];
std::snprintf(buf, sizeof(buf), "%.1f/%.1f", pairs > 0 ? gap_min / scale : 0.0,
pairs > 0 ? gap_max / scale : 0.0);
add(g_x_axes.gap, buf);
// The caption goes with the labels: a plot has a note exactly when its x strip draws labels. Only
// decidable while the labels are relative (otherwise no plot has a note); -1 says so.
int note_ok = rs.time_labels_relative() ? 1 : -1;
if (note_ok == 1) {
for (int p = 0; p < rs.row_count(); ++p) {
const RowSet::Row& r = rs.row(p);
if (r.plot == nullptr) continue;
const bool labelled = plot::x_strip_has_labels(r.plot->x_strip_mode());
if (labelled != !r.plot->x_note().empty()) note_ok = 0;
}
}
add(g_x_axes.note, std::to_string(note_ok));
}
void split_probe_clear_all(App& a) {
for (int r = 0; r < RowSet::kMaxRows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a.rows->assign(r, s, "", "", 0);
@@ -4203,14 +4290,13 @@ gboolean split_probe_step(gpointer user) {
const double scale2 = (a->area != nullptr && a->area->device_scale() > 0.0f)
? static_cast<double>(a->area->device_scale()) : 1.0;
const int plot1 = rs.first_plot_of_row(0) + 1;
// The cells compared are *plot* widths (`axes.w` plus the right gutter, i.e. the cell rect without
// the y gutter that only the first cell has): the weights apply to those, so the divider follows the
// pointer only when the drag is measured the same way. The rect widths carry the gutter and are
// reported separately (`rect=`), they are not what is checked.
// The cells compared are *plot* widths (`axes.w`): the plotter places cells by them and the weights
// share them out, so the divider follows the pointer only when the drag is measured the same way.
// The rect widths also carry the first cell's y gutter, the last cell's right gutter and half a column
// gap; the first cell's excess is reported separately (`gutter0=`), it is not what is checked.
const auto width = [&](int cell) {
(void)rs.figure().layout(); // non-const: re-lays the figure and every plot out on the weights
const plot::PlotLayout& pl = rs.row(cell).plot->layout();
return static_cast<double>(pl.axes.w + pl.right_gutter) / scale2;
return static_cast<double>(rs.row(cell).plot->layout().axes.w) / scale2;
};
const auto rect_width = [&](int cell) {
return static_cast<double>(rs.figure().layout().cells[static_cast<std::size_t>(cell)].w) / scale2;
@@ -4260,8 +4346,15 @@ gboolean split_probe_step(gpointer user) {
std::fabs((w0_min + w1_max) - pair0) <= 1.0 ? 1 : 0;
const int both_above_min = (w0_min >= kMinCellPx - 1.0 && w1_min >= kMinCellPx - 1.0 &&
w0_max >= kMinCellPx - 1.0 && w1_max >= kMinCellPx - 1.0) ? 1 : 0;
// The gesture's offset is relative to the drag handle, which follows the divider: a handle that has moved
// 40 px reports an offset 40 px short, and the helper adds the displacement back. The one piece of
// the drag handler a headless run can reach (a real pointer drag cannot be simulated).
const int handle_comp = std::fabs(layout_col_total_offset(40.0, 140.0, 100.0) - 80.0) < 1e-9 &&
std::fabs(layout_col_total_offset(80.0, 100.0, 100.0) - 80.0) < 1e-9 &&
std::fabs(layout_col_total_offset(-25.0, 70.0, 100.0) + 55.0) < 1e-9
? 1 : 0;
const int ok = left_gt && left_grew && follows_80 && sum_80 && relative && right_floor &&
left_floor && sum_ext && both_above_min ? 1 : 0;
left_floor && sum_ext && both_above_min && handle_comp ? 1 : 0;
// What the rect carries over the plot: the first cell's y gutter (the second has none).
const double gutter0 = rect_width(0) - width(0);
// Leave the drag ended, as the gesture's end does.
@@ -4269,10 +4362,10 @@ gboolean split_probe_step(gpointer user) {
a->layout_col_drag_start.clear();
std::fprintf(stderr,
"[split-probe] col-drag ok=%d left_gt_right=%d follows_80=%d relative=%d "
"sum_80=%d right_floor=%d left_floor=%d sum_ext=%d both_above_min=%d "
"sum_80=%d right_floor=%d left_floor=%d sum_ext=%d both_above_min=%d handle_comp=%d "
"tile=%d d80=[%.1f,%.1f]->[%.1f,%.1f] ext=[%.1f,%.1f|%.1f,%.1f] gutter0=%.1f\n",
ok, left_gt, follows_80, relative, sum_80, right_floor, left_floor, sum_ext,
both_above_min, split_probe_tile(*a), d80.l0, d80.r0, d80.l, d80.r, w0_max,
both_above_min, handle_comp, split_probe_tile(*a), d80.l0, d80.r0, d80.l, d80.r, w0_max,
w1_min, w0_min, w1_max, gutter0);
}
split_probe_next(*a);
@@ -4522,6 +4615,7 @@ gboolean split_probe_step(gpointer user) {
rs.set_cursor_mode(g_shared_y.was_mode);
rs.set_cursors_visible(g_shared_y.was_on);
rebuild_cursors_pane(*a);
split_probe_next(*a);
break;
}
const plot::Range a_rng = ra.plot->y_range(ra.axis_y);
@@ -4544,6 +4638,73 @@ gboolean split_probe_step(gpointer user) {
rs.set_cursor_mode(g_shared_y.was_mode);
rs.set_cursors_visible(g_shared_y.was_on);
rebuild_cursors_pane(*a);
split_probe_next(*a);
break;
}
// --- Steps 23..27: which rows carry an x axis, and the gap between a split row's plots ---
case 23: {
// [2]/[1]: row 0's columns differ from row 1's, so row 0 keeps its own x strip. The view is pinned to
// an absolute 50 ms span (a paused view's shape) so the labels go relative and the `t0` caption is on
// screen for the caption reading; the window is restored at the end.
g_x_axes = XAxesProbe{};
g_x_axes.window_before = rs.time_window();
rs.set_cells_per_row({2, 1});
split_probe_clear_all(*a);
split_probe_x_axes_fill(*a);
rs.pin_pause_window(kXAxesPinLo, kXAxesPinHi);
split_probe_restructured(*a);
split_probe_next(*a);
break;
}
case 24: {
split_probe_x_axes_read(*a);
// [2]/[2], equal weights: the columns match, so row 0 hands its axis to row 1.
rs.set_cells_per_row({2, 2});
split_probe_clear_all(*a);
split_probe_x_axes_fill(*a);
rs.pin_pause_window(kXAxesPinLo, kXAxesPinHi);
split_probe_restructured(*a);
split_probe_next(*a);
break;
}
case 25: {
split_probe_x_axes_read(*a);
// [2]/[2] with weights 1:3 over 1:1: the columns differ, so row 0 is labelled again.
rs.set_cell_weights(0, {1.0f, 3.0f});
rs.set_cell_weights(1, {1.0f, 1.0f});
split_probe_x_axes_fill(*a);
rs.pin_pause_window(kXAxesPinLo, kXAxesPinHi);
split_probe_restructured(*a);
split_probe_next(*a);
break;
}
case 26: {
split_probe_x_axes_read(*a);
// A zoom written by the controller onto the *last* plot's member axis (what a wheel or a drag on its x
// strip does): the pinned view must adopt it, as it adopts one on plot 0. The pin has been held for a
// frame by now, so every member reads as the host's own statement.
const RowSet::Row& last = rs.row(rs.row_count() - 1);
if (last.plot != nullptr) {
last.plot->set_x_range(last.axis_x, plot::Range{kXAxesZoomLo, kXAxesZoomHi});
}
split_probe_next(*a);
break;
}
case 27: {
int gesture = 1;
for (int p = 0; p < rs.row_count(); ++p) {
const RowSet::Row& r = rs.row(p);
if (r.plot == nullptr) continue;
const plot::Range x = r.plot->x_range(r.axis_x);
if (std::fabs(x.min - kXAxesZoomLo) > 1e-6 || std::fabs(x.max - kXAxesZoomHi) > 1e-6) gesture = 0;
}
std::fprintf(stderr,
"[split-probe] x-axes row0_labelled=[%s] bottom_labelled=[%s] gap_ok=[%s] "
"gap_px=[%s] col_gap_px=%.1f caption_ok=[%s] last_plot_zoom=%d "
"(arrangements: [2]/[1], [2]/[2] equal, [2]/[2] 1:3 over 1:1)\n",
g_x_axes.row0.c_str(), g_x_axes.bottom.c_str(), g_x_axes.gap_ok.c_str(),
g_x_axes.gap.c_str(), g_x_axes.col_gap, g_x_axes.note.c_str(), gesture);
rs.set_time_window(g_x_axes.window_before); // unpins and re-arms the live follow
break;
}
default: