fix: a fit undoes an x zoom instead of redefining the time window

The previous commit took "fit x sets the window properly" to mean a fit should
frame the data and propagate that as the new time scale. It should not: a fit is
a plot interaction, and the window is also the trigger's event span and how the
samples are stored, so a fit that framed the data could re-plan the rings and
drop the history on a menu click. What a fit means here is the undo of a zoom --
a 2 s window zoomed into reads 2 s again.

`fit_x()` therefore reads `window_s_` and never writes it. Live it takes the
window-control path with the window already set, which re-arms the follow and
parks the newest span at the live edge. Pinned -- a held event, a filling capture
or a paused view -- it restores the pin's full range, which is the snapshot's own
span and may be narrower than the requested window, and it writes the per-row
member axes as well as the link group: `hold_pinned_window()` prefers a member
that differs from `pinned_stated_`, so writing the group alone is reverted to the
zoomed range one frame later. `apply_window_scale()` is folded back into
`apply_time_window()`, which the window control is again the only caller of.

The probe now zooms in before fitting, since otherwise there is nothing to undo,
and reports `pinned=`. `window` and `event_span` must be unchanged on every line.

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:26:37 +02:00
1 parent 9888fd9977
commit 3d7e214295
7 files changed
+193 -150

No files matched your search

+52 -35
View File
@@ -449,18 +449,28 @@ 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 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 caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
the absolute form. the absolute form.
- `[fit-probe] <action> window=.. event_span=.. x=[..] span=.. y=[..]` (stderr, - `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare `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 `RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
below). It first puts the y scale and the window where the data is *not* (a fixed of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed
`[-100, 100]`, a 50 ms window), so the assertions have something to be wrong about: **`fit x` `[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter
must widen the window to the data's span and leave `y` exactly as it was**, and **`fit all` of it the way a gesture does — on the *member* axis — and fits. Four assertions.
must then move `y` to the data's own range**. A `y` that changed on the `fit x` line is the two **`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit
paths aliased. **`event_span` must equal `window` on every line**: a fit states a new time is a plot interaction and may not restate the time scale (see the invariant below), and they are
scale, so the trigger's event span, the buffer partition and the control have to move with it — the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the
a fit that moved the view alone left `event_span` on the *old* window, and the next decade step window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put
or unit change then applied the fitted value for real, so one action changed the time scale `span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the
twice. same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two
paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion:
the follow is re-armed on the newest data, which is what the whole window means there.
Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`),
which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must
restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the
requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and
must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
back as the zoomed range one frame later.
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]` - `[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 (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 up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
@@ -1361,30 +1371,37 @@ far the largest file.
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it 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 on a mere change of window. That is also why the window is typed into a popover rather
than shown in the bar. than shown in the bar.
- **A plot interaction may not quietly redefine the time scale.** The *time scale* is more than - **No plot interaction changes the time scale; only the window control does.** The *time scale*
the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the trigger's is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the
pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()` (which may trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()`
re-plan the partition, move the decimation and drop history), the window control's readout and (which may re-plan the partition, move the decimation and **drop the history**), the window
the session file. A **zoom or a pan therefore does not touch it at all** — the controller control's readout and the session file. A **zoom or a pan** does not touch it by construction —
writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so a gesture is the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so
view-only by construction and needs no code to keep it that way. The **two fits do** state a nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the
new scale, and they must state the whole of it: `on_fit_clicked`/`on_fit_x_clicked` take the *undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads
span `RowSet::fit_x()`/`fit_all()` report and hand it to **`apply_window_scale()`**. A fit that `window_s_` without ever writing it. Both fits used to frame the *data* instead and move
moved `window_s_` alone — which is what both of them used to do — left the event span on the `window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and
*old* window, and the next decade step or unit change then fed the fitted value in for real: an event span left on the *old* window until the next decade step or unit change applied the
one action, two changes of time scale, and `window == event_span` true at neither point. fitted value for real (one action, two changes of time scale).
- **`apply_window_scale()` is the only place the time scale changes, and `apply_time_window()` - **`apply_time_window()` is the only place the time scale changes**, and the window control is
is the only place the *view* changes with it.** The first clamps to `window_limit()` and moves its only caller. It clamps to `window_limit()` and moves `set_target_span()`,
`set_target_span()`, `set_event_span()`, the control and the session save together; the second `set_event_span()`, `RowSet::set_time_window()`, the control and the session save together —
calls it and then `RowSet::set_time_window()`. The split is not cosmetic: the partition **before** the axis, so the axis is framed over the rings that will exist. A fit
`set_time_window()` unpins and re-arms the live follow, which is right for a typed window (a must not come here for two reasons: it is not entitled to the scale at all, and
statement about what to watch from now on) and wrong for a fit of a held event or a paused `set_time_window()` unpins and re-arms the live follow, which would throw a held event or a
view, which has nothing bound to a live edge — so the fits take the scale half only, and paused view onto a live edge it has nothing bound to. Paths that change the span for their own
`fit_x()` writes the link group directly while `pinned_`. The partition is moved **before** the reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
axis, so the axis is framed over the rings that will exist. Paths that change the span for never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
their own reasons (an event pin) still call `sync_window_display()` alone, which only restates capture up to the requested window.
the control and never moves the plot — calling either `apply_` there would clamp a legitimately - **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.**
short capture up to the requested window. Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same
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
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.
- **The header bar's buttons are icon-only**, so headless probes must reach them - **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`, `find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
+20 -14
View File
@@ -983,9 +983,9 @@ that changes nothing still says why. Each plot's y axis has a right-click menu
with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
**Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on **Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on
it says so instead). The two fits are the same pair the overflow menu and the `f` key it says so instead). The two fits are the same pair the overflow menu and the `f` key
offer, split by what they touch: **Fit X axis** frames the window on the data the ring offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
holds and leaves every y scale exactly as the user set it, **Fit all** does that and window and leaves every y scale exactly as the user set it, **Fit all** does that and
auto-scales the y axes too. auto-scales the y axes too. Neither changes the time window itself — see below.
**The plot does not twitch.** Two independent causes were fixed, both of which **The plot does not twitch.** Two independent causes were fixed, both of which
showed up as the x axis moving a few pixels every frame — including while paused, showed up as the x axis moving a few pixels every frame — including while paused,
@@ -1276,18 +1276,24 @@ logged with both rates and the new anti-alias corner. A window change that leave
limitations below); only the shared clock is decimated. limitations below); only the shared clock is decimated.
**Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu) **Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu)
auto-scales every row's y axis and re-frames the x window on the data the ring holds, auto-scales every row's y axis to the data it holds and puts the x view back on the whole time
propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for window, propagated to every row. **Fit X axis** is that x half alone, for the common case where
the common case where the window should be the data's but the levels were placed by hand. a zoom should be undone but the levels were placed by hand. The status line acknowledges either
The status line acknowledges either (`x fitted to the buffered data (1.25 s)`). (`x back to the 2 s window`).
Either fit **states a new time window**, and states the whole of it: the span it framed goes **Nothing you do on the plot changes the time window** — only the window control does. A fit is
through the same door a typed window does, so the trigger's event span, the buffer partition and the *undo of a zoom*: a 2 s window zoomed into reads 2 s again. That matters because the window
the window control all land on the fitted value and the control reads it back. A **zoom or a pan is not just the axis. It is also the trigger's event span, how the samples are stored (a window
never does** — a gesture moves the view inside the window and leaves the window alone, which is long enough to move the decimation re-plans the rings and **drops the history**), the control's
what keeps a wheel from silently re-planning the buffer and dropping the history. The one thing a readout and what the session file records — so a wheel notch or a Fit that redefined it would
fit does *not* borrow from the typed path is the live re-follow: fitting a held event or a paused quietly throw the buffer away. A zoom and a pan are view-only by construction; the fits read the
view keeps it pinned rather than throwing it back onto the stream. window and never write it.
Which 2 s depends on the state, and the fit restores the whole of whatever the view is allowed to
show: live, the newest window at the live edge; and for a held event or a paused view, the pin's
full range — the snapshot's own span, which is legitimately *narrower* than the requested window
when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it
back onto the stream.
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one **Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group. `plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
+3
View File
@@ -19,6 +19,9 @@ unit, width-locked so a change of window cannot widen the header bar.
value. Stamping at the end of a group would shift every trace half a stored sample late. value. Stamping at the end of a group would shift every trace half a stored sample late.
• Moving the window across a decimation boundary rebuilds the buffer and drops the history. • Moving the window across a decimation boundary rebuilds the buffer and drops the history.
Walking it inside one decade is free. Walking it inside one decade is free.
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
notch that redefined the window could re-plan the buffer and throw your samples away.
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge • The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
found on a sample that is not in the buffer could not be cut out of it. found on a sample that is not in the buffer could not be cut out of it.
+5 -4
View File
@@ -19,10 +19,11 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is • A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data. outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
• A gesture never changes the <i>time window</i>, only the view inside it. The window is what the • Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
header control states, and it also sets the trigger's event span and how the buffer is stored — zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
so a wheel that redefined it would re-plan the buffer and drop your history. The two fits zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
<i>do</i> set it, deliberately, and the control then reads the fitted value back. the trigger's event span and how the samples are stored, so a wheel notch that redefined it
would re-plan the buffer and drop your history.
• A live view is clamped to the window you asked for, not to what the ring happens to hold. • A live view is clamped to the window you asked for, not to what the ring happens to hold.
That is why a buffer that has only just started filling leaves its left-hand side empty That is why a buffer that has only just started filling leaves its left-hand side empty
instead of squeezing the window down to the first few samples. instead of squeezing the window down to the first few samples.
+24 -23
View File
@@ -657,24 +657,27 @@ void RowSet::unpin_pause_window() {
set_time_window(window_s_); set_time_window(window_s_);
} }
bool RowSet::fit_x(double& span) { void RowSet::fit_x() {
Row& first = row(0); if (!figure_) return;
if (!first.plot || !figure_) return false; if (!pinned_) {
first.plot->auto_fit_x(); // Live: the same path the window control and a resume take, with the window it already has.
const plot::Range fitted = first.plot->x_range(first.axis_x); // That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
const double fitted_span = fitted.span(); // whole of what a live view may show.
if (!std::isfinite(fitted_span) || !(fitted_span > 0.0)) return false; set_time_window(window_s_);
if (pinned_) { return;
// Fitting a captured or paused view: the window is the snapshot's own span, and
// `set_time_window()` would re-arm the follow and re-frame on a live edge that a frozen
// view has nothing bound to.
window_s_ = fitted_span;
figure_->set_x_link_range(x_group_, fitted);
} else {
set_time_window(fitted_span);
} }
span = window_s_; // Pinned (a held event or a paused view): the pin's full range, and the pin stays. The
return true; // *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
// member that has moved since our own last statement -- that is how it tells a gesture from its
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
const plot::Range pin{-pinned_pre_, pinned_post_};
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (r.plot) r.plot->set_x_range(r.axis_x, pin);
}
pinned_stated_ = pin;
figure_->set_x_link_range(x_group_, pin);
} }
void RowSet::sync_cursors(int drag_row) { void RowSet::sync_cursors(int drag_row) {
@@ -788,16 +791,14 @@ void RowSet::pin_event_window(double pre_s, double post_s) {
figure_->set_x_link_range(x_group_, {-pre_s, post_s}); figure_->set_x_link_range(x_group_, {-pre_s, post_s});
} }
bool RowSet::fit_all(double& span) { void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) { for (int i = 0; i < rows_; ++i) {
Row& r = row(i); Row& r = row(i);
if (r.plot) r.plot->auto_fit_y(r.axis_y); if (r.plot) r.plot->auto_fit_y(r.axis_y);
} }
// The x half is `fit_x()`'s exactly -- row 0's data span, stated through the link group so // The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
// every row lands on it -- and sharing it is what keeps the two fits from drifting apart. // sharing it is what keeps the two fits from drifting apart.
// The y fit above has already happened either way: a row 0 that holds nothing still gets fit_x();
// every row's y scaled, it just has no span to report.
return fit_x(span);
} }
void RowSet::note_y_gutter(double need_px) { void RowSet::note_y_gutter(double need_px) {
+21 -17
View File
@@ -298,17 +298,24 @@ class RowSet {
void set_data_pin(double lo, double hi); void set_data_pin(double lo, double hi);
void unpin_pause_window(); void unpin_pause_window();
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x /// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the /// The x counterpart of `fit_all()`.
/// window should be.
/// ///
/// Reports the span it framed in `span` and `true` when it framed one at all (row 0 may hold /// **It does not change the time window** -- `window_s_` is read, never written. A fit is a
/// no data, and a degenerate range is refused). **The caller owes the rest of the time /// plot interaction, and the window is also the trigger's event span, the buffer partition and
/// scale**: this moves the view and `window_s_`, and the trigger's event span, the buffer /// the control's readout, so a fit that *framed the data* instead would redefine all of that
/// partition and the window control are the caller's to move with it -- see /// from a gesture (and re-plan the rings, dropping the history). What a user asks for here is
/// `apply_window_scale()` in `ui.cpp`. Leaving them behind is what made a fit and the control /// the zoom undone: a 2 s window zoomed into reads 2 s again.
/// disagree about what the window was. ///
bool fit_x(double& span); /// Which 2 s depends on the state, and both cases restore the *whole* of what the view is
/// allowed to show: live, the newest window at the live edge; pinned (a held event or a paused
/// view), the pin's full range, with the pin kept -- `set_time_window()` would clear it and
/// re-frame on a live edge that a frozen view has nothing bound to.
void fit_x();
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
/// `fit_x()` branches on, which is why the fit probe reports it.
bool window_pinned() const noexcept { return pinned_; }
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds /// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
/// a cursor), and nothing otherwise. /// a cursor), and nothing otherwise.
@@ -343,13 +350,10 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible. /// 0, so nothing outside the pre+post window is visible.
void pin_event_window(double pre_s, double post_s); void pin_event_window(double pre_s, double post_s);
/// Fits every row's y axis to the data it holds, and re-frames the shared x window /// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// on what row 0 retains (the same thing the plot's own `f` key does). /// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
/// /// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
/// Reports the framed x span exactly as `fit_x()` does, and leaves the caller the same debt: void fit_all();
/// the y fit is this object's business alone, but the x span is the *time scale*, and the
/// trigger's event span, the buffer partition and the window control have to move with it.
bool fit_all(double& span);
/// Reports the left gutter the rows currently need, and reserves at least the width /// Reports the left gutter the rows currently need, and reserves at least the width
/// of the widest *plausible* y label. /// of the widest *plausible* y label.
+68 -57
View File
@@ -1865,19 +1865,20 @@ void sync_window_display(App& app, double seconds) {
app.updating_panels = false; app.updating_panels = false;
} }
/// Everything the time scale consists of *except* the view: the buffer partition, the trigger's /// The one place the time scale changes. Clamps to what the buffer can hold, moves the plot and
/// event span, the control's readout and the session. Split out of `apply_time_window()` because /// the trigger's event span together -- its pre-trigger share is a share of *this* window -- and
/// a **fit** frames the axis itself -- and when the view is pinned it must keep the pin, which /// then restates the result in the control, so a clamped request reads back as the window that
/// `RowSet::set_time_window()` would clear, throwing a frozen picture onto the live stream -- but /// was actually taken rather than the one that was typed.
/// a fit still has to move all of this.
/// ///
/// That is the rule: a plot interaction may not quietly redefine the window. A zoom and a pan /// **Only the window control comes here.** The time scale is more than the axis: it is also the
/// never touch it (they move the axis, and `clamp_to_data()` caps them at `window_s_`); the two /// trigger's event span, the buffer partition (so a change of decimation drops the history) and
/// fits do set it, and so must come through here. A fit that moved `window_s_` alone -- which is /// the session, so a *plot interaction* may not state one. A zoom and a pan never touch it by
/// what both of them used to do -- left the trigger's event span on the *old* window, and the /// construction -- they move the row's axis, and `clamp_to_data()` caps the span at `window_s_` --
/// next decade step or unit change then fed the fitted value in for real, so the time scale /// and the two fits do not either: `RowSet::fit_x()` undoes a zoom by restoring the window that
/// changed twice for one action and the `window == event_span` invariant held at neither point. /// is already set. Both fits used to frame the data instead and move `window_s_` alone, which
void apply_window_scale(App& app, double seconds) { /// left the event span on the *old* window until the next decade step or unit change applied the
/// fitted value for real: one action, two changes of time scale.
void apply_time_window(App& app, double seconds) {
if (!(seconds > 0.0)) return; if (!(seconds > 0.0)) return;
const double clamped = std::min(std::max(seconds, kWindowMinS), window_limit(app)); const double clamped = std::min(std::max(seconds, kWindowMinS), window_limit(app));
// The buffer has to cover the window, so this may re-plan the partition and drop the // The buffer has to cover the window, so this may re-plan the partition and drop the
@@ -1891,24 +1892,12 @@ void apply_window_scale(App& app, double seconds) {
app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND;
} }
app.model->set_event_span(clamped); app.model->set_event_span(clamped);
// The partition before the axis, so the axis is framed over the rings that will exist.
app.rows->set_time_window(clamped);
sync_window_display(app, clamped); sync_window_display(app, clamped);
schedule_layout_save(app); schedule_layout_save(app);
} }
/// The one place the display window changes *on the control's behalf*. Clamps to what the buffer
/// can hold, moves the plot and the trigger's event span together -- its pre-trigger share is a
/// share of *this* window -- and then restates the result in the control, so a clamped request
/// reads back as the window that was actually taken rather than the one that was typed.
void apply_time_window(App& app, double seconds) {
if (!(seconds > 0.0)) return;
const double clamped = std::min(std::max(seconds, kWindowMinS), window_limit(app));
// The partition first, so the axis below is framed over the rings that will exist.
apply_window_scale(app, clamped);
// And the view, which unpins and re-arms the live follow -- right for a typed window, which
// is a statement about what to watch from now on, and the reason a fit does not come here.
app.rows->set_time_window(clamped);
}
/// Enter in the entry. A number that will not parse restores the current window instead of /// Enter in the entry. A number that will not parse restores the current window instead of
/// reporting an error: the entry is also the readout, so leaving junk in it would leave the /// reporting an error: the entry is also the readout, so leaving junk in it would leave the
/// control disagreeing with the plot. /// control disagreeing with the plot.
@@ -4067,28 +4056,25 @@ void on_signals_toggled(GtkToggleButton* button, gpointer user) {
gtk_toggle_button_get_active(button)); gtk_toggle_button_get_active(button));
} }
/// Fit: auto-scale every row's y axis and re-frame the x window on the data the /// Fit: auto-scale every row's y axis to the data it holds, and put x back on the whole time
/// ring holds (the plot's own `f` semantics), then apply that span as the time scale. /// window.
/// ///
/// `apply_window_scale()` and not `sync_window_display()`: a fitted x span *is* a new window, so /// No `apply_time_window()` here, deliberately: a fit is a plot interaction and may not restate
/// the trigger's event span and the buffer partition move with it. Restating the control alone /// the time scale (see that function). The y axes are fitted to the data; x is only un-zoomed.
/// left those on the previous window.
void on_fit_clicked(GtkButton*, gpointer user) { void on_fit_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user); App* app = static_cast<App*>(user);
double span = 0.0; app->rows->fit_all();
if (app->rows->fit_all(span)) apply_window_scale(*app, span); app->notice = "y fitted to the data, x back to the " +
app->notice = "fitted to the buffered data (" + format_window(app->rows->time_window()) + ")"; format_window(app->rows->time_window()) + " window";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
} }
/// Fit the x window only: the y axes keep the scales the user set. The counterpart of the /// Reset the x zoom only: the y axes keep the scales the user set. The counterpart of the other
/// other fit, for the common case where the interesting range is the data's but the levels /// fit, for the common case where a zoom should be undone but the levels were placed by hand.
/// were placed by hand.
void on_fit_x_clicked(GtkButton*, gpointer user) { void on_fit_x_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user); App* app = static_cast<App*>(user);
double span = 0.0; app->rows->fit_x();
if (app->rows->fit_x(span)) apply_window_scale(*app, span); app->notice = "x back to the " + format_window(app->rows->time_window()) + " window";
app->notice = "x fitted to the buffered data (" + format_window(app->rows->time_window()) + ")";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
} }
@@ -7200,20 +7186,21 @@ void cursors_probe_report(App& a, const char* what) {
gboolean cursors_probe_step(gpointer user); gboolean cursors_probe_step(gpointer user);
/// The two fits the right-click menu offers, driven through the handlers the menu wires /// The two fits the right-click menu offers, driven through the handlers the menu wires
/// (a null button: neither handler looks at it). `fit x` must narrow the window to the /// (a null button: neither handler looks at it). Both must put x back on the whole time window
/// data's own span and leave the y ranges alone; `fit all` must then move the y ranges too. /// and **leave the window itself alone**; `fit x` must leave the y ranges alone too, and
/// `fit all` must move them to the data's.
void fit_probe_report(App& a, const char* what) { void fit_probe_report(App& a, const char* what) {
const RowSet::Row& r0 = a.rows->row(0); const RowSet::Row& r0 = a.rows->row(0);
const plot::Range x = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{}; const plot::Range x = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x) : plot::Range{};
const plot::Range y = r0.plot != nullptr ? r0.plot->y_range(r0.axis_y) : plot::Range{}; const plot::Range y = r0.plot != nullptr ? r0.plot->y_range(r0.axis_y) : plot::Range{};
// `event_span` is the assertion that a fit moved the whole time scale and not just the view: // `window` and `event_span` are the assertion that a fit did *not* restate the time scale:
// it is the trigger's, written only by `apply_window_scale()`, so a fit that moved `window` // both are written only by `apply_time_window()`, so they must hold the typed value on every
// alone shows up here as the two disagreeing. // line, with `span` the reading that moves -- down on the zoom, back up on the fit.
std::fprintf(stderr, std::fprintf(stderr,
"[fit-probe] %-10s window=%.6f event_span=%.6f x=[%.6f..%.6f] span=%.6f " "[fit-probe] %-10s pinned=%d window=%.6f event_span=%.6f x=[%.6f..%.6f] span=%.6f "
"y=[%.6f..%.6f]\n", "y=[%.6f..%.6f]\n",
what, a.rows->time_window(), a.model->event_span(), x.min, x.max, x.span(), y.min, what, a.rows->window_pinned() ? 1 : 0, a.rows->time_window(), a.model->event_span(),
y.max); x.min, x.max, x.span(), y.min, y.max);
std::fflush(stderr); std::fflush(stderr);
} }
@@ -7290,33 +7277,57 @@ gboolean timelabels_probe_step(gpointer user) {
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
/// Zooms row 0 in to a quarter of what it shows, the way a gesture does -- on the *member* axis,
/// which `Figure::layout()` adopts into the group. A fit is the undo of this, so the probe has to
/// perform it or it has nothing to undo.
void fit_probe_zoom_in(App& a) {
RowSet::Row& r0 = a.rows->row(0);
if (r0.plot == nullptr) return;
const plot::Range x = r0.plot->x_range(r0.axis_x);
const double mid = x.min + 0.5 * x.span();
const double half = 0.125 * x.span();
r0.plot->set_x_range(r0.axis_x, {mid - half, mid + half});
}
gboolean fit_probe_step(gpointer user) { gboolean fit_probe_step(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE; if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_fit_step) { switch (a->dbg_fit_step) {
case 0: case 0:
// Move the y scale off the data's range first, so "fit x leaves y alone" is a // Move the y scale off the data's range first, so "fit x leaves y alone" is a statement
// statement with something to be wrong about. // with something to be wrong about -- and set the window through the control's own path,
// since that is the only thing allowed to, and its value is what both fits must preserve.
a->rows->set_y_range(0, -100.0, 100.0); a->rows->set_y_range(0, -100.0, 100.0);
a->rows->set_time_window(0.05); // Not while pinned: `apply_time_window()` unpins, and the pinned branch of the fit is the
// other half of what this probe is for. Timed inside a pause (`UDPSCOPE_PROBE_PAUSE_MS`)
// it takes the window the pause froze and reports `pinned=1`.
if (!a->rows->window_pinned()) apply_time_window(*a, 2.0);
fit_probe_report(*a, "start"); fit_probe_report(*a, "start");
g_timeout_add(400, fit_probe_step, a); g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 1; a->dbg_fit_step = 1;
break; break;
case 1: case 1:
// The handlers themselves, not `RowSet::fit_x()` alone: the whole point of the fit_probe_zoom_in(*a);
// `event_span=` reading below is that a fit moves the *time scale* and not just the view,
// and that half lives in the handler.
on_fit_x_clicked(nullptr, a);
g_timeout_add(400, fit_probe_step, a); g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 2; a->dbg_fit_step = 2;
break; break;
case 2: case 2:
fit_probe_report(*a, "fit x"); // A quarter of the window: what the fits have to undo, and the reading that proves the
on_fit_clicked(nullptr, a); // `span=` on the fit lines is the fit's work rather than a window nothing ever left.
fit_probe_report(*a, "zoomed");
// The handlers themselves, not `RowSet::fit_x()` alone: the window the fit must *not*
// move is the app's, and the notice and the control are the handler's half.
on_fit_x_clicked(nullptr, a);
g_timeout_add(400, fit_probe_step, a); g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 3; a->dbg_fit_step = 3;
break; break;
case 3:
fit_probe_report(*a, "fit x");
fit_probe_zoom_in(*a);
on_fit_clicked(nullptr, a);
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 4;
break;
default: default:
fit_probe_report(*a, "fit all"); fit_probe_report(*a, "fit all");
break; break;