diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index 017a35e..d3320de 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -138,9 +138,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear - **Widths are weights with a floor.** The plotter keeps 64 px logical per cell and a column gap (`kColGap`, 6 px); the editor's drag is stricter (below). A weight is a share of what is left *after* the floors, so cells of `{3, 1}` read 533:220 in a 756 px figure, not 3:1, and the - editor's 96 px drag floor is a floor on the *share it asks for*: a cell dragged to the limit realises - `64 + share * remainder` and reads wider (144 px in the split probe's `pre-insert0` line), so the - boundary does not follow the pointer one for one near either extreme. + editor's 96 px drag floor is a floor on the *width*, not on the share. Because a cell's width is + `64 + extra * weight / total` and not proportional to its weight, `layout_col_apply()` does not scale + the pair's weights by the width ratio: it keeps the pair's weight sum `S` from the drag's *start* + (`layout_col_drag_start`) and gives the left cell `S * (want_left - 64) / (pair - 128)`, clamped to + `[0.001, 0.999]` (the plotter refuses non-positive weights; `0.5` if the pair is not wider than 128). + `want_left` is the pointer's width clamped to `[96, pair - 96]` logical px. The divider therefore + follows the pointer, the shrinking cell reads 96 px at the extreme and the pair keeps its total width + (the split probe's `col-drag` line asserts all three). `kPlotterMinCellPx` (64) mirrors the plotter's + `kMinCellWidthLogical`; both are logical px (the plotter scales it by `ui_scale`, which is + `PlotArea::device_scale()`, the divisor used to turn layout rects into logical px), so the two + must change together. - **A cell is removed with its traces, and a new cell is empty.** `insert_cell(fr, at)` pushes the cells at and after `at` right *with their contents* (the same rule as `insert_row()`), and `remove_cell(plot)` on a one-cell row removes the row. Both return false at a cap or when they would diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 3c79b36..f26a0bc 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -2892,8 +2892,8 @@ The other lines, in order: | `invalid-rows` | `ok=1 frows/plots=[5/5 3/3 3/3 3/3]` | one of four unusable `row` sequences (five cells in a row; a skipped row; a row that goes back; a first row that is not 0) that was refused, or kept as a split instead of loading one cell per row | | `editor-on` -> `cell-split` | `cells=[1]` then `cell-split-ck cells=2` and `cell-split` with `widths=[376,376]`, the new `1b` empty | the editor's cell `+` not reaching `insert_cell()`, or the figure not re-pointed after it | | `cell-assigned` | `1a:sine\|1b:square` | an assignment into the new cell that landed on another plot | -| `col-drag` | `left_gt=1 both_above_min=1 left_gt_right=1 tile=1`; `pair_sum` constant (753 at the 756 px window) | the column drag not moving the boundary (`left_gt=0`), a cell dragged below `kMinCellPx` (`both_above_min=0`), or a drag that changes the pair's total instead of redistributing it. The probe applies +80 px, then +/-10000 px through `layout_col_apply()` | -| `pre-insert0` (state line) | after the extreme drags `widths=[144,609]` | not a defect: the drag floors the left share at 96 px of the pair, but the plotter realises `64 + share * remainder`, so a cell dragged to the limit reads wider than 96 (see [invariants-plot-and-layout.md](invariants-plot-and-layout.md)) | +| `col-drag` | `ok=1 left_gt_right=1 follows_80=1 relative=1 sum_80=1 right_floor=1 left_floor=1 sum_ext=1 both_above_min=1 tile=1`, then `d80=[376.5,376.5]->[456.5,296.5]` and `ext=[657.0,96.0\|96.0,657.0]` (all logical px, 753 px pair) | `ok` is the AND of the checks. `follows_80=0`: the drag of +80 did not put the left cell within 1 px of `start + 80` (the weights were taken as proportional to widths and the plotter's 64 px floor ignored -- an old reading was `[442.9,310.1]`); `relative=0`: three updates (+30, +60, +80) from one begin did not land at +80, i.e. the drag read the live weights instead of the begin snapshot `layout_col_drag_start`; `sum_80=0` / `sum_ext=0`: the pair's total changed instead of being redistributed; `right_floor=0` / `left_floor=0`: after +10000 / -10000 the shrinking cell does not read `kMinCellPx` (96) within 1 px; `both_above_min=0`: a cell below 96 px; `tile=1` is `split_probe_tile()`, the same check the state lines print. `left_gt_right` is `d80.l > d80.r` (after +80 from equal halves the left is the wider). The probe goes through `layout_col_drag_begin()` + `layout_col_apply()`, the pair the gesture calls | +| `pre-insert0` (state line) | after the extreme drags `widths=[96,657]` | the last extreme drag was -10000: the left cell sits on the 96 px floor and the right takes the rest of the pair. A left cell reading 144 is the old floor-ignoring weights (`weights=[0.25,1.75]`); correct weights read `[0.1,1.9]` | | `insert0-move` | `move_ok=1 before=[sine,square] after=[,sine,square]` | an insert at the left edge that renumbered the cells instead of moving their traces right with them | | `remove-mid` | `rm_ok=1 after=[,square]` | removing a middle cell that dropped or shifted the wrong neighbour | | `row-weights` | `wt_ok=1 before=[3,1,1] after=[1,1]` | the row weights not following an insert or removal of a *figure row* (the vector had the plot-count length once and drifted off the rows) | diff --git a/src/ui.cpp b/src/ui.cpp index f2a2b0f..e1f5aa8 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -758,30 +758,37 @@ void layout_col_apply(App& app, int plot, double offset_px) { const int fr = rs.figure_row_of(plot); const int k = rs.cell_of(plot); if (fr < 0 || k < 1) return; + // The weights the drag began with: a drag is relative to its start, so reading the live weights here + // would compound with every update. + if (app.layout_col_drag_start.size() != static_cast(rs.cells_in_row(fr))) 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 w = rs.cell_weights(fr); - w.resize(static_cast(rs.cells_in_row(fr)), 1.0f); + // The plotter lays a cell out as `floor + extra * weight / total`, so a width is *not* proportional to + // its weight: the pair's `pair` px are `2 * floor` plus `(pair - 2 * floor)` shared by the two weights. + // Solve for the share that puts the left cell at `want_left`; the pair's weight sum stays what it was, + // which keeps the pair's total width (and every other cell) where it was. + const double spare = pair - 2.0 * kPlotterMinCellPx; + const double frac = spare > 0.0 + ? std::clamp((want_left - kPlotterMinCellPx) / spare, 0.001, 0.999) + : 0.5; + std::vector w = app.layout_col_drag_start; const float sum = w[static_cast(k - 1)] + w[static_cast(k)]; - w[static_cast(k - 1)] = static_cast(sum * want_left / pair); + w[static_cast(k - 1)] = static_cast(sum * frac); w[static_cast(k)] = sum - w[static_cast(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(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; +bool layout_col_drag_begin(App& app, int plot) { + app.layout_col_drag_plot = plot; + if (app.rows == nullptr || plot < 1 || plot >= app.rows->row_count()) return false; const int fr = app.rows->figure_row_of(plot); const int k = app.rows->cell_of(plot); - if (fr < 0 || k < 1) return; + 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; @@ -789,6 +796,13 @@ void on_layout_col_begin(GtkGestureDrag* gesture, double, double, gpointer user) app.layout_col_drag_right0 = static_cast(fl.cells[static_cast(plot)].w) / scale; app.layout_col_drag_start = app.rows->cell_weights(fr); app.layout_col_drag_start.resize(static_cast(app.rows->cells_in_row(fr)), 1.0f); + return true; +} + +void on_layout_col_begin(GtkGestureDrag* gesture, double, double, gpointer user) { + App& app = *static_cast(user); + layout_col_drag_begin( + app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-col-drag"))); } void on_layout_col_update(GtkGestureDrag*, double offset_x, double, gpointer user) { diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 172d9f6..d64141c 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -209,7 +209,8 @@ struct App { int layout_drag_row = -1; std::vector layout_drag_start; /// Column drag state: which plot (right-hand cell), the two starting widths in logical px, - /// and the cell weights snapshot at drag begin. + /// 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; @@ -1622,6 +1623,11 @@ constexpr const char* kLayoutCellKey = "udpscope-layout-cell"; constexpr const char* kLayoutColKey = "udpscope-layout-col"; /// The minimum width a cell may be dragged to, in logical pixels. constexpr double kMinCellPx = 96.0; +/// The plotter's own per-cell floor, `kMinCellWidthLogical` in `deps/plotter/src/plot/figure.cpp` +/// (`place_cells()`): a cell is `floor + extra * weight / total`. Both it and `kMinCellPx` are +/// *logical* px -- the plotter scales its floor by the `ui_scale` the renderer installs, which is +/// `PlotArea::device_scale()`, the same divisor that turns a layout rect into logical px here. +constexpr double kPlotterMinCellPx = 64.0; constexpr gint64 kMeasureRefreshUs = 100 * 1000; constexpr gint64 kMeasureHeavyUs = 5 * 1000; constexpr const char* kCursorModeNames[] = {"vertical", "horizontal", "both", "mix"}; @@ -2110,6 +2116,9 @@ gboolean window_probe(gpointer user); GtkWidget* layout_button(App& a, bool insert, int index); GtkWidget* layout_cell_button(App& a, int fr, int pos); GtkWidget* layout_col_handle(App& a, int plot); +/// Records the starting widths (logical px) and weights of the cell pair a column drag on `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); 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 diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 342b268..c804375 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -3826,20 +3826,15 @@ gboolean layout_probe_step(gpointer user) { // Each step changes the arrangement of cells, re-points the area at the new figure, and lets the // next step (250 ms on) read what a frame was actually laid out with. -void split_probe_report(App& a, const char* tag) { +/// 1 when every row's cells fill its rect edge to edge, share its y and height, and are one `col_gap` +/// apart (and the layout has the cells it should); the readout both the state lines and `col-drag` use. +int split_probe_tile(App& a) { RowSet& rs = *a.rows; - const double scale = (a.area != nullptr && a.area->device_scale() > 0.0f) - ? static_cast(a.area->device_scale()) : 1.0; const plot::FigureLayout& fl = rs.figure().layout(); const int frows = rs.figure_row_count(); - // tile: every row's cells fill its rect edge to edge, share its y and height, and are one - // `col_gap` apart. int tile = fl.cells.size() == static_cast(rs.row_count()) && fl.rows.size() == static_cast(frows) ? 1 : 0; - std::string cells_s; - std::string widths_s; - std::string weights_s; for (int fr = 0; fr < frows && tile; ++fr) { const int first = rs.first_plot_of_row(fr); const int n = rs.cells_in_row(fr); @@ -3857,6 +3852,19 @@ void split_probe_report(App& a, const char* tag) { } } } + return tile; +} + +void split_probe_report(App& a, const char* tag) { + RowSet& rs = *a.rows; + const double scale = (a.area != nullptr && a.area->device_scale() > 0.0f) + ? static_cast(a.area->device_scale()) : 1.0; + const plot::FigureLayout& fl = rs.figure().layout(); + const int frows = rs.figure_row_count(); + const int tile = split_probe_tile(a); + std::string cells_s; + std::string widths_s; + std::string weights_s; for (int fr = 0; fr < frows; ++fr) { const int first = rs.first_plot_of_row(fr); const int n = rs.cells_in_row(fr); @@ -4176,37 +4184,73 @@ gboolean split_probe_step(gpointer user) { } case 12: { split_probe_report(*a, "cell-assigned"); - // Column drag: apply a +80 px offset, verify widths shifted, clamp to extremes. + // Column drag, through the real begin/apply pair: every drag is relative to the state it began + // from, and the divider must follow the pointer (the cell is not proportional to its weight -- + // the plotter floors it -- so the weights are solved for the width). if (rs.cells_in_row(0) >= 2) { - const plot::FigureLayout& fl2 = rs.figure().layout(); const double scale2 = (a->area != nullptr && a->area->device_scale() > 0.0f) ? static_cast(a->area->device_scale()) : 1.0; - a->layout_col_drag_plot = rs.first_plot_of_row(0) + 1; - a->layout_col_drag_left0 = static_cast(fl2.cells[0].w) / scale2; - a->layout_col_drag_right0 = static_cast(fl2.cells[1].w) / scale2; - a->layout_col_drag_start = rs.cell_weights(0); - a->layout_col_drag_start.resize(static_cast(rs.cells_in_row(0)), 1.0f); - layout_col_apply(*a, rs.first_plot_of_row(0) + 1, 80.0); - // Read back widths. - const plot::FigureLayout& fl3 = rs.figure().layout(); - const double w0 = static_cast(fl3.cells[0].w) / scale2; - const double w1 = static_cast(fl3.cells[1].w) / scale2; - const int left_gt = w0 > w1 ? 1 : 0; - // Extreme clamp test: +10000 px and -10000 px. - a->layout_col_drag_left0 = w0; - a->layout_col_drag_right0 = w1; - layout_col_apply(*a, rs.first_plot_of_row(0) + 1, 10000.0); - const double w0_max = static_cast(rs.figure().layout().cells[0].w) / scale2; - const double w1_min = static_cast(rs.figure().layout().cells[1].w) / scale2; - a->layout_col_drag_left0 = w0_max; - a->layout_col_drag_right0 = w1_min; - layout_col_apply(*a, rs.first_plot_of_row(0) + 1, -10000.0); - const double w0_min = static_cast(rs.figure().layout().cells[0].w) / scale2; - const double w1_max = static_cast(rs.figure().layout().cells[1].w) / scale2; - const int both_above_min = (w0_min >= kMinCellPx - 1.0 && w1_min >= kMinCellPx - 1.0) ? 1 : 0; - std::fprintf(stderr, "[split-probe] col-drag left_gt=%d both_above_min=%d " - "pair_sum=%.0f left_gt_right=%d tile=1\n", - left_gt, both_above_min, w0_min + w1_max, left_gt); + const int plot1 = rs.first_plot_of_row(0) + 1; + const auto width = [&](int cell) { + return static_cast(rs.figure().layout().cells[static_cast(cell)].w) / scale2; + }; + // One drag of `dx` logical px from the arrangement the figure is in now, then the widths read + // back from the layout (the figure re-lays out on the weights, not on a frame). + struct Pair { double l0, r0, l, r; }; + const auto drag = [&](double dx) { + Pair p{}; + layout_col_drag_begin(*a, plot1); + p.l0 = width(0); + p.r0 = width(1); + layout_col_apply(*a, plot1, dx * scale2); + p.l = width(0); + p.r = width(1); + return p; + }; + // A drag of +80 from the start: the left cell grows by 80 and the pair keeps its total. + const Pair d80 = drag(80.0); + const int left_gt = d80.l > d80.r ? 1 : 0; + const int left_grew = d80.l > d80.l0 ? 1 : 0; + const int follows_80 = std::fabs(d80.l - (d80.l0 + 80.0)) <= 1.0 ? 1 : 0; + const int sum_80 = std::fabs((d80.l + d80.r) - (d80.l0 + d80.r0)) <= 1.0 ? 1 : 0; + // The same drag, again: it is relative to its start, so this +80 must land where the last did + // (a drag that read the live weights would compound). + layout_col_drag_begin(*a, plot1); + const double l_start = width(0); + const double r_start = width(1); + layout_col_apply(*a, plot1, 30.0 * scale2); + layout_col_apply(*a, plot1, 60.0 * scale2); + layout_col_apply(*a, plot1, 80.0 * scale2); + const int relative = std::fabs(width(0) - (l_start + 80.0)) <= 1.0 && + std::fabs(width(1) - (r_start - 80.0)) <= 1.0 ? 1 : 0; + layout_col_apply(*a, plot1, 0.0); + // Extreme drags, each from the settled start: the shrinking cell reads the 96 px floor. + layout_col_drag_begin(*a, plot1); + const double pair0 = width(0) + width(1); + layout_col_apply(*a, plot1, 10000.0 * scale2); + const double w0_max = width(0); + const double w1_min = width(1); + layout_col_apply(*a, plot1, -10000.0 * scale2); + const double w0_min = width(0); + const double w1_max = width(1); + const int right_floor = std::fabs(w1_min - kMinCellPx) <= 1.0 ? 1 : 0; + const int left_floor = std::fabs(w0_min - kMinCellPx) <= 1.0 ? 1 : 0; + const int sum_ext = std::fabs((w0_max + w1_min) - pair0) <= 1.0 && + 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; + const int ok = left_gt && left_grew && follows_80 && sum_80 && relative && right_floor && + left_floor && sum_ext && both_above_min ? 1 : 0; + // Leave the drag ended, as the gesture's end does. + a->layout_col_drag_plot = -1; + 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 " + "tile=%d d80=[%.1f,%.1f]->[%.1f,%.1f] ext=[%.1f,%.1f|%.1f,%.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, + w1_min, w0_min, w1_max); } split_probe_next(*a); break;