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.
Moving the editor's frame into math-editor.ui left `math_body` and `math_actions` as
<child> elements of the *window* rather than of `math_outer`, because those edits were
anchored on the file's tail and closed the outer box two elements early. A GtkWindow
takes exactly one default <child>, so nothing failed loudly: the body -- with the canvas
and the parameter form inside it -- was left parentless, visible but never mapped, 0x0,
drawing nothing. Two symptoms, one cause: the canvas is blank, and the window is sized
around the head row and the buttons alone.
Measured against the pre-move revision in a worktree, same probe, same environment:
before this fix `canvas=0x0 mapped=0 window=756x940`; after it `canvas=420x836 mapped=1
window=938x940`, which is the old revision's own reading exactly.
It survived because that slice's probe read the editor's *values* (nodes, gain, output,
name, unit) and nothing about its structure. Every other panel moved that day has a
structural reading -- `controls_inside=13/13`, `nested=1`, `labels=2 list=1` -- and none
of them was affected. `[math-probe] editor frame:` is that reading now: the canvas's
allocation, its mapped state, and whether the canvas and the name entry are inside the
dialog at all, delayed 500 ms because an allocation only exists after a layout. In the
broken build it read `0x0 mapped=0 in_dialog=0`.
check_ui passes both files: the XML was valid and every property type-checked.
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.
`math_dialog_sync_form()` wrote the label and then moved a node whose source
names an unacquired channel -- a mutation that changes what
`math_dialog_problem()` reports. With the sync re-entry now guarded, the nested
call that used to repair the label is suppressed, so the line kept describing a
source that had just been fixed and left `Apply` insensitive until the next
mutation. The reconcile moves above the write and the dropdown reuses its list.
A review of the whole node editor. The hit test measured the distance to the
24 curve samples rather than to the segments between them, so a wire spanning
several rows was unpickable between two samples -- the probe now places its
boxes 280 units apart and prints the nearest sample's own 8.4 against a 3.0
tolerance, which the point test would have missed. A refused connection said
nothing: the message is now carried in `MathDialog::refusal` and shown once by
the single writer of the label, and a self-wire is refused through the real
drag-end path instead of only through `math_connect()`.
Two defects were found only by the new assertions. `math_dialog_sync()`
re-entered itself -- `gtk_drop_down_set_selected()` emits `notify::selected`
for a programmatic set too, and the handler answers by syncing again, which
consumed the refusal before it could be drawn -- so the body is now guarded
one level deep. And a deleted node left `selected_wire` naming an index that
afterwards belongs to a different wire, so `Delete` cleared an input on the
wrong node.
A node is placed in the first free grid cell rather than at the end of the
list's own cell, so a node added after a delete no longer lands on top of a
survivor; the label ellipsizes with a tooltip rather than wrapping, because a
wrapped line makes the dialog's height depend on the text in it.
Each invariant records what went wrong rather than only the rule: a port hit
that lost its node, a hit test at the identity view that asserts nothing, a
`Delete` that must not fall through to the selected node, and a column pitch
too narrow for a wire's own tread. The warning baseline drops to four.
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>