Files
udpscope/docs/claude/invariants-readouts-and-cursors.md
Martino Ferrari 91ca29d181 scope windows: the same instruments as the main window
A scope or detached window drew its traces and nothing else: no cursors, no gate band, no trigger
marker, no measurement marks. They are not per-window state to be recreated -- the cursors are one
pair of times, the gate one band, the trigger one instant -- so the main window's row set stays the
**owner** and every window is written from it.

`sync_scope_instruments()` runs in the main tick after the main's own `sync_cursors()`/
`sync_gate()`: a gesture inside a scope window is read back from *that* window's controllers first
(those two calls only adopt while their own plot holds the grab, which is exactly "the drag
happened here"), adopted by the owner, and then every window -- the dragged one included -- is
written from the owner with the same cursor pair, mode and visibility, gate bounds and visibility,
and trigger marker. `RowSet::set_cursors()` is the new pair setter; `trigger_marker_x()`/`_visible()`
are the getters the mirror reads. The measurement marks travel by signal name
(`RowSet::MeasureMark::signal`, filled by the measures pass), so each window finds the row that
plots the signal and draws its ruler or mark there -- or none, when it does not plot it. The search
marks are times on the window and are placed on every window by the same per-frame path, and the
crosshair-axis-labels preference is restated to open windows when it changes.

Probe: `[scope-probe] instruments windows=N cursors=N gate=N trigger=N marks=N (main trigger
on=..)`, after adding a real gated `Top` measurement on `sine` -- the gate is restated visible each
frame by `update_measures()` and marks come only from `show_on_plot` measurements, so the leg uses
the app's own path. Docs: README, the cursors invariant, and the probe readings.
2026-11-06 12:21:38 +01:00

144 lines
13 KiB
Markdown

# Invariants: readouts, cursors and the value formatter
What a readout is allowed to quote, how the cursors are read back, and the one formatter both panes share.
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.
- **A readout quotes a stored sample, never a value between two of them.** The hover
crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it
reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream
interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar
average* over `time_stride` samples, so a number between two stored samples is a second
estimate laid over the first that nothing on screen or in an export holds; and past the sample
spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The
change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host
whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and
**udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a
regression, not a feature.
**Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns
the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is
*snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's
`mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not
`crosshair_value()`) are both placed from it. Two consequences that look like defects and are
not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the
dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own
vertical cursor line** — up to half a sample period, invisible at a whole-window view and the
width of the panel at a few samples across, which is the only zoom at which anyone is asking
which point it is. The line says where the *marker* is; the dot says which *datum* is quoted.
The hover mark is also deliberately *larger* than a trace's own sample dots
(`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them
and at their size reads as one more sample. The guards are the library's
`crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`,
`crosshair_interpolated_mode_blends_the_two_samples` and
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
y=..` agreeing with the pane's first value.
- **A measurement with a non-zero `hf_fc` is the one exception**, and it is inherent rather than a rounding
error. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered
copy of the window, so the quoted value need not equal any stored sample -- and with `show_on_plot` its
ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it must
not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is
the default, still quotes a stored sample exactly as the rule above says. The filter is seeded with the
window's **first sample** and never from history before the window, so a measurement never depends on data
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
from earlier samples.
- **The dock's readouts and the pages' panels are one string, never two.** The dock (`src/ui_dock.cpp`)
shows a read-only copy of the cursors table and of the measurement values, and it must not format
anything: `update_cursors_pane()` and `update_measures()` format each value **once**, into a local, and
hand that same variable to the page's label **and** to `dock_cursor_cell()` / `dock_measure_value()` --
on every branch, including the `"--"` blanking one (cursors off, no sample). A second formatter in the
dock would be two spellings of one reading that drift the first time `format_measure()` changes, and
the dock exists so that the two can be read side by side. The `who` cell is copied from the page's label
verbatim (markup chip and name), and the dock's lines are rebuilt from the page's whenever the page
rebuilds (`dock_rebuild_cursors()`, `dock_rebuild_measures()`), so the line counts agree by
construction. A cell is **ellipsized at a fixed character count** (who 5, values 9, measurement type 12) with
the full text in its tooltip: the dock is 280 px wide and its layout must not depend on what a value
reads, so a long value is cut and quoted whole in the tooltip rather than widening the column (the
`who` cell was 8 characters, which made the readouts 304 px wide and pushed the sidebar past its
pin). `[cursors-probe] ... dock cursors:` and `[measures-probe] ... dock measures:` assert it: `same_text=1`
compares `gtk_label_get_text()` (the untruncated text) of every dock label with the page's, and
`fits=1` that the Readouts box is no wider than the dock.
- **The readouts strip is a third reader of the same string, never a second formatter.** When the dock is
not beside the plot (hidden, or the window collapsed so the dock is an overlay) the readouts are shown in the strip under the plot (`readouts_strip`, `src/ui_dock.cpp`): the
cursor chips' `who` and *delta* labels and the measurement chips' value labels. They are written **in the
same call** as the dock's -- `dock_cursor_cell()` and `dock_measure_value()` set the strip's label from the
argument they were handed, and `dock_rebuild_cursors()` / `dock_rebuild_measures()` rebuild its chips
beside the dock's lines -- so a value is still formatted once, into one local, and a feature that writes a
dock cell without going through those two setters leaves the strip stale. The strip keeps the dock's
rules: the same fixed-width ellipsized cells (who 6, type 10, value 9 characters, full text in the
tooltip), the delta cell reads `--` on the same branches, and the cursor chips are *insensitive* with the
cursors off -- through `dock_set_cursors_sensitive()`, which writes the dock's grid and the
strip's chip box together; a second `gtk_widget_set_sensitive()` on either is two spellings of one state.
The one place the strip hides what the dock only disables: with the cursors off the strip's Cursors group is
not shown, with no measurement its Measures group is not, and with neither the strip is not (the dock keeps
both blocks, `no measurements` included; the strip has no placeholder). That is
`sync_readouts_strip()`, not a sensitivity.
`[cursors-probe]` / `[measures-probe]` `strip: ... same_text=1` asserts it.
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
**`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
that range falls back to `%.3g`.
- **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the
pane *insensitive* (the page's body, and the dock's cursor grid beside it) rather than hiding it -- a panel you can still see is a panel you can read, and the
settings you are about to switch back on must not vanish while you look for them -- and every cell in
it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample;
with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting
a sample the cursors no longer name. Two spellings of one state in one grid would be the defect.
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
every frame therefore adopts its own echo back, and -- because the vertical pair is one value
shared by every row -- a row that has not been re-written yet hands the *previous* shared
time straight back. That is the "drag it and it springs back" defect, and the fix is
`RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports
`grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed
back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of
the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still
moving the cursor.
- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one
piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something
`insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is
how a row added while the cursors were already on came up with its instruments parked
while every other row showed them. The same goes for the vertical pair's `x`: it is one
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
genuinely per row (the rulers' values, the traces, the colours).
- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and
the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor
is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside
the axis is not enough**: the controller holds a marker for the gesture's life and follows the
pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger
marker says where the scope put the edge and a measurement mark says what a measurement measured;
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
- **The crosshair's axis badges are painted *over* the tick labels, and that costs a flush.**
A badge is a fill in the crosshair's colour framing the value where the line meets the axis
it names — and it sits *in the label strip*, on top of the tick label at that row. The tick
labels are staged into `glyphs_over_`, the over-text batch the frame flushes **after every
line batch**, so a panel fill drawn at the ordinary moment lands *under* them: the label
prints through the badge and the backplate reads as transparent (the defect this rule was
learned from). `draw_crosshair_axis_badges()` therefore flushes the accumulated over-text
*first* — those labels are already placed, so it is one batch — then draws both fills, then
stages the badge texts, which leave with the rest of the over batch over everything. An
assertion on badge pixels inside a label's rows is the regression test: with the fill under
the text, the label's glyph pixels are still lit inside the badge's rect.
- **The instruments have one owner: the main window's row set.** The two cursors, the gate band,
the trigger marker and the measurement marks are *shared*, not copied per window. The main tick's
`sync_scope_instruments()` writes the owner's state into every scope window each frame (the
setters early-out, so a steady frame is a handful of comparisons), and a gesture inside a scope
window is read back from **that** window's controllers first -- `sync_cursors()`/`sync_gate()`
only adopt while their own plot holds the grab, which is exactly "the drag happened here" -- and
adopted by the owner before anything is written out. The measurement marks travel by **signal
name** (`RowSet::MeasureMark::signal`), so a window finds the row that plots it; a window that
does not plot the signal draws none. The search marks are times on the window and are placed on
every window by the same per-frame path.