Files
Classroom_Desktop/scripts/classroom-window
T
ekklesiaandClaude Opus 5 36100ad40c fix: let restore rescue windows left in sway's scratchpad
Nothing puts windows there any more, but a window parked by an older
classroom build — the exact case this work exists to fix — would
otherwise be invisible to restore and stranded until the next reboot.
Counting the scratchpad as hidden means the Browser button and
Super+Shift+M both pull it back, tiled.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 17:13:51 -05:00

308 lines
8.0 KiB
Python
Executable File

#!/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())