Commit Graph
123 Commits
Author SHA1 Message Date
Martino Ferrari 4e29b958a2 fix: going live restores immediately instead of after a few trigger toggles
The symptom was "view a recorded event, click `<live>`, then toggle the trigger a few times before it
starts streaming again". The mechanism was the restore's own gate: `restore_live_state()` waits for the
*stream's* channel list, because the trigger's stored channel is a **name** and a file view that renamed its
channels (`square (A)`) must be gone before the name means the right signal — and that wait was gated on
`generation()` having moved since *Go live* was pressed.

That gate could never be satisfied in the common case. A **single** event keeps the stream's own channel
names, and the restarted client's CONFIG is identical — which the model treats as *no change on purpose*
(`same_stream_signals()`: "the honest recovery is nothing") — so nothing bumped the generation and the
restore sat there. Toggling the trigger *is* a structural change and moved it, which is exactly the
workaround the report describes.

The gate is now the question it was actually asking: is the snapshot's channel name resolvable against the
live channels (or did the snapshot name none)? For a single event that is true as soon as the client has a
channel list at all, so going live is immediate; for a group the names are renamed, so it waits for the
stream's own CONFIG exactly as before — which is what the gate was for. `live_restore_generation` is still
captured and printed (`gen=a/b`) as information, not as the gate.

Verified against the documented reading, both ways into a file view, from a session with a window and a
trigger so the paired fields have something to disagree with:

    [events] live restored (after replay):  window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=6/3
    [events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=16/12

Every want satisfied, `pending=0` in both, `make test-ui` 14 files, zero `Gtk-CRITICAL`. Docs: the
invariants' bullet rewritten around what the restore waits for and why the generation was the wrong
question, and `probe-readings.md` gaining the thing that cost me two runs to find — those four paired
fields are the probe's capture of the live view, so a bare first run reads the application's defaults and
is not a defect; reproduce the documented line from a session that has a window and a trigger.
2026-10-14 15:10:49 +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
Martino FerrariandClaude Opus 4.6 4235ee6225 fix: a file view states its pin once, so the axis holds still
`set_data_pin()` takes a range and `pin_event_window()` a pre/post
split; the file view handed the split to both. On a recording whose
stamps start before zero that stored a pin that no longer held the
data, so every second frame found the pin wrong and stated it in full
again -- rewriting the group's x range and discarding the user's
gesture with it. The view alternated between the file's span and the
corrupted one at the frame rate: a flicker in which no pan, zoom or
click survived. `[drop-probe]` now reports the view and the count of
pin statements, which must stay at two for a whole run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 01:11:04 +02:00
Martino Ferrari 50a1ddca62 added loading screen 2026-10-14 00:38:24 +02:00
Martino Ferrari 881b52748e fixed event/live transition 2026-10-13 23:37:49 +02:00
Martino Ferrari bb99be5889 improved perfs and event management 2026-10-13 23:00:42 +02:00
Martino Ferrari c38630643d test: the noise probe reads the de-noise node, and the math probe builds one
The probe gains a staged chain rather than one phase, and the schedule moves with it:
`UDPSCOPE_PROBE_NOISE_MS` now times the *setup* stage, which installs the de-noise graph through
`apply_math_channels()` -- a structural rebuild that replaces the buffer and drops its history --
five seconds before anything scans it. The legs and the five measurements then run at about
10.5 s, and the reading that says the median rejected every impulse at 15.5 s, so the documented
run needs a 20 s mock and an 18 s exit.

The de-noise channel is installed **disabled** and enabled only at 13 s, for its own reading. An
acquired channel is a share of the ring, so a fifth one shortens every channel's retained span --
and the six `BufferSearch` leg counts and `pkpk bare` are all peaks and counts *of that window*.
Installing it enabled (as the plan had it) moved `bare` from 49 to 21, `ratio` from 7.00 to 3.00
and `pkpk bare` from 2.988 to 2.488, i.e. it would have rewritten the older feature's documented
proof to record a regression that is not one. With it enabled only at the end, every reading from
Tasks 4 and 8 is bit-for-bit what the doc records, and the new line reads
`denoise raw=2.987696/1 denoised=2.000000/1 ratio=0.669 channels=1` -- `denoised` exactly the
clean sine's 2.0, which is the claim the node makes.

Two other findings, both folded in: a derived channel whose source is switched off through the
panel's checkbox follows it off and does *not* come back when the source returns (the enable-all
probe's last act switches `sine` off at ~3 s), and the probes reload leftover math channels from
`$XDG_CONFIG_HOME/udpscope`, which is why a `setup channels=` count above 1 means an earlier run's
session, not a defect.

`math_probe` gains a third channel with a `DeNoise` node so the editor's median row is built and
synced on a baseline run: `3 math channels on 'sine' and 'noise'`, every other line as before,
`math cost: 103.3 ns/slot` (one reading of the same path).

verification.md: the recipe, and why the two stages may not be collapsed -- a probe that rebuilt
the buffer under its own legs reads `bare=0` and looks like a broken comparator. Also that the
noise and math probes may not share a run: both write `app.math`.
2026-10-13 16:01:44 +02:00
Martino Ferrari 5c2c221777 ui: the node editor offers the de-noise node and persists its window
The form gains a `median samples` row, shown only for `DeNoise`; the corner row widens to
anything that reads `fc` (`math_op_uses_fc()`) while `q` stays band-pass-only, and the wiring
row keeps its place at the end so the form's order is unchanged for every existing op.

The session format gains a thirteenth field, `med_n`, after `y`. A twelve-field node -- a file
written before this node existed -- still loads, with `med_n` left at the struct's default,
which is what such a file meant; the reader's `return number(...) && ...` chain had to become a
guarded sequence so the thirteenth field could be read conditionally.

The math probe enables the channel it builds its graphs from, the same self-sufficiency rule
the noise and measurement probes already follow: `enable_all_probe` ends by switching one
signal off, and with `sine` gone the graphs landed on `square`, changing a documented line for
reasons unrelated to the math.

Readings: `[math-probe] 2 math channels on 'sine' and 'noise'`, `pane add: 'math3' sources
'sine'`, `graph edit: stored_slots 31000 -> 32000`, `dialog build: nodes=2, output after add=1`
and the rest of the suite unchanged; no `Gtk-CRITICAL`.
2026-10-13 15:50:58 +02:00
Martino Ferrari 84aebf4116 ui: a measurement's noise band and HF reject are settable and persisted
Two rows in the measure dialog, built the way the threshold and side rows are and appended
beside them: the band as a percentage of the window's own peak-to-peak (the unit the engine
takes), the corner in Hz with 0 off. They are seeded when editing, shown only where they do
something -- the band for the crossing types, since it moves the edge decision and nothing
else, the reject for all of them, since it sits before both walks -- and read into
`MeasureOptions` on apply.

