Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
verify/image-selection-test (10.4K)
1 #!/usr/bin/env bash
2 # image-selection-test — behavioral test for the overview drag-selection
3 # picking up the desktop IMAGES (cce-grid's pinned items) beside windows,
4 # and a group move carrying them (src/server/selection.rs, since
5 # 2026-09-30).
6 #
7 # Setup: a private cce-shadow instance whose seeded desktop holds two
8 # images (a 100x100 red one right of where cce-terminal maps, a 60x100 blue
9 # one left of it), cce-grid running against the shadow so it reports them
10 # (`grid-items` on the control socket), and one cce-terminal window. The
11 # camera stays at zoom 1 in overview with one window, so everything is
12 # driven in virtual units and converted through `ccectl camera`. The
13 # assertions, in order:
14 #
15 # 1. the grid's report landed (`ccectl selection` desk=...)
16 # 2. a band over the red image picks it, live, and the pick survives the
17 # release
18 # 3. a press on the selected image drags it (PointerOpType::GroupMove):
19 # the compositor's rect follows mid-drag, the grid saves the sidecar
20 # on `drop`
21 # 4. a band over everything, then a drag of the terminal, moves both
22 # images by exactly the terminal's offset (snap included) and the
23 # sidecar agrees
24 # 5. a background click drops the selection and stays in overview — also
25 # with only images selected (has_selection), where it used to exit
26 # 6. a press on an UNSELECTED image drops the selection and the grid
27 # drags the image itself, then re-reports
28 # 7. leaving overview clears it
29 #
30 # The compositor binary is whatever cce-shadow resolves (installed first,
31 # then the workspace target/release); extra args go to `cce-shadow start`,
32 # so `--bin ../target/release/cce-fx` pins the tree's own build. The grid
33 # is $CCE_GRID, else the workspace's target/release/cce-grid, else the one
34 # on PATH — build it with `cargo build --release -p cce-grid` to test the
35 # tree's.
36
37 set -euo pipefail
38
39 here=$(cd "$(dirname "$0")" && pwd)
40 repo=$(dirname "$here")
41 shadow="$repo/scripts/cce-shadow"
42 out=$(mktemp -d)
43 inst=""
44
45 cleanup() {
46 [ -n "$inst" ] && "$shadow" --instance "$inst" stop >/dev/null 2>&1 || true
47 rm -rf "$out"
48 }
49 trap cleanup EXIT
50
51 fail() {
52 echo "FAIL: $*" >&2
53 echo "--- grid log ---" >&2
54 cat "$out/grid.log" >&2 || true
55 echo "--- compositor log tail ---" >&2
56 [ -n "$inst" ] && "$shadow" --instance "$inst" logs 2>/dev/null | tail -20 >&2 || true
57 exit 1
58 }
59
60 grid=${CCE_GRID:-}
61 if [ -z "$grid" ]; then
62 if [ -x "$repo/../target/release/cce-grid" ]; then
63 grid="$repo/../target/release/cce-grid"
64 else
65 grid=$(command -v cce-grid) || fail "no cce-grid: build it or set CCE_GRID"
66 fi
67 fi
68 command -v cce-terminal >/dev/null || fail "cce-terminal is not installed"
69
70 echo "==> starting shadow instance"
71 inst=$("$shadow" start --new "$@" 2>&1 | sed -n "s/.*claimed instance '\([^']*\)'.*/\1/p")
72 [ -n "$inst" ] || fail "could not claim a shadow instance"
73 sc() { "$shadow" --instance "$inst" "$@"; }
74 home=$(sc env | sed -n 's/^export HOME=//p')
75 [ -n "$home" ] || fail "could not read the shadow's HOME"
76
77 echo "==> seeding two desktop images"
78 mkdir -p "$home/Desktop" "$home/.local/share/cce"
79 python3 - "$home/Desktop" <<'EOF'
80 import struct, sys, zlib
81 def png(path, w, h, rgb):
82 raw = b"".join(b"\0" + bytes(rgb) * w for _ in range(h))
83 def chunk(t, d): return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
84 open(path, "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
85 + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
86 png(sys.argv[1] + "/red.png", 100, 100, (220, 60, 60))
87 png(sys.argv[1] + "/blue.png", 60, 100, (60, 120, 220))
88 EOF
89 cat > "$home/.local/share/cce/desktop-items.json" <<EOF
90 [{"path":"$home/Desktop/red.png","x":960,"y":150,"w":100,"h":100},
91 {"path":"$home/Desktop/blue.png","x":-90,"y":150,"w":60,"h":100}]
92 EOF
93
94 echo "==> starting the grid ($grid) and a terminal"
95 sc run "$grid" >"$out/grid.log" 2>&1 &
96 sc ctl spawn cce-terminal >/dev/null
97 sleep 4
98
99 echo "==> driving"
100 python3 - "$shadow" "$inst" "$home" "$out/grid.log" <<'EOF' || fail "assertions failed"
101 import json, re, subprocess, sys, time
102 S, INST, H, GRIDLOG = sys.argv[1:5]
103 fails = 0
104
105 def ctl(*a):
106 return subprocess.run([S, "--instance", INST, "ctl", *a], capture_output=True, text=True).stdout.strip()
107
108 def cam():
109 c = ctl("camera")
110 g = lambda k: re.search(rf"{k}=([-\w.]+)", c).group(1)
111 return float(g("pan_x")), float(g("pan_y")), float(g("zoom")), g("mode")
112
113 def sel():
114 s = ctl("selection")
115 g = lambda k: re.search(rf"{k}=(\S+)", s).group(1)
116 desk = {}
117 if g("desk") != "-":
118 for tok in g("desk").split(";"):
119 i, rest = tok.split("@")
120 x, y, wh = rest.split(",")
121 w, h = wh.split("x")
122 desk[int(i)] = (float(x), float(y), float(w), float(h))
123 windows = [] if g("selected") == "-" else [int(v) for v in g("selected").split(",")]
124 items = [] if g("items") == "-" else [int(v) for v in g("items").split(",")]
125 return dict(raw=s, windows=windows, items=items, desk=desk, band=g("band"))
126
127 def term():
128 for line in ctl("windows").splitlines():
129 if "app_id=cce-terminal" in line:
130 g = lambda k: re.search(rf"\b{k}=([-\w.]+)", line).group(1)
131 return float(g("vx")), float(g("vy")), float(g("w")), float(g("h"))
132 return None
133
134 def move(vx, vy):
135 px, py, z, _ = cam()
136 ctl("pointer-move-to", str(round((vx - px) * z)), str(round((vy - py) * z)))
137 time.sleep(0.08)
138
139 def press(): ctl("pointer-press", "left"); time.sleep(0.1)
140 def release(): ctl("pointer-release", "left"); time.sleep(0.5)
141
142 def check(name, ok, detail=""):
143 global fails
144 print((" ok " if ok else " FAIL ") + name + ("" if ok else f" [{detail}]"))
145 if not ok: fails += 1
146
147 def band(x0, y0, x1, y1):
148 move(x0, y0); press()
149 move((x0 + x1) / 2, (y0 + y1) / 2); move(x1, y1); time.sleep(0.2)
150 mid = sel(); release(); return mid, sel()
151
152 def sidecar():
153 return {d["path"].rsplit("/", 1)[1]: (d["x"], d["y"]) for d in json.load(open(f"{H}/.local/share/cce/desktop-items.json"))}
154
155 def center(r): return (r[0] + r[2] / 2, r[1] + r[3] / 2)
156
157 # 1. the report
158 for _ in range(25):
159 s0 = sel()
160 if len(s0["desk"]) == 2 and term(): break
161 time.sleep(0.2)
162 check("grid reported both images", len(s0["desk"]) == 2, s0["raw"])
163 check("terminal mapped", term() is not None)
164 red = next(i for i, r in s0["desk"].items() if r[2] == 100)
165 blue = next(i for i, r in s0["desk"].items() if r[2] == 60)
166 if cam()[3] != "Overview":
167 ctl("overview"); time.sleep(1.5)
168 check("in overview", cam()[3] == "Overview", str(cam()))
169
170 # 2. band over the red image only (pressed to its right, clear of the terminal)
171 r = s0["desk"][red]
172 mid, after = band(r[0] + r[2] + 30, r[1] - 30, r[0] - 10, r[1] + r[3] + 10)
173 check("band mid-drag picks the red image only", mid["items"] == [red] and mid["windows"] == [] and mid["band"] != "-", mid["raw"])
174 check("selection persists after release", after["items"] == [red] and after["band"] == "-", after["raw"])
175
176 # 3. press the selected image, drag it down 100
177 cx, cy = center(r)
178 move(cx, cy); press(); move(cx, cy + 50); move(cx, cy + 100); time.sleep(0.3)
179 mid = sel()
180 check("image follows the drag mid-move", abs(mid["desk"][red][1] - (r[1] + 100)) < 1 and mid["items"] == [red], mid["raw"])
181 release(); time.sleep(0.5)
182 after = sel()
183 check("image stays where dropped, still selected", abs(after["desk"][red][1] - (r[1] + 100)) < 1 and after["items"] == [red], after["raw"])
184 check("grid saved the new position", abs(sidecar()["red.png"][1] - (r[1] + 100)) < 1, str(sidecar()))
185 check("grid logged the drop", "group move dropped" in open(GRIDLOG).read())
186
187 # 4. band over everything, drag the terminal, images follow by its offset
188 t = term(); d = sel()["desk"]
189 x0 = min(d[blue][0], t[0]) - 20; y0 = min(d[blue][1], d[red][1], t[1]) - 20
190 x1 = max(d[red][0] + d[red][2], t[0] + t[2]) + 20; y1 = max(d[red][1] + d[red][3], t[1] + t[3]) + 20
191 mid, after = band(x0, y0, x1, y1)
192 check("band over everything picks the window and both images", len(after["windows"]) == 1 and sorted(after["items"]) == sorted([red, blue]), after["raw"])
193 t0 = term(); d0 = sel()["desk"]
194 tcx, tcy = t0[0] + t0[2] / 2, t0[1] + t0[3] / 2
195 move(tcx, tcy); press(); move(tcx, tcy + 100); move(tcx, tcy + 200); time.sleep(0.3)
196 release(); time.sleep(0.6)
197 t1 = term(); d1 = sel()["desk"]
198 dt = (t1[0] - t0[0], t1[1] - t0[1])
199 check("terminal moved", dt != (0.0, 0.0), f"dt={dt}")
200 for name, i in (("red", red), ("blue", blue)):
201 di = (d1[i][0] - d0[i][0], d1[i][1] - d0[i][1])
202 check(f"{name} image moved by the terminal's offset", abs(di[0] - dt[0]) < 1 and abs(di[1] - dt[1]) < 1, f"image {di} terminal {dt}")
203 sc = sidecar()
204 check("sidecar matches the compositor's rects", abs(sc["red.png"][1] - d1[red][1]) < 1 and abs(sc["blue.png"][1] - d1[blue][1]) < 1, f"{sc} vs {d1}")
205
206 # 5. background clicks
207 t = term()
208 bx, by = t[0] - 15, t[1] + t[3] / 2
209 move(bx, by); press(); release()
210 s = sel()
211 check("background click drops the selection", s["windows"] == [] and s["items"] == [], s["raw"])
212 check("still in overview after dropping", cam()[3] == "Overview", str(cam()))
213 b = sel()["desk"][blue]
214 mid, after = band(b[0] - 12, b[1] - 12, b[0] + b[2] + 8, b[1] + b[3] + 12)
215 check("band picks the blue image only", after["items"] == [blue] and after["windows"] == [], after["raw"])
216 move(bx, by + 150); press(); release()
217 s = sel()
218 check("click drops an image-only selection", s["items"] == [], s["raw"])
219 check("and stays in overview", cam()[3] == "Overview", str(cam()))
220
221 # 6. a press on an unselected image
222 mid, after = band(b[0] - 12, b[1] - 12, b[0] + b[2] + 8, b[1] + b[3] + 12)
223 check("blue selected again", after["items"] == [blue], after["raw"])
224 rr = sel()["desk"][red]; cx, cy = center(rr)
225 move(cx, cy); press(); time.sleep(0.2)
226 s = sel()
227 check("press on the unselected red image drops the selection", s["items"] == [], s["raw"])
228 move(cx, cy - 30); move(cx, cy - 60); time.sleep(0.2); release(); time.sleep(0.8)
229 d = sel()["desk"]
230 check("grid dragged the red image itself and re-reported", abs(d[red][1] - (rr[1] - 60)) < 2, f"{d[red]} from {rr}")
231 check("blue untouched by that drag", abs(d[blue][1] - b[1]) < 1, str(d))
232
233 # 7. leaving overview
234 band(rr[0] - 10, rr[1] - 70, rr[0] + rr[2] + 10, rr[1] + rr[3])
235 check("something selected before exit", sel()["items"] == [red], sel()["raw"])
236 ctl("overview"); time.sleep(1.5)
237 s = sel()
238 check("leaving overview clears the selection", s["items"] == [] and s["windows"] == [] and cam()[3] == "Normal", s["raw"] + " " + str(cam()))
239 sys.exit(1 if fails else 0)
240 EOF
241
242 echo "PASS"