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

commitd53737e6568b88b2596c0ba753e7a19c6291cfef
parente29c57f349
authorIsaac Freund <[email protected]>
date2025-07-28 22:55
rwm: rework window-initiated interactive move/resize

And implement the now sensible requests/events in river.

 protocol/river-window-management-v1.xml | 75 +++++++++++++++------------------
 river/Cursor.zig                        | 13 ++++--
 river/Seat.zig                          | 41 +++++++++++-------
 river/Window.zig                        | 52 ++++++++++++++++-------
 river/XdgToplevel.zig                   | 11 +++--
 rivercompat/Seat.zig                    |  1 +
 rivercompat/Window.zig                  |  4 +-
 7 files changed, 113 insertions(+), 84 deletions(-)

diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 345f570..112c3b3 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -659,33 +659,30 @@
       <arg name="surface" type="object" interface="wl_surface"/>
     </request>
 
-    <event name="move_requested">
-      <description summary="the window requested interactive move">
+    <event name="pointer_move_requested">
+      <description summary="window requested interactive pointer move">
         This event informs the window manager that the window has requested to
-        be interactively moved using the pointer. The seat argument indicates
-        the seat for the move and the serial argument identifies the input event
-        (e.g. pointer button press or touch) that started the move.
+        be interactively moved using the pointer. The seat argument indicates the
+        seat for the move.
 
         The xdg-shell protocol for example allows windows to request that an
         interactive move be started, perhaps when a client-side rendered
         titlebar is dragged.
 
-        The window manager may use the river_seat_v1.op_start_serial request to
+        The window manager may use the river_seat_v1.op_start_pointer request to
         interactively move the window or ignore this event entirely.
 
         This event will be followed by a manage_start event after all other new
         state has been sent by the server.
       </description>
       <arg name="seat" type="object" interface="river_seat_v1"/>
-      <arg name="serial" type="uint"/>
     </event>
 
-    <event name="resize_requested">
-      <description summary="the window requested interactive resize">
+    <event name="pointer_resize_requested">
+      <description summary="window requested interactive pointer resize">
         This event informs the window manager that the window has requested to
         be interactively resized using the pointer. The seat argument indicates
-        the seat for the resize and the serial argument identifies the input
-        event (e.g. pointer button press or touch) that started the resize.
+        the seat for the resize.
 
         The edges argument indicates which edges the window has requested to be
         resized from. The edges argument will never be none and will never have
@@ -695,14 +692,13 @@
         interactive resize be started, perhaps when the corner of client-side
         rendered decorations is dragged.
 
-        The window manager may use the river_seat_v1.op_start_serial request to
+        The window manager may use the river_seat_v1.op_start_pointer request to
         interactively resize the window or ignore this event entirely.
 
         This event will be followed by a manage_start event after all other new
         state has been sent by the server.
       </description>
       <arg name="seat" type="object" interface="river_seat_v1"/>
-      <arg name="serial" type="uint"/>
       <arg name="edges" type="uint" enum="edges"/>
     </event>
 
@@ -1417,44 +1413,21 @@
       <arg name="shell_surface" type="object" interface="river_shell_surface_v1"/>
     </event>
 
-    <request name="op_start_serial">
-      <description summary="start an interactive operation with a serial">
-        Start an interactive seat operation with a serial from either the
-        river_window_v1.move_requested or river_window_v1.resize_requested
-        event.
-
-        During the operation, op_delta events will be sent based on input
-        corresponding to the provided serial (e.g. pointer or touch input).
-
-        The window manager may use this operation to implement interactive
-        move/resize of windows by setting the position of windows and proposing
-        dimensions based off of the op_delta events.
-
-        The operation continues until the pointer button, touch point or similar
-        corresponding to the given serial is released or the op_end request is
-        made and applied during a manage sequence.
-
-        This request is ignored if an operation is already in progress for a
-        given river_seat_v1.
-
-        This request modifies window management state and may only be made as
-        part of a manage sequence, see the river_window_manager_v1 description.
-      </description>
-      <arg name="serial" type="uint"/>
-    </request>
-
     <request name="op_start_pointer">
       <description summary="start an interactive pointer operation">
         Start an interactive pointer operation. During the operation, op_delta
         events will be sent based on pointer input.
 
+        When all pointer buttons are released, the op_release event is sent.
+
+        The pointer operation continues until the op_end request is made during
+        a manage sequence and that manage sequence is finished.
+
         The window manager may use this operation to implement interactive
         move/resize of windows by setting the position of windows and proposing
         dimensions based off of the op_delta events.
 
