Files
udpscope/docs/claude/invariants-time-axis-and-window.md
T

8.8 KiB
Raw Blame History

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. 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.