feat: the main window's skeleton is a UI file

src/ui/main-window.ui holds the window, its two panes, the plot column with the status line under it,
and the sidebar's scroller. What stays in code is what depends on the stream: the events pane, the two
plot areas, the sidebar's panels and the status popover, each put into a slot by id.

The header bar is deliberately not in the file. It is the window's own titlebar, every button on it is
wired to an action, and an AdwApplicationWindow refuses a titlebar outright -- a fatal Adwaita-ERROR
rather than a warning that leaves the window merely ugly -- so the window is a GtkApplicationWindow and
the header bar stays where it was.

gtk_paned_set_position() is the other thing that had to stay in code, and the reason is order rather
than shape: it clamps against its children *now*, so stating it before the sidebar's panels exist moved
the plot column by ~180 px.

The layout, events and box-zoom probes read what they read before this change, and the header reads
window_button_w=130 bar_w=756 as it always has -- checked by building the previous commit and running
the same probe against it, because the layout numbers in the notes turned out to be stale from an
earlier session. Five UI files now.
This commit is contained in:
Martino Ferrari committed 2026-10-11 17:39:04 +02:00
1 parent 68743a7d7f
commit 8073e7b3fd
8 files changed
+196 -70

No files matched your search

+50 -69
View File
@@ -4368,7 +4368,32 @@ void on_activate(GtkApplication* application, gpointer user) {
App* app = static_cast<App*>(user);
install_css();
app->window = gtk_application_window_new(application);
// The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status
// line under it, and the sidebar's scroller. Everything that depends on the stream -- the events
// pane, the two plot areas, the sidebar's panels -- is built below and put into the slots by id.
g_clear_object(&app->window_ui);
app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui");
if (app->window_ui == nullptr) return;
app->window = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "main_window"));
GtkWidget* paned = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "paned"));
GtkWidget* events_divider = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "events_divider"));
app->plot_box = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_box"));
app->plot_split = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_split"));
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
app->side_scroll = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_scroll"));
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would
// be a null pointer.
if (app->window == nullptr || paned == nullptr || events_divider == nullptr ||
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
app->status_label == nullptr || app->status_info_button == nullptr ||
app->side_scroll == nullptr || side == nullptr) {
return;
}
// The window's actions live on the application, so it has to know which one it belongs to.
gtk_window_set_application(GTK_WINDOW(app->window), application);
// A window closed by hand (a WM close button) must stop the frame tick: it holds
// pointers to widgets that are finalized with the window.
g_signal_connect(app->window, "destroy", G_CALLBACK(+[](GtkWidget*, gpointer user) {
@@ -4383,45 +4408,25 @@ void on_activate(GtkApplication* application, gpointer user) {
a->window = nullptr;
}),
app);
gtk_window_set_title(GTK_WINDOW(app->window), "udpscope");
// The shell takes a window's icon from this name, and GTK takes the WM class from the
// application ID -- which is the same string, which is what associates this window with the
// desktop entry.
gtk_window_set_icon_name(GTK_WINDOW(app->window), "org.udpscope.app");
gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800);
// The header bar is the window's own titlebar, and it is *not* in the `.ui`: every button on it is
// wired to an action the application owns, which is code by nature. Its title and the icon the shell
// shows are the window's, and those are in the file.
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
GtkWidget* paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
app->plot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_box_set_homogeneous(GTK_BOX(app->plot_box), true);
gtk_widget_set_hexpand(app->plot_box, true);
gtk_widget_set_vexpand(app->plot_box, true);
GtkWidget* left = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_box_append(GTK_BOX(left), app->plot_box);
gtk_widget_set_vexpand(app->plot_box, true);
// The badge and the text share one row so the badge sits at the line's head. It is a
// sibling rather than part of the label's own markup because the label's text is
// rewritten every frame and its markup is ellipsized -- a blinking span inside it would
// be reparsed sixty times a second and could be the character that gets elided.
GtkWidget* status_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app->status_badge = gtk_label_new(nullptr);
// The badge's markup is the one value the `.ui` cannot carry (it is a phase, not a string), and its
// tooltip says which phase the badge is *not* reporting.
gtk_label_set_markup(GTK_LABEL(app->status_badge), badge_markup(BadgePhase::Idle));
gtk_widget_set_tooltip_text(app->status_badge, "waiting for the stream");
gtk_box_append(GTK_BOX(status_row), app->status_badge);
app->status_label = gtk_label_new("starting...");
// The line is rewritten every frame and its text changes width as the counters
// grow. A label's *minimum* width is its text, so an unellipsized one pushes the
// paned divider around as the numbers change -- which resizes the plot, which
// re-lays out the axes and their ticks. Ellipsizing gives it a minimum of "…" and
// makes it fill whatever width it is given instead.
gtk_label_set_ellipsize(GTK_LABEL(app->status_label), PANGO_ELLIPSIZE_END);
gtk_widget_set_hexpand(app->status_label, true);
gtk_widget_set_halign(app->status_label, GTK_ALIGN_FILL);
gtk_box_append(GTK_BOX(status_row), app->status_label);
// Guard the construction so the counter means something: if status_popover is already
// non-null (e.g. run_ui called twice), count the extra build and avoid a leak.
@@ -4488,9 +4493,6 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_box_append(GTK_BOX(popover_box), app->capture_button);
gtk_popover_set_child(GTK_POPOVER(app->status_popover), popover_box);
app->status_info_button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app->status_info_button),
"help-about-symbolic");
// Above the button, always. The status row is the last thing in the window, so "below"
// is off the window's bottom edge and, on a window that reaches the bottom of the screen,
// off the screen: the bubble's top edge and its arrow stayed at the button while the body
@@ -4502,21 +4504,14 @@ void on_activate(GtkApplication* application, gpointer user) {
// where a menu button in a header bar wants it), and it is the *last* word that counts.
gtk_popover_set_position(GTK_POPOVER(app->status_popover), GTK_POS_TOP);
gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE);
gtk_widget_set_tooltip_text(app->status_info_button, "Stream and buffer detail");
gtk_widget_set_has_tooltip(app->status_info_button, TRUE);
gtk_box_append(GTK_BOX(status_row), app->status_info_button);
}
gtk_widget_set_margin_top(status_row, 2);
gtk_widget_set_margin_bottom(status_row, 2);
gtk_widget_set_margin_start(status_row, 6);
gtk_box_append(GTK_BOX(left), status_row);
// The right-hand side: two independently collapsible panels, each revealed by
// its toolbar toggle. No outer scrolled window -- the signal list scrolls
// inside its own panel -- so neither panel can be squeezed to nothing by the
// other's size request.
GtkWidget* side = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
// `side` is the sidebar column from the `.ui`, resolved above with the rest of the skeleton; the
// app keeps it because the panel geometry, the divide and the revealers all ask about it.
app->side_box = side;
// The two panels share a vertical box, so they collapse *vertically*: a
@@ -4562,47 +4557,33 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_revealer_set_child(GTK_REVEALER(app->signal_revealer), app->signal_panel);
gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer), TRUE);
gtk_box_append(GTK_BOX(side), app->signal_revealer);
gtk_widget_set_size_request(side, 300, -1);
// The column scrolls as a whole, so a long signal list or a revealed trigger
// panel can never push either panel out of the window.
app->side_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(app->side_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(app->side_scroll), side);
gtk_widget_set_size_request(app->side_scroll, 320, -1);
// Its size request and the scroller around it are the `.ui`'s: the column scrolls as a whole, so a
// long signal list or a revealed trigger panel can never push either panel out of the window.
// The left side is the events pane *and* the plot, in one row: the pane is a column of its own
// (the item's "collapsable pane on the left"), revealed by trigger mode.
// A *paned*: the pane's width is the user's to choose, and the plot takes what is left.
GtkWidget* left_row = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_paned_set_start_child(GTK_PANED(left_row), build_events_pane(*app));
// Once, now that both the button and the pane exist: trigger mode is off, so the pane is closed
// and its button is not on the bar -- stated by the one function that decides it rather than by
// whichever widget happened to be built with which default.
// The events pane is the one subtree of the inner divider that is built: whether it exists at all
// depends on trigger mode.
gtk_paned_set_start_child(GTK_PANED(events_divider), build_events_pane(*app));
// Once, now that both the button and the pane exist: trigger mode is off, so the pane is closed and
// its button is not on the bar -- stated by the one function that decides it rather than by whichever
// widget happened to be built with which default.
sync_events_availability(*app);
gtk_widget_set_hexpand(left, TRUE);
gtk_paned_set_end_child(GTK_PANED(left_row), left);
// Shrinkable, or the pane's own minimum would be reserved as an empty column even while it is
// closed, and the plot would pay for a pane nobody opened.
gtk_paned_set_shrink_start_child(GTK_PANED(left_row), TRUE);
gtk_paned_set_resize_start_child(GTK_PANED(left_row), FALSE);
app->events_divider = left_row;
gtk_paned_set_start_child(GTK_PANED(paned), left_row);
gtk_paned_set_end_child(GTK_PANED(paned), app->side_scroll);
gtk_paned_set_resize_end_child(GTK_PANED(paned), FALSE);
gtk_paned_set_shrink_end_child(GTK_PANED(paned), FALSE);
gtk_window_set_child(GTK_WINDOW(app->window), paned);
// After the children exist, or the position is clamped against an empty pane.
gtk_paned_set_position(GTK_PANED(paned), 940);
app->events_divider = events_divider;
// Two plots, in a paned so a comparison's divider can move: the primary takes everything while the
// second is hidden, and the width is the user's once it is not.
app->plot_split = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
// After the children exist -- the sidebar's panels are appended above -- or the position is clamped
// against an empty pane and the divider lands wherever the empty width happened to be. This is the
// one thing about a `GtkPaned` that cannot live in the `.ui`: it has to be stated after both sides
// have been filled, which is a fact about this function's order, not about the window's shape.
gtk_paned_set_position(GTK_PANED(paned), 940);
// The split is in the `.ui`; what goes in it is not, because an area is a GL surface with a figure
// handed to it.
gtk_paned_set_start_child(GTK_PANED(app->plot_split), make_area(*app));
gtk_paned_set_end_child(GTK_PANED(app->plot_split), make_compare_area(*app));
gtk_paned_set_shrink_end_child(GTK_PANED(app->plot_split), TRUE);
gtk_box_append(GTK_BOX(app->plot_box), app->plot_split);
// A waveform opened from a file is already in the model by the time the UI is built -- there is no
// drop and no CONFIG to wait for -- so its channels are bound to the rows here: four traces to a