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:
@@ -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)
|
||||||
Executable
+152
@@ -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())
|
||||||
Reference in New Issue
Block a user