-- ───────────────────────────────────────────────────────────────────────────── -- 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