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

commit701b32c973c0dd2ebe0c7860bbe115563f4c3d52
parent09071f7177
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-24 22:05
feat(gestures): keep stepping focus while a swipe continues

A three-finger focus swipe used to fire once and then ignore the rest of
the gesture. Now the accumulated travel restarts from zero at each fire,
so continuing without lifting steps focus again, and reversing steps back.

Steps after the first need window_manager { swipe_repeat_threshold }
(default four times swipe_threshold) so an ordinary swipe does not run on
through a second window. The factor comes from replaying a session's
logged swipes: single swipes travelled 150-250 units, and at twice the
threshold many stepped twice and then reversed to correct.

The lean after a step rides on the focus ease instead of freezing it, a
lift short of the next step eases only that lean back, and clients get a
single cancelled swipe_end at the first fire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                    | 22 +++++++++-
 src/server/config.rs         | 14 ++++++-
 src/server/cursor.rs         | 95 ++++++++++++++++++++++++++++++++------------
 src/server/window_manager.rs |  7 ++++
 4 files changed, 111 insertions(+), 27 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2ebc4cc0..8f38bec5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -539,7 +539,27 @@ fire eases a wobble back) and proportional to the travel —
 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
+a hesitant swipe shows where it would go without going. **A fire does
+not end the swipe** (since 2026-09-24): the accumulated travel restarts
+from zero at the fire, and a further `window_manager {
+swipe_repeat_threshold }` of travel (libinput units, default four times
+`swipe_threshold`; `WindowManager::swipe_repeat_threshold`) without
+lifting fires again — three windows over is one long swipe, with more
+resistance after the first step so it does not run on through the next
+window — and a reversal after a step goes straight back. The factor was
+two at first and read as too eager: replaying a session's logged swipes
+(every `handle_swipe_update` is logged at info with its delta, in
+`$XDG_RUNTIME_DIR/cce/cce.log`) showed ordinary single swipes travelling
+150-250 units, so many stepped twice and then reversed to correct
+(left-left-right-right); four times removes nearly all of those while a
+long deliberate swipe still steps again. libinput's swipe deltas are
+accelerated, so a fast flick covers far more travel than a slow push of
+the same length. The lean scales
+with whichever threshold is in force. The lean after a step rides on the focus ease
+the step started (its pan target moves with the fingers) instead of
+freezing it, and a lift short of the next threshold eases only that lean
+back out; the steps stay. Clients are sent one cancelled `swipe_end` at
+the first fire and hear nothing more of the gesture. 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
diff --git a/src/server/config.rs b/src/server/config.rs
index cbd4a8bc..c0910d43 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -454,6 +454,11 @@ pub struct WindowManagerConfig {
     /// Accumulated travel (libinput units, roughly mm) at which a swipe
     /// bind fires (default 50). KDL: `swipe_threshold (f64)50.0`.
     pub swipe_threshold: Option<f64>,
+    /// Travel each further fire of the same swipe needs after its first
+    /// (default four times `swipe_threshold`), so a swipe that has just
+    /// stepped focus meets more resistance before stepping again. KDL:
+    /// `swipe_repeat_threshold (f64)200.0`.
+    pub swipe_repeat_threshold: Option<f64>,
     /// Reverse the drag direction, on top of the natural-scroll correction
     /// the drag already makes. KDL: `touchpad_view_invert (bool)true`.
     pub touchpad_view_invert: Option<bool>,
@@ -2447,9 +2452,10 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let touchpad_view_sensitivity = get_child_arg_f64_opt(node, "touchpad_view_sensitivity");
         let swipe_peek = get_child_arg_f64_opt(node, "swipe_peek");
         let swipe_threshold = get_child_arg_f64_opt(node, "swipe_threshold");
+        let swipe_repeat_threshold = get_child_arg_f64_opt(node, "swipe_repeat_threshold");
         let touchpad_view_invert = get_child_arg_bool_opt(node, "touchpad_view_invert");
         let touchpad_hscroll_shift_apps = get_child_args_string_vec_opt(node, "touchpad_hscroll_shift_apps");
-        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi, xwayland_hidpi_except, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, swipe_peek, swipe_threshold, touchpad_view_invert, touchpad_hscroll_shift_apps });
+        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi, xwayland_hidpi_except, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, swipe_peek, swipe_threshold, swipe_repeat_threshold, touchpad_view_invert, touchpad_hscroll_shift_apps });
     }
 
     Ok(Config {
@@ -2531,6 +2537,10 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
             .and_then(|w| w.swipe_threshold)
             .filter(|v| v.is_finite() && *v > 0.0)
             .unwrap_or(50.0);
+        state.swipe_repeat_threshold = tv
+            .and_then(|w| w.swipe_repeat_threshold)
+            .filter(|v| v.is_finite() && *v > 0.0)
+            .unwrap_or(state.swipe_threshold * 4.0);
         state.touchpad_view_invert = tv.and_then(|w| w.touchpad_view_invert).unwrap_or(false);
         state.touchpad_hscroll_shift_apps = tv.and_then(|w| w.touchpad_hscroll_shift_apps.clone()).unwrap_or_default();
     }
@@ -3063,6 +3073,7 @@ style {
                 toggle_overview ("menu:swipe_up,swipe_down,swipe_left,swipe_right,pinch_in,pinch_out")"swipe_up"
                 swipe_peek (f64)40.0
                 swipe_threshold (f64)80.0
+                swipe_repeat_threshold (f64)200.0
             }
         "#;
         let config = parse_kdl_config(content).unwrap();
@@ -3073,6 +3084,7 @@ style {
         assert_eq!(wm.toggle_overview, Some("swipe_up".to_string()));
         assert_eq!(wm.swipe_peek, Some(40.0));
         assert_eq!(wm.swipe_threshold, Some(80.0));
+        assert_eq!(wm.swipe_repeat_threshold, Some(200.0));
         // Absent means "unset", which the apply step reads as the centring default.
         assert_eq!(wm.center_on_spawn, None);
     }
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index d24be7f6..c075f58c 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -96,10 +96,14 @@ pub struct Cursor {
     pub gesture_dx: f64,
     pub gesture_dy: f64,
     pub gesture_scale: f64,
+    /// A bind has fired during the in-flight swipe or pinch. For a swipe
+    /// it does not end the gesture — the travel restarts and can fire
+    /// again — it records that clients were sent a cancelled end and hear
+    /// nothing more of it; a pinch fires once.
     pub gesture_triggered: bool,
     /// Camera offset (virtual units, `[x, y]`) the in-flight swipe has
     /// 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.
+    /// swipe, and restarts from zero at each fire, like the travel.
     pub swipe_peek: [f64; 2],
     /// Finger count of a staged injected swipe (`pointer-swipe begin`),
     /// carried into its updates the way libinput repeats it per event.
@@ -3580,7 +3584,10 @@ unsafe extern "C" fn handle_touch_frame(listener: *mut ffi::wl_listener, _data:
 /// 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.
+/// three-finger swipe shows where it would go without going. Firing does
+/// not end the swipe: the travel restarts from zero, and each further
+/// `swipe_repeat_threshold` of travel (default four times the first) steps
+/// again — or back, on a reversal — until the fingers lift.
 
 /// Does firing `action` carry the view in the swipe's direction? Only
 /// such binds peek the camera beforehand: an overview toggle or a spawn
@@ -3646,11 +3653,11 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
     }
     seat.handle_activity();
 
-
-
-    if cursor.gesture_triggered {
-        return;
-    }
+    // A bind that already fired does not end the gesture: the travel
+    // restarts from zero at the fire (below), so the fingers can keep
+    // going and step focus again — or turn round and step back — without
+    // lifting. `gesture_triggered` only records that clients were sent
+    // their (cancelled) end, so they hear nothing more of this swipe.
 
     cursor.gesture_dx += (*event).dx;
     cursor.gesture_dy += (*event).dy;
@@ -3671,7 +3678,17 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
         0
     };
 
-    let threshold = (*seat.server).wm.swipe_threshold;
+    // The first step of a swipe comes at `swipe_threshold`; every further
+    // one at `swipe_repeat_threshold` (default four times that), so a swipe
+    // that has just switched focus meets resistance before switching
+    // again rather than running on through the next window. The lean
+    // scales with the threshold in force, so it stays a preview of how
+    // far the fingers are from the next step.
+    let threshold = if cursor.gesture_triggered {
+        (*seat.server).wm.swipe_repeat_threshold
+    } else {
+        (*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
@@ -3704,7 +3721,14 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
 
     if matched_action != crate::config::Action::None {
         log::info!("Swipe gesture matched action: {:?}", matched_action);
+        let first_fire = !cursor.gesture_triggered;
         cursor.gesture_triggered = true;
+        // The next step needs a full threshold of fresh travel from here,
+        // on both axes: a long swipe steps once per threshold, and a
+        // reversal after a step goes back rather than first having to
+        // undo the travel that got here.
+        cursor.gesture_dx = 0.0;
+        cursor.gesture_dy = 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
@@ -3765,14 +3789,16 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
             }
         }
 
-        let pointer_gestures = (*seat.server).input_manager.pointer_gestures;
-        if !pointer_gestures.is_null() {
-            ffi::wlr_pointer_gestures_v1_send_swipe_end(
-                pointer_gestures,
-                seat.wlr_seat,
-                (*event).time_msec,
-                true, // cancelled: true
-            );
+        if first_fire {
+            let pointer_gestures = (*seat.server).input_manager.pointer_gestures;
+            if !pointer_gestures.is_null() {
+                ffi::wlr_pointer_gestures_v1_send_swipe_end(
+                    pointer_gestures,
+                    seat.wlr_seat,
+                    (*event).time_msec,
+                    true, // cancelled: true
+                );
+            }
         }
         return;
     }
@@ -3796,12 +3822,25 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
         };
         let delta = [want[0] - cursor.swipe_peek[0], want[1] - cursor.swipe_peek[1]];
         if delta != [0.0, 0.0] {
-            wm.stop_panning_animation();
+            if cursor.gesture_triggered {
+                // After a step the camera may still be easing the window
+                // it focused into view. The lean rides on top of that ease
+                // rather than freezing it short: the ease's target moves
+                // with the fingers, and the lift (`handle_swipe_end`)
+                // moves it back.
+                if let Some(t) = wm.target_desk_pan_x.as_mut() { *t += delta[0]; }
+                if let Some(t) = wm.target_desk_pan_y.as_mut() { *t += delta[1]; }
+            } else {
+                wm.stop_panning_animation();
+            }
             wm.queue_pan(delta[0], delta[1]);
             cursor.swipe_peek = want;
         }
     }
 
