diff --git a/README.md b/README.md index 6a83df2..e06ee7c 100644 --- a/README.md +++ b/README.md @@ -2832,10 +2832,13 @@ also share **one y range**, the way the rows share one x window: the row has one drawn with its labels at the left of the first plot only, one pair of horizontal rulers drawn at the same value in every plot, and one fit. A wheel, a drag, a box zoom, **Fit all** and the Auto/Fixed dialog act on the whole row whichever plot they land in, and an auto-scaled row fits the union of its plots' traces (a plot -with none is ignored). Only the **bottom** row's plots carry the time labels, and the left margin +with none is ignored). The **bottom** row's plots carry the time labels, and so does any row above it whose +columns differ from the row below's (the `t0 = ...` caption goes with the labels, on each plot that draws +them); a row whose columns match the next one's leaves its labels to it. The left margin holding the y labels is one width for the whole figure, so the plots of a row line up with the rows -above and below it; a plot whose labels are narrower simply has more margin. Because only the first plot -has a margin, the plots of a row get equal plot areas at equal weights. +above and below it; a plot whose labels are narrower simply has more margin. Only the first plot has a left +margin and only the last a right one, so the plots of a row get equal plot areas at equal weights and +neighbouring plots are exactly one column gap apart. In the **Layout editor** (`l`): @@ -2843,7 +2846,7 @@ In the **Layout editor** (`l`): empty plot is added at that position and the plots after it move right *with their traces*. A one-cell row can be split from either side. The `+` is dim at 4 plots in the row or 8 plots in all (the row `+`, which adds a whole row, dims at 8 plots). -- A **drag arrow between two plots** resizes the two neighbours against each other; neither can be +- A **double-headed (left-right) arrow between two plots** resizes the two neighbours against each other; neither can be dragged below about 96 px. The plotter keeps 64 px as an absolute floor, and when the window is too narrow for that it shrinks every plot proportionally and drops the gap, so nothing has a negative size. The widths are saved when the drag ends. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index ce4a89e..f589b91 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -81,7 +81,7 @@ up to **4** plots, and the figure up to **8** in all. - **To split a row**, click the `+` at its left or right edge, or the `+` between two of its plots. The new plot is empty and the plots after it keep their traces. The `+` is dim at 4 plots in the row or 8 in the figure. -- **To resize**, drag the arrow between two plots left or right. The two neighbours trade width (each +- **To resize**, drag the double-headed arrow between two plots left or right. The two neighbours trade width (each stays at least about 96 px wide); the sizes are saved with the session. - **To remove a plot**, click its `×`. In a split row it removes just that plot and its traces; in a row of one plot it removes the row. diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 0a551e0..2214ac6 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -51,12 +51,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear how the first version of that line earned them. What stays in the menu is what acts on the *data* (Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list is the `signals` slide-over page, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text. -- **Five of this app's icons are its own, and they are a `GResource` compiled into the +- **An overlay handle that follows what it drags reports gesture offsets in its own moving + coordinates; add its displacement back.** A `GtkGestureDrag` measures its offset from where the + press began *in the widget's own coordinates*. The layout editor's column handle sits on the + divider and `sync_layout_editor()` moves it every frame, so after a few motion events the + offset is the pointer's travel **minus** the handle's travel, and the divider converges on half + the pointer's travel (`D = P - D`). Record the handle's x in the overlay's coordinates at + begin (`gtk_widget_compute_point`) and add `handle_now - handle_begin` to the offset on every update + (`layout_col_total_offset()`). The same applies to any future handle that is repositioned + from the state its own drag changes. +- **Six of this app's icons are its own, and they are a `GResource` compiled into the binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a measurement ruler, and the stock names those buttons used to carry said something else entirely: a *record dot* for the trigger and three different **magnifiers** for cursors, - measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows}-symbolic.svg` - are the five action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last + measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,resize-cols}-symbolic.svg` + are the six action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it -- an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is the Preferences window's icon and the **Dock toggle's** (the ruler: the dock is the readouts). diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index a2e13f3..ae4dd27 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -157,13 +157,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear (`kColGap`, 6 px); the editor's drag is stricter (below). The floors and the weights act on **plot widths**: the row's y gutter (carried by the first cell) is taken off the row width first, then the cells share the rest, so equal weights give equal plot areas and the first cell's rect is its gutter plus its plot width. A weight is a share of what is - left *after* the gutter and the floors, so cells of `{3, 1}` read 546:207 (rects) in a 756 px + left *after* the gutter and the floors, so cells of `{3, 1}` read 524:229 (rects) in a 756 px figure, not 3:1, and the editor's 96 px drag floor is a floor on the plot *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` and the pair are plot widths (`layout_col_drag_begin` subtracts the first cell's `y_axis_width + y_title_width` from its rect); `want_left` is the pointer's width clamped to `[96, pair - 96]` logical px. The divider therefore + `want_left` and the pair are plot widths (`layout_col_drag_begin` reads each plot's `layout().axes.w`, after a `layout()` on the current figure, so the gutter and the inter-axes gap are never counted as weight); `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 @@ -176,9 +176,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear **The editor's controls** (`rebuild_layout_editor()`): a cell `+` (`kLayoutCellKey`, tooltip "split here") at a row's left edge, at its right edge and at every internal boundary -- `n + 1` of them for a row of `n` cells, so a one-cell row can be split from either side -- inserting at that position; a drag - arrow (`kLayoutColKey`, `go-next-symbolic`) at each internal boundary that moves the boundary between + double-headed arrow (`kLayoutColKey`, `udpscope-resize-cols-symbolic`) at each internal boundary that moves the boundary between its two neighbours, **each floored at `kMinCellPx` = 96 px logical** (or half the pair when the pair is - narrower) and measured from the two widths at drag start, so the drag does not compound; and the per-plot + narrower) and measured from the two widths at drag start, so the drag does not compound. **The handle follows the divider it + drags, so the gesture's offset shrinks by the handle's own displacement**: `on_layout_col_update` adds it back + (`layout_col_total_offset(offset, handle_x_now, handle_x_at_begin)`, the handle's x in overlay coordinates, stored by + `on_layout_col_begin` in `layout_col_drag_handle_x0`) before multiplying by the scale; without it the divider settles at half the pointer's travel. The per-plot `×`, which removes the cell in a split row ("remove this plot") and the row in a one-cell row ("remove this row"), insensitive when it would leave no plot. Cell `+` is insensitive at 4 cells in its row or 8 plots in all; the row `+` is insensitive at 8 plots. **The cell `+` is keyed by `kLayoutCellKey` (figure row * 8 + position + 1) and diff --git a/docs/claude/invariants-time-axis-and-window.md b/docs/claude/invariants-time-axis-and-window.md index ec681cf..e1e26e4 100644 --- a/docs/claude/invariants-time-axis-and-window.md +++ b/docs/claude/invariants-time-axis-and-window.md @@ -73,8 +73,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because the caption has to name the anchor precisely enough that label + caption is the exact absolute - time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom - row** of the figure carries one, every other row being set to `""`; and the renderer measures it + time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only a plot + whose x strip draws labels** (`plot::x_strip_has_labels(x_strip_mode())`: the bottom row, and any row above it whose + columns differ from the row below's) carries one, every other plot being set to `""`; and the renderer measures it rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is no rect to read back, and a caption explaining the x labels must not be able to move the layout those labels were measured into. The guards are the library's @@ -86,7 +87,8 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the **pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower* than the requested window. It must write the per-row **member** axes as well as the link group: - `hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it + `hold_pinned_window()` prefers a member that differs from `pinned_stated_` (it scans **every** + row's member, in order, since a labelled strip on a row other than the first takes x gestures too), which is how it tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so the two cannot drift apart. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index b13a868..254abcf 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -488,7 +488,9 @@ Two lines of the self-check output are assertions in disguise: those means the resource path was never registered. That failure is otherwise *silent*: GTK substitutes a missing-image placeholder, the button still works, and every other line of the dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this - is the only check that it names something that exists. + is the only check that it names something that exists. The line ends with `resize_rows=udpscope-resize-rows-symbolic(ok) + resize_cols=udpscope-resize-cols-symbolic(ok)`: the two layout-editor handle icons, which no toolbar button carries but + which are the same resource and fail the same silent way. - `dock: shown=1 sensitive=1 w=280 scroller_pages=1` (in the exit dump, beside `panels:`) -- the sidebar's dock as the last tick found it. There is no tab switcher: the sidebar is one scroller (`dock_scroller`) holding the **readouts box only** -- a compact, read-only copy of the cursors and of the measurements, each with an Edit @@ -2853,19 +2855,20 @@ inside the panel does not close it), `click_outside_closes=1` (a click on the pl ``` [split-probe] start plots=1 frows=1 cells=[1] widths=[756] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1:sine,square,ramp,noise] -[split-probe] split plots=2 frows=1 cells=[2] widths=[403,350] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1a:sine|1b:square] -[split-probe] weights 3:1 plots=2 frows=1 cells=[2] widths=[546,207] tile=1 x_equal=1 cursors_aligned=1 weights=[3,1] traces=[1a:sine|1b:square] +[split-probe] split plots=2 frows=1 cells=[2] widths=[388,365] tile=1 x_equal=1 cursors_aligned=1 weights=[-] traces=[1a:sine|1b:square] +[split-probe] weights 3:1 plots=2 frows=1 cells=[2] widths=[524,229] tile=1 x_equal=1 cursors_aligned=1 weights=[3,1] traces=[1a:sine|1b:square] [split-probe] caps-calls insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8 [split-probe] roundtrip rt=1 expect=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] got=r0(h=2.0000)[w=1.0000:sine] r1(h=1.0000)[w=3.0000:square][w=1.0000:ramp] [split-probe] ruler-labels split=[row 1,row 2] mixed=[row 0,row 2] -[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[642.4,642.4] auto=1 +[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[671.599976,671.599976] auto=1 +[split-probe] x-axes row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] gap_px=[3.0/3.0,3.0/3.0,3.0/3.0] col_gap_px=3.0 caption_ok=[1,1,1] last_plot_zoom=1 ``` -`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 23 steps, 250 ms apart; +`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 28 steps (0..27), 250 ms apart; each restructures and the *next* step reports the frame that was laid out with it, so a line's label names the arrangement the line **reads**, not the call that made it. The state lines share one format: `plots` (`row_count()`), `frows` (`figure_row_count()`), `cells=[..]` (per figure row), `widths=[..]` -(logical pixels of each cell rect, `|` between rows; the first cell's rect includes the row's y gutter, ~52 px here, and the others have none, so equal weights read `[403,350]` -- equal *plot* widths, not equal rects), `weights=[..]` (the stored cell weights, `-` for a row with +(logical pixels of each cell rect, `|` between rows; the first cell's rect includes the row's y gutter, ~52 px here, and the others have none, so equal weights read `[388,365]` -- equal *plot* widths, not equal rects; the 15 px by which this moved from `[403,350]` is the plotter giving the row's right gutter to the last cell only and spacing adjacent axes by one `col_gap`, both of which widen the plots), `weights=[..]` (the stored cell weights, `-` for a row with none), `traces=[label:signals|..]` with the labels `plot_label()` gives (`1`, `1a`, `2b`). Every state line carries the same three assertions: @@ -2879,22 +2882,22 @@ The other lines, in order: | line | value it must read | the defect a wrong reading names | | --- | --- | --- | -| `start` / `split` | `cells=[1]` then `[2]` with `widths=[403,350]` (equal plot widths of 350.4 px; the first rect is 52 px wider by the y gutter it alone carries) and the traces on `1a`/`1b`; a second cell starts **empty**, the probe assigns it | a split that moved or dropped the first cell's traces, or unequal default plot widths (`[376,376]` is the pre-sharing reading: a second cell that still has its own gutter) | -| `weights 3:1` | `widths=[546,207]`, **not** 3:1, and `tile=1` | not a defect: the row's y gutter and the plotter's 64 px floor per plot are taken off first and the weights share the remainder, so the shares read compressed; a result of exactly 3:1 would be the floor missing | +| `start` / `split` | `cells=[1]` then `[2]` with `widths=[388,365]` (equal plot widths; the first rect is 52 px wider by the y gutter it alone carries) and the traces on `1a`/`1b`; a second cell starts **empty**, the probe assigns it | a split that moved or dropped the first cell's traces, or unequal default plot widths (`[376,376]` is the pre-sharing reading: a second cell that still has its own gutter) | +| `weights 3:1` | `widths=[524,229]`, **not** 3:1, and `tile=1` | not a defect: the row's y gutter and the plotter's 64 px floor per plot are taken off first and the weights share the remainder, so the shares read compressed; a result of exactly 3:1 would be the floor missing | | `row+cell` | `cells=[2,2]`, `weights=[3,1\|-]` | row 0's cell weights lost or moved when a row and a cell were inserted below it (they belong to the figure row) | | `caps-calls` | `insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8` | a cap that is not reached or not held: row 0 starts with two cells and takes two more, refusing the third call at 4; two rows then fit before the plot total reaches 8 and the third is refused; `plots` must stop at **8** | | `caps` | `plots=8 frows=4 cells=[4,2,1,1]`, `tile=1` | a ninth plot, or a fifth cell in a row | | `remove` | `plots=7 cells=[3,2,1,1]` | `remove_cell()` on a multi-cell row that removed the row, or that dropped a neighbour's traces | | `roundtrip-io` | `wrote=1 read=1 plots_in_file=3` | a save that was refused, or a file that lost a `[[plot]]` table | -| `roundtrip` (state line) | `cells=[1,2] widths=[756\|546,207] weights=[-\|3,1]` and the three traces on `1`, `2a`, `2b` | the arrangement did not survive `collect_layout()` -> file -> `apply_layout()` | +| `roundtrip` (state line) | `cells=[1,2] widths=[756\|524,229] weights=[-\|3,1]` and the three traces on `1`, `2a`, `2b` | the arrangement did not survive `collect_layout()` -> file -> `apply_layout()` | | `roundtrip rt=` | **`rt=1`** with `got=` equal to `expect=` (row weights `h=`, cell weights `w=` and each cell's trace, read back at the file's `%.4f`) | **`rt=0`** names the difference: a weight written per plot index instead of per figure row, widths applied before the arrangement (which resets them), or a trace that moved to another cell | | `legacy-io` | `read=1 plots_in_file=3 row_unset=1` | the stripped file still carrying a `row`, which would make the next line test nothing | | `legacy` | `cells=[1,1,1]`, `frows=3`, `widths=[756\|756\|756]`, `traces=[1:sine\|2:square\|3:ramp]` | a session from before the split that did not open as one cell per row, or lost its signals | | `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=[403,350]`, the new `1b` empty | the editor's cell `+` not reaching `insert_cell()`, or the figure not re-pointed after it | +| `editor-on` -> `cell-split` | `cells=[1]` then `cell-split-ck cells=2` and `cell-split` with `widths=[388,365]`, 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` | `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=[350.4,350.4]->[430.4,270.4]`, `ext=[604.8,96.0\|96.0,604.8]` and `gutter0=52.2` (all logical **plot** widths, `axes.w` plus the right gutter of the two plots, measured after a `layout()` on the new weights; 700.8 px pair) | `ok` is the AND of the checks. The compared widths are plot widths and not cell rects, because only the first cell carries the y gutter (`gutter0=` is its rect width minus its plot width, printed so a reader can tell the two apart): a drag that equates the rect widths moves the divider ~17 px more than the pointer (the gutter's share is treated as weight; `d80=[350.4,350.4]->[447.6,253.2]`, +97.2 for +80) and reaches the floor at 93.3 plot px instead of 96 (`ext=[607.5,93.3\|93.3,607.5]`, still the 700.8 px plot pair; in rect widths, which add the 52.2 px gutter to the left cell, the same wrong drag reads a 145.5 px left rect and a 753.0 px pair, `[659.7,93.3\|145.5,607.5]`), which reads `follows_80=0 relative=0 left_floor=0 right_floor=0 both_above_min=0`. `gutter0=` itself should read the y gutter (about 52 px; 0 means the first cell lost its gutter, a value near the cell width means the plot area collapsed). `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=[148,605]`, `weights=[0.111732,1.88827]` | the last extreme drag was -10000: the left *plot* sits on the 96 px floor (the rect reads 148 = 96 plus the 52 px y gutter) and the right takes the rest of the pair. A left rect reading 146 (`widths=[146,607]`, `weights` near `0.1024`) is the 93.3 px plot floor plus the 52.2 px gutter: the drag was measuring cell rects (the gutter's share treated as weight) and not plot widths, so the left plot stopped short of the 96 px floor. The pair still sums to 753 in the baseline and in that reading, so it is the floor, not the sum, that names the defect | +| `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 handle_comp=1 tile=1`, then `d80=[335.8,335.8]->[415.8,255.8]`, `ext=[575.6,96.0\|96.0,575.6]` and `gutter0=52.2` (all logical **plot** widths, each plot's `layout().axes.w`, measured after a `layout()` on the new weights; 671.6 px pair) | `ok` is the AND of the checks. The compared widths are plot widths and not cell rects, because only the first cell carries the y gutter (`gutter0=` is its rect width minus its plot width, printed so a reader can tell the two apart): a drag that equates the rect widths moves the divider more than the pointer (the gutter's share is treated as weight) and reaches the floor short of 96 plot px, which reads `follows_80=0 relative=0 left_floor=0 right_floor=0 both_above_min=0`. The widths are `axes.w` and not `rect.w - y_axis_width - y_title_width`: the latter also counts the right gutter and the gap between axes, so once the plotter spaced adjacent axes by one `col_gap` and gave the right gutter to the last cell only, the old measure read `ok=0 d80=[335.8,365.0]->[397.9,302.9]` (the pair came out 700.8 instead of 671.6 and the divider followed ~62 for +80). `gutter0=` itself should read the y gutter (about 52 px; 0 means the first cell lost its gutter, a value near the cell width means the plot area collapsed). `follows_80=0`: the drag of +80 did not put the left cell within 1 px of `start + 80`; `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). `handle_comp=1`: the pure `layout_col_total_offset(offset, handle_now, handle_begin)` adds the handle's displacement back (`(40,140,100)` and `(80,100,100)` read 80, `(-25,70,100)` reads -55); `0` is a gesture that would leave the divider lagging behind the pointer (it converges on half the travel). The probe goes through `layout_col_drag_begin()` + `layout_col_apply()`, the pair the gesture calls; **a real pointer drag is not exercised headlessly**, so the handle's position read in `on_layout_col_update` is covered only by this helper | +| `pre-insert0` (state line) | after the extreme drags `widths=[148,605]`, `weights=[0.117734,1.88227]` | the last extreme drag was -10000: the left *plot* sits on the 96 px floor (the rect reads 148 = 96 plus the 52 px y gutter) and the right takes the rest of the pair. The weights follow from the same 96 px floor over the 671.6 px pair: `S * (96 - 64) / (671.6 - 128)` with `S = 2` (it read `0.111732` while the pair was 700.8 px). A left rect reading 146 (`widths=[146,607]`) is a 93.3 px plot floor plus the 52.2 px gutter: the drag was measuring cell rects (the gutter's share treated as weight) and not plot widths, so the left plot stopped short of the 96 px floor | | `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) | @@ -2905,7 +2908,8 @@ The other lines, in order: | `assign-1b` / `drop-2b` | `assigned=1 notice='plotting 'sine' on plot 1b'`; `dropped=1 notice='plotting 'square' on plot 2b'` | a notice that names the plot by number alone | | `after-assign` (state line) | `traces=[1a:-\|1b:sine\|2a:-\|2b:square]` | the signal went to a neighbouring cell | | `ruler-labels` | `split=[row 1,row 2]` and, with cells `{1,2}`, `mixed=[row 0,row 2]` | the cursors pane's ruler lines (a label read through the pane's own widgets): **one line per figure row**, because the cells of a split row share their y axis and so their rulers. A split row is `row
` and sits on its first cell; an unsplit row keeps `row ` (**0-based**, as it always was -- `row 0` is the first plot, not a defect). One line per cell (`plot 1a,plot 1b,...`) is the pre-sharing reading | -| `shared-y` | `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, `ranges=[A=-3..7 B=-3..7 \| fit A=B]`, `axes_w` equal to within a pixel, `auto=1` | a row of two cells with a trace each. `y_shared`: a `set_y_range(-3, 7)` on cell A is B's after one frame. `y_gutter`: only cell A has a y gutter (B's `y_axis_width` and `y_label_area.w` are 0) and the two plots are equally wide. `y_fit`: `fit_all()` leaves both cells on one range that covers both traces (not the manual one). `ruler_mirror`: a ruler written on A and read back by `sync_cursors(0)` is B's too. A 0 in the first two is `set_share_row_y(true)` not reaching the figure; in `y_fit`, no frame between `fit_all()` and the read (the probe restructures to pump one) or a group that did not follow the flag | +| `shared-y` | `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, `ranges=[A=-3..7 B=-3..7 \| fit A=B]`, `axes_w` equal to within a pixel (both `671.6`; `642.4` was the reading before the plotter gave the right gutter to the last cell only), `auto=1` | a row of two cells with a trace each. `y_shared`: a `set_y_range(-3, 7)` on cell A is B's after one frame. `y_gutter`: only cell A has a y gutter (B's `y_axis_width` and `y_label_area.w` are 0) and the two plots are equally wide. `y_fit`: `fit_all()` leaves both cells on one range that covers both traces (not the manual one). `ruler_mirror`: a ruler written on A and read back by `sync_cursors(0)` is B's too. A 0 in the first two is `set_share_row_y(true)` not reaching the figure; in `y_fit`, no frame between `fit_all()` and the read (the probe restructures to pump one) or a group that did not follow the flag | +| `x-axes` | `row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] caption_ok=[1,1,1] last_plot_zoom=1`, `gap_px` equal on both sides of each `/` and equal to `col_gap_px` | three arrangements, read in order: `{2}/{1}`, `{2}/{2}` and `{2}/{2}` with cell weights 1:3 over 1:1, every plot on channel 0 and the view pinned to an absolute span (`pin_pause_window(100.0, 100.05)`), because the live axis is `[-window, 0]` and never relative. `row0_labelled`: `x_strip_has_labels(x_strip_mode())` of row 0's plots, a row draws labels only if its columns differ from the row below's, so `[1,0,1]` (differs, equal columns, differs by weight); `bottom_labelled`: the bottom row always labels. `gap_ok` / `gap_px`: `axes[k+1].x - axes[k].right()` against `col_gap` within 0.5 px, in the logical px the line prints; a different gap is the plotter's inter-axes spacing changed or a cell that still carries its own right gutter. `caption_ok`: a plot has a non-empty `x_note` (the `t0 = ...` caption) **exactly when** its strip draws labels (`-1` means the labels were not relative, so the caption could not be checked: the pin did not take); `0` is `update_time_labels()` putting the caption on the bottom row only, so a labelled row above reads no `t0`. `last_plot_zoom`: after a narrower x range is written onto the last plot's member (a gesture on a labelled strip that is not row 0) every plot adopts it, `0` is `hold_pinned_window()` looking at row 0 only and reverting the gesture. The step restores the window with `set_time_window()` afterwards | The run ends with the usual `gtk warnings: 0` and no `Gtk-CRITICAL`. `x_equal=1` and `cursors_aligned=1` on every state line are the only readings of the shared time window across cells. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 8377202..d72b145 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -36,8 +36,8 @@ E-1608 -- reads as before; a difference there is a finding, not the feature. Tak as a jitter control before believing one (a few mock readings, such as the sine's membership of the cursors mix list or a V/mV flip, move between two runs of the *same* build). A clean build emits **one** warning and no more: a `-Woverlength-strings` on the icons' -generated GResource `.c`, whose blob is **9248** against C99's 4095-byte string-literal limit with -the five action icons and the two app icons that remain (a note about a generated file, which GCC +generated GResource `.c`, whose blob is **10146** against C99's 4095-byte string-literal limit with +the six action icons and the two app icons that remain (a note about a generated file, which GCC itself supports). History: the help pages' generated `.c` (**516044** bytes) went when the manual became a Mallard set installed for Yelp; an unused `output_built` in `math_probe()` was deleted as dead code; and the last two, `-Wmissing-field-initializers` on `MathNode::source` in `math_probe()`, @@ -205,8 +205,8 @@ precedent, not an exception. with, so a synchronous sequence would read every position one action stale. It clicks by emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its 250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed. -- `UDPSCOPE_PROBE_SPLIT_MS` — drive the horizontal plot split, 23 steps 250 ms apart (so a run needs - `UDPSCOPE_PROBE_SPLIT_MS=1500 UDPSCOPE_EXIT_AFTER_MS=9500` at least; it enables and assigns the +- `UDPSCOPE_PROBE_SPLIT_MS` — drive the horizontal plot split, 28 steps 250 ms apart (so a run needs + `UDPSCOPE_PROBE_SPLIT_MS=1500 UDPSCOPE_EXIT_AFTER_MS=10500` at least; it enables and assigns the signals itself, so `UDPSCOPE_ENABLE_ALL_MS` is not needed). Each step restructures the cells, re-points the area at the new figure and lets the *next* step read a frame that was actually laid out with them, which is why every report line describes the previous step's change. It splits and diff --git a/help/C/org.udpscope.app/layout.page b/help/C/org.udpscope.app/layout.page index 25c48d4..6ad40ae 100644 --- a/help/C/org.udpscope.app/layout.page +++ b/help/C/org.udpscope.app/layout.page @@ -47,7 +47,7 @@ + between two plots, to add a plot to that row. The new plot starts empty.

-

Drag the arrow on the boundary between two plots to resize them. Neither plot can be +

Drag the double-headed arrow on the boundary between two plots to resize them. Neither plot can be made narrower than about 96 px, and the widths are saved with the session.

diff --git a/src/icons/scalable/actions/udpscope-resize-cols-symbolic.svg b/src/icons/scalable/actions/udpscope-resize-cols-symbolic.svg new file mode 100644 index 0000000..8a56f2e --- /dev/null +++ b/src/icons/scalable/actions/udpscope-resize-cols-symbolic.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/src/icons/udpscope.gresource.xml b/src/icons/udpscope.gresource.xml index b8e6e77..4b556ca 100644 --- a/src/icons/udpscope.gresource.xml +++ b/src/icons/udpscope.gresource.xml @@ -22,6 +22,7 @@ scalable/actions/udpscope-measure-symbolic.svg scalable/actions/udpscope-events-symbolic.svg scalable/actions/udpscope-resize-rows-symbolic.svg + scalable/actions/udpscope-resize-cols-symbolic.svg scalable/apps/org.udpscope.app.svg scalable/apps/org.udpscope.app-symbolic.svg diff --git a/src/plot_host.cpp b/src/plot_host.cpp index 8ea6eb2..3ddb812 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -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 diff --git a/src/ui.cpp b/src/ui.cpp index e098a24..690a2f9 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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(cell)].w) - gutter); + return r.plot != nullptr ? std::max(0.0, static_cast(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(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(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(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{"resize_rows", "udpscope-resize-rows-symbolic"}, + std::pair{"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. diff --git a/src/ui_app.hpp b/src/ui_app.hpp index be333a9..7ce88d2 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -209,12 +209,17 @@ struct App { int layout_drag_row = -1; std::vector 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 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 `|` diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index dcce21e..3153597 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -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(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(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(fl.col_gap)) > 0.5) gap_ok = 0; + } + } + if (pairs == 0) gap_ok = 0; + g_x_axes.col_gap = static_cast(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(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(pl.axes.w + pl.right_gutter) / scale2; + return static_cast(rs.row(cell).plot->layout().axes.w) / scale2; }; const auto rect_width = [&](int cell) { return static_cast(rs.figure().layout().cells[static_cast(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: