refactor: share the sway helpers
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
86dff744f7
commit
719b856040
@@ -0,0 +1,122 @@
|
|||||||
|
"""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"
|
||||||
+12
-111
@@ -10,18 +10,23 @@ scratchpad approach did.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
HIDDEN_WORKSPACE = "__ekklesia_hidden"
|
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||||
|
sys.path.insert(
|
||||||
|
0,
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||||
|
)
|
||||||
|
|
||||||
# sway parks scratchpad windows on a workspace of its own. Nothing here
|
from classroom_sway import ( # noqa: E402
|
||||||
# puts them there, but treating it as hidden means a window left in the
|
HIDDEN_WORKSPACE,
|
||||||
# scratchpad by an older classroom build can still be restored.
|
command,
|
||||||
HIDDEN_WORKSPACES = {HIDDEN_WORKSPACE, "__i3_scratch"}
|
matches,
|
||||||
|
visible_workspace,
|
||||||
|
windows,
|
||||||
|
)
|
||||||
|
|
||||||
STATE_DIR = os.path.join(
|
STATE_DIR = os.path.join(
|
||||||
os.environ.get("XDG_RUNTIME_DIR") or "/tmp",
|
os.environ.get("XDG_RUNTIME_DIR") or "/tmp",
|
||||||
@@ -30,110 +35,6 @@ STATE_DIR = os.path.join(
|
|||||||
STATE_FILE = os.path.join(STATE_DIR, "minimized")
|
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():
|
def read_state():
|
||||||
try:
|
try:
|
||||||
with open(STATE_FILE, "r", encoding="utf-8") as handle:
|
with open(STATE_FILE, "r", encoding="utf-8") as handle:
|
||||||
|
|||||||
Executable
+147
@@ -0,0 +1,147 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# Tests the minimise/restore state machine against a mock sway IPC, by
|
||||||
|
# asserting the exact commands classroom-window sends.
|
||||||
|
#
|
||||||
|
# tests/test-classroom-window.sh
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SCRIPT="$ROOT/scripts/classroom-window"
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
passed=0
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local label="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$actual" == "$expected" ]]; then
|
||||||
|
echo " ok $label"
|
||||||
|
passed=$((passed + 1))
|
||||||
|
else
|
||||||
|
echo " FAIL $label"
|
||||||
|
echo " expected: ${expected//$'\n'/ | }"
|
||||||
|
echo " got: ${actual//$'\n'/ | }"
|
||||||
|
failed=$((failed + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$WORK/bin"
|
||||||
|
cat > "$WORK/bin/swaymsg" <<'SHIM'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
args = sys.argv[1:]
|
||||||
|
state = json.load(open(os.environ["MOCK_TREE"]))
|
||||||
|
if args[:1] == ["-t"]:
|
||||||
|
print(json.dumps(state["tree" if args[1] == "get_tree" else "workspaces"]))
|
||||||
|
sys.exit(0)
|
||||||
|
with open(os.environ["MOCK_LOG"], "a") as log:
|
||||||
|
log.write(" ".join(a for a in args if a != "--") + "\n")
|
||||||
|
SHIM
|
||||||
|
chmod +x "$WORK/bin/swaymsg"
|
||||||
|
|
||||||
|
export PATH="$WORK/bin:$PATH"
|
||||||
|
export MOCK_LOG="$WORK/log"
|
||||||
|
export XDG_RUNTIME_DIR="$WORK/run"
|
||||||
|
|
||||||
|
tree() {
|
||||||
|
cat > "$WORK/tree.json"
|
||||||
|
export MOCK_TREE="$WORK/tree.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
run() { : > "$MOCK_LOG"; python3 "$SCRIPT" "$@" >/dev/null 2>&1; cat "$MOCK_LOG"; }
|
||||||
|
|
||||||
|
# ------------------------------------------------ a focused Chrome window
|
||||||
|
|
||||||
|
tree <<'JSON'
|
||||||
|
{"tree":{"type":"root","id":1,"nodes":[
|
||||||
|
{"type":"output","id":2,"name":"HDMI-A-1","nodes":[
|
||||||
|
{"type":"workspace","id":3,"name":"1","nodes":[
|
||||||
|
{"type":"con","id":10,"pid":100,"app_id":"google-chrome","focused":true,
|
||||||
|
"fullscreen_mode":0,"nodes":[],"floating_nodes":[]}],"floating_nodes":[]},
|
||||||
|
{"type":"workspace","id":4,"name":"__ekklesia_hidden","nodes":[
|
||||||
|
{"type":"con","id":11,"pid":101,"app_id":null,
|
||||||
|
"window_properties":{"class":"vlc","instance":"vlc"},"focused":false,
|
||||||
|
"fullscreen_mode":1,"nodes":[],"floating_nodes":[]}],"floating_nodes":[]}
|
||||||
|
],"floating_nodes":[]}],"floating_nodes":[]},
|
||||||
|
"workspaces":[{"name":"1","focused":true,"visible":true},
|
||||||
|
{"name":"__ekklesia_hidden","focused":false,"visible":false}]}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
echo "1. the browser button"
|
||||||
|
check "focused window is minimised, fullscreen dropped first" \
|
||||||
|
"[con_id=10] fullscreen disable
|
||||||
|
[con_id=10] move container to workspace __ekklesia_hidden" \
|
||||||
|
"$(run toggle --match google-chrome -- google-chrome)"
|
||||||
|
|
||||||
|
check "a minimised window comes back tiled, not floating" \
|
||||||
|
"[con_id=11] move container to workspace 1
|
||||||
|
[con_id=11] floating disable
|
||||||
|
[con_id=11] focus" \
|
||||||
|
"$(run toggle --match '(?i)vlc' -- vlc)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "2. keyboard"
|
||||||
|
check "Super+M minimises whatever is focused" \
|
||||||
|
"[con_id=10] fullscreen disable
|
||||||
|
[con_id=10] move container to workspace __ekklesia_hidden" \
|
||||||
|
"$(run minimize)"
|
||||||
|
|
||||||
|
check "Super+Shift+M restores from the hidden workspace" \
|
||||||
|
"[con_id=11] move container to workspace 1
|
||||||
|
[con_id=11] floating disable
|
||||||
|
[con_id=11] focus" \
|
||||||
|
"$(run restore-last)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "3. a running but unfocused window is focused, not minimised"
|
||||||
|
python3 - "$WORK/tree.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
data = json.load(open(sys.argv[1]))
|
||||||
|
data["tree"]["nodes"][0]["nodes"][0]["nodes"][0]["focused"] = False
|
||||||
|
json.dump(data, open(sys.argv[1], "w"))
|
||||||
|
PY
|
||||||
|
check "focus only" "[con_id=10] focus" "$(run toggle --match google-chrome -- x)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "4. nothing running launches the application"
|
||||||
|
: > "$MOCK_LOG"
|
||||||
|
output="$(python3 "$SCRIPT" toggle --match firefox -- /bin/echo LAUNCHED 2>&1)"
|
||||||
|
sleep 0.3
|
||||||
|
check "launch instead of a sway command" "LAUNCHED" "$output"
|
||||||
|
check "no sway command sent" "" "$(cat "$MOCK_LOG")"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "5. a window stranded in sway's own scratchpad is rescued"
|
||||||
|
tree <<'JSON'
|
||||||
|
{"tree":{"type":"root","id":1,"nodes":[
|
||||||
|
{"type":"output","id":90,"name":"__i3","nodes":[
|
||||||
|
{"type":"workspace","id":91,"name":"__i3_scratch","nodes":[],"floating_nodes":[
|
||||||
|
{"type":"floating_con","id":12,"pid":102,"app_id":"google-chrome",
|
||||||
|
"focused":false,"fullscreen_mode":0,"nodes":[],"floating_nodes":[]}]}],
|
||||||
|
"floating_nodes":[]},
|
||||||
|
{"type":"output","id":2,"name":"HDMI-A-1","nodes":[
|
||||||
|
{"type":"workspace","id":3,"name":"1","nodes":[],"floating_nodes":[]}],
|
||||||
|
"floating_nodes":[]}],"floating_nodes":[]},
|
||||||
|
"workspaces":[{"name":"1","focused":true,"visible":true},
|
||||||
|
{"name":"__i3_scratch","focused":false,"visible":false}]}
|
||||||
|
JSON
|
||||||
|
check "pulled back and forced out of floating" \
|
||||||
|
"[con_id=12] move container to workspace 1
|
||||||
|
[con_id=12] floating disable
|
||||||
|
[con_id=12] focus" \
|
||||||
|
"$(run toggle --match google-chrome -- x)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "6. sway not running is survivable"
|
||||||
|
export PATH="${PATH#"$WORK/bin:"}"
|
||||||
|
for action in list minimize restore-last; do
|
||||||
|
python3 "$SCRIPT" "$action" >/dev/null 2>&1
|
||||||
|
check "$action exits cleanly with no sway" "0" "$?"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "passed: $passed failed: $failed"
|
||||||
|
[[ "$failed" -eq 0 ]]
|
||||||
Reference in New Issue
Block a user