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
+1464 -147

No files matched your search

+215 -17
View File
@@ -35,8 +35,12 @@ drawn at a data position. `generate_ticks()` also stores the tick at the origin
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
when the stride is not a power of two, which labelled it `-139a`. All three carry
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`; README
records what they cured.
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`. The minor
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
grid instead of as a second set of ticks (`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`).
README records what they cured.
Changing any of this means committing in the submodule and moving its pin, and running
the library's own suite as well as the udpscope probes below:
@@ -76,8 +80,58 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
with, so a synchronous sequence would read every position one action stale. It clicks by
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
measurements that all read `--` *and* makes a performance run look free (each bails before
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
`peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave
mean of a sine, which is the check that the threshold types are not merely plausible.
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
number. It must never print `marks=0` while measurements with `show_on_plot` are on and the
data is there -- that was the rulers blinking off at a mode change.
`measure cost: N recomputes, X ms total, Y ms each` follows it: **read it against a run with
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost.
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a
gesture does — on the *member* axis, which `Figure::layout()` adopts into the group — and
reports the axis the frame after it produced. Time it *inside* one pinned state: a pan during
a capture whose check lands after the capture has ended is re-framed by the mode change, not
by the pin, and reads `held=1` with the hold removed (that is how the first version of this
probe fooled me). The pause probe makes a stable pin, so run them together:
`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200` puts the pan and its check inside
the pause. With `hold_pinned_window()` disabled it reads `held=0` and the axis sits at the
asked range. It then asks for a **zoom-in** (a window a quarter of what is on screen) and
reports `group=`/`member=` beside it: **`allowed` must be 1, with both equal to the ask** --
that is the single-trigger-mode case, where the wheel has to keep working, and where reading
the wrong one of the two made a working clamp look broken. All three verdicts are read from the
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) —
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture.
measured on a still picture. It now also asserts what a pause *is*
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]` on
stderr):
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
state as well as an acquisition one and the view snapshot and axis pin live in the
handler. Across the three lines **`ring_produced` must grow** (the producer never
stops — it fills the live ring exactly as it does with the trigger off), **`event_size`
must be non-zero exactly while paused** (the plot is bound to the frozen snapshot, not
the ring) and **`frozen_x` must not move between the pause and the resume**; after the
resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop
the producer, which is why the counts grew by nothing and the resume left a hole.
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
only the file chooser.
@@ -132,12 +186,17 @@ Two lines of the self-check output are assertions in disguise:
- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)`
(stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and
`dx_ratio` must stay within about a percent of `1.0`. `got` is the index window
`Renderer::draw_traces` gathers, recomputed here from its own `(x - x0()) / dx()`
*uniform grid* formula; `want` is the same window found by binary search over the real
time ring, which is what `x_at()`/`neighbours()` — and so the crosshair — use. A
`dx_ratio` above 1 scales every index up, `a` lands past the first visible sample, and
the samples in between sit in the buffer, readable by the crosshair, and are never
drawn: the live view's older part goes blank while its newest part looks fine. Also
`Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side —
and `want` is the same window from the same call, so read this line for the **rate**:
`dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that
used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the
samples in between in the buffer and readable by the crosshair but never drawn) was the
live view whose older part went blank while its newest part looked fine. That is a *fixed*
defect: the renderer no longer walks the grid at all, because on a ring-backed series it
also lost the two end segments and left the left and right margins of a zoomed-in view
empty. The guard for it is the library's own pixel test
(`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that
recomputes the renderer's formula cannot see a renderer that changed it. Also
read `axis=` in the companion line against the `window control:` line in the dump —
a live axis narrower than the requested window is the same symptom from the other
cause (see the `clamp_to_data()` invariant below). Both need a *saturated* ring to
@@ -201,6 +260,13 @@ Two lines of the self-check output are assertions in disguise:
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
the only check that distinguishes averaging from striding.
- `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a
plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and
`A == B`): the tick reads a row only while its controller holds a cursor, and this line is the
guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The
*adoption* half -- a gesture that does move a cursor, and the shared value reaching every row
-- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a
synthetic press here cannot reach the adapter's own event pump.)
- `[cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
`mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is
@@ -272,6 +338,20 @@ Two lines of the self-check output are assertions in disguise:
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
- `[timelabels-probe] <action> relative=D window=.. origin=.. note=[..] ticks=[..]
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D origin=..
note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
app's path). Absolute at 2 s and at 500 ms, `relative=1` below a second, and the caption
must be non-empty exactly then. The probe *races the paint* — it reads the axis on a timer,
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
the absolute form.
- `[fit-probe] <action> window=.. x=[..] span=.. y=[..]` (stderr, `UDPSCOPE_PROBE_FIT_MS`) —
the two fits, through the same `RowSet` calls the right-click menu wires. It first puts
the y scale and the window where the data is *not* (a fixed `[-100, 100]`, a 50 ms
@@ -455,6 +535,17 @@ far the largest file.
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
never a row: the area keeps a reference to the plot it was created with, and a rebuild
destroys every row.
- **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
@@ -525,11 +616,24 @@ far the largest file.
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
is not in the buffer would be cut from data that does not contain it.
- **The renderer maps the visible range onto indices through the series' own x, never the
`dx()` grid.** `deps/plotter`'s `Renderer::draw_traces` asks `Series::neighbours()` for the
bracketing samples. A grid walk is wrong for exactly the series this app plots: `dx()` here is
a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so
the window it computes lands inside the visible range and the two end segments — the ones that
draw into the left and right margins — are never emitted. That is the "empty margin when
zoomed in" defect. The library test is
`a_sparse_window_between_samples_still_inks_both_margins` (a stub whose `dx()` lies, with the
window strictly between two samples). It is the guard, not the probe: `[trig-probe/idx]` now
computes `got` from the same call the renderer makes, so it cannot catch a renderer that went
back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`).
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
`gather()` emits x relative to `x0()` because absolute epoch seconds do not survive
`f32`.
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
and it lost the two end segments (see the margin invariant above). `gather()` emits x
relative to `x0()` because absolute epoch seconds do not survive `f32`.
- **`gather()` emits x relative to `x0()` in *every* series here**, `EventSeries`
included, and the renderer adds `x0()` back. An event's stamps are small (edge
relative, `[-pre, +post]`), so emitting them absolute does not overflow anything and
@@ -564,12 +668,18 @@ far the largest file.
every live view. The library's `StreamSeries` is immune for a reason worth copying:
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
*y* value, never the geometry. Clamping buys the same bargain here.
- **`clamp_to_data()` may not shrink the view below the requested window.** It runs
every frame and cannot distinguish "the user zoomed out past the data" from "the ring
has only just started filling", so it clamps against `max(retained, window_s_)`: a
partly filled window is left empty on its left, which is what a scope that just
started should look like. Clamping to `retained` alone turned a requested 2 s window
into the ~0.02 s the first frames held, permanently — nothing there grows a span back.
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
from "the ring has only just started filling", so `window_s_` — what the window control
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the
other state instead — while the ring is filling the two are equal, but once it holds
more than the window the bound becomes the whole ring, and a wheel zoom-out walked the
view into data the window was never set to show. `window_s_` is right in both: it never
shrinks a window a caller asked for, and a partly filled buffer is left empty on its
left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS`
reports it as `zoom ... span=.. window=.. limited=1`.
Its pan clamping is likewise gated on the data being wider than the view, or the empty
left-hand side drags the live edge off the right-hand edge.
- **Layout must not depend on content.** The y gutter is reserved at the widest label
@@ -596,6 +706,61 @@ far the largest file.
out empty: any stream whose time signal advances in large steps drove it to its
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
itself. **Do not reintroduce a rate estimate on this path.**
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
clamps the position into it (and states `Off`, since a following group would re-derive from a
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
in single trigger mode: an acquired event could not be examined at all.
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
`group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers
what the clamp last wrote: a member that matches it is our own answer coming back, one that
differs is a gesture to honour -- which is also what keeps a *fresh* pin
(`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick)
from being reverted to the member's stale window. Without it nothing bounds a drag or
a zoom in those states: the library clamps a *following* group's offset only, and the live
clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
picture is equally immovable; resuming or leaving trigger mode clears it.
- **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.
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
Two things had to agree for this, and both were wrong in the same direction:
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded
`pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds.
Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it,
which is what made it look as if zooming caused it. `tick()` now re-states the pin from
`RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held,
from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis
to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside.
A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side
is empty on purpose, and that is the capture filling in view, not a margin.
- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The
pause freezes the *visible window*, but `publish_pause_view()` cuts that window 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. Pinning the axis to the request left the shortfall
as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in
moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin
is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while
paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
pause now agrees.)
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
capture may legitimately be shorter than asked for — the ring need not reach back a
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
@@ -640,6 +805,39 @@ far the largest file.
- **The export is collected at click time, not in the file-dialog callback.** The live
view keeps scrolling while the chooser is up, so a late collection would write a
window the user never saw. The writer is picked by the chosen path's extension.
- **The measurements pane is refreshed on a cadence, and its cost is the *window's*, not the
frame's.** A measurement is a statistic of the visible window and a live view moves that window
every frame, so "recompute when the window moves" is "recompute every frame" at O(visible
samples) *per measurement*. With the pane holding four measurements over a 4.7M-sample window
that measured **13.66 ms of tick mean** (baseline 0.41 ms) and cost the run a third of its
frames. Three things fixed it, and all three matter: `update_measures()` recomputes at most
every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), once
when the window settles and once per mode change -- and reading *that* backwards is what made
it worse first, 457 recomputes in one run, because a pinned window then recomputed every
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
`stats` argument is for.
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
stroke -- it is created in the same loop, so it does; a new *instrument* should not.
The **marks** the pane draws (`set_measure_marks()` -- full-width `h_ruler`s and full-height
cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a
throttled pass is a *stepped* ruler: acceptable, because the ruler and the number beside it
come from the same pass and agree. What is *not* acceptable is a ruler that appears and
disappears, so an invalid pass falls back to the last valid result for its **level** marks and
only drops its **time** marks: a level is a property of the signal (a mean, a top, an
amplitude) and outlives the window it was measured in, while a time mark is a position *inside*
that window and may not. Both are emitted in **one pass after the numbers, never inside the
loop that computes them**: that loop has three `continue`s (an unresolvable signal, a channel
with no window) and each one was a way for the rulers to blink off and back. Fixing one and not
the others is how that bug survived a round of "fixes". `clamp fires: N` in the dump is the other half of this: `clamp_to_data()`
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp.
- **The status line's per-second fields are measured once a second, not per frame.**
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
tick as the channel dump; the status line only formats what is already there. The
+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
+1 -1
-1
View File
@@ -332,7 +332,6 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
}
void Acquisition::on_data(const udps_frame_t* frame) {
if (paused_.load(std::memory_order_relaxed)) return;
std::lock_guard<std::mutex> lock(model_.mutex());
if (!running_.load(std::memory_order_relaxed)) return;
+8 -8
View File
@@ -80,14 +80,14 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// capacities are equal, so there is no slack at all): once the ring saturates, the
// few oldest samples of the window routinely point at slots the producer has
// overwritten since. Returning 0 for those put a stamp of 0 into `x_bounds()`, and
// from there into `dx()` and `x0()` -- which is the renderer's entire index
// mapping, `(x - x0()) / dx()`. So *one* unreadable slot at the front inflated
// `dx()` by the ratio of the whole elapsed stream time to the retained window and
// pushed every index up with it: `first` landed well past the first visible
// sample, and the samples in between were in the buffer, readable by the crosshair
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel),
// and never drawn. That is the "live trace is clipped, only the most recent part
// is visible, but the crosshair still reads values there" defect.
// from there into `dx()`/`x0()` and every consumer of the window's extent. So
// *one* unreadable slot at the front inflated `dx()` by the ratio of the whole
// elapsed stream time to the retained window: the axis in `x_bounds()` read the
// stream's start rather than the window's, every measurement cut computed from it
// came out too wide, and the readout disagreed with what the crosshair said
// (`x_at()`/`neighbours()` go to the ring per sample and never saw the sentinel).
// That is the "live trace is clipped, only the most recent part is visible, but
// the crosshair still reads values there" defect.
//
// Clamping degrades the way the library's own `StreamSeries` documents for this
// contract: a snapshot bounds the sample *count*, not the slots, so the oldest few
+11 -9
View File
@@ -25,8 +25,8 @@ using plot::u64;
/// does not hold here, because x is a *streamed timestamp*, not an index: a lost
/// datagram leaves a gap and the samples either side of it are not one grid step
/// apart. `dx()` and `x0()` therefore report a nominal grid (what the LOD level
/// and the snap use), while `gather()` and `x_bounds()` report the real
/// timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read
/// and the snap use), while `gather()`, `x_bounds()`, `x_at()` and `neighbours()`
/// report the real timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read
/// only y, so they are exact either way.
///
/// The time ring is shared by every channel of one buffer (spec: the +1 slice);
@@ -56,13 +56,15 @@ class ChannelBuffer final : public plot::data::Series {
// --- plot::data::Series ---------------------------------------------------
u64 size() const override { return pyramid_.size(); }
f64 x0() const override;
/// The *mean* spacing of the retained window. The renderer converts a visible x
/// range into an index range with `(x - x0()) / dx()`, so this must describe the
/// samples that are actually retained: reporting the nominal `stride * dt` made
/// that index range exceed the series by the decimation factor, the gather
/// clamped to nothing, and the trace drew empty -- which is exactly what a fast
/// channel looked like next to a slow one, because only the slow one is
/// recognisable from the few samples a wrong range still happens to cover.
/// The *mean* spacing of the retained window, and a nominal quantity: the renderer
/// picks the visible index window through `neighbours()` over the real stamps, not
/// through `(x - x0()) / dx()`, precisely because this is only a local average (a
/// lost datagram moves the real spacing around it). It still has to describe the
/// samples that are actually retained: the LOD level is chosen from the window's
/// sample count divided by its pixel width, so reporting the nominal `stride * dt`
/// over a decimated window picks a level the pyramid cannot serve and the trace
/// draws empty -- which is exactly what a fast channel looked like next to a slow
/// one.
f64 dx() const override;
/// The authoritative x of a sample: the streamed timestamp, not a grid. `dx()`
/// is only the mean spacing (the LOD level and a coarse snap use it); a lost
+4 -2
View File
@@ -103,8 +103,10 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
// otherwise -- including mid-capture, where the rows follow the live ring too. Reading
// the ring while an event is on screen would export data the user cannot see, and
// reading the stale snapshot mid-capture would export the *previous* event.
std::shared_ptr<const EventSnapshot> snap = model.event();
if (model.display_mode() == DisplayMode::Held && snap != nullptr) {
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
// snapshots, so the export is the picture the user is looking at either way.
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot();
if (snap != nullptr) {
// The snapshot is immutable and already index-aligned, so the visible window is two
// binary searches over its stamps and the same index range for every channel.
const std::vector<f32>& st = snap->stamps;
+34
View File
@@ -372,6 +372,40 @@ void ScopeModel::reset_live_history_locked() {
}
}
void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
auto snap = std::make_shared<EventSnapshot>();
std::lock_guard<std::mutex> lock(mutex_);
BufferStore& src = live();
u64 first = 0;
u64 last = 0;
live_window_locked(lo_s, hi_s, first, last);
const u64 n = last - first;
snap->stamps.resize(static_cast<std::size_t>(n));
if (n > 0) {
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the
// dates under the reader the way a trigger event's edge-relative axis deliberately does.
src.time().copy_from(first, n, snap->stamps.data());
}
snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
for (std::size_t c = 0; c < channels_.size(); ++c) {
const u64 stride = std::max<u64>(1, part_.stride[c]);
auto& vals = snap->values[c];
const u64 take = std::min(n / stride, src.values(c).size());
vals.resize(static_cast<std::size_t>(take));
if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo;
for (f32 v : vals) {
lo = std::min(lo, v);
hi = std::max(hi, v);
}
snap->y[c] = plot::Range{lo, hi};
}
pause_view_.store(std::move(snap), std::memory_order_release);
}
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
auto snap = std::make_shared<EventSnapshot>();
BufferStore& src = live();
+36
View File
@@ -157,6 +157,10 @@ enum class DisplayMode {
Capturing, ///< an edge was accepted: the live ring, edge-relative clock
Held, ///< the frozen snapshot of the last event
Waiting, ///< armed, nothing captured yet: blank
/// The user paused: the acquisition thread keeps writing the live ring, and the plot draws
/// the view that was frozen when the pause began -- a snapshot, exactly as `Held` is, so
/// resuming costs no data.
Paused,
};
/// The application's data model: the stream description, the two sample buffers,
@@ -238,6 +242,33 @@ class ScopeModel {
///
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
/// The frozen view taken when the user paused, or null while running. A pause is a
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
/// series the plot binds to -- and the difference is the anchoring: an event's stamps are
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause
/// begins and the reader keeps the times they were already reading.
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
return pause_view_.load(std::memory_order_acquire);
}
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what
/// is frozen is the picture, not the data.
void publish_pause_view(double lo_s, double hi_s);
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
/// bind, the measurement cut and the export all have to agree about what is on screen.
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
void set_paused(bool on) noexcept { paused_.store(on, std::memory_order_relaxed); }
/// The snapshot the plot should draw from, if any: the pause's frozen view while acquisition
/// is paused, else the trigger's held event. One accessor, so the bind, the measurements and
/// the export cannot disagree about which picture is on screen.
std::shared_ptr<const EventSnapshot> display_snapshot() const noexcept {
if (paused()) return pause_view();
return display_mode() == DisplayMode::Held ? event() : nullptr;
}
/// The event on screen, or null before the first latch.
std::shared_ptr<const EventSnapshot> event() const noexcept {
return event_.load(std::memory_order_acquire);
@@ -293,6 +324,9 @@ class ScopeModel {
/// answer around: calling it twice can straddle a latch, and then the bind and the
/// axis disagree about which clock they are on.
DisplayMode display_mode() const noexcept {
// A pause is the most recent instruction, so it wins over the trigger's own state: what the
// user last asked for is a still picture.
if (paused()) return DisplayMode::Paused;
if (!trigger_enabled()) return DisplayMode::Live;
if (trigger_pending()) return DisplayMode::Capturing;
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
@@ -372,6 +406,8 @@ class ScopeModel {
std::atomic<bool> trigger_pending_{false};
std::atomic<double> capture_edge_{0.0};
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
std::atomic<std::shared_ptr<const EventSnapshot>> pause_view_; ///< the view frozen at pause
std::atomic<bool> paused_{false};
/// Per-signal-name calibration (see `set_calibration`).
std::unordered_map<std::string, std::pair<double, double>> calibration_;
std::atomic<std::uint64_t> event_seq_{0};
+238 -38
View File
@@ -30,9 +30,17 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
/// mistaken for something the user placed.
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
/// The window-start caption's colour: quiet chrome, brighter than a grid line.
constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u;
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
/// the data is distinguishable by *form* from the instruments around it.
constexpr float kMeasureStrokeWidth = 0.75f;
constexpr float kMeasureDashOn = 4.0f;
constexpr float kMeasureDashOff = 4.0f;
/// The gap between two rows, in device pixels. The rows used to be separate widgets in
/// a box, so there was a widget gap; a figure tiles its viewport, and without a gap two
@@ -138,11 +146,29 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
// place that can say no.
r.plot->set_cursor_selectable(r.measure_h[mi], false);
r.plot->set_cursor_selectable(r.measure_v[mi], false);
// Thin and dashed: a measurement's mark is an *annotation over the data* -- a measured
// level, a peak, a pair of extremes -- and the solid 1.5 px chrome stroke it used to take
// made it read as another instrument framing the plot. The trigger marker below keeps that
// stroke on purpose: it marks an event the scope acted on, which is a different claim.
r.plot->set_cursor_stroke(r.measure_h[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
}
// The window-start caption for the relative x labels: an annotation, hidden until the
// labels are relative. Inside the axes on purpose -- its text changes with the window, and
// an axis title of a changing width would relayout the plot every frame.
r.time_origin_note = r.plot->add_annotation(
plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false});
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
// A marker, not an instrument: it must sit exactly on the edge's time, so its
// snap is Free and the controller never rounds it onto a sample.
r.plot->set_cursor_snap(r.trigger_marker, plot::SnapMode::Free);
// And it is a *readout* like the measurement marks above, for the same reason: it says where the
// scope put the edge, which is not something the pointer may drag. Parking it outside the axis
// does not stop a drag -- the controller holds the marker for the gesture's life and follows the
// pointer between frames -- so excluding it from the hit test is the only place that can say no.
r.plot->set_cursor_selectable(r.trigger_marker, false);
// No `set_x_follow(Live)`: the link group owns the window *and* the follow state, and
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
@@ -258,6 +284,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
// the *clock*, which the display epoch carries, not the binding.
const std::size_t ci = static_cast<std::size_t>(channel);
switch (mode) {
case DisplayMode::Paused:
// The view frozen at the pause. The ring behind it keeps filling, which is the point
// of a pause that does not stop acquisition.
if (auto frozen = model.pause_view()) {
r.slots[si].bind_event(std::move(frozen), ci);
} else {
r.slots[si].bind(nullptr);
}
break;
case DisplayMode::Held:
if (auto snap = model.event()) {
r.slots[si].bind_event(std::move(snap), ci);
@@ -325,7 +360,7 @@ void RowSet::set_y_range(int row_index, double lo, double hi) {
r.plot->set_y_range(r.axis_y, {a, b});
}
void RowSet::place_cursors(Row& r) {
void RowSet::place_cursors(Row& r, plot::CursorPlacement placement) {
if (!r.plot) return;
const bool on = cursors_on_;
// Which instruments the mode uses; the rest are parked (a `Plot` cannot drop a
@@ -333,10 +368,13 @@ void RowSet::place_cursors(Row& r) {
const bool vertical = on && cursor_mode_ != CursorMode::Horizontal;
const bool horizontal = on && (cursor_mode_ == CursorMode::Horizontal ||
cursor_mode_ == CursorMode::Both);
r.plot->set_cursor_x(r.cursor_a, vertical ? cursor_a_x_ : kParked);
r.plot->set_cursor_x(r.cursor_b, vertical ? cursor_b_x_ : kParked);
r.plot->set_cursor_y(r.ruler, horizontal ? r.ruler_y : kParked);
r.plot->set_cursor_y(r.ruler_b, horizontal ? r.ruler_b_y : kParked);
const double a = vertical ? cursor_a_x_ : kParked;
const double b = vertical ? cursor_b_x_ : kParked;
r.plot->set_cursor_x(r.cursor_a, a, placement);
r.plot->set_cursor_x(r.cursor_b, b, placement);
r.plot->set_cursor_y(r.ruler, horizontal ? r.ruler_y : kParked, placement);
r.plot->set_cursor_y(r.ruler_b, horizontal ? r.ruler_b_y : kParked, placement);
}
void RowSet::set_cursor_mode(CursorMode mode) {
@@ -452,6 +490,161 @@ void RowSet::set_measure_marks(const std::vector<MeasureMark>& marks) {
}
}
bool RowSet::update_time_labels() {
Row& first = row(0);
if (!first.plot || !figure_) return false;
const plot::Range view = first.plot->x_range(first.axis_x);
const double span = view.span();
if (!(span > 0.0) || !std::isfinite(span)) return false;
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker
// between two forms while a zoom is nudged.
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0;
const bool changed = want_relative != relative_x_;
relative_x_ = want_relative;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// The axis takes the origin *from its own range* at refresh while relative: the host cannot
// predict where the follow will place the window (that is derived from the newest sample at
// layout time), and a value written here is a frame of scroll stale -- more than a
// millisecond-scale window shows. `x_origin_` is read back below for the caption.
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
// one whose x axis is the figure's). Every row gets it parked.
const bool bottom = i == rows_ - 1;
if (r.time_origin_note >= r.plot->annotation_count()) continue;
plot::Annotation note = r.plot->annotation(r.time_origin_note);
note.visible = relative_x_ && bottom;
if (note.visible) {
// Inside the axes, just above the axis line, at their left edge: the reader adds this
// to a tick, so it sits beside the ticks.
const plot::Range yr = r.plot->y_range(r.axis_y);
note.x = view.min;
note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0);
char text[64];
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
// layout set from its range. Reading it back (rather than keeping our own copy) is what
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
const double shown = r.plot->x_axis_origin(r.axis_x);
x_origin_ = shown;
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
note.text = text;
}
r.plot->set_annotation(r.time_origin_note, note);
}
return changed;
}
void RowSet::hold_pinned_window() {
if (!figure_ || !pinned_) return;
// `Off` as well as the range: a rebuild or a mode change may have left the group following,
// and a following group would re-derive the window from a live edge on the next layout --
// ignoring the pin altogether.
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
const plot::Range pin{-pinned_pre_, pinned_post_};
// The *request*: what the user's gesture left on the row's own axis. A member's `set_x_range`
// -- what the controller writes -- is not adopted into the group while the group is not
// following, so reading the group here only ever saw the previous clamp's answer and the wheel
// did nothing at all. The group is preferred when the member has *not* moved since our own last
// statement, which is what keeps a fresh pin (`pin_event_window()`, `pin_pause_window()`, both
// of which write the group in this same tick) from being reverted to the member's stale window.
plot::Range now = figure_->x_link_range(x_group_);
if (rows_ > 0 && row(0).plot != nullptr) {
const plot::Range member = row(0).plot->x_range(row(0).axis_x);
const bool member_is_ours = std::abs(member.min - pinned_stated_.min) < 1e-12 &&
std::abs(member.max - pinned_stated_.max) < 1e-12;
if (!member_is_ours) now = member;
}
const double pin_span = pin.span();
// **A zoom *in* is allowed.** The rule is that the view may not leave the time window, and a
// window narrower than the event's is still inside it -- it shows less of the same event. This
// used to re-state the pin every frame, which made the wheel do nothing at all in single
// trigger mode, so an acquired event could not be examined. What is clamped is the *span* (at
// the pin's) and the *position* (inside it): together, "never outside".
if (!(pin_span > 0.0) || !std::isfinite(pin_span)) return;
const double span = std::clamp(now.span(), std::min(1.0e-6, pin_span), pin_span);
const double lo = std::clamp(now.min, pin.min, pin.max - span);
pinned_stated_ = plot::Range{lo, lo + span};
figure_->set_x_link_range(x_group_, pinned_stated_);
}
void RowSet::pin_pause_window(double lo, double hi) {
// The pin is the **snapshot's own span**, not the window that was requested when the pause began.
// The cut is clamped to what the ring holds, so a window that reaches back past the oldest
// retained sample produces a *shorter* snapshot -- and pinning the axis to the request left the
// difference as an empty margin on the left, visible before any zoom and made worse by one
// (zooming in moved the view *into* it). The event pin already works this way ("the event axis is
// pinned from the snapshot's own stamps, not from the request"); this is the pause agreeing.
const plot::Range pin{lo, hi};
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
if (!r.plot) continue;
r.plot->set_x_follow(r.axis_x, plot::XFollow::Off);
r.plot->set_x_range(r.axis_x, pin);
}
set_data_pin(pin.min, pin.max);
if (figure_) {
figure_->set_x_link_follow(x_group_, plot::XFollow::Off);
figure_->set_x_link_range(x_group_, pin);
}
}
bool RowSet::readouts_locked() const noexcept {
for (int i = 0; i < kMaxRows; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
if (r.plot->cursor(r.trigger_marker).selectable) return false;
for (const plot::u32 id : r.measure_h) {
if (r.plot->cursor(id).selectable) return false;
}
for (const plot::u32 id : r.measure_v) {
if (r.plot->cursor(id).selectable) return false;
}
}
return true;
}
bool RowSet::event_data_window(double& lo, double& hi) const {
bool have = false;
for (int i = 0; i < rows_; ++i) {
const Row& r = row(i);
if (!r.plot) continue;
for (int s = 0; s < kSlotsPerRow; ++s) {
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
if (ev == nullptr || ev->size() < 2) continue;
const plot::Range b = ev->x_bounds();
if (!std::isfinite(b.min) || !std::isfinite(b.max) || !(b.max > b.min)) continue;
lo = have ? std::min(lo, b.min) : b.min;
hi = have ? std::max(hi, b.max) : b.max;
have = true;
}
}
return have;
}
void RowSet::set_data_pin(double lo, double hi) {
if (!(hi > lo) || !std::isfinite(lo) || !std::isfinite(hi)) return;
pinned_ = true;
pinned_pre_ = -lo;
pinned_post_ = hi;
}
void RowSet::unpin_pause_window() {
// The same path the window control and a go-live take: re-arm the follow and frame the newest
// span, so a resume lands on the data that arrived while paused rather than on the frozen
// frame's left edge.
set_time_window(window_s_);
}
void RowSet::fit_x() {
Row& first = row(0);
if (!first.plot || !figure_) return;
@@ -469,24 +662,17 @@ void RowSet::fit_x() {
}
}
void RowSet::sync_cursors() {
if (!cursors_visible()) return;
void RowSet::sync_cursors(int drag_row) {
if (!cursors_visible() || drag_row < 0 || drag_row >= rows_) return;
// Only a mode that *shows* the vertical pair reads them back. With them parked (the
// `horizontal` mode) whatever the plot reports is our own park coming back -- and a
// snapped cursor parked far outside the axis reads back as something finite, which
// would be adopted as a gesture and destroy the shared times.
// Anything within this much of what we last pushed is our own placement coming back
// (the plot stores f64, so an exact compare would be brittle across a rebuild).
const auto moved = [](double a, double b) {
return std::fabs(a - b) > 1.0e-9 * std::max(1.0, std::fabs(b));
};
bool push = false;
for (int i = 0; i < rows_; ++i) {
Row& r = row(i);
if (!r.plot) continue;
// A gesture writes the *plot*; this is the only place its result is visible. A
// ruler is its own row's value -- nothing to share, but it still has to be read
// back or the pane would keep showing wherever the app last put it.
Row& r = row(drag_row);
if (!r.plot) return;
// A gesture writes the *plot*; while it runs, this is its only visible result. A ruler is its
// own row's value -- nothing to share, but it still has to be read back or the pane would
// keep showing wherever the app last put it.
const bool rulers = cursor_mode_ == CursorMode::Horizontal || cursor_mode_ == CursorMode::Both;
if (rulers) {
const double y = r.plot->cursor(r.ruler).y;
@@ -494,21 +680,25 @@ void RowSet::sync_cursors() {
if (y > kParked * 0.5) r.ruler_y = y;
if (yb > kParked * 0.5) r.ruler_b_y = yb;
}
if (cursor_mode_ == CursorMode::Horizontal) continue; // the pair is parked: not a value
bool push = false;
if (cursor_mode_ != CursorMode::Horizontal) {
const double a = r.plot->cursor(r.cursor_a).x;
const double b = r.plot->cursor(r.cursor_b).x;
if (a < kParked * 0.5 || b < kParked * 0.5) continue; // parked: not a value
if (moved(a, cursor_a_x_)) {
if (a > kParked * 0.5 && b > kParked * 0.5) {
if (a != cursor_a_x_) {
cursor_a_x_ = a;
push = true;
}
if (moved(b, cursor_b_x_)) {
if (b != cursor_b_x_) {
cursor_b_x_ = b;
push = true;
}
}
}
if (push) {
for (int i = 0; i < rows_; ++i) place_cursors(row(i));
// `Free`: the value came out of the plot a moment ago and is already the free position the
// controller is tracking; snapping it here would fight the hand that is still moving it.
for (int i = 0; i < rows_; ++i) place_cursors(row(i), plot::CursorPlacement::Free);
}
place_crossings();
}
@@ -536,27 +726,37 @@ void RowSet::clamp_to_data() {
have = true;
}
if (!have) return;
const double retained = data.max - data.min;
const double span = figure_->x_link_range(x_group_).span();
if (!(span > 0.0) || !std::isfinite(span)) return;
// The *requested* window is intent, and a buffer that has not filled it yet must not
// be allowed to redefine it. This runs every frame and cannot tell "the user zoomed
// out past the data" from "the ring has only just started filling", and the first
// frames after a signal is enabled hold a couple of hundred samples: clamping to
// those shrank a requested 2 s window to ~0.02 s, and nothing here ever grows a span
// back, so the view stayed pinned to the newest 20 ms of a 6.5 s buffer for the rest
// of the run. That is the loudest form of the "live trace is clipped, only the most
// recent part is visible, data is there because the crosshair reads it" defect -- the
// data was never clipped at all, the window was. Clamping against `max(retained,
// window_s_)` leaves a partly filled window empty on its left, which is what a scope
// that just started should look like.
// The bound is the **requested window itself** -- what the window control states, capped by
// `--max-window` -- and not `retained`, how much the ring happens to hold.
//
// The difference is the whole point of this function, and both halves of it have bitten:
// clamping to `retained` alone broke the *early* case, because the first frames after a signal
// is enabled hold a couple of hundred samples, a requested 2 s window was clamped to the
// ~0.02 s they covered, and nothing here ever grows a span back -- the view stayed pinned to
// the newest 20 ms of a 6.5 s buffer for the rest of the run. Loosening the bound to
// `max(retained, window_s_)` fixed that and broke the *late* case instead: while the ring is
// still filling the two are equal, but once it holds more than the window the bound becomes the
// whole ring, so zooming out walked the view past the window into data the window was never set
// to show -- "more points than the time window in the buffer, and it still zooms out of it".
// `window_s_` is the right bound in both states: it never shrinks a window the caller asked
// for, and it is the app's own statement of what the view *is*. A partly filled buffer is left
// with an empty left-hand side, which is what a scope that just started should look like.
//
// The span only. A pan is the group's `offset` behind its live edge and the group
// clamps it against what its members retain on every layout, so there is nothing
// here to do about `view.min < data.min` -- and doing it anyway would state a maximum
// away from the live edge, i.e. ask the group to scroll back.
const double limit = std::max(retained, window_s_);
if (span > limit) figure_->set_x_link_range(x_group_, {data.max - limit, data.max});
if (!(window_s_ > 0.0)) return;
// A *relative* tolerance, not an exact comparison: the range handed to the group is exactly
// `window_s_` wide, but the group's span comes back through f64 and can land an ULP above it --
// and then a clamp that re-frames on `span > window_s_` re-frames *every frame*, which is a
// flickering view rather than a clamped one. The tolerance is far below a pixel of data.
if (span > window_s_ * (1.0 + 1.0e-9)) {
++clamp_fires_;
figure_->set_x_link_range(x_group_, {data.max - window_s_, data.max});
}
}
void RowSet::pin_event_window(double pre_s, double post_s) {
+101 -8
View File
@@ -106,6 +106,11 @@ class RowSet {
/// it keeps its position whatever the cursor toggle says.
plot::u32 trigger_marker = 0;
/// The caption naming the window's start while the x labels are relative: an annotation
/// inside the axes, so its text can change every frame without moving the layout (an axis
/// *title* would, and it would be a different width each time).
plot::u32 time_origin_note = 0;
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
std::array<plot::u32, kMeasureMarks> measure_h{};
@@ -208,6 +213,19 @@ class RowSet {
/// end up with its instruments parked while every other row showed them.
bool cursors_visible() const noexcept { return cursors_on_; }
/// Whether the x labels are currently written relative to the window's start, and what that
/// start was. The mode is decided by the span (see `update_time_labels()`), which is why it
/// is state rather than a per-frame expression: the switch has hysteresis, so a span
/// hovering on the boundary cannot make the labels flicker between two forms.
bool time_labels_relative() const noexcept { return relative_x_; }
/// The value the x labels are relative to (meaningless when `time_labels_relative()` is
/// false). The host prints it beside the axis so the reader can add it to a tick.
double time_labels_origin() const noexcept { return x_origin_; }
/// Chooses the x label form for the window the rows are showing and applies it: relative
/// with the window's own start as the origin when the span is sub-second, absolute
/// otherwise. Called once per frame from the tick, and returns true when the form changed.
bool update_time_labels();
/// One measurement's readout mark on a row: a *level* (drawn as a horizontal ruler) or a
/// *time* (a vertical marker). Which one a measurement needs is the measurement's own
/// business; the row is the one that plots its signal.
@@ -227,18 +245,75 @@ class RowSet {
/// measurement is drawn", as opposed to merely computed.
int measure_marks_placed() const noexcept { return measure_marks_placed_; }
/// Whether every instrument this app *computed* is out of the pointer's reach: the trigger
/// marker and the measurement marks. They are readouts -- where the scope put the edge, what a
/// measurement measured -- and a user must not be able to drag them; the report in the dump is
/// how that is checked, since a drag is not something a probe can perform.
bool readouts_locked() const noexcept;
/// How many times `clamp_to_data()` re-framed the view. A clamp that fires every frame is a
/// *loop*, not a clamp -- and `span > window_s_` can stay true after clamping to exactly
/// `window_s_`, because the group's span is the range's span through f64 and lands a ULP over.
/// Reported in the dump so a flickering view has a number rather than a description.
int clamp_fires() const noexcept { return clamp_fires_; }
/// The window the rows are actually on: the *group's* range, which is what the gestures and the
/// clamps write. A member's own range only catches up at the next layout, so a probe that reads
/// a member to judge a clamp sees the previous frame's window.
plot::Range view_range() const noexcept {
return figure_ ? figure_->x_link_range(x_group_) : plot::Range{0.0, 0.0};
}
/// Re-states the pinned window, so a gesture cannot move it.
///
/// In trigger mode the group follows nothing: `clamp_to_data()` is live-only by design, 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 with the follow off nothing pulled a drag back -- a pan
/// or a zoom-out walked the frozen capture off its axis and left an empty plot over data the
/// scope never had. The view is kept inside the event instead: the span is capped at the event's
/// and the position clamped into it, so a **zoom in is allowed** (less of the same event is
/// still the event) while leaving it is not. Doing it by re-stating the pin -- this function's
/// first version -- made the wheel a no-op, i.e. an acquired event could not be examined at all.
/// Does nothing when no window is pinned.
void hold_pinned_window();
/// Pins the axes where they are for the pause's frozen view: the snapshot is not a streaming
/// series, so live follow has nothing to derive a window from, and the frame the pause froze
/// has to stay the frame on screen. `unpin_pause_window()` restores following and re-frames on
/// the newest data, which is what a resume should show.
/// `lo`/`hi` are the *snapshot's* own stamps -- the data the frozen view actually holds, which
/// may be narrower than the window that was requested when the pause began.
void pin_pause_window(double lo, double hi);
/// The window the bound **event data** spans: the union of the event series the rows hold. What
/// a `Held` view may be navigated *within* -- the snapshot's stamps, never the recorded
/// `pre_s`/`post_s` split, which is the *request* and can exceed what was captured. False when
/// no row holds an event.
bool event_data_window(double& lo, double& hi) const;
/// Sets the pin without touching what is on screen: the bound a gesture is clamped into. The
/// `Held` tick re-states it every frame (the snapshot does not move, so it is idempotent) while
/// a user's zoom -- which is a narrower window *inside* the pin -- survives.
void set_data_pin(double lo, double hi);
void unpin_pause_window();
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the
/// window should be.
void fit_x();
/// Adopts the instruments a gesture moved. The vertical pair is one time for the whole
/// figure, so a drag on any row's marker moves every row's; a ruler belongs to its own
/// row and is only read back. Either way the plot is where a drag writes, so this is the
/// one place the app can see it -- and the pane reads what is stored here, which is why a
/// ruler that was not read back showed a stale value while its marker on the plot moved.
/// Called once a frame from the tick.
void sync_cursors();
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
/// a cursor), and nothing otherwise.
///
/// Reading the plots is only meaningful *while a drag runs*: `cursor(id).x` reports the free
/// pointer position then, and the snapped one otherwise -- which is what this very code wrote
/// a frame earlier (or the snap of it). Adopting outside a drag therefore adopts our own echo
/// back and a row's stale copy of the shared time, which is what made a dragged cursor spring
/// to the position it had before the gesture. The vertical pair is one time for the whole
/// figure, so a drag on any row moves every row's; a ruler belongs to its own row alone.
/// Called once a frame from the tick, with `drag_row` < 0 when no cursor is held.
void sync_cursors(int drag_row);
/// Puts both vertical cursors at a quarter and three quarters of the x window, and
/// the two rulers at a third and two thirds of each row's y range. Also switches the
@@ -309,7 +384,13 @@ class RowSet {
static void clear_row(Row& r);
void install(Row& r, int row_index, plot::Plot& p);
void place_cursors(Row& r);
/// Writes a row's instruments from the stored values. `placement` is passed through to the
/// plot: a *programmatic* placement (reset, a mode change) wants the default `Snapped`, so
/// the marker lands on a sample; echoing a value a *drag* just produced wants `Free`, because
/// snapping it again is what made a dragged cursor spring back -- the library snaps a cursor
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
/// mid-drag has already been through that act.
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
/// The newest x any row's live traces hold, which is what the group follows.
bool live_edge(double& newest) const;
@@ -325,11 +406,23 @@ class RowSet {
double pinned_pre_ = 0.0;
double pinned_post_ = 0.0;
bool pinned_ = false;
int clamp_fires_ = 0;
/// The window the pinned clamp last stated. A row's own axis that differs from this is a
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
plot::Range pinned_stated_{0.0, 0.0};
double y_gutter_px_ = 0.0;
bool y_gutter_reserved_ = false;
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
CursorMode cursor_mode_ = CursorMode::Vertical;
bool cursors_on_ = false;
bool relative_x_ = false;
double x_origin_ = 0.0;
/// What was last *written* to each row's own vertical cursors. A row is only evidence of a
/// gesture when its value differs from its own last write: the pair's value is shared, so a
/// row that has not been re-written yet still holds the previous shared value, and adopting
/// *that* would undo the row the user actually dragged -- which is what made a drag spring
/// back, one row's stale echo at a time.
int measure_marks_placed_ = 0;
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
double cursor_a_x_ = 0.0;
+640 -55
View File
@@ -82,6 +82,11 @@ struct App {
int dbg_cursors_step = 0;
/// Which step of the fit probe runs next (`UDPSCOPE_PROBE_FIT_MS`).
int dbg_fit_step = 0;
/// Which step of the x-label-form probe runs next (`UDPSCOPE_PROBE_TIMELABELS_MS`).
int dbg_timelabels_step = 0;
/// The cursors probe's own bookkeeping: the stored time before a gestureless move, so the
/// report can assert the move was ignored.
double dbg_gestureless_before = 0.0;
/// What `PlotArea::create()` is handed. The area is in figure mode from the start and
/// never draws this, but it keeps a reference to the plot it was created with (its
/// single-plot controller holds one) -- and every plot of the figure is destroyed by a
@@ -171,7 +176,29 @@ struct App {
bool measure_was_held = false;
/// The visible samples, per channel, for the *pass*: several measurements on one signal
/// must not copy the ring several times.
std::unordered_map<int, std::pair<std::vector<double>, std::vector<double>>> measure_samples;
/// The visible window of one channel, with the extremes several measurements on it share:
/// each of them needs `lo`/`hi` (a threshold is a share of the range, and two of the types
/// *are* the range), and the scan is over every visible sample.
struct MeasureWindow {
std::vector<double> time;
std::vector<double> value;
MeasureStats stats;
};
std::unordered_map<int, MeasureWindow> measure_samples;
/// When the pane was last recomputed, for the refresh cadence above.
gint64 measure_stamp_us = 0;
bool measure_stamp_valid = false;
/// What the pane cost: the recomputes it ran and the wall time they took. A readout that is
/// recomputed per frame over a live window is exactly the kind of thing that is invisible in a
/// frame mean, so it is measured where it happens and reported in the dump.
gint64 measure_cost_us = 0;
int measure_passes = 0;
/// The last pass' own duration, which the cadence adapts to.
gint64 measure_last_pass_us = 0;
/// The last *valid* result per measurement, which the marks fall back to: a level is a property
/// of the signal and survives the window it was measured in, so the rulers do not blink off when
/// a pass lands on a window with no samples (a rebuild, a capture boundary, a mode change).
std::vector<MeasureResult> measure_last_valid;
/// The marks the last computation produced: re-placed every frame, which is what makes the
/// measurement cursors read-only.
std::vector<RowSet::MeasureMark> measure_marks;
@@ -259,6 +286,12 @@ struct App {
/// Frames drawn with every bound trace slot empty although its channel has
/// samples: the view was swapped and not yet published.
int dbg_blank_frames = 0;
/// The *renderer's* blank: a frame that staged no trace geometry while every bound trace held
/// samples. `dbg_blank_frames` cannot see this one -- it asks the rings, and a ring that holds
/// data while the renderer draws nothing is exactly the "the data disappears for a frame" glitch.
int dbg_render_blank_frames = 0;
/// The last frame's staged segment count, so the tick can judge the frame the renderer drew.
unsigned long long dbg_seg_last = 0;
/// Frames the one area staged: a figure is one `::render` for every row.
int dbg_frames_painted = 0;
/// Where a frame's time goes, split at the two boundaries that can be observed from
@@ -309,6 +342,20 @@ struct App {
int dbg_axes_w_max = -(1 << 20);
int dbg_axes_w_prev = -1;
int dbg_axes_w_changes = 0;
/// What the pan probe asked for, so the frame after it can be compared against it: a capture
/// during the probe is not a value either side can recompute, and a capturing lambda cannot be
/// a `GSourceFunc`.
double dbg_pan_asked_lo = 0.0;
double dbg_pan_asked_hi = 0.0;
bool dbg_pan_asked = false;
/// Which gesture the probe is on: 1 = the pan was asked and its verdict is due, 2 = the
/// zoom-out was asked and its verdict is due.
int dbg_gesture_stage = 0;
/// A verdict the *tick* owes, one frame after a gesture was asked: 1 pan, 2 zoom-out, 3
/// zoom-in. Reported from the frame loop rather than a `g_timeout`, because a 300 ms timer
/// slips past the state it was meant to observe as soon as the frames are heavy -- which is
/// exactly the load this probe runs under.
int dbg_gesture_verdict = 0;
int dbg_axes_w_change_frame = -1;
int dbg_axes_w_change_side = -1;
float dbg_scale_min = 1e9f;
@@ -773,6 +820,7 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
app->dbg_seg_sum += s.staged_segments;
app->dbg_seg_max = std::max(app->dbg_seg_max, s.staged_segments);
app->dbg_visible_max = std::max(app->dbg_visible_max, s.visible_samples);
app->dbg_seg_last = s.staged_segments;
if (s.raw_traces > 0) ++app->dbg_raw_frames;
if (s.lod_traces > 0) ++app->dbg_lod_frames;
}
@@ -1492,6 +1540,30 @@ void on_pause_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool paused = gtk_toggle_button_get_active(button);
app->acq->set_paused(paused);
app->model->set_paused(paused);
if (paused) {
// Freeze the *view*: snapshot exactly the window on screen, and pin the axes there. The
// acquisition thread keeps writing the live ring behind it, so resuming shows the data that
// arrived while paused instead of a hole.
if (RowSet::Row& r0 = app->rows->row(0); r0.plot != nullptr) {
const plot::Range view = r0.plot->x_range(r0.axis_x);
const double epoch = app->model->display_epoch();
app->model->publish_pause_view(view.min - epoch, view.max - epoch);
// Pin on what the snapshot *got*: the cut is clamped to the ring, so it can be narrower than
// the view, and pinning the request left the shortfall as an empty left margin.
plot::Range pin = view;
if (std::shared_ptr<const EventSnapshot> snap = app->model->pause_view()) {
if (!snap->stamps.empty()) {
pin = plot::Range{static_cast<double>(snap->stamps.front()),
static_cast<double>(snap->stamps.back())};
}
}
app->rows->pin_pause_window(pin.min, pin.max);
}
} else {
app->model->clear_pause_view();
app->rows->unpin_pause_window();
}
gtk_button_set_icon_name(GTK_BUTTON(button), paused ? "media-playback-start-symbolic"
: "media-playback-pause-symbolic");
gtk_widget_set_tooltip_text(GTK_WIDGET(button), paused ? "resume acquisition"
@@ -1553,19 +1625,23 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
if (r0.plot == nullptr) return false;
const plot::Range view = r0.plot->x_range(r0.axis_x);
if (app.model->display_mode() == DisplayMode::Held) {
std::shared_ptr<const EventSnapshot> snap = app.model->event();
if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) {
if (snap == nullptr || channel >= snap->values.size()) return false;
const std::vector<f32>& st = snap->stamps;
const std::vector<f32>& vals = snap->values[channel];
if (vals.size() != st.size()) return false;
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
for (auto it = lo; it != hi; ++it) {
time.push_back(static_cast<double>(*it));
value.push_back(static_cast<double>(vals[static_cast<std::size_t>(it - st.begin())]));
const std::size_t count = static_cast<std::size_t>(hi - lo);
if (count == 0) return false;
const std::size_t off = static_cast<std::size_t>(lo - st.begin());
time.resize(count);
value.resize(count);
for (std::size_t i = 0; i < count; ++i) {
time[i] = static_cast<double>(st[off + i]);
value[i] = static_cast<double>(vals[off + i]);
}
return !time.empty();
return true;
}
std::lock_guard<std::mutex> lock(app.model->mutex());
@@ -1577,17 +1653,26 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
app.model->live_window_locked(view.min - epoch, view.max - epoch, first, last);
const u64 n = last - first;
if (n == 0) return false;
std::vector<f32> stamps(static_cast<std::size_t>(n));
std::vector<f32> vals(static_cast<std::size_t>(n));
store.time().copy_from(first, n, stamps.data());
store.values(channel).copy_from(first, n, vals.data());
time.reserve(stamps.size());
value.reserve(vals.size());
for (std::size_t i = 0; i < stamps.size(); ++i) {
time.push_back(epoch + static_cast<double>(stamps[i]));
value.push_back(static_cast<double>(vals[i]));
time.resize(static_cast<std::size_t>(n));
value.resize(static_cast<std::size_t>(n));
// In bounded chunks rather than through two whole-window temporaries: the rings hold f32 and
// the engine reads f64, so the widening is unavoidable, but the *copies* are not -- a full
// window is millions of samples, and the temporaries cost a full write and read of it on top
// of the result. `kChunk` keeps the scratch in cache.
constexpr u64 kChunk = 8192;
std::vector<f32> scratch(kChunk);
for (u64 at = 0; at < n; at += kChunk) {
const u64 take = std::min<u64>(kChunk, n - at);
store.time().copy_from(first + at, take, scratch.data());
for (u64 i = 0; i < take; ++i) {
time[static_cast<std::size_t>(at + i)] = epoch + static_cast<double>(scratch[i]);
}
return !time.empty();
store.values(channel).copy_from(first + at, take, scratch.data());
for (u64 i = 0; i < take; ++i) {
value[static_cast<std::size_t>(at + i)] = static_cast<double>(scratch[i]);
}
}
return true;
}
/// The channel a measurement's signal resolved to, or -1 while the stream does not carry it.
@@ -1626,6 +1711,16 @@ std::string measure_unit(App& app, const std::string& signal) {
return "";
}
/// How often the measurements pane is recomputed while the window is still moving: a readout,
/// where the honest currency is the window's own settling, not the frame rate. Ten a second is
/// past the point a number can be read, and it makes the cost independent of the framerate.
constexpr gint64 kMeasureRefreshUs = 100 * 1000;
/// A pass that costs more than this is a *hitch*, not a refresh: the pane waits four times as
/// long next time. Bounding the interval alone does not bound what a frame pays -- a window of
/// thirteen million samples costs 13 ms, which is most of a frame -- so the cadence follows the
/// *work*, and what stays bounded is the duty cycle at any window size.
constexpr gint64 kMeasureHeavyUs = 5 * 1000;
void rebuild_measures_pane(App& app);
void update_measures(App& app, bool force);
void open_measure_dialog(App& app, int edit_index);
@@ -1997,6 +2092,13 @@ void measure_dialog_sync(MeasureDialog* d) {
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (ruler at the value, marker where it is)");
break;
case MeasureType::PeakToPeak:
case MeasureType::Amplitude:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
gtk_label_set_text(GTK_LABEL(d->side_label), "");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
"show on plot (rulers at both extremes)");
break;
case MeasureType::Top:
case MeasureType::Bottom:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range");
@@ -2212,7 +2314,32 @@ void update_measures(App& app, bool force) {
std::fabs(view.min - app.measure_window.min) +
std::fabs(view.max - app.measure_window.max) >
0.02 * std::max(1.0e-12, view.span());
if (!force && app.measure_window_valid && !moved && held == app.measure_was_held) {
// A measurement is a statistic of the visible window, and *a live view moves that window every
// frame* -- so "recompute when the window moves" is "recompute every frame", at O(visible
// samples) per measurement. That is what stalled the frame tick (0.4 ms -> 13.7 ms, a third of
// the run's frames lost) once the window held a few million samples. The pane is a *readout*,
// so it is refreshed on the terms the status line's own fields already use: at most every
// `kMeasureRefreshUs`, plus immediately whenever the window stops moving or the mode changes,
// which is when the number has to be exact. Between those a tenth of a second of lag is
// invisible on a statistic that is itself a window average, and the marks below are re-placed
// from the last result every frame either way.
const gint64 now_us = g_get_monotonic_time();
const gint64 interval =
app.measure_last_pass_us > kMeasureHeavyUs ? 4 * kMeasureRefreshUs : kMeasureRefreshUs;
const bool due = !app.measure_stamp_valid || now_us - app.measure_stamp_us >= interval;
// Two reasons to skip, and they are different questions. `measure_window` is the window the
// *last recompute described*, so no change at all means the pane already says the right thing:
// that is the settled case, and it must recompute once when it settles and then never again --
// getting this backwards is what had a *pinned* window recomputing every single frame (457
// passes in one run) instead of once. The other is a window still scrolling: at most one pass
// per `kMeasureRefreshUs`, because the number is a window average and a tenth of a second of
// lag on it is not readable.
const bool changed = !app.measure_window_valid || held != app.measure_was_held || moved;
if (!force && !changed) {
app.rows->set_measure_marks(app.measure_marks);
return;
}
if (!force && moved && held == app.measure_was_held && !due) {
// Nothing to recompute -- but the marks are still re-placed below, which is what makes
// them read-only: a gesture moves one on the plot, and this is what puts it back.
app.rows->set_measure_marks(app.measure_marks);
@@ -2221,33 +2348,51 @@ void update_measures(App& app, bool force) {
app.measure_window = view;
app.measure_window_valid = true;
app.measure_was_held = held;
app.measure_stamp_us = now_us;
app.measure_stamp_valid = true;
const gint64 pass_start_us = g_get_monotonic_time();
app.measure_samples.clear();
app.measure_results.assign(app.measures.size(), MeasureResult{});
app.measure_last_valid.resize(app.measures.size());
app.measure_text.assign(app.measures.size(), std::string("--"));
app.measure_marks.clear();
std::vector<RowSet::MeasureMark> marks;
struct PassTimer {
App& app;
gint64 start;
~PassTimer() {
const gint64 took = g_get_monotonic_time() - start;
app.measure_cost_us += took;
app.measure_last_pass_us = took;
++app.measure_passes;
}
} pass_timer{app, pass_start_us};
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
GtkWidget* label = app.measure_labels[i];
const int channel = measure_channel(app, m.signal);
std::vector<double> t;
std::vector<double> v;
if (channel < 0) {
// Its signal is momentarily unresolvable -- a rebuild renumbers the channels and empties
// the list -- so there is nothing to measure *this* pass. The marks still fall back (below);
// `continue`ing here was the other half of the rulers blinking off.
app.measure_text[i] = "--";
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue;
}
{
// One copy of the visible window per channel per pass, shared by every measurement on
// that signal (the common case is several on one trace).
// One *window* per channel per pass, shared by every measurement on that signal (the common
// case is several on one trace). It is held by reference from here on: this loop used to copy
// both vectors out of the cache for every measurement, which at four measurements over a
// 4.7M-sample window is 300 MB of memcpy per frame -- memory-bandwidth bound, and the tick's
// mean went from 0.4 ms to 13.7 ms with the pane holding them.
auto it = app.measure_samples.find(channel);
if (it == app.measure_samples.end()) {
std::pair<std::vector<double>, std::vector<double>> fresh;
if (visible_samples(app, static_cast<std::size_t>(channel), fresh.first, fresh.second)) {
App::MeasureWindow fresh;
if (visible_samples(app, static_cast<std::size_t>(channel), fresh.time, fresh.value)) {
fresh.stats = measure_stats(fresh.time, fresh.value);
it = app.measure_samples.emplace(channel, std::move(fresh)).first;
}
} else if (it->second.first.empty()) {
} else if (it->second.time.empty()) {
it = app.measure_samples.end();
}
if (it == app.measure_samples.end()) {
@@ -2255,11 +2400,10 @@ void update_measures(App& app, bool force) {
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
continue;
}
t = it->second.first;
v = it->second.second;
}
const MeasureResult r = compute_measure(t, v, m.type, m.options);
const App::MeasureWindow& w = it->second;
const MeasureResult r = compute_measure(w.time, w.value, m.type, m.options, &w.stats);
app.measure_results[i] = r;
if (r.valid) app.measure_last_valid[i] = r;
if (label != nullptr) {
std::string text = "--";
if (r.valid) {
@@ -2279,14 +2423,32 @@ void update_measures(App& app, bool force) {
app.measure_text[i] = text;
gtk_label_set_text(GTK_LABEL(label), text.c_str());
}
if (m.options.show_on_plot && r.valid) {
}
// The marks last, in their own pass over the measurements, so that no `continue` above can skip
// them. That is exactly how the rulers came to blink off -- first at a mode change, then at a
// rebuild where the signal is momentarily unresolvable -- while the plot put them back a few
// frames later, which reads as the lines flickering.
//
// A pass with no answer falls back to the last valid one, and the split is by *kind*: a
// **level** is a property of the signal (a mean, a top, an amplitude) and outlives the window it
// was measured in, so its ruler stays; a **time** mark is a position *inside* the window that
// result describes, so it may not outlive it and is dropped.
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const Measure& m = app.measures[i];
if (!m.options.show_on_plot) continue;
const MeasureResult& r = app.measure_results[i];
const MeasureResult* mk = &r;
if (!r.valid && i < app.measure_last_valid.size() && app.measure_last_valid[i].valid) {
mk = &app.measure_last_valid[i];
}
const int row = measure_row(app, m.signal);
const plot::u32 color = measure_color(i);
for (const MeasureMarkSpec& spec : r.marks) {
for (const MeasureMarkSpec& spec : mk->marks) {
if (!spec.level && !r.valid) continue;
marks.push_back({row, spec.level, spec.value, color});
}
}
}
app.measure_marks = std::move(marks);
app.rows->set_measure_marks(app.measure_marks);
}
@@ -2818,7 +2980,21 @@ gboolean tick(gpointer user) {
if (model.generation() != app->signal_generation) rebuild_signal_panels(*app);
// A gesture moves the *plot's* cursor; adopting it here is what makes the two
// vertical markers one time across every row, and what the pane then reads.
app->rows->sync_cursors();
app->rows->update_time_labels();
// Which row's controller holds a cursor: the only row whose plots are worth reading back
// (`cursor(id).x` is the free pointer position while a drag runs and the snapped value
// otherwise, so reading outside a gesture reads our own echo).
int cursor_drag_row = -1;
if (app->area != nullptr) {
for (int i = 0; i < app->rows->row_count(); ++i) {
plot::input::Controller* c = app->area->row_controller(static_cast<plot::u32>(i));
if (c != nullptr && c->grabbed_cursor() != plot::kInvalid) {
cursor_drag_row = i;
break;
}
}
}
app->rows->sync_cursors(cursor_drag_row);
update_cursors_pane(*app);
update_measures(*app, false);
@@ -2863,6 +3039,69 @@ gboolean tick(gpointer user) {
app->rows->acquire(model);
}
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
// `g_timeout` was the first version and it drifted past the paused/resumed boundary, so the
// "zoom in" verdict read the *live* window and called a working fix broken.
if (app->dbg_gesture_verdict != 0 && app->rows->row_count() > 0) {
const int was_verdict = app->dbg_gesture_verdict;
{
const RowSet::Row& r0 = app->rows->row(0);
const plot::Range after = app->rows->view_range();
if (app->dbg_gesture_verdict == 1) {
const bool moved = std::abs(after.min - app->dbg_pan_asked_lo) < 1e-9 &&
std::abs(after.max - app->dbg_pan_asked_hi) < 1e-9;
std::fprintf(stderr, "[pan-probe] after=[%.6f..%.6f] held=%d asked=[%.6f..%.6f]\n",
after.min, after.max, moved ? 0 : 1, app->dbg_pan_asked_lo,
app->dbg_pan_asked_hi);
// The next gesture: a zoom-out ten times the current span, anchored where a wheel zoom
// about the right edge would put it.
const double span = after.span();
if (span > 0.0) {
const plot::Range zoomed{after.max - 10.0 * span, after.max};
app->dbg_pan_asked_lo = zoomed.min;
app->dbg_pan_asked_hi = zoomed.max;
const_cast<RowSet::Row&>(r0).plot->set_x_range(r0.axis_x, zoomed);
// Judged on the next frame's tick: the member's range is only pushed by the layout.
app->dbg_gesture_verdict = 2;
}
} else if (app->dbg_gesture_verdict == 2) {
const double window = app->rows->time_window();
std::fprintf(stderr,
"[pan-probe] zoom asked=[%.6f..%.6f] after=[%.6f..%.6f] span=%.6f "
"window=%.6f limited=%d\n",
app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max,
after.span(), window, after.span() <= window * (1.0 + 1e-9) ? 1 : 0);
// The last gesture: a window a quarter of what is on screen, about its centre. A pinned
// view must allow it -- "not outside the event" is not "not readable".
const double span = after.span();
if (span > 0.0 && r0.plot != nullptr) {
const double centre = 0.5 * (after.min + after.max);
const plot::Range zoomed{centre - 0.125 * span, centre + 0.125 * span};
app->dbg_pan_asked_lo = zoomed.min;
app->dbg_pan_asked_hi = zoomed.max;
const_cast<RowSet::Row&>(r0).plot->set_x_range(r0.axis_x, zoomed);
app->dbg_gesture_verdict = 3;
}
} else if (app->dbg_gesture_verdict == 3) {
// The zoom-in ask was a quarter of the span: allowed means the view actually narrowed.
const double asked = app->dbg_pan_asked_hi - app->dbg_pan_asked_lo;
const plot::Range member = r0.plot != nullptr ? r0.plot->x_range(r0.axis_x)
: plot::Range{0.0, 0.0};
std::fprintf(stderr,
"[pan-probe] zoom-in asked=[%.6f..%.6f] group=[%.6f..%.6f] member=[%.6f..%.6f]"
" span=%.6f allowed=%d\n",
app->dbg_pan_asked_lo, app->dbg_pan_asked_hi, after.min, after.max,
member.min, member.max, after.span(),
after.span() <= 2.0 * asked ? 1 : 0);
}
std::fflush(stderr);
// Cleared only if this frame did not hand the probe the *next* gesture: a report that asks
// for the next one must leave it armed for the following frame.
app->dbg_gesture_verdict = app->dbg_gesture_verdict == was_verdict ? 0 : app->dbg_gesture_verdict;
app->dbg_gesture_stage = 0;
}
}
// Nothing to do about the rows' x windows: they are one window, the x link group's.
// A gesture changes the row it landed on, `Figure::layout()` adopts that change into
// the group and pushes it to the others, so the "which row acted" search this replaced
@@ -2873,7 +3112,24 @@ gboolean tick(gpointer user) {
// reports the acquisition buffer's raw coordinates, dragging the view along the ring.
// Clamping is a live-view concern only, and a capture is live data on a pinned axis,
// which is exactly the case it must not touch.
if (!app->event_held && !app->capturing) app->rows->clamp_to_data();
if (app->event_held && !app->capturing) {
// A held event is navigable *within its data*. `pin_event_window()` framed the axis from the
// snapshot's recorded `pre_s`/`post_s` -- the *request* -- and a capture shorter than it (a
// ring that had not reached back a full pre-trigger window, or a trimmed one) left the
// shortfall as an empty margin, exactly as the pause did. The pin is the snapshot's own stamps
// here; the framing's split is what the marker at x = 0 already says.
double lo = 0.0;
double hi = 0.0;
if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi);
app->rows->hold_pinned_window();
} else if (display == DisplayMode::Paused || app->capturing) {
// Pinned: the event (or the frozen pause), not a navigation surface. `clamp_to_data()` is
// the live-mode half of the same rule -- a live view must stay on the data -- and the two
// are complements, which is why this is one branch rather than two conditions.
app->rows->hold_pinned_window();
} else {
app->rows->clamp_to_data();
}
// Curator: the trigger's arm state can change under us (single mode disarms
// itself), so the button and the status line read the published atomics.
@@ -3001,6 +3257,8 @@ gboolean tick(gpointer user) {
}
}
if (bound > 0 && empty == bound) ++app->dbg_blank_frames;
// And the renderer's blank: the rings hold samples, yet the frame staged nothing.
if (bound > 0 && empty == 0 && app->dbg_seg_last == 0) ++app->dbg_render_blank_frames;
}
// The gutter the rows need, measured from the frame just laid out, imposed as a
@@ -3399,7 +3657,7 @@ void note_starvation(App& app) {
if (!starved) {
app.starve_reported = false;
return;
// (the zoom-in is judged on the next frame, after the layout pushes it)
}
app.notice =
"connected, but no data is arriving: another reader may hold the source (a unicast "
@@ -3901,15 +4159,27 @@ gboolean trig_trace_probe(gpointer user) {
dx_real = n >= 2 ? shown->x_at(n - 1) - shown->x_at(n - 2) : 0.0;
bound_lo = shown->x_bounds().min;
bound_hi = shown->x_bounds().max;
if (dx_nominal > 0.0) {
const auto clamp_index = [n](double v) -> long long {
if (!(v > 0.0)) return 0;
return v >= double(n) ? (long long)n : (long long)v;
};
got_lo = clamp_index(std::floor((axis.min - sx0) / dx_nominal) - 1.0);
got_hi = clamp_index(std::ceil((axis.max - sx0) / dx_nominal) + 1.0);
}
u64 i0 = 0, i1 = 0;
(void)sx0;
if (dx_nominal > 0.0) {
// What the renderer draws, through the series' own x: it used to walk the
// `(x - x0()) / dx()` grid, and on a ring-backed series that landed the window inside
// the visible range and dropped the two segments that draw into the left and right
// margins. `neighbours()`, one sample of slack either side, is the whole of it now --
// the same call the crosshair reads through. The *guard* against the grid walk coming
// back is the library's own pixel test
// (`a_sparse_window_between_samples_still_inks_both_margins`), because a probe that
// recomputes the renderer's formula cannot see one that no longer matches it; what this
// line still says is the rate: `dx_ratio` is how far the nominal grid sits from the real
// stamps, which is what made the walk wrong in the first place.
// A window edge outside the series keeps the renderer's own default on that side: 0 on
// the left (draw from the oldest sample) and n on the right (draw to the newest), which
// is why a view reaching past the data leaves the margin beyond it genuinely empty.
got_lo = shown->neighbours(axis.min, i0, i1) ? (i0 > 0 ? (long long)i0 - 1 : 0) : 0;
got_hi = shown->neighbours(axis.max, i0, i1)
? std::min<long long>((long long)i1 + 1, (long long)n)
: (long long)n;
}
want_lo = shown->neighbours(axis.min, i0, i1) ? (long long)i0
: (axis.min < shown->x_at(0) ? 0 : (long long)n);
want_hi = shown->neighbours(axis.max, i0, i1) ? (long long)i1 + 1
@@ -3935,6 +4205,93 @@ gboolean trig_trace_probe(gpointer user) {
return G_SOURCE_CONTINUE;
}
/// Headless self-check: adds one measurement of each statistical type, which is what a user does
/// from the pane's `+`.
///
/// It exists because the frame cost with measurements on is a thing to *measure*, not to reason
/// about: a measurement is a statistic of the visible window, and a live view moves that window
/// every frame. The values land in the exit dump (`measurements:`), next to `frame cost:`.
gboolean measure_trace(gpointer user);
gboolean measure_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::string name;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
// A *channel*, not `signals().front()`: the first signal of a stream is its timestamp, which
// is not acquired and has no samples to measure. That mistake made the probe add four
// measurements that all read `--` -- and, because a measurement with no channel bails before
// collecting anything, it also made the first baseline look free.
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
name = a->model->channels().front().name;
}
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
// The marks are what a wrong window shows up in, so the probe asks for them.
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
update_measures(*a, true);
std::fprintf(stderr, "[measure-probe] %zu measurements on '%s'\n", a->measures.size(),
name.c_str());
std::fflush(stderr);
g_timeout_add(250, measure_trace, a);
return G_SOURCE_REMOVE;
}
/// Repeating trace of the pane's numbers beside the *current* window and the marks actually
/// placed: the only way a stale result is visible as a number rather than as a stutter.
gboolean measure_trace(gpointer user) {
App* a = static_cast<App*>(user);
if (a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
const plot::Range view = r0.plot->x_range(r0.axis_x);
std::string line = "[measure-trace] view=[" + std::to_string(view.min) + ".." +
std::to_string(view.max) + "]";
for (std::size_t i = 0; i < a->measures.size() && i < a->measure_results.size(); ++i) {
const MeasureResult& r = a->measure_results[i];
line += " " + std::string(measure_type_name(a->measures[i].type)) + "=" +
(r.valid ? std::to_string(r.primary) : std::string("--"));
}
line += " marks=" + std::to_string(a->measure_marks.size());
for (const RowSet::MeasureMark& m : a->measure_marks) {
line += " " + std::string(m.level ? "level" : "time") + ":" + std::to_string(m.value);
}
std::fprintf(stderr, "%s\n", line.c_str());
std::fflush(stderr);
return G_SOURCE_CONTINUE;
}
/// Headless self-check: a gesture must not move a pinned window.
///
/// It writes the far-away range the way a gesture does -- on the *member* axis, which
/// `Figure::layout()` adopts into the group -- and then reports the axis the frame after it
/// produced: `held=1` when the pin survived, and the panned range when it did not.
gboolean pan_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot == nullptr) return G_SOURCE_REMOVE;
const plot::Range before = r0.plot->x_range(r0.axis_x);
const double span = before.span();
if (!(span > 0.0)) return G_SOURCE_REMOVE;
// Where the data is not: ten windows to the left, the same order of magnitude a drag moves.
const plot::Range panned{before.min - 10.0 * span, before.max - 10.0 * span};
r0.plot->set_x_range(r0.axis_x, panned);
std::fprintf(stderr, "[pan-probe] asked=[%.6f..%.6f] was=[%.6f..%.6f]\n", panned.min,
panned.max, before.min, before.max);
std::fflush(stderr);
a->dbg_pan_asked_lo = panned.min;
a->dbg_pan_asked_hi = panned.max;
a->dbg_gesture_verdict = 1;
return G_SOURCE_REMOVE;
}
/// Headless self-check: arms the trigger the way the toolbar's trigger switch does.
gboolean arm_probe(gpointer user) {
App* a = static_cast<App*>(user);
@@ -4289,11 +4646,53 @@ gboolean disarm_probe_step1(gpointer user) {
return G_SOURCE_REMOVE;
}
/// What the pause work is *for*: the acquisition keeps filling the ring while the picture is
/// frozen on the view the pause snapshotted, and resuming shows the data that arrived meanwhile
/// instead of a hole. Reported from the probe below.
void pause_probe_report(App& a, const char* what) {
double bounds_lo = 0.0;
double bounds_hi = 0.0;
std::size_t event_size = 0;
std::size_t ring_produced = 0;
{
std::lock_guard<std::mutex> lock(a.model->mutex());
if (!a.model->channels().empty()) ring_produced = a.model->live().values(0).produced();
}
if (const EventSeries* ev = a.rows->row(0).slots[0].bound_event()) {
event_size = ev->size();
const plot::Range b = ev->x_bounds();
bounds_lo = b.min;
bounds_hi = b.max;
}
// The axis beside the data, because those two disagreeing is the whole failure: the frozen view
// is the *snapshot*, and a pin taken from the requested window instead of from the snapshot left
// the difference as an empty margin. `frozen_x` must equal `axis` while paused.
plot::Range axis{0.0, 0.0};
if (a.rows->row_count() > 0 && a.rows->row(0).plot != nullptr) {
const RowSet::Row& r0 = a.rows->row(0);
axis = r0.plot->x_range(r0.axis_x);
}
std::fprintf(stderr,
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f]\n",
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
double(axis.min), double(axis.max));
std::fflush(stderr);
}
/// Headless self-check: pause the acquisition and reset the movement sample, so
/// what is measured is a still picture -- the state a wiggle is most visible in.
gboolean pause_probe(gpointer user) {
App* a = static_cast<App*>(user);
// Through the button, so the *view* snapshot and the axis pin run: pausing is a display
// state, not only an acquisition one.
pause_probe_report(*a, "live");
if (a->pause_button != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->pause_button), TRUE);
} else {
a->acq->set_paused(true);
a->model->set_paused(true);
}
a->dbg_axes_x_min = 1 << 20;
a->dbg_axes_x_max = -(1 << 20);
a->dbg_axes_w_min = 1 << 20;
@@ -4304,6 +4703,27 @@ gboolean pause_probe(gpointer user) {
a->dbg_axes_w_change_side = -1;
a->dbg_vp_w_min = 1 << 20;
a->dbg_vp_w_max = -(1 << 20);
// Reported again a second later (the probe is re-armed below) and once after the resume: the
// ring count must have grown while the frozen window did not move.
static bool reported_paused = false;
if (!reported_paused) {
reported_paused = true;
g_timeout_add(1200, +[](gpointer user) {
App* app = static_cast<App*>(user);
pause_probe_report(*app, "paused");
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(1600, +[](gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->pause_button), FALSE);
return G_SOURCE_REMOVE;
}, a);
g_timeout_add(2200, +[](gpointer user) {
App* app = static_cast<App*>(user);
pause_probe_report(*app, "resumed");
return G_SOURCE_REMOVE;
}, a);
}
a->dbg_xtick_min = 1 << 20;
a->dbg_xtick_max = -(1 << 20);
a->dbg_gutter_min = 1 << 20;
@@ -4435,6 +4855,79 @@ void fit_probe_report(App& a, const char* what) {
std::fflush(stderr);
}
/// The x label form across a zoom, driven through the *app's* window path (`apply_time_window`:
/// under the figure's x link group the group owns the window, so a row-level `set_time_window`
/// is ignored). Absolute at 2 s and at 500 ms, relative below a second with the caption naming
/// the anchor and the ticks written against it.
gboolean timelabels_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const char* what = "at 2 s";
switch (a->dbg_timelabels_step) {
case 0:
apply_time_window(*a, 2.0);
break;
case 1:
what = "at 500 ms";
apply_time_window(*a, 0.5);
break;
case 2:
what = "at 50 ms";
apply_time_window(*a, 0.05);
break;
default:
// Ends *in* a sub-second window: the dump runs after the last paint, so it is the
// race-free place to read whether the zero tick really sits on the left edge.
what = "final 20 ms";
apply_time_window(*a, 0.02);
break;
}
++a->dbg_timelabels_step;
if (a->dbg_timelabels_step <= 4) {
g_timeout_add(400, timelabels_probe_step, a);
}
// Report the *previous* step's state: the labels are laid out on the frame after the window
// changes, so reading them in the same call would report the old form.
const RowSet::Row& r0 = a->rows->row(0);
const RowSet::Row& last = a->rows->row(a->rows->row_count() - 1);
std::string ticks;
std::string note;
if (last.plot != nullptr) {
int n = 0;
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (!t.major) continue;
if (n++ != 0) ticks += ",";
ticks += t.label;
if (n >= 4) break;
}
if (last.time_origin_note < last.plot->annotation_count()) {
const plot::Annotation& an = last.plot->annotation(last.time_origin_note);
if (an.visible) note = an.text;
}
}
double first_value = 0.0;
plot::Range shown{};
if (last.plot != nullptr) {
for (const plot::Tick& t : last.plot->x_axis(last.axis_x).ticks) {
if (t.major) {
first_value = t.value;
break;
}
}
shown = last.plot->x_range(last.axis_x);
}
std::fprintf(stderr,
"[timelabels-probe] %-12s relative=%d window=%.6g origin=%.6f note=[%s] "
"ticks=[%s] first_value=%.6f first_rel=%.6f range=[%.6f..%.6f]\n",
what, a->rows->time_labels_relative() ? 1 : 0, a->rows->time_window(),
a->rows->time_labels_origin(), note.c_str(), ticks.c_str(), first_value,
first_value - (last.plot != nullptr ? last.plot->x_axis_origin(last.axis_x) : 0.0),
shown.min, shown.max);
std::fflush(stderr);
(void)r0;
return G_SOURCE_REMOVE;
}
gboolean fit_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
@@ -4502,9 +4995,12 @@ gboolean cursors_probe_step(gpointer user) {
cursors_probe_next(*a, 5, 300);
break;
case 5: {
// A drag, as the adapter delivers one: the *plot's* cursor moves and the frame
// tick is what notices. If the sync works, every row's marker follows.
// A plot-level move with *no gesture behind it*: the sync reads a row only while its
// controller holds a cursor, so this must be ignored. That is the invariant the
// "drag it and it springs back" defect broke -- the tick adopted a row's own echo, and a
// row that had not been re-written yet adopted the stale shared value straight back.
RowSet::Row& r0 = a->rows->row(0);
a->dbg_gestureless_before = a->rows->cursor_a_x();
if (r0.plot != nullptr) {
const plot::Range xr = r0.plot->x_range(r0.axis_x);
r0.plot->set_cursor_x(r0.cursor_a, xr.min + 0.20 * xr.span(), plot::CursorPlacement::Free);
@@ -4512,17 +5008,26 @@ gboolean cursors_probe_step(gpointer user) {
cursors_probe_next(*a, 6, 300);
break;
}
case 6:
case 6: {
const double after = a->rows->cursor_a_x();
std::fprintf(stderr,
"[cursors-probe] %-14s plot move with no gesture: stored %.6f -> %.6f "
"ignored=%d\n",
"gestureless", a->dbg_gestureless_before, after,
std::fabs(after - a->dbg_gestureless_before) < 1e-12 ? 1 : 0);
std::fflush(stderr);
cursors_probe_report(*a, "after drag");
// The other half of issue 1: a *ruler* is dragged through its own plot, and the
// pane's Y cells have to follow it. Nothing shares a ruler, so this is the read-back
// path alone -- the one that used to leave the numbers showing where the app last
// put them.
a->rows->set_cursor_mode(RowSet::CursorMode::Both);
// (The adoption half -- a gesture that *does* move a cursor -- is the library's own
// controller test plus the app's rule that the tick passes the row whose controller holds
// a cursor; a programmatic press here cannot reach the adapter's event pump, so this probe
// asserts the invariant that is reachable from here.)
cursors_probe_next(*a, 7, 300);
break;
}
case 7: {
cursors_probe_report(*a, "both");
// A ruler is dragged through its own plot, and the pane's Y cells have to follow it.
// Nothing shares a ruler, so this is the read-back path alone.
RowSet::Row& r0 = a->rows->row(0);
if (r0.plot != nullptr) {
const plot::Range yr = r0.plot->y_range(r0.axis_y);
@@ -4545,12 +5050,12 @@ gboolean cursors_probe_step(gpointer user) {
void measure_dialog_click(App& a, const char* label) {
if (a.measure_dialog == nullptr) {
std::fprintf(stderr, "[measures-probe] no dialog to click '%s' in\n", label);
return;
// (the zoom-in is judged on the next frame, after the layout pushes it)
}
GtkWidget* b = find_labelled_button(a.measure_dialog, label);
if (b == nullptr) {
std::fprintf(stderr, "[measures-probe] no '%s' button in the dialog\n", label);
return;
// (the zoom-in is judged on the next frame, after the layout pushes it)
}
g_signal_emit_by_name(b, "clicked");
}
@@ -4722,7 +5227,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.source_port = cfg.port;
GtkApplication* application =
gtk_application_new("org.udpscope.app", G_APPLICATION_DEFAULT_FLAGS);
// `NON_UNIQUE`: a scope is the kind of program a user runs twice side by side -- one
// instance per source, or two viewers of the same one -- and without it GApplication's
// D-Bus single-instance rule turns every later launch into an activation on the first
// (the new process exits at once, so only the first instance ever lives).
gtk_application_new("org.udpscope.app", G_APPLICATION_NON_UNIQUE);
g_signal_connect(application, "activate", G_CALLBACK(on_activate), &app);
// Headless self-check: exercise the toolbar's toggles, then exit and print what
@@ -4748,6 +5257,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), window_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_TIMELABELS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), timelabels_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measures_probe_step, &app);
}
@@ -4763,6 +5275,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), trig_trace_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), measure_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAN_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pan_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app);
}
@@ -4833,6 +5351,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_axes_w_changes, app.dbg_axes_w_change_frame,
app.dbg_axes_w_change_side);
std::printf("[udpscope] reset button visible: %d\n", app.dbg_reset_visible);
std::printf("[udpscope] render blank frames: %d of %d painted\n",
app.dbg_render_blank_frames, app.dbg_frames_painted);
std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames,
app.dbg_axes_frames);
std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it "
@@ -4855,6 +5375,30 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time,
app.dbg_trigger_time_valid);
std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str());
// Unconditional, unlike the pane's own lines below: this is about the instruments the app
// *created*, which exist whether or not any measurement was added.
std::printf("[udpscope] readout instruments locked: %d (trigger marker + measurement marks)\n",
app.rows->readouts_locked() ? 1 : 0);
// The measurements pane, with the numbers the engine computed: a probe adds measurements and
// this is where their values are read, so a wrong threshold or a window that moved under a
// stale result is visible in the run rather than only on screen.
if (!app.measures.empty()) {
std::printf("[udpscope] measurements: %zu", app.measures.size());
for (std::size_t i = 0; i < app.measures.size() && i < app.measure_results.size(); ++i) {
const MeasureResult& r = app.measure_results[i];
std::printf(" %s=%s", measure_type_name(app.measures[i].type),
r.valid ? std::to_string(r.primary).c_str() : "--");
if (r.has_secondary) std::printf("(%s)", std::to_string(r.secondary).c_str());
}
std::printf("\n");
std::printf("[udpscope] clamp fires: %d (a clamp that fires every frame is a re-frame loop)\n",
app.rows->clamp_fires());
if (app.measure_passes > 0) {
std::printf("[udpscope] measure cost: %d recomputes, %.2f ms total, %.2f ms each\n",
app.measure_passes, app.measure_cost_us / 1000.0,
app.measure_cost_us / 1000.0 / app.measure_passes);
}
}
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d\n", app.dbg_badge_frames[0],
app.dbg_badge_frames[1], app.dbg_badge_frames[2]);
std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; "
@@ -4946,6 +5490,47 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
}
std::printf("\n");
}
{
// The x label form: relative to the window's own start (with the caption that names
// that start) while the span is sub-second, absolute otherwise. `x_origin_set` is read
// back out of the plot, so this is what the axis is actually formatting with.
const RowSet::Row& rlt = rows.row(rows.row_count() - 1);
std::string note;
if (rlt.plot != nullptr && rlt.time_origin_note < rlt.plot->annotation_count()) {
const plot::Annotation& a = rlt.plot->annotation(rlt.time_origin_note);
if (a.visible) note = a.text;
}
// The labels themselves, from the axis the reader looks at: the assertion is what the
// ticks *say*, not only which mode is on.
std::string ticks;
if (rlt.plot != nullptr) {
int shown = 0;
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
if (!t.major) continue;
if (shown++ != 0) ticks += ",";
ticks += t.label;
if (shown >= 4) break;
}
}
// The labels against the anchor they were built with: the dump runs after the last
// paint, so unlike the probe's timer there is no race here -- the first major must sit
// on the origin (`first_rel` the fraction of a step the window has scrolled since).
double first_value = 0.0;
double origin_set = 0.0;
if (rlt.plot != nullptr) {
origin_set = rlt.plot->x_axis_origin(rlt.axis_x);
for (const plot::Tick& t : rlt.plot->x_axis(rlt.axis_x).ticks) {
if (t.major) {
first_value = t.value;
break;
}
}
}
std::printf("[udpscope] time labels: relative=%d origin=%.6f note=[%s] x_origin_set=%.6f "
"ticks=[%s] first_rel=%.9f\n",
rows.time_labels_relative() ? 1 : 0, rows.time_labels_origin(), note.c_str(),
origin_set, ticks.c_str(), first_value - origin_set);
}
{
std::lock_guard<std::mutex> lock(model.mutex());
// `results_for` is the window the numbers describe, `win` the one on screen now: the