diff --git a/lib/classroom_sway.py b/lib/classroom_sway.py new file mode 100644 index 0000000..8a5e37a --- /dev/null +++ b/lib/classroom_sway.py @@ -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" diff --git a/scripts/classroom-window b/scripts/classroom-window index 81d5363..0597f18 100755 --- a/scripts/classroom-window +++ b/scripts/classroom-window @@ -10,18 +10,23 @@ scratchpad approach did. """ import argparse -import json import os -import re import subprocess 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 -# 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"} +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", @@ -30,110 +35,6 @@ STATE_DIR = os.path.join( 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(): try: with open(STATE_FILE, "r", encoding="utf-8") as handle: diff --git a/tests/test-classroom-window.sh b/tests/test-classroom-window.sh new file mode 100755 index 0000000..635139f --- /dev/null +++ b/tests/test-classroom-window.sh @@ -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 ]]