Commit Graph
72 Commits
Author SHA1 Message Date
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 616cde5b13 badges: hide the Source badge when the source has no panel of its own
A Source pill that opens nothing (UDPS, Flat UDP, files) was noise. It is now shown only while the slide-over has a source page; the [badges] source line gains visible=.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 02:38:12 +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 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 a1672acc70 ui: tighten the badge/dock/tour probes after review and fix the tour's stale hole
The session old-file probe and save_scheduled now discriminate, Esc is read through the real key path, the dock-section probe proves the Measure section starts out of view, and the tour's spotlight is measured independently of the helper it is drawn from. A target scrolled out of its scroller no longer leaves the previous hole standing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 20:56:09 +01:00
Martino FerrariandClaude Opus 4.6 2826042156 ui: the tour and the contextual help follow the badge bar, the slide-over and the dock
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 20:46:59 +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 e3bc1f72f3 flatudp: vector rows as channels or as a waveform, a re-openable source, probes for both
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-02 01:18:43 +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 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 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 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 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 e83ad86b8f measure: top/bottom/amplitude read the flat levels, not the extremes
A bench scope separates Vtop/Vbase from Vmax/Vmin because a band-limited
edge overshoots and rings: the mock's `square` at 30 harmonics reaches
±1.179 where its plateau is ±1. On the rippled square the old readings were
a property of the edges rather than of the waveform -- `top` 0.986496,
`amplitude` 1.179350 -- and `amplitude` reported the overshoot.

`top`, `bottom` and `amplitude` are now the flat levels and their delta.
A flat level is a density question -- the plateau holds most of the
samples and the ringing a few percent -- so `measure_scan_buckets()` folds
a 64-bin histogram of the whole buckets' midpoints while it has the list in
hand (one observation per bucket, each bin carrying its buckets' exact sum
and finite count), and `measure_flat_levels()` takes, either side of the
split, the densest bin holding at least half the densest one's buckets --
the outermost of those, which is what makes a waveform with no plateau read
its peak -- and the exact mean of the samples of the buckets within 5 % of
the window's peak-to-peak of it. 5 % is deliberately under the ~9 %-of-swing
Gibbs overshoot. `amplitude` is `top - bottom` (a scope's Vamplitude), not
the half-swing it was.

Measured, fast mock, 2 s window, harmonics 0 -> 30: top 1.000000 ->
0.999983, bottom -1.000000 -> -0.999983, amplitude 2.000000 -> 1.999966,
while `peak to peak` keeps the extremes (2.000000 -> 2.358701). On the 1 Hz
sine top is 0.957988 (it was 0.636620, the half-wave mean; a sine has no
plateau, so the densest band is at its peak) with sd 0.0014 over a scrolling
window, and `peak to peak` is still exactly 2.

No raw pass and no new fetch: the histogram is the same fetched bucket list
read a second time, gated on a pane that holds one of the three types.
`raw` is unchanged (0.1% clean, 2.1% with the --noise 0.1 interferer,
against 0.1%/2.1% before) for 0.44 -> 0.50 ms and 0.52 -> 0.65 ms a pass.
A window the pyramid cannot summarize -- an hf-filtered walk, or one shorter
than a bucket -- keeps the old readings: the mean of each side of the split,
and the window's peak to peak for amplitude.

Docs: the flat-level invariant (and the two 2/pi assertions it replaces),
the probe readings, the manual's measurements page and the README.
2026-10-15 01:19:33 +02:00
Martino Ferrari fe499bfe41 help: the search block's (i) on its header, a 12 px mark, and a real search page 2026-10-14 23:29:00 +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 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
Martino Ferrari 1baa58b8fd feat: the help is a submenu, with a contextual-help layer and a guided tour
The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu**
now, which is where every GNOME menu keeps them, with the four things a first run cannot find:

  - Keyboard shortcuts   Ctrl+/   (the existing overview, `show_shortcuts()`)
  - Documentation        F1      (the existing manual, `show_help()`)
  - Guided tour          Ctrl+T  (new)
  - Enable contextual help        (new, a stateful row: its check *is* the setting)

The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same
reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing
to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything
else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of
the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()`
and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped
child would leave the view that is not showing without an owner.

**The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the
trigger, the cursors, the measurements, the math channels, the signals, the event history, the time
window, the status line, and the math editor's identity row. Each row names the *heading box* its
button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover
the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer.
Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a
list of pointers because the math editor's heading is built afresh on every opening and a list would
go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written
through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own
rows start from `load_settings()` and so carry them through untouched.

**The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side
panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer
grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts
that as a reading (`controllers=` against the count taken before it opened) rather than as a claim.
Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because a widget's
allocation is 0 on the frame it is first shown and a window resize moves it under a one-shot
measurement; the hole is kept from the last frame that could measure the target, because a frame that
lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish`
and `Escape` all hide every child and leave nothing behind.

A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a
user who changed the theme has a file and has still never seen it), and the flag is written when the
tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it.

`UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the
submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour
started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and
`help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's
annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can
still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu
action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap
the invariants already warn about), and the tour card cannot miss its target when the target is the
whole content -- which is what the plot is at the window's 756 px minimum, where the split view
overlays the side panel instead of putting it beside the plot. The probe prints both rectangles
rather than asserting a placement, so that case reads as the geometry it is.

Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a
280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a
feedback loop -- that is what made the tour card walk between the corner and the target from frame to
frame.
2026-10-14 17:02:29 +02:00
Martino Ferrari 620473a5be feat: the locked trigger pane carries Arm/Disarm, and one function states both arm buttons
The compact line -- the pane's locked, "what is it set to" mode -- showed the arm state in words and gave
no way to change it, so arming from there meant unfolding the panel first, which is the thing the lock
exists to avoid. The control is built into the pane's *heading*, the row that stays on screen when the
body goes, and it is wired to the same `on_arm_clicked` the body's button uses, so both do one thing.

Its label, sensitivity and visibility are stated by `sync_arm_buttons()`, called from the four places any of
its inputs can move: the click, the mode toggle, the curator's per-tick re-read (a single-shot latch disarms
by itself) and `apply_pane_lock()`. The label comes from the producer's `trigger_armed()` atomic, the other
two from the toolbar toggle that owns `enabled`, so no caller needs the model lock. Visibility is
`enabled && locked`: with trigger mode off the line beside it already reads "stream (trigger off)" and there
is no engine to arm, and with the pane open the body's own button is the one on screen. Never both -- two
arm buttons would be two controls for one state.

Verified with the arm probe, which locks the pane the way a user does (the [edit] click):

    compact arm with the trigger off: visible=0; with the trigger on and the pane locked:
      visible=1 sensitive=1 width=51 label=Disarm
    [udpscope] compact arm probe: locked=1 enabled=1 own=1 effective=1 width=51 panel_width=-1 label=Disarm

`panel_width=-1` is the "not both" assertion: the body's button is not on screen while the compact one is.
The width cost a defect to read. The control is created hidden and shown for the first time inside the
handler, so a same-tick `gtk_widget_get_width()` reads 0 while `gtk_widget_is_visible()` already says 1 --
and the body button's *stale* allocation (270 px, from when the body was still up) reads as a live one,
because `gtk_widget_get_visible()` is the widget's own flag and stays true under a hidden parent. The
reading is therefore taken one frame later, by a step of its own, and the control is compared with
`is_visible()`. Both defects are in the invariants, because the next control read this way will meet them.

0 Gtk-CRITICAL; clean build with the documented four warnings (the help resource re-measured); test-ui
14/14. Docs: the rule and both readings in `invariants-trigger-and-pinning.md`, the probe's entry in
`probe-readings.md`, and the user-facing sentence in `src/help/08-trigger.txt`.
2026-10-14 16:18:24 +02:00
Martino Ferrari 8b2dae7655 fix: the overlaid sidebar steps aside for a drag, and the compact view follows the trigger
Two reports, both real, and neither was about the lock itself.

