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; // A json value comes out of coerce() with a fresh identity every time, // so `===` never held for one and an identical write still bumped the // revision. The revision is what drives the video restore, the scheme // reconciliation and the theme coalesce timer, so re-storing the same // display map ran all three again for a change nobody made. if (root.sameStoredValue(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; } // Whether the store already holds `coerced` for `key`. Scalars compare by // value; json compares by serialization, which is the only comparison an // object or array has here. Two equal objects written in a different key // order serialize differently and are treated as a change -- that is the // old behaviour, so the comparison can only ever remove churn, never // swallow a real write. function sameStoredValue(key: string, coerced: var): bool { const stored = root.values[key]; if (PreferenceSchema.spec(key)?.type === "json") return stored !== undefined && JSON.stringify(stored) === JSON.stringify(coerced); return stored === coerced; } // 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 = {}; } const raw = (parsed && typeof parsed === "object") ? parsed : {}; // Upgrade before anything reads a value. A stored key the current // schema no longer recognizes is carried through untouched and silently // stops taking effect, so the conversion has to happen here rather than // being noticed later by whoever owns that setting. const result = Migrations.apply(raw); root.values = result.values; if (result.migrated) console.info("Settings migrated from version", result.from, "to", result.to + ":", result.applied.join("; ")); // Write whenever the version moved, which includes stamping a file // written before versioning existed. Left unwritten, the stamp lives // only in memory and is redone on every launch, and a migration that is // not idempotent would compound. if (result.changed) persistTimer.restart(); root.revision++; root.loaded = true; } FileView { id: preferencesFile path: root.path blockLoading: true printErrors: false atomicWrites: true // Adopt writes made from outside the shell -- a hand edit, a script, // a restored snapshot -- instead of holding a stale copy in memory // and silently erasing them at the next save. A change made anywhere // must survive everywhere; the shell is the editor, not the owner. // The shell's own atomic writes land here too and reload as a no-op. watchChanges: true onFileChanged: this.reload() 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 // Merge with what is on disk rather than overwriting it. This model // was loaded at startup; a key written to the file since then -- a // hand edit, a script, another shell instance during a session // handoff -- would otherwise be erased by the next unrelated save, // which is how a setting "changed itself back". Keys this shell has // set win; keys it has never seen survive. onTriggered: { let disk = {}; try { const text = preferencesFile.text(); if (text && text.trim().length > 0) disk = JSON.parse(text); } catch (error) { // An unreadable file loses the merge, never the write. } if (!disk || typeof disk !== "object") disk = {}; const merged = Object.assign({}, disk, root.values); root.values = merged; preferencesFile.setText(JSON.stringify(merged, 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 } }