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

commitb82a0eec9dcc91d36940b55e27b252425900d411
parent8dcdf5507e
authorLucas Galante <[email protected]>
date2026-08-26 12:52
feat: let the grid layer be hit-tested through its input region

The grid layer was forced input-transparent in Scene::at so every click
reached the desktop underneath. That is all-or-nothing, and it is why
nothing on the canvas could ever be clicked — including the images
pinned to it.

The grid now advertises an input region covering exactly its items, so
wlr_scene_node_at misses it over bare canvas by itself: background
clicks, hover, panning and the overview background-exit behave as
before, while a press ON an item reaches the client that owns it. An
empty region — a desktop with no items — is transparent exactly as the
old skip was.

Two consequences handled here:
- The drop target can no longer be found by hit-testing, since drops
  mostly land on bare canvas which the region excludes. grid_surface_at
  resolves the surface directly and maps the point through the surface
  node's own layout origin and the window's display scale. A drop target
  has to be named, not hit-tested.
- The grid must never be a border/move/resize target, nor an overview
  click target: it is the canvas, not a window. Without this a press on
  an item grabs the whole canvas as if dragging a window.

Shadow-verified after the change: dragging an image moves it, the
bare-canvas right-click still spawns the desktop menu, a background
click still exits overview, and a Firefox drop still lands.

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

 src/server/cursor.rs | 77 ++++++++++++++++++++++++++++++++++++----------------
 src/server/scene.rs  | 21 ++++++--------
 2 files changed, 62 insertions(+), 36 deletions(-)

diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index b40ffe1..f7f5e92 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -787,27 +787,11 @@ impl Cursor {
         // it, and only while the pointer is over the background, so clicks,
         // hover and the overview background-exit are untouched.
         if (*self.seat).drag != crate::seat::DragState::None {
-            if let Some(result) = (*server).scene.at_including_grid(lx, ly) {
-                if !result.surface.is_null() {
-                    if let SceneNodeDataVal::Window(window) = result.data {
-                        if (*window).is_grid() {
-                            log::debug!("[drag] focus -> grid at ({lx:.0}, {ly:.0})");
-                            ffi::wlr_seat_pointer_notify_enter(
-                                (*self.seat).wlr_seat,
-                                result.surface,
-                                result.sx,
-                                result.sy,
-                            );
-                            ffi::wlr_seat_pointer_notify_motion(
-                                (*self.seat).wlr_seat,
-                                time_msec,
-                                result.sx,
-                                result.sy,
-                            );
-                            return;
-                        }
-                    }
-                }
+            if let Some((surface, sx, sy)) = grid_surface_at(server, lx, ly) {
+                log::debug!("[drag] focus -> grid at ({lx:.0}, {ly:.0})");
+                ffi::wlr_seat_pointer_notify_enter((*self.seat).wlr_seat, surface, sx, sy);
+                ffi::wlr_seat_pointer_notify_motion((*self.seat).wlr_seat, time_msec, sx, sy);
+                return;
             }
         }
 
@@ -1184,9 +1168,13 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                         (*clicked_win).tiling_mode,
                         crate::tiling::TilingMode::Overlay | crate::tiling::TilingMode::Popup
                     ));
+            // The grid counts as background in overview: a press on it must
+            // exit overview like any desktop press, never grab the canvas
+            // itself as if it were a window.
             let overview_win_valid = !clicked_win.is_null()
                 && !(*clicked_win).is_status_bar()
-                && !(*clicked_win).is_wallpaper();
+                && !(*clicked_win).is_wallpaper()
+                && !(*clicked_win).is_grid();
             let overview_border_zone = if overview_win_valid {
                 get_border_zone(clicked_win, lx, ly)
             } else {
@@ -1395,7 +1383,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
             }
         }
 
