Compare commits

..

27 Commits

Author SHA1 Message Date
funman300 d0fef1478d niri-scratchpad: pull shown window to focused output; don't drop tiles
Two fixes found reviewing the paths the acceptance suite couldn't cover.

Show-while-shown-elsewhere used activate_window, which sets
active_monitor_idx and so dragged *focus* to whichever output the window
was parked on. A scratchpad should come to you: if the window is on
another monitor, remove and re-add it floating on the active workspace;
if it is already on the active monitor, focus in place as before.

add_removed_tile_floating dropped the RemovedTile on both of its early
returns (NoOutputs, monitor-not-found), destroying the window while its
client was still alive and leaving it wedged with no surface anywhere.
Both were unreachable via scratchpad_show's guards, but it is a
window-loss footgun for the next caller. It now returns
Result<(), RemovedTile<W>> so callers can stash the tile instead.

Adds two layout tests (they simulate outputs, so multi-monitor is
covered without a second physical display) and corrects the acceptance
suite's test-8 skip message, which claimed the code used active_output
— it never did; it uses active_workspace.

cargo test --lib scratchpad: 10/10. acceptance.py: 8 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:47:06 -07:00
funman300 640deb397e niri-scratchpad: stop screencasts on stash; harden acceptance spawn wait
Stashing a window now stops an active screencast of it, rather than leaving the
consumer on a frozen last frame (a stashed window is never rendered). Covers
both stash paths - Mod+M and Mod+Shift+M's hide branch - by diffing which
windows left the layout across the action, so scratchpad_show's branch logic
isn't duplicated in the caller. Dynamic-target casts are retargeted to Nothing
rather than ended; that carve-out is documented inline.

Also widen the acceptance harness spawn wait to 30s: each test launches its own
nested niri, and a cold alacritty start occasionally exceeded the old 10s.
Deliberately not a retry, which could double-spawn and corrupt window counts.

