Commit Graph
177 Commits
Author SHA1 Message Date
Martino Ferrari 9f3e3597af signals: label plots by row and cell, drop onto a cell 2026-11-03 18:04:58 +01:00
Martino FerrariandClaude Opus 4.6 645cb6f4fa layout editor: split a row into cells, resize cells by drag
Add cell `+` buttons (n+1 per figure row), column resize handles with
drag, and update the `x` handler to call remove_cell instead of
remove_row. Fix row_weights_ misalignment in insert_row/remove_row.
Split probe steps 9-15 exercise content movement, column drag clamp,
row-weight tracking, and sensitivity at caps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 17:33:29 +01:00
Martino FerrariandClaude Opus 4.6 ae1d262be4 plot host: rows hold 1..4 cells; split probe scaffold
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 17:11:01 +01:00
Martino FerrariandClaude Opus 4.6 87f61ea364 ui: a signal's details sit directly under its name; its unit is user-changeable and saved with the session
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 11:42:30 +01:00
Martino FerrariandClaude Opus 4.6 bbef7faf3d ui: a signal's page shows gain and offset as aligned rows with a pencil each, replacing Calibrate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 11:36:58 +01:00
Martino FerrariandClaude Sonnet 4.6 1d144663b9 ui: the readouts strip is also shown in a collapsed window
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 10:50:55 +01:00
Martino FerrariandClaude Sonnet 4.6 c8d9d04f59 ui: the readouts show in a strip under the plot when the dock is hidden
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 10:47:11 +01:00
Martino FerrariandClaude Sonnet 4.6 86ea0bac38 ui: stale tooltip wording
The dock button's tooltip, the guided-tour menu row's tooltip and the m / g shortcut labels still described a side panel and tabs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:28:16 +01:00
Martino FerrariandClaude Sonnet 4.6 794df9b4d0 ui: one way to open the events page, and stale wording after the pane's removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:11:34 +01:00
Martino FerrariandClaude Sonnet 4.6 8c866300d8 ui: the events list is a slide-over page; the left pane, its divider and the header toggle are gone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 07:07:57 +01:00
Martino FerrariandClaude Sonnet 4.6 baf1c73b21 ui: compact events block in the dock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:48:35 +01:00
Martino FerrariandClaude Sonnet 4.6 38b31360cc ui: the cursors and measure pages are insensitive until the stream has signals
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:29:32 +01:00
Martino FerrariandClaude Sonnet 4.6 3d307a0185 ui: the cursors and measurements panels are slide-over pages; the dock is the readouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 06:24:52 +01:00
Martino FerrariandClaude Sonnet 4.6 a0fc0762d5 ui: compact cursor and measurement readouts at the top of the dock
Both views are written from the same variable at the same call site, so the dock cannot disagree with the full panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-03 05:22:41 +01:00
Martino FerrariandClaude Opus 4.6 034c82f7d7 Budget the sample buffer in samples per signal, under a hard memory limit
--buffer-samples (k/M/G) replaces --buffer-mb: the request is rounded down to a
power of two for every signal and the shared time ring. A separate memory limit
(--memory-limit-mb, a Preferences row, default 8 GiB) halves the plan until the
real ring bytes fit and says so on stderr and in the status line. The per-source
key buffer_mb becomes buffer_samples; an old buffer_mb is ignored.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:47:04 +01:00
Martino FerrariandClaude Opus 4.6 b98a3478ff badges: source badge shows the name only; drop the signals lock; the signals page proposes what can be added
The link state is the status bar's, so the Source badge loses its state text. The signals have no lock
(the badges and signal pages are direct manipulation), and the signals page lists only unacquired
signals and uncomputed math channels (plus the acquired ones while the badge bar has overflowed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:14:37 +01:00
Martino Ferrari dad424f1de ui: read the signal count under the lock in the badge bar; validate the drop payload 2026-11-03 01:53:09 +01:00
Martino FerrariandClaude Opus 4.6 ca10dfb323 ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 01:30:55 +01:00
Martino Ferrari f27925da59 ui: one badge per enabled signal, an N signals overflow, and the order kept in memory 2026-11-03 01:19:13 +01:00
Martino FerrariandClaude Opus 4.6 baf4a1f20f ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 01:03:51 +01:00
Martino Ferrari 0dd235e41c ui: the signal list is the signals slide-over page, opened by a + badge or g 2026-11-03 00:54:25 +01:00
Martino FerrariandClaude Opus 4.6 4b65197f56 ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 20:37:55 +01:00
Martino Ferrari 31ff778e07 ui: the Source badge, and the source panels as slide-over pages 2026-11-02 20:19:47 +01:00
Martino FerrariandClaude Opus 4.6 5fb479bd69 ui: a badge bar with a Trigger badge, and the trigger form in the slide-over
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 20:12:16 +01:00
Martino FerrariandClaude Opus 4.6 a40863fa45 ui: a slide-over panel over the plot's right edge, with its probe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 19:57:17 +01:00
Martino FerrariandClaude Opus 4.6 0d8c583bbb e1608: a configurable generator update rate, and the achieved rate in the Analog out panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 12:19:23 +01:00
Martino FerrariandClaude Opus 4.6 0c6e69957d ui: draw the sidebar tabs without the placeholder icon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 12:11:16 +01:00
Martino FerrariandClaude Opus 4.6 dbf145094a e1608: probe and docs for the network scan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 10:20:16 +01:00
Martino FerrariandClaude Opus 4.6 16e6d7fa75 ui: sidebar tab selector uses text labels instead of icons
The two drawn tab glyphs were judged not understandable enough; the
switcher goes to the wide policy with titles only, and the glyphs,
their gresource entries and the icon-naming code are removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 09:49:55 +01:00
Martino FerrariandClaude Opus 4.6 78a29fbc39 flatudp: the source, kind, mock sender and stream probes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 01:05:38 +01:00
Martino Ferrari 6e8e71b32f flatudp: the signal-list codec, with a hand-computed decode check 2026-11-02 00:58:02 +01:00
Martino FerrariandClaude Sonnet 4.6 2e9f9664cf e1608: UDPSCOPE_PROBE_E1608 and the e1608 form sample in the source-kinds probe
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-11-01 09:50:59 +01:00
Martino Ferrari dd4aed8dec fix: escape the source title in markup; capture peer comes from the source config 2026-10-31 20:27:47 +01:00
Martino Ferrari 5cee00aaee source: capability panels as side-stack pages, with a probe for the path 2026-10-31 20:18:28 +01:00
Martino FerrariandClaude Opus 4.6 059b660586 source: a synthetic probe kind, and sessions that remember their source kind
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-31 20:04:42 +01:00
Martino Ferrari 504bc193ea acquisition: drive a DataSource from the registry; --source and --source-opt 2026-10-31 19:59:51 +01:00
Martino Ferrari fcf268d249 Add --renderer for displays with no usable GPU; gate the header reading
GTK4 defaults to Vulkan, and an X11-forwarded display has no device to make a
swapchain from; the plot is a GtkGLArea, so it then asks for a GL context that
is not there either. --renderer cairo is the software preset for that display:
it exports GSK_RENDERER=cairo and LIBGL_ALWAYS_SOFTWARE=1 in main() before
gtk_init()/adw_init(), because both are read when the renderer and the context
are created. --renderer gl is the faster choice when indirect GL works, and
--renderer vulkan makes the default explicit. Nothing is exported, and GTK's
own choice stands, when the option is absent.

The once-a-second header line is now behind UDPSCOPE_PROBE_HEADER, following
the [notrace] rule: its widths are still sampled for the exit dump, but the
line is detail behind that reading rather than output every run prints.
UDPSCOPE_PROBE_RENDERER prints the exported pair before GTK starts and the
GskRenderer GTK actually built.

README, the manual's overview and docs/claude record the two symptoms and the
cure.
2026-10-28 16:03:05 +01:00
Martino Ferrari da826f2cb5 fix: persistence is the trace's own phosphor, not a density behind a stroke
The plotter now deposits every acquisition -- the newest included -- into one grid per trace and
draws no solid trace over it, each grid in that trace's colour and with a one-sweep ceiling (its own
commit, **1b8b55d**). This side needed almost no change of its own, because the colour was already
the trace's and the trace's colour is `RowSet::signal_color()` -- the invariants' one route -- but the
figures it reads back are now per trace:

