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:
1 parent
0e256764d5
commit
4c56172005
11 files changed
+403
-247
No files matched your search
+60
-33
@@ -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
|
||||
|
||||
Reference in new issue
Block a user