Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because they had been typed to agree: looks.lua hardcoded values, DesktopPreferences independently defaulted the same values, and SystemSettings replayed them at startup. Nothing kept them in sync, and the Lua side read no shared state at all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl keyword refuses the write, prints the refusal to stdout, and still exits 0, so the HDR, VRR, and direct-scanout toggles persisted their value and reported success while the compositor never changed. Writes now go through hyprctl eval, which has the same hazard on syntax and runtime errors, so success is defined as reading the value back and finding it equal. The existing contract passed throughout the outage because it re-applied the values already in place; the new one flips each value to something it does not hold. Derive preferences from a schema. Every setting used to be restated four times -- a property alias, a JSON adapter property, a change handler, and a line in reset -- where omitting any one failed silently. PreferenceSchema.qml is now the single source, and persistence, validation, reset, and the Hyprland mapping all derive from it. Unknown keys on disk survive a write so a rollback does not discard a newer build's settings, and a corrupt file falls back to shipped defaults. The store moved to ~/.config/panama/settings.json, migrating from the old state directory without deleting it. Share that file with Hyprland. prefs.lua reads it at config time with every shipped literal kept as the fallback, so the config still stands alone. The Lua is the default, the JSON is the truth, and Settings is the editor. The compositor-adjustable surface goes from 3 keys to 23. Also fixes two test-hygiene bugs found by running the suite end to end for the first time: settings-pages-contract could see the window settings-window-contract leaves behind, and the new write contract was persisting its deliberately-wrong values into the user's real store. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -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", <shipped value>)`, 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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
+19
-17
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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("<key>", <current literal>)`, 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.
|
||||
@@ -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.
|
||||
Executable
+128
@@ -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'
|
||||
+149
@@ -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'
|
||||
+176
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user