-        The pointer operation continues until the op_end request is made during
-        a manage sequence and that manage sequence is finished. This request is
-        ignored if an operation is already in progress.
+        This request is ignored if an operation is already in progress.
 
         This request modifies window management state and may only be made as
         part of a manage sequence, see the river_window_manager_v1 description.
@@ -1465,11 +1438,29 @@
       <description summary="total cumulative motion since op start">
         This event indicates the total change in position since the start of the
         operation of the pointer/touch point/etc.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
       </description>
       <arg name="dx" type="int" summary="total change in x"/>
       <arg name="dy" type="int" summary="total change in y"/>
     </event>
 
+    <event name="op_release">
+      <description summary="operation input has been released">
+        The input driving the current interactive operation has been released.
+        For a pointer op for example, all pointer buttons have been released.
+
+        Depending on the op type, op_delta events may continue to be sent until
+        the op is ended with the op_end request.
+
+        This event is sent at most once during an interactive operation.
+
+        This event will be followed by a manage_start event after all other new
+        state has been sent by the server.
+      </description>
+    </event>
+
     <request name="op_end">
       <description summary="end an interactive operation">
         End an interactive operation.
diff --git a/river/Cursor.zig b/river/Cursor.zig
index 446e31b..e4446df 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -285,7 +285,7 @@ fn clearFocus(cursor: *Cursor) void {
     cursor.seat.wlr_seat.pointerNotifyClearFocus();
 }
 
