aboutsummaryrefslogtreecommitdiffstats
path: root/appearance/Hypridle.qml
diff options
context:
space:
mode:
Diffstat (limited to 'appearance/Hypridle.qml')
-rw-r--r--appearance/Hypridle.qml161
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";
+ }
+}