diff options
| -rw-r--r-- | AGENTS.md | 6 | ||||
| -rw-r--r-- | appearance/AppearancePanel.qml | 54 | ||||
| -rw-r--r-- | appearance/Field.qml | 50 | ||||
| -rw-r--r-- | appearance/Hypridle.qml | 161 | ||||
| -rw-r--r-- | appearance/Hyprsunset.qml | 326 | ||||
| -rw-r--r-- | appearance/Icons.qml | 326 | ||||
| -rw-r--r-- | appearance/IconsTab.qml | 253 | ||||
| -rw-r--r-- | appearance/IdleTab.qml | 100 | ||||
| -rw-r--r-- | appearance/README.md | 53 | ||||
| -rw-r--r-- | appearance/SunsetTab.qml | 169 | ||||
| -rw-r--r-- | appearance/Toggle.qml | 56 | ||||
| -rw-r--r-- | appearance/shell.qml | 6 | ||||
| -rw-r--r-- | window-switcher/README.md | 10 | ||||
| -rw-r--r-- | window-switcher/WindowCard.qml | 36 | ||||
| -rw-r--r-- | window-switcher/Windows.qml | 24 |
15 files changed, 1595 insertions, 35 deletions
@@ -241,9 +241,9 @@ changing that component. The ones that generalise: - **A notification appears as a balloon or in the drawer's reserved space, never both.** The drawer writes `notifyd/drawer` and the balloon shell reads it; `desktop/NotificationList.qml` is the space. -- **The dead `waybar/modules/custom/notification.jsonc` and - `waybar/scripts/notifications.py` still shell out to `dunstctl`.** They are - not in the live waybar config; do not resurrect them. +- **The old waybar notification module shelled out to `dunstctl`.** It and its + `notifications.py` are archived under `~/bin/archive/`, with the rest of the + stock waybar theme; do not resurrect them. ## Theme diff --git a/appearance/AppearancePanel.qml b/appearance/AppearancePanel.qml index 708682c..42bc454 100644 --- a/appearance/AppearancePanel.qml +++ b/appearance/AppearancePanel.qml @@ -21,6 +21,12 @@ Scope { property string tab: "wallpaper" property string notice: "" + readonly property var tabs: ["wallpaper", "theme", "sunset", "idle", "icons"] + readonly property var tabLabels: ({ + wallpaper: "Wallpaper", theme: "Theme", sunset: "Sunset", + idle: "Idle", icons: "Icons", + }) + // What the mock screens show. Hovering a thumbnail previews it on the // targeted screen; the other keeps whatever is currently set. property string hovering: "" @@ -35,6 +41,9 @@ Scope { // started, which for an autostarted one is the whole session. Udt.refresh(); Wallpapers.refresh(); + Hyprsunset.refresh(); + Hypridle.refresh(); + Icons.refresh(); root.open = true; } function close() { root.open = false; } @@ -99,7 +108,8 @@ Scope { Keys.onEscapePressed: root.close() Keys.onPressed: event => { if (event.key === Qt.Key_Tab) { - root.tab = root.tab === "theme" ? "wallpaper" : "theme"; + const i = root.tabs.indexOf(root.tab); + root.tab = root.tabs[(i + 1) % root.tabs.length]; event.accepted = true; } } @@ -121,20 +131,30 @@ Scope { anchors.margins: 20 spacing: 16 - Row { - spacing: 8 - Tab { - text: "Wallpaper" - selected: root.tab === "wallpaper" - onClicked: root.tab = "wallpaper" - } - Tab { - text: "Theme" - selected: root.tab === "theme" - onClicked: root.tab = "theme" + // 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 } @@ -154,7 +174,13 @@ Scope { Loader { width: parent.width height: parent.height - y - sourceComponent: root.tab === "theme" ? themeTab : wallpaperTab + // Existing tabs are inline Components; the new ones are + // files. sourceComponent wins when it is non-null. + sourceComponent: root.tab === "theme" ? themeTab + : root.tab === "wallpaper" ? wallpaperTab : null + source: root.tab === "sunset" ? Qt.resolvedUrl("SunsetTab.qml") + : root.tab === "idle" ? Qt.resolvedUrl("IdleTab.qml") + : root.tab === "icons" ? Qt.resolvedUrl("IconsTab.qml") : "" } } } diff --git a/appearance/Field.qml b/appearance/Field.qml new file mode 100644 index 0000000..004bda6 --- /dev/null +++ b/appearance/Field.qml @@ -0,0 +1,50 @@ +// 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. + +import QtQuick + +Rectangle { + id: field + property alias value: input.text + property string placeholder: "" + signal edited + + implicitWidth: 70 + height: 24 + radius: 5 + color: Qt.alpha(Theme.surface, 0.5) + border.width: 1 + border.color: input.activeFocus ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.text, 0.12) + + Text { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + verticalAlignment: Text.AlignVCenter + visible: input.text === "" + text: field.placeholder + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + TextInput { + id: input + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.text + onEditingFinished: field.edited() + } +} diff --git a/appearance/Hypridle.qml b/appearance/Hypridle.qml new file mode 100644 index 0000000..1696f5f --- /dev/null +++ b/appearance/Hypridle.qml @@ -0,0 +1,161 @@ +// 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 hypridle side. Commands are fixed: only each listener's timeout and +// whether it is active are editable. Everything before the first listener is +// kept verbatim so the general block and the rationale comments survive. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string confPath: `${home}/.config/hypr/hypridle.conf` + + property string prefix: "" + property var listeners: [] + property string tail: "" + property string notice: "" + property bool loaded: false + + function getField(body, key) { + const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); + return m ? m[1] : null; + } + + // A disabled listener is written every line prefixed with "# ". Strip it + // before reading fields. + function uncomment(body) { + return body.split("\n").map(l => l.replace(/^([ \t]*)#[ \t]?/, "$1")).join("\n"); + } + + // The closing brace is matched at the start of a line, because the dpms + // command contains a brace mid-line and a non-greedy match would stop + // inside it. A disabled listener has both its opening line and its closing + // brace commented, so the closing pattern allows a leading "#". The first + // listener's leading trivia is empty because its text is the prefix. + function parse(text) { + const re = /^([ \t]*)(#?)[ \t]*listener\s*\{([\s\S]*?)^[ \t]*#?[ \t]*\}/gm; + const found = []; + let firstStart = -1; + let prevEnd = 0; + let m; + while ((m = re.exec(text)) !== null) { + const start = m.index; + if (firstStart < 0) { firstStart = start; prevEnd = start; } + const leading = text.slice(prevEnd, start); + const enabled = m[2] !== "#"; + const body = enabled ? m[3] : root.uncomment(m[3]); + const t = root.getField(body, "timeout"); + found.push({ + leading: leading, + timeout: t === null ? 0 : parseInt(t, 10), + onTimeout: root.getField(body, "on-timeout") ?? "", + onResume: root.getField(body, "on-resume") ?? "", + enabled: enabled, + }); + prevEnd = start + m[0].length; + } + if (firstStart < 0) return { prefix: text, listeners: [], tail: "" }; + return { prefix: text.slice(0, firstStart), listeners: found, tail: text.slice(prevEnd) }; + } + + function serialize(prefix, list, tail) { + let out = prefix; + for (const l of list) { + const lines = ["listener {", ` timeout = ${l.timeout}`, + ` on-timeout = ${l.onTimeout}`]; + if (l.onResume) lines.push(` on-resume = ${l.onResume}`); + lines.push("}"); + const body = l.enabled ? lines.join("\n") : lines.map(x => "# " + x).join("\n"); + out += l.leading + body; + } + return out + tail; + } + + function describe(l) { + const cmd = l.onTimeout; + if (cmd.indexOf("notify-send") === 0) return "notify before lock"; + if (cmd.indexOf("loginctl lock-session") >= 0) return "lock session"; + if (cmd.indexOf("dpms") >= 0) return "monitors off"; + if (cmd.indexOf("loginctl suspend") >= 0) return "suspend"; + return cmd; + } + + function selftest(): string { + const fixture = String.raw`general { + lock_cmd = pidof hyprlock || hyprlock +} + +# 10:00s - screen lock +listener { + timeout = 600 + on-timeout = loginctl lock-session +} + +# 10:30s - monitor off +listener { + timeout = 630 + on-timeout = hyprctl dispatch "hl.dsp.dpms({ state = \"off\" })" + on-resume = hyprctl dispatch "hl.dsp.dpms({ state = \"on\" })" +} +`; + const p = root.parse(fixture); + if (p.listeners.length !== 2) return `SELFTEST Hypridle FAIL: ${p.listeners.length} listeners`; + if (p.listeners[0].timeout !== 600 || p.listeners[0].enabled !== true) + return "SELFTEST Hypridle FAIL: first listener"; + if (p.listeners[1].onTimeout.indexOf(String.raw`state = \"off\"`) < 0) + return "SELFTEST Hypridle FAIL: dpms command truncated"; + if (p.listeners[1].onResume.indexOf(String.raw`state = \"on\"`) < 0) + return "SELFTEST Hypridle FAIL: dpms on-resume lost"; + if (root.serialize(p.prefix, p.listeners, p.tail) !== fixture) + return "SELFTEST Hypridle FAIL: round-trip not byte-identical"; + const off = p.listeners.slice(); + off[1] = Object.assign({}, off[1], { enabled: false }); + const p2 = root.parse(root.serialize(p.prefix, off, p.tail)); + if (p2.listeners.length !== 2 || p2.listeners[1].enabled !== false || + p2.listeners[1].timeout !== 630) + return "SELFTEST Hypridle FAIL: disabled listener not round-tripped"; + return "SELFTEST Hypridle PASS"; + } + + function refresh() { + confProc.running = false; + confProc.running = true; + } + + Process { + id: confProc + command: ["cat", root.confPath] + stdout: StdioCollector { + onStreamFinished: { + const p = root.parse(text); + root.prefix = p.prefix; + root.listeners = p.listeners; + root.tail = p.tail; + root.loaded = true; + } + } + } + + FileView { id: confFile; path: root.confPath } + + function save() { + if (!root.loaded) return; // not loaded yet: writing would truncate the file + confFile.setText(root.serialize(root.prefix, root.listeners, root.tail)); + Quickshell.execDetached(["sh", "-c", "pkill -x hypridle; setsid -f hypridle"]); + root.notice = "saved; idle timers reset"; + } +} diff --git a/appearance/Hyprsunset.qml b/appearance/Hyprsunset.qml new file mode 100644 index 0000000..fc0d44a --- /dev/null +++ b/appearance/Hyprsunset.qml @@ -0,0 +1,326 @@ +// 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 hyprsunset side: profiles in ~/.config/hypr/hyprsunset.conf, the +// location in hyprsunset-qt's own config, and the daemon. The file format is +// byte-for-byte what ~/Programming/GIT/sunset-qt writes, so both tools edit +// the same file. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string confPath: `${home}/.config/hypr/hyprsunset.conf` + readonly property string appConfPath: `${home}/.config/hyprsunset-qt/config` + + property var profiles: [] + property string lat: "" + property string lon: "" + property bool autoDetect: true + property string daemonCommand: "hyprsunset" + property string cachePathRaw: "~/.config/hyprsunset-qt/sun.json" + property string cachePath: `${home}/.config/hyprsunset-qt/sun.json` + property string notice: "" + property string sunSummary: "" + property bool busy: false + property bool confLoaded: false + property bool appLoaded: false + + readonly property string header: + "# Managed by hyprsunset-qt. Edits here are overwritten on save.\n" + + // QML has no String.matchAll; everything here is exec loops. + function getField(body, key) { + const m = body.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m")); + return m ? m[1] : null; + } + + function parseProfiles(text) { + const list = []; + const re = /profile\s*\{([\s\S]*?)\}/g; + let m; + while ((m = re.exec(text)) !== null) { + const body = m[1]; + const t = root.getField(body, "temperature"); + const g = root.getField(body, "gamma"); + list.push({ + time: root.getField(body, "time") ?? "", + identity: (root.getField(body, "identity") ?? "").toLowerCase() === "true", + temperature: t === null ? null : parseInt(t, 10), + gamma: g === null ? null : parseFloat(g), + }); + } + return list; + } + + // Byte-for-byte the same construction as sunset-qt's config.serialize(): + // a list of parts joined with newlines, so a no-op save writes the file + // back exactly as it was. + function serializeProfiles(list) { + const di = root.dayIndex(list); + const ni = root.nightIndex(list); + const parts = [root.header]; + for (let i = 0; i < list.length; i++) { + const p = list[i]; + if (i === di) parts.push("\n# day profile -- sunrise"); + else if (i === ni) parts.push("\n# night profile -- sunset"); + else parts.push("\n# profile"); + const lines = ["profile {", ` time = ${p.time}`]; + if (p.identity) lines.push(" identity = true"); + if (p.temperature !== null && p.temperature !== undefined) + lines.push(` temperature = ${p.temperature}`); + if (p.gamma !== null && p.gamma !== undefined) + lines.push(` gamma = ${p.gamma}`); + lines.push("}"); + parts.push(lines.join("\n")); + } + return parts.join("\n") + "\n"; + } + + function dayIndex(list) { + for (let i = 0; i < list.length; i++) if (list[i].identity) return i; + return null; + } + + function nightIndex(list) { + for (let i = 0; i < list.length; i++) + if (list[i].temperature !== null && list[i].temperature !== undefined) return i; + return null; + } + + function validTime(s) { return /^([01]?\d|2[0-3]):([0-5]\d)$/.test(s); } + function validTemperature(t) { return t >= 1000 && t <= 20000; } + function validGamma(g) { return g >= 0.0 && g <= 2.0; } + + function localHM(iso) { + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); + } + + function selftest(): string { + // A file exactly as hyprsunset-qt writes it. String.raw keeps the + // backslashes, none here, but keeps this fixture readable. + // Two blank lines after the header: sunset-qt's serializer joins parts + // with a newline and each part starts with one, so the file really has + // them. + const fixture = String.raw`# Managed by hyprsunset-qt. Edits here are overwritten on save. + + +# day profile -- sunrise +profile { + time = 05:42 + identity = true +} + +# night profile -- sunset +profile { + time = 21:02 + temperature = 5500 + gamma = 0.8 +} +`; + const parsed = root.parseProfiles(fixture); + if (parsed.length !== 2) return `SELFTEST Hyprsunset FAIL: parsed ${parsed.length} profiles`; + if (parsed[0].time !== "05:42" || parsed[0].identity !== true) + return "SELFTEST Hyprsunset FAIL: day profile wrong"; + if (parsed[1].temperature !== 5500 || parsed[1].gamma !== 0.8) + return "SELFTEST Hyprsunset FAIL: night profile wrong"; + if (root.dayIndex(parsed) !== 0 || root.nightIndex(parsed) !== 1) + return "SELFTEST Hyprsunset FAIL: day/night index wrong"; + if (root.serializeProfiles(parsed) !== fixture) + return "SELFTEST Hyprsunset FAIL: round-trip not byte-identical"; + if (!root.validTime("05:42") || root.validTime("24:00") || root.validTime("5:6")) + return "SELFTEST Hyprsunset FAIL: validTime"; + if (!root.validTemperature(1000) || root.validTemperature(20001)) + return "SELFTEST Hyprsunset FAIL: validTemperature"; + if (!root.validGamma(0.8) || root.validGamma(2.1)) + return "SELFTEST Hyprsunset FAIL: validGamma"; + return "SELFTEST Hyprsunset PASS"; + } + + function refresh() { + confProc.running = false; + confProc.running = true; + appProc.running = false; + appProc.running = true; + } + + Process { + id: confProc + command: ["cat", root.confPath] + stdout: StdioCollector { + onStreamFinished: { + root.profiles = root.parseProfiles(text); + root.confLoaded = true; + } + } + } + + // hyprsunset-qt's own settings: location + daemon command. + function parseAppConf(text) { + const out = {}; + let section = ""; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + const sec = line.match(/^\[(.+)\]$/); + if (sec) { section = sec[1]; continue; } + const kv = line.match(/^([^=]+)=\s*(.*)$/); + if (kv) out[`${section}.${kv[1].trim()}`] = kv[2].trim(); + } + return out; + } + + function expandTilde(p) { + return p.startsWith("~/") ? root.home + p.slice(1) : p; + } + + Process { + id: appProc + command: ["cat", root.appConfPath] + stdout: StdioCollector { + onStreamFinished: { + const c = root.parseAppConf(text); + root.lat = c["location.lat"] ?? ""; + root.lon = c["location.lon"] ?? ""; + root.autoDetect = (c["location.auto_detect"] ?? "true") === "true"; + root.cachePathRaw = c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json"; + root.cachePath = root.expandTilde(root.cachePathRaw); + root.daemonCommand = c["daemon.command"] ?? "hyprsunset"; + root.appLoaded = true; + } + } + } + + FileView { id: confFile; path: root.confPath } + FileView { id: appFile; path: root.appConfPath } + FileView { id: cacheFile; path: root.cachePath } + + function appConfText() { + return `[location]\nlat = ${root.lat}\nlon = ${root.lon}\n` + + `auto_detect = ${root.autoDetect}\n\n` + + `[cache]\npath = ${root.cachePathRaw}\n\n` + + `[daemon]\ncommand = ${root.daemonCommand}\n\n`; + } + function saveSettings() { appFile.setText(root.appConfText()); } + + // hyprsunset holds Hyprland's CTM manager exclusively, and pkill only + // sends SIGTERM, so the replacement must wait for the old process to + // actually exit or it dies with "A CTM manager is already running". + function restart() { + Quickshell.execDetached(["sh", "-c", + `pkill -x hyprsunset; ` + + `for i in $(seq 50); do ` + + `pgrep -x hyprsunset >/dev/null 2>&1 || break; sleep 0.1; done; ` + + `setsid -f ${root.daemonCommand}`]); + } + + function save() { + if (!root.confLoaded || !root.appLoaded) return; + for (const p of root.profiles) { + if (!root.validTime(p.time)) { root.notice = `invalid time: ${p.time}`; return; } + if (p.temperature !== null && p.temperature !== undefined && + !root.validTemperature(p.temperature)) { + root.notice = `invalid temperature: ${p.temperature}`; return; + } + if (p.gamma !== null && p.gamma !== undefined && !root.validGamma(p.gamma)) { + root.notice = `invalid gamma: ${p.gamma}`; return; + } + } + confFile.setText(root.serializeProfiles(root.profiles)); + root.saveSettings(); + root.restart(); + root.notice = "saved + restarted"; + } + + // Live preview via the daemon's IPC. Identity wins, then temperature, then + // gamma, matching hyprsunset-qt. Nothing is written. + function preview() { + const i = root.nightIndex(root.profiles); + const target = i !== null ? root.profiles[i] + : (root.profiles.length ? root.profiles[0] : null); + if (!target) return; + if (target.identity) { + previewProc.command = ["hyprctl", "hyprsunset", "identity"]; + } else { + const parts = []; + if (target.temperature !== null && target.temperature !== undefined) + parts.push(`hyprctl hyprsunset temperature ${target.temperature}`); + if (target.gamma !== null && target.gamma !== undefined) + parts.push(`hyprctl hyprsunset gamma ${Math.round(target.gamma * 100)}`); + if (!parts.length) return; + previewProc.command = ["sh", "-c", parts.join("; ")]; + } + previewProc.running = false; + previewProc.running = true; + } + Process { id: previewProc } + + function detect() { + detectProc.running = false; + detectProc.running = true; + } + Process { + id: detectProc + command: ["curl", "-fsS", "http://ip-api.com/json"] + stdout: StdioCollector { + onStreamFinished: { + try { + const d = JSON.parse(text); + root.lat = String(d.lat); + root.lon = String(d.lon); + root.saveSettings(); + root.notice = `located ${root.lat}, ${root.lon}`; + } catch (e) { root.notice = `detect failed: ${e}`; } + } + } + } + + // sunrise-sunset.org is behind Cloudflare and 403s curl's default UA. + function fetchSun() { + const url = "https://api.sunrise-sunset.org/json?lat=" + + encodeURIComponent(root.lat) + "&lng=" + encodeURIComponent(root.lon) + + "&formatted=0"; + fetchProc.command = ["curl", "-fsS", "-A", + "Mozilla/5.0 (X11; Linux x86_64) hyprsunset-qt", url]; + fetchProc.running = false; + fetchProc.running = true; + root.busy = true; + } + Process { + id: fetchProc + stdout: StdioCollector { + onStreamFinished: { + root.busy = false; + let data; + try { data = JSON.parse(text); } + catch (e) { root.notice = `fetch failed: ${e}`; return; } + cacheFile.setText(JSON.stringify(data, null, 2)); + const r = data.results ?? {}; + const rise = r.sunrise ? root.localHM(r.sunrise) : ""; + const set = r.sunset ? root.localHM(r.sunset) : ""; + root.sunSummary = `sunrise ${rise || "—"} sunset ${set || "—"}`; + const di = root.dayIndex(root.profiles); + const ni = root.nightIndex(root.profiles); + const next = root.profiles.slice(); + if (di !== null && rise) next[di] = Object.assign({}, next[di], { time: rise }); + if (ni !== null && set) next[ni] = Object.assign({}, next[ni], { time: set }); + root.profiles = next; + } + } + } +} diff --git a/appearance/Icons.qml b/appearance/Icons.qml new file mode 100644 index 0000000..2149f6f --- /dev/null +++ b/appearance/Icons.qml @@ -0,0 +1,326 @@ +// 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) +` + // 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 + 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); + 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 }, + { name: "Adwaita", index: "Directories=16x16/apps\n", cursors: true, manifest: false }, + ]; + const r = root.classify(entries); + if (r.icons.length !== 2 || r.icons.indexOf("Adwaita") < 0 || + r.icons.indexOf("Material-Black-Plum-Suru") < 0) + return `SELFTEST Icons FAIL: icons ${JSON.stringify(r.icons)}`; + if (r.cursors.length !== 3 || r.cursors.indexOf("hypr_bibata-modern-amber") < 0 || + r.cursors.indexOf("breeze_cursors") < 0 || r.cursors.indexOf("Adwaita") < 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; ` + + `[ -L "$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(); + if (root.cursorThemes.length) root.previewCursors(); + } + } + } + + // 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; ` + + // 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; + } + } + } + + // 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: 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; + } + } + } + + // 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 } + // 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]; + 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.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, 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) + .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 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 new file mode 100644 index 0000000..908a003 --- /dev/null +++ b/appearance/IconsTab.qml @@ -0,0 +1,253 @@ +// 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. + +import Quickshell +import Quickshell.Widgets +import QtQuick + +Flickable { + contentHeight: col.implicitHeight + clip: true + + property string liveCursorCursor: "" + // Clicking a cursor card stages it; Apply commits. The hover preview above + // switches the real cursor and reverts, so staging must not be confused + // with it: pendingCursor survives unhover, liveCursorCursor does not. + property string pendingCursor: "" + + Column { + id: col + width: parent.width + spacing: 12 + + 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 + Rectangle { + id: iconCard + required property string modelData + readonly property bool current: Icons.currentIcon === modelData + 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" + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Icons.applyIcon(modelData) + } + + Column { + width: parent.width - 16 + anchors.centerIn: parent + spacing: 10 + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + Repeater { + model: ["folder", "text-x-generic", "image-x-generic", "network-wireless"] + IconImage { + required property string modelData + readonly property string glyph: (Icons.iconPreview[iconCard.modelData] ?? {})[modelData] ?? "" + // Absolute paths must carry file://, or they + // resolve against the config's qrc base and + // fail; Gtk's builtin icons come back as + // /org/gtk gresource paths Qt cannot open. + source: glyph.indexOf("/") === 0 && glyph.indexOf("/org/gtk/") !== 0 + ? "file://" + glyph : "" + implicitSize: 44 + } + } + } + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: iconCard.current ? modelData + " current" : modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + wrapMode: Text.WrapAnywhere + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + } + } + + Row { + 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 + readonly property bool staged: pendingCursor === modelData + width: 230; height: 150; radius: 10 + color: current || staged ? Qt.alpha(Theme.accent, 0.2) : Qt.alpha(Theme.surface, 0.35) + border.width: current || staged ? 2 : 1 + border.color: staged ? Theme.accent + : current ? Qt.alpha(Theme.accent, 0.5) + : "transparent" + + HoverHandler { + onHoveredChanged: { + if (hovered) { + // Previews are already batched on open; hovering + // only switches the real cursor and reverts it. + liveCursorCursor = modelData; + Quickshell.execDetached(["hyprctl", "setcursor", modelData, + String(Icons.cursorSize)]); + } else if (liveCursorCursor === modelData) { + liveCursorCursor = ""; + Quickshell.execDetached(["hyprctl", "setcursor", Icons.currentCursor, + String(Icons.cursorSize)]); + } + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: pendingCursor = modelData + } + + Column { + width: parent.width - 16 + anchors.centerIn: parent + spacing: 10 + Row { + anchors.horizontalCenter: parent.horizontalCenter + 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 { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: cursorCard.staged ? modelData + " staged" + : cursorCard.current ? modelData + " current" + : modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + wrapMode: Text.WrapAnywhere + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + } + } + + Row { + spacing: 8 + Text { + anchors.verticalCenter: parent.verticalCenter + text: pendingCursor !== "" ? "click Apply to set" : "click a cursor theme to stage it" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Tab { + text: "Apply" + selected: pendingCursor !== "" + enabled: pendingCursor !== "" + onClicked: { + // The hover revert would otherwise fire on the way out and + // set the old theme back over the one just applied. + liveCursorCursor = ""; + Icons.applyCursor(pendingCursor); + pendingCursor = ""; + } + } + Tab { + text: "Reset" + enabled: pendingCursor !== "" + onClicked: pendingCursor = "" + } + } + + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Icons.notice !== "" + text: Icons.notice + color: Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + + onVisibleChanged: if (!visible && liveCursorCursor !== "") { + 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, String(Icons.cursorSize)]); + liveCursorCursor = ""; + } +} diff --git a/appearance/IdleTab.qml b/appearance/IdleTab.qml new file mode 100644 index 0000000..75ce84a --- /dev/null +++ b/appearance/IdleTab.qml @@ -0,0 +1,100 @@ +// 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. + +import QtQuick + +Flickable { + contentHeight: col.implicitHeight + clip: true + + Column { + id: col + width: parent.width + spacing: 10 + + Text { + text: "Commands are fixed. Only the timeout and whether a listener runs are editable." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: Hypridle.listeners + Rectangle { + required property var modelData + required property int index + width: col.width + implicitHeight: 40 + radius: 8 + color: Qt.alpha(Theme.surface, modelData.enabled ? 0.4 : 0.2) + opacity: modelData.enabled ? 1 : 0.6 + + Row { + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.enabled + onToggled: Hypridle.listeners = Hypridle.listeners.map( + (l, i) => i === index ? Object.assign({}, l, { enabled: checked }) : l) + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: 150 + text: Hypridle.describe(modelData) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 70 + value: String(modelData.timeout) + placeholder: "seconds" + onEdited: Hypridle.listeners = Hypridle.listeners.map( + (l, i) => i === index + ? Object.assign({}, l, { timeout: parseInt(value, 10) || 0 }) : l) + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: `${Math.floor(modelData.timeout / 60)}:${String(modelData.timeout % 60).padStart(2, "0")}` + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 380 + elide: Text.ElideRight + text: modelData.onTimeout + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } + } + } + + Tab { + text: "Save + Restart hypridle" + selected: true + onClicked: Hypridle.save() + } + + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Hypridle.notice !== "" + text: Hypridle.notice + color: Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } +} diff --git a/appearance/README.md b/appearance/README.md index 2a39bd7..85fe5d7 100644 --- a/appearance/README.md +++ b/appearance/README.md @@ -3,15 +3,15 @@ Wallpapers and colour scheme in one drawer. `SUPER+Return` opens it on the Wallpaper tab; Tab switches tabs, Escape closes. - ┌─[ Wallpaper ]─[ Theme ]──────────────────────────┐ - │ Set on [Horizontal] [Vertical] 261 wallpapers │ - │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌──┐ ┌─────┐ │ - │ │ │ │ │ │ │ │ │ │ │ │ │ │ - │ └────┘ └────┘ └────┘ └────┘ └┬─┘ └──┬──┘ │ - │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ═╧═ ══╧══ │ - │ │ │ │ │ │ │ │ │ [Apply] [Reset]│ - │ └────┘ └────┘ └────┘ └────┘ │ - └──────────────────────────────────────────────────┘ + ┌─[ Wallpaper ]─[ Theme ]─[ Sunset ]─[ Idle ]─[ Icons ]──┐ + │ Set on [Horizontal] [Vertical] 261 wallpapers │ + │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌──┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ │ │ │ │ │ │ + │ └────┘ └────┘ └────┘ └────┘ └┬─┘ └──┬──┘ │ + │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ═╧═ ══╧══ │ + │ │ │ │ │ │ │ │ │ [Apply] [Reset] │ + │ └────┘ └────┘ └────┘ └────┘ │ + └────────────────────────────────────────────────────────┘ ## Running it @@ -24,7 +24,40 @@ be running for the keybind to work: "qs -p ~/Programming/GIT/quickshell/appearance ipc call appearance wallpaper")) Write that path out in full in the real config: `exec_cmd` has no shell to -expand `~`. `ipc call appearance theme` opens the other tab. +expand `~`. `ipc call appearance theme` opens the Theme tab. + +IPC verbs: `wallpaper`, `theme`, `sunset`, `idle`, `icons`. + +## Sunset + +A port of `hyprsunset-qt` (`~/Programming/GIT/sunset-qt`): profiles in +`~/.config/hypr/hyprsunset.conf`, location in +`~/.config/hyprsunset-qt/config`, sunrise/sunset from the same API and cache. +Both apps read and write the identical file format, so either can edit it. + +## Idle + +Timeout and enabled only; the commands in `~/.config/hypr/hypridle.conf` are +fixed. Everything before the first `listener` (the `general` block and the +comments explaining the design) is preserved verbatim. A disabled listener is +written commented out. Save restarts `hypridle`, which resets its timers. + +## Icons + +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 diff --git a/appearance/SunsetTab.qml b/appearance/SunsetTab.qml new file mode 100644 index 0000000..50f6ad6 --- /dev/null +++ b/appearance/SunsetTab.qml @@ -0,0 +1,169 @@ +// 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. + +import QtQuick + +Flickable { + contentHeight: col.implicitHeight + clip: true + + Column { + id: col + width: parent.width + spacing: 12 + + Row { + spacing: 8 + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Lat" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { width: 90; value: Hyprsunset.lat; onEdited: Hyprsunset.lat = value; placeholder: "lat" } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Lon" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { width: 90; value: Hyprsunset.lon; onEdited: Hyprsunset.lon = value; placeholder: "lon" } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: Hyprsunset.autoDetect + label: "auto" + onToggled: Hyprsunset.autoDetect = checked + } + Tab { text: "Detect"; onClicked: Hyprsunset.detect() } + Tab { text: Hyprsunset.busy ? "fetching…" : "Fetch sun times"; onClicked: Hyprsunset.fetchSun() } + Text { + anchors.verticalCenter: parent.verticalCenter + text: Hyprsunset.sunSummary + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + + Repeater { + model: Hyprsunset.profiles + Rectangle { + required property var modelData + required property int index + width: col.width + implicitHeight: 40 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Row { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: index === Hyprsunset.dayIndex(Hyprsunset.profiles) ? "day" + : index === Hyprsunset.nightIndex(Hyprsunset.profiles) ? "night" : "profile" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 60 + value: modelData.time + placeholder: "HH:MM" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, { time: value }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.identity + label: "identity" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, { identity: checked }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.temperature !== null && modelData.temperature !== undefined + label: "temp" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { temperature: checked ? (p.temperature ?? 5500) : null }) : p) + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 70 + value: modelData.temperature === null || modelData.temperature === undefined + ? "" : String(modelData.temperature) + placeholder: "5500" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { temperature: value === "" ? null : parseInt(value, 10) }) : p) + } + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: modelData.gamma !== null && modelData.gamma !== undefined + label: "gamma" + onToggled: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { gamma: checked ? (p.gamma ?? 1.0) : null }) : p) + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 60 + value: modelData.gamma === null || modelData.gamma === undefined + ? "" : String(modelData.gamma) + placeholder: "0.8" + onEdited: Hyprsunset.profiles = Hyprsunset.profiles.map( + (p, i) => i === index ? Object.assign({}, p, + { gamma: value === "" ? null : parseFloat(value) }) : p) + } + Item { width: 8; height: 1 } + Tab { + anchors.verticalCenter: parent.verticalCenter + text: "✕" + onClicked: Hyprsunset.profiles = + Hyprsunset.profiles.filter((p, i) => i !== index) + } + // The Row is pinned to the card width, so parent.width is + // real, but bound it anyway: a narrow enough panel would + // make the subtraction negative and put the Row in a + // negative-width state. + Item { width: Math.max(0, parent.width - 640); height: 1 } + } + } + } + + Row { + spacing: 8 + Tab { + text: "+ Add profile" + onClicked: Hyprsunset.profiles = + Hyprsunset.profiles.concat([{ time: "0:00", identity: false, + temperature: null, gamma: null }]) + } + Tab { text: "Live preview"; onClicked: Hyprsunset.preview() } + Tab { + text: "Save + Restart" + selected: true + onClicked: Hyprsunset.save() + } + } + + Text { + width: parent.width + wrapMode: Text.Wrap + visible: Hyprsunset.notice !== "" + text: Hyprsunset.notice + color: Hyprsunset.notice.indexOf("invalid") === 0 ? Theme.red : Theme.green + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } +} diff --git a/appearance/Toggle.qml b/appearance/Toggle.qml new file mode 100644 index 0000000..2bb56a2 --- /dev/null +++ b/appearance/Toggle.qml @@ -0,0 +1,56 @@ +// 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. + +import QtQuick + +Rectangle { + id: toggle + property bool checked: false + property string label: "" + signal toggled + + implicitWidth: row.implicitWidth + implicitHeight: 22 + color: "transparent" + + Row { + id: row + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 16; height: 16; radius: 4 + color: toggle.checked ? Qt.alpha(Theme.accent, 0.7) : Qt.alpha(Theme.surface, 0.5) + border.width: 1 + border.color: toggle.checked ? Qt.alpha(Theme.accent, 0.9) : Qt.alpha(Theme.text, 0.2) + Text { + anchors.centerIn: parent + visible: toggle.checked + text: "✓" + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 6; bold: true } + color: Theme.base + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: toggle.label !== "" + text: toggle.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { toggle.checked = !toggle.checked; toggle.toggled(); } + } +} diff --git a/appearance/shell.qml b/appearance/shell.qml index 56132c0..ea87fc3 100644 --- a/appearance/shell.qml +++ b/appearance/shell.qml @@ -20,5 +20,11 @@ ShellRoot { function toggle() { panel.toggle(""); } function wallpaper() { panel.toggle("wallpaper"); } function theme() { panel.toggle("theme"); } + function sunset() { panel.toggle("sunset"); } + function idle() { panel.toggle("idle"); } + function icons() { panel.toggle("icons"); } + function selftest(): string { + return Hyprsunset.selftest() + "\n" + Hypridle.selftest() + "\n" + Icons.selftest(); + } } } diff --git a/window-switcher/README.md b/window-switcher/README.md index a9ddbd4..0f1cb90 100644 --- a/window-switcher/README.md +++ b/window-switcher/README.md @@ -15,7 +15,7 @@ a rofi list that showed the same windows as text. │ └────────────────────┘ └────────────────────┘ │ │ firefox kitty │ │ a page title, elided... ~/Programming/GIT/... │ - │ [1] [4] │ + │ [browser icon] [server icon] │ │ │ └──────────────────────────────────────────────────────────────┘ @@ -23,6 +23,14 @@ The cards are ordered most recently used first, so the window you just left is the first one, the same order ALT+TAB implies. Windows on a `special:` workspace are the scratchpad, which has its own bind, and are left out. +Each card's desktop marker is the same icon waybar draws for that workspace, so +the switcher and the bar agree rather than showing a bare number. waybar keeps +the eight icons as CSS background images keyed by workspace number +(`styles/modules.css` in `waybar-theme-udt`), because its `format-icons` takes +text and not paths; here the same names are resolved through the active icon +theme with `Quickshell.iconPath`. A named workspace, or one past eight, has no +icon and falls back to `[name]`. + With nothing to switch to it says so, because a full-screen dim with nothing in it reads as a hang. diff --git a/window-switcher/WindowCard.qml b/window-switcher/WindowCard.qml index de026e8..b86d69d 100644 --- a/window-switcher/WindowCard.qml +++ b/window-switcher/WindowCard.qml @@ -29,6 +29,14 @@ Column { // that is appearing or going away. Everything below guards for it. readonly property var wl: toplevel.wayland ?? null + // The bar's icon for this window's workspace, empty for a named workspace. + // Resolved to a path here so the card can fall back to the number when the + // active icon theme has no such icon, rather than showing a blank marker. + readonly property string workspaceIconPath: { + const name = Windows.workspaceIcon(toplevel.workspace); + return name !== "" ? Quickshell.iconPath(name, true) : ""; + } + width: cardWidth spacing: 8 @@ -125,12 +133,26 @@ Column { font.pixelSize: 13 } - Text { - width: parent.width - horizontalAlignment: Text.AlignHCenter - text: "[" + (card.toplevel.workspace ? card.toplevel.workspace.name : "?") + "]" - color: Theme.accent - font.family: Theme.fontFamily - font.pixelSize: 13 + // The desktop marker, drawn with waybar's workspace icon rather than the + // number. A workspace with no icon keeps the number, so the line never + // goes blank. + Item { + width: card.width + height: 24 + + IconImage { + anchors.centerIn: parent + width: 22; height: 22 + source: card.workspaceIconPath + } + + Text { + anchors.centerIn: parent + visible: card.workspaceIconPath === "" + text: "[" + (card.toplevel.workspace ? card.toplevel.workspace.name : "?") + "]" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: 13 + } } } diff --git a/window-switcher/Windows.qml b/window-switcher/Windows.qml index 040f4d4..ec86ee5 100644 --- a/window-switcher/Windows.qml +++ b/window-switcher/Windows.qml @@ -64,4 +64,28 @@ Singleton { function closeWindow(toplevel) { Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${addressOf(toplevel)}" })`); } + + // The desktop marker on a card is the same icon waybar's bar draws for that + // workspace, so the switcher and the bar agree. waybar keeps them as CSS + // background images by workspace number (styles/modules.css); the names are + // repeated here and resolved through the active icon theme, because a + // QML-side icon has to go by name. A named workspace, or one past eight, + // has no icon and the card falls back to its number. + readonly property var workspaceIcons: ({ + 1: "web-browser", + 2: "text-editor", + 3: "utilities-terminal", + 4: "network-server", + 5: "document-edit", + 6: "applications-graphics", + 7: "dialog-messages", + 8: "input-gaming", + }) + + function workspaceIcon(workspace) { + if (!workspace) return ""; + const n = parseInt(String(workspace.name), 10); + if (isNaN(n)) return ""; + return root.workspaceIcons[n] ?? ""; + } } |
