// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari // // The small reusable pieces of GTK chrome: a menu row and the three rows built from it, a labelled\n// panel row, the one stylesheet rule the app needs, and the modal dialog frame every dialog here is\n// built in. They know nothing about this app beyond the widget types they return. #include "ui_app.hpp" #include #include namespace udpscope { namespace ui_impl { /// Loads one UI file and keeps its builder for the life of the window. /// /// A widget belongs to the builder that built it -- drop the builder and the widgets it made go with /// it -- so the panels' builders are kept in `App` beside the widgets they built. Unlike the dialogs /// (`prefs_open()`, `show_help()`), a panel is built once and lives as long as the window, so these are /// appended and never replaced. /// /// A resource that is not in the GResource aborts here rather than returning null: `GtkBuilder` reports /// a missing resource with a `Gtk-ERROR`, which is fatal. `make test-ui` checks that the xml lists every /// `.ui` file, which is the only way to make that a build-time failure instead. GtkBuilder* ui_load(App& app, const char* resource) { GtkBuilder* builder = gtk_builder_new_from_resource(resource); app.ui_builders.push_back(builder); return builder; } /// Installs the one style rule the app needs: a signal row that is as short as its /// contents. /// /// Adwaita gives a button a 34 px minimum height and a list row its own padding, and /// those -- not the widgets in it -- are what set a signal row's height, so no amount /// of trimming margins in C makes the list shorter. A handful of signals then filled /// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other /// button in the window. void install_css() { // Idempotent, because it is no longer installed from one place: the source chooser runs before the // application (it draws a menu built from the same `menu_button()` rows) and has to have the // stylesheet before it shows them, while `on_activate()` still installs it for every run. A second // provider would be a second copy of the same rules on the display. static bool installed = false; if (installed) return; installed = true; static const char kCss[] = "row.udpscope-compact { min-height: 0; padding: 1px 0; }\n" // A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding, // which is what made the overflow read as a stack of dialogs rather than a menu. The // shortcut label is a plain .dim-label at the row's right, so all that is left to trim // is the row itself. "popover.udpscope-menu button { min-height: 0; padding: 3px 8px; }\n" "popover.udpscope-menu button > box { min-height: 0; }\n" "popover.udpscope-menu button label { min-height: 0; }\n" "popover.udpscope-menu image { min-width: 12px; min-height: 0; }\n" "popover.udpscope-menu separator { margin: 4px 0; }\n" // The events pane: the row's time is what a person scans for, so it is the one thing in the // column that is not dim; the thumbnail gets an edge, or a dark plot on a dark pane has none. ".udpscope-event-title { font-weight: bold; }\n" ".udpscope-event-thumb { border: 1px solid alpha(currentColor, 0.15); border-radius: 4px; }\n" "row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n" "row.udpscope-compact checkbutton { min-height: 20px; }\n" // The guided tour. The dim is a half-transparent black rather than a theme colour: its whole // job is to darken whatever is underneath, on a light theme and a dark one alike. The card // needs its own background, or a box in a popover-less overlay is transparent and the text // under it shows through. ".udpscope-tour-dim { background-color: rgba(0, 0, 0, 0.45); }\n" ".udpscope-tour-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n" ".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;" " border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n" // The contextual-help "(i)": a *mark*, not a button. Adwaita's menu button is a 34x34 control // (a 24 px floor plus 5 px of padding) with a 16 px glyph, which beside a heading reads as a // second toolbar button. Measured on libadwaita 1.10: the outer `menubutton` node *and* its // `button` subnode both carry a floor and padding, and a class selector on either alone leaves // the other at 34 (`min-width` on the widget itself is ignored by `GtkMenuButton`'s measure, and // so is a size request -- tried both). Beyond those two there is a third node, and getting its // path wrong is silent: the button's own tree is `menubutton > button > box > image` (read off // the CSS node dump), so the direct-grandchild form `> button > image` matches *nothing* and // leaves the glyph at its natural 16 px -- which is the 16x16 the first version shipped and // mistook for a floor. The glyph is reached through the `box`; trimming the outer node, the // `button` subnode and the glyph together, and asking the glyph for 12 px with // `-gtk-icon-size` (which `GtkImage` honours when it is `GTK_ICON_SIZE_INHERIT`, and which // *scales* the drawing rather than merely clamping its box), is what brings the mark to // **12x12**. There is no lower floor to find: the symbolic glyph renders at any request // (measured down to 4 px), so 12 px is a legibility choice, and it is the size the rule always // meant -- the first version already wrote 12 px, on a selector that never matched. That is // the reading `hint=WxH` in the dump prints. "menubutton.udpscope-hint { min-width: 0; min-height: 0; padding: 0; margin: 0; }\n" "menubutton.udpscope-hint > button { min-width: 0; min-height: 0; padding: 0; }\n" "menubutton.udpscope-hint > button > box > image { min-width: 12px; min-height: 12px;" " -gtk-icon-size: 12px; }\n"; GtkCssProvider* p = gtk_css_provider_new(); gtk_css_provider_load_from_string(p, kCss); gtk_style_context_add_provider_for_display(gdk_display_get_default(), GTK_STYLE_PROVIDER(p), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); g_object_unref(p); } /// Keeps a menu row's check in step with its toggle. The check keeps its allocation when it is /// off (opacity, not visibility), so a checked and an unchecked row's labels line up. void on_menu_row_toggled(GtkToggleButton* button, gpointer) { GtkWidget* check = GTK_WIDGET(g_object_get_data(G_OBJECT(button), "udpscope-check")); if (check != nullptr) { gtk_widget_set_opacity(check, gtk_toggle_button_get_active(button) ? 1.0 : 0.0); } } /// One row of a popover menu, laid out the way a GNOME menu lays one out: the label at the /// left, the shortcut at the right when the action has one, and -- for a row that toggles /// something -- a check in its own column. Returned as a box for the caller to hand to /// `gtk_button_set_child()`; `.udpscope-menu` in install_css() is what trims the height. GtkWidget* menu_row(const char* label, const char* accel, bool with_check, GtkWidget** out_check) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); if (with_check) { GtkWidget* check = gtk_image_new_from_icon_name("object-select-symbolic"); gtk_widget_set_opacity(check, 0.0); gtk_box_append(GTK_BOX(box), check); if (out_check != nullptr) *out_check = check; } GtkWidget* text = gtk_label_new(label); gtk_label_set_xalign(GTK_LABEL(text), 0.0f); gtk_widget_set_hexpand(text, true); gtk_box_append(GTK_BOX(box), text); if (accel != nullptr) { GtkWidget* key = gtk_label_new(accel); gtk_label_set_xalign(GTK_LABEL(key), 1.0f); gtk_widget_add_css_class(key, "dim-label"); gtk_box_append(GTK_BOX(box), key); } return box; } /// A plain menu row. `accel` is what the app binds the action to (see shortcuts.cpp) -- it is /// shown, never bound here: the key handling stays in the one controller that refuses a key /// while a text field has focus. GtkWidget* menu_button(const char* label, const char* accel) { GtkWidget* button = gtk_button_new(); // The check column is reserved even though this row cannot be checked: a menu whose labels // start at two different x -- one for the rows that can be checked and one for the rest -- // is what "the text is not left-aligned" looks like. gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, nullptr)); gtk_widget_add_css_class(button, "flat"); gtk_widget_set_hexpand(button, true); gtk_widget_set_halign(button, GTK_ALIGN_FILL); return button; } /// A menu row that toggles something, with the check menu_row() reserves the column for. The /// initial state is set *before* the handler is connected: a programmatic `set_active()` emits /// `toggled`, the handler acts on widgets the caller has not built yet (the signal list's /// revealer is created later in run_ui()), and a row that is created on must not reach it. GtkWidget* menu_toggle(const char* label, const char* accel, bool active) { GtkWidget* button = gtk_toggle_button_new(); GtkWidget* check = nullptr; gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, &check)); gtk_widget_add_css_class(button, "flat"); gtk_widget_set_hexpand(button, true); gtk_widget_set_halign(button, GTK_ALIGN_FILL); g_object_set_data(G_OBJECT(button), "udpscope-check", check); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active); on_menu_row_toggled(GTK_TOGGLE_BUTTON(button), nullptr); g_signal_connect(button, "toggled", G_CALLBACK(on_menu_row_toggled), nullptr); return button; } GtkWidget* menu_separator() { return gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); } /// A labelled row whose control keeps its *natural* width. A `GtkSwitch`'s natural width is its size, /// so `labeled_box()`'s expanding child stretches it into a wide pill that reads as a slider -- this is /// the row a switch wants, with the label taking the slack instead. GtkWidget* labeled_switch_row(const char* label, GtkWidget* control) { GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* text = gtk_label_new(label); gtk_widget_set_halign(text, GTK_ALIGN_START); gtk_widget_set_hexpand(text, TRUE); gtk_box_append(GTK_BOX(row), text); gtk_widget_set_halign(control, GTK_ALIGN_END); gtk_widget_set_valign(control, GTK_ALIGN_CENTER); gtk_box_append(GTK_BOX(row), control); return row; } GtkWidget* labeled_box(const char* label, GtkWidget* child) { GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* l = gtk_label_new(label); gtk_widget_set_halign(l, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), l); gtk_widget_set_hexpand(child, true); gtk_box_append(GTK_BOX(box), child); return box; } /// A modal, transient window with a vertical content box; the caller appends its /// controls and then a button row. GTK 4.10 deprecated `GtkDialog` and /// `GtkMessageDialog`, so the dialogs here are built from a plain window. GtkWidget* dialog_window(GtkWidget* parent, const char* title) { GtkWidget* w = gtk_window_new(); gtk_window_set_title(GTK_WINDOW(w), title); if (parent != nullptr) gtk_window_set_transient_for(GTK_WINDOW(w), GTK_WINDOW(parent)); gtk_window_set_modal(GTK_WINDOW(w), TRUE); gtk_window_set_default_size(GTK_WINDOW(w), 340, -1); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); gtk_widget_set_margin_top(box, 12); gtk_widget_set_margin_bottom(box, 12); gtk_widget_set_margin_start(box, 12); gtk_widget_set_margin_end(box, 12); gtk_window_set_child(GTK_WINDOW(w), box); return w; } /// Appends `button` to a right-aligned row at the end of the dialog's box. void dialog_add_button(GtkWidget* window, GtkWidget* button) { GtkWidget* box = gtk_window_get_child(GTK_WINDOW(window)); GtkWidget* last = gtk_widget_get_last_child(box); GtkWidget* row = nullptr; if (last != nullptr && g_object_get_data(G_OBJECT(last), "udpscope-dialog-buttons") != nullptr) { row = last; } else { row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_widget_set_halign(row, GTK_ALIGN_END); g_object_set_data(G_OBJECT(row), "udpscope-dialog-buttons", GINT_TO_POINTER(1)); gtk_box_append(GTK_BOX(box), row); } gtk_box_append(GTK_BOX(row), button); } } // namespace ui_impl } // namespace udpscope