diff --git a/VERSION b/VERSION index 4a3e97d..278cbc8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.42 +0.1.43 diff --git a/docs/browser.md b/docs/browser.md index d179740..44171cb 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -142,9 +142,29 @@ polling, so dragging a popup costs nothing. ### Closing one -The Close button is **in the dock**, and only while a secondary window -is focused. Clicking it runs `[con_id=N] kill` on that exact container: -never the main browser, never a Chrome process. +The Close button is **in the dock**, and it is **sticky**. Once a +secondary window has been focused, the dock keeps offering to close it +until that window goes away. + +That stickiness is the whole design. It first followed focus, which made +the button impossible to click: moving the pointer down to the dock +moves focus off the popup, so the button vanished somewhere on the way +to it. Nobody could ever reach it. + +The remembered target changes only when: + +- the window it names closes — and it then falls back to another + secondary that is still open, so cancelling a print dialog leaves the + render window still closeable; or +- another secondary window is focused and takes over. + +Focus moving to the dock, the main browser, a print dialog or any +layer-shell surface changes nothing. The target can never be the main +browser: it is chosen from the secondary list, which never contains the +first window. + +Clicking it runs `[con_id=N] kill` on that exact container: never the +main browser, never a Chrome process. An earlier attempt anchored a close button to the popup's own corner as a separate layer-shell surface. On real hardware it chased the window diff --git a/scripts/classroom-chrome-windows b/scripts/classroom-chrome-windows index 86d4d5f..da66fea 100755 --- a/scripts/classroom-chrome-windows +++ b/scripts/classroom-chrome-windows @@ -84,11 +84,19 @@ def place(target): command(f"{target} move position center") -def configure_secondary(identifier): +def configure_secondary(identifier, arriving=True): """Float one exact container; never apply a broad Chrome float rule.""" target = criterion(identifier) command(f"{target} floating enable") command(f"{target} border none") + + if not arriving: + # Only restoring the floating role to a window that already + # existed. Never move it: that would undo wherever the teacher + # dragged it. Never focus it: that would raise the render window + # over the print dialog sitting above it. + return + place(target) command(f"{target} focus") @@ -97,19 +105,50 @@ def configure_secondary(identifier): place(target) -def focused_secondary(secondary, chrome): - """The secondary window the dock's Close button should act on. +class ActiveSecondary: + """The one secondary window the dock offers to close. - Only ever a focused one. If the teacher is in the main browser there is - nothing to close, and the dock shows no Close button at all. + Deliberately sticky. This used to be whichever secondary happened to + be focused, which made the button impossible to click: moving the + pointer down to the dock moves focus away from the popup, so the + button disappeared on the way to it. Nobody could ever reach it. + + It is remembered instead. Losing focus changes nothing. It only + changes when the window it names goes away, or when another + secondary takes over by being focused. """ - for identifier in secondary: - window = chrome.get(identifier) - if window and window["focused"] and not window["hidden"]: - return identifier + def __init__(self): + self.target = None - return None + def update(self, secondary, chrome): + # A focused secondary takes over. Focus on the dock, the print + # dialog, the main browser or any layer-shell surface is not a + # secondary, so none of them disturb this. + for identifier in secondary: + window = chrome.get(identifier) + + if window and window["focused"] and not window["hidden"]: + self.target = identifier + break + + # Never anything that has closed, and never the main browser: + # `secondary` cannot contain the primary. + if self.target is not None and self.target not in secondary: + self.target = None + + # If the remembered window closed, fall back to another one that + # is still open, so cancelling a print dialog leaves the render + # window still closeable from the dock. + if self.target is None: + live = [ + identifier + for identifier in secondary + if identifier in chrome and not chrome[identifier]["hidden"] + ] + self.target = live[-1] if live else None + + return self.target def publish(target, path): @@ -124,7 +163,7 @@ def publish(target, path): flush=True) -def reconcile(roles, path): +def reconcile(roles, active, path): current = windows() chrome = {window["id"]: window for window in chrome_windows(current)} changes = roles.update(current) @@ -142,9 +181,9 @@ def reconcile(roles, path): window = chrome.get(identifier) if window and not window["hidden"] and not window["floating"]: - configure_secondary(identifier) + configure_secondary(identifier, arriving=False) - publish(focused_secondary(roles.secondary, chrome), path) + publish(active.update(roles.secondary, chrome), path) def subscribe(): @@ -170,11 +209,12 @@ def watch(): return 0 roles = ChromeRoles() + active = ActiveSecondary() try: while True: # Whatever happened while there was no subscription. - reconcile(roles, path) + reconcile(roles, active, path) stream = subscribe() @@ -189,7 +229,7 @@ def watch(): continue if event.get("change") in INTERESTING: - reconcile(roles, path) + reconcile(roles, active, path) # Sway went away or restarted; wait, then pick it back up. stream.wait() diff --git a/tests/test-classroom-chrome-windows.sh b/tests/test-classroom-chrome-windows.sh index f0cdb35..9b14742 100755 --- a/tests/test-classroom-chrome-windows.sh +++ b/tests/test-classroom-chrome-windows.sh @@ -111,6 +111,16 @@ check("placed exactly twice, never on a loop", check("never matches all Chrome windows at once", all("app_id" not in item for item in commands)) +commands.clear() +script.configure_secondary(8, arriving=False) +check("restoring the role never moves the window", + not any("move position" in item for item in commands), commands) +check("restoring the role never raises it over a dialog", + not any("focus" in item for item in commands), commands) +check("it only puts the window back to floating and borderless", + commands == ["[con_id=8] floating enable", "[con_id=8] border none"], + commands) + commands.clear() script.configure_primary(8) check("a promoted window goes back to tiled", @@ -118,22 +128,51 @@ check("a promoted window goes back to tiled", check("and borderless", commands[1] == "[con_id=8] border none") print() -print("4. which popup the dock offers to close") -chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")} -check("none while the main browser is focused", - script.focused_secondary([8], chrome) is None) +print("4. the dock's Close target survives losing focus") +# The whole point: the pointer has to travel from the popup down to the +# dock, and that moves focus. If the target followed focus the button +# would vanish before anyone could reach it. +active = script.ActiveSecondary() + +chrome = {5: window(5, "main", focused=True)} +check("nothing to close with only the main browser", + active.update([], chrome) is None) chrome = {5: window(5, "main"), 8: window(8, "popup", focused=True)} -check("the focused popup", script.focused_secondary([8], chrome) == 8) +check("a focused popup becomes the target", active.update([8], chrome) == 8) -chrome = {8: window(8, "one"), 9: window(9, "two", focused=True)} -check("the focused one of several", script.focused_secondary([8, 9], chrome) == 9) +chrome = {5: window(5, "main"), 8: window(8, "popup")} +check("it stays the target when nothing is focused", + active.update([8], chrome) == 8) -chrome = {8: window(8, "one", hidden=True, focused=True)} -check("never a minimised one", script.focused_secondary([8], chrome) is None) +chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")} +check("it stays when focus moves to the main browser", + active.update([8], chrome) == 8) +check("the main browser is never the target", active.target != 5) -check("nothing focused means nothing to close", - script.focused_secondary([8], {8: window(8, "p")}) is None) +chrome = {5: window(5, "main"), 8: window(8, "popup"), 9: window(9, "dialog", focused=True)} +check("a newer focused popup takes over", active.update([8, 9], chrome) == 9) + +chrome = {5: window(5, "main"), 8: window(8, "popup"), 9: window(9, "dialog")} +check("and keeps it after focus moves to the dock", + active.update([8, 9], chrome) == 9) + +chrome = {5: window(5, "main"), 8: window(8, "popup")} +check("closing the dialog falls back to the render window", + active.update([8], chrome) == 8) + +chrome = {5: window(5, "main")} +check("closing the last popup clears it", active.update([], chrome) is None) + +fresh = script.ActiveSecondary() +chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")} +check("an unfocused popup is still closeable", + fresh.update([8], chrome) == 8) + +hidden = script.ActiveSecondary() +chrome = {8: window(8, "one", hidden=True)} +check("a minimised popup is not adopted", + hidden.update([8], chrome) is None) print() print("5. the state the dock reads")