The slide-over's header already prints the open page's title beside its Close button, so the
signal page's body carried the name twice and a "Back to signals" button that only repeated
navigation the header owns. Both are gone.
The header's title is now the **numbered** name -- `<n>. <name>`, `measure_channel_number()`'s
number, the same one the signal badges and the measurements pane use -- so the panel says
*which acquired channel* it is and not only what it is called. `slideover_set_title()`
retitles a page in place (the open one included): the number follows the stream's channel
numbering, and the page is refilled whenever the model's signal list changes.
Probe: `[signalpage]` reads `title='1. sine' title_ok=1` (the header text against the numbered
name) and `no_dup=1` (no "Back to signals" button and no `title-3` label in the body); the old
`back_to_list` reading is gone with the button. Help, README and the probe doc follow.
A batch of view and instrument work, with the probes and docs for each.
- **Full screen per figure row**: a toggle on each row's top-right corner, revealed by
pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an
overlay-level motion controller so a revealed button under the pointer cannot flicker);
the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The
arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout
of one figure carries no toggle.
- **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in
their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main
window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries:
the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*.
- **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing
the layout*, flipping the app's one toggle.
- **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause
snapshot (live falls back to the live ring), persisted with the session and shown in the
measure dialog.
- **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the
plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu
and the probe read them.
- **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`,
`retarget_event_store()` follows every configuration switch, and a pre-configuration cache
(loose files) is adopted into the configuration's folder on first start.
- **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"`
signal to `close-request`; scope windows are closed with it and the application quits
explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`.
- Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`,
`[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key,
`[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated.
Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure
object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
The dock's Events block, the events slide-over page, the availability-rule deviation, the probe lines and the removal of the left pane are described; the false remembered-width claim is gone.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Also wrap the signals page's hint line, which at 375 px minimum widened the whole slide-over panel to 376.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The two drawn tab glyphs were judged not understandable enough; the
switcher goes to the wide policy with titles only, and the glyphs,
their gresource entries and the icon-naming code are removed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
**The clock.** Trigger mode showed a blank plot and never latched when the session defined a math
channel, because such a session gives the model a plan *before* the stream's own signals are in it:
one derived channel, declared rate 0, so `plan_partition()`'s 1 kHz fallback is the interval those
early frames are stamped at -- 1300x too fast for a 1.3 MS/s stream -- and they have nowhere to go,
since the value loop skips derived channels. Relative mode then stamped a *position* times the plan's
*current* interval, so when the CONFIG's channels landed and the plan took the real rate (769 ns) the
same positions produced times 1300x *smaller* than what was already in the ring, and the monotonic
clamp held the clock at the old value for the rest of the run: one repeated stamp, `history=0`, no
latch, and a window the renderer cannot draw. The same family as the restarted-client poisoning
fixed in 620473a, through a different door. The filter's own output was finite and correct
throughout, and disabling the math channel only appeared to cure it because that is a structural
change which rebuilds the store -- the third time that channel has looked guilty while being a
bystander.
Relative mode now accumulates *seconds* (`rel_base_s_ += n * dt`), so a re-plan is a slope change and
the clock cannot jump backwards by construction; a frame is only stamped into a model that has a
*streamed* channel; and the epoch is adopted once per connection, with "unset" a state rather than
the value zero. A one-shot warning names the family if it returns: `the clock stalled at ...: N
samples stamped [a..b] are all behind it`.
**The dialog.** The Configurations window's Close button and the window-manager close both did
nothing: `close-request` was connected to a bare `void` lambda, and that signal is `gboolean` -- TRUE
means "handled" and suppresses the default handler that destroys the window, while `G_CALLBACK` is a
cast, so GTK read whatever the compiler left in the return register. A standalone GTK program of that
shape reproduces it: `void` handler -> `window_alive=1` after `gtk_window_close()`, `gboolean FALSE`
-> 0. The same audit found `gtk_window_present(GTK_WINDOW(...))` on an `AdwDialog`, a type assertion
and a no-op; it is `adw_dialog_present()` now. The menu row stays: it is the only way to switch or
save a configuration without restarting, and what made it look useless was that it could not close.
Verified: trigger mode with that session's graph reads `ring=[0.0058..0.2446] -> [0.0058..1.9969]`,
`history 0.237 -> 2.0 s`, `edge at 1.120 s, latched at 2.120 s`, `display=event(frozen) latches=1`,
`traceless paints 266` against 1519 before the fix. Baselines hold: 0 errors, the documented three
warnings, test-ui 13 ok, test-help green, `gtk warnings: 0`.
A Mallard set under $XDG_DATA_HOME/help is one Yelp cannot draw the figures
of: its sandbox exposes the system help directories but not the user's data
directory, and each figure is a separate file the page references. The pages
open without their images, and through the app's help: URI the viewer spins
for ever because the failed image fetch means the page's load event never
fires -- which reads as a navigation failure, not a missing resource.
The alert and the manual both recommended install-help-user as the no-root
route, so they led users into exactly that case. Name the system-wide install
first and state the user-local caveat where the advice is given, and record
the finding in the GTK traps invariant so the next figure that will not draw
is not chased as a markup or a file problem.
A stop whose target is on another page of a stack was entered without
switching to that page, so the spotlight aimed at a widget that was not
on screen. A stop now declares the page it stands on (`TourStop::page`)
and the tour selects it as the step is entered -- the window's trigger
and signals stops put the sidebar on Signals, the chooser's tabs stop
shows the Waveforms page and stepping off it brings Sources back.
The stop filter moved into `tour_begin()` and now runs on
`gtk_widget_get_mapped()`: GTK 4.24's `is_visible()` ignores
`child_visible`, so it kept a stop on another page *and* one in a
collapsed pane alike. A wrong page is kept and selected; a collapsed
pane, whose stack is unmapped too, is still dropped.
The contextual help becomes the affordance the ask names: a plain (i)
(`dialog-information-symbolic`) at **16x16**, and one that sits beside the
control it explains rather than only in a pane's heading.
- The mark: `GtkMenuButton` ignores `min-width` on the widget and a size
request, so the size is made in `install_css()` on all three nodes (outer
`menubutton`, its `button` subnode, the glyph). Measured 16x16 against the
default 34x34; the dump prints it as `hint=WxH`, measured with
`gtk_widget_compute_bounds()` on the first *visible* hint.
- More sites: `events_record` (the "record events" switch in the trigger
panel), `trigger_advanced` (the pre-trigger share row), `signals_config`
(the signals pane's hint line, wrapped in a box so a hint can join it).
Hints join as siblings, so none can cover a switch, pencil or dropdown.
- The source chooser gets its own layer -- `chooser_sources` (a new heading
above the saved-source list) and `chooser_waveform` (the waveforms action
row) -- built with the one `context_help_button()` both windows use and
re-synced by its Help menu's toggle. The form's `AdwEntryRow`s cannot host a
sibling hint, so none is invented for the host/port fields.
- The tour is one implementation over two windows: `ui_impl::TourView` plus a
list of `TourStop`s; each window keeps its own table. A walk that passes its
last stop calls `on_done`, and the chooser's sets `Config::tour_pending`, so
a first run is one tour that crosses from the chooser into the app. The
chooser's tour auto-starts from `settings.tour_seen` exactly as the window's
does, and its "Guided tour" row now starts *it* instead of only requesting
the window's.
- Probes: `UDPSCOPE_PROBE_CHOOSER_TOUR=1` / `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`,
the hint size in the `help layer:`/`[helpui] contextual on:` lines, and the
chooser's hints in its `chooser ui:` line. Docs updated in
invariants-gtk-and-chrome.md, probe-readings.md and verification.md.
The failure alert told people to run `cmake --install build --prefix ~/.local`;
that still works, but `cmake --build build --target install-help-user` is the
form that needs no prefix guessing and prints the path it wrote, so the alert
names it and stays its former length. The manual gains a short "Installing
this manual" section on the overview page naming both targets -- the no-root
user one and the prefix-driven one for a system-wide or packaged install,
which the caller wraps in sudo -- and README and the GTK invariants doc follow
the alert's new command.
The tour measured its card with a height-for-width request whose for_size
came from the natural width minus the margins the tour sets for positioning.
A measure reports margins *included*, so that for_size landed a margin short
of the minimum the box answers with and GTK warned once per frame:
Trying to measure GtkBox 0x... for width of N, but it needs at least M
(about 9/s for as long as the tour was up). The card now takes its size from
gtk_widget_compute_bounds() once a frame has run -- its allocation rectangle,
not gtk_widget_get_width(), which is only the content box -- and falls back
to a measure with no for_size at all only on a frame with nothing allocated.
GTK is built with structured logging, so its messages reach the log writer
and never the handler API; no existing probe can read one. The exit dump now
counts them (`gtk warnings:`, must read 0) through a writer function chained
to g_log_writer_default(), which leaves stderr unchanged.
The contextual help was on by default, so a first run met a window with a "?"
beside every surface it had not asked to have explained; `contextual_help` now
defaults to false and the layer is asked for from the Help menu, from the
chooser's new help menu, or with Ctrl+H.
The source chooser runs before `App` exists, which makes it the window a user
who does not know how to start is looking at, so it now carries the same three
help affordances the window's Help view does: Documentation (the manual is
another process and needs nothing of the app), Enable contextual help (a row
whose check *is* the setting, written to the file the app reads a moment
later), and Guided tour. The tour is drawn on the app's own window, so the
chooser can only record the wish: it sets `Config::tour_pending` -- the value
`main()` owns and hands to both `choose_source()` and `run_ui()` -- and
`on_activate()` starts it once the window is up and laid out, through its own
callback so the readings can tell a request from the first-run auto-start.
The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()`
therefore runs from `choose_source()` (it is idempotent now) so the popover
gets the `.udpscope-menu` rules before it is shown.
Opening the manual could also fail with a message that blamed the handler
whatever was missing. `help_installed_path()` now resolves `help:<id>` the way
the specification does and the failure alert says which of the two it is: with
the set installed it names the viewer, without it it names the identifier, the
expected installed path and the install command. `show_help()` gained an
optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading --
`[help] launch attempted= done= ok= error='' installed=` -- since a successful
launch leaves no window of ours to observe.
Tested: clean --clean-first build, three warnings as documented; test-ui 13/13
and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs.
The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu**
now, which is where every GNOME menu keeps them, with the four things a first run cannot find:
- Keyboard shortcuts Ctrl+/ (the existing overview, `show_shortcuts()`)
- Documentation F1 (the existing manual, `show_help()`)
- Guided tour Ctrl+T (new)
- Enable contextual help (new, a stateful row: its check *is* the setting)
The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same
reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing
to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything
else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of
the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()`
and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped
child would leave the view that is not showing without an owner.
**The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the
trigger, the cursors, the measurements, the math channels, the signals, the event history, the time
window, the status line, and the math editor's identity row. Each row names the *heading box* its
button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover
the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer.
Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a
list of pointers because the math editor's heading is built afresh on every opening and a list would
go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written
through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own
rows start from `load_settings()` and so carry them through untouched.
**The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side
panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer
grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts
that as a reading (`controllers=` against the count taken before it opened) rather than as a claim.
Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because a widget's
allocation is 0 on the frame it is first shown and a window resize moves it under a one-shot
measurement; the hole is kept from the last frame that could measure the target, because a frame that
lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish`
and `Escape` all hide every child and leave nothing behind.
A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a
user who changed the theme has a file and has still never seen it), and the flag is written when the
tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it.
`UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the
submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour
started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and
`help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's
annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can
still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu
action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap
the invariants already warn about), and the tour card cannot miss its target when the target is the
whole content -- which is what the plot is at the window's 756 px minimum, where the split view
overlays the side panel instead of putting it beside the plot. The probe prints both rectangles
rather than asserting a placement, so that case reads as the geometry it is.
Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a
280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a
feedback loop -- that is what made the tour card walk between the corner and the target from frame to
frame.
The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow`
built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references
and no translation, and it kept the manual in the *binary* when every other desktop application's is
a document on disk. It is now a real Mallard set:
help/C/org.udpscope.app/ index.page + 14 topic pages + figures/*.png
and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the `help:`
scheme -- Yelp on GNOME -- through a `GtkUriLauncher`, reporting a launch failure with a
`GtkAlertDialog` rather than a click that silently does nothing. The paged window's builder, the
page table and `help_self_check()`'s markup validation are gone with the resource.
The document identifier is the desktop file's own basename (its desktop-file id), derived in
CMakeLists from `data/org.udpscope.app.desktop.in`, so the same string is the `.desktop` name, the
GApplication id and the install directory at once. The set is installed by a new `install()` rule to
`<datadir>/help/C/org.udpscope.app/` -- where the freedesktop Help System Specification's `help:`
URI resolves `help:<id>` -- so the manual is read from a datadir by another process even though the
binary has no install step. `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that
one place.
The 14 pages were migrated fact for fact, but restructured the Mallard way: a `<desc>` per page
(what a search result shows), `<steps>` for procedures, `<terms>` for option lists,
`<gui>`/`<key>`/`<keyseq>` for interface words and keys, `<note>` for the asides, and a topic graph
via `<link type="guide" xref="index#main"/>`. The figures are annotated screenshots produced by
`tools/make-help-shots.sh`, which drives the app headlessly against `tools/mock_udpstreamer.py` and
draws the boxes with ImageMagick; the side panel's box is measured from the app's own `side=NNNx`
dump reading, the header and status bands come from a documented table, and a crop whose standard
deviation is 0 fails the script.
`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
`yelp-check validate` is deliberately absent -- it fetches the Mallard RelaxNG schema over the
network and fails offline for that reason, not for a fault in the pages.
The `[help]` probe reading changes with the resource it was written for. `parsed == pages` over
in-binary markup cannot exist now, so `help_self_check()` scans `UDPSCOPE_HELP_DIR` and the line
reads `topics=14 index=1 figures=3`; `topics=0 index=0` is the manual missing from the tree, which
is the finding the old count named.
Verified: clean build with the documented three warnings (the help bundle's `-Woverlength-strings`
is gone, so the baseline moves from four to three); test-ui 14/14; test-help clean (the four
subcommands and 15 pages rendered); a temp-prefix install lands 15 `.page` files and 3 figures at
`share/help/C/org.udpscope.app/`; the headless run reads `[help] topics=14 index=1 figures=3 dir=...`
with 0 Gtk-CRITICAL. Yelp's own window could not be exercised on this box -- it starts under Xvfb,
activates its portals and never maps, the same WebKit/portal limitation that stops a grab -- so its
rendering and its search index are unverified here; the content pipeline was (`yelp-build` renders
the whole set).
Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline
in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]`
line in `probe-readings.md`.
Two reports, both real, and neither was about the lock itself.
**A drag out of an overlaid sidebar had nowhere to land.** In a window too narrow to show both columns,
`AdwOverlaySplitView` puts the sidebar *on* the plot — that is what `collapsed` means — so the pointer
stays over the list and never over a drop target: a signal could be dragged but not dropped, which reads
as "dragging does not work". `row_drag_sidebar()` now hides the sidebar on `drag-begin` and restores it on
`drag-end`, and only when collapsed: side by side the plot is already visible, and hiding the list the drag
started in would be startling. The reading begins a drag through the *source* with the state forced
collapsed, the way the sidebar probe forces it:
pane drag: sidebar hidden for the drag=1 restored after it=1
**The compact view did not follow the trigger switch.** The locked line's first branch reads
`tc.enabled` — with trigger mode off it says `stream (trigger off)` and nothing else — and
`on_trigger_toggled()` never re-synced it, so the line kept saying whichever it had been. One call to
`sync_trigger_summary()` beside the switch sync. The dump's two `panes:` lines were already the test and
now pass it: with a session whose trigger is off, the line as found reads `stream (trigger off)` and the
live one reads the settings, because the exit probe turns the trigger on in between. Both reading `stream`
was the defect.
Verified: the two lines above; `panes: the same line as the probe found it: 'mode: stream (trigger off)'`
against the live `'signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: normal'`;
`make test-ui` 14 files; zero `Gtk-CRITICAL`. Docs: the `pane drag:` reading and the invariants bullet for
the overlay, and the `panes:` pair's second job — the refresh test — written down so the next reader knows
what both lines reading `stream` means.
Two things the lock got wrong, and they are the same mistake seen twice: trigger-off was treated as "the
pane is disabled".
**The configuration was unreachable exactly when it was wanted.** `on_trigger_toggled()` set the trigger
*body* insensitive with the mode — a rule carried over from the five-revealer sidebar, where a closed pane
had nothing to edit. A trigger is configured *before* it is switched on, so switching it off to change it,
or to read what it is set to, is the ordinary way to work. The body is now never disabled; only the
*cursors'* readout still is, which is the pane whose content has nothing to show with the instruments off.
**The locked pane described a trigger that was not running.** With trigger mode off the summary is one
line — `mode: stream (trigger off)` — because the four lines it used to show (signal, condition, level,
mode) describe a trigger that is not there. With the trigger on it is those four plus whatever is not at
its default (`noise band`, `HF reject`, `pre-trigger`, `hold-off`).
The reading changes with it: `trigger panel sensitive …=1` is now a **regression guard** rather than a
state — the trigger's body is always live, so a `0` there means the settings have been locked out again.
Verified in both states, from sessions whose only difference is `trigger_enabled`:
panes: trigger=locked signals=locked body_visible=0 summary='mode: stream (trigger off)'
panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: single | pre-trigger: 20 %'
trigger panel sensitive before the probe toggled it: 1
The locked trigger pane is 72 px with the trigger off and 192 px with it on. Docs: the invariants' rule
for which body is disabled and why the trigger's is not, the `summary`'s two forms with what each means,
the README's deviation note, and the second `panes:` line renamed to say what it is — the line as the
probe found it, because only a run whose *session* has the trigger off shows the stream form.
Two things the lock shipped wrong.
**The "done" icon did not exist.** `emblem-ok-symbolic` is a breeze icon; Adwaita — which is what this
app runs under — does not have it, so the button that leaves configuration mode drew a missing-image
placeholder, i.e. nothing. Both names are now Adwaita's own: `document-edit-symbolic` (the pencil: edit)
and `object-select-symbolic` (the check: done). This is exactly the silent failure `toolbar icons:`
exists for, so the two `[edit]` buttons get the same treatment: `pane edits: locked=…(ok) config=…(ok)`
is read by clicking into configuration mode and back, so one line carries both names and
`gtk_icon_theme_has_icon()`'s verdict for each. The invariants now carry the rule, including the trap
that a name found under `breeze` is not an answer.
**The locked pane was too compact.** One line could not hold what a user opens the pane for, so it is now
a small definition list: `signal`, `condition`, `level`, `mode` on four lines, and after that only the
figures that are *not* at their defaults — `noise band`, `HF reject`, `pre-trigger`, `hold-off` — because
a line reading "band 0" is a line that says nothing. The channel is resolved from the acquired channels
and the condition quotes the figures its own shape reads (a width and its comparison, two levels, or a
timeout), never one it ignores. The pane is ~192 px tall locked against 580 in configuration mode.
One self-inflicted critical on the way: the probe read the summary with `gtk_label_get_text()`, and the
container is a grid now — `Gtk-CRITICAL: assertion 'GTK_IS_LABEL (self)' failed`. The text comes from the
sync that writes it instead.
Verified: `pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)`;
`panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling
| level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms'`;
`make test-ui` 14 files; zero `Gtk-CRITICAL` and no missing icon.
The trigger pane and the cursors pane now have a `GtkSwitch` in their heading, and it is *one control in
two places* with the header bar's button: both run the same action (`run_app_action()`), and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change, so clicking either
end moves both. A pane whose feature is off is disabled, which is exactly the state you need the switch
in, so it belongs *in* the pane rather than only in the toolbar.
Three things it needed:
- **The sensitive target moved to the pane's body.** A control inside an insensitive parent cannot be
clicked, so a switch that greyed out with its own pane could never turn it back on. `on_trigger_toggled`
and `on_cursors_toggled` now disable `trigger_body` / `cursors_body`, and so does the startup default;
the headings, with their switches, stay live.
- **The trigger's heading moved out of `trigger-panel.ui`** and is built in `build_trigger_panel()`,
which returns the heading row and the panel together. It has to be a sibling of the body for the switch
to stay usable, and keeping it in the file would have meant the file owning a widget the code must
arrange. The file's own panel is unchanged otherwise, so the `[trigger panel:]` counts still read.
- **A guard on the sync.** Setting a `GtkSwitch` emits `notify::active` for a programmatic write just as
it does for a click, so without `App::panel_switch_sync` the program's own write would come back as a
click and flip the feature again.
Readings, both directions in one line:
trigger panel sensitive before the probe toggled it: 0; … ; pane switches live while their panes are off=1
panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1 (pane switch / header bar button)
`pane switches live …=1` is the design's point and is read before anything turns either pane on; `0` would
be a switch drawn, greyed and useless. The trigger pair is moved from the *toolbar* end and the cursors
pair from the *pane* end, so the line carries both halves of the sync, and `drove_toolbar=1` is the pane's
own switch having turned the cursors on with the button following. `trigger panel sensitive …=0` is the
body, not the panel.
Verified: the two lines above; `[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon. Docs: the new reading in `probe-readings.md` with what a `0` means,
the invariants bullet for the one-control-in-two-places rule and its two traps, the sidebar bullet's
sensitive target, and the README note on why the switch is in the pane.
The bold pass had turned the trigger into half a period — a signal rising above its level — and that
lost the thing the glyph is for: the crossings. A crest and a trough read as a wave; a hump reads as a
hill, and a trigger that shows no crossing names the "above the level" half of an event. The full-period
sine with the dashed level through it is restored, exactly as it was, and the Signals pill carries the
same wave again so the pill and the toolbar button stay one picture.
The Measure glyph keeps the bold redesign, which is the part that was better: two cursors (a cap over two
4px dashes) beside a ruler of two bands with three 2x4 marks and no end caps. The Signals pill also keeps
its two bold channel rows.
Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero
`Gtk-CRITICAL`. The icons' blob is 12352 bytes, still over the C99 limit, so the clean-build warning count
stays four.
The previous pair were detailed miniatures — 1px ticks, 2px dashes, a 13-column wave — and at 32x16 that
reads as *texture* rather than as a shape, which is what "not clear" looks like. All three glyphs are
redrawn bold: one thick band, two thick dashes, few features.
- **Measure** is two cursors beside the ruler they read against. Each cursor is a cap over two bold
dashes (4px), where three 2px dashes read as a dotted column; the ruler is two bands with three 2x4
marks between them and **no end caps**, because a closed frame reads as a rectangle and costs a fifth
of the ink on two lines that say nothing. This is the layout whose first attempt looked like two icons
in a box — the difference is entirely the weight and the open ends.
- **Signals** is a signal rising above its level beside two bold channel rows. Half a period instead of
a full one: at this size a full period is a zigzag and a hump is unmistakably a wave, and the level's
marks survive on both sides of it instead of being swallowed by the crest.
- **The trigger's own icon** is now the same picture as the Signals pill, so the pill and the button that
turns the trigger on are recognisably about the same thing.
Each was rendered and read back as an ink map at 1:1 and at 2x before it was installed, and three
candidates were compared for the Measure glyph (markers *on* a scale, cursors *over* a ruler, and this):
the first two turned the marks and the shafts into blobs at the crossings. Ink is 18%, 30% and 29%,
inside the range the icons above them set, and everything stays inset two pixels.
Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero
`Gtk-CRITICAL`. The icons' blob is 11774 bytes, still over the C99 limit, so the clean-build warning
count stays four.
The previous Measure glyph was two icons sharing a box: a heavy closed ruler frame on the right and
two light caps on the left, with nothing relating them. It is now one instrument — two cursors
*standing on* the scale they read against, each marker centred on one of the scale's marks, so the
pair reads the way the pane works: bracket a window, read it against the axis.
Three things the render said, each fixed:
- **The closed frame was most of the ink.** Two lines and two end caps cost about a fifth of the
glyph's ink and say nothing a ruler's marks do not, so the scale is now one band with alternating
marks — long, short, long — hanging above it.
- **The caps were dashes, not grips.** 5x2 at 32px read as a horizontal stroke; they are 5x3, like the
caps on the toolbar's cursors icon.
- **The ticks were finer than everything else.** At 1px they were thinner than the 2px weight every
icon here uses, so they are 2px, and every mark alternates in height the way a ruler's do.
The Markers' shafts are dashed and meet the band rather than hovering over it, and the whole glyph is
inset two pixels like the others, so nothing is flush with an edge that a button's padding or a crop
takes a pixel off. Ink: 27%, inside the range the icons above it set.
Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present" (the plain ruler it uses is untouched);
`make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' blob is 12624 bytes, still over the C99 limit, so
the clean-build warning count stays four.
The trigger's toolbar icon was a rising edge, which names only one of the four shape triggers the panel
offers. It is now a sine with a dashed level through it — the trigger's actual job, find the signal where
it crosses this — and the Signals tab's pill carries the *same* picture beside the channel list, so the
pill and the button that turns the trigger on read as the same idea. Both are one period of one-pixel
stairs, the way the plot rasterises its own traces.
Everything is inset two pixels now. The previous drafts ran their level and their bars flush to the cell
edge, which is what makes art look shaved wherever a button's padding or a crop takes a pixel: nothing in
either tab glyph, or in the trigger's, touches an edge.
Each shape was drawn and then read back as an ink map rather than trusted — the first sine came out as a
thin antialiased diagonal (a polygon through whole-pixel vertices has diagonal *edges*), which is why the
band is now per-column stairs; the level-through-the-centre arrangement was chosen over level-above-the-
centre after rendering both, since at these sizes a level nearer the crest merges with it into a blob. The
four glyphs measure 18%, 22%, 25% and 25% ink, inside the range the icons above them set.
Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)` and
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`;
`[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero `Gtk-CRITICAL` and no missing icon. The
icons' GResource blob is 12408 bytes — the SVG sources are embedded, comments included — still over the
C99 limit, so the clean-build warning count stays four.
Each tab's icon is now two motifs side by side instead of one in a 16px square: the Measure pill is the
two cursors beside the ruler, the Signals pill the trigger beside three channel rows. A 16px cell carries
one motif — squeezing a pair into it is what turned both into a smear — and the pill has the width for a
pair in a 300px column with two pills in it.
Two measurements decided the size, both of which I had wrong from the outside:
- **GDK paints an icon into a square box whatever the document's aspect.** `gtk_icon_theme_lookup_icon()`
at 32 reports a `32x32` paintable for a 32x16 document, and librsvg fits it: rendered with aspect kept,
the artwork comes out `32x16` inside a 32 request. So the pair is letterboxed, not stretched, and
`pixel_size` 32 draws it at exactly the size it is written — 1:1, so the 2px bars stay crisp. A
fractional scale (24 or 48) is what would blur them, which is why nothing here is drawn at one.
- **The bar does not get taller for it.** `switcher: … icon=32x32 size=308x36`: 36px is what the switcher
measured with 16px icons too, because the pill's fixed height takes a 32px box without growing.
Both glyphs were drawn and then read back as ink maps rather than trusted: the first drafts came out at
39% and 44% ink (a blot beside the house 25–36%), and the tab bar is the last place a shape can be
checked, since no screenshot works under this X setup. The finished two measure 23% and 33%.
Verified: `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present";
`make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' GResource blob is 11661 bytes, still over the
C99 limit, so the clean-build warning count stays four.
The Measure tab is now `udpscope-tab-measure-symbolic` — the two cursors standing across a ruler — and
the Signals tab is `udpscope-tab-signals-symbolic` — the trigger's rising edge over the channel list it
acts on. Each is a 16px composite rather than one of the icons the toolbar carries: a glyph the toolbar
already uses for one thing means two things the moment a tab shows it too, which is why the ruler alone
and the trigger alone were both wrong here.
The plain ruler stays exactly where it was. `udpscope-measure-symbolic` is the Preferences window's Plot
page icon and is untouched; the tab's pair is a new file beside it, so nothing that already meant "the
measurement ruler" changed meaning. Seven action icons bundled, nine with the two app icons.
Both composites are at the limit of what 16px holds — four features each — so each was drawn and then
*measured*: rasterized and read back as an ink map, since the shapes have to be checked as pixels and the
house range is 25-36% ink (trigger 25, cursors 27, ruler 36). The Signals list is two rows and not three,
so the edge and the list get half the cell each; its bars are all one length, because unequal ones read as
a bar chart and a chart is not a list. The Measure ruler's ends are open on purpose: a closed frame puts
four more pixels of ink at the left and right edges, which is what turns a ruler into a rectangle at this
size. First drafts came out at 39% and 44% ink and were rebalanced to 32% and 36%.
Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2`; `[side-tab-probe] … advanced=4` (switching by page
name still works); the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14
files; zero `Gtk-CRITICAL`. The icons' GResource blob is now 11309 bytes, still over C99's 4095-byte
string-literal limit, so the clean-build warning count is still four.
Each sidebar page gets its own glyph and no visible text. The Measure tab reuses the ruler
(`udpscope-measure-symbolic`, the icon its deleted toolbar toggle used and the Plot preferences page
still carries); the Signals tab gets a new `udpscope-signals-symbolic` — three rows of a colour chip
and a name, which is what that tab mostly is — rather than a second copy of the trigger glyph, since
one shape meaning two things in one window is worse than a new one. Its bars are all one length: a
set of unequal ones reads as a bar chart, and a chart is not a list.
`AdwViewSwitcherPolicy.narrow` alone does **not** give an icon-only bar. Measured on libadwaita 1.10
by walking the switcher's tree: with `narrow` and a title, each pill holds a `GtkImage` *and* a
`GtkLabel` — 48px of "Measure" inside a 54px pill. So the pages' `title` is **empty** (their `name` is
what identifies them to `select_side_tab()` and to the session file, and both are unchanged) and the
words are re-attached in code: `name_side_tabs()` runs on the first tick — the pills are
`AdwViewSwitcher`'s own and exist only once it is mapped — and gives each pill a **tooltip and an
accessible name** from the page's name, then hides the empty label that would otherwise leave the
icon off-centre. An icon-only pill is still a named control for a screen reader, and the page icon
that does not resolve leaves a *blank* pill, which is why it is read like the toolbar's icons are.
Two readings, because the claim is two claims:
switcher: policy=narrow pills=2 labels=0 names=2
page icons: measure=udpscope-measure-symbolic(ok) signals=udpscope-signals-symbolic(ok)
`labels` counts *mapped labels with text* — a plain mapped count reads 2 either way, because the
narrow pill keeps an empty label in its layout — and `pills=2` is the guard that makes it mean
something: with nothing mapped the rest of the line is void rather than a pass.
Docs: the icon census (six action icons, eight bundled, and what each of the two new users is), the
switcher bullet in `invariants-gtk-and-chrome.md` with the measurement above, the two new dump lines
in `probe-readings.md`, the re-measured GResource figures in `verification.md` (icons 8613 -> 9842
bytes, help 514660 after this session's help rewrite; the count stays four), and the README recording
that the design spec had the switcher showing titles and neither page an icon.
Verified: `make test-ui` 14 files; `[side-tab-probe] … advanced=4` (tab switching by name still
works with empty titles); `signals panel: … divider=1`; `[math-probe] 3 math channels`; zero
`Gtk-CRITICAL` and no missing icon.
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the design's §5 rule -- the statistics
accumulator keeps running on the hidden page -- which nothing tested before. It switches to
Measure, forces one pass so there is a baseline, switches away to Signals and reads again two
seconds later: `advanced=4` is the assertion, and `advanced=0` is the defect where the
accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as
long as the trigger was on screen.
Two things the probe has to do for itself, and getting either wrong makes `advanced=0` a lie
about the tab:
- it **enables its own signal**, because a signal starts disabled (enabling is what starts
acquisition) and a probe that waited for one to be on would be reading another probe's side
effects -- and `UDPSCOPE_ENABLE_ALL_MS` is not part of this run;
- it **assigns that channel to a plot**, because a channel with no trace leaves the live window
*still*, and a still window is precisely the state `update_measures()` is supposed to skip.
Measured the hard way: without the assignment the probe read `advanced=0 min_n=0` with the view
pinned and only four passes in twelve seconds, none of them the tab's doing.
Docs: `probe-readings.md` gains the probe's entry and the signals panel's new shape
(`rows == signals + 1 + math`, `divider=0` a list that lost its `+`), loses the whole `math
panel:` entry, and follows `pane_sensitive=`, the gone `revealed=` field, the six toolbar
controls and the tab-reading `m`/`g` key pairs. `verification.md` gains the probe's bullet and
the re-measured warning figures. The invariants record the sidebar as a switcher above a stack
with a scroller per page, the hidden-page trap that replaces the unrevealed-revealer one for it,
that a feature's on/off is sensitivity rather than visibility, that a math channel is a row of
the signal list, and that a cursors-off readout blanks to `--` with the panel left on screen.
`shortcuts.ui` and the help page's window and sidebar bullets stop describing five revealers.
The README records the deviation from `SPECS.md` that names the panes as separate collapsible
sections.
Verified: `[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=16`;
`signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `panels: … (page_current=1)
(sensitive=0) switcher=1`; `toolbar icons:` six controls with every name `(ok)`, with the
Preferences dialog opened in the same run; zero `Gtk-CRITICAL` and no missing icon.
README gains the three controls where each lives -- the trigger's `band`/`reject`, the
measurement's `noise`/`hf`, and the math channel's `de-noise` node -- each with its unit, what
"off" is, and what it costs, plus the two deliberate deviations from the design spec next to the
features they belong to: the band applies to `edge` and `window` only (a pulse's width is measured
from a crossing to its return, so a band there changes the measured width rather than rejecting
noise; a dropout is an absence with no level to compare), and the band is verified through
`BufferSearch` hit counts rather than `[condition-probe]` latch times, because probe-readings.md
states that a `t0` is not an assertion.
The invariant docs gain the rules a later change must not break: `EventCondition`'s two new fields
and the once-per-packet `configure(dt)` that must not reset; the crossing rule that a change is
between two *decided* sides; the measurement path's exception to "a readout quotes a stored
sample", why the band moves the edge decision only, why `measure_accumulate` loops samples outer,
and why a filtered window disables both bucket fast paths; that `MathOp` is append-only (the index
*is* the serialisation), that the median window is pooled in the runtime rather than held per node,
why the pole reuses `b0`/`z1`, and that median-then-pole is the design; the session's named
`noise`/`hf` keys and its thirteen-field node string that still reads twelve; and the two GTK
traps -- positionally brace-initialised dialogs, and a control that cannot apply being hidden
rather than disabled.
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.
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.
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.
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.