ui: the source chooser's window is a .ui file, and its pages an AdwViewStack

The last hand-built UI outside ui.cpp. The window, its header bar, its two pages and the
four buttons that are about the chooser rather than about a source now live in
src/ui/source-chooser.ui; the rows stay in code because a source row and a waveform row
are data, rebuilt whenever the known hosts or the recent files change.

The pages were a GtkNotebook and are an AdwViewStack under an AdwViewSwitcherBar, which
is what current libadwaita asks for and the same information without a tab strip. The
three actions moved from a button row at the foot into the header bar, the shape
source-form.ui already uses -- and deliberately without receives-default, since Enter
must keep activating the selected row.

Nothing carries a value: the tooltips and labels moved as they were, the handlers are
the same functions, and the switch is by page *name* rather than by index.

The probe reads the frame back -- `[sources] chooser ui: page='sources' title='Sources'
icon=yes page='waveforms' … switcher_wired=1 buttons=[prefs=1 new_host=1 open=1
cancel=1]` -- so a .ui that lost an id is named instead of opening a window with a hole
in it, and `check_ui` type-checks the file against the GIR (7 ok, 0 failed).

Two things the checks caught while doing it: the new frame dropped the call that fills
the waveforms page (the unused-function warning), and `AdwViewStackPage` is a GObject
that *wraps* a child rather than being one, so reading the pages needs
adw_view_stack_get_page().
This commit is contained in:
Martino Ferrari committed 2026-10-12 15:32:53 +02:00
1 parent 723f5c3b4c
commit 0a64e9e854
5 files changed
+216 -80

No files matched your search