-        if !border_target_win.is_null() && !(*border_target_win).is_status_bar() && !(*border_target_win).is_wallpaper() && (
+        if !border_target_win.is_null() && !(*border_target_win).is_status_bar() && !(*border_target_win).is_wallpaper() && !(*border_target_win).is_grid() && (
             (*border_target_win).tiling_mode != crate::tiling::TilingMode::Popup
             && (*border_target_win).tiling_mode != crate::tiling::TilingMode::Fullscreen
         ) {
@@ -2749,6 +2737,49 @@ pub enum BorderZone {
 
 pub use crate::window::HOVER_BAND_MIN;
 
+/// The grid client's surface and the surface-local coordinates of a layout
+/// point, ignoring the input region.
+///
+/// Hit-testing cannot be used for this: the grid's input region covers only
+/// its desktop items, so a point over bare canvas — where drops mostly land —
+/// misses it by design. A drop target does not need to be hit-testable, only
+/// named, so the surface is resolved directly and the point is mapped through
+/// the surface node's own layout origin and the window's display scale, which
+/// is the same pair the renderer draws with.
+pub unsafe fn grid_surface_at(
+    server: *mut crate::server::Server,
+    lx: f64,
+    ly: f64,
+) -> Option<(*mut ffi::wlr_surface, f64, f64)> {
+    for &w in (*server).wm.windows.iter() {
+        if w.is_null() || (*w).closed || !(*w).is_grid() {
+            continue;
+        }
+        if !matches!((*w).state, crate::window::WindowState::Mapped) {
+            continue;
+        }
+        let surface = (*w).root_surface();
+        if surface.is_null() {
+            continue;
+        }
+        let node = (*w).surfaces.tree as *mut ffi::wlr_scene_node;
+        let (mut nx, mut ny) = (0, 0);
+        if !ffi::wlr_scene_node_coords(node, &mut nx, &mut ny) {
+            continue;
+        }
+        let scale = if (*w).scale > 0.0 { (*w).scale } else { 1.0 };
+        let (sx, sy) = ((lx - nx as f64) / scale, (ly - ny as f64) / scale);
+        // Only claim points that actually fall on the grid's patch. box_geom
+        // is the surface's own logical size, which is the space sx/sy are in.
+        let (bw, bh) = ((*w).box_geom.width as f64, (*w).box_geom.height as f64);
+        if sx < 0.0 || sy < 0.0 || (bw > 0.0 && sx >= bw) || (bh > 0.0 && sy >= bh) {
+            continue;
+        }
+        return Some((surface, sx, sy));
+    }
+    None
+}
+
 pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f64) -> BorderZone {
     if (*window).tiling_mode == crate::tiling::TilingMode::Popup
         || (*window).tiling_mode == crate::tiling::TilingMode::Fullscreen
diff --git a/src/server/scene.rs b/src/server/scene.rs
index 81ba271..e720a2d 100644
--- a/src/server/scene.rs
+++ b/src/server/scene.rs
@@ -126,16 +126,6 @@ impl Scene {
     pub unsafe fn deinit(&mut self) {}
 
     pub unsafe fn at(&self, lx: f64, ly: f64) -> Option<AtResult> {
-        self.at_impl(lx, ly, false)
-    }
-
-    /// `at`, but the grid layer participates like any other surface. The only
-    /// caller is the drag path: the grid client is the desktop's drop target
-    /// (it draws the canvas, so it owns what "dropped here" means), and this
-    /// resolves the drop point through wlroots so the surface-local
-    /// coordinates account for the grid's buffer scale — hand-deriving them
-    /// from box_geom would drift the moment the camera zoomed.
-    pub unsafe fn at_including_grid(&self, lx: f64, ly: f64) -> Option<AtResult> {
         self.at_impl(lx, ly, true)
     }
 
@@ -160,9 +150,14 @@ impl Scene {
 
             if let Some(scene_node_data) = SceneNodeData::from_node(node) {
                 if let SceneNodeDataVal::Window(window) = scene_node_data.data {
-                    // The grid layer is input-transparent: every input path
-                    // (clicks, hover, overview background-exit) sees what is
-                    // underneath it, exactly as if it were the backdrop.
+                    // The grid layer used to be skipped here to keep it
+                    // input-transparent. It now advertises an input region
+                    // covering exactly its desktop items, so wlr_scene_node_at
+                    // already misses it over bare canvas and every other input
+                    // path (clicks, hover, overview background-exit) still sees
+                    // through it — while a click ON an item reaches the client
+                    // that owns it. The parameter stays for the callers that
+                    // must never see the grid at all.
                     if !include_grid && (*window).is_grid() {
                         let tree_node = (*window).tree as *mut ffi::wlr_scene_node;
                         ffi::wlr_scene_node_set_enabled(tree_node, false);