feat: minimize and restore windows the way a desktop does

The scratchpad technically minimized Chrome, but sway hands scratchpad
windows back floating, so restoring gave a draggable window instead of
the maximized one the user put away. Park windows on a hidden workspace
as ordinary tiled containers instead, and move them back to the visible
workspace on restore.

classroom-window carries the whole state machine, so the dock buttons
and Super+M share one implementation:

  not running  -> launch
  unfocused    -> focus
  focused      -> minimize
  minimized    -> restore, tiled and maximized

Fullscreen is dropped on the way out, since a container parked while
still fullscreen keeps drawing over everything else. floating disable on
the way back also rescues windows left floating by the old scratchpad
path.

Super+Shift+M restores the newest minimized window, so nothing can get
stranded on the hidden workspace.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-04 17:06:43 -05:00
co-authored by Claude Opus 5
parent d05f5bf909
commit af79aba21b
5 changed files with 366 additions and 41 deletions
+302
View File
@@ -0,0 +1,302 @@
#!/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"
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 == HIDDEN_WORKSPACE,
}
)
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") != HIDDEN_WORKSPACE:
return workspace.get("name")
for workspace in workspaces:
if workspace.get("visible") and workspace.get("name") != HIDDEN_WORKSPACE:
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())