diff --git a/docs/dev/BEHAVIOUR.md b/docs/dev/BEHAVIOUR.md index b1be931d..6c6343ee 100644 --- a/docs/dev/BEHAVIOUR.md +++ b/docs/dev/BEHAVIOUR.md @@ -61,6 +61,7 @@ runs, and goes stale when the code changes. | `tiling_off` | `toggle-tiling` leaves the windows where they are, a layout change moves none, and tiling again tiles them | pass | pass | | `scrolling` | in the scrolling layout each window in turn has the focus and is inside the work area | pass | pass | | `scrolling.columns` | five windows in two columns: the ones out of view rest at an edge with their width, the strip follows the focus and `pan`, and `consume`, `expel`, `toggle-column-stack` and `scrolling-layout-full-width` work | pass | not run yet | +| `wallpaper` | a monitor's wallpaper at start, a workspace's own while shown, the ring in the hue of the wallpaper, and a changed wallpaper put back | pass 12 | not run yet | | `scrolling.two_monitors` | a strip with a display next to it: scrolling with `focus` and `pan` shows no window on the second display | pass | not run yet | | `monitors` | the monitor commands (`send-to-monitor`, `move-to-monitor`, their cycle, workspace and named forms, `focus-monitor`, `cycle-monitor`, `focus-monitor-workspace`, `cycle-move-workspace-to-monitor`, `focus-monitor-at-cursor` where there is a pointer) put each window on the monitor and workspace asked for, tiled in its display, and the focus where each leaves it | not run | not run | | `layout_flip` | `layout_flip` in the config (`Horizontal`, `Vertical`, `HorizontalAndVertical`) mirrors a workspace's layout from the start, `flip-layout` turns it off and on, and it is the named workspace's own | not run | not run | diff --git a/docs/dev/platform-contract.md b/docs/dev/platform-contract.md index ba15fdb2..b985b9f3 100644 --- a/docs/dev/platform-contract.md +++ b/docs/dev/platform-contract.md @@ -23,6 +23,7 @@ docstrings of `Platform` are the contract; this is the audit behind them. | `displays()` | each display's rectangle in the desktop's coordinates, left to right, as the system says | displays are not one size (on Windows the first is the basic adapter, 1280x800, and the second, the only one whose mode can change, is 1024x768 by default and follows `resize_display`) and a scenario does not compute where the next begins | | `add_display()`, `remove_display()` | plugs a display in to the right, unplugs the last; return when the system agrees | Windows removes only a display `add_display` plugged in (a hotplug port is used once); KDE enables a disabled output and disables the last enabled one, so a headless session with two outputs can unplug at once. A scenario unplugs only after plugging in, unless its suite started with more than one display | | `work_area()` | the area of the first display that windows may take, as the system says: the display less panels, docks and the taskbar | KDE asks KWin (a script: its placement area), and answers with the whole output only if KWin does not answer. A headless session has no panel, so there it is the output | +| `put_file(path, data)`, `wallpaper(display)`, `set_wallpaper(path, display)` | a file the daemon can read; the picture a display shows; the wallpaper as the user sets it | KDE reads and sets it through the Plasma shell's D-Bus API. Windows: `IDesktopWallpaper`. `restart_with` in a real Plasma session restarts the module's unit with the user's `komorebi.json` | | `resize_grab(frame, grab)` | where the pointer is pressed to take a window by an edge or corner | KDE: the gap outside the frame on every side (`resize.outside_grab`). Windows: left, right and bottom are the invisible border outside the visible frame; the top has none, so it is inside the frame's top row | | `close`, `minimize`, `activate`, `move_window` | act on the window as the user would; raise if there is no such window | `close` returns before the window is gone (a scenario waits for `os_window` to be `None`); `move_window` puts the top left of the *visible* frame at (x, y) | | `lock()`, `unlock()` | lock and unlock the screen and return when it is so | the lock screen is `LogonUI` on Windows and the Plasma greeter on KDE; a scenario asks only what the daemon and the windows do | diff --git a/docs/dev/suite-ledger.md b/docs/dev/suite-ledger.md index 2b28aaf0..71b2932e 100644 --- a/docs/dev/suite-ledger.md +++ b/docs/dev/suite-ledger.md @@ -226,6 +226,16 @@ Not kept: for features A, "komorebi still has all three" (the shared scenario ch 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. +## wallpaper + +Wallpapers and the colors of the borders that come from them are in the config of both ports, so +the shared scenario `wallpaper.run` has what is neutral: a monitor's wallpaper at start, a +workspace's own while it is shown (a path with a space in it), the ring of the focused window in +the hue of the wallpaper, and a wallpaper the user changed being put back. It uses `put_file`, +`wallpaper` and `set_wallpaper`. The extra `wallpaper.run` keeps what only the Plasma shell tells: +the fill mode and how long a switch takes, a switch by a pager, the palette cache, and a daemon +that starts before the shell. 12 checks went to the shared scenario, 12 stayed. + ## The KDE-only suites Moved as they were into `komorebi_vm.platforms.kde.extras`, one module per suite (`kwin`, @@ -256,7 +266,7 @@ move: | module | 33 | | live-resize | 7 | | look | 40 | -| wallpaper | 22 | +| wallpaper | 12 | | features | 30 | | keys-engine | 34 | | run-kde | 22 | diff --git a/nix/tests/vm/lib/komorebi_vm/core/platform.py b/nix/tests/vm/lib/komorebi_vm/core/platform.py index cd1f8c05..78473c0e 100644 --- a/nix/tests/vm/lib/komorebi_vm/core/platform.py +++ b/nix/tests/vm/lib/komorebi_vm/core/platform.py @@ -258,6 +258,20 @@ class Platform(Protocol): size.""" ... + def put_file(self, path: str, data: bytes) -> None: + """Writes `data` to `path` on the machine, where the daemon can read it, and makes the + directories above it. `path` is one `temp_path` made.""" + ... + + def wallpaper(self, display: int = 0) -> str: + """The path of the picture a display shows, as the operating system says it (the Plasma + shell on KDE, `IDesktopWallpaper` on Windows), or the empty string when it does not know.""" + ... + + def set_wallpaper(self, path: str, display: int = 0) -> None: + """Sets the wallpaper of a display as the user does, and returns when it is set.""" + ... + def resize_grab(self, frame: Rect, grab: str) -> tuple[int, int]: """Where the pointer is pressed to take a window of this frame by `grab`: `left`, `right`, `top`, `bottom`, or a corner (`topleft`, `topright`, `bottomleft`, `bottomright`). diff --git a/nix/tests/vm/lib/komorebi_vm/core/scenarios/wallpaper.py b/nix/tests/vm/lib/komorebi_vm/core/scenarios/wallpaper.py new file mode 100644 index 00000000..39c70384 --- /dev/null +++ b/nix/tests/vm/lib/komorebi_vm/core/scenarios/wallpaper.py @@ -0,0 +1,160 @@ +"""Wallpapers, and the colors of the borders that come from them. + +A monitor's wallpaper is set when the daemon starts, and the wallpaper of a workspace replaces it +while the workspace is shown. The borders of the windows take the colors of the wallpaper's +palette, and follow it from workspace to workspace. A wallpaper the user changed by hand is put back +when its workspace is shown again. + +The pictures are a dozen bands of color each, since a palette wants at least eight: a warm one for +the monitor, a cool one for the second workspace, and a green one the user sets by hand. Both ports +read the same `wallpaper` of `komorebi.json`, and keep a palette for each picture. +""" + +import io + +from PIL import Image + +from komorebi_vm.core import pixels +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.geometry import Rect +from komorebi_vm.core.platform import Client, Platform + + +def picture(hue: tuple[int, int, int], ramp: int) -> bytes: + """A 192 by 108 PNG of diagonal bands of `hue`, going dark and light, `ramp` varying the hue.""" + data: list[tuple[int, int, int]] = [] + for y in range(108): + for x in range(192): + band = (x // 16 + y // 16) % 12 + shade = 40 + (band % 4) * 55 + red, green, blue = hue + green = min(255, green + band * ramp) + data.append( + (min(255, red * shade // 255), min(255, green * shade // 255), blue * shade // 255) + ) + image = Image.new("RGB", (192, 108)) + image.putdata(data) + out = io.BytesIO() + image.save(out, "PNG") + return out.getvalue() + + +def _hue(red: int, blue: int) -> str: + return "warm" if red > blue + 40 else "cool" if blue > red + 40 else "neither" + + +def hue_of(image: Image.Image) -> str: + """Warm, cool or neither: the average color of a grid of points of the screen.""" + width, height = image.size + points = [ + pixels.pixel(image, width * i // 10, height * j // 12) + for i in range(1, 10) + for j in range(1, 9) + ] + return _hue( + round(sum(p[0] for p in points) / len(points)), + round(sum(p[2] for p in points) / len(points)), + ) + + +def ring_hue(frame: Rect, image: Image.Image, reach: int = 12) -> str: + """The hue of the ring around a window: the colors within `reach` pixels of the left edge of + its frame, on either side of it, at its middle. The window's own color is neutral, so what has + a hue is the border; where the ports draw it, outside the frame or on its edge, differs.""" + seen = set() + for x in range(max(0, frame.x - reach), min(image.size[0], frame.x + reach)): + red, _, blue = pixels.pixel(image, x, frame.y + frame.h // 2) + seen.add(_hue(red, blue)) + seen.discard("neither") + return seen.pop() if len(seen) == 1 else "neither" if not seen else "mixed" + + +def run(platform: Platform, checks: Checks) -> None: + """The daemon starts with a wallpaper for the monitor and one for its second workspace; the + scenario follows them from workspace to workspace, with the borders, and sets one by hand. + The workspace is empty, and so it is when the scenario ends.""" + warm = platform.temp_path("wallpapers/warm.png") + cool = platform.temp_path("wallpapers/my pictures/cool.png") + other = platform.temp_path("wallpapers/other.png") + platform.put_file(warm, picture((255, 0, 20), 20)) + platform.put_file(cool, picture((20, 80, 255), 14)) + platform.put_file(other, picture((60, 255, 60), 0)) + + def shows(path: str) -> bool: + return platform.wallpaper() == path + + def screen_is(hue: str, name: str) -> bool: + """Takes pictures of the screen until it is `hue`: one can catch the shell half way.""" + return platform.wait_for(lambda: hue_of(platform.screenshot(name)) == hue, timeout=20) + + def has_ring(client: Client, hue: str, name: str) -> bool: + """The window has a ring of `hue` in a screenshot taken until it does.""" + + def ring() -> bool: + window = platform.os_window(client) + return window is not None and ring_hue(window.frame, platform.screenshot(name)) == hue + + return platform.wait_for(ring, timeout=20) + + platform.set_wallpaper(other) + checks.check( + "the screen shows a picture of the user's choice to begin with", + platform.wait_for(lambda: shows(other), timeout=20), + ) + + platform.restart_with( + { + "monitors": [ + { + "wallpaper": {"path": warm}, + "workspaces": [ + {"name": "one"}, + {"name": "two", "wallpaper": {"path": cool}}, + ], + } + ] + } + ) + checks.check( + "at start the screen shows the monitor's wallpaper", + platform.wait_for(lambda: shows(warm), timeout=20), + ) + checks.check("and the screen is warm", screen_is("warm", "wallpaper-a1")) + + platform.komorebic("focus-workspace", "1") + checks.check( + "workspace two's own wallpaper is shown when it is (the path has a space in it)", + platform.wait_for(lambda: shows(cool), timeout=10), + ) + checks.check("and the screen is cool", screen_is("cool", "wallpaper-a2")) + platform.komorebic("focus-workspace", "0") + checks.check( + "back on workspace one, the monitor's wallpaper again", + platform.wait_for(lambda: shows(warm), timeout=10), + ) + + left = platform.spawn("wallpaper-left") + checks.check( + "on the warm workspace the focused window's ring is warm", + has_ring(left, "warm", "wallpaper-c1"), + ) + platform.komorebic("focus-workspace", "1") + right = platform.spawn("wallpaper-right") + checks.check("on the cool one it is cool", has_ring(right, "cool", "wallpaper-c2")) + platform.komorebic("focus-workspace", "0") + checks.check("and warm again when the first is back", has_ring(left, "warm", "wallpaper-c3")) + + platform.set_wallpaper(other) + checks.check("the user changed it", platform.wait_for(lambda: shows(other), timeout=10)) + platform.komorebic("focus-workspace", "1") + platform.wait_for(lambda: shows(cool), timeout=10) + platform.komorebic("focus-workspace", "0") + checks.check( + "showing the workspace again set it back", + platform.wait_for(lambda: shows(warm), timeout=10), + ) + + platform.close(left) + platform.komorebic("focus-workspace", "1") + platform.close(right) + platform.komorebic("focus-workspace", "0") diff --git a/nix/tests/vm/lib/komorebi_vm/kde_platform.py b/nix/tests/vm/lib/komorebi_vm/kde_platform.py index 2703de98..e66cf966 100644 --- a/nix/tests/vm/lib/komorebi_vm/kde_platform.py +++ b/nix/tests/vm/lib/komorebi_vm/kde_platform.py @@ -1,10 +1,12 @@ """KDE as a [`Platform`](komorebi_vm.core.platform.Platform): a `Kde` session, seen through the neutral vocabulary.""" +import base64 import json import re import shlex from typing import Any +from urllib.parse import unquote from PIL import Image @@ -75,7 +77,24 @@ class KdePlatform: def crash(self) -> None: self.kde.kill_komorebi() + def _restart_the_modules_daemon(self, config: dict[str, Any]) -> None: + """A real Plasma session has the module's unit run the daemon, with the user's own + `komorebi.json`. The unit `komorebi@session` of a headless run has a bus of its own, + and a daemon on it has no Plasma shell to talk to.""" + kde = self.kde + kde.systemctl("stop", "komorebi.service") + kde.wait_for(lambda: kde.run("komorebic state")[0] != 0, timeout=30) + kde.close_colored_windows() + kde.sh("mkdir -p ~/.config/komorebi") + kde.sh(f"printf %s {shlex.quote(json.dumps(config))} > ~/.config/komorebi/komorebi.json") + kde.systemctl("start", "komorebi.service") + if not kde.wait_for(lambda: kde.run("komorebic state")[0] == 0, timeout=60): + raise AssertionError("the module's daemon did not answer komorebic") + def restart_with(self, config: dict[str, Any]) -> None: + if self.session: + self._restart_the_modules_daemon(config) + return if self.kde.komorebi_running(): self.kde.komorebic("stop") self.kde.wait_for(lambda: not self.kde.komorebi_running(), timeout=30) @@ -227,6 +246,24 @@ class KdePlatform: if not self.kde.wait_for(lambda: self._locked(False), timeout=30): raise AssertionError("the screen did not unlock") + def put_file(self, path: str, data: bytes) -> None: + directory = shlex.quote(path.rsplit("/", 1)[0]) + encoded = base64.b64encode(data).decode() + self.kde.sh(f"mkdir -p {directory} && chmod a+rx {directory}") + self.kde.sh( + f"echo {encoded} | base64 -d > {shlex.quote(path)} && chmod a+r {shlex.quote(path)}" + ) + + def wallpaper(self, display: int = 0) -> str: + shell = "org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell" + out = self.kde.run(f"busctl --user call {shell} wallpaper u {display}")[1] + image = re.search(r'"Image" s "([^"]*)"', out) + url = image.group(1) if image else "" + return unquote(url.removeprefix("file://")) + + def set_wallpaper(self, path: str, display: int = 0) -> None: + self.kde.sh(f"plasma-apply-wallpaperimage {shlex.quote(path)}") + def resize_grab(self, frame: Rect, grab: str) -> tuple[int, int]: return outside_grab(frame, grab) diff --git a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/wallpaper.py b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/wallpaper.py index acfa8124..ec02c58c 100644 --- a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/wallpaper.py +++ b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/wallpaper.py @@ -1,28 +1,23 @@ -r"""Wallpapers, and the borders' colors that come from them, in a real Plasma session: what the shell -shows and what is on the screen, read from the shell's D-Bus API and from screenshots. +r"""What only the Plasma shell tells about wallpapers, beside the shared `wallpaper` scenario, which +sets them, follows them from workspace to workspace with the borders, and sets one back: how the +shell fills the screen, a switch that komorebi did not make, the palette cache and a shell that +starts late. - A a monitor's wallpaper is set at start, and a workspace's own replaces it when it is shown + A the fill mode of the wallpaper, and how long a workspace takes to change it B a switch komorebi did not make (a pager's) sets the wallpaper too - C the borders take the colors of the wallpaper's palette, and follow it from workspace to - workspace - D a wallpaper the user changed by hand is put back when its workspace is shown again - E the palette cache, and a path with a space in it (the second picture's) + E the palette cache F komorebi that starts before the Plasma shell does The pictures are a dozen bands of color each, since a palette wants at least eight: a warm one for the monitor, a cool one for the second workspace, and a green one the test sets by hand. """ -import base64 -import io import re import shlex import time from typing import Any -from PIL import Image - -from komorebi_vm import Checks, Kde, Rect, pixels +from komorebi_vm import Checks, Kde UNIT = "komorebi.service" SHELL_UNIT = "plasma-plasmashell.service" @@ -44,36 +39,6 @@ def run(machine: Any, checks: Checks, scale: float) -> None: SCALE = scale # noqa: N806 kde = Kde.session(machine, scale=SCALE) - def picture(hue: tuple[int, int, int], ramp: int) -> bytes: - """A 192 by 108 PNG of diagonal bands of `hue`, going dark and light, `ramp` varying the hue.""" - data: list[tuple[int, int, int]] = [] - for y in range(108): - for x in range(192): - band = (x // 16 + y // 16) % 12 - shade = 40 + (band % 4) * 55 - red, green, blue = hue - green = min(255, green + band * ramp) - data.append( - ( - min(255, red * shade // 255), - min(255, green * shade // 255), - blue * shade // 255, - ) - ) - image = Image.new("RGB", (192, 108)) - image.putdata(data) - out = io.BytesIO() - image.save(out, "PNG") - return out.getvalue() - - def put(path: str, data: bytes) -> None: - """Writes `data` to `path` in the machine, where the test user can read it.""" - directory = shlex.quote(path.rsplit("/", 1)[0]) - encoded = base64.b64encode(data).decode() - machine.succeed(f"mkdir -p {directory} && chmod a+rx {directory}") - machine.succeed(f"echo {encoded} | base64 -d > {shlex.quote(path)}") - machine.succeed(f"chmod a+r {shlex.quote(path)}") - def running() -> bool: return kde.run(f"systemctl --user is-active --quiet {UNIT}")[0] == 0 @@ -101,78 +66,30 @@ def run(machine: Any, checks: Checks, scale: float) -> None: def shows(url: str) -> bool: return shell_wallpaper()[0] == url - def hue_of(image: Image.Image) -> str: - """Warm, cool or neither: the average color of a grid of points of the screen.""" - width, height = image.size - points = [ - pixels.pixel(image, width * i // 10, height * j // 12) - for i in range(1, 10) - for j in range(1, 9) - ] - red = sum(p[0] for p in points) / len(points) - blue = sum(p[2] for p in points) / len(points) - return "warm" if red > blue + 40 else "cool" if blue > red + 40 else "neither" - - def screen_is(hue: str, name: str) -> bool: - """Takes pictures of the screen until it is `hue`: one can catch the shell half way.""" - return kde.wait_for(lambda: hue_of(kde.shot(name)) == hue, timeout=20) - - def ring_hue(frame: Rect, image: Image.Image) -> str: - """The hue of the ring just outside the left edge of a frame.""" - red, _, blue = pixels.pixel(image, frame.x - 5, frame.y + frame.h // 2) - return "warm" if red > blue + 40 else "cool" if blue > red + 40 else "neither" - - def has_ring(title: str, hue: str, name: str) -> bool: - """Returns true if the window has a ring of `hue` in a screenshot taken until it does.""" - return kde.wait_for( - lambda: ring_hue(kde.rect(kde.wid(title)), kde.shot(name)) == hue, timeout=20 - ) - - print("== the pictures, and the wallpapers of the configuration") - put(WARM, picture((255, 0, 20), 20)) - put(COOL, picture((20, 80, 255), 14)) - put(OTHER, picture((60, 255, 60), 0)) - kde.sh(f"mkdir -p {shlex.quote(CONFIG.rsplit('/', 1)[0])}") + # the shared `wallpaper` scenario ran before this: the daemon runs with the wallpapers of its + # configuration, the pictures are in PICTURES, and the first workspace shows the warm one + # the shared scenario restarted the module's unit with the user's `komorebi.json` + # the unit starts komorebi with the user's own `komorebi.json`: the wallpapers are in it too config = ( '{ "monitors": [ { "wallpaper": { "path": "' + WARM + '" },' ' "workspaces": [ { "name": "one" },' ' { "name": "two", "wallpaper": { "path": "' + COOL + '" } } ] } ] }' ) + kde.sh(f"mkdir -p {shlex.quote(CONFIG.rsplit('/', 1)[0])}") kde.sh(f"echo {shlex.quote(config)} > {CONFIG}") - kde.sh("rm -rf ~/.cache/komorebi") - - # the session started the module's unit when it logged in: this test starts its own runs - stop_komorebi() - kde.sh(f"plasma-apply-wallpaperimage {shlex.quote(OTHER)}") - checks.check( - "the shell shows a picture of the user's choice to begin with", - kde.wait_for(lambda: shows(f"file://{OTHER}"), timeout=20), - ) - - print("== A. set at start, and by workspace") - start_komorebi() - checks.check( - "at start the shell shows the monitor's wallpaper", - kde.wait_for(lambda: shows(WARM_URL), timeout=20), - ) - checks.check("it fills the screen", shell_wallpaper()[1] == FILL) - checks.check("and the screen is warm", screen_is("warm", "a1")) + print("== A. what only the Plasma shell tells") + checks.check("the monitor's wallpaper fills the screen", shell_wallpaper()[1] == FILL) began = time.monotonic() kde.komorebic("focus-workspace", "1") checks.check( - "workspace two's own wallpaper is shown when it is (the path has a space in it)", + "workspace two's own wallpaper is shown when it is", kde.wait_for(lambda: shows(COOL_URL), timeout=10), ) took = time.monotonic() - began checks.check(f"it took under two seconds ({took:.1f} s)", took < 2 * max(1, SCALE)) - checks.check("and the screen is cool", screen_is("cool", "a2")) - kde.komorebic("focus-workspace", "0") - checks.check( - "back on workspace one, the monitor's wallpaper again", - kde.wait_for(lambda: shows(WARM_URL), timeout=10), - ) + kde.wait_for(lambda: shows(WARM_URL), timeout=10) print("== B. a switch somebody else made") kde.sh("kdotool set_desktop 2") @@ -183,35 +100,6 @@ def run(machine: Any, checks: Checks, scale: float) -> None: kde.sh("kdotool set_desktop 1") checks.check("and back", kde.wait_for(lambda: shows(WARM_URL), timeout=10)) - print("== C. the borders follow the wallpaper") - kde.colored_window("left", "303030") - time.sleep(1.5) - checks.check( - "on the warm workspace the focused window's ring is warm", has_ring("left", "warm", "c1") - ) - kde.komorebic("focus-workspace", "1") - time.sleep(2) - kde.colored_window("right", "303030") - time.sleep(2) - checks.check("on the cool one it is cool", has_ring("right", "cool", "c2")) - kde.komorebic("focus-workspace", "0") - time.sleep(2.5) - checks.check("and warm again when the first is back", has_ring("left", "warm", "c3")) - - print("== D. a wallpaper changed by hand is put back") - kde.sh(f"plasma-apply-wallpaperimage {shlex.quote(OTHER)}") - checks.check( - "the user changed it", - kde.wait_for(lambda: shows(f"file://{OTHER}"), timeout=10), - ) - kde.komorebic("focus-workspace", "1") - time.sleep(1) - kde.komorebic("focus-workspace", "0") - checks.check( - "showing the workspace again set it back", - kde.wait_for(lambda: shows(WARM_URL), timeout=10), - ) - print("== E. the palette is kept") cache = kde.sh("ls ~/.cache/komorebi 2>/dev/null || true").split() print(f" the cache: {cache}") diff --git a/nix/tests/vm/lib/tests/fake_desk.py b/nix/tests/vm/lib/tests/fake_desk.py index b67c3581..ec27e52d 100644 --- a/nix/tests/vm/lib/tests/fake_desk.py +++ b/nix/tests/vm/lib/tests/fake_desk.py @@ -157,6 +157,15 @@ class FakeDesk: def unlock(self) -> None: raise NotImplementedError + def put_file(self, path: str, data: bytes) -> None: # noqa: ARG002 + pass + + def wallpaper(self, display: int = 0) -> str: # noqa: ARG002 + return "" + + def set_wallpaper(self, path: str, display: int = 0) -> None: + pass + def resize_grab(self, frame, grab): # noqa: ANN001, ANN201 return outside_grab(frame, grab) diff --git a/nix/tests/vm/lib/tests/fake_platform.py b/nix/tests/vm/lib/tests/fake_platform.py index 072af36b..fc222eff 100644 --- a/nix/tests/vm/lib/tests/fake_platform.py +++ b/nix/tests/vm/lib/tests/fake_platform.py @@ -215,6 +215,15 @@ class FakePlatform: if self.retiles_on_unlock: self.moved.clear() + def put_file(self, path: str, data: bytes) -> None: # noqa: ARG002 + pass + + def wallpaper(self, display: int = 0) -> str: # noqa: ARG002 + return "" + + def set_wallpaper(self, path: str, display: int = 0) -> None: + pass + def resize_grab(self, frame, grab): # noqa: ANN001, ANN201 return outside_grab(frame, grab) diff --git a/nix/tests/vm/lib/tests/fake_wallpaper.py b/nix/tests/vm/lib/tests/fake_wallpaper.py new file mode 100644 index 00000000..ca640c54 --- /dev/null +++ b/nix/tests/vm/lib/tests/fake_wallpaper.py @@ -0,0 +1,64 @@ +"""A fake that sets wallpapers from the configuration it is started with, shows them on its +screenshots, and draws a ring around the focused window in a hue of the wallpaper.""" + +from typing import Any + +from PIL import Image, ImageDraw + +from komorebi_vm.core.platform import Client +from tests.fake_platform import FakePlatform + +HUES = {"warm": (230, 40, 40), "cool": (40, 60, 230), "other": (60, 230, 60)} + + +# the ring is the 4 pixels of the wallpaper's hue that the window leaves at its left +class FakeWallpaper(FakePlatform): + def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 + super().__init__(*args, **kwargs) + self.shown = "" + self.monitor_wallpaper = "" + self.workspace_wallpapers: dict[int, str] = {} + self.workspace = 0 + self.puts_back = True + + def put_file(self, path: str, data: bytes) -> None: + assert data.startswith(b"\x89PNG"), "a picture" + + def wallpaper(self, display: int = 0) -> str: + return self.shown + + def set_wallpaper(self, path: str, display: int = 0) -> None: + self.shown = path + + def restart_with(self, config: dict[str, Any]) -> None: + self.running = True + monitor = config["monitors"][0] + self.monitor_wallpaper = monitor["wallpaper"]["path"] + self.workspace_wallpapers = { + i: w["wallpaper"]["path"] + for i, w in enumerate(monitor["workspaces"]) + if "wallpaper" in w + } + self._show() + + def _show(self) -> None: + self.shown = self.workspace_wallpapers.get(self.workspace, self.monitor_wallpaper) + + def komorebic(self, *args: str) -> str: + if args[0] == "focus-workspace": + self.workspace = int(args[1]) + if self.puts_back or self.shown != self.wallpaper(): + self._show() + return "" + return super().komorebic(*args) + + def screenshot(self, name: str) -> Image.Image: + which = "other" if "other" in self.shown else "cool" if "cool" in self.shown else "warm" + image = Image.new("RGB", (1920, 1080), HUES[which]) + draw = ImageDraw.Draw(image) + for client in (Client(t) for c in self.containers for t in c): + window = self.os_window(client) + if window is not None and window.focused: + f = window.frame + draw.rectangle((f.x + 4, f.y, f.x + f.w, f.y + f.h), fill=(48, 48, 48)) + return image diff --git a/nix/tests/vm/lib/tests/test_focus_moving.py b/nix/tests/vm/lib/tests/test_focus_moving.py index d7067f55..a63614a5 100644 --- a/nix/tests/vm/lib/tests/test_focus_moving.py +++ b/nix/tests/vm/lib/tests/test_focus_moving.py @@ -36,11 +36,13 @@ from komorebi_vm.core.scenarios import ( state_fidelity, tiling, tiling_off, + wallpaper, workspace_moves, workspaces, ) from tests.fake_platform import FakePlatform from tests.fake_strip import FakeStrip +from tests.fake_wallpaper import FakeWallpaper def opened(platform: FakePlatform, checks: Checks): # noqa: ANN201 @@ -955,3 +957,19 @@ def test_a_daemon_that_rests_windows_on_the_next_monitor_fails_two_monitors() -> checks = Checks() scrolling.two_monitors(platform, checks, []) assert checks.failed > 0 + + +def test_a_daemon_that_sets_the_wallpapers_passes_wallpaper() -> None: + platform = FakeWallpaper() + checks = Checks() + wallpaper.run(platform, checks) + assert checks.failed == 0, checks.failures + assert checks.passed > 8 + + +def test_a_daemon_that_leaves_a_changed_wallpaper_fails_wallpaper() -> None: + platform = FakeWallpaper() + platform.puts_back = False + checks = Checks() + wallpaper.run(platform, checks) + assert checks.failed > 0 diff --git a/nix/tests/vm/lib/tests/test_tiling.py b/nix/tests/vm/lib/tests/test_tiling.py index 2018cf02..3c0bc391 100644 --- a/nix/tests/vm/lib/tests/test_tiling.py +++ b/nix/tests/vm/lib/tests/test_tiling.py @@ -104,6 +104,15 @@ class FakePlatform: def unlock(self) -> None: pass + def put_file(self, path: str, data: bytes) -> None: # noqa: ARG002 + pass + + def wallpaper(self, display: int = 0) -> str: # noqa: ARG002 + return "" + + def set_wallpaper(self, path: str, display: int = 0) -> None: + pass + def resize_grab(self, frame: Rect, grab: str) -> tuple[int, int]: return outside_grab(frame, grab) diff --git a/nix/tests/vm/suites/wallpaper/test.py b/nix/tests/vm/suites/wallpaper/test.py index 4817d03e..57035859 100644 --- a/nix/tests/vm/suites/wallpaper/test.py +++ b/nix/tests/vm/suites/wallpaper/test.py @@ -1,25 +1,38 @@ # Wallpapers, and the borders' colors that come from them, in a real Plasma session: what the shell # shows and what is on the screen, read from the shell's D-Bus API and from screenshots. # -# A a monitor's wallpaper is set at start, and a workspace's own replaces it when it is shown -# B a switch komorebi did not make (a pager's) sets the wallpaper too -# C the borders take the colors of the wallpaper's palette, and follow it from workspace to -# workspace -# D a wallpaper the user changed by hand is put back when its workspace is shown again -# E the palette cache, and a path with a space in it (the second picture's) -# F komorebi that starts before the Plasma shell does +# The shared scenario `wallpaper` (`komorebi_vm.core.scenarios.wallpaper`, which runs on Windows +# too) sets a wallpaper for a monitor and one for a workspace, follows them from workspace to +# workspace with the colors of the borders, and sets one back that the user changed. The extra +# is what only the Plasma shell tells: # -# The pictures are a dozen bands of color each, since a palette wants at least eight: a warm one for -# the monitor, a cool one for the second workspace, and a green one the test sets by hand. +# A the fill mode of the wallpaper, and how long a workspace takes to change it +# B a switch komorebi did not make (a pager's) sets the wallpaper too +# E the palette cache +# F komorebi that starts before the Plasma shell does from komorebi_vm import Checks -from komorebi_vm.core.suite import platform_only, run_suite +from komorebi_vm import Kde +from komorebi_vm.core.scenarios import wallpaper +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 wallpaper as kde_wallpaper start_all() machine.wait_for_unit("multi-user.target") checks = Checks() +kde = Kde.session(machine, scale=SCALE) +# the session started the module's unit when it logged in: the scenario restarts that daemon with +# the wallpapers it writes to the user's `komorebi.json` +checks.check("komorebi answers", kde.wait_for(lambda: kde.run("komorebic state")[0] == 0, 120)) +kde.wait_for_panel_room() +size = kde.state()["monitors"]["elements"][0]["size"] +platform = KdePlatform(kde, (size["right"], size["bottom"]), session=True) -run_suite(checks, platform_only("kde", kde_wallpaper.run, machine, checks, SCALE)) +run_suite( + checks, + shared(wallpaper.run, platform, checks), + platform_only("kde", kde_wallpaper.run, machine, checks, SCALE), +) checks.finish() diff --git a/scripts/sync-e2e-core b/scripts/sync-e2e-core index f7889a52..395d6f14 100755 --- a/scripts/sync-e2e-core +++ b/scripts/sync-e2e-core @@ -20,7 +20,7 @@ dest=${1:?usage: sync-e2e-core [--check] DEST} repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) lib=$repo/nix/tests/vm/lib -tests=(fake_platform.py test_checks.py test_suite.py test_pixels.py test_snapshot.py test_recorded.py test_tiling.py +tests=(fake_platform.py fake_strip.py fake_wallpaper.py test_checks.py test_suite.py test_pixels.py test_snapshot.py test_recorded.py test_tiling.py test_focus_moving.py) status=0