Commit Graph
152 Commits
Author SHA1 Message Date
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
Martino FerrariandClaude Opus 4.6 c8f10f59dc fix: a condition leg is reported once it is held, so a working leg cannot read nan
A finite trigger_time() only says the edge was accepted; the mode is Capturing
until the post window fills. Every field the report prints is read from
display_snapshot(), whose stamps span x = 0 only once the event is Held, so
reporting on the latch alone raced the capture: leg 2 printed
`measured=nan at0=nan ... mode=1` on one run and the documented
`at0=-0.500907 earlier=-0.498185 mode=2` on the next. The report now waits for
Held under the same poll budget, and says `latched but never held` when it runs
out -- a different defect from `no latch`, so the line names which.

The readings are corrected against three consecutive leg-2 runs and one leg-3
run: `t0` is a rebased stream stamp and varies every run, so it was never an
assertion; `hits=` likewise tracks the arm phase (12 at one arm, 11 at another).
The mode ordinals were wrong -- `mode=2` is `Held`, not `Capturing`. Shapes 2
and 3 are now measured rather than read from the arm code; shape 1 still is not.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:34:30 +02:00
Martino FerrariandClaude Opus 4.6 7aa9cf4664 fix: a rebuild, a spurious observation, and a width limit protect the invariants they were breaking
- A buffer rebuild mid-scan (CONFIG or window change) now calls `search.clear()` beside
  `reset_measure_stats()` at both sites; without it the scan kept stale at_/end_ and could
  report Done over fabricated hits (fix 1).
- `search_marks_label` now carries `PANGO_ELLIPSIZE_END` so the side pane's minimum width
  is bounded; the longest text (~58 chars) was driving the pane width (fix 2).
- `update_measures()` gains a third `accumulate` parameter (no default) so every call
  states its intent; the Statistics toggle now passes `false`, stopping the same window
  being counted twice and deflating sd on repeated toggles (fix 5).
- `search_jump()` records whether `publish_pause_view()` returned an empty snapshot and
  `update_search_panel()` appends "(no longer in the buffer)" to the position label when
  it did (fix 9).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:18:51 +02:00
Martino FerrariandClaude Opus 4.6 806e168ec4 fix: the condition tooltip says what the dropdown changes, not that it has entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 10:47:38 +02:00
Martino FerrariandClaude Sonnet 4.6 aefac2d796 fix: the condition dropdown's tooltip names all four shapes without listing them
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 10:43:36 +02:00
Martino FerrariandClaude Opus 4.6 2ed42fe632 fix: every branch of the marks label counts the marks it can see, so the counts read alike
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 10:34:34 +02:00
Martino Ferrari 4a58887cb1 fix: the search label no longer claims an off-screen selected hit is in view
place_search_marks() places the selected hit unconditionally to keep it marked when stepping
to it, but the label must distinguish this case from hits that are actually visible on screen,
so it does not lie about what is in view. add sel_off_view to track when the selected hit is
off screen, introduce a shown count that excludes it, and emit a third label case when only
the selected hit is off screen.
2026-10-08 10:25:27 +02:00
Martino Ferrari 5bb4bd207d fix: the marks are read a frame after the jump, so a placed count is read from the window it is on
The plan's Task 8 named the search probe as its test but never extended it, and leaned on the
exit dump instead -- which stops the tick from Live, where no hit is in view, so it reads
placed=0 for a correct implementation and a broken one alike.
2026-10-08 10:18:44 +02:00
Martino Ferrari db81ab2700 feat: a search's hits are marked on every row, and the panel says how many of them it is showing 2026-10-08 10:11:50 +02:00
Martino Ferrari 8ee4effb70 fix: a truncated scan stays truncated, so a hit list with a hole is not reported clean 2026-10-08 10:03:46 +02:00
Martino Ferrari 415fceca87 feat: the buffer can be searched for the trigger's own condition, and the hits stepped through 2026-10-08 09:56:47 +02:00
Martino FerrariandClaude Sonnet 4.6 2cee85c52b fix: a statistics line elides its own text, the toggle handler is guarded, and the doc comment names both labels
- The statistics label in measures_row() now carries PANGO_ELLIPSIZE_END,
  matching the kind label's own treatment, so a long reading cannot widen
  the pane beyond its measured minimum.
- The toggled handler on measures_stats_check now returns early when
  updating_panels is set; apply_layout() sets updating_panels around its
  gtk_check_button_set_active() call and then sets the Reset button's
  sensitivity directly, so the loaded state is fully applied without a
  spurious schedule_layout_save().
