niri: working minimize via declared stash workspace #1
@@ -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.
|
||||
Reference in New Issue
Block a user