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:
1 parent
9888fd9977
commit
3d7e214295
7 files changed
+193
-150
No files matched your search
+68
-57
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user