aboutsummaryrefslogtreecommitdiffstats
path: root/appearance
diff options
context:
space:
mode:
Diffstat (limited to 'appearance')
-rw-r--r--appearance/Icons.qml234
-rw-r--r--appearance/shell.qml4
2 files changed, 237 insertions, 1 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.`;
+ }
+}
diff --git a/appearance/shell.qml b/appearance/shell.qml
index 8519bf6..c0356a8 100644
--- a/appearance/shell.qml
+++ b/appearance/shell.qml
@@ -20,6 +20,8 @@ ShellRoot {
function toggle() { panel.toggle(""); }
function wallpaper() { panel.toggle("wallpaper"); }
function theme() { panel.toggle("theme"); }
- function selftest(): string { return Hyprsunset.selftest() + "\n" + Hypridle.selftest(); }
+ function selftest(): string {
+ return Hyprsunset.selftest() + "\n" + Hypridle.selftest() + "\n" + Icons.selftest();
+ }
}
}