Spec: niri gamemode (idle inhibit, DND, perf profile, hide waybar)
Hybrid trigger (Feral gamemode hooks + Mod+G toggle), reference-counted so overlapping sources apply once and revert once. Also removes three dead VRR window-rules (panel has no VRR support). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
# Niri Gamemode Design
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
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:
|
||||
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
## Current Behaviour
|
||||
|
||||
- **Compositor:** niri 26.04, single internal panel `eDP-1` (BOE, 2256×1504@60,
|
||||
**VRR not supported**). Config at `niri/config.kdl`.
|
||||
- **Idle:** `hypridle` blanks the panel at 5 min, locks at 10 min, suspend-then-
|
||||
hibernate at 30 min. It honors idle inhibitors (`ignore_dbus_inhibit = false`,
|
||||
`ignore_systemd_inhibit = false`). A controller/turn-based game with no kbd/mouse
|
||||
input therefore goes dark at 5 minutes.
|
||||
- **Notifications:** `mako`, currently with no config file (running on defaults) — so
|
||||
toasts pop over fullscreen games and there is no mode to suppress them.
|
||||
- **Power:** `power-profiles-daemon` (AMD pstate); active profile read/set via
|
||||
`powerprofilesctl`, waybar module refreshed with `pkill -RTMIN+8 waybar` (and
|
||||
`-RTMIN+9`). Fan via `fw-fanctrl` with a virtual `auto` mode that maps
|
||||
profile→strategy (`performance` → `agile`) when
|
||||
`$XDG_STATE_HOME/fan-profile-auto` exists.
|
||||
- **waybar:** launched directly (`spawn-at-startup "waybar"`); `waybar-restart`
|
||||
script exists.
|
||||
- **Feral gamemode:** `gamemoded` installed, **no `gamemode.ini`** → no custom hooks.
|
||||
- **Script convention:** `scripts/<name>.sh` is symlinked to `~/.local/bin/<name>`
|
||||
(no `.sh` in the bin name), e.g. `power-profile`, `fan-profile`, `screenshot`.
|
||||
|
||||
### Review findings (delivered separately, cleanup folded into this spec)
|
||||
|
||||
- 🔴 **Three VRR rules are dead code.** The panel doesn't support VRR, so all three
|
||||
`variable-refresh-rate true` rules are no-ops. The `match is-focused=true` one is
|
||||
also conceptually wrong (it targets every focused window, not games).
|
||||
- 🟠 **Idle blanks mid-game** (the 5-min issue above) — the main problem gamemode fixes.
|
||||
- 🟡 **Notifications interrupt fullscreen.**
|
||||
- 🟢 `match app-id="steam_app_"` is an unanchored regex (looser than the other
|
||||
anchored rules) — noted, left as-is.
|
||||
|
||||
## Target Behaviour
|
||||
|
||||
### Sources and reference counting
|
||||
|
||||
State lives in a runtime dir `${XDG_RUNTIME_DIR}/gamemode/`. A "source" is either
|
||||
`feral` or `manual`, represented by a marker file (`src.feral`, `src.manual`).
|
||||
Gamemode is **active iff at least one source marker exists**.
|
||||
|
||||
```
|
||||
add <source>:
|
||||
was_active = (any src.* exists)
|
||||
touch src.<source>
|
||||
if not was_active: snapshot baseline + apply effects
|
||||
|
||||
del <source>:
|
||||
rm -f src.<source>
|
||||
if no src.* remain: read baseline + revert effects, clear state dir
|
||||
|
||||
toggle: # what Mod+G calls
|
||||
if src.manual exists → del manual else → add manual
|
||||
|
||||
status: # for a future waybar module; prints active|inactive, exit 0/1
|
||||
```
|
||||
|
||||
Applying only on 0→1 and reverting only on 1→0 makes overlap safe: launch a Steam
|
||||
game (feral add) *and* hit `Mod+G` (manual add), and effects apply once; effects
|
||||
revert only when both are gone. Idempotent and crash-tolerant — stale markers are
|
||||
harmless, and baseline is re-snapshotted only when transitioning from zero sources.
|
||||
|
||||
### Baseline snapshot (captured on 0→1)
|
||||
|
||||
Written into the state dir so revert restores exactly what was there:
|
||||
|
||||
- `baseline.profile` — `powerprofilesctl get` at entry.
|
||||
- `baseline.waybar` — `1` if waybar was running at entry, else `0`.
|
||||
- `baseline.fanauto` — `1` if `$XDG_STATE_HOME/fan-profile-auto` exists at entry.
|
||||
- `idle.pid` — PID of the idle-inhibit process (written when it launches).
|
||||
|
||||
### Effects
|
||||
|
||||
| Effect | Enter | Revert |
|
||||
|---|---|---|
|
||||
| **Idle inhibit** | `systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block sleep infinity &`, record PID in `idle.pid`. hypridle honors systemd idle inhibitors, so its blank/lock/hibernate timers stop. | `kill` the recorded PID; remove `idle.pid`. |
|
||||
| **Do-not-disturb** | `makoctl mode -a do-not-disturb` (requires a `[mode=do-not-disturb]` block — see mako config below). | `makoctl mode -r do-not-disturb`. |
|
||||
| **Performance profile** | if `baseline.profile` != `performance`: `powerprofilesctl set performance`, then `pkill -RTMIN+8 waybar`. If `baseline.fanauto`=1: `fw-fanctrl use agile`. | restore `baseline.profile` (only if we changed it), `pkill -RTMIN+8 waybar`; if `baseline.fanauto`=1 remap fan to match the restored profile (`fan-profile`'s existing auto poll also reconciles within 5 s, so this is belt-and-suspenders). |
|
||||
| **Hide waybar** | if waybar running: `pkill -x waybar`. | if `baseline.waybar`=1: relaunch `waybar` detached (`setsid waybar >/dev/null 2>&1 &`). |
|
||||
|
||||
All effect steps are individually guarded (`command -v`, `2>/dev/null`) so a missing
|
||||
tool degrades to a no-op rather than aborting the rest — gamemode must never wedge a
|
||||
session. Revert always runs every restore step regardless of individual failures.
|
||||
|
||||
### Failure / edge cases
|
||||
|
||||
- **Stale markers after a crash:** next `add` sees markers, treats itself as
|
||||
already-active, and won't re-snapshot — but a manual `gamemode-session reset`
|
||||
(clears the state dir and force-reverts) is provided as an escape hatch. `toggle`
|
||||
from a stale-active state will `del manual` and, if that empties the sources,
|
||||
revert — self-healing in the common case.
|
||||
- **Idle PID already dead:** `kill` failure is ignored.
|
||||
- **profile unchanged at entry** (already `performance`): we don't touch it and don't
|
||||
restore it, so we never clobber a deliberate user choice.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. `scripts/gamemode-session.sh` (new)
|
||||
|
||||
`bash`, mirrors the house style of the other scripts. Structure:
|
||||
|
||||
- Constants: `STATE_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode"`,
|
||||
`FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"`.
|
||||
- Helpers: `is_active`, `snapshot_baseline`, `apply_effects`, `revert_effects`,
|
||||
each effect as a small function.
|
||||
- Dispatch on `$1`: `add`/`del` (with `$2` = source), `toggle`, `status`, `reset`.
|
||||
- Symlinked to `~/.local/bin/gamemode-session` (same as every other script; add the
|
||||
symlink line to `install.sh` if it enumerates scripts, else create it manually to
|
||||
match the existing ones).
|
||||
|
||||
Named `gamemode-session` (not `gamemode`) to avoid any collision with Feral's
|
||||
`gamemoderun`/`gamemoded` namespace.
|
||||
|
||||
### 2. `mako/config` (new file in dotfiles → `~/.config/mako/config`)
|
||||
|
||||
mako has no config today; DND needs a mode block. Fold in the current visible
|
||||
settings so nothing changes visually, then add the mode:
|
||||
|
||||
```ini
|
||||
background-color=#1d1f21
|
||||
text-color=#c5c8c6
|
||||
border-size=2
|
||||
border-color=#81a2be
|
||||
default-timeout=4000
|
||||
|
||||
[mode=do-not-disturb]
|
||||
invisible=1
|
||||
```
|
||||
|
||||
`invisible=1` hides new notifications while the mode is active; removing the mode
|
||||
restores normal display. (History is retained — nothing is lost, just not shown.)
|
||||
|
||||
### 3. `gamemode.ini` (new file in dotfiles → `~/.config/gamemode.ini`)
|
||||
|
||||
```ini
|
||||
[custom]
|
||||
start=/home/alex/.local/bin/gamemode-session add feral
|
||||
end=/home/alex/.local/bin/gamemode-session del feral
|
||||
```
|
||||
|
||||
Absolute path because gamemoded's exec environment is minimal. No other gamemode
|
||||
tuning (Feral's defaults already set the CPU governor).
|
||||
|
||||
### 4. `niri/config.kdl` (edited)
|
||||
|
||||
**Add** the keybind (near the other `spawn` binds):
|
||||
|
||||
```
|
||||
Mod+G { spawn "gamemode-session" "toggle"; }
|
||||
```
|
||||
|
||||
**Remove** the three dead VRR rules:
|
||||
- the `variable-refresh-rate true` line inside the `steam_app_` rule,
|
||||
- the entire `match is-focused=true { variable-refresh-rate true }` rule.
|
||||
|
||||
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.
|
||||
|
||||
## Files Touched
|
||||
|
||||
- `scripts/gamemode-session.sh` — **new**, the engine.
|
||||
- `mako/config` — **new**, adds the `do-not-disturb` mode (plus current settings).
|
||||
- `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).
|
||||
|
||||
## 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.
|
||||
|
||||
## Out of Scope (YAGNI)
|
||||
|
||||
- **gamescope wrapper / upscaling / frame limiting** — separate concern; can be
|
||||
layered into a game's launch command independently.
|
||||
- **Per-game profiles** — one uniform gamemode for now.
|
||||
- **waybar gamemode indicator** — the `status` subcommand is provided so a
|
||||
`custom/gamemode` module can be added later, but no module ships now.
|
||||
- **Cross-machine sync of gamemode state** — state is per-boot runtime only, by design.
|
||||
- **Touching VRR behaviour for external monitors** — left as a comment breadcrumb;
|
||||
no rule added until such a monitor exists.
|
||||
Reference in New Issue
Block a user