Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat(input): a swipe bind peeks the camera before it fires
A three-finger swipe bound to a directional focus or pan used to do
nothing until its 50-unit threshold, then jump. Now the camera leans
toward the bind the swipe is heading for, 1:1 with the fingers and
proportional to the travel (60 screen px at the threshold, clamped), and
eases back to where it started if the fingers lift short of it. Only
binds whose action navigates (focus/pan in a direction) peek, and only
toward a direction that has one, so a four-finger overview toggle leaves
the desktop still.
When the bind fires the action runs against the camera as it stood
before the peek, so a focus lands where a keyed one would; the ease then
resumes from the peeked position, and an action that sets no target is
given the origin as one so the lean never sticks.
Adds `ccectl pointer-swipe begin|update|end` (a swipe paced by the
caller, holdable short of its threshold) and `ccectl camera` (pan, zoom,
pan target), which is how a shadow reads the lean and its return back.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 18 +++++
src/cce_ctl.rs | 6 +-
src/server/cursor.rs | 152 +++++++++++++++++++++++++++++++++++++++++--
src/server/window_manager.rs | 33 +++++++++-
4 files changed, 200 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 4a62e9a..48280d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -506,6 +506,24 @@ prints, per output, mode / scale / logical size / mm / logical px per mm and whe
the mm came from (`configured`, `measured`, `none`); the creation log line says the
same.
+**Swipe binds peek before they fire.** A three-finger swipe bound to a
+directional focus or pan (`focus_left (gesture)"swipe3_left"` in input.kdl)
+fires once the accumulated travel passes `cursor::SWIPE_TRIGGER_DISTANCE`
+(50 libinput units). Short of that the camera *leans* toward the bind the
+swipe is heading for, 1:1 with the fingers and proportional to the travel —
+`SWIPE_PEEK_PX` (60 screen px) at the threshold, 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`,
+`update <dx> <dy>`, `end` — and reads the lean and its return back with
+`ccectl camera` (pan, zoom, pan target).
+
**Idle timeouts** — `idle { display_off <s>; sleep <s>; sleep_command "…" }`,
both 0 (off) by default — are `src/server/idle.rs`, a `Server` subcomponent
rather than window-manager state: two `wl_event_loop` timers re-armed from
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 107b02e..8a2fb57 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -87,6 +87,7 @@ fn usage(name: &str, to_stderr: bool) {
print(" retile");
print(" mode <floating|tiled|fullscreen|popup|overlay> <app_id> [title]");
print(" pointer-location");
+ print(" camera (pan, zoom, and the pan target being eased to)");
print(" pointer-move-to <x> <y> (layout pixels)");
print(" pointer-move-by <dx> <dy>");
print(" pointer-scroll <dy> [dx] [finger] [natural]");
@@ -94,9 +95,10 @@ fn usage(name: &str, to_stderr: bool) {
print(" two-finger swipe, ended by finger-stop; natural = from");
print(" a natural-scrolling touchpad, deltas already flipped)");
print(" pointer-scroll finger-stop");
- print(" pointer-swipe <fingers> <dx> <dy> [steps]");
+ print(" pointer-swipe <fingers> <dx> <dy> [steps] | begin <fingers> | update <dx> <dy> | end");
print(" (a 3/4-finger touchpad swipe; drives input.kdl gesture");
- print(" chords such as focus_left \"swipe3_left\")");
+ print(" chords such as focus_left \"swipe3_left\"; the staged");
+ print(" form holds a swipe short of its threshold)");
print(" pointer-pinch <scale> [rotation] [steps] | begin | update <scale> [rotation] | end");
print(" touchpad-view-regions <x11:ID|id|app_id> clear | <x,y,w,h> ...");
print(" (limit a touchpad_view_apps drag to these window-local");
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 21a5d60..ce4c2e0 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -97,6 +97,13 @@ pub struct Cursor {
pub gesture_dy: f64,
pub gesture_scale: f64,
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.
+ 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.
+ pub inject_swipe_fingers: u32,
pub panning_gesture_active: bool,
/// What `pointer-scroll ... natural` sets: an injected finger scroll
/// (no device behind it) reads as coming from a natural-scrolling
@@ -190,6 +197,8 @@ impl Default for Cursor {
gesture_dy: 0.0,
gesture_scale: 1.0,
gesture_triggered: false,
+ swipe_peek: [0.0, 0.0],
+ inject_swipe_fingers: 3,
panning_gesture_active: false,
inject_natural: false,
pan_vel: [0.0, 0.0],
@@ -1159,6 +1168,30 @@ impl Cursor {
ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
}
+ /// One stage of a touchpad swipe, paced by the caller (`pointer-swipe
+ /// begin <fingers> | update <dx> <dy> | end`): what lets a shadow hold
+ /// a swipe short of its threshold and read the camera's peek back.
+ pub unsafe fn inject_swipe_stage(&mut self, stage: &str, fingers: u32, dx: f64, dy: f64) {
+ let time = crate::util::msec_timestamp();
+ match stage {
+ "begin" => {
+ self.inject_swipe_fingers = fingers;
+ let mut ev = ffi::wlr_pointer_swipe_begin_event { pointer: std::ptr::null_mut(), time_msec: time, fingers };
+ handle_swipe_begin(&mut self.swipe_begin_listener as *mut ffi::wl_listener, &mut ev as *mut _ as *mut std::ffi::c_void);
+ }
+ "update" => {
+ let fingers = self.inject_swipe_fingers;
+ let mut ev = ffi::wlr_pointer_swipe_update_event { pointer: std::ptr::null_mut(), time_msec: time, fingers, dx, dy };
+ handle_swipe_update(&mut self.swipe_update_listener as *mut ffi::wl_listener, &mut ev as *mut _ as *mut std::ffi::c_void);
+ }
+ _ => {
+ let mut ev = ffi::wlr_pointer_swipe_end_event { pointer: std::ptr::null_mut(), time_msec: time, cancelled: false };
+ handle_swipe_end(&mut self.swipe_end_listener as *mut ffi::wl_listener, &mut ev as *mut _ as *mut std::ffi::c_void);
+ }
+ }
+ ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+ }
+
/// Inject a whole touchpad swipe: begin with `fingers`, `steps` updates
/// that together move the gesture centre by (`dx`, `dy`), end. Drives
/// the gesture-bind table (`swipe3_left` in input.kdl) headlessly;
@@ -3497,6 +3530,34 @@ unsafe extern "C" fn handle_touch_frame(listener: *mut ffi::wl_listener, _data:
ffi::wlr_seat_touch_notify_frame(seat.wlr_seat);
}
+/// Accumulated swipe travel (libinput units) at which a directional swipe
+/// bind fires. Until then the camera *peeks*: it pans toward the swipe
+/// direction in proportion to the travel, up to `SWIPE_PEEK_PX` on screen
+/// 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.
+const SWIPE_TRIGGER_DISTANCE: f64 = 50.0;
+const SWIPE_PEEK_PX: f64 = 60.0;
+
+/// Does firing `action` carry the view in the swipe's direction? Only
+/// such binds peek the camera beforehand: an overview toggle or a spawn
+/// on a swipe has no direction the desktop could lean toward.
+fn action_navigates(action: crate::config::Action) -> bool {
+ use crate::config::Action::*;
+ 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 the threshold, 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, zoom: f64) -> f64 {
+ if (d < 0.0 && neg) || (d > 0.0 && pos) {
+ (d / SWIPE_TRIGGER_DISTANCE).clamp(-1.0, 1.0) * SWIPE_PEEK_PX / zoom.max(1e-6)
+ } else {
+ 0.0
+ }
+}
+
unsafe extern "C" fn handle_swipe_begin(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
let cursor = &mut *crate::container_of!(listener, Cursor, swipe_begin_listener);
let event = data as *mut ffi::wlr_pointer_swipe_begin_event;
@@ -3512,6 +3573,7 @@ unsafe extern "C" fn handle_swipe_begin(listener: *mut ffi::wl_listener, data: *
cursor.gesture_dx = 0.0;
cursor.gesture_dy = 0.0;
cursor.gesture_triggered = false;
+ cursor.swipe_peek = [0.0, 0.0];
log::info!("handle_swipe_begin: fingers={}", (*event).fingers);
@@ -3566,21 +3628,31 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
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
for gb in &(*seat.server).wm.gesture_binds {
if gb.gesture_type == "swipe" && gb.fingers == (*event).fingers && gb.mods == modifiers {
- let matched = match gb.direction.as_str() {
- "left" => cursor.gesture_dx < -50.0,
- "right" => cursor.gesture_dx > 50.0,
- "up" => cursor.gesture_dy < -50.0,
- "down" => cursor.gesture_dy > 50.0,
- _ => false,
+ let (matched, slot) = match gb.direction.as_str() {
+ "left" => (cursor.gesture_dx < -SWIPE_TRIGGER_DISTANCE, Some(0)),
+ "right" => (cursor.gesture_dx > SWIPE_TRIGGER_DISTANCE, Some(1)),
+ "up" => (cursor.gesture_dy < -SWIPE_TRIGGER_DISTANCE, Some(2)),
+ "down" => (cursor.gesture_dy > SWIPE_TRIGGER_DISTANCE, Some(3)),
+ _ => (false, None),
};
if matched {
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;
+ }
+ }
}
}
@@ -3588,6 +3660,25 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
log::info!("Swipe gesture matched action: {:?}", matched_action);
cursor.gesture_triggered = true;
+ // Hand the action the camera as it stood before the peek, so a
+ // focus lands exactly where a keyed one would; then resume from
+ // the peeked position so the ease runs from where the screen is.
+ // An action that leaves the camera alone still gets a target: the
+ // origin, so the peek eases back instead of sticking.
+ let peek = std::mem::replace(&mut cursor.swipe_peek, [0.0, 0.0]);
+ let peeked = 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];
+ let peeked = (wm.desk_pan_x, wm.desk_pan_y);
+ wm.desk_pan_x -= peek[0];
+ wm.desk_pan_y -= peek[1];
+ Some(peeked)
+ } else {
+ None
+ };
+
if matched_action == crate::config::Action::Overview && (*seat.server).wm.mode == crate::window_manager::WindowManagerMode::Overview {
let lx = cursor.x();
let ly = cursor.y();
@@ -3614,6 +3705,24 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
};
(*seat.server).wm.execute_action(&matched_action, matched_command.as_deref());
+ if let Some((px, py)) = peeked {
+ let wm = &mut (*seat.server).wm;
+ let origin = (px - peek[0], py - peek[1]);
+ // An action that set the camera outright (no ease) owns it now.
+ if (wm.desk_pan_x, wm.desk_pan_y) == origin {
+ if let Some(ramp) = wm.camera_ramp_anim.as_mut() {
+ ramp.start.pan_x = px;
+ ramp.start.pan_y = py;
+ } else {
+ wm.target_desk_pan_x.get_or_insert(origin.0);
+ wm.target_desk_pan_y.get_or_insert(origin.1);
+ }
+ wm.desk_pan_x = px;
+ wm.desk_pan_y = py;
+ wm.start_panning_animation();
+ }
+ }
+
let pointer_gestures = (*seat.server).input_manager.pointer_gestures;
if !pointer_gestures.is_null() {
ffi::wlr_pointer_gestures_v1_send_swipe_end(
@@ -3626,6 +3735,24 @@ 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.
+ {
+ let wm = &mut (*seat.server).wm;
+ let want = [
+ swipe_peek_for(cursor.gesture_dx, navigates[0], navigates[1], wm.desk_zoom),
+ swipe_peek_for(cursor.gesture_dy, navigates[2], navigates[3], wm.desk_zoom),
+ ];
+ let delta = [want[0] - cursor.swipe_peek[0], want[1] - cursor.swipe_peek[1]];
+ if delta != [0.0, 0.0] {
+ wm.stop_panning_animation();
+ wm.queue_pan(delta[0], delta[1]);
+ cursor.swipe_peek = want;
+ }
+ }
+
let server = seat.server;
let pointer_gestures = (*server).input_manager.pointer_gestures;
if !pointer_gestures.is_null() {
@@ -3658,6 +3785,19 @@ unsafe extern "C" fn handle_swipe_end(listener: *mut ffi::wl_listener, data: *mu
return;
}
+ // Lifted short of the threshold: ease the camera back to where the
+ // swipe found it.
+ 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.start_panning_animation();
+ }
+
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 a0df769..3996233 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -5010,6 +5010,20 @@ impl WindowManager {
self.dirty_windowing();
return format!("ok {}\n", enable);
}
+ // The camera as it stands and where it is easing to — what a
+ // shadow reads back to assert a swipe's peek and its return.
+ "camera" => {
+ let fmt = |v: Option<f64>| v.map_or("-".to_string(), |v| format!("{:.1}", v));
+ format!(
+ "pan_x={:.1} pan_y={:.1} zoom={:.3} target_x={} target_y={} anim={}\n",
+ self.desk_pan_x + self.pan_pending[0],
+ self.desk_pan_y + self.pan_pending[1],
+ self.desk_zoom,
+ fmt(self.target_desk_pan_x),
+ fmt(self.target_desk_pan_y),
+ self.camera_anim_active
+ )
+ }
"pan-by" => {
if parts.len() < 3 { return "error: missing dx or dy\n".to_string(); }
if let (Ok(dx), Ok(dy)) = (parts[1].parse::<f64>(), parts[2].parse::<f64>()) {
@@ -5975,7 +5989,24 @@ impl WindowManager {
}
"pointer-swipe" => {
// pointer-swipe <fingers> <dx> <dy> [steps]
- if parts.len() < 4 { return "error: usage: pointer-swipe <fingers> <dx> <dy> [steps]\n".to_string(); }
+ // pointer-swipe begin <fingers> | update <dx> <dy> | end (paced by the caller)
+ let usage = "error: usage: pointer-swipe <fingers> <dx> <dy> [steps] | begin <fingers> | update <dx> <dy> | end\n";
+ if parts.len() >= 2 && matches!(parts[1], "begin" | "update" | "end") {
+ let stage = parts[1].to_string();
+ let num = |i: usize| parts.get(i).and_then(|v| v.parse::<f64>().ok());
+ let (fingers, dx, dy) = match parts[1] {
+ "begin" => (num(2).map(|f| f as u32).unwrap_or(3), 0.0, 0.0),
+ "update" => match (num(2), num(3)) {
+ (Some(dx), Some(dy)) => (3, dx, dy),
+ _ => return usage.to_string(),
+ },
+ _ => (3, 0.0, 0.0),
+ };
+ // The begin fixes the finger count; updates reuse it.
+ self.for_each_cursor(|cursor| cursor.inject_swipe_stage(&stage, fingers, dx, dy));
+ return "ok\n".to_string();
+ }
+ if parts.len() < 4 { return usage.to_string(); }
let fingers = parts[1].parse::<u32>();
let dx = parts[2].parse::<f64>();
let dy = parts[3].parse::<f64>();