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

+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);