feat: minimize and restore windows the way a desktop does
The scratchpad technically minimized Chrome, but sway hands scratchpad windows back floating, so restoring gave a draggable window instead of the maximized one the user put away. Park windows on a hidden workspace as ordinary tiled containers instead, and move them back to the visible workspace on restore. classroom-window carries the whole state machine, so the dock buttons and Super+M share one implementation: not running -> launch unfocused -> focus focused -> minimize minimized -> restore, tiled and maximized Fullscreen is dropped on the way out, since a container parked while still fullscreen keeps drawing over everything else. floating disable on the way back also rescues windows left floating by the old scratchpad path. Super+Shift+M restores the newest minimized window, so nothing can get stranded on the hidden workspace. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d05f5bf909
commit
af79aba21b
+7
-2
@@ -14,5 +14,10 @@ exec --no-startup-id /usr/local/bin/classroom-dock
|
||||
|
||||
include /etc/sway/config.d/*
|
||||
|
||||
# Minimize focused window to scratchpad
|
||||
bindsym $mod+m move container to workspace __ekklesia_hidden
|
||||
# Minimize and restore.
|
||||
#
|
||||
# classroom-window parks the window on a hidden workspace as an ordinary
|
||||
# tiled container, so restoring brings it back maximised instead of as a
|
||||
# floating, draggable window the way the scratchpad did.
|
||||
bindsym $mod+m exec /usr/local/bin/classroom-window minimize
|
||||
bindsym $mod+Shift+m exec /usr/local/bin/classroom-window restore-last
|
||||
|
||||
@@ -18,6 +18,7 @@ chown classroom:classroom /home/classroom/.bash_profile
|
||||
chmod 0644 /home/classroom/.bash_profile
|
||||
|
||||
chmod 0755 /usr/local/bin/classroom-browser
|
||||
chmod 0755 /usr/local/bin/classroom-window
|
||||
|
||||
if [ ! -f /etc/ekklesia-classroom/device.conf ]; then
|
||||
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
|
||||
|
||||
+20
-39
@@ -1,44 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
APP_ID='google-chrome'
|
||||
HIDDEN_WS='__ekklesia_hidden'
|
||||
# The dock's Browser button. classroom-window decides whether this is a
|
||||
# launch, a focus, a minimise or a restore.
|
||||
#
|
||||
# Chrome stays a full browser: Wonder Ink is only the starting page, and
|
||||
# the profile in ~/.config/google-chrome is never touched here so logins
|
||||
# and cookies survive reboots and updates.
|
||||
|
||||
if ! pgrep -x chrome >/dev/null 2>&1 &&
|
||||
! pgrep -x google-chrome >/dev/null 2>&1; then
|
||||
exec google-chrome \
|
||||
HOME_PAGE='https://app.wonderink.org/'
|
||||
|
||||
exec /usr/local/bin/classroom-window toggle \
|
||||
--match 'google-chrome' \
|
||||
--match 'chrome' \
|
||||
--match 'chrome-.*' \
|
||||
--match 'google-chrome-.*' \
|
||||
-- \
|
||||
google-chrome \
|
||||
--ozone-platform=wayland \
|
||||
--start-maximized \
|
||||
https://app.wonderink.org/
|
||||
fi
|
||||
|
||||
focused_app="$(
|
||||
swaymsg -t get_tree -r |
|
||||
python3 -c '
|
||||
import json, sys
|
||||
|
||||
tree = json.load(sys.stdin)
|
||||
|
||||
def find_focused(node):
|
||||
if node.get("focused"):
|
||||
return node
|
||||
for child in node.get("nodes", []) + node.get("floating_nodes", []):
|
||||
result = find_focused(child)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
node = find_focused(tree)
|
||||
print((node or {}).get("app_id") or "")
|
||||
'
|
||||
)"
|
||||
|
||||
if [ "$focused_app" = "$APP_ID" ]; then
|
||||
swaymsg '[app_id="google-chrome"] move container to workspace __ekklesia_hidden' >/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
swaymsg '[app_id="google-chrome"] move container to workspace current' >/dev/null 2>&1 || true
|
||||
swaymsg '[app_id="google-chrome"] focus' >/dev/null 2>&1 || true
|
||||
swaymsg '[app_id="google-chrome"] fullscreen disable' >/dev/null 2>&1 || true
|
||||
swaymsg '[app_id="google-chrome"] floating disable' >/dev/null 2>&1 || true
|
||||
--no-first-run \
|
||||
--no-default-browser-check \
|
||||
--password-store=basic \
|
||||
"$HOME_PAGE"
|
||||
|
||||
Executable
+302
@@ -0,0 +1,302 @@
|
||||
#!/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 json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HIDDEN_WORKSPACE = "__ekklesia_hidden"
|
||||
|
||||
STATE_DIR = os.path.join(
|
||||
os.environ.get("XDG_RUNTIME_DIR") or "/tmp",
|
||||
"ekklesia-classroom",
|
||||
)
|
||||
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 == HIDDEN_WORKSPACE,
|
||||
}
|
||||
)
|
||||
|
||||
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") != HIDDEN_WORKSPACE:
|
||||
return workspace.get("name")
|
||||
|
||||
for workspace in workspaces:
|
||||
if workspace.get("visible") and workspace.get("name") != HIDDEN_WORKSPACE:
|
||||
return workspace.get("name")
|
||||
|
||||
return "1"
|
||||
|
||||
|
||||
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())
|
||||
@@ -20,6 +20,38 @@ fi
|
||||
STAGE="$ROOT/packaging/root"
|
||||
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
|
||||
|
||||
# Syntax check everything the package ships, by shebang, so this cannot
|
||||
# drift out of date as classroom commands are added.
|
||||
echo "Validating sources"
|
||||
|
||||
for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/*; do
|
||||
[[ -f "$script" ]] || continue
|
||||
|
||||
case "$(head -n 1 "$script")" in
|
||||
*python3*)
|
||||
python3 -m py_compile "$script"
|
||||
;;
|
||||
*bash*|*bin/sh*)
|
||||
bash -n "$script"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
for module in "$ROOT"/lib/*.py; do
|
||||
[[ -f "$module" ]] || continue
|
||||
|
||||
python3 -m py_compile "$module"
|
||||
done
|
||||
|
||||
for policy in "$ROOT"/config/chrome/policies/*/*.json; do
|
||||
[[ -f "$policy" ]] || continue
|
||||
|
||||
python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$policy"
|
||||
done
|
||||
|
||||
find "$ROOT/scripts" "$ROOT/lib" \
|
||||
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
echo "Building Ekklesia Classroom ${VERSION}"
|
||||
|
||||
# Always construct the package from clean canonical sources.
|
||||
@@ -54,6 +86,10 @@ install -Dm755 \
|
||||
"$ROOT/scripts/classroom-browser" \
|
||||
"$STAGE/usr/local/bin/classroom-browser"
|
||||
|
||||
install -Dm755 \
|
||||
"$ROOT/scripts/classroom-window" \
|
||||
"$STAGE/usr/local/bin/classroom-window"
|
||||
|
||||
install -Dm644 \
|
||||
"$ROOT/config/wallpapers/classroom-default.png" \
|
||||
"$STAGE/usr/share/backgrounds/ekklesia/classroom-default.png"
|
||||
|
||||
Reference in New Issue
Block a user