They were written down five times: ThemeProfileModel.js for QML, looks.lua for the compositor, and again in panama-theme-apps and panama-lock. The GNOME accent-name mapping was a sixth list. Adding a ninth accent meant editing all of them, and the file most likely to be missed was the lock screen, which fails silently -- the machine locks in last season's colour and nothing says why. panama-theme-apps admitted it in a comment: "there is no shared source between QML and a shell script". config/palette.json is that source now. looks.lua reads it through a new prefs.readJson, which uses the same never-raise parser the settings store uses, so an unreadable palette costs the accent colours and never the compositor config. The two shell generators read it through scripts/panama-palette, which also carries the hex-to-rgb conversion hyprlock needs and the GNOME member lookup. QML keeps its table, because a .js module imported into QML cannot read a file. That is still a copy, so the palette contract compares the two value by value -- every accent, every field -- and fails on any disagreement. Verified by planting a wrong hex and watching it name the exact field. The adwaita contract used to check the shell's own copy of the GNOME mapping. It now checks that the shell resolves through the palette, and fails if that copy ever grows back.
280 lines
9.3 KiB
Lua
280 lines
9.3 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
|
|
|
|
-- Read and decode any JSON file, using the same never-raise parser the
|
|
-- settings store uses. Returns an empty table for a file that is missing,
|
|
-- empty, or malformed, so a caller can index the result without checking.
|
|
--
|
|
-- Exists so config/palette.json can be read by looks.lua rather than the eight
|
|
-- accents being written out a second time in Lua. A bad palette costs the
|
|
-- accent colours, never the compositor config.
|
|
function prefs.readJson(path)
|
|
if type(path) ~= "string" or path == "" then
|
|
return {}
|
|
end
|
|
local file = io.open(path, "r")
|
|
if not file then
|
|
return {}
|
|
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 not ok or type(parsed) ~= "table" then
|
|
return {}
|
|
end
|
|
return parsed
|
|
end
|
|
|
|
-- True when a settings file was actually read. Useful from overrides.lua.
|
|
function prefs.loaded()
|
|
return next(values) ~= nil
|
|
end
|
|
|
|
return prefs
|