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