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