improved measuraments ,cursors, zooming and user interactions
This commit is contained in:
1 parent
0eceaf537b
commit
02620ca946
12 files changed
+1488
-171
No files matched your search
@@ -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
|
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
|
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`,
|
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||||||
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`; README
|
`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`. The minor
|
||||||
records what they cured.
|
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
|
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:
|
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
|
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
|
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.
|
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
|
- `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
|
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
||||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
||||||
only the file chooser.
|
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)`
|
- `[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
|
(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
|
`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()`
|
`Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side —
|
||||||
*uniform grid* formula; `want` is the same window found by binary search over the real
|
and `want` is the same window from the same call, so read this line for the **rate**:
|
||||||
time ring, which is what `x_at()`/`neighbours()` — and so the crosshair — use. A
|
`dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that
|
||||||
`dx_ratio` above 1 scales every index up, `a` lands past the first visible sample, and
|
used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the
|
||||||
the samples in between sit in the buffer, readable by the crosshair, and are never
|
samples in between in the buffer and readable by the crosshair but never drawn) was the
|
||||||
drawn: the live view's older part goes blank while its newest part looks fine. Also
|
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 —
|
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
|
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
|
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
|
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
|
aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
|
||||||
the only check that distinguishes averaging from striding.
|
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 [..]`
|
- `[cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
|
||||||
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
|
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
|
||||||
`mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is
|
`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-
|
*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
|
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).
|
(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`) —
|
- `[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 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
|
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`,
|
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
|
never a row: the area keeps a reference to the plot it was created with, and a rebuild
|
||||||
destroys every row.
|
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
|
- **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
|
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
|
`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()`
|
- **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 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.
|
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
|
- **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
|
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()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
||||||
`gather()` emits x relative to `x0()` because absolute epoch seconds do not survive
|
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
|
||||||
`f32`.
|
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`
|
- **`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
|
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
|
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:
|
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
|
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.
|
*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
|
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
|
||||||
every frame and cannot distinguish "the user zoomed out past the data" from "the ring
|
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
|
||||||
has only just started filling", so it clamps against `max(retained, window_s_)`: a
|
from "the ring has only just started filling", so `window_s_` — what the window control
|
||||||
partly filled window is left empty on its left, which is what a scope that just
|
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
|
||||||
started should look like. Clamping to `retained` alone turned a requested 2 s window
|
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
|
||||||
into the ~0.02 s the first frames held, permanently — nothing there grows a span back.
|
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
|
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.
|
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
|
- **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
|
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
|
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.**
|
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
|
- **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
|
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]`
|
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
|
- **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
|
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.
|
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.**
|
- **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
|
`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
|
tick as the channel dump; the status line only formats what is already there. The
|
||||||
|
|||||||
@@ -60,6 +60,34 @@ single consumer), never through the lock.
|
|||||||
|
|
||||||
## Measurements
|
## 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:
|
**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
|
`[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
|
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
|
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
|
~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
|
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
|
against the **requested window** itself — not the retained span, which was the second
|
||||||
window simply has an empty left-hand side.
|
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,
|
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
|
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
|
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
|
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
|
`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
|
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,
|
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
|
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
|
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.
|
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
|
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
|
distinct intensities before, 10 after, with the labelled grid landing exactly on its
|
||||||
`grid_major`/`grid_minor` colours instead of a spread of partial blends.
|
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:
|
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*
|
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
|
||||||
that small keeps them.
|
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`,
|
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.
|
was verified to fail with its own fix reverted.
|
||||||
|
|
||||||
**Cursors start hidden** and are switched on from the overflow menu; **Reset cursors**
|
**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 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
|
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
|
out of the plot **while a gesture is on that row**, because a drag writes the *plot* and that is
|
||||||
it is visible. A ruler is per row: each row has its own y axis, so two rows' rulers are
|
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.
|
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
|
`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
|
hundred samples every fifth of a second made every frame span microseconds while
|
||||||
the frames were 100 ms apart).
|
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
|
**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
|
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
|
`silence_timeout_s` elapse, reconnects and displaces the other, and the two take turns
|
||||||
|
|||||||
Vendored
+1
-1
Submodule deps/plotter updated: 211c5b97bc...faef19ba60.
@@ -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) {
|
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());
|
std::lock_guard<std::mutex> lock(model_.mutex());
|
||||||
if (!running_.load(std::memory_order_relaxed)) return;
|
if (!running_.load(std::memory_order_relaxed)) return;
|
||||||
|
|||||||
+8
-8
@@ -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
|
// 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
|
// 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
|
// 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
|
// from there into `dx()`/`x0()` and every consumer of the window's extent. So
|
||||||
// mapping, `(x - x0()) / dx()`. So *one* unreadable slot at the front inflated
|
// *one* unreadable slot at the front inflated `dx()` by the ratio of the whole
|
||||||
// `dx()` by the ratio of the whole elapsed stream time to the retained window and
|
// elapsed stream time to the retained window: the axis in `x_bounds()` read the
|
||||||
// pushed every index up with it: `first` landed well past the first visible
|
// stream's start rather than the window's, every measurement cut computed from it
|
||||||
// sample, and the samples in between were in the buffer, readable by the crosshair
|
// 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),
|
// (`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
|
// That is the "live trace is clipped, only the most recent part is visible, but
|
||||||
// is visible, but the crosshair still reads values there" defect.
|
// the crosshair still reads values there" defect.
|
||||||
//
|
//
|
||||||
// Clamping degrades the way the library's own `StreamSeries` documents for this
|
// 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
|
// contract: a snapshot bounds the sample *count*, not the slots, so the oldest few
|
||||||
|
|||||||
+11
-9
@@ -25,8 +25,8 @@ using plot::u64;
|
|||||||
/// does not hold here, because x is a *streamed timestamp*, not an index: a lost
|
/// 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
|
/// 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
|
/// 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
|
/// and the snap use), while `gather()`, `x_bounds()`, `x_at()` and `neighbours()`
|
||||||
/// timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read
|
/// report the real timestamps. `reduce()`, `sum()` and `sumsq()` are the inherited ones and read
|
||||||
/// only y, so they are exact either way.
|
/// only y, so they are exact either way.
|
||||||
///
|
///
|
||||||
/// The time ring is shared by every channel of one buffer (spec: the +1 slice);
|
/// 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 ---------------------------------------------------
|
// --- plot::data::Series ---------------------------------------------------
|
||||||
u64 size() const override { return pyramid_.size(); }
|
u64 size() const override { return pyramid_.size(); }
|
||||||
f64 x0() const override;
|
f64 x0() const override;
|
||||||
/// The *mean* spacing of the retained window. The renderer converts a visible x
|
/// The *mean* spacing of the retained window, and a nominal quantity: the renderer
|
||||||
/// range into an index range with `(x - x0()) / dx()`, so this must describe the
|
/// picks the visible index window through `neighbours()` over the real stamps, not
|
||||||
/// samples that are actually retained: reporting the nominal `stride * dt` made
|
/// through `(x - x0()) / dx()`, precisely because this is only a local average (a
|
||||||
/// that index range exceed the series by the decimation factor, the gather
|
/// lost datagram moves the real spacing around it). It still has to describe the
|
||||||
/// clamped to nothing, and the trace drew empty -- which is exactly what a fast
|
/// samples that are actually retained: the LOD level is chosen from the window's
|
||||||
/// channel looked like next to a slow one, because only the slow one is
|
/// sample count divided by its pixel width, so reporting the nominal `stride * dt`
|
||||||
/// recognisable from the few samples a wrong range still happens to cover.
|
/// 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;
|
f64 dx() const override;
|
||||||
/// The authoritative x of a sample: the streamed timestamp, not a grid. `dx()`
|
/// 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
|
/// is only the mean spacing (the LOD level and a coarse snap use it); a lost
|
||||||
|
|||||||
+4
-2
@@ -103,8 +103,10 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
// otherwise -- including mid-capture, where the rows follow the live ring too. Reading
|
// 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
|
// 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.
|
// reading the stale snapshot mid-capture would export the *previous* event.
|
||||||
std::shared_ptr<const EventSnapshot> snap = model.event();
|
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
|
||||||
if (model.display_mode() == DisplayMode::Held && snap != nullptr) {
|
// 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
|
// 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.
|
// binary searches over its stamps and the same index range for every channel.
|
||||||
const std::vector<f32>& st = snap->stamps;
|
const std::vector<f32>& st = snap->stamps;
|
||||||
|
|||||||
@@ -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) {
|
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||||
auto snap = std::make_shared<EventSnapshot>();
|
auto snap = std::make_shared<EventSnapshot>();
|
||||||
BufferStore& src = live();
|
BufferStore& src = live();
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ enum class DisplayMode {
|
|||||||
Capturing, ///< an edge was accepted: the live ring, edge-relative clock
|
Capturing, ///< an edge was accepted: the live ring, edge-relative clock
|
||||||
Held, ///< the frozen snapshot of the last event
|
Held, ///< the frozen snapshot of the last event
|
||||||
Waiting, ///< armed, nothing captured yet: blank
|
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,
|
/// 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).
|
/// 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);
|
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.
|
/// The event on screen, or null before the first latch.
|
||||||
std::shared_ptr<const EventSnapshot> event() const noexcept {
|
std::shared_ptr<const EventSnapshot> event() const noexcept {
|
||||||
return event_.load(std::memory_order_acquire);
|
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
|
/// answer around: calling it twice can straddle a latch, and then the bind and the
|
||||||
/// axis disagree about which clock they are on.
|
/// axis disagree about which clock they are on.
|
||||||
DisplayMode display_mode() const noexcept {
|
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_enabled()) return DisplayMode::Live;
|
||||||
if (trigger_pending()) return DisplayMode::Capturing;
|
if (trigger_pending()) return DisplayMode::Capturing;
|
||||||
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
||||||
@@ -372,6 +406,8 @@ class ScopeModel {
|
|||||||
std::atomic<bool> trigger_pending_{false};
|
std::atomic<bool> trigger_pending_{false};
|
||||||
std::atomic<double> capture_edge_{0.0};
|
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>> 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`).
|
/// Per-signal-name calibration (see `set_calibration`).
|
||||||
std::unordered_map<std::string, std::pair<double, double>> calibration_;
|
std::unordered_map<std::string, std::pair<double, double>> calibration_;
|
||||||
std::atomic<std::uint64_t> event_seq_{0};
|
std::atomic<std::uint64_t> event_seq_{0};
|
||||||
|
|||||||
+249
-49
@@ -30,9 +30,17 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
|
|||||||
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
||||||
/// mistaken for something the user placed.
|
/// mistaken for something the user placed.
|
||||||
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
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 plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||||
|
|
||||||
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
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
|
/// 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
|
/// 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.
|
// place that can say no.
|
||||||
r.plot->set_cursor_selectable(r.measure_h[mi], false);
|
r.plot->set_cursor_selectable(r.measure_h[mi], false);
|
||||||
r.plot->set_cursor_selectable(r.measure_v[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));
|
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
|
// 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.
|
// snap is Free and the controller never rounds it onto a sample.
|
||||||
r.plot->set_cursor_snap(r.trigger_marker, plot::SnapMode::Free);
|
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
|
// 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.
|
// `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.
|
// the *clock*, which the display epoch carries, not the binding.
|
||||||
const std::size_t ci = static_cast<std::size_t>(channel);
|
const std::size_t ci = static_cast<std::size_t>(channel);
|
||||||
switch (mode) {
|
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:
|
case DisplayMode::Held:
|
||||||
if (auto snap = model.event()) {
|
if (auto snap = model.event()) {
|
||||||
r.slots[si].bind_event(std::move(snap), ci);
|
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});
|
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;
|
if (!r.plot) return;
|
||||||
const bool on = cursors_on_;
|
const bool on = cursors_on_;
|
||||||
// Which instruments the mode uses; the rest are parked (a `Plot` cannot drop a
|
// 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 vertical = on && cursor_mode_ != CursorMode::Horizontal;
|
||||||
const bool horizontal = on && (cursor_mode_ == CursorMode::Horizontal ||
|
const bool horizontal = on && (cursor_mode_ == CursorMode::Horizontal ||
|
||||||
cursor_mode_ == CursorMode::Both);
|
cursor_mode_ == CursorMode::Both);
|
||||||
r.plot->set_cursor_x(r.cursor_a, vertical ? cursor_a_x_ : kParked);
|
const double a = vertical ? cursor_a_x_ : kParked;
|
||||||
r.plot->set_cursor_x(r.cursor_b, vertical ? cursor_b_x_ : kParked);
|
const double b = vertical ? cursor_b_x_ : kParked;
|
||||||
r.plot->set_cursor_y(r.ruler, horizontal ? r.ruler_y : kParked);
|
r.plot->set_cursor_x(r.cursor_a, a, placement);
|
||||||
r.plot->set_cursor_y(r.ruler_b, horizontal ? r.ruler_b_y : kParked);
|
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) {
|
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() {
|
void RowSet::fit_x() {
|
||||||
Row& first = row(0);
|
Row& first = row(0);
|
||||||
if (!first.plot || !figure_) return;
|
if (!first.plot || !figure_) return;
|
||||||
@@ -469,46 +662,43 @@ void RowSet::fit_x() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::sync_cursors() {
|
void RowSet::sync_cursors(int drag_row) {
|
||||||
if (!cursors_visible()) return;
|
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
|
// 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
|
// `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
|
// snapped cursor parked far outside the axis reads back as something finite, which
|
||||||
// would be adopted as a gesture and destroy the shared times.
|
// 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
|
Row& r = row(drag_row);
|
||||||
// (the plot stores f64, so an exact compare would be brittle across a rebuild).
|
if (!r.plot) return;
|
||||||
const auto moved = [](double a, double b) {
|
// A gesture writes the *plot*; while it runs, this is its only visible result. A ruler is its
|
||||||
return std::fabs(a - b) > 1.0e-9 * std::max(1.0, std::fabs(b));
|
// 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;
|
||||||
|
const double yb = r.plot->cursor(r.ruler_b).y;
|
||||||
|
if (y > kParked * 0.5) r.ruler_y = y;
|
||||||
|
if (yb > kParked * 0.5) r.ruler_b_y = yb;
|
||||||
|
}
|
||||||
bool push = false;
|
bool push = false;
|
||||||
for (int i = 0; i < rows_; ++i) {
|
if (cursor_mode_ != CursorMode::Horizontal) {
|
||||||
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.
|
|
||||||
const bool rulers = cursor_mode_ == CursorMode::Horizontal || cursor_mode_ == CursorMode::Both;
|
|
||||||
if (rulers) {
|
|
||||||
const double y = r.plot->cursor(r.ruler).y;
|
|
||||||
const double yb = r.plot->cursor(r.ruler_b).y;
|
|
||||||
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
|
|
||||||
const double a = r.plot->cursor(r.cursor_a).x;
|
const double a = r.plot->cursor(r.cursor_a).x;
|
||||||
const double b = r.plot->cursor(r.cursor_b).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 (a > kParked * 0.5 && b > kParked * 0.5) {
|
||||||
if (moved(a, cursor_a_x_)) {
|
if (a != cursor_a_x_) {
|
||||||
cursor_a_x_ = a;
|
cursor_a_x_ = a;
|
||||||
push = true;
|
push = true;
|
||||||
}
|
}
|
||||||
if (moved(b, cursor_b_x_)) {
|
if (b != cursor_b_x_) {
|
||||||
cursor_b_x_ = b;
|
cursor_b_x_ = b;
|
||||||
push = true;
|
push = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (push) {
|
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();
|
place_crossings();
|
||||||
}
|
}
|
||||||
@@ -536,27 +726,37 @@ void RowSet::clamp_to_data() {
|
|||||||
have = true;
|
have = true;
|
||||||
}
|
}
|
||||||
if (!have) return;
|
if (!have) return;
|
||||||
const double retained = data.max - data.min;
|
|
||||||
const double span = figure_->x_link_range(x_group_).span();
|
const double span = figure_->x_link_range(x_group_).span();
|
||||||
if (!(span > 0.0) || !std::isfinite(span)) return;
|
if (!(span > 0.0) || !std::isfinite(span)) return;
|
||||||
// The *requested* window is intent, and a buffer that has not filled it yet must not
|
// The bound is the **requested window itself** -- what the window control states, capped by
|
||||||
// be allowed to redefine it. This runs every frame and cannot tell "the user zoomed
|
// `--max-window` -- and not `retained`, how much the ring happens to hold.
|
||||||
// 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
|
// The difference is the whole point of this function, and both halves of it have bitten:
|
||||||
// those shrank a requested 2 s window to ~0.02 s, and nothing here ever grows a span
|
// clamping to `retained` alone broke the *early* case, because the first frames after a signal
|
||||||
// back, so the view stayed pinned to the newest 20 ms of a 6.5 s buffer for the rest
|
// is enabled hold a couple of hundred samples, a requested 2 s window was clamped to the
|
||||||
// of the run. That is the loudest form of the "live trace is clipped, only the most
|
// ~0.02 s they covered, and nothing here ever grows a span back -- the view stayed pinned to
|
||||||
// recent part is visible, data is there because the crosshair reads it" defect -- the
|
// the newest 20 ms of a 6.5 s buffer for the rest of the run. Loosening the bound to
|
||||||
// data was never clipped at all, the window was. Clamping against `max(retained,
|
// `max(retained, window_s_)` fixed that and broke the *late* case instead: while the ring is
|
||||||
// window_s_)` leaves a partly filled window empty on its left, which is what a scope
|
// still filling the two are equal, but once it holds more than the window the bound becomes the
|
||||||
// that just started should look like.
|
// 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
|
// 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
|
// 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
|
// 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.
|
// away from the live edge, i.e. ask the group to scroll back.
|
||||||
const double limit = std::max(retained, window_s_);
|
if (!(window_s_ > 0.0)) return;
|
||||||
if (span > limit) figure_->set_x_link_range(x_group_, {data.max - limit, data.max});
|
// 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) {
|
void RowSet::pin_event_window(double pre_s, double post_s) {
|
||||||
|
|||||||
+101
-8
@@ -106,6 +106,11 @@ class RowSet {
|
|||||||
/// it keeps its position whatever the cursor toggle says.
|
/// it keeps its position whatever the cursor toggle says.
|
||||||
plot::u32 trigger_marker = 0;
|
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
|
/// 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.
|
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
|
||||||
std::array<plot::u32, kMeasureMarks> measure_h{};
|
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.
|
/// end up with its instruments parked while every other row showed them.
|
||||||
bool cursors_visible() const noexcept { return cursors_on_; }
|
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
|
/// 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
|
/// *time* (a vertical marker). Which one a measurement needs is the measurement's own
|
||||||
/// business; the row is the one that plots its signal.
|
/// business; the row is the one that plots its signal.
|
||||||
@@ -227,18 +245,75 @@ class RowSet {
|
|||||||
/// measurement is drawn", as opposed to merely computed.
|
/// measurement is drawn", as opposed to merely computed.
|
||||||
int measure_marks_placed() const noexcept { return measure_marks_placed_; }
|
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
|
/// 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
|
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the
|
||||||
/// window should be.
|
/// window should be.
|
||||||
void fit_x();
|
void fit_x();
|
||||||
|
|
||||||
/// Adopts the instruments a gesture moved. The vertical pair is one time for the whole
|
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
||||||
/// figure, so a drag on any row's marker moves every row's; a ruler belongs to its own
|
/// a cursor), and nothing otherwise.
|
||||||
/// 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
|
/// Reading the plots is only meaningful *while a drag runs*: `cursor(id).x` reports the free
|
||||||
/// ruler that was not read back showed a stale value while its marker on the plot moved.
|
/// pointer position then, and the snapped one otherwise -- which is what this very code wrote
|
||||||
/// Called once a frame from the tick.
|
/// a frame earlier (or the snap of it). Adopting outside a drag therefore adopts our own echo
|
||||||
void sync_cursors();
|
/// 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
|
/// 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
|
/// 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);
|
static void clear_row(Row& r);
|
||||||
|
|
||||||
void install(Row& r, int row_index, plot::Plot& p);
|
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.
|
/// The newest x any row's live traces hold, which is what the group follows.
|
||||||
bool live_edge(double& newest) const;
|
bool live_edge(double& newest) const;
|
||||||
|
|
||||||
@@ -325,11 +406,23 @@ class RowSet {
|
|||||||
double pinned_pre_ = 0.0;
|
double pinned_pre_ = 0.0;
|
||||||
double pinned_post_ = 0.0;
|
double pinned_post_ = 0.0;
|
||||||
bool pinned_ = false;
|
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;
|
double y_gutter_px_ = 0.0;
|
||||||
bool y_gutter_reserved_ = false;
|
bool y_gutter_reserved_ = false;
|
||||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||||
CursorMode cursor_mode_ = CursorMode::Vertical;
|
CursorMode cursor_mode_ = CursorMode::Vertical;
|
||||||
bool cursors_on_ = false;
|
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;
|
int measure_marks_placed_ = 0;
|
||||||
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
|
/// The one stream time each vertical cursor is at, whatever row's copy was last moved.
|
||||||
double cursor_a_x_ = 0.0;
|
double cursor_a_x_ = 0.0;
|
||||||
|
|||||||
+653
-68
@@ -82,6 +82,11 @@ struct App {
|
|||||||
int dbg_cursors_step = 0;
|
int dbg_cursors_step = 0;
|
||||||
/// Which step of the fit probe runs next (`UDPSCOPE_PROBE_FIT_MS`).
|
/// Which step of the fit probe runs next (`UDPSCOPE_PROBE_FIT_MS`).
|
||||||
int dbg_fit_step = 0;
|
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
|
/// 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
|
/// 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
|
/// 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;
|
bool measure_was_held = false;
|
||||||
/// The visible samples, per channel, for the *pass*: several measurements on one signal
|
/// The visible samples, per channel, for the *pass*: several measurements on one signal
|
||||||
/// must not copy the ring several times.
|
/// 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
|
/// The marks the last computation produced: re-placed every frame, which is what makes the
|
||||||
/// measurement cursors read-only.
|
/// measurement cursors read-only.
|
||||||
std::vector<RowSet::MeasureMark> measure_marks;
|
std::vector<RowSet::MeasureMark> measure_marks;
|
||||||
@@ -259,6 +286,12 @@ struct App {
|
|||||||
/// Frames drawn with every bound trace slot empty although its channel has
|
/// Frames drawn with every bound trace slot empty although its channel has
|
||||||
/// samples: the view was swapped and not yet published.
|
/// samples: the view was swapped and not yet published.
|
||||||
int dbg_blank_frames = 0;
|
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.
|
/// Frames the one area staged: a figure is one `::render` for every row.
|
||||||
int dbg_frames_painted = 0;
|
int dbg_frames_painted = 0;
|
||||||
/// Where a frame's time goes, split at the two boundaries that can be observed from
|
/// 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_max = -(1 << 20);
|
||||||
int dbg_axes_w_prev = -1;
|
int dbg_axes_w_prev = -1;
|
||||||
int dbg_axes_w_changes = 0;
|
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_frame = -1;
|
||||||
int dbg_axes_w_change_side = -1;
|
int dbg_axes_w_change_side = -1;
|
||||||
float dbg_scale_min = 1e9f;
|
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_sum += s.staged_segments;
|
||||||
app->dbg_seg_max = std::max(app->dbg_seg_max, 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_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.raw_traces > 0) ++app->dbg_raw_frames;
|
||||||
if (s.lod_traces > 0) ++app->dbg_lod_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);
|
App* app = static_cast<App*>(user);
|
||||||
const bool paused = gtk_toggle_button_get_active(button);
|
const bool paused = gtk_toggle_button_get_active(button);
|
||||||
app->acq->set_paused(paused);
|
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"
|
gtk_button_set_icon_name(GTK_BUTTON(button), paused ? "media-playback-start-symbolic"
|
||||||
: "media-playback-pause-symbolic");
|
: "media-playback-pause-symbolic");
|
||||||
gtk_widget_set_tooltip_text(GTK_WIDGET(button), paused ? "resume acquisition"
|
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;
|
if (r0.plot == nullptr) return false;
|
||||||
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
||||||
|
|
||||||
if (app.model->display_mode() == DisplayMode::Held) {
|
if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) {
|
||||||
std::shared_ptr<const EventSnapshot> snap = app.model->event();
|
|
||||||
if (snap == nullptr || channel >= snap->values.size()) return false;
|
if (snap == nullptr || channel >= snap->values.size()) return false;
|
||||||
const std::vector<f32>& st = snap->stamps;
|
const std::vector<f32>& st = snap->stamps;
|
||||||
const std::vector<f32>& vals = snap->values[channel];
|
const std::vector<f32>& vals = snap->values[channel];
|
||||||
if (vals.size() != st.size()) return false;
|
if (vals.size() != st.size()) return false;
|
||||||
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
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));
|
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
||||||
for (auto it = lo; it != hi; ++it) {
|
const std::size_t count = static_cast<std::size_t>(hi - lo);
|
||||||
time.push_back(static_cast<double>(*it));
|
if (count == 0) return false;
|
||||||
value.push_back(static_cast<double>(vals[static_cast<std::size_t>(it - st.begin())]));
|
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());
|
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);
|
app.model->live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
||||||
const u64 n = last - first;
|
const u64 n = last - first;
|
||||||
if (n == 0) return false;
|
if (n == 0) return false;
|
||||||
std::vector<f32> stamps(static_cast<std::size_t>(n));
|
time.resize(static_cast<std::size_t>(n));
|
||||||
std::vector<f32> vals(static_cast<std::size_t>(n));
|
value.resize(static_cast<std::size_t>(n));
|
||||||
store.time().copy_from(first, n, stamps.data());
|
// In bounded chunks rather than through two whole-window temporaries: the rings hold f32 and
|
||||||
store.values(channel).copy_from(first, n, vals.data());
|
// the engine reads f64, so the widening is unavoidable, but the *copies* are not -- a full
|
||||||
time.reserve(stamps.size());
|
// window is millions of samples, and the temporaries cost a full write and read of it on top
|
||||||
value.reserve(vals.size());
|
// of the result. `kChunk` keeps the scratch in cache.
|
||||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
constexpr u64 kChunk = 8192;
|
||||||
time.push_back(epoch + static_cast<double>(stamps[i]));
|
std::vector<f32> scratch(kChunk);
|
||||||
value.push_back(static_cast<double>(vals[i]));
|
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]);
|
||||||
|
}
|
||||||
|
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 !time.empty();
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The channel a measurement's signal resolved to, or -1 while the stream does not carry it.
|
/// 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 "";
|
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 rebuild_measures_pane(App& app);
|
||||||
void update_measures(App& app, bool force);
|
void update_measures(App& app, bool force);
|
||||||
void open_measure_dialog(App& app, int edit_index);
|
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),
|
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
|
||||||
"show on plot (ruler at the value, marker where it is)");
|
"show on plot (ruler at the value, marker where it is)");
|
||||||
break;
|
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::Top:
|
||||||
case MeasureType::Bottom:
|
case MeasureType::Bottom:
|
||||||
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range");
|
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.min - app.measure_window.min) +
|
||||||
std::fabs(view.max - app.measure_window.max) >
|
std::fabs(view.max - app.measure_window.max) >
|
||||||
0.02 * std::max(1.0e-12, view.span());
|
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
|
// 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.
|
// 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);
|
app.rows->set_measure_marks(app.measure_marks);
|
||||||
@@ -2221,45 +2348,62 @@ void update_measures(App& app, bool force) {
|
|||||||
app.measure_window = view;
|
app.measure_window = view;
|
||||||
app.measure_window_valid = true;
|
app.measure_window_valid = true;
|
||||||
app.measure_was_held = held;
|
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_samples.clear();
|
||||||
|
|
||||||
app.measure_results.assign(app.measures.size(), MeasureResult{});
|
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_text.assign(app.measures.size(), std::string("--"));
|
||||||
app.measure_marks.clear();
|
app.measure_marks.clear();
|
||||||
std::vector<RowSet::MeasureMark> marks;
|
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) {
|
for (std::size_t i = 0; i < app.measures.size(); ++i) {
|
||||||
const Measure& m = app.measures[i];
|
const Measure& m = app.measures[i];
|
||||||
GtkWidget* label = app.measure_labels[i];
|
GtkWidget* label = app.measure_labels[i];
|
||||||
const int channel = measure_channel(app, m.signal);
|
const int channel = measure_channel(app, m.signal);
|
||||||
std::vector<double> t;
|
|
||||||
std::vector<double> v;
|
|
||||||
if (channel < 0) {
|
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] = "--";
|
app.measure_text[i] = "--";
|
||||||
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
|
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
{
|
// One *window* per channel per pass, shared by every measurement on that signal (the common
|
||||||
// One copy of the visible window per channel per pass, shared by every measurement on
|
// case is several on one trace). It is held by reference from here on: this loop used to copy
|
||||||
// that signal (the common case is several on one trace).
|
// both vectors out of the cache for every measurement, which at four measurements over a
|
||||||
auto it = app.measure_samples.find(channel);
|
// 4.7M-sample window is 300 MB of memcpy per frame -- memory-bandwidth bound, and the tick's
|
||||||
if (it == app.measure_samples.end()) {
|
// mean went from 0.4 ms to 13.7 ms with the pane holding them.
|
||||||
std::pair<std::vector<double>, std::vector<double>> fresh;
|
auto it = app.measure_samples.find(channel);
|
||||||
if (visible_samples(app, static_cast<std::size_t>(channel), fresh.first, fresh.second)) {
|
if (it == app.measure_samples.end()) {
|
||||||
it = app.measure_samples.emplace(channel, std::move(fresh)).first;
|
App::MeasureWindow fresh;
|
||||||
}
|
if (visible_samples(app, static_cast<std::size_t>(channel), fresh.time, fresh.value)) {
|
||||||
} else if (it->second.first.empty()) {
|
fresh.stats = measure_stats(fresh.time, fresh.value);
|
||||||
it = app.measure_samples.end();
|
it = app.measure_samples.emplace(channel, std::move(fresh)).first;
|
||||||
}
|
}
|
||||||
if (it == app.measure_samples.end()) {
|
} else if (it->second.time.empty()) {
|
||||||
app.measure_text[i] = "--";
|
it = app.measure_samples.end();
|
||||||
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);
|
if (it == app.measure_samples.end()) {
|
||||||
|
app.measure_text[i] = "--";
|
||||||
|
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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;
|
app.measure_results[i] = r;
|
||||||
|
if (r.valid) app.measure_last_valid[i] = r;
|
||||||
if (label != nullptr) {
|
if (label != nullptr) {
|
||||||
std::string text = "--";
|
std::string text = "--";
|
||||||
if (r.valid) {
|
if (r.valid) {
|
||||||
@@ -2279,12 +2423,30 @@ void update_measures(App& app, bool force) {
|
|||||||
app.measure_text[i] = text;
|
app.measure_text[i] = text;
|
||||||
gtk_label_set_text(GTK_LABEL(label), text.c_str());
|
gtk_label_set_text(GTK_LABEL(label), text.c_str());
|
||||||
}
|
}
|
||||||
if (m.options.show_on_plot && r.valid) {
|
}
|
||||||
const int row = measure_row(app, m.signal);
|
|
||||||
const plot::u32 color = measure_color(i);
|
// The marks last, in their own pass over the measurements, so that no `continue` above can skip
|
||||||
for (const MeasureMarkSpec& spec : r.marks) {
|
// them. That is exactly how the rulers came to blink off -- first at a mode change, then at a
|
||||||
marks.push_back({row, spec.level, spec.value, color});
|
// 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 : mk->marks) {
|
||||||
|
if (!spec.level && !r.valid) continue;
|
||||||
|
marks.push_back({row, spec.level, spec.value, color});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.measure_marks = std::move(marks);
|
app.measure_marks = std::move(marks);
|
||||||
@@ -2818,7 +2980,21 @@ gboolean tick(gpointer user) {
|
|||||||
if (model.generation() != app->signal_generation) rebuild_signal_panels(*app);
|
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
|
// 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.
|
// 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_cursors_pane(*app);
|
||||||
update_measures(*app, false);
|
update_measures(*app, false);
|
||||||
|
|
||||||
@@ -2863,6 +3039,69 @@ gboolean tick(gpointer user) {
|
|||||||
app->rows->acquire(model);
|
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.
|
// 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
|
// 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
|
// 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.
|
// 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,
|
// 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.
|
// 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
|
// 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.
|
// 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;
|
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
|
// 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) {
|
if (!starved) {
|
||||||
app.starve_reported = false;
|
app.starve_reported = false;
|
||||||
return;
|
// (the zoom-in is judged on the next frame, after the layout pushes it)
|
||||||
}
|
}
|
||||||
app.notice =
|
app.notice =
|
||||||
"connected, but no data is arriving: another reader may hold the source (a unicast "
|
"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;
|
dx_real = n >= 2 ? shown->x_at(n - 1) - shown->x_at(n - 2) : 0.0;
|
||||||
bound_lo = shown->x_bounds().min;
|
bound_lo = shown->x_bounds().min;
|
||||||
bound_hi = shown->x_bounds().max;
|
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;
|
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
|
want_lo = shown->neighbours(axis.min, i0, i1) ? (long long)i0
|
||||||
: (axis.min < shown->x_at(0) ? 0 : (long long)n);
|
: (axis.min < shown->x_at(0) ? 0 : (long long)n);
|
||||||
want_hi = shown->neighbours(axis.max, i0, i1) ? (long long)i1 + 1
|
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;
|
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.
|
/// Headless self-check: arms the trigger the way the toolbar's trigger switch does.
|
||||||
gboolean arm_probe(gpointer user) {
|
gboolean arm_probe(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
@@ -4289,11 +4646,53 @@ gboolean disarm_probe_step1(gpointer user) {
|
|||||||
return G_SOURCE_REMOVE;
|
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
|
/// 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.
|
/// what is measured is a still picture -- the state a wiggle is most visible in.
|
||||||
gboolean pause_probe(gpointer user) {
|
gboolean pause_probe(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
a->acq->set_paused(true);
|
// 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_min = 1 << 20;
|
||||||
a->dbg_axes_x_max = -(1 << 20);
|
a->dbg_axes_x_max = -(1 << 20);
|
||||||
a->dbg_axes_w_min = 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_axes_w_change_side = -1;
|
||||||
a->dbg_vp_w_min = 1 << 20;
|
a->dbg_vp_w_min = 1 << 20;
|
||||||
a->dbg_vp_w_max = -(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_min = 1 << 20;
|
||||||
a->dbg_xtick_max = -(1 << 20);
|
a->dbg_xtick_max = -(1 << 20);
|
||||||
a->dbg_gutter_min = 1 << 20;
|
a->dbg_gutter_min = 1 << 20;
|
||||||
@@ -4435,6 +4855,79 @@ void fit_probe_report(App& a, const char* what) {
|
|||||||
std::fflush(stderr);
|
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) {
|
gboolean fit_probe_step(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
@@ -4502,9 +4995,12 @@ gboolean cursors_probe_step(gpointer user) {
|
|||||||
cursors_probe_next(*a, 5, 300);
|
cursors_probe_next(*a, 5, 300);
|
||||||
break;
|
break;
|
||||||
case 5: {
|
case 5: {
|
||||||
// A drag, as the adapter delivers one: the *plot's* cursor moves and the frame
|
// A plot-level move with *no gesture behind it*: the sync reads a row only while its
|
||||||
// tick is what notices. If the sync works, every row's marker follows.
|
// 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);
|
RowSet::Row& r0 = a->rows->row(0);
|
||||||
|
a->dbg_gestureless_before = a->rows->cursor_a_x();
|
||||||
if (r0.plot != nullptr) {
|
if (r0.plot != nullptr) {
|
||||||
const plot::Range xr = r0.plot->x_range(r0.axis_x);
|
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);
|
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);
|
cursors_probe_next(*a, 6, 300);
|
||||||
break;
|
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");
|
cursors_probe_report(*a, "after drag");
|
||||||
// The other half of issue 1: a *ruler* is dragged through its own plot, and the
|
// (The adoption half -- a gesture that *does* move a cursor -- is the library's own
|
||||||
// pane's Y cells have to follow it. Nothing shares a ruler, so this is the read-back
|
// controller test plus the app's rule that the tick passes the row whose controller holds
|
||||||
// path alone -- the one that used to leave the numbers showing where the app last
|
// a cursor; a programmatic press here cannot reach the adapter's event pump, so this probe
|
||||||
// put them.
|
// asserts the invariant that is reachable from here.)
|
||||||
a->rows->set_cursor_mode(RowSet::CursorMode::Both);
|
|
||||||
cursors_probe_next(*a, 7, 300);
|
cursors_probe_next(*a, 7, 300);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 7: {
|
case 7: {
|
||||||
cursors_probe_report(*a, "both");
|
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);
|
RowSet::Row& r0 = a->rows->row(0);
|
||||||
if (r0.plot != nullptr) {
|
if (r0.plot != nullptr) {
|
||||||
const plot::Range yr = r0.plot->y_range(r0.axis_y);
|
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) {
|
void measure_dialog_click(App& a, const char* label) {
|
||||||
if (a.measure_dialog == nullptr) {
|
if (a.measure_dialog == nullptr) {
|
||||||
std::fprintf(stderr, "[measures-probe] no dialog to click '%s' in\n", label);
|
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);
|
GtkWidget* b = find_labelled_button(a.measure_dialog, label);
|
||||||
if (b == nullptr) {
|
if (b == nullptr) {
|
||||||
std::fprintf(stderr, "[measures-probe] no '%s' button in the dialog\n", label);
|
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");
|
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;
|
app.source_port = cfg.port;
|
||||||
|
|
||||||
GtkApplication* application =
|
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);
|
g_signal_connect(application, "activate", G_CALLBACK(on_activate), &app);
|
||||||
|
|
||||||
// Headless self-check: exercise the toolbar's toggles, then exit and print what
|
// 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")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), window_probe, &app);
|
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")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), measures_probe_step, &app);
|
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")) {
|
if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), trig_trace_probe, &app);
|
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")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_PAUSE_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), pause_probe, &app);
|
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_changes, app.dbg_axes_w_change_frame,
|
||||||
app.dbg_axes_w_change_side);
|
app.dbg_axes_w_change_side);
|
||||||
std::printf("[udpscope] reset button visible: %d\n", app.dbg_reset_visible);
|
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,
|
std::printf("[udpscope] blank view frames: %d of %d\n", app.dbg_blank_frames,
|
||||||
app.dbg_axes_frames);
|
app.dbg_axes_frames);
|
||||||
std::printf("[udpscope] trigger events: %llu total, %llu at the disarm (no event after it "
|
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_marker_x, app.dbg_marker_max, app.dbg_trigger_time,
|
||||||
app.dbg_trigger_time_valid);
|
app.dbg_trigger_time_valid);
|
||||||
std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str());
|
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],
|
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]);
|
app.dbg_badge_frames[1], app.dbg_badge_frames[2]);
|
||||||
std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; "
|
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");
|
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());
|
std::lock_guard<std::mutex> lock(model.mutex());
|
||||||
// `results_for` is the window the numbers describe, `win` the one on screen now: the
|
// `results_for` is the window the numbers describe, `win` the one on screen now: the
|
||||||
|
|||||||
Reference in new issue
Block a user