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

commit2c2f79d7e8ca9d848ebb3ab754b7e26ac638c00d
parentdc53f2bde9
authorLucas Galante <[email protected]>
date2026-08-09 10:02
fix: refresh pointer focus when the scene mapping changes under a stationary cursor

A commit that changes a toplevel window-geometry box or surface extent
changes the surface-to-frame mapping without any pointer motion — the
scene helper re-anchors the subtree by -geometry, and a grown buffer
adds hoverable area. Pointer focus and the surface-local coordinates
went stale, and the very next click was dispatched against the old
mapping or dropped before reaching the client (exposed by cce-ui
overflow-rim popovers: a symmetric rim shifted the surface origin under
an unmoved cursor and the trigger re-click toggle silently died).

handle_commit now detects the mapping change (geometry box or surface
extent vs the last commit, mapped windows only) and schedules a
DEFERRED pointer-focus re-evaluation: one coalesced wl_event_loop idle
source per dispatch that runs Cursor::update_state for every seat —
deferred because wlroots own scene commit listeners re-anchor AFTER the
compositor toplevel handler, so an inline refresh would query the stale
scene. Seats mid-op skip (op_end restores focus itself); implicit
client grabs are already handled inside passthrough.

Verified by nested-compositor A/B with a symmetric-rim client (the
origin-shifting repro): on the old compositor the second in-place click
is dropped with the menu stuck open; on the fixed compositor it toggles
closed. The shipped right/bottom-rim client also passes.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/server/cursor.rs        | 17 ++++++++++++++++
 src/server/input_manager.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++
 src/server/xdg_toplevel.rs  | 32 ++++++++++++++++++++++++++++++
 3 files changed, 96 insertions(+)

diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 90eaf5b..8017da7 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -368,6 +368,23 @@ impl Cursor {
         self.passthrough(crate::util::msec_timestamp());
     }
 
