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

commit55989769b723509f2a5ef4ffdc24e717617ad927
parentc12c94bd52
authorLucas Galante <[email protected]>
date2026-09-08 14:53
feat(input): trackpad-to-view-drag emulation for apps that cannot see a trackpad

Houdini's "Enable Trackpad Gestures" cannot work under X11. Xwayland
attributes every scroll — a mouse wheel included — to its relative-pointer
device, which Qt classifies as a TouchPad; Houdini's wheel handler keys on
that type and routes the event to its touchpad "slide", which moves the
view by the QWheelEvent pixel deltas; and Qt's X11 backend only fills
those in for an XI2 scroll increment above 15 (Xwayland's is 1, the
libinput X driver's 15). Established against Houdini 22 running headless
with an event filter installed through its own Python: the slide is a
no-op, the wheel is swallowed with it, and Space + a button drag tumbles,
pans and dollies.

So the compositor synthesises that for the apps named in
`window_manager.touchpad_view_apps`: a two-finger swipe over such a
window presses Space, then the middle (pan) or left (tumble) button on
the first motion, feeds the finger deltas as surface-local pointer
motion — the on-screen cursor never moves — and releases both when the
fingers lift or after 180 ms of silence. `touchpad_view_swipe` picks pan
or tumble for the plain swipe and Shift does the other; a pinch dollies
through a vertical right-button drag of ln(scale) e-folds (Houdini
dollies in on an upward drag, ~150 px per e-fold, measured on the depth
of the world origin in view space); Ctrl + swipe passes through as a
plain scroll, the modifier Houdini itself assigns to "simulate the mouse
wheel". `touchpad_view_sensitivity` and `touchpad_view_invert` tune it.

Two things the first attempts taught: the button has to follow Space by
an event, and key repeat must be off while Space is held synthetically —
Xwayland autorepeats a held key as release/press pairs and Houdini left
view mode on the first release, mid-drag. The seat's repeat info is
zeroed for the drag and restored after.

`ccectl key-down` modifiers now count in the axis handler's modifier
checks, and `pointer-pinch begin|update|end` paces a pinch from a test,
which is how all of the above was verified.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/server/config.rs         |  25 +++-
 src/server/cursor.rs         | 304 ++++++++++++++++++++++++++++++++++++++++++-
 src/server/window_manager.rs |  23 +++-
 3 files changed, 349 insertions(+), 3 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 9265b4b..7d87fc6 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -399,6 +399,16 @@ pub struct WindowManagerConfig {
     /// of being upscaled from logical size. Default on. KDL:
     /// `xwayland_hidpi (bool)false` to get the old blurry-but-1:1 behaviour.
     pub xwayland_hidpi: Option<bool>,
+    /// Apps whose windows turn trackpad input into a view drag (Space +
+    /// button) — see `cursor::ViewDrag`. KDL: `touchpad_view_apps "Houdini FX"`.
+    pub touchpad_view_apps: Option<Vec<String>>,
+    /// What an unmodified two-finger swipe does there: "pan" (default) or
+    /// "tumble"; Shift does the other. KDL: `touchpad_view_swipe "tumble"`.
+    pub touchpad_view_swipe: Option<String>,
+    /// Finger-to-pointer distance factor for the emulated drag (default 1).
+    pub touchpad_view_sensitivity: Option<f64>,
+    /// Reverse the drag direction. KDL: `touchpad_view_invert (bool)true`.
+    pub touchpad_view_invert: Option<bool>,
 }
 
 #[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
@@ -2270,7 +2280,11 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let rounded_apps = get_child_args_string_vec_opt(node, "rounded_apps");
         let bevel_apps = get_child_args_string_vec_opt(node, "bevel_apps");
         let xwayland_hidpi = get_child_arg_bool_opt(node, "xwayland_hidpi");
-        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi });
+        let touchpad_view_apps = get_child_args_string_vec_opt(node, "touchpad_view_apps");
+        let touchpad_view_swipe = get_child_arg_string_opt(node, "touchpad_view_swipe");
+        let touchpad_view_sensitivity = get_child_arg_f64_opt(node, "touchpad_view_sensitivity");
+        let touchpad_view_invert = get_child_arg_bool_opt(node, "touchpad_view_invert");
+        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, touchpad_view_invert });
     }
 
     Ok(Config {
@@ -2331,6 +2345,15 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
         .as_ref()
         .and_then(|wm| wm.xwayland_hidpi)
         .unwrap_or(true);
+    {
+        let tv = config.window_manager.as_ref();
+        state.touchpad_view_apps = tv.and_then(|w| w.touchpad_view_apps.clone()).unwrap_or_default();
+        state.touchpad_view_swipe_tumble = tv
+            .and_then(|w| w.touchpad_view_swipe.as_deref())
+            .map_or(false, |s| s.eq_ignore_ascii_case("tumble"));
+        state.touchpad_view_sensitivity = tv.and_then(|w| w.touchpad_view_sensitivity).unwrap_or(1.0);
+        state.touchpad_view_invert = tv.and_then(|w| w.touchpad_view_invert).unwrap_or(false);
+    }
     state.display = config.display.clone();
     state.on_app_exit = config
         .window_manager
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 04c3356..b7614f9 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -11,6 +11,14 @@ pub struct Cursor {
     pub xcursor_manager: *mut ffi::wlr_xcursor_manager,
     pub constraint: *mut crate::pointer_constraint::PointerConstraint,
 
+    /// Trackpad-to-view-drag emulation for the apps `touchpad_view_apps`
+    /// names (see `ViewDrag`); `None` while no emulated drag is in progress.
+    pub view_drag: Option<ViewDrag>,
+    /// Ends an emulated drag that has seen no finger event for a while: a
+    /// two-finger scroll normally ends with a zero-delta axis event, but
+    /// not every path delivers one.
+    pub view_drag_timer: *mut ffi::wl_event_source,
+
     /// Animated-XCursor playback. An XCursor theme may ship several images per
     /// cursor with a per-frame `delay`; wlroots parses them but never advances
     /// them — `wlr_xcursor_frame` has no callers inside wlroots, so driving the
@@ -166,6 +174,8 @@ impl Default for Cursor {
             pan_last_msec: [0, 0],
             pinch_zoom_active: false,
             pinch_start_zoom: 1.0,
+            view_drag: None,
+            view_drag_timer: std::ptr::null_mut(),
             last_click_time: 0,
             last_click_window: std::ptr::null_mut(),
             hovered_border_window: std::ptr::null_mut(),
@@ -216,6 +226,11 @@ impl Cursor {
             return Err("Failed to create xcursor animation timer");
         }
         self.anim_timer = anim_timer;
+        self.view_drag_timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_view_drag_timeout),
+            self as *mut Cursor as *mut _,
+        );
 
         // Load default cursor theme
         ffi::wlr_xcursor_manager_load(xcursor_manager, 1.0);
@@ -1047,6 +1062,27 @@ impl Cursor {
     /// scale from 1 to `scale` (and the rotation to `rotation` degrees),
     /// end. Exercises the compositor's pinch policy and the
     /// pointer-gestures forward to clients headlessly.
+    /// One stage of a pinch, for a test that paces the updates itself:
+    /// `stage` is "begin", "update" (with `scale`/`rotation`) or "end".
+    pub unsafe fn inject_pinch_stage(&mut self, stage: &str, scale: f64, rotation: f64) {
+        let time = crate::util::msec_timestamp();
+        match stage {
+            "begin" => {
+                let mut ev = ffi::wlr_pointer_pinch_begin_event { pointer: std::ptr::null_mut(), time_msec: time, fingers: 2 };
+                handle_pinch_begin(&mut self.pinch_begin_listener as *mut ffi::wl_listener, &mut ev as *mut _ as *mut std::ffi::c_void);
+            }
+            "update" => {
+                let mut ev = ffi::wlr_pointer_pinch_update_event { pointer: std::ptr::null_mut(), time_msec: time, fingers: 2, dx: 0.0, dy: 0.0, scale, rotation };
+                handle_pinch_update(&mut self.pinch_update_listener as *mut ffi::wl_listener, &mut ev as *mut _ as *mut std::ffi::c_void);
+            }
+            _ => {
+                let mut ev = ffi::wlr_pointer_pinch_end_event { pointer: std::ptr::null_mut(), time_msec: time, cancelled: false };
+                handle_pinch_end(&mut self.pinch_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);
+    }
+
     pub unsafe fn inject_pinch(&mut self, scale: f64, rotation: f64, steps: u32) {
         let time = crate::util::msec_timestamp();
         let mut begin = ffi::wlr_pointer_pinch_begin_event {
@@ -1093,6 +1129,11 @@ impl Cursor {
 unsafe extern "C" fn handle_motion(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, motion_listener);
     let event = data as *mut ffi::wlr_pointer_motion_event;
+    // Real pointer motion ends an emulated view drag: the client must not
+    // see the synthetic drag position and the true one interleaved.
+    if cursor.view_drag.is_some() {
+        cursor.end_view_drag();
+    }
     
     let mut dx = (*event).delta_x;
     let mut dy = (*event).delta_y;
@@ -1187,6 +1228,9 @@ unsafe fn is_cloud_layer(layer_surface: *mut crate::layer_shell::LayerSurface) -
 unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, button_listener);
     let event = data as *mut ffi::wlr_pointer_button_event;
+    if cursor.view_drag.is_some() {
+        cursor.end_view_drag();
+    }
     
     let seat = &mut *cursor.seat;
     let lx = cursor.x();
@@ -2100,11 +2144,13 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
     }
 
     let wlr_keyboard = ffi::river_wlr_seat_get_keyboard(seat.wlr_seat);
+    // Modifiers held through `ccectl key-down` count too, so a headless
+    // session can exercise the modifier branches below.
     let modifiers = if !wlr_keyboard.is_null() {
         ffi::wlr_keyboard_get_modifiers(wlr_keyboard)
     } else {
         0
-    };
+    } | (*seat.server).wm.injected_key_mods;
 
     if (modifiers & 0x44) == 0x44 {
         if (*event).orientation == ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL {
@@ -2257,6 +2303,12 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
         return;
     }
 
+    // A two-finger scroll over an app in `touchpad_view_apps` becomes a
+    // view drag instead of a scroll (see `ViewDrag`).
+    if is_finger && cursor.view_drag_axis(event, delta, modifiers) {
+        return;
+    }
+
     ffi::wlr_seat_pointer_notify_axis(
         seat.wlr_seat,
         (*event).time_msec,
@@ -2268,6 +2320,243 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
     );
 }
 
+/// An emulated view drag: trackpad input over a window turned into what
+/// a 3D app's view tool understands, a held key plus a button drag.
+///
+/// Why this exists: Houdini's own trackpad gestures cannot work under X11.
+/// Xwayland attributes every scroll to a device Qt classifies as a
+/// TouchPad, Houdini's touchpad "slide" then moves the view by the wheel
+/// event's pixel deltas, and Qt's X11 backend never fills those in (it
+/// does so only for a scroll increment above 15; Xwayland's is 1, the
+/// libinput X driver's 15). Verified against Houdini 22 headless: the slide
+/// is a no-op and the mouse wheel is swallowed with it, while Space + a
+/// button drag tumbles, pans and dollies. So for apps listed in
+/// `window_manager.touchpad_view_apps` the compositor synthesises exactly
+/// that: Space down, button down, the finger deltas as pointer motion on
+/// the surface (the on-screen cursor never moves), button and Space up
+/// when the fingers lift. Two-finger swipe pans (middle button) or tumbles
+/// (left) per `touchpad_view_swipe`, Shift picks the other, a pinch
+/// dollies (right button, distance from the log of the scale), and Ctrl
+/// + swipe passes through as a plain scroll — the same modifier Houdini
+/// itself assigns to "simulate the mouse wheel" in gesture mode.
+pub struct ViewDrag {
+    pub button: u32,
+    pub surface: *mut ffi::wlr_surface,
+    pub window: *mut crate::window::Window,
+    /// Synthetic pointer position, surface-local.
+    pub sx: f64,
+    pub sy: f64,
+    pub origin_sx: f64,
+    pub origin_sy: f64,
+    /// Surface units per layout pixel (X11 HiDPI buffers, overview zoom).
+    pub ratio: f64,
+    pub from_pinch: bool,
+    /// The button goes down on the first motion, one event after Space:
+    /// Houdini feeds Qt input to its UI thread through a generator thread,
+    /// and a button that arrives in the same instant as Space can be
+    /// interpreted before the key — a right button then reads as a pan,
+    /// not a dolly.
+    pub button_down: bool,
+    /// The keyboard's repeat settings before the drag, restored at its end.
+    /// While Space is held synthetically the seat's repeat is switched off:
+    /// Xwayland autorepeats a held key as release/press pairs, and Houdini
+    /// left view mode on the first release, mid-drag.
+    pub repeat: Option<(i32, i32)>,
+}
+
+const KEY_SPACE: u32 = 57;
+const BTN_LEFT: u32 = 0x110;
+const BTN_RIGHT: u32 = 0x111;
+const BTN_MIDDLE: u32 = 0x112;
+/// Finger silence that ends a swipe drag when no zero-delta event came.
+const VIEW_DRAG_IDLE_MS: i32 = 180;
+/// Drag distance (layout px) per e-fold of pinch scale. Measured against
+/// Houdini 22 (depth of the world origin in view space, which is what a
+/// dolly changes — Houdini dollies toward the point under the pointer, so
+/// distances to a fixed pivot mislead): Space+RMB dollies on the VERTICAL
+/// drag, up is in, and 45 px up shortened the depth by a factor of 1.34,
+/// about 150 px per e-fold. So a pinch of scale s becomes an upward drag
+/// of ln(s) e-folds and the depth ends near 1/s.
+const VIEW_DRAG_PINCH_PX: f64 = 150.0;
+
+impl Cursor {
+    /// The window under the pointer, if `touchpad_view_apps` names its app.
+    unsafe fn view_drag_target(&mut self) -> Option<(*mut crate::window::Window, *mut ffi::wlr_surface, f64, f64, f64)> {
+        let server = (*self.seat).server;
+        let wm = &(*server).wm;
+        if wm.touchpad_view_apps.is_empty() {
+            return None;
+        }
+        let result = (*server).scene.at(self.x(), self.y())?;
+        let SceneNodeDataVal::Window(window) = result.data else { return None };
+        if window.is_null() || result.surface.is_null() || result.node.is_null() {
+            return None;
+        }
+        let app_id = (*window).get_app_id_string().unwrap_or_default();
+        if !wm.touchpad_view_apps.iter().any(|p| crate::window_manager::app_id_matches(p, &app_id)) {
+            return None;
+        }
+        let mut ratio = 1.0;
+        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 {
+            ratio = surf_w as f64 / dest_w as f64;
+        }
+        Some((window, result.surface, result.sx, result.sy, ratio))
+    }
+
+    unsafe fn begin_view_drag(&mut self, button: u32, from_pinch: bool) -> bool {
+        let Some((window, surface, sx, sy, ratio)) = self.view_drag_target() else { return false };
+        let seat = &mut *self.seat;
+        // Space must reach the window: give it keyboard focus as a click would.
+        if seat.focused != crate::seat::Focus::Window(window) {
+            seat.focus(crate::seat::Focus::Window(window));
+        }
+        seat.ensure_synthetic_keyboard();
+        let time = crate::util::msec_timestamp();
+        let mut repeat = None;
+        let kbd = ffi::river_wlr_seat_get_keyboard(seat.wlr_seat);
+        if !kbd.is_null() {
+            repeat = Some(((*kbd).repeat_info.rate, (*kbd).repeat_info.delay));
+            ffi::wlr_keyboard_set_repeat_info(kbd, 0, 0);
+        }
+        ffi::wlr_seat_pointer_notify_enter(seat.wlr_seat, surface, sx, sy);
+        ffi::wlr_seat_keyboard_notify_key(seat.wlr_seat, time, KEY_SPACE, ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED);
+        ffi::wlr_seat_pointer_notify_frame(seat.wlr_seat);
+        log::info!("[ViewDrag] begin button={:#x} from_pinch={} at surface ({:.0}, {:.0}) ratio={}", button, from_pinch, sx, sy, ratio);
+        self.view_drag = Some(ViewDrag { button, surface, window, sx, sy, origin_sx: sx, origin_sy: sy, ratio, from_pinch, button_down: false, repeat });
+        self.arm_view_drag_timer();
+        true
+    }
+
+    unsafe fn arm_view_drag_timer(&mut self) {
+        if !self.view_drag_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.view_drag_timer, VIEW_DRAG_IDLE_MS);
+        }
+    }
+
+    /// Move the synthetic pointer by layout pixels.
+    unsafe fn move_view_drag(&mut self, dx: f64, dy: f64) {
+        let Some(d) = self.view_drag.as_mut() else { return };
+        let seat = &mut *self.seat;
+        let time = crate::util::msec_timestamp();
+        if !d.button_down {
+            ffi::wlr_seat_pointer_notify_button(seat.wlr_seat, time, d.button, ffi::wl_pointer_button_state_WL_POINTER_BUTTON_STATE_PRESSED);
+            ffi::wlr_seat_pointer_notify_frame(seat.wlr_seat);
+            d.button_down = true;
+        }
+        d.sx += dx * d.ratio;
+        d.sy += dy * d.ratio;
+        let (sx, sy) = (d.sx, d.sy);
+        ffi::wlr_seat_pointer_notify_motion(seat.wlr_seat, time, sx, sy);
+        ffi::wlr_seat_pointer_notify_frame(seat.wlr_seat);
+        self.arm_view_drag_timer();
+    }
+
+    pub unsafe fn end_view_drag(&mut self) {
+        let Some(d) = self.view_drag.take() else { return };
+        log::info!("[ViewDrag] end button={:#x} from_pinch={} at surface ({:.0}, {:.0})", d.button, d.from_pinch, d.sx, d.sy);
+        if !self.view_drag_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.view_drag_timer, 0);
+        }
+        let seat = &mut *self.seat;
+        let time = crate::util::msec_timestamp();
+        if d.button_down {
+            ffi::wlr_seat_pointer_notify_button(seat.wlr_seat, time, d.button, ffi::wl_pointer_button_state_WL_POINTER_BUTTON_STATE_RELEASED);
+            ffi::wlr_seat_pointer_notify_frame(seat.wlr_seat);
+        }
+        ffi::wlr_seat_keyboard_notify_key(seat.wlr_seat, time, KEY_SPACE, ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_RELEASED);
+        if let Some((rate, delay)) = d.repeat {
+            let kbd = ffi::river_wlr_seat_get_keyboard(seat.wlr_seat);
+            if !kbd.is_null() {
+                ffi::wlr_keyboard_set_repeat_info(kbd, rate, delay);
+            }
+        }
+        // Put the client's idea of the pointer back where the cursor is.
+        self.passthrough(time);
+        ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+    }
+
+    /// A finger-source axis event over a `touchpad_view_apps` window.
+    /// Returns true when it was consumed by the emulation.
+    pub unsafe fn view_drag_axis(&mut self, event: *const ffi::wlr_pointer_axis_event, delta: f64, modifiers: u32) -> bool {
+        const SHIFT: u32 = 0x1;
+        const CTRL: u32 = 0x4;
+        if matches!(&self.view_drag, Some(d) if d.from_pinch) {
+            return false;
+        }
+        if delta == 0.0 {
+            // The fingers lifted.
+            if self.view_drag.is_some() {
+                self.end_view_drag();
+                return true;
+            }
+            return false;
+        }
+        if modifiers & CTRL != 0 {
+            // Houdini's own wheel modifier: a plain scroll.
+            if self.view_drag.is_some() {
+                self.end_view_drag();
+            }
+            return false;
+        }
+        if self.view_drag.is_none() {
+            let wm = &(*(*self.seat).server).wm;
+            let tumble = wm.touchpad_view_swipe_tumble != (modifiers & SHIFT != 0);
+            let button = if tumble { BTN_LEFT } else { BTN_MIDDLE };
+            if !self.begin_view_drag(button, false) {
+                return false;
+            }
+        }
+        let wm = &(*(*self.seat).server).wm;
+        let mut step = delta * wm.touchpad_view_sensitivity;
+        if wm.touchpad_view_invert {
+            step = -step;
+        }
+        if (*event).orientation == ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL {
+            self.move_view_drag(0.0, step);
+        } else {
+            self.move_view_drag(step, 0.0);
+        }
+        true
+    }
+
+    pub unsafe fn view_drag_pinch_begin(&mut self) -> bool {
+        if self.view_drag.is_some() {
+            self.end_view_drag();
+        }
+        self.begin_view_drag(BTN_RIGHT, true)
+    }
+
+    pub unsafe fn view_drag_pinch_update(&mut self, scale: f64) -> bool {
+        let Some(d) = self.view_drag.as_ref() else { return false };
+        if !d.from_pinch {
+            return false;
+        }
+        let wm = &(*(*self.seat).server).wm;
+        // Pinch out (scale > 1) dollies in: an upward drag.
+        let px = -scale.max(0.05).ln() * VIEW_DRAG_PINCH_PX * wm.touchpad_view_sensitivity;
+        let target_sy = d.origin_sy + px * d.ratio;
+        let dy_layout = (target_sy - d.sy) / d.ratio;
+        self.move_view_drag(0.0, dy_layout);
+        true
+    }
+
+    pub unsafe fn view_drag_pinch_end(&mut self) -> bool {
+        if matches!(&self.view_drag, Some(d) if d.from_pinch) {
+            self.end_view_drag();
+            return true;
+        }
+        false
+    }
+}
+
+unsafe extern "C" fn handle_view_drag_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let cursor = &mut *(data as *mut Cursor);
+    cursor.end_view_drag();
+    0
+}
+
 unsafe extern "C" fn handle_frame(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, frame_listener);
 
