niri: working minimize via declared stash workspace #1
@@ -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.
|
||||||
@@ -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/volume.sh" ~/.local/bin/volume
|
||||||
ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session
|
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/gamemode-watch.py" ~/.local/bin/gamemode-watch
|
||||||
|
ln -sf "$(pwd)/scripts/window-restore.py" ~/.local/bin/window-restore
|
||||||
|
|
||||||
echo "==> Enabling systemd user services"
|
echo "==> Enabling systemd user services"
|
||||||
mkdir -p ~/.config/systemd/user
|
mkdir -p ~/.config/systemd/user
|
||||||
|
|||||||
+31
-20
@@ -47,6 +47,13 @@ spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1"
|
|||||||
spawn-at-startup "xwayland-satellite"
|
spawn-at-startup "xwayland-satellite"
|
||||||
spawn-at-startup "gamemode-watch"
|
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 {
|
binds {
|
||||||
Mod+Q repeat=false { close-window; }
|
Mod+Q repeat=false { close-window; }
|
||||||
Mod+F { fullscreen-window; }
|
Mod+F { fullscreen-window; }
|
||||||
@@ -79,30 +86,34 @@ binds {
|
|||||||
Mod+Shift+Down { move-window-down; }
|
Mod+Shift+Down { move-window-down; }
|
||||||
Mod+Shift+Up { move-window-up; }
|
Mod+Shift+Up { move-window-up; }
|
||||||
|
|
||||||
Mod+1 { focus-workspace 1; }
|
// +1 offset: the "minimized" workspace is always index 1 (see the
|
||||||
Mod+2 { focus-workspace 2; }
|
// declaration above), so the first real workspace is index 2.
|
||||||
Mod+3 { focus-workspace 3; }
|
Mod+1 { focus-workspace 2; }
|
||||||
Mod+4 { focus-workspace 4; }
|
Mod+2 { focus-workspace 3; }
|
||||||
Mod+5 { focus-workspace 5; }
|
Mod+3 { focus-workspace 4; }
|
||||||
Mod+6 { focus-workspace 6; }
|
Mod+4 { focus-workspace 5; }
|
||||||
Mod+7 { focus-workspace 7; }
|
Mod+5 { focus-workspace 6; }
|
||||||
Mod+8 { focus-workspace 8; }
|
Mod+6 { focus-workspace 7; }
|
||||||
Mod+9 { focus-workspace 9; }
|
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+1 { move-window-to-workspace 2; }
|
||||||
Mod+Shift+2 { move-window-to-workspace 2; }
|
Mod+Shift+2 { move-window-to-workspace 3; }
|
||||||
Mod+Shift+3 { move-window-to-workspace 3; }
|
Mod+Shift+3 { move-window-to-workspace 4; }
|
||||||
Mod+Shift+4 { move-window-to-workspace 4; }
|
Mod+Shift+4 { move-window-to-workspace 5; }
|
||||||
Mod+Shift+5 { move-window-to-workspace 5; }
|
Mod+Shift+5 { move-window-to-workspace 6; }
|
||||||
Mod+Shift+6 { move-window-to-workspace 6; }
|
Mod+Shift+6 { move-window-to-workspace 7; }
|
||||||
Mod+Shift+7 { move-window-to-workspace 7; }
|
Mod+Shift+7 { move-window-to-workspace 8; }
|
||||||
Mod+Shift+8 { move-window-to-workspace 8; }
|
Mod+Shift+8 { move-window-to-workspace 9; }
|
||||||
Mod+Shift+9 { move-window-to-workspace 9; }
|
Mod+Shift+9 { move-window-to-workspace 10; }
|
||||||
|
|
||||||
Mod+Print { spawn "screenshot"; }
|
Mod+Print { spawn "screenshot"; }
|
||||||
|
|
||||||
Mod+M { move-window-to-workspace "minimized"; }
|
// focus=false is required here — otherwise focus follows the window into
|
||||||
Mod+Shift+M { focus-workspace "minimized"; }
|
// 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+E { spawn "hyprlock"; }
|
||||||
Mod+Shift+P { spawn "powermenu"; }
|
Mod+Shift+P { spawn "powermenu"; }
|
||||||
|
|||||||
@@ -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)
|
||||||
Executable
+183
@@ -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())
|
||||||
Reference in New Issue
Block a user