Commit Graph
28 Commits
Author SHA1 Message Date
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 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 5eb20f3cdb perf: editing a math graph recomputes that graph, not every channel
`set_math_channels()` rebuilt the store for any change, and a rebuild replaces the buffer:
every channel in the window started again, which is the "modifying one expression invalidates
all the others" report.

The plan is a *partition over the channels*, so it only moves when the set moves. When the
set is unchanged -- same channels, names and enables -- nothing is rebuilt now: the store is
untouched, every other channel keeps its samples and its runtimes, and the surgical path
prepares and recomputes only the affected graphs. The affected set is the edited channels plus
whatever *names* them, because one channel's output is another's input, and the recompute
computes the whole list in order (the inputs have to be there) while writing only those in
the mask -- which is why the step-loop is now `math_apply_locked(sample, write_mask, timed)`.

Adding, removing or renaming a channel still moves the plan, and still rebuilds.

    [math-probe] graph edit: stored_slots 800 -> 900 (want the second at least the first, and non-zero)

The count grows because the stream keeps filling while the edit runs; the point is that it
never falls to 0, and that every channel afterwards still reads `N samples vs M time slots`
with N == M.
2026-10-12 22:19:17 +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 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 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 07fcf61a70 docs: the measures panel's frame is read by switch_on/reset_sensitive
The dump line's two new fields, and what either of them disagreeing with
[stats-probe] on= means: a .ui that lost the widget, or the wiring to it. pane_rows is
what proves the readout rows landed in the frame's measures_list.
2026-10-12 15:35:47 +02:00
Martino Ferrari 0a64e9e854 ui: the source chooser's window is a .ui file, and its pages an AdwViewStack
The last hand-built UI outside ui.cpp. The window, its header bar, its two pages and the
four buttons that are about the chooser rather than about a source now live in
src/ui/source-chooser.ui; the rows stay in code because a source row and a waveform row
are data, rebuilt whenever the known hosts or the recent files change.

The pages were a GtkNotebook and are an AdwViewStack under an AdwViewSwitcherBar, which
is what current libadwaita asks for and the same information without a tab strip. The
three actions moved from a button row at the foot into the header bar, the shape
source-form.ui already uses -- and deliberately without receives-default, since Enter
must keep activating the selected row.

Nothing carries a value: the tooltips and labels moved as they were, the handlers are
the same functions, and the switch is by page *name* rather than by index.

The probe reads the frame back -- `[sources] chooser ui: page='sources' title='Sources'
icon=yes page='waveforms' … switcher_wired=1 buttons=[prefs=1 new_host=1 open=1
cancel=1]` -- so a .ui that lost an id is named instead of opening a window with a hole
in it, and `check_ui` type-checks the file against the GIR (7 ok, 0 failed).

Two things the checks caught while doing it: the new frame dropped the call that fills
the waveforms page (the unused-function warning), and `AdwViewStackPage` is a GObject
that *wraps* a child rather than being one, so reading the pages needs
adw_view_stack_get_page().
2026-10-12 15:32:53 +02:00
Martino Ferrari f00d161900 fix: row titles mean their mnemonics instead of drawing the underscore
Fifteen display strings in the two preference forms were written as GTK mnemonics -- `_Directory`,
`_Server`, `Multicast _group`, `_Theme` and the rest -- but the property that makes GTK read them was
never set, so GTK drew the underscore literally: `_Directory` in the row, not a D with a line under it.

Two switches are involved, FALSE by default in each case. `GtkLabel`/`GtkButton` take `use-underline`;
the rows take `AdwPreferencesRow:use-underline`, "whether an embedded underline in the title indicates a
mnemonic". The two buttons in the source form already set it (so their mnemonics worked and showed no
underscore); the thirteen rows did not. All thirteen now do.

The row classes that carry the property are not all related -- AdwEntryRow has its own, while AdwSpinRow,
AdwComboRow and AdwSwitchRow get it through AdwActionRow -- so the probe reads it by name on a GObject
(g_object_class_find_property, then g_object_get) rather than casting to one row type; a cast to a
`GtkWidget*` where the type is only forward-declared does not compile at all.

`_Cancel` and `_Connect` both claimed C in the same form, so Alt+C could only reach one of them: that
marker was unreachable, which is worse than absent because it is in the source to be trusted. `_Connect`
becomes `Conn_ect`, freeing C for Cancel.

[prefs-probe] reads the count (4/4) on the real widgets; check_ui validates the property against the GIR
for every object in every .ui file, inherited properties included.
2026-10-12 15:10:22 +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 a8c25f0cb3 test: the layout probe reads the sidebar's width
`side_w` is the sidebar column's width, and it must be its own request (320) rather than 0. It turned
a report of "I cannot see the right side panel" into `side_w=0` in one run, which is what a reading is
for.

It also shows the shape of the fault: the width is 320 during the first frames and 0 once the window
settles, while the divider sits at 940 and the plot column ends around 752 -- so the paned is not
giving the sidebar any width, whatever the column asks for.

