The Dock's pinned applications were a sixteen-entry literal in Settings.qml, so changing what sits in the Dock meant editing QML. They are now an ordered list in the shared store, with move up, move down, unpin, and a filtered picker for adding installed applications. Keeping them in the shared store rather than a file of their own means they are covered by Restore defaults like everything else. This needed a "json" schema type for values the schema stores and resets but does not validate field by field. It exists so structured settings can live in the one file rather than growing a fourth preference store; the owning service validates the contents. Snapshots make the settings app safe to experiment with. The whole configuration is one file, so a backup is a copy and a restore is an overwrite, and restoring snapshots what it replaces so it is itself undoable. A snapshot is validated as JSON before it can be restored over a working configuration, and a name that is not a plain snapshot filename from the backup directory is refused. Snapshot names carry milliseconds. At one-second resolution a save followed promptly by a restore produced the same filename twice, and the restore's own safety snapshot overwrote the file it was about to read -- found by the contract, which restores immediately after saving. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
159 lines
5.9 KiB
QML
159 lines
5.9 KiB
QML
pragma Singleton
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
|
|
//
|
|
// This object owns reading, validating, and writing. It knows nothing about
|
|
// which settings exist -- that is the schema's job -- so adding a setting never
|
|
// requires touching this file. That is the point: the previous implementation
|
|
// restated every key four times (a property alias, a JSON adapter property, a
|
|
// change handler, and a line in reset), and each omission failed silently.
|
|
//
|
|
// Reads go through get(), writes through set(). Typed accessors for the values
|
|
// the shell reads on every frame live in Settings.qml, which remains the stable
|
|
// public surface.
|
|
//
|
|
// The store lives at ~/.config/panama/settings.json rather than inside
|
|
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
|
|
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
|
|
// able to back it up, diff it, or keep it in a dotfiles repo.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
|
|
|
|
// Bumped on every accepted change. get() reads it so bindings built on get()
|
|
// have something to invalidate; a bare function call would otherwise capture
|
|
// no dependency and every reader would silently go stale.
|
|
property int revision: 0
|
|
|
|
// Everything currently on disk, including keys this build does not know
|
|
// about. Unknown keys are carried through untouched so that rolling back to
|
|
// an older Panama does not discard a newer version's settings.
|
|
property var values: ({})
|
|
|
|
property bool loaded: false
|
|
|
|
function get(key: string): var {
|
|
root.revision;
|
|
const stored = root.values[key];
|
|
if (stored === undefined)
|
|
return PreferenceSchema.defaultFor(key);
|
|
const coerced = PreferenceSchema.coerce(key, stored);
|
|
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
|
|
}
|
|
|
|
// Returns false when the key is unknown or the value cannot be represented,
|
|
// so a caller can surface the rejection instead of assuming it took.
|
|
function set(key: string, value: var): bool {
|
|
if (!PreferenceSchema.has(key))
|
|
return false;
|
|
const coerced = PreferenceSchema.coerce(key, value);
|
|
if (coerced === undefined)
|
|
return false;
|
|
if (root.values[key] === coerced)
|
|
return true;
|
|
|
|
// Reassign rather than mutate: QML does not notify on in-place changes
|
|
// to a var property's contents.
|
|
const next = Object.assign({}, root.values);
|
|
next[key] = coerced;
|
|
root.values = next;
|
|
root.revision++;
|
|
persistTimer.restart();
|
|
return true;
|
|
}
|
|
|
|
// Restores every schema default in one write. Complete by construction --
|
|
// there is no hand-maintained list to fall out of sync with the schema.
|
|
function resetDesktopDefaults(): void {
|
|
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
|
|
root.values = next;
|
|
root.revision++;
|
|
persistTimer.restart();
|
|
}
|
|
|
|
// Re-read the file from disk. Used after something outside the shell has
|
|
// rewritten it -- restoring a snapshot, or a hand edit -- so the running
|
|
// desktop reflects the new contents without waiting for the next change.
|
|
function reload(): void {
|
|
preferencesFile.reload();
|
|
root.load();
|
|
}
|
|
|
|
function load(): void {
|
|
let parsed = {};
|
|
try {
|
|
const text = preferencesFile.text();
|
|
if (text && text.trim().length > 0)
|
|
parsed = JSON.parse(text);
|
|
} catch (error) {
|
|
// A corrupt file must not cost the user a working desktop. Fall
|
|
// back to shipped defaults and let the next write replace it.
|
|
parsed = {};
|
|
}
|
|
root.values = (parsed && typeof parsed === "object") ? parsed : {};
|
|
root.revision++;
|
|
root.loaded = true;
|
|
}
|
|
|
|
FileView {
|
|
id: preferencesFile
|
|
|
|
path: root.path
|
|
blockLoading: true
|
|
printErrors: false
|
|
atomicWrites: true
|
|
|
|
onLoaded: root.load()
|
|
// No file yet is the normal first-run case, not an error.
|
|
onLoadFailed: root.load()
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
migration.adopt();
|
|
root.load();
|
|
}
|
|
|
|
// Coalesce a burst of changes into one atomic write. Writing on every
|
|
// assignment races a second assignment and can reload the older value.
|
|
Timer {
|
|
id: persistTimer
|
|
interval: 0
|
|
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
|
|
}
|
|
|
|
// One-time move from the pre-Stage-1 location inside Quickshell's state
|
|
// directory. Reads the old file only when the new one does not exist yet, so
|
|
// it can never overwrite newer settings, and never deletes the original.
|
|
QtObject {
|
|
id: migration
|
|
|
|
function adopt(): void {
|
|
if (preferencesFile.text())
|
|
return;
|
|
try {
|
|
const legacy = legacyFile.text();
|
|
if (legacy && legacy.trim().length > 0)
|
|
preferencesFile.setText(legacy);
|
|
} catch (error) {
|
|
// Nothing to migrate.
|
|
}
|
|
}
|
|
}
|
|
|
|
FileView {
|
|
id: legacyFile
|
|
|
|
path: Quickshell.stateDir + "/panama-settings.json"
|
|
blockLoading: true
|
|
printErrors: false
|
|
}
|
|
}
|