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
+162
-171
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user