diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-16 13:46:37 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-16 13:46:37 +0200 |
| commit | 516e44c6c87825bfc985cbe6a1e73fce3fa7762a (patch) | |
| tree | eca6909bf1897c7701d0ba2b99200b91c499f9fe /appearance/Icons.qml | |
| parent | 4d63b1d50a593ea2a2475d79bf17d67240eff969 (diff) | |
| download | quickshell-516e44c6c87825bfc985cbe6a1e73fce3fa7762a.tar.gz quickshell-516e44c6c87825bfc985cbe6a1e73fce3fa7762a.zip | |
feat(appearance): polish the Icons tab previews and apply
Cursor previews were extracted only on hover, so the row stayed blank until
each card was visited. They are now batched on open: one python pass extracts
four shapes per theme (arrow, hand, text, horizontal resize) through the
theme's aliases and every storage form it may use (hyprcursor .hlc, a shape
directory SVG, or a legacy Xcursor binary via xcur2png).
The card rows are Flows so they wrap instead of running off the right edge,
and the tab scrolls. Cards widened and names wrap to two lines. A size
selector (16-64) applies the pointer size live, to gsettings, the GTK
settings.ini files and environment.lua.
gsettings alone does not reliably switch icon/cursor here: both gtk-3.0 and
gtk-4.0 settings.ini carry the names, so apply now rewrites
gtk-icon-theme-name / gtk-cursor-theme-name / gtk-cursor-theme-size there too.
The symlinked `default` cursor alias is skipped in the scan and the current
value resolved through it, so the real theme carries the current marker.
The tab row is centred against the panel so it reads as drawer chrome rather
than the first line of a tab's content.
Diffstat (limited to 'appearance/Icons.qml')
| -rw-r--r-- | appearance/Icons.qml | 170 |
1 files changed, 130 insertions, 40 deletions
diff --git a/appearance/Icons.qml b/appearance/Icons.qml index 690fff2..2149f6f 100644 --- a/appearance/Icons.qml +++ b/appearance/Icons.qml @@ -38,11 +38,74 @@ for name in sys.argv[1:]: 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 @@ -92,6 +155,7 @@ for name in sys.argv[1:]: 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; ` + @@ -111,6 +175,7 @@ for name in sys.argv[1:]: root.cursorThemes = r.cursors; root.scanning = false; if (root.iconThemes.length) root.previewIcons(); + if (root.cursorThemes.length) root.previewCursors(); } } } @@ -144,54 +209,47 @@ for name in sys.argv[1:]: id: curProc command: ["sh", "-c", `gsettings get org.gnome.desktop.interface icon-theme; ` + - `gsettings get org.gnome.desktop.interface cursor-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; } } } - // A cursor theme is a manifest + .hlc shapes, a shape directory with SVGs, - // or a legacy Xcursor cursors/ directory. The extracted image goes to the - // per-shell cache; on failure the tab shows nothing rather than a broken - // image. - function previewCursor(name) { - const dir = Quickshell.cachePath("cursors"); - const out = `${dir}/${name}.png`; - curPreviewProc.themeName = name; - curPreviewProc.command = ["sh", "-c", - `set -e; ` + - `d=""; for r in ${root.home}/.icons ${root.home}/.local/share/icons /usr/share/icons; do ` + - `[ -d "$r/${name}" ] && d="$r/${name}" && break; done; [ -n "$d" ] || exit 1; ` + - `mkdir -p ${dir}; ` + - `c=""; for n in left_ptr default pointer hand2; do ` + - `if [ -f "$d/hyprcursors/$n.hlc" ]; then c="$d/hyprcursors/$n.hlc"; break; fi; ` + - `if [ -f "$d/hyprcursors/$n/$n.svg" ]; then c="$d/hyprcursors/$n/$n.svg"; break; fi; ` + - `if [ -f "$d/$n.hlc" ]; then c="$d/$n.hlc"; break; fi; ` + - `if [ -f "$d/$n/$n.svg" ]; then c="$d/$n/$n.svg"; break; fi; ` + - `if [ -f "$d/cursors/$n" ]; then c="$d/cursors/$n"; break; fi; done; ` + - `[ -n "$c" ] || exit 1; ` + - `case "$c" in ` + - `*.hlc) unzip -p "$c" '*.svg' > ${out} 2>/dev/null; ` + - `[ -s ${out} ] || unzip -p "$c" '*.png' > ${out} 2>/dev/null; ` + - `[ -s ${out} ] || exit 1;; ` + - `*.svg) cp "$c" ${out};; ` + - `*) rm -rf ${dir}/raw-${name}; mkdir -p ${dir}/raw-${name}; ` + - `xcur2png -d ${dir}/raw-${name} -c ${dir}/raw-${name}/out.conf "$c" >/dev/null 2>&1; ` + - `cp "$(ls ${dir}/raw-${name}/$(basename "$c")_*.png | tail -1)" ${out};; esac`] - curPreviewProc.running = false; - curPreviewProc.running = true; + // 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: curPreviewProc - property string themeName: "" - onExited: code => { - if (code === 0) { - const next = Object.assign({}, root.cursorPreview); - next[themeName] = `${Quickshell.cachePath("cursors")}/${themeName}.png`; + 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; } } @@ -202,6 +260,21 @@ for name in sys.argv[1:]: 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]; @@ -217,20 +290,37 @@ for name in sys.argv[1:]: 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, "24"]); + 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)); + 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. environment.lua updated for next login.`; + 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.`; } } |
