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.
This commit is contained in:
funman300
2026-07-16 09:58:25 -07:00
parent 9ae1d88937
commit 5979eb229f
2 changed files with 240 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
#!/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)
+152
View File
@@ -0,0 +1,152 @@
#!/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())