Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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") {