aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/modules/status/README.md28
-rw-r--r--shared/Status.qml51
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" }