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

commitbf35e291d258804ae0fc70e302a661eefbc6b7fd
parentec81d14ba5
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-03 04:09
fix(xdg_popup): a popup coming or going re-picks pointer focus under a still pointer

wlroots moves pointer focus on motion, so a popup destroyed under the
pointer left the surface beneath it without focus until the pointer moved,
and one mapped under it took none. In cce-designer the network menu turning
into its Add Node list (the menu's popup destroyed, the list drawn in the
window beneath) left a two-finger swipe back going nowhere.

Popups now ask for the deferred, coalesced re-evaluation a toplevel's commit
already asks for (InputManager::schedule_pointer_refresh): on destroy, and
on a commit that maps, moves or resizes one. The server is taken once at
creation, through the root's scene node, since a destroyed popup's tree may
already be gone. Checked in a shadow against the installed compositor: the
swipe back reached the client 0 times before, all of it after, and the
forward swipe on the menu that came back works too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/server/xdg_popup.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 54 insertions(+)

diff --git a/src/server/xdg_popup.rs b/src/server/xdg_popup.rs
index eb1bc8fd..1e4d1c33 100644
--- a/src/server/xdg_popup.rs
+++ b/src/server/xdg_popup.rs
@@ -14,6 +14,14 @@ pub struct XdgPopup {
     /// from here, because wlroots wants the unconstrain box in the root
     /// toplevel surface's coordinates, not the immediate parent's.
     pub root_tree: *mut ffi::wlr_scene_tree,
+    /// The server, found once at creation through the root's scene node —
+    /// a destroyed popup's own tree may already be gone when its destroy
+    /// signal runs, so nothing can be looked up then. Null for a root that
+    /// is neither a window nor a shell surface.
+    pub server: *mut crate::server::Server,
+    /// Where the popup stood and how big it was at its last commit, layout
+    /// coordinates: to tell a map, a move or a resize.
+    pub last_box: (i32, i32, i32, i32),
 
     pub destroy: ffi::wl_listener,
     pub commit: ffi::wl_listener,
@@ -51,6 +59,8 @@ impl XdgPopup {
             tree,
             capture_tree,
             root_tree: root,
+            server: tree_server(root),
+            last_box: (0, 0, 0, 0),
             destroy: std::mem::zeroed(),
             commit: std::mem::zeroed(),
             new_popup: std::mem::zeroed(),
@@ -78,8 +88,25 @@ impl XdgPopup {
     }
 }
 
+/// A popup coming, going, moving or changing size under a pointer that is
+/// standing still changes what is under it, and nothing else would say so:
+/// wlroots moves pointer focus on MOTION. So a menu closed under the pointer
+/// left the window beneath it without focus until the pointer moved — the
+/// designer's network menu turning into its Add Node list, a swipe back on
+/// the list going nowhere — and a menu opened under it took no focus either.
+/// The same deferred re-evaluation a toplevel's commit asks for when its
+/// mapping moves (`InputManager::schedule_pointer_refresh`), coalesced and
+/// run once the scene has caught up.
+unsafe fn refresh_pointer_under(popup: *mut XdgPopup) {
+    let server = (*popup).server;
+    if !server.is_null() {
+        (*server).input_manager.schedule_pointer_refresh();
+    }
+}
+
 unsafe extern "C" fn handle_destroy(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let popup = crate::container_of!(listener, XdgPopup, destroy);
+    refresh_pointer_under(popup);
 
     wl_listener_remove(&mut (*popup).destroy);
     wl_listener_remove(&mut (*popup).commit);
@@ -97,6 +124,33 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
         return;
     }
     update_blur(popup, base_surface);
+    // Mapped (its first buffer), moved or resized: what is under the
+    // pointer changed. Not on every commit — a refresh sends the client a
+    // motion, and an animating menu commits every frame.
+    let surface = ffi::river_wlr_xdg_surface_get_surface(base_surface);
+    let (mut lx, mut ly) = (0, 0);
+    ffi::wlr_scene_node_coords((*popup).tree as *mut ffi::wlr_scene_node, &mut lx, &mut ly);
+    let at = (lx, ly, ffi::river_wlr_surface_get_width(surface), ffi::river_wlr_surface_get_height(surface));
+    if at != (*popup).last_box {
+        (*popup).last_box = at;
+        refresh_pointer_under(popup);
+    }
+}
+
+/// The server a scene tree belongs to, through its node data — a window's
+/// or a shell surface's. Null for any other tree.
+unsafe fn tree_server(tree: *mut ffi::wlr_scene_tree) -> *mut crate::server::Server {
+    if tree.is_null() {
+        return std::ptr::null_mut();
+    }
+    match crate::scene_node_data::SceneNodeData::from_node(tree as *mut ffi::wlr_scene_node) {
+        Some(node_data) => match node_data.data {
+            crate::scene_node_data::SceneNodeDataVal::Window(w) => (*w).server,
+            crate::scene_node_data::SceneNodeDataVal::ShellSurface(s) => (*s).server,
+            _ => std::ptr::null_mut(),
+        },
+        None => std::ptr::null_mut(),
+    }
 }
 
 /// Blur behind the popup as behind a window: a translucent menu is frosted