From 516e44c6c87825bfc985cbe6a1e73fce3fa7762a Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 16 Sep 2026 13:46:37 +0200 Subject: 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. --- appearance/AppearancePanel.qml | 31 +++++--- appearance/Icons.qml | 170 +++++++++++++++++++++++++++++---------- appearance/IconsTab.qml | 177 ++++++++++++++++++++++++----------------- appearance/README.md | 21 +++-- 4 files changed, 270 insertions(+), 129 deletions(-) diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml index e034fda..42bc454 100644 --- a/appearance/AppearancePanel.qml +++ b/appearance/AppearancePanel.qml @@ -131,19 +131,30 @@ Scope { anchors.margins: 20 spacing: 16 - Row { - spacing: 8 - Repeater { - model: root.tabs - Tab { - required property var modelData - text: root.tabLabels[modelData] - selected: root.tab === modelData - onClicked: root.tab = modelData + // Centred against the panel, not against the hint: the row + // reads as the drawer's own chrome rather than the first + // line of a tab's content. + Item { + width: parent.width + height: tabBar.implicitHeight + + Row { + id: tabBar + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + Repeater { + model: root.tabs + Tab { + required property var modelData + text: root.tabLabels[modelData] + selected: root.tab === modelData + onClicked: root.tab = modelData + } } } - Item { width: 12; height: 1 } + Text { + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: "Tab switches · Esc closes" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } 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 + // nameshapepath, 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.`; } } diff --git a/appearance/IconsTab.qml b/appearance/IconsTab.qml index 92c3f74..b2c7ee4 100644 --- a/appearance/IconsTab.qml +++ b/appearance/IconsTab.qml @@ -13,32 +13,33 @@ import Quickshell import Quickshell.Widgets import QtQuick -Column { - spacing: 12 +Flickable { + contentHeight: col.implicitHeight + clip: true - Text { - text: Icons.scanning ? "scanning…" : `${Icons.iconThemes.length} icon themes · ${Icons.cursorThemes.length} cursor themes` - color: Theme.overlay - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - } - - Text { - text: "Icon theme" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } - } + property string liveCursorCursor: "" - Flickable { - id: iconList + Column { + id: col width: parent.width - height: 150 - contentWidth: iconRow.implicitWidth - contentHeight: height - clip: true - flickableDirection: Flickable.HorizontalFlick + spacing: 12 - Row { - id: iconRow + Text { + text: Icons.scanning ? "scanning…" : `${Icons.iconThemes.length} icon themes · ${Icons.cursorThemes.length} cursor themes` + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Text { + text: "Icon theme" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + + // Flow, not a horizontal strip: the cards wrap onto further rows rather + // than running off the right edge, and the tab scrolls vertically. + Flow { + width: parent.width spacing: 10 Repeater { model: Icons.iconThemes @@ -46,7 +47,7 @@ Column { id: iconCard required property string modelData readonly property bool current: Icons.currentIcon === modelData - width: 150; height: 120; radius: 10 + width: 230; height: 150; radius: 10 color: current ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) border.width: current ? 2 : 1 border.color: current ? Theme.accent : "transparent" @@ -58,10 +59,12 @@ Column { } Column { + width: parent.width - 16 anchors.centerIn: parent - spacing: 6 + spacing: 10 Row { - spacing: 6 + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 Repeater { model: ["folder", "text-x-generic", "image-x-generic", "network-wireless"] IconImage { @@ -73,45 +76,62 @@ Column { // /org/gtk gresource paths Qt cannot open. source: glyph.indexOf("/") === 0 && glyph.indexOf("/org/gtk/") !== 0 ? "file://" + glyph : "" - implicitSize: 26 + implicitSize: 44 } } } Text { - anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + horizontalAlignment: Text.AlignHCenter text: iconCard.current ? modelData + " current" : modelData color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + wrapMode: Text.WrapAnywhere + maximumLineCount: 2 + elide: Text.ElideRight } } } } } - } - - Text { - text: "Cursor theme" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } - } - - Flickable { - width: parent.width - height: 150 - contentWidth: cursorRow.implicitWidth - contentHeight: height - clip: true - flickableDirection: Flickable.HorizontalFlick Row { - id: cursorRow + spacing: 10 + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Cursor theme" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + Item { width: 12; height: 1 } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Size" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Repeater { + model: [16, 24, 32, 48, 64] + Tab { + required property var modelData + anchors.verticalCenter: parent.verticalCenter + text: String(modelData) + selected: Icons.cursorSize === modelData + onClicked: Icons.setCursorSize(modelData) + } + } + } + + Flow { + width: parent.width spacing: 10 Repeater { model: Icons.cursorThemes Rectangle { + id: cursorCard required property string modelData readonly property bool current: Icons.currentCursor === modelData - width: 120; height: 120; radius: 10 + width: 230; height: 150; radius: 10 color: current ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) border.width: current ? 2 : 1 border.color: current ? Theme.accent : "transparent" @@ -119,13 +139,15 @@ Column { HoverHandler { onHoveredChanged: { if (hovered) { - Icons.previewCursor(modelData); - // Live preview: the real cursor, reverted on leave. + // Previews are already batched on open; hovering + // only switches the real cursor and reverts it. liveCursorCursor = modelData; - Quickshell.execDetached(["hyprctl", "setcursor", modelData, "24"]); + Quickshell.execDetached(["hyprctl", "setcursor", modelData, + String(Icons.cursorSize)]); } else if (liveCursorCursor === modelData) { liveCursorCursor = ""; - Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, "24"]); + Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, + String(Icons.cursorSize)]); } } } @@ -136,49 +158,60 @@ Column { } Column { + width: parent.width - 16 anchors.centerIn: parent - spacing: 6 - Image { + spacing: 10 + Row { anchors.horizontalCenter: parent.horizontalCenter - width: 40; height: 40 - asynchronous: true - source: Icons.cursorPreview[modelData] ? "file://" + Icons.cursorPreview[modelData] : "" - fillMode: Image.PreserveAspectFit + spacing: 8 + Repeater { + // The cases that tell themes apart: arrow, hand, + // text and horizontal resize. + model: ["left_ptr", "hand2", "xterm", "resize"] + Image { + required property string modelData + readonly property string p: (Icons.cursorPreview[cursorCard.modelData] ?? {})[modelData] ?? "" + width: 44; height: 44 + asynchronous: true + source: p !== "" ? "file://" + p : "" + fillMode: Image.PreserveAspectFit + } + } } Text { - anchors.horizontalCenter: parent.horizontalCenter - text: parent.parent.current ? modelData + " current" : modelData - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } - width: 110 - elide: Text.ElideMiddle + width: parent.width horizontalAlignment: Text.AlignHCenter + text: cursorCard.current ? modelData + " current" : modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + wrapMode: Text.WrapAnywhere + maximumLineCount: 2 + elide: Text.ElideRight } } } } } - } - Text { - width: parent.width - wrapMode: Text.Wrap - visible: Icons.notice !== "" - text: Icons.notice - color: Theme.green - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Icons.notice !== "" + text: Icons.notice + color: Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } } - property string liveCursorCursor: "" onVisibleChanged: if (!visible && liveCursorCursor !== "") { - Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, "24"]); + Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, String(Icons.cursorSize)]); liveCursorCursor = ""; } // A Loader destroys this item on tab switch or drawer close, and visible // does not necessarily change first, so revert the real cursor here too. Component.onDestruction: if (liveCursorCursor !== "") { - Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, "24"]); + Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, String(Icons.cursorSize)]); liveCursorCursor = ""; } } diff --git a/appearance/README.md b/appearance/README.md index 9e1a41c..85fe5d7 100644 --- a/appearance/README.md +++ b/appearance/README.md @@ -44,13 +44,20 @@ written commented out. Save restarts `hypridle`, which resets its timers. ## Icons -Switches the icon and cursor theme. Icon previews come from a GTK lookup per -theme; cursor previews extract `left_ptr` from a hyprcursor `.hlc` with -`unzip` or from an Xcursor theme with `xcur2png`, and hovering changes the real -cursor. Applying writes `gsettings`, the Qt configs, and for cursors -`environment.lua`; apps and a relogin are needed to see the rest. The theme -name is still hardcoded in `unified-desktop-theme`, `waybar-theme-udt` and -rofi, which is tracked as a follow-up in those repos. +Switches the icon and cursor theme, and the pointer size. Icon previews come +from a GTK lookup per theme. Cursor previews come from one python pass per +open: for every theme it extracts four shapes (arrow, hand, text, horizontal +resize) out of a hyprcursor `.hlc`, a shape directory's SVG, or a legacy +Xcursor binary via `xcur2png`, so a card shows the cases that tell themes +apart. Hovering a card changes the real cursor and reverts it on leave. + +Applying writes `gsettings` (which GTK3 and GTK4 both read), the Qt configs +(`qt6ct`/`qt5ct`), and, for cursors, `environment.lua`. The GTK theme names do +not come from `gsettings` alone here, so the drawer also rewrites +`gtk-3.0/settings.ini` and `gtk-4.0/settings.ini` to match. Apps and a relogin +are needed to see the rest. The theme name is still hardcoded in +`unified-desktop-theme`, `waybar-theme-udt` and rofi, which is tracked as a +follow-up in those repos. ## Wallpapers -- cgit v1.2.3