- **The ceiling is left at the library's default of one sweep**, no longer derived from the fade.
  Deriving it from the steady state is the gain offset the user reported between the latest
  acquisition and the persistent image; a signal crossing the same place every acquisition now settles
  at the trace's colour, at full strength, and the newest sweep reads as the trace it is a picture of.
- **`cells`/`grid_bytes` are the sum over the row's traces** -- one grid each, a trace with no series
  holding none -- and both the probe and the exit dump carry `traces=` beside `rows=`. That is what
  makes the per-signal memory figure read: **556 537 cells and 2.2 MB per signal**, at 8904600 bytes
  over the four one-signal rows of the probe's run.

The probes, re-read on this build (the `verification.md` command, `--rate 500000 --batch 1000`):

    [density-probe] off enabled=0 ... rows=0 traces=0 cells=0 ink=0.0 peak=0.0000
    [density-probe] on enabled=1 ... rows=4 traces=4 cells=2226150 ink=226786.0 peak=1.0000
    [density-probe] step-acquisitions ... peak=1.5000
    [density-probe] time-before ... peak=2.1359
    [density-probe] step-time ... ratio=0.367879
    [density-probe] frame cost: tick 0.62 -> 6.96 ms (184/23 frames), draw 2.02 -> 6.16 ms (177/18)
    [udpscope]   density: frames=748 rows=4 traces=4 cells=2226150 grid_bytes=8904600 ink=489400.2 peak=2.2 upload_bytes=2226150

