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

commit2f7322c195b96ff03a757980dc52317dcaf121df
parentec8377ad94
authorLucas Galante <[email protected]>
date2026-09-01 13:04
feat: desktop images are draggable in overview mode

A press on the grid client used to count as background in overview and
exit it — but hitting the grid at all means the press landed on a desktop
item, since its input region covers exactly the item rects and nothing
else. Bare canvas misses the grid by the same mechanism, so the
background-press exit is untouched.

Three gates opened, all in cursor.rs:

- The overview click branch treats a grid hit as chrome-like fall-through
  (press delivered to the client) instead of an overview exit.
- should_block_button no longer counts the grid as an app surface, so the
  press is actually forwarded while overview is up.
- The hover path no longer treats the grid as a toplevel: the overview
  branch was clearing pointer focus over items (and focus-following onto
  a surface seat.focus refuses), so the grid never received the enter
  that button delivery depends on.

And one correctness fix the zoomed-out case forced: an implicit grab held
on the grid maps motion through the surface node (grid_surface_at, the
drop path's mapping) rather than the fixed-origin offset, which assumes
an unscaled surface. The grid displays at the patch's resolution ratio —
off 1 whenever the zoom sits between pow2 quantization steps — so offset
deltas would drag the item faster or slower than the pointer by exactly
that ratio. Verified in a shadow session at zoom 0.25: a 200x150 px
pointer drag moved the item 200x150 px on screen and 800x600 virtual.

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

 src/server/cursor.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 50 insertions(+), 7 deletions(-)

diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 3a9faa9..8b253b5 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -698,6 +698,26 @@ impl Cursor {
             let focused =
                 ffi::river_wlr_seat_get_pointer_focused_surface((*self.seat).wlr_seat);
             if !focused.is_null() {
+                // A grab held on the GRID maps through the surface node like
+                // the drop path below, not through the fixed origin: the
+                // offset formula assumes an unscaled surface, and the grid
+                // displays at the patch's resolution ratio — near 1, but off
+                // it whenever the zoom sits between pow2 quantization steps
+                // (most of overview) — so offset deltas would drag the item
+                // faster or slower than the pointer by exactly that ratio.
+                // A patch re-latch mid-drag moves the node under the pointer;
+                // the client's delta re-baselining absorbs that step.
+                if let Some((gsurf, gsx, gsy)) = grid_surface_at(server, lx, ly) {
+                    if gsurf == focused {
+                        ffi::wlr_seat_pointer_notify_motion(
+                            (*self.seat).wlr_seat,
+                            time_msec,
+                            gsx,
+                            gsy,
+                        );
+                        return;
+                    }
+                }
                 ffi::wlr_seat_pointer_notify_motion(
                     (*self.seat).wlr_seat,
                     time_msec,
@@ -728,7 +748,16 @@ impl Cursor {
             let mut hovered_toplevel: *mut crate::window::Window = std::ptr::null_mut();
             match result.data {
                 SceneNodeDataVal::Window(window) => {
-                    if !(*window).is_status_bar() && !(*window).is_wallpaper() {
+                    // The grid is not a toplevel for hover purposes: hitting it
+                    // means the pointer is on a desktop item (its input region
+                    // covers nothing else), and the item drag needs the
+                    // enter/motion delivery below — in overview too, where the
+                    // is_window branch would clear pointer focus and try to
+                    // focus-follow onto a surface seat.focus refuses.
+                    if !(*window).is_status_bar()
+                        && !(*window).is_wallpaper()
+                        && !(*window).is_grid()
+                    {
                         is_window = true;
                         hovered_toplevel = window;
                     }
@@ -1033,7 +1062,14 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
     if let Some(result) = (*server).scene.at(lx, ly) {
         match result.data {
             SceneNodeDataVal::Window(window) => {
-                if !(*window).is_status_bar() && !(*window).is_wallpaper() {
+                // The grid does not block: hitting it means the press is on a
+                // desktop item (its input region covers nothing else), and
+                // the item drag needs the press delivered in overview like
+                // any chrome click.
+                if !(*window).is_status_bar()
+                    && !(*window).is_wallpaper()
+                    && !(*window).is_grid()
+                {
                     is_app_surface = true;
                     // Popup counts as chrome like Overlay: the cce-cloud
                     // launcher must keep receiving clicks in overview.
@@ -1173,19 +1209,26 @@ 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.
+            // Hitting the grid means the press landed on a DESKTOP ITEM —
+            // its input region covers the item rects and nothing else — so
+            // it is chrome-like: fall through to normal delivery and the
+            // grid client starts its item drag, in overview exactly as in
+            // normal mode. Bare canvas misses the grid entirely (that is
+            // the input region again) and still exits overview below.
+            let clicked_grid = !clicked_win.is_null() && (*clicked_win).is_grid();
+            // The bare canvas 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_grid();
+                && !clicked_grid;
             let overview_border_zone = if overview_win_valid {
                 get_border_zone(clicked_win, lx, ly)
             } else {
                 BorderZone::None
             };
-            if overview_chrome {
+            if overview_chrome || clicked_grid {
                 // fall through
             } else if overview_win_valid && matches!(overview_border_zone, BorderZone::None) {
                 (*server).wm.stop_panning_animation();