From cb134e7908791c2c752ea0aa50263ce43640dd88 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Fri, 30 Oct 2026 12:48:40 -0700 Subject: [PATCH] feat(nix): fullscreen and the user's maximize are shared scenarios Platform.fullscreen and Platform.user_maximize take a window fullscreen or maximize it as the user would, without asking the daemon. The scenarios maximizing.user and fullscreen check that the daemon takes the window out of the layout, leaves it alone, and puts it back. The KDE extras keep the on-top screenshot, the daemon's log and the setup. --- docs/dev/suite-ledger.md | 18 ++ nix/tests/vm/lib/komorebi_vm/core/platform.py | 12 ++ .../komorebi_vm/core/scenarios/fullscreen.py | 73 +++++++ .../komorebi_vm/core/scenarios/maximizing.py | 109 +++++++++++ nix/tests/vm/lib/komorebi_vm/kde_platform.py | 7 + .../platforms/kde/extras/features.py | 94 +++------ .../platforms/kde/extras/user_maximize.py | 179 ++++-------------- nix/tests/vm/lib/tests/fake_platform.py | 55 +++++- nix/tests/vm/lib/tests/test_moved.py | 40 ++++ nix/tests/vm/lib/tests/test_tiling.py | 6 + nix/tests/vm/suites/features/test.py | 20 +- nix/tests/vm/suites/user-maximize/test.py | 27 ++- 12 files changed, 421 insertions(+), 219 deletions(-) create mode 100644 nix/tests/vm/lib/komorebi_vm/core/scenarios/fullscreen.py diff --git a/docs/dev/suite-ledger.md b/docs/dev/suite-ledger.md index d811c791..896b07a8 100644 --- a/docs/dev/suite-ledger.md +++ b/docs/dev/suite-ledger.md @@ -203,6 +203,24 @@ Ported whole as the extra `kwin_restart.run` (two restarts, 19 checks as run). ` that restarted KWin once with the same four checks; it is gone, and `core-e2e` now only opens the window `afterwards` that its last section looks for. +## features and user-maximize + +`Platform.fullscreen(client, on)` and `Platform.user_maximize(client, on)` are the user's fullscreen +and maximize, which the daemon is not asked to do. + +| Was | Now | +|---|---| +| features A, the window fills the work area, is in the daemon's state as the maximized window, and goes back to its tile in a container | shared `maximizing.run`; extra `features.run` keeps what KWin says: the frame of the tile before and after, and that it is on top, by a screenshot | +| features E, a fullscreen window covers the monitor, a window opening meanwhile does not take it out, back from fullscreen it is in a tile and not on another window | shared `fullscreen` (the suite runs it after the extra, on the windows the sections leave: `features.fullscreen_setup`) | +| user-maximize 1, the user maximizes: the daemon has it as its maximized window, it fills the work area, its tile is gone | shared `maximizing.user` | +| user-maximize 2, what is asked of the layout does nothing; the others did not move; nothing floats or is in the monocle | shared `maximizing.user` | +| user-maximize 2, the daemon's log notes that it ignored it; 1, the log has the `MaximizeChange` event | extra `user_maximize.log` | +| user-maximize 3 and 4, `toggle-maximize` and the user's restore put it back in its tile | shared `maximizing.user` | + +Not kept: for features A, "komorebi still has all three" (the shared scenario checks the count where +it is a check of the window it moves). The shared `fullscreen` is looser than E in one way: the +window covers the display up to a pixel, where E compared the frame with the monitor exactly. + ## The KDE-only suites Moved as they were into `komorebi_vm.platforms.kde.extras`, one module per suite (`kwin`, diff --git a/nix/tests/vm/lib/komorebi_vm/core/platform.py b/nix/tests/vm/lib/komorebi_vm/core/platform.py index 7af1e007..7c579b12 100644 --- a/nix/tests/vm/lib/komorebi_vm/core/platform.py +++ b/nix/tests/vm/lib/komorebi_vm/core/platform.py @@ -252,6 +252,18 @@ class Platform(Protocol): size.""" ... + def fullscreen(self, client: Client, on: bool = True) -> None: + """Takes the window fullscreen, as the program's own key does it (`on`), or brings it back + from fullscreen (`on` false). The daemon is not asked: it learns of it from the system, and + has to leave the window alone. Returns when the system has done it.""" + ... + + def user_maximize(self, client: Client, on: bool = True) -> None: + """Maximizes the window, as its title bar button does (`on`), or restores it from being + maximized (`on` false). The daemon is not asked: it learns of it from the system. Returns + when the system has done it.""" + ... + def work_area(self) -> Rect: """The area of the first display that windows may take, in the coordinates of the desktop: the display less what the operating system keeps for itself (a panel, a dock, the diff --git a/nix/tests/vm/lib/komorebi_vm/core/scenarios/fullscreen.py b/nix/tests/vm/lib/komorebi_vm/core/scenarios/fullscreen.py new file mode 100644 index 00000000..f7bb8978 --- /dev/null +++ b/nix/tests/vm/lib/komorebi_vm/core/scenarios/fullscreen.py @@ -0,0 +1,73 @@ +"""A window the user takes fullscreen is left alone by the daemon, and goes back to its tile.""" + +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.geometry import Rect +from komorebi_vm.core.platform import Client, Platform +from komorebi_vm.core.scenarios.support import focus_agrees + + +def _covers(frame: Rect, display: Rect) -> bool: + """The frame has the whole display, give or take a pixel.""" + return ( + frame.x <= display.x + 1 + and frame.y <= display.y + 1 + and frame.x + frame.w >= display.x + display.w - 1 + and frame.y + frame.h >= display.y + display.h - 1 + ) + + +def run(platform: Platform, checks: Checks, clients: list[Client]) -> None: + """Takes the last of `clients` fullscreen, opens a window, and brings it back. + + `clients` are tiled windows of the first workspace on the first display, each in a container + of its own. The window opened is closed at the end. + """ + target = clients[-1] + count = platform.state().managed() + display = platform.displays()[0] + platform.activate(target) + platform.wait_for(lambda: focus_agrees(platform, clients, target.title)) + # the daemon asks again for the frames of what it moved, for a little while after a command + platform.wait_for(lambda: False, timeout=3) + + def covers() -> bool: + window = platform.os_window(target) + return window is not None and _covers(window.frame, display) + + platform.fullscreen(target) + checks.check( + "a window taken fullscreen covers the display", + platform.wait_for(covers, timeout=20), + ) + extra = platform.spawn("fullscreen-extra") + checks.check( + "the daemon manages a window that opens meanwhile", + platform.wait_for(lambda: platform.state().managed() == count + 1, timeout=20), + ) + platform.wait_for(lambda: False, timeout=3) + checks.check("... and it does not take the window out of fullscreen", covers()) + + platform.fullscreen(target, on=False) + + def in_a_tile() -> bool: + window = platform.os_window(target) + return ( + window is not None + and window.frame.w < display.w - 100 + and window.frame.inside(display, 1) + ) + + checks.check( + "back from fullscreen it is in a tile, inside the display", + platform.wait_for(in_a_tile, timeout=20), + ) + frames = [w.frame for c in [*clients, extra] if (w := platform.os_window(c)) is not None] + checks.check( + "... not on any other window", + not any(a.overlaps(b) for i, a in enumerate(frames) for b in frames[i + 1 :]), + ) + checks.check( + "... and the daemon still has every window", platform.state().managed() == count + 1 + ) + platform.close(extra) + platform.wait_for(lambda: platform.state().managed() == count, timeout=20) diff --git a/nix/tests/vm/lib/komorebi_vm/core/scenarios/maximizing.py b/nix/tests/vm/lib/komorebi_vm/core/scenarios/maximizing.py index 1f7c6fb9..c1027c54 100644 --- a/nix/tests/vm/lib/komorebi_vm/core/scenarios/maximizing.py +++ b/nix/tests/vm/lib/komorebi_vm/core/scenarios/maximizing.py @@ -56,3 +56,112 @@ def run(platform: Platform, checks: Checks, clients: list[Client]) -> None: "... and every window is tiled where the layout says", platform.wait_for(lambda: tiled(platform, clients), timeout=20), ) + + +# What is asked of the layout, which a maximized window is not in: none of it does anything. +IGNORED = ( + "move left", + "move right", + "move up", + "move down", + "stack left", + "stack right", + "unstack", + "cycle-move next", + "promote", + "toggle-float", + "toggle-monocle", + "resize-edge left increase", + "send-to-workspace 1", + "move-to-workspace 1", + "toggle-lock", +) + + +def user(platform: Platform, checks: Checks, clients: list[Client]) -> None: + """A window the user maximizes, not the command, is taken out of the layout like one that + `toggle-maximize` maximized: the daemon has it as its maximized window, the others are left + alone, what is asked of the layout does nothing, and it goes back to its tile when it is + restored, by `toggle-maximize` or by the user. + + `clients` are three tiled windows or more of the first workspace, each in a container of its + own. The first workspace has the second one, so that `send-to-workspace 1` can name it. + """ + count = len(clients) + target, other = clients[1], clients[-1] + area = platform.work_area() + tiles = positions(platform, clients) + + def maximized() -> str | None: + window = platform.state().workspace().maximized + return window.title if window else None + + def containers() -> int: + return len(platform.state().workspace().containers) + + def fills(client: Client) -> bool: + window = platform.os_window(client) + return ( + window is not None + and window.frame.w >= area.w - 2 * platform.tolerance + and window.frame.h >= area.h - 4 * platform.tolerance + ) + + platform.user_maximize(target) + checks.check( + f"after the user maximizes {target.title} the daemon has it as its maximized window", + platform.wait_for(lambda: maximized() == target.title, timeout=20), + ) + checks.check("... it fills the work area", platform.wait_for(lambda: fills(target), timeout=10)) + checks.check( + "... its tile is gone, and the daemon still has every window", + containers() == count - 1 and platform.state().managed() == count, + ) + + beside = positions(platform, [c for c in clients if c != target]) + for command in IGNORED: + platform.komorebic_status(*command.split()) + platform.wait_for(lambda: False, timeout=1) + checks.check( + "what is asked of the layout does nothing: it is still maximized, and the others did not" + " move", + fills(target) + and maximized() == target.title + and positions(platform, [c for c in clients if c != target]) == beside, + ) + workspace = platform.state().workspace() + checks.check( + "... and nothing floats or is in the monocle", + containers() == count - 1 and workspace.floating == [] and workspace.monocle is None, + ) + + platform.komorebic("toggle-maximize") + checks.check( + f"toggle-maximize restores it: nothing is maximized and {target.title} is in its tile", + platform.wait_for( + lambda: ( + maximized() is None + and containers() == count + and positions(platform, [target]) == {target.title: tiles[target.title]} + ), + timeout=20, + ), + ) + + platform.user_maximize(other) + checks.check( + f"the user maximizes {other.title} too", + platform.wait_for(lambda: maximized() == other.title and containers() == count - 1), + ) + platform.user_maximize(other, on=False) + checks.check( + f"restored by the user, {other.title} is back in its tile and nothing is maximized", + platform.wait_for( + lambda: ( + maximized() is None + and containers() == count + and positions(platform, [other]) == {other.title: tiles[other.title]} + ), + timeout=20, + ), + ) diff --git a/nix/tests/vm/lib/komorebi_vm/kde_platform.py b/nix/tests/vm/lib/komorebi_vm/kde_platform.py index e1529728..4f20366e 100644 --- a/nix/tests/vm/lib/komorebi_vm/kde_platform.py +++ b/nix/tests/vm/lib/komorebi_vm/kde_platform.py @@ -226,6 +226,13 @@ class KdePlatform: def move_window(self, client: Client, x: int, y: int) -> None: self.kde.sh(f"kdotool windowmove {self._wid(client)} {x} {y}") + def fullscreen(self, client: Client, on: bool = True) -> None: + self.kde.fullscreen(self.kde.wid(client.title), on) + + def user_maximize(self, client: Client, on: bool = True) -> None: + wid = self.kde.wid(client.title) + self.kde.maximize(wid) if on else self.kde.unmaximize(wid) + def work_area(self) -> Rect: """What KWin says windows may take of the first output, which is the output less the panels and docks the shell has made (a headless session has none, so it is all of it). diff --git a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/features.py b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/features.py index b7ffb027..6da9f221 100644 --- a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/features.py +++ b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/features.py @@ -11,20 +11,24 @@ daemon: container that does not have the focus, a stack and a monocled window that have it, and a floating window that has it D animation curves: two curves put a window in different places at the same moment - E fullscreen: a fullscreen window covers the monitor, is not moved, and goes back to its tile + E fullscreen: the shared `fullscreen` scenario, which the suite runs after these, on the + windows they leave (`fullscreen_setup`) Every window is a `foot` of one color all over (`Kde.colored_window`), and a color is read at the -pixels just outside the frame of the window (`pixels.ring`); section E reads the frames KWin -reports for the windows, not a screenshot. `komorebi/tests/maximize.rs`, `maximize_commands.rs` -and `fullscreen.rs` check what the window manager does with a maximize and a fullscreen against -the fake; section A checks that KWin's own maximize does it, and section E that a window KWin has -taken fullscreen is left alone. +pixels just outside the frame of the window (`pixels.ring`). `komorebi/tests/maximize.rs`, +`maximize_commands.rs` and `fullscreen.rs` check what the window manager does with a maximize and a +fullscreen against the fake; section A checks that KWin's own maximize does it, and the shared +`fullscreen` that a window KWin has taken fullscreen is left alone. The shared `maximizing` checks +that the maximized window has the state and goes back to its tile; section A keeps what it reads in +KWin: that it is on top, from a screenshot. """ import time from typing import Any -from komorebi_vm import Checks, Kde, Rect, pixels +from komorebi_vm import Checks, Kde, pixels +from komorebi_vm.core.platform import Client +from komorebi_vm.kde_platform import KdePlatform UNIT = "komorebi.service" # the daemon's log of the day, without colors, which every run of the unit adds to @@ -168,23 +172,12 @@ def run(machine: Any, checks: Checks, scale: float) -> None: ) ) checks.check(f"and is the one on top ({inside})", near(inside, BLUE)) - workspace = kde.state()["monitors"]["elements"][0]["workspaces"]["elements"][0] - checks.check( - "komorebi's state has it as the maximized window, out of its containers", - workspace["maximized_window"] is not None and len(workspace["containers"]["elements"]) == 2, - ) - checks.check("komorebi still has all three", kde.managed_all() == 3) kde.komorebic("toggle-maximize") checks.check( "toggled back, it is in its tile again", kde.wait_for(lambda: kde.rect(blue) == tile, timeout=20), ) - workspace = kde.state()["monitors"]["elements"][0]["workspaces"]["elements"][0] - checks.check( - "... and in a container of its own", - workspace["maximized_window"] is None and len(workspace["containers"]["elements"]) == 3, - ) # the session started the module's unit when it logged in: section A used its daemon, and the # sections after it start their own runs @@ -304,56 +297,21 @@ def run(machine: Any, checks: Checks, scale: float) -> None: checks.check("Linear is less than 60% of the way", 0 <= linear < 60) checks.check("EaseOutExpo is at least 20 points further along than Linear", expo >= linear + 20) - print("== E. fullscreen") - # This section needs the daemon running and a red, a green and a blue window open and tiled, and - # does not depend on which section came before it: when one has stopped the daemon or closed the - # windows, they are started and opened again. - if not answering(): + +def fullscreen_setup(machine: Any, scale: float) -> tuple[KdePlatform, list[Client]]: + """What the shared `fullscreen` needs: the daemon running, and a red, a green and a blue window + open and tiled. When an earlier section stopped the daemon or closed the windows, they are + started and opened again.""" + kde = Kde.session(machine, scale=scale) + if kde.run("komorebic monitor-information")[0] != 0: kde.systemctl("start", UNIT) - kde.wait_for(answering, timeout=60) - for title, background in [("red", "ff0000"), ("green", "00ff00"), ("blue", "0000ff")]: - if kde.wid(title) == "": - open_window(title, background) + kde.wait_for(lambda: kde.run("komorebic monitor-information")[0] == 0, timeout=60) + monitor = kde.state()["monitors"]["elements"][0]["size"] + platform = KdePlatform(kde, (monitor["right"], monitor["bottom"]), session=True) + clients = [Client(title) for title in ("red", "green", "blue")] + for client in clients: + if kde.wid(client.title) == "": + platform.spawn(client.title) kde.wait_for(lambda: kde.managed() == 3, timeout=30) kde.wait_for(lambda: kde.focused_title() == "blue", timeout=20) - blue = kde.wid("blue") - monitor = kde.state()["monitors"]["elements"][0]["size"] - whole = Rect(monitor["left"], monitor["top"], monitor["right"], monitor["bottom"]) - # the bridge asks again for the position of a window that is not where komorebi put it for a second - # after it did, which is not the thing tested here - time.sleep(3 * SCALE) - kde.fullscreen(blue) - checks.check( - f"the window covers the monitor, panels included ({kde.rect(blue)}, the monitor is {whole})", - kde.wait_for(lambda: kde.rect(blue) == whole, timeout=20), - ) - open_window("yellow", "ffff00") - checks.check("komorebi manages the fourth window", kde.wait_for(lambda: kde.managed() == 4)) - time.sleep(3 * SCALE) - checks.check( - f"a window opening meanwhile does not take it out of fullscreen ({kde.rect(blue)})", - kde.rect(blue) == whole, - ) - kde.fullscreen(blue, on=False) - - def in_a_tile(r: Rect) -> bool: - """Smaller than the monitor, and inside it.""" - return ( - r.w < whole.w - 100 - and r.x >= whole.x - and r.y >= whole.y - and r.x + r.w <= whole.x + whole.w - and r.y + r.h <= whole.y + whole.h - ) - - checks.check( - "back from fullscreen it is in a tile", - kde.wait_for(lambda: in_a_tile(kde.rect(blue)), timeout=20), - ) - back = kde.rect(blue) - print(f" blue is back at {back}") - checks.check( - "and it does not sit on any other window", - not any(back.overlaps(kde.rect(kde.wid(other))) for other in ("red", "green", "yellow")), - ) - checks.check("komorebi still has all four", kde.managed_all() == 4) + return platform, clients diff --git a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/user_maximize.py b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/user_maximize.py index fa02c913..17e8e156 100644 --- a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/user_maximize.py +++ b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/user_maximize.py @@ -2,161 +2,58 @@ r"""A window the user maximizes, in a real headless KWin. The user's title bar m without asking komorebi: KWin does it and reports it, and komorebi has to take the window out of the layout as it does for `toggle-maximize`, leave the layout alone while the window is maximized, and put the window back in its tile when it is restored, by the user or by komorebi. -`komorebi/tests/user_maximize.rs` checks what the window manager does against the fake; this -checks that KWin reports the user's maximize, and the restore, the way komorebi reads them. +`komorebi/tests/user_maximize.rs` checks what the window manager does against the fake. + +The shared `maximizing.user` makes those checks through the neutral platform. What is here is the +start (`start`: a daemon with padding and no animation, and three tiled windows) and what only the +daemon's log of this port says (`log`): that KWin's report was read as a change of the window's +maximized state, and that what was asked of the layout was noted as ignored. """ -import json -import shlex -import time -from typing import Any +from komorebi_vm import daemon +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.platform import Client +from komorebi_vm.kde_platform import KdePlatform -from komorebi_vm import Checks, Kde, Rect, daemon - -W, H = 1920, 1080 # The windows are tiled with a gap of 10 pixels around the workspace and between the tiles: a tile # has padding, and a maximized window has none. The windows are not animated. -CONFIG = "/tmp/user-maximize-config.json" +CONFIG = { + "default_workspace_padding": 10, + "default_container_padding": 10, + "animation": {"enabled": False}, +} -def run(machine: Any, checks: Checks, scale: float) -> None: - """Runs the checks of the suite `user-maximize`, which the suite script calls.""" - SCALE = scale # noqa: N806 - kde = Kde.headless(machine, "main", size=(W, H), scale=SCALE) - - def workspace() -> dict[str, Any]: - return kde.state()["monitors"]["elements"][0]["workspaces"]["elements"][0] - - def containers() -> int: - return len(workspace()["containers"]["elements"]) - - def maximized_title() -> str | None: - window = workspace()["maximized_window"] - return None if window is None else window["details"]["title"] - - def fills(r: Rect) -> bool: - """The work area is the whole output: a virtual KWin has no panel.""" - return r.w >= W - 40 and r.h >= H - 80 - - def rect_of(title: str) -> Rect: - return kde.rect(kde.wid(title)) - - def open_window(title: str) -> None: - kde.spawn(f"foot -T {title} sleep 3000") - kde.wait_for(lambda: kde.wid(title) != "", timeout=30) - - print("== three windows, tiled") - kde.sh( - "printf %s " - + shlex.quote( - json.dumps( - { - "default_workspace_padding": 10, - "default_container_padding": 10, - "animation": {"enabled": False}, - } - ) - ) - + f" > {CONFIG}" - ) - kde.start_komorebi("--config", CONFIG) - kde.wait_for(lambda: kde.run("komorebic monitor-information")[0] == 0, timeout=30) - for title in ("um-a", "um-b", "um-c"): - open_window(title) - checks.check("komorebi has three tiles", kde.wait_for(lambda: containers() == 3, timeout=20)) - # The bridge asks again for the position of a window that is not where komorebi put it for a second - # after it did (PLATFORM.md, "How clients answer a frame"), which would put a window that is - # maximized in that second back where its tile was: wait that second out, as a person would. - kde.wait_for(lambda: kde.focused_title() == "um-c", timeout=20) - time.sleep(3 * SCALE) - tiles = {title: rect_of(title) for title in ("um-a", "um-b", "um-c")} - print(f" the tiles are {tiles}") - - print("== 1. the user maximizes a window with its title bar") - kde.maximize(kde.wid("um-b")) +def start(platform: KdePlatform, checks: Checks) -> list[Client]: + """Starts the daemon with the config, opens three windows and waits for them to be tiled.""" + platform.restart_with(CONFIG) + clients = [platform.spawn(title) for title in ("um-a", "um-b", "um-c")] checks.check( - "komorebi has it as its maximized window", - kde.wait_for(lambda: maximized_title() == "um-b", timeout=20), + "komorebi has three tiles", + platform.wait_for( + lambda: len(platform.state().workspace().containers) == len(clients), timeout=20 + ), ) - checks.check( - f"it fills the work area ({rect_of('um-b')})", - kde.wait_for(lambda: fills(rect_of("um-b")), timeout=10), - ) - checks.check("and its tile is gone: two are left", containers() == 2) + # The bridge asks again for the position of a window that is not where komorebi put it for a + # second after it did (PLATFORM.md, "How clients answer a frame"), which would put a window that + # is maximized in that second back where its tile was: wait that second out, as a person would. + platform.wait_for(lambda: platform.state().focused_title() == "um-c", timeout=20) + platform.wait_for(lambda: False, timeout=3) + return clients + + +def log(platform: KdePlatform, checks: Checks, clients: list[Client]) -> None: + """The daemon's log says it handled KWin's report as a change of the maximized state, and + that it ignored what was asked of the layout meanwhile.""" + text = platform.kde.komorebi_log() checks.check( "komorebi handled it as a change of the window's maximized state", any( event.kind == "MaximizeChange" and event.notification == "WindowMaximizedChanged" - for event in daemon.events(kde.komorebi_log()) + for event in daemon.events(text) ), ) - checks.check("it still has all three windows", kde.managed_all() == 3) - - print("== 2. what is asked of the layout does nothing") - beside = {title: rect_of(title) for title in ("um-a", "um-c")} - for command in [ - "move left", - "move right", - "move up", - "move down", - "stack left", - "stack right", - "unstack", - "cycle-move next", - "promote", - "toggle-float", - "toggle-monocle", - "resize-edge left increase", - "send-to-workspace 1", - "move-to-workspace 1", - "toggle-lock", - ]: - kde.komorebic(*command.split()) - time.sleep(1 * SCALE) checks.check( - "it is still maximized, and still komorebi's maximized window", - fills(rect_of("um-b")) and maximized_title() == "um-b", + "komorebi noted that it ignored what was asked of the layout", + "ignoring" in text and "the window in front is maximized" in text, ) - checks.check( - "the others did not move, and nothing floats or is in the monocle", - {title: rect_of(title) for title in ("um-a", "um-c")} == beside - and containers() == 2 - and workspace()["floating_windows"]["elements"] == [] - and workspace()["monocle_container"] is None, - ) - checks.check( - "komorebi noted that it ignored them", - "ignoring" in kde.komorebi_log() - and "the window in front is maximized" in kde.komorebi_log(), - ) - - print("== 3. toggle-maximize restores it") - kde.komorebic("toggle-maximize") - checks.check( - f"it is back in its tile ({tiles['um-b']})", - kde.wait_for(lambda: rect_of("um-b") == tiles["um-b"], timeout=20), - ) - checks.check( - "and nothing is maximized", - maximized_title() is None and containers() == 3, - ) - - print("== 4. the user restores it with the title bar") - kde.maximize(kde.wid("um-c")) - checks.check( - "maximized again", - kde.wait_for(lambda: maximized_title() == "um-c" and containers() == 2, timeout=20), - ) - kde.unmaximize(kde.wid("um-c")) - checks.check( - f"restored by the user, it is back in its tile ({tiles['um-c']})", - kde.wait_for(lambda: rect_of("um-c") == tiles["um-c"], timeout=20), - ) - checks.check( - "and nothing is maximized", - kde.wait_for(lambda: maximized_title() is None and containers() == 3, timeout=10), - ) - - kde.komorebic("stop") - kde.wait_for(lambda: not kde.komorebi_running()) - kde.stop() diff --git a/nix/tests/vm/lib/tests/fake_platform.py b/nix/tests/vm/lib/tests/fake_platform.py index fb980293..72459e5e 100644 --- a/nix/tests/vm/lib/tests/fake_platform.py +++ b/nix/tests/vm/lib/tests/fake_platform.py @@ -16,6 +16,20 @@ from komorebi_vm.core.scenarios.commands import PRINTED from komorebi_vm.core.snapshot import Snapshot, parse SCREEN = Rect(0, 0, 1920, 1080) +# what a maximized window is not in, so that asking it does nothing +ASKED_OF_THE_LAYOUT = ( + "move", + "stack", + "unstack", + "cycle-move", + "promote", + "toggle-float", + "toggle-monocle", + "resize-edge", + "send-to-workspace", + "move-to-workspace", + "toggle-lock", +) def _place_index(place: str, count: int) -> int: @@ -78,6 +92,8 @@ class FakePlatform: follows_user_moves: bool = True, resizable: bool = True, follows_modes: bool = True, + keeps_fullscreen: bool = True, + ignores_commands_when_maximized: bool = True, ) -> None: self.panel = panel """The height of a panel along the bottom of the first display, which the work area leaves @@ -86,6 +102,11 @@ class FakePlatform: """A daemon that tiles on the whole display, under the panel.""" self.second = second """The rectangle of the second display, when it is not one of the first's size.""" + self.keeps_fullscreen = keeps_fullscreen + self.ignores_commands_when_maximized = ignores_commands_when_maximized + self.fullscreened: str | None = None + self.max_home: tuple[int, int] | None = None + """Where the window the user maximized was, to put it back.""" self.resizable = resizable self.follows_modes = follows_modes self.mode: dict[int, tuple[int, int]] = {} @@ -311,6 +332,8 @@ class FakePlatform: self.containers.append([title]) if len(self.containers) > len(self.tiles): self._relayout() + if not self.keeps_fullscreen: + self.fullscreened = None if self.applies_rules_on_event: self.komorebic("enforce-workspace-rules") return Client(title) @@ -462,6 +485,26 @@ class FakePlatform: def work_area(self) -> Rect: return self.area + def fullscreen(self, client: Client, on: bool = True) -> None: + self.fullscreened = client.title if on else None + + def user_maximize(self, client: Client, on: bool = True) -> None: + if on: + self.max_home = self._find(client.title) + self._find_remove(client.title) + self.maximized = client.title + self.focus = max(0, min(self.focus, len(self.containers) - 1)) + elif self.maximized == client.title: + self._restore_maximized() + self._relayout() + + def _restore_maximized(self) -> None: + if self.max_home is not None and self.maximized is not None: + space, index = self.max_home + self.spaces[space].insert(min(index, len(self.spaces[space])), [self.maximized]) + self.max_home = None + self.maximized = None + def minimize(self, client: Client) -> None: self._find_remove(client.title) self.minimized.append(client.title) @@ -487,6 +530,8 @@ class FakePlatform: return f"/tmp/{name}" def os_window(self, client: Client) -> OsWindow | None: + if client.title == self.fullscreened: + return OsWindow(client.title, Rect(0, 0, 1920, 1080), focused=True) if client.title == self.maximized: return OsWindow(client.title, self.tiled_area, focused=True) if client.title in self.minimized: @@ -532,6 +577,10 @@ class FakePlatform: def komorebic(self, *args: str) -> str: n = len(self.containers) + # a daemon that is wrong obeys one of them, to be told by the windows that moved + obeyed = not self.ignores_commands_when_maximized and args[0] == "promote" + if self.maximized is not None and args[0] in ASKED_OF_THE_LAYOUT and not obeyed: + return "" match args: case ("stop",): self.running = False @@ -637,7 +686,11 @@ class FakePlatform: self.container_padding = int(size) self._relayout() case ("toggle-maximize",): - self.maximized = None if self.maximized else self.containers[self.focus][0] + if self.maximized is not None: + self._restore_maximized() + self._relayout() + else: + self.maximized = self.containers[self.focus][0] case ("resize-axis", "horizontal", way): d = 50 if way == "increase" else -50 i = self.focus diff --git a/nix/tests/vm/lib/tests/test_moved.py b/nix/tests/vm/lib/tests/test_moved.py index 928abff5..ca995cf3 100644 --- a/nix/tests/vm/lib/tests/test_moved.py +++ b/nix/tests/vm/lib/tests/test_moved.py @@ -6,8 +6,10 @@ from komorebi_vm.core.geometry import Rect from komorebi_vm.core.scenarios import ( commands, display_change, + fullscreen, handover, hotplug, + maximizing, monocle, placement, queries, @@ -272,3 +274,41 @@ def test_a_platform_with_no_pointer_skips_the_edge_cases() -> None: base = checks.passed resize.edge_cases(platform, checks, clients) assert (checks.passed, checks.failed) == (base, 0) + + +def test_a_daemon_that_leaves_a_fullscreen_window_alone_passes_fullscreen() -> None: + platform = FakePlatform() + checks = Checks() + clients = opened(platform, checks) + base = checks.passed + fullscreen.run(platform, checks, clients) + assert checks.failed == 0, checks.failures + assert checks.passed - base == 6 + assert platform.state().managed() == len(clients) + + +def test_a_daemon_that_takes_a_window_out_of_fullscreen_fails_fullscreen() -> None: + platform = FakePlatform(keeps_fullscreen=False) + checks = Checks() + clients = opened(platform, checks) + fullscreen.run(platform, checks, clients) + assert any("does not take the window out of fullscreen" in f for f in checks.failures) + + +def test_a_daemon_that_takes_up_a_maximize_passes_user_maximize() -> None: + platform = FakePlatform() + checks = Checks() + clients = opened(platform, checks) + base = checks.passed + maximizing.user(platform, checks, clients) + assert checks.failed == 0, checks.failures + assert checks.passed - base == 8 + + +def test_a_daemon_that_obeys_commands_when_maximized_fails_user_maximize() -> None: + platform = FakePlatform(ignores_commands_when_maximized=False) + checks = Checks() + clients = opened(platform, checks) + platform.activate(clients[-1]) + maximizing.user(platform, checks, clients) + assert any("what is asked of the layout does nothing" in f for f in checks.failures) diff --git a/nix/tests/vm/lib/tests/test_tiling.py b/nix/tests/vm/lib/tests/test_tiling.py index 296cba32..c79b834c 100644 --- a/nix/tests/vm/lib/tests/test_tiling.py +++ b/nix/tests/vm/lib/tests/test_tiling.py @@ -106,6 +106,12 @@ class FakePlatform: def activate(self, client: Client) -> None: pass + def fullscreen(self, client: Client, on: bool = True) -> None: + pass + + def user_maximize(self, client: Client, on: bool = True) -> None: + pass + def screenshot(self, name: str) -> Image.Image: return Image.new("RGB", (4, 4)) diff --git a/nix/tests/vm/suites/features/test.py b/nix/tests/vm/suites/features/test.py index 6d02f23d..e4153ea4 100644 --- a/nix/tests/vm/suites/features/test.py +++ b/nix/tests/vm/suites/features/test.py @@ -17,11 +17,12 @@ # pixels just outside the frame of the window (`pixels.ring`); section E reads the frames KWin # reports for the windows, not a screenshot. `komorebi/tests/maximize.rs`, `maximize_commands.rs` # and `fullscreen.rs` check what the window manager does with a maximize and a fullscreen against -# the fake; section A checks that KWin's own maximize does it, and section E that a window KWin has -# taken fullscreen is left alone. +# the fake; section A checks that KWin's own maximize does it, and the shared `fullscreen`, run +# after the sections, that a window KWin has taken fullscreen is left alone. from komorebi_vm import Checks -from komorebi_vm.core.suite import platform_only, run_suite +from komorebi_vm.core.scenarios import fullscreen +from komorebi_vm.core.suite import platform_only, run_suite, shared from komorebi_vm.platforms.kde.extras import features as kde_features start_all() @@ -29,5 +30,16 @@ machine.wait_for_unit("multi-user.target") checks = Checks() -run_suite(checks, platform_only("kde", kde_features.run, machine, checks, SCALE)) + + +def taken_fullscreen() -> None: + platform, clients = kde_features.fullscreen_setup(machine, SCALE) + fullscreen.run(platform, checks, clients) + + +run_suite( + checks, + platform_only("kde", kde_features.run, machine, checks, SCALE), + shared(taken_fullscreen), +) checks.finish() diff --git a/nix/tests/vm/suites/user-maximize/test.py b/nix/tests/vm/suites/user-maximize/test.py index a493ef62..f9f909fc 100644 --- a/nix/tests/vm/suites/user-maximize/test.py +++ b/nix/tests/vm/suites/user-maximize/test.py @@ -2,18 +2,35 @@ # without asking komorebi: KWin does it and reports it, and komorebi has to take the window out of # the layout as it does for `toggle-maximize`, leave the layout alone while the window is maximized, # and put the window back in its tile when it is restored, by the user or by komorebi. -# `komorebi/tests/user_maximize.rs` checks what the window manager does against the fake; this -# checks that KWin reports the user's maximize, and the restore, the way komorebi reads them. +# `komorebi/tests/user_maximize.rs` checks what the window manager does against the fake. The shared +# scenario `komorebi_vm.core.scenarios.maximizing.user` makes those checks through the neutral +# platform; `komorebi_vm.platforms.kde.extras.user_maximize` starts the daemon and reads the +# daemon's log for what KWin reported. -from komorebi_vm import Checks -from komorebi_vm.core.suite import platform_only, run_suite +from komorebi_vm import Checks, Kde +from komorebi_vm.core.scenarios import maximizing +from komorebi_vm.core.suite import platform_only, run_suite, shared +from komorebi_vm.kde_platform import KdePlatform from komorebi_vm.platforms.kde.extras import user_maximize as kde_user_maximize +W, H = 1920, 1080 + start_all() machine.wait_for_unit("multi-user.target") machine.wait_for_unit("user@1000.service") checks = Checks() +kde = Kde.headless(machine, "main", size=(W, H), scale=SCALE) +platform = KdePlatform(kde, (W, H)) +clients = kde_user_maximize.start(platform, checks) -run_suite(checks, platform_only("kde", kde_user_maximize.run, machine, checks, SCALE)) +run_suite( + checks, + shared(maximizing.user, platform, checks, clients), + platform_only("kde", kde_user_maximize.log, platform, checks, clients), +) + +kde.komorebic("stop") +kde.wait_for(lambda: not kde.komorebi_running()) +kde.stop() checks.finish()