diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-16 12:31:37 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-16 12:31:37 +0200 |
| commit | 85a8d57b69cccba0b546e1f436fa4d419db94b8a (patch) | |
| tree | 1c33043907f639286b4315174a4f1f29d6634112 /appearance/Icons.qml | |
| parent | 5cdbc880afa6201f8aa289d52e434d04ecdc2c84 (diff) | |
| download | quickshell-85a8d57b69cccba0b546e1f436fa4d419db94b8a.tar.gz quickshell-85a8d57b69cccba0b546e1f436fa4d419db94b8a.zip | |
feat(appearance): add the icon and cursor theme model
Lists themes from ~/.icons, ~/.local/share/icons and /usr/share/icons,
distinguishing cursor themes by cursors/ or manifest.hl. Icon previews
come from one GTK lookup process for every theme; cursor previews extract
left_ptr from a hyprcursor .hlc via unzip or from an Xcursor theme via
xcur2png. Application sets gsettings and the Qt configs, and rewrites the
cursor env in environment.lua.
Diffstat (limited to 'appearance/Icons.qml')
| -rw-r--r-- | appearance/Icons.qml | 234 |
1 files changed, 234 insertions, 0 deletions
diff --git a/appearance/Icons.qml b/appearance/Icons.qml new file mode 100644 index 0000000..4c97a75 --- /dev/null +++ b/appearance/Icons.qml @@ -0,0 +1,234 @@ +// 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) +` + + property var iconThemes: [] + property var cursorThemes: [] + property string currentIcon: "" + property string currentCursor: "" + 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); + else 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 }, + ]; + const r = root.classify(entries); + if (r.icons.length !== 1 || r.icons[0] !== "Material-Black-Plum-Suru") + return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`; + if (r.cursors.length !== 2 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 || + r.cursors.indexOf("breeze_cursors") < 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; ` + + `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(); + } + } + } + + // 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; ` + + `gsettings get org.gnome.desktop.interface cursor-theme`] + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().split("\n"); + root.currentIcon = (lines[0] ?? "").replace(/'/g, ""); + root.currentCursor = (lines[1] ?? "").replace(/'/g, ""); + } + } + } + + // 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" >/dev/null 2>&1; ` + + `cp "$(ls ${dir}/raw-${name}/$(basename "$c")_*.png | tail -1)" ${out};; esac`] + curPreviewProc.running = false; + curPreviewProc.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`; + 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 } + + 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.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"]); + 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)); + root.currentCursor = name; + root.notice = `${name} set live. environment.lua updated for next login.`; + } +} |
