feat(nix): the drag-resize edge cases are a shared scenario

resize.edge_cases makes its drags from the daemon's layout and reads the
frames from the operating system: both sides of a shared edge, a horizontal
edge, a corner and an edge of the screen, with a neighbour or without. The
KDE extra keeps pointer_is_exact.
This commit is contained in:
LGUG2Z committed 2026-10-30 12:53:13 -07:00
1 parent 4db3f97e2e
commit 595706d66f
6 files changed
+284 -218

No files matched your search

+9 -6
View File
@@ -58,12 +58,15 @@ every count, with the extra's own check left to say only where KWin has the wind
## drag-resize
The suite's own cases are all KDE's: the pointer put to the pixel, and eight drags of edges and
corners checked against frames, tiles and neighbors. None of them is removed; they moved to
`kde_drag_resize` as `pointer_is_exact` and `edge_cases`, and the suite calls the shared `resize`
and `focus_follows_mouse` after them, as before. The shared `resize` makes one drag of one edge,
which the extra's `win1:right:...` cases also make: that is a repeat by design, because the extra
reads the daemon's tiles and the frames together, and the shared one reads only the system.
The suite's own cases were all KDE's: the pointer put to the pixel, and eight drags of edges and
corners checked against frames, tiles and neighbors. The pointer check is the extra
`pointer_is_exact`. The eight drags are the shared `resize.edge_cases`, which makes its cases from
the daemon's layout (`latest_layout`) and reads the frames from the operating system, not from
KWin: both sides of an edge two tiles share, a horizontal edge, a corner and an edge of the screen.
With three windows in BSP the cases are the same eight as before. What changed: the wait for the
end of a drag is the layout changing (three seconds at most) and the frames settling, not the
daemon's log line `ResizeEnd`; and the strict checks use the platform's `tolerance` where the extra
used the cell of the font. The shared `resize.run` makes one drag of one edge, as before.
One run of the suite failed `win2:left:-240,0` ("frame R moved -96 but was not dragged": the
terminal's frame four cells short of an unchanged tile) and the next passed; not seen again.
@@ -93,3 +93,217 @@ def run(platform: Platform, checks: Checks, clients: list[Client], dx: int | Non
"and do not overlap",
not any(a.overlaps(b) for i, a in enumerate(frames) for b in frames[i + 1 :]),
)
GRABS = {
"left": "L",
"right": "R",
"top": "T",
"bottom": "B",
"topleft": "LT",
"topright": "RT",
"bottomleft": "LB",
"bottomright": "RB",
}
# how far apart two windows may be for one to be the neighbour of the other: twice the gap
NEIGHBOUR = 80
def _edges(r: Rect) -> dict[str, int]:
return {"L": r.x, "T": r.y, "R": r.x + r.w, "B": r.y + r.h}
def _tiles(platform: Platform) -> dict[str, Rect]:
"""The daemon's layout: the tile it gave each container, by the title of its window in view."""
workspace = platform.state().workspace()
return {
c[min(workspace.focused_windows[i], len(c) - 1)].title: workspace.latest_layout[i]
for i, c in enumerate(workspace.containers)
if i < len(workspace.latest_layout) and c
}
def _frames(platform: Platform, clients: list[Client]) -> dict[str, Rect]:
return {c.title: w.frame for c in clients if (w := platform.os_window(c)) is not None}
def _settled(platform: Platform, clients: list[Client]) -> dict[str, Rect]:
"""The frames once they have stopped changing."""
last = _frames(platform, clients)
for _ in range(20):
platform.wait_for(lambda: False, timeout=0.5)
now = _frames(platform, clients)
if now == last:
return now
last = now
return last
def _press_point(grab: str, r: Rect) -> tuple[int, int]:
"""Where the pointer is pressed for a grab: in the gap outside the frame."""
left, right, top, bottom = r.x - OFFSET, r.x + r.w + OFFSET, r.y - OFFSET, r.y + r.h + OFFSET
cx, cy = r.x + r.w // 2, r.y + r.h // 2
return {
"left": (left, cy),
"right": (right, cy),
"top": (cx, top),
"bottom": (cx, bottom),
"topleft": (left, top),
"topright": (right, top),
"bottomleft": (left, bottom),
"bottomright": (right, bottom),
}[grab]
def _has_neighbour(edge: str, name: str, frames: dict[str, Rect]) -> bool:
"""Is there another window within a gap's reach on that side of this one, overlapping it."""
me = frames[name]
for other, o in frames.items():
if other == name:
continue
vertical = o.y < me.y + me.h and me.y < o.y + o.h
horizontal = o.x < me.x + me.w and me.x < o.x + o.w
if edge == "L" and vertical and 0 <= me.x - (o.x + o.w) <= NEIGHBOUR:
return True
if edge == "R" and vertical and 0 <= o.x - (me.x + me.w) <= NEIGHBOUR:
return True
if edge == "T" and horizontal and 0 <= me.y - (o.y + o.h) <= NEIGHBOUR:
return True
if edge == "B" and horizontal and 0 <= o.y - (me.y + me.h) <= NEIGHBOUR:
return True
return False
def _stacked(layout: list[Rect]) -> tuple[int, int] | None:
"""The first pair of tiles of which the second is below the first and overlaps it sideways."""
for i, a in enumerate(layout):
for j, b in enumerate(layout):
if i != j and b.y >= a.y + a.h and a.x < b.x + b.w and b.x < a.x + a.w:
return i, j
return None
def _cases(layout: list[Rect], titles: list[str], area: Rect) -> list[tuple[str, str, int, int]]:
"""Drags to make, as (window, grab, dx, dy), from the layout: both sides of an edge that two
tiles share, a horizontal one, a corner next to two neighbours, and an edge of the screen."""
cases: list[tuple[str, str, int, int]] = []
if (pair := _side_by_side(layout)) is not None:
a, b = pair
cases += [
(titles[a], "right", 72, 0),
(titles[a], "right", -72, 0),
(titles[b], "left", -72, 0),
(titles[b], "left", -240, 0),
]
if (stack := _stacked(layout)) is not None:
a, b = stack
cases += [(titles[a], "bottom", 0, 72), (titles[b], "top", 0, -72)]
for k, tile in enumerate(layout):
right_of = any(
i != k and tile.x >= t.x + t.w and t.y < tile.y + tile.h and tile.y < t.y + t.h
for i, t in enumerate(layout)
)
above = any(
i != k and t.y >= tile.y + tile.h and tile.x < t.x + t.w and t.x < tile.x + tile.w
for i, t in enumerate(layout)
)
if right_of and above:
cases.append((titles[k], "bottomleft", -72, 72))
break
first = min(range(len(layout)), key=lambda i: layout[i].x)
if _press_point("left", layout[first])[0] >= area.x:
cases.append((titles[first], "left", -72, 0))
return cases
def _problems(
platform: Platform,
clients: list[Client],
case: tuple[str, str, int, int],
) -> list[str]:
"""Makes a drag and says what is wrong with the outcome: the frame the user sees, the layout,
the edges that were not dragged and the windows."""
name, grab, dx, dy = case
area = platform.work_area()
platform.komorebic("retile")
before = _settled(platform, clients)
tiles_before = _tiles(platform)
px, py = _press_point(grab, before[name])
end = (max(area.x, px + dx), max(area.y, py + dy))
platform.pointer.drag((px, py), end)
platform.wait_for(lambda: _tiles(platform) != tiles_before, timeout=3)
after = _settled(platform, clients)
tiles_after = _tiles(platform)
held = GRABS[grab]
want = {"L": dx, "R": dx, "T": dy, "B": dy}
problems = []
moved = {e: _edges(after[name])[e] - _edges(before[name])[e] for e in "LTRB"}
for e in "LTRB":
if e in held and _has_neighbour(e, name, before):
if abs(moved[e] - want[e]) > platform.tolerance:
problems.append(f"frame {e} moved {moved[e]}, the pointer moved {want[e]}")
elif abs(moved[e]) > platform.tolerance:
why = "was not dragged" if e not in held else "has no neighbour"
problems.append(f"frame {e} moved {moved[e]} but {why}")
tile_moved = {e: _edges(tiles_after[name])[e] - _edges(tiles_before[name])[e] for e in "LTRB"}
for e in "LTRB":
if e in held and _has_neighbour(e, name, before):
if abs(tile_moved[e] - want[e]) > 2:
problems.append(f"tile {e} moved {tile_moved[e]}, the pointer moved {want[e]}")
elif abs(tile_moved[e]) > 1:
why = "was not dragged" if e not in held else "has no neighbour"
problems.append(f"tile {e} moved {tile_moved[e]} but {why}")
# nothing else moves, but with a dragged edge: they share it
follows = [m for m in tile_moved.values() if abs(m) > 1]
for other in tiles_after:
if other == name:
continue
for e in "LTRB":
m = _edges(tiles_after[other])[e] - _edges(tiles_before[other])[e]
if abs(m) > 1 and not any(abs(m - f) <= 2 for f in follows):
problems.append(f"{other} tile {e} moved {m}: not with the dragged edge")
names = list(after)
for i, a in enumerate(names):
if not after[a].inside(area):
problems.append(f"{a} {after[a]} is outside the work area")
problems += [f"{a} and {b} overlap" for b in names[i + 1 :] if after[a].overlaps(after[b])]
print(f" {case}: frame {before[name]} -> {after[name]}, tile edges {tile_moved}")
return problems
def edge_cases(platform: Platform, checks: Checks, clients: list[Client]) -> None:
"""Drags both sides of an edge two tiles share, a horizontal edge, a corner and an edge of the
screen, and checks, against the layout the daemon computed (`latest_layout`) and the frames the
operating system has:
- a dragged edge that has a neighbour moves by what the pointer moved, and the neighbour's
edge moves with it
- a dragged edge with no neighbour does not move, and the window is put back
- edges that were not dragged do not move, and every edge of another tile stays or moves with
a dragged one
- the windows still tile: inside the work area, not on each other
`clients` are the tiled windows of the first workspace, each in a container of its own, in a
layout that has them side by side or stacked. The cases are made from the layout, so that
a layout with no stacked tiles has no horizontal case. The layout is retiled before each one.
"""
if not platform.has("pointer"):
print(" skipped: this platform has no pointer")
return
platform.komorebic("retile")
platform.wait_for(lambda: len(_tiles(platform)) == len(clients), timeout=20)
tiles = _tiles(platform)
titles = list(tiles)
layout = [tiles[t] for t in titles]
for case in _cases(layout, titles, platform.work_area()):
problems = _problems(platform, clients, case)
name, grab, dx, dy = case
checks.check(
f"{name}:{grab}:{dx},{dy}: " + ("as expected" if not problems else "; ".join(problems)),
not problems,
)
@@ -1,211 +1,22 @@
"""What only KDE can tell about dragging the edges of tiles with a real pointer.
"""What only KDE can tell about dragging with a real pointer.
The pointer is a device made inside the guest, and KWin gives the daemon the end of each drag. Edges
and corners of three tiles are grabbed and dragged, and what happened is checked against what a
user expects (`komorebi/tests/drag_resize.rs` checks the window manager's side against the fake):
The pointer is a device made inside the guest. The shared `resize` makes the drags of the edges and
corners of the tiles, against the daemon's layout and the frames the operating system has
(`komorebi/tests/drag_resize.rs` checks the window manager's side against the fake); what is here is
that the device puts the pointer to the pixel, which a drag at a gap of four pixels depends on.
* a dragged edge that has a neighbor moves by what the pointer moved, and the neighbor's edge moves
with it: they share it
* a dragged edge with no neighbor (the edge of the screen) does not move, and the window is put back
* edges that were not dragged do not move, and every edge of every other tile either stays or moves
with a dragged one
* the windows still tile: inside the screen, not overlapping
The strict checks are on the tiles, which are the daemon's own layout (`latest_layout` in
`komorebic state`). A terminal's frame is checked loosely: it sizes itself to whole cells of its
font, so it is a few pixels short of its tile. The shared `resize` makes one drag and reads the
operating system; these are the cases it leaves out.
The suite runs `pointer_is_exact` and `edge_cases` on a session with a pointer and three tiled
windows, `win1` to `win3`, then the shared scenarios.
The suite runs `pointer_is_exact` on a session with a pointer and three tiled windows, `win1` to
`win3`, then the shared `resize.edge_cases`, `resize.run` and `focus_follows_mouse`.
"""
import re
import time
from komorebi_vm import daemon
from komorebi_vm.core.checks import Checks
from komorebi_vm.core.platform import Client
from komorebi_vm.kde_platform import CELL, KdePlatform
from komorebi_vm.kde_platform import KdePlatform
# the module's unit, which the session starts when it logs in: the daemon the suite drags against
UNIT = "komorebi.service"
# how far from its frame a window's resize border is pressed: in the gap between two tiles
OFFSET = 4
WINDOWS = 3
GRABS = {
"left": "L",
"right": "R",
"top": "T",
"bottom": "B",
"topleft": "LT",
"topright": "RT",
"bottomleft": "LB",
"bottomright": "RB",
}
# BSP: win1 is the left half, win2 the top right and win3 the bottom right
CASES = [
"win1:right:72,0",
"win1:right:-72,0",
"win2:left:-72,0",
"win2:left:-240,0",
"win2:bottom:0,72",
"win3:top:0,-72",
"win2:bottomleft:-72,72",
"win1:left:-72,0",
]
Box = tuple[int, int, int, int]
def frames(platform: KdePlatform) -> dict[str, Box]:
kde = platform.kde
out = {}
for n in range(1, WINDOWS + 1):
r = kde.rect(kde.wid(f"win{n}"))
out[f"win{n}"] = (r.x, r.y, r.w, r.h)
return out
def tiles(platform: KdePlatform) -> dict[str, Box]:
"""komorebi's layout: the tile it gave each window, by title"""
workspace = platform.kde.state()["monitors"]["elements"][0]["workspaces"]["elements"][0]
out = {}
for i, container in enumerate(workspace["containers"]["elements"]):
windows = container["windows"]
title = windows["elements"][windows["focused"]]["details"]["title"]
tile = workspace["latest_layout"][i]
out[title] = (tile["left"], tile["top"], tile["right"], tile["bottom"])
return out
def edges(r: Box) -> dict[str, int]:
x, y, w, h = r
return {"L": x, "T": y, "R": x + w, "B": y + h}
def settled(platform: KdePlatform) -> dict[str, Box]:
"""The frames once they have stopped changing"""
last = frames(platform)
deadline = time.monotonic() + 10 * platform.kde.scale
while time.monotonic() < deadline:
time.sleep(0.5)
now = frames(platform)
if now == last:
return now
last = now
return last
def resize_ends(platform: KdePlatform) -> int:
"""How many drags of an edge the daemon has handled the end of, by the unit's log"""
log = platform.kde.run(f"journalctl --user -u {UNIT} --no-pager -o cat")[1]
return sum(1 for e in daemon.events(log) if e.kind == "ResizeEnd")
def press_point(grab: str, r: Box) -> tuple[int, int]:
x, y, w, h = r
left, right, top, bottom = x - OFFSET, x + w + OFFSET, y - OFFSET, y + h + OFFSET
cx, cy = x + w // 2, y + h // 2
return {
"left": (left, cy),
"right": (right, cy),
"top": (cx, top),
"bottom": (cx, bottom),
"topleft": (left, top),
"topright": (right, top),
"bottomleft": (left, bottom),
"bottomright": (right, bottom),
}[grab]
def has_neighbor(edge: str, name: str, before: dict[str, Box]) -> bool:
"""Is there another window within a gap's reach on that side of this one, overlapping it"""
mx, my, mw, mh = before[name]
for other, (ox, oy, ow, oh) in before.items():
if other == name:
continue
vertical_overlap = oy < my + mh and my < oy + oh
horizontal_overlap = ox < mx + mw and mx < ox + ow
if edge == "L" and vertical_overlap and 0 <= mx - (ox + ow) <= 80:
return True
if edge == "R" and vertical_overlap and 0 <= ox - (mx + mw) <= 80:
return True
if edge == "T" and horizontal_overlap and 0 <= my - (oy + oh) <= 80:
return True
if edge == "B" and horizontal_overlap and 0 <= oy - (my + mh) <= 80:
return True
return False
def run_case(platform: KdePlatform, spec: str) -> list[str]:
kde = platform.kde
W, H = platform.size
name, grab, d = spec.split(":")
dx, dy = (int(v) for v in d.split(","))
kde.komorebic("retile")
before = settled(platform)
tiles_before = tiles(platform)
ends = resize_ends(platform)
px, py = press_point(grab, before[name])
platform.pointer.drag((px, py), (px + dx, py + dy))
kde.wait_for(lambda: resize_ends(platform) > ends, timeout=10)
after = settled(platform)
tiles_after = tiles(platform)
held = GRABS[grab]
want = {"L": dx, "R": dx, "T": dy, "B": dy}
problems = []
# the frame the user sees: it did what was asked, give or take the client's cells
moved = {e: edges(after[name])[e] - edges(before[name])[e] for e in "LTRB"}
for e in "LTRB":
if e in held and has_neighbor(e, name, before):
if abs(moved[e] - want[e]) > CELL:
problems.append(f"frame {e} moved {moved[e]}, the pointer moved {want[e]}")
elif abs(moved[e]) > CELL:
why = "was not dragged" if e not in held else "has no neighbor"
problems.append(f"frame {e} moved {moved[e]} but {why}")
# the layout, which is exact
tile_moved = {e: edges(tiles_after[name])[e] - edges(tiles_before[name])[e] for e in "LTRB"}
for e in "LTRB":
if e in held and has_neighbor(e, name, before):
if abs(tile_moved[e] - want[e]) > 2:
problems.append(f"tile {e} moved {tile_moved[e]}, the pointer moved {want[e]}")
elif abs(tile_moved[e]) > 1:
why = "was not dragged" if e not in held else "has no neighbor"
problems.append(f"tile {e} moved {tile_moved[e]} but {why}")
# nothing else moves, but with a dragged edge: they share it
follows = [m for m in tile_moved.values() if abs(m) > 1]
for other in tiles_after:
if other == name:
continue
for e in "LTRB":
m = edges(tiles_after[other])[e] - edges(tiles_before[other])[e]
if abs(m) > 1 and not any(abs(m - f) <= 2 for f in follows):
problems.append(f"{other} tile {e} moved {m}: not with the dragged edge")
# still a tiling
names = list(after)
for i, n1 in enumerate(names):
x, y, w, h = after[n1]
if x < 0 or y < 0 or x + w > W or y + h > H:
problems.append(f"{n1} {after[n1]} is off the screen")
for n2 in names[i + 1 :]:
ox, oy, ow, oh = after[n2]
if x < ox + ow and ox < x + w and y < oy + oh and oy < y + h:
problems.append(f"{n1} and {n2} overlap")
print(f"{spec}: frame {before[name]} -> {after[name]}, tile edges {tile_moved}")
return problems
def _where(platform: KdePlatform) -> tuple[int, int]:
found = re.search(r"x:(-?\d+) y:(-?\d+)", platform.kde.sh("kdotool getmouselocation"))
@@ -225,13 +36,3 @@ def pointer_is_exact(platform: KdePlatform, checks: Checks, clients: list[Client
gx, gy = _where(platform)
worst = max(worst, abs(gx - x), abs(gy - y))
checks.check(f"the pointer is put within a pixel of where it is asked ({worst})", worst <= 1)
def edge_cases(platform: KdePlatform, checks: Checks, clients: list[Client]) -> None:
"""Drags each edge and corner of `CASES` and checks the frames, the tiles and the neighbors."""
for spec in CASES:
problems = run_case(platform, spec)
checks.check(
f"{spec}: " + ("as expected" if not problems else "; ".join(problems)),
not problems,
)
+5 -1
View File
@@ -203,7 +203,9 @@ class FakePlatform:
if not self.follows_pointer:
return
for i, tile in enumerate(self.tiles[:-1]):
if abs(start[0] - (tile.x + tile.w + 4)) <= 1:
right_of_it = abs(start[0] - (tile.x + tile.w + 4)) <= 1
left_of_next = abs(start[0] - (self.tiles[i + 1].x - 4)) <= 1
if right_of_it or left_of_next:
a, b = tile, self.tiles[i + 1]
self.tiles[i] = Rect(a.x, a.y, a.w + dx, a.h)
self.tiles[i + 1] = Rect(b.x + dx, b.y, b.w - dx, b.h)
@@ -659,6 +661,8 @@ class FakePlatform:
self.saved[path] = list(self.tiles)
case ("load-resize", path):
self._load_resize(path)
case ("retile",):
self._relayout()
case ("minimize",):
self.minimize(Client(self.containers[self.focus][0]))
case ("unmanage",):
+43
View File
@@ -11,6 +11,7 @@ from komorebi_vm.core.scenarios import (
monocle,
placement,
queries,
resize,
rules,
title_rule,
)
@@ -229,3 +230,45 @@ def test_a_daemon_that_does_not_follow_a_mode_change_fails_display_change() -> N
clients = opened(platform, checks)
display_change.run(platform, checks, clients)
assert any("the daemon's monitor follows" in f for f in checks.failures)
def test_a_daemon_that_follows_the_pointer_passes_the_edge_cases() -> None:
platform = FakePlatform()
checks = Checks()
clients = opened(platform, checks)
base = checks.passed
resize.edge_cases(platform, checks, clients)
assert checks.failed == 0, checks.failures
assert checks.passed - base == 4
def test_a_daemon_that_ignores_the_pointer_fails_the_edge_cases() -> None:
platform = FakePlatform(follows_pointer=False)
checks = Checks()
clients = opened(platform, checks)
resize.edge_cases(platform, checks, clients)
assert checks.failed == 4, checks.failures
def test_a_daemon_that_moves_the_other_edges_fails_the_edge_cases() -> None:
class Sloppy(FakePlatform):
def resize(self, start: tuple[int, int], dx: int) -> None:
super().resize(start, dx)
# the third tile is moved too, though no edge of it was dragged
a = self.tiles[2]
self.tiles[2] = Rect(a.x + dx, a.y, a.w, a.h) if dx else a
platform = Sloppy()
checks = Checks()
clients = opened(platform, checks)
resize.edge_cases(platform, checks, clients)
assert any("outside the work area" in f or "overlap" in f for f in checks.failures)
def test_a_platform_with_no_pointer_skips_the_edge_cases() -> None:
platform = FakePlatform(pointer=False)
checks = Checks()
clients = opened(platform, checks)
base = checks.passed
resize.edge_cases(platform, checks, clients)
assert (checks.passed, checks.failed) == (base, 0)
+5 -4
View File
@@ -1,8 +1,9 @@
# Resizing a tile by dragging its edge, in a Plasma session, with a pointer device the test drives
# from inside the guest (`komorebi_vm.Pointer`). What only a real KWin and a real pointer can tell is in
# `komorebi_vm.platforms.kde.extras.drag_resize`: edges and corners of three tiles are grabbed and
# dragged, and what happened is checked against what a user expects. The shared scenarios
# (`resize`, `focus_follows_mouse`) run on Windows too and make one drag and the focus changes.
# `komorebi_vm.platforms.kde.extras.drag_resize`: that the pointer is put to the pixel. The shared
# scenarios run on Windows too: `resize.edge_cases` grabs edges and corners of three tiles and drags
# them, and checks what happened against what a user expects; `resize.run` makes one more drag, and
# `focus_follows_mouse` the focus changes.
from komorebi_vm import Checks, Kde, Pointer
from komorebi_vm.core.platform import Client
from komorebi_vm.core.scenarios import focus_follows_mouse, resize
@@ -53,7 +54,7 @@ args = (platform, checks, clients)
run_suite(
checks,
platform_only("kde", kde_drag_resize.pointer_is_exact, *args),
platform_only("kde", kde_drag_resize.edge_cases, *args),
shared(resize.edge_cases, *args),
shared(resize.run, platform, checks, clients),
shared(focus_follows_mouse.run, platform, checks, clients[0], clients[2]),
)