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.
`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.
src/ui/cursors-panel.ui holds the panel's shape -- the header with the mode dropdown and the reset
button, and the grid the two cursor times are read in. What is not in it is the grid's contents,
which follow the cursor mode and the rows and are rebuilt whenever either changes.
Panels need a builder that outlives them, unlike the dialogs, which are rebuilt on every opening: a
widget belongs to the builder that built it, so App::ui_builders holds each panel's for the life of
the window. ui_load() is that, in ui_widgets.cpp.
make test-ui also checks that udpscope-ui.gresource.xml lists every .ui file now. That was the third
trap of this refactor -- a file on disk but not in the resource is a Gtk-ERROR at load, which aborts
-- and it is a compile-time mistake that nothing else in the build could see. Verified by unlisting
the new file and watching the check fail, then restoring it.
[cursors-probe] reads as it did: all four modes, pane=1, the timings and the drag cases.
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.
src/ui/source-form.ui is the form a source is added or edited with: an AdwDialog of
AdwPreferencesGroup rows (AdwEntryRow, AdwSpinRow, AdwSwitchRow) with its action in the header bar,
where the label says which mode the form is in -- Connect for a new host, Save for an existing
configuration. The grid of labels and bare entries it replaces had its hints in placeholders, which
vanish as soon as there is a value; they are the groups' descriptions now.
src/ui/help.ui is the help window's chrome: an AdwWindow with a header bar and an
AdwNavigationSplitView whose sidebar is the page list. The rows stay in C++ because the fourteen
pages are data, not template.
Four UI files now, 653 lines of template that used to be widget construction. Two libadwaita traps
cost a run each and are in docs/claude/invariants-gtk-and-chrome.md: an AdwDialog has no content
property (its child is a plain <child>, unlike an AdwToolbarView), and AdwEntryRow has no subtitle
(it is an AdwPreferencesRow, not an AdwActionRow). The unused form_field() helper went with them.
Every probe reading is unchanged -- [sources] form:, [sources] edit form:, [help] -- which is what
refactoring a shape rather than a behaviour should look like.
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.
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.
The lookup compared raw uint32 counters with >= and <, so a hole spanning the
2^32 wrap matched nothing and every late packet belonging to it was dropped. A
signed delta, the idiom deps/udpsclient already uses throughout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Four latches were process-lifetime -- two of them function-local statics, so
process-wide rather than per-object -- and a condition that warned once stayed
silent about every later occurrence in a long-lived session. A diagnostic that
goes quiet after the first occurrence is the failure mode worth fixing in a
feature whose job is diagnosis.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
rebuild_locked() replaced the store, so produced() restarted at 0 while the
producer's tick_ kept counting: a hole's slot and tick addressing then described
a buffer that was gone, forget_holes_before() could not retire it, and a
backfill's amend fell outside the ring -- repairing nothing while the hole was
still marked repaired, which is the band reporting the opposite of the truth. A
re-plan generation is the signal rather than the stride/channel-count guard,
which a reconnect with an identical CONFIG leaves unchanged. slot_base_ is left
alone: it is relative mode's clock origin and must not walk backwards when the
user retypes the window.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
- 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>
- The comment in `feed_window()` and three companion sites claimed a zero-width band
"never fires, because no sample can be on a different side of it than its neighbour".
That is false: `inside = value >= lo && value <= hi` with `lo == hi == 1.0` is true at
+1 and false at -1, so a ±1 square fires 19 times in 20 samples. The correct reason
the panel refuses one is that it reduces the condition to a float-equality test on a
real-valued signal, which in practice never fires (fix 4).
- `EventDetector::condition()`, `BufferSearch::channel()` and `BufferSearch::condition_`
were confirmed unused by grep and deleted (fix 10).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 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>
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.
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.
- 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>
- 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>
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>