pragma Singleton // Application-facing desktop style. // // Panama owns the durable choices; gsettings is an output boundary for GTK // and applications that follow GNOME's desktop schemas. Commands are arrays, // values are validated before storage, and no user text is ever sent through a // shell. Hyprland's pointer setting stays live through Accessibility. import Quickshell import Quickshell.Io import QtQuick import qs.config Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style" // SearchPicker consumes [{ value, label, detail }]. Keep the raw names as // a separate allow-list so a caller cannot smuggle a display label into a // stored theme name. property var cursorThemes: [] property var iconThemes: [] property var cursorThemeNames: [] property var iconThemeNames: [] property bool catalogLoaded: false property bool scanning: false property bool startupApplied: false property string lastError: "" property var pending: [] readonly property bool busy: root.scanning || catalogProcess.running || runner.running || root.pending.length > 0 readonly property int preferenceRevision: DesktopPreferences.revision readonly property string cursorTheme: DesktopPreferences.get("cursorTheme") readonly property string iconTheme: DesktopPreferences.get("iconTheme") readonly property string applicationFont: DesktopPreferences.get("applicationFont") readonly property string documentFont: DesktopPreferences.get("documentFont") readonly property string monospaceFont: DesktopPreferences.get("monospaceFont") Process { id: catalogProcess stdout: StdioCollector { onStreamFinished: root.acceptCatalog(this.text) } onExited: (exitCode, exitStatus) => { root.scanning = false; if (exitCode !== 0) { root.catalogLoaded = false; root.lastError = "Installed icon and pointer themes could not be read."; } } } Process { id: runner onExited: (exitCode, exitStatus) => { if (exitCode !== 0) root.lastError = "One desktop style setting could not be applied."; root.drain(); } } Component.onCompleted: { root.ensureStarted(); // Accessing the singleton here keeps its existing hyprctl setcursor // path alive for cursor-theme changes as well as cursor-size changes. Accessibility.applyAll(); } Timer { id: startupApply interval: 1200 onTriggered: { root.startupApplied = true; root.applyAll(); } } Connections { target: DesktopPreferences function onRevisionChanged(): void { applyCoalesce.restart(); } } Timer { id: applyCoalesce interval: 180 onTriggered: root.applyAll() } function ensureStarted(): void { if (!root.catalogLoaded && !root.scanning) root.refreshCatalog(); if (!root.startupApplied && !startupApply.running) startupApply.restart(); } function refreshCatalog(): void { if (catalogProcess.running) return; root.scanning = true; catalogProcess.exec([root.helperPath]); } function acceptCatalog(text: string): void { try { const parsed = JSON.parse(text); if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes)) throw new Error("invalid catalog shape"); const cursors = parsed.cursorThemes.filter(name => typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined); const icons = parsed.iconThemes.filter(name => typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined); root.cursorThemeNames = cursors; root.iconThemeNames = icons; root.cursorThemes = cursors.map(name => ({ value: name, label: name, detail: "Pointer theme" })); root.iconThemes = icons.map(name => ({ value: name, label: name, detail: "Application icon theme" })); root.catalogLoaded = true; root.lastError = ""; } catch (error) { root.cursorThemes = []; root.iconThemes = []; root.cursorThemeNames = []; root.iconThemeNames = []; root.catalogLoaded = false; root.lastError = "Installed icon and pointer themes could not be read."; } root.scanning = false; } function drain(): void { if (runner.running || root.pending.length === 0) return; const next = root.pending[0]; root.pending = root.pending.slice(1); runner.exec(next); } function enqueue(commands: var): void { // A fresh revision supersedes commands that have not started yet. The // currently running command is allowed to finish, then the newest full // state is replayed in a deterministic order. root.pending = commands; root.drain(); } // GVariant accepts JSON-style quoted strings. JSON.stringify escapes every // quote, backslash, and control character, and the schema patterns further // constrain stored font/theme names. Arguments still travel directly to // gsettings rather than through a shell. function gvariant(value: var): string { if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") return String(value); return JSON.stringify(String(value)); } function fontName(familyKey: string, sizeKey: string): string { return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`; } function buttonLayout(): string { const side = DesktopPreferences.get("titlebarButtonSide"); const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true; // Tokens are fixed. Only their side and whether maximize is present // vary, so preference data can never become command syntax. if (side === "left") return (maximize ? "close,maximize" : "close") + ":appmenu"; return "appmenu:" + (maximize ? "maximize,close" : "close"); } function setting(schema: string, key: string, value: var): var { return ["gsettings", "set", schema, key, root.gvariant(value)]; } function applyAll(): void { root.lastError = ""; root.enqueue([ root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme), root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme), root.setting("org.gnome.desktop.interface", "font-name", root.fontName("applicationFont", "applicationFontSize")), root.setting("org.gnome.desktop.interface", "document-font-name", root.fontName("documentFont", "documentFontSize")), root.setting("org.gnome.desktop.interface", "monospace-font-name", root.fontName("monospaceFont", "monospaceFontSize")), root.setting("org.gnome.desktop.interface", "font-hinting", DesktopPreferences.get("fontHinting")), root.setting("org.gnome.desktop.interface", "font-antialiasing", DesktopPreferences.get("fontAntialiasing")), root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste", DesktopPreferences.get("middleClickPaste")), root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()), root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar", DesktopPreferences.get("titlebarDoubleClick")), // The portal republishes this as org.freedesktop.appearance // contrast, which is what libadwaita reads -- so this reaches GTK4 // applications without any high-contrast theme being installed. root.setting("org.gnome.desktop.a11y.interface", "high-contrast", DesktopPreferences.get("highContrast")) ]); } function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool { if (!root.catalogLoaded || allowed.indexOf(value) < 0) { root.lastError = `That ${kind} theme is not installed.`; return false; } if (!DesktopPreferences.set(key, value)) { root.lastError = `That ${kind} theme name could not be saved.`; return false; } root.lastError = ""; return true; } function setCursorTheme(value: string): bool { return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer"); } function setIconTheme(value: string): bool { return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon"); } function storeFont(key: string, family: string, allowed: var, kind: string): bool { if (allowed.indexOf(family) < 0) { root.lastError = `That ${kind} font is not installed.`; return false; } if (!DesktopPreferences.set(key, family)) { root.lastError = `That ${kind} font name could not be saved.`; return false; } root.lastError = ""; return true; } function setApplicationFont(family: string): bool { return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application"); } function setDocumentFont(family: string): bool { return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document"); } function setMonospaceFont(family: string): bool { return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace"); } }