@@ -2805,6 +3094,11 @@ unsafe extern "C" fn handle_pinch_begin(listener: *mut ffi::wl_listener, data: *
         return;
     }
 
+    // A pinch over an app in `touchpad_view_apps` becomes a dolly drag.
+    if cursor.view_drag_pinch_begin() {
+        return;
+    }
+
     let pointer_gestures = (*server).input_manager.pointer_gestures;
     if !pointer_gestures.is_null() {
         ffi::wlr_pointer_gestures_v1_send_pinch_begin(
@@ -2828,6 +3122,10 @@ unsafe extern "C" fn handle_pinch_update(listener: *mut ffi::wl_listener, data:
     }
     seat.handle_activity();
 
+    if cursor.view_drag_pinch_update((*event).scale) {
+        return;
+    }
+
     if cursor.pinch_zoom_active {
         let wm = &mut (*seat.server).wm;
         let old_zoom = wm.desk_zoom;
@@ -2945,6 +3243,10 @@ unsafe extern "C" fn handle_pinch_end(listener: *mut ffi::wl_listener, data: *mu
     }
     seat.handle_activity();
 
+    if cursor.view_drag_pinch_end() {
+        return;
+    }
+
     if cursor.pinch_zoom_active {
         // Camera zoom consumed the whole gesture; clients got no begin, so
         // they get no end. The camera simply stays where the fingers left it.
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 3123ada..8f79015 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -117,6 +117,11 @@ pub struct WindowManager {
     /// Xwayland sees a physical-pixel screen and X11 surfaces draw at
     /// 1/scale (see `WindowManagerConfig::xwayland_hidpi`).
     pub xwayland_hidpi: bool,
+    /// Trackpad-to-view-drag emulation (see `cursor::ViewDrag`).
+    pub touchpad_view_apps: Vec<String>,
+    pub touchpad_view_swipe_tumble: bool,
+    pub touchpad_view_sensitivity: f64,
+    pub touchpad_view_invert: bool,
     /// Live override-redirect X11 surfaces (menus, tooltips, combo lists),
     /// so the per-frame pass can re-apply their 1/scale dest size — the
     /// scene's own commit listener resets it on every commit.
@@ -353,6 +358,10 @@ impl WindowManager {
         self.sent.output_config = std::ptr::null_mut();
         self.output_scale = 1.0;
         self.xwayland_hidpi = true;
+        self.touchpad_view_apps = Vec::new();
+        self.touchpad_view_swipe_tumble = false;
+        self.touchpad_view_sensitivity = 1.0;
+        self.touchpad_view_invert = false;
         self.display = std::collections::HashMap::new();
         self.input_rules = Vec::new();
         self.input_config = crate::config::InputConfig::default();
@@ -424,6 +433,10 @@ impl WindowManager {
         self.layout = crate::config::Layout::default();
         self.output_scale = 1.0;
         self.xwayland_hidpi = true;
+        self.touchpad_view_apps = Vec::new();
+        self.touchpad_view_swipe_tumble = false;
+        self.touchpad_view_sensitivity = 1.0;
+        self.touchpad_view_invert = false;
         self.display = std::collections::HashMap::new();
         self.has_restored_focused_window = false;
         self.restored_focused_window_mapped = false;
@@ -5046,7 +5059,15 @@ impl WindowManager {
             }
             "pointer-pinch" => {
                 // pointer-pinch <scale> [rotation-degrees] [steps]
-                if parts.len() < 2 { return "error: usage: pointer-pinch <scale> [rotation] [steps]\n".to_string(); }
+                // pointer-pinch begin | update <scale> [rotation] | end   (paced by the caller)
+                if parts.len() < 2 { return "error: usage: pointer-pinch <scale> [rotation] [steps] | begin | update <scale> [rotation] | end\n".to_string(); }
+                if matches!(parts[1], "begin" | "update" | "end") {
+                    let scale = parts.get(2).and_then(|v| v.parse::<f64>().ok()).unwrap_or(1.0);
+                    let rotation = parts.get(3).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
+                    let stage = parts[1].to_string();
+                    self.for_each_cursor(|cursor| cursor.inject_pinch_stage(&stage, scale, rotation));
+                    return "ok\n".to_string();
+                }
                 let scale = parts[1].parse::<f64>();
                 let rotation = parts.get(2).map(|v| v.parse::<f64>()).unwrap_or(Ok(0.0));
                 let steps = parts.get(3).map(|v| v.parse::<u32>()).unwrap_or(Ok(10));