#!/usr/bin/env python3 """Classroom window control. Sway has no real minimise concept, so the classroom uses a dedicated hidden workspace. A minimised window is parked there as an ordinary tiled container and moved back to the visible workspace on restore, which is why it returns maximised instead of floating the way the old scratchpad approach did. """ import argparse import json import os import re import subprocess import sys HIDDEN_WORKSPACE = "__ekklesia_hidden" # sway parks scratchpad windows on a workspace of its own. Nothing here # puts them there, but treating it as hidden means a window left in the # scratchpad by an older classroom build can still be restored. HIDDEN_WORKSPACES = {HIDDEN_WORKSPACE, "__i3_scratch"} STATE_DIR = os.path.join( os.environ.get("XDG_RUNTIME_DIR") or "/tmp", "ekklesia-classroom", ) STATE_FILE = os.path.join(STATE_DIR, "minimized") def swaymsg(*arguments): """Run swaymsg, returning stdout or None when sway is unavailable.""" try: result = subprocess.run( ["swaymsg", *arguments], capture_output=True, text=True, ) except (OSError, subprocess.SubprocessError): return None if result.returncode != 0: return None return result.stdout def command(argument): """Run a sway command, ignoring criteria that match nothing.""" swaymsg("--", argument) def query(kind): output = swaymsg("-t", kind, "-r") if not output: return None try: return json.loads(output) except ValueError: return None def collect(node, workspace, found): """Flatten the sway tree into the windows the classroom cares about.""" if node.get("type") == "workspace": workspace = node.get("name") is_window = node.get("pid") is not None and not node.get("nodes") if is_window: properties = node.get("window_properties") or {} found.append( { "id": node.get("id"), "app_id": node.get("app_id") or "", "class": properties.get("class") or "", "instance": properties.get("instance") or "", "focused": bool(node.get("focused")), "workspace": workspace, "hidden": workspace in HIDDEN_WORKSPACES, } ) for child in node.get("nodes", []) + node.get("floating_nodes", []): collect(child, workspace, found) return found def windows(): tree = query("get_tree") if tree is None: return [] return collect(tree, None, []) def matches(window, patterns): """Match a window against app_id / X11 class / X11 instance.""" identities = [ window["app_id"], window["class"], window["instance"], ] for pattern in patterns: expression = re.compile(pattern, re.IGNORECASE) for identity in identities: if identity and expression.fullmatch(identity): return True return False def visible_workspace(): """The workspace a restored window should come back to.""" workspaces = query("get_workspaces") or [] for workspace in workspaces: if workspace.get("focused") and workspace.get("name") not in HIDDEN_WORKSPACES: return workspace.get("name") for workspace in workspaces: if workspace.get("visible") and workspace.get("name") not in HIDDEN_WORKSPACES: return workspace.get("name") return "1" def read_state(): try: with open(STATE_FILE, "r", encoding="utf-8") as handle: return [int(line) for line in handle.read().split() if line.strip()] except (OSError, ValueError): return [] def write_state(identifiers): try: os.makedirs(STATE_DIR, exist_ok=True) with open(STATE_FILE, "w", encoding="utf-8") as handle: handle.write("\n".join(str(item) for item in identifiers)) except OSError: pass def remember(identifier): """Track minimise order so restore-last picks the newest window.""" state = [item for item in read_state() if item != identifier] state.append(identifier) write_state(state[-32:]) def forget(identifier): write_state([item for item in read_state() if item != identifier]) def minimize(window): # A fullscreen container keeps rendering over everything else if it # is parked while still fullscreen, so always drop out of it first. command(f'[con_id={window["id"]}] fullscreen disable') command(f'[con_id={window["id"]}] move container to workspace {HIDDEN_WORKSPACE}') remember(window["id"]) def restore(window): workspace = visible_workspace() command(f'[con_id={window["id"]}] move container to workspace {workspace}') # Windows parked by older classroom builds came back floating because # they had been through the scratchpad. Force them back into the tiling # layout so a restored browser is maximised like the user expects. command(f'[con_id={window["id"]}] floating disable') command(f'[con_id={window["id"]}] focus') forget(window["id"]) def launch(argv): if not argv: return try: subprocess.Popen(argv, start_new_session=True) except OSError as error: print(f"classroom-window: unable to launch {argv[0]}: {error}", file=sys.stderr) def action_toggle(patterns, argv): """Launch, focus, minimise or restore, in the order a user expects.""" candidates = [window for window in windows() if matches(window, patterns)] if not candidates: launch(argv) return 0 hidden = [window for window in candidates if window["hidden"]] if hidden: for window in hidden: restore(window) return 0 if any(window["focused"] for window in candidates): for window in candidates: minimize(window) return 0 command(f'[con_id={candidates[-1]["id"]}] focus') return 0 def action_minimize(): for window in windows(): if window["focused"] and not window["hidden"]: minimize(window) return 0 return 0 def action_restore_last(): hidden = {window["id"]: window for window in windows() if window["hidden"]} if not hidden: return 0 for identifier in reversed(read_state()): if identifier in hidden: restore(hidden[identifier]) return 0 restore(list(hidden.values())[-1]) return 0 def action_list(): for window in windows(): state = "minimized" if window["hidden"] else "visible" state = "focused" if window["focused"] else state print( f'{window["id"]}\t{state}\t' f'{window["app_id"] or window["class"]}\t{window["workspace"]}' ) return 0 def main(): parser = argparse.ArgumentParser( description="Minimise, restore and toggle classroom windows.", ) subparsers = parser.add_subparsers(dest="action", required=True) toggle = subparsers.add_parser( "toggle", help="Launch, focus, minimise or restore an application", ) toggle.add_argument( "--match", action="append", required=True, metavar="REGEX", help="app_id or X11 class to match (may be repeated)", ) toggle.add_argument( "launch", nargs=argparse.REMAINDER, help="Command to run when the application is not running", ) subparsers.add_parser("minimize", help="Minimise the focused window") subparsers.add_parser("restore-last", help="Restore the newest minimised window") subparsers.add_parser("list", help="List classroom windows and their state") arguments = parser.parse_args() if arguments.action == "toggle": argv = arguments.launch if argv and argv[0] == "--": argv = argv[1:] return action_toggle(arguments.match, argv) if arguments.action == "minimize": return action_minimize() if arguments.action == "restore-last": return action_restore_last() return action_list() if __name__ == "__main__": sys.exit(main())