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]>
123 lines
3.1 KiB
Python
123 lines
3.1 KiB
Python
"""Talking to sway.
|
|
|
|
Shared by classroom-window, which moves windows around, and by
|
|
classroom-dvd-session, which needs to know when VLC has actually taken
|
|
the screen.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
|
|
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"}
|
|
|
|
|
|
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")),
|
|
"fullscreen": bool(node.get("fullscreen_mode")),
|
|
"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"
|