Four attempts to fix that here were each reverted, because each one left `side_w=0`: placing the
divider from the paned's own width instead of an absolute 940, the property form of the paned's
children, re-parenting the column in code, and a code-set size request on the scroller and the column.
The cause is not one of those, and the next step is to read the paned's own width and the column's
parent chain rather than to guess again.
2026-10-12 11:06:31 +02:00
Martino Ferrari 4c56172005 feat: the preferences are a UI file, applying as they change
src/ui/preferences.ui is an AdwPreferencesDialog whose rows are libadwaita's own -- AdwEntryRow,
AdwSpinRow, AdwSwitchRow, AdwComboRow, AdwButtonRow -- instead of a label and a bare GtkEntry in a
GtkBox. There is no Apply button: a row applies as it changes, which is what those widgets are
designed around, and [prefs-probe] reads the settings file back after every change.

The folder chooser is cancellable now, so a picker still open when the dialog closes cannot write
into a row that is gone; the cancellable is also how the probe takes the cancel path without a
mouse.

Two traps cost a run each and are recorded in docs/claude/invariants-gtk-and-chrome.md: an
AdwDialog is not a GtkWindow (it cannot parent a GtkFileDialog, and in floating mode it adds a
native surface to gtk_window_get_toplevels() that is neither a widget nor a window), and a .ui
that is not listed in the GResource is a Gtk-ERROR at runtime rather than a build failure -- which
is exactly what the probe opening every window is for.
2026-10-11 15:30:10 +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 210d124814 docs: the five reseq probe cases as this build measures them, plus the overflow counter's reading
Re-measured all four recorded cases and added a fifth for the re-plan path,
which needs 100 kS/s: at 10 kS/s the ring already covers 838 s, so no window
the control can reach moves time_stride and the window probe re-plans nothing.
Also notes that `stale` is 0 by construction while resequencing is on, and that
the reservation cap is counted in packets, so its sample cost scales with the
batch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:52:31 +02:00
Martino FerrariandClaude Sonnet 4.6 eacb3f5efc docs: the reseq probe's readings, and the invariants resequencing changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 23:14:38 +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 728e7fbda5 docs: help pages, probe reference and session docs updated for the three new features
- 08-trigger.txt: names all four condition shapes and the buffer search; fixes the first
  paragraph and 'How to reach it' to match what the panel actually contains (fix 3).
- 10-measurements.txt: adds a Statistics bullet — one observation per recompute, what
  resets the history, what does not (fix 3).
- probe-readings.md: re-quotes condition-probe lines with level2= and mode= fields;
  corrects leg-2 window geometry (0.2 s / 10 % pre, not 1.0 s / 50 %); moves the
  wide-window reason to legs 1 and 3; fixes the search-probe scheduling note
  (search_probe calls condition_probe directly; do not also set CONDITION_MS) (fix 6).
- verification.md: corrects shape-2 window geometry to match the code (fix 7).
- invariants-session-and-export.md, README.md: document the measure_stats session key —
  position, type, default false (fix 8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-08 11:19:19 +02:00
Martino FerrariandClaude Sonnet 4.6 ffc3ec1b4d docs: the three new features are described where each of their readers looks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-08 10:43:39 +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
Martino FerrariandClaude Opus 4.6 4003084648 feat: a signal's chip is clickable, so a trace's colour is the user's to choose
Clicking the coloured chip in the signal list opens a GtkColorDialog and the
colour it returns overrides that signal's palette entry. Held by name, like
every other identity here, and persisted as a session `[[color]]` table.

RowSet::signal_color() becomes the one resolver: install() builds the trace
through it and all three chip sites read it, because a chip exists to say which
trace a row is about and an override is exactly the state in which two routes to
a colour would disagree. A grey chip names no trace and so is not clickable, and
the way back is `Default colour` in the signal's menu -- a setting with no undo
is a trap.

The gesture sits on the existing label rather than in a GtkButton: padding on
this row is what made the signal list twice as tall once. It claims its sequence
so a click on the chip cannot become the start of a row drag.

UDPSCOPE_PROBE_COLOR_MS reads the result out of plot::Plot::trace() and out of
the chip's own markup, with an override in no palette slot, so neither leg can
pass by asking the resolver what it already said.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 08:22:32 +02:00
Martino FerrariandClaude Opus 4.6 a7702acb63 docs: CLAUDE.md is a goal and a table of contents, so a context pays for what it reads
The file was 1967 lines and is auto-loaded into every context, so every
session paid for the whole of it to reach one invariant. It now carries the
project's goal, the three facts that are always relevant, and a table of
contents; the body is fourteen files under docs/claude/ reached by link
rather than by import, so nothing is loaded until it is asked for.

The content is moved verbatim -- the only edits are each file's own header
and the cross-references that became cross-file ("see the invariant below"
is now a link to the file that owns it).

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