diff options
Diffstat (limited to 'desktop/modules')
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}`)); + } + } +} |
