diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-19 18:23:29 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-19 18:23:29 +0200 |
| commit | eba0c13ad2f50964b6f88950366cb659c0d8b055 (patch) | |
| tree | 4d26bb384def848a673dd5e469a2aeed8dc1b476 | |
| parent | ae9636030bb46db6a42dba59ea3fcbf64e541ffe (diff) | |
| download | quickshell-eba0c13ad2f50964b6f88950366cb659c0d8b055.tar.gz quickshell-eba0c13ad2f50964b6f88950366cb659c0d8b055.zip | |
feat(status): auto-present while a game runs
Presentation is worth having automatically while a game runs: it is a
fullscreen app that already holds the screen, and the idle lock, DND and
breaktimer are all unwanted during it.
Detection is a 5s poll for a gamescope process or a Steam game binary,
either one enough. The Steam pattern is bracketed so pgrep -f cannot match
the sh running the check and report a game forever.
The result goes to its own status.gaming file, and presentation becomes the
OR of it and status.presentation. That separation is the point: a game
ending clears only the gaming half, so a presentation set by hand survives
a game session instead of being clobbered on exit.
| -rw-r--r-- | desktop/modules/status/README.md | 28 | ||||
| -rw-r--r-- | shared/Status.qml | 51 |
2 files changed, 77 insertions, 2 deletions
diff --git a/desktop/modules/status/README.md b/desktop/modules/status/README.md index 87654d5..e043dcd 100644 --- a/desktop/modules/status/README.md +++ b/desktop/modules/status/README.md @@ -1,13 +1,16 @@ # status Desktop modes as state: `dnd`, `presentation` and `nolock`, owned by the -`Status` singleton and stored as files under `$XDG_RUNTIME_DIR`. +`Status` singleton and stored as files under `$XDG_RUNTIME_DIR`. A fourth, +`status.gaming`, is written only by the shell's game detector; it is not a +mode `statusctl` exposes. ## The files are the interface $XDG_RUNTIME_DIR/status.dnd $XDG_RUNTIME_DIR/status.presentation $XDG_RUNTIME_DIR/status.nolock + $XDG_RUNTIME_DIR/status.gaming Each holds `0` or `1`; a missing file means off. That directory is tmpfs, so a reboot resets every mode and there is no cleanup code. A shell restart does @@ -95,6 +98,29 @@ toward `activeCount`: a running daemon is not a mode the user switched on. The daemon's own configuration lives in `~/.config/breaktimer.conf` and is not edited from here; `breaktimer.sh config` prints what is in effect. +## Game detection + +Presentation is set automatically while a game runs, so a fullscreen game does +not get locked or interrupted by the idle timer, DND or breaktimer. The shell +polls every five seconds for a `gamescope` process or a running Steam binary, +and either one is enough: + + pgrep -x gamescope || pgrep -f 'steamapps/commo[n]' + +The steam pattern is bracketed so it cannot match the check's own command +line: `pgrep -f` reads the whole argv, and a literal `steamapps/common` would +match the `sh` running the check and report a game forever. + +The result is written to `status.gaming`, a file separate from +`status.presentation`, and presentation is the OR of the two. That separation +is the point: a game ending clears only the gaming half, so a presentation the +user set by hand survives for the whole game session instead of being clobbered +on exit. It also means presentation cannot be turned off by hand while a game +runs, since `status.gaming` holds it on until the game exits. + +`status.gaming` is never set by `statusctl`; the detector owns it. The effects +are unchanged, because they key off the combined `presentation` value. + ## Waybar `custom/presentation` reads `statusctl presentation watch`. It replaces diff --git a/shared/Status.qml b/shared/Status.qml index dfb6b83..36853af 100644 --- a/shared/Status.qml +++ b/shared/Status.qml @@ -31,7 +31,11 @@ Singleton { readonly property string dir: Quickshell.env("XDG_RUNTIME_DIR") || "/tmp" readonly property bool dnd: dndFile.value - readonly property bool presentation: presFile.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 @@ -104,6 +108,12 @@ Singleton { else if (name === "nolock") root.setMode("nolock", !root.nolock); } + // 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 { @@ -162,6 +172,43 @@ Singleton { } } + // --- game detection --------------------------------------------------- + // + // A game is a fullscreen app that already holds the screen, and the idle + // lock, DND and breaktimer are all unwanted while it runs, so presentation + // is set automatically. This machine's games come from Steam and launch + // either under gamescope or directly, so both are tested and either one is + // enough. + // + // 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. + Process { + id: gameProc + command: ["sh", "-c", + "pgrep -x gamescope >/dev/null 2>&1 || pgrep -f 'steamapps/commo[n]' >/dev/null 2>&1"] + 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; @@ -250,6 +297,8 @@ Singleton { ModeFile { id: dndFile; path: root.dir + "/status.dnd" } ModeFile { id: presFile; path: root.dir + "/status.presentation" } ModeFile { id: nolockFile; path: root.dir + "/status.nolock" } + // Written only by the game detector above, never by statusctl. + ModeFile { id: gamingFile; path: root.dir + "/status.gaming" } WordFile { id: btStateFile; path: root.dir + "/breaktimer.state" } WordFile { id: btPhaseFile; path: root.dir + "/breaktimer.phase" } |
