niri: working minimize via declared stash workspace #1

Merged
funman300 merged 5 commits from feat/niri-minimize into main 2026-07-16 17:19:57 +00:00
6 changed files with 992 additions and 20 deletions
@@ -0,0 +1,452 @@
# Niri Minimize / Stash Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `Mod+M` stashes the focused window on a declared `minimized` workspace; `Mod+Shift+M` opens a wofi picker that restores any stashed window to the workspace you are currently on.
**Architecture:** The stash is a declared named workspace — declaring it is what makes the two existing (currently no-op) binds start working. Restore is a new Python script driven by niri's IPC, with pure logic split from the subprocess calls so it is testable without a compositor.
**Tech Stack:** niri 26.04 KDL config, Python 3 (stdlib only), wofi, `niri msg -j` IPC, libnotify.
**Spec:** `docs/superpowers/specs/2026-07-16-niri-minimize-design.md`
## Global Constraints
- **The stash workspace name is exactly `minimized`** — it must match between `niri/config.kdl` and the `STASH` constant in `scripts/window-restore.py`.
- **No `jq` on this system.** Parse `niri msg -j` JSON with Python 3 stdlib only. Do not add dependencies.
- **Script convention:** `scripts/<name>.py` is symlinked to `~/.local/bin/<name>` (no extension in the bin name) by `install.sh`.
- **Test convention:** plain `python3` scripts under `scripts/tests/`, no pytest. Load the hyphenated module via `importlib`, assert with a local `check(desc, got, want)` helper, `sys.exit(fails)`. Mirror `scripts/tests/gamemode-watch.test.py`.
- **Style:** pure logic as module-level functions, subprocess calls confined to `main()` and thin wrappers — mirror `scripts/gamemode-watch.py`.
- **Task order matters:** Task 1 (script) precedes Task 2 (config). Wiring `Mod+Shift+M` to a script that does not exist yet would leave the bind broken between commits.
---
### Task 1: `window-restore` script and its unit tests
**Files:**
- Create: `scripts/window-restore.py`
- Test: `scripts/tests/window-restore.test.py`
**Interfaces:**
- Produces, for Task 2: the executable `window-restore` (via symlink), invoked with no arguments by `Mod+Shift+M { spawn "window-restore"; }`. Exits 0 on success, on an empty stash, and on a cancelled picker; exits 1 when niri IPC is unavailable or the `minimized` workspace is not declared.
- Pure functions (used only by the test): `stash_workspace(workspaces)`, `current_workspace(workspaces)`, `stashed_windows(windows, stash_id)`, `label(window)`, `resolve(windows, chosen)`, `pick(labels, chooser)`.
- [ ] **Step 1: Write the failing test**
Create `scripts/tests/window-restore.test.py`:
```python
#!/usr/bin/env python3
"""Unit tests for window-restore's pure logic (no niri, no wofi, no subprocess).
Loads the hyphenated script by path and drives the pure functions with the JSON
shapes `niri msg -j workspaces` / `windows` actually return."""
import importlib.util
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_PATH = os.path.join(HERE, "..", "window-restore.py")
spec = importlib.util.spec_from_file_location("window_restore", MOD_PATH)
wr = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wr)
fails = 0
def check(desc, got, want):
global fails
if got == want:
print(f"ok - {desc}")
else:
print(f"FAIL - {desc} (got {got!r} want {want!r})")
fails = 1
# Shapes mirror real `niri msg -j` output: the stash is always idx 1.
WS_WITH_STASH = [
{"id": 1, "idx": 1, "name": "minimized", "is_focused": False},
{"id": 2, "idx": 2, "name": None, "is_focused": True},
{"id": 3, "idx": 3, "name": None, "is_focused": False},
]
WS_NO_STASH = [
{"id": 2, "idx": 1, "name": None, "is_focused": True},
{"id": 3, "idx": 2, "name": None, "is_focused": False},
]
WS_ON_STASH = [
{"id": 1, "idx": 1, "name": "minimized", "is_focused": True},
{"id": 2, "idx": 2, "name": None, "is_focused": False},
]
WINDOWS = [
{"id": 10, "title": "Balatro", "app_id": "steam_app_2379780", "workspace_id": 1},
{"id": 11, "title": "Pi-hole", "app_id": "firefox", "workspace_id": 1},
{"id": 12, "title": "notes", "app_id": "Alacritty", "workspace_id": 2},
]
# --- stash_workspace / current_workspace -----------------------------------
check("stash found when declared", wr.stash_workspace(WS_WITH_STASH)["id"], 1)
check("stash is None when undeclared", wr.stash_workspace(WS_NO_STASH), None)
check("stash is None for empty list", wr.stash_workspace([]), None)
check("current workspace is the focused one", wr.current_workspace(WS_WITH_STASH)["id"], 2)
check("current is None when none focused", wr.current_workspace([]), None)
check("focused-on-stash is detectable",
wr.current_workspace(WS_ON_STASH)["id"] == wr.stash_workspace(WS_ON_STASH)["id"], True)
# --- stashed_windows -------------------------------------------------------
check("only windows on the stash", [w["id"] for w in wr.stashed_windows(WINDOWS, 1)], [10, 11])
check("empty stash yields nothing", wr.stashed_windows(WINDOWS, 99), [])
# --- label -----------------------------------------------------------------
check("label joins app_id and title", wr.label(WINDOWS[1]), "firefox — Pi-hole")
check("label falls back to title when app_id missing",
wr.label({"id": 5, "app_id": None, "title": "Bare Title"}), "Bare Title")
check("label falls back to app_id when title empty",
wr.label({"id": 5, "app_id": "firefox", "title": ""}), "firefox")
check("label never renders empty",
wr.label({"id": 5, "app_id": None, "title": None}), "window 5")
# --- resolve ---------------------------------------------------------------
check("resolve maps a label back to its window",
wr.resolve(WINDOWS, "firefox — Pi-hole")["id"], 11)
check("resolve returns None for no match", wr.resolve(WINDOWS, "nope"), None)
DUPES = [
{"id": 20, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
{"id": 21, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
]
check("duplicate labels resolve to the first", wr.resolve(DUPES, "Alacritty — same")["id"], 20)
# --- pick ------------------------------------------------------------------
check("pick returns the chooser's selection",
wr.pick(["a", "b"], lambda text: "b"), "b")
check("pick passes newline-joined labels to the chooser",
wr.pick(["a", "b"], lambda text: text), "a\nb")
check("cancelled pick returns empty", wr.pick(["a"], lambda text: ""), "")
sys.exit(fails)
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `python3 scripts/tests/window-restore.test.py`
Expected: FAIL, non-zero exit — `FileNotFoundError` / `No such file or directory: '.../scripts/window-restore.py'`, because the script does not exist yet.
- [ ] **Step 3: Write the implementation**
Create `scripts/window-restore.py`:
```python
#!/usr/bin/env python3
"""window-restore — bring a minimized window back from the stash workspace.
Mod+M moves the focused window to the declared "minimized" workspace; this is
Mod+Shift+M: list what is stashed in a wofi picker, then move the pick to the
workspace you are currently on and focus it.
Restore targets the *current* workspace by design — the stash workspace is the
entire state, so nothing tracks where a window came from and nothing goes stale.
The stash must be declared (`workspace "minimized"` in niri/config.kdl). niri
silently ignores moves to an undeclared named workspace, so if the declaration
goes missing this script says so rather than failing quietly.
"""
import json
import subprocess
import sys
STASH = "minimized"
# --- pure logic -------------------------------------------------------------
def stash_workspace(workspaces):
"""The declared stash workspace, or None if it is not declared."""
return next((w for w in workspaces if w.get("name") == STASH), None)
def current_workspace(workspaces):
return next((w for w in workspaces if w.get("is_focused")), None)
def stashed_windows(windows, stash_id):
return [w for w in windows if w.get("workspace_id") == stash_id]
def label(window):
"""Display string for the picker. XWayland windows can lack app_id, so fall
back rather than rendering a bare dash or an empty row."""
app = (window.get("app_id") or "").strip()
title = (window.get("title") or "").strip()
if app and title:
return f"{app}{title}"
return app or title or f"window {window['id']}"
def resolve(windows, chosen):
"""First window whose label matches. Windows with identical labels are
indistinguishable in the menu anyway, and restoring the first still drains
the stash on repeat."""
return next((w for w in windows if label(w) == chosen), None)
def pick(labels, chooser):
"""chooser(text) -> selected line. Injected so tests need no wofi."""
return chooser("\n".join(labels))
# --- io ---------------------------------------------------------------------
def notify(message):
subprocess.run(
["notify-send", "-a", "window-restore", message],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def niri_json(what):
"""`niri msg -j <what>` parsed, or None if niri/IPC is unavailable."""
try:
out = subprocess.run(
["niri", "msg", "-j", what], capture_output=True, text=True
)
except FileNotFoundError:
return None
if out.returncode != 0:
return None
try:
return json.loads(out.stdout)
except json.JSONDecodeError:
return None
def niri_action(*args):
subprocess.run(
["niri", "msg", "action", *args],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def wofi_chooser(text):
try:
out = subprocess.run(
["wofi", "--dmenu", "--prompt", "Restore:", "--width", "600",
"--height", "400", "--insensitive"],
input=text, capture_output=True, text=True,
)
except FileNotFoundError:
return ""
return out.stdout.strip()
def main():
workspaces = niri_json("workspaces")
if workspaces is None:
notify("niri IPC unavailable")
return 1
stash = stash_workspace(workspaces)
if stash is None:
notify(f'no "{STASH}" workspace — declare it in niri/config.kdl')
return 1
current = current_workspace(workspaces)
if current is None:
notify("no focused workspace")
return 1
# Restoring here would move a window to the workspace it is already on.
# Reachable by clicking the waybar pill; no keybind goes here.
if current["id"] == stash["id"]:
notify("Already on the minimized workspace")
return 0
windows = niri_json("windows")
if windows is None:
notify("niri IPC unavailable")
return 1
stashed = stashed_windows(windows, stash["id"])
if not stashed:
notify("No minimized windows")
return 0
chosen = pick([label(w) for w in stashed], wofi_chooser)
if not chosen:
return 0 # cancelled
window = resolve(stashed, chosen)
if window is None:
return 0
# The window is not focused (it is in the stash), so --focus does not apply;
# focus it explicitly afterwards.
niri_action("move-window-to-workspace", "--window-id", str(window["id"]),
str(current["idx"]))
niri_action("focus-window", "--id", str(window["id"]))
return 0
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `python3 scripts/tests/window-restore.test.py`
Expected: every line prints `ok - ...`, exit code 0. Confirm with `echo $?``0`.
- [ ] **Step 5: Commit**
```bash
chmod +x scripts/window-restore.py
git add scripts/window-restore.py scripts/tests/window-restore.test.py
git commit -m "window-restore: wofi picker to restore minimized windows
Pure logic split from the niri/wofi calls so it unit-tests without a
compositor. Restores to the current workspace, so no origin tracking."
```
---
### Task 2: Declare the stash, rewire the binds, install the symlink
**Files:**
- Modify: `niri/config.kdl` (top level; binds at lines ~62-108)
- Modify: `install.sh:49-57` (the symlink block)
**Interfaces:**
- Consumes from Task 1: the `window-restore` executable, spawned by `Mod+Shift+M`.
- [ ] **Step 1: Declare the stash workspace**
In `niri/config.kdl`, add above the `binds {` block (after the `spawn-at-startup` lines):
```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 (niri validate still
// passes, because the name is just a string to the parser).
workspace "minimized"
```
- [ ] **Step 2: Rewire the two minimize binds**
In `niri/config.kdl`, replace:
```kdl
Mod+M { move-window-to-workspace "minimized"; }
Mod+Shift+M { focus-workspace "minimized"; }
```
with:
```kdl
// focus=false or focus follows the window into the stash — the opposite of
// minimizing.
Mod+M { move-window-to-workspace "minimized" focus=false; }
Mod+Shift+M { spawn "window-restore"; }
```
- [ ] **Step 3: Offset the index-based workspace binds**
In `niri/config.kdl`, replace the `Mod+1..9` and `Mod+Shift+1..9` blocks with (note the `+1` — index 1 is the stash):
```kdl
// +1 offset: the "minimized" workspace is always index 1 (see the
// declaration above), so the first real workspace is index 2.
Mod+1 { focus-workspace 2; }
Mod+2 { focus-workspace 3; }
Mod+3 { focus-workspace 4; }
Mod+4 { focus-workspace 5; }
Mod+5 { focus-workspace 6; }
Mod+6 { focus-workspace 7; }
Mod+7 { focus-workspace 8; }
Mod+8 { focus-workspace 9; }
Mod+9 { focus-workspace 10; }
Mod+Shift+1 { move-window-to-workspace 2; }
Mod+Shift+2 { move-window-to-workspace 3; }
Mod+Shift+3 { move-window-to-workspace 4; }
Mod+Shift+4 { move-window-to-workspace 5; }
Mod+Shift+5 { move-window-to-workspace 6; }
Mod+Shift+6 { move-window-to-workspace 7; }
Mod+Shift+7 { move-window-to-workspace 8; }
Mod+Shift+8 { move-window-to-workspace 9; }
Mod+Shift+9 { move-window-to-workspace 10; }
```
- [ ] **Step 4: Validate the config**
Run: `niri validate -c niri/config.kdl`
Expected: `config is valid`, exit 0.
Note: a passing validate does **not** prove the stash works — that is exactly the trap the old config was in. Step 6 is the real check.
- [ ] **Step 5: Add the symlink and install it**
In `install.sh`, after the `gamemode-watch` line (`install.sh:57`), add:
```bash
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
```
Then create it for the running session:
```bash
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
command -v window-restore
```
Expected: prints `/home/alex/.local/bin/window-restore`.
- [ ] **Step 6: Verify by hand**
niri live-reloads on save; confirm with
`journalctl --user --since "1 minute ago" | grep "loaded config"`.
"First workspace" below means the one `Mod+1` reaches (niri index 2 — the stash holds index 1).
1. `Mod+1`. Confirm it lands on a **real workspace, not the stash** — this is the offset working.
2. On a window there, press `Mod+M`. The window vanishes and **focus stays put**. If the view jumps to an empty workspace, `focus=false` is not working — stop and fix.
3. waybar shows a `minimized` pill on the far left.
4. `Mod+2`, then `Mod+Shift+M`. The picker lists the stashed window; pick it. It appears on this workspace and is focused.
5. `Mod+Shift+M` again with the stash now empty: a "No minimized windows" toast, no hang.
6. Click the `minimized` pill in waybar, then `Mod+Shift+M`: "Already on the minimized workspace". Leave with `Mod+1`.
Confirm the stash is actually at index 1 and holding windows:
```bash
niri msg -j workspaces | python3 -c "import sys,json; print([(w['idx'], w['name']) for w in json.load(sys.stdin)])"
```
Expected: a tuple `(1, 'minimized')` in the list.
- [ ] **Step 7: Commit**
```bash
git add niri/config.kdl install.sh
git commit -m "niri: working minimize via declared stash workspace
Mod+M referenced an undeclared 'minimized' workspace, so it silently did
nothing. Declare it, add focus=false so focus does not follow the window
into the stash, and point Mod+Shift+M at the restore picker.
niri pins declared named workspaces to index 1 and will not move them, so
Mod+1..9 are offset by one."
```
---
## Verification
Full check after both tasks:
```bash
python3 scripts/tests/window-restore.test.py # all ok, exit 0
niri validate -c niri/config.kdl # config is valid
bash scripts/tests/gamemode-session.test.sh # unchanged, still passes
python3 scripts/tests/gamemode-watch.test.py # unchanged, still passes
```
Then the Task 2 Step 6 manual sequence, which is the only thing that proves the stash actually stashes.
@@ -0,0 +1,232 @@
# 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.
+1
View File
@@ -55,6 +55,7 @@ ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot
ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume
ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session
ln -sf "$(pwd)/scripts/gamemode-watch.py" ~/.local/bin/gamemode-watch
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
echo "==> Enabling systemd user services"
mkdir -p ~/.config/systemd/user
+31 -20
View File
@@ -47,6 +47,13 @@ spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1"
spawn-at-startup "xwayland-satellite"
spawn-at-startup "gamemode-watch"
// 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 (niri validate still
// passes, because the name is just a string to the parser).
workspace "minimized"
binds {
Mod+Q repeat=false { close-window; }
Mod+F { fullscreen-window; }
@@ -79,30 +86,34 @@ binds {
Mod+Shift+Down { move-window-down; }
Mod+Shift+Up { move-window-up; }
Mod+1 { focus-workspace 1; }
Mod+2 { focus-workspace 2; }
Mod+3 { focus-workspace 3; }
Mod+4 { focus-workspace 4; }
Mod+5 { focus-workspace 5; }
Mod+6 { focus-workspace 6; }
Mod+7 { focus-workspace 7; }
Mod+8 { focus-workspace 8; }
Mod+9 { focus-workspace 9; }
// +1 offset: the "minimized" workspace is always index 1 (see the
// declaration above), so the first real workspace is index 2.
Mod+1 { focus-workspace 2; }
Mod+2 { focus-workspace 3; }
Mod+3 { focus-workspace 4; }
Mod+4 { focus-workspace 5; }
Mod+5 { focus-workspace 6; }
Mod+6 { focus-workspace 7; }
Mod+7 { focus-workspace 8; }
Mod+8 { focus-workspace 9; }
Mod+9 { focus-workspace 10; }
Mod+Shift+1 { move-window-to-workspace 1; }
Mod+Shift+2 { move-window-to-workspace 2; }
Mod+Shift+3 { move-window-to-workspace 3; }
Mod+Shift+4 { move-window-to-workspace 4; }
Mod+Shift+5 { move-window-to-workspace 5; }
Mod+Shift+6 { move-window-to-workspace 6; }
Mod+Shift+7 { move-window-to-workspace 7; }
Mod+Shift+8 { move-window-to-workspace 8; }
Mod+Shift+9 { move-window-to-workspace 9; }
Mod+Shift+1 { move-window-to-workspace 2; }
Mod+Shift+2 { move-window-to-workspace 3; }
Mod+Shift+3 { move-window-to-workspace 4; }
Mod+Shift+4 { move-window-to-workspace 5; }
Mod+Shift+5 { move-window-to-workspace 6; }
Mod+Shift+6 { move-window-to-workspace 7; }
Mod+Shift+7 { move-window-to-workspace 8; }
Mod+Shift+8 { move-window-to-workspace 9; }
Mod+Shift+9 { move-window-to-workspace 10; }
Mod+Print { spawn "screenshot"; }
Mod+M { move-window-to-workspace "minimized"; }
Mod+Shift+M { focus-workspace "minimized"; }
// focus=false is required here — otherwise focus follows the window into
// the stash, the opposite of minimizing.
Mod+M { move-window-to-workspace "minimized" focus=false; }
Mod+Shift+M { spawn "window-restore"; }
Mod+Shift+E { spawn "hyprlock"; }
Mod+Shift+P { spawn "powermenu"; }
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Unit tests for window-restore's pure logic (no niri, no wofi, no subprocess).
Loads the hyphenated script by path and drives the pure functions with the JSON
shapes `niri msg -j workspaces` / `windows` actually return."""
import importlib.util
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_PATH = os.path.join(HERE, "..", "window-restore.py")
spec = importlib.util.spec_from_file_location("window_restore", MOD_PATH)
wr = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wr)
fails = 0
def check(desc, got, want):
global fails
if got == want:
print(f"ok - {desc}")
else:
print(f"FAIL - {desc} (got {got!r} want {want!r})")
fails = 1
# Shapes mirror real `niri msg -j` output: the stash is always idx 1.
WS_WITH_STASH = [
{"id": 1, "idx": 1, "name": "minimized", "is_focused": False},
{"id": 2, "idx": 2, "name": None, "is_focused": True},
{"id": 3, "idx": 3, "name": None, "is_focused": False},
]
WS_NO_STASH = [
{"id": 2, "idx": 1, "name": None, "is_focused": True},
{"id": 3, "idx": 2, "name": None, "is_focused": False},
]
WS_ON_STASH = [
{"id": 1, "idx": 1, "name": "minimized", "is_focused": True},
{"id": 2, "idx": 2, "name": None, "is_focused": False},
]
WINDOWS = [
{"id": 10, "title": "Balatro", "app_id": "steam_app_2379780", "workspace_id": 1},
{"id": 11, "title": "Pi-hole", "app_id": "firefox", "workspace_id": 1},
{"id": 12, "title": "notes", "app_id": "Alacritty", "workspace_id": 2},
]
# --- stash_workspace / current_workspace -----------------------------------
check("stash found when declared", wr.stash_workspace(WS_WITH_STASH)["id"], 1)
check("stash is None when undeclared", wr.stash_workspace(WS_NO_STASH), None)
check("stash is None for empty list", wr.stash_workspace([]), None)
check("current workspace is the focused one", wr.current_workspace(WS_WITH_STASH)["id"], 2)
check("current is None when none focused", wr.current_workspace([]), None)
check("focused-on-stash is detectable",
wr.current_workspace(WS_ON_STASH)["id"] == wr.stash_workspace(WS_ON_STASH)["id"], True)
# --- stashed_windows -------------------------------------------------------
check("only windows on the stash", [w["id"] for w in wr.stashed_windows(WINDOWS, 1)], [10, 11])
check("empty stash yields nothing", wr.stashed_windows(WINDOWS, 99), [])
# --- label -----------------------------------------------------------------
check("label joins app_id and title", wr.label(WINDOWS[1]), "firefox — Pi-hole")
check("label falls back to title when app_id missing",
wr.label({"id": 5, "app_id": None, "title": "Bare Title"}), "Bare Title")
check("label falls back to app_id when title empty",
wr.label({"id": 5, "app_id": "firefox", "title": ""}), "firefox")
check("label never renders empty",
wr.label({"id": 5, "app_id": None, "title": None}), "window 5")
# --- resolve ---------------------------------------------------------------
check("resolve maps a label back to its window",
wr.resolve(WINDOWS, "firefox — Pi-hole")["id"], 11)
check("resolve returns None for no match", wr.resolve(WINDOWS, "nope"), None)
DUPES = [
{"id": 20, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
{"id": 21, "title": "same", "app_id": "Alacritty", "workspace_id": 1},
]
check("duplicate labels resolve to the first", wr.resolve(DUPES, "Alacritty — same")["id"], 20)
# --- window_exists -----------------------------------------------------------
check("window_exists is True for a present window", wr.window_exists(WINDOWS, 11), True)
check("window_exists is False once the window has closed", wr.window_exists(WINDOWS, 999), False)
check("window_exists is False for an empty list", wr.window_exists([], 11), False)
# --- pick ------------------------------------------------------------------
check("pick returns the chooser's selection",
wr.pick(["a", "b"], lambda text: "b"), "b")
check("pick passes newline-joined labels to the chooser",
wr.pick(["a", "b"], lambda text: text), "a\nb")
check("cancelled pick returns empty", wr.pick(["a"], lambda text: ""), "")
sys.exit(fails)
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""window-restore — bring a minimized window back from the stash workspace.
Mod+M moves the focused window to the declared "minimized" workspace; this is
Mod+Shift+M: list what is stashed in a wofi picker, then move the pick to the
workspace you are currently on and focus it.
Restore targets the *current* workspace by design — the stash workspace is the
entire state, so nothing tracks where a window came from and nothing goes stale.
The stash must be declared (`workspace "minimized"` in niri/config.kdl). niri
silently ignores moves to an undeclared named workspace, so if the declaration
goes missing this script says so rather than failing quietly.
"""
import json
import subprocess
import sys
STASH = "minimized"
# --- pure logic -------------------------------------------------------------
def stash_workspace(workspaces):
"""The declared stash workspace, or None if it is not declared."""
return next((w for w in workspaces if w.get("name") == STASH), None)
def current_workspace(workspaces):
return next((w for w in workspaces if w.get("is_focused")), None)
def stashed_windows(windows, stash_id):
return [w for w in windows if w.get("workspace_id") == stash_id]
def label(window):
"""Display string for the picker. XWayland windows can lack app_id, so fall
back rather than rendering a bare dash or an empty row."""
app = (window.get("app_id") or "").strip()
title = (window.get("title") or "").strip()
if app and title:
return f"{app}{title}"
return app or title or f"window {window['id']}"
def resolve(windows, chosen):
"""First window whose label matches. Windows with identical labels are
indistinguishable in the menu anyway, and restoring the first still drains
the stash on repeat."""
return next((w for w in windows if label(w) == chosen), None)
def window_exists(windows, window_id):
"""Whether window_id is still present in a fresh `windows` list. Used after
the picker returns, since wofi can block long enough for the chosen window
to have closed out from under us."""
return any(w.get("id") == window_id for w in windows)
def pick(labels, chooser):
"""chooser(text) -> selected line. Injected so tests need no wofi."""
return chooser("\n".join(labels))
# --- io ---------------------------------------------------------------------
def notify(message):
subprocess.run(
["notify-send", "-a", "window-restore", message],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def niri_json(what):
"""`niri msg -j <what>` parsed, or None if niri/IPC is unavailable."""
try:
out = subprocess.run(
["niri", "msg", "-j", what], capture_output=True, text=True
)
except FileNotFoundError:
return None
if out.returncode != 0:
return None
try:
return json.loads(out.stdout)
except json.JSONDecodeError:
return None
def niri_action(*args):
subprocess.run(
["niri", "msg", "action", *args],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def wofi_chooser(text):
try:
out = subprocess.run(
["wofi", "--dmenu", "--prompt", "Restore:", "--width", "600",
"--height", "400", "--insensitive"],
input=text, capture_output=True, text=True,
)
except FileNotFoundError:
return ""
return out.stdout.strip()
def main():
workspaces = niri_json("workspaces")
if workspaces is None:
notify("niri IPC unavailable")
return 1
stash = stash_workspace(workspaces)
if stash is None:
notify(f'no "{STASH}" workspace — declare it in niri/config.kdl')
return 1
current = current_workspace(workspaces)
if current is None:
notify("no focused workspace")
return 1
# Restoring here would move a window to the workspace it is already on.
# Reachable by clicking the waybar pill; no keybind goes here.
if current["id"] == stash["id"]:
notify("Already on the minimized workspace")
return 0
windows = niri_json("windows")
if windows is None:
notify("niri IPC unavailable")
return 1
stashed = stashed_windows(windows, stash["id"])
if not stashed:
notify("No minimized windows")
return 0
chosen = pick([label(w) for w in stashed], wofi_chooser)
if not chosen:
return 0 # cancelled
window = resolve(stashed, chosen)
if window is None:
return 0
# wofi may have sat open for however long the user took. In that time niri
# can destroy a dynamic workspace (its last window closed) and renumber
# every later one down, so the pre-pick `current["idx"]` may now name the
# wrong workspace; the stashed window may also have closed. Re-read state
# rather than trust anything captured before pick() blocked.
workspaces = niri_json("workspaces")
if workspaces is None:
notify("niri IPC unavailable")
return 1
current = current_workspace(workspaces)
if current is None:
notify("no focused workspace")
return 1
windows = niri_json("windows")
if windows is None:
notify("niri IPC unavailable")
return 1
if not window_exists(windows, window["id"]):
notify("that window closed before it could be restored")
return 0
# The window is not focused (it is in the stash), so --focus does not apply;
# focus it explicitly afterwards.
niri_action("move-window-to-workspace", "--window-id", str(window["id"]),
str(current["idx"]))
niri_action("focus-window", "--id", str(window["id"]))
return 0
if __name__ == "__main__":
sys.exit(main())