**A drag out of an overlaid sidebar had nowhere to land.** In a window too narrow to show both columns,
`AdwOverlaySplitView` puts the sidebar *on* the plot — that is what `collapsed` means — so the pointer
stays over the list and never over a drop target: a signal could be dragged but not dropped, which reads
as "dragging does not work". `row_drag_sidebar()` now hides the sidebar on `drag-begin` and restores it on
`drag-end`, and only when collapsed: side by side the plot is already visible, and hiding the list the drag
started in would be startling. The reading begins a drag through the *source* with the state forced
collapsed, the way the sidebar probe forces it:

    pane drag: sidebar hidden for the drag=1 restored after it=1

**The compact view did not follow the trigger switch.** The locked line's first branch reads
`tc.enabled` — with trigger mode off it says `stream (trigger off)` and nothing else — and
`on_trigger_toggled()` never re-synced it, so the line kept saying whichever it had been. One call to
`sync_trigger_summary()` beside the switch sync. The dump's two `panes:` lines were already the test and
now pass it: with a session whose trigger is off, the line as found reads `stream (trigger off)` and the
live one reads the settings, because the exit probe turns the trigger on in between. Both reading `stream`
was the defect.

Verified: the two lines above; `panes: the same line as the probe found it: 'mode: stream (trigger off)'`
against the live `'signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: normal'`;
`make test-ui` 14 files; zero `Gtk-CRITICAL`. Docs: the `pane drag:` reading and the invariants bullet for
the overlay, and the `panes:` pair's second job — the refresh test — written down so the next reader knows
what both lines reading `stream` means.
2026-10-14 15:04:10 +02:00
Martino Ferrari a43d3830f0 test: the locked signal list keeps its drag, and the reading says so
Asked for: dragging a signal onto a plot must still work in the compact (view) list. It does — the drag
source is on the *row* and was never conditional on the lock — but nothing said so, and "the lock rebuilds
this list and drops three of its controls" is exactly the change that could take the drag with it. Now it
is a reading: **`drag_sources` must be `rows - 1`**, one per signal row, the `MATH` divider not being
draggable.

    signals panel: … rows=3 signals=2 math=0 divider=1 drag_sources=2      (locked)
    signals panel: … rows=5 signals=4 math=0 divider=1 drag_sources=4      (configuration mode)

Measured the direct way first, with a throwaway walk of the list's rows and
`gtk_widget_observe_controllers()`: `locked=1 rows=3 rows_with_a_drag_source=2`, the third row being the
divider. That is where the number in the reading comes from, and the walk is what found the shape of the
answer rather than assuming it.

One consequence worth recording, because it is an improvement rather than a cost: a locked row can be
dragged from *anywhere* on it, the colour chip included. In configuration mode the chip carries a click
gesture that claims the event sequence precisely so a drag cannot start there (it opens the colour
chooser); the locked list does not build that gesture, so the chip is inert and the row's drag source
sees the press.

What the lock does *not* change is which signals are listed: only the ones that are on. A signal nobody
acquires draws nothing on a plot, so a row for it would be a drag that ends in an empty slot.

Verified: both lines above, from sessions differing only in whether one exists; `make test-ui` 14 files;
zero `Gtk-CRITICAL`. Docs: the `drag_sources` field with what a `0` would mean, the invariants' lock
bullet, and the README's note.
2026-10-14 14:57:29 +02:00
Martino Ferrari d8705a369f ui: Arm is disabled with trigger mode off, and the redundant Go-live button is gone
**`Arm` is now disabled while trigger mode is off**, and labelled `Arm` again: it is the one control in
that pane that is *runtime* rather than setting, so with no engine running there is nothing to arm.
It is also the only thing in the body that stays disabled — the settings themselves stay editable, which
is the point of the previous commit. `arm sensitive with the trigger off=0` is the reading.

