colour -> color, behaviour -> behavior, centre -> center, favourite -> favorite, and about twenty other pairs, applied consistently across comments, docs, error/UI copy, and a handful of QML identifiers that used the British spelling as their actual name: SystemSettings' serialiseValue/serialiseTable/normaliseGradient, Displays' normaliseModes, Wallpaper's normalisePolicy, SettingsBackup's serialiseHomeState, DateTime's ntpSynchronised property, Clipboard's _normalise helper, and ShortcutCapture's cancelled signal (with its onCancelled handler in ShortcutsPage.qml). Every call site and the two tests that assert on the literal source text (settings-ownership and settings-backup-live contracts) were updated in lockstep. Left untouched: config/dot/espanso/match/packages/misspell-en/ is a vendored third-party autocorrect dictionary -- its entries are typo corrections, not our prose, and rewriting them would fight the package's own purpose (and any future re-sync from upstream). The already-American `favorites` property (Home page pinned accessories) was never actually misspelled -- only nearby comments and error strings said "favourites" -- so no data migration was needed there. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
253 lines
8.5 KiB
Lua
253 lines
8.5 KiB
Lua
-- ─────────────────────────────────────────────────────────────────────────────
|
|
-- 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 customizations; 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
|