1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
|
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// 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<string> 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: <name>.conf holds colours under the scheme's own
// names, roles-<name>.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_<name> function each,
// so adding one in unified-desktop-theme needs no edit here.
property list<string> 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 = "";
}
}
}
|