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/*
|
include /etc/sway/config.d/*
|
||||||
|
|
||||||
# Minimize focused window to scratchpad
|
# Minimize and restore.
|
||||||
bindsym $mod+m move container to workspace __ekklesia_hidden
|
#
|
||||||
|
# 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 0644 /home/classroom/.bash_profile
|
||||||
|
|
||||||
chmod 0755 /usr/local/bin/classroom-browser
|
chmod 0755 /usr/local/bin/classroom-browser
|
||||||
|
chmod 0755 /usr/local/bin/classroom-window
|
||||||
|
|
||||||
if [ ! -f /etc/ekklesia-classroom/device.conf ]; then
|
if [ ! -f /etc/ekklesia-classroom/device.conf ]; then
|
||||||
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
|
cat > /etc/ekklesia-classroom/device.conf <<'CONFIG'
|
||||||
|
|||||||
+20
-39
@@ -1,44 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
APP_ID='google-chrome'
|
# The dock's Browser button. classroom-window decides whether this is a
|
||||||
HIDDEN_WS='__ekklesia_hidden'
|
# 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 &&
|
HOME_PAGE='https://app.wonderink.org/'
|
||||||
! pgrep -x google-chrome >/dev/null 2>&1; then
|
|
||||||
exec google-chrome \
|
exec /usr/local/bin/classroom-window toggle \
|
||||||
|
--match 'google-chrome' \
|
||||||
|
--match 'chrome' \
|
||||||
|
--match 'chrome-.*' \
|
||||||
|
--match 'google-chrome-.*' \
|
||||||
|
-- \
|
||||||
|
google-chrome \
|
||||||
--ozone-platform=wayland \
|
--ozone-platform=wayland \
|
||||||
--start-maximized \
|
--start-maximized \
|
||||||
https://app.wonderink.org/
|
--no-first-run \
|
||||||
fi
|
--no-default-browser-check \
|
||||||
|
--password-store=basic \
|
||||||
focused_app="$(
|
"$HOME_PAGE"
|
||||||
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
|
|
||||||
|
|||||||
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"
|
STAGE="$ROOT/packaging/root"
|
||||||
OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb"
|
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}"
|
echo "Building Ekklesia Classroom ${VERSION}"
|
||||||
|
|
||||||
# Always construct the package from clean canonical sources.
|
# Always construct the package from clean canonical sources.
|
||||||
@@ -54,6 +86,10 @@ install -Dm755 \
|
|||||||
"$ROOT/scripts/classroom-browser" \
|
"$ROOT/scripts/classroom-browser" \
|
||||||
"$STAGE/usr/local/bin/classroom-browser"
|
"$STAGE/usr/local/bin/classroom-browser"
|
||||||
|
|
||||||
|
install -Dm755 \
|
||||||
|
"$ROOT/scripts/classroom-window" \
|
||||||
|
"$STAGE/usr/local/bin/classroom-window"
|
||||||
|
|
||||||
install -Dm644 \
|
install -Dm644 \
|
||||||
"$ROOT/config/wallpapers/classroom-default.png" \
|
"$ROOT/config/wallpapers/classroom-default.png" \
|
||||||
"$STAGE/usr/share/backgrounds/ekklesia/classroom-default.png"
|
"$STAGE/usr/share/backgrounds/ekklesia/classroom-default.png"
|
||||||
|
|||||||
Reference in New Issue
Block a user