spec: niri minimize/stash design

Mod+M and Mod+Shift+M reference a "minimized" named workspace that was never
declared, so both binds have always been silent no-ops. Design covers the
declaration plus a wofi restore picker.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-16 09:15:02 -07:00
parent ff2ffe47b6
commit 1bdc4b5165
@@ -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/<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.