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

commitd03cd0628ef7b79943c2fe967291a1a4f6f870a7
parent426a83d379
authorLucas Galante <[email protected]>
date2026-09-22 12:47
fix(input): the swipe lean never reverses at the fire

Back to a blind lean along the swipe's dominant axis — the predicted
lean of 426a83d is retired: the lean answers the finger, not the layout.

What changes is the fire. The action now runs against the camera where
the lean left it, and the camera never reverses from there: a window
that needs a pan gets its ease from the lean onward, a window already in
view sets no target and the camera simply stops where the lean left it,
and a target on the leaned axis that would head back toward where the
swipe began is dropped. The spring back to the origin — which made every
switch between two visible windows lean out and back — is gone; a
release short of the threshold still eases home.

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

 CLAUDE.md                    |  44 ++++++-------
 src/server/cursor.rs         | 154 ++++++++++++++++---------------------------
 src/server/seat.rs           |  34 ++++------
 src/server/window_manager.rs |  37 -----------
 4 files changed, 92 insertions(+), 177 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index d4ecd1d..2e56a6a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -524,28 +524,28 @@ same.
 directional focus or pan (`focus_left (gesture)"swipe3_left"` in input.kdl)
 fires once the accumulated travel passes `window_manager { swipe_threshold }`
 (libinput units, default 50; `WindowManager::swipe_threshold`). Short of
