# 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.