diff --git a/docs/superpowers/specs/2026-07-16-niri-minimize-design.md b/docs/superpowers/specs/2026-07-16-niri-minimize-design.md new file mode 100644 index 0000000..ed88221 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-niri-minimize-design.md @@ -0,0 +1,228 @@ +# 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/.{sh,py}` symlinked to + `~/.local/bin/` (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 ` and `focus-window --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 "" 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.