// 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 import "../.." // 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 picked on the page, staged until Apply, like a wallpaper. property string pending: "" readonly property bool dirty: pending !== "" && pending !== current // 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; // install.sh also reloads what is running, and hands its whole output to // the steps window's `install` tab itself. 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 >/dev/null 2>&1`]; applyProc.running = true; } Process { id: applyProc property string scheme: "" onExited: code => { root.applying = false; if (code === 0) root.pending = ""; root.applied(applyProc.scheme, code === 0, code === 0 ? "" : `install.sh exited ${code}, its output is in the steps window`); } } // The consumers come from install.sh itself, one c_ function each, // so adding one in unified-desktop-theme needs no edit here. property list consumers: [] // name -> what it themes, the comment on its function line. property var consumerInfo: ({}) // name -> "software" or "hardware", for the page's two lists. property var consumerKind: ({}) function refreshStatus() { statusProc.running = false; statusProc.running = true; } Process { id: statusProc command: [`${root.repo}/install.sh`, "--list"] stdout: StdioCollector { onStreamFinished: { const rows = text.trim().split("\n").filter(s => s.length).map(l => l.split("\t")); // QML's JS engine has no Object.fromEntries. const info = {}, kind = {}; for (const [n, k, d] of rows) { kind[n] = k; info[n] = d ?? ""; } root.consumerInfo = info; root.consumerKind = kind; root.consumers = rows.map(r => r[0]); } } } // One run at a time, so two never race over the generated files. // "*" is every consumer at once, install.sh with no argument. // install.sh hands a failure's output to the steps window itself. property string reapplying: "" // name -> true (ok) / false (failed), for the run this session. property var reapplied: ({}) function reapply(name) { if (applying || reapplying !== "") return; reapplying = name; reapplyProc.command = [`${root.repo}/install.sh`].concat(name === "*" ? [] : [name]); reapplyProc.running = true; } function isReapplying(name) { return reapplying === "*" || reapplying === name; } Process { id: reapplyProc onExited: code => { const next = Object.assign({}, root.reapplied); // ponytail: a full run marks every row by the one exit code, since // install.sh stops at the first failure without naming it. for (const n of root.reapplying === "*" ? root.consumers : [root.reapplying]) next[n] = code === 0; root.reapplied = next; root.reapplying = ""; } } }