aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/appearance/Hyprsunset.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 10:16:40 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 10:16:40 +0200
commit70bbc970c75221c902c515efd124c47fd702e3b7 (patch)
treed35f6b1ffbb506b65430af7fc1eca2c432879a22 /desktop/modules/appearance/Hyprsunset.qml
parente5399f3e8d8572f1db7c4391a7c571d4845500f1 (diff)
downloadquickshell-70bbc970c75221c902c515efd124c47fd702e3b7.tar.gz
quickshell-70bbc970c75221c902c515efd124c47fd702e3b7.zip
appearance moves into the settings window
The appearance shell existed because a wallpaper picker needed more room than the 600px drawer, so it ran as its own process with a full-screen overlay. The settings window now gives it that room, and its five tabs become five settings pages in the desktop process: Wallpaper, Theme, Icons & cursor, Sunset and Idle. appearance/ is gone, and with it a process, a keepalive window and a layer blur rule. The files move to desktop/modules/appearance/ unchanged in what they do. Each tab gets a small *Module.qml; the window's refresh-on-open calls the singletons' refresh(), which the panel's show() used to. The two tabs that were inline Components in AppearancePanel become ThemeTab and WallpaperTab, owning the notices the panel printed. Sunset, Idle and Icons drop their own Flickable so the page scrolls them and search can reveal a row; the wallpaper list virtualises its rows and scrolls itself, so it takes the page's new viewHeight instead. Search finds a colour scheme and an icon or cursor theme by name. The appearance IPC target moves to desktop/shell.qml with the same verbs, so udt-steps and any keybind change only the path. Git now follows the appearance pages and autostart in the sidebar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/appearance/Hyprsunset.qml')
-rw-r--r--desktop/modules/appearance/Hyprsunset.qml327
1 files changed, 327 insertions, 0 deletions
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;
+ }
+ }
+ }
+}