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())