Fix client-driven floating resizes
426452a964d9ed0e9abe5e6a4940046a5cff4beb
Floating applications may choose their own size, and applications of any kind may round a size the compositor requested. Match the frame to the committed window geometry in both cases instead of configuring the application back to the size it declined. Bound these commits by the whole layout, as resize() does for an interactive move. A window dragged onto a neighbouring display keeps its old display until the drag ends, so bounding it by that display's usable area would snap it back on the next commit the application made. Adopting a committed geometry moves the window under a stationary pointer. Scroll events forward straight to the seat's pointer focus, and button presses hit-test only to pick keyboard focus before forwarding through the seat, so both reached the wrong surface until the pointer moved again. Recheck pointer focus the way arrange() does, reposition input popups so they follow their window, and republish the window list.
1 parent
1fdf63d7
swm.c
+32 -20
| 2229 | 2229 | ); |
|
| 2230 | 2230 | } |
|
| 2231 | 2231 | arrange_layers(l->mon); |
|
| 2232 | 2232 | } |
|
| 2233 | 2233 | ||
| 2234 | - | /* Apply a Wayland window's initial state or confirmed resize. */ |
|
| 2234 | + | /* Apply a Wayland window's initial state or committed geometry. */ |
|
| 2235 | 2235 | void commit_notify(struct wl_listener *listener, void *data) { |
|
| 2236 | 2236 | client_t *c = wl_container_of(listener, c, commit); |
|
| 2237 | - | struct wlr_box geom; |
|
| 2237 | + | struct wlr_box geom, old_geom; |
|
| 2238 | 2238 | ||
| 2239 | 2239 | if (c->surface.xdg->initial_commit) { |
|
| 2240 | 2240 | /* Pick a display early so the application receives the right scale. |
|
| 2241 | 2241 | * A title-based rule may move it once its title is available. */ |
|
| 2242 | 2242 | apply_rules(c, nullptr); |
| 2253 | 2253 | if (c->decoration) |
|
| 2254 | 2254 | request_decoration_mode(&c->set_decoration_mode, c->decoration); |
|
| 2255 | 2255 | wlr_xdg_toplevel_set_size(c->surface.xdg->toplevel, 0, 0); |
|
| 2256 | 2256 | return; |
|
| 2257 | 2257 | } |
|
| 2258 | - | /* Some applications, especially terminals, round requested sizes. Once |
|
| 2259 | - | * they confirm the resize, use the size they actually chose. */ |
|
| 2260 | - | if (!c->is_fullscreen && c->resize) { |
|
| 2258 | + | old_geom = c->geom; |
|
| 2259 | + | ||
| 2260 | + | /* Floating applications may choose their own size. Applications may also |
|
| 2261 | + | * round a compositor-requested size. Match the frame to the committed |
|
| 2262 | + | * window geometry in both cases. */ |
|
| 2263 | + | if (!c->is_fullscreen && (c->resize || c->is_floating)) { |
|
| 2261 | 2264 | client_get_geometry(c, &geom); |
|
| 2262 | 2265 | swm_box_reconcile_commit( |
|
| 2263 | 2266 | (struct swm_box *)&c->geom, geom.width, geom.height, c->bw, c->resize_edges |
|
| 2264 | 2267 | ); |
|
| 2265 | 2268 | } |
| 2275 | 2278 | else |
|
| 2276 | 2279 | c->geom = c->pending_geom; |
|
| 2277 | 2280 | } else if (c->resize) { |
|
| 2278 | 2281 | /* The client lost its display with a resize in flight; settle the |
|
| 2279 | 2282 | * resize once a display is available again. */ |
|
| 2283 | + | } else if (c->is_floating && !c->is_fullscreen && c->mon) { |
|
| 2284 | + | /* Bound floating windows by the whole layout, as resize() does for an |
|
| 2285 | + | * interactive move. A window dragged onto a neighbouring display keeps |
|
| 2286 | + | * its display until the drag ends, so bounding it by that display's |
|
| 2287 | + | * usable area would snap it back on the next commit. */ |
|
| 2288 | + | apply_bounds(c, &sgeom); |
|
| 2289 | + | resize_apply(c); |
|
| 2280 | 2290 | } else { |
|
| 2281 | 2291 | resize(c, c->geom, c->is_floating && !c->is_fullscreen); |
|
| 2282 | 2292 | } |
|
| 2293 | + | if (c->mon && !wlr_box_equal(&old_geom, &c->geom)) { |
|
| 2294 | + | print_status(); |
|
| 2295 | + | /* The window moved under a stationary pointer. Recheck pointer focus, |
|
| 2296 | + | * as arrange() does, so scroll and button events reach the surface now |
|
| 2297 | + | * beneath the cursor. Keep any input popups with their window. */ |
|
| 2298 | + | motion_notify(0, nullptr, 0, 0, 0, 0, 1); |
|
| 2299 | + | ||
| 2300 | + | if (c == focus_top(c->mon)) |
|
| 2301 | + | position_input_popups(); |
|
| 2302 | + | } |
|
| 2283 | 2303 | if (!c->resize) |
|
| 2284 | 2304 | c->resize_edges = WLR_EDGE_NONE; |
|
| 2285 | 2305 | } |
|
| 2286 | 2306 | ||
| 2287 | 2307 | /* Create and constrain a popup after its initial state is committed. */ |
| 4622 | 4642 | } |
|
| 4623 | 4643 | ||
| 4624 | 4644 | /* Publish visible window rectangles in slurp's predefined-region format. */ |
|
| 4625 | 4645 | void publish_windows(const char *runtime) { |
|
| 4626 | 4646 | client_t *c; |
|
| 4627 | - | bool floating; |
|
| 4628 | 4647 | FILE *file; |
|
| 4629 | 4648 | char path[MAX_STATE_PATH], tmppath[MAX_STATE_PATH], title[MAX_WINDOW_STATE_FIELD]; |
|
| 4630 | 4649 | ||
| 4631 | 4650 | if (!runtime || snprintf(path, sizeof(path), "%s/swm-windows", runtime) >= (int)sizeof(path) || |
|
| 4632 | 4651 | !(file = file_replace_begin(path, tmppath, sizeof(tmppath)))) |
|
| 4633 | 4652 | return; |
|
| 4634 | 4653 | ||
| 4635 | - | /* slurp resolves equal overlapping regions in input order. Publish the |
|
| 4636 | - | * floating layer last so its windows remain selectable above tiled windows. */ |
|
| 4637 | - | for (floating = false;; floating = true) { |
|
| 4638 | - | wl_list_for_each(c, &clients, link) { |
|
| 4639 | - | if (c->is_floating != floating || !c->mon || !client_surface(c)->mapped || |
|
| 4640 | - | !c->scene->node.enabled) |
|
| 4641 | - | continue; |
|
| 4642 | - | status_field(title, sizeof(title), client_get_title(c)); |
|
| 4643 | - | fprintf( |
|
| 4644 | - | file, "%d,%d %dx%d %s\n", c->geom.x, c->geom.y, c->geom.width, c->geom.height, title |
|
| 4645 | - | ); |
|
| 4646 | - | } |
|
| 4647 | - | if (floating) |
|
| 4648 | - | break; |
|
| 4654 | + | wl_list_for_each(c, &clients, link) { |
|
| 4655 | + | if (!c->mon || !client_surface(c)->mapped || !c->scene->node.enabled) |
|
| 4656 | + | continue; |
|
| 4657 | + | status_field(title, sizeof(title), client_get_title(c)); |
|
| 4658 | + | fprintf( |
|
| 4659 | + | file, "%d,%d %dx%d %s\n", c->geom.x, c->geom.y, c->geom.width, c->geom.height, title |
|
| 4660 | + | ); |
|
| 4649 | 4661 | } |
|
| 4650 | 4662 | file_replace_commit(file, tmppath, path); |
|
| 4651 | 4663 | } |
|
| 4652 | 4664 | ||
| 4653 | 4665 | /* Publish the status once the current batch of changes has settled. */ |
test/clients.py
+51 -0
| 129 | 129 | title: str, |
|
| 130 | 130 | color: int, |
|
| 131 | 131 | parent=None, |
|
| 132 | 132 | fullscreen: bool = False, |
|
| 133 | 133 | report: Path | None = None, |
|
| 134 | + | fixed_size: bool = False, |
|
| 134 | 135 | ): |
|
| 135 | 136 | self.connection = connection |
|
| 136 | 137 | self.compositor = connection.get(WlCompositor) |
|
| 137 | 138 | self.shm = connection.get(WlShm) |
|
| 138 | 139 | self.wm_base = connection.get(XdgWmBase) |
| 154 | 155 | self.toplevel.set_app_id("swm-test") |
|
| 155 | 156 | if parent is not None: |
|
| 156 | 157 | self.toplevel.set_parent(parent) |
|
| 157 | 158 | if fullscreen: |
|
| 158 | 159 | self.toplevel.set_fullscreen(None) |
|
| 160 | + | if fixed_size: |
|
| 161 | + | self.toplevel.set_min_size(self.width, self.height) |
|
| 162 | + | self.toplevel.set_max_size(self.width, self.height) |
|
| 159 | 163 | self.surface.commit() |
|
| 160 | 164 | ||
| 161 | 165 | def _toplevel_configure(self, proxy, width: int, height: int, states) -> None: |
|
| 162 | 166 | if width > 0: |
|
| 163 | 167 | self.width = width |
| 215 | 219 | connection.display.dispatch(block=True) |
|
| 216 | 220 | window.destroy() |
|
| 217 | 221 | connection.close() |
|
| 218 | 222 | ||
| 219 | 223 | ||
| 224 | + | def xdg_resize(arguments: list[str]) -> None: |
|
| 225 | + | """Commit a client-selected size after an XDG window maps. |
|
| 226 | + | ||
| 227 | + | The window maps with fixed size limits so the compositor cannot choose its |
|
| 228 | + | initial size, then drops those limits and commits each requested size on |
|
| 229 | + | its own once the runner triggers it. |
|
| 230 | + | """ |
|
| 231 | + | ||
| 232 | + | if len(arguments) != 4: |
|
| 233 | + | raise Failure("xdg-resize requires WIDTH HEIGHT WIDTH HEIGHT") |
|
| 234 | + | trigger = Path(os.environ["SWM_RESIZE_TRIGGER"]) |
|
| 235 | + | committed = Path(os.environ["SWM_RESIZE_COMMITTED"]) |
|
| 236 | + | connection = Connection(WlCompositor, WlShm, XdgWmBase) |
|
| 237 | + | window = Window( |
|
| 238 | + | connection, |
|
| 239 | + | "client-resize", |
|
| 240 | + | 0xFF336699, |
|
| 241 | + | fixed_size=True, |
|
| 242 | + | report=Path(os.environ["SWM_CLIENT_REPORT"]), |
|
| 243 | + | ) |
|
| 244 | + | connection.roundtrips(3) |
|
| 245 | + | # The window mapped at a size the compositor could not override. Unlock the |
|
| 246 | + | # size limits so later commits choose their own size the way an application |
|
| 247 | + | # that resizes itself does. |
|
| 248 | + | window.toplevel.set_min_size(0, 0) |
|
| 249 | + | window.toplevel.set_max_size(0, 0) |
|
| 250 | + | window.surface.commit() |
|
| 251 | + | connection.roundtrips(2) |
|
| 252 | + | for index in range(2): |
|
| 253 | + | deadline = time.monotonic() + 5 |
|
| 254 | + | while not trigger.exists() or trigger.read_text().strip() != str(index): |
|
| 255 | + | if time.monotonic() >= deadline: |
|
| 256 | + | raise Failure(f"timed out waiting for resize trigger {index}") |
|
| 257 | + | time.sleep(0.01) |
|
| 258 | + | window.width = int(arguments[index * 2]) |
|
| 259 | + | window.height = int(arguments[index * 2 + 1]) |
|
| 260 | + | window.xdg_surface.set_window_geometry(0, 0, window.width, window.height) |
|
| 261 | + | window.draw() |
|
| 262 | + | connection.display.flush() |
|
| 263 | + | committed.write_text(f"{index}\n") |
|
| 264 | + | while window.running: |
|
| 265 | + | connection.display.dispatch(block=True) |
|
| 266 | + | window.destroy() |
|
| 267 | + | connection.close() |
|
| 268 | + | ||
| 269 | + | ||
| 220 | 270 | def xdg_lifecycle(arguments: list[str]) -> None: |
|
| 221 | 271 | """Repeatedly map and unmap a focused XDG toplevel.""" |
|
| 222 | 272 | ||
| 223 | 273 | count = int(arguments[0]) if arguments else 8 |
|
| 224 | 274 | connection = Connection(WlCompositor, WlShm, XdgWmBase) |
| 963 | 1013 | connection.disconnect() |
|
| 964 | 1014 | ||
| 965 | 1015 | ||
| 966 | 1016 | ROLES = { |
|
| 967 | 1017 | "xdg": xdg_client, |
|
| 1018 | + | "xdg-resize": xdg_resize, |
|
| 968 | 1019 | "xdg-lifecycle": xdg_lifecycle, |
|
| 969 | 1020 | "transient": transient, |
|
| 970 | 1021 | "keyboard": keyboard, |
|
| 971 | 1022 | "pointer": pointer, |
|
| 972 | 1023 | "foreign": foreign, |
test/run.py
+67 -10
| 113 | 113 | time.sleep(0.02) |
|
| 114 | 114 | detail = f": {last}" if last else "" |
|
| 115 | 115 | raise Failure(f"timeout waiting for {description}{detail}") |
|
| 116 | 116 | ||
| 117 | 117 | ||
| 118 | + | def consistently(description: str, predicate, duration: float = 0.5): |
|
| 119 | + | """Fail if a predicate stops holding within a duration.""" |
|
| 120 | + | ||
| 121 | + | deadline = time.monotonic() + duration |
|
| 122 | + | while time.monotonic() < deadline: |
|
| 123 | + | if not predicate(): |
|
| 124 | + | raise Failure(f"{description} did not hold") |
|
| 125 | + | time.sleep(0.02) |
|
| 126 | + | ||
| 127 | + | ||
| 118 | 128 | def test(name: str, body) -> None: |
|
| 119 | 129 | """Run and report one named test.""" |
|
| 120 | 130 | ||
| 121 | 131 | global CURRENT_COMMAND, CURRENT_LOG |
|
| 122 | 132 | CURRENT_COMMAND = [] |
| 293 | 303 | ||
| 294 | 304 | path = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.geometry" |
|
| 295 | 305 | return list(map(int, path.read_text().split())) |
|
| 296 | 306 | ||
| 297 | 307 | ||
| 308 | + | def published_geometry(title: str) -> list[int]: |
|
| 309 | + | """Return a client's compositor-published outer geometry.""" |
|
| 310 | + | ||
| 311 | + | path = Path(os.environ["XDG_RUNTIME_DIR"]) / "swm-windows" |
|
| 312 | + | match = re.search( |
|
| 313 | + | rf"^(\d+),(\d+) (\d+)x(\d+) {re.escape(title)}$", path.read_text(), re.MULTILINE |
|
| 314 | + | ) |
|
| 315 | + | if not match: |
|
| 316 | + | raise Failure(f"missing published geometry for {title}") |
|
| 317 | + | return list(map(int, match.groups())) |
|
| 318 | + | ||
| 319 | + | ||
| 298 | 320 | def protocol(role: str, *args: object) -> str: |
|
| 299 | 321 | """Run one native-protocol action implemented in Python.""" |
|
| 300 | 322 | ||
| 301 | 323 | return run(*client_command(role, *args), env=os.environ.copy()) |
|
| 302 | 324 |
| 338 | 360 | ||
| 339 | 361 | one = start_client("one", "ff5588dd") |
|
| 340 | 362 | two = start_client("two", "ff55aa88") |
|
| 341 | 363 | ||
| 342 | 364 | def publication() -> None: |
|
| 343 | - | windows_path = Path(os.environ["XDG_RUNTIME_DIR"]) / "swm-windows" |
|
| 344 | - | windows = windows_path.read_text() |
|
| 365 | + | windows = (Path(os.environ["XDG_RUNTIME_DIR"]) / "swm-windows").read_text() |
|
| 345 | 366 | if not re.search(r"^\d+,\d+ \d+x\d+ one$", windows, re.MULTILINE): |
|
| 346 | 367 | raise Failure("missing published rectangle for one") |
|
| 347 | 368 | if not re.search(r"^\d+,\d+ \d+x\d+ two$", windows, re.MULTILINE): |
|
| 348 | 369 | raise Failure("missing published rectangle for two") |
|
| 349 | - | virtual_keyboard(36) |
|
| 350 | - | wait_title("one") |
|
| 351 | - | virtual_keyboard(20) |
|
| 352 | - | eventually( |
|
| 353 | - | "floating window publication order", |
|
| 354 | - | lambda: windows_path.read_text().index("two") < windows_path.read_text().index("one"), |
|
| 355 | - | ) |
|
| 356 | - | virtual_keyboard(20) |
|
| 357 | 370 | before = current_title() |
|
| 358 | 371 | virtual_keyboard(36) |
|
| 359 | 372 | wait_title("two" if before == "one" else "one") |
|
| 360 | 373 | ||
| 361 | 374 | test("window publication and focus", publication) |
|
| 362 | 375 | ||
| 376 | + | def client_resize() -> None: |
|
| 377 | + | report = Path(os.environ["SWM_TEST_DIR"]) / "client-resize.geometry" |
|
| 378 | + | trigger = Path(os.environ["SWM_TEST_DIR"]) / "client-resize.trigger" |
|
| 379 | + | committed = Path(os.environ["SWM_TEST_DIR"]) / "client-resize.committed" |
|
| 380 | + | env = os.environ.copy() |
|
| 381 | + | env["SWM_CLIENT_REPORT"] = str(report) |
|
| 382 | + | env["SWM_RESIZE_TRIGGER"] = str(trigger) |
|
| 383 | + | env["SWM_RESIZE_COMMITTED"] = str(committed) |
|
| 384 | + | targets = ([200, 120], [520, 320]) |
|
| 385 | + | client = spawn( |
|
| 386 | + | client_log("client-resize"), |
|
| 387 | + | *client_command("xdg-resize", *targets[0], *targets[1]), |
|
| 388 | + | env=env, |
|
| 389 | + | ) |
|
| 390 | + | wait_title("client-resize") |
|
| 391 | + | eventually("client resize report", lambda: report.exists() and report.stat().st_size) |
|
| 392 | + | ||
| 393 | + | def border_size(): |
|
| 394 | + | configured = geometry("client-resize") |
|
| 395 | + | outer = published_geometry("client-resize") |
|
| 396 | + | delta = [outer[2] - configured[0], outer[3] - configured[1]] |
|
| 397 | + | return delta if delta[0] == delta[1] and delta[0] >= 0 else None |
|
| 398 | + | ||
| 399 | + | border = eventually("settled floating geometry", border_size) |
|
| 400 | + | for index, target in enumerate(targets): |
|
| 401 | + | trigger.write_text(f"{index}\n") |
|
| 402 | + | eventually( |
|
| 403 | + | f"client resize commit {index}", |
|
| 404 | + | lambda index=index: committed.exists() |
|
| 405 | + | and committed.read_text().strip() == str(index), |
|
| 406 | + | ) |
|
| 407 | + | expected = [target[0] + border[0], target[1] + border[1]] |
|
| 408 | + | eventually( |
|
| 409 | + | f"client-selected floating size {index}", |
|
| 410 | + | lambda expected=expected: published_geometry("client-resize")[2:] == expected, |
|
| 411 | + | ) |
|
| 412 | + | consistently( |
|
| 413 | + | f"client-selected floating size {index}", |
|
| 414 | + | lambda expected=expected: published_geometry("client-resize")[2:] == expected, |
|
| 415 | + | ) |
|
| 416 | + | terminate(client) |
|
| 417 | + | ||
| 418 | + | test("client-selected floating resize", client_resize) |
|
| 419 | + | ||
| 363 | 420 | def window_control() -> None: |
|
| 364 | 421 | target = current_title() |
|
| 365 | 422 | virtual_keyboard(3, 1) |
|
| 366 | 423 | eventually("focus after moving switch target", lambda: current_title() != target) |
|
| 367 | 424 | listed = run(BIN / "swmctl", "window", "list").splitlines() |