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

+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
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
/// -free in the math editor.
GtkWidget* form = nullptr;
GtkWidget* form_server = nullptr;
GtkWidget* form_port = nullptr;
GtkWidget* form_group = nullptr;
GtkWidget* form_iface = nullptr;
AdwDialog* form = nullptr;
/// The form is a `.ui` file (`src/ui/source-form.ui`), so its builder is held with it, and its rows
/// are typed: a switch is read with `adw_switch_row_get_active()` and not off a widget.
GtkBuilder* form_ui = 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;
/// 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
/// existing one -- which is why the same form is used for both.
GtkWidget* form_name = nullptr;
GtkWidget* form_note = nullptr;
GtkWidget* form_buffer = nullptr;
GtkWidget* form_relative = nullptr;
AdwEntryRow* form_name = nullptr;
AdwEntryRow* form_note = nullptr;
AdwSpinRow* form_buffer = nullptr;
AdwSwitchRow* form_relative = nullptr;
/// The configuration the form is editing: empty for a new host. Copied rather than referenced,
/// because `chooser_populate()` rebuilds `known` under the form.
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
// window returns having done nothing.
if (c.form != nullptr) {
GtkWidget* w = c.form;
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
gtk_window_destroy(GTK_WINDOW(w));
AdwDialog* d = c.form;
c.form = nullptr; // before the close, so the `closed` handler cannot re-enter
adw_dialog_force_close(d);
}
// 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
@@ -357,10 +361,9 @@ void chooser_populate(Chooser& c) {
}
void form_close(Chooser& c) {
if (c.form == nullptr) return;
GtkWidget* w = c.form;
c.form = nullptr; // before the close, so the handler cannot re-enter
gtk_window_close(GTK_WINDOW(w));
// The form's own `closed` handler clears the pointer, so this is the only place that has to know how
// an `AdwDialog` closes.
if (c.form != nullptr) adw_dialog_close(c.form);
}
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
// 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.
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length(
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0);
const char* text = gtk_editable_get_text(entry);
gtk_widget_set_sensitive(c.form_connect, text != nullptr && text[0] != '\0');
}
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));
if (server == nullptr || server[0] == '\0') return;
const std::string host = server;
const std::uint16_t port = static_cast<std::uint16_t>(
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
const std::uint16_t port = static_cast<std::uint16_t>(adw_spin_row_get_value(c.form_port));
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));
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 double buffer_mb = gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer));
const bool relative = gtk_switch_get_active(GTK_SWITCH(c.form_relative));
const double buffer_mb = adw_spin_row_get_value(c.form_buffer);
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
if (name.empty()) name = "default";
c.cfg->server = host;
@@ -433,16 +435,6 @@ void on_form_connect(GtkButton*, gpointer user) {
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
/// 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.
@@ -459,95 +451,74 @@ void open_form(Chooser& c, int edit_index = -1) {
c.edit_port = src != nullptr ? src->port : 0;
c.edit_config = src != nullptr ? src->config : std::string();
c.form = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(c.form), src != nullptr ? "Edit configuration" : "New host");
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
// A builder per opening, as with every other `.ui` here: the dialog belongs to its object list and
// `closed` destroys it, so the resource is parsed again rather than a dead dialog fetched.
g_clear_object(&c.form_ui);
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);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_window_set_child(GTK_WINDOW(c.form), box);
// Which mode the form is in, said in the header bar: the title names it and the action's label says
// what pressing it does.
const bool editing = src != nullptr;
if (c.form_title != nullptr) {
adw_window_title_set_title(c.form_title, editing ? "Edit configuration" : "New host");
}
gtk_button_set_label(GTK_BUTTON(c.form_connect), editing ? "Save" : "Connect");
GtkWidget* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
gtk_box_append(GTK_BOX(box), grid);
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");
// The values: the configuration being edited, or what the command line asked for. The hints live in
// the rows' subtitles, so an empty field still says what empty means.
gtk_editable_set_text(GTK_EDITABLE(c.form_server), src != nullptr ? src->host.c_str() : "127.0.0.1");
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() : "");
gtk_editable_set_text(GTK_EDITABLE(c.form_iface),
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
// decided before the first socket is opened.
GtkWidget* heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(heading), "<b>Configuration</b>");
gtk_widget_set_halign(heading, GTK_ALIGN_START);
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");
if (GObject* cancel = gtk_builder_get_object(c.form_ui, "form_cancel"); cancel != nullptr) {
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
form_close(*static_cast<Chooser*>(user));
}),
&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);
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does
// not, which is how the math dialog once kept presenting a finalized window.
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) {
// `closed` clears the pointer. An `AdwDialog` emits that and not `close-request`, and it fires only
// when the dialog really went away -- which is what the comment here used to be about.
g_signal_connect(c.form, "closed", G_CALLBACK(+[](AdwDialog*, gpointer user) {
static_cast<Chooser*>(user)->form = nullptr;
return FALSE;
}),
&c);
gtk_window_present(GTK_WINDOW(c.form));
gtk_widget_grab_focus(c.form_server);
adw_dialog_present(c.form, GTK_WIDGET(c.window));
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
@@ -665,19 +636,19 @@ gboolean pick_probe(gpointer user) {
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
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_note), "made by the new-host probe");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer), 32);
gtk_switch_set_active(GTK_SWITCH(c.form_relative), TRUE);
adw_spin_row_set_value(c.form_buffer, 32);
adw_switch_row_set_active(c.form_relative, TRUE);
std::fprintf(stderr,
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d "
"connect_sensitive=%d\n",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)),
gtk_switch_get_active(GTK_SWITCH(c.form_relative)) ? 1 : 0,
adw_spin_row_get_value(c.form_buffer),
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
g_signal_emit_by_name(c.form_connect, "clicked");
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",
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)),
gtk_switch_get_active(GTK_SWITCH(c.form_relative)) ? 1 : 0,
adw_spin_row_get_value(c.form_buffer),
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
gtk_button_get_label(GTK_BUTTON(c.form_connect)));
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);
gtk_switch_set_active(GTK_SWITCH(c.form_relative), FALSE);
adw_spin_row_set_value(c.form_buffer, 16);
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
// survive, or the chooser lists the same session twice.
if (const char* rename_to = g_getenv("UDPSCOPE_PROBE_EDITNAME")) {