From 8073e7b3fd895766b28bf893d784908588e19088 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 11 Oct 2026 17:39:04 +0200 Subject: [PATCH] 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. --- docs/claude/invariants-gtk-and-chrome.md | 6 + docs/claude/invariants-plot-and-layout.md | 8 ++ .../2026-10-11-ui-files-and-gtk4-practice.md | 6 +- src/ui.cpp | 119 +++++++---------- src/ui/main-window.ui | 122 ++++++++++++++++++ src/ui/udpscope-ui.gresource.xml | 1 + src/ui_app.hpp | 3 + todo.md | 1 + 8 files changed, 196 insertions(+), 70 deletions(-) create mode 100644 src/ui/main-window.ui diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 215e981..92f528d 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -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" -- diff --git a/docs/claude/invariants-plot-and-layout.md b/docs/claude/invariants-plot-and-layout.md index cd4cd38..39f4b22 100644 --- a/docs/claude/invariants-plot-and-layout.md +++ b/docs/claude/invariants-plot-and-layout.md @@ -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 diff --git a/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md b/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md index 61d26df..71cf150 100644 --- a/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md +++ b/docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md @@ -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. diff --git a/src/ui.cpp b/src/ui.cpp index 0a5b516..a8c0f11 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4368,7 +4368,32 @@ void on_activate(GtkApplication* application, gpointer user) { App* app = static_cast(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 diff --git a/src/ui/main-window.ui b/src/ui/main-window.ui new file mode 100644 index 0000000..31dfd64 --- /dev/null +++ b/src/ui/main-window.ui @@ -0,0 +1,122 @@ + + + + + + + udpscope + org.udpscope.app + 1280 + 800 + + + horizontal + 0 + 0 + + + horizontal + + 1 + 0 + + + vertical + 1 + + + vertical + 1 + 4 + 1 + 1 + + + horizontal + 1 + + + + + + + horizontal + 6 + 2 + 2 + 6 + + + + + + + starting… + end + 1 + fill + + + + + help-about-symbolic + Stream and buffer detail + center + + + + + + + + + + + + never + automatic + 320 + + + vertical + 300 + + + + + + + + diff --git a/src/ui/udpscope-ui.gresource.xml b/src/ui/udpscope-ui.gresource.xml index ab2a4a0..756acea 100644 --- a/src/ui/udpscope-ui.gresource.xml +++ b/src/ui/udpscope-ui.gresource.xml @@ -13,5 +13,6 @@ preferences.ui help.ui source-form.ui + main-window.ui diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 02726ca..0137a55 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -399,6 +399,9 @@ struct App { plot::Figure* area_b_figure = nullptr; std::unique_ptr 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. diff --git a/todo.md b/todo.md index 704c588..ce072b2 100644 --- a/todo.md +++ b/todo.md @@ -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`