+91 -77
View File
@@ -69,10 +69,25 @@ struct Chooser {
GtkWidget* window = nullptr;
GtkWidget* list = nullptr;
std::vector<KnownSource> known;
/// The Waveforms page: the exports opened before, newest first, and the notebook that holds the
/// two pages. A waveform is the *other* answer to "which source", so it gets a page of its own
/// rather than a button that hides a file dialog behind one more click.
GtkWidget* notebook = nullptr;
/// The window's frame is a `.ui` file (`src/ui/source-chooser.ui`): the window, its header bar, the two
/// pages and the buttons that are about the chooser rather than about a source. The builder is held
/// with the window for the same reason the form's is -- the window belongs to the builder's object list.
GtkBuilder* ui = nullptr;
/// The Waveforms page: the exports opened before, newest first, and the stack that holds the two pages.
/// A waveform is the *other* answer to "which source", so it gets a page of its own rather than a button
/// that hides a file dialog behind one more click. A `GtkNotebook` was the first shape of this and a tab
/// strip is the wrong one: an `AdwViewStack` under an `AdwViewSwitcherBar` is what current libadwaita
/// asks for, and the switcher's stack is wired in code because a property cannot name a widget declared
/// elsewhere in the same tree.
GtkWidget* page_stack = nullptr;
GtkWidget* switcher = nullptr;
/// The buttons the header bar holds, kept because two of them are read at runtime: `new_host` takes the
/// focus when the list is empty (there is nothing else to do), and all of them are read back by
/// `report_chooser_ui()` so a `.ui` that lost one is named rather than silently missing its button.
GtkWidget* prefs_button = nullptr;
GtkWidget* new_host = nullptr;
GtkWidget* waves_open = nullptr;
GtkWidget* cancel_button = nullptr;
GtkWidget* wave_list = nullptr;
std::vector<std::string> waves;
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
@@ -136,6 +151,9 @@ void chooser_finish(Chooser& c, bool chosen) {
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
gtk_window_destroy(GTK_WINDOW(w));
}
// The window belonged to the builder's object list, so it is freed with it -- after the destroy, never
// before: a builder freed under a live window takes the window's children with it.
g_clear_object(&c.ui);
if (c.loop != nullptr) g_main_loop_quit(c.loop);
}
@@ -582,6 +600,34 @@ void open_chooser_preferences(Chooser& c) {
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
/// dialog built, filled and destroyed is as much the point as the values, the same way
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
/// switcher wired to the stack, and the four buttons the code resolved. `[help] ui=` does the same against
/// its tables -- the frame is what the file holds, so the frame is what is read, while the rows a user sees
/// are read by the probes that drive them.
void report_chooser_ui(const Chooser& c) {
std::fprintf(stderr, "[sources] chooser ui:");
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
w = gtk_widget_get_next_sibling(w)) {
// A page is not the child: `AdwViewStackPage` is a `GObject` that *wraps* one, so the stack is what
// turns a child back into its page.
AdwViewStackPage* page = adw_view_stack_get_page(ADW_VIEW_STACK(c.page_stack), w);
if (page == nullptr) continue;
std::fprintf(stderr, " page='%s' title='%s' icon=%s", adw_view_stack_page_get_name(page),
adw_view_stack_page_get_title(page),
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
}
std::fprintf(stderr,
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d]\n",
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
ADW_VIEW_STACK(c.page_stack)
? 1
: 0,
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0);
std::fflush(stderr);
}
gboolean pick_probe(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
@@ -589,6 +635,9 @@ gboolean pick_probe(gpointer user) {
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
c.pick_source = 0;
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
report_chooser_ui(c);
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
@@ -611,13 +660,13 @@ gboolean pick_probe(gpointer user) {
// page selected the way a click on its tab does. `UDPSCOPE_PROBE_WAVE=<path>` is the file dialog's
// answer without the dialog -- the same field, set the same way.
if (g_getenv("UDPSCOPE_PROBE_WAVEFORMS") != nullptr) {
if (c.notebook != nullptr) gtk_notebook_set_current_page(GTK_NOTEBOOK(c.notebook), 1);
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(c.page_stack), "waveforms");
std::fprintf(stderr, "[sources] waveforms: %zu recent", c.waves.size());
for (const std::string& p : c.waves) {
std::fprintf(stderr, " %s(%s)", p.c_str(), ::access(p.c_str(), R_OK) == 0 ? "ok" : "missing");
}
std::fprintf(stderr, " page=%d\n",
static_cast<int>(gtk_notebook_get_current_page(GTK_NOTEBOOK(c.notebook))));
std::fprintf(stderr, " page=%s\n",
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)));
std::fflush(stderr);
}
if (const char* path = g_getenv("UDPSCOPE_PROBE_WAVE")) {
@@ -762,88 +811,53 @@ bool choose_source(Config& cfg) {
Chooser c;
c.cfg = &cfg;
c.window = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source");
gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_window_set_child(GTK_WINDOW(c.window), box);
// The frame is a `.ui` file; the rows are not, because a source row and a waveform row are data and are
// rebuilt whenever the known hosts or the recent files change. `make test-ui` checks the file's types
// against the GIR; this checks that the code and the file still agree on the names, which nothing else
// can -- a missing id would otherwise open a window with a hole in it, or crash on the first read.
c.ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-chooser.ui");
c.window = GTK_WIDGET(gtk_builder_get_object(c.ui, "chooser_window"));
c.page_stack = GTK_WIDGET(gtk_builder_get_object(c.ui, "page_stack"));
c.switcher = GTK_WIDGET(gtk_builder_get_object(c.ui, "page_switcher"));
c.list = GTK_WIDGET(gtk_builder_get_object(c.ui, "sources_list"));
c.wave_list = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_list"));
c.prefs_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "prefs_button"));
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
c.waves_open == nullptr || c.cancel_button == nullptr) {
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
std::fflush(stderr);
return false;
}
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
// Two pages under one window: the sources it can connect to, and the waveforms it can open. A
// waveform is not a source with a file in place of an address -- there is no socket and nothing
// arrives -- so it is a *page* rather than a field, which is also where its history belongs.
c.notebook = gtk_notebook_new();
gtk_widget_set_vexpand(c.notebook, TRUE);
gtk_box_append(GTK_BOX(box), c.notebook);
GtkWidget* scroller = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scroller, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
c.list = gtk_list_box_new();
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
gtk_notebook_append_page(GTK_NOTEBOOK(c.notebook), scroller, gtk_label_new("Sources"));
GtkWidget* wave_page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget* wave_scroller = gtk_scrolled_window_new();
gtk_widget_set_vexpand(wave_scroller, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(wave_scroller), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
c.wave_list = gtk_list_box_new();
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.wave_list), TRUE);
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(wave_scroller), c.wave_list);
gtk_box_append(GTK_BOX(wave_page), wave_scroller);
GtkWidget* wave_actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(wave_actions, GTK_ALIGN_END);
gtk_widget_set_margin_top(wave_actions, 6);
gtk_widget_set_margin_bottom(wave_actions, 6);
gtk_widget_set_margin_end(wave_actions, 12);
GtkWidget* wave_open = gtk_button_new_with_label("Open\xe2\x80\xa6");
gtk_widget_set_tooltip_text(wave_open, "choose an exported .npy to view and analyse");
g_signal_connect(wave_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
gtk_box_append(GTK_BOX(wave_actions), wave_open);
gtk_box_append(GTK_BOX(wave_page), wave_actions);
gtk_notebook_append_page(GTK_NOTEBOOK(c.notebook), wave_page, gtk_label_new("Waveforms"));
chooser_populate_waves(c);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(actions, 12);
gtk_widget_set_margin_end(actions, 12);
gtk_widget_set_margin_top(actions, 12);
gtk_widget_set_margin_bottom(actions, 12);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
// The preferences are reachable from here as well as from the main window's menu: this dialog is
// the only window that exists before anything is planned, so it is where a user still has the
// chance to say how this run should behave.
GtkWidget* prefs = gtk_button_new_with_label("Preferences\xe2\x80\xa6");
gtk_widget_set_tooltip_text(prefs,
"the event cache, relative time and the interface's theme -- the same "
"dialog as the main window's menu, and the only place relative time can "
"still change this run");
g_signal_connect(prefs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_chooser_preferences(*static_cast<Chooser*>(user));
}),
&c);
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
g_signal_connect(c.new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_form(*static_cast<Chooser*>(user));
}),
&c);
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
g_signal_connect(c.cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
chooser_finish(*static_cast<Chooser*>(user), false);
}),
&c);
gtk_box_append(GTK_BOX(actions), prefs);
gtk_box_append(GTK_BOX(actions), new_host);
gtk_box_append(GTK_BOX(actions), cancel);
gtk_box_append(GTK_BOX(box), actions);
// The preferences are reachable from here as well as from the main window's menu: this dialog is the
// only window that exists before anything is planned, so it is where a user still has the chance to say
// how this run should behave.
g_signal_connect(c.prefs_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_chooser_preferences(*static_cast<Chooser*>(user));
}),
&c);
chooser_populate(c);
if (c.known.empty()) gtk_widget_grab_focus(new_host);
// The other page's rows too: fills the sources, and this is the pair of it.
chooser_populate_waves(c);
if (c.known.empty()) gtk_widget_grab_focus(c.new_host);
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
GtkEventController* keys = gtk_event_controller_key_new();