aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/appearance
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/appearance')
-rw-r--r--desktop/modules/appearance/Field.qml51
-rw-r--r--desktop/modules/appearance/Hypridle.qml162
-rw-r--r--desktop/modules/appearance/Hyprsunset.qml327
-rw-r--r--desktop/modules/appearance/Icons.qml335
-rw-r--r--desktop/modules/appearance/IconsModule.qml34
-rw-r--r--desktop/modules/appearance/IconsTab.qml228
-rw-r--r--desktop/modules/appearance/IdleModule.qml32
-rw-r--r--desktop/modules/appearance/IdleTab.qml100
-rw-r--r--desktop/modules/appearance/MockScreen.qml95
-rw-r--r--desktop/modules/appearance/README.md174
-rw-r--r--desktop/modules/appearance/Steps.qml115
-rw-r--r--desktop/modules/appearance/StepsWindow.qml198
-rw-r--r--desktop/modules/appearance/SunsetModule.qml (renamed from desktop/modules/appearance/AppearanceModule.qml)29
-rw-r--r--desktop/modules/appearance/SunsetTab.qml169
-rw-r--r--desktop/modules/appearance/Tab.qml46
-rw-r--r--desktop/modules/appearance/ThemeModule.qml33
-rw-r--r--desktop/modules/appearance/ThemeTab.qml179
-rw-r--r--desktop/modules/appearance/Toggle.qml57
-rw-r--r--desktop/modules/appearance/Udt.qml153
-rw-r--r--desktop/modules/appearance/WallpaperModule.qml34
-rw-r--r--desktop/modules/appearance/WallpaperTab.qml357
-rw-r--r--desktop/modules/appearance/Wallpapers.qml244
22 files changed, 3136 insertions, 16 deletions
diff --git a/desktop/modules/appearance/Field.qml b/desktop/modules/appearance/Field.qml
new file mode 100644
index 0000000..ad6af85
--- /dev/null
+++ b/desktop/modules/appearance/Field.qml
@@ -0,0 +1,51 @@
+// 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
+import "../.."
+
+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/desktop/modules/appearance/Hypridle.qml b/desktop/modules/appearance/Hypridle.qml
new file mode 100644
index 0000000..a1d3ecd
--- /dev/null
+++ b/desktop/modules/appearance/Hypridle.qml
@@ -0,0 +1,162 @@
+// 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
+import "../.."
+
+// 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/desktop/modules/appearance/Hyprsunset.qml b/desktop/modules/appearance/Hyprsunset.qml
new file mode 100644
index 0000000..5886735
--- /dev/null
+++ b/desktop/modules/appearance/Hyprsunset.qml
@@ -0,0 +1,327 @@
+// 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
+import "../.."
+
+// 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/desktop/modules/appearance/Icons.qml b/desktop/modules/appearance/Icons.qml
new file mode 100644
index 0000000..24d3a2f
--- /dev/null
+++ b/desktop/modules/appearance/Icons.qml
@@ -0,0 +1,335 @@
+// 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
+import "../.."
+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 }
+ // The cursor lives in its own file, not in hypr-theme's environment.lua:
+ // which cursor a machine runs is local state, and editing a tracked file
+ // left hypr-theme dirty after every switch. environment.lua dofile()s it.
+ // Written whole, so it needs no preload.
+ FileView { id: cursorFile; path: `${root.home}/.config/udt/cursor.lua` }
+ function writeCursor() {
+ cursorFile.setText(`return { theme = ${JSON.stringify(root.currentCursor)}, size = ${root.cursorSize} }\n`);
+ }
+ // 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 }
+
+ // The SDDM power icons resolve icon names from gsettings when udt-accent
+ // generates them, not when they run, so they are regenerated here,
+ // chained after the gsettings write they read. The island resolves its
+ // icons live and needs nothing.
+ readonly property string udtRepo: `${root.home}/Programming/GIT/unified-desktop-theme`
+
+ function applyIcon(name) {
+ Quickshell.execDetached(["sh", "-c",
+ `gsettings set org.gnome.desktop.interface icon-theme "$1" || exit; ` +
+ `[ -e ~/.cache/udt/wpaper ] && ${JSON.stringify(root.udtRepo)}/bin/udt-accent ~/.cache/udt/wpaper`,
+ "sh", 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. Waybar and the SDDM icons follow; restart other 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);
+ root.currentCursor = name;
+ root.writeCursor();
+ root.rewriteGtk("gtk-cursor-theme-name", name);
+ root.rewriteGtk("gtk-cursor-theme-size", root.cursorSize);
+ root.notice = `${name} set live at ${root.cursorSize}px. cursor.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));
+ root.writeCursor();
+ root.rewriteGtk("gtk-cursor-theme-size", size);
+ root.notice = `cursor size ${size}px. cursor.lua updated for next login.`;
+ }
+}
diff --git a/desktop/modules/appearance/IconsModule.qml b/desktop/modules/appearance/IconsModule.qml
new file mode 100644
index 0000000..f6559d1
--- /dev/null
+++ b/desktop/modules/appearance/IconsModule.qml
@@ -0,0 +1,34 @@
+// 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
+import "../.."
+
+// The icon and cursor theme, and the pointer size.
+Module {
+ name: "icons"
+ label: "Icons & cursor"
+ // nf-fa-mouse_pointer, present in Inconsolata Nerd Font.
+ icon: "\uf245"
+ settings: true
+ keywords: ["cursor", "pointer", "size", "gtk", "qt", "theme"]
+ searchItems: Icons.iconThemes.map(n => ({ text: "icons: " + n, key: "icon:" + n }))
+ .concat(Icons.cursorThemes.map(n => ({ text: "cursor: " + n, key: "cursor:" + n })))
+
+ function refresh() { Icons.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Icons & cursor"
+ IconsTab { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/IconsTab.qml b/desktop/modules/appearance/IconsTab.qml
new file mode 100644
index 0000000..777ea51
--- /dev/null
+++ b/desktop/modules/appearance/IconsTab.qml
@@ -0,0 +1,228 @@
+// 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
+import "../.."
+
+Item {
+ implicitHeight: col.implicitHeight
+
+ // Clicking a cursor card stages it; Apply commits. There is deliberately no
+ // live hover preview: hyprctl setcursor is not a read-only switch, Hyprland
+ // persists the theme to org.gnome.desktop.interface cursor-theme, so
+ // hovering wrote the setting and a close left the last hovered theme set.
+ // The four rendered shapes on each card are the preview.
+ 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
+ // What a search hit scrolls to (IconsModule's searchItems key).
+ objectName: "icon:" + 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
+ objectName: "cursor:" + 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"
+
+ 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: {
+ 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 }
+ }
+ }
+
+}
diff --git a/desktop/modules/appearance/IdleModule.qml b/desktop/modules/appearance/IdleModule.qml
new file mode 100644
index 0000000..b5636a1
--- /dev/null
+++ b/desktop/modules/appearance/IdleModule.qml
@@ -0,0 +1,32 @@
+// 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
+import "../.."
+
+// hypridle's listeners: timeouts and whether each runs.
+Module {
+ name: "idle"
+ label: "Idle"
+ // nf-fa-hourglass, present in Inconsolata Nerd Font.
+ icon: "\uf254"
+ settings: true
+ keywords: ["hypridle", "lock", "timeout", "screen", "dpms", "suspend", "sleep"]
+
+ function refresh() { Hypridle.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Idle"
+ IdleTab { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/IdleTab.qml b/desktop/modules/appearance/IdleTab.qml
new file mode 100644
index 0000000..1199543
--- /dev/null
+++ b/desktop/modules/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
+import "../.."
+
+Item {
+ implicitHeight: col.implicitHeight
+
+ 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/desktop/modules/appearance/MockScreen.qml b/desktop/modules/appearance/MockScreen.qml
new file mode 100644
index 0000000..7f6870b
--- /dev/null
+++ b/desktop/modules/appearance/MockScreen.qml
@@ -0,0 +1,95 @@
+// 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
+import "../.."
+
+// One monitor in the mock desk layout: a bezel, a stand, and the panel at the
+// screen's real aspect ratio. Drawn rather than loaded from an SVG so it takes
+// its colours from the theme and stays sharp at any scale.
+Item {
+ id: mock
+
+ property string label: ""
+ property string source: ""
+ property bool targeted: false
+
+ // The panel's size; the bezel and stand are added around it.
+ property real panelWidth: 100
+ property real panelHeight: 100
+
+ readonly property real bezel: Math.max(4, Math.round(panelWidth * 0.02))
+ // Proportional to width rather than height: the vertical monitor is tall
+ // and narrow, and a share of its height would give it an absurd stand.
+ readonly property real standHeight: Math.max(14, Math.round(panelWidth * 0.1))
+
+ implicitWidth: panelWidth + bezel * 2
+ implicitHeight: panelHeight + bezel * 2 + standHeight
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 0
+
+ // Case.
+ Rectangle {
+ width: mock.panelWidth + mock.bezel * 2
+ height: mock.panelHeight + mock.bezel * 2
+ radius: Math.max(3, mock.bezel)
+ color: "#1a1a1e"
+ border.width: 1
+ border.color: mock.targeted ? Theme.accent : Qt.alpha("#ffffff", 0.14)
+
+ // Screen.
+ Rectangle {
+ anchors.centerIn: parent
+ width: mock.panelWidth
+ height: mock.panelHeight
+ color: Theme.base
+ clip: true
+
+ Image {
+ anchors.fill: parent
+ source: mock.source === "" ? "" : "file://" + mock.source
+ asynchronous: true
+ cache: false
+ // swaybg is called with -m fill, so the mock crops the same way.
+ fillMode: Image.PreserveAspectCrop
+ sourceSize.width: 640
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: mock.source === ""
+ text: mock.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
+ }
+
+ // Neck.
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: Math.max(8, Math.round(mock.panelWidth * 0.09))
+ height: Math.round(mock.standHeight * 0.55)
+ color: "#26262e"
+ }
+
+ // Foot.
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: Math.max(30, Math.round(mock.panelWidth * 0.32))
+ height: Math.max(5, Math.round(mock.standHeight * 0.45))
+ radius: height / 2
+ color: "#2c2c35"
+ }
+ }
+}
diff --git a/desktop/modules/appearance/README.md b/desktop/modules/appearance/README.md
new file mode 100644
index 0000000..b1823fb
--- /dev/null
+++ b/desktop/modules/appearance/README.md
@@ -0,0 +1,174 @@
+# appearance
+
+Five pages of the settings window: Wallpaper, Theme, Icons & cursor, Sunset
+and Idle. Until October 2026 they were the tabs of a separate `appearance/`
+shell with an overlay of its own; they moved into the desktop process when
+the settings window gave them the room that shell existed to provide.
+
+Each page is a `*Module.qml` here wrapping a `*Tab.qml`, and the work is done
+by singletons (`Wallpapers`, `Udt`, `Icons`, `Hyprsunset`, `Hypridle`,
+`Steps`). The window's open calls each module's `refresh()`, which rescans:
+schemes, wallpapers and themes can all change during a session, and a scan
+done only at startup would go stale.
+
+The old shell's IPC target is kept in `desktop/shell.qml` with the same verbs,
+so its callers only changed the path:
+
+ qs -p ~/Programming/GIT/quickshell/desktop ipc call appearance wallpaper
+
+Verbs: `wallpaper` (also `toggle`), `theme`, `sunset`, `idle`, `icons` open
+that page; `steps` pokes the steps window; `selftest` runs the parsers' own
+checks. Write the path out in full in the Hyprland config: `exec_cmd` has no
+shell to expand `~`.
+
+Search finds a scheme by name and an icon or cursor theme by name, and
+scrolls to it.
+
+## 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, `~/.config/udt/cursor.lua`, which
+hypr-theme's `environment.lua` `dofile()`s at login. A local file rather than
+an edit to `environment.lua`, which is tracked and was left dirty by every
+cursor switch. The GTK theme names do
+not come from `gsettings` alone here, so the Icons page 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.
+
+An icon switch also regenerates the two consumers that resolve icons by name
+when they are generated rather than when they run: `udt-accent` rewrites the
+SDDM power icons. It is chained after the `gsettings` write in one shell,
+since it reads it. Waybar is gone, so the icon switch no longer reruns
+waybar-theme-udt's `install.sh` or signals waybar.
+
+## Wallpapers
+
+Clicking a thumbnail **stages** it rather than setting it, so both screens can
+be composed before anything changes, and Apply then makes a single
+`wallp --set H=… V=…` call. The mock screens on the right show what the desk
+would look like: the staged pick where there is one, what is currently set
+where there is not, and the hovered thumbnail on the targeted screen while the
+pointer is over it.
+
+The thumbnails are grouped by folder, each a header that opens and closes
+it, closed to begin with and remembered while the shell runs. The local
+groups are the top-level folders of `~/Pictures/wallpapers`, with the images
+loose in it as a group of its own; after them come the distribution's,
+`/usr/share/wallpapers`, as `system` for its loose images and `Plasma` for
+its packages. A Plasma package holds one picture at several resolutions,
+some in a dark version too, so it shows once: dark where there is one, then
+the largest, named after the package rather than its `5120x2880.png`.
+
+The list is flat, one row of thumbnails per item, so only the rows in view
+exist and decode, and a folder of hundreds loads as it scrolls. Opening or
+closing a folder inserts or removes just its rows, which keeps the scroll
+position and the thumbnails around it. A first version made each folder one
+item, which loaded every thumbnail of a folder the moment it opened.
+
+Each thumbnail's border says how it will look on the targeted screen, from
+its real size, which `file` reads from the header after each scan (half a
+second for a thousand). `swaybg -m fill` scales to cover, so the measure is
+how far it enlarges: not at all is green, up to 1.33x yellow, past that red.
+Under the mock screens a note in the same colour says why, for the hovered
+image or else the staged one. The staged image keeps the accent border.
+
+"Open wallhaven-dl" under the mock screens launches the wallhaven-dl app,
+which searches wallhaven.cc and finds larger copies of low-res wallpapers,
+and closes the settings window, as a successful Apply does, so the desk
+shows. It goes through `gtk-launch` and the app's desktop
+entry, so `~/bin` need not be on quickshell's PATH.
+
+`wallp` does the actual work, including running `udt-accent`, so the accent
+follows the new wallpaper exactly as it does from a terminal. This page
+replaces its qarma file dialog, not the script.
+
+The two monitors are drawn at their real proportions, read from
+`hyprctl monitors`: DP-1 is 2560x1080 and DP-3 is 1920x1080 with
+`transform=1`, which makes it 1080x1920 on the desk. They are centred against
+each other because that is how Hyprland has them, both spanning y=0.
+
+The bezel and stand are Rectangles rather than an SVG: no asset to ship, sharp
+at any size, and the case stays a fixed near-black while the accent marks
+which screen is targeted. Their proportions come from the pane **width**, not
+its height. Scaling the stand off height gave the wide monitor a 20px stand
+that was invisible and would have given the vertical one an absurd long neck.
+
+## Themes
+
+Each scheme shows its real colours, parsed from the pair of files
+unified-desktop-theme keeps for it: `palette/<name>.conf` holds the colours
+under the scheme's own names, `palette/roles-<name>.conf` says what each
+colour is for, so a role is resolved by looking its value up as a key in the
+first file. Beside the swatches is a small mock of a panel painted in that
+scheme, which says how the colours sit together rather than only what they
+are.
+
+The list comes from the palette directory rather than a hardcoded set, so
+adding a scheme to unified-desktop-theme is enough to make it appear here.
+Both the scheme list and the wallpaper scan are redone every time the
+settings window opens: the desktop process runs all session, so a scan done
+only at startup would go stale the first time anything was added.
+
+Applying writes the `scheme =` line in `~/.config/udt/roles.conf` and runs
+`install.sh`, which regenerates every themed config and reloads Hyprland,
+kitty and conky. Its whole output becomes the `install` tab of the
+steps window below. The notice names what is left: Qt and GTK apps only reread
+a theme when they restart, and neovim needs `:colorscheme udt`.
+
+## Steps
+
+A separate window, `StepsWindow.qml`, for the manual steps
+unified-desktop-theme cannot run itself: the root copies into `/boot` and
+`/usr/share/sddm`, the finish on the homepage host, and the last `install.sh`
+log, shown first as "General recap". Its `udt-steps` writes one file per consumer under `~/.cache/udt/steps/`
+and calls the `steps` verb, which reloads the tabs and opens the window if any
+remain; an empty file set closes it. Nothing is watched: the poke is the
+signal. The same verb reopens it after Close.
+
+Centred on DP-3, on the `top` layer with on-demand keyboard focus, so it can
+stay open while commands are pasted into a terminal elsewhere. A line indented
+two spaces is a command block with a Copy button (`wl-copy`), and the text is
+selectable too. Done deletes the tab's file; the producer also clears it once
+its consumer is up to date.
+
+The window's height comes from its content, so the scroll area is capped
+against the screen and never against the window: binding it to the window's
+own height collapsed the window to its margins.
+
+## Two QML traps met here
+
+**There is no `String.matchAll`.** QML's JS engine does not have it. It threw
+inside a `try` and left every swatch empty with nothing in the log. The
+palette parser uses an `exec` loop instead.
+
+**Assigning `running = true` to a Process that is already running does
+nothing.** The scheme loader reuses one `Process` for each scheme in turn and
+stopped after the first until it set `running = false` immediately before.
+
+## Theme
+
+`Theme` is the desktop's shared one, through `import "../.."`: the palette
+comes from `~/.cache/udt/udt-palette.qml` and is watched. The settings window
+is a normal toplevel, so it takes Hyprland's ordinary window blur; the old
+`blur-appearance` layer rule went with the overlay.
diff --git a/desktop/modules/appearance/Steps.qml b/desktop/modules/appearance/Steps.qml
new file mode 100644
index 0000000..d309b79
--- /dev/null
+++ b/desktop/modules/appearance/Steps.qml
@@ -0,0 +1,115 @@
+// 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
+import "../.."
+
+// The manual steps unified-desktop-theme cannot run itself: root copies into
+// /boot and /usr/share/sddm, the finish on the homepage host, and the last
+// install.sh log. Each is a file in ~/.cache/udt/steps/, written by
+// udt-steps, which then calls this shell's `steps` IPC verb. Nothing is
+// watched: the poke is the signal, and a shell started later finds the files
+// on its first reload.
+Singleton {
+ id: root
+
+ readonly property string dir: `${Quickshell.env("HOME")}/.cache/udt/steps`
+
+ // [{ name, label, blocks: [{ cmd, text }] }]: the install.sh recap first,
+ // then one tab per consumer by name. A file with no label here shows
+ // under its own name, so a new producer needs no edit.
+ property var tabs: []
+ readonly property var labels: ({
+ install: "General recap", grub: "GRUB", sddm: "SDDM", homepage: "Homepage",
+ })
+ signal loaded
+
+ // A line indented two spaces is a command, and a run of them is one block
+ // with its own copy button. Blank lines split prose into paragraphs. The
+ // format is what the producers already print to a terminal.
+ function parse(text) {
+ const out = [];
+ const lines = text.split("\n");
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const cmd = line.startsWith(" ");
+ const last = out.length ? out[out.length - 1] : null;
+ if (!cmd && line.trim() === "") {
+ if (last) last.closed = true;
+ continue;
+ }
+ const body = cmd ? line.slice(2) : line;
+ if (last && last.cmd === cmd && !last.closed) last.lines.push(body);
+ else out.push({ cmd: cmd, lines: [body], closed: false });
+ }
+ return out.map(b => ({ cmd: b.cmd, text: b.lines.join("\n") }));
+ }
+
+ function commands(tab) {
+ return tab.blocks.filter(b => b.cmd).map(b => b.text).join("\n");
+ }
+
+ function copy(text) {
+ Quickshell.execDetached(["wl-copy", "--", text]);
+ }
+
+ function refresh() {
+ listProc.running = false;
+ listProc.running = true;
+ }
+
+ function dismiss(name) {
+ rmProc.command = ["rm", "-f", `${root.dir}/${name}`];
+ rmProc.running = true;
+ }
+
+ // One pass over every file, each prefixed by an ASCII record separator
+ // and its name. The names are udt-steps' [a-z0-9-], and its temporary
+ // files are dotfiles, which the glob skips.
+ Process {
+ id: listProc
+ command: ["sh", "-c",
+ `cd ${JSON.stringify(root.dir)} 2>/dev/null || exit 0; ` +
+ `for f in *; do [ -f "$f" ] && printf '\\036%s\\n' "$f" && cat "$f"; done`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.tabs = text.split("\x1e").filter(s => s.length).map(rec => {
+ const nl = rec.indexOf("\n");
+ const name = rec.slice(0, nl);
+ return { name: name, label: root.labels[name] ?? name,
+ blocks: root.parse(rec.slice(nl + 1)) };
+ }).sort((a, b) => (b.name === "install") - (a.name === "install")
+ || a.label.localeCompare(b.label));
+ root.loaded();
+ }
+ }
+ }
+
+ Process {
+ id: rmProc
+ onExited: root.refresh()
+ }
+
+ function selftest(): string {
+ const b = root.parse("Intro line\nstill intro\n cmd one\n cmd two\n\nNext para\n cmd three\n");
+ if (b.length !== 4 || b[0].cmd || b[0].text !== "Intro line\nstill intro" ||
+ !b[1].cmd || b[1].text !== "cmd one\ncmd two" || b[2].text !== "Next para" ||
+ b[3].text !== "cmd three")
+ return `SELFTEST Steps FAIL: ${JSON.stringify(b)}`;
+ if (root.commands({ blocks: b }) !== "cmd one\ncmd two\ncmd three")
+ return "SELFTEST Steps FAIL: commands";
+ return "SELFTEST Steps PASS";
+ }
+}
diff --git a/desktop/modules/appearance/StepsWindow.qml b/desktop/modules/appearance/StepsWindow.qml
new file mode 100644
index 0000000..7349fcd
--- /dev/null
+++ b/desktop/modules/appearance/StepsWindow.qml
@@ -0,0 +1,198 @@
+// 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.Wayland
+import QtQuick
+import "../.."
+
+// One tab per consumer with manual steps pending. Centred on the vertical
+// screen and kept open on purpose: the commands are pasted into a root shell
+// elsewhere, so the window takes keyboard focus only when clicked and stays
+// until closed. Done deletes a tab's file; the producer also clears it once
+// the consumer is up to date.
+Scope {
+ id: root
+
+ property string monitor: "DP-3"
+ property bool open: false
+ property string tab: ""
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+ readonly property var current: Steps.tabs.find(t => t.name === root.tab) ?? Steps.tabs[0] ?? null
+
+ // A poke from udt-steps: reload, and open when something is pending.
+ // Also what a keybind calls to bring the window back after closing it.
+ function poke() {
+ root.opening = true;
+ Steps.refresh();
+ }
+ property bool opening: false
+
+ Connections {
+ target: Steps
+ function onLoaded() {
+ if (Steps.tabs.length === 0) root.open = false;
+ else if (root.opening) root.open = true;
+ root.opening = false;
+ }
+ }
+
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ id: win
+ screen: root.screenObj
+ // No anchors: a layer surface anchored nowhere is centred.
+ implicitWidth: 960
+ implicitHeight: body.implicitHeight + 40
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Top
+ WlrLayershell.namespace: "quickshell-steps"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 14
+ color: Qt.alpha(Theme.base, 0.92)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.12)
+
+ Item {
+ anchors.fill: parent
+ focus: true
+ Keys.onEscapePressed: root.open = false
+ }
+
+ Column {
+ id: body
+ anchors { top: parent.top; left: parent.left; right: parent.right; margins: 20 }
+ spacing: 16
+
+ Item {
+ width: parent.width
+ height: tabBar.implicitHeight
+
+ Row {
+ id: tabBar
+ spacing: 8
+ Repeater {
+ model: Steps.tabs
+ Tab {
+ required property var modelData
+ text: modelData.label
+ selected: root.current !== null && root.current.name === modelData.name
+ onClicked: root.tab = modelData.name
+ }
+ }
+ }
+
+ Tab {
+ anchors.right: parent.right
+ text: "Close"
+ onClicked: root.open = false
+ }
+ }
+
+ Flickable {
+ width: parent.width
+ // Sized from the screen, never from the window: the
+ // window's height comes from this, and a binding back
+ // to it collapsed the whole thing to its margins.
+ height: Math.min(content.implicitHeight,
+ win.screen.height - 240 - tabBar.implicitHeight - actions.implicitHeight - 32)
+ contentHeight: content.implicitHeight
+ clip: true
+
+ Column {
+ id: content
+ width: parent.width
+ spacing: 12
+
+ Repeater {
+ model: root.current ? root.current.blocks : []
+
+ Loader {
+ required property var modelData
+ width: content.width
+ sourceComponent: modelData.cmd ? cmdBlock : prose
+ property var block: modelData
+ }
+ }
+ }
+ }
+
+ Row {
+ id: actions
+ spacing: 8
+ anchors.right: parent.right
+ Tab {
+ text: "Copy all commands"
+ enabled: root.current !== null && Steps.commands(root.current) !== ""
+ onClicked: Steps.copy(Steps.commands(root.current))
+ }
+ Tab {
+ text: "Done"
+ enabled: root.current !== null
+ onClicked: Steps.dismiss(root.current.name)
+ }
+ }
+ }
+ }
+
+ Component {
+ id: prose
+ Text {
+ text: block.text
+ wrapMode: Text.Wrap
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: Theme.text
+ }
+ }
+
+ Component {
+ id: cmdBlock
+ Rectangle {
+ implicitHeight: Math.max(code.implicitHeight, copyBtn.implicitHeight) + 24
+ radius: 8
+ color: Qt.alpha(Theme.crust, 0.8)
+ border.width: 1
+ border.color: Qt.alpha(Theme.accent, 0.3)
+
+ // Selectable as well as copyable: sometimes one line of a
+ // block is what is wanted.
+ TextEdit {
+ id: code
+ anchors { left: parent.left; right: copyBtn.left; verticalCenter: parent.verticalCenter; margins: 12 }
+ text: block.text
+ readOnly: true
+ selectByMouse: true
+ wrapMode: TextEdit.WrapAnywhere
+ font { family: "Inconsolata Nerd Font Mono"; pixelSize: Theme.fontSize - 1 }
+ color: Theme.text
+ selectionColor: Qt.alpha(Theme.accent, 0.4)
+ selectedTextColor: Theme.text
+ }
+
+ Tab {
+ id: copyBtn
+ anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ text: "Copy"
+ onClicked: Steps.copy(block.text)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/AppearanceModule.qml b/desktop/modules/appearance/SunsetModule.qml
index ca9dc7d..5d37052 100644
--- a/desktop/modules/appearance/AppearanceModule.qml
+++ b/desktop/modules/appearance/SunsetModule.qml
@@ -9,27 +9,24 @@
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
-import Quickshell
-import Quickshell.Io
import QtQuick
import "../.."
-// Fire and forget. The appearance shell stays a separate process: a wallpaper
-// picker needs more room than a 600px drawer, so this tile only opens it.
+// hyprsunset's profiles and location.
Module {
- id: mod
+ name: "sunset"
+ label: "Sunset"
+ // nf-fa-moon_o, present in Inconsolata Nerd Font.
+ icon: "\uf186"
+ settings: true
+ keywords: ["hyprsunset", "night", "light", "temperature", "gamma", "location", "profile"]
- name: "appearance"
- icon: ""
- label: "Appearance"
+ function refresh() { Hyprsunset.refresh(); }
- function activate() {
- proc.running = false;
- proc.running = true;
- }
-
- property Process proc: Process {
- command: ["qs", "-p", `${Quickshell.env("HOME")}/Programming/GIT/quickshell/appearance`,
- "ipc", "call", "appearance", "wallpaper"]
+ page: Component {
+ Page {
+ title: "Sunset"
+ SunsetTab { width: parent.width }
+ }
}
}
diff --git a/desktop/modules/appearance/SunsetTab.qml b/desktop/modules/appearance/SunsetTab.qml
new file mode 100644
index 0000000..657a920
--- /dev/null
+++ b/desktop/modules/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
+import "../.."
+
+Item {
+ implicitHeight: col.implicitHeight
+
+ 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/desktop/modules/appearance/Tab.qml b/desktop/modules/appearance/Tab.qml
new file mode 100644
index 0000000..d0a7a5e
--- /dev/null
+++ b/desktop/modules/appearance/Tab.qml
@@ -0,0 +1,46 @@
+// 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
+import "../.."
+
+Rectangle {
+ id: tab
+ property alias text: label.text
+ property bool selected: false
+ property bool enabled: true
+ signal clicked
+
+ implicitWidth: label.implicitWidth + 24
+ implicitHeight: label.implicitHeight + 14
+ radius: 7
+ opacity: enabled ? 1 : 0.4
+ color: selected ? Qt.alpha(Theme.accent, 0.25)
+ : area.containsMouse ? Qt.alpha(Theme.surface, 0.8)
+ : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: selected ? Qt.alpha(Theme.accent, 0.5) : "transparent"
+
+ Text {
+ id: label
+ anchors.centerIn: parent
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: tab.selected ? Theme.text : Theme.subtext
+ }
+
+ MouseArea {
+ id: area
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: tab.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (tab.enabled) tab.clicked()
+ }
+}
diff --git a/desktop/modules/appearance/ThemeModule.qml b/desktop/modules/appearance/ThemeModule.qml
new file mode 100644
index 0000000..729ceb1
--- /dev/null
+++ b/desktop/modules/appearance/ThemeModule.qml
@@ -0,0 +1,33 @@
+// 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
+import "../.."
+
+// unified-desktop-theme's colour schemes.
+Module {
+ name: "theme"
+ label: "Theme"
+ // nf-fa-paintbrush, present in Inconsolata Nerd Font.
+ icon: "\uf1fc"
+ settings: true
+ keywords: ["colour", "color", "scheme", "palette", "accent", "udt"]
+ searchItems: Array.from(Udt.names).map(n => ({ text: n, key: n }))
+
+ function refresh() { Udt.refresh(); }
+
+ page: Component {
+ Page {
+ title: "Theme"
+ ThemeTab { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/ThemeTab.qml b/desktop/modules/appearance/ThemeTab.qml
new file mode 100644
index 0000000..acee97a
--- /dev/null
+++ b/desktop/modules/appearance/ThemeTab.qml
@@ -0,0 +1,179 @@
+// 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
+import "../.."
+
+// Colour schemes from unified-desktop-theme, each with its swatches and a mock
+// of a panel in its colours. Clicking one applies it through install.sh.
+Column {
+ id: tab
+ spacing: 10
+
+ // install.sh reloads Hyprland, kitty and conky; this says what it cannot
+ // reach, or why it failed.
+ property string notice: ""
+ Connections {
+ target: Udt
+ function onApplied(scheme, ok, message) {
+ tab.notice = ok
+ ? `${scheme} applied. Restart Qt, GTK, btop and Joplin; neovim needs :colorscheme udt.`
+ : `failed: ${message}`;
+ }
+ }
+
+ Text {
+ visible: tab.notice !== ""
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: tab.notice
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: tab.notice.indexOf("failed") === 0 ? Theme.red : Theme.green
+ }
+
+ Repeater {
+ model: Udt.names
+
+ Rectangle {
+ required property string modelData
+ // What a search hit scrolls to (ThemeModule's searchItems key).
+ objectName: modelData
+ readonly property var sw: Udt.swatches[modelData] ?? ({})
+ readonly property bool isCurrent: Udt.current === modelData
+
+ width: tab.width
+ implicitHeight: 96
+ radius: 10
+ color: hover.hovered ? Qt.alpha(Theme.surface, 0.75)
+ : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: isCurrent ? Qt.alpha(Theme.accent, 0.6) : "transparent"
+
+ HoverHandler { id: hover }
+ MouseArea {
+ anchors.fill: parent
+ enabled: !Udt.applying && !isCurrent
+ cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: Udt.apply(modelData)
+ }
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: 14
+ spacing: 16
+
+ // A mock of the desktop in that scheme's colours:
+ // swatches alone say what the colours are, this
+ // says how they sit together.
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 150; height: 68
+ radius: 6
+ color: sw.bg ?? Theme.base
+ border.width: 1
+ border.color: Qt.alpha(sw.text ?? Theme.text, 0.15)
+ clip: true
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: 7
+ spacing: 5
+
+ Row {
+ spacing: 4
+ Rectangle {
+ width: 26; height: 8; radius: 2
+ color: sw.accent ?? Theme.accent
+ }
+ Rectangle {
+ width: 16; height: 8; radius: 2
+ color: sw.surface ?? Theme.surface
+ }
+ Rectangle {
+ width: 16; height: 8; radius: 2
+ color: sw.surface ?? Theme.surface
+ }
+ }
+
+ Rectangle {
+ width: parent.width; height: 20; radius: 3
+ color: sw.surface ?? Theme.surface
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ x: 5; width: 60; height: 5; radius: 2
+ color: Qt.alpha(sw.text ?? Theme.text, 0.75)
+ }
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: 5
+ width: 22; height: 9; radius: 3
+ color: sw.accent ?? Theme.accent
+ }
+ }
+
+ Row {
+ spacing: 4
+ Rectangle { width: 9; height: 9; radius: 5; color: sw.green ?? Theme.green }
+ Rectangle { width: 9; height: 9; radius: 5; color: sw.yellow ?? Theme.yellow }
+ Rectangle { width: 9; height: 9; radius: 5; color: sw.red ?? Theme.red }
+ }
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ Row {
+ spacing: 8
+ Text {
+ text: modelData
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: isCurrent
+ text: "current"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.accent
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: Udt.applying
+ text: "applying…"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
+
+ Row {
+ spacing: 5
+ Repeater {
+ model: [sw.bg, sw.surface, sw.text, sw.accent,
+ sw.green, sw.yellow, sw.red]
+ Rectangle {
+ required property var modelData
+ visible: modelData !== undefined && modelData !== ""
+ width: 22; height: 22; radius: 4
+ color: modelData ?? "transparent"
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.1)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/Toggle.qml b/desktop/modules/appearance/Toggle.qml
new file mode 100644
index 0000000..1abdb77
--- /dev/null
+++ b/desktop/modules/appearance/Toggle.qml
@@ -0,0 +1,57 @@
+// 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
+import "../.."
+
+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/desktop/modules/appearance/Udt.qml b/desktop/modules/appearance/Udt.qml
new file mode 100644
index 0000000..a4bf296
--- /dev/null
+++ b/desktop/modules/appearance/Udt.qml
@@ -0,0 +1,153 @@
+// 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
+import "../.."
+
+// The unified-desktop-theme side: which schemes exist, what they look like,
+// and switching between them.
+Singleton {
+ id: root
+
+ readonly property string home: Quickshell.env("HOME")
+ readonly property string repo: `${home}/Programming/GIT/unified-desktop-theme`
+ readonly property string selector: `${home}/.config/udt/roles.conf`
+
+ property string current: ""
+ // name -> { bg, surface, text, accent, red, green, yellow }
+ property var swatches: ({})
+ property list<string> names: []
+
+ property bool applying: false
+ signal applied(string scheme, bool ok, string message)
+
+ // A scheme is two files: <name>.conf holds colours under the scheme's own
+ // names, roles-<name>.conf says what each colour is for. Resolving a role
+ // means looking its value up as a key in the first file.
+ function parseSwatch(rolesText, paletteText) {
+ // QML's JS engine has no String.matchAll, so this is an exec loop.
+ const colours = {};
+ const re = /^(\w+)\s*=\s*(#[0-9a-fA-F]{6})/gm;
+ let m;
+ while ((m = re.exec(paletteText)) !== null) colours[m[1]] = m[2];
+
+ const role = key => {
+ const m = rolesText.match(new RegExp(`^${key}\\s*=\\s*(\\S+)`, "m"));
+ // A role can carry a /alpha or *shade suffix; the base colour is
+ // what a swatch shows.
+ return m ? colours[m[1].split(/[/*]/)[0]] ?? "" : "";
+ };
+
+ return {
+ bg: role("bg"), surface: role("surface"), text: role("fg"),
+ accent: role("accent"), red: role("critical"),
+ green: role("success"), yellow: role("warning"),
+ };
+ }
+
+ // Which scheme is live. Watched, so switching it from an editor moves the
+ // panel's marker too.
+ FileView {
+ path: root.selector
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: {
+ const m = text().match(/^\s*scheme\s*=\s*(\S+)/m);
+ if (m) root.current = m[1];
+ }
+ }
+
+ // The scheme list comes from the palette directory rather than the comment
+ // in roles.conf: adding a scheme is adding two files, and this then needs
+ // no edit at all.
+ // Rescanned whenever the panel opens: adding a scheme is adding two
+ // files, and a shell that has been running since before they appeared
+ // would otherwise keep showing the old list.
+ function refresh() {
+ listProc.running = false;
+ listProc.running = true;
+ }
+
+ Process {
+ id: listProc
+ running: true
+ command: ["sh", "-c",
+ `ls ${root.repo}/palette/roles-*.conf 2>/dev/null | ` +
+ `sed 's|.*/roles-||; s|\\.conf$||' | sort`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.names = text.trim().split("\n").filter(s => s.length);
+ loadProc.next = 0;
+ loadProc.loadNext();
+ }
+ }
+ }
+
+ Process {
+ id: loadProc
+ property int next: 0
+ property string scheme: ""
+
+ function loadNext() {
+ if (next >= root.names.length) return;
+ scheme = root.names[next];
+ next++;
+ // Both files at once, split on a marker: one process per scheme
+ // rather than two, and the pair is always consistent.
+ command = ["sh", "-c",
+ `cat ${root.repo}/palette/roles-${scheme}.conf; ` +
+ `echo '===SPLIT==='; cat ${root.repo}/palette/${scheme}.conf`];
+ // Assigning true to an already-true `running` does nothing, and
+ // this Process is reused for every scheme in turn.
+ running = false;
+ running = true;
+ }
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const [rolesText, paletteText] = text.split("===SPLIT===");
+ if (rolesText && paletteText) {
+ const next = Object.assign({}, root.swatches);
+ next[loadProc.scheme] = root.parseSwatch(rolesText, paletteText);
+ root.swatches = next;
+ }
+ loadProc.loadNext();
+ }
+ }
+ }
+
+ // Switching writes the scheme line and regenerates every config;
+ // install.sh also reloads what is running, and hands its whole output to
+ // the steps window's `install` tab itself.
+ function apply(scheme) {
+ if (applying || scheme === current) return;
+ applying = true;
+ applyProc.scheme = scheme;
+ applyProc.command = ["sh", "-c",
+ `sed -i 's|^scheme *=.*|scheme = ${scheme}|' ${JSON.stringify(root.selector)} && ` +
+ `${JSON.stringify(root.repo)}/install.sh >/dev/null 2>&1`];
+ applyProc.running = true;
+ }
+
+ Process {
+ id: applyProc
+ property string scheme: ""
+ onExited: code => {
+ root.applying = false;
+ root.applied(applyProc.scheme, code === 0,
+ code === 0 ? "" : `install.sh exited ${code}, its output is in the steps window`);
+ }
+ }
+}
diff --git a/desktop/modules/appearance/WallpaperModule.qml b/desktop/modules/appearance/WallpaperModule.qml
new file mode 100644
index 0000000..246e8f5
--- /dev/null
+++ b/desktop/modules/appearance/WallpaperModule.qml
@@ -0,0 +1,34 @@
+// 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
+import "../.."
+
+// The wallpaper picker. The thumbnail list scrolls itself, so the tab gets the
+// page's visible height instead of the page's scroll.
+Module {
+ name: "wallpaper"
+ label: "Wallpaper"
+ // nf-fa-picture_o, present in Inconsolata Nerd Font.
+ icon: "\uf03e"
+ settings: true
+ keywords: ["background", "screen", "monitor", "wallp", "wallhaven", "horizontal", "vertical"]
+
+ function refresh() { Wallpapers.refresh(); }
+
+ page: Component {
+ Page {
+ id: pg
+ title: "Wallpaper"
+ WallpaperTab { width: parent.width; height: pg.viewHeight; onDismiss: pg.dismiss() }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/WallpaperTab.qml b/desktop/modules/appearance/WallpaperTab.qml
new file mode 100644
index 0000000..72d4dfe
--- /dev/null
+++ b/desktop/modules/appearance/WallpaperTab.qml
@@ -0,0 +1,357 @@
+// 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 QtQuick
+import "../.."
+
+// Wallpapers by folder, staged per screen and set together with Apply, beside
+// a mock of the two screens. Sized by its host: the thumbnail list scrolls
+// itself, so it needs a height rather than the page's scroll.
+Row {
+ id: tab
+ spacing: 20
+
+ // What the mock screens show. Hovering a thumbnail previews it on the
+ // targeted screen; the other keeps whatever is currently set.
+ property string hovering: ""
+ property string notice: ""
+ // Close the window: after a wallpaper is set, so the desk shows, and when
+ // wallhaven-dl opens.
+ signal dismiss
+
+ Connections {
+ target: Wallpapers
+ function onApplied(ok, message) {
+ tab.notice = ok ? "" : `wallpaper failed: ${message}`;
+ if (ok) tab.dismiss();
+ }
+ }
+
+ Column {
+ width: parent.width - mockPane.width - parent.spacing
+ height: parent.height
+ spacing: 12
+
+ Text {
+ visible: tab.notice !== ""
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: tab.notice
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: Theme.red
+ }
+
+ Row {
+ spacing: 8
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Set on"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.subtext
+ }
+ Tab {
+ text: "Horizontal"
+ selected: Wallpapers.target === "H"
+ onClicked: Wallpapers.target = "H"
+ }
+ Tab {
+ text: "Vertical"
+ selected: Wallpapers.target === "V"
+ onClicked: Wallpapers.target = "V"
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: Wallpapers.scanning ? "scanning…"
+ : Wallpapers.applying ? "applying…"
+ : Wallpapers.files.length + " wallpapers"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
+
+ // A folder is a header row followed, while open, by its
+ // thumbnails a row at a time, all in one flat ListView, so only
+ // the rows in view (and cacheBuffer around them) exist and
+ // decode: a large folder loads as it scrolls. Each row holds a
+ // group key and the index of its first file, start -1 for the
+ // header. A toggle inserts or removes just that folder's rows,
+ // so the rows around it, their decoded thumbnails and the
+ // scroll position all survive it.
+ ListView {
+ id: wallList
+ width: parent.width
+ height: parent.height - y
+ clip: true
+ readonly property int cols: Math.max(1, Math.floor(width / 300))
+ readonly property real cell: Math.floor(width / cols)
+ readonly property real thumbHeight: cell * 9 / 16 + 26
+ // The column is laid out after creation and reads negative
+ // until then, which the buffer refuses with a warning.
+ cacheBuffer: Math.max(0, thumbHeight * 2)
+ model: ListModel { id: rows }
+
+ function group(key) {
+ return Wallpapers.groups.find(g => g.key === key) ?? { key: key, name: "", files: [] };
+ }
+ function rowsOf(g) {
+ const out = [];
+ for (let i = 0; i < g.files.length; i += cols) out.push({ key: g.key, start: i });
+ return out;
+ }
+ function rebuild() {
+ rows.clear();
+ for (const g of Wallpapers.groups) {
+ rows.append({ key: g.key, start: -1 });
+ if (Wallpapers.expanded[g.key]) rows.append(rowsOf(g));
+ }
+ }
+ function toggle(index) {
+ const g = group(rows.get(index).key);
+ const body = rowsOf(g);
+ if (Wallpapers.expanded[g.key]) rows.remove(index + 1, body.length);
+ else body.forEach((r, i) => rows.insert(index + 1 + i, r));
+ Wallpapers.toggle(g.key);
+ }
+
+ // The width settles after creation, and a new column count
+ // regroups every row, so both rebuild.
+ Component.onCompleted: rebuild()
+ onColsChanged: rebuild()
+ Connections {
+ target: Wallpapers
+ function onGroupsChanged() { wallList.rebuild(); }
+ }
+
+ delegate: Item {
+ id: row
+ required property int index
+ required property string key
+ required property int start
+ readonly property var group: wallList.group(key)
+ readonly property bool open: !!Wallpapers.expanded[key]
+ width: ListView.view.width
+ height: start < 0 ? 40 : wallList.thumbHeight
+
+ Rectangle {
+ visible: row.start < 0
+ anchors { fill: parent; topMargin: 6 }
+ radius: 8
+ color: Qt.alpha(Theme.surface, headerHover.hovered ? 0.8 : 0.4)
+
+ HoverHandler { id: headerHover }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: wallList.toggle(row.index)
+ }
+
+ Text {
+ id: chevron
+ anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
+ text: row.open ? "\uf077" : "\uf078"
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+ Text {
+ anchors { left: chevron.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: row.group.name
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+ Text {
+ anchors { right: parent.right; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ text: row.group.files.length
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+ }
+
+ Row {
+ visible: row.start >= 0
+
+ Repeater {
+ model: row.start < 0 ? [] : row.group.files.slice(row.start, row.start + wallList.cols)
+
+ delegate: Item {
+ required property string modelData
+ width: wallList.cell
+ height: wallList.thumbHeight
+
+ Rectangle {
+ readonly property bool staged:
+ Wallpapers.pendingH === modelData || Wallpapers.pendingV === modelData
+
+ anchors.fill: parent
+ anchors.margins: 6
+ radius: 8
+ color: Qt.alpha(Theme.surface, thumbHover.hovered ? 0.8 : 0.3)
+ // Staged wins the border; otherwise it
+ // says how well the image fills the
+ // screen it would go to.
+ border.width: 2
+ border.color: staged ? Theme.accent
+ : Wallpapers.qualityColor(modelData, Wallpapers.target)
+ clip: true
+
+ HoverHandler {
+ id: thumbHover
+ // Hovering previews this image on the mock
+ // screen it would actually land on.
+ onHoveredChanged:
+ tab.hovering = hovered ? modelData
+ : (tab.hovering === modelData ? "" : tab.hovering)
+ }
+ MouseArea {
+ anchors.fill: parent
+ enabled: !Wallpapers.applying
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Wallpapers.pick(modelData)
+ }
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: 6
+ spacing: 4
+
+ Image {
+ width: parent.width
+ height: parent.height - 18
+ source: "file://" + modelData
+ asynchronous: true
+ cache: false
+ fillMode: Image.PreserveAspectCrop
+ // Decode at display size: full-resolution
+ // wallpapers would be megabytes each in memory.
+ sourceSize.width: wallList.cell
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideMiddle
+ text: Wallpapers.label(modelData)
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ color: Theme.subtext
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // The two screens as they sit on the desk: DP-3 rotated upright on
+ // the left, DP-1 wide on the right, centred against each other the
+ // way Hyprland has them. Hovering a thumbnail fills the screen it
+ // would be set on; the other keeps what is on it now.
+ Item {
+ id: mockPane
+ // A third of the panel rather than a fixed width, so the
+ // preview keeps its share when the panel is resized.
+ width: Math.round(parent.width * 0.33)
+ height: parent.height
+
+ // Divisor is the two panels side by side plus the gap; the
+ // bezels and stands are drawn outside that, hence the margin.
+ readonly property real scale: (width * 0.92) / (1080 + 2560 + 160)
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 14
+
+ Row {
+ spacing: Math.round(160 * mockPane.scale)
+
+ MockScreen {
+ anchors.verticalCenter: parent.verticalCenter
+ label: "DP-3"
+ targeted: Wallpapers.target === "V"
+ source: (targeted && tab.hovering !== "")
+ ? tab.hovering : Wallpapers.shown("V")
+ // DP-3 has transform=1, so it stands upright.
+ panelWidth: Math.round(1080 * mockPane.scale)
+ panelHeight: Math.round(1920 * mockPane.scale)
+ }
+
+ MockScreen {
+ anchors.verticalCenter: parent.verticalCenter
+ label: "DP-1"
+ targeted: Wallpapers.target === "H"
+ source: (targeted && tab.hovering !== "")
+ ? tab.hovering : Wallpapers.shown("H")
+ panelWidth: Math.round(2560 * mockPane.scale)
+ panelHeight: Math.round(1080 * mockPane.scale)
+ }
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: mockPane.width
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.Wrap
+ text: tab.hovering !== "" ? Wallpapers.label(tab.hovering)
+ : Wallpapers.dirty ? "click Apply to set"
+ : "click a wallpaper to stage it"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+
+ // Why the image under consideration, hovered or else the
+ // one staged for the target screen, will look worse than
+ // its thumbnail.
+ Text {
+ readonly property string subject: tab.hovering !== "" ? tab.hovering
+ : Wallpapers.target === "V" ? Wallpapers.pendingV : Wallpapers.pendingH
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: mockPane.width
+ visible: text !== ""
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.Wrap
+ text: subject !== "" ? Wallpapers.qualityNote(subject, Wallpapers.target) : ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Wallpapers.qualityColor(subject, Wallpapers.target)
+ }
+
+ // wallhaven-dl searches wallhaven.cc and finds larger
+ // copies of low-res wallpapers. Launched through its
+ // desktop entry, which needs no ~/bin on quickshell's PATH
+ // and keeps the app's own taskbar icon. The panel closes
+ // so the app window is not opened under the overlay.
+ Tab {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "Open wallhaven-dl"
+ onClicked: {
+ Quickshell.execDetached(["gtk-launch", "wallhaven-dl"]);
+ tab.dismiss();
+ }
+ }
+
+ Row {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 8
+
+ Tab {
+ text: Wallpapers.applying ? "applying…" : "Apply"
+ selected: Wallpapers.dirty && !Wallpapers.applying
+ enabled: Wallpapers.dirty && !Wallpapers.applying
+ onClicked: Wallpapers.apply()
+ }
+ Tab {
+ text: "Reset"
+ enabled: Wallpapers.dirty && !Wallpapers.applying
+ onClicked: Wallpapers.clearPending()
+ }
+ }
+ }
+ }
+}
diff --git a/desktop/modules/appearance/Wallpapers.qml b/desktop/modules/appearance/Wallpapers.qml
new file mode 100644
index 0000000..7d671e1
--- /dev/null
+++ b/desktop/modules/appearance/Wallpapers.qml
@@ -0,0 +1,244 @@
+// 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
+import "../.."
+
+// The wallpaper side. Setting one is `wallp`'s job: it knows about the two
+// outputs, the saved state and the accent run, and none of that is worth
+// reimplementing here.
+Singleton {
+ id: root
+
+ readonly property string home: Quickshell.env("HOME")
+ readonly property string dir: `${home}/Pictures/wallpapers`
+ // The distribution's wallpapers: loose images, and Plasma packages that
+ // each carry one picture at several resolutions under contents/images/,
+ // some with a dark version under contents/images_dark/.
+ readonly property string sysDir: "/usr/share/wallpapers"
+
+ // Which screen a pick applies to. wallp calls them H and V.
+ property string target: "H"
+
+ // Staged picks, empty until something is chosen. Selecting does not set
+ // anything: both screens can be composed and applied together, which is
+ // also a single wallp call rather than two.
+ property string pendingH: ""
+ property string pendingV: ""
+ readonly property bool dirty: pendingH !== "" || pendingV !== ""
+
+ function pick(path) {
+ if (target === "V") pendingV = path;
+ else pendingH = path;
+ }
+
+ function clearPending() { pendingH = ""; pendingV = ""; }
+
+ // What a screen should show: the staged pick if there is one, else what
+ // is actually set.
+ function shown(which) {
+ if (which === "V") return pendingV !== "" ? pendingV : currentV;
+ return pendingH !== "" ? pendingH : currentH;
+ }
+
+ property list<string> files: []
+ property bool scanning: true
+ property bool applying: false
+ signal applied(bool ok, string message)
+
+ // Sorted by most recent first: the wallpaper wanted now is usually one
+ // added recently. -maxdepth keeps a stray git checkout from being walked.
+ function refresh() {
+ scanning = true;
+ scanProc.running = false;
+ scanProc.running = true;
+ }
+
+ Process {
+ id: scanProc
+ running: true
+ // img DIR "DEPTH" [ACTIONS...]: the image files under DIR. DEPTH is
+ // left unquoted on purpose, so "-maxdepth 2" splits into two words
+ // and "" vanishes.
+ command: ["sh", "-c",
+ `img() { d=$1; o=$2; shift 2; find "$d" $o -type f ` +
+ `\\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \\) "$@" 2>/dev/null; }; ` +
+ `img ${JSON.stringify(root.dir)} "-maxdepth 2" -not -path '*/.*' -printf '%T@ %p\\n' | sort -rn | cut -d' ' -f2-; ` +
+ `img ${JSON.stringify(root.sysDir)} "-maxdepth 1" | sort; ` +
+ `img ${JSON.stringify(root.sysDir)} "" -path '*/contents/images*/*'`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.files = text.trim().split("\n").filter(s => s.length);
+ root.scanning = false;
+ sizeProc.running = false;
+ sizeProc.command = ["sh", "-c", 'printf "%s\\0" "$@" | xargs -0 -r file -N -0', "sh"].concat(root.files);
+ sizeProc.running = true;
+ }
+ }
+ }
+
+ // Each file's pixel size, by path, from `file`, which reads only the
+ // header: about half a second for a thousand. -0 ends the name with a
+ // NUL, since a name can hold the ": " that would otherwise end it. The
+ // size is the last WxH in the description, because a JPEG's comes after
+ // its "density 72x72". A file `file` gives no size for has no entry and
+ // gets no quality colour.
+ property var sizes: ({})
+ Process {
+ id: sizeProc
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const out = {};
+ for (const line of text.split("\n")) {
+ const nul = line.indexOf("\0");
+ if (nul < 0) continue;
+ const re = /(\d+) ?x ?(\d+)/g;
+ let m, last = null;
+ while ((m = re.exec(line.slice(nul + 1))) !== null) last = m;
+ if (last) out[line.slice(0, nul)] = { w: +last[1], h: +last[2] };
+ }
+ root.sizes = out;
+ }
+ }
+ }
+
+ // The screens wallp sets, as the mock draws them: DP-1 wide, DP-3 turned
+ // upright.
+ // ponytail: fixed, like the mock; read Quickshell.screens if a monitor
+ // changes.
+ readonly property var panels: ({ H: { w: 2560, h: 1080 }, V: { w: 1080, h: 1920 } })
+
+ // How far swaybg's `-m fill` has to enlarge an image to cover a screen,
+ // 0 when the size is unknown. At or under 1 it is downscaled and sharp;
+ // to 1.33 the softening is slight; past that it shows pixels.
+ function upscale(path, which) {
+ const s = sizes[path];
+ const p = panels[which];
+ return s && s.w > 0 && s.h > 0 ? Math.max(p.w / s.w, p.h / s.h) : 0;
+ }
+ function qualityColor(path, which) {
+ const u = upscale(path, which);
+ return u === 0 ? "transparent" : u <= 1 ? Theme.green : u <= 1.33 ? Theme.yellow : Theme.red;
+ }
+ // Why an image that fits will still look bad, or "" when it will not.
+ function qualityNote(path, which) {
+ const u = upscale(path, which);
+ if (u <= 1) return "";
+ const s = sizes[path], p = panels[which];
+ return `${s.w}×${s.h} is enlarged ${u.toFixed(2)}× to cover the ${p.w}×${p.h} screen: `
+ + (u <= 1.33 ? "each image pixel is spread over more than one screen pixel, so it will look slightly soft."
+ : "each image pixel becomes a visible block, so it will look pixelated.");
+ }
+
+ function basename(path) { return path.slice(path.lastIndexOf("/") + 1); }
+
+ // The files by the subfolder they sit in, folders by name, each keeping
+ // the newest-first order of `files`. Images loose in `dir` itself form a
+ // group named after it, which sorts first. A folder with no images at
+ // the scanned depth has no group. The system ones follow: its loose
+ // images as "system", and the Plasma packages as "Plasma", one image
+ // each: the dark version where the package has one, else the light, and
+ // of those the largest by the WxH its file is named after. `key` is what
+ // `expanded` is indexed by, so a local folder named "system" stays apart.
+ readonly property var groups: {
+ const local = {}, system = [], plasma = {};
+ for (const f of files) {
+ if (f.startsWith(dir + "/")) {
+ const rel = f.slice(dir.length + 1);
+ const key = rel.includes("/") ? rel.slice(0, rel.indexOf("/")) : "";
+ if (!local[key]) local[key] = [];
+ local[key].push(f);
+ } else if (f.includes("/contents/images")) {
+ const pkg = f.slice(sysDir.length + 1).split("/")[0];
+ if (!plasma[pkg] || rank(f) > rank(plasma[pkg])) plasma[pkg] = f;
+ } else {
+ system.push(f);
+ }
+ }
+ const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: "base" });
+ return Object.keys(local).sort(byName)
+ .map(k => ({ key: "local/" + k, name: k || basename(dir), files: local[k] }))
+ .concat([
+ { key: "system", name: "system", files: system },
+ { key: "plasma", name: "Plasma", files: Object.keys(plasma).sort(byName).map(k => plasma[k]) },
+ ])
+ .filter(g => g.files.length > 0);
+ }
+
+ // Dark outranks any light, then size decides.
+ function rank(path) {
+ const m = /(\d+)x(\d+)\.\w+$/.exec(path);
+ return (path.includes("/contents/images_dark/") ? 1e12 : 0) + (m ? m[1] * m[2] : 0);
+ }
+
+ // What a thumbnail is called: a Plasma package's file is named after its
+ // resolution, so it goes by the package instead.
+ function label(path) {
+ const i = path.indexOf("/contents/images");
+ return i < 0 ? basename(path) : basename(path.slice(0, i));
+ }
+
+ // Which groups are open, by key. Here rather than in the panel, which is
+ // destroyed on close, so a folder left open is open next time. Reassigned
+ // whole, because bindings do not see a key change inside a var.
+ property var expanded: ({})
+ function toggle(key) {
+ const e = Object.assign({}, expanded);
+ e[key] = !e[key];
+ expanded = e;
+ }
+
+ // What is on each screen now. wallp writes these on every set, so
+ // watching them keeps the mock honest even when set from a terminal.
+ property string currentH: ""
+ property string currentV: ""
+
+ FileView {
+ path: `${root.home}/.config/wallp/wall_h`
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: root.currentH = text().trim()
+ }
+
+ FileView {
+ path: `${root.home}/.config/wallp/wall_v`
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: root.currentV = text().trim()
+ }
+
+ // wallp does the work, including running udt-accent, so the accent follows
+ // the new wallpaper exactly as it does from the command line. Both screens
+ // go in one call: wallp accepts H= and V= together.
+ function apply() {
+ if (applying || !dirty) return;
+ applying = true;
+ const args = [];
+ if (pendingH !== "") args.push(`H=${JSON.stringify(pendingH)}`);
+ if (pendingV !== "") args.push(`V=${JSON.stringify(pendingV)}`);
+ applyProc.command = ["sh", "-c", `wallp --set ${args.join(" ")} >/dev/null 2>&1`];
+ applyProc.running = true;
+ }
+
+ Process {
+ id: applyProc
+ stderr: StdioCollector { id: applyErr }
+ onExited: code => {
+ root.applying = false;
+ if (code === 0) root.clearPending();
+ root.applied(code === 0, code === 0 ? "" : (applyErr.text.trim() || `wallp exited ${code}`));
+ }
+ }
+}