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

commit006f8b6164dc9a96afa66504b309d3d7c3aa5088
parent4f3d97b718
authorIsaac Freund <[email protected]>
date2026-02-11 11:45
protocol: allow wm to set pointer cursor

This seems to be the simplest way to give window managers control over
the cursor. It's quite nice to avoid a direct dependency on the
cursor-shape-v1 protocol and also avoid copy-pasting the shape enum.

We can add a river_seat_v1.sync_next_pointer_cursor_change request to
synchronize this with rendering state in the future if deemed necessary.

 protocol/river-window-management-v1.xml |  17 +++++
 river/Cursor.zig                        | 109 ++++++++++++++++++++++++++------
 river/Server.zig                        |  24 ++++---
 3 files changed, 122 insertions(+), 28 deletions(-)

diff --git a/protocol/river-window-management-v1.xml b/protocol/river-window-management-v1.xml
index 9ea4539..72a9931 100644
--- a/protocol/river-window-management-v1.xml
+++ b/protocol/river-window-management-v1.xml
@@ -1390,6 +1390,18 @@
       high-level information about pointer input, define pointer bindings, etc.
 
       For keyboard bindings, see the river-xkb-bindings-v1 protocol.
+
+      Since version 4: The cursor surface/shape set by the window manager on the
+      wl_pointer of this seat is used when no client has pointer focus, for
+      example during a pointer operation. Since the window manager is allowed to
+      set cursor surface/shape even when it does not have pointer focus, the
+      compositor must ignore the serial argument of wl_pointer.set_cursor and
+      wp_cursor_shape_device_v1.set_shape requests made by the window manager.
+
+      The most recent cursor surface/shape set by the window manager is
+      remembered by the compositor and restored whenever no client has pointer
+      focus. If the window manager never sets a cursor surface/shape, the
+      "default" shape is used.
     </description>
 
     <request name="destroy" type="destructor">
@@ -1565,6 +1577,11 @@
 
         This request is ignored if an operation is already in progress.
 
+        The compositor must ensure that no client has pointer focus during the
+        pointer operation. This means that the window manager has control
+        over the pointer's cursor surface/shape during the pointer operation.
+        See the river_seat_v1 description.
+
         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>
