diff --git a/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md b/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md index 0db040a..d4e8916 100644 --- a/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md +++ b/docs/superpowers/specs/2026-07-15-niri-gamemode-design.md @@ -8,16 +8,24 @@ Add a compositor-level "gamemode": while a game is running, inhibit idle/blanking, silence notifications, switch to the `performance` power profile (and bump the fan), and hide waybar — then revert everything cleanly when the game ends. One script, -`scripts/gamemode-session.sh`, is the whole engine. It is driven by **two independent -sources** and reference-counted, so the two triggers can overlap safely: +`scripts/gamemode-session.sh`, is the whole engine. It is driven by **three +independent sources** and reference-counted, so the triggers can overlap safely: -- **Automatic** — Feral `gamemoded` custom start/end hooks (any game launched with - `gamemoderun`). -- **Manual** — a `Mod+G` niri keybind, for games that don't call gamemode. +- **Window watch (`winwatch`)** — `scripts/gamemode-watch.py`, launched at niri + startup, subscribes to niri's event stream and toggles gamemode whenever a game + window (app_id matching `steam_app_*`, RuneLite, or gamescope) is open. This makes + Steam games automatic with **zero per-game setup** — the primary trigger. +- **Feral (`feral`)** — Feral `gamemoded` custom start/end hooks, for + gamemode-aware launchers run with `gamemoderun` that the window watch may not match. +- **Manual (`manual`)** — a `Mod+G` niri keybind, for anything else. Effects apply on the first activation (0→1 active sources) and revert on the last deactivation (1→0). Also removes three dead VRR window-rules found during review. +> **Addendum (2026-07-15):** The window-watch source was added after the initial +> design so Steam games trigger gamemode globally instead of needing +> `gamemoderun %command%` per game. See "Window watch" below. + ## Current Behaviour - **Compositor:** niri 26.04, single internal panel `eDP-1` (BOE, 2256×1504@60, @@ -174,22 +182,47 @@ Mod+G { spawn "gamemode-session" "toggle"; } Leave a one-line comment on the `steam_app_` rule noting to re-add `variable-refresh-rate true` if a VRR-capable external monitor is ever attached. +### 5. `scripts/gamemode-watch.py` (new) — window watch + +A Python daemon (Python already used by the theme generator) launched via +`spawn-at-startup "gamemode-watch"`. It runs `niri msg --json event-stream` and +maintains the set of open **game** windows: + +- `is_game(app_id)` matches `steam_app_` (substring), the RuneLite ids + (`net-runelite-client-RuneLite|runelite|RuneLite`), and `gamescope` + (case-insensitive) — mirrors the niri window rules. Bolt's *launcher* window is + deliberately excluded (it would trigger before you're in-game). +- `WindowsChanged` (connect snapshot) rebuilds the set; `WindowOpenedOrChanged` + adds/removes by app_id; `WindowClosed` (id only) discards by id. +- On empty→non-empty it calls `gamemode-session add winwatch`; non-empty→empty, + `del winwatch`. +- Event logic is a pure `Watcher` class (injected `emit`) so it unit-tests without + niri or subprocesses (`scripts/tests/gamemode-watch.test.py`). +- Robustness: reconnects if the stream drops while niri is alive; a SIGTERM handler + and a `finally` guarantee `del winwatch` on exit, so the watcher stopping can + never leave gamemode stuck on. + ## Files Touched - `scripts/gamemode-session.sh` — **new**, the engine. -- `mako/config` — **new**, adds the `do-not-disturb` mode (plus current settings). +- `scripts/gamemode-watch.py` — **new**, niri-event-stream window watch (primary + auto-trigger). Tested by `scripts/tests/gamemode-watch.test.py`. +- `mako/config` — **new**, adds the `do-not-disturb` mode (plus current settings); + also un-ignored in `.gitignore` (was previously untracked by choice). - `gamemode.ini` — **new**, Feral start/end hooks. -- `niri/config.kdl` — add `Mod+G` bind; remove 3 dead VRR rules; add one comment. -- `install.sh` — add the `gamemode-session` symlink + `gamemode.ini`/`mako/config` - placement, matching how existing scripts/configs are linked (or create the - symlink manually if install.sh doesn't enumerate). +- `niri/config.kdl` — add `Mod+G` bind; `spawn-at-startup "gamemode-watch"`; remove + 3 dead VRR rules; add one comment. +- `install.sh` — link `gamemode-session`, `gamemode-watch`, `gamemode.ini`, and + `mako/config` (the last was never linked before). ## Usage -- **Steam:** set launch options to `gamemoderun %command%` (per-game or as a global - default) → fully automatic enter/revert. -- **Other games / anything gamemode doesn't catch:** `Mod+G` to toggle. -- Both converge on `gamemode-session`; overlapping is safe. +- **Steam / RuneLite / gamescope games:** nothing to do — the window watch turns + gamemode on when a game window opens and off when the last one closes. +- **Gamemode-aware launchers** (Lutris/Heroic via `gamemoderun`): covered by the + Feral hooks even if their window app_id isn't matched. +- **Anything else / manual override:** `Mod+G` to toggle. +- All three converge on `gamemode-session`; overlapping is safe (reference-counted). ## Out of Scope (YAGNI) diff --git a/install.sh b/install.sh index b7a525a..1cc8b1f 100755 --- a/install.sh +++ b/install.sh @@ -54,6 +54,7 @@ ln -sf "$(pwd)/scripts/waybar-restart.sh" ~/.local/bin/waybar-restart ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session +ln -sf "$(pwd)/scripts/gamemode-watch.py" ~/.local/bin/gamemode-watch echo "==> Enabling systemd user services" mkdir -p ~/.config/systemd/user diff --git a/niri/config.kdl b/niri/config.kdl index d2d710d..3b7fa8f 100644 --- a/niri/config.kdl +++ b/niri/config.kdl @@ -45,6 +45,7 @@ spawn-at-startup "hypridle" spawn-at-startup "wl-paste" "--watch" "cliphist" "store" spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1" spawn-at-startup "xwayland-satellite" +spawn-at-startup "gamemode-watch" binds { Mod+Q repeat=false { close-window; } diff --git a/scripts/gamemode-watch.py b/scripts/gamemode-watch.py new file mode 100755 index 0000000..851825e --- /dev/null +++ b/scripts/gamemode-watch.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""gamemode-watch — drive gamemode-session from niri's window event stream. + +Subscribes to `niri msg --json event-stream`, tracks which open windows are +games (matched by app_id), and calls `gamemode-session add/del winwatch` on the +empty<->non-empty transition. 'winwatch' is just another reference-counted +source, so it coexists with the Mod+G toggle and the Feral gamemode.ini hooks. + +Launched at niri startup (spawn-at-startup "gamemode-watch"). Reconnects if the +event stream drops while niri is still alive, and always releases the source on +exit so gamemode can never get stuck on when the watcher stops. +""" +import json +import re +import signal +import subprocess +import sys +import time + +# app_id patterns that count as "a game" — mirrors the niri window rules. +GAME_PATTERNS = [ + re.compile(r"steam_app_"), + re.compile(r"^(net-runelite-client-RuneLite|runelite|RuneLite)$"), + re.compile(r"gamescope", re.IGNORECASE), +] + + +def is_game(app_id): + if not app_id: + return False + return any(p.search(app_id) for p in GAME_PATTERNS) + + +class Watcher: + """Pure event logic. `emit(action)` is called with "add"/"del" on the + transition into/out of "at least one game window open".""" + + def __init__(self, emit): + self.games = set() # ids of currently-open game windows + self.active = False + self.emit = emit + + def _reconcile(self): + want = len(self.games) > 0 + if want and not self.active: + self.active = True + self.emit("add") + elif not want and self.active: + self.active = False + self.emit("del") + + def handle(self, ev): + if "WindowsChanged" in ev: # full snapshot (sent on connect) + self.games = { + w["id"] + for w in ev["WindowsChanged"]["windows"] + if is_game(w.get("app_id")) + } + self._reconcile() + elif "WindowOpenedOrChanged" in ev: # fired on open AND on any change + w = ev["WindowOpenedOrChanged"]["window"] + if is_game(w.get("app_id")): + self.games.add(w["id"]) + else: + self.games.discard(w["id"]) + self._reconcile() + elif "WindowClosed" in ev: # only carries the id, no app_id + self.games.discard(ev["WindowClosed"]["id"]) + self._reconcile() + + +def call(action): + subprocess.run( + ["gamemode-session", action, "winwatch"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def niri_alive(): + try: + return subprocess.run( + ["niri", "msg", "version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode == 0 + except FileNotFoundError: + return False + + +def stream_lines(): + proc = subprocess.Popen( + ["niri", "msg", "--json", "event-stream"], + stdout=subprocess.PIPE, + text=True, + ) + try: + for line in proc.stdout: + yield line + finally: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + + +def main(): + watcher = Watcher(call) + try: + while True: + for line in stream_lines(): + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + continue + watcher.handle(ev) + # stream ended; stop if niri is gone, else reconnect + if not niri_alive(): + break + time.sleep(2) + finally: + if watcher.active: + call("del") # never leave gamemode stuck on + + +def _on_sigterm(signum, frame): + # SIGTERM (how niri/logout stops us) -> KeyboardInterrupt so main()'s + # `finally` runs and releases the winwatch source — never leave gamemode on. + raise KeyboardInterrupt + + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, _on_sigterm) + try: + main() + except KeyboardInterrupt: + pass + sys.exit(0) diff --git a/scripts/tests/gamemode-watch.test.py b/scripts/tests/gamemode-watch.test.py new file mode 100644 index 0000000..cbbd3bd --- /dev/null +++ b/scripts/tests/gamemode-watch.test.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Unit tests for gamemode-watch's pure event logic (no niri, no subprocess). +Loads the hyphenated script by path and drives Watcher with synthetic events, +asserting the exact sequence of add/del calls.""" +import importlib.util +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +MOD_PATH = os.path.join(HERE, "..", "gamemode-watch.py") +spec = importlib.util.spec_from_file_location("gamemode_watch", MOD_PATH) +gw = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gw) + +fails = 0 + + +def check(desc, got, want): + global fails + if got == want: + print(f"ok - {desc}") + else: + print(f"FAIL - {desc} (got {got!r} want {want!r})") + fails = 1 + + +# --- is_game --------------------------------------------------------------- +check("steam_app_ matches", gw.is_game("steam_app_480"), True) +check("bare steam_app_ matches", gw.is_game("steam_app_"), True) +check("RuneLite matches", gw.is_game("RuneLite"), True) +check("runelite matches", gw.is_game("runelite"), True) +check("runelite full id matches", gw.is_game("net-runelite-client-RuneLite"), True) +check("gamescope matches", gw.is_game("gamescope"), True) +check("Gamescope (case) matches", gw.is_game("Gamescope"), True) +check("Alacritty is not a game", gw.is_game("Alacritty"), False) +check("bolt-launcher excluded", gw.is_game("bolt-launcher"), False) +check("empty app_id is not a game", gw.is_game(""), False) +check("None app_id is not a game", gw.is_game(None), False) + + +# --- Watcher transitions --------------------------------------------------- +def drive(events): + calls = [] + w = gw.Watcher(lambda action: calls.append(action)) + for ev in events: + w.handle(ev) + return calls + + +def win(wid, app_id): + return {"WindowOpenedOrChanged": {"window": {"id": wid, "app_id": app_id}}} + + +def closed(wid): + return {"WindowClosed": {"id": wid}} + + +def snapshot(pairs): + return {"WindowsChanged": {"windows": [{"id": i, "app_id": a} for i, a in pairs]}} + + +check("empty snapshot: no calls", drive([snapshot([])]), []) +check("non-game window: no calls", drive([win(1, "Alacritty")]), []) +check("one game opens: add", drive([win(1, "steam_app_10")]), ["add"]) +check( + "second game keeps active (single add)", + drive([win(1, "steam_app_10"), win(2, "RuneLite")]), + ["add"], +) +check( + "close one of two: still active", + drive([win(1, "steam_app_10"), win(2, "RuneLite"), closed(2)]), + ["add"], +) +check( + "close last game: add then del", + drive([win(1, "steam_app_10"), closed(1)]), + ["add", "del"], +) +check( + "snapshot already containing a game: add (restart mid-game)", + drive([snapshot([(1, "Alacritty"), (5, "steam_app_9")])]), + ["add"], +) +check( + "game window changes to non-game app_id: del", + drive([win(1, "steam_app_10"), win(1, "Alacritty")]), + ["add", "del"], +) +check( + "repeated OpenedOrChanged for same game: single add", + drive([win(1, "gamescope"), win(1, "gamescope"), win(1, "gamescope")]), + ["add"], +) + +sys.exit(fails)