diff options
| -rw-r--r-- | appearance/AppearancePanel.qml | 31 | ||||
| -rw-r--r-- | appearance/Icons.qml | 170 | ||||
| -rw-r--r-- | appearance/IconsTab.qml | 177 | ||||
| -rw-r--r-- | 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 + // 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.`; } } 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 |
