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.
144 lines
13 KiB
Markdown
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.
|