diff --git a/scripts/tests/window-restore.test.py b/scripts/tests/window-restore.test.py new file mode 100644 index 0000000..9a61ce7 --- /dev/null +++ b/scripts/tests/window-restore.test.py @@ -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) diff --git a/scripts/window-restore.py b/scripts/window-restore.py new file mode 100755 index 0000000..ad757f8 --- /dev/null +++ b/scripts/window-restore.py @@ -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 ` 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())