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

commit89eb539795cf6ce2768313e1a825b22cb2100c5a
parent97638239d6
authorLucas Galante <[email protected]>
date2026-09-18 00:13
fix(input): Super-held adjust mode targets the hovered window; a drag never focuses

Pressing Super now puts the ring and handles on the window under the
pointer, focused or not (`Cursor::adjust_hover`, set by passthrough),
and the ring follows the pointer from window to window. Overview keeps
keying the ring on focus (hover-to-focus). The three sites that gated
on `is_seat_focused` — the reveal, the drawn handles and catchers, the
hit test — ask one predicate, `Window::is_adjust_target`.

Moving or resizing a window no longer focuses it: the body grab, the
border grabs and the pointer-bind grab call no `seat.focus`; the raise
a Floating window used to get from that focus is done in
`op_start_pointer`. A tap (press+release without motion) is a click and
focuses in `op_end`, keeping its focus-follow pan.

Verified in a shadow with two floating windows: ring lands on the
unfocused hovered window and not the focused one; a body drag and a
band resize move/resize it with focus unchanged; a tap focuses it;
hovering the other window swaps the ring; releasing Super clears both.

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

 CLAUDE.md            |  20 +++++++---
 src/server/cursor.rs | 102 +++++++++++++++++++++++++++++++++++++++------------
 src/server/seat.rs   |  26 +++++++------
 src/server/window.rs |  66 +++++++++++++++++++++++++++------
 4 files changed, 162 insertions(+), 52 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 3470cbf..1f38417 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -427,10 +427,16 @@ where the old band sat outside them.
   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.
 - **Holding Super is window-adjust mode at zoom 1**: the same handles and
-  body-drag as overview (but NOT hover-to-focus — the frame stays on the
-  focused window, so a focus chord pressed next acts on the window the
-  user had), gated by one predicate,
+  body-drag as overview, gated by one predicate,
   `WindowManager::window_adjust_active()` (overview OR `adjust_held`).
+  But NOT hover-to-focus: the ring lands on the window **under the
+  pointer** (`Cursor::adjust_hover`, set by `passthrough`), focused or not,
+  and focus stays put — so pressing Super arms whatever the pointer is
+  already on, and a focus chord pressed next acts on the window the user
+  had. **A drag never focuses the window it moves or resizes** (the grab
+  paths in `handle_button` call no `seat.focus`; `op_start_pointer` raises
+  a Floating one instead); a tap on the band or body — press+release
+  without motion — is a click and focuses in `op_end`.
   `adjust_held` is refreshed from the keyboard's modifier mask on every
   modifiers event (`refresh_adjust_held`), which also re-runs the pointer
   passthrough so the ring lands under a still pointer on key-down and the
@@ -438,9 +444,11 @@ where the old band sat outside them.
   shadow (injection bypasses the device mask, so it keeps its own flag).
   A background press with Super held is an ordinary desktop press — only
   overview exits on it.
