todo.md's item 10 is closed with what moved, what stayed in code and why, the reading each
slice added, and the three GTK facts in invariants-gtk-and-chrome.md. The plan's table is
marked complete, with the note that the header bar went last and in one change -- pack_start
appends after whatever a file packed, so staging it by group would have reordered the bar in
an intermediate commit.
Two facts added to the invariants: a code-added header-bar child lands after the file's, so
going first means the file declares a slot (which is what header-bar.ui does for the
time-window control); and <child type="end"> is added leftwards-from-the-right, so a header
bar's end group reads the reverse of the file.
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.
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.
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.
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.
The interface is hand-written GtkBuilder XML and nothing in the build can see a mistake in it: a
property a class does not have, or a child a container does not accept, is a Gtk-ERROR when the
window is built -- and Gtk-ERROR aborts the process, so a typo in a .ui takes the application down
rather than failing to open a dialog. xml-stripblanks catches malformed XML and nothing else, and
the probes catch the rest only for the windows they open. Three of those aborts happened during
this refactor.
tools/check_ui.py round-trips each file through blueprint-compiler: decompile to blueprint, then
compile that, which type-checks every property and child type against the GIR. The XML stays the
source of truth and stays hand-written -- this is a check, not a compiler, and nothing here is
needed to build the application. Which is why the target is opt-in (no ALL) and the tool is looked
for at configure time rather than required: without it the target is absent, not silently passing.
$ make -C build test-ui
check_ui: 5 files in src/ui
ok help.ui ... source-form.ui
check_ui: 5 ok, 0 failed
Injected the exact mistake that cost a run earlier (an AdwEntryRow subtitle) and the check caught
it, with the caret and the "did you mean title?" hint, exiting non-zero. --lint prints the linter's
advice separately: it is advice, and the linter has opinions this project does not share.
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 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>