Files
udpscope/docs/claude/invariants-readouts-and-cursors.md
T
Martino Ferrari bdf0ff7ddc scope: full-screen figures, second windows, gated measurements, per-configuration events
A batch of view and instrument work, with the probes and docs for each.

- **Full screen per figure row**: a toggle on each row's top-right corner, revealed by
  pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an
  overlay-level motion controller so a revealed button under the pointer cannot flicker);
  the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The
  arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout
  of one figure carries no toggle.
- **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in
  their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main
  window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries:
  the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*.
- **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing
  the layout*, flipping the app's one toggle.
- **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause
  snapshot (live falls back to the live ring), persisted with the session and shown in the
  measure dialog.
- **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the
  plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu
  and the probe read them.
- **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`,
  `retarget_event_store()` follows every configuration switch, and a pre-configuration cache
  (loose files) is adopted into the configuration's folder on first start.
- **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"`
  signal to `close-request`; scope windows are closed with it and the application quits
  explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`.
- Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`,
  `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key,
  `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated.

Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure
object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
2026-11-05 17:58:59 +01:00

12 KiB

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