They persist as two *named* keys, `noise` and `hf`, on the same `o <<` chain as the keys
beside them and read back through the same keyed reader, so a session written before this
change loads with 10.0 and 0.0: today's behaviour exactly, with no version check.

[configs] now proves the round trip rather than assuming it. The probe sets the first
measurement's band and corner to 7.5 and 250 before saving, so a key that never reached the
file reads back as the defaults:

    [configs] round trip read=1 comment='written by the configs probe' ... measures=4 math=0 noise=7.5 hf=250

The rest of that line is unchanged, and the run is free of Gtk-CRITICAL.
2026-10-13 15:42:26 +02:00
Martino Ferrari fec0a1032e test: the noise probe reads the trigger's band and HF reject
Six legs over one retained buffer, each a full rising-edge search through `BufferSearch` at
level 0, run back to back without yielding so every leg sees the same samples: the bare
comparator on a spiked sine, a 0.8 band, a 50 Hz reject, both, and the reject's group delay
on `square` -- which carries no impulses, so a one-pole's crossing of zero after a step is
exactly `tau*ln2` and the delay is an exact prediction rather than a coincidence.

    trigger band:  bare=49/2 band=7/2 ratio=7.00
    trigger hf:    hf=7/2 band+hf=7/2 band=7
    trigger delay: clean=4 filtered=4 delay=0.002206 want=0.002206 ratio=1.000

`ratio >= 5`, both HF legs within 2 of the band, `ratio` between 0.7 and 1.4, `state=2` on
every leg, no Gtk-CRITICAL. `bare` and `band` are proportional to the retained buffer, so
neither is a fixed number; the ratios and the agreement between legs are the assertions.

The probe enables the signals it measures itself rather than relying on another probe's side
effects -- `enable_all_probe` ends by switching one off again, which is how `sine` was missing
from the first run of this one -- and arms in two phases because that enable is a structural
change that replaces the store, so the ring has to fill before there is anything to search.
Its "no stream" line now names the channels that *are* there: an index alone says a probe
found nothing, not what it saw.
2026-10-13 15:35:03 +02:00
Martino Ferrari 1583741e03 ui: the trigger panel offers a noise band and an HF reject
Two label-only rows in trigger-panel.ui (band_row, hf_row, after holdoff_row and before
mode_row) and the two spins the code appends into them: the band on the level spins' scale
in signal units, the HF reject at 0-1e7 Hz with 0 off. Both are published with the other
fields -- read before the model lock is taken, assigned inside it -- and both join the
value-changed loop, so editing either republishes the condition.

