feat: the host form, and the help window, are UI files

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.
This commit is contained in:
Martino Ferrari committed 2026-10-11 17:27:05 +02:00
1 parent 4c56172005
commit 68743a7d7f
8 files changed
+388 -184

No files matched your search

+12
View File
@@ -213,6 +213,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live in - **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live in
`src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`, `src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`. `preprocess="xml-stripblanks"` `prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`. `preprocess="xml-stripblanks"`
catches a *malformed* file at build time — and that includes an XML rule easy to break by hand: **a
comment may not contain a double hyphen**, so prose written the way every C++ comment in this project
is written (` -- like this -- `) fails the build with a caret under the offending character. Use an em
dash in a UI file. `preprocess="xml-stripblanks"`
catches a *malformed* file at build time -- and nothing else does, which is why it is on every file in catches a *malformed* file at build time -- and nothing else does, which is why it is on every file in
every bundle. What it cannot catch is a file that is valid XML but wrong for GTK: `GtkBuilder` reports every bundle. What it cannot catch is a file that is valid XML but wrong for GTK: `GtkBuilder` reports
that with a **`Gtk-ERROR`, which aborts the process** by default, so a window built by hand from bad that with a **`Gtk-ERROR`, which aborts the process** by default, so a window built by hand from bad
@@ -244,6 +248,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse. that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
therefore belongs in the *group's* `description`, which is the better place for it anyway: a
placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has
**no `content` property**: its child is a plain `<child>`, where an `AdwToolbarView` does take
`content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by
the probe that opens every window rather than by anything at build time.
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from - **A dialog reachable from two windows cannot own its state in either.** The preferences open from
the main window's menu *and* from the source chooser, which exists before `App` does (`main()` the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
@@ -43,6 +43,11 @@ needs a probe that proves it *loads* and that its contents match whatever drives
## Already done ## Already done
- `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`, - `src/ui/preferences.ui` -- the preferences, as an `AdwPreferencesDialog` with `AdwEntryRow`,
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` and `AdwButtonRow` rows. The Apply button is gone: a row `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 applies as it changes, which is what those widgets are designed around, and `[prefs-probe]` reads the
@@ -56,8 +61,7 @@ needs a probe that proves it *loads* and that its contents match whatever drives
| where | what | why it is the right next step | | where | what | why it is the right next step |
|---|---|---| |---|---|---|
| `src/help_window.cpp` (351 lines) | the help window's chrome as a `.ui` (the pages themselves stay resources) | small, self-contained, and its probe already opens it | | `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 |
| `src/source_dialog.cpp` (~900 lines) | the source chooser and the host form: `.ui` + `AdwDialog`, with `AdwEntryRow`/`AdwSpinRow` for the form | the form is the second-biggest hand-built surface, and its probes drive every branch of it |
| `src/ui.cpp` (5774 lines) | the main window's **skeleton** only -- the `AdwApplicationWindow`, the header bar, the plot area's placeholder, the panes' containers, the status bar -- with the custom widgets, popovers and dialogs left in code | one file, one window: converting the skeleton is reviewable, converting the whole file is not. Do it in slices (header bar, then the panes, then the trigger panel, then the left panes) with the probe suite after each | | `src/ui.cpp` (5774 lines) | the main window's **skeleton** only -- the `AdwApplicationWindow`, the header bar, the plot area's placeholder, the panes' containers, the status bar -- with the custom widgets, popovers and dialogs left in code | one file, one window: converting the skeleton is reviewable, converting the whole file is not. Do it in slices (header bar, then the panes, then the trigger panel, then the left panes) with the probe suite after each |
Nothing in this list should be done in one pass. Each row is its own change, verified on its own. Nothing in this list should be done in one pass. Each row is its own change, verified on its own.
+51 -63
View File
@@ -20,6 +20,13 @@ namespace {
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on /// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
/// `::closed` and closes with `adw_dialog_close()`. /// `::closed` and closes with `adw_dialog_close()`.
GtkWidget* g_help = nullptr; GtkWidget* g_help = nullptr;
/// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the
/// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by
/// the window's own close handler: the page switch reads them, and it cannot run after the window is
/// gone.
GtkBuilder* g_help_ui = nullptr;
GtkWidget* g_help_body = nullptr;
GtkWidget* g_help_shots = nullptr;
AdwDialog* g_about = nullptr; AdwDialog* g_about = nullptr;
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window /// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the /// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
@@ -27,6 +34,17 @@ AdwDialog* g_about = nullptr;
GtkWidget* g_shortcuts = nullptr; GtkWidget* g_shortcuts = nullptr;
GtkBuilder* g_shortcuts_ui = nullptr; GtkBuilder* g_shortcuts_ui = nullptr;
/// The help window's own close handler, rather than the shared one: this window also owns the label a
/// page is written into and the column its screenshots are built in, and a page switch that ran after
/// the window was gone would read both.
gboolean on_help_close(GtkWindow* window, gpointer) {
(void)window;
g_help = nullptr;
g_help_body = nullptr;
g_help_shots = nullptr;
return FALSE; // let the default handler destroy it
}
gboolean on_window_close(GtkWindow* window, gpointer slot) { gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window; (void)window;
*static_cast<GtkWidget**>(slot) = nullptr; *static_cast<GtkWidget**>(slot) = nullptr;
@@ -107,20 +125,18 @@ std::string load_page(const HelpPage& page, bool* ok) {
return body; return body;
} }
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) { void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer) {
if (row == nullptr) return; if (row == nullptr || g_help_body == nullptr) return;
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page")); const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
if (idx < 0 || idx >= kPageCount) return; if (idx < 0 || idx >= kPageCount) return;
bool ok = false; bool ok = false;
const std::string body = load_page(kPages[idx], &ok); const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str()); gtk_label_set_markup(GTK_LABEL(g_help_body), body.c_str());
// The screenshots above it, if this page has any. The column is the label's previous sibling // The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than
// inside the page's box, which is what lets this handler stay a matter of the label alone -- // being found as the label's previous sibling: refilling it is a page *switch*, not a frame, so
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is // rebuilding it here is cheap and is also the only way to be sure the pictures are this page's and
// also the only way to be sure the pictures are this page's and not the last one's. // not the last one's.
GtkWidget* label = GTK_WIDGET(body_label); GtkWidget* shots = g_help_shots;
GtkWidget* parent = gtk_widget_get_parent(label);
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
if (shots != nullptr && GTK_IS_BOX(shots)) { if (shots != nullptr && GTK_IS_BOX(shots)) {
while (GtkWidget* old = gtk_widget_get_first_child(shots)) { while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
gtk_box_remove(GTK_BOX(shots), old); gtk_box_remove(GTK_BOX(shots), old);
@@ -200,47 +216,31 @@ void show_help(GtkWindow* parent) {
return; return;
} }
GtkWidget* window = gtk_window_new(); // A builder per opening, as with every other `.ui` here: the window belongs to its object list and
g_help = window; // `close-request` destroys the window, so the resource is parsed again rather than a dead window
gtk_window_set_title(GTK_WINDOW(window), "udpscope help"); // fetched. This is where the previous one goes, and it is safe because that window has been closed
gtk_window_set_default_size(GTK_WINDOW(window), 860, 640); // for as long as it takes someone to ask for help again.
gtk_window_set_transient_for(GTK_WINDOW(window), parent); g_clear_object(&g_help_ui);
g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui");
if (g_help_ui == nullptr) return;
GObject* window = gtk_builder_get_object(g_help_ui, "help_window");
GObject* list = gtk_builder_get_object(g_help_ui, "page_list");
GObject* body = gtk_builder_get_object(g_help_ui, "body");
GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box");
// A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a
// null widget on the first page switch.
if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return;
g_help = GTK_WIDGET(window);
g_help_body = GTK_WIDGET(body);
g_help_shots = GTK_WIDGET(shots);
gtk_window_set_transient_for(GTK_WINDOW(g_help), parent);
// One label, width-locked by the scrolled window, holding one page's markup. The // The rows are the pages, which is data and not template: a heading row per section, then one row per
// sidebar swaps the markup rather than rebuilding anything. // page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
GtkWidget* body = gtk_label_new(nullptr); // what a GtkListView with one item factory can draw.
gtk_label_set_use_markup(GTK_LABEL(body), TRUE);
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
gtk_label_set_yalign(GTK_LABEL(body), 0.0f);
gtk_label_set_selectable(GTK_LABEL(body), TRUE);
gtk_widget_set_margin_start(body, 18);
gtk_widget_set_margin_end(body, 18);
gtk_widget_set_margin_top(body, 14);
gtk_widget_set_margin_bottom(body, 14);
GtkWidget* body_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_start(shot_box, 18);
gtk_widget_set_margin_end(shot_box, 18);
gtk_widget_set_margin_top(shot_box, 14);
gtk_widget_set_visible(shot_box, false);
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_box_append(GTK_BOX(page_box), shot_box);
gtk_box_append(GTK_BOX(page_box), body);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
gtk_widget_set_hexpand(body_scroll, TRUE);
GtkWidget* list = gtk_list_box_new();
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_SINGLE);
const char* current_section = nullptr; const char* current_section = nullptr;
for (int i = 0; i < kPageCount; ++i) { for (int i = 0; i < kPageCount; ++i) {
if (current_section == nullptr || if (current_section == nullptr || std::string(current_section) != kPages[i].section) {
std::string(current_section) != kPages[i].section) {
current_section = kPages[i].section; current_section = kPages[i].section;
GtkWidget* heading = gtk_label_new(nullptr); GtkWidget* heading = gtk_label_new(nullptr);
char markup[128]; char markup[128];
@@ -267,26 +267,14 @@ void show_help(GtkWindow* parent) {
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i)); g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
gtk_list_box_append(GTK_LIST_BOX(list), row); gtk_list_box_append(GTK_LIST_BOX(list), row);
} }
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body); g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), nullptr);
GtkWidget* list_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
gtk_widget_set_size_request(list_scroll, 220, -1);
GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_box_append(GTK_BOX(split), list_scroll);
gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
gtk_box_append(GTK_BOX(split), body_scroll);
gtk_window_set_child(GTK_WINDOW(window), split);
// Select the first page so the window never opens blank. // Select the first page so the window never opens blank.
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1); GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first); if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help); g_signal_connect(g_help, "close-request", G_CALLBACK(on_help_close), &g_help);
gtk_window_present(GTK_WINDOW(window)); gtk_window_present(GTK_WINDOW(g_help));
} }
void show_shortcuts(GtkWindow* parent) { void show_shortcuts(GtkWindow* parent) {
+90 -119
View File
@@ -79,19 +79,23 @@ struct Chooser {
/// the chooser outlives the form by construction, so there is nothing to free on a close path /// the chooser outlives the form by construction, so there is nothing to free on a close path
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after /// -- and a dialog whose context is freed by a handler is how this project earned a use-after
/// -free in the math editor. /// -free in the math editor.
GtkWidget* form = nullptr; AdwDialog* form = nullptr;
GtkWidget* form_server = nullptr; /// The form is a `.ui` file (`src/ui/source-form.ui`), so its builder is held with it, and its rows
GtkWidget* form_port = nullptr; /// are typed: a switch is read with `adw_switch_row_get_active()` and not off a widget.
GtkWidget* form_group = nullptr; GtkBuilder* form_ui = nullptr;
GtkWidget* form_iface = nullptr; AdwWindowTitle* form_title = nullptr;
AdwEntryRow* form_server = nullptr;
AdwSpinRow* form_port = nullptr;
AdwEntryRow* form_group = nullptr;
AdwEntryRow* form_iface = nullptr;
GtkWidget* form_connect = nullptr; GtkWidget* form_connect = nullptr;
/// The configuration half of the form: what a source's *session* is called and the settings that /// The configuration half of the form: what a source's *session* is called and the settings that
/// can only be applied before the model exists. A new host needs all of them, and so does an /// can only be applied before the model exists. A new host needs all of them, and so does an
/// existing one -- which is why the same form is used for both. /// existing one -- which is why the same form is used for both.
GtkWidget* form_name = nullptr; AdwEntryRow* form_name = nullptr;
GtkWidget* form_note = nullptr; AdwEntryRow* form_note = nullptr;
GtkWidget* form_buffer = nullptr; AdwSpinRow* form_buffer = nullptr;
GtkWidget* form_relative = nullptr; AdwSwitchRow* form_relative = nullptr;
/// The configuration the form is editing: empty for a new host. Copied rather than referenced, /// The configuration the form is editing: empty for a new host. Copied rather than referenced,
/// because `chooser_populate()` rebuilds `known` under the form. /// because `chooser_populate()` rebuilds `known` under the form.
std::string edit_host; std::string edit_host;
@@ -119,9 +123,9 @@ void chooser_finish(Chooser& c, bool chosen) {
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized // cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
// window returns having done nothing. // window returns having done nothing.
if (c.form != nullptr) { if (c.form != nullptr) {
GtkWidget* w = c.form; AdwDialog* d = c.form;
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter c.form = nullptr; // before the close, so the `closed` handler cannot re-enter
gtk_window_destroy(GTK_WINDOW(w)); adw_dialog_force_close(d);
} }
// The preferences go first: they are presented *on* this window, and a dialog still up when its // The preferences go first: they are presented *on* this window, and a dialog still up when its
// window is destroyed is the lifetime this project has got wrong once already. `prefs_close()` is // window is destroyed is the lifetime this project has got wrong once already. `prefs_close()` is
@@ -357,10 +361,9 @@ void chooser_populate(Chooser& c) {
} }
void form_close(Chooser& c) { void form_close(Chooser& c) {
if (c.form == nullptr) return; // The form's own `closed` handler clears the pointer, so this is the only place that has to know how
GtkWidget* w = c.form; // an `AdwDialog` closes.
c.form = nullptr; // before the close, so the handler cannot re-enter if (c.form != nullptr) adw_dialog_close(c.form);
gtk_window_close(GTK_WINDOW(w));
} }
void on_form_server_changed(GtkEditable* entry, gpointer user) { void on_form_server_changed(GtkEditable* entry, gpointer user) {
@@ -369,8 +372,8 @@ void on_form_server_changed(GtkEditable* entry, gpointer user) {
// The only validation here. A *malformed* address is the acquisition layer's to report -- it // The only validation here. A *malformed* address is the acquisition layer's to report -- it
// already does, and the starvation notice covers a source that takes the socket and says // already does, and the starvation notice covers a source that takes the socket and says
// nothing -- so checking it twice would be two expressions of one rule. // nothing -- so checking it twice would be two expressions of one rule.
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length( const char* text = gtk_editable_get_text(entry);
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0); gtk_widget_set_sensitive(c.form_connect, text != nullptr && text[0] != '\0');
} }
void on_form_connect(GtkButton*, gpointer user) { void on_form_connect(GtkButton*, gpointer user) {
@@ -378,14 +381,13 @@ void on_form_connect(GtkButton*, gpointer user) {
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server)); const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
if (server == nullptr || server[0] == '\0') return; if (server == nullptr || server[0] == '\0') return;
const std::string host = server; const std::string host = server;
const std::uint16_t port = static_cast<std::uint16_t>( const std::uint16_t port = static_cast<std::uint16_t>(adw_spin_row_get_value(c.form_port));
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
const std::string group = gtk_editable_get_text(GTK_EDITABLE(c.form_group)); const std::string group = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
const std::string iface = gtk_editable_get_text(GTK_EDITABLE(c.form_iface)); const std::string iface = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name)); std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note)); const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
const double buffer_mb = gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)); const double buffer_mb = adw_spin_row_get_value(c.form_buffer);
const bool relative = gtk_switch_get_active(GTK_SWITCH(c.form_relative)); const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
if (name.empty()) name = "default"; if (name.empty()) name = "default";
c.cfg->server = host; c.cfg->server = host;
@@ -433,16 +435,6 @@ void on_form_connect(GtkButton*, gpointer user) {
chooser_finish(c, true); chooser_finish(c, true);
} }
/// A labelled row for the form's grid.
GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) {
GtkWidget* l = gtk_label_new(label);
gtk_widget_set_halign(l, GTK_ALIGN_END);
gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1);
gtk_widget_set_hexpand(widget, TRUE);
gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
return widget;
}
/// Opens the connection form: on a new host, or on an existing configuration (`edit_index`). One /// Opens the connection form: on a new host, or on an existing configuration (`edit_index`). One
/// form for both, because the fields are the same and a configuration *is* a host plus its /// form for both, because the fields are the same and a configuration *is* a host plus its
/// settings -- the only difference is where the values start and what the button is called. /// settings -- the only difference is where the values start and what the button is called.
@@ -459,95 +451,74 @@ void open_form(Chooser& c, int edit_index = -1) {
c.edit_port = src != nullptr ? src->port : 0; c.edit_port = src != nullptr ? src->port : 0;
c.edit_config = src != nullptr ? src->config : std::string(); c.edit_config = src != nullptr ? src->config : std::string();
c.form = gtk_window_new(); // A builder per opening, as with every other `.ui` here: the dialog belongs to its object list and
gtk_window_set_title(GTK_WINDOW(c.form), src != nullptr ? "Edit configuration" : "New host"); // `closed` destroys it, so the resource is parsed again rather than a dead dialog fetched.
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window)); g_clear_object(&c.form_ui);
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE); c.form_ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-form.ui");
if (c.form_ui == nullptr) return;
GObject* dialog = gtk_builder_get_object(c.form_ui, "source_form");
c.form_server = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "server_row"));
c.form_port = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "port_row"));
c.form_group = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "group_row"));
c.form_iface = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "iface_row"));
c.form_name = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "name_row"));
c.form_note = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "note_row"));
c.form_buffer = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "buffer_row"));
c.form_relative = ADW_SWITCH_ROW(gtk_builder_get_object(c.form_ui, "form_relative_row"));
c.form_title = ADW_WINDOW_TITLE(gtk_builder_get_object(c.form_ui, "form_title"));
c.form_connect = GTK_WIDGET(gtk_builder_get_object(c.form_ui, "form_apply"));
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every read below would
// be a null row.
if (dialog == nullptr || c.form_server == nullptr || c.form_port == nullptr ||
c.form_group == nullptr || c.form_iface == nullptr || c.form_name == nullptr ||
c.form_note == nullptr || c.form_buffer == nullptr || c.form_relative == nullptr ||
c.form_connect == nullptr) {
g_clear_object(&c.form_ui);
return;
}
c.form = ADW_DIALOG(dialog);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); // Which mode the form is in, said in the header bar: the title names it and the action's label says
gtk_widget_set_margin_start(box, 12); // what pressing it does.
gtk_widget_set_margin_end(box, 12); const bool editing = src != nullptr;
gtk_widget_set_margin_top(box, 12); if (c.form_title != nullptr) {
gtk_widget_set_margin_bottom(box, 12); adw_window_title_set_title(c.form_title, editing ? "Edit configuration" : "New host");
gtk_window_set_child(GTK_WINDOW(c.form), box); }
gtk_button_set_label(GTK_BUTTON(c.form_connect), editing ? "Save" : "Connect");
GtkWidget* grid = gtk_grid_new(); // The values: the configuration being edited, or what the command line asked for. The hints live in
gtk_grid_set_row_spacing(GTK_GRID(grid), 6); // the rows' subtitles, so an empty field still says what empty means.
gtk_grid_set_column_spacing(GTK_GRID(grid), 12); gtk_editable_set_text(GTK_EDITABLE(c.form_server), src != nullptr ? src->host.c_str() : "127.0.0.1");
gtk_box_append(GTK_BOX(box), grid); adw_spin_row_set_value(c.form_port, src != nullptr ? src->port : 8000);
gtk_editable_set_text(GTK_EDITABLE(c.form_group), src != nullptr ? src->multicast.c_str() : "");
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
src != nullptr ? src->host.c_str() : "127.0.0.1");
// A spin button holds no decimals by default, which is right for a port and wrong for every
// other numeric field in this project -- see the math dialog's fields.
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), src != nullptr ? src->port : 8000);
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
gtk_editable_set_text(GTK_EDITABLE(c.form_group),
src != nullptr ? src->multicast.c_str() : "");
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
gtk_editable_set_text(GTK_EDITABLE(c.form_iface), gtk_editable_set_text(GTK_EDITABLE(c.form_iface),
src != nullptr ? src->interface_addr.c_str() : ""); src != nullptr ? src->interface_addr.c_str() : "");
gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : "");
adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0
? src->buffer_mb
: std::max(1.0, c.cfg->buffer_mb));
adw_switch_row_set_active(c.form_relative,
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
: FALSE);
// The session half: what this source's configuration is called, and the settings that have to be if (GObject* cancel = gtk_builder_get_object(c.form_ui, "form_cancel"); cancel != nullptr) {
// decided before the first socket is opened. g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
GtkWidget* heading = gtk_label_new(nullptr); form_close(*static_cast<Chooser*>(user));
gtk_label_set_markup(GTK_LABEL(heading), "<b>Configuration</b>"); }),
gtk_widget_set_halign(heading, GTK_ALIGN_START); &c);
gtk_widget_set_margin_top(heading, 8); }
gtk_grid_attach(GTK_GRID(grid), heading, 0, 4, 2, 1);
c.form_name = form_field(grid, 5, "Name", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_name), "default");
gtk_editable_set_text(GTK_EDITABLE(c.form_name),
src != nullptr ? src->config.c_str() : "default");
c.form_note = form_field(grid, 6, "Note", gtk_entry_new());
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_note), "what this configuration is for");
gtk_editable_set_text(GTK_EDITABLE(c.form_note),
src != nullptr ? src->comment.c_str() : "");
c.form_buffer = form_field(grid, 7, "Buffer MB", gtk_spin_button_new_with_range(1, 65536, 1));
gtk_widget_set_tooltip_text(c.form_buffer,
"sample buffer per source, applied when this source is opened");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer),
src != nullptr && src->buffer_mb > 0.0
? src->buffer_mb
: std::max(1.0, c.cfg->buffer_mb));
GtkWidget* relative = gtk_switch_new();
gtk_widget_set_halign(relative, GTK_ALIGN_START);
gtk_widget_set_tooltip_text(relative,
"plot time from the packet counter and the declared rate, instead "
"of the stream's own time signal (--relative-time)");
gtk_switch_set_active(GTK_SWITCH(relative),
src != nullptr ? src->relative_time : c.cfg->relative_time);
c.form_relative = form_field(grid, 8, "Relative time", relative);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
form_close(*static_cast<Chooser*>(user));
}),
&c);
c.form_connect = gtk_button_new_with_label(src != nullptr ? "Save" : "Connect");
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c); g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
gtk_box_append(GTK_BOX(actions), cancel);
gtk_box_append(GTK_BOX(actions), c.form_connect);
gtk_box_append(GTK_BOX(box), actions);
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c); g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does // `closed` clears the pointer. An `AdwDialog` emits that and not `close-request`, and it fires only
// not, which is how the math dialog once kept presenting a finalized window. // when the dialog really went away -- which is what the comment here used to be about.
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) { g_signal_connect(c.form, "closed", G_CALLBACK(+[](AdwDialog*, gpointer user) {
static_cast<Chooser*>(user)->form = nullptr; static_cast<Chooser*>(user)->form = nullptr;
return FALSE;
}), }),
&c); &c);
gtk_window_present(GTK_WINDOW(c.form)); adw_dialog_present(c.form, GTK_WIDGET(c.window));
gtk_widget_grab_focus(c.form_server); gtk_widget_grab_focus(GTK_WIDGET(c.form_server));
} }
/// What the pick probes report: the connection the chooser came back with. One helper rather than /// What the pick probes report: the connection the chooser came back with. One helper rather than
@@ -665,19 +636,19 @@ gboolean pick_probe(gpointer user) {
gtk_editable_set_text(GTK_EDITABLE(c.form_server), gtk_editable_set_text(GTK_EDITABLE(c.form_server),
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str()); colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
if (colon != std::string::npos) { if (colon != std::string::npos) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1)); adw_spin_row_set_value(c.form_port, std::atof(s.c_str() + colon + 1));
} }
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host"); gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe"); gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer), 32); adw_spin_row_set_value(c.form_buffer, 32);
gtk_switch_set_active(GTK_SWITCH(c.form_relative), TRUE); adw_switch_row_set_active(c.form_relative, TRUE);
std::fprintf(stderr, std::fprintf(stderr,
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d " "[sources] form: name='%s' note='%s' buffer=%.6g relative=%d "
"connect_sensitive=%d\n", "connect_sensitive=%d\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)), gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)), gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)), adw_spin_row_get_value(c.form_buffer),
gtk_switch_get_active(GTK_SWITCH(c.form_relative)) ? 1 : 0, adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0); gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
g_signal_emit_by_name(c.form_connect, "clicked"); g_signal_emit_by_name(c.form_connect, "clicked");
report_pick(c); report_pick(c);
@@ -708,12 +679,12 @@ gboolean pick_probe(gpointer user) {
"[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n", "[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)), gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)), gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)), adw_spin_row_get_value(c.form_buffer),
gtk_switch_get_active(GTK_SWITCH(c.form_relative)) ? 1 : 0, adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_button_get_label(GTK_BUTTON(c.form_connect))); gtk_button_get_label(GTK_BUTTON(c.form_connect)));
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe"); gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer), 16); adw_spin_row_set_value(c.form_buffer, 16);
gtk_switch_set_active(GTK_SWITCH(c.form_relative), FALSE); adw_switch_row_set_active(c.form_relative, FALSE);
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not // `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
// survive, or the chooser lists the same session twice. // survive, or the chooser lists the same session twice.
if (const char* rename_to = g_getenv("UDPSCOPE_PROBE_EDITNAME")) { if (const char* rename_to = g_getenv("UDPSCOPE_PROBE_EDITNAME")) {
+93
View File
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The help window's chrome: a window, its own header bar, and a two-pane split whose sidebar is the
list of pages and whose content is one page at a time.
The split is AdwNavigationSplitView rather than a GtkBox with a separator in it, so the sidebar
collapses on a narrow window and comes back behind the header bar's button instead of squeezing the
text. On a wide one it is the same two panes it always was.
The page list itself is *not* here: its rows are the fourteen pages in `help_window.cpp`, each with
section headings between the groups, and a template cannot know them. What is here is the shape they
are put into: an id to find the list by, the scroller around the page, and the label that holds one
page's markup with the screenshot column above it.
-->
<interface>
<object class="AdwWindow" id="help_window">
<property name="title" translatable="yes">udpscope help</property>
<property name="default-width">860</property>
<property name="default-height">640</property>
<property name="content">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
<property name="show-title">1</property>
</object>
</child>
<property name="content">
<object class="AdwNavigationSplitView">
<property name="sidebar">
<object class="AdwNavigationPage">
<property name="title" translatable="yes">Pages</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="width-request">220</property>
<property name="child">
<object class="GtkListBox" id="page_list">
<property name="selection-mode">single</property>
</object>
</property>
</object>
</property>
</object>
</property>
<property name="content">
<object class="AdwNavigationPage">
<property name="title" translatable="yes">Page</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="hexpand">1</property>
<property name="child">
<object class="GtkBox" id="page_box">
<property name="orientation">vertical</property>
<child>
<object class="GtkBox" id="shot_box">
<property name="orientation">vertical</property>
<property name="spacing">8</property>
<property name="margin-start">18</property>
<property name="margin-end">18</property>
<property name="margin-top">14</property>
<property name="visible">0</property>
</object>
</child>
<child>
<object class="GtkLabel" id="body">
<property name="use-markup">1</property>
<property name="wrap">1</property>
<property name="xalign">0</property>
<property name="yalign">0</property>
<property name="selectable">1</property>
<property name="margin-start">18</property>
<property name="margin-end">18</property>
<property name="margin-top">14</property>
<property name="margin-bottom">14</property>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</property>
</object>
</property>
</object>
</property>
</object>
</interface>
+132
View File
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The form a source is added or edited with: one dialog, two groups, eight rows.
A grid of labels and bare entries is what this was, and a grid is the wrong shape for it. Each row
here is a whole row — a title, a subtitle under it where the hint used to be a placeholder, and the
editing widget on the right — so the form reads the way every GNOME application's does, and the hints
survive being typed over (a placeholder does not: it disappears the moment there is a value, which is
exactly when a user may want to know what empty meant).
The action is in the header bar rather than a button row at the foot: it is what the dialog is *for*,
it is what the keyboard's Enter reaches, and its label says which mode the form is in — `Connect` for
a new host, `Save` for an existing configuration, which `source_dialog.cpp` sets.
Nothing here carries a value. The dialog is filled from the source being edited, or from the command
line for a new one, by the code that opens it.
-->
<interface>
<object class="AdwDialog" id="source_form">
<property name="content-width">420</property>
<property name="content-height">560</property>
<child>
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
<property name="title-widget">
<object class="AdwWindowTitle" id="form_title">
<property name="title" translatable="yes">New host</property>
</object>
</property>
<child type="end">
<object class="GtkButton" id="form_cancel">
<property name="label" translatable="yes">_Cancel</property>
<property name="use-underline">1</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="form_apply">
<property name="label" translatable="yes">_Connect</property>
<property name="use-underline">1</property>
<property name="receives-default">1</property>
<property name="css-classes">suggested-action</property>
</object>
</child>
</object>
</child>
<property name="content">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="propagate-natural-height">1</property>
<property name="child">
<object class="AdwPreferencesPage">
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Connection</property>
<property name="description" translatable="yes">Leave the multicast group and the interface empty for unicast on the default route</property>
<child>
<object class="AdwEntryRow" id="server_row">
<property name="title" translatable="yes">_Server</property>
</object>
</child>
<child>
<object class="AdwSpinRow" id="port_row">
<property name="title" translatable="yes">_Port</property>
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">1</property>
<property name="upper">65535</property>
<property name="step-increment">1</property>
<property name="value">8000</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwEntryRow" id="group_row">
<property name="title" translatable="yes">Multicast _group</property>
</object>
</child>
<child>
<object class="AdwEntryRow" id="iface_row">
<property name="title" translatable="yes">_Interface</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Configuration</property>
<property name="description" translatable="yes">The name is what this source's session is called. The buffer and the clock are decided before the first socket is opened.</property>
<child>
<object class="AdwEntryRow" id="name_row">
<property name="title" translatable="yes">_Name</property>
</object>
</child>
<child>
<object class="AdwEntryRow" id="note_row">
<property name="title" translatable="yes">N_ote</property>
</object>
</child>
<child>
<object class="AdwSpinRow" id="buffer_row">
<property name="title" translatable="yes">_Buffer MB</property>
<property name="subtitle" translatable="yes">sample buffer per source, applied when this source is opened</property>
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">1</property>
<property name="upper">65536</property>
<property name="step-increment">1</property>
<property name="value">64</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwSwitchRow" id="form_relative_row">
<property name="title" translatable="yes">_Relative time</property>
<property name="subtitle" translatable="yes">plot time from the packet counter and the declared rate, instead of the stream's own time signal</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</interface>
+2
View File
@@ -11,5 +11,7 @@
<gresource prefix="/org/udpscope/ui"> <gresource prefix="/org/udpscope/ui">
<file preprocess="xml-stripblanks">shortcuts.ui</file> <file preprocess="xml-stripblanks">shortcuts.ui</file>
<file preprocess="xml-stripblanks">preferences.ui</file> <file preprocess="xml-stripblanks">preferences.ui</file>
<file preprocess="xml-stripblanks">help.ui</file>
<file preprocess="xml-stripblanks">source-form.ui</file>
</gresource> </gresource>
</gresources> </gresources>
+2
View File
@@ -27,5 +27,7 @@
10. [ ] move the interface into `.ui` resources, and to current GTK4/libadwaita widgets while doing it: 10. [ ] move the interface into `.ui` resources, and to current GTK4/libadwaita widgets while doing it:
- the shortcut overview is **done** (`src/ui/shortcuts.ui`, with the mouse gestures drawn as icons, and `[help]`'s `ui=` against `tables=` as the guard) - the shortcut overview is **done** (`src/ui/shortcuts.ui`, with the mouse gestures drawn as icons, and `[help]`'s `ui=` against `tables=` as the guard)
- the preferences are **done** (`src/ui/preferences.ui`: `AdwPreferencesDialog` with `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`/`AdwComboRow`, applying as they change, no Apply button) - the preferences are **done** (`src/ui/preferences.ui`: `AdwPreferencesDialog` with `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`/`AdwComboRow`, applying as they change, no Apply button)
- the help window's chrome is **done** (`src/ui/help.ui`: `AdwWindow` + header bar + `AdwNavigationSplitView`)
- the host form is **done** (`src/ui/source-form.ui`); the source chooser's own window is next
- next: the preferences (`AdwPreferencesDialog` + `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`), the help window's chrome, the source chooser and its host form, then the main window's skeleton - next: the preferences (`AdwPreferencesDialog` + `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`), the help window's chrome, the source chooser and its host form, then the main window's skeleton
- plan, conventions and acceptance for each slice: `docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md` - plan, conventions and acceptance for each slice: `docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md`