`on` `peak=1.0000`, `step-acquisitions` `peak=1.5000` and `step-time` `ratio=0.367879` are the legs
that were expected, unchanged; the *draw* mean is now **below** the run before the split (2.02 -> 6.16
against 2.09 -> 20.91), because the solid traces are no longer drawn over the image.

Docs updated with it: the trigger manual page (`persistence` is the trace, one image per signal, the
newest included), the README's persistence section, `invariants-trigger-and-pinning.md` (five rules,
the ceiling and the no-stroke rule among them), `verification.md` and `probe-readings.md` (the lines
above), the trigger panel's comment and tooltips, and the plotter submodule.

`cmake --build build --clean-first -j4`: **0 errors, the documented three warnings** (two
`MathNode::source`, one `-Woverlength-strings` at 12352); `make -C build test-ui` **13 ok**;
`make -C build test-help` **green** (15 pages); the self-check **0 `Gtk-WARNING`/`Gtk-CRITICAL`** and
`gtk warnings: 0`, two `buffer allocated:` lines.
2026-10-16 20:21:57 +02:00
Martino Ferrari 5e32199597 fix: the density's ceiling comes from the fade, so the ramp is visible
The library's `full_scale` now defaults to the default fade's steady state and `gamma` to 1.0 (its
own commit); this side states the ceiling for the fade *it* configured, because that is what the
ceiling means: the ink the display settles at, so the newest acquisition is a fraction of full ink
and white is reached only where acquisitions agree.

- **acquisitions**: `1/(1 - factor)` -- the geometric sum, 2.0 at the panel's 0.5, and 1.0 at 0
  (only the newest sweep, at full ink).
- **time**: the library's default of two sweeps. A time fade has no fixed steady state -- it
  settles where the trigger's rate and the constant meet, which is the *point* of it -- so a fast
  trigger reads brighter than a slow one the way a phosphor's does.
- `gamma` is left at the library's 1.0: a cell is a count of sweeps, so half as many must read as
  half the ink. With the ceiling at one sweep and gamma at 0.45 (the library's first defaults) one
  sweep was already full white and every display looked the same -- which is exactly what the user
  reported seeing, and why the assertion for it is a framebuffer one in the library's case
  (`empty=21 full=83 quarter=54`) rather than anything `ink`/`peak` can say.

The probe's own figures are unchanged in kind (they are the grid's): `on` `peak=1.0000`,
`step-acquisitions` `peak=1.5000`, `step-time` `ratio=0.367879`, `off` all zeros -- re-read on this
build, with the frame cost `tick 0.53 -> 7.49 ms`, `draw 1.89 -> 16.78 ms` and `gtk warnings: 0` in
the same run. The trigger/pinning invariants carry the ceiling rule and why the eye found it.
2026-10-16 17:38:06 +02:00
Martino Ferrari 974d6aa2d9 trigger: a phosphor persistence, driven by the plotter's density image
The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.

**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).

- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
  and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
  grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
  reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
  way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
  deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
  computed inside the paint that follows the tick -- so `density_pending` is set beside
  `record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
  transition and wrong on the first latch, whose axis was still the live ring's. Switching the
  display on under an acquisition already on screen deposits it too, so the switch does not appear
  to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
  the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
  or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
  file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
  sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
  sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
  now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
  own commit.

`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.

Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).

Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
2026-10-16 17:28:54 +02:00
Martino Ferrari e14e1ee069 trigger: a phosphor display keeps the last N acquisitions; and a Force button
Two changes in the trigger pane, landed together.

**Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward
the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour
exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per
(signal, age) in the row's free trace slots, named with the accumulate vocabulary so
`layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is
not a signal: the signal list, the colours and the measurements see one row per signal.
`ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the
snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because
the channel list is renumbered by any structural change since that acquisition was cut.
`RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and
`event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The
schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the
previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the
ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth,
which is the bound that cannot be driven into an OOM, and the panel says when either bound bites.
This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was
reading -- so the documented 0.35 translucency of an accumulation was opaque in fact.

**Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one
acquisition now, with the configured window. It is a synthetic edge rather than a second publisher
-- the engine opens one capture at the present moment and everything else is the ordinary latch, so
a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and
the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now
is a request for the history that exists, and the snapshot reports its own shorter pre_s), a
capture already filling is refused rather than restarted, and the state afterwards is the mode's:
Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex
with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so
a request written between the two would be dropped -- and consumed at the end of a packet, after
the channel loop and before the pending publish.

**A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed
`TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into
`signals()`). With one signal acquired, arming switched on the stream's clock signal and left the
watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt
the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was
about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`.
`trigger_signal_index()` is now the one resolution, used by both buttons.
2026-10-16 16:46:17 +02:00
Martino Ferrari fc4700d1c9 perf: a measurement pane is priced by its window, and its fold is inline
A pane is recomputed over the whole visible window on a cadence, and at a 15 s window
(11.8 M samples, 184 k level-0 level buckets, `--buffer-mb 4000` so the stride stays 1)
that is priced per *measured channel* and per *distinct share*. Measured with the new
`[levels-probe]` sweep, per recompute: one window with one level 6.58 ms, four shares
10.28 ms, sixteen shares 24.55 ms, and four measurements one share each over four
channels **35.69 ms** -- against a tick mean of 1.34 ms with an empty pane and 4.58 ms
with that one. That is the reported "very slow with multiple measurements": the pass
runs inside `tick()`, so it is a *frame* cost, and not an ingest one -- `gaps` and
`packets` stay clean even though each `MeasureWindowReader` holds the model mutex for
its whole life, because a socket buffer absorbs a few tens of milliseconds of producer.

The largest single term inside a pass was a call. `measure_accumulate_bucket()` folds
one bucket into one level, and it is the innermost loop of a walk whose iteration count
is the window's buckets *times* the pane's levels; it lived out of line in
`measure.cpp`, so every one of those iterations was a cross-translation-unit call.
Moving it, and the crossing state it updates, into `measure.hpp` as `inline` takes
sixteen shares on one channel from 24.55 to **17.25 ms** a recompute -- with every value
in the dump's `measurements:` unchanged. Two levers remain and both are measured: the
fetch's three out-of-line pyramid accessors plus a 48-byte copy per bucket (3.3 ms of an
11.8 M-sample window's 11.6 ms pass) needs a combined accessor upstream, and `raw` is
the *signal's* share (4.6 % on the mock's noisy channel against 0.1 % on its sine),
because a level inside a bucket's own `[lo, hi]` has to be walked sample by sample.

Bisected with the pane populated (the earlier bisect had it empty, which cannot see
this): the parent of the recent range and HEAD read **8.29** and **7.92 ms** a
recompute for the same four-type pane at the same window, and neither of the two
commits between them touches `ui_measure.cpp` or `measure.cpp` at all. So the cost is
the scale and not a regression, and the fix makes a pass cheaper rather than removing
one -- the plot's own per-frame cost was checked the same way (four commits, equal
within 3 %, and unchanged by the window, the ring size and the visible sample count).