**`Go live (trigger off)` is deleted.** Its handler was `trigger_toggle → FALSE`, i.e. exactly the
toolbar's trigger switch and the pane's own switch, which is what it was for: "the way out of trigger
mode, from inside the panel that froze the view: it is the toolbar's trigger switch, so there is exactly
one piece of state." With that switch now in the pane's own heading, the button was the same control
drawn a third time. The status line that told users about it says "or switch the trigger off to stream"
instead. The events pane's own `events_live` button is a different button on a different path — the file
view restore — and is untouched.

**And one silent regression this work uncovered.** The `trigger panel:` frame reading had been walking the
*wrapper* box the app puts around the `.ui`'s panel (heading row + locked summary + panel) ever since the
heading moved out of the file in the lock commit, so it reported `boxes=2 … search_inside=0/4` against a
panel that was entirely correct. It reads `trigger_body` — the file's own panel — again, and now says so in
its comment, because a reading that can silently start describing the wrong widget is the failure it exists
to catch.

Verified: `trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4` (the
documented shape, restored); `arm sensitive with the trigger off=0`; `trigger panel sensitive …=1`;
`panes:`/`panel switches:`/`pane edits:` unchanged; `make test-ui` 14 files; zero `Gtk-CRITICAL`.
2026-10-14 14:50:04 +02:00
Martino Ferrari 796dda4ad5 fix: the trigger stays editable while it is off, and the locked pane says "stream"
Two things the lock got wrong, and they are the same mistake seen twice: trigger-off was treated as "the
pane is disabled".

**The configuration was unreachable exactly when it was wanted.** `on_trigger_toggled()` set the trigger
*body* insensitive with the mode — a rule carried over from the five-revealer sidebar, where a closed pane
had nothing to edit. A trigger is configured *before* it is switched on, so switching it off to change it,
or to read what it is set to, is the ordinary way to work. The body is now never disabled; only the
*cursors'* readout still is, which is the pane whose content has nothing to show with the instruments off.

**The locked pane described a trigger that was not running.** With trigger mode off the summary is one
line — `mode: stream (trigger off)` — because the four lines it used to show (signal, condition, level,
mode) describe a trigger that is not there. With the trigger on it is those four plus whatever is not at
its default (`noise band`, `HF reject`, `pre-trigger`, `hold-off`).

The reading changes with it: `trigger panel sensitive …=1` is now a **regression guard** rather than a
state — the trigger's body is always live, so a `0` there means the settings have been locked out again.

Verified in both states, from sessions whose only difference is `trigger_enabled`:

    panes: trigger=locked signals=locked body_visible=0 summary='mode: stream (trigger off)'
    panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: single | pre-trigger: 20 %'
    trigger panel sensitive before the probe toggled it: 1

The locked trigger pane is 72 px with the trigger off and 192 px with it on. Docs: the invariants' rule
for which body is disabled and why the trigger's is not, the `summary`'s two forms with what each means,
the README's deviation note, and the second `panes:` line renamed to say what it is — the line as the
probe found it, because only a run whose *session* has the trigger off shows the stream form.
2026-10-14 14:46:15 +02:00
Martino Ferrari 4f4851f4e3 fix: the [edit] buttons' icons, and the locked pane says four lines not one
Two things the lock shipped wrong.

**The "done" icon did not exist.** `emblem-ok-symbolic` is a breeze icon; Adwaita — which is what this
app runs under — does not have it, so the button that leaves configuration mode drew a missing-image
placeholder, i.e. nothing. Both names are now Adwaita's own: `document-edit-symbolic` (the pencil: edit)
and `object-select-symbolic` (the check: done). This is exactly the silent failure `toolbar icons:`
exists for, so the two `[edit]` buttons get the same treatment: `pane edits: locked=…(ok) config=…(ok)`
is read by clicking into configuration mode and back, so one line carries both names and
`gtk_icon_theme_has_icon()`'s verdict for each. The invariants now carry the rule, including the trap
that a name found under `breeze` is not an answer.