- The measures_row() header comment now names both labels that
  update_measures() rewrites and explains the two-line layout.
- gtk_widget_set_sensitive on measures_stats_reset is null-checked in the
  toggled handler, matching every neighbouring handler in the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 09:44:04 +02:00
Martino Ferrari 4f496b14b7 feat: the measurements pane can show how each reading varies, and remembers that it was asked to 2026-10-08 09:34:06 +02:00
Martino Ferrari 1a8218887c feat: a measurement accumulates how it varies, so one reading can be told from a trend 2026-10-08 09:22:35 +02:00
Martino FerrariandClaude Sonnet 4.6 d79810c7ba fix: dead probe fields are removed and the channel name is chosen once, so arming cannot disagree with enabling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-07 18:25:33 +02:00
Martino Ferrari 601923f1d1 fix: a window leg's band comment names the sine it is now read against
The leg moved from the square to the sine when the band proved untraversable
by a +/-1 square; the comment beside the band still described the square.
2026-10-07 18:18:33 +02:00
Martino FerrariandClaude Sonnet 4.6 a00663510f fix: a dropout probe outlasts the sine's own crossings, so only a real silence latches it
- Leg 1: widen to 1.0 s / 50% pre so the square's opening edge is in the snapshot
- Leg 2: use sine (not square) for the window channel, edge = falling so the band exit
  is deterministic, and read earlier from vals[end-2] rather than half the snapshot
- Leg 3: raise timeout to 0.3 s (> half-period), accept either crossing direction,
  widen to 1.0 s / 50% pre to reach the crossing that sits 0.3 s before x=0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-07 18:13:26 +02:00
Martino FerrariandClaude Opus 4.6 a26770ec32 feat: a level band and a signal that stops can be the trigger's event too
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 18:01:07 +02:00
Martino FerrariandClaude Sonnet 4.6 0876ecfe22 fix: condition-probe assertion uses snapshot width, not mock-clock phase
The old `phase=` field compared `trigger_time()` against the mock's absolute
`glitch_at + glitch_width` target.  The app's stream epoch is the first packet
it received, not the mock's t = 0; the two clocks differ by however many
packets the CONNECT handshake cost, making the comparison meaningless.

Replace it with a snapshot-relative measurement: scan backwards from x = 0 in
the held event snapshot for the most recent upward crossing of `condition.level`
(the same `prev < level && val >= level` test the detector uses) and negate the
crossing's stamp to get the measured pulse width.  A `measured` near 0.002 s is
the glitch caught exactly; near 0.200 s means an ordinary edge was latched.

Remove the now-dead `glitch_at`, `glitch_width`, `square_period` fields from
`ConditionProbe`.  No other files changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-07 17:48:25 +02:00
Martino Ferrari d147678b1c feat: a pulse whose width is wrong can be the trigger's event, so a glitch can be caught 2026-10-07 17:32:11 +02:00
Martino FerrariandClaude Sonnet 4.6 b3fd9ae00f refactor: what counts as an event moves into its own evaluator, so a search can ask the same question the trigger asks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-07 17:15:37 +02:00
Martino FerrariandClaude Opus 4.6 f179fbbb44 fix: a clock glitch is bridged and a channel is known by name, so a capture keeps its t0
Two defects behind one report ("a trigger during a capture overrides the first,
and the hold-off does not apply"); the second is also what made a spike in one
channel look like the acquisition resetting.

The trigger engine keyed its invalidation on the trigger channel's *index*, which
every rebuild of the channel list renumbers -- enabling a signal that sorts before
it is enough. Any rebuild therefore abandoned an open capture and set no hold-off,
so the next edge inside the post window became t0. It keys on the channel's name
now, which changes only when the source genuinely does.

A packet whose stamps jump more than 256 packet spans ahead is now rejected as a
fault and *bridged* at the declared cadence, instead of poisoning the monotonic
mark (which pinned every later packet flat at the glitched value) and condemning
the next good packet. Only four consecutive failures abandon the declared time
signal, and that fallback no longer wipes the rings: the arrival clock is anchored
to continue from the monotonic mark, so the history is still on one timeline --
the wipe is what made one glitching packet look like the stream restarting from 0.
The declared unit is resolved before those checks, since they weigh the stamps
against a cadence in seconds.

Guards: UDPSCOPE_PROBE_TRIGRENUM_MS prints t0_held=1 through the real widgets,
and the mock's --time-glitch-at/--time-glitch-by injects the clock fault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 10:29:05 +02:00