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

+4 -2
View File
@@ -945,14 +945,16 @@ configuration that carries settings, is the defect this exists for — a saved h
## `[sources] waveforms` and the waveform load
```
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=1
[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1]
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=waveforms
[sources] picked waveform /tmp/rt.npy
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 span=2.000000s rate=10000 time=[-2.000000..0.000000]
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=1` | the Waveforms page not built, or a stale path offered as if it were openable |
| `chooser ui:` | both pages with a `title` and an `icon=yes`, `switcher_wired=1`, and all four buttons `1` | the chooser's `.ui` losing an id: the code names the missing one and refuses the window, and this line is the frame that *did* build -- a page with `icon=NO` is one the switcher cannot draw |
| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=waveforms` once the page is shown | the Waveforms page not built, or a stale path offered as if it were openable |
| `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads |
| `signals=`, `samples=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels |
| `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp |
+1 -1
View File
@@ -343,7 +343,7 @@ as distinct from the fault injection below, which exercises ingest:
reads as one that cannot plot anything. A file-mode run must also write **no session** for the
default `127.0.0.1:8000` (`App::source_port` is 0), or it overwrites that source's saved layout.
- **The chooser's Waveforms page**: `UDPSCOPE_PROBE_WAVEFORMS=1` prints
`[sources] waveforms: N recent <path>(ok|missing) … page=1`, and `UDPSCOPE_PROBE_WAVE=<path>` sets
`[sources] waveforms: N recent <path>(ok|missing) … page=waveforms`, and `UDPSCOPE_PROBE_WAVE=<path>` sets
the field the file dialog's callback sets and prints `[sources] picked waveform <path>` — then the
load line above, with no socket involved.
- **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME`
+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();
+119
View File
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
What to watch: one page of sources it can connect to, one page of waveforms opened before, and the
three buttons that are about the chooser rather than about a source.
This window exists *before* the application does. `main()` asks it what to open and only then builds
the model and the rest of the interface, which is why the preferences are reachable from here at all
— and why nothing in this file may reach for `App`.
The two pages are an `AdwViewStack` under an `AdwViewSwitcherBar` and not a `GtkNotebook`: a tab strip
is the wrong shape for two views of one thing, and the switcher is what current libadwaita asks for.
The bar is revealed unconditionally because this window is small enough that it always is narrow.
The rows are not here, deliberately. A source row and a waveform row are *data* — `source_dialog.cpp`
builds them from the known hosts and the recent files, and rebuilds them whenever either changes — so
this file holds the frame and the fixed buttons, and nothing here carries a value.
-->
<interface>
<object class="AdwWindow" id="chooser_window">
<property name="title" translatable="yes">udpscope — choose a source</property>
<property name="default-width">420</property>
<property name="default-height">360</property>
<child>
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
<child type="start">
<object class="GtkButton" id="prefs_button">
<property name="label" translatable="yes">_Preferences…</property>
<property name="use-underline">1</property>
<property name="tooltip-text" translatable="yes">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</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">_Cancel</property>
<property name="use-underline">1</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="new_host_button">
<property name="label" translatable="yes">_New host…</property>
<property name="use-underline">1</property>
</object>
</child>
</object>
</child>
<property name="content">
<object class="AdwViewStack" id="page_stack">
<child>
<object class="AdwViewStackPage">
<property name="name">sources</property>
<property name="title" translatable="yes">Sources</property>
<property name="icon-name">network-transmit-receive-symbolic</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="child">
<object class="GtkListBox" id="sources_list">
<property name="activate-on-single-click">1</property>
</object>
</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwViewStackPage">
<property name="name">waveforms</property>
<property name="title" translatable="yes">Waveforms</property>
<property name="icon-name">document-open-recent-symbolic</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">1</property>
<property name="hscrollbar-policy">never</property>
<property name="child">
<object class="GtkListBox" id="waves_list">
<property name="activate-on-single-click">1</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="halign">end</property>
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<property name="margin-end">12</property>
<child>
<object class="GtkButton" id="waves_open">
<property name="label" translatable="yes">_Open…</property>
<property name="use-underline">1</property>
<property name="tooltip-text" translatable="yes">choose an exported .npy to view and analyse</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</property>
<child type="bottom">
<object class="AdwViewSwitcherBar" id="page_switcher">
<property name="reveal">1</property>
</object>
</child>
</object>
</child>
</object>
</interface>
+1
View File
@@ -13,6 +13,7 @@
<file preprocess="xml-stripblanks">preferences.ui</file>
<file preprocess="xml-stripblanks">help.ui</file>
<file preprocess="xml-stripblanks">source-form.ui</file>
<file preprocess="xml-stripblanks">source-chooser.ui</file>
<file preprocess="xml-stripblanks">main-window.ui</file>
<file preprocess="xml-stripblanks">cursors-panel.ui</file>
</gresource>