`UDPSCOPE_PROBE_LEVELS_MS` with `UDPSCOPE_PROBE_LEVELS` and
`UDPSCOPE_PROBE_LEVELS_CHANNELS` is the sweep; `docs/claude/invariants-measurements.md`
records the curve and the inline rule, `verification.md` and `probe-readings.md` the
probe and what its numbers must read, and the README the pane's cost at this scale.
2026-10-16 08:00:54 +02:00
Martino Ferrari 0a209becb1 fix: a still frame's measurements are the frame's, not the viewport's
A measurement is a statistic of the window the pane is *about*, and on a
pinned view -- a held event, a paused stream, an opened file -- that is the
frame, not the part of it on screen. The pane followed the viewport
instead, so a wheel on a latched event moved `average` and `top`: the
reported "when measuring a still frame, moving the zoom makes measurements
such as average or top change". Nothing about the data had changed; the
user was only looking closer.

The window is `RowSet::pinned_range()` while the pin frames the view and
the viewport otherwise, and the choice is made in `update_measures()` --
the call site -- rather than inside `MeasureWindowReader`, because it is
*booked* as well as read: that window is what the "it moved" test compares
against `App::measure_window`, so a gesture inside the pin spends no
recompute either, and a reader that chose its own would leave a pinned
view recomputing every gesture frame for a number that cannot change.
`pinned_range()` is now the one expression of the pin's extent, shared
with the framing (`hold_pinned_window()`, `fit_x()`), and the reader is
handed the window instead of reading the plot. Live is unchanged: there
the window really moves and the statistic follows it, and the
accumulator's rule about what resets the history is about neither.

`UDPSCOPE_PROBE_MEASUREPIN_MS` is the guard, three legs of before/after a
gesture on the row's own axis. On a latched event zoomed to a quarter
(`view=[-1..1]` -> `[-0.25..0.25]`, `pin` unchanged): `same=4/4
for_same=1 pass_delta=0`, every value bit-identical -- `average=0.000000
top=0.957975 peak to peak=2.000000 amplitude=1.915950` both sides. The
same on a pause taken from it. Since the key `for=` follows `pin=` now,
`[measure-trace]` gained the field, so a held run reads `for==pin` with
`swin` free to sit inside it. On a live view a zoom to an eighth reads
`same=0/4 for_same=0 pass_delta=3 span_after=0.125000 follows=1` with the
values moving -- the case the rule must not swallow.

Before the fix the same probe reads `same=1/4 for_same=0 pass_delta=1
span_after=0.500000` on the held event (`top` `0.957975` -> `0.957966`)
and `same=0/4 span_after=0.125000` on the pause (`top` `0.957944` ->
`0.358614`). Live reads the same either way.

Also fixed, found by the pause leg: pausing while an event is *held*
froze an **empty** picture. `on_pause_toggled()` read `stream_at_x0()`
after setting the pause state, and that accessor follows the display --
`paused` with no pause view published yet answers `-display_epoch()`,
which on a held event is 0, so the cut searched the ring at edge-relative
x, found nothing, and the frame bound an empty snapshot: every
measurement `--`, every ruler gone (`ev=650001` on the probe's line now,
`ev=0` before). The offset and the window are read first, which is the
offset the axis is actually displaying. The live case is unaffected, which
is why the pause probe, which pauses a live view, never saw it.

Docs corrected where they asserted the old half-truth: the invariants now
separate the accumulator's rule (unchanged) from the window a value is
computed over (this change), the trigger/pinning file names the pin as
load-bearing for both the framing and the numbers and records the pause
ordering rule, `verification.md`/`probe-readings.md` describe the new
probe and `pin=`, and the README and the manual say what a still frame
measures.

