git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitb6a42b33b87741412bcdbc22ad3f74422b21a748
parentdfedc50f5d
authorIsaac Freund <[email protected]>
date2025-01-01 10:54
Seat: implement pointer move operations

Resize and starting the operation with a serial are TODO though there is
now some shared infrastructure in place.

 river/Cursor.zig               | 219 +++++++--------------------------------
 river/PointerConstraint.zig    |   2 +-
 river/Seat.zig                 | 150 +++++++++++++++++++++++++--
 river/Window.zig               | 229 ++++++++++++++++++++++++++---------------
 river/WindowManager.zig        |   2 +
 river/WmNode.zig               |   6 +-
 river/XdgToplevel.zig          |   6 +-
 rivercompat/PointerBinding.zig |  13 ++-
 rivercompat/Seat.zig           |  13 ++-
 9 files changed, 355 insertions(+), 285 deletions(-)

diff --git a/river/Cursor.zig b/river/Cursor.zig
index 057f4dd..08e2d4d 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -64,9 +64,7 @@ const Mode = union(enum) {
         sx: f64,
         sy: f64,
     },
-    move: struct {
-        window: *Window,
-
+    op: struct {
         /// Window coordinates are stored as i32s as they are in logical pixels.
         /// However, it is possible to move the cursor by a fraction of a
         /// logical pixel and this happens in practice with low dpi, high
@@ -75,34 +73,6 @@ const Mode = union(enum) {
         /// motions to 0.
         delta_x: f64 = 0,
         delta_y: f64 = 0,
-
-        /// Offset from the left edge
-        offset_x: i32,
-        /// Offset from the top edge
-        offset_y: i32,
-    },
-    resize: struct {
-        window: *Window,
-
-        delta_x: f64 = 0,
-        delta_y: f64 = 0,
-
-        /// Total x/y movement of the pointer device since the start of the resize,
-        /// clamped to the bounds of the resize as defined by the window min/max
-        /// dimensions and output dimensions.
-        /// This is not directly tied to the rendered cursor position.
-        x: i32 = 0,
-        y: i32 = 0,
-
-        /// Resize edges, maximum of 2 are set and they may not be opposing edges.
-        edges: wlr.Edges,
-        /// Offset from the left or right edge
-        offset_x: i32,
-        /// Offset from the top or bottom edge
-        offset_y: i32,
-
-        initial_width: u31,
-        initial_height: u31,
     },
 };
 
@@ -116,12 +86,6 @@ const LayoutPoint = struct {
 /// Current cursor mode as well as any state needed to implement that mode
 mode: Mode = .passthrough,
 
-/// Set to whatever the current mode is when a transaction is started.
-/// This is necessary to handle termination of move/resize modes properly
-/// since the termination is not complete until a transaction completes and
-/// Window.resizeUpdatePosition() is called.
-inflight_mode: Mode = .passthrough,
-
 seat: *Seat,
 wlr_cursor: *wlr.Cursor,
 
@@ -315,6 +279,28 @@ fn clearFocus(cursor: *Cursor) void {
     cursor.seat.wlr_seat.pointerNotifyClearFocus();
 }
 
+pub fn startOpPointer(cursor: *Cursor) void {
+    if (cursor.constraint) |constraint| {
+        if (constraint.state == .active) constraint.deactivate();
+    }
+
+    log.debug("entering cursor mode op", .{});
+    cursor.mode = .{ .op = .{} };
+
+    cursor.clearFocus();
+}
+
+pub fn endOpPointer(cursor: *Cursor) void {
+    if (cursor.pressed.count() == 0) {
+        log.debug("entering cursor mode passthrough", .{});
+        cursor.mode = .passthrough;
+        cursor.updateState();
+    } else {
+        log.debug("entering cursor mode ignore", .{});
+        cursor.mode = .ignore;
+    }
+}
+
 pub fn processMotionRelative(cursor: *Cursor, event: *const wlr.Pointer.event.Motion) void {
     server.input_manager.relative_pointer_manager.sendRelativeMotion(
         cursor.seat.wlr_seat,
@@ -362,67 +348,14 @@ pub fn processMotionRelative(cursor: *Cursor, event: *const wlr.Pointer.event.Mo
                 constraint.maybeActivate();
             }
         },
-        .move => |*data| {
-            dx += data.delta_x;
-            dy += data.delta_y;
-            data.delta_x = dx - @trunc(dx);
-            data.delta_y = dy - @trunc(dy);
-
-            // XXX move window
-
-        },
-        .resize => |*data| {
+        .op => |*data| {
             dx += data.delta_x;
             dy += data.delta_y;
             data.delta_x = dx - @trunc(dx);
             data.delta_y = dy - @trunc(dy);
 
-            data.x += @intFromFloat(dx);
-            data.y += @intFromFloat(dy);
-
-            if (true) return; // XXX resize window
-
-            // Modify width/height of the pending box, taking constraints into account
-            // The x/y coordinates of the window will be adjusted as needed in Window.resizeCommit()
-            // based on the dimensions actually committed by the client.
-            const border_width = if (data.window.pending.ssd) server.config.border_width else 0;
-
-            // TODO
-            const output_width: i32 = 1920;
-            const output_height: i32 = 1080;
-
-            const constraints = &data.window.constraints;
-            const box = &data.window.pending.box;
-
-            if (data.edges.left) {
-                const x2 = box.x + box.width;
-                box.width = data.initial_width - data.x;
-                box.width = @max(box.width, constraints.min_width);
-                box.width = @min(box.width, constraints.max_width);
-                box.width = @min(box.width, x2 - border_width);
-                data.x = data.initial_width - box.width;
-            } else if (data.edges.right) {
-                box.width = data.initial_width + data.x;
-                box.width = @max(box.width, constraints.min_width);
-                box.width = @min(box.width, constraints.max_width);
-                box.width = @min(box.width, output_width - border_width - box.x);
-                data.x = box.width - data.initial_width;
-            }
-
-            if (data.edges.top) {
-                const y2 = box.y + box.height;
-                box.height = data.initial_height - data.y;
-                box.height = @max(box.height, constraints.min_height);
-                box.height = @min(box.height, constraints.max_height);
-                box.height = @min(box.height, y2 - border_width);
-                data.y = data.initial_height - box.height;
-            } else if (data.edges.bottom) {
-                box.height = data.initial_height + data.y;
-                box.height = @max(box.height, constraints.min_height);
-                box.height = @min(box.height, constraints.max_height);
-                box.height = @min(box.height, output_height - border_width - box.y);
-                data.y = box.height - data.initial_height;
-            }
+            cursor.wlr_cursor.move(event.device, dx, dy);
+            cursor.seat.updateOp(@intFromFloat(dx), @intFromFloat(dy));
         },
     }
 }
@@ -495,8 +428,8 @@ pub fn processButton(cursor: *Cursor, event: *const wlr.Pointer.event.Button) vo
             .down => {
                 _ = cursor.seat.wlr_seat.pointerNotifyButton(event.time_msec, event.button, event.state);
             },
-            // No client has pointer focus while in ignore/move/resize mode.
-            .ignore, .move, .resize => {},
+            // No client has pointer focus while in ignore/op mode.
+            .ignore, .op => {},
         }
     } else {
         assert(event.state == .released);
@@ -508,16 +441,17 @@ pub fn processButton(cursor: *Cursor, event: *const wlr.Pointer.event.Button) vo
 
             switch (cursor.mode) {
                 .passthrough => unreachable,
-                .down => {
-                    _ = cursor.seat.wlr_seat.pointerNotifyButton(event.time_msec, event.button, event.state);
+                .down, .ignore => {
+                    if (cursor.mode == .down) {
+                        _ = cursor.seat.wlr_seat.pointerNotifyButton(event.time_msec, event.button, event.state);
+                    }
+                    if (cursor.pressed.count() == 0) {
+                        log.debug("exiting cursor mode {s}", .{@tagName(cursor.mode)});
+                        cursor.mode = .passthrough;
+                        cursor.passthrough(event.time_msec);
+                    }
                 },
-                // No client has pointer focus while in ignore/move/resize mode.
-                .ignore, .move, .resize => {},
-            }
-            if (cursor.pressed.count() == 0) {
-                log.debug("exiting cursor mode {s}", .{@tagName(cursor.mode)});
-                cursor.mode = .passthrough;
-                cursor.passthrough(event.time_msec);
+                .op => {},
             }
         } else {
             log.err("ignoring duplicate pointer button {d} release", .{event.button});
@@ -707,19 +641,6 @@ fn handleTabletToolButton(
     tool.button(tablet, event);
 }
 
-pub fn startMove(cursor: *Cursor, window: *Window) void {
-    if (cursor.constraint) |constraint| {
-        if (constraint.state == .active) constraint.deactivate();
-    }
-
-    const new_mode: Mode = .{ .move = .{
-        .window = window,
-        .offset_x = @as(i32, @intFromFloat(cursor.wlr_cursor.x)) - window.current.box.x,
-        .offset_y = @as(i32, @intFromFloat(cursor.wlr_cursor.y)) - window.current.box.y,
-    } };
-    cursor.enterMode(new_mode, window, "move");
-}
-
 pub fn startResize(cursor: *Cursor, window: *Window, proposed_edges: ?wlr.Edges) void {
     if (cursor.constraint) |constraint| {
         if (constraint.state == .active) constraint.deactivate();
@@ -787,21 +708,6 @@ fn computeEdges(cursor: *const Cursor, window: *const Window) wlr.Edges {
     }
 }
 
-fn enterMode(cursor: *Cursor, mode: Mode, _: *Window, xcursor_name: [*:0]const u8) void {
-    assert(cursor.mode == .passthrough or cursor.mode == .down);
-    assert(mode == .move or mode == .resize);
-
-    log.debug("enter {s} cursor mode", .{@tagName(mode)});
-
-    cursor.mode = mode;
-
-    cursor.seat.wlr_seat.pointerNotifyClearFocus();
-    cursor.setXcursor(xcursor_name);
-}
-
-/// Handle potential change in location of windows on the output, as well as
-/// the target window of a cursor operation potentially being moved to a non-visible tag,
-/// becoming fullscreen, etc.
 pub fn updateState(cursor: *Cursor) void {
     if (cursor.constraint) |constraint| {
         constraint.updateState();
@@ -816,54 +722,7 @@ pub fn updateState(cursor: *Cursor) void {
             cursor.passthrough(msec);
         },
         // TODO: Leave down mode if the target surface is no longer visible.
-        .ignore, .down => {},
-        .move, .resize => {
-            // Moving and resizing of windows is handled through the transaction system. Therefore,
-            // we must inspect the inflight_mode instead if a move or a resize is in progress.
-            //
-            // The cases when a move/resize is being started or ended and e.g. mode is resize
-            // while inflight_mode is passthrough or mode is passthrough while inflight_mode
-            // is resize shouldn't need any special handling.
-            //
-            // In the first case, a move/resize has been started along with a transaction but the
-            // transaction hasn't been committed yet so there is nothing to do.
-            //
-            // In the second case, a move/resize has been terminated by the user but the
-            // transaction carrying out the final size/position change is still inflight.
-            // Therefore, the user already expects the cursor to be free from the window and
-            // we should not warp it back to the fixed offset of the move/resize.
-            switch (cursor.inflight_mode) {
-                .passthrough, .ignore, .down => {},
-                inline .move, .resize => |data, mode| {
-
-                    // These conditions are checked in WindowManager.dirtyPending()
-                    assert(!data.window.current.fullscreen);
-
-                    // Keep the cursor locked to the original offset from the edges of the window.
-                    const box = &data.window.current.box;
-                    const new_x: f64 = blk: {
-                        if (mode == .move or data.edges.left) {
-                            break :blk @floatFromInt(data.offset_x + box.x);
-                        } else if (data.edges.right) {
-                            break :blk @floatFromInt(box.x + box.width - data.offset_x);
-                        } else {
-                            break :blk cursor.wlr_cursor.x;
-                        }
-                    };
-                    const new_y: f64 = blk: {
-                        if (mode == .move or data.edges.top) {
-                            break :blk @floatFromInt(data.offset_y + box.y);
-                        } else if (data.edges.bottom) {
-                            break :blk @floatFromInt(box.y + box.height - data.offset_y);
-                        } else {
-                            break :blk cursor.wlr_cursor.y;
-                        }
-                    };
-
-                    cursor.wlr_cursor.warpClosest(null, new_x, new_y);
-                },
-            }
-        },
+        .ignore, .down, .op => {},
     }
 }
 
diff --git a/river/PointerConstraint.zig b/river/PointerConstraint.zig
index e193091..f460afd 100644
--- a/river/PointerConstraint.zig
+++ b/river/PointerConstraint.zig
@@ -76,7 +76,7 @@ pub fn maybeActivate(constraint: *PointerConstraint) void {
 
     if (constraint.state == .active) return;
 
-    if (seat.cursor.mode == .move or seat.cursor.mode == .resize) return;
+    if (seat.cursor.mode == .op) return;
 
     const result = server.scene.at(seat.cursor.wlr_cursor.x, seat.cursor.wlr_cursor.y) orelse return;
     if (result.surface != constraint.wlr_constraint.surface) return;
diff --git a/river/Seat.zig b/river/Seat.zig
index fe39cd2..679253c 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -74,10 +74,14 @@ pub const Event = union(enum) {
 
 pub const WmState = struct {
     focus: WmFocus = .none,
-    // TODO pointer move/resize state
+    op: union(enum) {
+        none,
+        //TODO start_serial: ?u32,
+        start_pointer,
+        end,
+    } = .none,
     // TODO confine region
     // TODO pointer warp
-    // TODO xkb/pointer bindings
 };
 
 pub const WmFocus = union(enum) {
@@ -142,6 +146,20 @@ pointer_bindings: wl.list.Head(PointerBinding, .link),
 /// Multiple physical mice are handled by the same Cursor
 cursor: Cursor,
 
+op: ?struct {
+    // We always want to process as many input events as possible before sending configures
+    // and starting a transaction. Therefore, we set this flag if a seat operation modifies
+    // pending state and check it at the end of Seat.processEvents() rather than calling
+    // WindowManager.dirtyPending() directly in Seat.updateOp().
+    dirty: bool = false,
+    input: enum {
+        pointer,
+    },
+    /// Coordinates of the cursor/touch point/etc. at the start of the operation.
+    start_x: i32,
+    start_y: i32,
+} = null,
+
 relay: InputRelay,
 
 keyboard_groups: std.TailQueue(KeyboardGroup) = .{},
@@ -202,6 +220,26 @@ pub fn destroy(seat: *Seat) void {
         while (it.next()) |device| assert(device.seat != seat);
     }
 
+    {
+        // Remove pointers to the seat before they become dangling
+        var it = server.wm.windows.iterator(.forward);
+        while (it.next()) |window| {
+            inline for (.{
+                &window.uncommitted,
+                &window.committed,
+                &window.inflight,
+                &window.current,
+            }) |state| {
+                switch (state.op) {
+                    .none => {},
+                    inline .move, .resize => |data| {
+                        if (data.seat == seat) state.op = .none;
+                    },
+                }
+            }
+        }
+    }
+
     seat.link.remove();
     seat.link_sent.remove();
 
@@ -260,6 +298,13 @@ pub fn processEvents(seat: *Seat) void {
             .pointer_pinch_end => |ev| pg.sendPinchEnd(seat.wlr_seat, ev.time_msec, ev.cancelled),
         }
     }
+
+    if (seat.op) |*op| {
+        if (op.dirty) {
+            server.wm.dirtyPending();
+            op.dirty = false;
+        }
+    }
 }
 
 pub fn sendDirty(seat: *Seat) void {
@@ -395,10 +440,16 @@ fn handleRequest(
         .clear_focus => seat.uncommitted.focus = .none,
 
         .op_start_serial => {},
-        .op_start_pointer => {},
-        .op_add_move_window => {},
+        .op_start_pointer => seat.uncommitted.op = .start_pointer,
+        .op_add_move_window => |args| {
+            const data = args.window.getUserData() orelse return;
+            const window: *Window = @ptrCast(@alignCast(data));
+            window.uncommitted.op = .{ .move = .{
+                .seat = seat,
+            } };
+        },
         .op_add_resize_window => {},
-        .op_end => {},
+        .op_end => seat.uncommitted.op = .end,
 
         .pointer_confine_to_region => {},
         .pointer_warp => {},
@@ -449,14 +500,71 @@ pub fn commitWmState(seat: *Seat) void {
     }
 
     seat.committed = seat.uncommitted;
+    seat.uncommitted.op = .none;
 }
 
 pub fn applyCommitted(seat: *Seat) void {
-    if (server.lock_manager.state == .unlocked) {
-        switch (seat.committed.focus) {
-            .none => seat.focus(.none),
-            .window => |window| seat.focus(.{ .window = window }),
-        }
+    if (server.lock_manager.state != .unlocked) return;
+
+    switch (seat.committed.focus) {
+        .none => seat.focus(.none),
+        .window => |window| seat.focus(.{ .window = window }),
+    }
+
+    switch (seat.committed.op) {
+        .none => {},
+        .start_pointer => if (seat.op == null) {
+            log.debug("start seat op pointer", .{});
+            seat.op = .{
+                .input = .pointer,
+                .start_x = @intFromFloat(seat.cursor.wlr_cursor.x),
+                .start_y = @intFromFloat(seat.cursor.wlr_cursor.y),
+            };
+            seat.cursor.startOpPointer();
+
+            {
+                var it = server.wm.windows.iterator(.forward);
+                while (it.next()) |window| {
+                    switch (window.committed.op) {
+                        .none => {},
+                        .move => |data| {
+                            if (data.seat == seat) {
+                                assert(window.inflight.op == .none);
+                                window.inflight.op = .{
+                                    .move = .{
+                                        .seat = seat,
+                                        .start_x = window.pending.box.x,
+                                        .start_y = window.pending.box.y,
+                                    },
+                                };
+                            }
+                        },
+                        .resize => {}, // TODO
+                    }
+                }
+            }
+        },
+        .end => if (seat.op) |op| {
+            log.debug("end seat op", .{});
+            seat.op = null;
+            switch (op.input) {
+                .pointer => seat.cursor.endOpPointer(),
+            }
+
+            {
+                var it = server.wm.windows.iterator(.forward);
+                while (it.next()) |window| {
+                    switch (window.inflight.op) {
+                        .none => {},
+                        inline .move, .resize => |data| {
+                            if (data.seat == seat) {
+                                window.inflight.op = .none;
+                            }
+                        },
+                    }
+                }
+            }
+        },
     }
 }
 
@@ -633,6 +741,28 @@ pub fn handleSwitchMapping(
     }
 }
 
+pub fn updateOp(seat: *Seat, dx: i32, dy: i32) void {
+    assert(seat.op != null);
+
+    {
+        var it = server.wm.windows.iterator(.forward);
+        while (it.next()) |window| {
+            switch (window.inflight.op) {
+                .none => {},
+                .move => |data| {
+                    if (data.seat != seat) continue;
+
+                    window.pending.box.x += dx;
+                    window.pending.box.y += dy;
+
+                    seat.op.?.dirty = true;
+                },
+                .resize => {}, // TODO
+            }
+        }
+    }
+}
+
 pub fn addDevice(seat: *Seat, wlr_device: *wlr.InputDevice) void {
     seat.tryAddDevice(wlr_device) catch |err| switch (err) {
         error.OutOfMemory => log.err("out of memory", .{}),
diff --git a/river/Window.zig b/river/Window.zig
index 46bcae4..5ad0f3e 100644
--- a/river/Window.zig
+++ b/river/Window.zig
@@ -78,13 +78,28 @@ pub const State = struct {
     capabilities: river.WindowV1.Capabilities = .{},
     maximized: bool = false,
     fullscreen: bool = false,
-    resizing: bool = false,
+
+    op: union(enum) {
+        none,
+        move: struct {
+            seat: *Seat,
+            start_x: i32,
+            start_y: i32,
+        },
+        resize: struct {
+            seat: *Seat,
+            edges: river.WindowV1.Edges = .{},
+            start_box: wlr.Box,
+        },
+    } = .none,
 };
 
 pub const WmState = struct {
-    x: i32 = 0,
-    y: i32 = 0,
-    proposed: ?struct {
+    position: ?struct {
+        x: i32,
+        y: i32,
+    } = null,
+    dimensions: ?struct {
         width: u31,
         height: u31,
     } = null,
@@ -101,6 +116,16 @@ pub const WmState = struct {
     maximized: bool = false,
     fullscreen: bool = false, // XXX output
     close: bool = false,
+    op: union(enum) {
+        none,
+        move: struct {
+            seat: *Seat,
+        },
+        resize: struct {
+            seat: *Seat,
+            edges: river.WindowV1.Edges = .{},
+        },
+    } = .none,
 };
 
 /// The window management protocol object for this window
@@ -142,8 +167,8 @@ pending: struct {
         /// Indicates that the closed event will be sent in the next update sequence.
         closing,
     } = .init,
-    dimensions_hint: DimensionsHint = .{},
     box: wlr.Box = .{ .x = 0, .y = 0, .width = 0, .height = 0 },
+    dimensions_hint: DimensionsHint = .{},
     decoration_hint: river.WindowV1.DecorationHint = .only_supports_csd,
     /// Set back to no_request at the end of each update sequence
     fullscreen_requested: enum {
@@ -158,8 +183,9 @@ pending: struct {
 /// This state is only kept around in order to avoid sending redundant events
 /// to the window manager client.
 sent: struct {
+    position: ?struct { x: i32, y: i32 } = null,
+    dimensions: ?struct { width: i32, height: i32 } = null,
     dimensions_hint: DimensionsHint = .{},
-    box: wlr.Box = .{ .x = 0, .y = 0, .width = 0, .height = 0 },
     decoration_hint: river.WindowV1.DecorationHint = .only_supports_csd,
 } = .{},
 
@@ -258,6 +284,15 @@ pub fn setDimensionsHint(window: *Window, hint: DimensionsHint) void {
     }
 }
 
+pub fn setPosition(window: *Window, x: i32, y: i32) void {
+    window.pending.box.x = x;
+    window.pending.box.y = y;
+
+    if (x != window.sent.box.x or y != window.sent.box.y) {
+        server.wm.dirtyPending();
+    }
+}
+
 pub fn setDimensions(window: *Window, width: i32, height: i32) void {
     window.pending.box.width = width;
     window.pending.box.height = height;
@@ -265,7 +300,9 @@ pub fn setDimensions(window: *Window, width: i32, height: i32) void {
     window.inflight.box.width = width;
     window.inflight.box.height = height;
 
-    if (width != window.sent.box.width or height != window.sent.box.height) {
+    if (window.sent.dimensions == null or
+        width != window.sent.dimensions.?.width or height != window.sent.dimensions.?.height)
+    {
         server.wm.dirtyPending();
     }
 }
@@ -334,6 +371,27 @@ pub fn sendDirty(window: *Window) void {
             const sent = &window.sent;
 
             // XXX send all dirty pending state
+            if (new or sent.position == null or
+                pending.box.x != sent.position.?.x or pending.box.y != sent.position.?.y)
+            {
+                if (window.node.object) |node_v1| {
+                    node_v1.sendPosition(pending.box.x, pending.box.y);
+                    sent.position = .{
+                        .x = pending.box.x,
+                        .y = pending.box.y,
+                    };
+                }
+            }
+            if (!pending.box.empty() and (new or sent.dimensions == null or
+                pending.box.width != sent.dimensions.?.width or
+                pending.box.height != sent.dimensions.?.height))
+            {
+                window_v1.sendDimensions(window.pending.box.width, window.pending.box.height);
+                sent.dimensions = .{
+                    .width = pending.box.width,
+                    .height = pending.box.height,
+                };
+            }
             if (new or !meta.eql(pending.dimensions_hint, sent.dimensions_hint)) {
                 window_v1.sendDimensionsHint(
                     pending.dimensions_hint.min_width,
@@ -343,13 +401,6 @@ pub fn sendDirty(window: *Window) void {
                 );
                 sent.dimensions_hint = pending.dimensions_hint;
             }
-            if ((new or pending.box.width != sent.box.width or
-                pending.box.height != sent.box.height) and !pending.box.empty())
-            {
-                window_v1.sendDimensions(window.pending.box.width, window.pending.box.height);
-                sent.box.width = pending.box.width;
-                sent.box.height = pending.box.height;
-            }
             if (new or pending.decoration_hint != sent.decoration_hint) {
                 window_v1.sendDecorationHint(window.pending.decoration_hint);
                 sent.decoration_hint = pending.decoration_hint;
@@ -400,7 +451,7 @@ fn handleRequest(
             if (args.width < 0 or args.height < 0) {
                 // XXX send protocol error
             }
-            uncommitted.proposed = .{
+            uncommitted.dimensions = .{
                 .width = @intCast(args.width),
                 .height = @intCast(args.height),
             };
@@ -433,14 +484,13 @@ fn handleRequest(
 }
 
 pub fn commitWmState(window: *Window) void {
-    if (!window.initialized and window.uncommitted.proposed != null) {
+    if (!window.initialized and window.uncommitted.dimensions != null) {
         window.initialized = true;
     }
 
     window.committed = .{
-        .x = window.uncommitted.x,
-        .y = window.uncommitted.y,
-        .proposed = window.uncommitted.proposed orelse window.committed.proposed,
+        .position = window.uncommitted.position orelse window.committed.position,
+        .dimensions = window.uncommitted.dimensions orelse window.committed.dimensions,
         .hidden = window.uncommitted.hidden,
         .ssd = window.uncommitted.ssd,
         .border = window.uncommitted.border,
@@ -449,8 +499,83 @@ pub fn commitWmState(window: *Window) void {
         .maximized = window.uncommitted.maximized,
         .fullscreen = window.uncommitted.fullscreen,
         .close = window.uncommitted.close,
+        .op = window.uncommitted.op,
+    };
+    window.uncommitted.position = null;
+    window.uncommitted.dimensions = null;
+    window.uncommitted.op = .none;
+}
+
+/// Applies committed state from the window manager client to the inflight state.
+/// Returns true if the configure should be waited for by the transaction system.
+pub fn configure(window: *Window) bool {
+    if (!window.initialized) return false;
+
+    assert(!window.destroying);
+
+    if (window.committed.close) {
+        window.close();
+    }
+
+    const activated = blk: {
+        var it = server.wm.sent.seats.iterator(.forward);
+        while (it.next()) |seat| {
+            if (seat.committed.focus == .window and seat.committed.focus.window == window) {
+                break :blk true;
+            }
+        }
+        break :blk false;
+    };
+
+    const committed = &window.committed;
+
+    if (window.inflight.op == .none) {
+        if (committed.position) |position| {
+            window.pending.box.x = position.x;
+            window.pending.box.y = position.y;
+        }
+        if (committed.dimensions) |dimensions| {
+            window.pending.box.width = dimensions.width;
+            window.pending.box.height = dimensions.height;
+        }
+    }
+    window.inflight = .{
+        .box = window.pending.box,
+        .hidden = committed.hidden,
+        .activated = activated,
+        .ssd = committed.ssd,
+        .border = committed.border,
+        .tiled = committed.tiled,
+        .capabilities = committed.capabilities,
+        .maximized = committed.maximized,
+        .fullscreen = committed.fullscreen,
+        .op = window.inflight.op,
+    };
+
+    const track_configure = switch (window.impl) {
+        .toplevel => |*toplevel| toplevel.configure(committed.dimensions != null),
+        .xwayland => |*xwindow| xwindow.configure(),
+        .none => unreachable,
     };
-    window.uncommitted.proposed = null;
+
+    // Ensure a position/dimension event is sent if the window manager has
+    // modified them even if the actual position/dimensions do not change.
+    if (committed.position != null) {
+        window.sent.position = null;
+        committed.position = null;
+    }
+    if (committed.dimensions != null) {
+        window.sent.dimensions = null;
+        committed.dimensions = null;
+    }
+    committed.op = .none;
+
+    if (track_configure and window.mapped) {
+        window.saveSurfaceTree();
+        window.sendFrameDone();
+    }
+
+    return track_configure;
 }
 
 /// The change in x/y position of the window during resize cannot be determined
@@ -517,7 +642,7 @@ pub fn commitTransaction(window: *Window) void {
                     // If we did not use the current geometry of the toplevel at this point
                     // we would be rendering the SSD border at initial size X but the surface
                     // would be rendered at size Y.
-                    if (window.inflight.resizing) {
+                    if (false and window.inflight.resizing) {
                         window.resizeUpdatePosition(toplevel.geometry.width, toplevel.geometry.height);
                     }
 
@@ -602,68 +727,6 @@ pub fn updateSceneState(window: *Window) void {
     }
 }
 
-/// Applies committed state from the window manager client to the inflight state.
-/// Returns true if the configure should be waited for by the transaction system.
-pub fn configure(window: *Window) bool {
-    if (!window.initialized) return false;
-
-    assert(!window.destroying);
-
-    if (window.committed.close) {
-        window.close();
-    }
-
-    const activated = blk: {
-        var it = server.wm.sent.seats.iterator(.forward);
-        while (it.next()) |seat| {
-            if (seat.committed.focus == .window and seat.committed.focus.window == window) {
-                break :blk true;
-            }
-        }
-        break :blk false;
-    };
-
-    const committed = &window.committed;
-    window.inflight = .{
-        .box = .{
-            .x = committed.x,
-            .y = committed.y,
-            .width = if (committed.proposed) |p| p.width else window.pending.box.width,
-            .height = if (committed.proposed) |p| p.height else window.pending.box.height,
-        },
-        .hidden = committed.hidden,
-        .activated = activated,
-        .ssd = committed.ssd,
-        .border = committed.border,
-        .tiled = committed.tiled,
-        .capabilities = committed.capabilities,
-        .maximized = committed.maximized,
-        .fullscreen = committed.fullscreen,
-        .resizing = false, // XXX
-    };
-
-    const track_configure = switch (window.impl) {
-        .toplevel => |*toplevel| toplevel.configure(committed.proposed != null),
-        .xwayland => |*xwindow| xwindow.configure(),
-        .none => unreachable,
-    };
-
-    // Ensure a dimensions event is sent if the window manager has proposed dimensions
-    // even if the actual dimensions commited by the window do not change.
-    if (committed.proposed != null) {
-        window.sent.box.width = 0;
-        window.sent.box.height = 0;
-        committed.proposed = null;
-    }
-
-    if (track_configure and window.mapped) {
-        window.saveSurfaceTree();
-        window.sendFrameDone();
-    }
-
-    return track_configure;
-}
-
 /// Returns null if the window is currently being destroyed and no longer has
 /// an associated surface.
 /// May also return null for Xwayland windows that are not currently mapped.
diff --git a/river/WindowManager.zig b/river/WindowManager.zig
index 6502851..27db7d5 100644
--- a/river/WindowManager.zig
+++ b/river/WindowManager.zig
@@ -293,6 +293,8 @@ fn sendConfigures(wm: *WindowManager) void {
     wm.committed.dirty = false;
 
     {
+        // Order is important here, Seat.applyCommitted() must be called
+        // before configures are sent.
         var it = wm.sent.seats.iterator(.forward);
         while (it.next()) |seat| seat.applyCommitted();
     }
diff --git a/river/WmNode.zig b/river/WmNode.zig
index a2170bd..58dc7f1 100644
--- a/river/WmNode.zig
+++ b/river/WmNode.zig
@@ -106,8 +106,10 @@ fn handleRequest(
         },
         .set_position => |args| switch (node.get()) {
             .window => |window| {
-                window.uncommitted.x = args.x;
-                window.uncommitted.y = args.y;
+                window.uncommitted.position = .{
+                    .x = args.x,
+                    .y = args.y,
+                };
             },
         },
         .place_top => {
diff --git a/river/XdgToplevel.zig b/river/XdgToplevel.zig
index 2f58450..769993a 100644
--- a/river/XdgToplevel.zig
+++ b/river/XdgToplevel.zig
@@ -165,7 +165,7 @@ pub fn configure(toplevel: *XdgToplevel, force: bool) bool {
     });
     _ = wlr_toplevel.setMaximized(inflight.maximized);
     _ = wlr_toplevel.setFullscreen(inflight.fullscreen);
-    _ = wlr_toplevel.setResizing(inflight.resizing);
+    _ = wlr_toplevel.setResizing(inflight.op == .resize);
 
     if (toplevel.decoration) |decoration| {
         _ = decoration.wlr_decoration.setMode(if (inflight.ssd) .server_side else .client_side);
@@ -214,7 +214,7 @@ fn needsConfigure(toplevel: *XdgToplevel) bool {
     if (!std.meta.eql(inflight.capabilities, current.capabilities)) return true;
     if (inflight.maximized != current.maximized) return true;
     if (inflight.fullscreen != current.fullscreen) return true;
-    if (inflight.resizing != current.resizing) return true;
+    if ((inflight.op == .resize) != (current.op == .resize)) return true;
 
     return false;
 }
@@ -362,7 +362,7 @@ fn handleCommit(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
         .acked, .timed_out_acked => {
             toplevel.wlr_toplevel.base.getGeometry(&toplevel.geometry);
 
-            if (window.inflight.resizing) {
+            if (false and window.inflight.resizing) {
                 window.resizeUpdatePosition(toplevel.geometry.width, toplevel.geometry.height);
             }
 
diff --git a/rivercompat/PointerBinding.zig b/rivercompat/PointerBinding.zig
index 2d51a3b..6779753 100644
--- a/rivercompat/PointerBinding.zig
+++ b/rivercompat/PointerBinding.zig
@@ -29,20 +29,23 @@ const gpa = std.heap.c_allocator;
 
 seat: *Seat,
 pointer_binding_v1: *river.PointerBindingV1,
-action: Seat.Action,
+press_action: Seat.Action,
+release_action: ?Seat.Action,
 
 pub fn create(
     seat: *Seat,
     button: u32,
     modifiers: river.SeatV1.Modifiers,
-    action: Seat.Action,
+    press_action: Seat.Action,
+    release_action: ?Seat.Action,
 ) void {
     const pointer_binding_v1 = seat.seat_v1.getPointerBinding(button, modifiers) catch @panic("OOM");
     const binding = gpa.create(PointerBinding) catch @panic("OOM");
     binding.* = .{
         .seat = seat,
         .pointer_binding_v1 = pointer_binding_v1,
-        .action = action,
+        .press_action = press_action,
+        .release_action = release_action,
     };
     pointer_binding_v1.setListener(*PointerBinding, handleEvent, binding);
     pointer_binding_v1.enable();
@@ -51,7 +54,7 @@ pub fn create(
 fn handleEvent(pointer_binding_v1: *river.PointerBindingV1, event: river.PointerBindingV1.Event, binding: *PointerBinding) void {
     assert(binding.pointer_binding_v1 == pointer_binding_v1);
     switch (event) {
-        .pressed => binding.seat.execute(binding.action),
-        .released => {},
+        .pressed => binding.seat.execute(binding.press_action),
+        .released => if (binding.release_action) |a| binding.seat.execute(a),
     }
 }
diff --git a/rivercompat/Seat.zig b/rivercompat/Seat.zig
index 938414a..8439975 100644
--- a/rivercompat/Seat.zig
+++ b/rivercompat/Seat.zig
@@ -51,7 +51,8 @@ pub fn create(wm: *WindowManager, seat_v1: *river.SeatV1) void {
     XkbBinding.create(seat, xkb.Keysym.n, .{ .mod4 = true }, .focus_next);
     XkbBinding.create(seat, xkb.Keysym.h, .{ .mod4 = true }, .hide_focused);
     XkbBinding.create(seat, xkb.Keysym.s, .{ .mod4 = true }, .show_all);
-    PointerBinding.create(seat, c.BTN_RIGHT, .{ .mod4 = true }, .close_focused);
+    PointerBinding.create(seat, c.BTN_LEFT, .{ .mod4 = true }, .move_start, .move_end);
+    PointerBinding.create(seat, c.BTN_MIDDLE, .{ .mod4 = true }, .close_focused, null);
 }
 
 pub fn focus(seat: *Seat, target: ?*Window) void {
@@ -101,6 +102,8 @@ pub const Action = enum {
     close_focused,
     hide_focused,
     show_all,
+    move_start,
+    move_end,
 };
 
 pub fn execute(seat: *Seat, action: Action) void {
@@ -114,5 +117,13 @@ pub fn execute(seat: *Seat, action: Action) void {
                 window.window_v1.show();
             }
         },
+        .move_start => {
+            seat.seat_v1.opStartPointer();
+            var it = seat.wm.windows.iterator(.forward);
+            while (it.next()) |window| {
+                seat.seat_v1.opAddMoveWindow(window.window_v1);
+            }
+        },
+        .move_end => seat.seat_v1.opEnd(),
     }
 }