+    if cursor.gesture_triggered {
+        return;
+    }
     let server = seat.server;
     let pointer_gestures = (*server).input_manager.pointer_gestures;
     if !pointer_gestures.is_null() {
@@ -3829,24 +3868,30 @@ unsafe extern "C" fn handle_swipe_end(listener: *mut ffi::wl_listener, data: *mu
 
     log::info!("handle_swipe_end: cancelled={}", (*event).cancelled);
 
-    if cursor.gesture_triggered {
-        cursor.gesture_triggered = false;
-        return;
-    }
+    let fired = std::mem::replace(&mut cursor.gesture_triggered, false);
 
-    // Lifted short of the threshold: ease the camera back to where the
-    // swipe found it.
+    // Lifted short of a threshold — the first, or the next one after a
+    // step — ease the lean back out: to where the swipe found the camera,
+    // or, after a step, to where the step's own ease was heading before
+    // the lean moved its target along (`handle_swipe_update`). The steps
+    // themselves stay: the camera never returns to where the swipe began.
     let peek = std::mem::replace(&mut cursor.swipe_peek, [0.0, 0.0]);
     if peek != [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];
-        wm.target_desk_pan_x = Some(wm.desk_pan_x - peek[0]);
-        wm.target_desk_pan_y = Some(wm.desk_pan_y - peek[1]);
+        wm.target_desk_pan_x = Some(wm.target_desk_pan_x.unwrap_or(wm.desk_pan_x) - peek[0]);
+        wm.target_desk_pan_y = Some(wm.target_desk_pan_y.unwrap_or(wm.desk_pan_y) - peek[1]);
         wm.start_panning_animation();
     }
 
+    // Clients heard a cancelled end at the first step; the lift after one
+    // is not theirs.
+    if fired {
+        return;
+    }
+
     let server = seat.server;
     let pointer_gestures = (*server).input_manager.pointer_gestures;
     if !pointer_gestures.is_null() {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 4c9a0d9e..0f3535a9 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -220,6 +220,11 @@ pub struct WindowManager {
     /// `window_manager { swipe_threshold }`: accumulated swipe travel
     /// (libinput units) at which a swipe bind fires (default 50).
     pub swipe_threshold: f64,
+    /// `window_manager { swipe_repeat_threshold }`: the travel each FURTHER
+    /// fire of the same swipe needs after its first (default four times
+    /// `swipe_threshold`) — the resistance that keeps a swipe from
+    /// running on through a second window.
+    pub swipe_repeat_threshold: f64,
     /// See `WindowManagerConfig::touchpad_hscroll_shift_apps`.
     pub touchpad_hscroll_shift_apps: Vec<String>,
     /// Live override-redirect X11 surfaces (menus, tooltips, combo lists),
@@ -532,6 +537,7 @@ impl WindowManager {
         self.touchpad_view_sensitivity = 1.0;
         self.swipe_peek_px = 60.0;
         self.swipe_threshold = 50.0;
+        self.swipe_repeat_threshold = 200.0;
         self.touchpad_view_invert = false;
         self.touchpad_hscroll_shift_apps = Vec::new();
         self.display = std::collections::HashMap::new();
@@ -614,6 +620,7 @@ impl WindowManager {
         self.touchpad_view_sensitivity = 1.0;
         self.swipe_peek_px = 60.0;
         self.swipe_threshold = 50.0;
+        self.swipe_repeat_threshold = 200.0;
         self.touchpad_view_invert = false;
         self.touchpad_hscroll_shift_apps = Vec::new();
         self.display = std::collections::HashMap::new();