Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
101 lines
8.8 KiB
Markdown
101 lines
8.8 KiB
Markdown
# Invariants: the time axis and the window control
|
||
|
||
What may change the time scale (one function, one caller), how the x labels pick their form, and what a fit restores.
|
||
|
||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||
|
||
- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for
|
||
the same reason as the status label: it sits in the header bar above the plot, so a label
|
||
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.
|
||
- **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.
|
||
- **With nothing bound, `set_time_window()` frames `{-window, 0}` and never `{0, window}`.** Under
|
||
a following x group a range states two things — its span, and *through its maximum* where the
|
||
window sits behind the live edge — so a maximum **at** the edge is what makes the offset zero.
|
||
When there is a live edge to read that is `{newest - window, newest}`; when there is not, the
|
||
edge the live view would put at x = 0 is 0, so the equivalent statement is `{-window, 0}`.
|
||
Asking for `{0, window}` there is an offset of a *whole window*. This is not a rare fallback:
|
||
"no samples bound" is the state every structural change leaves behind, and
|
||
`apply_time_window()` re-plans the rings and *then* calls this, so it is the axis a mode change
|
||
is drawn with until the first sample lands. With `{0, window}` the two or three frames after
|
||
every event or waveform open drew the time base one screen to the left of where the following
|
||
frames drew it — a trace that jumps a screen sideways and comes back, which is what "opening
|
||
events or even waveform sometime result in data flickering (like time base) and is unusable"
|
||
was. The reading is the dump's traceless paint counts: `mode=live` 38 → 0 and `mode=waiting`
|
||
2 → 40, the latter being the point — a waiting view now paints its empty axis in the *same*
|
||
place every frame instead of twitching into a framed one.
|
||
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
||
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
||
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
||
below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of
|
||
days above — so the resolution is a step function of the magnitude, which `time_label_resolution()`
|
||
states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both
|
||
directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed
|
||
absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about
|
||
the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0`
|
||
caption that had nothing to explain. Three details hold it up. The step is read back from the
|
||
majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the
|
||
range and the axis' pixel width, never on the label form) but *is* absent before the first
|
||
layout — and with no ticks the form must be **left alone**, because a step guessed from the span
|
||
would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative
|
||
form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick
|
||
values and a resolution from `pow(10, n)` differ in their last bits when they are the same
|
||
number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit
|
||
is exactly enough), so the bare comparison picked a different form from one frame to the next.
|
||
The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather
|
||
than which form a given window gets.
|
||
- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to
|
||
`deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own
|
||
`draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an
|
||
`Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore
|
||
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
|
||
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
|
||
the caption has to name the anchor precisely enough that label + caption is the exact absolute
|
||
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only a plot
|
||
whose x strip draws labels** (`plot::x_strip_has_labels(x_strip_mode())`: the bottom row, and any row above it whose
|
||
columns differ from the row below's) carries one, every other plot being set to `""`; and the renderer measures it
|
||
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
|
||
no rect to read back, and a caption explaining the x labels must not be able to move the layout
|
||
those labels were measured into. The guards are the library's
|
||
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame
|
||
with the caption differs from one without, and a pan plus a 64× zoom must give the same box to
|
||
the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back.
|
||
- **`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_` (it scans **every**
|
||
row's member, in order, since a labelled strip on a row other than the first takes x gestures too), 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.
|
||
- **Relative time is decided by the newest statement, not by the default.** `Config::relative_time_specified`
|
||
is set by `--relative-time` and by the preferences — which the source chooser can reach before the run
|
||
is planned, so that is the one place a preference still changes *this* run. Both the list's own copy
|
||
(`apply_source()`) and a source's stored configuration (`apply_stored_settings()`) only fill in when
|
||
nothing newer was said. Without the marker, clicking a source row after applying the preference
|
||
silently reverted it — `[sources] picked ... relative=0`, with the dialog having just written `1`.
|