improved measuraments ,cursors, zooming and user interactions

This commit is contained in:
Martino Ferrari committed 2026-10-03 16:05:45 +02:00
1 parent 0eceaf537b
commit 02620ca946
12 files changed
+1488 -171

No files matched your search

+176 -8
View File
@@ -60,6 +60,34 @@ single consumer), never through the lock.
## Measurements
**Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring
while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot*
of the window that was on screen at the pause, with the axes pinned there. That is the same
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`); the
difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own,
so the times on the axis do not change when the pause begins. Resuming drops the snapshot and
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole —
which is what pausing used to leave, because it stopped the producer outright.
**Sub-second windows read relative.** The x ticks are absolute until the window's span goes
under a second (with a band to 1.05 s, so the form cannot flicker while a zoom is nudged), and
relative below it: the ticks are then generated on a grid anchored at the window's left edge —
so they read `0`, `5ms`, `10ms` instead of `12.345671s`, `12.345672s`, which three significant
digits cannot even tell apart — and a caption names that anchor in the axes' top-left corner:
`t0 = 12.3456 s`. The labels scale through the prefixed units the SI formatter uses — `500ms`,
`500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of `0.005ms`. Adding the two is the tick's absolute time. The caption is an annotation
inside the axes rather than the axis title on purpose: its text changes with the window, and a
title of a changing width would relayout the plot every frame. Everything a user copies out —
the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds.
The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written
by the app: the window's edge is derived from the newest sample during the layout, so a value
the app wrote a frame earlier is a frame of scroll stale — at 1 MS/s more than a millisecond
window shows. One consequence worth knowing: the zero tick can sit up to one tick step to the
*left* of the window's edge in the frame it was drawn, because the grid is anchored to the
edge the ticks were generated for; the caption always names the anchor the labels were built
with, so the addition stays exact.
**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement:
`[badge] <type> <value> [edit]`. `[+]` opens a dialog with the signal, the type, the options
that type actually uses, and "show on plot"; `edit` opens the same dialog on that
@@ -313,15 +341,120 @@ that apart from a ring that has only just started filling: the first frames afte
signal is enabled hold a couple of hundred samples, a 2 s window was clamped to the
~0.02 s they covered, and nothing there ever grows a span back. The view then stayed
pinned to the newest 20 ms of a 6.5 s buffer for the rest of the session. It now clamps
against the larger of the retained span and the requested window, so a partly filled
window simply has an empty left-hand side.
against the **requested window** itself — not the retained span, which was the second
attempt and the second bug: while the ring is still filling the two are the same, but once
it holds more than the window the bound became the whole ring, and a zoom-out walked the
view past the window into data the window was never set to show. A partly filled window
simply has an empty left-hand side.
**The trigger marker is a readout, not a handle.** It is a cursor, and a cursor is grabbable by
default — so parking it outside the axis, which is how it is hidden when no event is on screen, did
not stop a *drag*: the controller holds a marker for the gesture's life and follows the pointer
between frames. It takes `set_cursor_selectable(false)` like the measurement marks, and the dump
reports `readout instruments locked: 1`.
**A pinned view is navigable within its data, and in live view too.** Three places could leave the
window showing data the scope never had, and all three are now bounded by *the data* rather than by
the window that was requested: the **pause** pinned on the window it asked for (the snapshot is a
cut clamped to the ring, so it can be shorter), the **held event** pinned on the snapshot's recorded
`pre_s`/`post_s`, which is again the request — a capture shorter than it exceeds it — and a
**following group**, whose scroll offset was bounded by the retained span alone. That last one is
the live case: with the loose bound, scrolling back put the window's *right* edge on the oldest
sample, which leaves the whole span to its left over nothing — an empty margin exactly as wide as
the view. The group's bound has two cases because it has to: while the members retain at least a
window's worth, the whole window is kept on the data; when the window is *wider* than what is
retained (a ring still filling, a trimmed one) no placement avoids a margin, and the least-empty
one — right edge on the newest sample — is kept, which is also the behaviour every existing test
expects. A `Capturing` view is deliberately not clamped: its post side is empty until the capture
completes, which is the feature.
**The pause pins on its snapshot, not on the window it asked for.** The pause freezes the visible
window, but that window is *cut* out of the ring and the cut is clamped to what the ring holds — so
a window reaching back past the oldest retained sample produces a shorter snapshot. The axis was
pinned to the request, which left the shortfall as an **empty left margin** (a run showed
`frozen_x=[0.000000..0.561537]` against an axis of `[-1.44..0.56]`), and zooming in moved the view
deeper into the empty part, which is what made it look like zooming *caused* it. The pin is the
snapshot's own stamps now, so the frozen picture fills the frame and a zoom stays on its data; the
event pin already worked this way, and the pause agrees with it. `[pause-probe]` prints `axis=`
beside `frozen_x=`, and while paused the axis must lie *inside* `frozen_x` — equal at the pause, a
subset after a zoom-in.
**A pinned window is not a navigation surface.** In trigger mode the x window is the event —
`[-pre, +post]` while a capture fills, the snapshot's own span once it is held — and a gesture
must not move it. Nothing was pulling a drag back: `clamp_to_data()` is the live-view half of
the rule above and is deliberately skipped while a capture or an event is displayed, and the
library's own pan bound lives in the *following* path (a `Live` group's offset is clamped
against what its members retain), so a group with the follow off had no bound at all. A drag or
a wheel zoom therefore walked the frozen capture off its axis, leaving an empty plot over data
the scope never had. The pin is now re-stated every frame while it is pinned, in the same
branch that decides `clamp_to_data()` is not to run, which makes a pan or a zoom in trigger mode
(and on a paused view, whose frozen window is held the same way) a no-op. Leaving trigger mode,
or resuming, is what makes the view navigable again. `UDPSCOPE_PROBE_PAN_MS` is the guard: it
writes the range a gesture writes and reports `held=1` when the axis ignored it.
**Four measurements cost a third of the run's frames.** A measurement is a statistic of the
visible window, so it was recomputed whenever that window moved — which in a live view is every
frame, at O(visible samples) *per measurement*. Adding a `top` measurement to a 1.3 MS/s stream
whose window held 4.7M samples took the frame tick's mean from **0.41 ms to 13.66 ms** and the
run from 631 frames to 407. Three separate costs, found by instrumenting the pane rather than by
reading the frame mean (`measure cost: N recomputes, X ms total, Y ms each` in the dump, next to
the values):
1. *The pane was recomputed per frame.* It is a readout, so it now follows the rule the status
line's own fields already had: at most every 100 ms while the window scrolls, one pass the
moment it settles, one per mode change — and the interval follows the *work*, stretching to
400 ms when a pass costs more than 5 ms, so what stays bounded is the duty cycle rather than
the period. (Getting the "settled" case backwards is what made it *worse* first: 457
recomputes in one run, because a *pinned* window then recomputed on every frame.)
2. *Both window vectors were copied per measurement.* Four measurements over a 4.7M-sample
window is 300 MB of memcpy per frame — memory-bandwidth bound. The loop holds the cached
window by reference now, and it is materialized in bounded chunks instead of through two
whole-window `float` temporaries.
3. *Every type rescanned the window.* Average, min, max and the two new types are all derived
from one shared pass — both extremes with their first positions, the sum and the count — so
several measurements on one signal share a single scan and adding a type adds no pass.
Measured on the same stream and window: tick mean **1.39 ms** with the pane holding four
measurements against **0.73 ms** without (595 frames against 610), the pass itself 11 ms every
400 ms — and the 82 ms worst tick is the *control's* too, i.e. the publish stall, not this.
**"Show on plot" draws full-width rulers, and they must not blink.** A level measurement draws an
`h_ruler` across the whole plot and a time one a full-height cursor, so a pane holding four
measurements can put six lines over the data — that is the feature, and the lines move with the
window. Because the pane is now refreshed on a cadence, the marks were re-placed from a result
that could be a pass old; worse, a pass landing on an empty window (a rebuild, a capture
boundary, a mode change) emitted no marks at all, so the rulers vanished and returned a frame
later. The fallback is by *kind*: an invalid pass keeps the last valid pass' **level** marks — a
level is a property of the signal and outlives the window it was measured in — and drops its
**time** marks, which are positions inside the window that result described. `marks=0` with
measurements on and data arriving is now the assertion (`[measure-trace]` in a probe run), and it
never occurs.
Their stroke is the app's own setting, not the library default: **0.75 px and dashed 4/4**
(`set_cursor_stroke()`), so a measurement's mark reads as an annotation over the data rather than
as another instrument framing the plot. The trigger marker keeps the solid `line_width_px` stroke
on purpose — it marks an event the scope acted on, which is a different claim from a measured
level. The library's own test is `a_cursor_stroke_can_be_thinner_and_dashed`.
Two related guards came out of the same hunt: `clamp_to_data()` compares against the window with
a relative tolerance (clamping to *exactly* `window_s_` can leave the group's span an ULP over,
and a clamp that fires every frame is a re-frame loop — the dump counts it as `clamp fires:`), and
the sample-window walk it does is unchanged.
**Amplitude and peak to peak** are the two new types: `max - min`, and half of it. Deliberately
`(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero, which for a
signal riding on an offset is a different and less useful number. Both come out of the shared
pass, so they are free, and both read exactly what a unit sine should: `amplitude=1`,
`peak to peak=2`.
The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value,
stamp) pair split across two rings of equal capacity, and the published window is pinned
by `acquire()` while both rings keep sliding under it — so the oldest samples of the
window point at time slots the producer has since overwritten. Those used to read back as
`0`, a perfectly plausible coordinate, which landed in `x_bounds()` and from there in
`dx()` and `x0()`. That is the renderer's entire index mapping: it converts the visible x
`dx()` and `x0()` — the window's extent, which the axis, the measurements and the LOD level are
all read from. (The renderer itself no longer walks that grid to find its sample window; see
the margins note below.) It converts the visible x
range into indices with `(x - x0()) / dx()`, so *one* unreadable slot inflated `dx()` by
the ratio of elapsed stream time to the retained window, scaled every index up with it,
and the gather began well inside the visible range. The stamp is now clamped to the oldest
@@ -557,8 +690,10 @@ the fix is a submodule commit and a pin move (see CLAUDE.md):
cursors, region edges, the crosshair — because the rounding moves a line by up to
half a pixel, and a cursor that reports a value may not be moved to look tidy.
Diagonals are untouched either way. Measured on one scanline of 620 px: 22
distinct intensities before, 10 after, with the grid landing exactly on its own
`grid_major`/`grid_minor` colours instead of a spread of partial blends.
distinct intensities before, 10 after, with the labelled grid landing exactly on its
own `grid_major` colour instead of a spread of partial blends. (That measurement
predates the minor grid's own styling below: the *minor* class now blends on
purpose, so it no longer lands on its exact colour — the majors still do.)
A third, found the same way, is in the tick generator rather than the shaders:
@@ -575,8 +710,33 @@ A third, found the same way, is in the tick generator rather than the shaders:
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
that small keeps them.
**The minor grid is texture, the labelled grid is structure.** The subdivisions used to be
drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to
need them read as two competing sets of ticks. They are now three things the style states
explicitly: **thinner** (`grid_minor_width = 0.75`, a sub-pixel logical width, which at 1x is
expressed as reduced coverage and on a HiDPI display as a literally narrower line),
**dashed** (`grid_minor_dash_on`/`_off = 3/3`, a fragment-shader discard, so the vertices are
unchanged), and **blended into the background** (`grid_minor` carries 50% alpha, and the line
shader applies it). The labelled grid is also brighter than it was (`grid_major` `0x2A3038` ->
`0x39414C`), still well under the axis line's `0x8A94A6`, so the three-way hierarchy the eye reads
is minor < major < axis. Together the defaults leave a minor line an order of magnitude less ink
than a major one, which is what gives the eye one grid to read instead of two. Set `grid_minor_dash_off = 0` for the old
solid subdivision, and `grid_minor_width`/`grid_minor` for the rest. The library test is
`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`.
A fourth lives in the renderer rather than the atlas: the visible x range is mapped onto
sample indices through the **series' own x**, not the `(x - x0()) / dx()` grid. `dx()` is
documented as a *nominal* spacing and on a ring-backed series — whose x is a streamed
timestamp — it is only the mean of the retained window, so the grid walk landed the draw
window inside the visible range and dropped the two segments that enter and leave the frame.
The symptom is the one zooming in shows first: the trace stops short of the left and right
margins and those edges go empty. `Series::neighbours()` answers where the bracketing samples
are, over the real x, and a uniform series answers the same question through the same call —
so it is one path, and it costs what the crosshair's own lookup costs.
Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`,
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`), and each
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`,
`a_sparse_window_between_samples_still_inks_both_margins`), and each
was verified to fail with its own fix reverted.
**Cursors start hidden** and are switched on from the overflow menu; **Reset cursors**
@@ -596,8 +756,10 @@ chooses what the instruments are:
The vertical pair is one stream time shared by every row, so the markers line up down
the column and dragging one on any row moves them all — the app reads the position back
out of the plot each frame, because a drag writes the *plot* and that is the only place
it is visible. A ruler is per row: each row has its own y axis, so two rows' rulers are
out of the plot **while a gesture is on that row**, because a drag writes the *plot* and that is
the only place it is visible. Outside a gesture the plot is not read at all: `cursor.x` is the
snapped value then, which is what the app wrote itself a frame earlier, and adopting that back
is what made a dragged cursor spring to where it had been. A ruler is per row: each row has its own y axis, so two rows' rulers are
two different values, and the pane names the row.
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
@@ -833,6 +995,12 @@ now — they are listed so they are not mistaken for anything else:
hundred samples every fifth of a second made every frame span microseconds while
the frames were 100 ms apart).
**Every launch is its own instance.** The GTK application is created `G_APPLICATION_NON_UNIQUE`:
a scope is the kind of program a user runs twice side by side — one instance per source, or two
views of the same one — and without the flag `GApplication`'s D-Bus single-instance rule turned
every later launch into an activation on the first, so the new process exited at once and only
one instance ever lived.
**One source, one client, over unicast.** A UDPStreamer over unicast keeps a single
session: a second `udpscope`'s CONNECT displaces the first, the displaced client sees its
`silence_timeout_s` elapse, reconnects and displaces the other, and the two take turns