Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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();