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

commit426a83d37954d9f6df1aef2be75962289ef6e89b
parent8c0d28b08b
authorLucas Galante <[email protected]>
date2026-09-22 12:33
fix(input): the swipe leans toward where the bind will take the camera

Switching between two windows both in view leaned the camera out and
sprang it back on every swipe: the lean went by the swipe's direction
alone, and the fire, finding no camera move to make, eased the lean home.

The lean now runs along the bind's predicted destination.
`WindowManager::predict_action_camera` runs the policy on the origin
camera and reads the pan its commands imply — a Focus through the new
`Seat::focus_pan_target`, the pure half of `focus_follow_pan`, so the two
cannot disagree; a PanTo; a SetCamera — once per heading. The lean is
proportional along that vector and never past it, and a bind that moves
the camera nowhere leans nothing, so the fire only ever continues the
motion the lean began.

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

 CLAUDE.md                    |  38 +++++++++-------
 src/server/cursor.rs         | 104 ++++++++++++++++++++++++++++---------------
 src/server/seat.rs           |  34 +++++++++-----
 src/server/window_manager.rs |  37 +++++++++++++++
 4 files changed, 150 insertions(+), 63 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index eff0e61..d4ecd1d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -524,22 +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 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 as it stood BEFORE the peek, so a focus lands where
-a keyed one would, and the ease then resumes from the peeked position; an
-action that sets no camera target is given the origin as one, or the peek
-would stick. A shadow drives it staged — `ccectl pointer-swipe begin 3`,
+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`,
 `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 ef87647..f60721b 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -101,6 +101,13 @@ 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,
@@ -198,6 +205,8 @@ 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,
@@ -3576,11 +3585,12 @@ 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 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.
+/// *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.
 
 /// Does firing `action` carry the view in the swipe's direction? Only
 /// such binds peek the camera beforehand: an overview toggle or a spawn
@@ -3590,17 +3600,20 @@ fn action_navigates(action: crate::config::Action) -> bool {
     matches!(action, FocusLeft | FocusRight | FocusUp | FocusDown | PanLeft | PanRight | PanUp | PanDown)
 }
 
-/// 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
-    }
+/// 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]
 }
 
 unsafe extern "C" fn handle_swipe_begin(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
@@ -3619,6 +3632,8 @@ 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);
 
@@ -3674,9 +3689,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;
-    // 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
+    // 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
 
     for gb in &(*seat.server).wm.gesture_binds {
         if gb.gesture_type == "swipe" && gb.fingers == (*event).fingers && gb.mods == modifiers {
@@ -3693,10 +3708,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 peeks.
+            // direction decides whether that way leans.
             if let Some(i) = slot {
-                if !navigates[i] && action_navigates(gb.action) {
-                    navigates[i] = true;
+                if nav_action[i].is_none() && action_navigates(gb.action) {
+                    nav_action[i] = Some(gb.action);
                 }
             }
         }
@@ -3781,22 +3796,41 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
         return;
     }
 
-    // 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.
+    // 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.
     {
         let wm = &mut (*seat.server).wm;
-        let peek_px = wm.swipe_peek_px;
         let (dx, dy) = (cursor.gesture_dx, cursor.gesture_dy);
-        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 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 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 17a18f4..5ddd701 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -1011,15 +1011,29 @@ 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;
+            return None;
         }
         // 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;
+            return None;
         }
         if window.is_null()
             || !matches!(
@@ -1031,11 +1045,11 @@ impl Seat {
                     | crate::tiling::TilingMode::Utility
             )
         {
-            return;
+            return None;
         }
         let app_id = (*window).get_app_id_string();
         if app_id.as_ref().map(|id| id == "cce-cloud").unwrap_or(false) {
-            return;
+            return None;
         }
         let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
         let mut curr_out = (*outputs_list).next;
@@ -1079,8 +1093,7 @@ impl Seat {
                 600.0
             };
 
-            let wm = &mut (*self.server).wm;
-            let cam = wm.camera();
+            let cam = (*self.server).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.
@@ -1092,7 +1105,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.
-            if let Some(target) = crate::policy::camera::pan_into_view(
+            return crate::policy::camera::pan_into_view(
                 (*window).virtual_x,
                 (*window).virtual_y,
                 vw_w,
@@ -1100,12 +1113,9 @@ 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 3ced192..a8c4731 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -4634,6 +4634,43 @@ 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();