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

+51 -63
View File
@@ -20,6 +20,13 @@ namespace {
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
/// `::closed` and closes with `adw_dialog_close()`.
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;
/// 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
@@ -27,6 +34,17 @@ AdwDialog* g_about = nullptr;
GtkWidget* g_shortcuts = 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) {
(void)window;
*static_cast<GtkWidget**>(slot) = nullptr;
@@ -107,20 +125,18 @@ std::string load_page(const HelpPage& page, bool* ok) {
return body;
}
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
if (row == nullptr) return;
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer) {
if (row == nullptr || g_help_body == nullptr) return;
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
if (idx < 0 || idx >= kPageCount) return;
bool ok = false;
const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
// The screenshots above it, if this page has any. The column is the label's previous sibling
// inside the page's box, which is what lets this handler stay a matter of the label alone --
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
// also the only way to be sure the pictures are this page's and not the last one's.
GtkWidget* label = GTK_WIDGET(body_label);
GtkWidget* parent = gtk_widget_get_parent(label);
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
gtk_label_set_markup(GTK_LABEL(g_help_body), body.c_str());
// The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than
// being found as the label's previous sibling: refilling it is a page *switch*, not a frame, so
// rebuilding it here is cheap and is also the only way to be sure the pictures are this page's and
// not the last one's.
GtkWidget* shots = g_help_shots;
if (shots != nullptr && GTK_IS_BOX(shots)) {
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
gtk_box_remove(GTK_BOX(shots), old);
@@ -200,47 +216,31 @@ void show_help(GtkWindow* parent) {
return;
}
GtkWidget* window = gtk_window_new();
g_help = window;
gtk_window_set_title(GTK_WINDOW(window), "udpscope help");
gtk_window_set_default_size(GTK_WINDOW(window), 860, 640);
gtk_window_set_transient_for(GTK_WINDOW(window), parent);
// A builder per opening, as with every other `.ui` here: the window belongs to its object list and
// `close-request` destroys the window, so the resource is parsed again rather than a dead window
// fetched. This is where the previous one goes, and it is safe because that window has been closed
// for as long as it takes someone to ask for help again.
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
// sidebar swaps the markup rather than rebuilding anything.
GtkWidget* body = gtk_label_new(nullptr);
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);
// The rows are the pages, which is data and not template: a heading row per section, then one row per
// page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
// what a GtkListView with one item factory can draw.
const char* current_section = nullptr;
for (int i = 0; i < kPageCount; ++i) {
if (current_section == nullptr ||
std::string(current_section) != kPages[i].section) {
if (current_section == nullptr || std::string(current_section) != kPages[i].section) {
current_section = kPages[i].section;
GtkWidget* heading = gtk_label_new(nullptr);
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));
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);
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);
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), nullptr);
// Select the first page so the window never opens blank.
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);
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
gtk_window_present(GTK_WINDOW(window));
g_signal_connect(g_help, "close-request", G_CALLBACK(on_help_close), &g_help);
gtk_window_present(GTK_WINDOW(g_help));
}
void show_shortcuts(GtkWindow* parent) {