feat: the preferences are a UI file, applying as they change

src/ui/preferences.ui is an AdwPreferencesDialog whose rows are libadwaita's own -- AdwEntryRow,
AdwSpinRow, AdwSwitchRow, AdwComboRow, AdwButtonRow -- instead of a label and a bare GtkEntry in a
GtkBox. There is no Apply button: a row applies as it changes, which is what those widgets are
designed around, and [prefs-probe] reads the settings file back after every change.

The folder chooser is cancellable now, so a picker still open when the dialog closes cannot write
into a row that is gone; the cancellable is also how the probe takes the cancel path without a
mouse.

Two traps cost a run each and are recorded in docs/claude/invariants-gtk-and-chrome.md: an
AdwDialog is not a GtkWindow (it cannot parent a GtkFileDialog, and in floating mode it adds a
native surface to gtk_window_get_toplevels() that is neither a widget nor a window), and a .ui
that is not listed in the GResource is a Gtk-ERROR at runtime rather than a build failure -- which
is exactly what the probe opening every window is for.
This commit is contained in:
Martino Ferrari committed 2026-10-11 15:30:10 +02:00
1 parent 0e256764d5
commit 4c56172005
11 files changed
+403 -247

No files matched your search

+7 -10
View File
@@ -123,13 +123,10 @@ void chooser_finish(Chooser& c, bool chosen) {
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
gtk_window_destroy(GTK_WINDOW(w));
}
// The preferences window is transient for the chooser, so it has to go first: a window destroyed
// with a transient still up is the lifetime this project has already got wrong once.
if (c.prefs.window != nullptr) {
GtkWidget* w = c.prefs.window;
c.prefs.window = nullptr;
gtk_window_destroy(GTK_WINDOW(w));
}
// The preferences go first: they are presented *on* this window, and a dialog still up when its
// window is destroyed is the lifetime this project has got wrong once already. `prefs_close()` is
// `adw_dialog_close()`, and the form's own `closed` handler clears it.
ui_impl::prefs_close(c.prefs);
if (c.window != nullptr) {
GtkWidget* w = c.window;
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
@@ -628,9 +625,9 @@ gboolean pick_probe(gpointer user) {
// open: the pick below destroys the chooser with it still up, which is the lifetime at risk.
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_PREFS") != nullptr) {
open_chooser_preferences(c);
std::fprintf(stderr, "[sources] prefs: opened=%d apply=%d\n", c.prefs.window != nullptr ? 1 : 0,
c.prefs.apply != nullptr ? 1 : 0);
ui_impl::prefs_probe_fill_and_apply(c.prefs);
std::fprintf(stderr, "[sources] prefs: opened=%d rows=%d\n", c.prefs.dialog != nullptr ? 1 : 0,
c.prefs.dir_row != nullptr ? 1 : 0);
ui_impl::prefs_probe_change(c.prefs);
const Settings back = load_settings();
std::fprintf(stderr,
"[sources] prefs: dir=%s keep=%d rel=%d theme=%s cfg_relative=%d\n",
+95
View File
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The preferences, as an AdwPreferencesDialog: pages of groups of rows, which is what every GNOME
application shows and what libadwaita's own widgets are for. The rows are AdwEntryRow, AdwSpinRow,
AdwSwitchRow, AdwComboRow and AdwButtonRow rather than a label and a bare GtkEntry in a GtkBox, so
they behave the way a reader expects — each one is a whole row, with its own title, an optional
subtitle underneath, and the editing widget on the right where a finger expects it.
**There is no Apply button, deliberately.** A preferences row applies as it changes, which is the
practice these widgets are designed around and why they carry an `apply` of their own (AdwEntryRow's
show-apply-button is off here: the write is a few bytes to one file, so a keystroke is not worth
batching). The two settings that cannot take effect immediately — the cache directory and relative
time, both of which are planned before any source is known — say so in their subtitles instead of
being held back by a button.
Nothing here carries a value: `ui_prefs.cpp` fills the rows from `Settings` when the dialog opens,
and writes it back on every change. This file is the shape, that is the state.
-->
<interface>
<object class="AdwPreferencesDialog" id="preferences">
<property name="title" translatable="yes">Preferences</property>
<property name="search-enabled">0</property>
<child>
<object class="AdwPreferencesPage">
<property name="title" translatable="yes">Event cache</property>
<property name="icon-name">udpscope-events-symbolic</property>
<property name="description" translatable="yes">Latches are written here as they happen; whether anything is written at all is the trigger panel's own switch.</property>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwEntryRow" id="dir_row">
<property name="title" translatable="yes">_Directory</property>
<property name="tooltip-text" translatable="yes">One directory in which every source gets a folder of its own, never one directory between two sources</property>
</object>
</child>
<child>
<object class="AdwSpinRow" id="keep_row">
<property name="title" translatable="yes">Keep _events</property>
<property name="subtitle" translatable="yes">Per source; 0 keeps every event, which is a decision about the disk</property>
<property name="adjustment">
<object class="GtkAdjustment">
<property name="lower">0</property>
<property name="upper">100000</property>
<property name="step-increment">1</property>
<property name="value">32</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesPage">
<property name="title" translatable="yes">Plot</property>
<property name="icon-name">udpscope-measure-symbolic</property>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwSwitchRow" id="relative_row">
<property name="title" translatable="yes">_Relative time</property>
<property name="subtitle" translatable="yes">Take the plot's time from the sample rate and the packet counter rather than from the stream's own clock, so arrival and clock jitter cannot move a sample; absolute time is still kept for export. Read at start-up, because the buffers are planned before any source is known.</property>
</object>
</child>
<child>
<object class="AdwComboRow" id="theme_row">
<property name="title" translatable="yes">_Theme</property>
<property name="subtitle" translatable="yes">What the interface looks like; auto follows the system</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesPage">
<property name="title" translatable="yes">Reset</property>
<property name="icon-name">view-refresh-symbolic</property>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwButtonRow" id="reset_row">
<property name="title" translatable="yes">_Reset to defaults</property>
<property name="start-icon-name">view-refresh-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>
+1
View File
@@ -10,5 +10,6 @@
<gresources>
<gresource prefix="/org/udpscope/ui">
<file preprocess="xml-stripblanks">shortcuts.ui</file>
<file preprocess="xml-stripblanks">preferences.ui</file>
</gresource>
</gresources>
+162 -171
View File
@@ -1,23 +1,14 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The preferences form: the values `settings.hpp` holds, in one place, with the settings file behind
// them. It is a *view* of that struct -- it reads one, edits it and hands it back -- so nothing here
// decides what a preference means; `PrefsForm::on_applied` is where that happens, and it happens
// differently in the app and in the source chooser.
//
// Two rows can only take effect on the next start in the app's case (the cache directory and relative
// time: the buffers are planned and the cache is opened before any stream is known), and the dialog
// says so rather than pretending otherwise. From the chooser, both are honoured by the run the
// chooser is about to start, because that run has not been planned yet.
#include "ui_prefs.hpp"
#include "event_store.hpp" // `default_events_dir()`, so the field shows the path the cache uses
#include "ui_app.hpp" // the app's own half of the contract, at the bottom of this file
#include "event_store.hpp" // `default_events_dir()`, so the row shows the path the cache uses
#include "ui_app.hpp" // the application's half of the contract, at the bottom of this file
#include <adwaita.h>
#include <cstdio>
#include <utility>
namespace udpscope {
@@ -32,26 +23,73 @@ AdwColorScheme theme_scheme(const std::string& name) {
return ADW_COLOR_SCHEME_DEFAULT;
}
/// Reads the form into a `Settings`. It starts from the file rather than from an empty struct so that
/// a field this dialog does not show is carried through untouched instead of being reset by every
/// apply.
Settings read_form(const PrefsForm& f) {
const char* theme_name(guint selected) {
if (selected == 1) return "light";
if (selected == 2) return "dark";
return "auto";
}
/// What the rows hold. It starts from the file so that a field this dialog does not show is carried
/// through untouched rather than being reset by every keystroke.
Settings read_rows(const PrefsForm& f) {
Settings s = load_settings();
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_entry));
// The field shows the *effective* directory, so the default is visible in full rather than described.
// Typing it back unchanged is not a choice to pin it: the file keeps `unset`, and the default stays
// the default of whatever cache directory the next run happens to have.
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_row));
s.event_cache_max = static_cast<int>(adw_spin_row_get_value(f.keep_row));
s.relative_time = adw_switch_row_get_active(f.relative_row) != FALSE;
s.theme = theme_name(adw_combo_row_get_selected(f.theme_row));
// The row shows the *effective* directory, so the default is written out in full rather than
// described. Leaving it at that default is not a choice to pin it: the file keeps "unset", and the
// setting goes on following the cache directory it is named after.
if (s.event_cache_dir == default_events_dir()) s.event_cache_dir.clear();
s.event_cache_max = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(f.keep_spin));
s.relative_time = gtk_switch_get_active(GTK_SWITCH(f.relative_switch));
const guint selected = gtk_drop_down_get_selected(GTK_DROP_DOWN(f.theme_drop));
s.theme = selected == 1 ? "light" : selected == 2 ? "dark" : "auto";
return s;
}
/// Cancelling must change *nothing*: the field keeps what it had and the status line stays quiet. The
/// null check is not defensive padding -- `prefs_close()` clears every widget pointer, and a folder
/// chooser still open when its window closes is exactly how a callback lands on a dead field.
void write_rows(PrefsForm& f) {
const Settings s = read_rows(f);
const bool written = save_settings(s);
// The theme goes on even when the write failed: the user asked to see it, and the file is the memory
// of the choice rather than the choice itself.
apply_theme(s.theme);
// The caller decides what a failure looks like: the app has a status bar and the source chooser does
// not. A *success* is deliberately not reported -- it happens once per keystroke in the directory.
if (f.on_changed) f.on_changed(s, written);
}
/// Fills the rows from `s`, writing nothing back.
void fill_rows(PrefsForm& f, const Settings& s) {
f.filling = true;
gtk_editable_set_text(
GTK_EDITABLE(f.dir_row),
(s.event_cache_dir.empty() ? default_events_dir() : s.event_cache_dir).c_str());
adw_spin_row_set_value(f.keep_row, s.event_cache_max < 0 ? 0.0 : s.event_cache_max);
adw_switch_row_set_active(f.relative_row, s.relative_time ? TRUE : FALSE);
adw_combo_row_set_selected(f.theme_row, s.theme == "light" ? 1 : (s.theme == "dark" ? 2 : 0));
f.filling = false;
}
/// `AdwEntryRow` is a `GtkEditable`, so its change signal is that interface's.
void on_entry_changed(GtkEditable*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
if (!f.filling) write_rows(f);
}
/// One handler for the three rows that carry their value in a property: `adw_spin_row_set_value()`,
/// `adw_switch_row_set_active()` and `adw_combo_row_set_selected()` all say it with `notify`.
void on_row_notify(GObject*, GParamSpec*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
if (!f.filling) write_rows(f);
}
void on_reset(GtkWidget*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
fill_rows(f, default_settings());
// `fill_rows()` writes nothing on purpose, so the reset says so itself.
write_rows(f);
}
/// Cancelling must change *nothing*: the row keeps what it had, and nothing is written. The null check
/// is not defensive padding -- closing clears every pointer, and a folder chooser still open when the
/// dialog closes is exactly how a callback lands on a dead row.
void on_browse_done(GObject* source, GAsyncResult* result, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
GFile* folder = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source), result, nullptr);
@@ -59,7 +97,8 @@ void on_browse_done(GObject* source, GAsyncResult* result, gpointer user) {
char* path = g_file_get_path(folder);
g_object_unref(folder);
if (path == nullptr) return;
if (f.dir_entry != nullptr) gtk_editable_set_text(GTK_EDITABLE(f.dir_entry), path);
// Through the row, so the change applies like any other -- one value, one path to the file.
if (f.dir_row != nullptr) gtk_editable_set_text(GTK_EDITABLE(f.dir_row), path);
g_free(path);
}
@@ -68,175 +107,126 @@ void on_browse_done(GObject* source, GAsyncResult* result, gpointer user) {
/// of drawing a GTK-shaped impression of one.
void on_browse(GtkButton*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
// One chooser at a time, and the previous one is cancelled rather than abandoned: two open pickers
// would each write the row when they answered.
g_cancellable_cancel(f.browse_cancel);
g_clear_object(&f.browse_cancel);
f.browse_cancel = g_cancellable_new();
GtkFileDialog* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Event cache directory");
const char* current =
f.dir_entry != nullptr ? gtk_editable_get_text(GTK_EDITABLE(f.dir_entry)) : "";
f.dir_row != nullptr ? gtk_editable_get_text(GTK_EDITABLE(f.dir_row)) : "";
if (current != nullptr && current[0] != '\0') {
GFile* folder = g_file_new_for_path(current);
gtk_file_dialog_set_initial_folder(dialog, folder);
g_object_unref(folder);
}
gtk_file_dialog_select_folder(dialog, f.window != nullptr ? GTK_WINDOW(f.window) : nullptr, nullptr,
on_browse_done, &f);
gtk_file_dialog_select_folder(dialog, f.parent, f.browse_cancel, on_browse_done, &f);
g_object_unref(dialog); // the call holds its own reference
}
void on_defaults(GtkButton*, gpointer user) {
/// Everything goes: a row read after the dialog closed would be a dangling pointer, and the probes read
/// the rows while it is open and the file after it is not.
///
/// The *builder* is left for the next `prefs_open()` to drop. Freeing it here would finalise the dialog
/// while GTK is still unwinding the close that is emitting this signal, which is the use-after-free
/// this project has paid for once already.
void on_closed(AdwDialog*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
const Settings d = default_settings();
gtk_editable_set_text(GTK_EDITABLE(f.dir_entry),
(d.event_cache_dir.empty() ? default_events_dir() : d.event_cache_dir).c_str());
gtk_spin_button_set_value(GTK_SPIN_BUTTON(f.keep_spin), d.event_cache_max);
gtk_switch_set_active(GTK_SWITCH(f.relative_switch), d.relative_time);
gtk_drop_down_set_selected(GTK_DROP_DOWN(f.theme_drop), 0);
}
void on_apply(GtkButton*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
const Settings s = read_form(f);
const bool written = save_settings(s);
// The theme goes on even when the write failed: the user asked to see it, and the file is the
// memory of the choice rather than the choice itself. The status line is what says it will not
// survive a restart.
apply_theme(s.theme);
if (f.status != nullptr) {
gtk_label_set_text(GTK_LABEL(f.status),
written ? "" : "could not write the preferences file -- this session only");
gtk_widget_set_visible(f.status, !written);
}
if (f.on_applied) f.on_applied(s, written);
}
/// The one close path, whether it came from the button, the window manager or `prefs_close()`.
gboolean on_close_request(GtkWindow*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
f.window = nullptr;
f.dir_entry = nullptr;
// A picker still open answers after this, and its answer writes a row: cancel it here, while the
// rows are still there to be checked against null.
g_cancellable_cancel(f.browse_cancel);
g_clear_object(&f.browse_cancel);
f.dialog = nullptr;
f.dir_row = nullptr;
f.keep_row = nullptr;
f.relative_row = nullptr;
f.theme_row = nullptr;
f.browse = nullptr;
f.keep_spin = nullptr;
f.relative_switch = nullptr;
f.theme_drop = nullptr;
f.apply = nullptr;
f.status = nullptr;
return FALSE; // the default handler destroys the window
f.parent = nullptr;
}
void on_close_clicked(GtkButton*, gpointer user) {
prefs_close(*static_cast<PrefsForm*>(user));
/// The rows a dialog built from `prefs.ui` declares resolved, or cleared if the file is missing one.
///
/// The file is checked by the build (`xml-stripblanks`) and by `GtkBuilder` at load; a missing *id* is
/// the one mistake neither sees, and it would be a null row read on the first change.
void resolve_rows(PrefsForm& f) {
f.dir_row = ADW_ENTRY_ROW(gtk_builder_get_object(f.builder, "dir_row"));
f.keep_row = ADW_SPIN_ROW(gtk_builder_get_object(f.builder, "keep_row"));
f.relative_row = ADW_SWITCH_ROW(gtk_builder_get_object(f.builder, "relative_row"));
f.theme_row = ADW_COMBO_ROW(gtk_builder_get_object(f.builder, "theme_row"));
if (f.dir_row == nullptr || f.keep_row == nullptr || f.relative_row == nullptr ||
f.theme_row == nullptr) {
f.dir_row = nullptr;
f.keep_row = nullptr;
f.relative_row = nullptr;
f.theme_row = nullptr;
g_clear_object(&f.builder);
}
}
} // namespace
void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
std::function<void(const Settings&, bool written)> on_applied) {
if (form.window != nullptr) {
gtk_window_present(GTK_WINDOW(form.window));
std::function<void(const Settings&, bool written)> on_changed) {
if (form.dialog != nullptr) {
adw_dialog_present(form.dialog, parent);
return;
}
form.on_applied = std::move(on_applied);
form.on_changed = std::move(on_changed);
form.parent = GTK_IS_WINDOW(parent) ? GTK_WINDOW(parent) : nullptr;
GtkWidget* window = dialog_window(parent, "Preferences");
form.window = window;
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), &form);
// A builder per opening: the dialog belongs to its object list and `closed` destroys the dialog, so
// the resource is parsed again rather than an already-dead dialog fetched. This is where the previous
// builder goes, which is safe because that dialog has been closed for as long as it takes a user to
// open the menu again.
g_clear_object(&form.builder);
form.builder = gtk_builder_new_from_resource("/org/udpscope/ui/preferences.ui");
if (form.builder == nullptr) return;
GObject* dialog = gtk_builder_get_object(form.builder, "preferences");
resolve_rows(form);
if (dialog == nullptr || form.dir_row == nullptr) return;
form.dialog = ADW_DIALOG(dialog);
GtkWidget* column = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_top(column, 12);
gtk_widget_set_margin_bottom(column, 12);
gtk_widget_set_margin_start(column, 12);
gtk_widget_set_margin_end(column, 12);
// Guarded while the widgets are dressed: setting the theme list moves the combo's selection, and
// filling a row fires its own signal -- either would write a settings file the user has not touched.
form.filling = true;
// --- the event cache ------------------------------------------------------
GtkWidget* cache_heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(cache_heading), "<b>Event cache</b>");
gtk_widget_set_halign(cache_heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(column), cache_heading);
// The theme list in code: a `GtkStringList` from a strv is one line, and saying the same thing in a
// `.ui` needs an `<items>` model with three entries.
const char* themes[] = {"auto", "light", "dark", nullptr};
adw_combo_row_set_model(form.theme_row, G_LIST_MODEL(gtk_string_list_new(themes)));
form.dir_entry = gtk_entry_new();
gtk_editable_set_text(GTK_EDITABLE(form.dir_entry),
(current.event_cache_dir.empty() ? default_events_dir()
: current.event_cache_dir)
.c_str());
gtk_widget_set_tooltip_text(form.dir_entry,
"where events are cached: one directory in which every source gets a "
"folder of its own, never one directory between two sources. Left at "
"the default shown here, it follows the cache directory");
// A standard icon rather than the word: an icon-only button needs its meaning kept somewhere, so the
// name goes to assistive technology and the explanation to the tooltip. `folder-open-symbolic` is
// Adwaita's, not one of the six this program bundles -- it is the theme's own word for "choose a
// directory", and a second drawing of it here would only be a worse one.
// The folder chooser, as a suffix of the directory row.
form.browse = gtk_button_new_from_icon_name("folder-open-symbolic");
gtk_widget_set_tooltip_text(form.browse,
"choose the directory in a file chooser; what it returns is put in "
"the field beside it, which stays the value that is stored");
"choose the directory in a file chooser; what it returns goes into "
"the row beside it, which stays the value that is stored");
// Terminated by -1, not by a null: this call is varargs, and `nullptr` reads as property id 0 --
// which is `autocomplete`, not "no more properties", so GTK then reads the next word as its value.
gtk_accessible_update_property(GTK_ACCESSIBLE(form.browse), GTK_ACCESSIBLE_PROPERTY_LABEL,
"Choose the event cache directory", -1);
g_signal_connect(form.browse, "clicked", G_CALLBACK(on_browse), &form);
GtkWidget* dir_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_box_append(GTK_BOX(dir_row), form.dir_entry);
gtk_box_append(GTK_BOX(dir_row), form.browse);
gtk_box_append(GTK_BOX(column), labeled_box("directory", dir_row));
adw_entry_row_add_suffix(form.dir_row, form.browse);
form.keep_spin = spin(0, 100000, 1, 0, current.event_cache_max);
gtk_widget_set_tooltip_text(form.keep_spin,
"how many events one source keeps; the oldest are deleted as new ones "
"arrive. 0 keeps every event, which is a decision about the disk");
gtk_box_append(GTK_BOX(column), labeled_box("keep events", form.keep_spin));
// Every row applies as it changes. Each row says so with the property it carries; the entry says it
// with `GtkEditable::changed`, which is the interface it implements.
g_signal_connect(form.dir_row, "changed", G_CALLBACK(on_entry_changed), &form);
g_signal_connect(form.keep_row, "notify::value", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.relative_row, "notify::active", G_CALLBACK(on_row_notify), &form);
g_signal_connect(form.theme_row, "notify::selected", G_CALLBACK(on_row_notify), &form);
if (GObject* reset = gtk_builder_get_object(form.builder, "reset_row"); reset != nullptr) {
g_signal_connect(reset, "activated", G_CALLBACK(on_reset), &form);
}
g_signal_connect(form.dialog, "closed", G_CALLBACK(on_closed), &form);
// --- the plot -------------------------------------------------------------
GtkWidget* plot_heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(plot_heading), "<b>Plot</b>");
gtk_widget_set_halign(plot_heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(column), plot_heading);
form.relative_switch = gtk_switch_new();
gtk_switch_set_active(GTK_SWITCH(form.relative_switch), current.relative_time);
gtk_widget_set_tooltip_text(form.relative_switch,
"ignore the stream's own clock and plot from the sample rate and the "
"sample index, for a source whose time stamps jitter");
gtk_box_append(GTK_BOX(column), labeled_switch_row("relative time", form.relative_switch));
const char* themes[] = {"auto", "light", "dark", nullptr};
form.theme_drop = gtk_drop_down_new_from_strings(themes);
gtk_drop_down_set_selected(GTK_DROP_DOWN(form.theme_drop),
current.theme == "light" ? 1 : current.theme == "dark" ? 2 : 0);
gtk_widget_set_tooltip_text(form.theme_drop,
"what the interface looks like; auto follows the system");
gtk_box_append(GTK_BOX(column), labeled_box("theme", form.theme_drop));
// --- the way out ----------------------------------------------------------
form.status = gtk_label_new(nullptr);
gtk_label_set_wrap(GTK_LABEL(form.status), TRUE);
gtk_label_set_xalign(GTK_LABEL(form.status), 0.0F);
gtk_widget_add_css_class(form.status, "error");
gtk_widget_set_visible(form.status, FALSE);
gtk_box_append(GTK_BOX(column), form.status);
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(buttons, GTK_ALIGN_END);
GtkWidget* defaults = gtk_button_new_with_label("Defaults");
g_signal_connect(defaults, "clicked", G_CALLBACK(on_defaults), &form);
gtk_box_append(GTK_BOX(buttons), defaults);
form.apply = gtk_button_new_with_label("Apply");
gtk_widget_add_css_class(form.apply, "suggested-action");
g_signal_connect(form.apply, "clicked", G_CALLBACK(on_apply), &form);
gtk_box_append(GTK_BOX(buttons), form.apply);
GtkWidget* close = gtk_button_new_with_label("Close");
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &form);
gtk_box_append(GTK_BOX(buttons), close);
gtk_box_append(GTK_BOX(column), buttons);
gtk_window_set_child(GTK_WINDOW(window), column);
gtk_window_present(GTK_WINDOW(window));
fill_rows(form, current);
adw_dialog_present(form.dialog, parent);
}
void prefs_close(PrefsForm& form) {
if (form.window == nullptr) return;
GtkWidget* window = form.window;
form.window = nullptr; // before the close, so `close-request` cannot re-enter a stale window
gtk_window_close(GTK_WINDOW(window));
// `closed` clears the pointers, so this is the only place that has to know how a dialog closes.
if (form.dialog != nullptr) adw_dialog_close(form.dialog);
}
void apply_theme(const std::string& name) {
@@ -244,23 +234,24 @@ void apply_theme(const std::string& name) {
if (manager != nullptr) adw_style_manager_set_color_scheme(manager, theme_scheme(name));
}
void prefs_probe_fill_and_apply(PrefsForm& form) {
if (form.window == nullptr) return;
gtk_editable_set_text(GTK_EDITABLE(form.dir_entry), "/tmp/udpscope-prefs-events");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(form.keep_spin), 7);
gtk_switch_set_active(GTK_SWITCH(form.relative_switch), TRUE);
gtk_drop_down_set_selected(GTK_DROP_DOWN(form.theme_drop), 2);
if (form.apply != nullptr) g_signal_emit_by_name(form.apply, "clicked");
void prefs_probe_change(PrefsForm& form) {
if (form.dialog == nullptr) return;
gtk_editable_set_text(GTK_EDITABLE(form.dir_row), "/tmp/udpscope-prefs-events");
adw_spin_row_set_value(form.keep_row, 7.0);
adw_switch_row_set_active(form.relative_row, TRUE);
adw_combo_row_set_selected(form.theme_row, 2);
}
/// The app's entry point: the preferences button in the overflow menu. Kept here rather than in
/// `ui.cpp` with the rest of the chrome, because it is three lines of *this* form's contract -- what
/// the app must do when a preference changes -- and reading it next to the form is the only way to
/// see that the two agree.
/// The application's entry point: **Preferences…** in the overflow menu. Kept here rather than in
/// `ui.cpp` with the rest of the chrome, because it is the application's half of *this* form's
/// contract -- what a preference changing means to a running scope -- and reading it beside the form is
/// the only way to see that the two agree.
void open_preferences(App& app) {
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s;
set_notice(app, written ? "preferences saved" : "could not write the preferences file", 4);
// Only a failure is announced. A change arrives once per keystroke in the directory row, and a
// status line that flashed on each one would be the app shouting about its own bookkeeping.
if (!written) set_notice(app, "could not write the preferences file", 4);
});
}
+60 -33
View File
@@ -1,14 +1,22 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The preferences form, without an owner.
// The preferences, without an owner.
//
// It used to be six widget fields on `App` and a function taking `App&`, which made it unreachable
// from the one other place that needs it: the source chooser, which runs *before* `App` exists --
// `main()` chooses a source, and only then builds the model and the UI. So the widgets and what they
// contain are a struct the caller owns, and what a change *means* is a callback, because it means
// different things on the two sides: the app follows its own switch and puts up a notice, the chooser
// hands relative time to the run it is about to start.
// The dialog is a `.ui` file (`src/ui/preferences.ui`) and its rows are libadwaita's own -- an
// `AdwEntryRow`, an `AdwSpinRow`, an `AdwSwitchRow`, an `AdwComboRow` and an `AdwButtonRow` -- so this
// file is only two things: how a `Settings` gets *into* the rows, and how a change to a row gets
// written back out.
//
// **A change applies as it is made.** There is no Apply button: a preferences row that applies when it
// changes is what these widgets are built around, and the two settings that cannot take effect at once
// -- the cache directory and relative time, both of which are planned before any source is known -- say
// so in their subtitles instead of being held back by a button. The writer is a few bytes to one file,
// so a keystroke in the directory row is not worth batching.
//
// The form has no owner either: the application's menu opens it, and so does the source chooser, which
// runs before the application's widgets exist. What a change *means* is therefore the caller's
// business -- `PrefsForm::on_changed` -- because it means different things on the two sides.
#ifndef UDPSCOPE_UI_PREFS_HPP
#define UDPSCOPE_UI_PREFS_HPP
@@ -16,6 +24,7 @@
#include <functional>
#include <string>
#include <adwaita.h>
#include <gtk/gtk.h>
#include "settings.hpp"
@@ -23,46 +32,64 @@
namespace udpscope {
namespace ui_impl {
/// One preferences window's widgets, owned by whoever opened it.
/// One preferences dialog's widgets, owned by whoever opened it.
///
/// Every pointer is valid only while `window` is non-null: the close path clears the lot, so a handler
/// that fires after the window went away reads null rather than freed memory.
/// Every pointer is valid only while `dialog` is non-null: the close path clears the lot, so a handler
/// that fires after the dialog went away reads null rather than freed memory.
struct PrefsForm {
GtkWidget* window = nullptr;
GtkWidget* dir_entry = nullptr;
/// The folder chooser beside it. A text field alone asks a user to know a path and type it correctly;
/// the chooser is how they find one, and it writes what it found into the same field -- so the text
/// stays the single value, and there is nothing to keep in step.
AdwDialog* dialog = nullptr;
/// The builder the dialog came from, replaced on every opening: the dialog belongs to the builder's
/// object list and closing destroys it, so the file is parsed again rather than a dead dialog fetched.
GtkBuilder* builder = nullptr;
/// The rows, typed. A switch is read with `adw_switch_row_get_active()` and not off a widget.
AdwEntryRow* dir_row = nullptr;
AdwSpinRow* keep_row = nullptr;
AdwSwitchRow* relative_row = nullptr;
AdwComboRow* theme_row = nullptr;
/// The folder chooser beside the directory row. A text field alone asks a user to know a path and
/// type it correctly; the chooser is how they find one, and what it returns goes into that row -- so
/// the text stays the single value and there is nothing to keep in step.
GtkWidget* browse = nullptr;
GtkWidget* keep_spin = nullptr;
GtkWidget* relative_switch = nullptr;
GtkWidget* theme_drop = nullptr;
GtkWidget* apply = nullptr;
/// Shown only when a write fails. A preference that silently does not stick is worse than one that
/// says so, and the two owners have nowhere in common to say it: the app has a status bar, the
/// chooser does not.
GtkWidget* status = nullptr;
/// Called on *Apply*, with what the form held and whether it reached the file -- which is already
/// written by the time this runs. Empty is allowed.
std::function<void(const Settings&, bool written)> on_applied;
/// The window the file chooser is transient for, taken from whatever opened this form.
///
/// Stored because an `AdwDialog` is **not** a `GtkWindow` -- it is a `GObject` that a window presents
/// -- so the dialog cannot parent the chooser, and the caller's window is the only one there is.
GtkWindow* parent = nullptr;
/// True while the rows are being filled from the settings, so filling them cannot write them back: a
/// row's change signal fires for a programmatic change exactly as it does for a typed one.
/// The pending folder chooser's cancellable, so a chooser still open when this form goes away cannot
/// call back into it. Cancelled by the `closed` handler, and replaced when another chooser opens --
/// two open pickers would each write the row when they answered.
///
/// A probe cancels it to take the *cancel* path: a user's own Cancel button arrives in the same
/// callback with the same null answer.
GCancellable* browse_cancel = nullptr;
/// True while the rows are being filled from the settings, so filling them cannot write them back: a
/// row's change signal fires for a programmatic change exactly as it does for a typed one.
bool filling = false;
/// Called on every change, with what the rows now hold and whether it reached the file.
///
/// It runs once per keystroke in the directory row, which is why it should report *failures* rather
/// than announce every success. Empty is allowed.
std::function<void(const Settings&, bool written)> on_changed;
};
/// Opens the preferences for `form`, transient for `parent` (which may be null), starting from
/// `current`. Called while the window is up it raises that window instead of building a second one:
/// two forms editing one file is how a user's change disappears.
/// `current`. Called while the dialog is up it raises that dialog instead of building a second one.
void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
std::function<void(const Settings&, bool written)> on_applied);
std::function<void(const Settings&, bool written)> on_changed);
/// Closes it. Safe to call when it is not open, and safe to call from inside the apply callback.
/// Closes it. Safe to call when it is not open.
void prefs_close(PrefsForm& form);
/// Puts the interface into the named theme at once. Separate from the form because it is the one
/// preference with nothing to plan: it applies to windows that are already on screen.
void apply_theme(const std::string& name);
/// Fills every field the way a user would and presses *Apply*: the self-check's two entry points both
/// end up here, so the app's probe and the chooser's probe cannot drift apart in what they exercise.
void prefs_probe_fill_and_apply(PrefsForm& form);
/// Sets every row the way a user would, which applies each one as it is set: the self-check's two entry
/// points both end up here, so the app's probe and the chooser's probe cannot drift apart in what they
/// exercise.
void prefs_probe_change(PrefsForm& form);
} // namespace ui_impl
} // namespace udpscope
+19 -29
View File
@@ -1633,17 +1633,18 @@ gboolean prefs_probe(gpointer user) {
App& a = *static_cast<App*>(user);
open_preferences(a);
// `dir` is the directory the dialog is offering and `store` is the one the cache is really using:
// `store` must be `dir` plus the source's folder name, which is what says the field shows the path in
// force rather than a plausible-looking string.
// `store` must be `dir` plus the source's folder name, which is what says the row shows the path in
// force rather than a plausible-looking string. `rows=1` is the dialog's rows having resolved from
// the `.ui` -- the one mistake neither the build nor `GtkBuilder` sees is a missing id.
const char* shown =
a.prefs.dir_entry != nullptr ? gtk_editable_get_text(GTK_EDITABLE(a.prefs.dir_entry)) : "-";
std::fprintf(stderr, "[prefs-probe] opened=%d apply=%d dir=%s store=%s\n",
a.prefs.window != nullptr ? 1 : 0, a.prefs.apply != nullptr ? 1 : 0,
a.prefs.dir_entry != nullptr ? shown : "-",
a.prefs.dir_row != nullptr ? gtk_editable_get_text(GTK_EDITABLE(a.prefs.dir_row)) : "-";
std::fprintf(stderr, "[prefs-probe] opened=%d rows=%d dir=%s store=%s\n",
a.prefs.dialog != nullptr ? 1 : 0, a.prefs.dir_row != nullptr ? 1 : 0,
a.prefs.dir_row != nullptr ? shown : "-",
a.event_store != nullptr ? a.event_store->dir().c_str() : "-");
g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
prefs_probe_fill_and_apply(app.prefs);
prefs_probe_change(app.prefs);
const Settings back = load_settings();
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s live=%d\n",
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
@@ -1652,8 +1653,8 @@ gboolean prefs_probe(gpointer user) {
// And typing the default back must not *pin* it: the file keeps "unset", so the default still
// follows whatever cache directory the next run has. An empty value here is the whole point --
// a path written into the file is a user's choice, and this one was not a choice.
gtk_editable_set_text(GTK_EDITABLE(app.prefs.dir_entry), default_events_dir().c_str());
g_signal_emit_by_name(app.prefs.apply, "clicked");
// Setting the row *is* applying it: there is no Apply button to press.
gtk_editable_set_text(GTK_EDITABLE(app.prefs.dir_row), default_events_dir().c_str());
std::fprintf(stderr, "[prefs-probe] default restored: file='%s'\n",
load_settings().event_cache_dir.c_str());
// The folder chooser, opened from its button and then *cancelled from outside*: a callback that
@@ -1665,31 +1666,20 @@ gboolean prefs_probe(gpointer user) {
g_signal_emit_by_name(app.prefs.browse, "clicked");
g_timeout_add(600, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
GListModel* tops = gtk_window_get_toplevels();
const guint n = g_list_model_get_n_items(tops);
guint extra = 0;
for (guint i = 0; i < n; ++i) {
GtkWidget* w = GTK_WIDGET(g_list_model_get_item(tops, i));
if (w != app.window && w != app.prefs.window) {
++extra;
gtk_window_close(GTK_WINDOW(w));
}
g_object_unref(w);
}
// The button is an icon and nothing else, so "the theme has this icon" is the difference
// between a control and a blank square: a name the icon theme does not know draws a
// missing-image placeholder and says nothing anywhere else.
std::fprintf(stderr, "[prefs-probe] browse: toplevels=%u extra=%u icon_ok=%d field='%s'\n", n,
extra,
// Cancel through the chooser's own cancellable: a user's Cancel button lands in the same
// callback with the same null answer, and it is the *cancel* path a picker invites -- a
// callback after the thing it belongs to is gone.
if (app.prefs.browse_cancel != nullptr) g_cancellable_cancel(app.prefs.browse_cancel);
std::fprintf(stderr, "[prefs-probe] browse: cancelled=%d icon_ok=%d field='%s'\n",
app.prefs.browse_cancel != nullptr ? 1 : 0,
app.prefs.browse != nullptr &&
gtk_icon_theme_has_icon(
gtk_icon_theme_get_for_display(
gtk_widget_get_display(app.prefs.browse)),
gtk_icon_theme_get_for_display(gtk_widget_get_display(app.prefs.browse)),
"folder-open-symbolic")
? 1
: 0,
app.prefs.dir_entry != nullptr
? gtk_editable_get_text(GTK_EDITABLE(app.prefs.dir_entry))
app.prefs.dir_row != nullptr
? gtk_editable_get_text(GTK_EDITABLE(app.prefs.dir_row))
: "-");
g_timeout_add(600, +[](gpointer u2) -> gboolean {
prefs_close(static_cast<App*>(u2)->prefs);