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

+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;