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" | "json" // 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 // // "json" holds a structured value -- a list or an object -- that the schema // stores and resets but does not validate field by field. It exists so that // settings like the dock's pinned applications live in the same file, and are // covered by the same reset, as everything else rather than growing a fourth // preference store. The service that owns such a value is responsible for // validating it; see services/Dock-related consumers. // 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, unit: "ms", 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, unit: "ms", 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, unit: "min", 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, unit: "px", 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, unit: "px", 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, unit: "px", 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, unit: "px", 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, unit: "px", 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, unit: "px", 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, unit: "ms", 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, unit: "/s", 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, unit: "s", group: "input", label: "Hide pointer after", detail: "Seconds of stillness before the pointer fades out; 0 never hides it", // Reported as a float even though it is only ever set to whole // seconds. `readAs` describes what getoption answers with, not what // the setting means -- getting this wrong makes every write to it // look rejected. hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" } }, // ── 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, unit: "K", group: "nightLight", label: "Color temperature", detail: "Lower is warmer" }, // ── Desktop background ────────────────────────────────────────────── // Applied through hyprpaper's IPC. Not an hl.config option, so it has // no `hypr` block; services/Wallpaper.qml owns applying it. { key: "wallpaperPath", type: "string", def: "", group: "wallpaper", // Reaches hyprpaper as the "," argument form, so a // comma would split it into a different request. Absolute paths // only, no commas, no newlines. pattern: "^(|/[^,\\n]+)$", label: "Wallpaper", detail: "Shown on every output" }, // ── Idle, lock, and sleep ─────────────────────────────────────────── // Written into a generated hypridle config; see scripts/panama-idle. // Zero means never for all three. { key: "screenBlankMinutes", type: "int", def: 5, min: 0, max: 120, step: 1, unit: "min", group: "idle", label: "Turn the screen off after", detail: "Blanks the display; nothing is locked yet" }, { key: "lockMinutes", type: "int", def: 10, min: 0, max: 240, step: 1, unit: "min", group: "idle", label: "Lock the screen after", detail: "Counted from when the session went idle, not from blanking" }, { key: "suspendMinutes", type: "int", def: 0, min: 0, max: 480, step: 5, unit: "min", group: "idle", label: "Suspend after", detail: "This is a desktop, so Panama ships with automatic suspend off" }, { key: "lockOnSleep", type: "bool", def: true, group: "idle", label: "Lock before sleeping", detail: "Requires your password when the machine wakes" }, // ── Accessibility ─────────────────────────────────────────────────── // Backed by gsettings so GTK applications agree with the shell, and // pushed to the compositor as well where it has its own notion. { key: "cursorSize", type: "int", def: 24, min: 16, max: 64, step: 4, unit: "px", group: "accessibility", label: "Pointer size", detail: "Applies to the compositor and to applications" }, { key: "textScale", type: "real", def: 1.0, min: 0.75, max: 2.0, step: 0.05, group: "accessibility", label: "Text size", detail: "Scales interface text everywhere; 1.00 is the design size" }, // ── Weather ───────────────────────────────────────────────────────── { key: "temperatureUnit", type: "enum", def: "fahrenheit", group: "weather", label: "Temperature unit", detail: "Choose Fahrenheit or Celsius for the weather card", options: [ { value: "fahrenheit", label: "Fahrenheit" }, { value: "celsius", label: "Celsius" } ] }, { key: "weatherRefreshMinutes", type: "int", def: 20, min: 5, max: 120, step: 5, unit: "min", group: "weather", label: "Weather refresh", detail: "How often Panama updates the current conditions" }, // ── Vitals refresh ────────────────────────────────────────────────── { key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500, unit: "ms", group: "vitals", label: "Vitals refresh", detail: "How often processor, memory, and graphics usage update" }, // ── Notifications ─────────────────────────────────────────────────── { key: "notificationTimeoutMs", type: "int", def: 5000, min: 1000, max: 30000, step: 500, unit: "ms", group: "notifications", label: "Notification duration", detail: "How long ordinary notification banners remain visible" }, { key: "notificationTimeoutCriticalMs", type: "int", def: 0, min: 0, max: 60000, step: 1000, unit: "ms", group: "notifications", label: "Critical notification duration", detail: "Zero keeps critical notification banners visible until dismissed" }, { key: "notificationHistoryLimit", type: "int", def: 100, min: 10, max: 500, step: 10, group: "notifications", label: "Notification history", detail: "Maximum notifications retained in the notification center" }, { key: "maxVisibleToasts", type: "int", def: 4, min: 1, max: 8, step: 1, group: "notifications", label: "Visible banners", detail: "Maximum notification banners shown at once" }, // ── Capture ───────────────────────────────────────────────────────── // Directories and encoder arguments are enums rather than free text: // both are handed to a recorder process, and an arbitrary string there // is a much larger surface than a settings page needs to expose. { key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture", label: "Screenshot folder", detail: "Folder under your home directory for screenshots", options: [ { value: "Pictures/Screenshots", label: "Pictures / Screenshots" }, { value: "Pictures", label: "Pictures" }, { value: "Desktop", label: "Desktop" } ] }, { key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture", label: "Recording folder", detail: "Folder under your home directory for screen recordings", options: [ { value: "Videos/Recordings", label: "Videos / Recordings" }, { value: "Videos", label: "Videos" }, { value: "Desktop", label: "Desktop" } ] }, { key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128", group: "capture", label: "Recording encoder", detail: "Hardware encoding keeps recording off the processor while gaming", options: [ { value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" }, { value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" }, { value: "-c libx264", label: "CPU x264" } ] }, // ── Dock contents ─────────────────────────────────────────────────── // A "json" value: the ordered list of desktop entry ids pinned to the // dock. Kept in the shared store so that reordering the dock is covered // by Restore defaults like everything else, rather than living in its // own file. The shipped order is the GNOME dash it replaced. { key: "dockPinned", type: "json", group: "dock", label: "Pinned applications", detail: "Applications that stay in the Dock whether or not they are running", def: [ "org.gnome.Settings", "kitty", "org.gnome.Nautilus", "com.bitwarden.desktop", "org.gnome.Software", "helium", "org.mozilla.thunderbird_esr", "com.slack.Slack", "app.bluebubbles.BlueBubbles", "rustdesk", "io.podman_desktop.PodmanDesktop", "claude-desktop", "codex-desktop", "md.obsidian.Obsidian", "com.obsproject.Studio", "steam" ] }, // ── 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 "json": // Accepted as-is. Anything JSON.parse produced is representable, // and per-field meaning belongs to the owning service rather than // here. A scalar is rejected so a corrupt file falls back to the // default instead of handing a list-shaped consumer a number. return (typeof value === "object") ? 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; } }