Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
fix(swipe): a focus step past the last window leaves the camera on it
A long three-finger swipe that runs on past the last window in its
direction fires a repeat step that finds nothing to focus. Both action
paths stop the camera ease first, so the previous step's pan into view
froze part way, and the lean accumulated since that step stayed
committed -- each further threshold walked the camera further off the
window just focused. Such a step now restores the interrupted ease and
eases its lean back out, as a lift does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 7 ++++++-
src/server/cursor.rs | 24 ++++++++++++++++++++++++
2 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index cdf32f46..8579513e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -768,7 +768,12 @@ 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`).
+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.
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 f710e923..e37a2a03 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -3931,11 +3931,35 @@ 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