diff --git a/CLAUDE.md b/CLAUDE.md index 04f1acf..7a58581 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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] window=.. event_span=.. x=[..] span=.. y=[..]` (stderr, +- `[fit-probe] 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] 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`, diff --git a/README.md b/README.md index 46a7d99..e373652 100644 --- a/README.md +++ b/README.md @@ -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 '' 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. diff --git a/src/help/05-window.txt b/src/help/05-window.txt index c1d2d21..4c60de2 100644 --- a/src/help/05-window.txt +++ b/src/help/05-window.txt @@ -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. +• This control is the only thing that changes the window. A zoom, a pan or a fit moves the + view inside 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 stored (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. diff --git a/src/help/06-navigate.txt b/src/help/06-navigate.txt index ef66fac..6f0c8a0 100644 --- a/src/help/06-navigate.txt +++ b/src/help/06-navigate.txt @@ -19,10 +19,11 @@ The full table is in the shortcut overview (Ctrl+/). • A pinned 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 in 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 time window, 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 - do set it, deliberately, and the control then reads the fitted value back. +• Nothing you do on the plot changes the time window — only the header control does. A + zoom or a pan moves the view inside the window; f (and Fit X axis) is the undo 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. diff --git a/src/plot_host.cpp b/src/plot_host.cpp index 9f2f707..ae48429 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -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) { diff --git a/src/plot_host.hpp b/src/plot_host.hpp index d8b76cd..50e5e0c 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -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. diff --git a/src/ui.cpp b/src/ui.cpp index 8159343..a0de078 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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(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(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(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;