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

commitf6bed9a0b27f58addeff1c6f38754831214a6625
parent62b473127a
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 02:28
feat(touch): touchscreen gestures -- desk pan/zoom, 3/4-finger swipes and pinches, edge swipes

Touch handling moves out of cursor.rs into src/server/touch.rs, and the
compositor now keeps some gestures for itself (touch::Claim):

- A finger dragged on the bare desk pans it (normal mode); a second finger
  joining one that went down on the desk pinch-zooms about the midpoint.
  The lift coasts like a trackpad pan.
- Three or four fingers anywhere are claimed (clients get wl_touch.cancel)
  and, once they have moved enough to tell, swipe through the touchpad's
  own handle_swipe_* -- same swipe3_*/swipe4_* binds, lean, repeat steps
  and focus aim -- or fire the pinch3_*/pinch4_* binds. A touchscreen
  swipe is natural: the bind is the way the camera goes, opposite the
  fingers.
- New edge_left|right|top|bottom chords in input.kdl fire on one finger
  swiped inward from a screen edge. A finger that goes along the edge or
  lifts in place is handed back to the normal route from its down point.

The emulated pointer's press now waits until the finger moves past a
10 px slop or lifts, so a gesture never has a half-made click to undo.
The pinch-bind lookup and the gesture action run are shared helpers now
(gesture_bind, pinch_hits, run_gesture_action).

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

 CLAUDE.md            | 112 ++++---
 src/lib.rs           |   2 +
 src/server/config.rs |  41 +++
 src/server/cursor.rs | 379 ++++++----------------
 src/server/touch.rs  | 870 +++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 1077 insertions(+), 327 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5f0d747c..64364176 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -213,13 +213,14 @@ Native libs via `pkg-config`: `wlroots-0.20`, `wayland-server`, `xkbcommon`,
 
 ## Tests
 
