diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-16 12:22:46 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-16 12:22:46 +0200 |
| commit | a8fba0cb72f7bfcff9013e5e28a4dfdfd9d1a872 (patch) | |
| tree | ec4544026d28344fcb20546941e24334e9592f61 /appearance | |
| parent | 63df22d6186530f4f3732af79d26d4cba2fa1746 (diff) | |
| download | quickshell-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.qml | 309 | ||||
| -rw-r--r-- | appearance/shell.qml | 1 |
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(); } } } |
