Compare commits
27 Commits
6e26cc62aa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0fef1478d | |||
| 640deb397e | |||
| f557ffdfc7 | |||
| a9ce3a13cf | |||
| 0e0b5fcbe3 | |||
| d05b9f8a0e | |||
| 07791f0c23 | |||
| 286bb8a3b3 | |||
| b8a858d7a7 | |||
| 05cc026d75 | |||
| 41f0ee88ff | |||
| 5979eb229f | |||
| 9ae1d88937 | |||
| 1bdc4b5165 | |||
| ff2ffe47b6 | |||
| 2e168053c6 | |||
| 3d08abb060 | |||
| 0ab08e8561 | |||
| 98f6c56fe3 | |||
| 1fd160f19d | |||
| 259de11868 | |||
| 2c9cff6bb9 | |||
| 77b2866e3d | |||
| 4b1773ac4e | |||
| 05707afc04 | |||
| e0c12ff0c9 | |||
| 5b24b21610 |
@@ -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 1–2.
|
||||
|
||||
- [ ] **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 1–2.
|
||||
|
||||
- [ ] **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 1–5.
|
||||
|
||||
- [ ] **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 336–369; inits at ~695–708 and ~721–732; 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 2–3); `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 ~50–54) 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 2–4 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 2–4) + 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.
|
||||
@@ -0,0 +1,3 @@
|
||||
[custom]
|
||||
start=/home/alex/.local/bin/gamemode-session add feral
|
||||
end=/home/alex/.local/bin/gamemode-session del feral
|
||||
+11
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
src/
|
||||
pkg/
|
||||
niri/
|
||||
*.pkg.tar.*
|
||||
*.log
|
||||
0001-scratchpad.patch
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
Executable
+127
@@ -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
|
||||
Executable
+160
@@ -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)
|
||||
Executable
+44
@@ -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
|
||||
@@ -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
@@ -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}%"
|
||||
|
||||
Reference in New Issue
Block a user