// 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 Quickshell.Wayland import QtQuick // Desktop modes as state. One file per mode under $XDG_RUNTIME_DIR, holding // "0" or "1"; a missing file means off. The runtime directory is tmpfs, so a // reboot resets every mode with no cleanup code here. // // The files are the interface, not this singleton: statusctl reads and writes // them directly so it works while the shell is down, and the FileView watch // means an external write repaints the drawer with no polling. It also means // a mode set from outside still fires its effects, because the watch reaches // the same handler a tile click would. Singleton { id: root readonly property string dir: Quickshell.env("XDG_RUNTIME_DIR") || "/tmp" readonly property bool dnd: dndFile.value // Set by the game detector below, on its own file so it never fights the // manual toggle: presentation is the OR, and a game ending clears only its // half. A manual "on" therefore survives a game session. readonly property bool gaming: gamingFile.value readonly property bool presentation: presFile.value || gamingFile.value // Stored as "auto-lock disabled", so the missing-file default of off is // the wanted default of locking on. The row inverts it for the label. readonly property bool nolock: nolockFile.value // Mutes the Claude and opencode sounds; their popups are unaffected. The // sound scripts read the file through statusctl, so this is only the // drawer's view of it. readonly property bool quiet: quietFile.value readonly property int activeCount: (root.dnd ? 1 : 0) + (root.presentation ? 1 : 0) + (root.nolock ? 1 : 0) + (root.quiet ? 1 : 0) // Set once by shell.qml. IdleInhibitor does nothing with a null window, // and the singleton has no window of its own to offer. property var inhibitWindow: null // What dnd and nolock were before presentation mode drove them, so turning // presentation mode off restores them rather than clearing them. Held here // rather than in a file: they are meaningful only while presentation mode // is on, and presentation mode does not survive a reboot. // // Known limit: on a shell restart while presentation is already on, // onPresentationChanged can run before these FileViews have loaded, so the // recorded prior values depend on which file loads first. property bool dndBeforePresentation: false property bool nolockBeforePresentation: false property bool quietBeforePresentation: false // Deadline for a timed disable, owned by the singleton rather than the row // because the drawer page (and so the row) is destroyed on close while the // effect has to outlive it. Shell-lifetime only: a restart while a timed // disable is pending leaves the lock off until it is toggled, which is // safe because the user opted into the disable. property double nolockUntil: 0 function setMode(name, on) { if (name === "dnd") { dndFile.write(on); } else if (name === "presentation") { presFile.write(on); } else if (name === "nolock") { // Any write clears a pending deadline; the timed path sets it back. root.nolockUntil = 0; nolockFile.write(on); } else if (name === "quiet") { quietFile.write(on); } } // Disable auto-lock, optionally for a number of minutes. Zero or a // non-number means until toggled back. function disableLockFor(minutes) { const m = Number(minutes); root.setMode("nolock", true); if (m > 0) root.nolockUntil = Date.now() + m * 60000; } function toggleMode(name) { if (name === "dnd") root.setMode("dnd", !root.dnd); else if (name === "presentation") root.setMode("presentation", !root.presentation); else if (name === "nolock") root.setMode("nolock", !root.nolock); else if (name === "quiet") root.setMode("quiet", !root.quiet); } // The detector calls this every poll; a write on an unchanged value would // still land as an atomic write and re-fire the FileView watch for nothing. function setGaming(on) { if (on !== root.gaming) gamingFile.write(on); } // The timed disable re-enables itself. Runs only while a deadline is // pending, so no tick burns while the mode is off or permanently on. Timer { id: nolockTimer interval: 1000 repeat: true running: root.nolockUntil > 0 onTriggered: { if (Date.now() >= root.nolockUntil) root.setMode("nolock", false); } } // Effects follow the mode rather than the setter, so a mode set by // statusctl while the drawer is closed asserts them too. onPresentationChanged: { if (root.presentation) { root.dndBeforePresentation = root.dnd; root.nolockBeforePresentation = root.nolock; root.quietBeforePresentation = root.quiet; root.setMode("dnd", true); root.setMode("nolock", true); root.setMode("quiet", true); } else { root.setMode("dnd", root.dndBeforePresentation); root.setMode("nolock", root.nolockBeforePresentation); root.setMode("quiet", root.quietBeforePresentation); } } // --- game detection --------------------------------------------------- // // A game is a fullscreen app that already holds the screen, and the idle // lock and DND are both unwanted while it runs, so presentation // is set automatically. This machine's games come from Steam and launch // either under gamescope or directly, and the DuckStation and PCSX2 emulators // run standalone, so all four are tested and any one is enough. // // A Teams call presents too. Teams runs all session, so its process is no // signal; a call is Teams holding a mic stream, which pactl lists as a // source-output tagged with its binary. Outside a call it holds none. // // The steam pattern is bracketed so it cannot match this very command // line: pgrep -f reads the whole argv, and the literal "steamapps/common" // here would otherwise match the sh running the check and report a game // forever. The character class makes the needle miss itself while still // matching a real game path. gamescope and the emulators use pgrep -x, an // exact match on the process name, which cannot self-match. Process { id: gameProc command: ["sh", "-c", "pgrep -x gamescope >/dev/null 2>&1 || pgrep -f 'steamapps/commo[n]' >/dev/null 2>&1 || pgrep -x duckstation-qt >/dev/null 2>&1 || pgrep -x pcsx2-qt >/dev/null 2>&1 || pactl list source-outputs 2>/dev/null | grep -q 'application.process.binary = \"teams-for-linux\"'"] onExited: code => root.setGaming(code === 0) } // Process lifecycle is not a file to watch, so this polls. A game launch // does not need sub-second latency to present; a missed start corrects on // the next tick. triggeredOnStart covers a shell start while a game is // already up. Timer { interval: 5000 repeat: true running: true triggeredOnStart: true onTriggered: { // Re-assigning running on an already-running Process is a no-op, // so it is cleared first. gameProc.running = false; gameProc.running = true; } } // Wayland idle inhibit. The compositor advertises // zwp_idle_inhibit_manager_v1 and hypridle honours it, so no D-Bus path // is needed even though elogind runs here. nolock is the sole holder; // presentation drives nolock, so it needs no second inhibitor. IdleInhibitor { window: root.inhibitWindow enabled: root.nolock && root.inhibitWindow !== null } component ModeFile: FileView { id: mf property bool value: false // FileView is documented to fire fileChanged on its own setText, so a // write would re-enter this handler. Comparing before assigning makes // that harmless: the reparse yields the value just written and the // binding does not change. function reparse() { const t = mf.text().trim(); const v = (t === "1"); if (v !== mf.value) mf.value = v; } function write(on) { const s = on ? "1\n" : "0\n"; mf.value = on; mf.setText(s); } // Both are the documented defaults in 0.3.1, set explicitly because // the CLI depends on them: statusctl watches create,moved_to // precisely because an atomic write lands as a link (first write, // no file yet) or a rename, so a future release flipping this // default would break the watcher silently. atomicWrites: true watchChanges: true printErrors: false onFileChanged: mf.reload() onLoaded: mf.reparse() // A missing file is the off state, not an error worth logging. onLoadFailed: mf.value = false } ModeFile { id: dndFile; path: root.dir + "/status.dnd" } ModeFile { id: presFile; path: root.dir + "/status.presentation" } ModeFile { id: nolockFile; path: root.dir + "/status.nolock" } ModeFile { id: quietFile; path: root.dir + "/status.quiet" } // Written only by the game detector above, never by statusctl. ModeFile { id: gamingFile; path: root.dir + "/status.gaming" } }