aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md6
-rw-r--r--appearance/AppearancePanel.qml54
-rw-r--r--appearance/Field.qml50
-rw-r--r--appearance/Hypridle.qml161
-rw-r--r--appearance/Hyprsunset.qml326
-rw-r--r--appearance/Icons.qml326
-rw-r--r--appearance/IconsTab.qml253
-rw-r--r--appearance/IdleTab.qml100
-rw-r--r--appearance/README.md53
-rw-r--r--appearance/SunsetTab.qml169
-rw-r--r--appearance/Toggle.qml56
-rw-r--r--appearance/shell.qml6
-rw-r--r--window-switcher/README.md10
-rw-r--r--window-switcher/WindowCard.qml36
-rw-r--r--window-switcher/Windows.qml24
15 files changed, 1595 insertions, 35 deletions
diff --git a/AGENTS.md b/AGENTS.md
index e387b2a..138148f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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] ?? "";
+ }
}