Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because they had been typed to agree: looks.lua hardcoded values, DesktopPreferences independently defaulted the same values, and SystemSettings replayed them at startup. Nothing kept them in sync, and the Lua side read no shared state at all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl keyword refuses the write, prints the refusal to stdout, and still exits 0, so the HDR, VRR, and direct-scanout toggles persisted their value and reported success while the compositor never changed. Writes now go through hyprctl eval, which has the same hazard on syntax and runtime errors, so success is defined as reading the value back and finding it equal. The existing contract passed throughout the outage because it re-applied the values already in place; the new one flips each value to something it does not hold. Derive preferences from a schema. Every setting used to be restated four times -- a property alias, a JSON adapter property, a change handler, and a line in reset -- where omitting any one failed silently. PreferenceSchema.qml is now the single source, and persistence, validation, reset, and the Hyprland mapping all derive from it. Unknown keys on disk survive a write so a rollback does not discard a newer build's settings, and a corrupt file falls back to shipped defaults. The store moved to ~/.config/panama/settings.json, migrating from the old state directory without deleting it. Share that file with Hyprland. prefs.lua reads it at config time with every shipped literal kept as the fallback, so the config still stands alone. The Lua is the default, the JSON is the truth, and Settings is the editor. The compositor-adjustable surface goes from 3 keys to 23. Also fixes two test-hygiene bugs found by running the suite end to end for the first time: settings-pages-contract could see the window settings-window-contract leaves behind, and the new write contract was persisting its deliberately-wrong values into the user's real store. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -1,8 +1,23 @@
|
||||
pragma Singleton
|
||||
|
||||
// User choices that Panama Settings may change at runtime. This is deliberately
|
||||
// separate from Settings.qml: the latter remains the stable public surface used
|
||||
// by the shell, while this object owns durable mutation and shipped defaults.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
@@ -11,110 +26,125 @@ import QtQuick
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property alias use24Hour: values.use24Hour
|
||||
property alias showSeconds: values.showSeconds
|
||||
property alias showWeekday: values.showWeekday
|
||||
property alias showCpu: values.showCpu
|
||||
property alias showMemory: values.showMemory
|
||||
property alias showGpu: values.showGpu
|
||||
property alias dockAutohide: values.dockAutohide
|
||||
property alias dockRevealDelayMs: values.dockRevealDelayMs
|
||||
property alias dockHideDelayMs: values.dockHideDelayMs
|
||||
property alias focusDurationMinutes: values.focusDurationMinutes
|
||||
property alias autoHdr: values.autoHdr
|
||||
property alias vrrPolicy: values.vrrPolicy
|
||||
property alias directScanoutPolicy: values.directScanoutPolicy
|
||||
property alias nightLightEnabled: values.nightLightEnabled
|
||||
property alias nightLightAutomatic: values.nightLightAutomatic
|
||||
property alias nightLightTemperature: values.nightLightTemperature
|
||||
property alias lastPage: values.lastPage
|
||||
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();
|
||||
}
|
||||
|
||||
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: Quickshell.stateDir + "/panama-settings.json"
|
||||
path: root.path
|
||||
blockLoading: true
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
|
||||
JsonAdapter {
|
||||
id: values
|
||||
|
||||
property bool use24Hour: false
|
||||
property bool showSeconds: true
|
||||
property bool showWeekday: true
|
||||
property bool showCpu: true
|
||||
property bool showMemory: true
|
||||
property bool showGpu: true
|
||||
property bool dockAutohide: true
|
||||
property int dockRevealDelayMs: 0
|
||||
property int dockHideDelayMs: 250
|
||||
property int focusDurationMinutes: 45
|
||||
property bool autoHdr: true
|
||||
property int vrrPolicy: 3
|
||||
property int directScanoutPolicy: 2
|
||||
property bool nightLightEnabled: false
|
||||
property bool nightLightAutomatic: false
|
||||
property int nightLightTemperature: 3500
|
||||
property string lastPage: "home"
|
||||
}
|
||||
onLoaded: root.load()
|
||||
// No file yet is the normal first-run case, not an error.
|
||||
onLoadFailed: root.load()
|
||||
}
|
||||
|
||||
// Listen after the adapter has been constructed instead of writing from
|
||||
// FileView.onAdapterUpdated. The latter also fires for default-property
|
||||
// initialization, which can overwrite a valid file before it is loaded.
|
||||
Connections {
|
||||
target: values
|
||||
function onUse24HourChanged(): void { persistTimer.restart(); }
|
||||
function onShowSecondsChanged(): void { persistTimer.restart(); }
|
||||
function onShowWeekdayChanged(): void { persistTimer.restart(); }
|
||||
function onShowCpuChanged(): void { persistTimer.restart(); }
|
||||
function onShowMemoryChanged(): void { persistTimer.restart(); }
|
||||
function onShowGpuChanged(): void { persistTimer.restart(); }
|
||||
function onDockAutohideChanged(): void { persistTimer.restart(); }
|
||||
function onDockRevealDelayMsChanged(): void { persistTimer.restart(); }
|
||||
function onDockHideDelayMsChanged(): void { persistTimer.restart(); }
|
||||
function onFocusDurationMinutesChanged(): void { persistTimer.restart(); }
|
||||
function onAutoHdrChanged(): void { persistTimer.restart(); }
|
||||
function onVrrPolicyChanged(): void { persistTimer.restart(); }
|
||||
function onDirectScanoutPolicyChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightEnabledChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightAutomaticChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightTemperatureChanged(): void { persistTimer.restart(); }
|
||||
function onLastPageChanged(): void { persistTimer.restart(); }
|
||||
Component.onCompleted: {
|
||||
migration.adopt();
|
||||
root.load();
|
||||
}
|
||||
|
||||
// Singleton construction can happen after FileView's preload phase when a
|
||||
// test or a lazy page first references it. An explicit reload makes the
|
||||
// durable state authoritative in that case as well as in the main shell.
|
||||
Component.onCompleted: preferencesFile.reload()
|
||||
|
||||
// Coalesce a group of UI changes into one atomic write. Writing from the
|
||||
// adapter's change signal itself can race a second property assignment and
|
||||
// reload the older value before the batch has finished.
|
||||
// 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.writeAdapter()
|
||||
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
|
||||
}
|
||||
|
||||
function resetDesktopDefaults(): void {
|
||||
values.use24Hour = false;
|
||||
values.showSeconds = true;
|
||||
values.showWeekday = true;
|
||||
values.showCpu = true;
|
||||
values.showMemory = true;
|
||||
values.showGpu = true;
|
||||
values.dockAutohide = true;
|
||||
values.dockRevealDelayMs = 0;
|
||||
values.dockHideDelayMs = 250;
|
||||
values.focusDurationMinutes = 45;
|
||||
values.autoHdr = true;
|
||||
values.vrrPolicy = 3;
|
||||
values.directScanoutPolicy = 2;
|
||||
values.nightLightEnabled = false;
|
||||
values.nightLightAutomatic = false;
|
||||
values.nightLightTemperature = 3500;
|
||||
values.lastPage = "home";
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user