diff options
Diffstat (limited to 'appearance/Hypridle.qml')
| -rw-r--r-- | appearance/Hypridle.qml | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/appearance/Hypridle.qml b/appearance/Hypridle.qml new file mode 100644 index 0000000..1696f5f --- /dev/null +++ b/appearance/Hypridle.qml @@ -0,0 +1,161 @@ +// 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 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"; + } +} |
