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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
|
// 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
// The icon and cursor themes installed on this machine. Previews are resolved
// from each theme, not just the active one: Quickshell.iconPath can only read
// the platform theme or QS_ICON_THEME, both fixed at load.
Singleton {
id: root
readonly property string home: Quickshell.env("HOME")
readonly property string iconScript: `
import sys
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
SAMPLES = ["folder","text-x-generic","image-x-generic","network-wireless",
"audio-x-generic","video-x-generic","battery-full","printer"]
t = Gtk.IconTheme.new()
for name in sys.argv[1:]:
t.set_custom_theme(name)
for s in SAMPLES:
info = t.lookup_icon(s, 32, 0)
if info:
print("%s\\t%s\\t%s" % (name, s, info.get_filename()))
print("%s\\tEND\\t" % name)
`
// Four shapes per theme, so a card shows the cases that matter (arrow,
// hand, text, horizontal resize) instead of one ambiguous pointer. Each
// shape falls back through common aliases and through every storage form a
// theme may use: a hyprcursor .hlc zip, a shape directory with an SVG, or
// a legacy Xcursor binary that goes through xcur2png.
readonly property string cursorScript: `
import sys, os, glob, shutil, zipfile, subprocess
outdir = sys.argv[1]
SAMPLES = {
"left_ptr": ["left_ptr", "default", "pointer"],
"hand2": ["hand2", "pointer", "hand"],
"xterm": ["xterm", "text", "ibeam"],
"resize": ["sb_h_double_arrow", "size_hor", "ew-resize", "h_double_arrow"],
}
roots = [os.path.expanduser("~/.icons"),
os.path.expanduser("~/.local/share/icons"),
"/usr/share/icons"]
def pick(d, names):
for n in names:
for cand in (f"{d}/hyprcursors/{n}.hlc", f"{d}/hyprcursors/{n}/{n}.svg",
f"{d}/{n}.hlc", f"{d}/{n}/{n}.svg", f"{d}/cursors/{n}"):
if os.path.isfile(cand):
return cand
return None
os.makedirs(outdir, exist_ok=True)
for name in sys.argv[2:]:
d = next((os.path.join(r, name) for r in roots if os.path.isdir(os.path.join(r, name))), None)
if not d:
continue
for shape, aliases in SAMPLES.items():
c = pick(d, aliases)
if not c:
continue
out = f"{outdir}/{name}-{shape}.png"
try:
if c.endswith(".hlc"):
with zipfile.ZipFile(c) as z:
members = [e for e in z.namelist() if e.endswith(".svg")]
if not members:
members = [e for e in z.namelist() if e.endswith(".png")]
if not members:
continue
with open(out, "wb") as f:
f.write(z.read(members[0]))
elif c.endswith(".svg"):
shutil.copyfile(c, out)
else:
raw = f"{outdir}/raw-{name}-{shape}"
shutil.rmtree(raw, ignore_errors=True)
os.makedirs(raw, exist_ok=True)
subprocess.run(["xcur2png", "-d", raw, "-c", f"{raw}/out.conf", c],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
pngs = sorted(glob.glob(f"{raw}/*_*.png"))
if not pngs:
continue
shutil.copyfile(pngs[-1], out)
print(f"{name}\\t{shape}\\t{out}")
except Exception:
continue
`
property var iconThemes: []
property var cursorThemes: []
property string currentIcon: ""
property string currentCursor: ""
property int cursorSize: 24
property var iconPreview: ({})
property var cursorPreview: ({})
property bool scanning: true
property string notice: ""
// Pure: classify works on entries shaped { name, index, cursors, manifest }
// so it can be tested without touching the filesystem.
function classify(entries) {
const icons = [], cursors = [];
for (const e of entries) {
if (e.cursors || e.manifest) cursors.push(e.name);
if (e.index && /Directories=\S/.test(e.index)) icons.push(e.name);
}
const uniq = a => a.filter((v, i) => a.indexOf(v) === i).sort();
return { icons: uniq(icons), cursors: uniq(cursors) };
}
function selftest(): string {
const entries = [
{ name: "Material-Black-Plum-Suru", index: "Directories=32x32/apps\n", cursors: false, manifest: false },
{ name: "hypr_bibata-modern-amber", index: "", cursors: false, manifest: true },
{ name: "default", index: "Inherits=Bibata-Modern-Amber\n", cursors: false, manifest: false },
{ name: "breeze_cursors", index: "", cursors: true, manifest: false },
{ name: "Adwaita", index: "Directories=16x16/apps\n", cursors: true, manifest: false },
];
const r = root.classify(entries);
if (r.icons.length !== 2 || r.icons.indexOf("Adwaita") < 0 ||
r.icons.indexOf("Material-Black-Plum-Suru") < 0)
return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`;
if (r.cursors.length !== 3 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 ||
r.cursors.indexOf("breeze_cursors") < 0 || r.cursors.indexOf("Adwaita") < 0)
return `SELFTEST Icons FAIL: cursors ${JSON.stringify(r.cursors)}`;
return "SELFTEST Icons PASS";
}
function refresh() {
scanProc.running = false;
scanProc.running = true;
curProc.running = false;
curProc.running = true;
}
// One shell pass over every theme root. Format per line:
// name|hasIndex|cursors|manifest|Directories=
Process {
id: scanProc
command: ["sh", "-c",
`for d in ${root.home}/.icons/* ${root.home}/.local/share/icons/* ` +
`/usr/share/icons/*; do [ -d "$d" ] || continue; ` +
`[ -L "$d" ] && continue; ` +
`i=0; c=0; m=0; dirs=""; ` +
`[ -d "$d/cursors" ] && c=1; ` +
`[ -f "$d/manifest.hl" ] && m=1; ` +
`if [ -f "$d/index.theme" ]; then i=1; dirs=$(sed -n 's/^Directories=//p' "$d/index.theme"); fi; ` +
`echo "$(basename "$d")|$i|$c|$m|$dirs"; done`]
stdout: StdioCollector {
onStreamFinished: {
const entries = [];
for (const line of text.trim().split("\n")) {
const p = line.split("|");
if (p.length < 5) continue;
entries.push({ name: p[0], index: p[1] === "1" ? "Directories=" + (p[4] || "x") : "",
cursors: p[2] === "1", manifest: p[3] === "1" });
}
const r = root.classify(entries);
root.iconThemes = r.icons;
root.cursorThemes = r.cursors;
root.scanning = false;
if (root.iconThemes.length) root.previewIcons();
if (root.cursorThemes.length) root.previewCursors();
}
}
}
// One python process for every icon theme, so opening the tab costs one
// spawn, not one per theme.
function previewIcons() {
if (!root.iconThemes.length) return;
iconProc.command = ["python3", "-c", root.iconScript].concat(root.iconThemes);
iconProc.running = false;
iconProc.running = true;
}
Process {
id: iconProc
stdout: StdioCollector {
onStreamFinished: {
const next = {};
for (const line of text.split("\n")) {
const p = line.split("\t");
if (p.length < 2 || p[1] === "END" || !p[2]) continue;
if (!next[p[0]]) next[p[0]] = {};
next[p[0]][p[1]] = p[2];
}
root.iconPreview = next;
}
}
}
// gsettings for the current values.
Process {
id: curProc
command: ["sh", "-c",
`gsettings get org.gnome.desktop.interface icon-theme; ` +
// The system's cursor theme is often the `default` alias, a symlink
// to the real theme. The scan skips symlinked dirs, so resolve the
// current value to the target name or nothing would read as current.
`ct=$(gsettings get org.gnome.desktop.interface cursor-theme | sed "s/'//g"); ` +
`for r in ${root.home}/.icons ${root.home}/.local/share/icons /usr/share/icons; do ` +
`[ -L "$r/$ct" ] && ct=$(basename "$(readlink -f "$r/$ct")") && break; done; ` +
`printf '%s\\n' "$ct"; ` +
`gsettings get org.gnome.desktop.interface cursor-size`]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split("\n");
root.currentIcon = (lines[0] ?? "").replace(/'/g, "");
root.currentCursor = (lines[1] ?? "").replace(/'/g, "");
const size = parseInt(lines[2] ?? "", 10);
if (!isNaN(size)) root.cursorSize = size;
}
}
}
// One python pass extracts four shapes for EVERY cursor theme and prints
// name<TAB>shape<TAB>path, so the whole row paints on open instead of
// waiting for a hover per card. A theme missing a shape simply shows fewer
// images rather than a broken one.
function previewCursors() {
if (!root.cursorThemes.length) return;
cursorProc.command = ["python3", "-c", root.cursorScript,
Quickshell.cachePath("cursors")].concat(root.cursorThemes);
cursorProc.running = false;
cursorProc.running = true;
}
Process {
id: cursorProc
stdout: StdioCollector {
onStreamFinished: {
const next = {};
for (const line of text.split("\n")) {
const p = line.split("\t");
if (p.length < 3 || !p[0] || !p[1] || !p[2]) continue;
if (!next[p[0]]) next[p[0]] = {};
next[p[0]][p[1]] = p[2];
}
root.cursorPreview = next;
}
}
}
// Writes go through FileView so no shell quoting is involved. They are
// preloaded because setText on an unloaded FileView would write empty.
FileView { id: qt6File; path: `${root.home}/.config/qt6ct/qt6ct.conf`; blockLoading: true }
FileView { id: qt5File; path: `${root.home}/.config/qt5ct/qt5ct.conf`; blockLoading: true }
FileView { id: envFile; path: `${root.home}/.config/hypr/sections/environment.lua`; blockLoading: true }
// GTK3 and GTK4 read their own settings.ini, with the theme names written
// out; gsettings alone does not switch them.
FileView { id: gtk3File; path: `${root.home}/.config/gtk-3.0/settings.ini`; blockLoading: true }
FileView { id: gtk4File; path: `${root.home}/.config/gtk-4.0/settings.ini`; blockLoading: true }
// Replaces a `key=value` line in both GTK settings files. A key the file
// does not carry is left alone, the same as the Qt configs.
function rewriteGtk(key, value) {
const files = [gtk3File, gtk4File];
const re = new RegExp(`^${key}=.*`, "m");
for (let i = 0; i < files.length; i++) {
const text = files[i].text();
if (text !== "") files[i].setText(text.replace(re, `${key}=${value}`));
}
}
function gsettingsSet(key, value) {
gsetProc.command = ["gsettings", "set", "org.gnome.desktop.interface", key, value];
gsetProc.running = false;
gsetProc.running = true;
}
Process { id: gsetProc }
function applyIcon(name) {
root.gsettingsSet("icon-theme", name);
const files = [qt6File, qt5File];
for (let i = 0; i < files.length; i++) {
const text = files[i].text();
if (text !== "") files[i].setText(text.replace(/^icon_theme=.*/m, `icon_theme=${name}`));
}
root.rewriteGtk("gtk-icon-theme-name", name);
root.currentIcon = name;
root.notice = `${name} set. Restart apps to see it.`;
}
function applyCursor(name) {
// Live switch first, then persistence.
Quickshell.execDetached(["hyprctl", "setcursor", name, String(root.cursorSize)]);
root.gsettingsSet("cursor-theme", name);
const text = envFile.text();
// Matches both XCURSOR_THEME and HYPRCURSOR_THEME: both end in
// CURSOR_THEME", ".
if (text !== "")
envFile.setText(text.replace(/(CURSOR_THEME", ")[^"]*/g, (m, p1) => p1 + name)
.replace(/(CURSOR_SIZE", ")[^"]*/g, (m, p1) => p1 + root.cursorSize));
root.rewriteGtk("gtk-cursor-theme-name", name);
root.rewriteGtk("gtk-cursor-theme-size", root.cursorSize);
root.currentCursor = name;
root.notice = `${name} set live at ${root.cursorSize}px. environment.lua updated for next login.`;
}
function setCursorSize(size) {
root.cursorSize = size;
// Live on the current theme, then GTK and the next login's env.
Quickshell.execDetached(["hyprctl", "setcursor", root.currentCursor, String(size)]);
root.gsettingsSet("cursor-size", String(size));
const text = envFile.text();
// Matches both XCURSOR_SIZE and HYPRCURSOR_SIZE.
if (text !== "")
envFile.setText(text.replace(/(CURSOR_SIZE", ")[^"]*/g, (m, p1) => p1 + size));
root.rewriteGtk("gtk-cursor-theme-size", size);
root.notice = `cursor size ${size}px. environment.lua updated for next login.`;
}
}
|