**The locked pane was too compact.** One line could not hold what a user opens the pane for, so it is now
a small definition list: `signal`, `condition`, `level`, `mode` on four lines, and after that only the
figures that are *not* at their defaults — `noise band`, `HF reject`, `pre-trigger`, `hold-off` — because
a line reading "band 0" is a line that says nothing. The channel is resolved from the acquired channels
and the condition quotes the figures its own shape reads (a width and its comparison, two levels, or a
timeout), never one it ignores. The pane is ~192 px tall locked against 580 in configuration mode.

One self-inflicted critical on the way: the probe read the summary with `gtk_label_get_text()`, and the
container is a grid now — `Gtk-CRITICAL: assertion 'GTK_IS_LABEL (self)' failed`. The text comes from the
sync that writes it instead.

Verified: `pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)`;
`panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling
| level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms'`;
`make test-ui` 14 files; zero `Gtk-CRITICAL` and no missing icon.
2026-10-14 14:37:41 +02:00
Martino Ferrari 121abbefae feat: locked panes for a returning user, and the trigger is persisted
Two panes, two modes. **Locked** is what a returning user gets: the trigger pane shows one line saying
what it is set to, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — each heading has an `[edit]` button (the Adwaita `emblem-system-symbolic`,
swapping to `emblem-ok-symbolic` in configuration mode) that flips its pane. A **first run** — no session
file for that source — never reaches `apply_layout()` and comes up in configuration mode, because
configuring the host is what a first run is for. The mode is a mode and not a setting, so it is
deliberately not persisted.

The locked signal list drops the rows of every signal that is off, which is the whole of "show active
signals": the list already filters on nothing, so the lock is one `continue`. With the rows go the acquire
tick, the overflow menu (Information, Calibrate…, Delete, rename) and the `MATH` divider's `+` — and the
colour chip stops being clickable, because choosing a colour is configuration too. Dragging a signal onto
a plot stays: that is which view shows what, not what the scope is set up to do.

**The trigger is persisted, settings and state both** — the settings *even while it is off*, which is the
point: a trigger configured once and switched off is still the trigger you come back to. `LayoutState`
gains fifteen flat `trigger_*` keys; the channel is stored by *name* (every rebuild renumbers the
channels, the model's own rule) and the two enumerations as **the words the panel's dropdowns show**,
because an index ties the file to a declaration order — the lesson `math_op_at()` already carries. The
words are shared with the panel now (`trigger_type_text()` and friends build the dropdown arrays), so the
two cannot drift.

Two ordering traps, both found by running it:

- **The channel name resolves twice over.** `rebuild_trigger_signals()` picks up
  `App::pending_trigger_channel` when the stream arrives *after* the load, and `apply_trigger_layout()`
  resolves it itself when the channels are already there — without the second path the name stayed
  pending past the one rebuild that would have used it, and the trigger silently sat on channel 0
  (`summary='pulse width · sine …'` where the session said `square`).
- **The trigger's state is applied by pressing the control, not by setting a flag**: the toggle is set
  *outside* the `updating_panels` window `apply_layout()` uses, because a toggle set inside it emits
  `toggled` into a handler that returns at once and nothing would happen — no arming, no events button,
  no sensitivity.

Verified, in both modes and both directions:

    panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.3…0.6 · 20 % pre'
    panes: trigger=config signals=config body_visible=1 summary=''
    signals panel: labels=2 list=1 rows=7 signals=2 math=4 divider=1

The locked trigger pane is 42 px tall against 580 unlocked. A hand-written session round-trips: every
value it named came back in the panel's own controls, `trigger=1/1` (the state arrived and the pane's
switch followed it), and the file the app wrote back carried all fifteen keys with the untouched ones at
their defaults. `[side-tab-probe] … advanced=4` on its documented recipe; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon.