-Seventeen modules carry unit tests — `window_manager.rs` (the most of any, among
+Eighteen modules carry unit tests — `window_manager.rs` (the most of any, among
 them the saved-state matchers: same-program borrowing, untitled entries),
 `config.rs` (among them `backdrop_compress_params`), `idle.rs`,
 `idle_inhibit_manager.rs`, `xwayland_window.rs`, `screenshot.rs`, `window.rs`, `migrate_input.rs`,
 `text.rs`, `global_shortcuts.rs` (trigger parsing),
 `cursor.rs` (the swipe lean's direction, `swipe_lean`), `min_sizes.rs`,
 `selection.rs` (the rubber band's rect and its hit rule),
+`touch.rs` (edge-swipe progress, finger centroid/spread tracking, swipe vs pinch),
 `ipc_server.rs` (command framing and cutting off a stalled subscriber),
 `status_server.rs` (a slow reader, and a client that never reads),
 `keyboard_group.rs` (the keys a locked session keeps) and
@@ -1108,54 +1109,91 @@ headless seat has no keyboard and Chromium crashes in
 
 ### Touchscreens
 
-Until 2026-10-04 the seat never offered the touch capability, so no client
-ever bound `wl_touch` and every `wlr_seat_touch_notify_*` call went
-nowhere: a touchscreen did nothing. The seat now offers it while a touch
-device is attached (`Seat::touch_devices`, counted in `attach_device` /
-`detach_device`), and **each finger is routed once, at touch-down**
-(`cursor::TouchRoute`, decided by `Cursor::touch_route_at`):
+`src/server/touch.rs`. Until 2026-10-04 the seat never offered the touch
+capability, so no client ever bound `wl_touch` and a touchscreen did
+nothing. The seat now offers it while a touch device is attached
+(`Seat::touch_devices`, counted in `attach_device` / `detach_device`), and
+**each finger is routed once, at touch-down** (`touch::TouchRoute`, decided
+by `Cursor::touch_route_at`):
 
 - **`Client`** — the surface under it belongs to a client that bound
   `wl_touch` (`wlr_surface_accepts_touch`: GTK, Qt, Chromium, Xwayland,
-  foot) and the compositor is not in window-adjust mode. It gets real touch
-  events, focus as a click would give, and the press-time menu dismissals
+  foot, and every cce-ui app since cce-ui's `backend/touch.rs`) and the
+  compositor is not in window-adjust mode. It gets real touch events,
+  focus as a click would give, and the press-time menu dismissals
   (`press_dismissals`, shared with `handle_button`). Motion is mapped through
   the surface frame frozen at down (origin plus the scene buffer's scale,
   the pointer implicit grab's `grab_origin`/`grab_scale`), so a finger that
   slides off the window keeps reporting surface-local positions to it.
 - **`Pointer`** — everything else: a left button held where the finger is,
-  run through the real `handle_button` and motion path. That covers every
-  cce-ui app (the toolkit has no `wl_touch` handling), the status bar, the
-  lock screen, the desktop, and all the compositor's own presses — overview
-  (where every finger takes this route, even over a touch client), the
-  adjust handles, window body drags and the rubber band — none of which
-  needed a touch path of its own. One finger at a time, since the pointer is
-  singular.
+  run through the real `handle_button` and motion path, which is how the
+  desktop and all the compositor's own presses — overview (where every
+  finger takes this route, even over a touch client), the adjust handles,
+  window body drags and the rubber band — work by finger with no touch path
+  of their own. One finger at a time, since the pointer is singular. **The
+  press waits** (since 2026-10-05): the pointer hovers at the down point,
+  and the press lands there only once the finger moves past `TAP_SLOP`
+  (10 px; then the drag follows) or lifts (a tap). That is what lets a
+  second or third finger turn the touch into a gesture with no half-made
+  click to take back.
 - **`Ignored`** — a second finger while one drives the pointer, or any
   finger while a real button or seat op holds it.
-
-A cancel on a `Pointer` finger releases the button (a stuck button is worse
-than a stray click), and on a `Client` finger sends `wl_touch.cancel`
-(`river_wlr_seat_touch_cancel_point`, which voids that client's whole
-sequence, the protocol's unit). Unplugging the last touchscreen cancels any
-fingers still down. The cursor image goes away on touch-down
-(`Cursor::hidden_by_touch`; `set_xcursor` and `handle_request_set_cursor`
-both honour it) and real pointer motion brings it back
-(`unhide_after_touch`, which clears pointer focus so the client under it
-re-enters and sets its cursor again).
-
-cce-ui binds `wl_touch` since 2026-10-05, so its windows take the `Client`
-route and decide tap / scroll / hold-drag themselves (`cce-ui`'s
-`backend/touch.rs`); before that a finger drag in one was a held button and
-selected rather than scrolled. **Not yet done:** touch gestures (edge
-swipes, a two-finger pan or pinch of the desk).
+- **`Claimed`** — owned by a gesture, below.
+
+**Gestures** (since 2026-10-05, `touch::Claim`). While one is live every
+finger is the compositor's; fingers already given to clients get
+`wl_touch.cancel` when it begins.
+
+- **Desk pan and zoom.** One finger dragged on the bare desk pans it, in
+  normal mode (in overview it stays the selection band). A second finger
+  joining a finger that went down on the desk — in either mode — makes it
+  a two-finger pan that pinch-zooms about the midpoint
+  (`queue_pan`/`queue_pinch`, as the trackpad's). The lift coasts on the
+  pan's velocity, like a trackpad pan, unless the fingers had rested. Two
+  fingers that start on a window are the app's (a browser's pinch-zoom).
+- **Three or four fingers**, anywhere, claim as the third lands (unless a
+  pointer finger is mid-drag). `decide` waits for `DECIDE_TRAVEL` of
+  centroid travel (a swipe) or a `DECIDE_SCALE` spread change (a pinch),
+  counting the most fingers seen, so four fingers landing one at a time are
+  a four-finger gesture. A swipe runs through the touchpad's own
+  `handle_swipe_*` (`gesture_from_touch` keeps it off clients'
+  pointer-gesture streams and past the trackpad's `gestures { swipe }`
+  switch), so the `swipe3_*`/`swipe4_*` binds, the lean, repeat steps and
+  focus aim all apply, with travel in layout px against the same
+  thresholds. **A touchscreen swipe is natural**: the desk follows the
+  fingers, so the bind that fires is the way the CAMERA goes — fingers
+  dragging left fire `swipe3_right`, four fingers dragging up fire
+  `swipe4_down`. A pinch fires the `pinch3_*`/`pinch4_*` binds
+  (`pinch_hits`, the trackpad's thresholds) once.
+- **Edge swipes** bind like any gesture, in input.kdl:
+  `overview (gesture)"edge_bottom"` (`config::parse_edge_gesture`;
+  `edge_left|right|top|bottom`, the edge the finger starts from, optional
+  modifiers). A first finger landing within `EDGE_ZONE` (24 px) of a
+  screen edge — one no other output continues past — is held only when
+  such a bind exists; `EDGE_FIRE` (60 px) inward fires it once. A finger
+  that goes along the edge or back out is handed to the normal route from
+  its down point (`edge_release`), one that lifts where it landed is
+  delivered as the tap it was, late, and a second finger ends the edge
+  claim the same way. So a bound `edge_top` delays every tap on the status
+  bar's top 24 px until the lift.
+
+A cancel on a `Pointer` finger releases the button if it was pressed (a
+stuck button is worse than a stray drop), and on a `Client` finger sends
+`wl_touch.cancel` (`river_wlr_seat_touch_cancel_point`, which voids that
+client's whole sequence, the protocol's unit). Unplugging the last
+touchscreen cancels any fingers still down. The cursor image goes away on
+touch-down (`Cursor::hidden_by_touch`; `set_xcursor` and
+`handle_request_set_cursor` both honour it) and real pointer motion brings
+it back (`unhide_after_touch`, which clears pointer focus so the client
+under it re-enters and sets its cursor again).
 
 Drive it in a shadow with `ccectl touch down <id> <x> <y>`, `motion <id> <x>
-<y>`, `up <id>`, `cancel <id>` and `tap <x> <y>` (layout pixels). The first
-use sets `Seat::touch_injected`, which offers the capability as a
-touchscreen would, so `weston-simple-touch` under `WAYLAND_DEBUG=1` shows
-the `Client` route's `wl_touch` traffic and a cce-ui app shows the
-`Pointer` route.
+<y>`, `up <id>`, `cancel <id>` and `tap <x> <y>` (layout pixels; several ids
+down at once are several fingers). The first use sets
+`Seat::touch_injected`, which offers the capability as a touchscreen would,
+so `weston-simple-touch` under `WAYLAND_DEBUG=1` shows the `Client` route's
+`wl_touch` traffic — and the `cancel` a third finger sends it. A shadow's
+input.kdl is its own copy: add `edge_*`/`pinch3_*` binds there to try them.
 
 ### Portal global shortcuts
 
diff --git a/src/lib.rs b/src/lib.rs
index 7b235344..dd365a9c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -95,6 +95,8 @@ pub mod xwayland_window;
 pub mod min_sizes;
 #[path = "server/selection.rs"]
 pub mod selection;
+#[path = "server/touch.rs"]
+pub mod touch;
 #[path = "server/xwayland_override_redirect.rs"]
 pub mod xwayland_override_redirect;
 #[path = "server/text_input.rs"]
diff --git a/src/server/config.rs b/src/server/config.rs
index 47d951fe..1582d352 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -1228,6 +1228,21 @@ pub fn parse_modifiers(mod_str: &str) -> u32 {
     mods
 }
 
+/// A touchscreen edge-swipe chord: `edge_left`, `edge_right`, `edge_top`
+/// or `edge_bottom` — the edge the finger starts from — optionally behind
+/// modifiers (`super+edge_top`). Returns the modifier mask and the edge.
+/// Kept apart from `cce_window_manager::bindings::parse_gesture`, whose
+/// gestures are the touchpad's and carry a finger count.
+pub fn parse_edge_gesture(chord: &str) -> Option<(u32, String)> {
+    let lower = chord.trim().to_lowercase().replace('-', "_");
+    let (mods, gesture) = match lower.rsplit_once('+') {
+        Some((mods, gesture)) => (parse_modifiers(mods), gesture.trim()),
+        None => (0, lower.as_str()),
+    };
+    let edge = gesture.strip_prefix("edge_")?;
+    matches!(edge, "left" | "right" | "top" | "bottom").then(|| (mods, edge.to_string()))
+}
+
 pub fn parse_button(s: &str) -> u32 {
     match s.trim() {
         "left" => 0x110,   // BTN_LEFT
@@ -2935,6 +2950,22 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
             }
             _ => None,
         };
+        // A touchscreen edge swipe: `edge_left` fires on a finger swiped in
+        // from the left edge (`touch::Claim::Edge`).
+        if let Some((mods, edge)) = parse_edge_gesture(&entry.chord) {
+            if input_gesture_binds.iter().any(|b| b.mods == mods && b.gesture_type == "edge" && b.direction == edge) {
+                eprintln!("[WARNING] input.kdl: {:?} is bound more than once", entry.chord);
+            }
+            input_gesture_binds.push(GestureBind {
+                mods,
+                gesture_type: "edge".to_string(),
+                fingers: 1,
+                direction: edge,
+                action,
+                command: command.clone(),
+            });
+            continue;
+        }
         // A touchpad gesture rides in the chord slot: `swipe3_left`,
         // `super+pinch_out`. The fingerless spelling binds three AND four
         // fingers, as `toggle_overview "swipe_down"` always has.
@@ -3442,6 +3473,16 @@ style {
         );
     }
 
+    #[test]
+    fn test_parse_edge_gesture() {
+        assert_eq!(parse_edge_gesture("edge_left"), Some((0, "left".to_string())));
+        assert_eq!(parse_edge_gesture("Edge-Bottom"), Some((0, "bottom".to_string())));
+        assert_eq!(parse_edge_gesture("super+edge_top"), Some((0x40, "top".to_string())));
+        assert_eq!(parse_edge_gesture("edge_middle"), None);
+        assert_eq!(parse_edge_gesture("swipe3_left"), None);
+        assert_eq!(parse_edge_gesture("super+t"), None);
+    }
+
     #[test]
     fn test_kdl_touchpad_hscroll_shift_apps() {
         let content = r#"
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 4a958f43..01d294e7 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -5,36 +5,7 @@ use crate::scene_node_data::SceneNodeDataVal;
 use crate::drag_icon::DragIcon;
 use std::collections::{HashMap, HashSet};
 
-/// One finger on a touchscreen (`Cursor::touch_points`).
-#[derive(Clone, Copy, Debug)]
-pub struct TouchPoint {
-    pub lx: f64,
-    pub ly: f64,
-    pub route: TouchRoute,
-}
-
-/// Where a touch point's events go, fixed at touch-down for its lifetime —
-/// a finger that slides onto another window keeps talking to the one it
-/// went down on, exactly like a held button's implicit grab.
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub enum TouchRoute {
-    /// A client that bound `wl_touch` gets real touch events. Positions are
-    /// surface-local through the frame frozen at down: `origin` is the
-    /// surface's layout origin and `scale` its surface-per-layout-pixel
-    /// ratio, the same mapping the pointer's implicit grab uses
-    /// (`grab_origin` / `grab_scale`).
-    Client { origin: (f64, f64), scale: f64 },
-    /// This finger drives the pointer: a left button held at its position.
-    /// Everything that is not a touch-capable client goes this way — cce-ui
-    /// apps, the status bar, the lock screen, the desktop, and every press
-    /// the compositor itself handles (overview, the adjust-mode handles,
-    /// drag-selection) — so all of those work by touch without a touch path
-    /// of their own. One finger at a time: the pointer is singular.
-    Pointer,
-    /// Swallowed until it lifts: a second finger while another drives the
-    /// pointer, or a touch while a real button is held.
-    Ignored,
-}
+pub use crate::touch::{TouchPoint, TouchRoute};
 
 pub struct Cursor {
     pub seat: *mut Seat,
@@ -98,6 +69,15 @@ pub struct Cursor {
     /// every tap would otherwise leave an arrow wherever the hand last was.
     /// Real pointer motion brings it back (`unhide_after_touch`).
     pub hidden_by_touch: bool,
+    /// A gesture the compositor has taken from the touchscreen: an edge
+    /// swipe, a desk pan/zoom, or a three/four-finger swipe or pinch
+    /// (`touch::Claim`). While one is live, every finger is its.
+    pub touch_claim: crate::touch::Claim,
+    /// The swipe being run through `handle_swipe_*` comes from the
+    /// touchscreen (`touch::Claim::Multi`), not a touchpad: the trackpad's
+    /// gesture switches do not gate it, and no client hears it as a
+    /// pointer gesture.
+    pub gesture_from_touch: bool,
     pub pressed: HashMap<u32, Option<*mut crate::pointer_binding::PointerBinding>>,
     /// Buttons whose PRESS was forwarded to the focused client. The paired
     /// release must reach the client no matter what the compositor is doing
@@ -219,6 +199,8 @@ impl Default for Cursor {
 
             touch_points: HashMap::new(),
             hidden_by_touch: false,
+            touch_claim: crate::touch::Claim::None,
+            gesture_from_touch: false,
             pressed: HashMap::new(),
             notified_pressed: HashSet::new(),
             grab_origin: (0.0, 0.0),
@@ -383,35 +365,35 @@ impl Cursor {
         );
 
         let touch_down_ptr = &mut self.touch_down_listener as *mut ffi::wl_listener as *mut WlListener;
-        (*touch_down_ptr).notify = Some(handle_touch_down);
+        (*touch_down_ptr).notify = Some(crate::touch::handle_touch_down);
         wl_signal_add(
             ffi::river_wlr_cursor_get_touch_down_signal(wlr_cursor),
             &mut self.touch_down_listener,
         );
 
         let touch_motion_ptr = &mut self.touch_motion_listener as *mut ffi::wl_listener as *mut WlListener;
-        (*touch_motion_ptr).notify = Some(handle_touch_motion);
+        (*touch_motion_ptr).notify = Some(crate::touch::handle_touch_motion);
         wl_signal_add(
             ffi::river_wlr_cursor_get_touch_motion_signal(wlr_cursor),
             &mut self.touch_motion_listener,
         );
 
         let touch_up_ptr = &mut self.touch_up_listener as *mut ffi::wl_listener as *mut WlListener;
-        (*touch_up_ptr).notify = Some(handle_touch_up);
+        (*touch_up_ptr).notify = Some(crate::touch::handle_touch_up);
         wl_signal_add(
             ffi::river_wlr_cursor_get_touch_up_signal(wlr_cursor),
             &mut self.touch_up_listener,
         );
 
         let touch_cancel_ptr = &mut self.touch_cancel_listener as *mut ffi::wl_listener as *mut WlListener;
-        (*touch_cancel_ptr).notify = Some(handle_touch_cancel);
+        (*touch_cancel_ptr).notify = Some(crate::touch::handle_touch_cancel);
         wl_signal_add(
             ffi::river_wlr_cursor_get_touch_cancel_signal(wlr_cursor),
             &mut self.touch_cancel_listener,
         );
 
         let touch_frame_ptr = &mut self.touch_frame_listener as *mut ffi::wl_listener as *mut WlListener;
-        (*touch_frame_ptr).notify = Some(handle_touch_frame);
+        (*touch_frame_ptr).notify = Some(crate::touch::handle_touch_frame);
         wl_signal_add(
             ffi::river_wlr_cursor_get_touch_frame_signal(wlr_cursor),
             &mut self.touch_frame_listener,
@@ -1055,7 +1037,7 @@ impl Cursor {
     }
 
     /// Take the cursor image off for a touch (see `hidden_by_touch`).
-    unsafe fn hide_for_touch(&mut self) {
+    pub(crate) unsafe fn hide_for_touch(&mut self) {
         if self.hidden_by_touch {
             return;
         }
@@ -1106,7 +1088,7 @@ impl Cursor {
 
     /// `inject_motion_to` without bringing a touch-hidden cursor back: the
     /// emulated pointer of a touch (`TouchRoute::Pointer`) moves this way.
-    unsafe fn warp_to(&mut self, x: f64, y: f64) {
+    pub(crate) unsafe fn warp_to(&mut self, x: f64, y: f64) {
         (*self.seat).handle_activity();
         ffi::wlr_cursor_warp(self.wlr_cursor, std::ptr::null_mut(), x, y);
         self.update_hovered();
@@ -1480,7 +1462,7 @@ unsafe fn is_cloud_layer(layer_surface: *mut crate::layer_shell::LayerSurface) -
 /// never given keyboard focus, and the one that relies on it — an on-screen
 /// keyboard (cce-keyboard), typing into the window it was clicked over —
 /// cannot work if a click on its keys takes focus off that window.
-unsafe fn layer_takes_click_focus(layer_surface: *mut crate::layer_shell::LayerSurface) -> bool {
+pub(crate) unsafe fn layer_takes_click_focus(layer_surface: *mut crate::layer_shell::LayerSurface) -> bool {
     if layer_surface.is_null() || (*layer_surface).wlr_layer_surface.is_null() {
         return false;
     }
@@ -1492,7 +1474,7 @@ unsafe fn layer_takes_click_focus(layer_surface: *mut crate::layer_shell::LayerS
 /// lands outside of. Shared by a pointer press (`handle_button`) and a
 /// touch that goes to a client as touch (`handle_touch_down`); an
 /// emulated touch arrives as a pointer press and runs it there.
-unsafe fn press_dismissals(server: *mut crate::server::Server, lx: f64, ly: f64) {
+pub(crate) unsafe fn press_dismissals(server: *mut crate::server::Server, lx: f64, ly: f64) {
     // Click-away-close for in-surface status menus: if any status
     // segment is expanded (menu open) and this press did not land on it,
     // push a one-shot dismiss over the status socket. The line carries
@@ -3656,217 +3638,6 @@ unsafe extern "C" fn handle_tablet_tool_button(listener: *mut ffi::wl_listener,
     }
 }
 
-// ── Touchscreen ──────────────────────────────────────────────────────────────
-// A finger is routed once, at touch-down (`TouchRoute`): a surface whose
-// client bound `wl_touch` gets real touch events; anything else gets the
-// pointer, a left button held where the finger is. The `Cursor` methods take
-// layout coordinates, so the device handlers below and `ccectl touch` (a
-// headless shadow has no touchscreen) share every line of the routing.
-
-impl Cursor {
-    /// Decide where a new finger at `lx, ly` goes. In window-adjust mode
-    /// (overview, or Super held) every press is the compositor's to spend
-    /// — the handles, a window body's drag, the rubber band — and those live
-    /// on the pointer path, so the finger becomes the pointer even over a
-    /// touch-capable window.
-    unsafe fn touch_route_at(&self, lx: f64, ly: f64) -> TouchRoute {
-        let seat = &*self.seat;
-        let server = seat.server;
-        if let Some(result) = (*server).scene.at(lx, ly) {
-            // Locked, only the lock surface may hear a finger, as only it
-            // may hear the pointer (`passthrough`).
-            let locked = (*server).lock_manager.state != crate::lock_manager::LockState::Unlocked;
-            let lock_ok = !locked || matches!(result.data, SceneNodeDataVal::LockSurface(_));
-            if lock_ok
-                && !result.surface.is_null()
-                && !(*server).wm.window_adjust_active()
-                && ffi::wlr_surface_accepts_touch(result.surface, seat.wlr_seat)
-            {
-                // The implicit grab's frame (see the button path): the
-                // surface's scene buffer may be drawn scaled, so map through
-                // its destination size rather than assume 1:1.
-                let mut scale = 1.0;
-                if !result.node.is_null() {
-                    let dest_w = ffi::river_scene_buffer_get_dest_width(result.node as *mut ffi::wlr_scene_buffer);
-                    let surf_w = ffi::river_wlr_surface_get_width(result.surface);
-                    if dest_w > 0 && surf_w > 0 {
-                        scale = surf_w as f64 / dest_w as f64;
-                    }
-                }
-                let origin = (lx - result.sx / scale, ly - result.sy / scale);
-                return TouchRoute::Client { origin, scale };
-            }
-        }
-        // The pointer is singular: a second finger cannot also drive it, and
-        // a real button held (or a seat op it started) owns it already.
-        let pointer_busy = self.touch_points.values().any(|p| p.route == TouchRoute::Pointer)
-            || !self.pressed.is_empty()
-            || seat.op.is_some();
-        if pointer_busy {
-            TouchRoute::Ignored
-        } else {
-            TouchRoute::Pointer
-        }
-    }
-
-    pub unsafe fn touch_down(&mut self, id: i32, lx: f64, ly: f64, time_msec: u32) {
-        (*self.seat).handle_activity();
-        let server = (*self.seat).server;
-        // A touch is deliberate input, like a press (see `Seat::focus`).
-        (*server).wm.startup_input_seen = true;
-        self.hide_for_touch();
-        // A device that reuses a live id without lifting it first has lost
-        // the up; finish the old point so its button or client is released.
-        if self.touch_points.contains_key(&id) {
-            log::warn!("touch: down for live touch id {id}; lifting the old point first");
-            self.touch_up(id, time_msec);
-        }
-
-        let route = self.touch_route_at(lx, ly);
-        self.touch_points.insert(id, TouchPoint { lx, ly, route });
-        log::debug!("touch: down id={id} at ({lx:.0}, {ly:.0}) -> {route:?}");
-
-        match route {
-            TouchRoute::Client { origin, scale } => {
-                press_dismissals(server, lx, ly);
-                let Some(result) = (*server).scene.at(lx, ly) else { return };
-                // Focus as a click would (`handle_button`).
-                let seat = &mut *self.seat;
-                match result.data {
-                    SceneNodeDataVal::Window(window) => {
-                        if !(*window).is_status_bar() && !(*window).is_wallpaper() {
-                            seat.focus(Focus::Window(window));
-                        }
-                    }
-                    SceneNodeDataVal::LayerSurface(layer_surface) => {
-                        if layer_takes_click_focus(layer_surface) {
-                            seat.focus(Focus::LayerSurface(result.surface));
-                        }
-                    }
-                    _ => {}
-                }
-                ffi::wlr_seat_touch_notify_down(
-                    seat.wlr_seat,
-                    result.surface,
-                    time_msec,
-                    id,
-                    (lx - origin.0) * scale,
-                    (ly - origin.1) * scale,
-                );
-            }
-            TouchRoute::Pointer => {
-                self.warp_to(lx, ly);
-                self.inject_button(BTN_LEFT, true);
-            }
-            TouchRoute::Ignored => {}
-        }
-    }
-
-    pub unsafe fn touch_motion(&mut self, id: i32, lx: f64, ly: f64, time_msec: u32) {
-        (*self.seat).handle_activity();
-        let Some(point) = self.touch_points.get_mut(&id) else { return };
-        point.lx = lx;
-        point.ly = ly;
-        let route = point.route;
-        match route {
-            TouchRoute::Client { origin, scale } => {
-                ffi::wlr_seat_touch_notify_motion(
-                    (*self.seat).wlr_seat,
-                    time_msec,
-                    id,
-                    (lx - origin.0) * scale,
-                    (ly - origin.1) * scale,
-                );
-                // A touch drag's icon follows the finger.
-                self.update_drag_icons();
-            }
-            TouchRoute::Pointer => self.warp_to(lx, ly),
-            TouchRoute::Ignored => {}
-        }
-    }
-
-    pub unsafe fn touch_up(&mut self, id: i32, time_msec: u32) {
-        (*self.seat).handle_activity();
-        let Some(point) = self.touch_points.remove(&id) else { return };
-        match point.route {
-            TouchRoute::Client { .. } => {
-                ffi::wlr_seat_touch_notify_up((*self.seat).wlr_seat, time_msec, id);
-            }
-            TouchRoute::Pointer => self.inject_button(BTN_LEFT, false),
-            TouchRoute::Ignored => {}
-        }
-    }
-
-    /// The device gave up on a finger (a palm, a gesture the kernel took).
-    /// A client is told `wl_touch.cancel`, which voids its whole sequence; a
-    /// finger driving the pointer releases the button — a cancelled tap
-    /// still clicks, but a held button must never be left behind.
-    pub unsafe fn touch_cancel(&mut self, id: i32) {
-        (*self.seat).handle_activity();
-        let Some(point) = self.touch_points.remove(&id) else { return };
-        match point.route {
-            TouchRoute::Client { .. } => ffi::river_wlr_seat_touch_cancel_point((*self.seat).wlr_seat, id),
-            TouchRoute::Pointer => self.inject_button(BTN_LEFT, false),
-            TouchRoute::Ignored => {}
-        }
-    }
-
-    pub unsafe fn touch_frame(&mut self) {
-        // The emulated pointer frames each of its own events; only touch
-        // clients are waiting on this one.
-        if self.touch_points.values().any(|p| matches!(p.route, TouchRoute::Client { .. }))
-            || ffi::wlr_seat_touch_num_points((*self.seat).wlr_seat) > 0
-        {
-            ffi::wlr_seat_touch_notify_frame((*self.seat).wlr_seat);
-        }
-    }
-
-    /// Layout coordinates of a touch event's normalized `x, y`, through the
-    /// device's output mapping (`map_to_output` / `map_to_region`).
-    unsafe fn touch_layout_coords(&self, touch: *mut ffi::wlr_touch, x: f64, y: f64) -> (f64, f64) {
-        let wlr_device = if touch.is_null() {
-            std::ptr::null_mut()
-        } else {
-            &mut (*touch).base as *mut ffi::wlr_input_device
-        };
-        let mut lx = 0.0;
-        let mut ly = 0.0;
-        ffi::wlr_cursor_absolute_to_layout_coords(self.wlr_cursor, wlr_device, x, y, &mut lx, &mut ly);
-        (lx, ly)
-    }
-}
-
-unsafe extern "C" fn handle_touch_down(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_down_listener);
-    let event = data as *mut ffi::wlr_touch_down_event;
-    let (lx, ly) = cursor.touch_layout_coords((*event).touch, (*event).x, (*event).y);
-    cursor.touch_down((*event).touch_id, lx, ly, (*event).time_msec);
-}
-
-unsafe extern "C" fn handle_touch_motion(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_motion_listener);
-    let event = data as *mut ffi::wlr_touch_motion_event;
-    let (lx, ly) = cursor.touch_layout_coords((*event).touch, (*event).x, (*event).y);
-    cursor.touch_motion((*event).touch_id, lx, ly, (*event).time_msec);
-}
-
-unsafe extern "C" fn handle_touch_up(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_up_listener);
-    let event = data as *mut ffi::wlr_touch_up_event;
-    cursor.touch_up((*event).touch_id, (*event).time_msec);
-}
-
-unsafe extern "C" fn handle_touch_cancel(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_cancel_listener);
-    let event = data as *mut ffi::wlr_touch_cancel_event;
-    cursor.touch_cancel((*event).touch_id);
-}
-
-unsafe extern "C" fn handle_touch_frame(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_frame_listener);
-    cursor.touch_frame();
-}
-
 /// A directional swipe bind fires once the accumulated travel (libinput
 /// units) passes `window_manager { swipe_threshold }`
 /// (`WindowManager::swipe_threshold`, default 70). Until then the camera
@@ -3983,7 +3754,8 @@ unsafe extern "C" fn handle_swipe_begin(listener: *mut ffi::wl_listener, data: *
 
     let seat = &mut *cursor.seat;
     let wm = &(*seat.server).wm;
-    let swipe_enabled = wm.input_config.touchpad.as_ref().and_then(|t| t.gestures.as_ref()).and_then(|g| g.swipe).unwrap_or(true);
+    let swipe_enabled = wm.input_config.touchpad.as_ref().and_then(|t| t.gestures.as_ref()).and_then(|g| g.swipe).unwrap_or(true)
+        || cursor.gesture_from_touch;
     if !swipe_enabled {
         return;
     }
@@ -4007,7 +3779,7 @@ unsafe extern "C" fn handle_swipe_begin(listener: *mut ffi::wl_listener, data: *
 
     let server = seat.server;
     let pointer_gestures = (*server).input_manager.pointer_gestures;
-    if !pointer_gestures.is_null() {
+    if !pointer_gestures.is_null() && !cursor.gesture_from_touch {
         ffi::wlr_pointer_gestures_v1_send_swipe_begin(
             pointer_gestures,
             seat.wlr_seat,
@@ -4023,7 +3795,8 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
 
     let seat = &mut *cursor.seat;
     let wm = &(*seat.server).wm;
-    let swipe_enabled = wm.input_config.touchpad.as_ref().and_then(|t| t.gestures.as_ref()).and_then(|g| g.swipe).unwrap_or(true);
+    let swipe_enabled = wm.input_config.touchpad.as_ref().and_then(|t| t.gestures.as_ref()).and_then(|g| g.swipe).unwrap_or(true)
+        || cursor.gesture_from_touch;
     if !swipe_enabled {
         return;
     }
@@ -4176,7 +3949,7 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
 
         if first_fire {
             let pointer_gestures = (*seat.server).input_manager.pointer_gestures;
-            if !pointer_gestures.is_null() {
+            if !pointer_gestures.is_null() && !cursor.gesture_from_touch {
                 ffi::wlr_pointer_gestures_v1_send_swipe_end(
                     pointer_gestures,
                     seat.wlr_seat,
@@ -4238,7 +4011,7 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
     }
     let server = seat.server;
     let pointer_gestures = (*server).input_manager.pointer_gestures;
-    if !pointer_gestures.is_null() {
+    if !pointer_gestures.is_null() && !cursor.gesture_from_touch {
         ffi::wlr_pointer_gestures_v1_send_swipe_update(
             pointer_gestures,
             seat.wlr_seat,
@@ -4255,7 +4028,8 @@ unsafe extern "C" fn handle_swipe_end(listener: *mut ffi::wl_listener, data: *mu
 
     let seat = &mut *cursor.seat;
     let wm = &(*seat.server).wm;
-    let swipe_enabled = wm.input_config.touchpad.as_ref().and_then(|t| t.gestures.as_ref()).and_then(|g| g.swipe).unwrap_or(true);
+    let swipe_enabled = wm.input_config.touchpad.as_ref().and_then(|t| t.gestures.as_ref()).and_then(|g| g.swipe).unwrap_or(true)
+        || cursor.gesture_from_touch;
     if !swipe_enabled {
         return;
     }
@@ -4289,7 +4063,7 @@ unsafe extern "C" fn handle_swipe_end(listener: *mut ffi::wl_listener, data: *mu
 
     let server = seat.server;
     let pointer_gestures = (*server).input_manager.pointer_gestures;
-    if !pointer_gestures.is_null() {
+    if !pointer_gestures.is_null() && !cursor.gesture_from_touch {
         ffi::wlr_pointer_gestures_v1_send_swipe_end(
             pointer_gestures,
             seat.wlr_seat,
@@ -4299,6 +4073,59 @@ unsafe extern "C" fn handle_swipe_end(listener: *mut ffi::wl_listener, data: *mu
     }
 }
 
+/// The modifiers a gesture bind is matched under: the keyboard's, less
+/// Caps and Num Lock (`& 0x4d`), as for every gesture.
+pub(crate) unsafe fn gesture_mods(seat: &Seat) -> u32 {
+    let wlr_keyboard = ffi::river_wlr_seat_get_keyboard(seat.wlr_seat);
+    if wlr_keyboard.is_null() {
+        0
+    } else {
+        ffi::wlr_keyboard_get_modifiers(wlr_keyboard) & 0x4d
+    }
+}
+
+/// The first gesture bind of `kind` ("swipe", "pinch", "edge") for this
+/// finger count and modifiers whose direction `hit` accepts. First match
+/// wins, as everywhere in the bind table.
+pub(crate) fn gesture_bind(
+    wm: &crate::window_manager::WindowManager,
+    kind: &str,
+    fingers: u32,
+    mods: u32,
+    hit: impl Fn(&str) -> bool,
+) -> Option<(crate::config::Action, Option<String>)> {
+    wm.gesture_binds
+        .iter()
+        .find(|gb| gb.gesture_type == kind && gb.fingers == fingers && gb.mods == mods && hit(&gb.direction))
+        .map(|gb| (gb.action, gb.command.clone()))
+}
+
+/// Whether a pinch at `scale` (the fingers' spread relative to the start)
+/// has gone far enough to fire a bind on `direction`.
+pub(crate) fn pinch_hits(direction: &str, scale: f64) -> bool {
+    match direction {
+        "in" => scale < 0.7,
+        "out" => scale > 1.3,
+        _ => false,
+    }
+}
+
+/// Run a gesture's bound action. The overview toggle lands on the hovered
+/// window, else on the FOCUSED one — never on the empty desktop under the
+/// pointer.
+pub(crate) unsafe fn run_gesture_action(
+    wm: &mut crate::window_manager::WindowManager,
+    action: crate::config::Action,
+    command: Option<&str>,
+) {
+    let action = if action == crate::config::Action::Overview {
+        wm.overview_action_for_gesture()
+    } else {
+        action
+    };
+    wm.execute_action(&action, command);
+}
+
 unsafe extern "C" fn handle_pinch_begin(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, pinch_begin_listener);
     let event = data as *mut ffi::wlr_pointer_pinch_begin_event;
@@ -4408,41 +4235,13 @@ unsafe extern "C" fn handle_pinch_update(listener: *mut ffi::wl_listener, data:
 
     cursor.gesture_scale = (*event).scale;
 
-    let wlr_keyboard = ffi::river_wlr_seat_get_keyboard(seat.wlr_seat);
-    let modifiers = if !wlr_keyboard.is_null() {
-        ffi::wlr_keyboard_get_modifiers(wlr_keyboard) & 0x4d
-    } else {
-        0
-    };
-
-    let mut matched_action = crate::config::Action::None;
-    let mut matched_command = None;
-
-    for gb in &(*seat.server).wm.gesture_binds {
-        if gb.gesture_type == "pinch" && gb.fingers == (*event).fingers && gb.mods == modifiers {
-            let matched = match gb.direction.as_str() {
-                "in" => cursor.gesture_scale < 0.7,
-                "out" => cursor.gesture_scale > 1.3,
-                _ => false,
-            };
-            if matched {
-                matched_action = gb.action;
-                matched_command = gb.command.clone();
-                break;
-            }
-        }
-    }
+    let modifiers = gesture_mods(seat);
+    let scale = cursor.gesture_scale;
+    let bind = gesture_bind(&(*seat.server).wm, "pinch", (*event).fingers, modifiers, |d| pinch_hits(d, scale));
 
-    if matched_action != crate::config::Action::None {
+    if let Some((matched_action, matched_command)) = bind {
         cursor.gesture_triggered = true;
-        // The overview toggle lands on the hovered window, else on the
-        // FOCUSED one — never on the empty desktop under the pointer.
-        let matched_action = if matched_action == crate::config::Action::Overview {
-            (*seat.server).wm.overview_action_for_gesture()
-        } else {
-            matched_action
-        };
-        (*seat.server).wm.execute_action(&matched_action, matched_command.as_deref());
+        run_gesture_action(&mut (*seat.server).wm, matched_action, matched_command.as_deref());
 
         let pointer_gestures = (*seat.server).input_manager.pointer_gestures;
         if !pointer_gestures.is_null() {
diff --git a/src/server/touch.rs b/src/server/touch.rs
new file mode 100644
index 00000000..6149c47b
--- /dev/null
+++ b/src/server/touch.rs
@@ -0,0 +1,870 @@
+//! Touchscreen input: where each finger goes, and the gestures the
+//! compositor keeps for itself.
+//!
+//! **Routing.** A finger is routed once, at touch-down (`TouchRoute`): a
+//! surface whose client bound `wl_touch` gets real touch events; anything
+//! else gets the pointer, a left button held where the finger is. The
+//! pointer's press waits until the finger has moved past `TAP_SLOP` (then
+//! it is pressed where the finger went down, and the drag follows) or has
+//! lifted (a tap: press and release). Nothing is lost by the wait — the
+//! press lands at the down point either way — and it is what lets a second
+//! or third finger turn the touch into a gesture with no half-made click
+//! to take back.
+//!
+//! **Gestures** (`Claim`). While one is live, every finger is the
+//! compositor's; fingers already given to clients are cancelled
+//! (`wl_touch.cancel`) when it begins.
+//!
+//! - **Edge swipe**: one finger landing within `EDGE_ZONE` of a screen edge
+//!   that has an `edge_<side>` bind in input.kdl, and moving `EDGE_FIRE`
+//!   inward, fires that bind once. A finger that goes along or back out
+//!   instead is handed to the normal route, from its down point; one that
+//!   lifts without moving is delivered as the tap it was.
+//! - **Desk pan and zoom**: a finger dragged on the bare desk (in normal
+//!   mode — in overview a one-finger drag there is the selection band)
+//!   pans the desk under it, and two fingers that start on the desk pan it
+//!   and pinch-zoom about their midpoint; the lift coasts, like a trackpad
+//!   pan's.
+//! - **Three and four fingers**, anywhere: once the fingers have moved
+//!   enough to tell (`decide`), a swipe runs through the touchpad's swipe
+//!   handling — the same `swipe3_*` / `swipe4_*` binds, lean, repeat steps
+//!   and focus aim — and a pinch fires the `pinch3_*` / `pinch4_*` binds.
+//!   **A touchscreen swipe is natural**: the desk follows the fingers, so
+//!   the bind fires for the direction the camera goes, which is opposite
+//!   the fingers — dragging the desk right reveals what is to the left, and
+//!   fires `swipe3_left`. On a touchpad the bind is the direction the
+//!   fingers move.
+
+use crate::cursor::{gesture_bind, gesture_mods, pinch_hits, run_gesture_action, Cursor};
+use crate::ffi;
+use crate::scene_node_data::SceneNodeDataVal;
+use crate::seat::Focus;
+
+/// Travel (layout px) a finger may wander and still be a tap.
+pub const TAP_SLOP: f64 = 10.0;
+/// How near a screen edge a finger must land to start an edge swipe.
+pub const EDGE_ZONE: f64 = 24.0;
+/// Inward travel that fires an edge swipe's bind.
+pub const EDGE_FIRE: f64 = 60.0;
+/// Centroid travel that decides three or more fingers are swiping.
+pub const DECIDE_TRAVEL: f64 = 24.0;
+/// Spread change (as a ratio) that decides they are pinching.
+pub const DECIDE_SCALE: f64 = 1.15;
+
+const BTN_LEFT: u32 = 0x110;
+
+/// One finger on a touchscreen (`Cursor::touch_points`).
+#[derive(Clone, Copy, Debug)]
+pub struct TouchPoint {
+    pub lx: f64,
+    pub ly: f64,
+    pub route: TouchRoute,
+}
+
+/// Where a finger's events go, fixed at touch-down for its lifetime — a
+/// finger that slides onto another window keeps talking to the one it went
+/// down on, exactly like a held button's implicit grab.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum TouchRoute {
+    /// A client that bound `wl_touch` gets real touch events. Positions are
+    /// surface-local through the frame frozen at down: `origin` is the
+    /// surface's layout origin and `scale` its surface-per-layout-pixel
+    /// ratio, the same mapping the pointer's implicit grab uses
+    /// (`grab_origin` / `grab_scale`).
+    Client { origin: (f64, f64), scale: f64 },
+    /// This finger drives the pointer. Everything that is not a
+    /// touch-capable client goes this way — the desktop, and every press
+    /// the compositor itself handles (overview, the adjust-mode handles,
+    /// drag-selection). `pressed` stays false until the finger has moved
+    /// past `TAP_SLOP` or lifted (see the module comment); `on_desk` is a
+    /// down on the bare desk, which a drag or a second finger turns into
+    /// a desk pan.
+    Pointer { start: (f64, f64), pressed: bool, on_desk: bool },
+    /// Owned by the live gesture (`Cursor::touch_claim`).
+    Claimed,
+    /// Swallowed until it lifts: a second finger while another drives the
+    /// pointer, or a touch while a real button is held.
+    Ignored,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Edge {
+    Left,
+    Right,
+    Top,
+    Bottom,
+}
+
+impl Edge {
+    /// The chord's spelling (`edge_left`) without the prefix.
+    pub fn name(self) -> &'static str {
+        match self {
+            Edge::Left => "left",
+            Edge::Right => "right",
+            Edge::Top => "top",
+            Edge::Bottom => "bottom",
+        }
+    }
+
+    /// Unit vector pointing away from the edge, into the screen.
+    pub fn inward(self) -> (f64, f64) {
+        match self {
+            Edge::Left => (1.0, 0.0),
+            Edge::Right => (-1.0, 0.0),
+            Edge::Top => (0.0, 1.0),
+            Edge::Bottom => (0.0, -1.0),
+        }
+    }
+}
+
+/// Where an edge swipe stands after the finger has moved by `(dx, dy)`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EdgeProgress {
+    /// Not far enough to tell.
+    Undecided,
+    /// Inward past `EDGE_FIRE`, and more inward than along the edge.
+    Fire,
+    /// Past the slop, but along the edge or back out: not an edge swipe.
+    NotEdge,
+}
+
+pub fn edge_progress(edge: Edge, dx: f64, dy: f64) -> EdgeProgress {
+    let (ix, iy) = edge.inward();
+    let inward = dx * ix + dy * iy;
+    let along = (dx * iy - dy * ix).abs();
+    if inward >= EDGE_FIRE && inward > along {
+        EdgeProgress::Fire
+    } else if dx.hypot(dy) >= TAP_SLOP && inward < along {
+        EdgeProgress::NotEdge
+    } else {
+        EdgeProgress::Undecided
+    }
+}
+
+/// The fingers as one shape: their centroid and their spread (mean
+/// distance from it), which is what a pan follows and a pinch measures.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct Shape {
+    pub centroid: (f64, f64),
+    pub spread: f64,
+    pub count: usize,
+}
+
+pub fn shape(points: &[(f64, f64)]) -> Shape {
+    if points.is_empty() {
+        return Shape::default();
+    }
+    let n = points.len() as f64;
+    let cx = points.iter().map(|p| p.0).sum::<f64>() / n;
+    let cy = points.iter().map(|p| p.1).sum::<f64>() / n;
+    let spread = points.iter().map(|p| (p.0 - cx).hypot(p.1 - cy)).sum::<f64>() / n;
+    Shape { centroid: (cx, cy), spread, count: points.len() }
+}
+
+/// A gesture's motion, accumulated across finger sets: a finger landing or
+/// lifting moves the centroid and the spread without anything having moved,
+/// so a change of set rebases (`rebase`) instead of counting as travel.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Track {
+    last: Shape,
+    /// Centroid travel since the gesture began.
+    pub travel: (f64, f64),
+    /// Product of the spread's ratios since the gesture began.
+    pub scale: f64,
+    /// The most fingers seen at once: what a swipe or pinch bind matches.
+    pub fingers: usize,
+}
+
+impl Track {
+    pub fn new(s: Shape) -> Self {
+        Track { last: s, travel: (0.0, 0.0), scale: 1.0, fingers: s.count }
+    }
+
+    pub fn rebase(&mut self, s: Shape) {
+        self.last = s;
+        self.fingers = self.fingers.max(s.count);
+    }
+
+    /// One sample of the current fingers: the centroid's motion since the
+    /// last one. A different finger count is a rebase, not motion.
+    pub fn step(&mut self, s: Shape) -> (f64, f64) {
+        if s.count != self.last.count {
+            self.rebase(s);
+            return (0.0, 0.0);
+        }
+        let d = (s.centroid.0 - self.last.centroid.0, s.centroid.1 - self.last.centroid.1);
+        self.travel.0 += d.0;
+        self.travel.1 += d.1;
+        // A single finger has no spread to compare, and two fingers almost
+        // on top of each other would turn a pixel of jitter into a zoom.
+        if s.count >= 2 && self.last.spread > 4.0 && s.spread > 4.0 {
+            self.scale *= s.spread / self.last.spread;
+        }
+        self.last = s;
+        d
+    }
+}
+
+/// What three or more fingers are doing, once it can be told.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MultiKind {
+    Undecided,
+    Swipe,
+    /// `fired`: its bind has run; the rest of the gesture is ignored.
+    Pinch { fired: bool },
+}
+
+pub fn decide(track: &Track) -> MultiKind {
+    if track.scale >= DECIDE_SCALE || track.scale <= 1.0 / DECIDE_SCALE {
+        MultiKind::Pinch { fired: false }
+    } else if track.travel.0.hypot(track.travel.1) >= DECIDE_TRAVEL {
+        MultiKind::Swipe
+    } else {
+        MultiKind::Undecided
+    }
+}
+
+/// The finger count a bind matches for a gesture tracked with `fingers`:
+/// binds name two to four, and a fifth finger is still a four-finger swipe.
+fn bind_fingers(fingers: usize) -> u32 {
+    fingers.clamp(3, 4) as u32
+}
+
+/// The gesture the compositor holds (`Cursor::touch_claim`).
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Claim {
+    None,
+    /// One finger from a bound edge (see the module comment).
+    Edge { id: i32, edge: Edge, start: (f64, f64), fired: bool },
+    /// The desk follows the fingers. `start_zoom` is the zoom the pinch
+    /// scales; `vel` (virtual px/s per axis) and `last_ms` feed the coast.
+    Desk { track: Track, start_zoom: f64, vel: [f64; 2], last_ms: u32 },
+    /// Three or more fingers.
+    Multi { track: Track, kind: MultiKind },
+}
+
+impl Default for Claim {
+    fn default() -> Self {
+        Claim::None
+    }
+}
+
+impl Cursor {
+    /// Decide where a new finger at `lx, ly` goes. In window-adjust mode
+    /// (overview, or Super held) every press is the compositor's to spend
+    /// — the handles, a window body's drag, the rubber band — and those live
+    /// on the pointer path, so the finger becomes the pointer even over a
+    /// touch-capable window.
+    unsafe fn touch_route_at(&self, lx: f64, ly: f64) -> TouchRoute {
+        let seat = &*self.seat;
+        let server = seat.server;
+        let result = (*server).scene.at(lx, ly);
+        if let Some(ref result) = result {
+            // Locked, only the lock surface may hear a finger, as only it
+            // may hear the pointer (`passthrough`).
+            let locked = (*server).lock_manager.state != crate::lock_manager::LockState::Unlocked;
+            let lock_ok = !locked || matches!(result.data, SceneNodeDataVal::LockSurface(_));
+            if lock_ok
+                && !result.surface.is_null()
+                && !(*server).wm.window_adjust_active()
+                && ffi::wlr_surface_accepts_touch(result.surface, seat.wlr_seat)
+            {
+                // The implicit grab's frame (see the button path): the
+                // surface's scene buffer may be drawn scaled, so map through
+                // its destination size rather than assume 1:1.
+                let mut scale = 1.0;
+                if !result.node.is_null() {
+                    let dest_w = ffi::river_scene_buffer_get_dest_width(result.node as *mut ffi::wlr_scene_buffer);
+                    let surf_w = ffi::river_wlr_surface_get_width(result.surface);
+                    if dest_w > 0 && surf_w > 0 {
+                        scale = surf_w as f64 / dest_w as f64;
+                    }
+                }
+                let origin = (lx - result.sx / scale, ly - result.sy / scale);
+                return TouchRoute::Client { origin, scale };
+            }
+        }
+        // The pointer is singular: a second finger cannot also drive it, and
+        // a real button held (or a seat op it started) owns it already.
+        let pointer_busy = self.touch_points.values().any(|p| matches!(p.route, TouchRoute::Pointer { .. }))
+            || !self.pressed.is_empty()
+            || seat.op.is_some();
+        if pointer_busy {
+            return TouchRoute::Ignored;
+        }
+        let on_desk = match result {
+            None => (*server).lock_manager.state == crate::lock_manager::LockState::Unlocked,
+            Some(r) => matches!(r.data, SceneNodeDataVal::Window(w) if (*w).is_wallpaper()),
+        };
+        TouchRoute::Pointer { start: (lx, ly), pressed: false, on_desk }
+    }
+
+    pub unsafe fn touch_down(&mut self, id: i32, lx: f64, ly: f64, time_msec: u32) {
+        (*self.seat).handle_activity();
+        // A touch is deliberate input, like a press (see `Seat::focus`).
+        (*(*self.seat).server).wm.startup_input_seen = true;
+        self.hide_for_touch();
+        // A device that reuses a live id without lifting it first has lost
+        // the up; finish the old point so its button or client is released.
+        if self.touch_points.contains_key(&id) {
+            log::warn!("touch: down for live touch id {id}; lifting the old point first");
+            self.touch_up(id, time_msec);
+        }
+
+        match self.touch_claim {
+            // A second finger is not an edge swipe: hand the first back
+            // before routing this one.
+            Claim::Edge { .. } => self.edge_release(time_msec),
+            Claim::Desk { track, .. } => {
+                self.touch_points.insert(id, TouchPoint { lx, ly, route: TouchRoute::Claimed });
+                // Three fingers on a desk that has not moved yet were a
+                // three-finger gesture landing one finger at a time.
+                let still = track.travel.0.hypot(track.travel.1) < TAP_SLOP && (track.scale - 1.0).abs() < 0.05;
+                if still && self.touch_points.len() >= 3 {
+                    self.touch_claim = Claim::Multi { track: Track::new(self.claimed_shape()), kind: MultiKind::Undecided };
+                } else {
+                    self.claim_rebase();
+                }
+                return;
+            }
+            Claim::Multi { .. } => {
+                self.touch_points.insert(id, TouchPoint { lx, ly, route: TouchRoute::Claimed });
+                self.claim_rebase();
+                return;
+            }
+            Claim::None => {}
+        }
+
+        let already = self.touch_points.len();
+        if already == 0 {
+            if let Some(edge) = self.bound_edge_at(lx, ly) {
+                log::debug!("touch: down id={id} in the {} edge zone", edge.name());
+                self.touch_points.insert(id, TouchPoint { lx, ly, route: TouchRoute::Claimed });
+                self.touch_claim = Claim::Edge { id, edge, start: (lx, ly), fired: false };
+                return;
+            }
+        }
+        let pointer_pressed = self.touch_points.values().any(|p| matches!(p.route, TouchRoute::Pointer { pressed: true, .. }));
+        if already + 1 >= 3 && !pointer_pressed {
+            self.claim_all();
+            self.touch_points.insert(id, TouchPoint { lx, ly, route: TouchRoute::Claimed });
+            self.touch_claim = Claim::Multi { track: Track::new(self.claimed_shape()), kind: MultiKind::Undecided };
+            log::debug!("touch: {} fingers claimed for a gesture", self.touch_points.len());
+            return;
+        }
+        if already == 1 {
+            let first_on_desk = self
+                .touch_points
+                .values()
+                .any(|p| matches!(p.route, TouchRoute::Pointer { pressed: false, on_desk: true, .. }));
+            if first_on_desk {
+                self.claim_all();
+                self.touch_points.insert(id, TouchPoint { lx, ly, route: TouchRoute::Claimed });
+                self.start_desk();
+                return;
+            }
+        }
+        self.touch_begin(id, lx, ly, time_msec);
+    }
+
+    /// Route a finger normally (`touch_route_at`) and deliver its down.
+    unsafe fn touch_begin(&mut self, id: i32, lx: f64, ly: f64, time_msec: u32) {
+        let server = (*self.seat).server;
+        let route = self.touch_route_at(lx, ly);
+        self.touch_points.insert(id, TouchPoint { lx, ly, route });
+        log::debug!("touch: down id={id} at ({lx:.0}, {ly:.0}) -> {route:?}");
+
+        match route {
+            TouchRoute::Client { origin, scale } => {
+                crate::cursor::press_dismissals(server, lx, ly);
+                let Some(result) = (*server).scene.at(lx, ly) else { return };
+                // Focus as a click would (`handle_button`).
+                let seat = &mut *self.seat;
+                match result.data {
+                    SceneNodeDataVal::Window(window) => {
+                        if !(*window).is_status_bar() && !(*window).is_wallpaper() {
+                            seat.focus(Focus::Window(window));
+                        }
+                    }
+                    SceneNodeDataVal::LayerSurface(layer_surface) => {
+                        if crate::cursor::layer_takes_click_focus(layer_surface) {
+                            seat.focus(Focus::LayerSurface(result.surface));
+                        }
+                    }
+                    _ => {}
+                }
+                ffi::wlr_seat_touch_notify_down(
+                    seat.wlr_seat,
+                    result.surface,
+                    time_msec,
+                    id,
+                    (lx - origin.0) * scale,
+                    (ly - origin.1) * scale,
+                );
+            }
+            // Hover now; the press waits (see `TouchRoute::Pointer`).
+            TouchRoute::Pointer { .. } => self.warp_to(lx, ly),
+            TouchRoute::Claimed | TouchRoute::Ignored => {}
+        }
+    }
+
+    pub unsafe fn touch_motion(&mut self, id: i32, lx: f64, ly: f64, time_msec: u32) {
+        (*self.seat).handle_activity();
+        let Some(point) = self.touch_points.get_mut(&id) else { return };
+        point.lx = lx;
+        point.ly = ly;
+        let route = point.route;
+        match route {
+            TouchRoute::Claimed => self.claim_motion(time_msec),
+            TouchRoute::Client { origin, scale } => {
+                ffi::wlr_seat_touch_notify_motion(
+                    (*self.seat).wlr_seat,
+                    time_msec,
+                    id,
+                    (lx - origin.0) * scale,
+                    (ly - origin.1) * scale,
+                );
+                // A touch drag's icon follows the finger.
+                self.update_drag_icons();
+            }
+            TouchRoute::Pointer { start, pressed: false, on_desk } => {
+                if (lx - start.0).hypot(ly - start.1) < TAP_SLOP {
+                    return;
+                }
+                let normal = (*(*self.seat).server).wm.mode == crate::window_manager::WindowManagerMode::Normal;
+                if on_desk && normal && self.touch_points.len() == 1 {
+                    // A drag on the bare desk pans it. The track starts at
+                    // the down point, so the desk catches up with the
+                    // finger's whole travel, not just what follows the slop.
+                    self.touch_points.get_mut(&id).unwrap().route = TouchRoute::Claimed;
+                    let mut track = Track::new(shape(&[start]));
+                    track.fingers = 1;
+                    self.touch_claim = Claim::Desk {
+                        track,
+                        start_zoom: (*(*self.seat).server).wm.desk_zoom,
+                        vel: [0.0, 0.0],
+                        last_ms: 0,
+                    };
+                    (*(*self.seat).server).wm.stop_panning_animation();
+                    self.claim_motion(time_msec);
+                    return;
+                }
+                // A drag: press where the finger went down, then follow it.
+                self.touch_points.get_mut(&id).unwrap().route = TouchRoute::Pointer { start, pressed: true, on_desk };
+                self.warp_to(start.0, start.1);
+                self.inject_button(BTN_LEFT, true);
+                self.warp_to(lx, ly);
+            }
+            TouchRoute::Pointer { pressed: true, .. } => self.warp_to(lx, ly),
+            TouchRoute::Ignored => {}
+        }
+    }
+
+    pub unsafe fn touch_up(&mut self, id: i32, time_msec: u32) {
+        (*self.seat).handle_activity();
+        let Some(point) = self.touch_points.remove(&id) else { return };
+        match point.route {
+            TouchRoute::Claimed => self.claim_lift(id, point, time_msec, false),
+            TouchRoute::Client { .. } => {
+                ffi::wlr_seat_touch_notify_up((*self.seat).wlr_seat, time_msec, id);
+            }
+            TouchRoute::Pointer { start, pressed: false, .. } => {
+                // A tap.
+                self.warp_to(start.0, start.1);
+                self.inject_button(BTN_LEFT, true);
+                self.inject_button(BTN_LEFT, false);
+            }
+            TouchRoute::Pointer { pressed: true, .. } => self.inject_button(BTN_LEFT, false),
+            TouchRoute::Ignored => {}
+        }
+    }
+
+    /// The device gave up on a finger (a palm, a gesture the kernel took).
+    /// A client is told `wl_touch.cancel`, which voids its whole sequence; a
+    /// finger driving the pointer releases the button — a cancelled drag
+    /// still drops where it was, but a held button must never be left
+    /// behind — and one that never pressed clicks nothing.
+    pub unsafe fn touch_cancel(&mut self, id: i32) {
+        (*self.seat).handle_activity();
+        let Some(point) = self.touch_points.remove(&id) else { return };
+        match point.route {
+            TouchRoute::Claimed => self.claim_lift(id, point, crate::util::msec_timestamp(), true),
+            TouchRoute::Client { .. } => ffi::river_wlr_seat_touch_cancel_point((*self.seat).wlr_seat, id),
+            TouchRoute::Pointer { pressed: true, .. } => self.inject_button(BTN_LEFT, false),
+            TouchRoute::Pointer { pressed: false, .. } | TouchRoute::Ignored => {}
+        }
+    }
+
+    pub unsafe fn touch_frame(&mut self) {
+        // The emulated pointer frames each of its own events; only touch
+        // clients are waiting on this one.
+        if self.touch_points.values().any(|p| matches!(p.route, TouchRoute::Client { .. }))
+            || ffi::wlr_seat_touch_num_points((*self.seat).wlr_seat) > 0
+        {
+            ffi::wlr_seat_touch_notify_frame((*self.seat).wlr_seat);
+        }
+    }
+
+    // ── Gestures ────────────────────────────────────────────────────────
+
+    /// Take every finger already down for a gesture: clients hear
+    /// `wl_touch.cancel`, and a pointer finger that never pressed is simply
+    /// dropped (the reason the press waits).
+    unsafe fn claim_all(&mut self) {
+        let seat = (*self.seat).wlr_seat;
+        let ids: Vec<i32> = self.touch_points.keys().copied().collect();
+        for id in ids {
+            let point = self.touch_points.get_mut(&id).unwrap();
+            let route = std::mem::replace(&mut point.route, TouchRoute::Claimed);
+            match route {
+                TouchRoute::Client { .. } => ffi::river_wlr_seat_touch_cancel_point(seat, id),
+                TouchRoute::Pointer { pressed: true, .. } => self.inject_button(BTN_LEFT, false),
+                _ => {}
+            }
+        }
+    }
+
+    unsafe fn claimed_shape(&self) -> Shape {
+        let points: Vec<(f64, f64)> = self
+            .touch_points
+            .values()
+            .filter(|p| p.route == TouchRoute::Claimed)
+            .map(|p| (p.lx, p.ly))
+            .collect();
+        shape(&points)
+    }
+
+    unsafe fn claim_rebase(&mut self) {
+        let s = self.claimed_shape();
+        match &mut self.touch_claim {
+            Claim::Desk { track, .. } | Claim::Multi { track, .. } => track.rebase(s),
+            _ => {}
+        }
+    }
+
+    unsafe fn start_desk(&mut self) {
+        let wm = &mut (*(*self.seat).server).wm;
+        wm.stop_panning_animation();
+        self.touch_claim = Claim::Desk {
+            track: Track::new(self.claimed_shape()),
+            start_zoom: wm.desk_zoom,
+            vel: [0.0, 0.0],
+            last_ms: 0,
+        };
+        log::debug!("touch: desk gesture with {} fingers", self.touch_points.len());
+    }
+
+    unsafe fn claim_motion(&mut self, time_msec: u32) {
+        let s = self.claimed_shape();
+        let server = (*self.seat).server;
+        match self.touch_claim {
+            Claim::None => {}
+            Claim::Edge { id, edge, start, fired } => {
+                if fired {
+                    return;
+                }
+                let Some(p) = self.touch_points.get(&id) else { return };
+                match edge_progress(edge, p.lx - start.0, p.ly - start.1) {
+                    EdgeProgress::Undecided => {}
+                    EdgeProgress::NotEdge => self.edge_release(time_msec),
+                    EdgeProgress::Fire => {
+                        self.touch_claim = Claim::Edge { id, edge, start, fired: true };
+                        let mods = gesture_mods(&*self.seat);
+                        let wm = &mut (*server).wm;
+                        if let Some((action, command)) = gesture_bind(wm, "edge", 1, mods, |d| d == edge.name()) {
+                            log::info!("touch: edge_{} fired {action:?}", edge.name());
+                            run_gesture_action(wm, action, command.as_deref());
+                        }
+                    }
+                }
+            }
+            Claim::Desk { mut track, start_zoom, mut vel, mut last_ms } => {
+                let d = track.step(s);
+                let wm = &mut (*server).wm;
+                if d != (0.0, 0.0) {
+                    // The desk follows the fingers: the camera goes the
+                    // other way, in virtual units.
+                    let step = (-d.0 / wm.desk_zoom, -d.1 / wm.desk_zoom);
+                    wm.queue_pan(step.0, step.1);
+                    let dt = time_msec.wrapping_sub(last_ms).clamp(4, 100) as f64 / 1000.0;
+                    for (axis, v) in [step.0, step.1].into_iter().enumerate() {
+                        let sample = v / dt;
+                        vel[axis] = if last_ms == 0 { sample } else { vel[axis] * 0.65 + sample * 0.35 };
+                    }
+                    last_ms = time_msec;
+                    wm.pan_finger_v = vel;
+                }
+                if s.count >= 2 && track.scale != 1.0 {
+                    let zoom = crate::policy::camera::pinch_zoom(start_zoom, track.scale);
+                    let (ax, ay) = output_local(server, s.centroid);
+                    wm.queue_pinch(zoom, ax, ay);
+                }
+                self.touch_claim = Claim::Desk { track, start_zoom, vel, last_ms };
+            }
+            Claim::Multi { mut track, kind } => {
+                let d = track.step(s);
+                let fingers = bind_fingers(track.fingers);
+                let kind = match kind {
+                    MultiKind::Undecided => match decide(&track) {
+                        MultiKind::Swipe => {
+                            log::info!("touch: {fingers}-finger swipe");
+                            // Natural: the camera goes against the fingers.
+                            self.touch_swipe("begin", fingers, 0.0, 0.0);
+                            self.touch_swipe("update", fingers, -track.travel.0, -track.travel.1);
+                            MultiKind::Swipe
+                        }
+                        MultiKind::Pinch { .. } => {
+                            log::info!("touch: {fingers}-finger pinch");
+                            self.touch_pinch(fingers, track.scale)
+                        }
+                        MultiKind::Undecided => MultiKind::Undecided,
+                    },
+                    MultiKind::Swipe => {
+                        if d != (0.0, 0.0) {
+                            self.touch_swipe("update", fingers, -d.0, -d.1);
+                        }
+                        MultiKind::Swipe
+                    }
+                    MultiKind::Pinch { fired: false } => self.touch_pinch(fingers, track.scale),
+                    fired @ MultiKind::Pinch { fired: true } => fired,
+                };
+                self.touch_claim = Claim::Multi { track, kind };
+            }
+        }
+    }
+
+    /// A claimed finger lifted (or was cancelled). The gesture ends with
+    /// its last finger.
+    unsafe fn claim_lift(&mut self, id: i32, point: TouchPoint, time_msec: u32, cancelled: bool) {
+        match self.touch_claim {
+            Claim::None => {}
+            Claim::Edge { id: edge_id, start, fired, .. } => {
+                if edge_id != id {
+                    return;
+                }
+                self.touch_claim = Claim::None;
+                // Lifted where it landed: it was a tap in the edge zone (the
+                // status bar's, say), so deliver it as one, late.
+                if !fired && !cancelled && (point.lx - start.0).hypot(point.ly - start.1) < TAP_SLOP {
+                    self.touch_begin(id, start.0, start.1, time_msec);
+                    self.touch_up(id, time_msec);
+                }
+            }
+            Claim::Desk { vel, last_ms, .. } => {
+                if self.touch_points.values().any(|p| p.route == TouchRoute::Claimed) {
+                    self.claim_rebase();
+                    return;
+                }
+                self.touch_claim = Claim::None;
+                let wm = &mut (*(*self.seat).server).wm;
+                wm.pan_finger_v = [0.0, 0.0];
+                // Fling on the last velocity unless the fingers had come to
+                // rest first, as a trackpad pan's lift does (`handle_axis`).
+                let resting = last_ms == 0 || time_msec.wrapping_sub(last_ms) > 80;
+                if !cancelled && !resting && wm.kinetic_scroll() && vel != [0.0, 0.0] {
+                    wm.pan_coast_vx = vel[0];
+                    wm.pan_coast_vy = vel[1];
+                    wm.start_panning_animation();
+                }
+            }
+            Claim::Multi { kind, track } => {
+                if self.touch_points.values().any(|p| p.route == TouchRoute::Claimed) {
+                    self.claim_rebase();
+                    return;
+                }
+                self.touch_claim = Claim::None;
+                if kind == MultiKind::Swipe {
+                    self.touch_swipe("end", bind_fingers(track.fingers), 0.0, 0.0);
+                }
+            }
+        }
+    }
+
+    /// The edge finger is not an edge swipe after all: route it normally
+    /// from where it went down, and catch it up to where it is.
+    unsafe fn edge_release(&mut self, time_msec: u32) {
+        let Claim::Edge { id, start, .. } = self.touch_claim else { return };
+        self.touch_claim = Claim::None;
+        let Some(now) = self.touch_points.get(&id).map(|p| (p.lx, p.ly)) else { return };
+        self.touch_begin(id, start.0, start.1, time_msec);
+        self.touch_motion(id, now.0, now.1, time_msec);
+    }
+
+    /// The edge whose zone `lx, ly` is in, if an `edge_<side>` bind exists
+    /// for it under the current modifiers. An edge is a screen edge only
+    /// where no other output continues past it.
+    unsafe fn bound_edge_at(&self, lx: f64, ly: f64) -> Option<Edge> {
+        let server = (*self.seat).server;
+        let wm = &(*server).wm;
+        if !wm.gesture_binds.iter().any(|b| b.gesture_type == "edge") {
+            return None;
+        }
+        let layout = (*server).om.output_layout;
+        let output = ffi::wlr_output_layout_output_at(layout, lx, ly);
+        if output.is_null() {
+            return None;
+        }
+        let mut b = ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 };
+        ffi::wlr_output_layout_get_box(layout, output, &mut b);
+        let (x0, y0) = (b.x as f64, b.y as f64);
+        let (x1, y1) = (x0 + b.width as f64, y0 + b.height as f64);
+        let beyond_is_empty = |x: f64, y: f64| ffi::wlr_output_layout_output_at(layout, x, y).is_null();
+        let candidates = [
+            (Edge::Left, lx - x0 < EDGE_ZONE && beyond_is_empty(x0 - 1.0, ly)),
+            (Edge::Right, x1 - lx <= EDGE_ZONE && beyond_is_empty(x1 + 1.0, ly)),
+            (Edge::Top, ly - y0 < EDGE_ZONE && beyond_is_empty(lx, y0 - 1.0)),
+            (Edge::Bottom, y1 - ly <= EDGE_ZONE && beyond_is_empty(lx, y1 + 1.0)),
+        ];
+        let mods = gesture_mods(&*self.seat);
+        candidates
+            .into_iter()
+            .filter(|&(_, near)| near)
+            .map(|(edge, _)| edge)
+            .find(|edge| gesture_bind(wm, "edge", 1, mods, |d| d == edge.name()).is_some())
+    }
+
+    /// One stage of a touchscreen swipe, through the touchpad's handling.
+    unsafe fn touch_swipe(&mut self, stage: &str, fingers: u32, dx: f64, dy: f64) {
+        self.gesture_from_touch = true;
+        self.inject_swipe_stage(stage, fingers, dx, dy);
+        self.gesture_from_touch = false;
+    }
+
+    /// Fire a pinch bind if the spread has gone far enough; once per gesture.
+    unsafe fn touch_pinch(&mut self, fingers: u32, scale: f64) -> MultiKind {
+        let mods = gesture_mods(&*self.seat);
+        let wm = &mut (*(*self.seat).server).wm;
+        match gesture_bind(wm, "pinch", fingers, mods, |d| pinch_hits(d, scale)) {
+            Some((action, command)) => {
+                log::info!("touch: pinch fired {action:?}");
+                run_gesture_action(wm, action, command.as_deref());
+                MultiKind::Pinch { fired: true }
+            }
+            None => MultiKind::Pinch { fired: false },
+        }
+    }
+
+    /// Layout coordinates of a touch event's normalized `x, y`, through the
+    /// device's output mapping (`map_to_output` / `map_to_region`).
+    unsafe fn touch_layout_coords(&self, touch: *mut ffi::wlr_touch, x: f64, y: f64) -> (f64, f64) {
+        let wlr_device = if touch.is_null() {
+            std::ptr::null_mut()
+        } else {
+            &mut (*touch).base as *mut ffi::wlr_input_device
+        };
+        let mut lx = 0.0;
+        let mut ly = 0.0;
+        ffi::wlr_cursor_absolute_to_layout_coords(self.wlr_cursor, wlr_device, x, y, &mut lx, &mut ly);
+        (lx, ly)
+    }
+}
+
+/// A layout point in its output's own coordinates, the anchor a pinch zoom
+/// takes (`WindowManager::queue_pinch`).
+unsafe fn output_local(server: *mut crate::server::Server, (x, y): (f64, f64)) -> (f64, f64) {
+    let wlr_output = (*server).om.output_at(x, y);
+    if wlr_output.is_null() {
+        return (x, y);
+    }
+    let mut b = ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 };
+    ffi::wlr_output_layout_get_box((*server).om.output_layout, wlr_output, &mut b);
+    (x - b.x as f64, y - b.y as f64)
+}
+
+pub(crate) unsafe extern "C" fn handle_touch_down(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_down_listener);
+    let event = data as *mut ffi::wlr_touch_down_event;
+    let (lx, ly) = cursor.touch_layout_coords((*event).touch, (*event).x, (*event).y);
+    cursor.touch_down((*event).touch_id, lx, ly, (*event).time_msec);
+}
+
+pub(crate) unsafe extern "C" fn handle_touch_motion(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_motion_listener);
+    let event = data as *mut ffi::wlr_touch_motion_event;
+    let (lx, ly) = cursor.touch_layout_coords((*event).touch, (*event).x, (*event).y);
+    cursor.touch_motion((*event).touch_id, lx, ly, (*event).time_msec);
+}
+
+pub(crate) unsafe extern "C" fn handle_touch_up(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_up_listener);
+    let event = data as *mut ffi::wlr_touch_up_event;
+    cursor.touch_up((*event).touch_id, (*event).time_msec);
+}
+
+pub(crate) unsafe extern "C" fn handle_touch_cancel(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_cancel_listener);
+    let event = data as *mut ffi::wlr_touch_cancel_event;
+    cursor.touch_cancel((*event).touch_id);
+}
+
+pub(crate) unsafe extern "C" fn handle_touch_frame(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_frame_listener);
+    cursor.touch_frame();
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn an_edge_swipe_fires_inward_and_lets_go_along_the_edge() {
+        // From the left edge, straight in.
+        assert_eq!(edge_progress(Edge::Left, 30.0, 2.0), EdgeProgress::Undecided);
+        assert_eq!(edge_progress(Edge::Left, EDGE_FIRE, 5.0), EdgeProgress::Fire);
+        // From the bottom, inward is up.
+        assert_eq!(edge_progress(Edge::Bottom, 0.0, -EDGE_FIRE), EdgeProgress::Fire);
+        assert_eq!(edge_progress(Edge::Bottom, 0.0, EDGE_FIRE), EdgeProgress::NotEdge);
+        // Along the top edge (a drag in the status bar) is not an edge swipe.
+        assert_eq!(edge_progress(Edge::Top, 40.0, 3.0), EdgeProgress::NotEdge);
+        // Diagonal but mostly inward still fires.
+        assert_eq!(edge_progress(Edge::Right, -70.0, 40.0), EdgeProgress::Fire);
+        // Jitter inside the slop decides nothing.
+        assert_eq!(edge_progress(Edge::Top, 5.0, -3.0), EdgeProgress::Undecided);
+    }
+
+    #[test]
+    fn shape_is_centroid_and_mean_spread() {
+        let s = shape(&[(0.0, 0.0), (10.0, 0.0)]);
+        assert_eq!(s.centroid, (5.0, 0.0));
+        assert_eq!(s.spread, 5.0);
+        assert_eq!(s.count, 2);
+        assert_eq!(shape(&[]).count, 0);
+    }
+
+    #[test]
+    fn a_track_rebases_when_a_finger_lands_or_lifts() {
+        let mut t = Track::new(shape(&[(0.0, 0.0), (100.0, 0.0)]));
+        assert_eq!(t.step(shape(&[(10.0, 0.0), (110.0, 0.0)])), (10.0, 0.0));
+        // A third finger lands far away: the centroid jumps, but nothing moved.
+        assert_eq!(t.step(shape(&[(10.0, 0.0), (110.0, 0.0), (60.0, 300.0)])), (0.0, 0.0));
+        assert_eq!(t.travel, (10.0, 0.0));
+        assert_eq!(t.fingers, 3);
+        // Spreading to twice the distance scales by two.
+        let mut p = Track::new(shape(&[(0.0, 0.0), (100.0, 0.0)]));
+        p.step(shape(&[(-50.0, 0.0), (150.0, 0.0)]));
+        assert!((p.scale - 2.0).abs() < 1e-9);
+        assert_eq!(p.travel, (0.0, 0.0));
+    }
+
+    #[test]
+    fn three_fingers_decide_between_swipe_and_pinch() {
+        let fingers = [(0.0, 0.0), (60.0, 0.0), (30.0, 50.0)];
+        let mut t = Track::new(shape(&fingers));
+        let moved = |dx: f64, dy: f64| shape(&fingers.map(|(x, y)| (x + dx, y + dy)));
+        t.step(moved(10.0, 0.0));
+        assert_eq!(decide(&t), MultiKind::Undecided);
+        t.step(moved(DECIDE_TRAVEL, 0.0));
+        assert_eq!(decide(&t), MultiKind::Swipe);
+
+        let mut p = Track::new(shape(&fingers));
+        p.step(shape(&fingers.map(|(x, y)| ((x - 30.0) * 0.6 + 30.0, (y - 16.0) * 0.6 + 16.0))));
+        assert_eq!(decide(&p), MultiKind::Pinch { fired: false });
+    }
+
+    #[test]
+    fn a_fifth_finger_still_matches_four_finger_binds() {
+        assert_eq!(bind_fingers(3), 3);
+        assert_eq!(bind_fingers(4), 4);
+        assert_eq!(bind_fingers(5), 4);
+    }
+}