classroom-dvd-session needs to ask sway whether VLC has taken the screen, which is the same tree walking classroom-window already does. One copy, in a module both import; classroom-window loses a third of its length. The state machine is worth protecting through a move like that, so its regression test comes with it: the exact swaymsg commands for launch, focus, minimise, restore, Super+M, Super+Shift+M, the scratchpad rescue and sway not running at all. Co-Authored-By: Claude Opus 5 <[email protected]>
209 lines
5.4 KiB
Python
Executable File
209 lines
5.4 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 os
|
|
import subprocess
|
|
import sys
|
|
|
|
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
|
sys.path.insert(
|
|
0,
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
|
)
|
|
|
|
from classroom_sway import ( # noqa: E402
|
|
HIDDEN_WORKSPACE,
|
|
command,
|
|
matches,
|
|
visible_workspace,
|
|
windows,
|
|
)
|
|
|
|
STATE_DIR = os.path.join(
|
|
os.environ.get("XDG_RUNTIME_DIR") or "/tmp",
|
|
"ekklesia-classroom",
|
|
)
|
|
STATE_FILE = os.path.join(STATE_DIR, "minimized")
|
|
|
|
|
|
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())
|