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

commit223bbe0b63e399684619c4a81ca05ba9384c6eec
parent6d8edada0d
authorLucas Galante <[email protected]>
date2026-08-04 21:57
feat: placeholder clicks follow the focus-follow camera rules

Restore placeholders are bare scene rects the hit-test cannot see, so
clicking one fell through to the desktop path and did nothing — even
though the placeholder stands in for a restored window at its saved
geometry. The button handler now hit-tests placeholders on a scene miss
and applies the same camera rules a real window gets via a new
WindowManager::pan_to_virtual_rect (center when mostly hidden, nudge
when clipped, no-op when fully visible); desktop clicks elsewhere still
clear focus as before.

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

 src/server/cursor.rs         | 12 ++++++--
 src/server/window_manager.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 79 insertions(+), 2 deletions(-)

diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index ab57ada..2b7358a 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -1259,8 +1259,16 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
         }
 
         if !clicked_something && (*event).button == 0x110 {
-            seat.focus(Focus::None);
-            (*(*seat).server).wm.dirty_windowing();
+            // Restore placeholders are bare scene rects the hit-test can't
+            // see, but they stand in for restored windows — clicking one
+            // gets the same camera rules as clicking the real window.
+            let wm = &mut (*server).wm;
+            if let Some((pvx, pvy, pw, ph)) = wm.placeholder_at(lx, ly) {
+                wm.pan_to_virtual_rect(pvx, pvy, pw, ph);
+            } else {
+                seat.focus(Focus::None);
+                (*(*seat).server).wm.dirty_windowing();
+            }
         }
     } else {
         assert_eq!((*event).state, ffi::wl_pointer_button_state_WL_POINTER_BUTTON_STATE_RELEASED);
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index b3cb1b2..f81d88f 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -404,6 +404,75 @@ impl WindowManager {
         }
     }
 
+    /// The restore placeholder under a layout-space point, as its virtual
+    /// rect `(vx, vy, w, h)`. Topmost (latest-created) wins on overlap.
+    pub unsafe fn placeholder_at(&self, lx: f64, ly: f64) -> Option<(f64, f64, f64, f64)> {
+        if self.restore_placeholders.is_empty() {
+            return None;
+        }
+        let (mut out_x, mut out_y) = (0.0, 0.0);
+        let outputs_list = &(*self.server).om.outputs as *const ffi::wl_list as *mut WlList;
+        let mut curr_out = (*outputs_list).next;
+        while curr_out != outputs_list {
+            let output = crate::container_of!(curr_out, crate::output::Output, link);
+            if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                let wlr_box = (*output).sent.box_layout();
+                out_x = wlr_box.x as f64;
+                out_y = wlr_box.y as f64;
+                break;
+            }
+            curr_out = (*curr_out).next;
+        }
+        let zoom = self.desk_zoom;
+        for p in self.restore_placeholders.iter().rev() {
+            let x = out_x + (p.vx - self.desk_pan_x) * zoom;
+            let y = out_y + (p.vy - self.desk_pan_y) * zoom;
+            let w = p.w as f64 * zoom;
+            let h = p.h as f64 * zoom;
+            if lx >= x && lx < x + w && ly >= y && ly < y + h {
+                return Some((p.vx, p.vy, p.w as f64, p.h as f64));
+            }
+        }
+        None
+    }
+
+    /// Focus-follow camera rules for a bare virtual rect — the same
+    /// center-when-mostly-hidden / nudge-when-clipped behavior windows get,
+    /// for things that are not windows (restore placeholders).
+    pub unsafe fn pan_to_virtual_rect(&mut self, vx: f64, vy: f64, w: f64, h: f64) {
+        let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
+        let mut curr_out = (*outputs_list).next;
+        let mut viewport: Option<ffi::wlr_box> = None;
+        while curr_out != outputs_list {
+            let output = crate::container_of!(curr_out, crate::output::Output, link);
+            if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                viewport = Some((*output).sent.box_layout());
+                break;
+            }
+            curr_out = (*curr_out).next;
+        }
+        let Some(viewport) = viewport else { return };
+        let (vw, vh) = (viewport.width as f64, viewport.height as f64);
+        let cam = self.camera();
+        let visible = crate::policy::camera::visible_fraction(vx, vy, w, h, cam, vw, vh);
+        let target = if visible < crate::policy::camera::FOCUS_VISIBLE_THRESHOLD {
+            Some(crate::policy::camera::center_on(
+                vx + w / 2.0,
+                vy + h / 2.0,
+                vw,
+                vh,
+                cam.zoom,
+            ))
+        } else {
+            crate::policy::camera::nudge_into_view(vx, vy, w, h, cam, vw, vh)
+        };
+        if let Some(target) = target {
+            self.target_desk_pan_x = Some(target.pan_x);
+            self.target_desk_pan_y = Some(target.pan_y);
+            self.start_panning_animation();
+        }
+    }
+
     /// Keep placeholders tracking the camera, same transform as windows.
     pub unsafe fn update_restore_placeholders(&mut self) {
         if self.restore_placeholders.is_empty() {