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

+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.
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
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
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
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.
• A gesture never changes the <i>time window</i>, only the view inside it. The window is what the
header control states, and it also sets the trigger's event span and how the buffer is stored —
so a wheel that redefined it would re-plan the buffer and drop your history. The two fits
<i>do</i> set it, deliberately, and the control then reads the fitted value back.
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
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.
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.
+24 -23
View File
@@ -657,24 +657,27 @@ void RowSet::unpin_pause_window() {
set_time_window(window_s_);
}
bool RowSet::fit_x(double& span) {
Row& first = row(0);
if (!first.plot || !figure_) return false;
first.plot->auto_fit_x();
const plot::Range fitted = first.plot->x_range(first.axis_x);
const double fitted_span = fitted.span();
if (!std::isfinite(fitted_span) || !(fitted_span > 0.0)) return false;
if (pinned_) {
// 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);
void RowSet::fit_x() {
if (!figure_) return;
if (!pinned_) {
// Live: the same path the window control and a resume take, with the window it already has.
// That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
// whole of what a live view may show.
set_time_window(window_s_);
return;
}
span = window_s_;
return true;
// Pinned (a held event or a paused view): the pin's full range, and the pin stays. The
// *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) {
@@ -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});
}
bool RowSet::fit_all(double& span) {
void RowSet::fit_all() {
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
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
// every row lands on it -- and 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
// every row's y scaled, it just has no span to report.
return fit_x(span);
// The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
// sharing it is what keeps the two fits from drifting apart.
fit_x();
}
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 unpin_pause_window();
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the
/// window should be.
/// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
/// The x counterpart of `fit_all()`.
///
/// Reports the span it framed in `span` and `true` when it framed one at all (row 0 may hold
/// no data, and a degenerate range is refused). **The caller owes the rest of the time
/// scale**: this moves the view and `window_s_`, and the trigger's event span, the buffer
/// partition and the window control are the caller's to move with it -- see
/// `apply_window_scale()` in `ui.cpp`. Leaving them behind is what made a fit and the control
/// disagree about what the window was.
bool fit_x(double& span);
/// **It does not change the time window** -- `window_s_` is read, never written. A fit is a
/// plot interaction, and the window is also the trigger's event span, the buffer partition and
/// the control's readout, so a fit that *framed the data* instead would redefine all of that
/// from a gesture (and re-plan the rings, dropping the history). What a user asks for here is
/// the zoom undone: a 2 s window zoomed into reads 2 s again.
///
/// 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
/// a cursor), and nothing otherwise.
@@ -343,13 +350,10 @@ class RowSet {
/// 0, so nothing outside the pre+post window is visible.
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
/// on what row 0 retains (the same thing the plot's own `f` key does).
///
/// Reports the framed x span exactly as `fit_x()` does, and leaves the caller the same debt:
/// 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);
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
/// `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.
void fit_all();
/// Reports the left gutter the rows currently need, and reserves at least the width
/// 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;
}
/// Everything the time scale consists of *except* the view: the buffer partition, the trigger's
/// event span, the control's readout and the session. Split out of `apply_time_window()` because
/// a **fit** frames the axis itself -- and when the view is pinned it must keep the pin, which
/// `RowSet::set_time_window()` would clear, throwing a frozen picture onto the live stream -- but
/// a fit still has to move all of this.
/// The one place the time scale changes. 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.
///
/// That is the rule: a plot interaction may not quietly redefine the window. A zoom and a pan
/// never touch it (they move the axis, and `clamp_to_data()` caps them at `window_s_`); the two
/// fits do set it, and so must come through here. A fit that moved `window_s_` alone -- which is
/// what both of them used to do -- left the trigger's event span on the *old* window, and the
/// next decade step or unit change then fed the fitted value in for real, so the time scale
/// changed twice for one action and the `window == event_span` invariant held at neither point.
void apply_window_scale(App& app, double seconds) {
/// **Only the window control comes here.** The time scale is more than the axis: it is also the
/// trigger's event span, the buffer partition (so a change of decimation drops the history) and
/// the session, so a *plot interaction* may not state one. A zoom and a pan never touch it by
/// construction -- they move the row's axis, and `clamp_to_data()` caps the span at `window_s_` --
/// and the two fits do not either: `RowSet::fit_x()` undoes a zoom by restoring the window that
/// is already set. Both fits used to frame the data instead and move `window_s_` alone, which
/// 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;
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
@@ -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.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);
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
/// reporting an error: the entry is also the readout, so leaving junk in it would leave the
/// control disagreeing with the plot.
@@ -4067,28 +4056,25 @@ void on_signals_toggled(GtkToggleButton* button, gpointer user) {
gtk_toggle_button_get_active(button));
}
/// Fit: auto-scale every row's y axis and re-frame the x window on the data the
/// ring holds (the plot's own `f` semantics), then apply that span as the time scale.
/// Fit: auto-scale every row's y axis to the data it holds, and put x back on the whole time
/// window.
///
/// `apply_window_scale()` and not `sync_window_display()`: a fitted x span *is* a new window, so
/// the trigger's event span and the buffer partition move with it. Restating the control alone
/// left those on the previous window.
/// No `apply_time_window()` here, deliberately: a fit is a plot interaction and may not restate
/// the time scale (see that function). The y axes are fitted to the data; x is only un-zoomed.
void on_fit_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
double span = 0.0;
if (app->rows->fit_all(span)) apply_window_scale(*app, span);
app->notice = "fitted to the buffered data (" + format_window(app->rows->time_window()) + ")";
app->rows->fit_all();
app->notice = "y fitted to the data, x back to the " +
format_window(app->rows->time_window()) + " window";
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
/// other fit, for the common case where the interesting range is the data's but the levels
/// were placed by hand.
/// Reset the x zoom only: the y axes keep the scales the user set. The counterpart of the other
/// fit, for the common case where a zoom should be undone but the levels were placed by hand.
void on_fit_x_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
double span = 0.0;
if (app->rows->fit_x(span)) apply_window_scale(*app, span);
app->notice = "x fitted to the buffered data (" + format_window(app->rows->time_window()) + ")";
app->rows->fit_x();
app->notice = "x back to the " + format_window(app->rows->time_window()) + " window";
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);
/// 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
/// data's own span and leave the y ranges alone; `fit all` must then move the y ranges too.
/// (a null button: neither handler looks at it). Both must put x back on the whole time window
/// 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) {
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 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:
// it is the trigger's, written only by `apply_window_scale()`, so a fit that moved `window`
// alone shows up here as the two disagreeing.
// `window` and `event_span` are the assertion that a fit did *not* restate the time scale:
// both are written only by `apply_time_window()`, so they must hold the typed value on every
// line, with `span` the reading that moves -- down on the zoom, back up on the fit.
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",
what, a.rows->time_window(), a.model->event_span(), x.min, x.max, x.span(), y.min,
y.max);
what, a.rows->window_pinned() ? 1 : 0, a.rows->time_window(), a.model->event_span(),
x.min, x.max, x.span(), y.min, y.max);
std::fflush(stderr);
}
@@ -7290,33 +7277,57 @@ gboolean timelabels_probe_step(gpointer user) {
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) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
switch (a->dbg_fit_step) {
case 0:
// Move the y scale off the data's range first, so "fit x leaves y alone" is a
// statement with something to be wrong about.
// Move the y scale off the data's range first, so "fit x leaves y alone" is a statement
// 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_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");
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 1;
break;
case 1:
// The handlers themselves, not `RowSet::fit_x()` alone: the whole point of the
// `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);
fit_probe_zoom_in(*a);
g_timeout_add(400, fit_probe_step, a);
a->dbg_fit_step = 2;
break;
case 2:
fit_probe_report(*a, "fit x");
on_fit_clicked(nullptr, a);
// A quarter of the window: what the fits have to undo, and the reading that proves the
// `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);
a->dbg_fit_step = 3;
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:
fit_probe_report(*a, "fit all");
break;