Files
udpscope/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md
Martino Ferrari afb9680fd7 docs: item 10 is done, and the three GTK facts it cost are written down
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.
2026-10-12 16:33:51 +02:00

7.2 KiB

UI files and current GTK practice

Where the application's interface should be a .ui resource instead of widget construction, what is already there, and the order to finish it in. Written 2026-10-11.

Why

Three reasons, in the order they bite.

  1. Some things can only be said in a UI file. The shortcut overview is the proof: GTK's GtkShortcutsShortcut has exactly one gesture type that draws an icon, and libadwaita's AdwShortcutsItem has no icon property at all -- so a mouse gesture can only be shown as a glyph from a .ui. src/ui/shortcuts.ui is that, and it is the reason the file exists.
  2. Structure is data. A window's arrangement rewritten in C++ is a hundred lines of gtk_box_append that no one can read at a glance, cannot be compared with a diff, and cannot be previewed. A UI file is the same arrangement in the notation the GNOME ecosystem reads.
  3. Current GTK and libadwaita have better widgets than this app uses. The forms here are GtkEntry/GtkSpinButton in hand-built label rows. The modern equivalents -- AdwEntryRow, AdwSpinRow, AdwSwitchRow, AdwComboRow, inside AdwPreferencesPage/AdwPreferencesGroup in an AdwPreferencesDialog -- are what the preferences dialog should be, and they come from the same move.

Conventions (established, and in docs/claude/invariants-gtk-and-chrome.md)

  • .ui files live in src/ui/ and are compiled by the third GResource (udpscope-ui.gresource.xml, prefix=/org/udpscope/ui), loaded with gtk_builder_new_from_resource().
  • Every file carries preprocess="xml-stripblanks", which is the only thing that catches a malformed file at build time.
  • A file that is valid XML but wrong for GTK aborts the process (Gtk-ERROR). The probe that opens every window is what catches that; there is no build-time substitute.
  • A GIcon property takes a nested object, not a name.
  • A .ui over 4095 characters needs -Wno-overlength-strings on its generated source.
  • A window built from a builder is replaced (not reused) on the next opening: it belongs to the builder's object list, and a destroyed GtkWindow cannot be presented again.
  • A deprecated widget is acceptable when no deprecated function is called.

Acceptance for every slice

The same as any other change here: the build stays at four warnings, a run stays at no Gtk-CRITICAL, and the slice's own surface is read back by a probe rather than asserted. A new .ui needs a probe that proves it loads and that its contents match whatever drives it -- [help]'s ui= against tables= is the model.

Already done

  • src/ui/main-window.ui -- the main window's skeleton: the window, its two panes, the plot column with the status line under it, and the sidebar's scroller. The four subtrees that depend on the stream (the events pane, the two plot areas, the sidebar's panels, the status popover) are still built by code and put into the slots by id.
  • src/ui/source-form.ui -- the form a source is added or edited with: an AdwDialog of AdwPreferencesGroup rows, with its action in the header bar where the label says which mode it is in.
  • src/ui/help.ui -- 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.
  • src/ui/preferences.ui -- the preferences, as an AdwPreferencesDialog with AdwEntryRow, AdwSpinRow, AdwSwitchRow, AdwComboRow and AdwButtonRow rows. The Apply button is gone: a row applies as it changes, which is what those widgets are designed around, and [prefs-probe] reads the file back after every change.
  • src/ui/shortcuts.ui -- the shortcut overview, with the mouse rows drawn as an icon cell.
  • The src/ui/ GResource and its build block.
  • [help]'s ui=/ui_accel=/ui_icon=/ui_bare= reading, which checks the file against the tables and asserts that no row is left without a visual.

Remaining, smallest first

where what why it is the right next step
src/source_dialog.cpp (the chooser's own window) the chooser: .ui for the window, its two pages and its action row. Its host form is done; what is left is the list, the notebook and the buttons around it the last hand-built UI outside ui.cpp, and the file is already open
build_measures_panel (src/ui.cpp, ~82 lines) the measurements panel: .ui + tools/check_ui.py after small, self-contained, and its own probe
src/ui/ui_trigger.cpp (~22 kB) the trigger panel: the .ui holds the panel and its groups; the signal chips and the condition rows stay in code because they follow CONFIG the largest user-visible panel
src/ui/ui_signals.cpp (~20 kB) the signals panel mostly a list of data-driven rows, so the .ui holds the frame
src/ui/ui_math.cpp (~48 kB) the math panel and the node editor's chrome (the canvas itself is drawn, not declared) the largest file, and the one where least of it is template
src/ui/ui_events.cpp (~44 kB) the events pane the rows are events, the frame is not
src/ui/ui_layout.cpp (~20 kB) the layout editor's overlay buttons, which are positioned by code only the buttons are template
build_toolbar (src/ui.cpp, ~212 lines) the header bar's layout, with every button still wired in code delicate: the [udpscope] header: probe reads its geometry

Every row of the table above is done or accounted for. The header bar went last and in one change, because gtk_header_bar_pack_start() appends after whatever a file has already packed -- staging it by group would have reordered the bar in an intermediate commit. Its frame is src/ui/header-bar.ui; the overflow menu's rows, the window control and every handler stayed in code, and the probe's header readings are byte-identical before and after.

The layout editor's buttons are not a .ui row, and the note above is wrong about them. Their count is the row count (one per row, one per boundary), and GtkBuilder instantiates a file's objects once, so a file can hold neither the number nor a prototype for the code to copy. What was real in that row was the duplication: the shared look is now one function (layout_overlay_button()), and the layout probe reads byte-identical before and after. See invariants-gtk-and-chrome.md.

Done since, one row per commit: the source chooser's window (0a64e9e), the measurements panel (d344361, 07fcf61), the trigger panel (1ccc70c), the signals panel (c37fbdd), the math panel (90a17eb) and the math editor's window, identity row and canvas. What is left of that row is the palette and the buttons around the canvas, which stay in code for the reason the canvas's drawing does.

Done since: the sidebar is an AdwOverlaySplitView (src/ui/main-window.ui) rather than a paned's end child, so its width is its own 320 px and it overlays the plot instead of collapsing to zero — see [sidebar-probe].

Each of these is one change on the pattern src/ui/ui_cursors.cpp now follows: ui_load() the file, resolve the ids, keep every value, signal and rebuild in code, and run the panel's own probe before committing.

Nothing in this list should be done in one pass. Each row is its own change, verified on its own.