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 hand-rolled grid existed to avoid GTK's deprecated GtkShortcutsWindow;
libadwaita 1.8's AdwShortcutsDialog is the supported replacement, so use the
platform widget and get its search, sectioning and shortcut labels for free.
The table strings become GTK accelerator syntax, since the shortcut label parses
them. The six mouse rows cannot: no accelerator syntax names a wheel or a drag,
so they carry an empty accelerator and prose in the new subtitle field.
adw_init() is called in run_ui() and in choose_source() (replacing its
gtk_init()), which means the whole app is drawn with libadwaita's stylesheet.
Verified settled: viewport.w 870..870, axes.w changes 2, x-desync 0.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The lifetime fix in ffec797 removes the UDPSCOPE_PROBE_PICK_MS timeout after
the nested loop returns, so a row picked before the delay elapsed cannot leave
an armed source pointing at a gone stack frame. But the probe returns
G_SOURCE_REMOVE on every path, so on the ordinary run -- the probe fires, picks
row 0 and the loop quits -- the id was already stale and the removal printed
"Source ID N was not found when attempting to remove it" on every probe run.
The id moves onto the Chooser and pick_probe clears it on entry, so the
cancellation applies only to a probe that never fired.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Seven correctness and documentation fixes found in the final review:
- `known_sources()`: check the strtol end pointer and add a round-trip
guard so a filename that layout_path() could not have produced is
rejected; without it `foo_8000x.toml` listed as `foo:8000` whose `x`
would unlink `foo_8000.toml`, a different configured session.
- `chooser_finish()`: destroy the form window before the chooser window;
a row click or Escape while the form was up left a window whose handlers
pointed at a dead stack frame (latent use-after-free).
- `choose_source()`: keep the pick_probe timeout id and g_source_remove
it after the loop; a row picked before the delay elapsed left an armed
source on the main context pointing at the gone frame.
- `chooser_populate()`: replace a false comment ("not a GtkListBoxRow")
with the truth — GTK4 auto-wraps, but the wrapped row carries no
source-index data so the bounds check refuses it.
- `pick_probe()`: collapse the duplicate `known.empty()` / `row==nullptr`
guards; the second was unreachable and two identical lines for one cause
makes probe output ambiguous.
- `save_layout()`: state the TOML rule directly in the multicast/interface
comment rather than cross-referencing the adjacent enabled comment.
- `save_layout_now()`: refuse to write when signals() is empty; a run
against a dead port receives no CONFIG, so every slot name is empty and
the save would overwrite a configured session with signals=["",...],
which the chooser filter refuses, silently removing that source from
the only list it can be picked from.
- Fix include order in layout_store.cpp (<cstdio> before <cstdlib>).
- Update CLAUDE.md and README.md to document the empty-signals guard and
fix the probe-output quote to match the emitted text exactly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server, port, multicast group and interface, and nothing else: --buffer-mb and
friends are tuning rather than which source, and a form that asks for them is
one a user has to read first. The form's widgets live on the chooser, which
outlives them, so no close path frees a live context.
One row per stored session, titled by its server -- the host the file is named
after and the one CONFIG comes from -- with the group as subtitle. A forget
repopulates rather than removing one row, because the rows carry their index
into the list and dropping one renumbers the rest.
The chooser runs before ScopeModel exists, so declining costs no buffer and no
socket. UDPSCOPE_PROBE_SOURCES=1 prints what the discovery found and exits,
before gtk_init(), so the list is checkable with no display.