From a8e79ca5d2ca0d5d4464cfe74603c8f26e2122da Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Fri, 30 Oct 2026 16:18:32 -0700 Subject: [PATCH] test(nix): share the columns of the scrolling layout, and two monitors The checks of the scrolling suite were KDE's, but kfde and komorebi for Windows lay the strip out with the same model. scrolling.columns is the scenario of the five windows in two columns, now run by the scrolling suite and by core-e2e; scrolling.two_monitors checks that a strip with a display next to it leaves no window shown on that display. --- docs/dev/BEHAVIOUR.md | 2 + docs/dev/scrolling-next.md | 7 +- docs/dev/suite-ledger.md | 18 +- .../komorebi_vm/core/scenarios/scrolling.py | 216 +++++++++++++++++- .../platforms/kde/extras/scrolling.py | 159 ------------- nix/tests/vm/lib/tests/fake_strip.py | 150 ++++++++++++ nix/tests/vm/lib/tests/test_focus_moving.py | 37 +++ nix/tests/vm/suites/core-e2e/test.py | 4 + nix/tests/vm/suites/scrolling/test.py | 30 +-- 9 files changed, 438 insertions(+), 185 deletions(-) delete mode 100644 nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/scrolling.py create mode 100644 nix/tests/vm/lib/tests/fake_strip.py diff --git a/docs/dev/BEHAVIOUR.md b/docs/dev/BEHAVIOUR.md index e9532429..b1be931d 100644 --- a/docs/dev/BEHAVIOUR.md +++ b/docs/dev/BEHAVIOUR.md @@ -60,6 +60,8 @@ runs, and goes stale when the code changes. | `pausing` | a paused daemon leaves a new window alone, and an unpaused one takes the next | pass | pass | | `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 | +| `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/scrolling-next.md b/docs/dev/scrolling-next.md index 2f7b2e7c..b8e89f0f 100644 --- a/docs/dev/scrolling-next.md +++ b/docs/dev/scrolling-next.md @@ -55,8 +55,9 @@ is, whether it is hidden, how wide it really is) is a call to the backend in kfd `WindowManager::process_command` against the fake desktop, a strip next to another monitor, and a drag of the left edge of a column. - `komorebic/tests/cli.rs`: each new subcommand sends its message. -- `nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/scrolling.py`, run by `vm-scrolling`: where KWin - keeps a window at the edge and the commands of the columns, in a real session. Run on KDE: 24 passed, - 0 failed. KWin leaves a window resting at the left edge with its right edge a few pixels inside the +- `nix/tests/vm/lib/komorebi_vm/core/scenarios/scrolling.py` (`columns`, shared with komorebi for + Windows), run by `vm-scrolling` and `core-e2e`: where the daemon keeps a window at the edge and the + commands of the columns, in a real session. Run on KDE: the `scrolling` suite passes + (32 checks, with `two_monitors`). KWin leaves a window resting at the left edge with its right edge a few pixels inside the work area, or a cell short of them, because foot rounds its frame to whole cells. The shared `scrolling` scenario passes in `core-e2e` too. diff --git a/docs/dev/suite-ledger.md b/docs/dev/suite-ledger.md index ec10b30a..2b28aaf0 100644 --- a/docs/dev/suite-ledger.md +++ b/docs/dev/suite-ledger.md @@ -181,15 +181,15 @@ per-output desktops. ## scrolling -The shared `scrolling` scenario (run by `core-e2e`) puts each window in view in turn. This suite is -about the windows that are not in view: where KWin keeps the columns that scroll off the screen -(at an edge, with a few pixels of them on it), and that the strip follows the focus. It also runs -the commands of the columns of several windows (`pan`, `consume`, `toggle-column-stack`, `expel`, -`scrolling-layout-full-width`), which are kfde's and not in the shared scenario because komorebi -for Windows has them only on its `next` branch. All of it is KWin's; it is the extra -`scrolling.run`. The check that a window keeps the distance of a column from the one in view, -which the layout does not keep any more (a window rests at the edge), was replaced by two that it -rests there and keeps its width. +The shared `scrolling` scenario (run by `core-e2e`) puts each window in view in turn. This suite +runs the shared `scrolling.columns`: five windows in two columns, where the daemon rests the ones +that scroll out of view (at an edge, a few pixels of them on the screen, their width kept), that the +strip follows the focus and `pan`, and the commands of the columns of several windows (`consume`, +`toggle-column-stack`, `expel`, `scrolling-layout-full-width`). kfde and komorebi for Windows lay +the strip out with the same model, so none of it is KDE's: the extra `scrolling.run` is gone, and +`core-e2e` runs `scrolling.columns` too, from a workspace with no window. The check that a window +keeps the distance of a column from the one in view, which the layout does not keep any more (a +window rests at the edge), was replaced by two that it rests there and keeps its width. ## stacks diff --git a/nix/tests/vm/lib/komorebi_vm/core/scenarios/scrolling.py b/nix/tests/vm/lib/komorebi_vm/core/scenarios/scrolling.py index f082748f..043a08b7 100644 --- a/nix/tests/vm/lib/komorebi_vm/core/scenarios/scrolling.py +++ b/nix/tests/vm/lib/komorebi_vm/core/scenarios/scrolling.py @@ -1,10 +1,24 @@ -"""In the scrolling layout the window that has the focus is in view, wherever the others are.""" +"""In the scrolling layout the window that has the focus is in view, wherever the others are. + +`run` checks that on the windows a suite has open. `columns` opens five windows of its own in two +columns, and checks where the strip puts the ones that are out of view, that it scrolls with the +focus and with `pan`, and the commands of the columns (`consume`, `expel`, `toggle-column-stack`, +`scrolling-layout-full-width`). Both ports lay the strip out with the same model. +""" 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.layouts import tiled +from komorebi_vm.core.scenarios.monitors import _plug from komorebi_vm.core.scenarios.support import focus_agrees +STRIP = 5 + +# how many pixels of a window that rests at an edge of the work area are on the screen: 5 of them by +# design, and the paddings, what the compositor rounds and a terminal's grid of cells are the rest +PARKED_AT_MOST = 30 + def run(platform: Platform, checks: Checks, clients: list[Client]) -> None: """Changes the layout of the first workspace, which has `clients` tiled in a container each, @@ -31,3 +45,203 @@ def run(platform: Platform, checks: Checks, clients: list[Client]) -> None: "back in bsp every window is where the layout says", platform.wait_for(lambda: tiled(platform, clients), timeout=20), ) + + +def columns(platform: Platform, checks: Checks, clients: list[Client]) -> None: + """Five windows in two columns on a workspace with none: the last two are in view and the + others are scrolled out, where the system keeps them; focusing to the far left and back, and + `pan`, scroll the strip; then the commands of a column of several windows. The layout is bsp + again and the windows are closed at the end.""" + area = platform.work_area() + left, right = area.x, area.x + area.w + tol = platform.tolerance + titles = [f"strip{i}" for i in range(1, STRIP + 1)] + + def frame(i: int) -> Rect: + window = platform.os_window(Client(titles[i - 1])) + return window.frame if window is not None else Rect(0, 0, 0, 0) + + def in_view(i: int) -> bool: + r = frame(i) + return r.x >= left - tol and r.x + r.w <= right + tol + + def out_of_view(i: int) -> bool: + r = frame(i) + return r.x >= right - PARKED_AT_MOST or r.x + r.w <= left + PARKED_AT_MOST + + def agree(i: int) -> bool: + return focus_agrees(platform, [Client(t) for t in titles], titles[i - 1]) + + def wait(predicate, timeout: float = 10) -> bool: # noqa: ANN001 + return platform.wait_for(predicate, timeout=timeout) + + platform.komorebic("change-layout", "scrolling") + platform.komorebic("scrolling-layout-columns", "2") + opened = [platform.spawn(t) for t in titles] + checks.check( + "the daemon manages all five", wait(lambda: platform.state().managed() >= STRIP, 20) + ) + checks.check( + "the focused (last) window is in view, and so is the one before it", + wait(lambda: in_view(5) and in_view(4), 20), + ) + checks.check( + "the first three are scrolled out of view", + wait(lambda: all(out_of_view(i) for i in (1, 2, 3)), 20), + ) + # the window rests with its right edge a few pixels inside the work area, as wide as the one + # in view: nothing pulled it further onto the screen, and nothing was resized. A terminal draws + # in whole cells, so its frame can be a cell narrower than the tile it was rested by + checks.check( + "the first window rests at the left edge, a few pixels of it on the screen or a cell short" + f" of them (right edge at {frame(1).x + frame(1).w}), not pulled back", + wait(lambda: left - tol < frame(1).x + frame(1).w <= left + PARKED_AT_MOST), + ) + checks.check( + f"... and it kept its width ({frame(1).w} against {frame(4).w})", + wait(lambda: abs(frame(1).w - frame(4).w) <= tol), + ) + checks.check("the system and the daemon agree on the focus", wait(lambda: agree(5))) + + for _ in range(STRIP - 1): + platform.komorebic("focus", "left") + checks.check( + "focusing to the far left scrolls the strip back: the first is in view", + wait(lambda: in_view(1) and agree(1)), + ) + checks.check("... and the last is past the right edge", wait(lambda: out_of_view(5))) + for _ in range(STRIP - 1): + platform.komorebic("focus", "right") + checks.check( + "walking right again reaches the last, in view", wait(lambda: agree(5) and in_view(5)) + ) + + # the view moves a column without the focus moving first + platform.komorebic("pan", "left") + checks.check( + "panning left brings the third into view and puts the last out of it", + wait(lambda: in_view(3) and out_of_view(5)), + ) + checks.check( + "... and the focus goes to the nearest column in view, the fourth", wait(lambda: agree(4)) + ) + platform.komorebic("pan", "right") + checks.check("panning right puts the last in view again", wait(lambda: in_view(5))) + + # a column of two windows: the fourth is put at the top of the column of the fifth + platform.komorebic("focus", "right") + wait(lambda: agree(5)) + platform.komorebic("consume", "left") + checks.check( + "consuming left puts the fourth above the fifth, in one column", + wait(lambda: abs(frame(4).x - frame(5).x) <= tol and frame(4).y < frame(5).y), + ) + checks.check( + "... each with a share of the height of the column", + wait(lambda: frame(4).h < area.h * 0.6 and frame(5).h < area.h * 0.6), + ) + checks.check("... and the focus stays on the fifth", wait(lambda: agree(5))) + + pair = [Client(titles[3]), Client(titles[4])] + platform.komorebic("toggle-column-stack") + checks.check( + "stacking the column shows one window of the two", + wait(lambda: sum(platform.hidden(c) for c in pair) == 1), + ) + platform.komorebic("toggle-column-stack") + checks.check( + "spreading it out shows both again, one above the other", + wait(lambda: not any(platform.hidden(c) for c in pair) and frame(4).y < frame(5).y), + ) + + platform.komorebic("expel") + checks.check( + "expelling puts the focused window in a column of its own on the right", + wait(lambda: frame(5).x > frame(4).x + tol and abs(frame(5).y - frame(4).y) <= tol), + ) + + platform.komorebic("scrolling-layout-full-width") + checks.check( + "the full width makes the focused column as wide as the work area", + wait(lambda: frame(5).w >= area.w - 60 and in_view(5)), + ) + platform.komorebic("scrolling-layout-full-width") + checks.check( + "and again puts it back to the width it had", + wait(lambda: abs(frame(5).w - frame(4).w) <= tol), + ) + + platform.komorebic("change-layout", "bsp") + for client in opened: + platform.close(client) + + +def two_monitors(platform: Platform, checks: Checks, clients: list[Client]) -> None: + """A strip with a display next to it leaks no window onto it. + + Five windows in two columns on the first monitor, with a second display on its right. A window + shows on whichever display it overlaps, so one that rests past the right edge, or overhangs it, + would be seen on the neighbour: the daemon hides it, or makes it narrower, instead. Scrolling + with `focus` and `pan`, both ways, leaves no shown window on the second display, and no window + that is shown past the right edge of the first. The layout is bsp again, the windows are + closed, and the display is unplugged if the scenario plugged it, at the end.""" + plugged = _plug(platform) + first, second = platform.displays()[0], platform.displays()[1] + tol = platform.tolerance + titles = [f"leak{i}" for i in range(1, STRIP + 1)] + mine = [Client(t) for t in titles] + + def leaks() -> list[str]: + """The windows that are shown and have a part on the second display, or reach past the + right edge of the first.""" + out = [] + for client in mine: + window = platform.os_window(client) + if window is None or platform.hidden(client): + continue + frame = window.frame + if frame.overlaps(second) or frame.x + frame.w > first.x + first.w + tol: + out.append(f"{client.title} {frame}") + return out + + def settled(title: str) -> bool: + return focus_agrees(platform, mine, title) + + def quiet(step: str, title: str) -> None: + platform.wait_for(lambda: settled(title), timeout=10) + platform.wait_for(lambda: not leaks(), timeout=5) + checks.check(f"{step}: no window is shown on the second display", not leaks()) + if leaks(): + print(" leaked: " + "; ".join(leaks())) + + platform.komorebic("focus-monitor", "0") + platform.komorebic("change-layout", "scrolling") + platform.komorebic("scrolling-layout-columns", "2") + opened = [platform.spawn(t) for t in titles] + checks.check( + "the daemon manages all five", + platform.wait_for(lambda: platform.state().managed() >= STRIP, timeout=20), + ) + quiet("with the strip at its right end", titles[-1]) + for i in range(STRIP - 1, 0, -1): + platform.komorebic("focus", "left") + quiet(f"focusing left to {titles[i - 1]}", titles[i - 1]) + for i in range(1, STRIP): + platform.komorebic("focus", "right") + quiet(f"focusing right to {titles[i]}", titles[i]) + platform.komorebic("pan", "left") + platform.wait_for(lambda: not leaks(), timeout=10) + checks.check("panning left: no window is shown on the second display", not leaks()) + platform.komorebic("pan", "right") + platform.wait_for(lambda: not leaks(), timeout=10) + checks.check("panning right: no window is shown on the second display", not leaks()) + + platform.komorebic("change-layout", "bsp") + for client in opened: + platform.close(client) + platform.komorebic("focus-workspace", "0") + if plugged: + platform.remove_display() + platform.wait_for( + lambda: len(platform.state().monitors) == len(platform.displays()), timeout=30 + ) diff --git a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/scrolling.py b/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/scrolling.py deleted file mode 100644 index 761489d5..00000000 --- a/nix/tests/vm/lib/komorebi_vm/platforms/kde/extras/scrolling.py +++ /dev/null @@ -1,159 +0,0 @@ -"""What only KDE can tell about the scrolling layout, beside the shared `scrolling` scenario. - -The strip runs past the edge of the work area, and KWin is expected to leave the windows exactly -where the daemon puts them: a window that is out of view rests at the edge of the work area it left -by, with a few pixels of it on the screen and its size unchanged. KWin keeps part of a window it -resizes on an output, and the bridge asks again for the position of a window that was pulled back -(`settle` in `bridge.js`). The shared scenario checks that the focused window is in view; these -check where KWin keeps the ones that are not, that the strip scrolls with the focus `komorebic -focus` moves, and the commands of the columns that hold several windows (`consume`, `expel`, -`toggle-column-stack`, `scrolling-layout-full-width`, `pan`). Those commands are kfde's: the shared -scenario is also run against komorebi for Windows, which has them only on its `next` branch. -""" - -from komorebi_vm.core.checks import Checks -from komorebi_vm.core.geometry import Rect -from komorebi_vm.core.platform import Client -from komorebi_vm.kde_platform import KdePlatform - -WINDOWS = 5 - -# how many pixels of a window that rests at an edge of the work area are on the screen (5 of them -# by design, and the paddings and what the compositor rounds are the rest) -PARKED_AT_MOST = 30 - - -def run(platform: KdePlatform, checks: Checks, clients: list[Client]) -> None: - """Five windows in two columns: the last two are in view and the others are scrolled out, where - KWin keeps them; focusing to the far left and back scrolls the strip with the focus. The daemon - must be running with no window. The daemon is stopped at the end.""" - kde = platform.kde - width = platform.size[0] - - def n(i: int) -> Rect: - return kde.rect(kde.wid(f"win{i}")) - - def in_view(r: Rect) -> bool: - return r.x >= 0 and r.x + r.w <= width - - def out_of_view(r: Rect) -> bool: - return r.x >= width - PARKED_AT_MOST or r.x + r.w <= PARKED_AT_MOST - - def focused_monitor() -> int: - return platform.state().focused_monitor - - def agree(title: str) -> bool: - return kde.title(kde.active()) == title and kde.focused_title() == title - - kde.komorebic("change-layout", "scrolling") - kde.komorebic("scrolling-layout-columns", "2") - for i in range(1, WINDOWS + 1): - platform.spawn(f"win{i}") - checks.check( - "the daemon manages all five", kde.wait_for(lambda: kde.managed() == WINDOWS, timeout=20) - ) - checks.check( - "the focused (last) window is in view, and so is the one before it", - kde.wait_for(lambda: in_view(n(5)) and in_view(n(4)), timeout=20), - ) - checks.check( - "the first three are scrolled out of view", - kde.wait_for(lambda: all(out_of_view(n(i)) for i in (1, 2, 3)), timeout=20), - ) - for i in range(1, WINDOWS + 1): - print(f" win{i}: {n(i)}") - # the window rests with its right edge a few pixels inside the work area, and is as wide as - # the one in view: KWin did not pull it further onto the screen, and nothing was resized - # a terminal draws in whole cells, so its frame can be a cell narrower than the tile the daemon - # rests it by, and its right edge a cell short of the few pixels that were asked for - checks.check( - f"KWin left win1 at the left edge, a few pixels of it on the screen or a cell short of them" - f" (right edge at {n(1).x + n(1).w}), not pulled back", - kde.wait_for(lambda: -platform.tolerance < n(1).x + n(1).w <= PARKED_AT_MOST, timeout=10), - ) - checks.check( - f"... and it kept its width ({n(1).w} against {n(4).w})", - kde.wait_for(lambda: abs(n(1).w - n(4).w) <= platform.tolerance, timeout=10), - ) - checks.check("KWin and the daemon agree on the focus", kde.wait_for(lambda: agree("win5"))) - - for _ in range(4): - kde.komorebic("focus", "left") - kde.wait_for(lambda: kde.title(kde.active()) == kde.focused_title()) - checks.check( - "focusing to the far left scrolls the strip back: win1 in view", - kde.wait_for(lambda: in_view(n(1)), timeout=10), - ) - checks.check("... and win5 is past the right edge", kde.wait_for(lambda: out_of_view(n(5)))) - checks.check("... and the focus is win1 on both sides", kde.wait_for(lambda: agree("win1"))) - checks.check("... on the first monitor", focused_monitor() == 0) - - for _ in range(4): - kde.komorebic("focus", "right") - kde.wait_for(lambda: kde.title(kde.active()) == kde.focused_title()) - checks.check("walking right again reaches win5", kde.wait_for(lambda: agree("win5"))) - checks.check("... without the focus having left the first monitor", focused_monitor() == 0) - checks.check("... and win5 is in view", kde.wait_for(lambda: in_view(n(5)), timeout=10)) - - # the view moves a column without the focus moving first - kde.komorebic("pan", "left") - checks.check( - "panning left brings win3 into view and puts win5 out of it", - kde.wait_for(lambda: in_view(n(3)) and out_of_view(n(5)), timeout=10), - ) - checks.check( - "... and the focus goes to the nearest column in view, win4", - kde.wait_for(lambda: agree("win4")), - ) - kde.komorebic("pan", "right") - checks.check("panning right puts win5 in view again", kde.wait_for(lambda: in_view(n(5)))) - - # a column of two windows: win4 is put at the top of the column of win5 - kde.komorebic("focus", "right") - kde.wait_for(lambda: agree("win5")) - kde.komorebic("consume", "left") - checks.check( - "consuming left puts win4 above win5, in one column", - kde.wait_for( - lambda: abs(n(4).x - n(5).x) <= platform.tolerance and n(4).y < n(5).y, timeout=10 - ), - ) - checks.check( - "... each with a share of the height of the column", - kde.wait_for(lambda: n(4).h < platform.size[1] * 0.6 and n(5).h < platform.size[1] * 0.6), - ) - checks.check("... and the focus stays on win5", kde.wait_for(lambda: agree("win5"))) - - kde.komorebic("toggle-column-stack") - stacked = [Client("win4"), Client("win5")] - checks.check( - "stacking the column shows one window of the two", - kde.wait_for(lambda: sum(platform.hidden(c) for c in stacked) == 1, timeout=10), - ) - kde.komorebic("toggle-column-stack") - checks.check( - "spreading it out shows both again, one above the other", - kde.wait_for( - lambda: not any(platform.hidden(c) for c in stacked) and n(4).y < n(5).y, timeout=10 - ), - ) - - kde.komorebic("expel") - checks.check( - "expelling puts the focused window in a column of its own on the right", - kde.wait_for(lambda: n(5).x > n(4).x + platform.tolerance and n(5).y == n(4).y, timeout=10), - ) - - kde.komorebic("scrolling-layout-full-width") - checks.check( - "the full width makes the focused column as wide as the work area", - kde.wait_for(lambda: n(5).w >= width - 60 and in_view(n(5)), timeout=10), - ) - kde.komorebic("scrolling-layout-full-width") - checks.check( - "and again puts it back to the width it had", - kde.wait_for(lambda: abs(n(5).w - n(4).w) <= platform.tolerance, timeout=10), - ) - - kde.komorebic("stop") - kde.wait_for(lambda: not kde.komorebi_running()) diff --git a/nix/tests/vm/lib/tests/fake_strip.py b/nix/tests/vm/lib/tests/fake_strip.py new file mode 100644 index 00000000..5e7c69cf --- /dev/null +++ b/nix/tests/vm/lib/tests/fake_strip.py @@ -0,0 +1,150 @@ +"""A fake whose scrolling layout is a strip of columns, for the shared `scrolling.columns`. + +Every window is a container of the workspace, as the daemon has it; the columns the strip lays +out are what this class adds: the windows of each, whether the column is stacked, its width, and +how far the strip is scrolled. +""" + +from komorebi_vm.core.geometry import Rect +from komorebi_vm.core.platform import Client, OsWindow +from tests.fake_platform import FakePlatform + +REST = 5 + + +class FakeStrip(FakePlatform): + def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 + super().__init__(*args, **kwargs) + self.cols: list[list[str]] = [] + self.stacked: set[int] = set() + self.wide: set[int] = set() + self.per_view = 2 + self.offset = 0 + self.leaks = False + + # the geometry of the strip + def _scrolls(self) -> bool: + return self.layout_name == "Scrolling" + + def _width(self, i: int) -> int: + w = self.area.w + return w if i in self.wide else w // self.per_view + + def _left(self, i: int) -> int: + return sum(self._width(j) for j in range(i)) + + def _column_of(self, title: str) -> int: + return next(i for i, col in enumerate(self.cols) if title in col) + + def _title_in_focus(self) -> str: + return self.containers[self.focus][0] + + def _keep_in_view(self) -> None: + i = self._column_of(self._title_in_focus()) + left, right = self._left(i), self._left(i) + self._width(i) + if left < self.offset: + self.offset = left + elif right > self.offset + self.area.w: + self.offset = right - self.area.w + + def _focus_column(self, i: int) -> None: + title = self.cols[i][0] + self.focus = next(k for k, c in enumerate(self.containers) if c[0] == title) + + def _strip_frame(self, title: str) -> Rect: + i = self._column_of(title) + x, w = self._left(i) - self.offset, self._width(i) + if x + w <= 0: + x = REST - w + elif x >= self.area.w: + x = self.area.w - REST + if self._next_door() and x < self.area.w < x + w: + w = self.area.w - x + col = self.cols[i] + if len(col) == 1 or i in self.stacked: + return Rect(x, 0, w, self.area.h) + half = self.area.h // len(col) + return Rect(x, col.index(title) * half, w, half) + + def _next_door(self) -> bool: + """Another display is next to the right edge, and the daemon looks after it.""" + return self.display_count > 1 and not self.leaks + + # what the windows are and do + def spawn(self, title, *args, **kwargs) -> Client: # noqa: ANN001, ANN002, ANN003 + client = super().spawn(title, *args, **kwargs) + if title in [c[0] for c in self.containers]: + self.cols.append([title]) + self.focus = len(self.containers) - 1 + self._keep_in_view() + return client + + def close(self, client: Client) -> None: + super().close(client) + self.cols = [[t for t in col if t != client.title] for col in self.cols] + self.cols = [col for col in self.cols if col] + self.stacked = set() + self.wide = set() + + def os_window(self, client: Client) -> OsWindow | None: + window = super().os_window(client) + if window is None or not self._scrolls() or client.title not in sum(self.cols, []): + return window + return OsWindow(window.title, self._strip_frame(client.title), window.focused) + + def hidden(self, client: Client) -> bool: + if self._scrolls() and client.title in sum(self.cols, []): + i = self._column_of(client.title) + if self._next_door() and self._strip_frame(client.title).x >= self.area.w - REST: + return True + return i in self.stacked and client.title != self._title_in_focus() + return super().hidden(client) + + def komorebic(self, *args: str) -> str: + if args[0] in ("focus-monitor", "focus-workspace"): + return "" + if not self._scrolls() and args[0] != "change-layout": + return super().komorebic(*args) + i = self._column_of(self._title_in_focus()) if self.cols and self._scrolls() else 0 + match args: + case ("focus", "left" | "right" as way): + self._focus_column( + max(0, min(i + (1 if way == "right" else -1), len(self.cols) - 1)) + ) + self._keep_in_view() + case ("scrolling-layout-columns", n): + self.per_view = int(n) + case ("pan", way): + step = self._width(0) * (1 if way == "right" else -1) + limit = max(0, self._left(len(self.cols)) - self.area.w) + self.offset = max(0, min(self.offset + step, limit)) + shown = [ + j + for j in range(len(self.cols)) + if self._left(j) >= self.offset + and self._left(j) + self._width(j) <= self.offset + self.area.w + ] + if i not in shown: + self._focus_column(min(shown, key=lambda j: abs(j - i))) + case ("consume", "left") if i > 0: + self.cols[i - 1 : i + 1] = [self.cols[i - 1] + self.cols[i]] + self.stacked = set() + self.wide = set() + case ("expel",) if len(self.cols[i]) > 1: + title = self._title_in_focus() + self.cols[i].remove(title) + self.cols.insert(i + 1, [title]) + self.stacked = set() + self.wide = set() + self._focus_column(i + 1) + self._keep_in_view() + case ("toggle-column-stack",): + self.stacked ^= {i} + case ("scrolling-layout-full-width",): + self.wide ^= {i} + self._keep_in_view() + case ("change-layout", name): + self.layout_name = "BSP" if name == "bsp" else name.title() + case _: + return super().komorebic(*args) + return "" diff --git a/nix/tests/vm/lib/tests/test_focus_moving.py b/nix/tests/vm/lib/tests/test_focus_moving.py index 466cd75a..d7067f55 100644 --- a/nix/tests/vm/lib/tests/test_focus_moving.py +++ b/nix/tests/vm/lib/tests/test_focus_moving.py @@ -40,6 +40,7 @@ from komorebi_vm.core.scenarios import ( workspaces, ) from tests.fake_platform import FakePlatform +from tests.fake_strip import FakeStrip def opened(platform: FakePlatform, checks: Checks): # noqa: ANN201 @@ -918,3 +919,39 @@ def test_a_platform_that_cannot_move_a_window_while_locked_fails_locking() -> No clients = opened(platform, checks) locking.run(platform, checks, clients) assert any("was moved while the screen was locked" in f for f in checks.failures) + + +def test_a_daemon_that_is_right_passes_the_scrolling_columns() -> None: + platform = FakeStrip() + checks = Checks() + scrolling.columns(platform, checks, []) + assert checks.failed == 0, checks.failures + assert checks.passed > 15 + + +def test_a_daemon_that_pulls_the_windows_back_fails_the_scrolling_columns() -> None: + class Pulls(FakeStrip): + def _strip_frame(self, title): # noqa: ANN001, ANN202 + r = super()._strip_frame(title) + return Rect(max(r.x, 0), r.y, r.w, r.h) + + platform = Pulls() + checks = Checks() + scrolling.columns(platform, checks, []) + assert checks.failed > 0 + + +def test_a_strip_with_a_monitor_next_to_it_leaks_nothing() -> None: + platform = FakeStrip() + checks = Checks() + scrolling.two_monitors(platform, checks, []) + assert checks.failed == 0, checks.failures + assert checks.passed > 10 + + +def test_a_daemon_that_rests_windows_on_the_next_monitor_fails_two_monitors() -> None: + platform = FakeStrip() + platform.leaks = True + checks = Checks() + scrolling.two_monitors(platform, checks, []) + assert checks.failed > 0 diff --git a/nix/tests/vm/suites/core-e2e/test.py b/nix/tests/vm/suites/core-e2e/test.py index 7e16e11f..7c827d6b 100644 --- a/nix/tests/vm/suites/core-e2e/test.py +++ b/nix/tests/vm/suites/core-e2e/test.py @@ -150,6 +150,10 @@ run_suite( # the option of a workspace in the config, on a daemon started with it shared(layout_flip.run, platform, checks), platform_only("kde", kde_core.no_window_is_left, *nothing), + shared(scrolling.columns, platform, checks, []), + platform_only("kde", kde_core.no_window_is_left, *nothing), + shared(scrolling.two_monitors, platform, checks, []), + platform_only("kde", kde_core.no_window_is_left, *nothing), shared(config_reload.run, platform, checks), platform_only( "kde", kde_core.no_window_is_left, platform, checks, [Client("reload-stays")] diff --git a/nix/tests/vm/suites/scrolling/test.py b/nix/tests/vm/suites/scrolling/test.py index a223e78a..76daac4e 100644 --- a/nix/tests/vm/suites/scrolling/test.py +++ b/nix/tests/vm/suites/scrolling/test.py @@ -1,16 +1,14 @@ -# The scrolling layout in a real KWin. The strip runs past the edge of the work area, and KWin is -# expected to leave the windows exactly where komorebi puts them: a window that is out of view rests -# at the edge it left by, with a few pixels of it on the screen. What only a real KWin can tell is in -# `komorebi_vm.platforms.kde.extras.scrolling`: that KWin keeps them there, that the strip scrolls -# with the focus `komorebic focus` moves, and the commands of the columns that hold several windows -# (`consume`, `expel`, `toggle-column-stack`, `scrolling-layout-full-width`, `pan`). The shared scenario -# (`komorebi_vm.core.scenarios.scrolling`), which `core-e2e` runs and which runs on Windows too, -# checks the focused window is in view. `komorebi/tests/layout_options.rs` checks where komorebi -# puts the columns against the fake. +# The scrolling layout in a real KWin, with the shared scenario `scrolling.columns` +# (`komorebi_vm.core.scenarios.scrolling`), which runs on Windows too: the strip runs past the edge +# of the work area, a window that is out of view rests at the edge it left by with a few pixels of +# it on the screen, a strip with a monitor next to it leaks no window onto it (`two_monitors`), the strip scrolls with the focus and with `pan`, and the commands of the columns +# (`consume`, `expel`, `toggle-column-stack`, `scrolling-layout-full-width`) work. `core-e2e` runs it +# too, among the scenarios that start from a workspace with no window. +# `komorebi/tests/layout_options.rs` checks where komorebi puts the columns against the fake. from komorebi_vm import Checks, Kde -from komorebi_vm.core.suite import platform_only, run_suite +from komorebi_vm.core.scenarios import scrolling +from komorebi_vm.core.suite import run_suite, shared from komorebi_vm.kde_platform import KdePlatform -from komorebi_vm.platforms.kde.extras import scrolling as kde_scrolling W, H = 1920, 1080 @@ -19,11 +17,17 @@ 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) +# two outputs, of which the second is unplugged before the daemon starts: `two_monitors` plugs it in +kde = Kde.headless(machine, "main", size=(W, H), outputs=2, scale=SCALE) platform = KdePlatform(kde, (W, H)) +platform.remove_display() platform.start_komorebi() -run_suite(checks, platform_only("kde", kde_scrolling.run, platform, checks, [])) +run_suite( + checks, + shared(scrolling.columns, platform, checks, []), + shared(scrolling.two_monitors, platform, checks, []), +) kde.stop() checks.finish()