Docs: the `panes:` reading with what each field means, the corrected `labels=` note (the panel's frame is
counted above the list now, because the heading sits in a heading row), the invariants for both the
persistence and the lock, the README's deviation note, and the removal of its claim that the trigger has
never been persisted — it is now, and the locked view is built from exactly that.
2026-10-14 14:31:46 +02:00
Martino Ferrari cc8335c514 ui: each pane carries its own switch, the same one the header bar has
The trigger pane and the cursors pane now have a `GtkSwitch` in their heading, and it is *one control in
two places* with the header bar's button: both run the same action (`run_app_action()`), and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change, so clicking either
end moves both. A pane whose feature is off is disabled, which is exactly the state you need the switch
in, so it belongs *in* the pane rather than only in the toolbar.

Three things it needed:

- **The sensitive target moved to the pane's body.** A control inside an insensitive parent cannot be
  clicked, so a switch that greyed out with its own pane could never turn it back on. `on_trigger_toggled`
  and `on_cursors_toggled` now disable `trigger_body` / `cursors_body`, and so does the startup default;
  the headings, with their switches, stay live.
- **The trigger's heading moved out of `trigger-panel.ui`** and is built in `build_trigger_panel()`,
  which returns the heading row and the panel together. It has to be a sibling of the body for the switch
  to stay usable, and keeping it in the file would have meant the file owning a widget the code must
  arrange. The file's own panel is unchanged otherwise, so the `[trigger panel:]` counts still read.
- **A guard on the sync.** Setting a `GtkSwitch` emits `notify::active` for a programmatic write just as
  it does for a click, so without `App::panel_switch_sync` the program's own write would come back as a
  click and flip the feature again.

Readings, both directions in one line:

    trigger panel sensitive before the probe toggled it: 0; … ; pane switches live while their panes are off=1
    panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1  (pane switch / header bar button)

`pane switches live …=1` is the design's point and is read before anything turns either pane on; `0` would
be a switch drawn, greyed and useless. The trigger pair is moved from the *toolbar* end and the cursors
pair from the *pane* end, so the line carries both halves of the sync, and `drove_toolbar=1` is the pane's
own switch having turned the cursors on with the button following. `trigger panel sensitive …=0` is the
body, not the panel.

