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.
Alexis Sellier committed ago 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()