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

commit6ac3b0d01fb3b9f03155616ea646e7a6fefd2282
parentc59820299e
authorLucas Galante <[email protected]>
date2026-08-29 22:31
feat(handles): ring on the focused window only; focus follows hover

The ring drew on every window in overview, which read as noise once it
became a bold moulding. Now only the focused window carries it, and focus
follows the pointer, so the ring slides from window to window as you hover
— super+hjkl moves it too, through the same seat focus.

Three details carry the weight:

Hovering must not move the camera. The hover-focus call sets
suppress_focus_pan around seat.focus: zoom was never at stake (focus-follow
pans at the current zoom), but a partially visible window would still get
dragged on-screen mid-hover without it. Keyboard and click focus keep
their pan.

The call is guarded on an actual focus change, because seat.focus raises a
Floating window BEFORE its same-focus short-circuit — unguarded, every
motion event on a window would raise and relayout.

The hit test and the invisible catcher rects are focused-gated along with
the drawing, so an unfocused window offers no grab it does not show.
Hover-to-focus is what makes that workable: reaching a window's edge
focuses it on the way, so the band is live by the time the pointer arrives
(one motion event later, imperceptibly). seat.focus arms the border fade so
the swap eases instead of waiting for an unrelated redraw.

Verified headless with two windows: hover moved focus without a pixel of
camera movement (left-strip diff exactly zero), the ring migrated
completely (24368 -> 0 px on one window, 2 -> 24116 on the other), ctl
focus-left swapped it back identically, and a resize grabbed on a freshly
hover-focused window works (+40 screen px -> +103 world px at 0.39 zoom).

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

 CLAUDE.md            | 19 +++++++++++++------
 src/server/cursor.rs | 29 +++++++++++++++++++++++++++++
 src/server/seat.rs   |  5 +++++
 src/server/window.rs | 50 +++++++++++++++++++++++++++++++-------------------
 4 files changed, 78 insertions(+), 25 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 41e5d2c..19f3524 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -370,12 +370,19 @@ where the old band sat outside them.
   this arrangement exists to prevent. Note the *grab* zone stays the full
   even band (the four catcher rects) even where the ring is drawn thin: the
   swell is ornament, and a corner you can see but not grab would be worse.
-- Handles are shown for **every** eligible window the whole time overview is
-  on, not just the hovered one, via the `all_on` branch in
-  `step_border_fade`; hover still reads through as `hover_color`. Because
-  that fade is timer-driven, every site that flips the mode goes through
-  `WindowManager::set_mode`, which arms it — assigning `self.mode` directly
-  would leave the handles waiting for an unrelated redraw.
+- Handles are shown on the **focused window only**, for as long as overview
+  is on (`step_border_fade`'s `all_on` branch, gated on
+  `Window::is_seat_focused`). **Focus follows the pointer in overview**: the
+  motion path focuses the hovered toplevel — guarded on an actual change,
+  because `seat.focus` raises a Floating window *before* its same-focus
+  short-circuit, so an unguarded call would raise and relayout on every
+  motion event — and with `suppress_focus_pan` set, so hovering never moves
+  the camera; only clicks and the keyboard may. The ring's fade is
+  timer-driven, so both `WindowManager::set_mode` and `seat.focus` arm it —
+  assigning `self.mode` or `self.focused` directly would leave the ring
+  waiting for an unrelated redraw. The hit test and the invisible catcher
+  rects are focused-gated too; hover-to-focus is what keeps that workable,
+  since reaching a window's edge focuses it on the way.
 - The per-side foam clipping the outside band carried is gone: it split a gap
   SHARED with a neighbouring window, and an inside ring shares nothing.
 
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index e7ce388..605389e 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -725,10 +725,12 @@ impl Cursor {
             }
 
             let mut is_window = false;
+            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() {
                         is_window = true;
+                        hovered_toplevel = window;
                     }
                     // No mode gate: the band scales with the window
                     // (get_border_zone is zoom-aware), so the resize/move
