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

+6
View File
@@ -248,6 +248,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
- **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()`
on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that
leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window
would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar`
stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar`
draws them only as a titlebar.
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
@@ -4,6 +4,14 @@ How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state be
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
*function's order* rather than about the window's shape, which is why it is the one part of the
window skeleton that cannot live in `main-window.ui`. `gtk_paned_set_position()` clamps against what
its children ask for *now*: called before the sidebar's panels have been appended it is clamped
against an empty pane, and the divider lands wherever that left it. Moving the call into the `.ui`
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
taller figure.
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
thing that forces a rebuild. Cursors are parked outside the axis range instead of
@@ -43,6 +43,10 @@ needs a probe that proves it *loads* and that its contents match whatever drives
## Already done
- `src/ui/main-window.ui` -- the main window's **skeleton**: the window, its two panes, the plot column
with the status line under it, and the sidebar's scroller. The four subtrees that depend on the stream
(the events pane, the two plot areas, the sidebar's panels, the status popover) are still built by
code and put into the slots by id.
- `src/ui/source-form.ui` -- the form a source is added or edited with: an `AdwDialog` of
`AdwPreferencesGroup` rows, with its action in the header bar where the label says which mode it is in.
- `src/ui/help.ui` -- the help window's chrome: an `AdwWindow` with a header bar and an
@@ -62,6 +66,6 @@ needs a probe that proves it *loads* and that its contents match whatever drives
| where | what | why it is the right next step |
|---|---|---|
| `src/source_dialog.cpp` (the chooser's own window) | the chooser: `.ui` for the window, its two pages and its action row. Its **host form is done**; what is left is the list, the notebook and the buttons around it | the last hand-built UI outside `ui.cpp`, and the file is already open |
| `src/ui.cpp` (5774 lines) | the main window's **skeleton** only -- the `AdwApplicationWindow`, the header bar, the plot area's placeholder, the panes' containers, the status bar -- with the custom widgets, popovers and dialogs left in code | one file, one window: converting the skeleton is reviewable, converting the whole file is not. Do it in slices (header bar, then the panes, then the trigger panel, then the left panes) with the probe suite after each |
| `src/ui.cpp` (the panels inside the skeleton) | the trigger panel, the signal panel, the cursors/measurements/math panels, the layout editor's overlay, the menus and the popovers: `.ui` for each, in that order, with the probe that reads it run after each | each is self-contained inside the skeleton that is now in place, and each has a probe of its own |
Nothing in this list should be done in one pass. Each row is its own change, verified on its own.
+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
+122
View File
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The main window's skeleton: the window, the two panes that make its three columns, the plot column
with the status line under it, and the sidebar's scroller.
What is *not* here is as deliberate as what is. Everything this file nests is fixed — the shape of
the window does not depend on the stream, the trigger or the layout — while the four subtrees that
do are built by code and put in place:
- the events pane, into the inner paned's start side;
- the two plot areas, into the split inside `plot_box`;
- the sidebar's panels, appended to `side_column`;
- the status popover, attached to `status_info`.
A `GtkApplicationWindow` and not an `AdwApplicationWindow`, deliberately: the header bar is the
window's own titlebar and Adw's window refuses one outright (`gtk_window_set_titlebar() is not
supported for AdwApplicationWindow`, which is a fatal `Adwaita-ERROR`, not a warning). An Adw window
would want its header as an `AdwToolbarView`'s top bar instead, and this one is a `GtkHeaderBar`
packed by hand — a change for its own sake, and one that would take the window controls away from it
(a GtkHeaderBar draws those only as a titlebar).
The header bar is *not* in this file: it is the window's own titlebar,
built by `build_toolbar()` because every button on it is wired to an action. The status line is not a
bottom bar either — it belongs to the plot column, not to the window, so it stays inside `left_column`
where the paned divider can move past it.
The `GtkPaned`s are here rather than in code because their *rules* are the skeleton: the events pane
is shrinkable and does not resize (so a closed pane costs the plot nothing), and the sidebar is
neither. `gtk_paned_set_position()` is not, because it has to run after the children exist.
-->
<interface>
<object class="GtkApplicationWindow" id="main_window">
<property name="title">udpscope</property>
<property name="icon-name">org.udpscope.app</property>
<property name="default-width">1280</property>
<property name="default-height">800</property>
<child>
<object class="GtkPaned" id="paned">
<property name="orientation">horizontal</property>
<property name="resize-end-child">0</property>
<property name="shrink-end-child">0</property>
<child type="start">
<object class="GtkPaned" id="events_divider">
<property name="orientation">horizontal</property>
<!-- Shrinkable, or the pane's own minimum is reserved as an empty column even while it is
closed and the plot pays for a pane nobody opened. -->
<property name="shrink-start-child">1</property>
<property name="resize-start-child">0</property>
<child type="end">
<object class="GtkBox" id="left_column">
<property name="orientation">vertical</property>
<property name="hexpand">1</property>
<child>
<object class="GtkBox" id="plot_box">
<property name="orientation">vertical</property>
<property name="homogeneous">1</property>
<property name="spacing">4</property>
<property name="hexpand">1</property>
<property name="vexpand">1</property>
<child>
<object class="GtkPaned" id="plot_split">
<property name="orientation">horizontal</property>
<property name="shrink-end-child">1</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="status_row">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="margin-top">2</property>
<property name="margin-bottom">2</property>
<property name="margin-start">6</property>
<child>
<object class="GtkLabel" id="status_badge"/>
</child>
<child>
<!-- The line is rewritten every frame and its counters grow, so it is
ellipsized: a label's minimum width is its text, and an unellipsized one
would push the paned divider around as the numbers change. -->
<object class="GtkLabel" id="status_label">
<property name="label">starting…</property>
<property name="ellipsize">end</property>
<property name="hexpand">1</property>
<property name="halign">fill</property>
</object>
</child>
<child>
<object class="GtkMenuButton" id="status_info">
<property name="icon-name">help-about-symbolic</property>
<property name="tooltip-text" translatable="yes">Stream and buffer detail</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
<child type="end">
<!-- 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. -->
<object class="GtkScrolledWindow" id="side_scroll">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="width-request">320</property>
<child>
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>
+1
View File
@@ -13,5 +13,6 @@
<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">main-window.ui</file>
</gresource>
</gresources>
+3
View File
@@ -399,6 +399,9 @@ struct App {
plot::Figure* area_b_figure = nullptr;
std::unique_ptr<RowSet> rows_b;
GtkWidget* plot_split = nullptr;
/// The main window's builder. Held because the window belongs to its object list: it is replaced on
/// the next build rather than reused, and everything found by id comes out of it.
GtkBuilder* window_ui = nullptr;
bool comparing = false;
/// The last x/y range seen on each side of a comparison, one entry per row: a frame compares them
/// with the plots' own to tell *which* side the user moved, and copies from that side to the other.
+1
View File
@@ -29,5 +29,6 @@
- the preferences are **done** (`src/ui/preferences.ui`: `AdwPreferencesDialog` with `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`/`AdwComboRow`, applying as they change, no Apply button)
- the help window's chrome is **done** (`src/ui/help.ui`: `AdwWindow` + header bar + `AdwNavigationSplitView`)
- the host form is **done** (`src/ui/source-form.ui`); the source chooser's own window is next
- the main window's skeleton is **done** (`src/ui/main-window.ui`: the window, the two panes, the plot column and the status line, the sidebar's scroller); the panels inside it are the next slices
- next: the preferences (`AdwPreferencesDialog` + `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`), the help window's chrome, the source chooser and its host form, then the main window's skeleton
- plan, conventions and acceptance for each slice: `docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md`