The rows are shown per type: the band for every type but Dropout, which is an absence a
band cannot describe, and the HF reject for all of them, since it sits before every
comparison. `sync_trigger_rows()` resolves a row as its spin's parent rather than keeping a
second handle to it: the row comes from the file and holds one child.

The exit dump counts the panel's controls, so it reads controls_inside=15/15 now -- and the
probe confirms it, along with both search readings and no Gtk-CRITICAL:

    [udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
2026-10-13 15:31:44 +02:00
Martino FerrariandClaude Opus 4.6 be71a1d4bb perf: a frozen frame is not re-rendered sixty times a second
The tick queued a GL render unconditionally. In `Held` and `Paused` the rows
are bound to an immutable snapshot on a pinned axis, so a frame in which none
of the picture's own terms moved is the same frame again. The gate keys on a
coarse signature of what a frozen view can be re-framed by, opens for
`kSettleRenders` frames on a change because the figure lays out per render, and
has a floor that renders anyway every few ticks. The layout editor is excluded:
its buttons are placed from the last frame's rects, so a skipped frame there is
a stale widget rather than a stale picture. `skips=` in the probe dump is the
reading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 14:50:55 +02:00
Martino Ferrari b02419dee3 feat: a signal switched off switches its dependent math channels off too
A derived channel cannot be computed from a signal nobody acquires: `Source` resolves to a
*channel*, and a disabled signal has none -- so a channel that reads one silently computed
nothing, which is what "all math signals depending on it do not compute anymore" looked like
from outside.

Rather than leave it enabled and blank, the channels that read the signal follow it off:
`disable_dependent_math_channels()` walks the graphs to a **fixpoint**, because one math
channel may name another, and the switch goes through the same path the pane's own toggle
uses -- `MathChannel::enabled` then `apply_math_channels()`, the single writer -- so the
pane's switches follow. Their traces leave the plots with the signal's, and the notice says
both halves.

The channels that do *not* read it are untouched, which is the assertion's other half:

    [enable-all-probe] switched 'sine' off: traces_left=0 (want 0)
        math_off=[math x2,math3] math_on=[math lp]
        notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more'

`math x2` and `math3` both source `sine`; `math lp` sources `noise` and stays on. The probe's
switch step now runs late enough that a math probe armed behind it has installed its graphs,
which is why the earlier reading was empty -- `app.math` had nothing in it yet, not
"no dependants".

Re-enabling the signal does not switch them back on, for the same reason it does not restore
the traces: that is the user's decision, and the rule is written down in
invariants-math-channels.md.
2026-10-12 22:25:15 +02:00
Martino Ferrari 8b3240cacb feat: switching a signal off takes its traces off every plot
The switch in the signal list means "do not acquire this", and until now a trace of it
stayed on its plot drawing nothing: a slot that holds one of the four a plot has, reads
`--`, and blocks the drop that would fill it with something live.

`on_signal_switch()` now clears every slot that holds that signal, by the **signal name**
the slot remembers -- never the channel index, which is what the enable change renumbers --
and says what it did: `'sine' is off: 1 trace left the plots`. The follow-ups (a relayout,
the panels' chips, the layout save) happen once rather than per slot.

Re-enabling deliberately does not put the traces back: the arrangement is the user's, and
guessing where they were is worse than leaving them off. Say the word if you want that
direction too.

[enable-all-probe] reads it end to end -- it enables every signal, assigns the channels
across the rows, then switches one off *through its own acquire tick*:

    [enable-all-probe] switched 'sine' off: traces_left=0 (want 0) notice=''sine' is off: 1 trace left the plots'

which is also why the notice shares the status line's warning styling from the previous
commit: the trace leaving the plots is something the user should see happen.
2026-10-12 22:12:24 +02:00
Martino Ferrari aaf544be1e ui: a refused drop says so, and looks like a message rather than the status
The refusal was never silent -- `on_drop_channel()` has always set a notice -- but the
status bar renders `notice + "   |   " + glance` in one label, so "plot 1 already holds
four signals" arrived looking like part of the routine text a reader skims, which is the
"no clear user feedback" report. The probe's own reading showed it plainly: the label held
`plotting 'sine' on plot 1   |   live · 4.44 kS/s · 2 s window`.

Three changes:

- While a notice is fresh the status label carries the stock `.warning` class, so a message
  looks like a message and the class goes when the notice does. Every notice benefits, not
  just this one.
- The refusal says what to *do*: "plot 1 holds four signals already: drop it on another
  plot, or add a row with the layout editor (L)".
- The drop's inline notice now goes through `set_notice()`, the one setter, instead of
  writing `app.notice` and its deadline by hand.

[drop-probe] reads the whole path, with the row filled first so the drop under test is the
refusal:

    full row refused the drop: 0 (want 0)
    refused feedback: notice='plot 1 holds four signals already: ...' warning=1
    the status line, composed: warning=1 status='plot 1 holds four signals already: ... | live · 9.97 kS/s · 2 s window'

The label is read in the third line and not the second on purpose: the status line composes
the notice into itself on its own cadence, so a beat after the refusal it may still hold the
previous text -- which the first attempt at this reading mistook for the fix not working.

The row limit is not silent in the same way: the layout editor's `+` is insensitive at
`kMaxRows`, which is a control that says "no" by being unusable rather than a message.
2026-10-12 20:11:36 +02:00
Martino Ferrari d34b97158e ui: a press anywhere in the window closes the time-window popover
The window control is a GtkMenuButton whose popover autohides -- which works for as long as
GTK holds the popover's grab, and the grab is what can go missing: a window manager moving
it, the nested popover the unit dropdown opens taking it, and every headless run, where
there is no manager to hand one out at all. After that a click anywhere else in the window
leaves the bubble up, which is the "sometimes it is awkward to close" report.

`on_window_popover_press()` states the rule outright instead of relying on the grab: any
press that reaches the window's own surface closes it. That needs no hit test beyond the
button -- a popover is its own surface, so a click *inside* it never arrives here -- and the
button is left alone, because its own click is the toggle that closes it. The press gesture
is on the window in the capture phase, so a widget under the press (the plot's own gestures
among them) cannot hide it.

[windowpop] reads both halves, with autohide turned off for the probe -- the state a user
meets when the grab goes missing:

    [windowpop] opened=1 visible=1 click_gestures=2
    [windowpop] after a press elsewhere: active=0 (want 0)
    [windowpop] after a press on the button: active=1 (want 1)

`click_gestures` is read as well as the handler, so "the rule works" and "the rule is
connected" are two facts rather than one.
2026-10-12 20:02:58 +02:00
Martino Ferrari 47d49e75ff perf: a file view re-reads the file only when a structural change really dropped it
Three tightenings, all measured with the reloads now reported on stderr.

`set_math_channels()` treats an identical list as no change at all. It rebuilt
unconditionally, and a rebuild replaces the buffer and drops its history -- which a file
view then has to re-read from disk. The comparison is the graphs', and it leaves the nodes'
`x`/`y` out deliberately: those are the canvas's own, no op reads them, and a graph that
merely *moved* should not cost the buffer.

`reload_file_view()` fires only when the store is actually empty. A change that really
happened leaves it so in a file view (the rebuild replaces it, and there is no producer to
refill it); one that found nothing to do leaves it full, and reading a 200 MB file again for
that is the waste. `ScopeModel::stored_slots()` is the one-line accessor it needed.

Every reload now says so, because a second read of a file is worth accounting for:

    [udpscope] reloaded <path> after a structural change: 402 samples back

Measured, `--open` a file:

    session with no graphs            0 reloads
    the math probe (3 real changes)   3 reloads, and all the readings stay equal
                                      (`math x2 402 samples vs 402 time slots`)

I had offered this as "one extra read at `--open` startup", and that was wrong: with no
session there is no `apply_math_channels()` call at all, so there was never an extra read
to remove. What the tightenings buy is that a no-op application is now free, and that a
reload cannot happen unless the buffer was really lost. Each *genuine* change still costs
one re-read -- a rebuild drops the history by design, and preserving it across a rebuild is
a model change rather than this one.
2026-10-12 19:55:31 +02:00
Martino Ferrari b090cc2a21 fix: math in a file view -- the buffer was emptied, and the derived ring filled twice
Two defects, both reached by adding a math channel to an open waveform or a replayed
event, and both measured before and after.

**A file view has no producer, so a structural change left it empty.** `set_math_channels()`
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream,
whose producer refills it from the next packet, and wrong for a file, which has nothing to
refill it with. The `--open` dump read `0 samples vs 0 time slots` for *every* channel,
the file's own `sine`/`square`/`ramp`/`noise` included, though the load had reported 402 --
that is what "adding a math node breaks the plotter" was. `reload_file_view()` re-reads the
file after such a change, which is also what computes the derived channels over it: the
load's own pushes run the math once the set is in place, so `--open` with a session that
defines graphs works for the first time.

**The file path pushed into the derived rings, and so did the engine.** `push_loaded_slot()`
did not honour the rule `ChannelInfo::math` states for the packet path ("the ingest must skip
it"), so each loaded slot put a value in a derived ring and `push_math_sample()` then put the
computed one: two samples per slot, a trace at double rate -- the derived channels read
`804 samples vs 402 time slots`. Skipping them there is the whole fix.

Readings now, all equal, which is the assertion this file's own invariant makes:

    --open the file:  math x2  402 samples vs 402 time slots, y=[-1.9021, 0.0063]  (2x the sine)
                      math lp  402 vs 402, mean=-0.4900 (a real filter output, not zero)
    live:             math x2  25100 vs 25100
    replayed event:   math x2  4000 vs 4000

I first tried filtering the file's *derived columns* out by name, since an export writes
every plotted channel. It removed the double count and zeroed `math lp`, because the column
it dropped was the source a session graph reads -- so it is reverted: those columns are
legitimate data, and the fix belongs in the push, not the loader.
2026-10-12 19:49:39 +02:00
Martino Ferrari 28d80f2e90 ui: the header bar's frame is a .ui file, closing item 10's last row
src/ui/header-bar.ui holds the bar: the time-window control's slot, the trigger, pause and
events group with the separator that marks where the view's controls end, and the five at
the far end. What stays in code is everything that acts on the model -- the overflow
menu's rows, the window control itself, and every handler.

The order is the file's, which is the part the code used to explain in three comments: a
<child type="end"> is added leftwards-from-the-right in document order, so the screen reads
the reverse of it -- cursors, measurements, math, sidebar, menu.

The first start child is a *slot* and not the control, and that is the one thing this move
could not do naively: gtk_header_bar_pack_start() appends after whatever a file has already
packed, so the control -- which is code, its menu's rows coming from the buffer's limits --
would have landed to the right of the whole group instead of at its head.

The probe is the check, and every header reading is byte-identical to before the move: 12
lines of `header:` (window_button_w=130 bar_w=756), `window control:`, `panels:`, both
width-probe frames and all seven layout-probe lines. Only `badge frames:` differs, and it
is a frame counter. check_ui: 14 ok, 0 failed.
2026-10-12 16:33:25 +02:00
Martino Ferrari c94bc4316c ui: the layout editor's buttons keep their look in one place, closing that plan row
The row asked for these buttons in a .ui file and that is not possible: there is one per
row and one more than that per boundary, and a GtkBuilder instantiates a file's objects
once, so the file can hold neither the count nor a prototype for the code to copy. The
plan's note ("only the buttons are template") is wrong for the same reason, and the plan
document now says so.

What was real in the row was the duplication: the look was written out three times -- the
icon, the two CSS classes that make a small round on-screen-display control, the size,
the overlay append and the bookkeeping -- and is now one function,
`layout_overlay_button()`. Each loop keeps only what makes it different: its alignment,
its sensitivity, the data it carries and, for the resize handle, its drag gesture.

The layout probe is the check and its readings are byte-identical before and after, all
seven lines: `buttons=5 done=1 done_box=[680,4,72x42] close=[4] add=[0,832] resize=[(1:-)]`
and the row geometry through add-above, remove, add-at-end and Editor off.
2026-10-12 16:30:00 +02:00
Martino Ferrari 148d1168d6 ui: the events pane's frame is a .ui file
src/ui/events-pane.ui holds the pane: the GtkRevealer that slides it in, the header with
the way back to the live stream, the Compare and Accumulate actions, and the list inside
its scroller. The rows stay in code -- one per latched event, rebuilt whenever the cache
changes -- and their sensitivity stays with update_group_buttons(), which is why the file
only starts the two actions insensitive.

Two statements moved into the file along with the widgets, because they are about what the
pane is for rather than about a default: multiple selection, and activation on *double*
click. With single-click activation the plain click that builds a selection would replay
the event instead.

Read back as `events pane: compare=1 accumulate=1 list=1 revealer=1 nested=1`, where
`nested` is the list being inside the revealer -- the one thing a lost id cannot show. The
[events] probe is the other half and drives the pane end to end: recording, trigger, the
history button, a row click and the replay, then Compare and Accumulate, so the file's
buttons are read by being clicked. check_ui: 13 ok, 0 failed.
2026-10-12 16:27:56 +02:00
Martino Ferrari c86d2117fb ui: the math editor's action row is the file's, closing that plan row
The row is the file's -- the box and its two buttons, the second carrying the
suggested-action class -- and what is code is what they do and what the second one says:
"Save" edits an existing channel and "Add" makes a new one, which the file cannot know.

With it the editor's frame is whole: the window, the identity row, the body with the
canvas in it, and the buttons that end it. The parameter form and the palette stay in
code, and for the reason the canvas's *drawing* does -- the form follows which node is
selected and the palette is the operation table itself.

The probe clicks the apply button rather than calling its handler, so
`dialog build: ... name='math3' unit=''; saved nodes=2 output=1` is that button having
worked from the file, and `after close: dialog=cleared` / `reopened: dialog=ok, nodes=2`
are the lifetime around it. check_ui: 12 ok, 0 failed.
2026-10-12 15:59:23 +02:00
Martino Ferrari 1a02946656 ui: the math editor's window, identity row and canvas are a .ui file
The second half of this plan row, and the half of that dialog which is template.
src/ui/math-editor.ui holds the window (its size and modality), the box around it,
the identity row -- the name and the unit -- and the body with the canvas in it.

What stays in code is the drawing: the canvas is a GtkDrawingArea the file *declares*
and the code draws, with the gestures that make it a graph editor, and the parameter
form follows which node is selected. The palette and the buttons go next.

The builder is held on `App` and cleared on the *next* opening rather than with the
window: the window belongs to the builder's object list, so freeing the builder under a
live window frees the window with it. That is the source form's pattern, and the probe's
close-then-reopen path is what exercises it -- `reopened: dialog=ok, nodes=2`.

The identity row is read back too, since a `.ui` can hold the widget but not the value:
`dialog build: ... name='math3' unit='' ...`, the entries having been filled from the
channel being edited. check_ui is at 12 ok, 0 failed.
2026-10-12 15:57:43 +02:00
Martino Ferrari 90a17eb186 ui: the math panel's frame is a .ui file
The first half of this plan row. src/ui/math-panel.ui holds the panel: the box and its
margins, the heading row with the title and the add button, and the list the channel rows
go in. The rows stay in code -- each is a channel with its name, unit, expression, colour
and its own edit and remove buttons -- so the file holds the frame, as in the measurements
and signals panels beside it.

Read back by a dump line that also reads the file's own text:
`math panel: head=2 buttons=1 list=1 title='Math channels'`, where buttons=0 is a lost
`+` and title='-' a lost label. `[math-probe]` still drives the add and opens the editor,
so the rows landing in the file's list is read as well as the frame.

The other half is the node editor's chrome, which is where least of that file is template:
the canvas is a GtkDrawingArea that is drawn rather than declared, and the gestures, the
parameter fields and the palette are code. That is its own change.
2026-10-12 15:53:35 +02:00
Martino Ferrari c37fbdde21 ui: the signals panel's frame is a .ui file
src/ui/signals-panel.ui holds the panel's shape: the box and its margins, the heading,
the hint that says what a signal is for, and the list. The rows stay in code -- one per
signal, carrying that signal's switch, colour, calibration and drop targets, rebuilt
whenever the stream's signals change -- so the file holds the frame, which is the shape
the plan gives this slice.

The list's width-only size request moved with it, comment and all: a height floor there
made the list occupy 120 px whatever it held, which hid the compact rows' height behind
it.

Read back by a new dump line, `signals panel: labels=2 list=1`, where a lost id reads as
`labels=1` or `list=0` instead of a panel quietly missing its hint; `panels: … row_h=26`
still reads the list's geometry and the height a row takes. check_ui is at 10 ok.
2026-10-12 15:50:52 +02:00
Martino Ferrari 1ccc70c5ac ui: the trigger panel's frame is a .ui file
The panel's shape -- the heading, one labelled row per setting, the two buttons that
run it, and the search block -- now lives in src/ui/trigger-panel.ui. Every control
that carries a value stays in code and is appended into the file's row: a dropdown's
model and a spin's range are the application's, a label is text a translator owns, so
that is where the line runs. `row_for()` is `labeled_box()`'s arrangement with the
labels moved into the file; the thirteen old calls to `labeled_box()` here are gone and
its eleven uses elsewhere are untouched.

`sync_trigger_rows()` now guards the rows it shows and hides, because a row that comes
from the file can be missing rather than merely misnamed.

The frame is read back by a new dump line:

  trigger panel: boxes=13 controls_inside=13/13 search_widgets=4/4 search_inside=4/4

where `controls_inside` is what a drifted id breaks -- a control built and never placed
leaves the panel with a field missing and nothing else complaining -- and
`[defaults] trigger: 0.001 0.002 0 0.001 0 50 0.01` still reads the seven spins in the
same tree order, which is the check that the restructure kept the panel's contents.

Two days of grief came out of one GTK fact, now in the invariants: an expander's child
is not in the widget-parent chain (`gtk_widget_get_parent()` is NULL for it even after
the window is presented, while `gtk_expander_get_child()` returns it), so the probe
walk had to start there instead of at the panel. The throwaway program that settled it
is what stopped the workaround -- a second root in the file plus a code attach -- from
being the answer.
2026-10-12 15:47:50 +02:00
Martino Ferrari d344361165 ui: the measurements panel's frame is a .ui file
The panel's shape -- the title row with its add button, the one statistics switch
the whole panel shares, and the box the readings go into -- now lives in
src/ui/measures-panel.ui. The readings stay in code: each row is a measurement a
user placed, rebuilt whenever the set or the mode changes.

A faithful move, not a redesign: the same margins, spacing, labels, tooltips, flat
classes and insensitive Reset button, the same two lambdas on the same two signals.

The frame is read back through the dump line, which grew the two fields that can
disagree with the code that drives them -- `measures pane: n=4 revealed=0
marks_shown=6 pane_rows=4 pane_w=0 pane_h=0 switch_on=1 reset_sensitive=1`. In that
reading pane_rows=4 is the four readout rows having landed in the frame's
`measures_list`, and switch_on/reset_sensitive are the switch widget and the Reset
button following measure_stats_on, which the probe set through the same signal a user
would. `check_ui` type-checks the file (8 ok, 0 failed) and the build stays at four
warnings.
2026-10-12 15:35:39 +02:00
Martino Ferrari 2d9f327da7 fix: the sidebar button follows the panel, and the wish survives having no source
The toolbar button was the panel's only input: `update_side_visibility()` derived the
panel from the button and never the other way, so the button went on saying "shown" in
two cases where the panel was off -- while there were no signals to show, and after
anything else moved the split view's state.

Binding the two together is not the fix. AdwOverlaySplitView writes `show-sidebar`
itself, at startup and when it collapses, with no user involved; a binding pushes that
into the button, the button's handler reads it as the user's wish, and the sidebar then
never comes back when a source connects -- a worse bug than the one reported, and one
that a "was this our own write" flag does not catch either, because the toggle's signal
may fire after the write returns.

So the direction is stated instead of inferred: `show-sidebar` is an output, the button
is the only input, and `App::sidebar_wanted` holds the wish between them. The wish is
written by the toggle's handler alone (guarded while we set the button ourselves), the
no-signals case forces the panel off without touching the wish, and tick() re-asserts
both from the wish when the widget disagrees -- so a hide gesture does not stick either.

[sidebar-probe] now reads the pair (shown vs button) in both states and against a hide
that did not come from the button; shown=1 button=1 with a source, shown=0 button=0
without, and the source reconnecting brings both back is the reading this exists for.
2026-10-12 13:23:40 +02:00
Martino Ferrari bb01278d88 fix: the sidebar keeps its own width, so a small window can no longer hide it
The sidebar's width was a GtkPaned divider position: `window - 940` in pixels. On any window
narrower than 940 px the divider landed past the right edge and the sidebar came up 0 px wide,
reachable only by dragging the divider back.

Every attempt that kept the paned failed for that reason -- deriving the position from the paned's
own width, re-parenting the column, property-form children, code-set size requests. The mechanism was
the fault, so it is gone: the row is an AdwOverlaySplitView, which is what a utility sidebar is in
GNOME, with min-sidebar-width = max-sidebar-width = 320 in pixels. Its width is therefore its own at
any window size, and when there is no room beside the plot it *overlays* the plot (collapsed, set from
the window's width in tick()) instead of being squeezed to nothing.

Two things found on the way, both recorded in the invariants:

- sidebar-width-fraction is a 0..1 fraction of the window whatever sidebar-width-unit says. Setting a
  pixel count there is a GObject critical and it falls back to 0.25, which is why the sidebar first
  read 560. The width is pinned with min/max instead.
- the window opened at its content minimum (756 px, the plot column plus the sidebar) because the
  explicit gtk_window_set_default_size() was dropped when the window moved into the .ui. Restored.

[sidebar-probe] reads both states and is the guard: window=756 sidebar=320 under a 2x scale, which is
the case that used to read 0.
2026-10-12 12:17:56 +02:00
Martino Ferrari 8073e7b3fd feat: the main window's skeleton is a UI file
src/ui/main-window.ui holds the window, its two panes, the plot column with the status line under it,
and the sidebar's scroller. What stays in code is what depends on the stream: the events pane, the two
plot areas, the sidebar's panels and the status popover, each put into a slot by id.

The header bar is deliberately not in the file. It is the window's own titlebar, every button on it is
wired to an action, and an AdwApplicationWindow refuses a titlebar outright -- a fatal Adwaita-ERROR
rather than a warning that leaves the window merely ugly -- so the window is a GtkApplicationWindow and
the header bar stays where it was.

gtk_paned_set_position() is the other thing that had to stay in code, and the reason is order rather
than shape: it clamps against its children *now*, so stating it before the sidebar's panels exist moved
the plot column by ~180 px.

The layout, events and box-zoom probes read what they read before this change, and the header reads
window_button_w=130 bar_w=756 as it always has -- checked by building the previous commit and running
the same probe against it, because the layout numbers in the notes turned out to be stale from an
earlier session. Five UI files now.
2026-10-11 17:39:04 +02:00
Martino Ferrari 0e256764d5 udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored
udpsclient, buffers it in a large ring, and draws it with the vendored
plotter: a trigger with a pre/post event window, a pause that freezes the
window, cursors, measurements, math channels in a node editor, an event cache,
named configurations, preferences, and an export of the visible window to
.npy/.csv. Waveform files can be opened and analysed in place.

The interface is two threads -- a producer that owns the socket and the GTK
main thread that owns every widget and the GL context -- sharing one model
under one mutex, with samples crossing through lock-free rings.

deps/plotter is a submodule pinned to the commit this tree builds against;
deps/udpsclient is vendored in place. docs/claude/ documents the rules that
cost a defect to learn, and SPECS.md is the requirement document.
2026-10-11 15:21:40 +02:00
Martino Ferrari 8f54841c2c working on todo.md features 2026-10-10 22:52:12 +02:00
Martino FerrariandClaude Opus 4.6 176ef1df9f fix: a full reorder ring keeps the arriving packet and counts the overflow, instead of dropping it silently
A packet that needed a slot and found none was neither held nor delivered: no
counter recorded it and the consumer reserved a hole nothing could ever backfill.
It now commits the gap to free a slot and takes the ordering decision once more
against the sequence that commit left behind, bounded to one retry; past that it
is delivered in order with the counters it skipped as its `lost`, never flagged
late, because a packet ahead of the sequence has no hole to be looked up by. A
duplicate can now replace its slot at a full ring, as hold_packet's own comment
already promised, and the 20 ms default window is a named constant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:50:43 +02:00
Martino Ferrari 3859d2ca79 feat: a repaired hole keeps a fainter band, so a report is not a false accusation 2026-10-08 23:00:22 +02:00
Martino Ferrari 4666b807fc test: a probe reads the resequencing path's state, not only its counters 2026-10-08 20:23:38 +02:00
Martino Ferrari ee55ac84b7 feat: a reserved hole is shaded, so a sub-pixel gap is still a report 2026-10-08 20:12:38 +02:00
Martino Ferrari fc81e661d0 feat: the status bar and the exit dump report resequencing, holes and stale packets 2026-10-08 18:58:06 +02:00
Martino FerrariandClaude Opus 4.6 cd9657663a fix: the math editor's source dropdown no longer hands a GParamSpec to a value handler
notify::selected passes a GParamSpec* ahead of the user data and
GtkSpinButton::value-changed does not, so on_math_param_changed read the
pspec as its MathDialog* and faulted on the first field it touched --
which is to say on opening the math editor and picking a source.

The probe missed it because filling the form sets the dropdown to the
value it already holds, and GObject emits no notify for that: a channel
sourcing channels().front() picks index 0 on a dropdown already at 0.
math_probe now opens on a channel sourcing the *last* acquired signal,
which does move the selection, and reads back the source the handler
wrote into the node.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 16:56:25 +02:00
Martino FerrariandClaude Opus 4.6 aa8ec1794a fix: a zero-width band fires on equality or not at all, and the text now says which
The earlier correction moved the claim rather than fixing it: four places still
said the float-equality test "never fires on a real-valued signal", and a +/-1
square is a real-valued signal -- a standalone check against a band of [1, 1]
fired 19 times, because `inside` is true at +1 and false at -1, so the signal
changes side on every transition.

All four now state the measured behaviour: an exact float-equality test against
the level, which an ordinary signal never satisfies and a signal sitting at the
level satisfies on every transition. Neither answer is a band, which is the
reason the second spin is clamped an increment away rather than the detector
special-cased -- the same conclusion, now resting on a true premise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:39:22 +02:00