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
@@ -609,8 +609,12 @@ all is the trigger pane's own switch, and there is deliberately no second one he
|
||||
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache
|
||||
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory
|
||||
is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
|
||||
chosen. A key that is absent means its default, so the file only mentions what changed, and a missing
|
||||
file is a first run rather than an error. The directory field shows the directory in use: the default
|
||||
chosen. A change
|
||||
applies as it is made — there is no Apply button, and a **Reset to defaults** row at the end of the
|
||||
dialog puts everything back — and the rows are the ones every GNOME application shows: a title, a
|
||||
subtitle under it where the setting needs explaining, and the control on the right. A key that is
|
||||
absent means its default, so the file only mentions what changed, and a missing file is a first run
|
||||
rather than an error. The directory field shows the directory in use: the default
|
||||
is filled in rather than described, and typing it back leaves the setting unset, so it goes on
|
||||
following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder
|
||||
button beside it, which writes what it found into the same field -- the field stays the one value, and
|
||||
|
||||
@@ -231,6 +231,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
|
||||
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
|
||||
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
|
||||
- **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences
|
||||
are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a
|
||||
`GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and
|
||||
hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an
|
||||
`AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that
|
||||
are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`,
|
||||
and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test
|
||||
`GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a
|
||||
cancellable will do.
|
||||
- **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's
|
||||
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
|
||||
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
|
||||
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
|
||||
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
|
||||
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
|
||||
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
|
||||
|
||||
@@ -903,7 +903,7 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
|
||||
## `[sources] prefs`
|
||||
|
||||
```
|
||||
[sources] prefs: opened=1 apply=1
|
||||
[sources] prefs: opened=1 rows=1
|
||||
[sources] prefs: dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark cfg_relative=1
|
||||
```
|
||||
|
||||
@@ -1042,6 +1042,40 @@ library measures and draws the rect in tests of its own.
|
||||
|
||||
## `[prefs-probe]`
|
||||
|
||||
```
|
||||
[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
|
||||
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
|
||||
[prefs-probe] default restored: file=''
|
||||
[prefs-probe] browse: cancelled=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
|
||||
```
|
||||
|
||||
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences, changes every row, and reads the settings file *back
|
||||
from disk* after each change -- not from `App::settings`, because a dialog that edits its own copy and
|
||||
never writes is exactly the failure this has to catch. **A change applies as it is made**: the rows are
|
||||
libadwaita's (`AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`), and the `preferences.ui`
|
||||
dialog has no Apply button for the probe to press.
|
||||
|
||||
- `opened=1 rows=1` is the dialog having come up *and* its rows having resolved by id. The file is
|
||||
checked by the build (`xml-stripblanks`) and by `GtkBuilder` at load; a missing **id** is the one
|
||||
mistake neither sees, and reading a null row on the first change is what it costs.
|
||||
- `dir` is the directory the *row* is offering -- the preference if there is one, otherwise the default
|
||||
written out in full -- and `store` is the directory the cache is really using. **`store` must be `dir`
|
||||
plus the source's folder name**, which is the reading that says the row shows the path in force rather
|
||||
than a plausible string.
|
||||
- The second line must echo the probe's own input: directory `/tmp/udpscope-prefs-events`, keep `7`,
|
||||
relative time `1`, theme `dark`. Any field that differs means the row, the write or the parse dropped
|
||||
it. `live=1` is the theme having reached the running style manager rather than only the file.
|
||||
- `default restored: file=''` closes the loop from the other side: the probe puts the default path back
|
||||
into the row, and the file must end up with **no** `event_cache_dir` at all. A path in the file is a
|
||||
user's choice, and leaving the default is not one -- pinning it there would stop the setting following
|
||||
the cache directory it is named after.
|
||||
- `browse: cancelled=1 icon_ok=1 field='...'` is the folder chooser. `cancelled=1` is its cancellable
|
||||
having been cancelled, which is the *cancel* path a picker invites (a user's own Cancel button arrives
|
||||
in the same callback with the same null answer); `icon_ok=1` is the icon theme knowing
|
||||
`folder-open-symbolic`; and the field must still read the directory it read before -- **a cancelled
|
||||
picker changes nothing**.
|
||||
|
||||
|
||||
```
|
||||
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
|
||||
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
|
||||
|
||||
@@ -43,6 +43,10 @@ needs a probe that proves it *loads* and that its contents match whatever drives
|
||||
|
||||
## Already done
|
||||
|
||||
- `src/ui/preferences.ui` -- the preferences, as an `AdwPreferencesDialog` with `AdwEntryRow`,
|
||||
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` and `AdwButtonRow` rows. The Apply button is gone: a row
|
||||
applies as it changes, which is what those widgets are designed around, and `[prefs-probe]` reads the
|
||||
file back after every change.
|
||||
- `src/ui/shortcuts.ui` -- the shortcut overview, with the mouse rows drawn as an icon cell.
|
||||
- The `src/ui/` GResource and its build block.
|
||||
- `[help]`'s `ui=`/`ui_accel=`/`ui_icon=`/`ui_bare=` reading, which checks the file against the tables
|
||||
@@ -52,7 +56,6 @@ needs a probe that proves it *loads* and that its contents match whatever drives
|
||||
|
||||
| where | what | why it is the right next step |
|
||||
|---|---|---|
|
||||
| `src/ui_prefs.cpp` (~330 lines) | the preferences: `.ui` + `AdwPreferencesDialog`, `AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` | the most visible win per line: it replaces four hand-built rows with four rows that look and behave like every other GNOME application's, and the file is self-contained |
|
||||
| `src/help_window.cpp` (351 lines) | the help window's chrome as a `.ui` (the pages themselves stay resources) | small, self-contained, and its probe already opens it |
|
||||
| `src/source_dialog.cpp` (~900 lines) | the source chooser and the host form: `.ui` + `AdwDialog`, with `AdwEntryRow`/`AdwSpinRow` for the form | the form is the second-biggest hand-built surface, and its probes drive every branch of it |
|
||||
| `src/ui.cpp` (5774 lines) | the main window's **skeleton** only -- the `AdwApplicationWindow`, the header bar, the plot area's placeholder, the panes' containers, the status bar -- with the custom widgets, popovers and dialogs left in code | one file, one window: converting the skeleton is reviewable, converting the whole file is not. Do it in slices (header bar, then the panes, then the trigger panel, then the left panes) with the probe suite after each |
|
||||
|
||||
+7
-10
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
|
||||
10. [ ] move the interface into `.ui` resources, and to current GTK4/libadwaita widgets while doing it:
|
||||
- the shortcut overview is **done** (`src/ui/shortcuts.ui`, with the mouse gestures drawn as icons, and `[help]`'s `ui=` against `tables=` as the guard)
|
||||
- the preferences are **done** (`src/ui/preferences.ui`: `AdwPreferencesDialog` with `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`/`AdwComboRow`, applying as they change, no Apply button)
|
||||
- next: the preferences (`AdwPreferencesDialog` + `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`), the help window's chrome, the source chooser and its host form, then the main window's skeleton
|
||||
- plan, conventions and acceptance for each slice: `docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md`
|
||||
Reference in new issue
Block a user