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:
Gabriel Brown
2026-08-17 23:26:56 -04:00
parent c42794c5e2
commit 00a81edadd
26 changed files with 2108 additions and 222 deletions
+41
View File
@@ -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:
+5
View File
@@ -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")
+8 -6
View File
@@ -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
View File
@@ -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 = {
+252
View File
@@ -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