GTK4 and libadwaita. It receives one UDP stream through the vendored udpsclient, buffers it in a large ring, and draws it with the vendored plotter: a trigger with a pre/post event window, a pause that freezes the window, cursors, measurements, math channels in a node editor, an event cache, named configurations, preferences, and an export of the visible window to .npy/.csv. Waveform files can be opened and analysed in place. The interface is two threads -- a producer that owns the socket and the GTK main thread that owns every widget and the GL context -- sharing one model under one mutex, with samples crossing through lock-free rings. deps/plotter is a submodule pinned to the commit this tree builds against; deps/udpsclient is vendored in place. docs/claude/ documents the rules that cost a defect to learn, and SPECS.md is the requirement document.
182 lines
8.6 KiB
C++
182 lines
8.6 KiB
C++
// 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 <gtk/gtk.h>
|
|
|
|
#include <string>
|
|
|
|
namespace udpscope {
|
|
namespace ui_impl {
|
|
|
|
/// 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() {
|
|
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";
|
|
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
|