Checked: `cmake --build build --clean-first -j4` 0 errors and the
documented three warnings; `make -C build test-ui` 13 ok; `make -C build
test-help` green; an exit-dump run 0 Gtk-WARNING/Gtk-CRITICAL and `gtk
warnings: 0`; `[measurepin-probe]`, `[pan-probe]` (`held=1`,
`allowed=1`, `limited=1`), `[pause-probe]` (`event_size` non-zero exactly
while paused, `axis` inside `frozen_x`, `skips` 0 live / 30 paused),
`[stats-probe]` (`dn=1` across the pause), `[zoom-probe]` (`gap_px=0.0`
at every step) and `[measure-probe]` + the dump's `measures
window:` (all-types run `marks_in_window=4/4`, `pin=off` live) on the fast
mock. `measure cost:` is unchanged in kind: `raw=37298 (0.0%)` of
`covered=121704537`, 1.04 ms a pass -- the runs that read higher are the
probe's own zoomed-to-a-slice windows, where the window is legitimately
shorter than a bucket.
2026-10-16 06:10:24 +02:00
Martino FerrariandClaude Opus 4.6 d91957a2fe fix: a recompute that renumbered its ring, and readouts frozen under a zoom
Three defects, all of them a window describing data that is not there.

A math edit refilled the derived ring with `clear()`, which renumbers it
from 0, leaving `produced()` short by the inputs' retained base. Since
`live_window_locked()` clamps against every channel's `produced()`, that
one lagging channel answered `n = 0` for the live view, every pause view
and every trigger capture until a whole ring of fresh data had gone by --
the reported freeze, invisible until the ring first wraps, hence its
intermittence. It now restarts with `clear_to(base)`, and
`ChannelBuffer::republish_from(base)` puts the published window back:
`acquire()` is incremental, so a rewrite in place published nothing and
the pyramid went on describing the samples the edit replaced.

The trigger's channel is an index, re-resolved by name after a renumbering
-- but only in `rebuild_enabled_locked()`, so a math add or delete went
through `set_math_channels()` with no restore, the index was clamped to
-1, and `process()` returned at once while the panel still named the
channel. The restore moves into `rebuild_channels()`, which *is* the
renumbering, so no caller can forget it.

And `update_measures()` suppressed its "the window moved" test while an
event was held -- the one mode where a gesture is the only thing that can
move it. Zooming a latched event left the pane describing the whole
captured event. A pinned window needs no exemption: it lands on the range
it already measured and fails the 2 % test by itself.

The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a
graph whose ring has already wrapped, which `math_probe` structurally
cannot reach; `[measure-trace]` now prints the mode, the window on screen
and the window the numbers describe; and the dump's `marks_in_window`
counts against the window on screen instead of against the one the marks
were computed over, where it was a tautology.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-15 16:30:48 +02:00
Martino Ferrari 074cb611bc fix: a clock that stalled on a re-plan, and a dialog that could not close
**The clock.** Trigger mode showed a blank plot and never latched when the session defined a math
channel, because such a session gives the model a plan *before* the stream's own signals are in it:
one derived channel, declared rate 0, so `plan_partition()`'s 1 kHz fallback is the interval those
early frames are stamped at -- 1300x too fast for a 1.3 MS/s stream -- and they have nowhere to go,
since the value loop skips derived channels. Relative mode then stamped a *position* times the plan's
*current* interval, so when the CONFIG's channels landed and the plan took the real rate (769 ns) the
same positions produced times 1300x *smaller* than what was already in the ring, and the monotonic
clamp held the clock at the old value for the rest of the run: one repeated stamp, `history=0`, no
latch, and a window the renderer cannot draw. The same family as the restarted-client poisoning
fixed in 620473a, through a different door. The filter's own output was finite and correct
throughout, and disabling the math channel only appeared to cure it because that is a structural
change which rebuilds the store -- the third time that channel has looked guilty while being a
bystander.

Relative mode now accumulates *seconds* (`rel_base_s_ += n * dt`), so a re-plan is a slope change and
the clock cannot jump backwards by construction; a frame is only stamped into a model that has a
*streamed* channel; and the epoch is adopted once per connection, with "unset" a state rather than
the value zero. A one-shot warning names the family if it returns: `the clock stalled at ...: N
samples stamped [a..b] are all behind it`.

**The dialog.** The Configurations window's Close button and the window-manager close both did
nothing: `close-request` was connected to a bare `void` lambda, and that signal is `gboolean` -- TRUE
means "handled" and suppresses the default handler that destroys the window, while `G_CALLBACK` is a
cast, so GTK read whatever the compiler left in the return register. A standalone GTK program of that
shape reproduces it: `void` handler -> `window_alive=1` after `gtk_window_close()`, `gboolean FALSE`
-> 0. The same audit found `gtk_window_present(GTK_WINDOW(...))` on an `AdwDialog`, a type assertion
and a no-op; it is `adw_dialog_present()` now. The menu row stays: it is the only way to switch or
save a configuration without restarting, and what made it look useless was that it could not close.

Verified: trigger mode with that session's graph reads `ring=[0.0058..0.2446] -> [0.0058..1.9969]`,
`history 0.237 -> 2.0 s`, `edge at 1.120 s, latched at 2.120 s`, `display=event(frozen) latches=1`,
`traceless paints 266` against 1519 before the fix. Baselines hold: 0 errors, the documented three
warnings, test-ui 13 ok, test-help green, `gtk warnings: 0`.
2026-10-15 10:50:09 +02:00
Martino Ferrari 22e7a8efcc measure: the split's own means are back as above/below/between
`top`, `bottom` and `amplitude` became the *flat levels* in e83ad86 -- the level the
signal dwells at, read from the density of the window's buckets -- and that change kept
the split's own two means as its internal fallback for a window the pyramid cannot
summarize. Those means are now three types of their own, so the two different questions
about one split can be asked side by side: `above %` and `below %` are the plain mean of
the samples on one side of a share -- the reading the old `top`/`bottom` gave -- and
`between % %` is the mean of the band between two shares, which no single split can
answer, because a band's two edges are two thresholds.

Nothing about the arithmetic is new, and nothing is a new pass. `Above`/`Below` are the
`sum[]`/`count[]` split `measure_accumulate()` already folds -- the very figures
`Top`/`Bottom` fall back to -- and `Between` is the **difference of two levels' upper
sides**, both of which come out of one pass two over one window. It rides the bucket
fast path unchanged: a bucket wholly inside the band is strictly above the lower level
and strictly below the upper one, so both accumulators fold it whole, and only the
buckets that straddle either edge are opened. `measure_level_pcts()` is the one place the
shares are read, and it clamps both and puts `between`'s pair **in order**, so "between
80 % and 20 %" is the band between 20 % and 80 % -- normalized rather than refused,
because the shares name two edges rather than a direction. Nothing to measure reads `--`,
as for every other type: a share at 100 % has no sample above it, and a band can hold
none at all.

Measured, `[bandavg-probe]`, fast mock at a 2 s window (`--rate 1300000 --batch 1000
--freq 1`):

  sine   `above 50` 0.636620 (2/pi), `below 50` -0.636620, `between 50 75` 0.255873
  square `above 50` 0.986496 (30 harmonics) / 1.000000 (--square-harmonics 0),
         `between 50 75` 0.302744 / --
  ramp   `between 50 75` 0.250000 and `above 50` 0.500000, `above 100` --

The first three are the figures the old `top`/`bottom` documented; the ramp's are what a
uniform density predicts (a band's mean is its own centre); the ideal square's empty band
is the nothing-to-measure rule. The pane offers the types and edits their percentages
through the dialog's second share -- which only `between` shows -- and a measurement's
pair survives add/edit/delete and the session file (`threshold2`), where a file written
before it existed loads at the default.

No raw pass: with the measurements probe adding one of every type at a 2 s window, `raw`
went from 113966 of 240719031 covered (0.0%) to 134978 of 255372033 (0.1%), 0.8 -> 1.1
ns a covered sample.

Docs: the band-average invariant (and the two assertions the flat-level change moved),
the probe readings for the new probe, the verification recipe, the manual's measurements
page and the README.
2026-10-15 07:56:37 +02:00
Martino Ferrari d549223889 fix: the guided tour selects the page a stop's target lives on
A stop whose target is on another page of a stack was entered without
switching to that page, so the spotlight aimed at a widget that was not
on screen. A stop now declares the page it stands on (`TourStop::page`)
and the tour selects it as the step is entered -- the window's trigger
and signals stops put the sidebar on Signals, the chooser's tabs stop
shows the Waveforms page and stepping off it brings Sources back.

The stop filter moved into `tour_begin()` and now runs on
`gtk_widget_get_mapped()`: GTK 4.24's `is_visible()` ignores
`child_visible`, so it kept a stop on another page *and* one in a
collapsed pane alike. A wrong page is kept and selected; a collapsed
pane, whose stack is unmapped too, is still dropped.
2026-10-14 23:01:43 +02:00
Martino Ferrari 829176248a help: a tinier (i), hints beside their controls, and the tour starting in the chooser
The contextual help becomes the affordance the ask names: a plain (i)
(`dialog-information-symbolic`) at **16x16**, and one that sits beside the
control it explains rather than only in a pane's heading.

- The mark: `GtkMenuButton` ignores `min-width` on the widget and a size
  request, so the size is made in `install_css()` on all three nodes (outer
  `menubutton`, its `button` subnode, the glyph). Measured 16x16 against the
  default 34x34; the dump prints it as `hint=WxH`, measured with
  `gtk_widget_compute_bounds()` on the first *visible* hint.
- More sites: `events_record` (the "record events" switch in the trigger
  panel), `trigger_advanced` (the pre-trigger share row), `signals_config`
  (the signals pane's hint line, wrapped in a box so a hint can join it).
  Hints join as siblings, so none can cover a switch, pencil or dropdown.
- The source chooser gets its own layer -- `chooser_sources` (a new heading
  above the saved-source list) and `chooser_waveform` (the waveforms action
  row) -- built with the one `context_help_button()` both windows use and
  re-synced by its Help menu's toggle. The form's `AdwEntryRow`s cannot host a
  sibling hint, so none is invented for the host/port fields.
- The tour is one implementation over two windows: `ui_impl::TourView` plus a
  list of `TourStop`s; each window keeps its own table. A walk that passes its
  last stop calls `on_done`, and the chooser's sets `Config::tour_pending`, so
  a first run is one tour that crosses from the chooser into the app. The
  chooser's tour auto-starts from `settings.tour_seen` exactly as the window's
  does, and its "Guided tour" row now starts *it* instead of only requesting
  the window's.
- Probes: `UDPSCOPE_PROBE_CHOOSER_TOUR=1` / `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`,
  the hint size in the `help layer:`/`[helpui] contextual on:` lines, and the
  chooser's hints in its `chooser ui:` line. Docs updated in
  invariants-gtk-and-chrome.md, probe-readings.md and verification.md.
2026-10-14 22:42:38 +02:00
Martino Ferrari bb62b8178c fix: place the guided tour's card from its allocation, and count GTK warnings
The tour measured its card with a height-for-width request whose for_size
came from the natural width minus the margins the tour sets for positioning.
A measure reports margins *included*, so that for_size landed a margin short
of the minimum the box answers with and GTK warned once per frame:

  Trying to measure GtkBox 0x... for width of N, but it needs at least M

(about 9/s for as long as the tour was up). The card now takes its size from
gtk_widget_compute_bounds() once a frame has run -- its allocation rectangle,
not gtk_widget_get_width(), which is only the content box -- and falls back
to a measure with no for_size at all only on a frame with nothing allocated.

GTK is built with structured logging, so its messages reach the log writer
and never the handler API; no existing probe can read one. The exit dump now
counts them (`gtk warnings:`, must read 0) through a writer function chained
to g_log_writer_default(), which leaves stderr unchanged.
2026-10-14 17:38:25 +02:00
Martino Ferrari d8a79b96ba help: default the contextual layer off, and carry the help into the chooser
The contextual help was on by default, so a first run met a window with a "?"
beside every surface it had not asked to have explained; `contextual_help` now
defaults to false and the layer is asked for from the Help menu, from the
chooser's new help menu, or with Ctrl+H.

The source chooser runs before `App` exists, which makes it the window a user
who does not know how to start is looking at, so it now carries the same three
help affordances the window's Help view does: Documentation (the manual is
another process and needs nothing of the app), Enable contextual help (a row
whose check *is* the setting, written to the file the app reads a moment
later), and Guided tour. The tour is drawn on the app's own window, so the
chooser can only record the wish: it sets `Config::tour_pending` -- the value
`main()` owns and hands to both `choose_source()` and `run_ui()` -- and
`on_activate()` starts it once the window is up and laid out, through its own
callback so the readings can tell a request from the first-run auto-start.
The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()`
therefore runs from `choose_source()` (it is idempotent now) so the popover
gets the `.udpscope-menu` rules before it is shown.

Opening the manual could also fail with a message that blamed the handler
whatever was missing. `help_installed_path()` now resolves `help:<id>` the way
the specification does and the failure alert says which of the two it is: with
the set installed it names the viewer, without it it names the identifier, the
expected installed path and the install command. `show_help()` gained an
optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading --
`[help] launch attempted= done= ok= error='' installed=` -- since a successful
launch leaves no window of ours to observe.

Tested: clean --clean-first build, three warnings as documented; test-ui 13/13
and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs.
2026-10-14 17:22:03 +02:00