@@ -765,6 +767,25 @@ impl Cursor {
             self.set_border_hover(std::ptr::null_mut(), None);
 
             if is_window && (*server).wm.mode == crate::window_manager::WindowManagerMode::Overview {
+                // Focus follows the pointer in overview: the ring is drawn on
+                // the focused window only, so hovering is how it moves between
+                // windows without a click. Guarded on an actual change —
+                // seat.focus raises a Floating window BEFORE its same-focus
+                // short-circuit, so an unguarded call would raise and relayout
+                // on every motion event. And with the pan suppressed: hovering
+                // must not move the camera, only the click and keyboard paths
+                // may. (Zoom was never at stake — focus_follow_pan pans at the
+                // current zoom — but a partially visible window would still
+                // get dragged on-screen mid-hover.)
+                if !hovered_toplevel.is_null()
+                    && (*self.seat).focused
+                        != crate::seat::Focus::Window(hovered_toplevel)
+                {
+                    let prev = (*self.seat).suppress_focus_pan;
+                    (*self.seat).suppress_focus_pan = true;
+                    (*self.seat).focus(crate::seat::Focus::Window(hovered_toplevel));
+                    (*self.seat).suppress_focus_pan = prev;
+                }
                 self.clear_focus();
                 return;
             }
@@ -2804,6 +2825,14 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
     if !crate::window::window_takes_handles(window) {
         return BorderZone::None;
     }
+    // Focused window only, matching what draw_borders draws. Unfocused
+    // windows show no ring, and a grab that is not drawn is the failure mode
+    // this file keeps warning about; hover-to-focus (the motion path) means
+    // reaching a window's edge focuses it on the way, so its band is live by
+    // the time the pointer arrives.
+    if !(*window).is_seat_focused() {
+        return BorderZone::None;
+    }
 
     let bw_unscaled = crate::window::border_band_width((*window).rendering_requested.border.width);
     if bw_unscaled <= 0.0 {
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 5ad07e2..cff4058 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -434,6 +434,11 @@ impl Seat {
         }
 
         self.focused = new_focus;
+        // The overview resize ring is drawn on the focused window only and
+        // eases in and out through the border fade — a focus change has to
+        // arm that timer or the old ring lingers and the new one waits for an
+        // unrelated redraw (the same reason WindowManager::set_mode arms it).
+        (*self.server).wm.arm_border_fade();
         if let Focus::Window(window) = new_focus {
             if !window.is_null() {
                 (*self.server).wm.record_focus(window);
diff --git a/src/server/window.rs b/src/server/window.rs
index a3a7d07..adfb6da 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -3221,6 +3221,24 @@ impl Window {
     /// The light direction is the DE's convention — the same top-left source
     /// the drop shadow is offset away from — so a window reads as a slab lit
     /// from the same place as everything else on the desktop.
+    /// Is this window any seat's keyboard focus? The window's `activated`
+    /// field is a configure-time snapshot, not live state, so live answers
+    /// come from the seats.
+    pub unsafe fn is_seat_focused(&self) -> bool {
+        let seats = &mut (*self.server).input_manager.seats as *mut ffi::wl_list as *mut WlList;
+        let mut curr = (*seats).next;
+        while curr != seats {
+            let seat = crate::container_of!(curr, crate::seat::Seat, link);
+            if let crate::seat::Focus::Window(w) = (*seat).focused {
+                if w == self as *const Window as *mut Window {
+                    return true;
+                }
+            }
+            curr = (*curr).next;
+        }
+        false
+    }
+
     pub unsafe fn update_bevel(&self, width: i32, height: i32, radius: i32, want: bool, want_focus: bool) {
         if self.bevel.is_null() {
             return;
@@ -3235,20 +3253,7 @@ impl Window {
         // bevel app list still enables the node: the shader's focus branch
         // draws ONLY the glint, so it lays cleanly over a cce-ui app's own
         // client-side bevel instead of doubling its shading.
-        let mut focused = false;
-        {
-            let seats = &mut (*self.server).input_manager.seats as *mut ffi::wl_list as *mut WlList;
-            let mut curr = (*seats).next;
-            while curr != seats {
-                let seat = crate::container_of!(curr, crate::seat::Seat, link);
-                if let crate::seat::Focus::Window(w) = (*seat).focused {
-                    if w == self as *const Window as *mut Window {
-                        focused = true;
-                    }
-                }
-                curr = (*curr).next;
-            }
-        }
+        let focused = self.is_seat_focused();
         let enabled = (want || (focused && want_focus))
             && layout.bevel_enabled
             && layout.bevel_thickness > 0.0
@@ -3367,12 +3372,15 @@ impl Window {
     pub unsafe fn step_border_fade(&mut self) -> bool {
         let mut moving = false;
         let mut changed = false;
-        // In overview every handle is shown for as long as the mode is on,
-        // not just the one under the pointer: the point is to see what is
-        // grabbable at a glance. Hover still reads through, as `color_for`
-        // paints the hovered zone in hover_color over the same full reveal.
+        // In overview the FOCUSED window shows its whole ring for as long as
+        // the mode is on; other windows show nothing. Hover-to-focus in the
+        // motion path means the ring follows the pointer from window to
+        // window, each swap easing through this same fade. Hover still reads
+        // through on the focused ring, as `color_for` paints the hovered
+        // zone in hover_color over the full reveal.
         let all_on = (*self.server).wm.mode == crate::window_manager::WindowManagerMode::Overview
-            && window_takes_handles(self as *mut Window);
+            && window_takes_handles(self as *mut Window)
+            && self.is_seat_focused();
         for elem in BorderElement::ALL {
             let i = elem.index();
             let target = if all_on || self.hovered_border_element == Some(elem) { 1.0 } else { 0.0 };
@@ -3727,6 +3735,10 @@ impl Window {
             // ring; drawing one would be a solid block over the whole window.
             let handles_on = in_overview
                 && window_takes_handles(self_ptr)
+                // Focused-only, like the reveal in step_border_fade: without
+                // this the invisible catcher rects would keep intercepting
+                // scene hits on windows whose ring is not even drawn.
+                && self.is_seat_focused()
                 && !is_virtual_border
                 && bw > 0
                 && (cw as f64 * sc) >= 12.0