Verified: the two lines above; `[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon. Docs: the new reading in `probe-readings.md` with what a `0` means,
the invariants bullet for the one-control-in-two-places rule and its two traps, the sidebar bullet's
sensitive target, and the README note on why the switch is in the pane.
2026-10-14 14:13:49 +02:00
Martino Ferrari dbe6377415 ui: the tab glyphs are 32x16 pairs, drawn twice as wide
Each tab's icon is now two motifs side by side instead of one in a 16px square: the Measure pill is the
two cursors beside the ruler, the Signals pill the trigger beside three channel rows. A 16px cell carries
one motif — squeezing a pair into it is what turned both into a smear — and the pill has the width for a
pair in a 300px column with two pills in it.

Two measurements decided the size, both of which I had wrong from the outside:

- **GDK paints an icon into a square box whatever the document's aspect.** `gtk_icon_theme_lookup_icon()`
  at 32 reports a `32x32` paintable for a 32x16 document, and librsvg fits it: rendered with aspect kept,
  the artwork comes out `32x16` inside a 32 request. So the pair is letterboxed, not stretched, and
  `pixel_size` 32 draws it at exactly the size it is written — 1:1, so the 2px bars stay crisp. A
  fractional scale (24 or 48) is what would blur them, which is why nothing here is drawn at one.
- **The bar does not get taller for it.** `switcher: … icon=32x32 size=308x36`: 36px is what the switcher
  measured with 16px icons too, because the pill's fixed height takes a 32px box without growing.

Both glyphs were drawn and then read back as ink maps rather than trusted: the first drafts came out at
39% and 44% ink (a blot beside the house 25–36%), and the tab bar is the last place a shape can be
checked, since no screenshot works under this X setup. The finished two measure 23% and 33%.

Verified: `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present";
`make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' GResource blob is 11661 bytes, still over the
C99 limit, so the clean-build warning count stays four.
2026-10-14 13:40:09 +02:00
Martino Ferrari 0b146f1d53 ui: the tabs are icons, and the pills keep their names
Each sidebar page gets its own glyph and no visible text. The Measure tab reuses the ruler
(`udpscope-measure-symbolic`, the icon its deleted toolbar toggle used and the Plot preferences page
still carries); the Signals tab gets a new `udpscope-signals-symbolic` — three rows of a colour chip
and a name, which is what that tab mostly is — rather than a second copy of the trigger glyph, since
one shape meaning two things in one window is worse than a new one. Its bars are all one length: a
set of unequal ones reads as a bar chart, and a chart is not a list.

`AdwViewSwitcherPolicy.narrow` alone does **not** give an icon-only bar. Measured on libadwaita 1.10
by walking the switcher's tree: with `narrow` and a title, each pill holds a `GtkImage` *and* a
`GtkLabel` — 48px of "Measure" inside a 54px pill. So the pages' `title` is **empty** (their `name` is
what identifies them to `select_side_tab()` and to the session file, and both are unchanged) and the
words are re-attached in code: `name_side_tabs()` runs on the first tick — the pills are
`AdwViewSwitcher`'s own and exist only once it is mapped — and gives each pill a **tooltip and an
accessible name** from the page's name, then hides the empty label that would otherwise leave the
icon off-centre. An icon-only pill is still a named control for a screen reader, and the page icon
that does not resolve leaves a *blank* pill, which is why it is read like the toolbar's icons are.

Two readings, because the claim is two claims:

    switcher: policy=narrow pills=2 labels=0 names=2
    page icons: measure=udpscope-measure-symbolic(ok) signals=udpscope-signals-symbolic(ok)

`labels` counts *mapped labels with text* — a plain mapped count reads 2 either way, because the
narrow pill keeps an empty label in its layout — and `pills=2` is the guard that makes it mean
something: with nothing mapped the rest of the line is void rather than a pass.

Docs: the icon census (six action icons, eight bundled, and what each of the two new users is), the
switcher bullet in `invariants-gtk-and-chrome.md` with the measurement above, the two new dump lines
in `probe-readings.md`, the re-measured GResource figures in `verification.md` (icons 8613 -> 9842
bytes, help 514660 after this session's help rewrite; the count stays four), and the README recording
that the design spec had the switcher showing titles and neither page an icon.

Verified: `make test-ui` 14 files; `[side-tab-probe] … advanced=4` (tab switching by name still
works with empty titles); `signals panel: … divider=1`; `[math-probe] 3 math channels`; zero
`Gtk-CRITICAL` and no missing icon.
2026-10-14 13:29:11 +02:00
Martino Ferrari a493737ca1 ui: one channel list, and the math pane is gone
`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()` already
appends one synthetic entry per math channel -- "they go through the ordinary signal path on
purpose: a derived channel is a channel" -- so **every math channel was already a row of the
signal list**, with its tick, chip, name, unit and menu. The math pane listed the same channels a
second time, so this is mostly a deletion: `build_math_panel()`, `rebuild_math_pane()`,
`src/ui/math-panel.ui` and its resource entry, `App::math_list`, and the `math panel:` dump line.

What the one list gains:

- a non-selectable `MATH` divider before the first math row, carrying the `+` that creates a
  channel -- appended even when there are no math channels at all, because a `+` conditional on
  one existing could never create the first one;
- a math row's own menu: *Edit graph…*, *Default colour*, *Delete*. No Information and no
  Calibrate (calibration is applied at ingest, and a derived channel has no stream to describe),
  and no rename, because the node editor owns a channel's name and unit -- a second place to
  change them would be a second answer to the same question. An item dismisses the row's popover
  before opening or destroying anything from it;
- `App::math` mirrored from a math row's tick: `enable_locked()` mirrors it into the model, but
  `App::math` is the copy the session file is written from. The mirror sits *before* the
  `apply_math_channels()` in the `!on` path, which would otherwise push a stale `enabled = true`
  straight back into the model;
- a tick visible in a file view too: a derived channel is computed, and a file view has no
  acquisition to start, which is the other tick's question.

`rebuild_signal_list()` also publishes the list's own shape -- `rows`, `signals`, `math` -- and
the dump prints `divider=1` when `rows == signals + 1 + math`: the reading for a list that lost
its `+`.

Verified: `make test-ui` 14 files (was 15); `signals panel: labels=2 list=1 rows=9 signals=4
math=4 divider=1`; `[math-probe] 3 math channels`, every other line unchanged; no `math panel:`
line, and zero `Gtk-CRITICAL`.
2026-10-14 12:56:31 +02:00
Martino FerrariandClaude Opus 4.6 619b5e356c fix: five review findings on the tabbed-sidebar commit
Finding 2 — four builder ids had no null check. `side_stack`,
`side_switcher`, `measure_page` and `signals_page` were resolved at
lines 4709-4712, well past the skeleton null-guard. A typo in the `.ui`
would have been a silent segfault.  The four resolutions are moved to
the guard block and added to its condition, in the same form the guard
already uses.

Finding 3 — the 'editable focused: m refused' leg of keys_probe was
deleted instead of ported.  It is restored, adapted for the new
behaviour: 'm' now selects the Measure tab instead of toggling a pane,
so the refusal is observed as the side_tab name being unchanged across
the synthesised key, not as a toggle that did not flip.  The dump line
keeps its existing name and `refused=` field so probe-readings.md:601
still describes it.

Finding 4 — `exit_probe_step3` assigned `sidebar_shown` to both
`dbg_side_visible` and `dbg_side_column_after_hide`, and the panels:
dump line read the wrong one (dbg_side_visible) for its `(visible=)`
field.  Two readings that cannot disagree diagnose nothing.  Resolved
by making `panels:` read `dbg_side_column_after_hide` and removing the
redundant `dbg_side_visible` field.

Finding 5 — the three-line rationale comment above `reload_file_view`
in `apply_math_channels` was removed by the commit under review.
Nothing it said became untrue.  Restored verbatim from e10a92f.

Finding 6 — three comments the change made false:
- `on_activate`: 'the sidebar's scroller' replaced with 'the sidebar's
  tab switcher and two-page stack', matching the actual ui file.
- `update_side_visibility`: 'Hiding the scroller instead left an empty
  column where the panels should be' removed; the design it described
  no longer exists.
- `header-bar.ui`: 'two of them are toggles' updated to 'one of them
  is a toggle'; after signal_toggle's deletion the overflow menu holds
  one toggle (layout).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 11:45:00 +02:00
Martino FerrariandClaude Sonnet 4.6 44d2fd35dc ui: the sidebar is two tabs, and a toggle disables its pane
Replace the sidebar's five-panel scrolling column with an AdwViewStack
carrying two pages — Measure (cursors + measurements) and Signals
(trigger + math + channel list) — with an AdwViewSwitcher above it. The
trigger and cursors toggles change from "reveal/hide my pane" to
"enable/disable my feature, leaving the pane insensitive", following the
disable-not-hide principle. The keys probe loses a duplicate registration
that was toggling the sidebar back on before the exit probe captured its
state; the exit-probe captures now read adw_overlay_split_view_get_show_
sidebar() instead of gtk_widget_get_visible(), which AdwOverlaySplitView
never changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 11:11:21 +02:00