// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. pragma Singleton import Quickshell import Quickshell.Io import QtQuick // The unified-desktop-theme side: which schemes exist, what they look like, // and switching between them. Singleton { id: root readonly property string home: Quickshell.env("HOME") readonly property string repo: `${home}/Programming/GIT/unified-desktop-theme` readonly property string selector: `${home}/.config/udt/roles.conf` property string current: "" // name -> { bg, surface, text, accent, red, green, yellow } property var swatches: ({}) property list names: [] property bool applying: false signal applied(string scheme, bool ok, string message) // A scheme is two files: .conf holds colours under the scheme's own // names, roles-.conf says what each colour is for. Resolving a role // means looking its value up as a key in the first file. function parseSwatch(rolesText, paletteText) { // QML's JS engine has no String.matchAll, so this is an exec loop. const colours = {}; const re = /^(\w+)\s*=\s*(#[0-9a-fA-F]{6})/gm; let m; while ((m = re.exec(paletteText)) !== null) colours[m[1]] = m[2]; const role = key => { const m = rolesText.match(new RegExp(`^${key}\\s*=\\s*(\\S+)`, "m")); // A role can carry a /alpha or *shade suffix; the base colour is // what a swatch shows. return m ? colours[m[1].split(/[/*]/)[0]] ?? "" : ""; }; return { bg: role("bg"), surface: role("surface"), text: role("fg"), accent: role("accent"), red: role("critical"), green: role("success"), yellow: role("warning"), }; } // Which scheme is live. Watched, so switching it from an editor moves the // panel's marker too. FileView { path: root.selector watchChanges: true onFileChanged: reload() onLoaded: { const m = text().match(/^\s*scheme\s*=\s*(\S+)/m); if (m) root.current = m[1]; } } // The scheme list comes from the palette directory rather than the comment // in roles.conf: adding a scheme is adding two files, and this then needs // no edit at all. // Rescanned whenever the panel opens: adding a scheme is adding two // files, and a shell that has been running since before they appeared // would otherwise keep showing the old list. function refresh() { listProc.running = false; listProc.running = true; } Process { id: listProc running: true command: ["sh", "-c", `ls ${root.repo}/palette/roles-*.conf 2>/dev/null | ` + `sed 's|.*/roles-||; s|\\.conf$||' | sort`] stdout: StdioCollector { onStreamFinished: { root.names = text.trim().split("\n").filter(s => s.length); loadProc.next = 0; loadProc.loadNext(); } } } Process { id: loadProc property int next: 0 property string scheme: "" function loadNext() { if (next >= root.names.length) return; scheme = root.names[next]; next++; // Both files at once, split on a marker: one process per scheme // rather than two, and the pair is always consistent. command = ["sh", "-c", `cat ${root.repo}/palette/roles-${scheme}.conf; ` + `echo '===SPLIT==='; cat ${root.repo}/palette/${scheme}.conf`]; // Assigning true to an already-true `running` does nothing, and // this Process is reused for every scheme in turn. running = false; running = true; } stdout: StdioCollector { onStreamFinished: { const [rolesText, paletteText] = text.split("===SPLIT==="); if (rolesText && paletteText) { const next = Object.assign({}, root.swatches); next[loadProc.scheme] = root.parseSwatch(rolesText, paletteText); root.swatches = next; } loadProc.loadNext(); } } } // Switching writes the scheme line and regenerates every config. Nothing // is reloaded here: install.sh reloads nothing itself, and which apps to // signal is the panel's message to the user rather than its job. function apply(scheme) { if (applying || scheme === current) return; applying = true; applyProc.scheme = scheme; applyProc.command = ["sh", "-c", `sed -i 's|^scheme *=.*|scheme = ${scheme}|' ${JSON.stringify(root.selector)} && ` + `${JSON.stringify(root.repo)}/install.sh`]; applyProc.running = true; } Process { id: applyProc property string scheme: "" stderr: StdioCollector { id: applyErr } onExited: code => { root.applying = false; root.applied(applyProc.scheme, code === 0, code === 0 ? "" : (applyErr.text.trim() || `install.sh exited ${code}`)); } } }