diff --git a/river/Cursor.zig b/river/Cursor.zig
index e7b897c..0266771 100644
--- a/river/Cursor.zig
+++ b/river/Cursor.zig
@@ -65,6 +65,19 @@ const Mode = union(enum) {
 
 const default_size = 24;
 
+const Image = union(enum) {
+    /// No cursor image
+    none,
+    /// Name of the current Xcursor shape
+    xcursor: [*:0]const u8,
+    /// Cursor surface configured by the client
+    client: struct {
+        surface: *wlr.Surface,
+        hotspot_x: i32,
+        hotspot_y: i32,
+    },
+};
+
 const LayoutPoint = struct {
     lx: f64,
     ly: f64,
@@ -78,9 +91,12 @@ wlr_cursor: *wlr.Cursor,
 
 /// Xcursor manager for the currently configured Xcursor theme.
 xcursor_manager: *wlr.XcursorManager,
-/// Name of the current Xcursor shape, or null if a client has configured a
-/// surface to be used as the cursor shape instead.
-xcursor_name: ?[*:0]const u8 = null,
+/// The currently rendered cursor image
+image: Image = .none,
+image_surface_destroy: wl.Listener(*wlr.Surface) = .init(handleImageSurfaceDestroy),
+/// The most recent cursor image set by the window manager client
+wm_image: Image = .{ .xcursor = "default" },
+wm_image_surface_destroy: wl.Listener(*wlr.Surface) = .init(handleWmImageSurfaceDestroy),
 
 /// The set of currently pressed pointer buttons and the corresponding pointer mapping if any.
 pressed: std.AutoHashMapUnmanaged(u32, ?*PointerBinding) = .{},
@@ -231,39 +247,94 @@ pub fn setTheme(cursor: *Cursor, theme: ?[*:0]const u8, _size: ?u32) !void {
     cursor.xcursor_manager.destroy();
     cursor.xcursor_manager = xcursor_manager;
 
-    if (cursor.xcursor_name) |name| {
-        cursor.setXcursor(name);
+    switch (cursor.image) {
+        .none, .client => {},
+        .xcursor => |name| cursor.wlr_cursor.setXcursor(xcursor_manager, name),
+    }
+}
+
+pub fn setImage(cursor: *Cursor, image: Image) void {
+    if (cursor.image == .client) {
+        cursor.image_surface_destroy.link.remove();
+    }
+    cursor.image = image;
+    switch (cursor.image) {
+        .none => cursor.wlr_cursor.unsetImage(),
+        .xcursor => |name| cursor.wlr_cursor.setXcursor(cursor.xcursor_manager, name),
+        .client => |client| {
+            client.surface.events.destroy.add(&cursor.image_surface_destroy);
+            cursor.wlr_cursor.setSurface(client.surface, client.hotspot_x, client.hotspot_y);
+        },
+    }
+}
+
+fn handleImageSurfaceDestroy(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
+    const cursor: *Cursor = @fieldParentPtr("image_surface_destroy", listener);
+    // wlroots calls wlr_cursor_unset_image() automatically
+    // when the cursor surface is destroyed.
+    cursor.image = .none;
+    cursor.image_surface_destroy.link.remove();
+}
+
+pub fn setWmImage(cursor: *Cursor, wm_image: Image) void {
+    if (cursor.wm_image == .client) {
+        cursor.wm_image_surface_destroy.link.remove();
+    }
+    cursor.wm_image = wm_image;
+    if (cursor.wm_image == .client) {
+        cursor.wm_image.client.surface.events.destroy.add(&cursor.wm_image_surface_destroy);
+    }
+    if (cursor.seat.wlr_seat.pointer_state.focused_client == null) {
+        cursor.setImage(wm_image);
     }
 }
 
-pub fn setXcursor(cursor: *Cursor, name: [*:0]const u8) void {
-    cursor.wlr_cursor.setXcursor(cursor.xcursor_manager, name);
-    cursor.xcursor_name = name;
+fn handleWmImageSurfaceDestroy(listener: *wl.Listener(*wlr.Surface), _: *wlr.Surface) void {
+    const cursor: *Cursor = @fieldParentPtr("wm_image_surface_destroy", listener);
+    cursor.wm_image = .none;
+    cursor.wm_image_surface_destroy.link.remove();
 }
 
 fn handleRequestSetCursor(
     listener: *wl.Listener(*wlr.Seat.event.RequestSetCursor),
     event: *wlr.Seat.event.RequestSetCursor,
 ) void {
-    // This event is rasied by the seat when a client provides a cursor image
     const cursor: *Cursor = @fieldParentPtr("request_set_cursor", listener);
     const focused_client = cursor.seat.wlr_seat.pointer_state.focused_client;
 
-    // This can be sent by any client, so we check to make sure this one is
-    // actually has pointer focus first.
-    if (focused_client == event.seat_client) {
-        // Once we've vetted the client, we can tell the cursor to use the
-        // provided surface as the cursor image. It will set the hardware cursor
-        // on the output that it's currently on and continue to do so as the
-        // cursor moves between outputs.
+    // Only the client with pointer focus is allowed to set the cursor
+    if (event.seat_client == focused_client) {
         log.debug("focused client set cursor", .{});
-        cursor.wlr_cursor.setSurface(event.surface, event.hotspot_x, event.hotspot_y);
-        cursor.xcursor_name = null;
+        if (event.surface) |surface| {
+            cursor.setImage(.{ .client = .{
+                .surface = surface,
+                .hotspot_x = event.hotspot_x,
+                .hotspot_y = event.hotspot_y,
+            } });
+        } else {
+            cursor.setImage(.none);
+        }
+    }
+    // Except for the window manager client
+    if (server.wm.object) |object| {
+        if (event.seat_client.client == object.getClient() and
+            object.getVersion() >= 4)
+        {
+            if (event.surface) |surface| {
+                cursor.setWmImage(.{ .client = .{
+                    .surface = surface,
+                    .hotspot_x = event.hotspot_x,
+                    .hotspot_y = event.hotspot_y,
+                } });
+            } else {
+                cursor.setWmImage(.none);
+            }
+        }
     }
 }
 
 fn clearFocus(cursor: *Cursor) void {
-    cursor.setXcursor("default");
+    cursor.setImage(cursor.wm_image);
     cursor.seat.wlr_seat.pointerNotifyClearFocus();
 }
 
diff --git a/river/Server.zig b/river/Server.zig
index 2053e6e..fc5845d 100644
--- a/river/Server.zig
+++ b/river/Server.zig
@@ -472,30 +472,36 @@ fn handleRequestActivate(
 }
 
 fn handleRequestSetCursorShape(
-    _: *wl.Listener(*wlr.CursorShapeManagerV1.event.RequestSetShape),
+    listener: *wl.Listener(*wlr.CursorShapeManagerV1.event.RequestSetShape),
     event: *wlr.CursorShapeManagerV1.event.RequestSetShape,
 ) void {
+    const server: *Server = @fieldParentPtr("request_set_cursor_shape", listener);
     const seat: *Seat = @ptrCast(@alignCast(event.seat_client.seat.data));
 
+    const name = wlr.CursorShapeManagerV1.shapeName(event.shape);
+
     if (event.tablet_tool) |wp_tool| {
         assert(event.device_type == .tablet_tool);
 
         const tool = TabletTool.get(event.seat_client.seat, wp_tool.wlr_tool) catch return;
-
         if (tool.allowSetCursor(event.seat_client, event.serial)) {
-            const name = wlr.CursorShapeManagerV1.shapeName(event.shape);
             tool.wlr_cursor.setXcursor(seat.cursor.xcursor_manager, name);
         }
     } else {
         assert(event.device_type == .pointer);
 
+        // Only the client with pointer focus is allowed to set the cursor
         const focused_client = event.seat_client.seat.pointer_state.focused_client;
-
-        // This can be sent by any client, so we check to make sure this one is
-        // actually has pointer focus first.
-        if (focused_client == event.seat_client) {
-            const name = wlr.CursorShapeManagerV1.shapeName(event.shape);
-            seat.cursor.setXcursor(name);
+        if (event.seat_client == focused_client) {
+            seat.cursor.setImage(.{ .xcursor = name });
+        }
+        // Except for the window manager client
+        if (server.wm.object) |object| {
+            if (event.seat_client.client == object.getClient() and
+                object.getVersion() >= 4)
+            {
+                seat.cursor.setWmImage(.{ .xcursor = name });
+            }
         }
     }
 }