05cc026d75
The picker blocks on user input for however long they take. In that window niri can destroy a dynamic workspace (its last window closed) and renumber every later one down, so the pre-pick current["idx"] can point at the wrong workspace by the time move-window-to-workspace runs — restoring to the wrong place. The stashed window itself can also have closed in the meantime, in which case the move/focus calls would silently no-op. main() now re-reads workspaces/windows after pick() returns, recomputes the current workspace, and confirms the chosen window still exists before acting; each bails with a notify and the documented exit code otherwise. Adds window_exists() as a pure, tested helper alongside the existing pure functions. Also: clarify the focus=false comment in niri/config.kdl (it read as if focus=false caused the problem it actually prevents), and note in the design doc's Out of scope section that the stash's index-1 pinning is per-output, so a second monitor would need its own analysis of the Mod+1..9 offset.
233 lines
10 KiB
Markdown
233 lines
10 KiB
Markdown
# 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.
|