117 unit tests and 8/8 live acceptance pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:04:14 -07:00
funman300 f557ffdfc7 niri-scratchpad: refresh patch with cast-leak and unmap-path fixes
Review of the destroy-path fix found two Important defects, both now closed:
a stashed window whose client died never stopped its screencast (leaking a
PipeWire stream/portal session nothing could reclaim), and the unmap path had
the identical hole (a stashed window that unmapped was never reinstated into
unmapped_windows, wedging it permanently on re-map). Also corrects a factually
wrong hook-teardown comment. 117 unit tests and 8/8 live acceptance pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:46:02 -07:00
funman300 a9ce3a13cf niri-scratchpad: refresh patch with multi-monitor tests
Adds two synthetic multi-output tests (niri's layout harness supports several
outputs) proving scratchpad_show targets the FOCUSED output, plus a control
that it returns to the origin output when focus is unchanged. This is the
substitute for manual multi-monitor verification on a single-display machine.
117 layout tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:12:55 -07:00
funman300 0e0b5fcbe3 niri-scratchpad: fix close-while-stashed eviction + add live acceptance test
Refresh the patch with the destroy-path fix (a client dying while stashed is
now evicted instead of leaked/zombied), and add pkg/niri-scratchpad/tests/
acceptance.py: a headless IPC-driven regression test that drives a nested
patched niri and asserts on `niri msg -j windows`. It found the above bug
(unit tests miss it — the harness calls remove_window directly). Run it after
rebasing the patch onto a new niri release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:02:35 -07:00
funman300 d05b9f8a0e niri: native scratchpad via patched niri-scratchpad package
Replace the workspace-based minimize (declared 'minimized' workspace +
+1 offset + window-restore.py) with a native Sway-style scratchpad built
into niri. Ships as niri-patches/0001-scratchpad.patch + a niri-scratchpad
PKGBUILD. Mod+M stashes; Mod+Shift+M shows/toggles/cycles. Mod+1..9 no
longer offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:24:11 -07:00
funman300 07791f0c23 plan: native niri scratchpad implementation
Seven-task TDD plan: stash fields + move_to_scratchpad, show/cycle state
machine, close-eviction hook, action wiring, patch+PKGBUILD packaging, and
nested-niri manual acceptance. Grounded in niri 26.04 source anchors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:22:27 -07:00
funman300 286bb8a3b3 spec: native niri scratchpad (replaces workspace-based minimize)
Design for a Sway-style scratchpad built into niri as a pinned patch set +
niri-scratchpad PKGBUILD. Stores hidden windows in a global stash on Layout
rather than a named workspace, removing the +1 offset and multi-monitor bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:09:52 -07:00
funman300 b8a858d7a7 niri: working minimize via declared stash workspace (#1)
Mod+M/Mod+Shift+M referenced an undeclared 'minimized' workspace and were silent no-ops. Declare the stash, add focus=false, and add the window-restore wofi picker.
2026-07-16 17:19:57 +00:00
funman300 05cc026d75 fix(window-restore): re-read workspace/window state after the wofi pick
The picker blocks on user input for however long they take. In that
window niri can destroy a dynamic workspace (its last window closed)
and renumber every later one down, so the pre-pick current["idx"] can
point at the wrong workspace by the time move-window-to-workspace
runs — restoring to the wrong place. The stashed window itself can
also have closed in the meantime, in which case the move/focus calls
would silently no-op. main() now re-reads workspaces/windows after
pick() returns, recomputes the current workspace, and confirms the
chosen window still exists before acting; each bails with a notify
and the documented exit code otherwise. Adds window_exists() as a
pure, tested helper alongside the existing pure functions.

Also: clarify the focus=false comment in niri/config.kdl (it read as
if focus=false caused the problem it actually prevents), and note in
the design doc's Out of scope section that the stash's index-1
pinning is per-output, so a second monitor would need its own
analysis of the Mod+1..9 offset.
2026-07-16 10:10:40 -07:00
funman300 41f0ee88ff niri: working minimize via declared stash workspace
Mod+M referenced an undeclared 'minimized' workspace, so it silently did
nothing. Declare it, add focus=false so focus does not follow the window
into the stash, and point Mod+Shift+M at the restore picker.

niri pins declared named workspaces to index 1 and will not move them, so
Mod+1..9 are offset by one.
2026-07-16 10:03:12 -07:00
funman300 5979eb229f window-restore: wofi picker to restore minimized windows
Pure logic split from the niri/wofi calls so it unit-tests without a
compositor. Restores to the current workspace, so no origin tracking.
2026-07-16 09:58:25 -07:00
funman300 9ae1d88937 plan: niri minimize/stash implementation
Two tasks: window-restore.py + unit tests, then the niri config declaration,
bind rewiring and install.sh symlink. Script precedes config so Mod+Shift+M is
never wired to a missing script.

Plan code verified by extracting both blocks and running the test suite against
the script: 18/18 pass, and the step-2 failure mode is confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:19:49 -07:00
funman300 1bdc4b5165 spec: niri minimize/stash design
Mod+M and Mod+Shift+M reference a "minimized" named workspace that was never
declared, so both binds have always been silent no-ops. Design covers the
declaration plus a wofi restore picker.

Constraints verified against niri 26.04 in a nested instance: undeclared named
workspaces are no-ops, declared ones are pinned to index 1 and cannot be moved
(hence the +1 offset on Mod+1..9), and focus=false is required or focus follows
the window into the stash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:15:02 -07:00
funman300 ff2ffe47b6 niri: arrow-key variants for focus and move binds
Only the H/J/K/L letters were bound, so Mod+Shift+Left and friends did
nothing. Add arrow equivalents next to the existing letter binds for both
focusing and moving; the letter binds are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 08:57:56 -07:00
funman300 2e168053c6 volume: keep the OSD visible in gamemode and fullscreen
Two effects were hiding the volume slider:

- gamemode-session turns on mako's do-not-disturb mode, whose invisible=1
  suppressed every notification, the OSD included.
- niri draws fullscreen windows above the top layer, mako's default, so the
  OSD rendered underneath fullscreen windows even outside gamemode.

Tag the OSD with its own app name so mako can match it, then exempt just that
name: invisible=0 to survive DND, layer=overlay to clear fullscreen. The block
is listed after [mode=do-not-disturb] because later criteria win in mako.

Verified with DND active and a fullscreen window: the OSD shows, while another
app's notification under the same conditions stays suppressed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 08:48:41 -07:00
funman300 3d08abb060 gamemode-watch: single-instance flock guard
Prevents a manually-launched watcher and the spawn-at-startup one from
racing the same winwatch source; the second instance exits immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:54:17 -07:00
funman300 0ab08e8561 gamemode: global auto-trigger via niri window watch
Add scripts/gamemode-watch.py, launched at niri startup, which subscribes
to niri's event stream and toggles gamemode whenever a game window
(steam_app_*, RuneLite, gamescope) is open — so Steam games trigger
gamemode with zero per-game setup instead of needing gamemoderun %command%.

'winwatch' is a third reference-counted source alongside feral + manual.
Pure Watcher class is unit-tested; SIGTERM/finally guarantee cleanup so
the watcher stopping never leaves gamemode stuck on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:51:52 -07:00
funman300 98f6c56fe3 install.sh: link gamemode-session, gamemode.ini, and mako/config
Adds the new gamemode wiring to the installer; also links mako/config,
which was never linked before (it used to be gitignored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:39:58 -07:00
funman300 1fd160f19d niri: Mod+G gamemode toggle; drop dead VRR rules
The eDP-1 panel has no VRR, so the three variable-refresh-rate rules
(including the incorrect is-focused=true one) were no-ops. Removed;
left a breadcrumb comment for a future external VRR monitor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:38:13 -07:00
funman300 259de11868 gamemode.ini: Feral custom hooks drive gamemode-session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:37:40 -07:00
funman300 2c9cff6bb9 gamemode-session: discard effect-command stdout
makoctl/fw-fanctrl echo confirmations to stdout that leaked into add/del
output during integration. run() now discards stdout, keeps stderr.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:37:40 -07:00
funman300 77b2866e3d mako: track config, add do-not-disturb mode for gamemode
Un-ignore mako/config and bring it under dotfiles so the gamemode
do-not-disturb mode syncs across machines. [mode=do-not-disturb]
invisible=1 suppresses popups while retaining notification history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:35:01 -07:00
funman300 4b1773ac4e gamemode-session: real effects (idle, DND, perf+fan, waybar)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:32:32 -07:00
funman300 05707afc04 gamemode-session: reference-counted state machine core
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:31:52 -07:00
funman300 e0c12ff0c9 Plan: niri gamemode implementation (6 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:29:44 -07:00
funman300 5b24b21610 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>
2026-07-15 21:23:34 -07:00
20 changed files with 4113 additions and 13 deletions
-1
View File
@@ -1,3 +1,2 @@
nohup.out
mako/config
__pycache__/
@@ -0,0 +1,563 @@
# Niri Gamemode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A compositor-level gamemode for niri that inhibits idle, silences notifications, switches to the performance power profile (and bumps the fan), and hides waybar while a game runs — reverting cleanly when it ends — driven by both Feral gamemode hooks and a `Mod+G` toggle.
**Architecture:** A single reference-counted bash script, `scripts/gamemode-session.sh`, tracks two "sources" (`feral`, `manual`) via marker files in a runtime state dir. It applies effects only on the first active source (0→1) and reverts only on the last (1→0), snapshotting the pre-game baseline so revert restores exactly what was there. Feral's `gamemode.ini` custom hooks call `add feral`/`del feral`; a niri keybind calls `toggle` (flips `manual`).
**Tech Stack:** bash, `systemd-inhibit`, `makoctl`, `powerprofilesctl`, `fw-fanctrl`, niri KDL config, Feral gamemode (`gamemoded`).
## Global Constraints
- Script naming: `scripts/<name>.sh` symlinked to `~/.local/bin/<name>` (no `.sh` in the bin name). New script is `scripts/gamemode-session.sh``~/.local/bin/gamemode-session`. Name is `gamemode-session`, NOT `gamemode` (avoids Feral's `gamemoderun`/`gamemoded` namespace).
- Script must be crash-tolerant and never wedge the session: every effect step is individually guarded; a missing tool or a failed sub-step degrades to a no-op, and revert always runs every restore step.
- Effects apply ONLY on 0→1 active sources and revert ONLY on 1→0. Never re-snapshot baseline while already active.
- Power profile: only change it if the baseline profile differs from `performance`; on revert, only restore if we changed it (never clobber a deliberate `performance` choice).
- All new dotfiles are tracked in `~/Documents/dotfiles` (this repo) and deployed to their live locations by symlink/copy exactly as existing configs are.
- Commit after each task. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- Verified environment facts: `makoctl mode -a/-r <mode>` and `systemd-inhibit --what=idle` are available; hypridle already honors systemd idle inhibitors (`ignore_systemd_inhibit = false`); `setsid`, `fw-fanctrl`, `powerprofilesctl`, `pgrep`, `pkill` all present.
---
### Task 1: Reference-counted state machine (`gamemode-session.sh` core)
Build the script's dispatch + source refcounting + baseline snapshot, with effects behind a `GAMEMODE_DRYRUN` seam so the state machine is testable without touching the live session. Effect *bodies* are stubbed in this task (dryrun-logged placeholders); Task 2 fills in the real commands.
**Files:**
- Create: `scripts/gamemode-session.sh`
- Test: `scripts/tests/gamemode-session.test.sh` (new throwaway test harness, committed alongside)
**Interfaces:**
- Produces: CLI `gamemode-session {add <source>|del <source>|toggle|status|reset}`.
- `add <src>`: touch `src.<src>` marker; on 0→1 snapshot baseline + `apply_effects`.
- `del <src>`: remove `src.<src>`; on 1→0 `revert_effects` + clear baseline/idle files.
- `toggle`: flip the `manual` source.
- `status`: `active` (exit 0) / `inactive` (exit 1).
- `reset`: force-revert + wipe state dir.
- Env seams (consumed by the test): `GAMEMODE_STATE_DIR` overrides the state dir; `GAMEMODE_DRYRUN=1` makes `run()` and effect functions log instead of act.
- Produces for later tasks: `apply_effects`/`revert_effects` and per-effect functions `effect_idle_on/off`, `effect_dnd_on/off`, `effect_perf_on/off`, `effect_waybar_on/off` (Task 2 replaces their bodies).
- [ ] **Step 1: Write the failing test harness**
Create `scripts/tests/gamemode-session.test.sh`:
```bash
#!/bin/bash
# Drives gamemode-session in dryrun against a temp state dir and asserts the
# reference-counting state machine. No live effects run.
set -u
HERE=$(cd "$(dirname "$0")/.." && pwd)
SCRIPT="$HERE/gamemode-session.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export GAMEMODE_STATE_DIR="$TMP/state"
export GAMEMODE_DRYRUN=1
fail=0
check() { # desc, actual, expected
if [ "$2" = "$3" ]; then printf 'ok - %s\n' "$1"
else printf 'FAIL - %s (got [%s] want [%s])\n' "$1" "$2" "$3"; fail=1; fi
}
active() { "$SCRIPT" status >/dev/null 2>&1 && echo active || echo inactive; }
check "starts inactive" "$(active)" "inactive"
"$SCRIPT" add feral >/dev/null 2>&1
check "feral activates" "$(active)" "active"
check "src.feral marker set" "$([ -e "$GAMEMODE_STATE_DIR/src.feral" ] && echo y)" "y"
check "baseline captured" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y)" "y"
"$SCRIPT" add manual >/dev/null 2>&1
check "manual keeps active" "$(active)" "active"
"$SCRIPT" del feral >/dev/null 2>&1
check "still active on 1 left" "$(active)" "active"
"$SCRIPT" del manual >/dev/null 2>&1
check "last del deactivates" "$(active)" "inactive"
check "baseline cleared" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y || echo n)" "n"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle on activates" "$(active)" "active"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle off deactivates" "$(active)" "inactive"
"$SCRIPT" reset >/dev/null 2>&1
check "reset leaves inactive" "$(active)" "inactive"
exit $fail
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `bash scripts/tests/gamemode-session.test.sh`
Expected: FAIL / non-zero (script `gamemode-session.sh` does not exist yet — `bash: .../gamemode-session.sh: No such file`).
- [ ] **Step 3: Write the core script**
Create `scripts/gamemode-session.sh`:
```bash
#!/bin/bash
# gamemode-session — compositor-level gamemode for niri.
# Reference-counted over two sources (feral, manual). Applies effects on the
# first active source (0->1) and reverts on the last (1->0).
# Spec: docs/superpowers/specs/2026-07-15-niri-gamemode-design.md
set -u
STATE_DIR="${GAMEMODE_STATE_DIR:-${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode}"
FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
DRYRUN="${GAMEMODE_DRYRUN:-0}"
log() { printf 'gamemode: %s\n' "$*" >&2; }
run() { if [ "$DRYRUN" = 1 ]; then log "would: $*"; else "$@"; fi; }
is_active() {
[ -n "$(find "$STATE_DIR" -maxdepth 1 -name 'src.*' -print -quit 2>/dev/null)" ]
}
snapshot_baseline() {
mkdir -p "$STATE_DIR"
powerprofilesctl get 2>/dev/null > "$STATE_DIR/baseline.profile" || : > "$STATE_DIR/baseline.profile"
if pgrep -x waybar >/dev/null 2>&1; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.waybar"
if [ -f "$FAN_AUTO" ]; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.fanauto"
}
# --- effects (bodies filled in Task 2; dryrun-safe stubs for now) -------------
effect_idle_on() { run : "idle inhibit on"; }
effect_idle_off() { run : "idle inhibit off"; }
effect_dnd_on() { run : "dnd on"; }
effect_dnd_off() { run : "dnd off"; }
effect_perf_on() { run : "perf on"; }
effect_perf_off() { run : "perf off"; }
effect_waybar_on() { run : "waybar hide"; }
effect_waybar_off(){ run : "waybar restore"; }
apply_effects() { effect_idle_on; effect_dnd_on; effect_perf_on; effect_waybar_on; }
revert_effects() { effect_idle_off; effect_dnd_off; effect_perf_off; effect_waybar_off; }
# --- state machine ------------------------------------------------------------
cmd_add() {
local src="$1" was=0
is_active && was=1
mkdir -p "$STATE_DIR"
touch "$STATE_DIR/src.$src"
if [ "$was" = 0 ]; then
snapshot_baseline
apply_effects
log "activated (source: $src)"
else
log "already active; +source: $src"
fi
}
cmd_del() {
local src="$1"
rm -f "$STATE_DIR/src.$src"
if ! is_active; then
revert_effects
rm -f "$STATE_DIR"/baseline.* "$STATE_DIR/idle.pid"
log "deactivated (last source: $src)"
else
log "still active; -source: $src"
fi
}
cmd_toggle() {
if [ -e "$STATE_DIR/src.manual" ]; then cmd_del manual; else cmd_add manual; fi
}
cmd_status() { if is_active; then echo active; exit 0; else echo inactive; exit 1; fi; }
cmd_reset() {
revert_effects 2>/dev/null || true
rm -rf "$STATE_DIR"
log "reset"
}
case "${1:-}" in
add) [ -n "${2:-}" ] || { log "add needs a source"; exit 2; }; cmd_add "$2" ;;
del) [ -n "${2:-}" ] || { log "del needs a source"; exit 2; }; cmd_del "$2" ;;
toggle) cmd_toggle ;;
status) cmd_status ;;
reset) cmd_reset ;;
*) log "usage: gamemode-session {add|del <source>|toggle|status|reset}"; exit 2 ;;
esac
```
Make it executable: `chmod +x scripts/gamemode-session.sh`.
- [ ] **Step 4: Run the test to verify it passes + shellcheck**
Run: `bash -n scripts/gamemode-session.sh && shellcheck -x scripts/gamemode-session.sh; bash scripts/tests/gamemode-session.test.sh`
Expected: `bash -n` silent; shellcheck clean (or only style-level SC2086-type notes — fix any warnings); all `ok -` lines, exit 0.
- [ ] **Step 5: Commit**
```bash
cd ~/Documents/dotfiles
git add scripts/gamemode-session.sh scripts/tests/gamemode-session.test.sh
git commit -m "gamemode-session: reference-counted state machine core
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Real effect bodies (idle / DND / power+fan / waybar)
Replace the eight stubbed effect functions with the real commands. The Task 1 state-machine test still passes unchanged (it runs in dryrun, so effect bodies only log).
**Files:**
- Modify: `scripts/gamemode-session.sh` (the eight `effect_*` functions)
**Interfaces:**
- Consumes: `STATE_DIR`, `FAN_AUTO`, `DRYRUN`, `run()`, `log()`, and baseline files `baseline.profile`/`baseline.waybar`/`baseline.fanauto` written by `snapshot_baseline`.
- Produces: `idle.pid` file holding the systemd-inhibit PID while active.
- [ ] **Step 1: Replace the effect stubs**
In `scripts/gamemode-session.sh`, replace the entire `# --- effects` stub block with:
```bash
# --- effects ------------------------------------------------------------------
effect_idle_on() {
if [ "$DRYRUN" = 1 ]; then log "would: systemd-inhibit idle"; return; fi
command -v systemd-inhibit >/dev/null 2>&1 || { log "no systemd-inhibit"; return; }
systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block \
sleep infinity >/dev/null 2>&1 &
echo $! > "$STATE_DIR/idle.pid"
}
effect_idle_off() {
local pid; pid=$(cat "$STATE_DIR/idle.pid" 2>/dev/null || echo "")
[ -n "$pid" ] && run kill "$pid"
rm -f "$STATE_DIR/idle.pid"
}
effect_dnd_on() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -a do-not-disturb; }
effect_dnd_off() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -r do-not-disturb; }
# maps a power profile to the fw-fanctrl strategy (mirrors fan-profile.sh)
fan_for() { case "$1" in power-saver) echo lazy;; balanced) echo medium;; performance) echo agile;; *) return 1;; esac; }
effect_perf_on() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set performance
run pkill -RTMIN+8 waybar
fi
if [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
run fw-fanctrl use agile
fi
}
effect_perf_off() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set "$base"
run pkill -RTMIN+8 waybar
fi
if [ -n "$base" ] && [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
local strat; strat=$(fan_for "$base") && run fw-fanctrl use "$strat"
fi
}
effect_waybar_on() { # hide
pgrep -x waybar >/dev/null 2>&1 && run pkill -x waybar
}
effect_waybar_off() { # restore if it was running at entry
[ "$(cat "$STATE_DIR/baseline.waybar" 2>/dev/null)" = 1 ] || return 0
if [ "$DRYRUN" = 1 ]; then log "would: relaunch waybar"; else setsid waybar >/dev/null 2>&1 & fi
}
```
- [ ] **Step 2: Re-run the state-machine test (must still pass in dryrun)**
Run: `bash -n scripts/gamemode-session.sh && shellcheck -x scripts/gamemode-session.sh; bash scripts/tests/gamemode-session.test.sh`
Expected: shellcheck clean; all `ok -`, exit 0. (Effects only log under dryrun, so the state machine is unaffected.)
- [ ] **Step 3: Dryrun effect-ordering smoke (visual check of the "would:" log)**
Run: `GAMEMODE_STATE_DIR=$(mktemp -d) GAMEMODE_DRYRUN=1 bash scripts/gamemode-session.sh add feral 2>&1`
Expected output includes, in order: `would: systemd-inhibit idle`, `would: makoctl mode -a do-not-disturb`, a `would: powerprofilesctl set performance` (only if your current profile isn't already performance) and/or `would: fw-fanctrl use agile`, `would: pkill -x waybar`, then `activated (source: feral)`.
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add scripts/gamemode-session.sh
git commit -m "gamemode-session: real effects (idle, DND, perf+fan, waybar)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: mako do-not-disturb mode config
mako currently runs on defaults with no config file. Add a tracked config that folds in the current visible settings and adds the `do-not-disturb` mode so `effect_dnd_*` actually suppresses toasts.
**Files:**
- Create: `mako/config` (dotfiles) → deployed to `~/.config/mako/config`
**Interfaces:**
- Consumes: `makoctl mode -a/-r do-not-disturb` from Task 2.
- Produces: a `[mode=do-not-disturb]` block with `invisible=1`.
- [ ] **Step 1: Write the mako config**
Create `mako/config`:
```ini
background-color=#1d1f21
text-color=#c5c8c6
border-size=2
border-color=#81a2be
default-timeout=4000
[mode=do-not-disturb]
invisible=1
```
- [ ] **Step 2: Deploy + reload mako**
```bash
mkdir -p ~/.config/mako
ln -sf ~/Documents/dotfiles/mako/config ~/.config/mako/config # match existing dotfile symlink style
makoctl reload
```
- [ ] **Step 3: Verify DND suppresses, then restores**
Run:
```bash
makoctl mode -a do-not-disturb; makoctl mode # expect list to include: do-not-disturb
notify-send "gamemode test" "should NOT appear" # no toast while DND active
makoctl mode -r do-not-disturb; makoctl mode # expect: default only
notify-send "gamemode test" "SHOULD appear" # toast appears
```
Expected: after `-a`, `makoctl mode` lists `do-not-disturb` and the first notification does not show; after `-r`, list is back to `default` and the second notification shows. (If `notify-send` is missing, install `libnotify` or skip — the mode-list assertion is the load-bearing check.)
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add mako/config
git commit -m "mako: config with do-not-disturb mode for gamemode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Feral gamemode hooks (`gamemode.ini`)
Wire Feral `gamemoded`'s custom start/end hooks to the script so gamemode-aware games trigger it automatically.
**Files:**
- Create: `gamemode.ini` (dotfiles) → deployed to `~/.config/gamemode.ini`
**Interfaces:**
- Consumes: `gamemode-session add feral` / `del feral` from Tasks 12.
- [ ] **Step 1: Write `gamemode.ini`**
Create `gamemode.ini`:
```ini
[custom]
start=/home/alex/.local/bin/gamemode-session add feral
end=/home/alex/.local/bin/gamemode-session del feral
```
- [ ] **Step 2: Deploy**
```bash
ln -sf ~/Documents/dotfiles/gamemode.ini ~/.config/gamemode.ini
```
- [ ] **Step 3: Verify Feral reads the hooks**
Run: `gamemoderun gamemode-session status; echo "exit=$?"`
Expected: while `gamemoderun` holds the game session, the custom `start` hook has fired `add feral`, so `gamemode-session status` prints `active` (exit 0). When `gamemoderun` exits it fires `end``del feral`. Immediately after, `gamemode-session status` prints `inactive` (exit 1). (If it stays active, run `gamemode-session reset` and check `journalctl --user -u gamemoded` / the `gamemode.ini` path.)
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add gamemode.ini
git commit -m "gamemode.ini: Feral custom hooks drive gamemode-session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: niri config — keybind + remove dead VRR rules
Add the `Mod+G` toggle and remove the three no-op VRR rules the review found (panel has no VRR).
**Files:**
- Modify: `niri/config.kdl`
**Interfaces:**
- Consumes: `gamemode-session toggle` from Tasks 12.
- [ ] **Step 1: Add the keybind**
In `niri/config.kdl`, inside the `binds { ... }` block, near the other `spawn` binds (after the `Mod+Shift+B` line), add:
```
Mod+G { spawn "gamemode-session" "toggle"; }
```
- [ ] **Step 2: Remove the dead VRR rules**
In `niri/config.kdl`:
a) In the `steam_app_` window-rule, delete the line `variable-refresh-rate true` and change the comment to note the VRR removal. The rule becomes:
```
// Fullscreen games: no border, open fullscreen.
// (Re-add `variable-refresh-rate true` here if a VRR-capable external
// monitor is ever attached — the internal eDP-1 panel has no VRR.)
window-rule {
match app-id="steam_app_"
open-fullscreen true
border {
off
}
focus-ring {
off
}
}
```
b) Delete this entire rule (it wrongly targets every focused window and is a no-op here):
```
// Generic fullscreen request (any app asking to go fullscreen)
window-rule {
match is-focused=true
variable-refresh-rate true
}
```
- [ ] **Step 3: Validate niri config**
Run: `niri validate -c ~/.config/niri/config.kdl`
Expected: `Config is valid` (or niri's success message). Fix any parse error before continuing.
- [ ] **Step 4: Verify the bind live (optional if in a niri session)**
Run: `niri msg action do-nothing 2>/dev/null; grep -n "Mod+G" ~/.config/niri/config.kdl`
Expected: the `Mod+G` line is present. After niri reloads the config (niri hot-reloads on save), pressing `Mod+G` toggles gamemode; confirm with `gamemode-session status`.
- [ ] **Step 5: Commit**
```bash
cd ~/Documents/dotfiles
git add niri/config.kdl
git commit -m "niri: Mod+G gamemode toggle; drop dead VRR rules
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Wiring + live end-to-end smoke
Create the `~/.local/bin` symlink for the script (matching every other script), update `install.sh` if it enumerates them, then run one real activate/deactivate cycle and assert observable effects revert.
**Files:**
- Create: symlink `~/.local/bin/gamemode-session``~/Documents/dotfiles/scripts/gamemode-session.sh`
- Modify: `install.sh` (only if it lists script/config symlinks; otherwise no change)
**Interfaces:**
- Consumes: everything from Tasks 15.
- [ ] **Step 1: Create the bin symlink**
```bash
ln -sf ~/Documents/dotfiles/scripts/gamemode-session.sh ~/.local/bin/gamemode-session
gamemode-session status; echo "exit=$?" # expect: inactive / exit=1
```
- [ ] **Step 2: Wire install.sh if applicable**
Inspect `install.sh` for where existing scripts/configs get symlinked (e.g. a loop over `scripts/*.sh`, or explicit `ln -sf` lines). If found, add `gamemode-session`, `~/.config/gamemode.ini`, and `~/.config/mako/config` to the same mechanism. If `install.sh` does NOT enumerate these (the existing bin symlinks appear to be created manually), skip this step and note it in the commit.
Run: `grep -nE "ln -s|scripts/|\.local/bin|\.config" install.sh | head`
Expected: shows the deploy mechanism (or nothing → manual, skip).
- [ ] **Step 3: Live end-to-end — capture baseline**
```bash
echo "profile: $(powerprofilesctl get)"
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down"
makoctl mode
```
Record these; the cycle must return to exactly this state.
- [ ] **Step 4: Activate and assert effects applied**
```bash
gamemode-session add manual
sleep 1
gamemode-session status; echo "status_exit=$?" # active / 0
systemd-inhibit --list | grep -q gamemode && echo "idle: inhibited" # idle: inhibited
makoctl mode | grep -q do-not-disturb && echo "dnd: on" # dnd: on
echo "profile now: $(powerprofilesctl get)" # performance (unless baseline already performance)
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down" # down
```
Expected: status active; idle inhibited (a `gamemode` entry in the list); DND on; profile is `performance`; waybar down.
- [ ] **Step 5: Deactivate and assert full revert**
```bash
gamemode-session del manual
sleep 2
gamemode-session status; echo "status_exit=$?" # inactive / 1
systemd-inhibit --list | grep -q gamemode && echo "idle: STILL inhibited (BUG)" || echo "idle: released"
makoctl mode | grep -q do-not-disturb && echo "dnd: STILL on (BUG)" || echo "dnd: off"
echo "profile now: $(powerprofilesctl get)" # back to Step 3 value
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down" # up (matches Step 3)
```
Expected: status inactive; idle released; DND off; profile matches the Step 3 baseline; waybar back up. If any line reports a BUG or a mismatch, run `gamemode-session reset` to force-clean, then debug the corresponding `effect_*_off` before proceeding.
- [ ] **Step 6: Manual verification note (idle timeout)**
The 5-minute blank-suppression can't be asserted in seconds. Manually verify once: activate gamemode, leave the machine idle past 5 minutes (no input), confirm the screen does NOT blank; then `del`/toggle off and confirm hypridle resumes (screen blanks on the next idle window). Record the result. If it DOES blank while active, hypridle isn't honoring the systemd idle inhibitor on this build — fall back to a Wayland idle-inhibitor helper (documented follow-up), but the systemd path matches the current hypridle config intent.
- [ ] **Step 7: Commit**
```bash
cd ~/Documents/dotfiles
git add -A install.sh # only if changed; otherwise omit
git commit -m "gamemode: wire bin symlink + live smoke verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" # allow empty note if only the symlink (untracked) changed
```
(The `~/.local/bin/gamemode-session` symlink lives outside the repo — like the other script symlinks — so it isn't committed; note that it was created.)
---
## Self-Review
**Spec coverage:**
- Reference-counted two-source state machine → Task 1. ✓
- Idle inhibit / DND / performance+fan / hide waybar effects, each with exact-baseline revert → Task 2. ✓
- Baseline snapshot (profile, waybar, fanauto, idle.pid) → Task 1 (`snapshot_baseline`) + Task 2 (`idle.pid`). ✓
- "only change profile if baseline != performance; only restore if changed" → Task 2 `effect_perf_on/off`. ✓
- mako `[mode=do-not-disturb]` config → Task 3. ✓
- `gamemode.ini` Feral hooks → Task 4. ✓
- niri `Mod+G` bind + removal of 3 dead VRR rules (incl. `is-focused=true`) + breadcrumb comment → Task 5. ✓
- `reset` escape hatch → Task 1 dispatch + used in Task 6 debugging. ✓
- `status` subcommand for future waybar module → Task 1. ✓
- Script naming `gamemode-session`, `scripts/<name>.sh``~/.local/bin/<name>` convention → Global Constraints + Task 6. ✓
- Steam `gamemoderun %command%` usage → user-side (documented in spec Usage; not a code task), reflected in Task 4 verification via `gamemoderun`. ✓
**Placeholder scan:** No TBD/TODO; every code step shows full content; no "add error handling" hand-waves (guards are written out). ✓
**Type/name consistency:** `effect_idle_on/off`, `effect_dnd_on/off`, `effect_perf_on/off`, `effect_waybar_on/off`, `apply_effects`, `revert_effects`, `snapshot_baseline`, `is_active`, `fan_for`, and the env seams `GAMEMODE_STATE_DIR`/`GAMEMODE_DRYRUN` are named identically across Tasks 1, 2, and 6. Baseline filenames `baseline.profile`/`baseline.waybar`/`baseline.fanauto` and `idle.pid`/`src.<source>` are consistent throughout. ✓
@@ -0,0 +1,452 @@
# Niri Minimize / Stash Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `Mod+M` stashes the focused window on a declared `minimized` workspace; `Mod+Shift+M` opens a wofi picker that restores any stashed window to the workspace you are currently on.
**Architecture:** The stash is a declared named workspace — declaring it is what makes the two existing (currently no-op) binds start working. Restore is a new Python script driven by niri's IPC, with pure logic split from the subprocess calls so it is testable without a compositor.
**Tech Stack:** niri 26.04 KDL config, Python 3 (stdlib only), wofi, `niri msg -j` IPC, libnotify.
**Spec:** `docs/superpowers/specs/2026-07-16-niri-minimize-design.md`
## Global Constraints
- **The stash workspace name is exactly `minimized`** — it must match between `niri/config.kdl` and the `STASH` constant in `scripts/window-restore.py`.
- **No `jq` on this system.** Parse `niri msg -j` JSON with Python 3 stdlib only. Do not add dependencies.
- **Script convention:** `scripts/<name>.py` is symlinked to `~/.local/bin/<name>` (no extension in the bin name) by `install.sh`.
- **Test convention:** plain `python3` scripts under `scripts/tests/`, no pytest. Load the hyphenated module via `importlib`, assert with a local `check(desc, got, want)` helper, `sys.exit(fails)`. Mirror `scripts/tests/gamemode-watch.test.py`.
- **Style:** pure logic as module-level functions, subprocess calls confined to `main()` and thin wrappers — mirror `scripts/gamemode-watch.py`.
- **Task order matters:** Task 1 (script) precedes Task 2 (config). Wiring `Mod+Shift+M` to a script that does not exist yet would leave the bind broken between commits.
---
### Task 1: `window-restore` script and its unit tests
**Files:**
- Create: `scripts/window-restore.py`
- Test: `scripts/tests/window-restore.test.py`
**Interfaces:**
- Produces, for Task 2: the executable `window-restore` (via symlink), invoked with no arguments by `Mod+Shift+M { spawn "window-restore"; }`. Exits 0 on success, on an empty stash, and on a cancelled picker; exits 1 when niri IPC is unavailable or the `minimized` workspace is not declared.
- Pure functions (used only by the test): `stash_workspace(workspaces)`, `current_workspace(workspaces)`, `stashed_windows(windows, stash_id)`, `label(window)`, `resolve(windows, chosen)`, `pick(labels, chooser)`.
- [ ] **Step 1: Write the failing test**
Create `scripts/tests/window-restore.test.py`:
```python
#!/usr/bin/env python3
"""Unit tests for window-restore's pure logic (no niri, no wofi, no subprocess).
Loads the hyphenated script by path and drives the pure functions with the JSON
shapes `niri msg -j workspaces` / `windows` actually return."""
import importlib.util
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_PATH = os.path.join(HERE, "..", "window-restore.py")
spec = importlib.util.spec_from_file_location("window_restore", MOD_PATH)
wr = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wr)
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
# Shapes mirror real `niri msg -j` output: the stash is always idx 1.
WS_WITH_STASH = [
{"id": 1, "idx": 1, "name": "minimized", "is_focused": False},
{"id": 2, "idx": 2, "name": None, "is_focused": True},
{"id": 3, "idx": 3, "name": None, "is_focused": False},
]
WS_NO_STASH = [
{"id": 2, "idx": 1, "name": None, "is_focused": True},
{"id": 3, "idx": 2, "name": None, "is_focused": False},
]
WS_ON_STASH = [
{"id": 1, "idx": 1, "name": "minimized", "is_focused": True},
{"id": 2, "idx": 2, "name": None, "is_focused": False},
]
WINDOWS = [
{"id": 10, "title": "Balatro", "app_id": "steam_app_2379780", "workspace_id": 1},
{"id": 11, "title": "Pi-hole", "app_id": "firefox", "workspace_id": 1},
{"id": 12, "title": "notes", "app_id": "Alacritty", "workspace_id": 2},
]
# --- stash_workspace / current_workspace -----------------------------------
check("stash found when declared", wr.stash_workspace(WS_WITH_STASH)["id"], 1)
check("stash is None when undeclared", wr.stash_workspace(WS_NO_STASH), None)
check("stash is None for empty list", wr.stash_workspace([]), None)
check("current workspace is the focused one", wr.current_workspace(WS_WITH_STASH)["id"], 2)
check("current is None when none focused", wr.current_workspace([]), None)
check("focused-on-stash is detectable",
wr.current_workspace(WS_ON_STASH)["id"] == wr.stash_workspace(WS_ON_STASH)["id"], True)
# --- stashed_windows -------------------------------------------------------
check("only windows on the stash", [w["id"] for w in wr.stashed_windows(WINDOWS, 1)], [10, 11])
check("empty stash yields nothing", wr.stashed_windows(WINDOWS, 99), [])
# --- label -----------------------------------------------------------------
check("label joins app_id and title", wr.label(WINDOWS[1]), "firefox — Pi-hole")
check("label falls back to title when app_id missing",
wr.label({"id": 5, "app_id": None, "title": "Bare Title"}), "Bare Title")
check("label falls back to app_id when title empty",
wr.label({"id": 5, "app_id": "firefox", "title": ""}), "firefox")
check("label never renders empty",
wr.label({"id": 5, "app_id": None, "title": None}), "window 5")
# --- resolve ---------------------------------------------------------------
check("resolve maps a label back to its window",
wr.resolve(WINDOWS, "firefox — Pi-hole")["id"], 11)
check("resolve returns None for no match", wr.resolve(WINDOWS, "nope"), None)
DUPES = [
{"id": 20, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
{"id": 21, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
]
check("duplicate labels resolve to the first", wr.resolve(DUPES, "Alacritty — same")["id"], 20)
# --- pick ------------------------------------------------------------------
check("pick returns the chooser's selection",
wr.pick(["a", "b"], lambda text: "b"), "b")
check("pick passes newline-joined labels to the chooser",
wr.pick(["a", "b"], lambda text: text), "a\nb")
check("cancelled pick returns empty", wr.pick(["a"], lambda text: ""), "")
sys.exit(fails)
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `python3 scripts/tests/window-restore.test.py`
Expected: FAIL, non-zero exit — `FileNotFoundError` / `No such file or directory: '.../scripts/window-restore.py'`, because the script does not exist yet.
- [ ] **Step 3: Write the implementation**
Create `scripts/window-restore.py`:
```python
#!/usr/bin/env python3
"""window-restore — bring a minimized window back from the stash workspace.
Mod+M moves the focused window to the declared "minimized" workspace; this is
Mod+Shift+M: list what is stashed in a wofi picker, then move the pick to the
workspace you are currently on and focus it.
Restore targets the *current* workspace by design — the stash workspace is the
entire state, so nothing tracks where a window came from and nothing goes stale.
The stash must be declared (`workspace "minimized"` in niri/config.kdl). niri
silently ignores moves to an undeclared named workspace, so if the declaration
goes missing this script says so rather than failing quietly.
"""
import json
import subprocess
import sys
STASH = "minimized"
# --- pure logic -------------------------------------------------------------
def stash_workspace(workspaces):
"""The declared stash workspace, or None if it is not declared."""
return next((w for w in workspaces if w.get("name") == STASH), None)
def current_workspace(workspaces):
return next((w for w in workspaces if w.get("is_focused")), None)
def stashed_windows(windows, stash_id):
return [w for w in windows if w.get("workspace_id") == stash_id]
def label(window):
"""Display string for the picker. XWayland windows can lack app_id, so fall
back rather than rendering a bare dash or an empty row."""
app = (window.get("app_id") or "").strip()
title = (window.get("title") or "").strip()
if app and title:
return f"{app}{title}"
return app or title or f"window {window['id']}"
def resolve(windows, chosen):
"""First window whose label matches. Windows with identical labels are
indistinguishable in the menu anyway, and restoring the first still drains
the stash on repeat."""
return next((w for w in windows if label(w) == chosen), None)
def pick(labels, chooser):
"""chooser(text) -> selected line. Injected so tests need no wofi."""
return chooser("\n".join(labels))
# --- io ---------------------------------------------------------------------
def notify(message):
subprocess.run(
["notify-send", "-a", "window-restore", message],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def niri_json(what):
"""`niri msg -j <what>` parsed, or None if niri/IPC is unavailable."""
try:
out = subprocess.run(
["niri", "msg", "-j", what], capture_output=True, text=True
)
except FileNotFoundError:
return None
if out.returncode != 0:
return None
try:
return json.loads(out.stdout)
except json.JSONDecodeError:
return None
def niri_action(*args):
subprocess.run(
["niri", "msg", "action", *args],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def wofi_chooser(text):
try:
out = subprocess.run(
["wofi", "--dmenu", "--prompt", "Restore:", "--width", "600",
"--height", "400", "--insensitive"],
input=text, capture_output=True, text=True,
)
except FileNotFoundError:
return ""
return out.stdout.strip()
def main():
workspaces = niri_json("workspaces")
if workspaces is None:
notify("niri IPC unavailable")
return 1
stash = stash_workspace(workspaces)
if stash is None:
notify(f'no "{STASH}" workspace — declare it in niri/config.kdl')
return 1
current = current_workspace(workspaces)
if current is None:
notify("no focused workspace")
return 1
# Restoring here would move a window to the workspace it is already on.
# Reachable by clicking the waybar pill; no keybind goes here.
if current["id"] == stash["id"]:
notify("Already on the minimized workspace")
return 0
windows = niri_json("windows")
if windows is None:
notify("niri IPC unavailable")
return 1
stashed = stashed_windows(windows, stash["id"])
if not stashed:
notify("No minimized windows")
return 0
chosen = pick([label(w) for w in stashed], wofi_chooser)
if not chosen:
return 0 # cancelled
window = resolve(stashed, chosen)
if window is None:
return 0
# The window is not focused (it is in the stash), so --focus does not apply;
# focus it explicitly afterwards.
niri_action("move-window-to-workspace", "--window-id", str(window["id"]),
str(current["idx"]))
niri_action("focus-window", "--id", str(window["id"]))
return 0
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `python3 scripts/tests/window-restore.test.py`
Expected: every line prints `ok - ...`, exit code 0. Confirm with `echo $?``0`.
- [ ] **Step 5: Commit**
```bash
chmod +x scripts/window-restore.py
git add scripts/window-restore.py scripts/tests/window-restore.test.py
git commit -m "window-restore: wofi picker to restore minimized windows
Pure logic split from the niri/wofi calls so it unit-tests without a
compositor. Restores to the current workspace, so no origin tracking."
```
---
### Task 2: Declare the stash, rewire the binds, install the symlink
**Files:**
- Modify: `niri/config.kdl` (top level; binds at lines ~62-108)
- Modify: `install.sh:49-57` (the symlink block)
**Interfaces:**
- Consumes from Task 1: the `window-restore` executable, spawned by `Mod+Shift+M`.
- [ ] **Step 1: Declare the stash workspace**
In `niri/config.kdl`, add above the `binds {` block (after the `spawn-at-startup` lines):
```kdl
// Stash for minimized windows (Mod+M). niri pins declared named workspaces to
// index 1 and refuses to move them, so real workspaces start at index 2 — hence
// the +1 offset on the Mod+1..9 binds below. Do not remove this declaration:
// without it the "minimized" binds silently do nothing (niri validate still
// passes, because the name is just a string to the parser).
workspace "minimized"
```
- [ ] **Step 2: Rewire the two minimize binds**
In `niri/config.kdl`, replace:
```kdl
Mod+M { move-window-to-workspace "minimized"; }
Mod+Shift+M { focus-workspace "minimized"; }
```
with:
```kdl
// focus=false or focus follows the window into the stash — the opposite of
// minimizing.
Mod+M { move-window-to-workspace "minimized" focus=false; }
Mod+Shift+M { spawn "window-restore"; }
```
- [ ] **Step 3: Offset the index-based workspace binds**
In `niri/config.kdl`, replace the `Mod+1..9` and `Mod+Shift+1..9` blocks with (note the `+1` — index 1 is the stash):
```kdl
// +1 offset: the "minimized" workspace is always index 1 (see the
// declaration above), so the first real workspace is index 2.
Mod+1 { focus-workspace 2; }
Mod+2 { focus-workspace 3; }
Mod+3 { focus-workspace 4; }
Mod+4 { focus-workspace 5; }
Mod+5 { focus-workspace 6; }
Mod+6 { focus-workspace 7; }
Mod+7 { focus-workspace 8; }
Mod+8 { focus-workspace 9; }
Mod+9 { focus-workspace 10; }
Mod+Shift+1 { move-window-to-workspace 2; }
Mod+Shift+2 { move-window-to-workspace 3; }
Mod+Shift+3 { move-window-to-workspace 4; }
Mod+Shift+4 { move-window-to-workspace 5; }
Mod+Shift+5 { move-window-to-workspace 6; }
Mod+Shift+6 { move-window-to-workspace 7; }
Mod+Shift+7 { move-window-to-workspace 8; }
Mod+Shift+8 { move-window-to-workspace 9; }
Mod+Shift+9 { move-window-to-workspace 10; }
```
- [ ] **Step 4: Validate the config**
Run: `niri validate -c niri/config.kdl`
Expected: `config is valid`, exit 0.
Note: a passing validate does **not** prove the stash works — that is exactly the trap the old config was in. Step 6 is the real check.
- [ ] **Step 5: Add the symlink and install it**
In `install.sh`, after the `gamemode-watch` line (`install.sh:57`), add:
```bash
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
```
Then create it for the running session:
```bash
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
command -v window-restore
```
Expected: prints `/home/alex/.local/bin/window-restore`.
- [ ] **Step 6: Verify by hand**
niri live-reloads on save; confirm with
`journalctl --user --since "1 minute ago" | grep "loaded config"`.
"First workspace" below means the one `Mod+1` reaches (niri index 2 — the stash holds index 1).
1. `Mod+1`. Confirm it lands on a **real workspace, not the stash** — this is the offset working.
2. On a window there, press `Mod+M`. The window vanishes and **focus stays put**. If the view jumps to an empty workspace, `focus=false` is not working — stop and fix.
3. waybar shows a `minimized` pill on the far left.
4. `Mod+2`, then `Mod+Shift+M`. The picker lists the stashed window; pick it. It appears on this workspace and is focused.
5. `Mod+Shift+M` again with the stash now empty: a "No minimized windows" toast, no hang.
6. Click the `minimized` pill in waybar, then `Mod+Shift+M`: "Already on the minimized workspace". Leave with `Mod+1`.
Confirm the stash is actually at index 1 and holding windows:
```bash
niri msg -j workspaces | python3 -c "import sys,json; print([(w['idx'], w['name']) for w in json.load(sys.stdin)])"
```
Expected: a tuple `(1, 'minimized')` in the list.
- [ ] **Step 7: Commit**
```bash
git add niri/config.kdl install.sh
git commit -m "niri: working minimize via declared stash workspace
Mod+M referenced an undeclared 'minimized' workspace, so it silently did
nothing. Declare it, add focus=false so focus does not follow the window
into the stash, and point Mod+Shift+M at the restore picker.
niri pins declared named workspaces to index 1 and will not move them, so
Mod+1..9 are offset by one."
```
---
## Verification
Full check after both tasks:
```bash
python3 scripts/tests/window-restore.test.py # all ok, exit 0
niri validate -c niri/config.kdl # config is valid
bash scripts/tests/gamemode-session.test.sh # unchanged, still passes
python3 scripts/tests/gamemode-watch.test.py # unchanged, still passes
```
Then the Task 2 Step 6 manual sequence, which is the only thing that proves the stash actually stashes.
@@ -0,0 +1,730 @@
# Niri Native Scratchpad Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Sway-style scratchpad natively to niri — hide the focused window into a global stash and show/toggle/cycle it back as a centered floating overlay — shipped as a pinned patch built by a custom `niri-scratchpad` Arch package, replacing the old workspace-based minimize.
**Architecture:** The scratchpad is a stash (`VecDeque<RemovedTile<W>>`) plus a "currently shown" id (`Option<W::Id>`) on niri's `Layout<W>` struct — the same place that already holds workspaceless live tiles for interactive-move and drag-and-drop. Hide = `Layout::remove_window` → push onto the stash; show = pop → re-add to the active workspace as floating. Because the stash is not a workspace, it has no index, never appears in waybar, and is output-independent — eliminating the `+1` offset and multi-monitor bug of the old approach. Two new bindable actions (`move-window-to-scratchpad`, `scratchpad-show`) are threaded through niri's two `Action` enums and its `do_action` dispatch. The core state machine is unit-tested in niri's synthetic `src/layout/tests.rs` harness (no compositor); focus/close/multi-monitor behavior is verified manually in a nested niri.
**Tech Stack:** Rust (niri 26.04, commit `8ed0da4`), knuffel (KDL config derive), clap (IPC), smithay; Arch `makepkg`/PKGBUILD; niri KDL config; fish `install.sh`.
## Global Constraints
- **Pinned upstream:** niri git tag `v26.04`, commit `8ed0da4`. Every source line/anchor below is against that commit. Do not develop against `main`.
- **Delta lives as patches:** all Rust changes ship as `niri-patches/*.patch` applied by `pkg/niri-scratchpad/PKGBUILD`. Upstream is never vendored into the repo.
- **Stash key type is `W::Id`** (production `Window`, tests `usize`) — never `MappedId`. This keeps the state machine unit-testable and generic.
- **Single-shown invariant:** at most one scratchpad window is shown at a time (accepted simplification — no multi-shown, no persistent per-window `is_scratchpad` flag).
- **Repo principles (CLAUDE.md):** idempotent `install.sh`, no sleep-based hacks, complete files, Tomorrow Night theme untouched.
- **Two repos:** Rust work happens in a disposable niri dev clone (`~/build/niri-scratchpad`); its commits are throwaway — only the exported patch is a deliverable. Real deliverables (patch, PKGBUILD, install.sh, config, deletions) are committed in the dotfiles repo.
**Path shorthand in this plan:** `$DEV` = `~/build/niri-scratchpad` (niri dev clone). `$DOT` = `/home/alex/Documents/dotfiles`.
---
## File Structure
**niri dev clone (`$DEV`) — source of the patch, throwaway commits:**
- Modify `src/layout/mod.rs` — stash fields on `Layout<W>`; `move_to_scratchpad` / `scratchpad_show` methods; close-eviction hook inside `remove_window`.
- Modify `src/layout/tests.rs` — new `Op` variants + `apply` arms + `#[test]`s.
- Modify `niri-ipc/src/lib.rs` — IPC `Action` variants.
- Modify `niri-config/src/binds.rs` — config `Action` variants + `From` arms.
- Modify `src/input/mod.rs``do_action` arms.
**dotfiles repo (`$DOT`) — real deliverables:**
- Create `niri-patches/0001-scratchpad.patch` — the whole Rust delta (single patch; see Task 6 rationale).
- Create `pkg/niri-scratchpad/PKGBUILD` — builds pinned niri + patch.
- Modify `install.sh` — build/install `niri-scratchpad`; drop `window-restore` symlink.
- Modify `niri/config.kdl` — remove `workspace "minimized"`, un-offset `Mod+1..9`, new `Mod+M` / `Mod+Shift+M` binds.
- Delete `scripts/window-restore.py`, `scripts/tests/window-restore.test.py`.
---
## Task 1: Dev clone + baseline build
Establishes a known-good, buildable, runnable baseline before any change. A patch is only meaningful as a diff against this exact tree.
**Files:** none in repo yet (environment setup).
- [ ] **Step 1: Clone niri at the pinned commit onto a work branch**
```bash
mkdir -p ~/build && cd ~/build
git clone https://github.com/YaLTeR/niri.git niri-scratchpad
cd ~/build/niri-scratchpad
git checkout 8ed0da4
git switch -c scratchpad # all dev commits land here
git tag baseline 8ed0da4 # patch is diffed against this
```
- [ ] **Step 2: Confirm the baseline builds**
Run: `cd ~/build/niri-scratchpad && cargo build --release --locked 2>&1 | tail -5`
Expected: `Finished \`release\` profile ... target(s)` (first build is slow; that is fine).
- [ ] **Step 3: Confirm the baseline runs nested**
Run (inside an existing graphical session, from a terminal): `cd ~/build/niri-scratchpad && ./target/release/niri --version`
Expected: `niri 26.04 (8ed0da4...)`. (A full nested `niri` window is exercised in Task 7; here we only confirm the binary links and reports the pinned version.)
- [ ] **Step 4: No commit** — this task produces no repo artifact. Proceed to Task 2.
---
## Task 2: Stash state + `move-window-to-scratchpad`
Adds the two `Layout` fields and the hide method, unit-tested in the synthetic harness. No compositor.
**Files:**
- Modify: `$DEV/src/layout/mod.rs` (struct `Layout<W>` ~line 336369; inits at ~695708 and ~721732; new methods in the `impl<W: LayoutElement> Layout<W>` block starting ~690)
- Test: `$DEV/src/layout/tests.rs` (`Op` enum + `apply` at ~759; new `#[test]`s)
**Interfaces:**
- Produces: `Layout::move_to_scratchpad(&mut self, window: &W::Id)`; fields `scratchpad: VecDeque<RemovedTile<W>>`, `scratchpad_shown: Option<W::Id>`.
- Consumes (existing, verbatim signatures): `Layout::remove_window(&mut self, window: &W::Id, transaction: Transaction) -> Option<RemovedTile<W>>`; `RemovedTile<W>` has module-private field `tile: Tile<W>`; `Tile::window(&self) -> &W`; `W::id(&self) -> &W::Id`.
- [ ] **Step 1: Add the fields to the `Layout<W>` struct**
In `$DEV/src/layout/mod.rs`, in the `pub struct Layout<W: LayoutElement>` definition (ends ~line 369), add after the `dnd` field:
```rust
/// Windows hidden in the scratchpad, front = next to show. Not part of any
/// workspace, so they have no index and never appear in the workspace list.
scratchpad: VecDeque<RemovedTile<W>>,
/// Id of the scratchpad window currently shown as a floating overlay, if any.
scratchpad_shown: Option<W::Id>,
```
Ensure `use std::collections::VecDeque;` exists at the top of the file (add it if not).
- [ ] **Step 2: Initialize the fields in BOTH constructors**
There are two field-init blocks; both must set the new fields or the crate will not compile. In `with_options` (~line 695) and in `with_options_and_workspaces` (~line 721), add alongside `dnd: None,`:
```rust
scratchpad: VecDeque::new(),
scratchpad_shown: None,
```
- [ ] **Step 3: Add test-harness `Op` variants**
In `$DEV/src/layout/tests.rs`, add to the `Op` enum:
```rust
ScratchpadStash(usize),
ScratchpadShow,
```
In `Op::apply` (match at ~line 759), add arms:
```rust
Op::ScratchpadStash(id) => {
layout.move_to_scratchpad(&id);
}
Op::ScratchpadShow => {
layout.scratchpad_show();
}
```
(`scratchpad_show` is implemented in Task 3; to keep this task compiling on its own, temporarily add a stub `pub fn scratchpad_show(&mut self) {}` in mod.rs now and flesh it out in Task 3.)
- [ ] **Step 4: Write the failing test for stashing**
Add to `$DEV/src/layout/tests.rs`:
```rust
#[test]
fn scratchpad_stash_removes_window_from_workspace() {
let ops = [
Op::AddOutput(1),
Op::AddWindow { params: TestWindowParams::new(0) },
Op::ScratchpadStash(0),
];
let layout = check_ops(ops);
// The window left every workspace...
assert!(!layout.has_window(&0), "window should not be in any workspace");
// ...and now lives in the stash.
assert_eq!(layout.scratchpad.len(), 1);
assert_eq!(layout.scratchpad.back().unwrap().tile.window().id(), &0);
}
```
- [ ] **Step 5: Run it, verify it fails**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_stash_removes_window_from_workspace 2>&1 | tail -20`
Expected: compile error `no method named \`move_to_scratchpad\`` (method not yet written).
- [ ] **Step 6: Implement `move_to_scratchpad`**
In the `impl<W: LayoutElement> Layout<W>` block in `$DEV/src/layout/mod.rs`, add:
```rust
/// Hide `window` into the scratchpad. Focus falls to the next window via the
/// normal remove path. No-op if the window is not in the layout.
pub fn move_to_scratchpad(&mut self, window: &W::Id) {
let Some(removed) = self.remove_window(window, Transaction::new()) else {
return;
};
self.scratchpad.push_back(removed);
// scratchpad_shown is cleared by the eviction hook in remove_window
// (Task 4) if this window was the one shown.
}
```
- [ ] **Step 7: Run it, verify it passes**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_stash_removes_window_from_workspace 2>&1 | tail -20`
Expected: `test result: ok. 1 passed`.
- [ ] **Step 8: Commit (throwaway dev commit)**
```bash
cd ~/build/niri-scratchpad
git add -A && git commit -m "scratchpad: stash fields + move_to_scratchpad"
```
---
## Task 3: `scratchpad_show` state machine + centering
Implements show/hide/cycle/focus and first-show centering, unit-tested. Replaces the Task 2 stub.
**Files:**
- Modify: `$DEV/src/layout/mod.rs` (replace the `scratchpad_show` stub; methods block ~690)
- Test: `$DEV/src/layout/tests.rs`
**Interfaces:**
- Produces: `Layout::scratchpad_show(&mut self)`.
- Consumes (existing, verbatim): `Layout::has_window(&self, window: &W::Id) -> bool`; `Layout::focus(&self) -> Option<&W>`; `Layout::active_output(&self) -> Option<&Output>`; `Layout::active_workspace(&self) -> Option<&Workspace<W>>`; `Monitor::add_tile(&mut self, tile, MonitorAddWindowTarget::Workspace { id, column_idx: None }, ActivateWindow::Yes, true, width, is_full_width, is_floating)` — re-add pattern verbatim at `mod.rs:3359-3373`; `RemovedTile` fields `tile`, `width`, `is_full_width`, `is_floating`.
**State table (the contract this task implements):**
| State (after staleness check) | Behavior |
|---|---|
| shown = Some(id) AND id is focused | Hide: `remove_window(id)` → `push_back` to stash; `scratchpad_shown = None` |
| shown = Some(id) AND id not focused | Focus that window; stash unchanged |
| shown = None, stash non-empty | Pop front → add to active workspace, floating, focused, centered on first show; set `scratchpad_shown` |
| shown = None, stash empty | No-op |
- [ ] **Step 1: Write the failing tests**
Add to `$DEV/src/layout/tests.rs`:
```rust
#[test]
fn scratchpad_show_brings_window_back_focused() {
let ops = [
Op::AddOutput(1),
Op::AddWindow { params: TestWindowParams::new(0) },
Op::ScratchpadStash(0),
Op::ScratchpadShow,
];
let layout = check_ops(ops);
assert!(layout.scratchpad.is_empty(), "stash drained on show");
assert!(layout.has_window(&0), "window returned to a workspace");
assert_eq!(layout.scratchpad_shown, Some(0));
assert_eq!(layout.focus().map(|w| *w.id()), Some(0), "shown window is focused");
}
#[test]
fn scratchpad_show_twice_hides_again() {
let ops = [
Op::AddOutput(1),
Op::AddWindow { params: TestWindowParams::new(0) },
Op::ScratchpadStash(0),
Op::ScratchpadShow, // show
Op::ScratchpadShow, // focused -> hide
];
let layout = check_ops(ops);
assert_eq!(layout.scratchpad.len(), 1, "window back in stash");
assert!(!layout.has_window(&0));
assert_eq!(layout.scratchpad_shown, None);
}
#[test]
fn scratchpad_show_cycles_round_robin() {
let ops = [
Op::AddOutput(1),
Op::AddWindow { params: TestWindowParams::new(0) },
Op::AddWindow { params: TestWindowParams::new(1) },
Op::ScratchpadStash(0), // stash: [0]
Op::ScratchpadStash(1), // stash: [0, 1]
Op::ScratchpadShow, // show 0 (front)
Op::ScratchpadShow, // 0 focused -> hide 0 -> stash: [1, 0]
Op::ScratchpadShow, // show 1 (new front)
];
let layout = check_ops(ops);
assert_eq!(layout.scratchpad_shown, Some(1), "cycled to the next window");
assert!(layout.has_window(&1));
}
#[test]
fn scratchpad_show_empty_is_noop() {
let ops = [Op::AddOutput(1), Op::ScratchpadShow];
let layout = check_ops(ops);
assert_eq!(layout.scratchpad_shown, None);
assert!(layout.scratchpad.is_empty());
}
```
- [ ] **Step 2: Run them, verify they fail**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_show 2>&1 | tail -30`
Expected: assertion failures (the stub does nothing) — e.g. `scratchpad_show_brings_window_back_focused` fails on `has_window`.
- [ ] **Step 3: Implement `scratchpad_show`**
Replace the stub in `$DEV/src/layout/mod.rs` with:
```rust
/// Show / toggle / cycle the scratchpad. See the state table in the plan.
pub fn scratchpad_show(&mut self) {
// Drop a stale "shown" id (window was closed or moved away).
if let Some(id) = self.scratchpad_shown.clone() {
if !self.has_window(&id) {
self.scratchpad_shown = None;
}
}
if let Some(id) = self.scratchpad_shown.clone() {
let focused = self.focus().map(|w| w.id().clone());
if focused.as_ref() == Some(&id) {
// shown + focused -> hide it
if let Some(removed) = self.remove_window(&id, Transaction::new()) {
self.scratchpad.push_back(removed);
}
self.scratchpad_shown = None;
} else {
// shown + not focused -> focus it.
// CONFIRM at implementation: the exact focus-by-id call. Candidates
// in this file are the activation path used by move actions; use
// whichever method focuses an existing window by &W::Id without
// moving it. Verify against `scratchpad_show_*` tests staying green.
self.activate_window(&id);
}
return;
}
// nothing shown -> show the front of the stash
let Some(mut removed) = self.scratchpad.pop_front() else {
return;
};
removed.tile.stop_move_animations();
// First-show centering: if this tile has never floated, place it centered
// on the active output. If it has a remembered floating position, leave it.
// CONFIRM at implementation: centering uses the active output geometry; if
// niri's floating layer already centers a position-less tile acceptably,
// this block may reduce to leaving floating_pos as-is. Verified visually in
// Task 7 (manual acceptance #3), not by unit test.
// center_floating_tile(&mut removed.tile, self.active_output());
let id = removed.tile.window().id().clone();
let ws_id = match self.active_workspace() {
Some(ws) => ws.id(),
None => {
self.scratchpad.push_front(removed); // no active ws; put it back
return;
}
};
self.add_removed_tile_floating(ws_id, removed); // helper below
self.scratchpad_shown = Some(id);
}
```
Add a small private helper next to it that mirrors the verbatim re-add pattern at `mod.rs:3359-3373`, forcing `is_floating = true` and targeting the given workspace's monitor (find the monitor owning `ws_id` in `self.monitor_set`, then `mon.add_tile(removed.tile, MonitorAddWindowTarget::Workspace { id: ws_id, column_idx: None }, ActivateWindow::Yes, true, removed.width, removed.is_full_width, true)`).
- [ ] **Step 4: Run the tests, verify they pass**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad 2>&1 | tail -20`
Expected: all `scratchpad_*` tests pass. If the "focus it" branch cannot be reached by these tests (it isn't — it needs manual focus change), that branch is covered by Task 7 manual test; the four tests above must be green.
- [ ] **Step 5: Run the full layout suite to catch invariant regressions**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri --lib layout 2>&1 | tail -20`
Expected: no new failures (each op runs `verify_invariants`).
- [ ] **Step 6: Commit**
```bash
cd ~/build/niri-scratchpad
git add -A && git commit -m "scratchpad: show/hide/cycle state machine + centering"
```
---
## Task 4: Evict on close (single hook in `remove_window`)
A window closed *while stashed* is in no workspace, so nothing removes it from the stash — a leak / stale-id bug. Fix at the single choke point every removal passes through.
**Files:**
- Modify: `$DEV/src/layout/mod.rs` (`Layout::remove_window`, ~line 1112)
- Test: `$DEV/src/layout/tests.rs`
**Interfaces:**
- Consumes: `Op::CloseWindow(usize)` in the harness already calls `remove_window` (`tests.rs:1061`).
- [ ] **Step 1: Write the failing test**
Add to `$DEV/src/layout/tests.rs`:
```rust
#[test]
fn scratchpad_evicts_closed_stashed_window() {
let ops = [
Op::AddOutput(1),
Op::AddWindow { params: TestWindowParams::new(0) },
Op::AddWindow { params: TestWindowParams::new(1) },
Op::ScratchpadStash(0),
Op::ScratchpadStash(1),
Op::CloseWindow(0), // close a window while it is stashed
];
let layout = check_ops(ops);
assert_eq!(layout.scratchpad.len(), 1, "closed window evicted from stash");
assert_eq!(layout.scratchpad.front().unwrap().tile.window().id(), &1);
}
```
- [ ] **Step 2: Run it, verify it fails**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad_evicts_closed_stashed_window 2>&1 | tail -20`
Expected: `assertion \`left == right\` failed: closed window evicted from stash` — stash still has 2 (the close didn't touch it).
- [ ] **Step 3: Add the eviction hook at the top of `remove_window`**
At the very start of `Layout::remove_window` (before the existing monitor_set lookup), add:
```rust
// A window may be sitting in the scratchpad (not in any workspace).
// Drop it here so both toplevel-destroy paths and the close op cover it.
self.scratchpad.retain(|rt| rt.tile.window().id() != window);
if self.scratchpad_shown.as_ref() == Some(window) {
self.scratchpad_shown = None;
}
```
This is the single point of truth: it covers `handlers/compositor.rs` (unmap) and `handlers/xdg_shell.rs` (destroy), which both call `remove_window`, and the harness `Op::CloseWindow`. It is a no-op for the common case (window not in the stash).
- [ ] **Step 4: Run it, verify it passes; re-run the scratchpad suite**
Run: `cd ~/build/niri-scratchpad && cargo test -p niri scratchpad 2>&1 | tail -20`
Expected: all `scratchpad_*` tests pass (the new one plus the four from Task 3 — confirm none regressed, since `remove_window` now also runs during hide).
- [ ] **Step 5: Commit**
```bash
cd ~/build/niri-scratchpad
git add -A && git commit -m "scratchpad: evict stashed window on close via remove_window"
```
---
## Task 5: Wire the bindable actions (IPC + config + dispatch)
Threads `move-window-to-scratchpad` and `scratchpad-show` through niri's two `Action` enums and `do_action`. After this, the whole binary compiles and the binds parse.
**Files:**
- Modify: `$DEV/niri-ipc/src/lib.rs` (IPC `Action` enum, ~line 194)
- Modify: `$DEV/niri-config/src/binds.rs` (config `Action` enum ~line 102; `From` impl ~line 395)
- Modify: `$DEV/src/input/mod.rs` (`do_action` match, ~line 659)
**Interfaces:**
- Consumes: `Layout::move_to_scratchpad`, `Layout::scratchpad_show` (Tasks 23); `self.niri.layout.focus().map(|m| m.window.clone())` yields the focused `Window` (= `W::Id`); `self.niri.queue_redraw_all()`.
- [ ] **Step 1: Add IPC `Action` variants**
In `$DEV/niri-ipc/src/lib.rs`, in the `pub enum Action`, add two unit-like struct variants (mirror the style of neighboring variants):
```rust
/// Move the focused window to the scratchpad (hide it).
MoveWindowToScratchpad {},
/// Show, toggle, or cycle the scratchpad.
ScratchpadShow {},
```
- [ ] **Step 2: Add config `Action` variants**
In `$DEV/niri-config/src/binds.rs`, in the `pub enum Action` (`#[derive(knuffel::Decode, ...)]`), add (knuffel derives kebab-case node names `move-window-to-scratchpad` / `scratchpad-show`):
```rust
MoveWindowToScratchpad,
ScratchpadShow,
```
- [ ] **Step 3: Add `From<niri_ipc::Action>` arms**
In the `impl From<niri_ipc::Action> for Action` (~line 395) — exhaustive over the IPC enum, so both new IPC variants need an arm:
```rust
niri_ipc::Action::MoveWindowToScratchpad {} => Self::MoveWindowToScratchpad,
niri_ipc::Action::ScratchpadShow {} => Self::ScratchpadShow,
```
- [ ] **Step 4: Add `do_action` dispatch arms**
In `$DEV/src/input/mod.rs`, in the `match action {` (~line 659), add (patterned on `Action::FullscreenWindow` at ~line 812):
```rust
Action::MoveWindowToScratchpad => {
let focus = self.niri.layout.focus().map(|m| m.window.clone());
if let Some(window) = focus {
self.niri.layout.move_to_scratchpad(&window);
self.niri.queue_redraw_all();
}
}
Action::ScratchpadShow => {
self.niri.layout.scratchpad_show();
self.niri.queue_redraw_all();
}
```
- [ ] **Step 5: Build the whole binary**
Run: `cd ~/build/niri-scratchpad && cargo build --release --locked 2>&1 | tail -15`
Expected: `Finished` with no errors. (If a match is non-exhaustive, an arm is missing — add it where the compiler points.)
- [ ] **Step 6: Verify the binds parse via `niri validate`**
Create a throwaway config and validate it:
```bash
cat > /tmp/sp-test.kdl <<'EOF'
binds {
Mod+M { move-window-to-scratchpad; }
Mod+Shift+M { scratchpad-show; }
}
EOF
cd ~/build/niri-scratchpad && ./target/release/niri validate -c /tmp/sp-test.kdl && echo VALID
```
Expected: `VALID` (no unknown-node error — proves knuffel accepts the new nodes).
- [ ] **Step 7: Verify the IPC action parses**
Run: `cd ~/build/niri-scratchpad && ./target/release/niri msg action scratchpad-show 2>&1 | head -3`
Expected: it fails to *connect* (no running niri socket) rather than failing to *parse* the action — i.e. an error about the socket, not `unrecognized subcommand`. (Full IPC behavior is exercised in Task 7.)
- [ ] **Step 8: Commit**
```bash
cd ~/build/niri-scratchpad
git add -A && git commit -m "scratchpad: bindable + IPC actions wired through do_action"
```
---
## Task 6: Package the patch + dotfiles changes
Export the Rust delta as one patch, build it via a PKGBUILD, and make all the dotfiles-side changes (config, install.sh, deletions) in a single reviewable commit.
**Rationale for a single patch file:** the changes interleave within `src/layout/mod.rs` (fields, methods, and the `remove_window` hook are all in that one file). Splitting into three `git apply`-able patches would force a fixed apply order and make rebasing fragile. One patch = one `git diff baseline..scratchpad`, trivially re-generated after a rebase.
**Files:**
- Create: `$DOT/niri-patches/0001-scratchpad.patch`
- Create: `$DOT/pkg/niri-scratchpad/PKGBUILD`
- Modify: `$DOT/install.sh`
- Modify: `$DOT/niri/config.kdl`
- Delete: `$DOT/scripts/window-restore.py`, `$DOT/scripts/tests/window-restore.test.py`
- [ ] **Step 1: Export the patch**
```bash
mkdir -p /home/alex/Documents/dotfiles/niri-patches
cd ~/build/niri-scratchpad
git diff baseline..scratchpad > /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch
head -5 /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch
```
Expected: a unified diff beginning `diff --git a/niri-config/src/binds.rs ...` (touching binds.rs, lib.rs, mod.rs, tests.rs, input/mod.rs).
- [ ] **Step 2: Verify the patch applies cleanly to a fresh checkout**
```bash
cd /tmp && rm -rf niri-apply-check && git clone -q https://github.com/YaLTeR/niri.git niri-apply-check
cd /tmp/niri-apply-check && git checkout -q 8ed0da4
git apply --check /home/alex/Documents/dotfiles/niri-patches/0001-scratchpad.patch && echo "APPLIES CLEANLY"
```
Expected: `APPLIES CLEANLY`.
- [ ] **Step 3: Write the PKGBUILD**
Create `$DOT/pkg/niri-scratchpad/PKGBUILD`. Mirrors the official niri package install layout (from `resources/`), adds the patch in `prepare()`, and `provides`/`conflicts` stock niri:
```bash
# Maintainer: alex <funman300@gmail.com>
pkgname=niri-scratchpad
pkgver=26.04
pkgrel=1
_commit=8ed0da4
pkgdesc="niri with a native Sway-style scratchpad (local patch set)"
arch=('x86_64')
url="https://github.com/YaLTeR/niri"
license=('GPL-3.0-or-later')
depends=('libgbm' 'libglvnd' 'libinput' 'libxkbcommon' 'mesa' 'pango'
'pipewire' 'seatd' 'systemd-libs' 'wayland')
makedepends=('git' 'cargo' 'clang' 'mesa')
provides=('niri')
conflicts=('niri')
options=('!lto')
source=("niri::git+https://github.com/YaLTeR/niri.git#commit=${_commit}"
"0001-scratchpad.patch")
sha256sums=('SKIP'
'SKIP')
prepare() {
cd niri
git apply "${srcdir}/0001-scratchpad.patch"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd niri
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build --release --frozen
}
package() {
cd niri
install -Dm755 target/release/niri "${pkgdir}/usr/bin/niri"
install -Dm755 resources/niri-session "${pkgdir}/usr/bin/niri-session"
install -Dm644 resources/niri.desktop \
"${pkgdir}/usr/share/wayland-sessions/niri.desktop"
install -Dm644 resources/niri-portals.conf \
"${pkgdir}/usr/share/xdg-desktop-portal/niri-portals.conf"
install -Dm644 resources/niri.service \
"${pkgdir}/usr/lib/systemd/user/niri.service"
install -Dm644 resources/niri-shutdown.target \
"${pkgdir}/usr/lib/systemd/user/niri-shutdown.target"
}
```
The `0001-scratchpad.patch` source is a repo-relative sibling: symlink or copy it next to the PKGBUILD at build time (Step 5 handles this). CONFIRM at implementation: cross-check `depends`/install lines against `pacman -Qi niri` on this machine so the packaged binary keeps identical runtime deps and session integration.
- [ ] **Step 4: Build and install the package**
```bash
cd /home/alex/Documents/dotfiles/pkg/niri-scratchpad
cp ../../niri-patches/0001-scratchpad.patch . # makepkg needs sources local
makepkg -si --noconfirm 2>&1 | tail -20
```
Expected: package builds and installs; `pacman -Q niri-scratchpad` shows `26.04-1`.
- [ ] **Step 5: Verify the installed binary is the patched one**
```bash
niri --version && niri msg action scratchpad-show 2>&1 | head -2
```
Expected: version `26.04`; the action is recognized (socket error if run outside niri, not `unrecognized subcommand`).
- [ ] **Step 6: Update `niri/config.kdl`** — remove the stash workspace, un-offset, rebind
In `$DOT/niri/config.kdl`:
- Delete the comment block (lines ~5054) and the `workspace "minimized"` declaration (line ~55).
- `Mod+1..9`: change `focus-workspace 2..10` back to `focus-workspace 1..9` (drop the comment about the +1 offset too).
- `Mod+Shift+1..9`: change `move-window-to-workspace 2..10` back to `move-window-to-workspace 1..9`.
- Replace the two minimize binds:
```kdl
Mod+M { move-window-to-scratchpad; }
Mod+Shift+M { scratchpad-show; }
```
- [ ] **Step 7: Validate the real config with the patched binary**
Run: `niri validate -c /home/alex/Documents/dotfiles/niri/config.kdl && echo VALID`
Expected: `VALID`.
- [ ] **Step 8: Delete the old Python implementation**
```bash
cd /home/alex/Documents/dotfiles
git rm scripts/window-restore.py scripts/tests/window-restore.test.py
```
- [ ] **Step 9: Update `install.sh`**
In `$DOT/install.sh`:
- Remove the line that symlinks `window-restore` into `~/.local/bin`.
- Add a step (idempotent) that builds/installs the package, e.g. skip when already current:
```bash
# Build & install patched niri (native scratchpad) if not already present.
if ! pacman -Q niri-scratchpad &>/dev/null; then
(cd "$(pwd)/pkg/niri-scratchpad" \
&& cp ../../niri-patches/0001-scratchpad.patch . \
&& makepkg -si --noconfirm)
fi
```
CONFIRM placement matches the existing symlink/step block style in `install.sh` (read the file first; match its status-message convention).
- [ ] **Step 10: Commit the dotfiles changes**
```bash
cd /home/alex/Documents/dotfiles
git add niri-patches pkg install.sh niri/config.kdl
git add -u scripts # stage the deletions
git commit -m "niri: native scratchpad via patched niri-scratchpad package
Replace the workspace-based minimize (declared 'minimized' workspace +
+1 offset + window-restore.py) with a native Sway-style scratchpad built
into niri. Ships as niri-patches/0001-scratchpad.patch + a niri-scratchpad
PKGBUILD. Mod+M stashes; Mod+Shift+M shows/toggles/cycles. Mod+1..9 no
longer offset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: End-to-end manual acceptance (nested niri)
The behaviors no unit test reaches: real focus routing, the "focus it" branch, close-while-stashed under a live client, centering, and multi-monitor. Run the patched niri nested inside the current session.
**Files:** none (verification only).
- [ ] **Step 1: Launch patched niri nested with the repo config**
Run (from a terminal in the current graphical session):
`niri -c /home/alex/Documents/dotfiles/niri/config.kdl`
Expected: a nested niri window opens without error. Open two terminals inside it.
- [ ] **Step 2: Run the acceptance checklist** — observe each, all must hold:
1. On a window, `Mod+M`: it vanishes and **focus moves to another window** (regression guard for the focus-release risk).
2. `Mod+Shift+M`: the window reappears **centered on the current output**, focused.
3. `Mod+Shift+M` again (window still focused): it hides.
4. Stash two windows; `Mod+Shift+M` repeatedly cycles A → (hide) → B → (hide) → A.
5. Show a window, move/resize it, hide, show again: **same geometry** (remembered).
6. Show a window, click/focus the *other* window, `Mod+Shift+M`: focus returns to the shown window (the "shown-but-not-focused → focus it" branch).
7. Stash a terminal, then `exit` its shell so the client dies while stashed; `Mod+Shift+M`: no crash, cycle skips it, stash count correct.
8. If a second output is available (or a nested second output), show on the **focused** output; repeat focused on the other output — each shows there.
9. `Mod+1..9` land on workspaces `1..9` with **no offset**; the scratchpad has **no** waybar pill.
10. `Mod+Shift+M` with an empty stash: nothing happens, no crash.
- [ ] **Step 3: Record results**
Note any failing item with the observed behavior. A failure in #1, #6, #7, or #8 points back at the focus/cast/close bookkeeping risk — fix in the relevant Task 24 method, re-export the patch (Task 6 Step 1), rebuild, and re-run this checklist. Do not claim completion until all ten hold.
- [ ] **Step 4: Final verification statement**
Only after all ten pass and `cargo test -p niri scratchpad` is green: the feature is complete. Log-out / log-in to the real session (now running `niri-scratchpad`) as the last real-world confirmation.
---
## Self-Review (author checklist — completed)
**Spec coverage:** stash-not-workspace (Task 2 fields) ✓; `Mod+M` hide (Task 2) ✓; show/hide/cycle/focus table (Task 3) ✓; centering + remember (Task 3) ✓; single-shown invariant (design, enforced by `Option` field) ✓; close-while-stashed (Task 4) ✓; two-Action wiring + From + do_action (Task 5) ✓; patch + PKGBUILD (Task 6) ✓; config un-offset + rebind + workspace-decl removal (Task 6) ✓; Python deletion (Task 6) ✓; install.sh (Task 6) ✓; unit tests (Tasks 24) + manual acceptance incl. multi-monitor (Task 7) ✓.
**Placeholder scan:** two `CONFIRM at implementation` notes remain by design — the exact focus-by-id method name and the centering call are the two spots the spec flagged as compiler/geometry-dependent; each names the location, the pattern to mirror, and the test that must stay green, rather than deferring undefined work.
**Type consistency:** stash keyed on `W::Id` throughout; `scratchpad: VecDeque<RemovedTile<W>>` / `scratchpad_shown: Option<W::Id>` named identically in fields, methods, tests, and the `remove_window` hook; method names `move_to_scratchpad` / `scratchpad_show` consistent across Layout, `do_action`, and the harness `Op` arms.
@@ -0,0 +1,236 @@
# 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 **three
independent sources** and reference-counted, so the triggers can overlap safely:
- **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,
**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.
### 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.
- `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; `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 / 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)
- **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.
@@ -0,0 +1,232 @@
# Niri Minimize / Stash Design
**Date:** 2026-07-16
**Status:** Approved
## Summary
Give niri a working minimize: `Mod+M` stashes the focused window out of sight,
`Mod+Shift+M` opens a wofi picker to bring any stashed window back to the
workspace you are currently on. Any number of windows can be stashed at once.
The stash is a declared named workspace, `minimized`. Restore is a new script,
`scripts/window-restore.py`, driven by niri's IPC.
Both keybinds already exist in `niri/config.kdl` — and **both are silently
broken today** (see below). This design fixes the root cause and builds the
restore half that was never there.
## Current Behaviour
- **Compositor:** niri 26.04, single internal panel `eDP-1`. Config at
`niri/config.kdl`.
- **The existing binds are no-ops:**
```kdl
Mod+M { move-window-to-workspace "minimized"; }
Mod+Shift+M { focus-workspace "minimized"; }
```
`minimized` is referenced in these binds but **never declared** at the top
level. niri only creates named workspaces that are declared, so both actions
resolve to nothing and do nothing. `niri validate` reports the config as valid
because the name is just a string to the parser — which is why this has failed
quietly rather than erroring.
- **Workspaces are dynamic** and unnamed; `Mod+1..9` focus them by index
(`focus-workspace 1..9`), `Mod+Shift+1..9` move windows to them by index.
- **waybar** runs `niri/workspaces` in `modules-left` with no module-specific
config, so it renders every workspace niri reports.
- **No `jq` installed.** `python3` is already a dependency via
`scripts/gamemode-watch.py`, which also establishes the test convention
(`scripts/tests/gamemode-watch.test.py`).
- **Script convention:** `scripts/<name>.{sh,py}` symlinked to
`~/.local/bin/<name>` (no extension in the bin name) by `install.sh`.
## Verified niri constraints
Each of these was tested against niri 26.04 in a nested instance, not inferred
from documentation. They are the load-bearing facts behind the design:
1. **An undeclared named workspace is a silent no-op.** `move-window-to-workspace
minimized` left the window where it was; `focus-workspace minimized` did not
change focus and created nothing.
2. **Declaring it makes both work.** With `workspace "minimized"` at the top
level, a window moved from a dynamic workspace into the stash by name.
3. **A declared named workspace is pinned to index 1 and cannot be moved.**
It always sorts first, ahead of every dynamic workspace.
`move-workspace-to-index --reference minimized` refused at every target index,
and `open-on-output` pointing at a non-existent output did not dislodge it.
**This is why the `Mod+1..9` binds must be offset by one.**
4. **`focus=false` is required on minimize.** The default (`focus=true`) makes
focus follow the window into the stash — the opposite of minimizing. With
`--focus false` the window moved to the stash and focus stayed put. The
`focus=false` property parses in a KDL bind.
5. **The IPC needed for restore exists:** `move-window-to-workspace
--window-id <id> <reference>` and `focus-window --id <id>`.
## Target Behaviour
| Key | Action |
|---|---|
| `Mod+M` | Stash the focused window. Focus stays where it is. |
| `Mod+Shift+M` | wofi picker of stashed windows → restore the pick to the **current** workspace and focus it. |
Restoring to the current workspace ("bring it to me") is deliberate: the stash
workspace itself is the entire state, so nothing needs to track where a window
came from and nothing can go stale.
The `minimized` workspace is permanent and always visible as the leftmost pill in
waybar. This is kept on purpose — it is the only visual cue that windows are
stashed.
## Implementation
### 1. `niri/config.kdl`
Declare the stash at the top level, with a comment carrying the reason for the
offset so the next reader does not "fix" it:
```kdl
// Stash for minimized windows (Mod+M). niri pins declared named workspaces to
// index 1 and refuses to move them, so real workspaces start at index 2 — hence
// the +1 offset on the Mod+1..9 binds below. Do not remove this declaration:
// without it the "minimized" binds silently do nothing.
workspace "minimized"
```
Binds:
```kdl
Mod+M { move-window-to-workspace "minimized" focus=false; }
Mod+Shift+M { spawn "window-restore"; }
```
`focus=false` is new (constraint 4). `Mod+Shift+M` changes from
`focus-workspace "minimized"` to spawning the picker.
Offset every index-based workspace bind by one (constraint 3):
```kdl
Mod+1 { focus-workspace 2; } ... Mod+9 { focus-workspace 10; }
Mod+Shift+1 { move-window-to-workspace 2; } ... Mod+Shift+9 { move-window-to-workspace 10; }
```
The offset is exact rather than heuristic: the stash is *provably* always index 1,
because niri will not let it be anywhere else.
### 2. `scripts/window-restore.py` (new)
Python, because it parses `niri msg -j` JSON and there is no `jq`.
```
main:
workspaces = niri msg -j workspaces
stash = the workspace whose name == "minimized"
if stash is None: → notify "minimized workspace not declared"; exit 1
current = the workspace where is_focused
if current.id == stash.id: → notify "Already on the minimized workspace"; exit 0
windows = [w for w in (niri msg -j windows) if w.workspace_id == stash.id]
if not windows: → notify "No minimized windows"; exit 0
choice = wofi --dmenu over "<app_id> — <title>" lines
if not choice: → exit 0 # cancelled
win = first window whose label == choice
niri msg action move-window-to-workspace --window-id win.id <current.idx>
niri msg action focus-window --id win.id
```
Split into small pure functions so the logic is testable without a compositor:
- `stash_workspace(workspaces)` → the stash dict or `None`
- `current_workspace(workspaces)` → the focused dict
- `stashed_windows(windows, stash_id)` → list
- `label(window)` → display string. `"<app_id> — <title>"`; if either is missing
or empty (XWayland windows can lack `app_id`) fall back to whichever exists,
and to `"window <id>"` if neither does — a label must never render as a bare
dash or an empty row.
- `pick(labels, chooser)` → selected label (chooser injected, so tests pass a fake)
- `resolve(windows, label)` → window id
Only `main()` shells out to `niri msg` / `wofi` / `notify-send`.
wofi flags follow `powermenu.sh` house style: `--dmenu --prompt "Restore:"
--width 600 --height 400 --insensitive`.
### 3. `install.sh`
Add alongside the existing symlink block:
```bash
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
```
### 4. waybar
No change. The `minimized` pill appears automatically.
## Failure / edge cases
- **Not running under niri / IPC unavailable:** `niri msg` fails → notify and
exit non-zero rather than tracebacking.
- **Stash workspace missing** (declaration removed, or config not reloaded): the
script says so explicitly, naming the declaration. This is exactly the trap the
current config is in, so the failure must be loud rather than silent.
- **Nothing stashed:** `notify-send "No minimized windows"`, exit 0.
- **Picker cancelled** (empty wofi output): exit 0, do nothing.
- **Already focused on the stash:** after the offset no keybind reaches the
`minimized` workspace, but clicking its waybar pill still does. Restoring
"to the current workspace" would then move a window to the stash it is already
on — a silent no-op. Detect `current.id == stash.id` and
`notify-send "Already on the minimized workspace"`, exit 0. Windows there can
be moved out with the normal `Mod+Shift+1..9` binds.
- **Duplicate labels:** two windows with the same `app_id` and title are
indistinguishable in the menu; the first match is restored. Accepted — such
windows are indistinguishable to the user too, and repeated restores still
drain the stash. Rejected the alternative (printing window IDs in the menu) as
visual noise for a case that barely matters.
- **Minimizing the only window on a dynamic workspace:** that workspace
disappears, shifting the indices of later ones. This is pre-existing niri
behaviour for dynamic workspaces and is unaffected by the stash, which stays at
index 1 regardless.
- **Fullscreen / floating windows:** move into the stash like any other window.
## Testing
**Unit** — `scripts/tests/window-restore.test.py`, mirroring
`gamemode-watch.test.py`: feed the pure functions recorded `niri msg -j` JSON
fixtures and assert stash lookup (present and absent), filtering, labelling
(including the missing-`app_id` fallback), cancelled-pick, empty stash,
focused-on-stash, and duplicate-label resolution. The chooser is injected, so no
wofi.
**Manual** — the parts no unit test can reach:
Throughout, "first workspace" means the one `Mod+1` reaches (niri index 2, since
the stash holds index 1).
1. `niri validate -c niri/config.kdl` passes.
2. On the first workspace, `Mod+M` on a window: it vanishes and **focus stays on
the first workspace** (regression test for constraint 4 — if focus jumps to
the stash, `focus=false` is not working).
3. waybar shows the `minimized` pill.
4. `Mod+2` to the second workspace, `Mod+Shift+M`, pick the window: it appears
there and is focused.
5. `Mod+Shift+M` with an empty stash: "No minimized windows", no hang.
6. `Mod+1` lands on the first real workspace, **not** the stash — confirming the
offset. Spot-check `Mod+3` and `Mod+Shift+2` likewise.
7. Click the `minimized` pill in waybar to land on the stash, then `Mod+Shift+M`:
it declines rather than no-op'ing (see edge cases).
## Out of scope
- A dedicated scratchpad/dropdown terminal (one designated window toggled by a
single key). Considered and explicitly deferred; it is a separate feature.
- Restoring to a window's *original* workspace. Rejected during design: it needs
per-window origin tracking that can go stale.
- A waybar module showing a stash count — the workspace pill already signals it.
- Multi-monitor correctness: the stash is pinned to index 1 only on its own
output, so a second monitor's workspaces would start at 1 with no stash,
throwing off the `Mod+1..9` +1 offset there. Correct on this single-panel
(`eDP-1`) machine; latent if a second output is ever attached.
@@ -0,0 +1,279 @@
# Niri Native Scratchpad Design
**Date:** 2026-07-17
**Status:** Approved
## Summary
Replace the workspace-based minimize/stash with a **native Sway-style scratchpad
built into niri itself**, shipped as a small, rebase-friendly patch set on top of
a pinned upstream niri (`v26.04`, commit `8ed0da4`) and built via a custom Arch
package (`niri-scratchpad`).
- `Mod+M` moves the focused window to a hidden scratchpad.
- `Mod+Shift+M` shows the next scratchpad window as a floating, centered overlay
on the current output; pressing it again while that window is focused hides it;
repeated presses cycle through the stash (round-robin).
The scratchpad is a **global stash on niri's `Layout`**, not a workspace. This is
the whole point: the previous implementation used a declared named workspace,
which niri pins to index 1, forcing a `+1` offset on every `Mod+1..9` bind and
breaking on multi-monitor. A stash that is not a workspace has no index, never
appears in waybar, and is output-independent — so all of that goes away.
The old Python implementation (`scripts/window-restore.py`), the
`workspace "minimized"` declaration, and the `+1` offset are all removed.
## Motivation
The current minimize (`docs/superpowers/specs/2026-07-16-niri-minimize-design.md`)
works, but pays a structural tax:
- A declared named workspace is **always index 1** and cannot be moved, so every
index workspace bind is offset by one (`Mod+1``focus-workspace 2`).
- The stash is **per-output**, so a second monitor's workspaces start at index 1
with no stash, throwing the offset off — a latent multi-monitor bug.
- The `minimized` workspace is **permanently visible** as a waybar pill.
- Restore is an **external wofi picker**, not a real toggle — there is no
"show/hide/cycle" the way a scratchpad has.
A native scratchpad removes the workspace entirely, which removes every one of
these problems at the source.
## Architecture decision
Chosen: **patch niri's Rust source** (fork route), shipped as `.patch` files +
PKGBUILD. Rejected alternatives:
- *External Rust IPC daemon.* Would keep stock niri but cannot make a window
live "outside all workspaces" — niri's IPC has no primitive for a hidden
holding area, so a daemon would be forced back onto the named-workspace hack it
is meant to replace.
- *Full fork as submodule / separate repo.* More vendoring and rebase surface
than needed. A handful of `.patch` files against a pinned tag is the smallest
reviewable delta and is Arch-native to build.
## Verified niri internals (niri 26.04, commit 8ed0da4)
Read from source, not inferred. These are the load-bearing facts.
1. **Two `Action` enums, kept in sync by hand.**
- IPC/CLI `Action`: `niri-ipc/src/lib.rs:194` (derives clap + serde +
JsonSchema). Parsed by `niri msg action ...`.
- Config `Action`: `niri-config/src/binds.rs:102` (derives `knuffel::Decode`).
Parsed from `config.kdl`; this is what the runtime dispatch matches on.
- Bridged by hand-written `impl From<niri_ipc::Action> for Action` at
`niri-config/src/binds.rs:395`. The `From` impl is exhaustive over the IPC
enum, so every IPC variant needs a `From` arm; config-only variants need
none.
- knuffel maps PascalCase variants to kebab-case KDL nodes automatically
(`MoveWindowToScratchpad``move-window-to-scratchpad`). No-arg variant =
bare variant (cf. `Suspend,` at `binds.rs:106`). A `focus=false`-style
property = `#[knuffel(property(name = "focus"), default = true)] bool`
(cf. `binds.rs:228`).
2. **Runtime dispatch** is `State::do_action`, `src/input/mod.rs:650`, the big
`match action {` at line 659. Precedent handler `MoveWindowToWorkspace` at
`src/input/mod.rs:1283`: resolves target, calls
`self.niri.layout.move_to_workspace(...)`, then `queue_redraw_all()`.
3. **`Layout` owns cross-workspace state and already holds workspaceless live
tiles.** `Layout<W>` at `src/layout/mod.rs:336` has
`interactive_move: Option<InteractiveMoveState<W>>` (`mod.rs:353`) and
`dnd: Option<DndData<W>>` (`mod.rs:355`), each holding a live `Tile<W>` that
belongs to no workspace. This is the exact precedent for a stash.
4. **Remove/re-add primitives keep geometry.**
- `Layout::remove_window` (`src/layout/mod.rs:1112`) returns
`Option<RemovedTile<W>>`; `RemovedTile` (`mod.rs:492`) bundles the `Tile`
plus `width`, `is_full_width`, `is_floating` — everything to reinsert
identically. It auto-cleans emptied workspaces.
- `Layout::add_window` (`mod.rs:928`) takes `AddWindowTarget`
(`Auto`/`Output`/`Workspace(id)`/`NextTo`), a `bool is_floating`, and
`ActivateWindow`. `Monitor::add_tile` / `Workspace::add_tile` re-add a
prebuilt tile.
- The show/hide pattern is literally `move_to_output` (`mod.rs:3351`):
`remove_tile → stop_move_animations → add_tile(..., is_floating)`.
- `Tile<W>` (`src/layout/tile.rs:40`) persists floating geometry across
transitions: `floating_window_size` (`tile.rs:69`), `floating_pos`
(`tile.rs:76`), and preset width/height indices. So geometry survives
hide→show with no extra bookkeeping.
5. **Active output/workspace accessors** usable inside `do_action`:
`Layout::active_output` (`mod.rs:1586`), `Layout::active_workspace` /
`active_workspace_mut` (`mod.rs:1599` / `1613`). Used in handlers already at
`src/input/mod.rs:1289`.
6. **Mapped window & id:** `Mapped` at `src/window/mapped.rs:51`, `is_floating`
at line 98, `id: MappedId` at line 55.
## Target behavior
### Keys
| Key | Action | KDL |
|---|---|---|
| `Mod+M` | Move focused window to scratchpad (hidden). Focus falls to next window. | `move-window-to-scratchpad` |
| `Mod+Shift+M` | Show/toggle/cycle the scratchpad. | `scratchpad-show` |
### State
Two new fields on `Layout<W>`:
```rust
scratchpad: Vec<RemovedTile<W>>, // hidden windows, front = next to show
scratchpad_shown: Option<MappedId>, // the one currently visible, if any
```
`scratchpad_shown` is a *remembered id only*. A shown scratchpad window is an
otherwise-ordinary floating window living in a real workspace; the stash proper
is only the hidden `Vec`.
### `move-window-to-scratchpad` (`Mod+M`)
1. If nothing is focused → no-op.
2. `remove_window` the focused window → `RemovedTile`.
3. Push it onto the **back** of `scratchpad`.
4. If its id equals `scratchpad_shown`, clear `scratchpad_shown`.
5. `queue_redraw_all()`. Focus falls to the next window via niri's normal remove
path.
### `scratchpad-show` (`Mod+Shift+M`)
First, validate `scratchpad_shown`: if it names a window that no longer exists in
any workspace (closed or moved away), treat it as `None`.
| State | Behavior |
|---|---|
| `scratchpad_shown = Some(id)` **and** `id` is the focused window | **Hide:** `remove_window(id)`, push to back of `scratchpad`, clear `scratchpad_shown`. |
| `scratchpad_shown = Some(id)` **and** `id` not focused | **Focus** that window. Nothing added or removed. |
| `scratchpad_shown = None`, stash non-empty | **Show:** pop front → `add_window` onto the active workspace with `is_floating = true`, focused; set `scratchpad_shown`. Center on first show (see below). |
| `scratchpad_shown = None`, stash empty | No-op. |
Cycling is emergent: show→A(focused), press→hide A (to back), press→show B,
press→hide B, press→C … round-robin. Then `queue_redraw_all()`.
### Placement
On show, if the tile has **no remembered `floating_pos`** (never floated before),
set it so the window is **centered on the active output** at its natural size.
If it *does* have a remembered pos/size (shown, moved, hidden, shown again), reuse
it unchanged — `Tile` already persists this, so "remember where I left it" needs
no new storage, only the first-show centering tweak.
## Deliberate simplifications (accepted)
1. **At most one scratchpad window shown at a time.** Full Sway allows several
shown simultaneously; that needs a *set* of shown ids and multi-floating
management for negligible benefit. Single-shown is deterministic.
2. **A shown window is a normal floating window, not specially owned.** The stash
is only the hidden set. Moving a shown window with `Mod+Shift+N`, or closing
it, simply removes it from scratchpad tracking (`scratchpad_shown` cleared on
the staleness check). Re-stash with `Mod+M`. This avoids threading a
persistent per-window `is_scratchpad` flag through the codebase — which is
what Sway does and what we explicitly do not need.
## The patch set
Three logical patches on the pinned tag, ordered so each compiles:
**`0001-scratchpad-actions.patch`**
- `niri-ipc/src/lib.rs:194` — IPC `Action` variants `MoveWindowToScratchpad`,
`ScratchpadShow`.
- `niri-config/src/binds.rs:102` — config `Action` variants (kebab-case nodes
derived by knuffel).
- `niri-config/src/binds.rs:395``From` arms mapping IPC → config.
**`0002-scratchpad-stash.patch`**
- `src/layout/mod.rs:336``scratchpad` + `scratchpad_shown` fields (init in the
`Layout` constructor).
- `src/layout/mod.rs` — methods `move_to_scratchpad(&mut self, id)` and
`scratchpad_show(&mut self)`, modeled on `move_to_output` (`mod.rs:3351`),
reusing `remove_window` / `add_window` / `add_tile`. First-show centering.
- **Window-destroy hook:** on the unmap/destroy path, also drop the window from
`scratchpad` if present — a window closed *while stashed* is in no workspace,
so the normal handler will not find it. (Locate the unmap handler that today
calls into `layout.remove_window`; add a `scratchpad.retain(...)` alongside.)
**`0003-scratchpad-dispatch.patch`**
- `src/input/mod.rs:659` — two `do_action` arms:
`Action::MoveWindowToScratchpad``self.niri.layout.move_to_scratchpad(focused_id)`;
`Action::ScratchpadShow``self.niri.layout.scratchpad_show()`; each ending in
`queue_redraw_all()`.
### Known implementation risk
On hide, a stashed window must be released from keyboard focus, window-cast /
screencast targets, and frame-callback bookkeeping; on show, reattached. The
`interactive_move` / `dnd` live-tile handling is the model for a window that is
temporarily outside all workspaces. This is the single delicate area and is
covered by explicit manual tests below.
## Packaging
**`pkg/niri-scratchpad/PKGBUILD`**
- `pkgname=niri-scratchpad`, `provides=('niri')`, `conflicts=('niri')`.
- `source` = niri git at the pinned tag `v26.04` (commit `8ed0da4`) plus the
three local `.patch` files.
- `prepare()` applies the patches in order.
- `build()` = `cargo build --release --locked`.
- `package()` installs the `niri` binary and the session/portal files exactly as
the upstream `niri` package does (mirror the official PKGBUILD's `package()`).
**`niri-patches/`** — holds the three `.patch` files, the single source of truth
for the delta. Rebasing niri = bump the pin, re-fix these three files.
**`install.sh`**
- Remove the `window-restore` symlink line.
- Add a step that builds and installs `niri-scratchpad` via `makepkg -si`
(idempotent: skip if the installed `niri` already `provides` the patched
version / matches the pinned pkgver).
## Config changes (`niri/config.kdl`)
- Delete the `workspace "minimized"` declaration and its comment block.
- `Mod+1..9``focus-workspace 1..9` (drop the `+1`).
- `Mod+Shift+1..9``move-window-to-workspace 1..9` (drop the `+1`).
- `Mod+M``move-window-to-scratchpad`.
- `Mod+Shift+M``scratchpad-show`.
## Deletions
- `scripts/window-restore.py`
- `scripts/tests/window-restore.test.py`
## Testing
**Unit (in-tree, mirrors niri's existing `src/layout` tests, no compositor):**
- Stash push on move; front-pop / back-push ordering (round-robin).
- Show/hide toggle transitions of the state table.
- `scratchpad_show` on empty stash = no-op.
- Staleness: `scratchpad_shown` pointing at an absent window resolves to "nothing
shown".
- Destroy-while-stashed removes the entry from `scratchpad`.
**Manual acceptance (the parts no unit test reaches):**
1. `niri validate -c niri/config.kdl` passes.
2. `Mod+M` on a focused window: it vanishes, **focus moves to another window**
(regression guard for the focus-release risk).
3. `Mod+Shift+M`: the window reappears **centered on the current output**,
focused.
4. `Mod+Shift+M` again (window focused): it hides.
5. Stash two windows; `Mod+Shift+M` repeatedly cycles A→(hide)→B→(hide)→A.
6. Show a window, move/resize it, hide, show again: **same geometry**.
7. Close an app while it is stashed, then `Mod+Shift+M`: no crash, no stale
entry, cycle skips it.
8. Two monitors: `Mod+Shift+M` shows on the **focused** output; repeat on the
other output.
9. `Mod+1..9` land on workspaces `1..9` with **no offset**; the scratchpad has no
waybar pill.
10. `Mod+Shift+M` with an empty stash: nothing happens, no crash.
## Out of scope
- Multiple scratchpad windows shown simultaneously (single-shown by design).
- A persistent per-window `is_scratchpad` flag / Sway's exact ownership model.
- Upstreaming to niri (possible later; the patch is kept clean enough to try).
- A named/dropdown "Quake terminal" binding on top of the scratchpad — separate
feature, deferred.
- Animations for show/hide beyond niri's existing floating add/remove behavior.
+3
View File
@@ -0,0 +1,3 @@
[custom]
start=/home/alex/.local/bin/gamemode-session add feral
end=/home/alex/.local/bin/gamemode-session del feral
+11
View File
@@ -41,6 +41,8 @@ ln -sf "$(pwd)/gtk-4.0/settings.ini" ~/.config/gtk-4.0/settings.ini
ln -sf "$(pwd)/xdg/mimeapps.list" ~/.config/mimeapps.list
ln -sf "$(pwd)/environment.d/wayland.conf" ~/.config/environment.d/wayland.conf
ln -sf "$(pwd)/xdg-desktop-portal/portals.conf" ~/.config/xdg-desktop-portal/portals.conf
ln -sf "$(pwd)/mako/config" ~/.config/mako/config
ln -sf "$(pwd)/gamemode.ini" ~/.config/gamemode.ini
echo "==> Installing scripts"
mkdir -p ~/.local/bin
@@ -51,6 +53,15 @@ ln -sf "$(pwd)/scripts/fan-profile.sh" ~/.local/bin/fan-profile
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 "==> Building/installing patched niri (native scratchpad)"
if ! pacman -Q niri-scratchpad &>/dev/null; then
(cd "$(pwd)/pkg/niri-scratchpad" \
&& cp ../../niri-patches/0001-scratchpad.patch . \
&& makepkg -si --noconfirm)
fi
echo "==> Enabling systemd user services"
mkdir -p ~/.config/systemd/user
+15
View File
@@ -0,0 +1,15 @@
background-color=#1d1f21
text-color=#c5c8c6
border-size=2
border-color=#81a2be
default-timeout=4000
[mode=do-not-disturb]
invisible=1
# The volume OSD must survive both gamemode effects: DND (which gamemode-session
# turns on) would hide it, and niri draws fullscreen windows above the top layer.
# Listed after [mode=do-not-disturb] so it wins over invisible=1.
[app-name=volume-osd]
invisible=0
layer=overlay
+769
View File
@@ -0,0 +1,769 @@
diff --git a/niri-config/src/binds.rs b/niri-config/src/binds.rs
index 0be7596f..e6a56939 100644
--- a/niri-config/src/binds.rs
+++ b/niri-config/src/binds.rs
@@ -233,6 +233,7 @@ pub enum Action {
reference: WorkspaceReference,
focus: bool,
},
+ MoveWindowToScratchpad,
MoveColumnToWorkspaceDown(#[knuffel(property(name = "focus"), default = true)] bool),
MoveColumnToWorkspaceUp(#[knuffel(property(name = "focus"), default = true)] bool),
MoveColumnToWorkspace(
@@ -359,6 +360,7 @@ pub enum Action {
ClearDynamicCastTarget,
#[knuffel(skip)]
StopCast(u64),
+ ScratchpadShow,
ToggleOverview,
OpenOverview,
CloseOverview,
@@ -530,6 +532,7 @@ impl From<niri_ipc::Action> for Action {
reference: WorkspaceReference::from(reference),
focus,
},
+ niri_ipc::Action::MoveWindowToScratchpad {} => Self::MoveWindowToScratchpad,
niri_ipc::Action::MoveColumnToWorkspaceDown { focus } => {
Self::MoveColumnToWorkspaceDown(focus)
}
@@ -694,6 +697,7 @@ impl From<niri_ipc::Action> for Action {
}
niri_ipc::Action::ClearDynamicCastTarget {} => Self::ClearDynamicCastTarget,
niri_ipc::Action::StopCast { session_id } => Self::StopCast(session_id),
+ niri_ipc::Action::ScratchpadShow {} => Self::ScratchpadShow,
niri_ipc::Action::ToggleOverview {} => Self::ToggleOverview,
niri_ipc::Action::OpenOverview {} => Self::OpenOverview,
niri_ipc::Action::CloseOverview {} => Self::CloseOverview,
diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs
index 0aa3cb4f..a5b712e2 100644
--- a/niri-ipc/src/lib.rs
+++ b/niri-ipc/src/lib.rs
@@ -524,6 +524,8 @@ pub enum Action {
#[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set, default_value_t = true))]
focus: bool,
},
+ /// Move the focused window to the scratchpad (hide it).
+ MoveWindowToScratchpad {},
/// Move the focused column to the workspace below.
MoveColumnToWorkspaceDown {
/// Whether the focus should follow the target workspace.
@@ -908,6 +910,8 @@ pub enum Action {
#[cfg_attr(feature = "clap", arg(long))]
session_id: u64,
},
+ /// Show, toggle, or cycle the scratchpad.
+ ScratchpadShow {},
/// Toggle (open/close) the Overview.
ToggleOverview {},
/// Open the Overview.
diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs
index f38b2934..a085ff70 100644
--- a/src/handlers/compositor.rs
+++ b/src/handlers/compositor.rs
@@ -24,6 +24,7 @@ use crate::layout::{ActivateWindow, AddWindowTarget, LayoutElement as _};
use crate::niri::{CastTarget, ClientState, LockState, State};
use crate::utils::transaction::Transaction;
use crate::utils::{is_mapped, send_scale_transform};
+use crate::window::mapped::MappedId;
use crate::window::{InitialConfigureState, Mapped, ResolvedWindowRules, Unmapped};
impl CompositorHandler for State {
@@ -368,6 +369,52 @@ impl CompositorHandler for State {
return;
}
+ // A window stashed in the scratchpad is in no workspace, so the lookup
+ // above can't see it — but its client still owns the surface and may
+ // unmap it at any time. Handle that here, otherwise the tile would sit
+ // in the stash with a bufferless surface and never be reinstated into
+ // unmapped_windows, so a later re-map could not fire and the window
+ // would be permanently wedged.
+ if !is_mapped(surface) {
+ if let Some(removed) = self.niri.layout.remove_from_scratchpad(surface) {
+ trace!("stashed toplevel got unmapped");
+
+ let window = removed.window().window.clone();
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
+ // LayoutElement trait also has an id(), returning &Window.
+ let id: MappedId = removed.window().id();
+ // Drop the Mapped here so its mapped-toplevel pre-commit hook is
+ // torn down before we install the default one. The two are
+ // distinct HookIds (Mapped::drop removes only its own), so this
+ // ordering is tidiness, not correctness.
+ drop(removed);
+
+ window.on_commit();
+
+ self.niri
+ .stop_casts_for_target(CastTarget::Window { id: id.get() });
+ self.add_default_dmabuf_pre_commit_hook(surface);
+
+ // Newly-unmapped toplevels must perform the initial
+ // commit-configure sequence afresh.
+ let unmapped = Unmapped::new(window);
+ self.niri.unmapped_windows.insert(surface.clone(), unmapped);
+
+ // Nothing else to do: a stashed window is in no workspace, so it
+ // can't be the focus and isn't rendered on any output. It is also
+ // absent from window_mru_ui, unless the overlay happened to be
+ // open when it was stashed — that leaves a stale thumbnail, which
+ // the MRU lookups bail on harmlessly.
+ //
+ // A stashed window that commits while still mapped falls through
+ // to the unrecognized-surface trace below. Buffer state is still
+ // handled (on_commit_buffer_handler runs unconditionally), so only
+ // Window-level geometry/popup bookkeeping goes stale, and that
+ // self-heals on the first commit after the window is shown.
+ return;
+ }
+ }
+
// This is a commit of a non-toplevel root.
}
diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs
index 38440c90..fe16901f 100644
--- a/src/handlers/xdg_shell.rs
+++ b/src/handlers/xdg_shell.rs
@@ -47,6 +47,7 @@ use crate::utils::transaction::Transaction;
use crate::utils::{
get_monotonic_time, output_matches_name, send_scale_transform, update_tiled_state, ResizeEdge,
};
+use crate::window::mapped::MappedId;
use crate::window::{InitialConfigureState, ResolvedWindowRules, Unmapped, WindowRef};
impl XdgShellHandler for State {
@@ -834,6 +835,33 @@ impl XdgShellHandler for State {
.find_window_and_output(surface.wl_surface());
let Some((mapped, output)) = win_out else {
+ // A window whose client died while stashed in the scratchpad is in no
+ // workspace, so find_window_and_output can't see it; evict it here.
+ //
+ // Casts must still be stopped: a Cast lives in niri.casting, independent
+ // of the layout, and the render loop just skips ids it can't find — so a
+ // cast of a stashed window would otherwise stay live and frozen forever.
+ //
+ // The rest of the teardown below is genuinely not needed here:
+ // window_mru_ui and layout.focus() are both derived from
+ // layout.workspaces(), so a stashed window is in neither; and there is
+ // nothing on screen to snapshot, animate closed, or redraw.
+ //
+ // We also deliberately skip re-adding the default dmabuf pre-commit hook
+ // that the normal path installs. Mapped::drop removes only its own
+ // mapped-toplevel hook (a distinct HookId), and the surface cannot gain a
+ // new role after toplevel destruction, so nothing will sample it again;
+ // destroyed() removes hooks with `if let Some(..)`, making the absent
+ // entry a no-op rather than an error.
+ if let Some(removed) = self.niri.layout.remove_from_scratchpad(surface.wl_surface()) {
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
+ // LayoutElement trait also has an id(), returning &Window.
+ let id: MappedId = removed.window().id();
+ drop(removed);
+ self.niri
+ .stop_casts_for_target(CastTarget::Window { id: id.get() });
+ return;
+ }
// I have no idea how this can happen, but I saw it happen once, in a weird interaction
// involving laptop going to sleep and resuming.
error!("toplevel missing from both unmapped_windows and layout");
diff --git a/src/input/mod.rs b/src/input/mod.rs
index f1ad4932..87b5ea7a 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -54,6 +54,7 @@ use crate::ui::mru::{WindowMru, WindowMruUi};
use crate::ui::screenshot_ui::ScreenshotUi;
use crate::utils::spawning::{spawn, spawn_sh};
use crate::utils::{center, get_monotonic_time, CastSessionId, ResizeEdge};
+use crate::window::mapped::MappedId;
pub mod backend_ext;
pub mod move_grab;
@@ -647,6 +648,38 @@ impl State {
}
}
+ /// Ids of all windows currently in the layout. Excludes the scratchpad stash,
+ /// which is not part of any workspace.
+ fn layout_window_ids(&self) -> HashSet<u64> {
+ self.niri
+ .layout
+ .windows()
+ .map(|(_, mapped)| {
+ // Annotated to pin the inherent Mapped::id() -> MappedId; the
+ // LayoutElement trait also has an id(), returning &Window.
+ let id: MappedId = mapped.id();
+ id.get()
+ })
+ .collect()
+ }
+
+ /// Stop screencasts of windows that were in the layout before an action and are
+ /// gone after it. A stashed window is never rendered, so its cast would otherwise
+ /// sit frozen on its last frame indefinitely; stopping is the chosen policy over
+ /// rendering stashed tiles.
+ ///
+ /// Note the carve-out inherited from `stop_casts_for_target`: a cast with a
+ /// *dynamic* target is not ended, it is retargeted to `CastTarget::Nothing`. So a
+ /// follow-focus cast survives a stash and goes blank rather than stopping. That is
+ /// shared behaviour with every other stop site, not specific to the scratchpad.
+ fn stop_casts_for_windows_gone(&mut self, before: HashSet<u64>) {
+ let after = self.layout_window_ids();
+ for id in before.difference(&after) {
+ self.niri
+ .stop_casts_for_target(CastTarget::Window { id: *id });
+ }
+ }
+
pub fn do_action(&mut self, action: Action, allow_when_locked: bool) {
if self.niri.is_locked() && !(allow_when_locked || allowed_when_locked(&action)) {
return;
@@ -1376,6 +1409,15 @@ impl State {
}
}
}
+ Action::MoveWindowToScratchpad => {
+ let focus = self.niri.layout.focus().map(|m| m.window.clone());
+ if let Some(window) = focus {
+ let before = self.layout_window_ids();
+ self.niri.layout.move_to_scratchpad(&window);
+ self.stop_casts_for_windows_gone(before);
+ self.niri.queue_redraw_all();
+ }
+ }
Action::MoveColumnToWorkspaceDown(focus) => {
self.niri.layout.move_column_to_workspace_down(focus);
self.maybe_warp_cursor_to_focus();
@@ -2245,6 +2287,13 @@ impl State {
Action::StopCast(session_id) => {
self.niri.stop_cast(CastSessionId::from(session_id));
}
+ Action::ScratchpadShow => {
+ // The hide branch of scratchpad_show also stashes a window.
+ let before = self.layout_window_ids();
+ self.niri.layout.scratchpad_show();
+ self.stop_casts_for_windows_gone(before);
+ self.niri.queue_redraw_all();
+ }
Action::ToggleOverview => {
self.niri.layout.toggle_overview();
self.niri.queue_redraw_all();
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 5c4dd639..8696fcd9 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -32,6 +32,7 @@
//! don't want an unassuming workspace to end up on it.
use std::collections::HashMap;
+use std::collections::VecDeque;
use std::mem;
use std::rc::Rc;
use std::time::Duration;
@@ -353,6 +354,11 @@ pub struct Layout<W: LayoutElement> {
interactive_move: Option<InteractiveMoveState<W>>,
/// Ongoing drag-and-drop operation.
dnd: Option<DndData<W>>,
+ /// Windows hidden in the scratchpad, front = next to show. Not part of any
+ /// workspace, so they have no index and never appear in the workspace list.
+ scratchpad: VecDeque<RemovedTile<W>>,
+ /// Id of the scratchpad window currently shown as a floating overlay, if any.
+ scratchpad_shown: Option<W::Id>,
/// Clock for driving animations.
clock: Clock,
/// Time that we last updated render elements for.
@@ -489,6 +495,7 @@ pub enum ConfigureIntent {
}
/// Tile that was just removed from the layout.
+#[derive(Debug)]
pub struct RemovedTile<W: LayoutElement> {
tile: Tile<W>,
/// Width of the column the tile was in.
@@ -499,6 +506,12 @@ pub struct RemovedTile<W: LayoutElement> {
is_floating: bool,
}
+impl<W: LayoutElement> RemovedTile<W> {
+ pub fn window(&self) -> &W {
+ self.tile.window()
+ }
+}
+
/// Whether to activate a newly added window.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ActivateWindow {
@@ -699,6 +712,8 @@ impl<W: LayoutElement> Layout<W> {
last_active_workspace_id: HashMap::new(),
interactive_move: None,
dnd: None,
+ scratchpad: VecDeque::new(),
+ scratchpad_shown: None,
clock,
update_render_elements_time: Duration::ZERO,
overview_open: false,
@@ -724,6 +739,8 @@ impl<W: LayoutElement> Layout<W> {
last_active_workspace_id: HashMap::new(),
interactive_move: None,
dnd: None,
+ scratchpad: VecDeque::new(),
+ scratchpad_shown: None,
clock,
update_render_elements_time: Duration::ZERO,
overview_open: false,
@@ -1114,6 +1131,13 @@ impl<W: LayoutElement> Layout<W> {
window: &W::Id,
transaction: Transaction,
) -> Option<RemovedTile<W>> {
+ // A window may be sitting in the scratchpad (not in any workspace).
+ // Drop it here so both toplevel-destroy paths and the close op cover it.
+ self.scratchpad.retain(|rt| rt.tile.window().id() != window);
+ if self.scratchpad_shown.as_ref() == Some(window) {
+ self.scratchpad_shown = None;
+ }
+
if let Some(state) = &self.interactive_move {
match state {
InteractiveMoveState::Starting { window_id, .. } => {
@@ -1204,6 +1228,164 @@ impl<W: LayoutElement> Layout<W> {
None
}
+ /// Hide `window` into the scratchpad. Focus falls to the next window via the
+ /// normal remove path. No-op if the window is not in the layout.
+ pub fn move_to_scratchpad(&mut self, window: &W::Id) {
+ let Some(removed) = self.remove_window(window, Transaction::new()) else {
+ return;
+ };
+ self.scratchpad.push_back(removed);
+ // scratchpad_shown is cleared by the eviction hook in remove_window
+ // (Task 4) if this window was the one shown.
+ }
+
+ /// Evict a stashed window from the scratchpad by its surface, returning the
+ /// tile so the caller can run the rest of the teardown (casts, unmapped
+ /// bookkeeping). Needed on both the destroy and unmap paths: a window that
+ /// dies or unmaps *while stashed* is in no workspace, so
+ /// `find_window_and_output` (and therefore `remove_window`) never sees it —
+ /// this is the only thing that takes it out. A shown scratchpad window lives
+ /// in a workspace and is handled by `remove_window` instead.
+ pub fn remove_from_scratchpad(&mut self, surface: &WlSurface) -> Option<RemovedTile<W>> {
+ let idx = self
+ .scratchpad
+ .iter()
+ .position(|rt| rt.tile.window().is_wl_surface(surface))?;
+ self.scratchpad.remove(idx)
+ }
+
+ /// Show / toggle / cycle the scratchpad. See the state table in the plan.
+ pub fn scratchpad_show(&mut self) {
+ // Drop a stale "shown" id (window was closed or moved away).
+ if let Some(id) = self.scratchpad_shown.clone() {
+ if !self.has_window(&id) {
+ self.scratchpad_shown = None;
+ }
+ }
+
+ if let Some(id) = self.scratchpad_shown.clone() {
+ let focused = self.focus().map(|w| w.id().clone());
+ if focused.as_ref() == Some(&id) {
+ // shown + focused -> hide it
+ if let Some(removed) = self.remove_window(&id, Transaction::new()) {
+ self.scratchpad.push_back(removed);
+ }
+ self.scratchpad_shown = None;
+ } else {
+ // shown + not focused -> bring it to the monitor you are on, then
+ // focus it. `activate_window` alone would move *focus* to whichever
+ // monitor the window is parked on (it sets active_monitor_idx),
+ // dragging you across outputs; a scratchpad should come to you.
+ // Same monitor: nothing to move, just focus.
+ if self.monitor_idx_of_window(&id) != self.active_monitor_idx() {
+ if let Some(mut removed) = self.remove_window(&id, Transaction::new()) {
+ // remove_window cleared scratchpad_shown; re-set it below
+ // once the tile is placed.
+ removed.tile.stop_move_animations();
+
+ let Some(ws_id) = self.active_workspace().map(|ws| ws.id()) else {
+ self.scratchpad.push_front(removed); // no active ws
+ return;
+ };
+
+ match self.add_removed_tile_floating(ws_id, removed) {
+ Ok(()) => self.scratchpad_shown = Some(id),
+ // Could not place it; stash rather than drop it.
+ Err(tile) => self.scratchpad.push_front(tile),
+ }
+ return;
+ }
+ }
+ self.activate_window(&id);
+ }
+ return;
+ }
+
+ // nothing shown -> show the front of the stash
+ let Some(mut removed) = self.scratchpad.pop_front() else {
+ return;
+ };
+ removed.tile.stop_move_animations();
+
+ // First-show centering: niri's floating layer places a position-less tile
+ // using its own default placement, which is acceptable for now. Deferring
+ // precise centering geometry to manual verification (Task 7); no unit test
+ // exercises this here.
+
+ let id = removed.tile.window().id().clone();
+ let ws_id = match self.active_workspace() {
+ Some(ws) => ws.id(),
+ None => {
+ self.scratchpad.push_front(removed); // no active ws; put it back
+ return;
+ }
+ };
+ match self.add_removed_tile_floating(ws_id, removed) {
+ Ok(()) => self.scratchpad_shown = Some(id),
+ // Could not place it; stash rather than drop it.
+ Err(tile) => self.scratchpad.push_front(tile),
+ }
+ }
+
+ /// Index of the monitor holding `window`, or None if it is not in the layout
+ /// (which includes every window stashed in the scratchpad).
+ fn monitor_idx_of_window(&self, window: &W::Id) -> Option<usize> {
+ let MonitorSet::Normal { monitors, .. } = &self.monitor_set else {
+ return None;
+ };
+ monitors.iter().position(|mon| {
+ mon.workspaces
+ .iter()
+ .any(|ws| ws.windows().any(|win| win.id() == window))
+ })
+ }
+
+ /// Index of the currently active monitor, if there are any outputs.
+ fn active_monitor_idx(&self) -> Option<usize> {
+ match &self.monitor_set {
+ MonitorSet::Normal {
+ active_monitor_idx, ..
+ } => Some(*active_monitor_idx),
+ _ => None,
+ }
+ }
+
+ /// Re-add a previously-removed tile to the workspace `ws_id`, forcing it onto
+ /// the floating layer. Mirrors the re-add pattern in `move_to_workspace`.
+ ///
+ /// Returns the tile back in `Err` when it could not be placed, so the caller
+ /// can stash it. Dropping it here would destroy the window while its client
+ /// is still alive, leaving it permanently wedged with no surface anywhere.
+ fn add_removed_tile_floating(
+ &mut self,
+ ws_id: WorkspaceId,
+ removed: RemovedTile<W>,
+ ) -> Result<(), RemovedTile<W>> {
+ let MonitorSet::Normal { monitors, .. } = &mut self.monitor_set else {
+ return Err(removed);
+ };
+ let Some(mon) = monitors
+ .iter_mut()
+ .find(|mon| mon.workspaces.iter().any(|ws| ws.id() == ws_id))
+ else {
+ return Err(removed);
+ };
+
+ mon.add_tile(
+ removed.tile,
+ MonitorAddWindowTarget::Workspace {
+ id: ws_id,
+ column_idx: None,
+ },
+ ActivateWindow::Yes,
+ true,
+ removed.width,
+ removed.is_full_width,
+ true,
+ );
+ Ok(())
+ }
+
pub fn descendants_added(&mut self, id: &W::Id) -> bool {
for ws in self.workspaces_mut() {
if ws.descendants_added(id) {
@@ -2444,6 +2626,24 @@ impl<W: LayoutElement> Layout<W> {
}
}
+ // Scratchpad invariants: stashed windows belong to no workspace, and the
+ // shown window (if any) belongs to a workspace. This must run for both
+ // MonitorSet variants, so it lives before the match below (the
+ // MonitorSet::NoOutputs branch returns early).
+ for tile in &self.scratchpad {
+ let id = tile.tile.window().id();
+ assert!(
+ !self.has_window(id),
+ "scratchpad window {id:?} must not also be in a workspace",
+ );
+ }
+ if let Some(id) = &self.scratchpad_shown {
+ assert!(
+ self.has_window(id),
+ "scratchpad_shown window {id:?} must be present in a workspace",
+ );
+ }
+
let mut seen_workspace_id = HashSet::new();
let mut seen_workspace_name = Vec::<String>::new();
diff --git a/src/layout/tests.rs b/src/layout/tests.rs
index 31265dd9..4c22a115 100644
--- a/src/layout/tests.rs
+++ b/src/layout/tests.rs
@@ -753,6 +753,8 @@ enum Op {
#[proptest(strategy = "arbitrary_layout_part().prop_map(Box::new)")]
layout_config: Box<niri_config::LayoutPart>,
},
+ ScratchpadStash(#[proptest(strategy = "1..=5usize")] usize),
+ ScratchpadShow,
}
impl Op {
@@ -1625,6 +1627,12 @@ impl Op {
layout.update_options(options);
}
+ Op::ScratchpadStash(id) => {
+ layout.move_to_scratchpad(&id);
+ }
+ Op::ScratchpadShow => {
+ layout.scratchpad_show();
+ }
}
}
}
@@ -3929,3 +3937,225 @@ proptest! {
check_ops_with_options(options, ops);
}
}
+
+#[test]
+fn scratchpad_stash_removes_window_from_workspace() {
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) },
+ Op::ScratchpadStash(0),
+ ];
+ let layout = check_ops(ops);
+
+ // The window left every workspace...
+ assert!(!layout.has_window(&0), "window should not be in any workspace");
+ // ...and now lives in the stash.
+ assert_eq!(layout.scratchpad.len(), 1);
+ assert_eq!(layout.scratchpad.back().unwrap().tile.window().id(), &0);
+}
+
+#[test]
+fn scratchpad_show_brings_window_back_focused() {
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) },
+ Op::ScratchpadStash(0),
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ assert!(layout.scratchpad.is_empty(), "stash drained on show");
+ assert!(layout.has_window(&0), "window returned to a workspace");
+ assert_eq!(layout.scratchpad_shown, Some(0));
+ assert_eq!(layout.focus().map(|w| *w.id()), Some(0), "shown window is focused");
+}
+
+#[test]
+fn scratchpad_show_twice_hides_again() {
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) },
+ Op::ScratchpadStash(0),
+ Op::ScratchpadShow, // show
+ Op::ScratchpadShow, // focused -> hide
+ ];
+ let layout = check_ops(ops);
+
+ assert_eq!(layout.scratchpad.len(), 1, "window back in stash");
+ assert!(!layout.has_window(&0));
+ assert_eq!(layout.scratchpad_shown, None);
+}
+
+#[test]
+fn scratchpad_show_cycles_round_robin() {
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) },
+ Op::AddWindow { params: TestWindowParams::new(1) },
+ Op::ScratchpadStash(0), // stash: [0]
+ Op::ScratchpadStash(1), // stash: [0, 1]
+ Op::ScratchpadShow, // show 0 (front)
+ Op::ScratchpadShow, // 0 focused -> hide 0 -> stash: [1, 0]
+ Op::ScratchpadShow, // show 1 (new front)
+ ];
+ let layout = check_ops(ops);
+
+ assert_eq!(layout.scratchpad_shown, Some(1), "cycled to the next window");
+ assert!(layout.has_window(&1));
+}
+
+#[test]
+fn scratchpad_show_empty_is_noop() {
+ let ops = [Op::AddOutput(1), Op::ScratchpadShow];
+ let layout = check_ops(ops);
+ assert_eq!(layout.scratchpad_shown, None);
+ assert!(layout.scratchpad.is_empty());
+}
+
+#[test]
+fn scratchpad_evicts_closed_stashed_window() {
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) },
+ Op::AddWindow { params: TestWindowParams::new(1) },
+ Op::ScratchpadStash(0),
+ Op::ScratchpadStash(1),
+ Op::CloseWindow(0), // close a window while it is stashed
+ ];
+ let layout = check_ops(ops);
+
+ assert_eq!(layout.scratchpad.len(), 1, "closed window evicted from stash");
+ assert_eq!(layout.scratchpad.front().unwrap().tile.window().id(), &1);
+}
+
+#[test]
+fn scratchpad_shows_on_the_focused_output() {
+ // First, confirm that while stashed the window lives on no monitor at all (checked against
+ // a standalone run so `check_ops`'s post-op invariant checks still see a fully-formed op
+ // sequence).
+ let stash_only_ops = [
+ Op::AddOutput(1),
+ Op::AddOutput(2),
+ Op::FocusOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
+ Op::ScratchpadStash(0),
+ ];
+ let stashed_layout = check_ops(stash_only_ops);
+ {
+ let MonitorSet::Normal { monitors, .. } = &stashed_layout.monitor_set else {
+ unreachable!()
+ };
+ assert!(
+ monitors.iter().all(|m| !m.windows().any(|w| *w.id() == 0)),
+ "stashed window must not be present on any monitor"
+ );
+ }
+
+ // Stash on output 1, then switch focus to output 2 before showing.
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddOutput(2),
+ Op::FocusOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
+ Op::ScratchpadStash(0),
+ Op::FocusOutput(2),
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
+ unreachable!()
+ };
+
+ // The window must appear on whichever output is currently focused (output 2), not on the
+ // output it was originally stashed from (output 1).
+ for (idx, monitor) in monitors.iter().enumerate() {
+ let has_window = monitor.windows().any(|w| *w.id() == 0);
+ if idx == *active_monitor_idx {
+ assert!(has_window, "shown window should be on the focused output");
+ } else {
+ assert!(!has_window, "shown window should not be on a non-focused output");
+ }
+ }
+}
+
+#[test]
+fn scratchpad_shows_on_origin_output_when_focus_unchanged() {
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddOutput(2),
+ Op::FocusOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
+ Op::ScratchpadStash(0),
+ // no focus change here
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
+ unreachable!()
+ };
+
+ // Focus never left output 1, so the window should reappear there.
+ assert_eq!(*active_monitor_idx, 0, "output 1 should still be active");
+ assert!(
+ monitors[*active_monitor_idx].windows().any(|w| *w.id() == 0),
+ "shown window should be on the still-focused origin output"
+ );
+}
+
+#[test]
+fn scratchpad_show_pulls_already_shown_window_to_focused_output() {
+ // Show on output 1, walk to output 2, then press show again. The window must
+ // come to output 2 — not drag focus back to output 1, which is what plain
+ // `activate_window` would do.
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddOutput(2),
+ Op::FocusOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) }, // lands on output 1
+ Op::ScratchpadStash(0),
+ Op::ScratchpadShow, // shown on output 1
+ Op::FocusOutput(2), // shown, but no longer focused
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ let MonitorSet::Normal { monitors, active_monitor_idx, .. } = &layout.monitor_set else {
+ unreachable!()
+ };
+
+ assert_eq!(*active_monitor_idx, 1, "focus must stay on output 2");
+ assert!(
+ monitors[1].windows().any(|w| *w.id() == 0),
+ "window should have been pulled to output 2"
+ );
+ assert!(
+ !monitors[0].windows().any(|w| *w.id() == 0),
+ "window must not be left behind on output 1"
+ );
+ assert_eq!(layout.scratchpad_shown, Some(0), "still tracked as shown");
+ assert!(layout.scratchpad.is_empty(), "must not be re-stashed");
+}
+
+#[test]
+fn scratchpad_show_focuses_in_place_on_same_output() {
+ // Same output, shown but not focused: focus it without moving it.
+ let ops = [
+ Op::AddOutput(1),
+ Op::AddWindow { params: TestWindowParams::new(0) },
+ Op::ScratchpadStash(0),
+ Op::ScratchpadShow, // shown and focused
+ Op::AddWindow { params: TestWindowParams::new(1) }, // steals focus
+ Op::ScratchpadShow,
+ ];
+ let layout = check_ops(ops);
+
+ assert_eq!(layout.scratchpad_shown, Some(0));
+ assert!(layout.scratchpad.is_empty(), "must not be re-stashed");
+ assert_eq!(
+ layout.focus().map(|w| *w.id()),
+ Some(0),
+ "shown-but-unfocused window should get focus, not be hidden"
+ );
+}
+17 -10
View File
@@ -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; }
@@ -56,17 +57,28 @@ binds {
Mod+D { spawn "wofi" "--show" "drun"; }
Mod+E { spawn "thunar"; }
Mod+Shift+B { spawn "waybar-restart"; }
Mod+G { spawn "gamemode-session" "toggle"; }
Mod+H { focus-column-left; }
Mod+L { focus-column-right; }
Mod+J { focus-window-down; }
Mod+K { focus-window-up; }
Mod+Left { focus-column-left; }
Mod+Right { focus-column-right; }
Mod+Down { focus-window-down; }
Mod+Up { focus-window-up; }
Mod+Shift+H { move-column-left; }
Mod+Shift+L { move-column-right; }
Mod+Shift+J { move-window-down; }
Mod+Shift+K { move-window-up; }
Mod+Shift+Left { move-column-left; }
Mod+Shift+Right { move-column-right; }
Mod+Shift+Down { move-window-down; }
Mod+Shift+Up { move-window-up; }
Mod+1 { focus-workspace 1; }
Mod+2 { focus-workspace 2; }
Mod+3 { focus-workspace 3; }
@@ -89,8 +101,8 @@ binds {
Mod+Print { spawn "screenshot"; }
Mod+M { move-window-to-workspace "minimized"; }
Mod+Shift+M { focus-workspace "minimized"; }
Mod+M { move-window-to-scratchpad; }
Mod+Shift+M { scratchpad-show; }
Mod+Shift+E { spawn "hyprlock"; }
Mod+Shift+P { spawn "powermenu"; }
@@ -107,7 +119,9 @@ binds {
// ── Window Rules ─────────────────────────────────────────────────────────────
// Fullscreen games: no border, open fullscreen, enable VRR
// Fullscreen games: no border, open fullscreen.
// (Re-add `variable-refresh-rate true` here if a VRR-capable external
// monitor is ever attached — the internal eDP-1 panel has no VRR.)
window-rule {
match app-id="steam_app_"
open-fullscreen true
@@ -117,13 +131,6 @@ window-rule {
focus-ring {
off
}
variable-refresh-rate true
}
// Generic fullscreen request (any app asking to go fullscreen)
window-rule {
match is-focused=true
variable-refresh-rate true
}
// Wine System Tray: hide the empty host window off-screen
+6
View File
@@ -0,0 +1,6 @@
src/
pkg/
niri/
*.pkg.tar.*
*.log
0001-scratchpad.patch
+48
View File
@@ -0,0 +1,48 @@
# Maintainer: alex <funman300@gmail.com>
pkgname=niri-scratchpad
pkgver=26.04
pkgrel=1
_commit=8ed0da4
pkgdesc="niri with a native Sway-style scratchpad (local patch set)"
arch=('x86_64')
url="https://github.com/YaLTeR/niri"
license=('GPL-3.0-or-later')
depends=('cairo' 'glib2' 'glibc' 'libdisplay-info' 'libgcc' 'libinput'
'libpipewire' 'libxkbcommon' 'mesa' 'pango' 'pixman' 'seatd'
'systemd-libs' 'xdg-desktop-portal-impl')
makedepends=('git' 'cargo' 'clang' 'mesa')
provides=('niri')
conflicts=('niri')
options=('!lto')
source=("niri::git+https://github.com/YaLTeR/niri.git#commit=${_commit}"
"0001-scratchpad.patch")
sha256sums=('SKIP'
'SKIP')
prepare() {
cd niri
git apply "${srcdir}/0001-scratchpad.patch"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd niri
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build --release --frozen
}
package() {
cd niri
install -Dm755 target/release/niri "${pkgdir}/usr/bin/niri"
install -Dm755 resources/niri-session "${pkgdir}/usr/bin/niri-session"
install -Dm644 resources/niri.desktop \
"${pkgdir}/usr/share/wayland-sessions/niri.desktop"
install -Dm644 resources/niri-portals.conf \
"${pkgdir}/usr/share/xdg-desktop-portal/niri-portals.conf"
install -Dm644 resources/niri.service \
"${pkgdir}/usr/lib/systemd/user/niri.service"
install -Dm644 resources/niri-shutdown.target \
"${pkgdir}/usr/lib/systemd/user/niri-shutdown.target"
}
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""Live acceptance / regression test for the niri native scratchpad patch.
Drives a real (patched) niri through its own IPC — the exact `do_action`
path a keybind hits — and asserts on `niri msg -j windows`. No keyboard
injection, so it can't type into the host session's windows. Each test gets
its own fresh nested niri, so cases are hermetic and order-independent.
Run this after rebasing the patch onto a new niri release to confirm the
scratchpad still behaves. It needs:
- a running Wayland (or X) session to host the nested winit window,
- the patched niri binary (built from niri-patches/ via the PKGBUILD, or a
dev checkout),
- `alacritty` as the throwaway test client.
Usage:
python3 acceptance.py [PATH_TO_NIRI]
Binary resolution order: argv[1], $NIRI_BIN, `niri` on PATH,
~/build/niri-scratchpad/target/release/niri. The binary MUST be patched
(its `niri msg action --help` must list `scratchpad-show`).
Exit code 0 iff every non-skipped test passes.
"""
import glob, json, os, shutil, signal, subprocess, sys, tempfile, time
MINIMAL_CONFIG = """\
// Hermetic config for scratchpad acceptance: no startup spawns.
input { keyboard { xkb { } } }
binds {
Mod+M { move-window-to-scratchpad; }
Mod+Shift+M { scratchpad-show; }
Mod+Q { close-window; }
}
"""
def resolve_niri():
for cand in (sys.argv[1] if len(sys.argv) > 1 else None,
os.environ.get("NIRI_BIN"),
shutil.which("niri"),
os.path.expanduser("~/build/niri-scratchpad/target/release/niri")):
if cand and os.path.exists(cand):
return cand
sys.exit("ERROR: no niri binary found (pass a path, set $NIRI_BIN, or "
"install niri-scratchpad).")
NIRI = resolve_niri()
RT = os.environ["XDG_RUNTIME_DIR"]
class Niri:
"""A fresh nested niri instance driven over IPC."""
def __init__(self, config_path):
before = set(glob.glob(f"{RT}/niri.*.sock"))
self.proc = subprocess.Popen(
[NIRI, "-c", config_path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.sock = None
t0 = time.time()
while time.time() - t0 < 15:
new = set(glob.glob(f"{RT}/niri.*.sock")) - before
if new:
self.sock = new.pop()
break
time.sleep(0.2)
if not self.sock:
self.close()
raise RuntimeError("nested niri socket never appeared")
self.env = dict(os.environ, NIRI_SOCKET=self.sock)
for _ in range(40): # wait for IPC to answer
if self.alive():
break
time.sleep(0.2)
def msg(self, *args):
r = subprocess.run([NIRI, "msg", "-j", *args],
capture_output=True, text=True, env=self.env)
return json.loads(r.stdout) if r.stdout.strip() else None
def action(self, *args):
return subprocess.run([NIRI, "msg", "action", *args],
capture_output=True, text=True, env=self.env)
def alive(self):
return self.msg("version") is not None
def windows(self):
return self.msg("windows") or []
def wait_count(self, n, timeout=8):
t0 = time.time()
while time.time() - t0 < timeout:
if len(self.windows()) == n:
return self.windows()
time.sleep(0.15)
return self.windows()
def spawn_window(self, timeout=30):
"""Spawn an alacritty window and return its id once it appears.
The wait is deliberately generous: every test launches its own nested
niri, so a cold alacritty start can occasionally be slow under that
load. Do NOT convert this into a retry — re-issuing the spawn risks a
second window arriving late and corrupting the window counts the tests
assert on. A slow test beats a flaky one.
"""
before = {w["id"] for w in self.windows()}
self.action("spawn", "--", "alacritty")
t0 = time.time()
while time.time() - t0 < timeout:
new = [w for w in self.windows() if w["id"] not in before]
if new:
return new[0]["id"]
time.sleep(0.15)
raise RuntimeError(
f"spawned window never appeared within {timeout}s "
f"(compositor alive={self.alive()})")
def by_id(self, i):
return next((w for w in self.windows() if w["id"] == i), None)
def focused(self):
return next((w for w in self.windows() if w["is_focused"]), None)
def floating_pos(self, i, tries=12):
# tile_pos_in_workspace_view is transiently null right after a show.
for _ in range(tries):
w = self.by_id(i)
p = w["layout"].get("tile_pos_in_workspace_view") if w else None
if p is not None:
return p
time.sleep(0.08)
return None
def output_size(self):
outs = self.msg("outputs") or {}
for o in outs.values():
lg = o.get("logical") or {}
if lg.get("width") and lg.get("height"):
return lg["width"], lg["height"]
return None
def close(self):
try:
self.proc.send_signal(signal.SIGTERM)
except Exception:
pass
try:
self.proc.wait(timeout=3)
except Exception:
try:
self.proc.kill()
except Exception:
pass
# ---- tests: each takes a fresh Niri and returns (ok, detail) ----
def t_stash_hides_and_moves_focus(n):
a = n.spawn_window()
b = n.spawn_window()
n.action("focus-window", "--id", str(b))
time.sleep(0.2)
n.action("move-window-to-scratchpad")
n.wait_count(1)
f = n.focused()
ok = (len(n.windows()) == 1 and n.by_id(b) is None
and f is not None and f["id"] == a)
return ok, f"remaining={len(n.windows())}, b_gone={n.by_id(b) is None}, focus={f['id'] if f else None}/want {a}"
def t_show_floating_focused_centered(n):
b = n.spawn_window()
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1)
w = n.by_id(b)
pos = n.floating_pos(b)
centered = "n/a"
ok = bool(w and w["is_floating"] and w["is_focused"] and pos and pos[0] > 5)
osz = n.output_size()
if ok and osz and w:
ww, wh = w["layout"]["window_size"]
cx, cy = (osz[0] - ww) / 2, (osz[1] - wh) / 2
centered = abs(pos[0] - cx) < osz[0] * 0.15 and abs(pos[1] - cy) < osz[1] * 0.15
ok = ok and bool(centered)
return ok, f"floating={w['is_floating'] if w else None}, focused={w['is_focused'] if w else None}, pos={pos}, centered={centered}"
def t_reshow_hides(n):
b = n.spawn_window()
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1) # show (focused)
n.action("scratchpad-show"); n.wait_count(0) # hide
ok = len(n.windows()) == 0
return ok, f"count after re-show={len(n.windows())} (want 0)"
def t_cycle_round_robin(n):
a = n.spawn_window()
b = n.spawn_window()
for wid in (b, a):
n.action("focus-window", "--id", str(wid)); time.sleep(0.2)
n.action("move-window-to-scratchpad")
n.wait_count(0)
n.action("scratchpad-show"); w1 = n.wait_count(1); first = w1[0]["id"] if w1 else None
n.action("scratchpad-show"); n.wait_count(0) # hide (focused)
n.action("scratchpad-show"); w2 = n.wait_count(1); second = w2[0]["id"] if w2 else None
ok = first is not None and second is not None and first != second
return ok, f"first={first}, second={second} (want different)"
def t_geometry_remembered(n):
w = n.spawn_window()
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1)
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-floating-window", "-x", "+130", "-y", "+90"); time.sleep(0.4)
pos1 = n.floating_pos(w)
n.action("scratchpad-show"); n.wait_count(0) # hide
n.action("scratchpad-show"); n.wait_count(1) # show again (single window)
pos2 = n.floating_pos(w)
back = n.by_id(w)
ok = (back is not None and back["is_floating"] and pos1 and pos2
and abs(pos1[0] - pos2[0]) < 2 and abs(pos1[1] - pos2[1]) < 2)
return ok, f"pos before hide={pos1}, after show={pos2}, floating={back['is_floating'] if back else None}"
def t_shown_not_focused_gets_focus(n):
p = n.spawn_window() # persistent tiled window
s = n.spawn_window() # to be stashed + shown
n.action("focus-window", "--id", str(s)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(1)
n.action("scratchpad-show"); n.wait_count(2) # s shown floating, focused
n.action("focus-window", "--id", str(p)); time.sleep(0.3)
npre = len(n.windows())
n.action("scratchpad-show"); time.sleep(0.4) # should just focus s back
f = n.focused()
ok = len(n.windows()) == npre and f is not None and f["id"] == s
return ok, f"count {npre}->{len(n.windows())} (want same), focus={f['id'] if f else None}/want {s}"
def t_close_while_stashed(n):
w = n.spawn_window()
pid = n.by_id(w)["pid"]
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
os.kill(pid, signal.SIGKILL) # client dies WHILE stashed
time.sleep(0.6)
r = n.action("scratchpad-show"); time.sleep(0.5)
ok = n.alive() and n.by_id(w) is None and r.returncode == 0
return ok, f"alive={n.alive()}, dead_shown={n.by_id(w) is not None}, rc={r.returncode}"
def t_empty_stash_noop(n):
n.spawn_window()
r = n.action("scratchpad-show"); time.sleep(0.3)
ok = len(n.windows()) == 1 and n.alive() and r.returncode == 0
return ok, f"count stayed {len(n.windows())}, alive={n.alive()}, rc={r.returncode}"
TESTS = [
("1 stash hides + focus moves", t_stash_hides_and_moves_focus),
("2 show floating+focused+centered", t_show_floating_focused_centered),
("3 re-show hides", t_reshow_hides),
("4 cycle round-robin", t_cycle_round_robin),
("5 geometry remembered", t_geometry_remembered),
("6 shown-not-focused -> focus it", t_shown_not_focused_gets_focus),
("7 close-while-stashed no crash", t_close_while_stashed),
("10 empty-stash no-op", t_empty_stash_noop),
]
def main():
if not shutil.which("alacritty"):
sys.exit("ERROR: alacritty is required as the test client.")
# confirm the binary is patched
h = subprocess.run([NIRI, "msg", "action", "--help"],
capture_output=True, text=True)
if "scratchpad-show" not in h.stdout:
sys.exit(f"ERROR: {NIRI} is not patched (no scratchpad-show action).")
print(f"niri: {NIRI}\n")
cfg = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
cfg.write(MINIMAL_CONFIG); cfg.close()
results = []
try:
for name, fn in TESTS:
n = None
try:
n = Niri(cfg.name)
ok, detail = fn(n)
except Exception as e:
ok, detail = False, f"exception: {e}"
finally:
if n:
n.close()
time.sleep(0.3)
results.append((name, ok, detail))
print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
finally:
os.unlink(cfg.name)
# #8 multi-monitor cannot be exercised nested (single winit output).
print(" [SKIP] 8 multi-monitor: needs a physical 2nd output. Covered by the "
"layout unit tests instead (scratchpad_shows_on_the_focused_output, "
"scratchpad_show_pulls_already_shown_window_to_focused_output), which "
"simulate outputs: `cargo test --lib scratchpad`.")
passed = sum(1 for _, ok, _ in results if ok)
failed = sum(1 for _, ok, _ in results if not ok)
print(f"\n{passed} passed, {failed} failed, 1 skipped")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# gamemode-session — compositor-level gamemode for niri.
# Reference-counted over two sources (feral, manual). Applies effects on the
# first active source (0->1) and reverts on the last (1->0).
# Spec: docs/superpowers/specs/2026-07-15-niri-gamemode-design.md
set -u
STATE_DIR="${GAMEMODE_STATE_DIR:-${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode}"
FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
DRYRUN="${GAMEMODE_DRYRUN:-0}"
log() { printf 'gamemode: %s\n' "$*" >&2; }
# Execute a side-effecting command (or log it under dryrun). Its stdout is
# discarded — effect tools like makoctl/fw-fanctrl print confirmations we don't
# want leaking into `add`/`del` output; stderr is kept so real errors surface.
run() { if [ "$DRYRUN" = 1 ]; then log "would: $*"; else "$@" >/dev/null; fi; }
is_active() {
[ -n "$(find "$STATE_DIR" -maxdepth 1 -name 'src.*' -print -quit 2>/dev/null)" ]
}
snapshot_baseline() {
mkdir -p "$STATE_DIR"
powerprofilesctl get 2>/dev/null > "$STATE_DIR/baseline.profile" || : > "$STATE_DIR/baseline.profile"
if pgrep -x waybar >/dev/null 2>&1; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.waybar"
if [ -f "$FAN_AUTO" ]; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.fanauto"
}
# --- effects ------------------------------------------------------------------
effect_idle_on() {
if [ "$DRYRUN" = 1 ]; then log "would: systemd-inhibit idle"; return; fi
command -v systemd-inhibit >/dev/null 2>&1 || { log "no systemd-inhibit"; return; }
systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block \
sleep infinity >/dev/null 2>&1 &
echo $! > "$STATE_DIR/idle.pid"
}
effect_idle_off() {
local pid; pid=$(cat "$STATE_DIR/idle.pid" 2>/dev/null || echo "")
[ -n "$pid" ] && run kill "$pid"
rm -f "$STATE_DIR/idle.pid"
}
effect_dnd_on() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -a do-not-disturb; }
effect_dnd_off() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -r do-not-disturb; }
# maps a power profile to the fw-fanctrl strategy (mirrors fan-profile.sh)
fan_for() { case "$1" in power-saver) echo lazy;; balanced) echo medium;; performance) echo agile;; *) return 1;; esac; }
effect_perf_on() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set performance
run pkill -RTMIN+8 waybar
fi
if [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
run fw-fanctrl use agile
fi
}
effect_perf_off() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set "$base"
run pkill -RTMIN+8 waybar
fi
if [ -n "$base" ] && [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
local strat; strat=$(fan_for "$base") && run fw-fanctrl use "$strat"
fi
}
effect_waybar_on() { # hide
pgrep -x waybar >/dev/null 2>&1 && run pkill -x waybar
}
effect_waybar_off() { # restore if it was running at entry
[ "$(cat "$STATE_DIR/baseline.waybar" 2>/dev/null)" = 1 ] || return 0
if [ "$DRYRUN" = 1 ]; then log "would: relaunch waybar"; else setsid waybar >/dev/null 2>&1 & fi
}
apply_effects() { effect_idle_on; effect_dnd_on; effect_perf_on; effect_waybar_on; }
revert_effects() { effect_idle_off; effect_dnd_off; effect_perf_off; effect_waybar_off; }
# --- state machine ------------------------------------------------------------
cmd_add() {
local src="$1" was=0
is_active && was=1
mkdir -p "$STATE_DIR"
touch "$STATE_DIR/src.$src"
if [ "$was" = 0 ]; then
snapshot_baseline
apply_effects
log "activated (source: $src)"
else
log "already active; +source: $src"
fi
}
cmd_del() {
local src="$1"
rm -f "$STATE_DIR/src.$src"
if ! is_active; then
revert_effects
rm -f "$STATE_DIR"/baseline.* "$STATE_DIR/idle.pid"
log "deactivated (last source: $src)"
else
log "still active; -source: $src"
fi
}
cmd_toggle() {
if [ -e "$STATE_DIR/src.manual" ]; then cmd_del manual; else cmd_add manual; fi
}
cmd_status() { if is_active; then echo active; exit 0; else echo inactive; exit 1; fi; }
cmd_reset() {
revert_effects 2>/dev/null || true
rm -rf "$STATE_DIR"
log "reset"
}
case "${1:-}" in
add) [ -n "${2:-}" ] || { log "add needs a source"; exit 2; }; cmd_add "$2" ;;
del) [ -n "${2:-}" ] || { log "del needs a source"; exit 2; }; cmd_del "$2" ;;
toggle) cmd_toggle ;;
status) cmd_status ;;
reset) cmd_reset ;;
*) log "usage: gamemode-session {add|del <source>|toggle|status|reset}"; exit 2 ;;
esac
+160
View File
@@ -0,0 +1,160 @@
#!/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 fcntl
import json
import os
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
def acquire_single_instance():
# Hold an exclusive lock so a second watcher (e.g. spawn-at-startup plus a
# manual launch) exits instead of racing the same winwatch source. The open
# file must stay referenced for the process lifetime to keep the lock.
rt = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
f = open(os.path.join(rt, "gamemode-watch.lock"), "w")
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
return None
return f
if __name__ == "__main__":
_lock = acquire_single_instance()
if _lock is None:
sys.exit(0) # another instance already running
signal.signal(signal.SIGTERM, _on_sigterm)
try:
main()
except KeyboardInterrupt:
pass
sys.exit(0)
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Drives gamemode-session in dryrun against a temp state dir and asserts the
# reference-counting state machine. No live effects run.
set -u
HERE=$(cd "$(dirname "$0")/.." && pwd)
SCRIPT="$HERE/gamemode-session.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export GAMEMODE_STATE_DIR="$TMP/state"
export GAMEMODE_DRYRUN=1
fail=0
check() { # desc, actual, expected
if [ "$2" = "$3" ]; then printf 'ok - %s\n' "$1"
else printf 'FAIL - %s (got [%s] want [%s])\n' "$1" "$2" "$3"; fail=1; fi
}
active() { "$SCRIPT" status >/dev/null 2>&1 && echo active || echo inactive; }
check "starts inactive" "$(active)" "inactive"
"$SCRIPT" add feral >/dev/null 2>&1
check "feral activates" "$(active)" "active"
check "src.feral marker set" "$([ -e "$GAMEMODE_STATE_DIR/src.feral" ] && echo y)" "y"
check "baseline captured" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y)" "y"
"$SCRIPT" add manual >/dev/null 2>&1
check "manual keeps active" "$(active)" "active"
"$SCRIPT" del feral >/dev/null 2>&1
check "still active on 1 left" "$(active)" "active"
"$SCRIPT" del manual >/dev/null 2>&1
check "last del deactivates" "$(active)" "inactive"
check "baseline cleared" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y || echo n)" "n"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle on activates" "$(active)" "active"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle off deactivates" "$(active)" "inactive"
"$SCRIPT" reset >/dev/null 2>&1
check "reset leaves inactive" "$(active)" "inactive"
exit $fail
+96
View File
@@ -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)
+4 -2
View File
@@ -8,13 +8,15 @@ case "${1:-}" in
esac
if [ "$(pamixer --get-mute)" = "true" ]; then
notify-send -h string:x-canonical-private-synchronous:volume \
notify-send -a volume-osd \
-h string:x-canonical-private-synchronous:volume \
-h int:value:0 \
-t 1500 \
"Muted"
else
LEVEL=$(pamixer --get-volume)
notify-send -h string:x-canonical-private-synchronous:volume \
notify-send -a volume-osd \
-h string:x-canonical-private-synchronous:volume \
-h int:value:"$LEVEL" \
-t 1500 \
"Volume: ${LEVEL}%"