+    /// Re-evaluate pointer focus at the current position after the scene
+    /// mapping changed beneath a STATIONARY cursor — a commit moved/resized a
+    /// surface or re-anchored its window geometry. No motion fired (the
+    /// cursor did not move), so enter/leave and the surface-local coordinates
+    /// would otherwise go stale and the next click could be dispatched
+    /// against the old mapping or dropped entirely (the cce-ui overflow-rim
+    /// popovers exposed exactly this). Skips seats mid-op: the compositor
+    /// owns the pointer during a drag/resize op and `op_end_pointer` restores
+    /// focus itself; an implicit client grab is already handled inside
+    /// `passthrough`.
+    pub unsafe fn refresh_after_scene_change(&mut self) {
+        if (*self.seat).op.is_some() {
+            return;
+        }
+        self.update_state();
+    }
+
     pub unsafe fn update_drag_icons(&mut self) {
         let drag_icons_tree = (*(*self.seat).server).scene.drag_icons;
         let children_head = ffi::river_scene_tree_get_children(drag_icons_tree) as *mut WlList;
diff --git a/src/server/input_manager.rs b/src/server/input_manager.rs
index fcc1f3d..51bc4c2 100644
--- a/src/server/input_manager.rs
+++ b/src/server/input_manager.rs
@@ -27,6 +27,11 @@ pub struct InputManager {
     pub new_text_input: ffi::wl_listener,
     pub new_input_method: ffi::wl_listener,
     pub new_virtual_pointer_listener: ffi::wl_listener,
+
+    /// Pending deferred pointer-focus re-evaluation (see
+    /// [`InputManager::schedule_pointer_refresh`]); null when none. One idle
+    /// source coalesces every scene-mapping change of a dispatch.
+    pub pointer_refresh_idle: *mut ffi::wl_event_source,
 }
 
 pub struct InputManagerObject {
@@ -102,8 +107,37 @@ impl InputManager {
         Ok(())
     }
 
+    /// Schedule a pointer-focus re-evaluation for every seat, deferred to an
+    /// idle callback and coalesced (one source no matter how many commits
+    /// land in a dispatch). Used when a commit changes the surface↔frame
+    /// mapping under a stationary cursor — geometry re-anchor or surface
+    /// extent change. Deferred, NOT inline: wlroots' own scene commit
+    /// listeners re-anchor the surface tree AFTER the compositor's toplevel
+    /// commit handler runs, so an inline refresh would query the stale
+    /// mapping.
+    pub unsafe fn schedule_pointer_refresh(&mut self) {
+        if !self.pointer_refresh_idle.is_null() {
+            return;
+        }
+        let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+        let idle = ffi::wl_event_loop_add_idle(
+            event_loop,
+            Some(handle_pointer_refresh_idle),
+            self as *mut InputManager as *mut _,
+        );
+        if idle.is_null() {
+            log::error!("Failed to schedule pointer-refresh idle source");
+            return;
+        }
+        self.pointer_refresh_idle = idle;
+    }
+
     pub unsafe fn deinit(&mut self) {
         log::info!("[deinit] InputManager::deinit started");
+        if !self.pointer_refresh_idle.is_null() {
+            ffi::wl_event_source_remove(self.pointer_refresh_idle);
+            self.pointer_refresh_idle = std::ptr::null_mut();
+        }
         if !self.global.is_null() {
             log::info!("[deinit] destroying input manager global");
             ffi::wl_global_destroy(self.global);
@@ -399,3 +433,16 @@ unsafe extern "C" fn handle_new_virtual_pointer(listener: *mut ffi::wl_listener,
     // Attach device to the default seat
     (*im.default_seat).attach_device(device);
 }
+
+unsafe extern "C" fn handle_pointer_refresh_idle(data: *mut std::ffi::c_void) {
+    let manager = data as *mut InputManager;
+    (*manager).pointer_refresh_idle = std::ptr::null_mut();
+    let seats_head = &mut (*manager).seats as *mut ffi::wl_list as *mut WlList;
+    let mut curr = (*seats_head).next;
+    while curr != seats_head {
+        let next = (*curr).next;
+        let seat = crate::container_of!(curr, Seat, link);
+        (*seat).cursor.refresh_after_scene_change();
+        curr = next;
+    }
+}
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 0737f9f..3043b82 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -20,6 +20,9 @@ pub struct XdgToplevel {
     pub wlr_toplevel: *mut ffi::wlr_xdg_toplevel,
     pub decoration: *mut XdgDecoration,
     pub geometry: ffi::wlr_box,
+    /// Surface extent as of the last commit — with `geometry`, the mapping
+    /// change detector for the deferred pointer refresh (see `handle_commit`).
+    pub last_surface_size: (i32, i32),
     pub configure_state: ConfigureState,
 
     pub destroy: ffi::wl_listener,
@@ -59,6 +62,7 @@ impl XdgToplevel {
             wlr_toplevel,
             decoration: std::ptr::null_mut(),
             geometry: std::mem::zeroed(),
+            last_surface_size: (0, 0),
             configure_state: ConfigureState::Idle,
 
             destroy: std::mem::zeroed(),
@@ -520,10 +524,38 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
     let toplevel = crate::container_of!(listener, XdgToplevel, commit);
     let window = (*toplevel).window;
     let base = ffi::river_wlr_xdg_toplevel_get_base((*toplevel).wlr_toplevel);
+    let old_geometry = (*toplevel).geometry;
     let mut new_geometry = std::mem::zeroed();
     ffi::river_wlr_xdg_surface_get_geometry(base, &mut new_geometry);
     (*toplevel).geometry = new_geometry;
 
+    // A commit that changes the window-geometry box or the surface extent
+    // changes the surface↔frame mapping under a STATIONARY cursor (the scene
+    // helper re-anchors the subtree by -geometry; a grown buffer adds
+    // hoverable area): pointer focus and surface-local coords go stale with
+    // no motion to fix them, and the next click is dispatched against the old
+    // mapping or dropped — the cce-ui overflow-rim popovers exposed this.
+    // Deferred to idle: wlroots' own scene commit listeners re-anchor AFTER
+    // this handler, so an inline refresh would query the stale scene.
+    {
+        let surface = ffi::river_wlr_xdg_surface_get_surface(base);
+        let surf_size = (
+            ffi::river_wlr_surface_get_width(surface),
+            ffi::river_wlr_surface_get_height(surface),
+        );
+        let mapping_changed = old_geometry.x != new_geometry.x
+            || old_geometry.y != new_geometry.y
+            || old_geometry.width != new_geometry.width
+            || old_geometry.height != new_geometry.height
+            || surf_size != (*toplevel).last_surface_size;
+        (*toplevel).last_surface_size = surf_size;
+        if mapping_changed
+            && matches!((*window).state, crate::window::WindowState::Mapped)
+        {
+            (*(*window).server).input_manager.schedule_pointer_refresh();
+        }
+    }
+
     let app_id = (*window).get_app_id_string().unwrap_or_default();
     let mut ignore_transparent = (*(*window).server).wm.layout.window_backdrop_blur_ignore_transparent;
     if app_id.starts_with("cce-status") {