Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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