The render helper sat on top of the Print dialog, so the dialog could not be reached and nothing could be printed. Two causes. The daemon could not see window titles at all — the tree walk never collected them — and both windows arrive close enough together to be configured in the same pass, in whatever order the tree lists them. Configuring a window focuses it, so whichever was done last ended up in front, and that was not reliably the dialog. The dialog is now recognised by its exact title and configured last, and if a Chrome popup takes focus while it exists, focus is handed straight back. Focus is only ever reclaimed from another popup: clicking the main browser or the dock is the teacher's business and is left alone. A title match, narrowly, and only for this one known dialog. Creation order still decides which Chrome window is the browser; nothing else is classified by what it happens to be called. Nothing auto-closes the helper. After Print is cancelled it stays, and the sticky dock X still closes it. Proven by looking rather than by state. The render window is given a saturated background in the fixture so a screenshot answers "which window is in front" unambiguously: Print opens in front, the helper is then deliberately raised over it and Print comes back in front, cancelling Print leaves the helper visible with the dock X still offering it, and that X closes exactly the helper and leaves the main browser untouched. Co-Authored-By: Claude Opus 5 <[email protected]>
128 lines
3.3 KiB
Python
128 lines
3.3 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, floating=False):
|
|
"""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 "",
|
|
"name": node.get("name") 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,
|
|
"floating": floating,
|
|
}
|
|
)
|
|
|
|
for child in node.get("nodes", []):
|
|
collect(child, workspace, found, floating)
|
|
|
|
for child in node.get("floating_nodes", []):
|
|
collect(child, workspace, found, True)
|
|
|
|
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"
|