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

+7 -4
View File
@@ -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 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 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 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 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 above and below it; a plot whose labels are narrower simply has more margin. Only the first plot has a left
has a margin, the plots of a row get equal plot areas at equal weights. 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`): 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 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 `+`, 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). 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 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. 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. The widths are saved when the drag ends.
+1 -1
View File
@@ -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 - **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 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. 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. 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 - **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. of one plot it removes the row.
+12 -3
View File
@@ -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* 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 (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. 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 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 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, 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` measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,resize-cols}-symbolic.svg`
are the five action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last 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 -- 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 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). the Preferences window's icon and the **Dock toggle's** (the ruler: the dock is the readouts).
+7 -4
View File
@@ -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 (`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 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 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 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 `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* 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 (`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). `[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 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 (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 `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 **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 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 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 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 `×`, 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 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 plots in all; the row `+` is insensitive at 8 plots. **The cell `+` is keyed by `kLayoutCellKey` (figure row * 8 + position + 1) and
@@ -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 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 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 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 time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only a plot
row** of the figure carries one, every other row being set to `""`; and the renderer measures it 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 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 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 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 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* **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: 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 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 range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
the two cannot drift apart. the two cannot drift apart.
+17 -13
View File
@@ -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 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 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 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 - `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 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 **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] 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] 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=[546,207] tile=1 x_equal=1 cursors_aligned=1 weights=[3,1] 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] 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] 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] 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 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: 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=[..]` `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 none), `traces=[label:signals|..]` with the labels `plot_label()` gives (`1`, `1a`, `2b`). Every state line
carries the same three assertions: 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 | | 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) | | `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=[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 | | `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) | | `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-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 | | `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 | | `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-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 | | `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-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 | | `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 | | `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 | | `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 | | `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.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 | | `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 | | `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 | | `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) | | `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 | | `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 | | `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 <figure row, from 1>` and sits on its first cell; an unsplit row keeps `row <plot index>` (**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 | | `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 <figure row, from 1>` and sits on its first cell; an unsplit row keeps `row <plot index>` (**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 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. the only readings of the shared time window across cells.
+4 -4
View File
@@ -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 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). 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' 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 generated GResource `.c`, whose blob is **10146** 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 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 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 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()`, 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 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 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. 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` — drive the horizontal plot split, 28 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=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, 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 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 with them, which is why every report line describes the previous step's change. It splits and
+1 -1
View File
@@ -47,7 +47,7 @@
<gui>+</gui> between two plots, to add a plot to that row. The new plot starts empty.</p> <gui>+</gui> between two plots, to add a plot to that row. The new plot starts empty.</p>
</item> </item>
<item> <item>
<p>Drag the arrow on the boundary between two plots to resize them. Neither plot can be <p>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.</p> made narrower than about 96 px, and the widths are saved with the session.</p>
</item> </item>
<item> <item>
@@ -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-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-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-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.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
</gresource> </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. // 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_); 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); 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 // The caption belongs with the labels, so it goes on every plot whose x strip draws them: the
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing. // bottom row's, and any row above it that carries a strip of its own (a split row whose columns
const bool bottom = i >= first_plot_of_row(figure_row_count() - 1); // differ from the row below's; see `Figure`'s x-axis rule). The plots without labels get an empty
if (!relative_x_ || !bottom) { // one, which draws nothing.
if (!relative_x_ || !plot::x_strip_has_labels(r.plot->x_strip_mode())) {
r.plot->set_x_note(""); r.plot->set_x_note("");
continue; 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 // 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. // 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_); plot::Range now = figure_->x_link_range(x_group_);
if (rows_ > 0 && row(0).plot != nullptr) { // Any plot's member may carry the gesture: the controller writes the plot under the pointer, and an x
const plot::Range member = row(0).plot->x_range(row(0).axis_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 && const bool member_is_ours = std::abs(member.min - pinned_stated_.min) < 1e-12 &&
std::abs(member.max - pinned_stated_.max) < 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(); 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 // **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 ----------------------------------------------- // --- column (cell) resize drag -----------------------------------------------
// The two cells' widths here are *plot* widths (a cell's rect without the first cell's y gutter): the // The two cells' widths here are *plot* widths -- `layout().axes.w`, the framed plotting area, which is
// 96 px floor and the plotter's 64 px floor both apply to them, and the weights share them out. // 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) { void layout_col_apply(App& app, int plot, double offset_px) {
RowSet& rs = *app.rows; RowSet& rs = *app.rows;
const int fr = rs.figure_row_of(plot); 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 fr = app.rows->figure_row_of(plot);
const int k = app.rows->cell_of(plot); const int k = app.rows->cell_of(plot);
if (fr < 0 || k < 1) return false; if (fr < 0 || k < 1) return false;
const plot::FigureLayout& fl = app.rows->figure().layout();
const double scale = const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0; 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 // The weights share out *plot* widths, so the pair is measured as plots: each cell's `axes.w`, the
// gutter (axis strip + title column) that only the first cell of a row has. The left cell of the pair // plotting area the plotter places a cell by. Not the cell rect: that adds the y gutter of the first
// is the first one when `plot - 1` starts the row; the right cell never has a gutter, and neither does // cell, the right gutter of the last and half a column gap, none of which the weights apply to.
// a non-first left cell (its plot's strips read 0). (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 auto plot_width = [&](int cell) {
const RowSet::Row& r = app.rows->row(cell); const RowSet::Row& r = app.rows->row(cell);
const double gutter = return r.plot != nullptr ? std::max(0.0, static_cast<double>(r.plot->layout().axes.w)) : 0.0;
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);
}; };
app.layout_col_drag_left0 = plot_width(plot - 1) / scale; app.layout_col_drag_left0 = plot_width(plot - 1) / scale;
app.layout_col_drag_right0 = plot_width(plot) / 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; 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) { void on_layout_col_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
App& app = *static_cast<App*>(user); App& app = *static_cast<App*>(user);
layout_col_drag_begin( layout_col_drag_begin(
app, GPOINTER_TO_INT(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-col-drag"))); 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); 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) { 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_plot = -1;
app.layout_col_drag_left0 = 0.0; app.layout_col_drag_left0 = 0.0;
app.layout_col_drag_right0 = 0.0; app.layout_col_drag_right0 = 0.0;
app.layout_col_drag_handle_x0 = 0.0;
app.layout_col_drag_start.clear(); app.layout_col_drag_start.clear();
schedule_layout_save(app); schedule_layout_save(app);
} }
@@ -948,7 +973,7 @@ void rebuild_layout_editor(App& app) {
const int first = app.rows->first_plot_of_row(fr); const int first = app.rows->first_plot_of_row(fr);
for (int k = 1; k < n; ++k) { for (int k = 1; k < n; ++k) {
const int plot = first + 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_halign(b, GTK_ALIGN_START);
gtk_widget_set_valign(b, GTK_ALIGN_START); gtk_widget_set_valign(b, GTK_ALIGN_START);
g_object_set_data(G_OBJECT(b), kLayoutColKey, GINT_TO_POINTER(plot)); 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 + "=" + app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + name + "=" +
icon + (gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)"); 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 // 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 // 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. // 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; int layout_drag_row = -1;
std::vector<float> layout_drag_start; std::vector<float> layout_drag_start;
/// Column drag state: which plot (right-hand cell), the two starting *plot* widths in logical px /// 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 /// (each plot's `layout().axes.w`, the area the plotter places a cell by -- not the cell rect, which
/// snapshot at drag begin (`layout_col_apply()` redistributes the pair's weight sum from these, so /// adds the y and right gutters and the gap), and the cell weights snapshot at drag begin
/// a drag stays relative to where it started). /// (`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; int layout_col_drag_plot = -1;
double layout_col_drag_left0 = 0.0; double layout_col_drag_left0 = 0.0;
double layout_col_drag_right0 = 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; 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'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 /// 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. /// (the right-hand cell) begins with. False when `plot` has no left neighbour.
bool layout_col_drag_begin(App& app, int plot); bool layout_col_drag_begin(App& app, int plot);
void layout_col_apply(App& app, int plot, double offset_px); 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); 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 /// 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 `|` /// 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; 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) { void split_probe_clear_all(App& a) {
for (int r = 0; r < RowSet::kMaxRows; ++r) { for (int r = 0; r < RowSet::kMaxRows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a.rows->assign(r, s, "", "", 0); 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) const double scale2 = (a->area != nullptr && a->area->device_scale() > 0.0f)
? static_cast<double>(a->area->device_scale()) : 1.0; ? static_cast<double>(a->area->device_scale()) : 1.0;
const int plot1 = rs.first_plot_of_row(0) + 1; 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 cells compared are *plot* widths (`axes.w`): the plotter places cells by them and the weights
// the y gutter that only the first cell has): the weights apply to those, so the divider follows the // share them out, so the divider follows the pointer only when the drag is measured the same way.
// pointer only when the drag is measured the same way. The rect widths carry the gutter and are // The rect widths also carry the first cell's y gutter, the last cell's right gutter and half a column
// reported separately (`rect=`), they are not what is checked. // gap; the first cell's excess is reported separately (`gutter0=`), it is not what is checked.
const auto width = [&](int cell) { const auto width = [&](int cell) {
(void)rs.figure().layout(); // non-const: re-lays the figure and every plot out on the weights (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>(rs.row(cell).plot->layout().axes.w) / scale2;
return static_cast<double>(pl.axes.w + pl.right_gutter) / scale2;
}; };
const auto rect_width = [&](int cell) { const auto rect_width = [&](int cell) {
return static_cast<double>(rs.figure().layout().cells[static_cast<std::size_t>(cell)].w) / scale2; 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; 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 && 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; 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 && 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). // 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); const double gutter0 = rect_width(0) - width(0);
// Leave the drag ended, as the gesture's end does. // 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(); a->layout_col_drag_start.clear();
std::fprintf(stderr, std::fprintf(stderr,
"[split-probe] col-drag ok=%d left_gt_right=%d follows_80=%d relative=%d " "[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", "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, 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); w1_min, w0_min, w1_max, gutter0);
} }
split_probe_next(*a); 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_cursor_mode(g_shared_y.was_mode);
rs.set_cursors_visible(g_shared_y.was_on); rs.set_cursors_visible(g_shared_y.was_on);
rebuild_cursors_pane(*a); rebuild_cursors_pane(*a);
split_probe_next(*a);
break; break;
} }
const plot::Range a_rng = ra.plot->y_range(ra.axis_y); 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_cursor_mode(g_shared_y.was_mode);
rs.set_cursors_visible(g_shared_y.was_on); rs.set_cursors_visible(g_shared_y.was_on);
rebuild_cursors_pane(*a); 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; break;
} }
default: default: