From aadb1b881977b0eb25e1ca10e060977bed31a48f Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 7 Sep 2026 20:22:53 -0500 Subject: [PATCH] fix: keep the system print dialog in front of the window that rendered it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- VERSION | 2 +- lib/classroom_sway.py | 1 + scripts/classroom-chrome-windows | 70 +++++++++++++++++++++++++- tests/test-classroom-chrome-windows.sh | 53 +++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 278cbc8..6871b9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.43 +0.1.44 diff --git a/lib/classroom_sway.py b/lib/classroom_sway.py index 591a481..aaa685e 100644 --- a/lib/classroom_sway.py +++ b/lib/classroom_sway.py @@ -65,6 +65,7 @@ def collect(node, workspace, found, floating=False): { "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")), diff --git a/scripts/classroom-chrome-windows b/scripts/classroom-chrome-windows index da66fea..a755b0b 100755 --- a/scripts/classroom-chrome-windows +++ b/scripts/classroom-chrome-windows @@ -54,6 +54,15 @@ INTERESTING = frozenset( RESUBSCRIBE_SECONDS = 2 +# The system print dialog, matched by its exact title. +# +# This is the one place a title is used, and only to recognise a single +# known dialog. It is not classifying Chrome windows — creation order +# still does that. The dialog and the window that rendered the document +# for it are otherwise identical to sway, and if the render window ends +# up in front the teacher cannot print at all. +PRINT_TITLE = "Print" + STATE_NAME = "chrome-close-target" @@ -105,6 +114,10 @@ def configure_secondary(identifier, arriving=True): place(target) +def is_print_dialog(window): + return (window or {}).get("name") == PRINT_TITLE + + class ActiveSecondary: """The one secondary window the dock offers to close. @@ -171,7 +184,16 @@ def reconcile(roles, active, path): if changes["promoted"] is not None: configure_primary(changes["promoted"]) - for identifier in changes["new_secondary"]: + # The print dialog is configured last, because configuring a window + # focuses it and whichever is done last ends up in front. Both can + # arrive between two events, and the tree does not promise to list + # them in the order they were created. + arriving = sorted( + changes["new_secondary"], + key=lambda identifier: is_print_dialog(chrome.get(identifier)), + ) + + for identifier in arriving: configure_secondary(identifier) # Reassert the role if something put a secondary back into the tiling @@ -183,9 +205,55 @@ def reconcile(roles, active, path): if window and not window["hidden"] and not window["floating"]: configure_secondary(identifier, arriving=False) + keep_print_in_front(roles.secondary, chrome) + publish(active.update(roles.secondary, chrome), path) +def keep_print_in_front(secondary, chrome): + """The print dialog stays the obvious foreground window. + + The window that rendered the document is a secondary like any other + and will raise itself over the dialog given the chance, which leaves + the teacher looking at a document they cannot print. + + Focus is only ever taken back from another Chrome popup. Clicking + the main browser, the dock or anything else is the teacher's + business and is left alone. + """ + dialogs = [ + identifier + for identifier in secondary + if identifier in chrome + and is_print_dialog(chrome[identifier]) + and not chrome[identifier]["hidden"] + ] + + if not dialogs: + return + + dialog = dialogs[-1] + + if chrome[dialog]["focused"]: + return + + intruder = next( + ( + identifier + for identifier in secondary + if identifier in chrome + and chrome[identifier]["focused"] + and identifier != dialog + ), + None, + ) + + if intruder is None: + return + + command(f"[con_id={dialog}] focus") + + def subscribe(): try: return subprocess.Popen( diff --git a/tests/test-classroom-chrome-windows.sh b/tests/test-classroom-chrome-windows.sh index 9b14742..cbe0a9b 100755 --- a/tests/test-classroom-chrome-windows.sh +++ b/tests/test-classroom-chrome-windows.sh @@ -174,6 +174,59 @@ chrome = {8: window(8, "one", hidden=True)} check("a minimised popup is not adopted", hidden.update([8], chrome) is None) +print() +print("4b. the print dialog stays in front") + +def win(identifier, title, focused=False, hidden=False): + w = window(identifier, title, focused=focused, hidden=hidden) + w["name"] = title + return w + +check("the dialog is recognised by its exact title", + script.is_print_dialog({"name": "Print"})) +check("the render window is not", + not script.is_print_dialog({"name": "Printable Document - Google Chrome"})) +check("nothing else is", not script.is_print_dialog({"name": "Print Preview"})) +check("a missing title is not", not script.is_print_dialog({})) + +commands.clear() +# The render window has raised itself over the dialog. +chrome = {5: win(5, "main"), 8: win(8, "Printable Document", focused=True), + 9: win(9, "Print")} +script.keep_print_in_front([8, 9], chrome) +check("focus is taken back for the dialog", + commands == ["[con_id=9] focus"], commands) + +commands.clear() +chrome = {5: win(5, "main"), 8: win(8, "Printable Document"), 9: win(9, "Print", focused=True)} +script.keep_print_in_front([8, 9], chrome) +check("nothing happens while the dialog already has focus", commands == [], commands) + +commands.clear() +chrome = {5: win(5, "main", focused=True), 8: win(8, "Printable Document"), + 9: win(9, "Print")} +script.keep_print_in_front([8, 9], chrome) +check("focus is never stolen from the main browser", commands == [], commands) + +commands.clear() +chrome = {5: win(5, "main"), 8: win(8, "Printable Document")} +script.keep_print_in_front([8], chrome) +check("nothing happens when there is no dialog", commands == [], commands) + +commands.clear() +chrome = {8: win(8, "Printable Document", focused=True), 9: win(9, "Print", hidden=True)} +script.keep_print_in_front([8, 9], chrome) +check("a minimised dialog is left alone", commands == [], commands) + +# Both arriving together: whichever is configured last ends up in front, +# so the dialog has to be last. +order = sorted([8, 9], key=lambda i: script.is_print_dialog( + {8: {"name": "Printable Document"}, 9: {"name": "Print"}}[i])) +check("the dialog is configured after the render window", order == [8, 9], order) +order = sorted([9, 8], key=lambda i: script.is_print_dialog( + {8: {"name": "Printable Document"}, 9: {"name": "Print"}}[i])) +check("even when the tree lists it first", order == [8, 9], order) + print() print("5. the state the dock reads") work = Path(tempfile.mkdtemp())