// Copyright (C) 2026 Danilo M. // // 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"; } }