fix: only a window change sets the time scale; a fit states the whole of it

The time scale is more than the axis: it is also the trigger's event span, the
buffer partition and the control's readout. A zoom or a pan was already clean --
the controller writes the member axis and `clamp_to_data()` caps the span -- but
both fits moved `window_s_` alone and only restated the control. The event span
then stayed on the previous window until the next decade step or unit change
applied the fitted value for real, so one action changed the time scale twice.

`apply_window_scale()` is split out of `apply_time_window()` for the fits to use:
it moves the partition, the event span, the control and the session, and leaves
the view alone, because `set_time_window()` re-arms the live follow and would
throw a held event or a paused view back onto the stream. The fits report the
span they framed, `fit_all()` delegates its x half to `fit_x()` so the two cannot
drift, and the probe drives the menu handlers rather than `RowSet` so it sees the
half that lives there. `[fit-probe]` gained `event_span=`, which must equal
`window=` on every line.

Also moves the plotter pin to 03c41ad (three build warnings, no behaviour).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 16:12:59 +02:00
1 parent 1f6738031a
commit 9888fd9977
7 files changed
+128 -63

No files matched your search

+36 -13
View File
@@ -449,13 +449,18 @@ Two lines of the self-check output are assertions in disguise:
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
the absolute form.
- `[fit-probe] <action> window=.. x=[..] span=.. y=[..]` (stderr, `UDPSCOPE_PROBE_FIT_MS`) —
the two fits, through the same `RowSet` calls the right-click menu wires. It first puts
the y scale and the window where the data is *not* (a fixed `[-100, 100]`, a 50 ms
window), so the assertions have something to be wrong about: **`fit x` must widen the
window to the data's span and leave `y` exactly as it was**, and **`fit all` must then
move `y` to the data's own range**. A `y` that changed on the `fit x` line is the two
paths aliased.
- `[fit-probe] <action> window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
`RowSet` calls, because half of what a fit now does lives there (see the time-scale invariant
below). It first puts the y scale and the window where the data is *not* (a fixed
`[-100, 100]`, a 50 ms window), so the assertions have something to be wrong about: **`fit x`
must widen the window to the data's span and leave `y` exactly as it was**, and **`fit all`
must then move `y` to the data's own range**. A `y` that changed on the `fit x` line is the two
paths aliased. **`event_span` must equal `window` on every line**: a fit states a new time
scale, so the trigger's event span, the buffer partition and the control have to move with it —
a fit that moved the view alone left `event_span` on the *old* window, and the next decade step
or unit change then applied the fitted value for real, so one action changed the time scale
twice.
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
@@ -1356,12 +1361,30 @@ far the largest file.
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
on a mere change of window. That is also why the window is typed into a popover rather
than shown in the bar.
- **`apply_time_window()` is the only place the display window changes.** It clamps to
`window_limit()` and moves `RowSet::set_time_window()` and `ScopeModel::set_event_span()`
together, because the trigger's pre-trigger share is a share of *this* window. Paths that
change the span for their own reasons (Fit, an event pin) call `sync_window_display()`
instead, which only restates the control and never moves the plot — calling `apply_` there
would clamp a legitimately short capture up to the requested window.
- **A plot interaction may not quietly redefine the time scale.** The *time scale* is more than
the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the trigger's
pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()` (which may
re-plan the partition, move the decimation and drop history), the window control's readout and
the session file. A **zoom or a pan therefore does not touch it at all** — the controller
writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so a gesture is
view-only by construction and needs no code to keep it that way. The **two fits do** state a
new scale, and they must state the whole of it: `on_fit_clicked`/`on_fit_x_clicked` take the
span `RowSet::fit_x()`/`fit_all()` report and hand it to **`apply_window_scale()`**. A fit that
moved `window_s_` alone — which is what both of them used to do — left the event span on the
*old* window, and the next decade step or unit change then fed the fitted value in for real:
one action, two changes of time scale, and `window == event_span` true at neither point.
- **`apply_window_scale()` is the only place the time scale changes, and `apply_time_window()`
is the only place the *view* changes with it.** The first clamps to `window_limit()` and moves
`set_target_span()`, `set_event_span()`, the control and the session save together; the second
calls it and then `RowSet::set_time_window()`. The split is not cosmetic:
`set_time_window()` unpins and re-arms the live follow, which is right for a typed window (a
statement about what to watch from now on) and wrong for a fit of a held event or a paused
view, which has nothing bound to a live edge — so the fits take the scale half only, and
`fit_x()` writes the link group directly while `pinned_`. The partition is moved **before** the
axis, so the axis is framed over the rings that will exist. Paths that change the span for
their own reasons (an event pin) still call `sync_window_display()` alone, which only restates
the control and never moves the plot — calling either `apply_` there would clamp a legitimately
short capture up to the requested window.
- **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,