From 30db44eaa0ff450e19226e64c232c08800ed7bfe Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 2 Oct 2026 08:19:10 +0200 Subject: [PATCH] Correct downsampling and better layout --- CLAUDE.md | 203 ++++++++- README.md | 229 ++++++++-- deps/plotter | 2 +- src/acquisition.cpp | 41 +- src/acquisition.hpp | 14 + src/config.cpp | 11 +- src/config.hpp | 3 +- src/event_series.hpp | 97 ++++- src/model.cpp | 70 ++- src/model.hpp | 38 +- src/plot_host.cpp | 47 +++ src/plot_host.hpp | 27 +- src/ui.cpp | 981 +++++++++++++++++++++++++++++++++++-------- 13 files changed, 1511 insertions(+), 252 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a7f14f2..75ed71e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,8 @@ cmake --build build ``` Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per -buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (1..4). +buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row +count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards). `deps/plotter` is built from source with only the GTK4 adapter enabled (`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a @@ -25,7 +26,25 @@ there rather than worked around here: `Figure::set_min_y_gutter()` (a floor unde common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** — `set_figure()`, one `input::Controller` per row, nearest-row event routing, a figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over. -Changing those means committing in the submodule and moving its pin. + +The render-quality work went there for the same reason: `kTextFS` now **filters** the +glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling +it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto +whole pixels for the chrome that frames a plot (grid and axis lines), never for anything +drawn at a data position. `generate_ticks()` also stores the tick at the origin as +exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero +when the stride is not a power of two, which labelled it `-139a`. All three carry +upstream tests — `the_atlas_is_filtered_not_point_sampled`, +`grid_hairlines_land_on_whole_pixels` and `the_tick_at_the_origin_reads_zero`; README +records what they cured. + +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: + +```sh +cmake --build deps/plotter/build/debug +xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed" +``` ## Verification @@ -49,6 +68,14 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no SIGSEGV *is* the assertion. +- `UDPSCOPE_PROBE_WINDOW_MS` — drive the window control every way a user can: type a + number, change the unit under it, step a decade either way. +- `UDPSCOPE_PROBE_LAYOUT_MS` — drive the layout editor: switch it on, insert above the + first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms + apart on purpose — the buttons are placed from the rects the *last frame* was laid out + with, so a synchronous sequence would read every position one action stale. It clicks by + emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its + 250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed. - `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are measured on a still picture. - `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default @@ -111,7 +138,7 @@ Two lines of the self-check output are assertions in disguise: `dx_ratio` above 1 scales every index up, `a` lands past the first visible sample, and the samples in between sit in the buffer, readable by the crosshair, and are never drawn: the live view's older part goes blank while its newest part looks fine. Also - read `axis=` in the companion line against the `window dropdown:` 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 cause (see the `clamp_to_data()` invariant below). Both need a *saturated* ring to show up, so run them under `--buffer-mb 1`, which wraps in a few seconds. @@ -150,6 +177,47 @@ Two lines of the self-check output are assertions in disguise: `64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing either writer. +- `[window-probe] label=.. entry=.. unit=U window=W event_span=E cap=C decim=M + stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal + `event_span`** on every line (the trigger's event span is the display window; a capture + cut to a different length than the axis frames is the "post window is empty" defect's + cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches + the feedback loop: changing the unit re-interprets the typed number, so "500" in ms + becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own + rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit` + invariant below. `label` is a reading to three decimals in a natural unit and is + *expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to + be exact. + For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the + decimation is for, and a `buffered` short of the window is the old "the control is capped + to what the ring holds" behaviour coming back. `decim` must be a power of two, + `stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it + under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s + decimate; at 200 MB nothing inside `--max-window` needs to. +- **The anti-alias check** is the same probe run against a waveform above the stored + Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives + `decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's + `ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes + 2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has + aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is + the only check that distinguishes averaging from striding. +- `[layout-probe] rows=N buttons=B rows_y=[..] close=[..] add=[..] traces=[..]` + (stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is + up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the + buttons exist only while the editor does. **`traces=` is the real assertion**: an insert + or a remove moves the rows' *contents* with them, so `add above 0` must read + `[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row — + and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0 + means `carry_row()` was skipped and the rows were renumbered under the user. + Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and + `add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the + last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than + `kRowGap` means the figure and the overlay disagree about where a row is, which is how a + `×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor + adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the + floor are the two refusals, and because the probe emits `::clicked` straight at the + widget it reaches the handler even when the button is insensitive, so `add above 0` there + must leave `rows=` and `traces=` untouched rather than growing a ninth row. - `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it blinks" means, and a badge stuck lit is indistinguishable from a working one in a @@ -157,6 +225,23 @@ Two lines of the self-check output are assertions in disguise: connected; a large `idle` is correct only when the run paused or disarmed (a `UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). The `mode ...` lines carry the rest of the status line, so `rate` and `last event ... ago` are checked there. +- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and + `frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the + drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame + clock's `before-paint` to the end of the host's `render` handler) and the whole GTK + paint. **`segments` max must stay in the tens of thousands and `lod` must dominate `raw` + on any run holding a large capture**: a max in the *millions* with `lod=0` is the + empty-pyramid defect (see the `MinMaxPyramid` invariant below) — the renderer drawing + every raw sample of a held event on every frame, which measured 6,000,073 segments and + 100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per + pixel, which is where `level_for()`'s own gate puts the boundary. **`draw`'s max must sit + at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a + late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is + the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a + latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these + lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything — + `tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a + whole window fits in the raw path and the publish is too short to lose a race. The probes are the regression net — when changing UI or plot-host behaviour, extend the probe/dump in `src/ui.cpp` rather than adding a separate harness. @@ -228,7 +313,8 @@ Layers to keep straight: `ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair. - `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame to either a `ChannelBuffer` or an `EventSeries`. -- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to 4 rows, each a +- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added + and removed one at a time by the layout editor), each a borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns everything the rows have to agree on: the x window (one x link group every row joins), the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since @@ -258,12 +344,69 @@ far the largest file. again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`, never a row: the area keeps a reference to the plot it was created with, and a rebuild destroys every row. +- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and + `remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the + figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather + than renumbering the rows under the user. `carry_row()` deliberately copies fields + instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the + axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong + to the row's *position*. It clears the destination's slot bindings for the same reason — + the next `bind()` re-resolves them, and without the clear the frame in between draws the + moved row's samples under the new row's legend. + Both call `rebuild_all()` themselves, so their callers must re-point the area + (`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on + a *dirty* row. +- **The layout editor's buttons are *separate* overlay children, never one transparent + child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and + `gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a + full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested + in. Each button is therefore a small widget aligned to a corner or the centre and placed + by its **margins**, and the overlay is only measured from its main child, so moving them + cannot reach the plot's layout. + They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from — + divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK + margin is logical ones. Reading the figure's own rects rather than dividing the widget + height by the row count is what keeps a `×` on the row it names when the rows stop being + an equal division. `sync_layout_editor()` runs every frame on purpose: + `gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs + the comparisons and nothing else, and a resize needs no notification. - **Trace slots are identified by signal name, not channel index.** Enabling a signal or a new CONFIG renumbers the channel list, so an index would silently re-point a trace at another signal. - **Any structural change rebuilds the buffer and drops history**: a CONFIG, an - enable/disable, or a calibration change. Calibration is applied at ingest, which is - why it re-partitions. + enable/disable, a calibration change, or a **change of display window that moves the + decimation factor**. Calibration is applied at ingest, which is why it re-partitions. + `ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when + `time_stride` actually changes, so walking the window inside one decade is free; it asks + `plan_partition()` rather than recomputing the factor, because two expressions for that + decision is how the buffer and the view end up disagreeing about the stored rate. It + takes `mutex()` itself — do not call it holding the lock. +- **The shared clock is decimated to fit the requested window; channels never are.** A + window too long to store at the wire rate used to be refused (the control was capped to + `Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable. + `plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has + bought all the ring the budget allows — deciding it earlier over-decimates by whatever + factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of + `round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's + own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the + same phase and the channel plots flat. +- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads + and accumulates *every* sample and pushes the mean of each group of `time_stride` + (`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is + at the stored rate, so content that would fold back is attenuated instead of reappearing + as a false low frequency — which is the whole reason decimation was once dropped + entirely. Skipping the read for unstored samples re-introduces the aliasing; the + `UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (see below). + The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's + group delay puts the value, and stamping at the end of the group shifts every trace half + a stored sample late. The accumulators are reset whenever `time_stride` or the channel + count changes, so a re-plan starts a fresh group rather than finishing one at the wrong + length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike + narrower than a group is now filtered away rather than aliased, which is the correct + trade and is why the status line's `rate` field is worth reading. +- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()` + is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that + is not in the buffer would be cut from data that does not contain it. - **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while `gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring. @@ -277,6 +420,21 @@ far the largest file. at the end of the data / nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which was right all along. The probe's `data=` vs `drawn=` is the guard. +- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an + immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a + pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an + empty one is re-gathered sample by sample on every frame it is bound. That was the + "large data is laggy" defect: `EventSeries` had no pyramid, and a held 2 s capture at + 1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The + pyramid lives on `EventSnapshot`, not on `EventSeries`: the snapshot is immutable, so it + is built once in `publish_event()` inside the `O(n)` pass that already computes the y + bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per + channel. `frame path:`'s `segments` is the guard. +- **`EventSeries::gather()` emits a bucket's envelope, not every `2^level`-th sample.** + Point decimation hides exactly the spike the capture was triggered on; a scope has to + draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's + pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is + 0 and a window index *is* the absolute one. - **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins the published window to the value ring's base and both rings keep sliding afterwards — their capacities are equal, so there is no slack — which means the oldest few samples @@ -324,6 +482,19 @@ far the largest file. 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]` for a ±1 s request is correct there rather than a defect. +- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The + tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes + inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick + can read `Capturing`, block on the lock through `publish_event()`, and reach the framing + step with a new sequence number it is not bound to. (The other case is benign and was + always handled: a publication and the *next* edge in one frame, where the capture has + framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin + permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter + snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view — + so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a + frame is bound to the snapshot. Visible only at high rates, where the publish is long + enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard, + which means **running the disarm probe at 10 kS/s alone does not check this.** - **A capture in progress is the one place the axis is pinned from the *request*.** There is no snapshot to measure yet, so `DisplayMode::Capturing` frames `[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own @@ -365,6 +536,26 @@ far the largest file. points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one `publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard. +- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it + returns**, so the `updating_panels` flag — set around a programmatic write and cleared when + it returns — does *not* suppress it. Where the write is part of a readout the handler reads + back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading + into the window entry and picks the unit for it, and the late notify had + `on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into + 499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection + equal to it is ours, since GTK only notifies on a change. Any new control that both writes + and reads a `GtkDropDown` needs the same shape; the flag alone is not enough. +- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for + the same reason as the status label: it sits in the header bar above the plot, so a label + that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it + on a mere change of window. That is also why the window is typed into a popover rather + than shown in the bar. +- **`apply_time_window()` is the only place the display window changes.** It clamps to + `window_limit()` and moves `RowSet::set_time_window()` and `ScopeModel::set_event_span()` + together, because the trigger's pre-trigger share is a share of *this* window. Paths that + change the span for their own reasons (Fit, an event pin) call `sync_window_display()` + instead, which only restates the control and never moves the plot — calling `apply_` there + would clamp a legitimately short capture up to the requested window. - **The header bar's buttons are icon-only**, so headless probes must reach them through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via `find_labelled_button()`. The Arm/Disarm button in the trigger panel still has text. diff --git a/README.md b/README.md index 061d1e2..dcbb88d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ cmake --build build Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (size of the sample buffer, default 200), `--max-window` (largest time window the toolbar offers, default 600), `--holdoff` (default trigger hold-off), -`--recv-buffer`, `--plots` (initial number of stacked plots, 1..4). +`--recv-buffer`, `--plots` (the number of stacked plots to start with, 1..8; rows are +added and removed from the **Layout editor** afterwards). Every CONFIG is dumped to stderr once -- publish mode and, per signal, its name, element count, rate, time mode and time-signal index -- because a signal that @@ -67,8 +68,9 @@ bought nothing and cost half the memory budget, so `--buffer-mb 200` really is `plan_partition()` sizes the rings to cover `--max-window` seconds and then grows them with the rest of the budget. When a channel's declared sample rate is lower than the fastest channel's, it is decimated by the integer ratio -(`Partition::stride`); when even the target window does not fit the budget, the -shared clock is decimated too (`Partition::time_stride`). Because the split is by +(`Partition::stride`); when the requested window does not fit the budget, the shared +clock is resampled too (`Partition::time_stride`, an averaging decimation — see +"A window longer than the buffer is resampled"). Because the split is by *time span* rather than by bytes, every channel and the time base retain the same history -- which is what makes one shared time slice meaningful. With no decimation the two rules coincide and every slice is the same size. @@ -129,6 +131,19 @@ back to the *previous* event, or to blank if this was the first. No event fired, latch counter does not move; a separate `ScopeModel::event_seq()` counts published snapshots, and that is what the frame tick keys its framing on. +The frame that **frames** a snapshot is the one that consumes its sequence number, not +merely the first frame to see it. The tick reads `display_mode()` before it takes the +model's mutex, while the producer publishes the snapshot inside that mutex and only lowers +`trigger_pending` at the end of its packet — so a tick can read `Capturing`, wait out the +publish on the lock, and arrive at the framing step bound to a capture that no longer +exists. Consuming the number there dropped the pin for good: the axis kept the capture's +*requested* `[-pre, +post]` while the snapshot was shorter, and the "an event is held" flag +stayed false, so `clamp_to_data()` was free to drag the frozen view off it. The window was +visibly wrong only at high rates, where the publish is long enough to lose that race often +(`axis=[-1..1]` against `snap=[-1..0.27]` in the disarm probe at 1.3 MS/s, correct at +10 kS/s). Leaving the number unconsumed costs nothing: the pin is owed until a frame is +bound to the snapshot, and by then it is still the newest one. + Mid-capture the live ring is read on the *event's* clock: `display_epoch()` is set to minus the edge, so `x = 0` is the edge for the live channels exactly as it is in a snapshot's own stamps. That re-basing machinery already existed (it was written for the @@ -217,13 +232,41 @@ costs a y value and never the geometry. The self-check prints `dx_ratio` and the renderer's index window beside the binary-searched one, so both are checked rather than assumed. +**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets +laggy with large data" was. The renderer picks a level per trace per frame with +`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid +with no levels, whatever the sample count says. `ChannelBuffer` maintains a real pyramid +incrementally, so the live view was never affected; `EventSeries` returned a permanently +empty one, so every frame a capture was held re-gathered every sample it held. A 2 s +capture at 1 MS/s on three traces staged **6,000,073 line segments per frame** and took +100–230 ms to draw — and it stayed that way for as long as the event was on screen, which +in trigger mode is most of the time. + +The pyramid belongs to `EventSnapshot`, not to the series: the snapshot is immutable and +shared, so building it in `publish_event()` costs one `append()` per sample inside the +`O(n)` copy-and-bounds pass that was already there, and the same channel on two rows +shares one pyramid instead of building and storing it twice. `EventSeries::gather()` then +draws envelopes per bucket with raw samples at the two partial edges, the same shape +`ChannelBuffer::gather()` uses — the old implementation took every `2^level`-th sample, +which hides exactly the spike a capture was triggered on, and since the level was always 0 +it never even ran. On the same repro: **138,078 segments** (43× fewer) and a 66 ms worst +frame, with the steady-state draw around 2 ms. It costs about 1 byte per sample per +channel (≈25 % on top of the snapshot's `f32` values) and one stall per latch, since the +build runs under `ScopeModel::mutex()` — at 1.3 MS/s that is a ~40 ms tick, once, with no +packet gaps. + +The self-check's two `frame cost:` / `frame path:` lines are the guard: they split a frame +into the tick, the plot's own draw and the whole GTK paint, and report the segment counts +and whether the LOD path was taken at all. A segment count in the millions is this defect +returning. + **The panels and the toolbar.** The header bar carries only what you reach for -*while watching a signal*: the time window, the plot layout (1..4 rows), -pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those +*while watching a signal*: the time window, pause/resume, the trigger toggle, the +side-panel toggle and an overflow menu. Those four buttons are icons with tooltips; nine text buttons was a wall of words to read past every time you wanted the one you needed. The overflow menu holds **Fit to -data**, **Cursors**, **Reset cursors**, **Export visible range…** and the -**Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a +data**, the **Layout editor**, **Cursors**, **Reset cursors**, +**Export visible range…** and the **Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a CONFIG arrives — there is nothing to configure before the stream describes itself — and the first CONFIG reveals the signal panel (the trigger panel stays behind its own toggle). @@ -239,7 +282,7 @@ is a share of *that*. Tying it to the display window was a mistake — with the minutes and the trigger looked dead. With the default 50 ms event window a latch completes 25 ms after the edge. When an event latches and the plot is zoomed out wider than the event window, the display zooms to the event so the captured -waveform fills the plot; the window dropdown follows. +waveform fills the plot; the window control follows. **Hold-off is the quiet time after an event has been captured** — measured from the end of the capture, not from the edge, because the capture itself already @@ -278,8 +321,11 @@ the buffer's size and span. Three of those are worth calling out: cannot change its width — a badge that grew and shrank twice a second would move the label beside it and re-lay out the plot with it. - **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not - the rate the CONFIG declared. The app does **no** decimation (see `plan_partition()`), - so the two normally agree and a gap between them is the datasource's real wire rate. + the rate the CONFIG declared. The two agree unless the requested window is too long + to hold at the wire rate, in which case the ingest resamples (see "A window longer + than the buffer is resampled" below) and the stored rate is the readout of how much + resolution the window costs; otherwise a gap between them is the datasource's real + wire rate. One figure when every channel agrees within a percent, `lo..hi` when they do not; the per-channel breakdown, stored against declared, is on the line's tooltip. A per-channel list in the line itself would make its width depend on the stream. @@ -372,23 +418,123 @@ The app also logs, once per second, one line per acquired channel with the numbe of samples stored and the rate that implies, next to the rate the CONFIG declared: ``` -[udpscope] ch 1 'ch2_2kHz' stored=8500 (+4603 kS/s, declared 0 S/s) stride=1 clock=1 y=[-0.98, 0.98] +[udpscope] ch 1 'ch2_2kHz' stored=8500 (+4603 kS/s, declared 0 S/s) stride=1 decim=1 y=[-0.98, 0.98] ``` -The app no longer decimates (`stride=1 clock=1`), so a stored rate far below the -declared one is the datasource's own wire rate, not something the viewer did. +`stride` is the per-channel decimation (always 1) and `decim` the shared clock's +(`Partition::time_stride`, 1 unless the requested window forced a resample). With +`decim=1`, a stored rate far below the declared one is the datasource's own wire rate, +not something the viewer did; with `decim=N` the stored rate is the wire rate over `N`. + +**The labels and the hairlines were fixed in the plotter, not worked around here.** +Two defects made the plot look rough, and both were in `deps/plotter`'s shaders, so +the fix is a submodule commit and a pin move (see CLAUDE.md): + +1. *Glyphs lost whole strokes.* `kTextFS` **point**-sampled the atlas — `GL_NEAREST` + plus `floor(v_uv * u_tex_size)` — and a point sample at a non-integer scale does + not blend texels, it **deletes** them. The library's own default is a 12 px style + over the 13 px embedded bake, a scale of 0.9231, which dropped one of every 8 + columns and one of every 17 rows of every glyph; `500m` under a y axis rendered + with broken zeros, and which stroke went missing depended on where the unsnapped + pen fell. `kTextFS` now does a 2×2 bilinear fetch **clamped to the instance's own + cell**, so the no-bleed guarantee the `GL_NEAREST` sampler mode protects still + holds — the atlas is a tight strip of cells with no padding, and a filter that + knew nothing of cell bounds would pick up a glyph's neighbour. At scale 1 with a + whole pen the weights are exactly zero and the result is the point sample again. + Filtered glyphs peak at 0.844 coverage rather than 1.0, which is the honest cost + of not deleting strokes; total ink rose 5.7 %. +2. *A 1 px grid line lit two columns at half coverage.* The grid and axis lines are + staged in **data** space and mapped in the vertex shader, so their centres landed + on arbitrary fractions: x = 100.0 smears across two pixel columns, x = 100.5 + lights one crisply, a frame mixed both, and panning made each shimmer between the + two. `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink + *leading edge* onto a pixel boundary — integer+0.5 for odd widths, integer for + even ones, from one expression. It is on for the chrome that *frames* a plot (the + grid, the axis lines) and **off** for everything drawn at a data position — traces, + cursors, region edges, the crosshair — because the rounding moves a line by up to + half a pixel, and a cursor that reports a value may not be moved to look tidy. + Diagonals are untouched either way. Measured on one scanline of 620 px: 22 + distinct intensities before, 10 after, with the grid landing exactly on its own + `grid_major`/`grid_minor` colours instead of a spread of partial blends. + +A third, found the same way, is in the tick generator rather than the shaders: + +3. *The tick that meant zero was labelled `-139a`.* A y axis over `[-1, 1]` read + `-1 -500m -139a 500m 1`. `generate_ticks()` walks from + `ceil(min / stride) * stride`, which is not representable whenever the stride is + not a power of two — 0.1 and 0.2 are the everyday ones — so a range straddling + the origin arrives there a few ULP off, and `format_si` dutifully reaches six + prefixes down the SI table for it. Every tick is an integer multiple of the + stride, so the smallest a *non-zero* tick can be is the stride itself while the + residue is a few ULP of the range: anything inside a millionth of the stride is + the origin and nothing else. It is now stored as exactly `0.0`, not merely + labelled `0`, because `Axis::refresh()` re-formats a time axis from `tick.value` + — where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately* + that small keeps them. + +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 +was verified to fail with its own fix reverted. **Cursors start hidden** and are switched on from the overflow menu; **Reset cursors** switches them on as well, because a reset nobody can see reads as a dead button. The two panels collapse vertically (`SLIDE_DOWN`), so hiding one gives its space back instead of leaving an invisible gap in the column. -The window dropdown offers the standard ladder up to `--max-window`, capped to -what one buffer actually holds (the cap is recomputed when a CONFIG changes the -partition, and never on a mere enable or calibration change). A span that is not -on the ladder — `Fit` frames whatever the ring holds, which is rarely a round -number — gets its own entry rather than being rounded to a neighbour, so the -dropdown reads what is on screen instead of approximating it. +**The time window is typed, not picked off a ladder.** The header bar's `window` +button reads the current span and opens onto `÷10` · a number · a unit (µs, ms, s, +min) · `×10`. A ladder of round numbers could only ever offer the spans someone had +thought of, and the useful range runs from microseconds — a fast datasource's few +hundred samples — to minutes; the two steppers cover "an order of magnitude wider" +without typing at all. The request is clamped to `--max-window` and nothing else — +a window too long to store at the wire rate is resampled rather than refused (next +section) — and the control then restates the window that was *taken*, so a clamped +request reads back honestly. + +Changing the unit **re-interprets** the number rather than converting it: `2` +switched from ms to s means 2 s. Converting would make the selector a no-op, since +the span it names would not change. The button's label is a reading to three +decimals in whichever unit keeps the number above 1, so a 500 s window reads +`8.333 min` while the window itself stays exactly 500 s — the label is deliberately +lossy and width-locked, because it sits in the header bar directly above the plot +and a label that grew with its text would relayout everything under it on a mere +change of window. `Fit`, which frames whatever the ring holds and so rarely lands on +a round number, restates the control without going back through the clamp. + +**A window longer than the buffer is resampled, not refused.** `window × rate × +sample size` can exceed the byte budget long before `--max-window` does, and the +control used to be capped to whatever one buffer held — so on a fast datasource most +of the typed range was unreachable. Instead, `plan_partition()` now picks a +power-of-two `time_stride` for the shared clock: the growth loop first buys as long a +ring as the budget allows, and only then is the clock decimated by the smallest factor +that makes the requested span fit. Deciding it earlier would over-decimate by whatever +the growth loop was about to add. + +What makes that safe is that the decimation is a **low-pass filter, not a stride**. +`Acquisition::on_data()` keeps one running sum per channel (and one for the clock) and +stores the *average* of each group of `time_stride` base samples; the stamp stored is +the group's mean, which is the boxcar's own group-delay centre. A boxcar of length *M* +has its first null exactly at the new stored rate and is about −3 dB at 0.886× the new +Nyquist, so content that would fold back is attenuated instead of reappearing as a +false low frequency. It is O(1) per sample and its only state is the accumulator, which +is cleared whenever a re-plan changes the group length — a group cannot be finished at +the wrong length. The trigger therefore sees the stored, filtered signal, which is the +same signal the user is looking at. + +This reverses the earlier "the app does no decimation at all" decision, and keeps its +reason: a naive stride is what made a 2 kHz sine on a 1 MHz clock plot as a +full-amplitude 125 Hz sine — a plausible-looking waveform that was never in the data. +The headless check is the same aliasing case with the filter in place: at +`--buffer-mb 1 --freq 2000` with a 60 s window the clock decimates by 16 to a 625 S/s +stored rate (312.5 Hz corner) and the channel dump reads `y=[-0.059, 0.079]`, roughly +the boxcar's predicted 6%. A `y=[-1, 1]` there would mean the filter had been lost. + +Changing the decimation factor is a **structural change**, like a CONFIG or an enable: +the rings are re-planned and history is dropped. The status line says so +(`window 1 min: resampled to 625 S/s, history cleared`) and the same transition is +logged with both rates and the new anti-alias corner. A window change that leaves +`time_stride` alone rebuilds nothing. Per-channel `stride` is still always 1 (see the +limitations below); only the shared clock is decimated. **Fit to data** (overflow menu) does what the plot's own `f` key does: auto-scales every row's y axis and re-frames the x window on the data the ring holds, propagated to every @@ -416,6 +562,34 @@ the widget keeps its context. A displayed event survives the rebuild because the pinned `[-pre, +post]` window is restated rather than re-derived from the live edge. +**Rows are edited in place, not chosen from a count.** The header bar used to carry a +`plots` dropdown of 1 to 4, and a count is the wrong handle: it says how many rows you +want and leaves which row your traces land on to the renumbering. **Layout editor** +(overflow menu) instead puts the controls on the rows — a `×` at the top-left of each row +removes it, a `+` on each boundary inserts one there — so the row an action names is the +row you are pointing at. `--plots` is now only the count to start with, and `RowSet::kMaxRows` +(8) is a ceiling rather than a menu: past about that a row is chrome and no plot. + +An insert or a remove **moves the rows' contents with them**: what row 1 plotted, in the +colours it plotted it in and with its cursors where they were, is on row 2 after inserting +above row 1. Only that state moves; the figure's `Plot`s are rebuilt and re-dealt, which is +the mechanism a row-count change already used. + +There is **one `+` per boundary, not two per row**. The request was for a `+` at the top +centre and another at the bottom centre of every row, but the rows tile the figure, so a +row's bottom edge and the next row's top edge are the same line: "add below row 1" and "add +above row 2" are the same insertion, and two buttons there would sit one on top of the +other. The `rows + 1` boundary buttons — above the first row and below the last included — +reach every position the two-per-row reading offers. + +The buttons are `GtkOverlay` children placed by their margins over the figure's own row +rects (`FigureLayout::rows`, divided by the area's device scale), deliberately **not** one +transparent widget covering the plot: an overlay child is picked wherever its allocation +is, and a full-area child would swallow every pan, drop and hover the editor has no +interest in. Reading the figure's rects rather than dividing the widget height by the row +count is what keeps a `×` on the row it names if the rows ever stop being an equal +division. + **Export visible range…** (overflow menu) writes every signal bound to a visible row, over the shared x window, at the **stored** sample rate — never the LOD pyramid's buckets, which are the envelope the renderer drew rather than the acquisition. A @@ -462,13 +636,16 @@ now — they are listed so they are not mistaken for anything else: allocation lands in `(budget/2, budget]` rather than exactly on it. Every CONFIG, enable/disable and calibration re-partitions (and drops the history) because the buffer layout *is* the channel list. -- **Channels are not decimated at ingest.** The spec's `window * rate > buffer` - rule was implemented as `stride = round(rate_fastest / rate_channel)`, which for - a 2 kHz sine on a 1 MHz clock is exactly one sample per period: every kept - sample sat at the same phase and the channel plotted as a flat line. Channels - are now stored at the shared clock's rate and the LOD min/max pyramid preserves - the extremes at draw time. When the requested window does not fit the budget the - *shared clock* is decimated instead (the status line's `time xN`). +- **Channels are never decimated against each other.** The spec's + `window * rate > buffer` rule was first implemented as + `stride = round(rate_fastest / rate_channel)`, which for a 2 kHz sine on a 1 MHz + clock is exactly one sample per period: every kept sample sat at the same phase and + the channel plotted as a flat line. Channels are stored at the shared clock's rate + and the LOD min/max pyramid preserves the extremes at draw time. The budget rule is + honoured by resampling the *shared clock* (`Partition::time_stride`, the status + line's `time xN`) with an anti-alias average in front of it — see "A window longer + than the buffer is resampled" above. The filter is the difference: it is a stride + that aliases, not a rate reduction. - **Pre-trigger % is a share of the *event* window**, not of the retained history — see the trigger section above for why (the spec's sentence predates the event window being a separate control). diff --git a/deps/plotter b/deps/plotter index 98cfc58..e337c6a 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit 98cfc5805b63f8d86edfa1e50c0f1ef73a719995 +Subproject commit e337c6a60c4ea072558c4056b10337f908ad16b0 diff --git a/src/acquisition.cpp b/src/acquisition.cpp index 828ba4f..ed0295f 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -319,6 +319,17 @@ void Acquisition::on_data(const udps_frame_t* frame) { BufferStore& store = model_.live(); const u64 g = part.time_stride; + // The filter's groups are `g` long, so a re-plan has to start a fresh group rather than + // finish the one it was in with the wrong length. + if (acc_stride_ != g || acc_channels_ != channels.size()) { + acc_stride_ = g; + acc_channels_ = channels.size(); + value_acc_.assign(channels.size(), 0.0); + value_acc_n_.assign(channels.size(), 0); + time_acc_ = 0.0; + time_acc_n_ = 0; + } + // Calibration is read per frame; it only changes under the same lock. TriggerEngine& engine = engine_; const int trig_channel = tc.channel; @@ -424,7 +435,16 @@ void Acquisition::on_data(const udps_frame_t* frame) { last_stamp_used = rel; last_stamp_ = rel; - if (tick % g == g - 1) store.time().push(static_cast(rel)); + // The stored stamp is the *mean* of the group, not its last member: that is where the + // boxcar's group delay puts the averaged value, and stamping it at the end of the + // group would shift every trace half a stored sample late. + time_acc_ += rel; + ++time_acc_n_; + if (tick % g == g - 1) { + store.time().push(static_cast(time_acc_ / static_cast(time_acc_n_))); + time_acc_ = 0.0; + time_acc_n_ = 0; + } // The latch belongs to this sample, but it cannot be published from inside the // channel loop: `publish_event()` clamps the slice to every value ring's @@ -438,8 +458,6 @@ void Acquisition::on_data(const udps_frame_t* frame) { double latch_edge = 0.0; for (std::size_t i = 0; i < channels.size(); ++i) { - const u64 period = g * part.stride[i]; - if (tick % period != period - 1) continue; const ChannelInfo& ch = channels[i]; // A signal's columns are consecutive samples of the same channel: a scalar // (one element) carries one value per batched cycle, a block signal carries @@ -449,8 +467,23 @@ void Acquisition::on_data(const udps_frame_t* frame) { ? udps_frame_value(frame, static_cast(ch.signal), 0, std::min(k, ch.elements - 1)) : udps_frame_value(frame, static_cast(ch.signal), k, 0); - const f32 value = static_cast(raw * ch.gain + ch.offset); + // Every sample is read and accumulated even when only one in `period` is stored: + // that is the whole point of the filter. Skipping the read -- which is what the + // old stride did -- is what made decimation alias. + value_acc_[i] += raw * ch.gain + ch.offset; + ++value_acc_n_[i]; + + const u64 period = g * part.stride[i]; + if (tick % period != period - 1) continue; + const f32 value = + static_cast(value_acc_[i] / static_cast(value_acc_n_[i])); + value_acc_[i] = 0.0; + value_acc_n_[i] = 0; store.values(i).push(value); + // The trigger sees the *stored* signal, not the wire signal. An edge found in a + // sample that is not in the buffer would be cut from data that does not contain it, + // and with the filter in front of it a spike above the stored Nyquist is exactly + // the thing that must not latch -- it is not in the record the user would get. if (static_cast(i) == trig_channel) { if (engine.process(value, rel)) { latch_here = true; diff --git a/src/acquisition.hpp b/src/acquisition.hpp index 93995ea..494840b 100644 --- a/src/acquisition.hpp +++ b/src/acquisition.hpp @@ -77,6 +77,20 @@ class Acquisition { double arrival_epoch_ = 0.0; bool have_arrival_epoch_ = false; std::vector stamps_; + /// The decimation filter's state: a running sum per channel (and one for the shared + /// clock) over the group of base samples that becomes one stored sample. A boxcar + /// average, which is what turns decimation into a *resampling* rather than a stride: + /// its first null sits at the stored rate, so what would fold back is attenuated + /// instead of reappearing as a false low frequency. Cleared whenever the partition + /// changes, since the group length is `time_stride`. + std::vector value_acc_; + std::vector value_acc_n_; + double time_acc_ = 0.0; + std::uint64_t time_acc_n_ = 0; + /// The partition the accumulators were sized for, so a re-plan resets them rather than + /// averaging across the boundary. + std::uint64_t acc_stride_ = 0; + std::size_t acc_channels_ = 0; std::chrono::steady_clock::time_point started_{}; }; diff --git a/src/config.cpp b/src/config.cpp index ea7a87e..c29860b 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -3,6 +3,10 @@ #include "config.hpp" +// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling +// is what stops the message and the clamp from drifting apart. +#include "plot_host.hpp" + #include #include #include @@ -48,7 +52,8 @@ void print_usage(std::FILE* out, const char* argv0) { " --max-window SEC largest time window the toolbar offers (default 600)\n" " --holdoff SEC default trigger hold-off (default 0.010)\n" " --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n" - " --plots N initial number of stacked plots, 1..4 (default 1)\n" + " --plots N stacked plots to start with, 1..8 (default 1); rows are\n" + " added and removed from the layout editor\n" " -h, --help this text\n", argv0); } @@ -88,7 +93,9 @@ Config parse_args(int argc, char** argv) { } else if (a == "--plots" || a.rfind("--plots=", 0) == 0) { const std::string v = value_of(argc, argv, i, "--plots"); const long n = std::strtol(v.c_str(), nullptr, 10); - if (n < 1 || n > 4) fail("--plots must be 1..4: " + v); + if (n < 1 || n > RowSet::kMaxRows) { + fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v); + } c.plots = static_cast(n); } else { fail("unknown argument: " + a); diff --git a/src/config.hpp b/src/config.hpp index 384b6e1..8392db1 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -27,7 +27,8 @@ struct Config { double trigger_holdoff_s = 0.010; int recv_buffer_bytes = 8 * 1024 * 1024; - /// Number of stacked plots (rows) to start with, 1..4. + /// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the + /// starting point: rows are added and removed from the layout editor. int plots = 1; }; diff --git a/src/event_series.hpp b/src/event_series.hpp index 12daa90..92a632d 100644 --- a/src/event_series.hpp +++ b/src/event_series.hpp @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once +#include #include #include @@ -30,6 +31,18 @@ struct EventSnapshot { double pre_s = 0.0; ///< seconds of pre-trigger data kept double post_s = 0.0; ///< seconds of post-trigger data kept std::vector y; ///< per-channel value bounds, precomputed + /// One LOD pyramid per channel, built once here rather than by the series. + /// + /// It is the *renderer's* performance contract that needs it: `level_for()` descends + /// to level 0 on a pyramid with no levels, so a snapshot without one is drawn from + /// every raw sample it holds, every frame, for as long as the event is held -- a + /// 2 s capture at 1 MS/s staged 6 million segments across three traces and took + /// ~100 ms a frame, which is the "large data is laggy" report. It belongs to the + /// snapshot and not to `EventSeries` because the snapshot is immutable and shared: + /// built here it costs one O(n) pass per channel inside the copy loop that is + /// already O(n), and the same channel on two rows shares it instead of building and + /// storing it twice. + std::vector pyramids; }; /// A plot::data::Series view of one channel of an EventSnapshot. The stamps are @@ -70,19 +83,35 @@ class EventSeries final : public plot::data::Series { plot::Range y_bounds() const override { return channel_ < snap_->y.size() ? snap_->y[channel_] : plot::Range{0.0, 0.0}; } - const plot::data::MinMaxPyramid& pyramid() const override { return empty_pyramid_; } + /// The snapshot's pyramid for this channel (see `EventSnapshot::pyramids`). The + /// empty one only stands in for a channel the snapshot does not carry, where the + /// series has no samples either. + const plot::data::MinMaxPyramid& pyramid() const override { + return channel_ < snap_->pyramids.size() ? snap_->pyramids[channel_] : empty_pyramid_; + } bool streaming() const noexcept override { return false; } + /// Envelope geometry per whole bucket, raw samples at the two partial edges -- + /// the same shape as `ChannelBuffer::gather()`, and for the same two reasons: the + /// emitted geometry must span `[begin, end)` exactly and never reach outside it, + /// and a *bucket's extremes* are what a scope has to draw. The decimation this + /// replaced took every `2^level`-th sample, which hides exactly the spike a + /// capture was triggered on -- and, since the level was always 0 before the + /// snapshot carried a pyramid, it never even ran. + /// + /// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever + /// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one + /// (unlike the ring, whose window slides). void gather(u32 level, u64 begin, u64 end, std::vector& out_xy, - plot::data::GatherMode /*mode*/ = + plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override { if (channel_ >= snap_->values.size()) return; const auto& vals = snap_->values[channel_]; const auto& stamps = snap_->stamps; const u64 n = size(); - const u64 stride = u64{1} << level; - if (stride == 0 || begin >= n) return; - if (end > n) end = n; + const u64 b = std::min(begin, n); + const u64 e = std::min(std::max(end, b), n); + if (b >= e) return; // x **relative to x0()**, which is the library's gather contract: the renderer // adds `x0()` back before mapping to the screen. Emitting the absolute stamp made // it add x0 twice, so the whole capture was drawn one `x0()` -- a full @@ -94,11 +123,61 @@ class EventSeries final : public plot::data::Series { // crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the // right values. const f64 base = x0(); - out_xy.reserve(out_xy.size() + static_cast((end - begin) / stride * 2 + 4)); - for (u64 i = (begin / stride) * stride; i < end; i += stride) { - out_xy.push_back(static_cast(stamps[static_cast(i)] - base)); - out_xy.push_back(vals[static_cast(i)]); + const auto rel = [&](u64 i) { + return static_cast(stamps[static_cast(i)] - base); + }; + const auto emit_raw = [&](u64 from, u64 to) { + for (u64 i = from; i < to; ++i) { + out_xy.push_back(rel(i)); + out_xy.push_back(vals[static_cast(i)]); + } + }; + + if (level == 0) { + emit_raw(b, e); + return; } + level = std::min(level, plot::data::kMaxPyramidLevel); + const plot::data::MinMaxPyramid& pyr = pyramid(); + const u64 span = u64{plot::data::kBucket} << level; + const u64 lbase = pyr.level_base(level); + const u64 lend = lbase + pyr.level_size(level); + const u64 first = std::max(b / span + (b % span == 0 ? u64{0} : u64{1}), lbase); + const u64 last = std::min(e / span, lend); + if (first >= last) { + emit_raw(b, e); + return; + } + if (b < first * span) emit_raw(b, std::min(first * span, e)); + + for (u64 bi = first; bi < last; ++bi) { + const u32 count = pyr.bucket_count(level, bi); + switch (mode) { + case plot::data::GatherMode::Nearest: { + const u64 centre = bi * span + span / 2; + out_xy.push_back(rel(centre)); + out_xy.push_back(vals[static_cast(centre)]); + break; + } + case plot::data::GatherMode::Mean: { + if (count == 0) break; // no finite sample: no mean to draw + const u64 centre = bi * span + span / 2; + out_xy.push_back(rel(centre)); + out_xy.push_back(static_cast(pyr.bucket_sum(level, bi) / f64(count))); + break; + } + default: { // Envelope: min at the bucket's first sample, max at its last + if (count == 0) break; + const plot::data::MinMax m = pyr.bucket(level, bi); + out_xy.push_back(rel(bi * span)); + out_xy.push_back(m.min); + out_xy.push_back(rel(std::min((bi + 1) * span, e) - 1)); + out_xy.push_back(m.max); + break; + } + } + } + if (last * span < e) emit_raw(last * span, e); } bool neighbours(f64 x, u64& i0, u64& i1) const override { diff --git a/src/model.cpp b/src/model.cpp index f37f325..fb8f176 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -29,7 +30,8 @@ u64 Partition::total_samples() const noexcept { return total; } -Partition plan_partition(const std::vector& channel_dt, double buffer_bytes) { +Partition plan_partition(const std::vector& channel_dt, double buffer_bytes, + double target_span_s) { Partition p; const std::size_t n = channel_dt.size(); p.stride.assign(n, 1); @@ -48,16 +50,12 @@ Partition plan_partition(const std::vector& channel_dt, double buffer_by if (!(base_dt > 0.0)) base_dt = 1.0e-3; p.base_dt = base_dt; - // No per-channel decimation, and none on the shared clock either. A stride of - // `round(dt_i / dt_fastest)` throws away all but one sample per stride, and when - // that stride equals the signal's own period -- a 2 kHz sine on a 1 MHz clock, - // stride 500 -- every kept sample sits at the same phase: the channel plots as a - // flat line. Decimating the shared clock to make a 600 s window fit did the same - // to *every* channel. The LOD min/max pyramid is what preserves the extremes at - // draw time, and it needs the real samples; the buffer simply holds as many as - // it holds, and the window dropdown is capped to that span. + // No *per-channel* decimation. A stride of `round(dt_i / dt_fastest)` keeps one sample + // in every stride, and when that stride equals the signal's own period -- a 2 kHz sine + // on a 1 MHz clock, stride 500 -- every kept sample sits at the same phase and the + // channel plots as a flat line. The shared clock is decimated below instead, which + // affects every channel equally and goes through the ingest filter. for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1; - p.time_stride = 1; const u64 budget_bytes = static_cast(std::max(1.0, buffer_bytes)); const u64 budget = budget_bytes / sizeof(f32); @@ -97,6 +95,23 @@ Partition plan_partition(const std::vector& channel_dt, double buffer_by for (std::size_t i = 0; i < n; ++i) { p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]); } + + // Only now, with the ring as long as the budget can buy, decimate the shared clock + // enough for the requested window to fit in it: deciding this earlier would over- + // decimate by whatever factor the growth loop was about to add. A power of two keeps + // the ring capacities (themselves powers of two) in step and keeps `tick % period` + // cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from + // spinning here; at 2^20 the stored rate is six decades below the wire rate and the + // picture is averages of averages anyway. + u64 m = 1; + if (target_span_s > 0.0) { + while (m < (u64(1) << 20) && + static_cast(time_capacity) * static_cast(m) * base_dt < + target_span_s) { + m *= 2; + } + } + p.time_stride = m; p.span_s = static_cast(p.time_capacity) * static_cast(p.time_stride) * base_dt; return p; } @@ -184,12 +199,37 @@ void ScopeModel::rebuild_locked() { return; } - part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0); + part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, + target_span_.load(std::memory_order_relaxed)); store_ = std::make_unique(part_, part_.span_s); if (trigger_.channel >= static_cast(channels_.size())) trigger_.channel = -1; } +bool ScopeModel::set_target_span(double seconds) { + if (!(seconds > 0.0)) return false; + std::lock_guard lock(mutex_); + const double previous = target_span_.exchange(seconds, std::memory_order_relaxed); + if (channels_.empty()) return false; + // Re-plan only if the factor moves. The planner is cheap, so ask it rather than + // reimplementing its arithmetic here -- two expressions for the same decision is how + // the buffer and the view end up disagreeing about the stored rate. + std::vector dts; + dts.reserve(channels_.size()); + for (const ChannelInfo& c : channels_) dts.push_back(c.dt); + const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds); + if (want.time_stride == part_.time_stride) return false; + std::fprintf(stderr, + "[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate " + "%.4g -> %.4g S/s (anti-alias %.4g Hz); buffer rebuilt\n", + previous, seconds, static_cast(part_.time_stride), + static_cast(want.time_stride), part_.stored_rate(), + want.stored_rate(), want.antialias_hz()); + std::fflush(stderr); + rebuild_locked(); + return true; +} + bool ScopeModel::is_time_signal(const std::vector& signals, std::size_t signal) { if (signal >= signals.size()) return false; @@ -331,15 +371,23 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) { snap->values.resize(channels_.size()); snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); + snap->pyramids.resize(channels_.size()); for (std::size_t c = 0; c < channels_.size(); ++c) { auto& vals = snap->values[c]; vals.resize(static_cast(n)); if (n > 0) src.values(c).copy_from(first, n, vals.data()); f32 lo = vals.empty() ? 0.0f : vals.front(); f32 hi = lo; + // The LOD index is folded into the bounds pass that was already here, so the + // snapshot costs one O(n) walk per channel rather than two. Without a pyramid + // the renderer's `level_for()` descends to level 0 and the whole capture is + // drawn raw on every frame the event is held: see `EventSnapshot::pyramids`. + auto& pyr = snap->pyramids[c]; + pyr.reserve(n); for (f32 v : vals) { lo = std::min(lo, v); hi = std::max(hi, v); + pyr.append(v); } snap->y[c] = plot::Range{lo, hi}; } diff --git a/src/model.hpp b/src/model.hpp index b8c21e8..003eaf6 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -69,7 +69,12 @@ struct ChannelInfo { /// coincide and every slice is the same size. struct Partition { u64 time_capacity = 0; ///< time-base slots retained - u64 time_stride = 1; ///< base ticks per time-base slot (reserved; 1) + /// Base ticks per stored slot: the decimation factor the requested window forced. 1 + /// until the window needs more samples than the budget can hold. The ingest path + /// *averages* each group of this many samples rather than keeping one of them (see + /// `Acquisition::on_data()`), so this is a sample rate reduction with an anti-alias + /// filter in front of it, not a stride. + u64 time_stride = 1; std::vector stride; ///< time slots per channel sample (>= 1) std::vector value_capacity; ///< samples retained per channel double base_dt = 1.0; ///< seconds per base tick @@ -79,12 +84,23 @@ struct Partition { u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); } /// Seconds between two time-base slots (the sample interval of every channel). double slot_dt() const noexcept { return base_dt * static_cast(time_stride); } + /// The rate samples are actually stored at, in samples per second. + double stored_rate() const noexcept { + const double dt = slot_dt(); + return dt > 0.0 ? 1.0 / dt : 0.0; + } + /// The anti-alias filter's corner, in Hz: half the stored rate, which is the new + /// Nyquist. The boxcar the ingest path averages with has its first null at the stored + /// rate itself, so everything that would fold back is attenuated. + double antialias_hz() const noexcept { return 0.5 * stored_rate(); } }; -/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, targeting a -/// span of `target_span_s` seconds. Throws std::runtime_error when the budget -/// cannot hold even one sample per channel. -Partition plan_partition(const std::vector& channel_dt, double buffer_bytes); +/// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least +/// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever +/// power of two that takes. Throws std::runtime_error when the budget cannot hold even +/// one sample per channel. +Partition plan_partition(const std::vector& channel_dt, double buffer_bytes, + double target_span_s); /// One of the two identical sample buffers: a shared time-base ring plus one /// value ring and one `ChannelBuffer` per channel. @@ -287,6 +303,14 @@ class ScopeModel { } double event_span() const noexcept { return event_span_.load(std::memory_order_relaxed); } + /// The window the buffer has to cover, which is the display window. Re-plans the + /// partition -- and so drops the history -- only when the decimation factor it implies + /// actually changes, so walking the window inside one decade costs nothing. Takes + /// `mutex()` itself; do not call it holding the lock. + /// Returns true when the buffer was rebuilt. + bool set_target_span(double seconds); + double target_span() const noexcept { return target_span_.load(std::memory_order_relaxed); } + /// Trigger state for the UI, published by the acquisition thread. Atomics /// rather than fields of `TriggerConfig` because the producer writes the arm /// state when a single-shot trigger disarms itself, and the frame tick reads it @@ -321,6 +345,10 @@ class ScopeModel { StreamStats stats_{}; f64 epoch_ = 0.0; std::atomic event_span_{1.0}; + /// The window the buffer is planned to cover. Read by `rebuild_locked()`, which a + /// CONFIG can reach from the producer thread, so it is an atomic rather than a plain + /// field. + std::atomic target_span_{0.0}; std::atomic trigger_pending_{false}; std::atomic capture_edge_{0.0}; std::atomic> event_; ///< the event on screen diff --git a/src/plot_host.cpp b/src/plot_host.cpp index 2bd44cc..425d63c 100644 --- a/src/plot_host.cpp +++ b/src/plot_host.cpp @@ -54,6 +54,53 @@ void RowSet::set_row_count(int rows) { rebuild_all(); } +void RowSet::carry_row(Row& dst, const Row& src) { + dst.signal_name = src.signal_name; + dst.name = src.name; + dst.channel = src.channel; + dst.color_index = src.color_index; + dst.cursors_visible = src.cursors_visible; + dst.cursor_a_x = src.cursor_a_x; + dst.cursor_b_x = src.cursor_b_x; + dst.ruler_y = src.ruler_y; + // The slot's series stays where it is, but what it is bound to belonged to the row + // this state came *from*. `bind()` re-resolves every slot next frame; clearing it here + // is what stops the frame in between from drawing the moved row's data under the new + // row's legend. + for (SlotSeries& s : dst.slots) s.bind(nullptr); +} + +void RowSet::clear_row(Row& r) { + r.signal_name = {}; + r.name = {}; + r.channel = {-1, -1, -1, -1}; + r.color_index = {0, 1, 2, 3}; + r.cursors_visible = false; + r.cursor_a_x = 0.0; + r.cursor_b_x = 0.0; + r.ruler_y = 0.0; + for (SlotSeries& s : r.slots) s.bind(nullptr); +} + +bool RowSet::insert_row(int at) { + if (rows_ >= kMaxRows) return false; + const int pos = std::clamp(at, 0, rows_); + for (int i = rows_; i > pos; --i) carry_row(row(i), row(i - 1)); + clear_row(row(pos)); + ++rows_; + rebuild_all(); + return true; +} + +bool RowSet::remove_row(int at) { + if (rows_ <= 1 || at < 0 || at >= rows_) return false; + for (int i = at; i + 1 < rows_; ++i) carry_row(row(i), row(i + 1)); + --rows_; + clear_row(row(rows_)); + rebuild_all(); + return true; +} + void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) { r.plot = &p; r.axis_x = r.plot->add_x_axis( diff --git a/src/plot_host.hpp b/src/plot_host.hpp index 8971ef3..d62247f 100644 --- a/src/plot_host.hpp +++ b/src/plot_host.hpp @@ -41,7 +41,12 @@ namespace udpscope { /// context and is simply handed the new figure. class RowSet { public: - static constexpr int kMaxRows = 4; + /// The ceiling on rows, not the count: rows are added and removed one at a time + /// (`insert_row()`, `remove_row()`) rather than picked off a list of fixed layouts. + /// A cap still exists because `storage_` is the rows' persistent state and has to be + /// allocated somewhere, and because the figure keeps a floor height per row -- past + /// about this many a row is chrome and no plot. + static constexpr int kMaxRows = 8; static constexpr int kSlotsPerRow = 4; RowSet(); @@ -93,6 +98,17 @@ class RowSet { /// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row /// count is fixed at construction. void set_row_count(int rows); + /// Inserts an empty row at `at` (0 = above the first row, `row_count()` = below the + /// last), pushing the rows at and below it down. Returns false at `kMaxRows`. + /// + /// The rows below move as *rows*: their trace assignments, colours and cursors follow + /// them, so inserting above row 1 does not renumber what row 1 was plotting. The + /// figure's `Plot`s are not what moves -- they are rebuilt and re-dealt -- which is + /// the same mechanism a row-count change already used. + bool insert_row(int at); + /// Removes row `at` and pulls the rows below it up. Returns false when only one row + /// is left: a figure with no rows has nothing to draw and no way back. + bool remove_row(int at); Row& row(int i) { return storage_[static_cast(i)]; } const Row& row(int i) const { return storage_[static_cast(i)]; } @@ -192,6 +208,15 @@ class RowSet { static plot::Color unplotted_color(); private: + /// Moves one row's *persistent* state -- what it plots, in which colours, where its + /// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also + /// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries` + /// objects the traces point at, and those belong to the row's *position*, not to its + /// contents. `rebuild_all()` re-deals them straight after. + static void carry_row(Row& dst, const Row& src); + /// Back to an empty row: nothing plotted, the default palette entries, no cursors. + static void clear_row(Row& r); + void install(Row& r, int row_index, plot::Plot& p); void place_cursors(Row& r); /// The newest x any row's live traces hold, which is what the group follows. diff --git a/src/ui.cpp b/src/ui.cpp index d6a8b44..33ce24e 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -50,6 +50,28 @@ struct App { /// count is. GtkWidget* area_widget = nullptr; plot::gtk4::PlotArea* area = nullptr; + /// The GL area wrapped in a `GtkOverlay`, which is what the plot box actually holds. + /// The layout editor's buttons are overlay children: each is a small widget placed by + /// its margins over the row it acts on, so it intercepts pointer events only where it + /// is -- a single transparent child covering the area would swallow every pan, drop and + /// hover the editor is not interested in. + GtkWidget* area_overlay = nullptr; + /// On while the layout editor is up. Nothing else about the plot changes: the editor is + /// a set of overlay buttons, not a mode the figure knows about. + bool layout_editing = false; + GtkWidget* layout_toggle = nullptr; + /// The editor's buttons, in the order they were added, so they can be taken off the + /// overlay again. Rebuilt when the row count changes, repositioned when the rows move. + std::vector layout_buttons; + /// The row count and figure geometry the buttons were placed for. Their positions are + /// restated only when one of these moves: a `gtk_widget_set_margin_top()` per frame is a + /// relayout per frame, and overlay children are measured out of the main child's + /// allocation, so the churn would reach the plot. + int layout_buttons_rows = 0; + /// Which step of the layout probe runs next (`UDPSCOPE_PROBE_LAYOUT_MS`). The steps are + /// a frame apart, because the buttons are placed from the rects the last frame was laid + /// out with, so the sequence cannot be one function. + int dbg_layout_step = 0; /// What `PlotArea::create()` is handed. The area is in figure mode from the start and /// never draws this, but it keeps a reference to the plot it was created with (its /// single-plot controller holds one) -- and every plot of the figure is destroyed by a @@ -63,7 +85,21 @@ struct App { /// What the badge was last set to (`BadgePhase`), so the markup is rewritten on a /// change of phase rather than on every one of the 60 frames a second. int badge_phase = -1; - GtkWidget* window_drop = nullptr; + /// The display-window control: a header-bar button whose label is the current window + /// and whose popover holds the number, its unit and the two decade steppers. The label + /// is width-locked (see `kWindowLabelChars`) because the header bar sits above the plot + /// and a label that grew with the text would relayout everything under it. + GtkWidget* window_button = nullptr; + GtkWidget* window_label = nullptr; + GtkWidget* window_entry = nullptr; + GtkWidget* window_unit_drop = nullptr; + /// The unit `sync_window_display()` last wrote into the dropdown. `GtkDropDown`'s + /// `notify::selected` arrives *after* the call that caused it returns, so the + /// `updating_panels` flag is already clear by then and cannot tell our own write from the + /// user's pick; the entry the sync wrote is rounded for reading, so re-parsing it made the + /// rounding the window (a 500 s request came back as 499.98 s). A selection that matches + /// what we synced is ours -- GTK only notifies on a change, so the user cannot produce it. + int window_sync_unit = -1; GtkWidget* trigger_toggle = nullptr; GtkWidget* trigger_revealer = nullptr; GtkWidget* signal_toggle = nullptr; @@ -119,12 +155,13 @@ struct App { /// 0 until the first event of the session. gint64 last_latch_us = 0; double timed_edge = 0.0; - double window_choices_limit = -1.0; ///< the cap the dropdown was last built with + double window_limit_s = -1.0; ///< the cap the window control was last clamped against /// The rate each channel actually *stored* over the last second, and the declared rate - /// beside it. The app does no decimation, so the stored rate is the datasource's own - /// wire rate rather than anything the viewer chose -- which is why it is worth showing: - /// the declared rate is a claim and this is the receipt. Re-measured once a second (the - /// same tick as the channel dump), never per frame. + /// beside it. The two differ for two reasons now and both are worth seeing: the declared + /// rate is a claim the stream may not keep, and a window too long to hold at the wire + /// rate is resampled on ingest (`Partition::time_stride`), so this is also the readout + /// of how much resolution the current window costs. Re-measured once a second (the same + /// tick as the channel dump), never per frame. std::vector stored_rate; std::vector declared_rate; /// Self-check: how much the axes' left edge moves between frames. @@ -140,6 +177,38 @@ struct App { int dbg_blank_frames = 0; /// Frames the one area staged: a figure is one `::render` for every row. int dbg_frames_painted = 0; + /// Where a frame's time goes, split at the two boundaries that can be observed from + /// here: `tick` is this function (bind, acquire, clamp, status), `draw` is everything + /// the plot renderer did (measured from the frame clock's `before-paint` to the return + /// of `PlotArea`'s own `::render` handler, which is why the adapter returns FALSE from + /// it), and `paint` is the whole GTK paint including the toolkit compositing the GL + /// area's framebuffer. A complaint about lag names one of the three; a summary that + /// does not separate them cannot say which. + int dbg_tick_n = 0; + double dbg_tick_ms_sum = 0.0; + double dbg_tick_ms_max = 0.0; + int dbg_paint_n = 0; + double dbg_paint_ms_sum = 0.0; + double dbg_paint_ms_max = 0.0; + double dbg_draw_ms_sum = 0.0; + double dbg_draw_ms_max = 0.0; + /// Which paint the worst `draw` was. A mean far below the max says the cost is + /// concentrated in a few frames but not *which*, and the two answers need opposite + /// responses: an early frame is the warm-up (the glyph atlas bake and the first + /// vertex-buffer growth land on it), a late one is a frame whose own geometry was + /// expensive and is the thing to chase. + int dbg_draw_max_frame = -1; + gint64 dbg_paint_start_us = 0; + GdkFrameClock* dbg_clock = nullptr; + /// The renderer's own account of what each frame cost it: whether the traces went + /// through the LOD pyramid or were drawn raw, and how much geometry that staged. The + /// *path* is the first question to ask of a slow frame -- `raw` frames are O(visible + /// samples) by construction. + std::uint64_t dbg_seg_sum = 0; + std::uint32_t dbg_seg_max = 0; + std::uint64_t dbg_visible_max = 0; + int dbg_raw_frames = 0; + int dbg_lod_frames = 0; /// Frames the badge spent in each `BadgePhase`. Both `lit` and `dim` have to be non-zero /// over a run that acquired anything -- that is what "it blinks" means, and a badge /// stuck lit looks identical to a working one in a screenshot. @@ -180,7 +249,7 @@ struct App { int dbg_trigger_selected = -1; int dbg_side_scroll_after_hide = 1; std::string dbg_mode_triggered; - std::string dbg_window_drop_text; + std::string dbg_window_text; double dbg_marker_x = 0.0; double dbg_marker_max = 0.0; double dbg_trigger_time = 0.0; @@ -271,31 +340,60 @@ void on_popover_closed(GtkPopover* popover, gpointer) { gtk_widget_unparent(GTK_WIDGET(popover)); } -/// The time windows the toolbar offers: the standard ladder, capped by the -/// configured maximum and by what one buffer actually holds. -std::vector window_choices(const Config& cfg, double span_limit) { - static const double kStd[] = {0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, - 10.0, 20.0, 30.0, 60.0, 120.0, 300.0, 600.0}; - double limit = cfg.max_window_s; - if (span_limit > 0.0) limit = std::min(limit, span_limit); - std::vector out; - for (double w : kStd) { - if (w <= limit + 1.0e-9) out.push_back(w); +/// The units the window control offers, coarsest last. A window is typed as a number in +/// one of these rather than picked off a ladder, so any span the buffer can hold is +/// reachable -- the ladder could only ever offer the spans someone had thought of. +struct WindowUnit { + const char* name; + double seconds; +}; +constexpr WindowUnit kWindowUnits[] = { + {"\u00b5s", 1.0e-6}, {"ms", 1.0e-3}, {"s", 1.0}, {"min", 60.0}}; +constexpr int kWindowUnitCount = 4; + +/// The narrowest window the control will accept. Below a microsecond the axis labels stop +/// being distinguishable and no datasource this talks to streams that fast. +constexpr double kWindowMinS = 1.0e-6; + +/// The window button's label is pinned to this many characters and ellipsized, exactly like +/// the status label: it sits in the header bar directly above the plot, so a label that grew +/// with its text ("1 s" -> "123.456 ms") would widen the bar and relayout everything under +/// it on a mere change of window. +constexpr int kWindowLabelChars = 9; + +/// Which unit reads most naturally for `s`: the coarsest whose value is still at least 1, +/// so 0.05 s is "50 ms" and not "0.05 s" or "50000 us". +int window_unit_for(double s) { + int unit = 0; + for (int i = kWindowUnitCount - 1; i >= 0; --i) { + if (s >= kWindowUnits[i].seconds * (1.0 - 1.0e-9)) { + unit = i; + break; + } + } + return unit; +} + +/// The number part alone, in `unit`. Trailing zeros are trimmed so a decade stepper walking +/// 1 -> 10 -> 100 does not print "1.000"; three decimals is enough to survive a fit, whose +/// span is whatever the ring happened to hold. +std::string format_window_value(double s, int unit) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.3f", s / kWindowUnits[unit].seconds); + std::string out = buf; + const std::size_t dot = out.find('.'); + if (dot != std::string::npos) { + std::size_t last = out.size(); + while (last > dot + 1 && out[last - 1] == '0') --last; + if (last == dot + 1) last = dot; + out.resize(last); } - if (out.empty()) out.push_back(limit); return out; } std::string format_window(double s) { - char buf[32]; - if (s < 1.0) { - std::snprintf(buf, sizeof(buf), "%.0f ms", s * 1000.0); - } else if (s < 60.0) { - std::snprintf(buf, sizeof(buf), "%.2g s", s); - } else { - std::snprintf(buf, sizeof(buf), "%.0f s", s); - } - return buf; + const int unit = window_unit_for(s); + return format_window_value(s, unit) + " " + kWindowUnits[unit].name; } /// What the activity badge is saying. `Lit`/`Dim` are the two halves of the blink, so @@ -403,6 +501,47 @@ void cancel_drag_on(App& app, int row) { } } +/// Frame-cost probe, end of the plot's own draw. Connected *after* the adapter's +/// `::render`, which is the whole reason `PlotArea::on_render` returns FALSE: the signal +/// accumulates with `g_signal_accumulator_true_handled`, so a TRUE there would stop the +/// emission and this would never run. Reading the stats here rather than in `tick()` is +/// what makes them this frame's: `begin_frame()` resets them. +int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { + App* app = static_cast(user); + if (app->dbg_paint_start_us != 0) { + const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0; + app->dbg_draw_ms_sum += ms; + if (ms > app->dbg_draw_ms_max) { + app->dbg_draw_ms_max = ms; + app->dbg_draw_max_frame = app->dbg_paint_n; + } + } + if (app->area != nullptr) { + if (const plot::Renderer* r = app->area->renderer()) { + const plot::FrameStats& s = r->stats(); + app->dbg_seg_sum += s.staged_segments; + app->dbg_seg_max = std::max(app->dbg_seg_max, s.staged_segments); + app->dbg_visible_max = std::max(app->dbg_visible_max, s.visible_samples); + if (s.raw_traces > 0) ++app->dbg_raw_frames; + if (s.lod_traces > 0) ++app->dbg_lod_frames; + } + } + return FALSE; // same reason the adapter does: never stop the emission +} + +void on_before_paint(GdkFrameClock*, gpointer user) { + static_cast(user)->dbg_paint_start_us = g_get_monotonic_time(); +} + +void on_after_paint(GdkFrameClock*, gpointer user) { + App* app = static_cast(user); + if (app->dbg_paint_start_us == 0) return; + const double ms = double(g_get_monotonic_time() - app->dbg_paint_start_us) / 1000.0; + app->dbg_paint_ms_sum += ms; + app->dbg_paint_ms_max = std::max(app->dbg_paint_ms_max, ms); + ++app->dbg_paint_n; +} + GtkWidget* make_area(App& app) { plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(app.placeholder_plot); app.area = area; @@ -413,6 +552,7 @@ GtkWidget* make_area(App& app) { gtk_widget_set_hexpand(widget, true); gtk_widget_set_vexpand(widget, true); gtk_widget_set_size_request(widget, 320, 120); + g_signal_connect(widget, "render", G_CALLBACK(on_render_probe), &app); GtkDropTarget* target = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY); g_signal_connect(target, "drop", G_CALLBACK(on_drop_channel), &app); @@ -423,7 +563,14 @@ GtkWidget* make_area(App& app) { g_signal_connect(right, "pressed", G_CALLBACK(on_right_click), &app); gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(right)); - return widget; + // The overlay exists for the layout editor's buttons and is otherwise empty, so it + // costs one pass-through widget: with no overlay children, picking and allocation + // reach the GL area exactly as before. + app.area_overlay = gtk_overlay_new(); + gtk_overlay_set_child(GTK_OVERLAY(app.area_overlay), widget); + gtk_widget_set_hexpand(app.area_overlay, true); + gtk_widget_set_vexpand(app.area_overlay, true); + return app.area_overlay; } /// Brings the plot area in line with the rows: a changed trace set (or row count) @@ -442,6 +589,167 @@ void relayout_areas(App& app) { /// drawn as, so it goes stale the moment the placement changes. void rebuild_signal_panels(App& app); +// --- the layout editor ------------------------------------------------------ +// +// Rows are added and removed in place rather than picked off a list of layouts: with a +// count you can only say "I want three", and which of the three your traces end up on is +// whatever the renumbering decides. The editor's buttons sit *on* the rows, so the row +// the action names is the row you are pointing at. +// +// Geometry comes from `FigureLayout::rows` -- the same rects the frame was drawn from -- +// divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK +// margin is logical ones. +// +// One `+` per *boundary*, not two per row: a row's bottom edge and the next row's top +// edge are the same line (the rows tile the figure), so "add below row 1" and "add above +// row 2" are the same insertion and two buttons there would be one on top of the other. +// There are `rows + 1` of them -- above the first row and below the last included -- so +// every position the two-per-row reading offers is still reachable. + +constexpr int kLayoutButtonPx = 24; +constexpr int kLayoutButtonPad = 4; +/// `g_object_set_data` key telling a remove button from an insert one. The row/boundary +/// index is the usual `set_row_key()`. +constexpr const char* kLayoutKindKey = "udpscope-layout-insert"; + +void rebuild_layout_editor(App& app); + +/// Takes the editor's buttons off the overlay. `gtk_overlay_remove_overlay()` drops the +/// overlay's reference, which is the only one, so the widgets go with it. +/// +/// This runs from a *button's own* `::clicked` handler (the click rebuilds the set), which +/// is safe because the emission holds its own reference on the instance: the button is +/// finalized after the handler returns, not under it. +void clear_layout_editor(App& app) { + if (app.area_overlay != nullptr) { + for (GtkWidget* b : app.layout_buttons) { + gtk_overlay_remove_overlay(GTK_OVERLAY(app.area_overlay), b); + } + } + app.layout_buttons.clear(); + app.layout_buttons_rows = 0; +} + +void on_layout_row_remove(GtkButton* button, gpointer user) { + App* app = static_cast(user); + const int row = row_key(GTK_WIDGET(button)); + if (!app->rows->remove_row(row)) { + app->notice = "a figure needs at least one row"; + app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; + return; + } + // `remove_row()` has already built the new figure (a `Figure`'s row count is fixed at + // construction), so the area has to be re-pointed here rather than through + // `relayout_areas()`, which only acts on a *dirty* row. + if (app->area != nullptr) app->area->set_figure(&app->rows->figure()); + rebuild_layout_editor(*app); + // The signal list's chips name the trace a signal is drawn as, and a removed row took + // its traces with it. + rebuild_signal_panels(*app); +} + +void on_layout_row_insert(GtkButton* button, gpointer user) { + App* app = static_cast(user); + const int at = row_key(GTK_WIDGET(button)); + if (!app->rows->insert_row(at)) { + app->notice = "at most " + std::to_string(RowSet::kMaxRows) + " rows"; + app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; + return; + } + if (app->area != nullptr) app->area->set_figure(&app->rows->figure()); + rebuild_layout_editor(*app); + rebuild_signal_panels(*app); +} + +/// Restates where the buttons sit, from the rects the last frame was laid out with. +/// Called every frame while the editor is up: `gtk_widget_set_margin_*()` returns early +/// on an unchanged value, so a settled layout costs the comparisons and nothing else. +void sync_layout_editor(App& app) { + if (app.layout_buttons.empty() || app.area == nullptr) return; + const plot::FigureLayout& fl = app.rows->figure().layout(); + const int rows = app.rows->row_count(); + // Before the first frame the figure has no rects yet; placing against them would put + // every button in the top-left corner and then move it, which is a visible jump. + if (static_cast(fl.rows.size()) != rows) return; + const double scale = app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0; + const double height = static_cast(gtk_widget_get_height(app.area_widget)); + if (!(height > 0.0)) return; + const double half = kLayoutButtonPx * 0.5; + for (GtkWidget* b : app.layout_buttons) { + const int idx = row_key(b); + const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr; + if (insert) { + // Centred on the boundary: the top edge of row `idx`, or the bottom edge of the + // last row for the one past the end. + const double edge = idx < rows ? static_cast(fl.rows[static_cast(idx)].y) / scale + : (static_cast(fl.rows.back().y) + + static_cast(fl.rows.back().h)) / scale; + const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx); + gtk_widget_set_margin_top(b, static_cast(top + 0.5)); + } else { + const plot::Rect& r = fl.rows[static_cast(idx)]; + const double top = std::clamp(static_cast(r.y) / scale + kLayoutButtonPad, + 0.0, height - kLayoutButtonPx); + gtk_widget_set_margin_top(b, static_cast(top + 0.5)); + // Inside the axes, not over the y labels: the gutter is the one piece of row chrome + // whose width is reserved rather than measured per frame, so this does not move. + const plot::Plot* p = app.rows->row(idx).plot; + const double left = (p != nullptr ? static_cast(p->layout().axes.x) / scale + : static_cast(r.x) / scale) + + kLayoutButtonPad; + gtk_widget_set_margin_start(b, static_cast(left + 0.5)); + } + } +} + +/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the +/// editor is switched off. +void rebuild_layout_editor(App& app) { + clear_layout_editor(app); + if (!app.layout_editing || app.area_overlay == nullptr) return; + const int rows = app.rows->row_count(); + app.layout_buttons_rows = rows; + + for (int i = 0; i < rows; ++i) { + GtkWidget* b = gtk_button_new_from_icon_name("window-close-symbolic"); + gtk_widget_set_tooltip_text(b, "remove this row"); + gtk_widget_add_css_class(b, "circular"); + gtk_widget_add_css_class(b, "osd"); + gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx); + gtk_widget_set_halign(b, GTK_ALIGN_START); + gtk_widget_set_valign(b, GTK_ALIGN_START); + gtk_widget_set_sensitive(b, rows > 1); + set_row_key(b, i); + g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_remove), &app); + gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b); + app.layout_buttons.push_back(b); + } + for (int b_i = 0; b_i <= rows; ++b_i) { + GtkWidget* b = gtk_button_new_from_icon_name("list-add-symbolic"); + gtk_widget_set_tooltip_text(b, b_i == 0 ? "add a row above" + : b_i == rows ? "add a row below" + : "add a row here"); + gtk_widget_add_css_class(b, "circular"); + gtk_widget_add_css_class(b, "osd"); + gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx); + gtk_widget_set_halign(b, GTK_ALIGN_CENTER); + gtk_widget_set_valign(b, GTK_ALIGN_START); + gtk_widget_set_sensitive(b, rows < RowSet::kMaxRows); + set_row_key(b, b_i); + g_object_set_data(G_OBJECT(b), kLayoutKindKey, b); + g_signal_connect(b, "clicked", G_CALLBACK(on_layout_row_insert), &app); + gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b); + app.layout_buttons.push_back(b); + } + sync_layout_editor(app); +} + +void on_layout_editor_toggled(GtkToggleButton* button, gpointer user) { + App* app = static_cast(user); + app->layout_editing = gtk_toggle_button_get_active(button); + rebuild_layout_editor(*app); +} + void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(user); if (app->updating_panels) return; @@ -800,109 +1108,100 @@ void rebuild_trigger_signals(App& app) { // --- toolbar handlers ------------------------------------------------------- -void on_time_window_changed(GObject* obj, GParamSpec*, gpointer user) { - App* app = static_cast(user); - if (app->updating_panels) return; - const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)); - const Partition& part = app->model->partition(); - const std::vector choices = window_choices(app->model->config(), part.span_s); - if (sel < choices.size()) { - app->rows->set_time_window(choices[sel]); - // The trigger captures this much of the stream: its pre-trigger share is a - // share of the window the toolbar selects. - app->model->set_event_span(choices[sel]); - } -} - -/// Points the display-window dropdown at `seconds`, so a control that changes the -/// span on screen (the trigger's event window, or Fit) does not leave the toolbar -/// disagreeing with the plot. A span that is not on the ladder -- a fit frames -/// whatever data the ring holds -- is added as its own entry rather than rounded -/// to a neighbour, because the dropdown is a reading as much as it is a control. -void select_window_choice(App& app, double seconds) { - if (app.window_drop == nullptr || !(seconds > 0.0)) return; - const Partition& part = app.model->partition(); - const std::vector choices = window_choices(app.model->config(), part.span_s); - guint select = GTK_INVALID_LIST_POSITION; - bool extra = true; - for (guint i = 0; i < choices.size(); ++i) { - if (std::fabs(choices[i] - seconds) < 1.0e-9) { - select = i; - extra = false; - break; - } - } - - std::vector labels; - std::vector cstrs; - for (double w : choices) labels.push_back(format_window(w)); - if (extra) { - labels.push_back(format_window(seconds) + " (fit)"); - select = static_cast(choices.size()); - } - for (const std::string& l : labels) cstrs.push_back(l.c_str()); - cstrs.push_back(nullptr); +/// The widest window that is worth offering. `--max-window` only: the buffer no longer +/// sets the ceiling, because a window too long to store at the wire rate is decimated on +/// ingest to fit rather than refused (see `plan_partition()`). It used to be capped to +/// `buffered_span()` as well, which is what made a 600 s view of a 1 MS/s stream +/// unreachable. +double window_limit(const App& app) { return app.model->config().max_window_s; } +/// Writes `seconds` into the control -- the button's label, the entry and the unit -- and +/// nothing else. Read-only with respect to the plot, so every path that changes the span on +/// screen for its own reasons (a fit, an event pin) can say so without looping back through +/// `apply_time_window()`. +void sync_window_display(App& app, double seconds) { + if (app.window_label == nullptr || !(seconds > 0.0)) return; app.updating_panels = true; - GtkStringList* list = gtk_string_list_new(cstrs.data()); - gtk_drop_down_set_model(GTK_DROP_DOWN(app.window_drop), G_LIST_MODEL(list)); - g_object_unref(list); - gtk_drop_down_set_selected(GTK_DROP_DOWN(app.window_drop), select); + gtk_label_set_text(GTK_LABEL(app.window_label), format_window(seconds).c_str()); + const int unit = window_unit_for(seconds); + app.window_sync_unit = unit; + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.window_unit_drop), static_cast(unit)); + gtk_editable_set_text(GTK_EDITABLE(app.window_entry), + format_window_value(seconds, unit).c_str()); app.updating_panels = false; } -/// What one buffer actually holds, in seconds; the configured maximum until a -/// CONFIG has arrived. -double buffered_span(const App& app) { - const Partition& part = app.model->partition(); - return part.span_s > 0.0 ? part.span_s : app.model->config().max_window_s; +/// The one place the display window changes. Clamps to what the buffer can hold, moves the +/// plot and the trigger's event span together -- its pre-trigger share is a share of *this* +/// window -- and then restates the result in the control, so a clamped request reads back as +/// the window that was actually taken rather than the one that was typed. +void apply_time_window(App& app, double seconds) { + if (!(seconds > 0.0)) return; + const double clamped = std::min(std::max(seconds, kWindowMinS), window_limit(app)); + // The buffer has to cover the window, so this may re-plan the partition and drop the + // history -- a change of decimation factor cannot keep samples stored at the old rate. + // It is announced rather than silent, because losing the trace is otherwise unexplained. + // The frame tick rebinds every slot from the model, so the new rings are picked up + // without being told -- the same path an enable or a CONFIG already takes. + if (app.model->set_target_span(clamped)) { + app.notice = "window " + format_window(clamped) + ": resampled to " + + format_rate(app.model->partition().stored_rate()) + ", history cleared"; + app.notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; + } + app.rows->set_time_window(clamped); + app.model->set_event_span(clamped); + sync_window_display(app, clamped); } -/// Rebuilds the window dropdown for the current cap, keeping the selected window -/// when it still exists. Called once at start-up and again when a CONFIG changes -/// how much history one buffer holds -- never on a mere enable or calibration +/// Enter in the entry. A number that will not parse restores the current window instead of +/// reporting an error: the entry is also the readout, so leaving junk in it would leave the +/// control disagreeing with the plot. +void on_window_entry_activate(GtkEntry* entry, gpointer user) { + App* app = static_cast(user); + if (app->updating_panels) return; + const char* text = gtk_editable_get_text(GTK_EDITABLE(entry)); + char* end = nullptr; + const double value = std::strtod(text, &end); + if (end == text || !(value > 0.0)) { + sync_window_display(*app, app->rows->time_window()); + return; + } + const guint unit = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->window_unit_drop)); + apply_time_window(*app, value * kWindowUnits[unit < kWindowUnitCount ? unit : 2].seconds); +} + +/// Changing the unit *re-interprets* the number already typed rather than converting it: "2" +/// switched from ms to s means 2 s. Converting instead would make the unit selector a +/// no-op, since the window it names would not change. +void on_window_unit_changed(GObject* obj, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->updating_panels) return; + // Our own sync, arriving late (see `window_sync_unit`). + if (gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)) == + static_cast(app->window_sync_unit)) { + return; + } + on_window_entry_activate(GTK_ENTRY(app->window_entry), user); +} + +/// The decade steppers. A factor of ten per click, from the window actually in force rather +/// than from the entry, so a half-typed number cannot be stepped. +void on_window_step(GtkButton* button, gpointer user) { + App* app = static_cast(user); + const double factor = + g_object_get_data(G_OBJECT(button), "udpscope-window-up") != nullptr ? 10.0 : 0.1; + apply_time_window(*app, app->rows->time_window() * factor); +} + +/// Re-clamps the window against the current cap. Called once at start-up and again when a +/// CONFIG changes how much history one buffer holds -- never on a mere enable or calibration /// change, so those cannot silently re-anchor the view. -void refresh_window_choices(App& app) { - if (app.window_drop == nullptr) return; - const double limit = buffered_span(app); - if (std::fabs(limit - app.window_choices_limit) < 1.0e-9) return; - app.window_choices_limit = limit; - - const std::vector choices = window_choices(app.model->config(), limit); - std::vector labels; - std::vector cstrs; - for (double w : choices) labels.push_back(format_window(w)); - for (const std::string& s : labels) cstrs.push_back(s.c_str()); - cstrs.push_back(nullptr); - - const double wanted = app.rows->time_window(); - guint best = 0; - for (guint i = 0; i < choices.size(); ++i) { - if (std::fabs(choices[i] - wanted) < std::fabs(choices[best] - wanted)) best = i; - } - - app.updating_panels = true; - GtkStringList* list = gtk_string_list_new(cstrs.data()); - gtk_drop_down_set_model(GTK_DROP_DOWN(app.window_drop), G_LIST_MODEL(list)); - g_object_unref(list); - gtk_drop_down_set_selected(GTK_DROP_DOWN(app.window_drop), best); - app.updating_panels = false; - - app.rows->set_time_window(choices[best]); - app.model->set_event_span(choices[best]); -} - -void on_layout_changed(GObject* obj, GParamSpec*, gpointer user) { - App* app = static_cast(user); - if (app->updating_panels) return; - const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)); - const int rows = static_cast(sel) + 1; - if (rows == app->rows->row_count()) return; - // A row count is the figure's row count, fixed at construction, so this builds a new - // figure -- and the area has to be told about it. Nothing is shown or hidden: the rows - // divide the one area's height between them. - app->rows->set_row_count(rows); - if (app->area != nullptr) app->area->set_figure(&app->rows->figure()); +void refresh_window_limits(App& app) { + if (app.window_label == nullptr) return; + const double limit = window_limit(app); + if (std::fabs(limit - app.window_limit_s) < 1.0e-9) return; + app.window_limit_s = limit; + apply_time_window(app, app.rows->time_window()); } void on_pause_toggled(GtkToggleButton* button, gpointer user) { @@ -968,7 +1267,7 @@ void on_signals_toggled(GtkToggleButton* button, gpointer user) { void on_fit_clicked(GtkButton*, gpointer user) { App* app = static_cast(user); app->rows->fit_all(); - select_window_choice(*app, app->rows->time_window()); + sync_window_display(*app, app->rows->time_window()); app->notice = "fitted to the buffered data (" + format_window(app->rows->time_window()) + ")"; app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND; } @@ -1317,7 +1616,7 @@ void rebuild_signal_panels(App& app) { } rebuild_signal_list(app, signals); rebuild_trigger_signals(app); - refresh_window_choices(app); + refresh_window_limits(app); // Nothing to configure before a CONFIG arrives: hide the whole column and its // toolbar buttons rather than showing two empty panels. The first CONFIG @@ -1341,6 +1640,17 @@ gboolean tick(gpointer user) { App* app = static_cast(user); if (app->window == nullptr) return G_SOURCE_REMOVE; ScopeModel& model = *app->model; + const gint64 tick_start_us = g_get_monotonic_time(); + + // The paint probe's clock, claimed on the first tick that finds one: a widget has no + // frame clock until it is realized, and the area is realized on its first draw. + if (app->dbg_clock == nullptr && app->area_widget != nullptr) { + if (GdkFrameClock* fc = gtk_widget_get_frame_clock(app->area_widget)) { + app->dbg_clock = fc; + g_signal_connect(fc, "before-paint", G_CALLBACK(on_before_paint), app); + g_signal_connect(fc, "after-paint", G_CALLBACK(on_after_paint), app); + } + } if (app->relayout_pending) { app->relayout_pending = false; @@ -1412,7 +1722,7 @@ gboolean tick(gpointer user) { app->event_held = false; app->rows->pin_event_window(span - post, post); app->rows->set_trigger_marker(0.0, true); - select_window_choice(*app, span); + sync_window_display(*app, span); // The user saw the event at its edge, so that is when the counter starts. app->timed_edge = cap_edge; app->last_latch_us = g_get_monotonic_time(); @@ -1437,40 +1747,52 @@ gboolean tick(gpointer user) { // to term. The latch count stays the answer to "how many events fired", which is what // the probes assert on. const std::uint64_t seq = model.event_seq(); - if (seq != app->seen_event_seq) { + // The frame that *frames* the snapshot is the one that consumes its sequence number, + // not merely the first frame to see it. Two things can bind something else while a new + // snapshot is already published: + // + // - A publication and the *next* edge land in the same frame: the display follows the + // newer of the two and the capture above has framed its own window. + // - `display` is read before the lock the producer publishes under, and + // `trigger_pending` is only lowered at the end of the producer's packet loop -- long + // after `publish_event()`. So a tick can read `Capturing`, block on the mutex while + // the snapshot is published and the flag lowered, and get here bound to a capture + // that no longer exists. + // + // Consuming the number in either case dropped the pin permanently: the axis kept the + // capture's *requested* [-pre, +post] while the snapshot was shorter, and `event_held` + // stayed false, so `clamp_to_data()` was free to drag the frozen view off it. Leaving + // it unconsumed costs nothing -- the pin is simply owed until a frame is bound to the + // snapshot, and by then it is still the newest one. + if (seq != app->seen_event_seq && display == DisplayMode::Held) { app->seen_event_seq = seq; - // A publication and the *next* edge can land in the same frame. The display follows - // the newer of the two, so re-framing on the snapshot is only right when the snapshot - // is what is bound; otherwise the capture above has already framed its own window. - if (display == DisplayMode::Held) { - app->event_held = true; - // The window is pinned to exactly what the snapshot captured, read off the - // snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was - // *asked for*, in stream seconds, while how much the capture actually covers - // depends on the cadence the trigger channel was running at. Pinning to the - // request collapsed the waveform into a sliver at x = 0 and left the rest of - // the axis -- the whole post side included -- blank. - // - // The snapshot is edge-relative (the edge at x = 0) and the epoch is back to 0, so - // the bind at the top of this frame already put the slots and the axis on the same - // coordinates the pin is about to state. - double pre = 0.0; - double post = 0.0; - if (auto snap = model.event()) { - pre = snap->pre_s; - post = snap->post_s; - } - if (!(pre + post > 0.0)) { - // Nothing was captured (no samples in the ring yet): fall back to the - // requested window so the axis is at least readable. - const double span = std::max(1.0e-3, model.event_span()); - post = model.trigger().post_span_s(span); - pre = span - post; - } - app->rows->pin_event_window(pre, post); - app->rows->set_trigger_marker(0.0, true); // the edge sits at x = 0 - select_window_choice(*app, pre + post); + app->event_held = true; + // The window is pinned to exactly what the snapshot captured, read off the + // snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was + // *asked for*, in stream seconds, while how much the capture actually covers + // depends on the cadence the trigger channel was running at. Pinning to the + // request collapsed the waveform into a sliver at x = 0 and left the rest of + // the axis -- the whole post side included -- blank. + // + // The snapshot is edge-relative (the edge at x = 0) and the epoch is back to 0, so + // the bind at the top of this frame already put the slots and the axis on the same + // coordinates the pin is about to state. + double pre = 0.0; + double post = 0.0; + if (auto snap = model.event()) { + pre = snap->pre_s; + post = snap->post_s; } + if (!(pre + post > 0.0)) { + // Nothing was captured (no samples in the ring yet): fall back to the + // requested window so the axis is at least readable. + const double span = std::max(1.0e-3, model.event_span()); + post = model.trigger().post_span_s(span); + pre = span - post; + } + app->rows->pin_event_window(pre, post); + app->rows->set_trigger_marker(0.0, true); // the edge sits at x = 0 + sync_window_display(*app, pre + post); } // The trigger instant is marked on every row while its event is the thing on screen -- // being captured or held. x is seconds from the event in both: the display epoch is @@ -1514,6 +1836,10 @@ gboolean tick(gpointer user) { app->rows->note_y_gutter(gutter); app->rows->apply_y_gutter(); + // The editor's buttons are placed from the rects this frame was laid out with, so they + // follow a resize and the gutter settling without being told. + if (app->layout_editing) sync_layout_editor(*app); + if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget)); if (plot::Plot* p0 = app->rows->row(0).plot; p0 != nullptr && p0->viewport().w > 100.0f) { // steady state only @@ -1563,10 +1889,11 @@ gboolean tick(gpointer user) { ++app->dbg_axes_frames; } - // Once a second: how many samples each active channel has *stored*, and the rate - // that implies. The app no longer decimates (stride 1, clock stride 1), so a - // stored rate far below the datasource's declared rate is the datasource's own - // wire rate -- this line is the receipt for that. + // Once a second: how many samples each active channel has *stored*, and the rate that + // implies. Channels are never decimated against each other (`stride=1`), so with + // `decim=1` a stored rate far below the declared one is the datasource's own wire rate; + // with `decim=N` it is the wire rate over N, the resampling the window asked for. This + // line is the receipt for both. static gint64 next_channels = 0; const gint64 now_c = g_get_monotonic_time(); /// Filled only on a measuring tick, and applied to the status label's tooltip after the @@ -1612,9 +1939,11 @@ gboolean tick(gpointer user) { const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0}; std::fprintf(stderr, "[udpscope] ch %zu '%s' stored=%llu (%.4g MS/s, declared %.4g S/s) " - "elements=%llu stride=1 clock=1 published=%llu y=[%.6g, %.6g]\n", + "elements=%llu stride=%llu decim=%llu published=%llu y=[%.6g, %.6g]\n", c, info.name.c_str(), static_cast(stored), per_s / 1e6, declared, static_cast(info.elements), + static_cast(model.partition().stride[c]), + static_cast(model.partition().time_stride), static_cast(buf.size()), double(y.min), double(y.max)); } std::fflush(stderr); @@ -1757,6 +2086,10 @@ gboolean tick(gpointer user) { gtk_label_set_text(GTK_LABEL(app->status_label), status); } if (!rate_tip.empty()) gtk_widget_set_tooltip_text(app->status_label, rate_tip.c_str()); + const double tick_ms = double(g_get_monotonic_time() - tick_start_us) / 1000.0; + app->dbg_tick_ms_sum += tick_ms; + app->dbg_tick_ms_max = std::max(app->dbg_tick_ms_max, tick_ms); + ++app->dbg_tick_n; return G_SOURCE_CONTINUE; } @@ -1848,6 +2181,70 @@ GtkWidget* build_signal_panel(App& app) { return box; } +/// The display-window control: a button reading the current window, and a popover holding +/// the number, its unit and a decade stepper either side. +/// +/// [ 2 s ▾ ] -> [ /10 ] [ 2____ ] [ s ▾ ] [ x10 ] +/// +/// A ladder dropdown could only offer the spans someone had thought of, and the useful spans +/// run from microseconds (a fast datasource's few hundred samples) to minutes; a typed number +/// plus a unit covers that without an entry per decade, and the steppers cover the common +/// case of "an order of magnitude wider" without typing at all. +GtkWidget* build_window_control(App& app) { + app.window_label = gtk_label_new(""); + gtk_label_set_width_chars(GTK_LABEL(app.window_label), kWindowLabelChars); + gtk_label_set_max_width_chars(GTK_LABEL(app.window_label), kWindowLabelChars); + gtk_label_set_ellipsize(GTK_LABEL(app.window_label), PANGO_ELLIPSIZE_END); + + GtkWidget* face = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_box_append(GTK_BOX(face), app.window_label); + gtk_box_append(GTK_BOX(face), gtk_image_new_from_icon_name("pan-down-symbolic")); + + app.window_button = gtk_menu_button_new(); + gtk_menu_button_set_child(GTK_MENU_BUTTON(app.window_button), face); + gtk_widget_set_tooltip_text(app.window_button, + "the time span on screen, and the trigger's event window"); + + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_margin_top(row, 6); + gtk_widget_set_margin_bottom(row, 6); + gtk_widget_set_margin_start(row, 6); + gtk_widget_set_margin_end(row, 6); + + GtkWidget* slower = gtk_button_new_with_label("\u00f710"); + gtk_widget_set_tooltip_text(slower, "a decade narrower"); + g_signal_connect(slower, "clicked", G_CALLBACK(on_window_step), &app); + gtk_box_append(GTK_BOX(row), slower); + + app.window_entry = gtk_entry_new(); + gtk_entry_set_input_purpose(GTK_ENTRY(app.window_entry), GTK_INPUT_PURPOSE_NUMBER); + gtk_editable_set_width_chars(GTK_EDITABLE(app.window_entry), 8); + // Enter only. `changed` would re-frame the plot on every keystroke, so typing "100" would + // pass through 1 and 10 -- and each of those re-partitions nothing but does move the axis. + g_signal_connect(app.window_entry, "activate", G_CALLBACK(on_window_entry_activate), &app); + gtk_box_append(GTK_BOX(row), app.window_entry); + + const char* units[kWindowUnitCount + 1] = {}; + for (int i = 0; i < kWindowUnitCount; ++i) units[i] = kWindowUnits[i].name; + app.window_unit_drop = gtk_drop_down_new_from_strings(units); + g_signal_connect(app.window_unit_drop, "notify::selected", G_CALLBACK(on_window_unit_changed), + &app); + gtk_box_append(GTK_BOX(row), app.window_unit_drop); + + GtkWidget* faster = gtk_button_new_with_label("\u00d710"); + gtk_widget_set_tooltip_text(faster, "a decade wider"); + // Which direction a stepper steps is data on the button rather than a second callback: both + // do the same thing to the same number. + g_object_set_data(G_OBJECT(faster), "udpscope-window-up", faster); + g_signal_connect(faster, "clicked", G_CALLBACK(on_window_step), &app); + gtk_box_append(GTK_BOX(row), faster); + + GtkWidget* popover = gtk_popover_new(); + gtk_popover_set_child(GTK_POPOVER(popover), row); + gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.window_button), popover); + return app.window_button; +} + /// The header bar carries only the controls you reach for *while watching a signal*; /// everything you set up once lives in the overflow menu. Nine text buttons in a /// header bar is a wall of words to read past every time you want the one you need, @@ -1857,17 +2254,9 @@ GtkWidget* build_signal_panel(App& app) { GtkWidget* build_toolbar(App& app) { GtkWidget* bar = gtk_header_bar_new(); - app.window_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr); - g_signal_connect(app.window_drop, "notify::selected", G_CALLBACK(on_time_window_changed), &app); - gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), labeled_box("window", app.window_drop)); - refresh_window_choices(app); - - const char* layouts[] = {"1", "2", "3", "4", nullptr}; - GtkWidget* layout_drop = gtk_drop_down_new_from_strings(layouts); - gtk_drop_down_set_selected(GTK_DROP_DOWN(layout_drop), - static_cast(app.model->config().plots - 1)); - g_signal_connect(layout_drop, "notify::selected", G_CALLBACK(on_layout_changed), &app); - gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), labeled_box("plots", layout_drop)); + gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), + labeled_box("window", build_window_control(app))); + refresh_window_limits(app); // --- the overflow menu ---------------------------------------------------- // Plain buttons in a popover rather than a GMenu: these act on the `RowSet` @@ -1884,6 +2273,13 @@ GtkWidget* build_toolbar(App& app) { g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app); gtk_box_append(GTK_BOX(menu_box), fit); + app.layout_toggle = gtk_toggle_button_new_with_label("Layout editor"); + gtk_widget_set_tooltip_text(app.layout_toggle, + "add and remove plot rows in place: \u00d7 on a row removes " + "it, + between rows inserts one there"); + g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app); + gtk_box_append(GTK_BOX(menu_box), app.layout_toggle); + app.cursors_toggle = gtk_toggle_button_new_with_label("Cursors"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.cursors_toggle), false); g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app); @@ -2076,15 +2472,10 @@ gboolean exit_probe_step3(gpointer user) { a->dbg_trigger_selected = static_cast( gtk_drop_down_get_selected(GTK_DROP_DOWN(a->trigger_signal_drop))); } - if (a->window_drop != nullptr) { - GtkStringList* options = - GTK_STRING_LIST(gtk_drop_down_get_model(GTK_DROP_DOWN(a->window_drop))); - const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(a->window_drop)); - if (options != nullptr && sel != GTK_INVALID_LIST_POSITION) { - a->dbg_window_drop_text = gtk_string_list_get_string(options, sel); - } else { - a->dbg_window_drop_text = "(none selected)"; - } + if (a->window_label != nullptr) { + // The button's label, not the entry: the label is what the user reads, and the entry can + // legitimately hold a half-typed number. + a->dbg_window_text = gtk_label_get_text(GTK_LABEL(a->window_label)); } a->dbg_toggles_visible = (gtk_widget_get_visible(a->signal_toggle) && gtk_widget_get_visible(a->trigger_toggle)) ? 1 @@ -2303,6 +2694,196 @@ gboolean fields_probe(gpointer user) { return G_SOURCE_REMOVE; } +/// Headless self-check: drives the window control the four ways a user can -- type a number, +/// change the unit under it, and step a decade either way -- and prints what each produced. +/// Each line reports the typed request, the window the plot took and the trigger's event +/// span: all three must agree except where the cap clamps, and the event span must track the +/// display window or a capture would be cut to a different length than the axis frames. +gboolean window_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr || a->window_entry == nullptr) return G_SOURCE_REMOVE; + GtkEditable* entry = GTK_EDITABLE(a->window_entry); + GtkDropDown* unit = GTK_DROP_DOWN(a->window_unit_drop); + + auto report = [a](const char* what) { + double span = 0.0; + u64 stride = 0; + double stored = 0.0; + double buffered = 0.0; + double aa = 0.0; + { + std::lock_guard lock(a->model->mutex()); + span = a->model->event_span(); + const Partition& part = a->model->partition(); + stride = part.time_stride; + stored = part.stored_rate(); + buffered = part.span_s; + aa = part.antialias_hz(); + } + std::fprintf(stderr, "[window-probe] %-14s label='%s' entry='%s' unit=%u window=%.6fs " + "event_span=%.6fs cap=%.3fs decim=%llu stored=%.4gS/s aa=%.4gHz " + "buffered=%.4gs\n", + what, gtk_label_get_text(GTK_LABEL(a->window_label)), + gtk_editable_get_text(GTK_EDITABLE(a->window_entry)), + gtk_drop_down_get_selected(GTK_DROP_DOWN(a->window_unit_drop)), + a->rows->time_window(), span, a->window_limit_s, + static_cast(stride), stored, aa, buffered); + std::fflush(stderr); + }; + + report("initial"); + // 500 ms typed: the entry's own path, and a span no ladder of round numbers had. + gtk_drop_down_set_selected(unit, 1); + gtk_editable_set_text(entry, "500"); + on_window_entry_activate(GTK_ENTRY(a->window_entry), a); + report("type 500ms"); + // The unit re-interprets the number rather than converting it, so this is 500 s -- past the + // cap, and therefore the clamp's own test. + gtk_drop_down_set_selected(unit, 2); + report("unit ms->s"); + gtk_editable_set_text(entry, "2"); + on_window_entry_activate(GTK_ENTRY(a->window_entry), a); + report("type 2s"); + GtkWidget* up = gtk_button_new_with_label("x10"); + g_object_set_data(G_OBJECT(up), "udpscope-window-up", up); + on_window_step(GTK_BUTTON(up), a); + report("x10"); + GtkWidget* down = gtk_button_new_with_label("/10"); + on_window_step(GTK_BUTTON(down), a); + report("/10"); + // Both were created only to carry the direction flag; they were never parented, so they + // still hold the floating reference GTK4 created them with. + g_object_ref_sink(up); + g_object_unref(up); + g_object_ref_sink(down); + g_object_unref(down); + return G_SOURCE_REMOVE; +} + +/// The editor button of one kind at one index, or null. The probe clicks the real +/// widgets rather than calling the handlers, so what it drives is what a user reaches. +GtkWidget* layout_button(App& a, bool insert, int index) { + for (GtkWidget* b : a.layout_buttons) { + const bool is_insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr; + if (is_insert == insert && row_key(b) == index) return b; + } + return nullptr; +} + +/// One line of the layout probe: the row count, how many buttons are on the overlay, the +/// rows' own rects in logical pixels, where each button was placed, and what every row is +/// plotting. The last of those is the point of the whole probe -- an insert or a remove +/// has to move the rows' *contents* with them, not renumber which row a signal is on. +void layout_probe_report(App& a, const char* what) { + const int rows = a.rows->row_count(); + const double scale = (a.area != nullptr && a.area->device_scale() > 0.0f) + ? a.area->device_scale() : 1.0; + std::string geom; + const plot::FigureLayout& fl = a.rows->figure().layout(); + for (std::size_t i = 0; i < fl.rows.size(); ++i) { + char buf[48]; + std::snprintf(buf, sizeof(buf), "%s%.0f..%.0f", i == 0 ? "" : ",", + double(fl.rows[i].y) / scale, + (double(fl.rows[i].y) + double(fl.rows[i].h)) / scale); + geom += buf; + } + std::string close_tops; + std::string add_tops; + for (int i = 0; i < rows; ++i) { + char buf[24]; + if (GtkWidget* b = layout_button(a, false, i)) { + std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b)); + close_tops += buf; + } + } + for (int i = 0; i <= rows; ++i) { + char buf[24]; + if (GtkWidget* b = layout_button(a, true, i)) { + std::snprintf(buf, sizeof(buf), "%s%d", i == 0 ? "" : ",", gtk_widget_get_margin_top(b)); + add_tops += buf; + } + } + std::string traces; + for (int r = 0; r < rows; ++r) { + traces += (r == 0 ? "" : "|"); + traces += std::to_string(r) + ":"; + const RowSet::Row& row = a.rows->row(r); + bool any = false; + for (int s = 0; s < RowSet::kSlotsPerRow; ++s) { + const std::string& n = row.signal_name[static_cast(s)]; + if (n.empty()) continue; + if (any) traces += ","; + traces += n; + any = true; + } + if (!any) traces += "-"; + } + std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu rows_y=[%s] close=[%s] " + "add=[%s] traces=[%s]\n", + what, rows, a.layout_buttons.size(), geom.c_str(), close_tops.c_str(), + add_tops.c_str(), traces.c_str()); + std::fflush(stderr); +} + +/// Headless self-check: drives the layout editor the way a user does -- switch it on, +/// insert above the first row, remove a row, append one at the bottom, switch it off -- +/// with a frame between each step, because the buttons are placed from the rects the last +/// frame was laid out with. +gboolean layout_probe_step(gpointer user); + +/// Clicks one editor button. `::clicked` directly and not `gtk_widget_activate()`: a +/// `GtkButton` activated that way holds the press for `ACTIVATE_TIMEOUT` (250 ms) and only +/// then emits `::clicked`, so every step of the probe read the *previous* step's result -- +/// and the button whose timeout was pending is destroyed by the rebuild the step before it +/// caused, which dropped the click entirely. +void layout_click(GtkWidget* b) { + if (b != nullptr) g_signal_emit_by_name(b, "clicked"); +} + +void layout_probe_next(App& a, int step, guint delay_ms) { + a.dbg_layout_step = step; + g_timeout_add(delay_ms, layout_probe_step, &a); +} + +gboolean layout_probe_step(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + switch (a->dbg_layout_step) { + case 0: + // Through the menu's toggle, so the probe exercises the handler the user reaches. + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), true); + layout_probe_report(*a, "editor on"); + layout_probe_next(*a, 1, 200); + break; + case 1: + layout_probe_report(*a, "placed"); + layout_click(layout_button(*a, true, 0)); + layout_probe_next(*a, 2, 200); + break; + case 2: + // What row 0 held must now be on row 1: an insert moves rows, it does not renumber. + layout_probe_report(*a, "add above 0"); + layout_click(layout_button(*a, false, 0)); + layout_probe_next(*a, 3, 200); + break; + case 3: + layout_probe_report(*a, "remove 0"); + layout_click(layout_button(*a, true, a->rows->row_count())); + layout_probe_next(*a, 4, 200); + break; + case 4: + layout_probe_report(*a, "add at end"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->layout_toggle), false); + layout_probe_next(*a, 5, 200); + break; + default: + // Nothing left on the overlay: the editor's buttons exist only while it is up. + layout_probe_report(*a, "editor off"); + break; + } + return G_SOURCE_REMOVE; +} + /// Headless self-check: acquire every signal the CONFIG described, the way the /// signal panel's switches would, and put each resulting channel on a plot, the way /// a drag from the panel onto a row would. Without the second half nothing is bound @@ -2528,6 +3109,12 @@ int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& r if (const char* env = g_getenv("UDPSCOPE_PROBE_FIELDS_MS")) { g_timeout_add(static_cast(std::atoi(env)), fields_probe, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOW_MS")) { + g_timeout_add(static_cast(std::atoi(env)), window_probe, &app); + } + if (const char* env = g_getenv("UDPSCOPE_PROBE_LAYOUT_MS")) { + g_timeout_add(static_cast(std::atoi(env)), layout_probe_step, &app); + } if (const char* env = g_getenv("UDPSCOPE_TRIG_TRACE_MS")) { g_timeout_add(static_cast(std::atoi(env)), trig_trace_probe, &app); } @@ -2562,11 +3149,33 @@ int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& r // `frames_painted` is the area's, read from the snapshot taken before the window was // destroyed (the PlotArea wrapper dies with its widget): one figure, one render per // frame, so it is the same count for every row. + std::printf("[udpscope] rows: %d of at most %d, layout editor %s\n", rows.row_count(), + RowSet::kMaxRows, app.layout_editing ? "up" : "down"); for (int i = 0; i < rows.row_count() && i < RowSet::kMaxRows; ++i) { if (rows.row(i).plot == nullptr) continue; std::printf("[udpscope] plot %d frames_painted=%d traces=%u\n", i, app.dbg_frames_painted, rows.row(i).plot->trace_count()); } + // Where a frame's time went, split at the only two boundaries observable from the + // host: the frame function, the plot's draw, and the toolkit's whole paint. `draw` + // close to `paint` is the library's cost; `paint` far above `draw` is the toolkit + // compositing a framebuffer (which llvmpipe dominates). `path` says whether the + // traces went through the LOD pyramid at all -- a raw frame is O(visible samples). + std::printf("[udpscope] frame cost: tick %.2f/%.2f ms over %d, draw %.2f/%.2f ms " + "(max at frame %d), paint %.2f/%.2f ms over %d (mean/max)\n", + app.dbg_tick_n > 0 ? app.dbg_tick_ms_sum / app.dbg_tick_n : 0.0, + app.dbg_tick_ms_max, app.dbg_tick_n, + app.dbg_paint_n > 0 ? app.dbg_draw_ms_sum / app.dbg_paint_n : 0.0, + app.dbg_draw_ms_max, app.dbg_draw_max_frame, + app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0, + app.dbg_paint_ms_max, app.dbg_paint_n); + std::printf("[udpscope] frame path: raw=%d lod=%d frames, segments %llu/%u " + "(mean/max), visible max %llu samples\n", + app.dbg_raw_frames, app.dbg_lod_frames, + static_cast(app.dbg_paint_n > 0 + ? app.dbg_seg_sum / app.dbg_paint_n + : 0), + app.dbg_seg_max, static_cast(app.dbg_visible_max)); // Layout stability: every one of these must be a single value. A range means // something is resizing or re-measuring the plot, which is what a wiggling axis // looks like from the outside. @@ -2585,16 +3194,16 @@ int run_ui(int argc, char** argv, ScopeModel& model, Acquisition& acq, RowSet& r "means Disarm reached the engine)\n", static_cast(model.trigger_latches().load()), static_cast(app.dbg_latches_at_disarm)); - std::printf("[udpscope] window dropdown: '%s'\n", app.dbg_window_drop_text.c_str()); + std::printf("[udpscope] window control: '%s'\n", app.dbg_window_text.c_str()); std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d " "(revealed=%d) trigger=%dx%d (revealed=%d) toggles=%d cursors=%d window=%.3fs " - "choices_cap=%.1fs\n", + "window_cap=%.1fs\n", app.dbg_side_w, app.dbg_side_h, app.dbg_side_visible, app.dbg_signal_w, app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_revealed, app.dbg_trigger_w, app.dbg_trigger_h, app.dbg_trigger_revealed, app.dbg_toggles_visible, rows.row(0).cursors_visible ? 1 : 0, rows.time_window(), - app.window_choices_limit); + app.window_limit_s); std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str()); std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f " "valid=%d\n",