Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
fix(swipe): a focus step with nowhere to go holds the lean instead of firing
8725a4b5 let such a step fire and then eased its lean out, which snapped
the camera back while the fingers were still going out -- and again at
every repeat threshold, a sawtooth jitter for as long as the swipe ran on.
The step now asks the policy first (WindowManager::focus_toward_lands, no
side effects, once per swipe via Cursor::swipe_dead_end) and, with no
window that way, does not fire: the lean holds at its limit, the travel is
scaled back onto the threshold so a reversal unwinds it at once, and the
lift eases it out as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 16 ++++++----
src/server/cursor.rs | 72 +++++++++++++++++++++++++++-----------------
src/server/window_manager.rs | 16 ++++++++++
3 files changed, 70 insertions(+), 34 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8579513e..af4c9eb8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -768,12 +768,16 @@ and `WindowManager::focus_toward` hands it to the policy crate's
`vector_focus`: the nearest window center within `window_manager {
swipe_focus_cone }` degrees (default 45; `swipe_focus_cone_deg`) of a ray
from the focused window's center takes focus, and a swipe toward nothing
-in the cone changes nothing (logged `focus_toward …: no window within`) —
-the camera included: such a step puts back the previous step's pan into
-view, which both action paths stop up front, and eases its own lean out as
-a lift would. Until 2026-10-06 a long swipe run on past the last window
-froze that pan part way and kept each step's lean, walking the camera off
-the window it had just focused.
+in the cone does not fire at all: `WindowManager::focus_toward_lands` asks
+the policy first, side-effect free, and on no the lean holds at its limit
+like a wall (`Cursor::swipe_dead_end`, asked once per swipe), with the
+travel scaled back onto the threshold so turning the fingers round unwinds
+the lean at once; the lift eases it out. Until 2026-10-06 such a step
+fired: first it froze the previous step's pan part way and kept each
+step's lean, walking the camera off the window it had just focused, and
+then (fixed that morning by easing the lean out at the fire) it snapped
+the camera back while the fingers were still going out, every threshold —
+a sawtooth jitter for as long as the swipe ran on.
With no focused window there is no ray, and the four-way action runs for
its entry rule. The keyboard's focus chords stay four-way. Only binds whose
action `cursor::action_navigates` (focus/pan left/right/up/down) peek, and
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index e37a2a03..e0d9b1bf 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -125,6 +125,11 @@ pub struct Cursor {
/// of the gesture fires nothing more, so one swipe toggles the
/// overview once however far the fingers go. Cleared at swipe begin.
pub swipe_spent: bool,
+ /// A focus bind the in-flight swipe reached with no window that way
+ /// (`WindowManager::focus_toward_lands`): it does not fire, and while
+ /// the swipe keeps matching it the lean holds at its limit rather than
+ /// stepping. Cleared at swipe begin and by a step that lands.
+ pub swipe_dead_end: Option<crate::config::Action>,
/// 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 restarts from zero at each fire, like the travel.
@@ -228,6 +233,7 @@ impl Default for Cursor {
gesture_scale: 1.0,
gesture_triggered: false,
swipe_spent: false,
+ swipe_dead_end: None,
swipe_peek: [0.0, 0.0],
inject_swipe_fingers: 3,
panning_gesture_active: false,
@@ -3773,6 +3779,7 @@ unsafe extern "C" fn handle_swipe_begin(listener: *mut ffi::wl_listener, data: *
cursor.gesture_dy = 0.0;
cursor.gesture_triggered = false;
cursor.swipe_spent = false;
+ cursor.swipe_dead_end = None;
cursor.swipe_peek = [0.0, 0.0];
log::info!("handle_swipe_begin: fingers={}", (*event).fingers);
@@ -3858,13 +3865,13 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
"down" => (cursor.gesture_dy > threshold, Some(3)),
_ => (false, None),
};
- if matched {
+ // First match wins in this table. The scan goes on past it
+ // because a focus step that turns out to have nowhere to go
+ // (below) leans instead, and the lean needs every direction.
+ if matched && matched_action == crate::config::Action::None {
matched_action = gb.action;
matched_command = gb.command.clone();
- break;
}
- // First match wins in this table, so only the first bind per
- // direction decides whether that way peeks.
if let Some(i) = slot {
if !navigates[i] && action_navigates(gb.action) {
navigates[i] = true;
@@ -3873,8 +3880,41 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
}
}
+ // A focus step with no window that way does not fire. Firing it moved
+ // nothing but the camera: the step took the lean, restarted the
+ // travel, and the camera eased back while the fingers were still
+ // going out, then leaned out again toward the next threshold and
+ // snapped back at it — a sawtooth for as long as the swipe ran on.
+ // Instead the lean holds at its limit, as at a wall, and the lift eases
+ // it out (`handle_swipe_end`). The travel is scaled back onto the
+ // threshold, keeping its direction, so turning the fingers round
+ // unwinds the lean at once rather than first spending the overshoot.
+ // The answer is kept for the rest of the swipe, so the policy is asked
+ // once per dead end, not once per event.
+ if is_directional_focus(matched_action) {
+ let travel = (cursor.gesture_dx, cursor.gesture_dy);
+ let dead = cursor.swipe_dead_end == Some(matched_action) || {
+ let v = swipe_focus_vector(&(*seat.server).wm.gesture_binds, (*event).fingers, modifiers, travel);
+ !(*seat.server).wm.focus_toward_lands(v, &matched_action)
+ };
+ if dead {
+ if cursor.swipe_dead_end != Some(matched_action) {
+ log::info!("Swipe gesture {:?}: no window that way, holding the lean", matched_action);
+ }
+ cursor.swipe_dead_end = Some(matched_action);
+ let over = travel.0.abs().max(travel.1.abs()) / threshold;
+ if over > 1.0 {
+ cursor.gesture_dx /= over;
+ cursor.gesture_dy /= over;
+ }
+ matched_action = crate::config::Action::None;
+ matched_command = None;
+ }
+ }
+
if matched_action != crate::config::Action::None {
log::info!("Swipe gesture matched action: {:?}", matched_action);
+ cursor.swipe_dead_end = None;
// This step's travel, before it restarts below: a focus swipe
// aims along it (`swipe_focus_vector`).
let travel = (cursor.gesture_dx, cursor.gesture_dy);
@@ -3931,35 +3971,11 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
// `swipe3_right` mirrors them), and the window manager picks the
// nearest window center along the result.
let focus_vector = swipe_focus_vector(&(*seat.server).wm.gesture_binds, (*event).fingers, modifiers, travel);
- let focused_before = (*seat.server).wm.focused_window();
- let ease_before = ((*seat.server).wm.target_desk_pan_x, (*seat.server).wm.target_desk_pan_y);
match focus_vector {
Some(v) if is_directional_focus(matched_action) => (*seat.server).wm.focus_toward(v, &matched_action),
_ => (*seat.server).wm.execute_action(&matched_action, matched_command.as_deref()),
}
- // A focus step that found no window (a long swipe run on past the
- // last one) must leave the camera where the last real step was
- // taking it. Both action paths stop the camera ease up front, so
- // without this the previous step's pan into view froze part way
- // with the window still clipped, and the lean since that step —
- // committed above, or carried in the ease's target — stayed, so
- // each further threshold walked the camera off the window. Put the
- // ease back and take the lean out of it, as a lift would
- // (`handle_swipe_end`). A step that did set a target of its own is
- // left to it.
- let wm = &mut (*seat.server).wm;
- if is_directional_focus(matched_action)
- && wm.focused_window() == focused_before
- && wm.target_desk_pan_x.is_none()
- && wm.target_desk_pan_y.is_none()
- && (lean != [0.0, 0.0] || ease_before != (None, None))
- {
- wm.target_desk_pan_x = Some(ease_before.0.unwrap_or(wm.desk_pan_x) - lean[0]);
- wm.target_desk_pan_y = Some(ease_before.1.unwrap_or(wm.desk_pan_y) - lean[1]);
- wm.start_panning_animation();
- }
-
// The action ran against the leaned camera. It set a pan target
// only if the window it focused crosses a screen edge from there,
// and only as far as bringing it in needs (`pan_into_view`), so a
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index e3dc4358..4d79fafb 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -5108,6 +5108,22 @@ impl WindowManager {
}
}
+ /// Whether a focus swipe's step would move focus — `focus_toward(v,
+ /// action)` with a vector, `execute_action(action)` without one — asked
+ /// of the policy without applying anything or touching the camera. A
+ /// directional focus with nowhere to go is an empty command list on
+ /// both paths, and the legacy arms do nothing with it either.
+ pub unsafe fn focus_toward_lands(&mut self, v: Option<(f64, f64)>, action: &crate::config::Action) -> bool {
+ use crate::policy::api::Policy;
+ let ctx = self.build_action_ctx();
+ let has_ray = ctx.windows.iter().any(|w| w.focus_cyclable && Some(w.id) == ctx.focused);
+ let cmds = match v {
+ Some(v) if has_ray => crate::policy::actions::focus_toward(&ctx, v, self.swipe_focus_cone_deg),
+ _ => crate::policy::actions::DefaultPolicy.action(&ctx, *action, None),
+ };
+ !cmds.is_empty()
+ }
+
pub unsafe fn execute_action(&mut self, action: &crate::config::Action, command: Option<&str>) {
use crate::config::Action;
self.stop_panning_animation();