-- 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
+- Handles are shown on the **adjust target only** — `Window::is_adjust_target`:
+  the focused window in overview, the hovered one with Super held — for as
+  long as the mode is on (`step_border_fade`'s `all_on` branch, `draw_borders`'
+  `handles_live`, and `get_border_zone` all ask it). **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
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 35f7382..32e0eb4 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -123,6 +123,14 @@ pub struct Cursor {
     pub hovered_border_window: *mut crate::window::Window,
     /// Which of that window's 8 border zones is highlighted.
     pub hovered_border_element: Option<crate::window::BorderElement>,
+    /// The toplevel under the pointer while Super-held adjust mode is on:
+    /// the window the ring lands on and whose handles are live, focused or
+    /// not. Set by `passthrough` on every hover evaluation and cleared when
+    /// the pointer rests on nothing adjustable or the mode is off. Overview
+    /// keys the ring on focus instead — see `Window::is_adjust_target`. May
+    /// dangle after a close: compared by address only, and nulled in
+    /// `Window::destroy`.
+    pub adjust_hover: *mut crate::window::Window,
     pub right_click_on_bg: bool,
     pub right_click_on_border: bool,
     pub left_click_on_bg_in_overview: bool,
@@ -197,6 +205,7 @@ impl Default for Cursor {
             last_click_window: std::ptr::null_mut(),
             hovered_border_window: std::ptr::null_mut(),
             hovered_border_element: None,
+            adjust_hover: std::ptr::null_mut(),
             right_click_on_bg: false,
             right_click_on_border: false,
             left_click_on_bg_in_overview: false,
@@ -690,6 +699,23 @@ impl Cursor {
 
         log::debug!("entering cursor mode op");
         ffi::wlr_seat_pointer_notify_clear_focus((*self.seat).wlr_seat);
+
+        // A grab does not focus the window it moves or resizes, and it was
+        // the press's `seat.focus` that used to raise a Floating one — so
+        // the raise happens here for whatever the op grabbed. (A window
+        // un-tiled by the drag is raised in `op_update` instead.)
+        let grabbed = match &(*self.seat).op {
+            Some(op) => op.window_ptr,
+            None => std::ptr::null_mut(),
+        };
+        if !grabbed.is_null()
+            && !(*grabbed).closed
+            && !(*grabbed).is_status_bar()
+            && (*grabbed).tiling_mode == crate::tiling::TilingMode::Floating
+        {
+            (*(*self.seat).server).wm.raise_window(grabbed);
+            (*(*self.seat).server).wm.dirty_windowing();
+        }
     }
 
     pub unsafe fn op_end_pointer(&mut self) {
@@ -732,6 +758,17 @@ impl Cursor {
         (*(*self.seat).server).wm.arm_border_fade();
     }
 
+    /// Move the Super-held adjust target to `target` (null to clear). The
+    /// ring eases off the old window and onto the new one through the same
+    /// border fade a hover swap uses, so a change arms that timer.
+    pub unsafe fn set_adjust_hover(&mut self, target: *mut crate::window::Window) {
+        if self.adjust_hover == target {
+            return;
+        }
+        self.adjust_hover = target;
+        (*(*self.seat).server).wm.arm_border_fade();
+    }
+
     pub unsafe fn passthrough(&mut self, time_msec: u32) {
         let lx = self.x();
         let ly = self.y();
@@ -798,12 +835,14 @@ impl Cursor {
             if lock_state != crate::lock_manager::LockState::Unlocked {
                 if !matches!(result.data, SceneNodeDataVal::LockSurface(_)) {
                     self.set_border_hover(std::ptr::null_mut(), None);
+                    self.set_adjust_hover(std::ptr::null_mut());
                     self.clear_focus();
                     return;
                 }
             } else {
                 if matches!(result.data, SceneNodeDataVal::LockSurface(_)) {
                     self.set_border_hover(std::ptr::null_mut(), None);
+                    self.set_adjust_hover(std::ptr::null_mut());
                     self.clear_focus();
                     return;
                 }
@@ -826,6 +865,16 @@ impl Cursor {
                         is_window = true;
                         hovered_toplevel = window;
                     }
+                    // Super held at zoom 1: the ring lands on the window
+                    // under the pointer, focused or not. Set BEFORE the zone
+                    // test below, so the band is live on the first hover.
+                    // (Null for the status bar, wallpaper and grid; overview
+                    // keys the ring on focus and ignores this.)
+                    self.set_adjust_hover(if (*server).wm.window_adjust_active() {
+                        hovered_toplevel
+                    } else {
+                        std::ptr::null_mut()
+                    });
                     // No mode gate: the band scales with the window
                     // (get_border_zone is zoom-aware), so the resize/move
                     // controls reveal and work at any zoom, not just 1.
@@ -855,8 +904,11 @@ impl Cursor {
                 }
                 SceneNodeDataVal::ShellSurface(_) | SceneNodeDataVal::OverrideRedirect(_) => {
                     is_window = true;
+                    self.set_adjust_hover(std::ptr::null_mut());
+                }
+                _ => {
+                    self.set_adjust_hover(std::ptr::null_mut());
                 }
-                _ => {}
             }
             self.set_border_hover(std::ptr::null_mut(), None);
 
@@ -876,9 +928,9 @@ impl Cursor {
                 && !hovered_chrome
                 && (*server).wm.window_adjust_active()
             {
-                // 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. (Super-held adjust mode takes this
+                // Focus follows the pointer in overview: there the ring is
+                // drawn on the focused window only, so hovering is how it
+                // moves between windows without a click. (Super-held adjust mode takes this
                 // branch too, for the pointer-focus clear below, but not the
                 // refocus.) Guarded on an actual change —
                 // seat.focus raises a Floating window BEFORE its same-focus
@@ -894,9 +946,10 @@ impl Cursor {
                 // launcher's own first configure — so a stationary pointer
                 // resting on a world window was refocusing that window and
                 // dismissing the launcher the instant it mapped.
-                // Overview only: with Super held at zoom 1 the frame stays
-                // on the focused window, so a focus chord pressed next acts
-                // on the window the user had, not the one under the pointer.
+                // Overview only: with Super held at zoom 1 focus stays put,
+                // so a focus chord pressed next acts on the window the user
+                // had — the ring follows the pointer through `adjust_hover`
+                // instead (`Window::is_adjust_target`).
                 if (*server).wm.mode == crate::window_manager::WindowManagerMode::Overview
                     && !hovered_toplevel.is_null()
                     && !(*self.seat).focus_is_chrome()
@@ -939,6 +992,7 @@ impl Cursor {
         }
 
         self.set_border_hover(std::ptr::null_mut(), None);
+        self.set_adjust_hover(std::ptr::null_mut());
         self.clear_focus();
     }
 
@@ -1508,12 +1562,12 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
             if overview_chrome || clicked_grid {
                 // fall through
             } else if overview_win_valid && matches!(overview_border_zone, BorderZone::None) {
-                // Super-held at zoom 1: the grab focuses the window, as a
-                // normal press would — the frame moves to it for the drag.
-                // In overview hover already focused it.
-                if !in_overview {
-                    seat.focus(Focus::Window(clicked_win));
-                }
+                // The grab does NOT focus the window: moving a window is
+                // not choosing it, and the ring already sits on it through
+                // `adjust_hover`. A tap — press and release without motion
+                // — is a click and focuses in `op_end`; a Floating window
+                // is raised for the drag in `op_start_pointer`. (In
+                // overview hover already focused it.)
                 (*server).wm.stop_panning_animation();
                 let cursor_x = (*cursor.wlr_cursor).x;
                 let cursor_y = (*cursor.wlr_cursor).y;
@@ -1643,8 +1697,9 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                     (*target_win).tiling_mode = crate::tiling::TilingMode::Floating;
                     (*target_win).mode_locked = true;
                 }
-                seat.focus(Focus::Window(target_win));
-                
+                // No focus on the grab: a bound move/resize drag acts on
+                // the window under the pointer without choosing it (a tap
+                // focuses in `op_end`, the raise is in `op_start_pointer`).
                 let op_type = match pb.action {
                     crate::config::Action::Move => Some(crate::seat::PointerOpType::Move),
                     // The modifier binding is a resize path the border zones
@@ -1751,7 +1806,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                             (*border_target_win).mode_locked = true;
                         }
 
-                        seat.focus(Focus::Window(border_target_win));
+                        // No focus on the grab (see the body grab above).
                         (*server).wm.stop_panning_animation();
                         let cursor_x = (*cursor.wlr_cursor).x;
                         let cursor_y = (*cursor.wlr_cursor).y;
@@ -1827,7 +1882,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                             (*border_target_win).mode_locked = true;
                         }
 
-                        seat.focus(Focus::Window(border_target_win));
+                        // No focus on the grab (see the body grab above).
                         (*server).wm.stop_panning_animation();
                         let cursor_x = (*cursor.wlr_cursor).x;
                         let cursor_y = (*cursor.wlr_cursor).y;
@@ -3954,12 +4009,13 @@ 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() {
+    // The adjust target only, matching what draw_borders draws: the focused
+    // window in overview, the hovered one with Super held. A window showing
+    // no ring has no band, and a grab that is not drawn is the failure mode
+    // this file keeps warning about. Either way the pointer reaches a
+    // window's edge through its body — hover-to-focus in overview, the hover
+    // target at zoom 1 — so the band is live by the time it arrives.
+    if !(*window).is_adjust_target() {
         return BorderZone::None;
     }
 
diff --git a/src/server/seat.rs b/src/server/seat.rs
index f8d3aaf..c9ec1af 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -1611,20 +1611,24 @@ impl Seat {
                         (*self.server).wm.dirty_windowing();
                     }
                 }
-                // A border TAP — press+release without meaningful motion —
-                // is a click, not a drag. The press focused the window and
-                // killed any focus-follow pan (drag protection), which left
-                // a mostly-hidden window stranded: aiming at a thin content
+                // A TAP — press+release without meaningful motion — is a
+                // click, not a drag. A drag never focuses the window it
+                // moves or resizes (the press grabs without focusing), but
+                // a click on a window chooses it as any click does, so the
+                // tap focuses here. And it pans: the press killed any
+                // focus-follow pan (drag protection), which left a
+                // mostly-hidden window stranded — aiming at a thin content
                 // sliver at the screen edge, it is easy to land on the
-                // border band instead, focus the window, and see nothing
-                // happen. Restore the pan for taps; real drags (any actual
-                // motion) keep the camera still. An overview tap is
-                // excluded: its release already launched the exit flight
-                // centered on this window, and a second pan computed from
-                // the still-overview camera drags that flight off target.
+                // border band instead and see nothing happen. Real drags
+                // (any actual motion) keep the camera still. An overview
+                // tap is excluded: its release already launched the exit
+                // flight centered on this window, and a second pan computed
+                // from the still-overview camera drags that flight off
+                // target (hover focused it there anyway).
                 let dx = (op.x - op.start_x).abs();
                 let dy = (op.y - op.start_y).abs();
-                if dx < 4 && dy < 4 && !op.started_in_overview {
+                if dx < 4 && dy < 4 && !op.started_in_overview && !(*win).is_status_bar() {
+                    self.focus(Focus::Window(win));
                     self.focus_follow_pan(win);
                 }
             }
diff --git a/src/server/window.rs b/src/server/window.rs
index 64eafb9..4ed2b4b 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -2036,6 +2036,20 @@ impl Window {
         (*window).node.deinit();
 
         (*(*window).server).wm.remove_from_history(window);
+        // A seat cursor may still name this window as its adjust target.
+        // The next hover evaluation would replace it, but a window allocated
+        // at the same address in the meantime must not inherit the ring.
+        {
+            let seats = &mut (*(*window).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 (*seat).cursor.adjust_hover == window {
+                    (*seat).cursor.adjust_hover = std::ptr::null_mut();
+                }
+                curr = (*curr).next;
+            }
+        }
         (*(*window).server).wm.windows.remove((*window).ref_key);
         (*(*window).server).wm.check_clean_exit_progress();
 
@@ -3534,6 +3548,32 @@ impl Window {
         false
     }
 
+    /// Whether this is the window the adjust-mode ring and handles belong
+    /// to. In overview that is the focused window (hover-to-focus moves it
+    /// from window to window); with Super held at zoom 1 it is the toplevel
+    /// under some seat's pointer (`Cursor::adjust_hover`), focused or not —
+    /// pressing Super arms the window the pointer is already on, and a drag
+    /// on it never changes focus. The reveal (`step_border_fade`), the drawn
+    /// handles and catchers (`draw_borders`) and the hit test
+    /// (`cursor::get_border_zone`) all ask this one predicate, so the ring
+    /// cannot be drawn on one window and grabbed on another.
+    pub unsafe fn is_adjust_target(&self) -> bool {
+        if (*self.server).wm.mode == crate::window_manager::WindowManagerMode::Overview {
+            return self.is_seat_focused();
+        }
+        let me = self as *const Window as *mut Window;
+        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 (*seat).cursor.adjust_hover == me {
+                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;
@@ -3667,15 +3707,16 @@ impl Window {
     pub unsafe fn step_border_fade(&mut self) -> bool {
         let mut moving = false;
         let mut changed = false;
-        // 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.
+        // The adjust TARGET shows its whole ring for as long as the mode is
+        // on — the focused window in overview, the hovered one with Super
+        // held; other windows show nothing. Either way the ring follows the
+        // pointer from window to window, each swap easing through this same
+        // fade. Hover still reads through on the revealed ring, as
+        // `color_for` paints the hovered zone in hover_color over the full
+        // reveal.
         let all_on = (*self.server).wm.window_adjust_active()
             && window_takes_handles(self as *mut Window)
-            && self.is_seat_focused();
+            && self.is_adjust_target();
         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 };
@@ -4033,11 +4074,12 @@ impl Window {
             let (cw, ch) = (content.width, content.height);
             // A window thinner than two bands has no interior left for a
             // ring; drawing one would be a solid block over the whole window.
-            // Live handles: the mode is on and this is the focused window.
-            // 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.
-            let handles_live = in_overview && self.is_seat_focused();
+            // Live handles: the mode is on and this is the adjust target
+            // (focused in overview, hovered with Super held). Target-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.
+            let handles_live = in_overview && self.is_adjust_target();
             // Drawn handles: live, OR still fading out — releasing Super (or
             // leaving overview, or losing focus) eases the ring away instead
             // of cutting it, so the ring stays drawn while any reveal is