diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md index 34c11a0..c07ac82 100644 --- a/config/dot/hypr/README.md +++ b/config/dot/hypr/README.md @@ -27,6 +27,7 @@ Don't "fix" them. | File | Contents | |---|---| | `hyprland.lua` | Entry point. Each `require()` is its own error scope | +| `prefs.lua` | Reads the settings file Panama Settings writes. See below | | `env.lua` | Environment. Note the uwsm caveat below | | `monitors.lua` | DP-2 geometry, scaling, and the HDR decision | | `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout | @@ -43,6 +44,46 @@ Validate any change without leaving your session: Hyprland --verify-config ``` +## Settings: one file, both sides + +`~/.config/panama/settings.json` is shared with the Quickshell side. The +relationship is: + +- **This config is the default.** Every adjustable value is written + `prefs.get("key", )`, so the config still works standalone with + no settings file at all. +- **The JSON is the truth.** Hyprland and Quickshell both read it. +- **Panama Settings is the editor.** It writes the file *and* applies the change + live, so nothing needs a reload and the two sides cannot drift apart. + +To add an adjustable setting: add an entry to +`quickshell/config/PreferenceSchema.qml` with a `hypr` block naming the +`hl.config` path, then read it here with `prefs.get`. Nothing else is needed — +persistence, validation, reset, and the live write are all derived from that +entry. + +`prefs.lua` never raises. A missing, empty, truncated, malformed, or +wrong-typed settings file costs you your customisations and nothing else; +`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still +accepts the config in each of those states. + +### Never use `hyprctl keyword` + +On a Lua-configured Hyprland it refuses the write, prints +`keyword can't work with non-legacy parsers` to **stdout**, and still **exits 0**: + +```sh +$ hyprctl getoption decoration:rounding -j # → "int": 18 +$ hyprctl keyword decoration:rounding 4 # → the refusal above +$ echo $? # → 0 +$ hyprctl getoption decoration:rounding -j # → "int": 18, unchanged +``` + +Use `hyprctl eval 'hl.config({ ... })'` instead. Note that `eval` *also* exits 0 +on syntax and runtime errors, reporting them as an `error:` line on stdout — so +for either command, the only trustworthy signal that a write landed is reading +the value back with `hyprctl getoption`. + ## The look Tokyo Night Moon, with two accents that come from the tmux theme: diff --git a/config/dot/hypr/hyprland.lua b/config/dot/hypr/hyprland.lua index 4fc243b..31261de 100644 --- a/config/dot/hypr/hyprland.lua +++ b/config/dot/hypr/hyprland.lua @@ -14,6 +14,11 @@ -- Check: Hyprland --verify-config -- ───────────────────────────────────────────────────────────────────────────── +-- Shared preferences first: looks/input/monitors read their defaults through it. +-- It never raises, so a missing or malformed settings file costs customisations +-- and nothing else. +require("prefs") + require("env") require("monitors") require("looks") diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua index 96391e9..2b2b33c 100644 --- a/config/dot/hypr/input.lua +++ b/config/dot/hypr/input.lua @@ -5,28 +5,30 @@ -- acceleration changes. The goal is that muscle memory transfers untouched. -- ───────────────────────────────────────────────────────────────────────────── +local prefs = require("prefs") + hl.config({ input = { - kb_layout = "us", + kb_layout = prefs.get("keyboardLayout", "us"), kb_variant = "", kb_model = "", kb_options = "", kb_rules = "", - numlock_by_default = true, + numlock_by_default = prefs.get("numlockByDefault", true), -- GNOME's defaults are 500ms delay / 33Hz repeat. - repeat_delay = 500, - repeat_rate = 33, + repeat_delay = prefs.get("keyRepeatDelay", 500), + repeat_rate = prefs.get("keyRepeatRate", 33), -- 1 = click to focus. GNOME's behaviour; NOT sloppy focus. - follow_mouse = 1, + follow_mouse = prefs.getInt("followMouse", 1), -- Don't refocus on mouse move alone -- only on click. mouse_refocus = false, -- Flat pointer response, no acceleration. Matters for gaming. - sensitivity = 0, + sensitivity = prefs.get("pointerSensitivity", 0), accel_profile = "flat", -- Clicking a floating window raises and focuses it. diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index cd1dd74..f345813 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -12,12 +12,14 @@ -- display. -- ───────────────────────────────────────────────────────────────────────────── +local prefs = require("prefs") + hl.config({ general = { - gaps_in = 5, - gaps_out = 10, + gaps_in = prefs.get("gapsIn", 5), + gaps_out = prefs.get("gapsOut", 10), - border_size = 2, + border_size = prefs.get("borderSize", 2), col = { -- The prism: blue leads, orchid follows, on a diagonal so the pair @@ -44,16 +46,16 @@ hl.config({ decoration = { -- 18 to match the shell's popover radius, so a window and a panel sitting -- next to each other read as the same object family. - rounding = 18, + rounding = prefs.get("windowRounding", 18), rounding_power = 2, active_opacity = 1.0, - inactive_opacity = 1.0, + inactive_opacity = prefs.get("inactiveOpacity", 1.0), blur = { - enabled = true, - size = 8, - passes = 3, + enabled = prefs.get("blurEnabled", true), + size = prefs.get("blurSize", 8), + passes = prefs.get("blurPasses", 3), -- Required for blur to be affordable. Never turn this off. new_optimizations = true, @@ -75,8 +77,8 @@ hl.config({ }, shadow = { - enabled = true, - range = 20, + enabled = prefs.get("shadowEnabled", true), + range = prefs.get("shadowRange", 20), render_power = 3, sharp = false, color = "rgba(15161eee)", @@ -88,8 +90,8 @@ hl.config({ -- border is the signature, and a strong halo would compete with it. -- This is just enough to lift the focused window off the wallpaper. glow = { - enabled = true, - range = 8, + enabled = prefs.get("glowEnabled", true), + range = prefs.get("glowRange", 8), render_power = 2, color = "rgba(82aaff33)", color_inactive = "rgba(00000000)", @@ -99,7 +101,7 @@ hl.config({ motion_blur = { enabled = false }, }, - animations = { enabled = true }, + animations = { enabled = prefs.get("animationsEnabled", true) }, dwindle = { -- Keep the split orientation a window was created with. Closest match @@ -118,7 +120,7 @@ hl.config({ -- Variable refresh rate. 3 = enable only for fullscreen windows whose -- content type is "video" or "game" -- the tag rules.lua applies to -- games. Keeps VRR off the desktop, where it causes visible flicker. - vrr = 3, + vrr = prefs.getInt("vrrPolicy", 3), -- Blur behind the lock screen. session_lock_blur = true, @@ -137,12 +139,12 @@ hl.config({ -- 1 = automatically flip the monitor into HDR for fullscreen content -- that asks for it, and back out afterwards. This is how games get HDR -- without the desktop paying the screencopy cost. See monitors.lua. - cm_auto_hdr = 1, + cm_auto_hdr = prefs.getInt("autoHdr", 1), -- 2 = direct scanout only for windows tagged content = "game" -- (set by the rules in rules.lua). Bypasses compositing for real -- fullscreen games. - direct_scanout = 2, + direct_scanout = prefs.getInt("directScanoutPolicy", 2), }, cursor = { @@ -158,7 +160,7 @@ hl.config({ sync_gsettings_theme = true, -- Fade the cursor out after 4s of no movement, like GNOME does. - inactive_timeout = 4, + inactive_timeout = prefs.get("cursorInactiveTimeout", 4), }, ecosystem = { diff --git a/config/dot/hypr/prefs.lua b/config/dot/hypr/prefs.lua new file mode 100644 index 0000000..cafe792 --- /dev/null +++ b/config/dot/hypr/prefs.lua @@ -0,0 +1,252 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Shared preferences +-- +-- Reads the same file Panama Settings writes: +-- $XDG_CONFIG_HOME/panama/settings.json (default ~/.config/panama/settings.json) +-- +-- This is what makes the desktop one product rather than two that happen to +-- agree. The relationship is: +-- +-- * the Lua config is the DEFAULT -- every prefs.get() call passes the shipped +-- value as its fallback, so this config still works standalone with no JSON +-- file at all; +-- * the JSON file is the TRUTH -- both Hyprland and Quickshell read it; +-- * Panama Settings is the EDITOR -- it writes the file and applies the change +-- live through `hyprctl eval`, so nothing needs a reload and the two sides +-- cannot drift apart. +-- +-- Nothing here may raise. A missing, empty, truncated, or actively malformed +-- file must cost the user nothing worse than their customisations; it must +-- never cost them a working compositor. Every failure path returns the caller's +-- fallback. +-- ───────────────────────────────────────────────────────────────────────────── + +local prefs = {} + +-- ── A small JSON reader ───────────────────────────────────────────────────── +-- Hyprland's Lua has no JSON support and pulling in a rock for a flat object of +-- scalars is not worth the dependency. Handles the whole format apart from +-- non-ASCII \u escapes, which are replaced rather than decoded -- no setting is +-- a non-ASCII string, and mangling one is preferable to failing the parse. + +local function decode(text) + local pos = 1 + + local function skipSpace() + pos = text:find("[^ \t\r\n]", pos) or #text + 1 + end + + local parseValue + + local function parseString() + pos = pos + 1 -- opening quote + local parts = {} + while true do + local char = text:sub(pos, pos) + if char == "" then + error("unterminated string") + elseif char == '"' then + pos = pos + 1 + break + elseif char == "\\" then + local escape = text:sub(pos + 1, pos + 1) + local simple = { + n = "\n", t = "\t", r = "\r", b = "\b", f = "\f", + ['"'] = '"', ["\\"] = "\\", ["/"] = "/", + } + if simple[escape] then + parts[#parts + 1] = simple[escape] + pos = pos + 2 + elseif escape == "u" then + local code = tonumber(text:sub(pos + 2, pos + 5), 16) + parts[#parts + 1] = (code and code < 128) and string.char(code) or "?" + pos = pos + 6 + else + error("invalid escape") + end + else + parts[#parts + 1] = char + pos = pos + 1 + end + end + return table.concat(parts) + end + + local function parseNumber() + local literal = text:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos) + if not literal or literal == "" then + error("invalid number") + end + pos = pos + #literal + local value = tonumber(literal) + if not value then + error("invalid number") + end + return value + end + + local function parseObject() + pos = pos + 1 -- opening brace + local out = {} + skipSpace() + if text:sub(pos, pos) == "}" then + pos = pos + 1 + return out + end + while true do + skipSpace() + if text:sub(pos, pos) ~= '"' then + error("expected key") + end + local key = parseString() + skipSpace() + if text:sub(pos, pos) ~= ":" then + error("expected colon") + end + pos = pos + 1 + out[key] = parseValue() + skipSpace() + local char = text:sub(pos, pos) + pos = pos + 1 + if char == "}" then + return out + elseif char ~= "," then + error("expected comma or closing brace") + end + end + end + + local function parseArray() + pos = pos + 1 -- opening bracket + local out = {} + skipSpace() + if text:sub(pos, pos) == "]" then + pos = pos + 1 + return out + end + while true do + out[#out + 1] = parseValue() + skipSpace() + local char = text:sub(pos, pos) + pos = pos + 1 + if char == "]" then + return out + elseif char ~= "," then + error("expected comma or closing bracket") + end + end + end + + parseValue = function() + skipSpace() + local char = text:sub(pos, pos) + if char == "{" then + return parseObject() + elseif char == "[" then + return parseArray() + elseif char == '"' then + return parseString() + elseif text:sub(pos, pos + 3) == "true" then + pos = pos + 4 + return true + elseif text:sub(pos, pos + 4) == "false" then + pos = pos + 5 + return false + elseif text:sub(pos, pos + 3) == "null" then + pos = pos + 4 + return nil + elseif char == "" then + error("unexpected end of input") + else + return parseNumber() + end + end + + local value = parseValue() + if type(value) ~= "table" then + error("top level value is not an object") + end + return value +end + +-- ── Loading ───────────────────────────────────────────────────────────────── + +local function settingsPath() + local configHome = os.getenv("XDG_CONFIG_HOME") + if configHome == nil or configHome == "" then + local home = os.getenv("HOME") + if home == nil or home == "" then + return nil + end + configHome = home .. "/.config" + end + return configHome .. "/panama/settings.json" +end + +local function read() + local path = settingsPath() + if not path then + return {} + end + local file = io.open(path, "r") + if not file then + return {} -- no file yet is the normal first-run case, not an error + end + local text = file:read("*a") + file:close() + if not text or text:match("^%s*$") then + return {} + end + local ok, parsed = pcall(decode, text) + if ok and type(parsed) == "table" then + return parsed + end + return {} +end + +-- Loaded once at config time. A pcall around the whole thing so that even an +-- unanticipated failure in the reader degrades to shipped defaults. +local values = {} +do + local ok, parsed = pcall(read) + if ok and type(parsed) == "table" then + values = parsed + end +end + +-- ── Public interface ──────────────────────────────────────────────────────── + +-- Returns the stored value for `key`, or `fallback` when it is absent or is not +-- the same type as the fallback. The type guard matters: a stale or hand-edited +-- file that puts a string where Hyprland needs a number would otherwise abort +-- the config, taking down far more than the one setting that was wrong. +function prefs.get(key, fallback) + local value = values[key] + if value == nil then + return fallback + end + if type(value) ~= type(fallback) then + return fallback + end + return value +end + +-- Hyprland has no boolean-to-integer coercion for options that take 0/1, and +-- several of them read more naturally as a toggle in the settings UI. +function prefs.getInt(key, fallback) + local value = values[key] + if type(value) == "boolean" then + return value and 1 or 0 + end + if type(value) ~= "number" then + return fallback + end + return math.floor(value + 0.5) +end + +-- True when a settings file was actually read. Useful from overrides.lua. +function prefs.loaded() + return next(values) ~= nil +end + +return prefs diff --git a/config/dot/quickshell/config/DesktopPreferences.qml b/config/dot/quickshell/config/DesktopPreferences.qml index fdd6e50..552ca6e 100644 --- a/config/dot/quickshell/config/DesktopPreferences.qml +++ b/config/dot/quickshell/config/DesktopPreferences.qml @@ -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 } } diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml new file mode 100644 index 0000000..c98dd51 --- /dev/null +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -0,0 +1,398 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// The schema is the single source of truth for every user-changeable setting. +// +// One entry describes a setting completely: its name, type, default, valid +// range, which group it belongs to, and how to label it. Persistence, +// validation, reset, and (from Stage 3) the settings UI itself are all derived +// from these entries rather than restated. +// +// Adding a setting means adding one entry here. It does not mean editing a +// property alias, a JSON adapter, a change handler, and a reset function -- +// which is what it used to mean, and why the settings app stalled at sixteen +// knobs while forty comparable values stayed hardcoded one file away. +// +// Entry fields +// key unique identifier; also the JSON key on disk +// type "bool" | "int" | "real" | "string" | "enum" +// def shipped default, used when the file is absent or a value is invalid +// min/max inclusive bounds for int and real; values outside are clamped +// step UI increment for int and real +// options for "enum": [{ value, label }], the only accepted values +// group grouping id, used to build settings pages +// label short UI name +// detail one line explaining what changing it does +// internal true for state the shell keeps but the user never edits directly +// pattern for "string": a regular expression the value must match in full +// hypr present when the setting maps onto an Hyprland option: +// path the hl.config table path, e.g. ["decoration","blur","size"] +// option the getoption path used to read the value back +// readAs which field getoption returns it in -- "int", "bool", +// "float", "str", or "css" (gaps, returned as a box) +// +// Anything with a `hypr` block is applied live by services/SystemSettings.qml +// and read at startup by config/dot/hypr/prefs.lua, using the same key. The Lua +// keeps the shipped value as its fallback, so the config still works with no +// settings file at all. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import QtQuick + +Singleton { + id: root + + readonly property var entries: [ + // ── Clock ─────────────────────────────────────────────────────────── + { + key: "use24Hour", type: "bool", def: false, group: "clock", + label: "24-hour time", + detail: "Use 18:30 instead of 6:30 PM" + }, + { + key: "showSeconds", type: "bool", def: true, group: "clock", + label: "Show seconds", + detail: "Keep a precise clock in the center of the bar" + }, + { + key: "showWeekday", type: "bool", def: true, group: "clock", + label: "Show weekday", + detail: "Include the abbreviated weekday before the date" + }, + + // ── Vitals ────────────────────────────────────────────────────────── + { + key: "showCpu", type: "bool", def: true, group: "vitals", + label: "Processor", + detail: "Show processor usage beside the workspace indicator" + }, + { + key: "showMemory", type: "bool", def: true, group: "vitals", + label: "Memory", + detail: "Show memory usage beside the workspace indicator" + }, + { + key: "showGpu", type: "bool", def: true, group: "vitals", + label: "Graphics", + detail: "Show graphics usage beside the workspace indicator" + }, + + // ── Dock ──────────────────────────────────────────────────────────── + { + key: "dockAutohide", type: "bool", def: true, group: "dock", + label: "Automatically hide the Dock", + detail: "Reveal it at the bottom edge when a workspace is occupied" + }, + { + key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25, + group: "dock", + label: "Reveal delay", + detail: "Zero reveals the Dock the instant the pointer reaches the edge" + }, + { + key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25, + group: "dock", + label: "Hide delay", + detail: "Prevents flicker when crossing between icons" + }, + + // ── Focus ─────────────────────────────────────────────────────────── + { + key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5, + group: "focus", + label: "Focus session length", + detail: "How long a focus session runs before it ends itself" + }, + + // ── Display policy ────────────────────────────────────────────────── + // These three are written to the compositor and verified by read-back. + // See services/SystemSettings.qml for why the exit code cannot be + // trusted for either hyprctl keyword or hyprctl eval. + { + key: "autoHdr", type: "bool", def: true, group: "display", + label: "Game-aware HDR", + detail: "Hand HDR to fullscreen games while the desktop stays SDR", + hypr: { path: ["render", "cm_auto_hdr"], option: "render:cm_auto_hdr", readAs: "int" } + }, + { + key: "vrrPolicy", type: "enum", def: 3, group: "display", + label: "Variable refresh rate", + detail: "Content-aware matches the display to what is on screen", + options: [ + { value: 0, label: "Off" }, + { value: 3, label: "Content-aware" } + ], + hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" } + }, + { + key: "directScanoutPolicy", type: "enum", def: 2, group: "display", + label: "Direct scanout", + detail: "Lets fullscreen content bypass compositing", + options: [ + { value: 0, label: "Off" }, + { value: 2, label: "Automatic" } + ], + hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" } + }, + + // ── Window appearance ─────────────────────────────────────────────── + // These adjust the parameters of the Prism identity -- how much space, + // how soft, how much motion -- rather than replacing it. Shipped values + // are duplicated as the fallbacks in config/dot/hypr/looks.lua so that + // the Hyprland config still stands on its own. + { + key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1, + group: "windows", + label: "Inner gaps", + detail: "Space between neighbouring tiled windows", + hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" } + }, + { + key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1, + group: "windows", + label: "Outer gaps", + detail: "Space between the tiled area and the screen edge", + hypr: { path: ["general", "gaps_out"], option: "general:gaps_out", readAs: "css" } + }, + { + key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1, + group: "windows", + label: "Border width", + detail: "Thickness of the gradient border on the focused window", + hypr: { path: ["general", "border_size"], option: "general:border_size", readAs: "int" } + }, + { + key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1, + group: "windows", + label: "Corner radius", + detail: "Matches the shell's popover radius so windows and panels agree", + hypr: { path: ["decoration", "rounding"], option: "decoration:rounding", readAs: "int" } + }, + { + key: "inactiveOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05, + group: "windows", + label: "Unfocused window opacity", + detail: "Fade windows that do not have focus", + hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" } + }, + + // ── Effects ───────────────────────────────────────────────────────── + { + key: "blurEnabled", type: "bool", def: true, group: "effects", + label: "Blur", + detail: "Blur the desktop behind translucent surfaces", + hypr: { path: ["decoration", "blur", "enabled"], option: "decoration:blur:enabled", readAs: "bool" } + }, + { + key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1, + group: "effects", + label: "Blur radius", + detail: "Larger is softer and costs more frame time", + hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" } + }, + { + key: "blurPasses", type: "int", def: 3, min: 1, max: 5, step: 1, + group: "effects", + label: "Blur passes", + detail: "More passes look smoother and cost more frame time", + hypr: { path: ["decoration", "blur", "passes"], option: "decoration:blur:passes", readAs: "int" } + }, + { + key: "shadowEnabled", type: "bool", def: true, group: "effects", + label: "Window shadows", + detail: "Lift windows off the wallpaper with a soft shadow", + hypr: { path: ["decoration", "shadow", "enabled"], option: "decoration:shadow:enabled", readAs: "bool" } + }, + { + key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1, + group: "effects", + label: "Shadow size", + detail: "How far the shadow spreads from the window edge", + hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" } + }, + { + key: "glowEnabled", type: "bool", def: true, group: "effects", + label: "Focus glow", + detail: "A faint halo behind the focused window", + hypr: { path: ["decoration", "glow", "enabled"], option: "decoration:glow:enabled", readAs: "bool" } + }, + { + key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1, + group: "effects", + label: "Glow size", + detail: "Kept small deliberately: the gradient border is the signature", + hypr: { path: ["decoration", "glow", "range"], option: "decoration:glow:range", readAs: "int" } + }, + { + key: "animationsEnabled", type: "bool", def: true, group: "effects", + label: "Animations", + detail: "Window, workspace, and panel motion", + hypr: { path: ["animations", "enabled"], option: "animations:enabled", readAs: "bool" } + }, + + // ── Input ─────────────────────────────────────────────────────────── + { + key: "keyboardLayout", type: "string", def: "us", group: "input", + // Reaches an hl.config string, so it is constrained to the shape of + // an XKB layout list and nothing else. + pattern: "^[a-z]{2,8}(,[a-z]{2,8})*$", + label: "Keyboard layout", + detail: "XKB layout name, or a comma-separated list to switch between", + hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" } + }, + { + key: "numlockByDefault", type: "bool", def: true, group: "input", + label: "Num Lock on login", + detail: "Turn Num Lock on when the session starts", + hypr: { path: ["input", "numlock_by_default"], option: "input:numlock_by_default", readAs: "bool" } + }, + { + key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25, + group: "input", + label: "Repeat delay", + detail: "How long a key is held before it starts repeating", + hypr: { path: ["input", "repeat_delay"], option: "input:repeat_delay", readAs: "int" } + }, + { + key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1, + group: "input", + label: "Repeat rate", + detail: "How many characters a second a held key produces", + hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" } + }, + { + key: "followMouse", type: "enum", def: 1, group: "input", + label: "Focus follows pointer", + detail: "Click to focus matches GNOME; sloppy focus follows the pointer", + options: [ + { value: 0, label: "Never" }, + { value: 1, label: "Click to focus" }, + { value: 2, label: "Sloppy focus" } + ], + hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" } + }, + { + key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05, + group: "input", + label: "Pointer speed", + detail: "Zero is flat, unaccelerated response", + hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" } + }, + { + key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1, + group: "input", + label: "Hide pointer after", + detail: "Seconds of stillness before the pointer fades out; 0 never hides it", + hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "int" } + }, + + // ── Night light ───────────────────────────────────────────────────── + { + key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", + label: "Night Light", + detail: "Shift the display warmer to reduce blue light" + }, + { + key: "nightLightAutomatic", type: "bool", def: false, group: "nightLight", + label: "Schedule automatically", + detail: "Turn Night Light on and off at the scheduled hours" + }, + { + key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100, + group: "nightLight", + label: "Color temperature", + detail: "Lower is warmer" + }, + + // ── Internal ──────────────────────────────────────────────────────── + { + key: "lastPage", type: "string", def: "home", group: "internal", + internal: true, + label: "Last settings page", + detail: "Restores the page Settings was left on" + } + ] + + // key -> entry, built once. Every lookup below goes through this rather than + // scanning `entries`, since get/set are called from bindings. + readonly property var byKey: { + const index = {}; + for (const entry of root.entries) + index[entry.key] = entry; + return index; + } + + readonly property var userKeys: root.entries.filter(entry => !entry.internal).map(entry => entry.key) + + function spec(key: string): var { + return root.byKey[key] ?? null; + } + + function has(key: string): bool { + return root.byKey[key] !== undefined; + } + + function defaultFor(key: string): var { + const entry = root.byKey[key]; + return entry ? entry.def : undefined; + } + + function defaults(): var { + const out = {}; + for (const entry of root.entries) + out[entry.key] = entry.def; + return out; + } + + function inGroup(group: string): var { + return root.entries.filter(entry => entry.group === group && !entry.internal); + } + + // Entries the compositor owns, used to build one hl.config{} payload. + function hyprEntries(): var { + return root.entries.filter(entry => entry.hypr !== undefined); + } + + // Returns the value coerced into the entry's type and range, or `undefined` + // if it cannot be represented at all. Out-of-range numbers are clamped + // rather than rejected: a stale file with a since-narrowed bound should + // still yield a usable desktop. + function coerce(key: string, value: var): var { + const entry = root.byKey[key]; + if (!entry || value === undefined || value === null) + return undefined; + + switch (entry.type) { + case "bool": + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + return undefined; + + case "int": + case "real": { + const numeric = Number(value); + if (!isFinite(numeric)) return undefined; + const rounded = entry.type === "int" ? Math.round(numeric) : numeric; + const lower = entry.min !== undefined ? Math.max(rounded, entry.min) : rounded; + return entry.max !== undefined ? Math.min(lower, entry.max) : lower; + } + + case "enum": + return entry.options.some(option => option.value === value) ? value : undefined; + + case "string": { + const text = typeof value === "string" ? value : String(value); + // A constrained string is rejected rather than sanitised. Several + // of these are serialised into an hl.config payload, and quietly + // stripping characters would turn a typo into a different setting + // instead of an error the user can see. + if (entry.pattern && !new RegExp(entry.pattern).test(text)) + return undefined; + return text; + } + } + return undefined; + } +} diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index 94d5be5..494351b 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -15,9 +15,9 @@ Singleton { // ── Clock ─────────────────────────────────────────────────────────────── // Carried over from GNOME: 12-hour, weekday + date shown, seconds on. - readonly property bool use24Hour: DesktopPreferences.use24Hour - readonly property bool showSeconds: DesktopPreferences.showSeconds - readonly property bool showWeekday: DesktopPreferences.showWeekday + readonly property bool use24Hour: DesktopPreferences.get("use24Hour") + readonly property bool showSeconds: DesktopPreferences.get("showSeconds") + readonly property bool showWeekday: DesktopPreferences.get("showWeekday") // ── Weather ───────────────────────────────────────────────────────────── // Coordinates taken from the GNOME night-light setting, which had already @@ -35,9 +35,9 @@ Singleton { // The GNOME Vitals extension showed processor usage, memory usage and GPU // usage, in that order. Same here. readonly property int vitalsIntervalMs: 2000 - readonly property bool showCpu: DesktopPreferences.showCpu - readonly property bool showMemory: DesktopPreferences.showMemory - readonly property bool showGpu: DesktopPreferences.showGpu + readonly property bool showCpu: DesktopPreferences.get("showCpu") + readonly property bool showMemory: DesktopPreferences.get("showMemory") + readonly property bool showGpu: DesktopPreferences.get("showGpu") // amdgpu exposes utilisation here. Verified present on this machine; the // widget hides itself if the path is missing rather than showing zeros. @@ -45,10 +45,10 @@ Singleton { // ── Night light ───────────────────────────────────────────────────────── // Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00. - readonly property int nightLightTemperature: DesktopPreferences.nightLightTemperature + readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature") readonly property real nightLightFrom: 17.0 readonly property real nightLightTo: 10.0 - readonly property bool nightLightEnabledByDefault: DesktopPreferences.nightLightEnabled + readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled") // ── Notifications ─────────────────────────────────────────────────────── readonly property int notificationTimeoutMs: 5000 @@ -59,7 +59,7 @@ Singleton { // ── Focus ────────────────────────────────────────────────────────────── // One deliberate default rather than a preset picker: quick settings and // the keyboard shortcut should start a useful session in one action. - readonly property int focusDurationMinutes: DesktopPreferences.focusDurationMinutes + readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes") // ── Dock ──────────────────────────────────────────────────────────────── // Pinned apps, in order, taken from the GNOME dash favourites. @@ -84,16 +84,16 @@ Singleton { // Dash-to-Dock was set to intellihide against all windows: the dock hides // when any window would overlap it, and comes back on hover. - readonly property bool dockAutohide: DesktopPreferences.dockAutohide + readonly property bool dockAutohide: DesktopPreferences.get("dockAutohide") // 0: reveal the instant the pointer reaches the bottom edge. A reveal delay // is indistinguishable from lag, because the user has already committed to // the gesture by the time the strip is hit. - readonly property int dockRevealDelayMs: DesktopPreferences.dockRevealDelayMs + readonly property int dockRevealDelayMs: DesktopPreferences.get("dockRevealDelayMs") // Hiding keeps a delay, so brushing past the bottom edge or crossing the // gap between two icons doesn't make the dock flicker. - readonly property int dockHideDelayMs: DesktopPreferences.dockHideDelayMs + readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs") // ── Capture ───────────────────────────────────────────────────────────── readonly property string screenshotDir: "Pictures/Screenshots" diff --git a/config/dot/quickshell/config/qmldir b/config/dot/quickshell/config/qmldir index 584b488..d2ae946 100644 --- a/config/dot/quickshell/config/qmldir +++ b/config/dot/quickshell/config/qmldir @@ -1,4 +1,5 @@ module qs.config singleton DesktopPreferences 1.0 DesktopPreferences.qml +singleton PreferenceSchema 1.0 PreferenceSchema.qml singleton Settings 1.0 Settings.qml singleton Theme 1.0 Theme.qml diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index b95151d..0380ada 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -32,29 +32,29 @@ Item { label: "24-hour time" detail: "Use 18:30 instead of 6:30 PM" controlWidth: 48 - SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.use24Hour; onToggled: value => DesktopPreferences.use24Hour = value } + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("use24Hour"); onToggled: value => DesktopPreferences.set("use24Hour", value) } } SettingRow { label: "Show seconds" detail: "Keep a precise clock in the center of the bar" controlWidth: 48 - SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showSeconds; onToggled: value => DesktopPreferences.showSeconds = value } + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showSeconds"); onToggled: value => DesktopPreferences.set("showSeconds", value) } } SettingRow { label: "Show weekday" detail: "Include the abbreviated weekday before the date" divider: false controlWidth: 48 - SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showWeekday; onToggled: value => DesktopPreferences.showWeekday = value } + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showWeekday"); onToggled: value => DesktopPreferences.set("showWeekday", value) } } } SettingsCard { title: "System vitals" subtitle: "Choose what appears beside the workspace indicator." - SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value } } - SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showMemory; onToggled: value => DesktopPreferences.showMemory = value } } - SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showGpu; onToggled: value => DesktopPreferences.showGpu = value } } + SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showCpu"); onToggled: value => DesktopPreferences.set("showCpu", value) } } + SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showMemory"); onToggled: value => DesktopPreferences.set("showMemory", value) } } + SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showGpu"); onToggled: value => DesktopPreferences.set("showGpu", value) } } } } } diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index 71009b0..f949333 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -25,10 +25,10 @@ Item { label: "Automatically hide the Dock" detail: "Reveal it at the bottom edge when a workspace is occupied" controlWidth: 48 - SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.dockAutohide; onToggled: value => DesktopPreferences.dockAutohide = value } + SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("dockAutohide"); onToggled: value => DesktopPreferences.set("dockAutohide", value) } } - SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.dockRevealDelayMs === 0 ? "Instant" : `${DesktopPreferences.dockRevealDelayMs} ms` } - SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.dockHideDelayMs} ms`; divider: false } + SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.get("dockRevealDelayMs") === 0 ? "Instant" : `${DesktopPreferences.get("dockRevealDelayMs")} ms` } + SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.get("dockHideDelayMs")} ms`; divider: false } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml index d06c088..82fc817 100644 --- a/config/dot/quickshell/modules/settings/NotificationsPage.qml +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -54,8 +54,8 @@ Item { SettingsButton { required property int modelData text: `${modelData}m` - tone: DesktopPreferences.focusDurationMinutes === modelData ? "accent" : "normal" - onClicked: DesktopPreferences.focusDurationMinutes = modelData + tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal" + onClicked: DesktopPreferences.set("focusDurationMinutes", modelData) } } } diff --git a/config/dot/quickshell/preference-schema-harness.qml b/config/dot/quickshell/preference-schema-harness.qml new file mode 100644 index 0000000..0908b08 --- /dev/null +++ b/config/dot/quickshell/preference-schema-harness.qml @@ -0,0 +1,48 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config + +ShellRoot { + IpcHandler { + target: "preference-schema-test" + + // Values arrive as a JSON object so the contract can exercise real + // types -- booleans, integers, and strings -- through one entry point. + // Returns the per-key result of set(), so a rejection is observable + // rather than inferred from the value not changing. + function applyJson(payload: string): string { + const requested = JSON.parse(payload); + const accepted = {}; + for (const key in requested) + accepted[key] = DesktopPreferences.set(key, requested[key]); + return JSON.stringify(accepted); + } + + // Every schema key and its effective value. + function dump(): string { + const out = {}; + for (const entry of PreferenceSchema.entries) + out[entry.key] = DesktopPreferences.get(entry.key); + return JSON.stringify(out); + } + + // The raw in-memory store, including keys this build does not know. + function raw(): string { + return JSON.stringify(DesktopPreferences.values); + } + + function defaults(): string { + return JSON.stringify(PreferenceSchema.defaults()); + } + + function reset(): void { + DesktopPreferences.resetDesktopDefaults(); + } + + function keyCount(): int { + return PreferenceSchema.entries.length; + } + } +} diff --git a/config/dot/quickshell/services/NightLight.qml b/config/dot/quickshell/services/NightLight.qml index c1a9054..957fc2b 100644 --- a/config/dot/quickshell/services/NightLight.qml +++ b/config/dot/quickshell/services/NightLight.qml @@ -33,7 +33,7 @@ Singleton { // Follow Settings.nightLightFrom .. nightLightTo instead of the manual // switch. Off by default because GNOME's schedule was disabled. - property bool automatic: DesktopPreferences.nightLightAutomatic + property bool automatic: DesktopPreferences.get("nightLightAutomatic") // What is actually applied right now. readonly property bool active: root.automatic ? root.scheduled : root.enabled @@ -81,13 +81,13 @@ Singleton { // Re-tune in place; restarting the daemon would flash the display. onTemperatureChanged: { - DesktopPreferences.nightLightTemperature = root.temperature; + DesktopPreferences.set("nightLightTemperature", root.temperature); if (daemon.running) Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", String(root.temperature)]); } - onEnabledChanged: DesktopPreferences.nightLightEnabled = root.enabled - onAutomaticChanged: DesktopPreferences.nightLightAutomatic = root.automatic + onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled) + onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic) onActiveChanged: { if (!root.initialized) diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index dd503de..5555bdf 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -94,7 +94,7 @@ Singleton { function openSettings(page: string): void { const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; - DesktopPreferences.lastPage = root.settingsPage; + DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; } @@ -103,7 +103,7 @@ Singleton { root.closeSettings(); return; } - root.openSettings(DesktopPreferences.lastPage || "home"); + root.openSettings(DesktopPreferences.get("lastPage") || "home"); } function closeSettings(): void { diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index 9f83aef..29365e8 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -33,11 +33,32 @@ Singleton { property string lastError: "" readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running - || autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running + || configWrite.running || configVerify.running - readonly property bool autoHdr: DesktopPreferences.autoHdr - readonly property int vrrPolicy: DesktopPreferences.vrrPolicy - readonly property int directScanoutPolicy: DesktopPreferences.directScanoutPolicy + readonly property bool autoHdr: DesktopPreferences.get("autoHdr") + readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy") + readonly property int directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy") + + // ── The Hyprland write boundary ───────────────────────────────────────── + // Every option Panama may write, with the hl.config path used to set it and + // the getoption path used to read it back. The UI never names an option or + // supplies a raw value: it calls a setter, which resolves the option here + // and range-checks the value against `allowed`. Nothing user-supplied is + // ever interpolated into the payload. + // + // `hyprctl keyword` is deliberately NOT used. On a Lua-configured Hyprland + // it refuses the write, prints "keyword can't work with non-legacy parsers" + // to stdout, and still exits 0 -- so code branching on the exit status + // believes it succeeded. `hyprctl eval` has the same hazard: it exits 0 on + // syntax and runtime errors, reporting them as an "error:" line instead. + // + // Success therefore means exactly one thing here: the value was read back + // from the compositor and matched what was requested. + // + // The set of writable options is not restated here: it is every schema + // entry carrying a `hypr` block. Adding a live-adjustable Hyprland setting + // is a schema entry plus a prefs.get() call in the Lua, and needs no new + // code in this file. Process { id: monitorQuery @@ -79,42 +100,33 @@ Singleton { } } + // Applies a validated batch of options in one `hl.config{}` call, then hands + // off to configVerify. Never commits anything on its own: an "ok" here only + // means Hyprland parsed the payload. Process { - id: autoHdrWrite - property bool requested: true - onExited: (exitCode, exitStatus) => { - if (exitCode === 0) { - DesktopPreferences.autoHdr = requested; - root.lastError = ""; - } else { - root.lastError = "Hyprland rejected the HDR policy."; + id: configWrite + + // id -> integer value, already validated by applyOptions(). + property var pending: ({}) + + stdout: StdioCollector { + onStreamFinished: { + if (this.text.indexOf("error:") >= 0) { + root.reportWriteFailure(configWrite.pending, this.text); + return; + } + root.verifyPending(); } } } + // Reads the written options back out of the compositor. This is the only + // thing that decides whether a write succeeded. Process { - id: vrrWrite - property int requested: 3 - onExited: (exitCode, exitStatus) => { - if (exitCode === 0) { - DesktopPreferences.vrrPolicy = requested; - root.lastError = ""; - } else { - root.lastError = "Hyprland rejected the VRR policy."; - } - } - } + id: configVerify - Process { - id: directScanoutWrite - property int requested: 2 - onExited: (exitCode, exitStatus) => { - if (exitCode === 0) { - DesktopPreferences.directScanoutPolicy = requested; - root.lastError = ""; - } else { - root.lastError = "Hyprland rejected the direct-scanout policy."; - } + stdout: StdioCollector { + onStreamFinished: root.commitVerified(configWrite.pending, this.text) } } @@ -172,33 +184,167 @@ Singleton { } } + // ── Applying options ──────────────────────────────────────────────────── + // `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }. + // The whole batch is validated before anything is sent, so one bad value + // rejects the batch rather than half-applying it. + function applyOptions(values: var): bool { + const requested = {}; + for (const key in values) { + const entry = PreferenceSchema.spec(key); + if (!entry || !entry.hypr) { + root.lastError = "That setting is not applied by the compositor."; + return false; + } + const coerced = PreferenceSchema.coerce(key, values[key]); + if (coerced === undefined) { + root.lastError = `Unsupported value for ${entry.label}.`; + return false; + } + requested[key] = coerced; + } + if (Object.keys(requested).length === 0) + return false; + if (configWrite.running || configVerify.running) { + root.lastError = "Another change is still being applied."; + return false; + } + + configWrite.pending = requested; + configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]); + return true; + } + + // The value as Hyprland stores it. Several options are a toggle in the UI + // but an integer in the compositor (cm_auto_hdr, follow_mouse); `readAs` + // decides, and config/dot/hypr/prefs.lua does the same conversion via + // prefs.getInt so both sides agree. + function hyprValue(entry: var, value: var): var { + if (typeof value === "boolean" && entry.hypr.readAs !== "bool") + return value ? 1 : 0; + return value; + } + + // Serialises validated values into a nested hl.config{} call. Table paths + // come from the schema and values have already passed coerce(), including + // the pattern check on constrained strings, so nothing caller-supplied + // reaches the payload unchecked. + function buildConfigPayload(requested: var): string { + const tree = {}; + for (const key in requested) { + const entry = PreferenceSchema.spec(key); + const path = entry.hypr.path; + let node = tree; + for (let i = 0; i < path.length - 1; i++) + node = node[path[i]] = node[path[i]] ?? {}; + node[path[path.length - 1]] = root.serialiseValue(root.hyprValue(entry, requested[key])); + } + return `hl.config(${root.serialiseTable(tree)})`; + } + + function serialiseValue(value: var): string { + if (typeof value === "boolean") + return value ? "true" : "false"; + if (typeof value === "number") + return String(value); + // Strings only reach here after the schema's pattern check; quoting is + // belt-and-braces rather than the primary defence. + return `"${String(value).replace(/["\\]/g, "")}"`; + } + + function serialiseTable(node: var): string { + const parts = []; + for (const name in node) { + const child = node[name]; + parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`); + } + return `{ ${parts.join(", ")} }`; + } + + function verifyPending(): void { + const options = Object.keys(configWrite.pending) + .map(key => `getoption ${PreferenceSchema.spec(key).hypr.option}`) + .join(" ; "); + configVerify.exec(["hyprctl", "-j", "--batch", options]); + } + + // The compositor's answer is authoritative. Preferences are only updated for + // options that actually read back with the requested value. + function commitVerified(requested: var, text: string): void { + // Each getoption answers with its own flat JSON object, and the field + // carrying the value depends on the option's type -- int, bool, float, + // str, or css for the gap box. + const observed = {}; + for (const block of text.match(/\{[^{}]*\}/g) ?? []) { + try { + const parsed = JSON.parse(block); + if (parsed.option !== undefined) + observed[parsed.option] = parsed; + } catch (error) { + // A partial line is treated as "not observed", which fails the + // comparison below rather than being mistaken for success. + } + } + + const rejected = []; + for (const key in requested) { + const entry = PreferenceSchema.spec(key); + if (!root.matchesObserved(entry, requested[key], observed[entry.hypr.option])) { + rejected.push(entry.label); + continue; + } + DesktopPreferences.set(key, requested[key]); + } + + root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`; + } + + function matchesObserved(entry: var, value: var, answer: var): bool { + if (!answer) + return false; + const expected = root.hyprValue(entry, value); + switch (entry.hypr.readAs) { + case "bool": + return answer.bool === expected; + case "int": + return answer.int === expected; + case "float": + // getoption prints six decimal places; compare within that. + return Math.abs(answer.float - expected) < 1e-5; + case "str": + return answer.str === expected; + case "css": + // Gaps read back as a box, e.g. "10 10 10 10". + return Number(String(answer.css).trim().split(/\s+/)[0]) === expected; + } + return false; + } + + function reportWriteFailure(requested: var, text: string): void { + const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label); + root.lastError = `Hyprland rejected ${labels.join(" and ")}.`; + } + function setAutoHdr(enabled: bool): void { - autoHdrWrite.requested = enabled; - autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]); + root.applyOptions({ autoHdr: enabled }); } function setVrrPolicy(policy: int): void { - if (policy !== 0 && policy !== 3) { - root.lastError = "Unsupported VRR policy."; - return; - } - vrrWrite.requested = policy; - vrrWrite.exec(["hyprctl", "keyword", "misc:vrr", String(policy)]); + root.applyOptions({ vrrPolicy: policy }); } function setDirectScanoutPolicy(policy: int): void { - if (policy !== 0 && policy !== 2) { - root.lastError = "Unsupported direct-scanout policy."; - return; - } - directScanoutWrite.requested = policy; - directScanoutWrite.exec(["hyprctl", "keyword", "render:direct_scanout", String(policy)]); + root.applyOptions({ directScanoutPolicy: policy }); } + // Replays every compositor-owned preference in one batch at shell start, so + // a value the user changed in Settings survives a reboot even though the + // Lua config only reads the file once, at launch. function applyPersistedDisplayPolicy(): void { - root.setAutoHdr(DesktopPreferences.autoHdr); - root.setVrrPolicy(DesktopPreferences.vrrPolicy); - root.setDirectScanoutPolicy(DesktopPreferences.directScanoutPolicy); + const values = {}; + for (const entry of PreferenceSchema.hyprEntries()) + values[entry.key] = DesktopPreferences.get(entry.key); + root.applyOptions(values); } function isGnomePanelAllowed(panel: string): bool { diff --git a/config/dot/quickshell/settings-preferences-harness.qml b/config/dot/quickshell/settings-preferences-harness.qml index 95b0595..1a5bfea 100644 --- a/config/dot/quickshell/settings-preferences-harness.qml +++ b/config/dot/quickshell/settings-preferences-harness.qml @@ -9,32 +9,32 @@ ShellRoot { target: "settings-pref-test" function applyFixture(): void { - DesktopPreferences.use24Hour = true; - DesktopPreferences.showSeconds = false; - DesktopPreferences.dockAutohide = false; - DesktopPreferences.focusDurationMinutes = 70; - DesktopPreferences.autoHdr = false; - DesktopPreferences.vrrPolicy = 0; - DesktopPreferences.directScanoutPolicy = 0; - DesktopPreferences.nightLightEnabled = true; - DesktopPreferences.nightLightAutomatic = true; - DesktopPreferences.nightLightTemperature = 4100; - DesktopPreferences.lastPage = "desktop"; + DesktopPreferences.set("use24Hour", true); + DesktopPreferences.set("showSeconds", false); + DesktopPreferences.set("dockAutohide", false); + DesktopPreferences.set("focusDurationMinutes", 70); + DesktopPreferences.set("autoHdr", false); + DesktopPreferences.set("vrrPolicy", 0); + DesktopPreferences.set("directScanoutPolicy", 0); + DesktopPreferences.set("nightLightEnabled", true); + DesktopPreferences.set("nightLightAutomatic", true); + DesktopPreferences.set("nightLightTemperature", 4100); + DesktopPreferences.set("lastPage", "desktop"); } function status(): string { return JSON.stringify({ - use24Hour: DesktopPreferences.use24Hour, - showSeconds: DesktopPreferences.showSeconds, - dockAutohide: DesktopPreferences.dockAutohide, - focusDurationMinutes: DesktopPreferences.focusDurationMinutes, - autoHdr: DesktopPreferences.autoHdr, - vrrPolicy: DesktopPreferences.vrrPolicy, - directScanoutPolicy: DesktopPreferences.directScanoutPolicy, - nightLightEnabled: DesktopPreferences.nightLightEnabled, - nightLightAutomatic: DesktopPreferences.nightLightAutomatic, - nightLightTemperature: DesktopPreferences.nightLightTemperature, - lastPage: DesktopPreferences.lastPage, + use24Hour: DesktopPreferences.get("use24Hour"), + showSeconds: DesktopPreferences.get("showSeconds"), + dockAutohide: DesktopPreferences.get("dockAutohide"), + focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes"), + autoHdr: DesktopPreferences.get("autoHdr"), + vrrPolicy: DesktopPreferences.get("vrrPolicy"), + directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy"), + nightLightEnabled: DesktopPreferences.get("nightLightEnabled"), + nightLightAutomatic: DesktopPreferences.get("nightLightAutomatic"), + nightLightTemperature: DesktopPreferences.get("nightLightTemperature"), + lastPage: DesktopPreferences.get("lastPage"), stateDir: Quickshell.stateDir }); } diff --git a/config/dot/quickshell/settings-system-harness.qml b/config/dot/quickshell/settings-system-harness.qml index 5e110b4..f6f44c3 100644 --- a/config/dot/quickshell/settings-system-harness.qml +++ b/config/dot/quickshell/settings-system-harness.qml @@ -11,10 +11,22 @@ ShellRoot { function refresh(): void { SystemSettings.refresh(); } + // One batch, matching how the shell applies persisted policy. Three + // separate setters would be refused as overlapping writes, since each + // one is only complete after its value has been read back. function apply(autoHdr: bool, vrr: int, directScanout: int): void { - SystemSettings.setAutoHdr(autoHdr); - SystemSettings.setVrrPolicy(vrr); - SystemSettings.setDirectScanoutPolicy(directScanout); + SystemSettings.applyOptions({ + autoHdr: autoHdr, + vrrPolicy: vrr, + directScanoutPolicy: directScanout + }); + } + + // Applies an arbitrary batch of schema keys, so the contract can cover + // every getoption answer shape -- int, bool, float, str, and the css + // box that gaps read back as -- not just the display policies. + function applyJson(payload: string): bool { + return SystemSettings.applyOptions(JSON.parse(payload)); } function panelAllowed(panel: string): bool { diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 09a8396..e7450da 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -273,7 +273,7 @@ ShellRoot { IpcHandler { target: "settings" - function open(): void { ShellState.openSettings(DesktopPreferences.lastPage || "home"); } + function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); } function toggle(): void { ShellState.toggleSettings(); } function close(): void { ShellState.closeSettings(); } function page(name: string): void { ShellState.openSettings(name); } diff --git a/docs/superpowers/plans/2026-08-17-panama-cohesion.md b/docs/superpowers/plans/2026-08-17-panama-cohesion.md new file mode 100644 index 0000000..f48820d --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-panama-cohesion.md @@ -0,0 +1,240 @@ +# Panama Cohesion Implementation Plan + +**Goal:** Make Panama one product instead of several good parts. A user changes +how their desktop looks and behaves entirely from Panama Settings; the Lua config +holds the shipped defaults; one JSON file is the truth both sides read. + +**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md` + +**Tech Stack:** Quickshell 0.3.0, Qt 6 QML, Hyprland 0.56.2 (Lua config), +`hyprctl eval`, Bash contract tests. + +## Global constraints + +- `hyprctl keyword` is banned. It exits 0 without acting on this build. +- Verify every write by reading the value back (`hyprctl getoption`), never by + trusting an exit code. +- No UI-supplied string is interpolated into `eval`, a shell command, or a config + value. Numbers range-checked, choices allow-listed, colours hex-validated. +- A missing or malformed `settings.json` degrades to shipped defaults. The Lua + read is `pcall`-wrapped so it can never take down the compositor config. +- Tokyo Night Moon and Prism stay the only identity; customisation adjusts its + parameters, it does not replace it. +- Motion stays event-driven at every setting. No idle repaint. +- Do not restart the running Quickshell process during development; it hot-reloads. +- Keep unrelated in-flight Panama work untouched (see the + `feat/home-accessories-customization` worktree). + +--- + +## Stage 0 — Stop the lying (ship first, standalone) + +The display-policy toggles report success while doing nothing. This is a +correctness bug in shipped behaviour and does not depend on any of the +architecture below. + +**Files:** Modify `config/dot/quickshell/services/SystemSettings.qml`; +Test `tests/quickshell/settings-hyprland-write-contract.sh` + +- [x] Write a contract that sets a display policy through `SystemSettings`, then + asserts via `hyprctl getoption` that the compositor value actually changed — + and that a rejected write leaves `lastError` non-empty. +- [x] Run it; confirm it fails against the current `hyprctl keyword` implementation. +- [x] Add a single `applyOptions(values)` boundary that serialises validated values + into `hl.config{}` and runs them through `hyprctl eval`. +- [x] Route `setAutoHdr`, `setVrrPolicy`, and `setDirectScanoutPolicy` through it. +- [x] Treat "wrote it back and read it back equal" as the only success condition; + persist to preferences only on verified success. +- [x] Run the contract to green, and confirm live that HDR/VRR/scanout change. + +**Exit criteria:** the three toggles do what they claim, and a failed write says so. + +**Landed.** `hyprctl eval` also exits 0 on syntax and runtime errors — it reports +them as an `error:` line on stdout — so exit status is useless for both commands. +`applyOptions` therefore parses stdout for the error line *and* reads every +written option back with `hyprctl -j --batch getoption`, committing to +preferences only for options that read back equal. A `writableOptions` registry +holds the group/key/option path and allow-list per option, so the UI never names +an option or supplies an unchecked value. Policy is applied as one batch, so the +shell cannot come up half-configured. + +New: `tests/quickshell/settings-hyprland-write-contract.sh` — flips each policy +to a value it does not hold and reads it back, so a no-op write cannot pass. +The pre-existing `settings-system-contract.sh` re-applied the values already in +place, which is why it passed throughout the outage. + +--- + +## Stage 1 — One schema, one store + +**Files:** Create `config/dot/quickshell/config/PreferenceSchema.qml`; +Modify `config/dot/quickshell/config/DesktopPreferences.qml`, +`config/dot/quickshell/config/Settings.qml`; +Test `tests/quickshell/preference-schema-contract.sh` + +Schema entry shape: + +```qml +{ key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25, + group: "dock", label: "Hide delay", + detail: "Prevents flicker when crossing icons" } +``` + +- [x] Write a contract asserting: every schema key round-trips through disk; + an out-of-range value is clamped rather than stored; an unknown key in the + file is preserved rather than dropped; and `reset()` returns *every* key to + its schema default with no hand-maintained list. +- [x] Run it; confirm it fails. +- [x] Author `PreferenceSchema.qml` covering the existing 16 user-facing keys. +- [x] Rewrite `DesktopPreferences` to derive persistence, change notification, + validation, and reset from the schema — deleting the four-places-per-key + boilerplate and `resetDesktopDefaults()`'s hand-written body. +- [x] Move the store to `~/.config/panama/settings.json`, migrating the existing + file from `Quickshell.stateDir` on first run if present. +- [x] Keep `Settings.qml` as the stable public read surface; existing consumers + must not change. +- [x] Run the new contract plus `settings-preferences-contract.sh` and + `settings-pages-contract.sh` to green. + +**Exit criteria:** adding a setting is one schema line; reset is complete by +construction; the store lives at a stable, user-visible path. + +**Landed.** Reads go through `DesktopPreferences.get(key)` and writes through +`set(key, value)`; a `revision` counter gives function-call bindings something to +invalidate, which a bare call would not have. `Settings.qml` stays the typed +public surface — every consumer outside it was already reading through it. +`set()` returns false on an unknown key or an unrepresentable value, so a +rejection is observable instead of inferred. + +Unknown keys on disk are carried through writes untouched, so rolling back to an +older Panama does not discard a newer version's settings. A corrupt file falls +back to shipped defaults rather than costing the user a working desktop. Both are +pinned by contract. + +Migration reads the old `Quickshell.stateDir` file only when the new one is +absent, and never deletes the original. Verified live: the running shell adopted +all 17 values into `~/.config/panama/settings.json` with the legacy files intact. + +New: `config/PreferenceSchema.qml`, `preference-schema-harness.qml`, +`tests/quickshell/preference-schema-contract.sh`. Full suite green — 6 settings +contracts, 22 other Quickshell contracts, 3 Python bridge tests. + +--- + +## Stage 2 — Hyprland reads the same file + +**Files:** Create `config/dot/hypr/prefs.lua`; +Modify `config/dot/hypr/hyprland.lua`, `looks.lua`, `input.lua`, `monitors.lua`; +Test `tests/hypr/prefs-fallback-contract.sh` + +- [x] Write a contract that verifies `Hyprland --verify-config` passes with the + file absent, empty, truncated mid-object, and containing wrong-typed values — + and that each case yields the shipped default. +- [x] Run it; confirm it fails (no `prefs.lua` yet). +- [x] Implement a dependency-free JSON reader exposing `prefs.get(key, fallback)`, + `pcall`-wrapped, reading `$XDG_CONFIG_HOME/panama/settings.json`. +- [x] Require it first in `hyprland.lua`, before `looks`. +- [x] Convert the appearance and behaviour literals in `looks.lua` and `input.lua` + to `prefs.get("", )`, keeping every current value as + the fallback so shipped behaviour is byte-identical. +- [x] Extend `PreferenceSchema.qml` with the Hyprland-owned keys, each carrying + the `hl.config` path it maps to. +- [x] Have `SystemSettings` derive its `eval` payload from that mapping, so a new + Hyprland setting needs no new writer code. +- [x] Run the contract, `Hyprland --verify-config`, and a live reload to green. + +**Exit criteria:** one file is the truth; the Lua is the default; Settings is the +editor; changes apply live *and* survive a reboot. + +**Landed.** The compositor-adjustable surface went from 3 keys to 22. +`SystemSettings` no longer names any option: it walks `PreferenceSchema.hyprEntries()`, +builds one nested `hl.config{}` payload from each entry's table path, and verifies +against the entry's `option` path. Adding a live-adjustable Hyprland setting is now +a schema entry plus a `prefs.get` call, with no new writer code. + +Verification had to learn the compositor's answer shapes: `getoption` returns the +value in a different field per type — `int`, `bool`, `float`, `str`, and `css` for +gaps, which read back as a four-value box (`"10 10 10 10"`). A verifier that only +understood `int` would have reported every other type as rejected. All five are +covered by contract. + +`keyboardLayout` is the first setting whose value reaches an `hl.config` string, +so the schema gained a `pattern` field enforced in `coerce()`. The contract +includes a Lua-injection attempt through it; the value is rejected, nothing +executes, and the layout is unchanged. + +Two test-hygiene bugs found and fixed along the way, both pre-existing in shape: +`settings-window-contract` leaves a Settings window that the compositor destroys +asynchronously, which made `settings-pages-contract` see a duplicate when run +straight after it — the pages contract now waits for a clean slate. And the new +write contract was persisting its deliberately-wrong values into the *real* +`~/.config/panama/settings.json`, where the next `hyprctl reload` would faithfully +apply them; it now runs against an isolated `XDG_CONFIG_HOME` while still driving +the live compositor. + +Verified live: writing `gapsOut`/`windowRounding` into the shared file and running +`hyprctl reload` — the path a fresh login takes — applied both, and the compositor +and store agree on every key. Full suite green: 29 shell contracts, 3 Python +bridge tests, run sequentially. + +--- + +## Stage 3 — Generic rows, then fill the pages + +Per the visual-work rule, this stage stops for a decision before any page is +rewritten. + +**Files:** Create `modules/settings/SettingsPage.qml`, `ToggleRow.qml`, +`SliderRow.qml`, `ChoiceRow.qml`, `ActionRow.qml`, `TextRow.qml`; +Modify all eleven `*Page.qml`; Test `tests/quickshell/settings-rows-contract.sh` + +- [ ] **Build static mocks** of the new Appearance page and one rebuilt existing + page, serve them over HTTP, report the URL, and **stop for a decision.** +- [ ] Write a contract asserting each row type binds a schema key by name, reflects + external changes, and clamps out-of-range input. +- [ ] Implement the row components and `SettingsPage` (the scaffold currently + copy-pasted eleven times). +- [ ] Rewrite the eleven pages on top of them; delete the dead read-only rows that + only existed because a real control was expensive. +- [ ] Promote the hardcoded `Settings.qml` values into real controls: weather + location/unit/interval, vitals interval, night-light schedule, the four + notification timing and history limits, capture directories, and recorder + arguments. +- [ ] Give Appearance real content: accent pair, window rounding, gaps, border + size, blur, animation speed, bar height, font scale, wallpaper. +- [ ] Make the dock pin list editable (reorder, add, remove) instead of a + 16-entry literal. +- [ ] Run the rows contract and the existing settings contracts to green. + +**Exit criteria:** no shipped behaviour value is reachable only by editing a file. + +--- + +## Stage 4 — Shortcuts from the compositor + +**Files:** Create `services/Keybinds.qml`; Modify `modules/settings/ShortcutsPage.qml`, +`config/dot/hypr/keybinds.lua`; Test `tests/quickshell/keybinds-contract.sh` + +- [ ] Write a contract asserting the page's bind count matches `hyprctl binds -j` + exactly, so it can never drift again. +- [ ] Run it; confirm it fails at 19 of 113. +- [ ] Implement `Keybinds.qml` reading `hyprctl binds -j`, grouped and searchable. +- [ ] Backfill `description` in `keybinds.lua` for the 29 binds that lack one. +- [ ] Rebuild `ShortcutsPage` on the live data; delete the hardcoded array. +- [ ] Add rebinding: overrides in the same JSON, applied by `keybinds.lua` after + the defaults and live via `eval`, with conflict detection against existing binds. +- [ ] Run the contract to green. + +**Exit criteria:** the page shows every real bind, always current, and can change them. + +--- + +## Sequencing note + +Stage 0 is independent — ship it alone. Stages 1 and 2 are the architecture and +should land together, since Stage 2 is what makes Stage 1 worth doing. Stage 3 +is the largest and is gated on a visual decision. Stage 4 is independent of 3 and +can run in parallel with it. + +Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work +that should get a restore point before Stage 1 begins. diff --git a/docs/superpowers/specs/2026-08-17-panama-cohesion-design.md b/docs/superpowers/specs/2026-08-17-panama-cohesion-design.md new file mode 100644 index 0000000..c493611 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-panama-cohesion-design.md @@ -0,0 +1,242 @@ +# Panama Cohesion Design + +## Purpose + +Panama has grown from a Hyprland config into a desktop: 15 Lua/conf files, 130 QML +files, 18 services, a 12-page settings application, and 29 contract tests. Each +feature was built well on its own. What is missing is the seam between them. + +This document audits the current state and defines the architecture that turns +the pieces into one product, with a single goal: + +> **A user should never need a text editor to change how their desktop behaves.** + +That goal is not currently met, and the reason is structural rather than a matter +of missing pages. + +--- + +## Part 1 — Audit + +### Finding 1 (critical, live bug): every Hyprland write from Settings is a no-op + +`services/SystemSettings.qml` applies display policy with `hyprctl keyword`: + +```qml +autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]); +``` + +On a Lua-configured Hyprland, `hyprctl keyword` does not work: + +``` +$ hyprctl getoption decoration:rounding -j → "int": 18 +$ hyprctl keyword decoration:rounding 4 +keyword can't work with non-legacy parsers. Use eval. +$ echo $? → 0 +$ hyprctl getoption decoration:rounding -j → "int": 18 +``` + +It prints the refusal to **stdout** and exits **0**. `SystemSettings` branches on +`exitCode === 0`, so all three writers take the success path: they persist the +requested value to `panama-settings.json`, clear `lastError`, and the UI redraws +as if the change took effect. Nothing reached the compositor. + +Game-aware HDR, VRR policy, and direct scanout have therefore never worked from +Settings, and the app confidently reports that they did. `applyPersistedDisplayPolicy()` +replays the same three no-ops one second after every shell start. + +The correct mechanism on this build is `hyprctl eval`, which is verified working: + +``` +$ hyprctl eval 'hl.config({ decoration = { rounding = 4 } })' → ok +$ hyprctl getoption decoration:rounding -j → "int": 4 +``` + +`eval` is also strictly more capable than `keyword` — it can set *any* config +value, including gradients, animation curves, and nested tables. It executes +arbitrary Lua, so the existing "never interpolate UI text into a command" rule +must extend to it: values are validated and serialised numerically, never +concatenated from user input. + +### Finding 2: three disconnected sources of truth for the same three values + +| Value | `looks.lua` | `DesktopPreferences.qml` | Applied by | +| --- | --- | --- | --- | +| `render:cm_auto_hdr` | `1` | `autoHdr: true` | `hyprctl keyword` (no-op) | +| `misc:vrr` | `3` | `vrrPolicy: 3` | `hyprctl keyword` (no-op) | +| `render:direct_scanout` | `2` | `directScanoutPolicy: 2` | `hyprctl keyword` (no-op) | + +They agree today only because they were typed to agree. Editing the Lua does not +change what Settings displays; changing Settings does not touch the Lua. The Lua +side reads no shared state whatsoever — there is no bridge in either direction. + +### Finding 3: the preference store costs four hand-edits per knob + +Every key in `config/DesktopPreferences.qml` is written out four times: a +`property alias`, a `JsonAdapter` property, a `Connections` handler, and a line +in `resetDesktopDefaults()`. Seventeen keys produce 68 lines of pure bookkeeping. + +The failure modes are silent. Omit the `Connections` handler and the setting +stops persisting with no error. Omit the reset line and "Restore defaults" +quietly skips it. This cost is the direct reason the settings app stalled at 16 +user-facing knobs. + +### Finding 4: ~40 comparable values are hardcoded one file away + +`config/Settings.qml` still hardcodes, as `readonly`: weather latitude/longitude, +location label, temperature unit and refresh interval; the vitals poll interval +and the GPU sysfs path; the night-light schedule (`17.0`–`10.0`); all four +notification timing and history limits; the 16-entry dock pin list; the +screenshot and recording directories; and the `wf-recorder` argument string. + +Every one of these is exactly the kind of thing the settings app exists for. None +is reachable from it. + +### Finding 5: appearance is not adjustable at all + +`Theme.qml` defines ~50 tokens, all `readonly`, none mutable. The Appearance +page's "Theme" card is two dead text rows: + +```qml +SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" } +SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System" } +``` + +Meanwhile `looks.lua` hardcodes gaps, border size, rounding, blur, shadow, glow, +and fourteen animation curves. The page named "Appearance" adjusts a clock format +and three vitals toggles. + +### Finding 6: Shortcuts is a hand-typed copy of 19 of 113 real binds + +`keybinds.lua` makes 95 `hl.bind` calls producing **113** live binds. The page +hardcodes an array of **19**. It cannot show the other 94, cannot change any, and +drifts the moment a bind is edited. + +The compositor already exposes the real list, and it is 74% self-describing: + +``` +$ hyprctl binds -j → 113 binds, 84 carrying a human description + modmask 64 key T description "Terminal" +``` + +### Finding 7: "Restore defaults" is incomplete by construction + +`resetDesktopDefaults()` resets `DesktopPreferences` only. Anything persisted +elsewhere survives an action that claims to restore Panama's defaults. + +### Finding 8: page scaffolding is copy-pasted eleven times + +Every page repeats the same `Flickable` → `Column` → `x: 34` → `y: 30` → +title/subtitle block. Every toggle repeats a four-property inline anchor +incantation: + +```qml +SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; + checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value } +``` + +Across eleven pages there are 56 rows but only ~15 toggles, 14 buttons, and 3 +sliders — over half the rows are static text. Pages settled for read-only text +because a real control was expensive to add. That is a tooling problem wearing a +product problem's clothes. + +### What is genuinely good and must be preserved + +- The `SystemSettings` allow-list discipline — UI never builds a command string. +- The Prism design language and its restraint, documented in `Theme.qml`. +- Event-driven motion; nothing repaints while idle. +- The 29 contract tests and the spec → plan → implement workflow. +- Delegation of hardware, accounts, and printers to GNOME rather than + half-reimplementing them. + +--- + +## Part 2 — Target architecture + +Four changes, in dependency order. Each is independently useful and independently +shippable. + +### A. One schema, one store + +Replace the hand-maintained preference object with a declarative schema — +one entry per setting carrying key, type, default, bounds or options, group, +label, and detail: + +```qml +{ key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25, + group: "dock", label: "Hide delay", + detail: "Prevents flicker when crossing icons" } +``` + +Persistence, change notification, validation, reset, and the settings UI all +derive from that one entry. Adding a knob becomes one line instead of four edits +plus a hand-built row, and reset becomes complete by construction rather than by +diligence. + +The store moves from Quickshell's opaque per-shell state directory to +`~/.config/panama/settings.json`, so it is a stable path that the compositor can +also read, and one a user can back up, diff, or put in a dotfiles repo. + +### B. Hyprland reads the same file + +`config/dot/hypr/prefs.lua` gains a small dependency-free JSON reader and exposes +`prefs.get(key, fallback)`. `looks.lua`, `input.lua`, and `monitors.lua` read +through it, keeping their current literals as the fallback: + +```lua +rounding = prefs.get("windowRounding", 18), +gaps_out = prefs.get("gapsOut", 10), +``` + +A missing, empty, or malformed file yields the shipped defaults. The read is +wrapped in `pcall` so a corrupt file can never take down the config. + +This closes the loop: + +- **Lua is the default.** It ships the curated values and works standalone. +- **The JSON is the truth.** Both sides read it. +- **Settings is the editor.** It writes the JSON *and* applies live via + `hyprctl eval`, so changes take effect immediately and survive a reboot. + +### C. Generic rows, then fill the pages + +Add `SettingsPage` (the repeated scaffold), plus `ToggleRow`, `SliderRow`, +`ChoiceRow`, `ActionRow`, and `TextRow`. Rewrite the eleven pages on top of them, +promote the ~40 hardcoded `Settings.qml` values into real controls, and give +Appearance genuine content: accent pair, window rounding, gaps, border size, +blur, animation speed, bar height, font scale, and wallpaper. + +Appearance customisation stays inside the design language. The user picks how much +of it there is — spacing, softness, motion — not a free-form palette editor that +would let the Prism identity be dismantled by accident. + +### D. Shortcuts from the compositor, then editable + +Generate the Shortcuts page from `hyprctl binds -j` so it shows all 113 binds and +can never drift. Backfill descriptions for the 29 binds that lack one. Then allow +rebinding: overrides live in the same JSON, `keybinds.lua` applies them after the +defaults, and Settings applies them live with `hyprctl eval`. + +--- + +## Constraints + +- No UI-supplied string is ever interpolated into an `eval`, a shell command, or + a config value. Numbers are range-checked; choices are matched against an + allow-list; colours are validated as hex before serialisation. +- A malformed or absent `settings.json` must degrade to shipped defaults, never + to a broken compositor. +- `hyprctl keyword` is banned in this codebase. It exits 0 without acting. +- Every write path must be observably verified — read the value back rather than + trusting an exit code. Finding 1 exists because an exit code was trusted. +- Tokyo Night Moon and Prism remain the only visual identity. Customisation + adjusts its parameters, it does not replace it. +- Motion stays event-driven. No idle repaint, at any setting. +- GNOME keeps ownership of hardware, accounts, printers, and users. + +## Out of scope + +- Arbitrary theme/palette import. +- A global menu (previously investigated; GTK apps expose `org.gtk.Actions` but + not `org.gtk.Menus`, so coverage would be too inconsistent to ship). +- Replacing any GNOME-delegated panel. diff --git a/tests/hypr/prefs-fallback-contract.sh b/tests/hypr/prefs-fallback-contract.sh new file mode 100755 index 0000000..b77784f --- /dev/null +++ b/tests/hypr/prefs-fallback-contract.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +# config/dot/hypr/prefs.lua is read at config time by looks.lua, input.lua, and +# monitors.lua. It is therefore the one piece of Panama that can cost the user a +# working compositor rather than merely a working feature. +# +# This contract pins the only behaviour that matters: whatever is in the +# settings file -- including nothing, garbage, or values of the wrong type -- +# the config still parses, and every setting either takes the stored value or +# falls back to the value shipped in the Lua. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +hypr_dir="$repo_dir/config/dot/hypr" +work="$(mktemp -d /tmp/panama-prefs-contract.XXXXXX)" + +fail() { + printf 'prefs fallback contract: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + rm -rf "$work" +} +trap cleanup EXIT + +# Exercise prefs.lua directly rather than through a full compositor launch: the +# question is what the module returns, and Hyprland's own parse is covered by +# the --verify-config case at the end. +probe() { + local settings="$1" + mkdir -p "$work/config/panama" + if [[ "$settings" == "__absent__" ]]; then + rm -f "$work/config/panama/settings.json" + else + printf '%s' "$settings" >"$work/config/panama/settings.json" + fi + + XDG_CONFIG_HOME="$work/config" lua -e " + package.path = '$hypr_dir/?.lua;' .. package.path + local ok, prefs = pcall(require, 'prefs') + if not ok then + print('LOAD_ERROR ' .. tostring(prefs)) + os.exit(0) + end + print(string.format( + 'gapsOut=%s rounding=%s blur=%s layout=%s hdr=%s', + tostring(prefs.get('gapsOut', 10)), + tostring(prefs.get('windowRounding', 18)), + tostring(prefs.get('blurEnabled', true)), + tostring(prefs.get('keyboardLayout', 'us')), + tostring(prefs.getInt('autoHdr', 1)))) + " 2>&1 +} + +shipped='gapsOut=10 rounding=18 blur=true layout=us hdr=1' + +# ── No file at all: the normal first run ───────────────────────────────────── +result="$(probe '__absent__')" +[[ "$result" == "$shipped" ]] || fail "an absent settings file did not yield shipped defaults: $result" + +# ── Empty file ─────────────────────────────────────────────────────────────── +result="$(probe '')" +[[ "$result" == "$shipped" ]] || fail "an empty settings file did not yield shipped defaults: $result" + +# ── Whitespace only ────────────────────────────────────────────────────────── +result="$(probe ' + ')" +[[ "$result" == "$shipped" ]] || fail "a whitespace-only file did not yield shipped defaults: $result" + +# ── Truncated mid-object, the shape a crashed write leaves behind ──────────── +result="$(probe '{ "gapsOut": 24, "windowRounding":')" +[[ "$result" == "$shipped" ]] || fail "a truncated file did not yield shipped defaults: $result" + +# ── Not JSON at all ────────────────────────────────────────────────────────── +result="$(probe 'gaps_out = 24')" +[[ "$result" == "$shipped" ]] || fail "a non-JSON file did not yield shipped defaults: $result" + +# ── A JSON array rather than an object ─────────────────────────────────────── +result="$(probe '[1, 2, 3]')" +[[ "$result" == "$shipped" ]] || fail "a top-level array did not yield shipped defaults: $result" + +# ── Wrong types: each bad value falls back on its own ──────────────────────── +result="$(probe '{"gapsOut": "wide", "windowRounding": 24, "blurEnabled": 3, "keyboardLayout": 7}')" +[[ "$result" == 'gapsOut=10 rounding=24 blur=true layout=us hdr=1' ]] \ + || fail "wrong-typed values did not fall back per key: $result" + +# ── Good values are actually used ──────────────────────────────────────────── +result="$(probe '{"gapsOut": 24, "windowRounding": 6, "blurEnabled": false, "keyboardLayout": "us,de", "autoHdr": false}')" +[[ "$result" == 'gapsOut=24 rounding=6 blur=false layout=us,de hdr=0' ]] \ + || fail "stored values were not applied: $result" + +# ── A boolean maps onto an integer option, matching SystemSettings.hyprValue ─ +result="$(probe '{"autoHdr": true}')" +[[ "$result" == 'gapsOut=10 rounding=18 blur=true layout=us hdr=1' ]] \ + || fail "getInt did not convert a boolean: $result" + +# ── Escapes and nesting do not break the reader ────────────────────────────── +result="$(probe '{"note": "a \"quoted\" value\nwith escapes", "nested": {"a": [1, 2, {"b": null}]}, "gapsOut": 12}')" +[[ "$result" == 'gapsOut=12 rounding=18 blur=true layout=us hdr=1' ]] \ + || fail "a file with escapes and nesting was not parsed: $result" + +# ── The real config parses in every one of those states ───────────────────── +# This is the case that actually protects the desktop: prefs.lua returning +# defaults is only useful if Hyprland still accepts the config around it. +# +# Hyprland resolves its own config from $HOME/.config/hypr regardless of +# XDG_CONFIG_HOME, while prefs.lua honours XDG_CONFIG_HOME. That asymmetry is +# what makes this loop useful rather than vacuous: the *real* looks.lua and +# input.lua are parsed against a *fixture* settings file. The probe cases above +# already establish that prefs.lua reads the fixture and not the live file. +for settings in '__absent__' '' '{ "gapsOut": 24, "windowRounding":' 'gaps_out = 24' '{"gapsOut": "wide"}' \ + '{"gapsOut": 24, "windowRounding": 6, "blurEnabled": false, "borderSize": 0, "keyboardLayout": "us,de"}'; do + if [[ "$settings" == "__absent__" ]]; then + rm -f "$work/config/panama/settings.json" + else + mkdir -p "$work/config/panama" + printf '%s' "$settings" >"$work/config/panama/settings.json" + fi + output="$(XDG_CONFIG_HOME="$work/config" Hyprland --verify-config 2>&1 || true)" + grep -q 'config ok' <<<"$output" \ + || fail "Hyprland rejected the config with settings=<$settings>: $(tail -5 <<<"$output")" +done + +trap - EXIT +cleanup +printf 'prefs fallback contract: PASS\n' diff --git a/tests/quickshell/preference-schema-contract.sh b/tests/quickshell/preference-schema-contract.sh new file mode 100755 index 0000000..a8c071a --- /dev/null +++ b/tests/quickshell/preference-schema-contract.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash + +# The preference store is derived from config/PreferenceSchema.qml rather than +# restating each key. This contract pins the properties that derivation is +# supposed to buy, so that a future change cannot quietly reintroduce the +# hand-maintained variant: +# +# * every schema key round-trips through disk +# * out-of-range numbers are clamped, not stored raw and not rejected +# * unknown keys in the file are preserved across a write +# * set() refuses a key that is not in the schema +# * reset() restores *every* schema default with no hand-written list +# * a corrupt file yields shipped defaults rather than a broken shell + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/preference-schema-harness.qml" +state_home="$(mktemp -d /tmp/panama-schema-state.XXXXXX)" +config_home="$(mktemp -d /tmp/panama-schema-config.XXXXXX)" +store="$config_home/panama/settings.json" + +fail() { + printf 'preference schema contract: %s\n' "$1" >&2 + exit 1 +} + +qs_for_harness() { + XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@" +} + +cleanup() { + qs_for_harness kill >/dev/null 2>&1 || true +} +trap cleanup EXIT + +start_harness() { + XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null + for _ in $(seq 1 40); do + if qs_for_harness ipc show 2>/dev/null | rg -q '^target preference-schema-test$'; then + return + fi + sleep 0.1 + done + fail 'test IPC target did not start' +} + +stop_harness() { + qs_for_harness kill >/dev/null 2>&1 || true + for _ in $(seq 1 40); do + # A bare `return` would propagate the failed `ipc show` status, which + # under `set -e` ends the whole contract instead of the function. + qs_for_harness ipc show >/dev/null 2>&1 || return 0 + sleep 0.1 + done + fail 'test shell did not stop cleanly' +} + +wait_for_store() { + for _ in $(seq 1 40); do + [[ -f "$store" ]] && jq -e . "$store" >/dev/null 2>&1 && return + sleep 0.1 + done + fail 'preferences file was not written' +} + +# ── A fresh store reports schema defaults ──────────────────────────────────── +start_harness +key_count="$(qs_for_harness ipc call preference-schema-test keyCount)" +[[ "$key_count" -gt 0 ]] || fail 'schema is empty' + +initial="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)" +defaults="$(qs_for_harness ipc call preference-schema-test defaults | jq -cS .)" +[[ "$initial" == "$defaults" ]] || fail "a fresh store did not report schema defaults: $initial" + +# ── Out-of-range numbers are clamped, not stored raw ───────────────────────── +qs_for_harness ipc call preference-schema-test applyJson \ + '{"dockHideDelayMs": 99999, "nightLightTemperature": 100, "focusDurationMinutes": 45}' >/dev/null +clamped="$(qs_for_harness ipc call preference-schema-test dump)" +[[ "$(jq -r .dockHideDelayMs <<<"$clamped")" == "2000" ]] \ + || fail "an above-range value was not clamped to the schema maximum: $(jq -r .dockHideDelayMs <<<"$clamped")" +[[ "$(jq -r .nightLightTemperature <<<"$clamped")" == "2000" ]] \ + || fail "a below-range value was not clamped to the schema minimum: $(jq -r .nightLightTemperature <<<"$clamped")" + +# ── An unknown key is refused rather than silently accepted ────────────────── +verdict="$(qs_for_harness ipc call preference-schema-test applyJson '{"__not_a_setting__": 1}')" +[[ "$(jq -r .__not_a_setting__ <<<"$verdict")" == "false" ]] || fail 'set() accepted a key outside the schema' +qs_for_harness ipc call preference-schema-test dump | jq -e 'has("__not_a_setting__") | not' >/dev/null \ + || fail 'a key outside the schema entered the store' + +# ── An out-of-range enum value is refused ──────────────────────────────────── +verdict="$(qs_for_harness ipc call preference-schema-test applyJson '{"vrrPolicy": 7}')" +[[ "$(jq -r .vrrPolicy <<<"$verdict")" == "false" ]] || fail 'set() accepted an enum value outside its options' + +# ── Every schema key round-trips across a restart ──────────────────────────── +qs_for_harness ipc call preference-schema-test applyJson \ + '{"use24Hour": true, "showSeconds": false, "showWeekday": false, "showCpu": false, + "showMemory": false, "showGpu": false, "dockAutohide": false, "dockRevealDelayMs": 75, + "dockHideDelayMs": 400, "focusDurationMinutes": 25, "autoHdr": false, "vrrPolicy": 0, + "directScanoutPolicy": 0, "nightLightEnabled": true, "nightLightAutomatic": true, + "nightLightTemperature": 4100, "lastPage": "desktop"}' >/dev/null +wait_for_store +before="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)" +[[ "$before" != "$defaults" ]] || fail 'the fixture did not change anything' + +stop_harness +start_harness +after="" +for _ in $(seq 1 40); do + after="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)" + [[ "$after" == "$before" ]] && break + sleep 0.1 +done +[[ "$after" == "$before" ]] || fail "values did not survive a restart: $after" + +# ── A key this build does not know is carried through, not dropped ─────────── +stop_harness +jq '. + {"__future_setting__": "keep me"}' "$store" >"$store.tmp" && mv "$store.tmp" "$store" +start_harness +qs_for_harness ipc call preference-schema-test applyJson '{"dockHideDelayMs": 500}' >/dev/null +for _ in $(seq 1 40); do + jq -e '.__future_setting__ == "keep me" and .dockHideDelayMs == 500' "$store" >/dev/null 2>&1 && break + sleep 0.1 +done +jq -e '.__future_setting__ == "keep me"' "$store" >/dev/null \ + || fail 'a setting from a newer build was dropped on write' + +# ── reset() restores every schema key, with no hand-maintained list ────────── +qs_for_harness ipc call preference-schema-test reset >/dev/null +restored="" +for _ in $(seq 1 40); do + restored="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)" + [[ "$restored" == "$defaults" ]] && break + sleep 0.1 +done +[[ "$restored" == "$defaults" ]] || fail "reset did not restore every schema default: $restored" +jq -e '.__future_setting__ == "keep me"' "$store" >/dev/null \ + || fail 'reset discarded a setting it does not own' + +# ── A corrupt file degrades to defaults instead of breaking the shell ──────── +stop_harness +printf '{ this is not json' >"$store" +start_harness +corrupt="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)" +[[ "$corrupt" == "$defaults" ]] || fail "a corrupt store did not fall back to defaults: $corrupt" + +trap - EXIT +cleanup +printf 'preference schema contract: PASS\n' diff --git a/tests/quickshell/settings-hyprland-write-contract.sh b/tests/quickshell/settings-hyprland-write-contract.sh new file mode 100755 index 0000000..9fa9ad0 --- /dev/null +++ b/tests/quickshell/settings-hyprland-write-contract.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash + +# Panama Settings must actually change the compositor, not merely believe it did. +# +# The pre-existing settings-system-contract.sh applies the values the compositor +# already holds and asserts they are unchanged, so it passes whether the write +# works or does nothing at all. That is how `hyprctl keyword` silently failing -- +# it prints "keyword can't work with non-legacy parsers" to stdout and exits 0 -- +# went unnoticed on this Lua-configured Hyprland. +# +# This contract flips each policy to a value it does not currently hold, reads it +# back from the compositor, and restores the original. A write path that no-ops +# cannot pass it. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml" + +# A verified write commits to preferences, and preferences live at +# $XDG_CONFIG_HOME/panama/settings.json. Without an isolated config home this +# contract would persist its deliberately-wrong test values into the user's real +# store, where the next `hyprctl reload` would faithfully apply them. The +# compositor is still the live one -- that is the point of the contract -- and +# the EXIT trap restores it. +config_home="$(mktemp -d /tmp/panama-write-config.XXXXXX)" + +fail() { + printf 'settings hyprland write contract: %s\n' "$1" >&2 + exit 1 +} + +qs_for_harness() { + XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@" +} + +read_option() { + hyprctl -j getoption "$1" | jq -r .int +} + +# Every value this contract touches is captured up front, before anything is +# changed. Capturing later risks recording a value an earlier failed run left +# behind and then "restoring" the daily-driver desktop to it. +original_auto_hdr="$(read_option render:cm_auto_hdr)" +original_vrr="$(read_option misc:vrr)" +original_direct="$(read_option render:direct_scanout)" +original_rounding="$(read_option decoration:rounding)" +original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" +original_blur="$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" +original_opacity="$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)" +original_layout="$(hyprctl -j getoption input:kb_layout | jq -r .str)" + +restore() { + # Restore through hyprctl rather than the harness: if the harness write path + # is the thing that is broken, the daily-driver desktop must still come back. + # Unconditional and idempotent, so it is safe on both the pass and fail path. + hyprctl eval "hl.config({ + render = { cm_auto_hdr = $original_auto_hdr, direct_scanout = $original_direct }, + misc = { vrr = $original_vrr }, + general = { gaps_out = $original_gaps }, + input = { kb_layout = \"$original_layout\" }, + decoration = { + rounding = $original_rounding, + inactive_opacity = $original_opacity, + blur = { enabled = $original_blur } + } + })" >/dev/null 2>&1 || true + qs_for_harness kill >/dev/null 2>&1 || true + rm -rf "$config_home" +} +trap restore EXIT + +# Pick a target each policy does not currently hold, staying inside the values +# SystemSettings allow-lists (VRR 0|3, direct scanout 0|2). +target_auto_hdr=$([[ "$original_auto_hdr" == 1 ]] && printf false || printf true) +target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0) +target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3) +target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2) + +XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null +for _ in $(seq 1 40); do + if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then + break + fi + sleep 0.1 +done +qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start' + +# ── The write must reach the compositor ────────────────────────────────────── +qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" "$target_vrr" "$target_direct" >/dev/null + +applied=false +for _ in $(seq 1 40); do + if [[ "$(read_option render:cm_auto_hdr)" == "$target_auto_hdr_int" \ + && "$(read_option misc:vrr)" == "$target_vrr" \ + && "$(read_option render:direct_scanout)" == "$target_direct" ]]; then + applied=true + break + fi + sleep 0.1 +done + +[[ "$applied" == true ]] || fail "policy write did not reach the compositor: \ +cm_auto_hdr=$(read_option render:cm_auto_hdr) (want $target_auto_hdr_int), \ +vrr=$(read_option misc:vrr) (want $target_vrr), \ +direct_scanout=$(read_option render:direct_scanout) (want $target_direct)" + +# ── A successful write must not report an error ────────────────────────────── +last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" +[[ -z "$last_error" ]] || fail "a successful write reported an error: $last_error" + +# ── A rejected value must be refused, not silently accepted ────────────────── +qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null +sleep 0.3 +[[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor' +[[ -n "$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" ]] \ + || fail 'a rejected VRR value did not surface an error' + +# ── Every getoption answer shape must be handled, not just integers ────────── +# The compositor reports each option in a different JSON field depending on its +# type, and gaps come back as a four-value box. A verifier that only understood +# "int" would report every other type as rejected. +qs_for_harness ipc call settings-system-test applyJson \ + '{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null + +typed=false +for _ in $(seq 1 40); do + if [[ "$(read_option decoration:rounding)" == "7" \ + && "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \ + && "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \ + && "$(hyprctl -j getoption decoration:inactive_opacity | jq -r '.float | (.*100|round)')" == "85" ]]; then + typed=true + break + fi + sleep 0.1 +done +if [[ "$typed" != true ]]; then + fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \ +gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \ +blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \ +opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)" +fi + +# Verification must recognise those shapes as success, not report them rejected. +last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" +if [[ -n "$last_error" ]]; then + fail "a verified typed write was reported as failed: $last_error" +fi + +# ── A string that violates its schema pattern must never reach hl.config ────── +[[ "$(qs_for_harness ipc call settings-system-test applyJson '{"keyboardLayout": "us\"; os.execute(\"touch /tmp/panama-pwned\")--"}')" == "false" ]] \ + || fail 'a keyboard layout violating the schema pattern was accepted' +[[ ! -e /tmp/panama-pwned ]] || fail 'a settings value was executed as Lua' +[[ "$(hyprctl -j getoption input:kb_layout | jq -r .str)" == "$original_layout" ]] \ + || fail 'the keyboard layout changed despite a rejected value' + +# ── Restoring through the real path must also work ─────────────────────────── +qs_for_harness ipc call settings-system-test apply \ + "$([[ "$original_auto_hdr" == 1 ]] && printf true || printf false)" \ + "$original_vrr" "$original_direct" >/dev/null + +restored=false +for _ in $(seq 1 40); do + if [[ "$(read_option render:cm_auto_hdr)" == "$original_auto_hdr" \ + && "$(read_option misc:vrr)" == "$original_vrr" \ + && "$(read_option render:direct_scanout)" == "$original_direct" ]]; then + restored=true + break + fi + sleep 0.1 +done +[[ "$restored" == true ]] || fail 'the original policy values could not be restored through SystemSettings' + +trap - EXIT +restore +printf 'settings hyprland write contract: PASS\n' diff --git a/tests/quickshell/settings-pages-contract.sh b/tests/quickshell/settings-pages-contract.sh index d35c19d..f7d9e16 100755 --- a/tests/quickshell/settings-pages-contract.sh +++ b/tests/quickshell/settings-pages-contract.sh @@ -12,6 +12,19 @@ cleanup() { } trap cleanup EXIT +# Start from a clean slate. Closing the Settings window is asynchronous: the +# shell reports it closed as soon as it drops its own state, while the toplevel +# survives until the compositor destroys it. Without this wait, running straight +# after settings-window-contract sees the outgoing window and reads it as a +# duplicate. +qs ipc call settings close >/dev/null 2>&1 || true +for _ in $(seq 1 40); do + hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 0' >/dev/null && break + sleep 0.1 +done +hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 0' >/dev/null \ + || fail 'a Settings window was still open when the contract started' + pages=(home appearance displays connectivity desktop sound notifications screen-intelligence shortcuts services about) for page in "${pages[@]}"; do qs ipc call settings page "$page" >/dev/null diff --git a/tests/quickshell/settings-preferences-contract.sh b/tests/quickshell/settings-preferences-contract.sh index 731e641..4428924 100755 --- a/tests/quickshell/settings-preferences-contract.sh +++ b/tests/quickshell/settings-preferences-contract.sh @@ -5,6 +5,7 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" harness="$repo_dir/config/dot/quickshell/settings-preferences-harness.qml" state_home="$(mktemp -d /tmp/panama-settings-state.XXXXXX)" +config_home="$(mktemp -d /tmp/panama-settings-config.XXXXXX)" fail() { printf 'settings preferences contract: %s\n' "$1" >&2 @@ -12,7 +13,7 @@ fail() { } qs_for_harness() { - XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" + XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@" } cleanup() { @@ -21,7 +22,7 @@ cleanup() { trap cleanup EXIT start_harness() { - XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null + XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null for _ in $(seq 1 40); do if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-pref-test$'; then return @@ -51,7 +52,7 @@ before="$(qs_for_harness ipc call settings-pref-test status | jq -c 'del(.stateD state_file="" for _ in $(seq 1 40); do - state_file="$(find "$state_home" -name panama-settings.json -print -quit)" + state_file="$(find "$config_home" -path '*/panama/settings.json' -print -quit)" if [[ -n "$state_file" ]] && jq -e '.lastPage == "desktop" and .focusDurationMinutes == 70' "$state_file" >/dev/null 2>&1; then break fi