-that the camera *leans* toward where the bind the swipe is heading for
-would take it: `WindowManager::predict_action_camera` runs the policy on
-the origin camera and reads the destination off its commands (a `Focus`
-through `Seat::focus_pan_target` — the pure half of `focus_follow_pan`, so
-the two cannot disagree — a `PanTo`, a `SetCamera`), once per heading
-(the swipe's dominant axis; a hand's sideways drift picks no heading of
-its own). The lean runs along that vector 1:1 with the fingers,
-proportional to the travel — `window_manager { swipe_peek }` screen px at
-the threshold (default 60, 0 disables; `WindowManager::swipe_peek_px` —
-not under `input`, whose config.kdl block input.kdl's replaces wholesale)
-— and never past the destination; a bind that moves the camera nowhere
-(both windows in view) leans nothing, where an unpredicted lean sprang
-out and back on every such switch. It eases back to where it started if
-the fingers lift first (`handle_swipe_end`), so a hesitant swipe shows
-where it would go without going. Only binds whose action
-`cursor::action_navigates` (focus/pan left/right/up/down) lean; a
-four-finger overview toggle leaves the desktop still. When the bind fires
-(`handle_swipe_update`), the action runs against the camera as it stood
-BEFORE the lean, so a focus lands where a keyed one would, and the ease
-then resumes from the leaned position along the same vector; an action
-that sets no camera target is given the origin as one, or a lean would
-stick. A shadow drives it staged — `ccectl pointer-swipe begin 3`,
+that the camera *leans* toward the bind the
+swipe is heading for, 1:1 with the fingers along the swipe's dominant axis
+only (a hand's sideways drift must not lean the camera vertically, or the
+fire eases a wobble back) and proportional to the travel —
+`window_manager { swipe_peek }` screen px at the threshold (default 60, 0
+disables; `WindowManager::swipe_peek_px` — not under `input`, whose
+config.kdl block input.kdl's replaces wholesale), clamped there — and eases
+back to where it started if the fingers lift first (`handle_swipe_end`), so
+a hesitant swipe shows where it would go without going. Only binds whose
+action `cursor::action_navigates` (focus/pan left/right/up/down) peek, and
+only toward a direction that has one; a four-finger overview toggle leaves
+the desktop still. When the bind fires (`handle_swipe_update`) the action
+runs against the camera where the lean left it, and **the camera never
+reverses at the fire**: a window that needs a pan gets its ease from
+there, a window already in view sets no target and the camera simply stops
+where the lean left it, and a target on the leaned axis that would head
+back toward where the swipe began is dropped. It does not predict the
+destination (tried on 2026-09-22 — a lean along the policy's predicted pan,
+nothing at all when the target was in view — and retired the same day: the
+lean is meant to answer the finger, not the layout), and it does not spring
+back to the origin (the first version did, and a switch between two
+windows both in view leaned out and back on every swipe). A shadow drives it staged — `ccectl pointer-swipe begin 3`,
 `update <dx> <dy>`, `end` — and reads the lean and its return back with
 `ccectl camera` (pan, zoom, pan target).
 
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index f60721b..d24be7f 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -101,13 +101,6 @@ pub struct Cursor {
     /// peeked the desktop by so far — see `swipe_peek_for`. Zero outside a
     /// swipe, and once the swipe's bind has fired or the fingers lifted.
     pub swipe_peek: [f64; 2],
-    /// Where the in-flight swipe's bind would send the camera, as an offset
-    /// from the camera the swipe found (virtual units), predicted for the
-    /// swipe's dominant direction (`swipe_dest_dir`: 0 left, 1 right, 2 up,
-    /// 3 down) — the vector the lean runs along. `None` when that bind
-    /// moves the camera nowhere, or there is no navigating bind that way.
-    pub swipe_dest: Option<(f64, f64)>,
-    pub swipe_dest_dir: Option<usize>,
     /// Finger count of a staged injected swipe (`pointer-swipe begin`),
     /// carried into its updates the way libinput repeats it per event.
     pub inject_swipe_fingers: u32,
@@ -205,8 +198,6 @@ impl Default for Cursor {
             gesture_scale: 1.0,
             gesture_triggered: false,
             swipe_peek: [0.0, 0.0],
-            swipe_dest: None,
-            swipe_dest_dir: None,
             inject_swipe_fingers: 3,
             panning_gesture_active: false,
             inject_natural: false,
@@ -3585,12 +3576,11 @@ unsafe extern "C" fn handle_touch_frame(listener: *mut ffi::wl_listener, _data:
 /// A directional swipe bind fires once the accumulated travel (libinput
 /// units) passes `window_manager { swipe_threshold }`
 /// (`WindowManager::swipe_threshold`, default 50). Until then the camera
-/// *peeks*: it pans toward where the bind would take it
-/// (`WindowManager::predict_action_camera`) in proportion to the travel,
-/// up to `window_manager { swipe_peek }` screen px
-/// (`WindowManager::swipe_peek_px`, default 60) at the threshold and never
-/// past the destination, and eases back if the fingers lift short of it —
-/// so a hesitant three-finger swipe shows where it would go without going.
+/// *peeks*: it pans toward the swipe direction in proportion to the
+/// travel, up to `window_manager { swipe_peek }` screen px
+/// (`WindowManager::swipe_peek_px`, default 60) at the threshold, and
+/// eases back if the fingers lift short of it — so a hesitant
+/// three-finger swipe shows where it would go without going.
 
 /// Does firing `action` carry the view in the swipe's direction? Only
 /// such binds peek the camera beforehand: an overview toggle or a spawn
@@ -3600,20 +3590,17 @@ fn action_navigates(action: crate::config::Action) -> bool {
     matches!(action, FocusLeft | FocusRight | FocusUp | FocusDown | PanLeft | PanRight | PanUp | PanDown)
 }
 
-/// The lean the accumulated travel `d` along the dominant axis calls for,
-/// in virtual units: along the predicted destination vector `dest`,
-/// proportional to the travel and clamped at `threshold` (`peek_px` on
-/// screen there), and never past the destination itself — a window a few
-/// pixels off the edge is leaned those few pixels, not the full peek and
-/// back.
-fn swipe_lean_for(d: f64, dest: (f64, f64), threshold: f64, peek_px: f64, zoom: f64) -> [f64; 2] {
-    let dist = dest.0.hypot(dest.1);
-    if dist <= 0.0 {
-        return [0.0, 0.0];
-    }
-    let len = (d.abs() / threshold).min(1.0) * peek_px / zoom.max(1e-6);
-    let s = len.min(dist) / dist;
-    [dest.0 * s, dest.1 * s]
+/// The peek the accumulated travel `d` along one axis calls for, in
+/// virtual units: proportional and clamped at `threshold`, `peek_px` on
+/// screen there, and only toward a direction that has a navigating bind
+/// (`neg` / `pos`) — a swipe with nothing bound its way leaves the
+/// desktop still.
+fn swipe_peek_for(d: f64, neg: bool, pos: bool, threshold: f64, peek_px: f64, zoom: f64) -> f64 {
+    if (d < 0.0 && neg) || (d > 0.0 && pos) {
+        (d / threshold).clamp(-1.0, 1.0) * peek_px / zoom.max(1e-6)
+    } else {
+        0.0
+    }
 }
 
 unsafe extern "C" fn handle_swipe_begin(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
@@ -3632,8 +3619,6 @@ unsafe extern "C" fn handle_swipe_begin(listener: *mut ffi::wl_listener, data: *
     cursor.gesture_dy = 0.0;
     cursor.gesture_triggered = false;
     cursor.swipe_peek = [0.0, 0.0];
-    cursor.swipe_dest = None;
-    cursor.swipe_dest_dir = None;
 
     log::info!("handle_swipe_begin: fingers={}", (*event).fingers);
 
@@ -3689,9 +3674,9 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
     let threshold = (*seat.server).wm.swipe_threshold;
     let mut matched_action = crate::config::Action::None;
     let mut matched_command = None;
-    // The navigating bind this finger count + chord would fire in each
-    // direction — what the camera may lean toward.
-    let mut nav_action: [Option<crate::config::Action>; 4] = [None; 4]; // left, right, up, down
+    // Which directions this finger count + chord could still fire a
+    // navigating bind in — the directions the camera may peek toward.
+    let mut navigates = [false; 4]; // left, right, up, down
 
     for gb in &(*seat.server).wm.gesture_binds {
         if gb.gesture_type == "swipe" && gb.fingers == (*event).fingers && gb.mods == modifiers {
@@ -3708,10 +3693,10 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
                 break;
             }
             // First match wins in this table, so only the first bind per
-            // direction decides whether that way leans.
+            // direction decides whether that way peeks.
             if let Some(i) = slot {
-                if nav_action[i].is_none() && action_navigates(gb.action) {
-                    nav_action[i] = Some(gb.action);
+                if !navigates[i] && action_navigates(gb.action) {
+                    navigates[i] = true;
                 }
             }
         }
@@ -3721,24 +3706,16 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
         log::info!("Swipe gesture matched action: {:?}", matched_action);
         cursor.gesture_triggered = true;
 
-        // Hand the action the camera as it stood before the peek, so a
-        // focus lands exactly where a keyed one would; then resume from
-        // the peeked position so the ease runs from where the screen is.
-        // An action that leaves the camera alone still gets a target: the
-        // origin, so the peek eases back instead of sticking.
-        let peek = std::mem::replace(&mut cursor.swipe_peek, [0.0, 0.0]);
-        let peeked = if peek != [0.0, 0.0] {
+        // The lean is where the camera IS now: the action runs against
+        // it, and whatever it asks for is applied from here. The one rule
+        // is that the camera never reverses at the fire — see below.
+        let lean = std::mem::replace(&mut cursor.swipe_peek, [0.0, 0.0]);
+        if lean != [0.0, 0.0] {
             let wm = &mut (*seat.server).wm;
             wm.desk_pan_x += wm.pan_pending[0];
             wm.desk_pan_y += wm.pan_pending[1];
             wm.pan_pending = [0.0, 0.0];
-            let peeked = (wm.desk_pan_x, wm.desk_pan_y);
-            wm.desk_pan_x -= peek[0];
-            wm.desk_pan_y -= peek[1];
-            Some(peeked)
-        } else {
-            None
-        };
+        }
 
         if matched_action == crate::config::Action::Overview && (*seat.server).wm.mode == crate::window_manager::WindowManagerMode::Overview {
             let lx = cursor.x();
@@ -3766,21 +3743,25 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
         };
         (*seat.server).wm.execute_action(&matched_action, matched_command.as_deref());
 
-        if let Some((px, py)) = peeked {
+        // No reversal at the fire. The action set a pan target if the
+        // window it focused needs one (from the leaned camera — so a
+        // window the lean already brought fully into view asks for
+        // nothing, and the camera stops right here); it set none if the
+        // window is already in view, and then the camera stays where the
+        // lean left it rather than springing back. Either way the lean
+        // only ever continues in its own direction: a target on the leaned
+        // axis that lies back toward where the swipe began is dropped.
+        {
             let wm = &mut (*seat.server).wm;
-            let origin = (px - peek[0], py - peek[1]);
-            // An action that set the camera outright (no ease) owns it now.
-            if (wm.desk_pan_x, wm.desk_pan_y) == origin {
-                if let Some(ramp) = wm.camera_ramp_anim.as_mut() {
-                    ramp.start.pan_x = px;
-                    ramp.start.pan_y = py;
-                } else {
-                    wm.target_desk_pan_x.get_or_insert(origin.0);
-                    wm.target_desk_pan_y.get_or_insert(origin.1);
+            for (axis, target) in [(0usize, &mut wm.target_desk_pan_x), (1usize, &mut wm.target_desk_pan_y)] {
+                let here = if axis == 0 { wm.desk_pan_x } else { wm.desk_pan_y };
+                if lean[axis] != 0.0 {
+                    if let Some(t) = *target {
+                        if (t - here) * lean[axis].signum() < 0.0 {
+                            *target = None;
+                        }
+                    }
                 }
-                wm.desk_pan_x = px;
-                wm.desk_pan_y = py;
-                wm.start_panning_animation();
             }
         }
 
@@ -3796,41 +3777,22 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
         return;
     }
 
-    // Short of the threshold: lean the camera toward where the bind the
-    // swipe is heading for would take it, 1:1 with the fingers like a
-    // two-finger pan (queued for the frame, no easing), recomputed from
-    // the total travel so a reversal leans back through zero. The heading
-    // is the swipe's DOMINANT axis — a hand swiping left drifts a little
-    // up or down as well, and that drift must not lean anything — and the
-    // destination is predicted once per heading, from the camera as the
-    // swipe found it, so the lean runs along the very vector the fire
-    // then eases the rest of. A bind that moves the camera nowhere (both
-    // windows in view) leans nothing: there is nothing to preview, and a
-    // lean would only spring back.
+    // Short of the threshold: lean the camera toward the bind the swipe
+    // is heading for, 1:1 with the fingers like a two-finger pan (queued
+    // for the frame, no easing), recomputed from the total travel so a
+    // reversal leans back through zero. Along the DOMINANT axis only: a
+    // hand swiping left drifts a little up or down as well, and with
+    // up/down binds present that drift leaned the camera vertically too,
+    // then eased it back when the bind fired — a wobble on top of the
+    // real move.
     {
         let wm = &mut (*seat.server).wm;
+        let peek_px = wm.swipe_peek_px;
         let (dx, dy) = (cursor.gesture_dx, cursor.gesture_dy);
-        let dir = if dx.abs() >= dy.abs() { if dx < 0.0 { 0 } else { 1 } } else if dy < 0.0 { 2 } else { 3 };
-        if cursor.swipe_dest_dir != Some(dir) {
-            cursor.swipe_dest_dir = Some(dir);
-            cursor.swipe_dest = nav_action[dir].and_then(|action| {
-                // Predict from the origin: rewind the lean for the call.
-                wm.desk_pan_x += wm.pan_pending[0];
-                wm.desk_pan_y += wm.pan_pending[1];
-                wm.pan_pending = [0.0, 0.0];
-                let peek = cursor.swipe_peek;
-                wm.desk_pan_x -= peek[0];
-                wm.desk_pan_y -= peek[1];
-                let origin = (wm.desk_pan_x, wm.desk_pan_y);
-                let dest = wm.predict_action_camera(action).map(|(x, y)| (x - origin.0, y - origin.1));
-                wm.desk_pan_x += peek[0];
-                wm.desk_pan_y += peek[1];
-                dest
-            });
-        }
-        let want = match cursor.swipe_dest {
-            Some(dest) => swipe_lean_for(if dir < 2 { dx } else { dy }, dest, threshold, wm.swipe_peek_px, wm.desk_zoom),
-            None => [0.0, 0.0],
+        let want = if dx.abs() >= dy.abs() {
+            [swipe_peek_for(dx, navigates[0], navigates[1], threshold, peek_px, wm.desk_zoom), 0.0]
+        } else {
+            [0.0, swipe_peek_for(dy, navigates[2], navigates[3], threshold, peek_px, wm.desk_zoom)]
         };
         let delta = [want[0] - cursor.swipe_peek[0], want[1] - cursor.swipe_peek[1]];
         if delta != [0.0, 0.0] {
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 5ddd701..17a18f4 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -1011,29 +1011,15 @@ impl Seat {
     /// not desk citizens, so other modes no-op, as do cce-cloud and windows
     /// already fully visible.
     pub unsafe fn focus_follow_pan(&mut self, window: *mut crate::window::Window) {
-        if let Some(target) = self.focus_pan_target(window) {
-            let wm = &mut (*self.server).wm;
-            wm.target_desk_pan_x = Some(target.pan_x);
-            wm.target_desk_pan_y = Some(target.pan_y);
-            wm.start_panning_animation();
-        }
-    }
-
-    /// Where `focus_follow_pan` would send the camera for `window`, from
-    /// the camera as it stands — `None` when focusing it moves nothing.
-    /// Split out so a swipe can lean toward the destination BEFORE it
-    /// fires (`WindowManager::predict_action_camera`); every guard the
-    /// pan applies is here, so the two agree.
-    pub unsafe fn focus_pan_target(&self, window: *mut crate::window::Window) -> Option<crate::policy::camera::Camera> {
         if self.suppress_focus_pan {
-            return None;
+            return;
         }
         // While a camera ramp owns the camera (an overview enter/exit
         // flight), the current camera is a mid-flight sample — any pan
         // target computed from it is stale by construction. Never retarget
         // out from under the ramp.
         if (*self.server).wm.camera_ramp_anim.is_some() {
-            return None;
+            return;
         }
         if window.is_null()
             || !matches!(
@@ -1045,11 +1031,11 @@ impl Seat {
                     | crate::tiling::TilingMode::Utility
             )
         {
-            return None;
+            return;
         }
         let app_id = (*window).get_app_id_string();
         if app_id.as_ref().map(|id| id == "cce-cloud").unwrap_or(false) {
-            return None;
+            return;
         }
         let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
         let mut curr_out = (*outputs_list).next;
@@ -1093,7 +1079,8 @@ impl Seat {
                 600.0
             };
 
-            let cam = (*self.server).wm.camera();
+            let wm = &mut (*self.server).wm;
+            let cam = wm.camera();
             // box_geom is already virtual units (its screen footprint is
             // box_geom * zoom) — dividing by zoom here inflated the window
             // whenever zoom != 1 and mistargeted the pan.
@@ -1105,7 +1092,7 @@ impl Seat {
             // `policy::camera::pan_into_view` carries the reasoning, and
             // `WindowManager::pan_to_virtual_rect` applies it to the
             // non-window rects (restore placeholders) from the same place.
-            return crate::policy::camera::pan_into_view(
+            if let Some(target) = crate::policy::camera::pan_into_view(
                 (*window).virtual_x,
                 (*window).virtual_y,
                 vw_w,
@@ -1113,9 +1100,12 @@ impl Seat {
                 cam,
                 viewport_w,
                 viewport_h,
-            );
+            ) {
+                wm.target_desk_pan_x = Some(target.pan_x);
+                wm.target_desk_pan_y = Some(target.pan_y);
+                wm.start_panning_animation();
+            }
         }
-        None
     }
 
     pub unsafe fn make_inert(&mut self) {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index a8c4731..3ced192 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -4634,43 +4634,6 @@ impl WindowManager {
         }
     }
 
-    /// Where `execute_action(action)` would send the camera, from the
-    /// camera as it stands: the pan the policy's commands imply — a focus
-    /// through `Seat::focus_pan_target`, a `PanTo`, a `SetCamera` — or
-    /// `None` when the action moves the camera nowhere. Pure: nothing is
-    /// applied. A swipe leans toward this before it fires, so a switch
-    /// between two windows both in view leans nothing instead of leaning
-    /// out and springing back.
-    pub unsafe fn predict_action_camera(&mut self, action: crate::config::Action) -> Option<(f64, f64)> {
-        use crate::policy::api::{Command, Policy};
-        let ctx = self.build_action_ctx();
-        let cmds = crate::policy::actions::DefaultPolicy.action(&ctx, action, None);
-        let cam = self.camera();
-        let mut dest: Option<(f64, f64)> = None;
-        for cmd in &cmds {
-            match cmd {
-                Command::Focus(id) => {
-                    if let Some(&win) = self.windows.get(id.0) {
-                        if !win.is_null() && !(*win).closed {
-                            if let Some(seat) = self.first_seat() {
-                                if let Some(t) = (*seat).focus_pan_target(win) {
-                                    dest = Some((t.pan_x, t.pan_y));
-                                }
-                            }
-                        }
-                    }
-                }
-                Command::PanTo { x, y } => {
-                    let base = dest.unwrap_or((cam.pan_x, cam.pan_y));
-                    dest = Some((x.unwrap_or(base.0), y.unwrap_or(base.1)));
-                }
-                Command::SetCamera { camera, .. } => dest = Some((camera.pan_x, camera.pan_y)),
-                _ => {}
-            }
-        }
-        dest.filter(|d| *d != (cam.pan_x, cam.pan_y))
-    }
-
     pub unsafe fn execute_action(&mut self, action: &crate::config::Action, command: Option<&str>) {
         use crate::config::Action;
         self.stop_panning_animation();