aboutsummaryrefslogtreecommitdiffstats
path: root/appearance
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-16 12:22:46 +0200
committerDanilo M. <danix@danix.xyz>2026-09-16 12:22:46 +0200
commita8fba0cb72f7bfcff9013e5e28a4dfdfd9d1a872 (patch)
treeec4544026d28344fcb20546941e24334e9592f61 /appearance
parent63df22d6186530f4f3732af79d26d4cba2fa1746 (diff)
downloadquickshell-a8fba0cb72f7bfcff9013e5e28a4dfdfd9d1a872.tar.gz
quickshell-a8fba0cb72f7bfcff9013e5e28a4dfdfd9d1a872.zip
feat(appearance): add the hyprsunset profile model
Parses and serializes ~/.config/hypr/hyprsunset.conf in the exact format hyprsunset-qt writes, so both editors share the file. Location lives in hyprsunset-qt's own INI; fetching mirrors its curl calls and browser User-Agent. Selftest round-trips a fixture byte-for-byte.
Diffstat (limited to 'appearance')
-rw-r--r--appearance/Hyprsunset.qml309
-rw-r--r--appearance/shell.qml1
2 files changed, 310 insertions, 0 deletions
diff --git a/appearance/Hyprsunset.qml b/appearance/Hyprsunset.qml
new file mode 100644
index 0000000..acfc365
--- /dev/null
+++ b/appearance/Hyprsunset.qml
@@ -0,0 +1,309 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+// The hyprsunset side: profiles in ~/.config/hypr/hyprsunset.conf, the
+// location in hyprsunset-qt's own config, and the daemon. The file format is
+// byte-for-byte what ~/Programming/GIT/sunset-qt writes, so both tools edit
+// the same file.
+Singleton {
+ id: root
+
+ readonly property string home: Quickshell.env("HOME")
+ readonly property string confPath: `${home}/.config/hypr/hyprsunset.conf`
+ readonly property string appConfPath: `${home}/.config/hyprsunset-qt/config`
+
+ property var profiles: []
+ property string lat: ""
+ property string lon: ""
+ property bool autoDetect: true
+ property string daemonCommand: "hyprsunset"
+ property string cachePath: `${home}/.config/hyprsunset-qt/sun.json`
+ property string notice: ""
+ property string sunSummary: ""
+ property bool busy: 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) }
+ }
+
+ // 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.cachePath = root.expandTilde(c["cache.path"] ?? "~/.config/hyprsunset-qt/sun.json");
+ root.daemonCommand = c["daemon.command"] ?? "hyprsunset";
+ }
+ }
+ }
+
+ 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 = ~/.config/hyprsunset-qt/sun.json\n\n` +
+ `[daemon]\ncommand = ${root.daemonCommand}\n\n`;
+ }
+ function saveSettings() { appFile.setText(root.appConfText()); }
+
+ function restart() {
+ Quickshell.execDetached(["sh", "-c",
+ `pkill -x hyprsunset; setsid -f ${root.daemonCommand}`]);
+ }
+
+ function save() {
+ for (const p of root.profiles) {
+ if (!root.validTime(p.time)) { root.notice = `invalid time: ${p.time}`; return; }
+ if (p.temperature !== null && p.temperature !== undefined &&
+ !root.validTemperature(p.temperature)) {
+ root.notice = `invalid temperature: ${p.temperature}`; return;
+ }
+ if (p.gamma !== null && p.gamma !== undefined && !root.validGamma(p.gamma)) {
+ root.notice = `invalid gamma: ${p.gamma}`; return;
+ }
+ }
+ confFile.setText(root.serializeProfiles(root.profiles));
+ root.saveSettings();
+ root.restart();
+ root.notice = "saved + restarted";
+ }
+
+ // Live preview via the daemon's IPC. Identity wins, then temperature, then
+ // gamma, matching hyprsunset-qt. Nothing is written.
+ function preview() {
+ const i = root.nightIndex(root.profiles);
+ const target = i !== null ? root.profiles[i]
+ : (root.profiles.length ? root.profiles[0] : null);
+ if (!target) return;
+ if (target.identity) {
+ previewProc.command = ["hyprctl", "hyprsunset", "identity"];
+ } else {
+ const parts = [];
+ if (target.temperature !== null && target.temperature !== undefined)
+ parts.push(`hyprctl hyprsunset temperature ${target.temperature}`);
+ if (target.gamma !== null && target.gamma !== undefined)
+ parts.push(`hyprctl hyprsunset gamma ${Math.round(target.gamma * 100)}`);
+ if (!parts.length) return;
+ previewProc.command = ["sh", "-c", parts.join("; ")];
+ }
+ previewProc.running = false;
+ previewProc.running = true;
+ }
+ Process { id: previewProc }
+
+ function detect() {
+ detectProc.running = false;
+ detectProc.running = true;
+ }
+ Process {
+ id: detectProc
+ command: ["curl", "-fsS", "http://ip-api.com/json"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const d = JSON.parse(text);
+ root.lat = String(d.lat);
+ root.lon = String(d.lon);
+ root.saveSettings();
+ root.notice = `located ${root.lat}, ${root.lon}`;
+ } catch (e) { root.notice = `detect failed: ${e}`; }
+ }
+ }
+ }
+
+ // sunrise-sunset.org is behind Cloudflare and 403s curl's default UA.
+ function fetchSun() {
+ const url = "https://api.sunrise-sunset.org/json?lat=" +
+ encodeURIComponent(root.lat) + "&lng=" + encodeURIComponent(root.lon) +
+ "&formatted=0";
+ fetchProc.command = ["curl", "-fsS", "-A",
+ "Mozilla/5.0 (X11; Linux x86_64) hyprsunset-qt", url];
+ fetchProc.running = false;
+ fetchProc.running = true;
+ root.busy = true;
+ }
+ Process {
+ id: fetchProc
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.busy = false;
+ let data;
+ try { data = JSON.parse(text); }
+ catch (e) { root.notice = `fetch failed: ${e}`; return; }
+ cacheFile.setText(JSON.stringify(data, null, 2));
+ const r = data.results ?? {};
+ const rise = r.sunrise ? root.localHM(r.sunrise) : "";
+ const set = r.sunset ? root.localHM(r.sunset) : "";
+ root.sunSummary = `sunrise ${rise || "—"} sunset ${set || "—"}`;
+ const di = root.dayIndex(root.profiles);
+ const ni = root.nightIndex(root.profiles);
+ const next = root.profiles.slice();
+ if (di !== null && rise) next[di] = Object.assign({}, next[di], { time: rise });
+ if (ni !== null && set) next[ni] = Object.assign({}, next[ni], { time: set });
+ root.profiles = next;
+ }
+ }
+ }
+}
diff --git a/appearance/shell.qml b/appearance/shell.qml
index 56132c0..1a6aa75 100644
--- a/appearance/shell.qml
+++ b/appearance/shell.qml
@@ -20,5 +20,6 @@ ShellRoot {
function toggle() { panel.toggle(""); }
function wallpaper() { panel.toggle("wallpaper"); }
function theme() { panel.toggle("theme"); }
+ function selftest(): string { return Hyprsunset.selftest(); }
}
}