-pub fn startOpPointer(cursor: *Cursor) void {
+pub fn opStartPointer(cursor: *Cursor) void {
     if (cursor.constraint) |constraint| {
         if (constraint.state == .active) constraint.deactivate();
     }
@@ -296,7 +296,7 @@ pub fn startOpPointer(cursor: *Cursor) void {
     cursor.clearFocus();
 }
 
-pub fn endOpPointer(cursor: *Cursor) void {
+pub fn opEndPointer(cursor: *Cursor) void {
     if (cursor.pressed.count() == 0) {
         log.debug("entering cursor mode passthrough", .{});
         cursor.mode = .passthrough;
@@ -362,7 +362,7 @@ pub fn processMotionRelative(cursor: *Cursor, event: *const wlr.Pointer.event.Mo
             data.delta_y = dy - @trunc(dy);
 
             cursor.wlr_cursor.move(event.device, dx, dy);
-            cursor.seat.updateOp(@intFromFloat(cursor.wlr_cursor.x), @intFromFloat(cursor.wlr_cursor.y));
+            cursor.seat.opUpdate(@intFromFloat(cursor.wlr_cursor.x), @intFromFloat(cursor.wlr_cursor.y));
         },
     }
 }
@@ -494,7 +494,12 @@ pub fn processButton(cursor: *Cursor, event: *const wlr.Pointer.event.Button) vo
                         cursor.passthrough(event.time_msec);
                     }
                 },
-                .op => {},
+                .op => {
+                    if (cursor.pressed.count() == 0) {
+                        cursor.seat.wm_scheduled.op_release = true;
+                        server.wm.dirtyWindowing();
+                    }
+                },
             }
         } else {
             log.err("ignoring duplicate pointer button {d} release", .{event.button});
diff --git a/river/Seat.zig b/river/Seat.zig
index b17bce1..5e9bd33 100644
--- a/river/Seat.zig
+++ b/river/Seat.zig
@@ -121,6 +121,7 @@ wm_scheduled: struct {
     window: ?*Window = null,
     /// The window clicked on, touched, etc.
     interaction: WmFocus = .none,
+    op_release: bool = false,
 } = .{},
 
 /// State sent to the wm in the latest manage sequence.
@@ -135,7 +136,6 @@ wm_requested: struct {
     focus: WmFocus = .none,
     op: union(enum) {
         none,
-        //TODO start_serial: ?u32,
         start_pointer,
         end,
     } = .none,
@@ -151,6 +151,7 @@ cursor: Cursor,
 
 op: ?struct {
     dirty: bool = false,
+    sent_release: bool = false,
     input: enum {
         pointer,
     },
@@ -356,8 +357,14 @@ pub fn manageStart(seat: *Seat) void {
             },
         }
 
-        if (seat.op) |op| {
+        if (seat.op) |*op| {
             seat_v1.sendOpDelta(op.x - op.start_x, op.y - op.start_y);
+
+            if (seat.wm_scheduled.op_release and !op.sent_release) {
+                seat_v1.sendOpRelease();
+                seat.wm_scheduled.op_release = false;
+                op.sent_release = true;
+            }
         }
 
         {
@@ -411,6 +418,7 @@ fn handleRequestInert(
 
 fn handleDestroy(_: *river.SeatV1, seat: *Seat) void {
     seat.object = null;
+    seat.opEnd();
 }
 
 fn handleRequest(
@@ -438,10 +446,6 @@ fn handleRequest(
         },
         .clear_focus => seat.wm_requested.focus = .none,
 
-        .op_start_serial => {
-            if (!server.wm.ensureWindowing()) return;
-            // TODO
-        },
         .op_start_pointer => {
             if (!server.wm.ensureWindowing()) return;
             seat.wm_requested.op = .start_pointer;
@@ -496,7 +500,8 @@ pub fn manageFinish(seat: *Seat) void {
 
     switch (seat.wm_requested.op) {
         .none => {},
-        .start_pointer => if (seat.op == null) {
+        .start_pointer,
+        => if (seat.op == null) {
             log.debug("start seat op pointer", .{});
             seat.op = .{
                 .input = .pointer,
@@ -505,15 +510,9 @@ pub fn manageFinish(seat: *Seat) void {
                 .x = @intFromFloat(seat.cursor.wlr_cursor.x),
                 .y = @intFromFloat(seat.cursor.wlr_cursor.y),
             };
-            seat.cursor.startOpPointer();
-        },
-        .end => if (seat.op) |op| {
-            log.debug("end seat op", .{});
-            seat.op = null;
-            switch (op.input) {
-                .pointer => seat.cursor.endOpPointer(),
-            }
+            seat.cursor.opStartPointer();
         },
+        .end => seat.opEnd(),
     }
     seat.wm_requested.op = .none;
 }
@@ -689,13 +688,23 @@ pub fn handleSwitchMapping(
     }
 }
 
-pub fn updateOp(seat: *Seat, x: i32, y: i32) void {
+pub fn opUpdate(seat: *Seat, x: i32, y: i32) void {
     const op = &seat.op.?;
     op.x = x;
     op.y = y;
     op.dirty = true;
 }
 
+pub fn opEnd(seat: *Seat) void {
+    if (seat.op) |op| {
+        log.debug("end seat op", .{});
+        seat.op = null;
+        switch (op.input) {
+            .pointer => seat.cursor.opEndPointer(),
+        }
+    }
+}
+
 pub fn addDevice(seat: *Seat, wlr_device: *wlr.InputDevice, virtual: bool) void {
     seat.tryAddDevice(wlr_device, virtual) catch |err| switch (err) {
         error.OutOfMemory => log.err("out of memory", .{}),
diff --git a/river/Window.zig b/river/Window.zig
index 6ce8e6a..03b293f 100644
--- a/river/Window.zig
+++ b/river/Window.zig
@@ -145,6 +145,11 @@ wm_scheduled: struct {
     fullscreen_requested: FullscreenRequest = .no_request,
     dirty_app_id: bool = false,
     dirty_title: bool = false,
+    pointer_move_requested: ?*Seat = null,
+    pointer_resize_requested: ?struct {
+        seat: *Seat,
+        edges: river.WindowV1.Edges,
+    } = null,
 } = .{},
 
 /// State sent to the wm in the latest manage sequence.
@@ -364,24 +369,24 @@ pub fn manageStart(window: *Window) void {
             };
             errdefer comptime unreachable;
 
-            const pending = &window.wm_scheduled;
+            const scheduled = &window.wm_scheduled;
             const sent = &window.wm_sent;
 
-            // XXX send all dirty pending state
-            if (new or !meta.eql(pending.dimensions_hint, sent.dimensions_hint)) {
+            // XXX send all dirty scheduled state
+            if (new or !meta.eql(scheduled.dimensions_hint, sent.dimensions_hint)) {
                 window_v1.sendDimensionsHint(
-                    pending.dimensions_hint.min_width,
-                    pending.dimensions_hint.min_height,
-                    pending.dimensions_hint.max_width,
-                    pending.dimensions_hint.max_height,
+                    scheduled.dimensions_hint.min_width,
+                    scheduled.dimensions_hint.min_height,
+                    scheduled.dimensions_hint.max_width,
+                    scheduled.dimensions_hint.max_height,
                 );
-                sent.dimensions_hint = pending.dimensions_hint;
+                sent.dimensions_hint = scheduled.dimensions_hint;
             }
-            if (new or pending.decoration_hint != sent.decoration_hint) {
+            if (new or scheduled.decoration_hint != sent.decoration_hint) {
                 window_v1.sendDecorationHint(window.wm_scheduled.decoration_hint);
-                sent.decoration_hint = pending.decoration_hint;
+                sent.decoration_hint = scheduled.decoration_hint;
             }
-            switch (pending.fullscreen_requested) {
+            switch (scheduled.fullscreen_requested) {
                 .no_request => {},
                 .fullscreen => |output_hint| {
                     if (output_hint) |output| {
@@ -392,16 +397,31 @@ pub fn manageStart(window: *Window) void {
                 },
                 .exit => window_v1.sendExitFullscreenRequested(),
             }
-            pending.fullscreen_requested = .no_request;
+            scheduled.fullscreen_requested = .no_request;
 
-            if (new or pending.dirty_app_id) {
+            if (new or scheduled.dirty_app_id) {
                 window_v1.sendAppId(window.getAppId());
-                pending.dirty_app_id = false;
+                scheduled.dirty_app_id = false;
             }
-            if (new or pending.dirty_title) {
+            if (new or scheduled.dirty_title) {
                 window_v1.sendTitle(window.getTitle());
-                pending.dirty_title = false;
+                scheduled.dirty_title = false;
+            }
+
+            if (scheduled.pointer_move_requested) |seat| {
+                if (seat.object) |seat_v1| {
+                    log.debug("send pointer move requested", .{});
+                    window_v1.sendPointerMoveRequested(seat_v1);
+                }
+            }
+            scheduled.pointer_move_requested = null;
+            if (scheduled.pointer_resize_requested) |data| {
+                if (data.seat.object) |seat_v1| {
+                    log.debug("send pointer resize requested", .{});
+                    window_v1.sendPointerResizeRequested(seat_v1, data.edges);
+                }
             }
+            scheduled.pointer_resize_requested = null;
         },
     }
 }
diff --git a/river/XdgToplevel.zig b/river/XdgToplevel.zig
index a9259c8..a93aa87 100644
--- a/river/XdgToplevel.zig
+++ b/river/XdgToplevel.zig
@@ -383,23 +383,26 @@ fn handleRequestMove(
     event: *wlr.XdgToplevel.event.Move,
 ) void {
     const toplevel: *XdgToplevel = @fieldParentPtr("request_move", listener);
-    _ = toplevel;
     const seat: *Seat = @alignCast(@ptrCast(event.seat.seat.data));
 
     // Moving windows with touch or tablet tool is not yet supported.
     if (seat.wlr_seat.validatePointerGrabSerial(null, event.serial)) {
-        // XXX queue pointer_move_requested, dirtyWindowing()
+        toplevel.window.wm_scheduled.pointer_move_requested = seat;
+        server.wm.dirtyWindowing();
     }
 }
 
 fn handleRequestResize(listener: *wl.Listener(*wlr.XdgToplevel.event.Resize), event: *wlr.XdgToplevel.event.Resize) void {
     const toplevel: *XdgToplevel = @fieldParentPtr("request_resize", listener);
-    _ = toplevel;
     const seat: *Seat = @alignCast(@ptrCast(event.seat.seat.data));
 
     // Resizing windows with touch or tablet tool is not yet supported.
     if (seat.wlr_seat.validatePointerGrabSerial(null, event.serial)) {
-        // XXX queue pointer_resize_requested, dirtyWindowing()
+        toplevel.window.wm_scheduled.pointer_resize_requested = .{
+            .seat = seat,
+            .edges = @bitCast(event.edges),
+        };
+        server.wm.dirtyWindowing();
     }
 }
 
diff --git a/rivercompat/Seat.zig b/rivercompat/Seat.zig
index b9493c0..9571acb 100644
--- a/rivercompat/Seat.zig
+++ b/rivercompat/Seat.zig
@@ -83,6 +83,7 @@ fn handleEvent(seat_v1: *river.SeatV1, event: river.SeatV1.Event, seat: *Seat) v
             seat.op.?.dx = args.dx;
             seat.op.?.dy = args.dy;
         },
+        .op_release => {},
         .pointer_activity => {},
         .window_interaction => |args| {
             const window_v1 = args.window orelse return;
diff --git a/rivercompat/Window.zig b/rivercompat/Window.zig
index 94274c8..2fd8c66 100644
--- a/rivercompat/Window.zig
+++ b/rivercompat/Window.zig
@@ -111,8 +111,8 @@ fn handleEvent(window_v1: *river.WindowV1, event: river.WindowV1.Event, window:
         .title => {},
         .parent => {},
         .decoration_hint => {},
-        .move_requested => {},
-        .resize_requested => {},
+        .pointer_move_requested => {},
+        .pointer_resize_requested => {},
         .show_window_menu_requested => {},
         .maximize_requested => {},
         .unmaximize_requested => {},