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
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=.. 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
`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.
`RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed
`[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter
of it the way a gesture does — on the *member* axis — and fits. Four assertions.
**`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit
is a plot interaction and may not restate the time scale (see the invariant below), and they are
the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the
window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put
`span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the
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=[..]`
(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
@@ -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
on a mere change of window. That is also why the window is typed into a popover rather
than shown in the bar.
- **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.
- **No plot interaction changes the time scale; only the window control does.** 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 the history**), the window
control's readout and the session file. A **zoom or a pan** does not touch it by construction —
the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so
nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the
*undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads
`window_s_` without ever writing it. Both fits used to frame the *data* instead and move
`window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and
an event span left 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).
- **`apply_time_window()` is the only place the time scale changes**, and the window control is
its only caller. It clamps to `window_limit()` and moves `set_target_span()`,
`set_event_span()`, `RowSet::set_time_window()`, the control and the session save together —
the partition **before** the axis, so the axis is framed over the rings that will exist. A fit
must not come here for two reasons: it is not entitled to the scale at all, and
`set_time_window()` unpins and re-arms the live follow, which would throw a held event or a
paused view onto a live edge it has nothing bound to. Paths that change the span for their own
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
capture up to the requested window.
- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.**
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
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`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
**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
offer, split by what they touch: **Fit X axis** frames the window on the data the ring
holds and leaves every y scale exactly as the user set it, **Fit all** does that and
auto-scales the y axes too.
offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
window and leaves every y scale exactly as the user set it, **Fit all** does that and
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
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.
**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,
propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for
the common case where the window should be the data's but the levels were placed by hand.
The status line acknowledges either (`x fitted to the buffered data (1.25 s)`).
auto-scales every row's y axis to the data it holds and puts the x view back on the whole time
window, propagated to every row. **Fit X axis** is that x half alone, for the common case where
a zoom should be undone but the levels were placed by hand. The status line acknowledges either
(`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
through the same door a typed window does, so the trigger's event span, the buffer partition and
the window control all land on the fitted value and the control reads it back. A **zoom or a pan
never does** — a gesture moves the view inside the window and leaves the window alone, which is
what keeps a wheel from silently re-planning the buffer and dropping the history. The one thing a
fit does *not* borrow from the typed path is the live re-follow: fitting a held event or a paused
view keeps it pinned rather than throwing it back onto the stream.
**Nothing you do on the plot changes the time window** — only the window control does. A fit is
the *undo of a zoom*: a 2 s window zoomed into reads 2 s again. That matters because the window
is not just the axis. It is also the trigger's event span, how the samples are stored (a window
long enough to move the decimation re-plans the rings and **drops the history**), the control's
readout and what the session file records — so a wheel notch or a Fit that redefined it would
quietly throw the buffer away. A zoom and a pan are view-only by construction; the fits read the
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
`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.
• 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;