diff --git a/docs/wiki/Configuration:-Input.md b/docs/wiki/Configuration:-Input.md
index 60a3808d23..a913c55660 100644
--- a/docs/wiki/Configuration:-Input.md
+++ b/docs/wiki/Configuration:-Input.md
@@ -96,7 +96,30 @@ input {
touch {
// off
map-to-output "eDP-1"
+ // natural-scroll
// calibration-matrix 1.0 0.0 0.0 0.0 1.0 0.0
+
+ gestures {
+ workspace-switch {
+ // off
+ // finger-count 3
+ // sensitivity 0.4
+ // natural-scroll
+ }
+ view-scroll {
+ // off
+ // finger-count 3
+ // sensitivity 0.4
+ // natural-scroll
+ }
+ overview-toggle {
+ // off
+ // finger-count 4
+ // sensitivity 0.4
+ // natural-scroll
+ }
+ // recognition-threshold 16.0
+ }
}
// disable-power-key-handling
@@ -263,6 +286,27 @@ Settings specific to `tablet` and `touch`:
- Since: 25.02 for `tablet`
- Since: 25.11 for `touch`
+Settings specific to `touch`:
+
+- `natural-scroll`: Since: next if set, inverts the scrolling direction for touchscreen gestures (workspace switching and view scrolling).
+- `gestures {}`: Since: next configure touchscreen multi-finger gestures:
+ - `workspace-switch {}`: 3-finger vertical swipe to switch workspaces.
+ - `off`: disable this gesture.
+ - `finger-count `: number of fingers required (default: 3).
+ - `sensitivity `: speed multiplier (default: 0.4).
+ - `natural-scroll`: if set, inverts direction for this gesture (inherits from touch-level `natural-scroll` if not set).
+ - `view-scroll {}`: 3-finger horizontal swipe to scroll between columns.
+ - `off`: disable this gesture.
+ - `finger-count `: number of fingers required (default: 3).
+ - `sensitivity `: speed multiplier (default: 0.4).
+ - `natural-scroll`: if set, inverts direction for this gesture (inherits from touch-level `natural-scroll` if not set).
+ - `overview-toggle {}`: 4-finger vertical swipe to toggle the overview.
+ - `off`: disable this gesture.
+ - `finger-count `: number of fingers required (default: 4).
+ - `sensitivity `: speed multiplier (default: 0.4).
+ - `natural-scroll`: if set, inverts direction for this gesture (inherits from touch-level `natural-scroll` if not set).
+ - `recognition-threshold `: distance in pixels before a gesture direction is locked (default: 16.0).
+
Tablets and touchscreens are absolute pointing devices that can be mapped to a specific output like so:
```kdl
diff --git a/niri-config/src/input.rs b/niri-config/src/input.rs
index 12af80aec5..530802a94d 100644
--- a/niri-config/src/input.rs
+++ b/niri-config/src/input.rs
@@ -176,7 +176,7 @@ pub struct ScrollFactor {
impl ScrollFactor {
pub fn h_v_factors(&self) -> (f64, f64) {
- let base_value = self.base.map(|f| f.0).unwrap_or(1.0);
+ let base_value = self.base.map(|f| f.0).unwrap_or(0.4);
let h = self.horizontal.map(|f| f.0).unwrap_or(base_value);
let v = self.vertical.map(|f| f.0).unwrap_or(base_value);
(h, v)
@@ -371,10 +371,137 @@ pub struct Tablet {
pub struct Touch {
#[knuffel(child)]
pub off: bool,
+ #[knuffel(child)]
+ pub natural_scroll: bool,
#[knuffel(child, unwrap(arguments))]
pub calibration_matrix: Option>,
#[knuffel(child, unwrap(argument))]
pub map_to_output: Option,
+ #[knuffel(child)]
+ pub gestures: Option,
+}
+
+impl Touch {
+ pub fn gesture_threshold(&self) -> f64 {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.recognition_threshold)
+ .unwrap_or(16.0)
+ }
+
+ pub fn workspace_switch_enabled(&self) -> bool {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.workspace_switch.as_ref())
+ .map_or(true, |a| !a.off)
+ }
+
+ pub fn workspace_switch_fingers(&self) -> usize {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.workspace_switch.as_ref())
+ .and_then(|a| a.finger_count)
+ .unwrap_or(3) as usize
+ }
+
+ pub fn workspace_switch_sensitivity(&self) -> f64 {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.workspace_switch.as_ref())
+ .and_then(|a| a.sensitivity)
+ .unwrap_or(0.4)
+ }
+
+ pub fn workspace_switch_natural_scroll(&self) -> bool {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.workspace_switch.as_ref())
+ .map_or(self.natural_scroll, |a| a.natural_scroll || self.natural_scroll)
+ }
+
+ pub fn view_scroll_enabled(&self) -> bool {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.view_scroll.as_ref())
+ .map_or(true, |a| !a.off)
+ }
+
+ pub fn view_scroll_fingers(&self) -> usize {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.view_scroll.as_ref())
+ .and_then(|a| a.finger_count)
+ .unwrap_or(3) as usize
+ }
+
+ pub fn view_scroll_sensitivity(&self) -> f64 {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.view_scroll.as_ref())
+ .and_then(|a| a.sensitivity)
+ .unwrap_or(0.4)
+ }
+
+ pub fn view_scroll_natural_scroll(&self) -> bool {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.view_scroll.as_ref())
+ .map_or(self.natural_scroll, |a| a.natural_scroll || self.natural_scroll)
+ }
+
+ pub fn overview_toggle_enabled(&self) -> bool {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.overview_toggle.as_ref())
+ .map_or(true, |a| !a.off)
+ }
+
+ pub fn overview_toggle_fingers(&self) -> usize {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.overview_toggle.as_ref())
+ .and_then(|a| a.finger_count)
+ .unwrap_or(4) as usize
+ }
+
+ pub fn overview_toggle_sensitivity(&self) -> f64 {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.overview_toggle.as_ref())
+ .and_then(|a| a.sensitivity)
+ .unwrap_or(0.4)
+ }
+
+ pub fn overview_toggle_natural_scroll(&self) -> bool {
+ self.gestures
+ .as_ref()
+ .and_then(|g| g.overview_toggle.as_ref())
+ .map_or(self.natural_scroll, |a| a.natural_scroll || self.natural_scroll)
+ }
+}
+
+#[derive(knuffel::Decode, Debug, Default, Clone, PartialEq)]
+pub struct TouchGesturesConfig {
+ #[knuffel(child)]
+ pub workspace_switch: Option,
+ #[knuffel(child)]
+ pub view_scroll: Option,
+ #[knuffel(child)]
+ pub overview_toggle: Option,
+ #[knuffel(child, unwrap(argument))]
+ pub recognition_threshold: Option,
+}
+
+#[derive(knuffel::Decode, Debug, Default, Clone, PartialEq)]
+pub struct TouchGestureActionConfig {
+ #[knuffel(child)]
+ pub off: bool,
+ #[knuffel(child, unwrap(argument))]
+ pub finger_count: Option,
+ #[knuffel(child, unwrap(argument))]
+ pub sensitivity: Option,
+ #[knuffel(child)]
+ pub natural_scroll: bool,
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
diff --git a/resources/default-config.kdl b/resources/default-config.kdl
index 3fa09d56b1..bc528e75cc 100644
--- a/resources/default-config.kdl
+++ b/resources/default-config.kdl
@@ -61,6 +61,30 @@ input {
// middle-emulation
}
+ // touch {
+ // // off
+ // // natural-scroll
+ // // map-to-output "eDP-1"
+ // // gestures {
+ // // workspace-switch {
+ // // // off
+ // // finger-count 3
+ // // sensitivity 0.4
+ // // }
+ // // view-scroll {
+ // // // off
+ // // finger-count 3
+ // // sensitivity 0.4
+ // // }
+ // // overview-toggle {
+ // // // off
+ // // finger-count 4
+ // // sensitivity 0.4
+ // // }
+ // // recognition-threshold 16.0
+ // // }
+ // }
+
// Uncomment this to make the mouse warp to the center of newly focused windows.
// warp-mouse-to-focus
diff --git a/src/input/mod.rs b/src/input/mod.rs
index d46166a007..b7448ecf19 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -62,6 +62,7 @@ pub mod scroll_swipe_gesture;
pub mod scroll_tracker;
pub mod spatial_movement_grab;
pub mod swipe_tracker;
+pub mod touch_gesture;
pub mod touch_overview_grab;
pub mod touch_resize_grab;
@@ -4054,220 +4055,8 @@ impl State {
self.compute_absolute_location(evt, self.niri.output_for_touch())
}
- fn on_touch_down(&mut self, evt: I::TouchDownEvent) {
- let Some(handle) = self.niri.seat.get_touch() else {
- return;
- };
- let Some(pos) = self.compute_touch_location(&evt) else {
- return;
- };
- let slot = evt.slot();
-
- let serial = SERIAL_COUNTER.next_serial();
-
- let under = self.niri.contents_under(pos);
-
- let mod_key = self.backend.mod_key(&self.niri.config.borrow());
-
- if self.niri.screenshot_ui.is_open() {
- if let Some(output) = under.output.clone() {
- let geom = self.niri.global_space.output_geometry(&output).unwrap();
- let mut point = (pos - geom.loc.to_f64())
- .to_physical(output.current_scale().fractional_scale())
- .to_i32_round();
-
- let size = output.current_mode().unwrap().size;
- let transform = output.current_transform();
- let size = transform.transform_size(size);
- point.x = min(size.w - 1, point.x);
- point.y = min(size.h - 1, point.y);
-
- if self
- .niri
- .screenshot_ui
- .pointer_down(output, point, Some(slot))
- {
- self.niri.queue_redraw_all();
- }
- }
- } else if let Some(mru_output) = self.niri.window_mru_ui.output() {
- if let Some((output, pos_within_output)) = self.niri.output_under(pos) {
- if mru_output == output {
- let id = self.niri.window_mru_ui.pointer_motion(pos_within_output);
- if id.is_some() {
- self.confirm_mru();
- } else {
- self.niri.cancel_mru();
- }
- } else {
- self.niri.cancel_mru();
- }
- }
- } else if !handle.is_grabbed() {
- let mods = self.niri.seat.get_keyboard().unwrap().modifier_state();
- let mods = modifiers_from_state(mods);
- let mod_down = mods.contains(mod_key.to_modifiers());
-
- if self.niri.layout.is_overview_open()
- && !mod_down
- && under.layer.is_none()
- && under.output.is_some()
- {
- let (output, pos_within_output) = self.niri.output_under(pos).unwrap();
- let output = output.clone();
-
- let mut matched_narrow = true;
- let mut ws = self.niri.workspace_under(false, pos);
- if ws.is_none() {
- matched_narrow = false;
- ws = self.niri.workspace_under(true, pos);
- }
- let ws_id = ws.map(|(_, ws)| ws.id());
-
- let mapped = self.niri.window_under(pos);
- let window = mapped.map(|mapped| mapped.window.clone());
-
- let start_data = TouchGrabStartData {
- focus: None,
- slot,
- location: pos,
- };
- let start_timestamp = Duration::from_micros(evt.time());
- let grab = TouchOverviewGrab::new(
- start_data,
- start_timestamp,
- output,
- pos_within_output,
- ws_id,
- matched_narrow,
- window,
- );
- handle.set_grab(self, grab, serial);
- } else if let Some((window, _)) = under.window {
- self.niri.layout.activate_window(&window);
-
- // Check if we need to start a touch move grab.
- if mod_down {
- let start_data = TouchGrabStartData {
- focus: None,
- slot,
- location: pos,
- };
- let start_data = PointerOrTouchStartData::Touch(start_data);
- if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), true, None)
- {
- handle.set_grab(self, grab, serial);
- }
- }
-
- // FIXME: granular.
- self.niri.queue_redraw_all();
- } else if let Some(output) = under.output {
- self.niri.layout.focus_output(&output);
-
- // FIXME: granular.
- self.niri.queue_redraw_all();
- }
- self.niri.focus_layer_surface_if_on_demand(under.layer);
- };
-
- handle.down(
- self,
- under.surface,
- &DownEvent {
- slot,
- location: pos,
- serial,
- time: evt.time_msec(),
- },
- );
-
- // We're using touch, hide the pointer.
- self.niri.pointer_visibility = PointerVisibility::Disabled;
- }
- fn on_touch_up(&mut self, evt: I::TouchUpEvent) {
- let Some(handle) = self.niri.seat.get_touch() else {
- return;
- };
- let slot = evt.slot();
-
- if let Some(capture) = self.niri.screenshot_ui.pointer_up(Some(slot)) {
- if capture {
- self.confirm_screenshot(true);
- } else {
- self.niri.queue_redraw_all();
- }
- }
-
- let serial = SERIAL_COUNTER.next_serial();
- handle.up(
- self,
- &UpEvent {
- slot,
- serial,
- time: evt.time_msec(),
- },
- )
- }
- fn on_touch_motion(&mut self, evt: I::TouchMotionEvent) {
- let Some(handle) = self.niri.seat.get_touch() else {
- return;
- };
- let Some(pos) = self.compute_touch_location(&evt) else {
- return;
- };
- let slot = evt.slot();
-
- if let Some(output) = self.niri.screenshot_ui.selection_output().cloned() {
- let geom = self.niri.global_space.output_geometry(&output).unwrap();
- let mut point = (pos - geom.loc.to_f64())
- .to_physical(output.current_scale().fractional_scale())
- .to_i32_round::();
-
- let size = output.current_mode().unwrap().size;
- let transform = output.current_transform();
- let size = transform.transform_size(size);
- point.x = point.x.clamp(0, size.w - 1);
- point.y = point.y.clamp(0, size.h - 1);
-
- self.niri.screenshot_ui.pointer_motion(point, Some(slot));
- self.niri.queue_redraw(&output);
- }
-
- let under = self.niri.contents_under(pos);
- handle.motion(
- self,
- under.surface,
- &TouchMotionEvent {
- slot,
- location: pos,
- time: evt.time_msec(),
- },
- );
-
- // Inform the layout of an ongoing DnD operation.
- let is_dnd_grab = handle
- .with_grab(|_, grab| Self::is_dnd_grab(grab.as_any()))
- .unwrap_or(false);
- if is_dnd_grab {
- if let Some((output, pos_within_output)) = self.niri.output_under(pos) {
- let output = output.clone();
- self.niri.layout.dnd_update(output, pos_within_output);
- }
- }
- }
- fn on_touch_frame(&mut self, _evt: I::TouchFrameEvent) {
- let Some(handle) = self.niri.seat.get_touch() else {
- return;
- };
- handle.frame(self);
- }
- fn on_touch_cancel(&mut self, _evt: I::TouchCancelEvent) {
- let Some(handle) = self.niri.seat.get_touch() else {
- return;
- };
- handle.cancel(self);
- }
+ // Touch gesture handlers (on_touch_down, on_touch_up, on_touch_motion,
+ // on_touch_frame, on_touch_cancel) are in touch_gesture.rs.
fn on_switch_toggle(&mut self, evt: I::SwitchToggleEvent) {
let Some(switch) = evt.switch() else {
diff --git a/src/input/touch_gesture.rs b/src/input/touch_gesture.rs
new file mode 100644
index 0000000000..db3fefbf5e
--- /dev/null
+++ b/src/input/touch_gesture.rs
@@ -0,0 +1,447 @@
+//! Touchscreen multi-finger gesture handling.
+//!
+//! Processes 3+ finger touch gestures for workspace switching, view scrolling,
+//! and overview toggling. Gesture recognition, sensitivity, finger count, and
+//! natural scroll are all configurable per-gesture.
+
+use std::cmp::min;
+use std::time::Duration;
+
+use smithay::backend::input::{Event as _, TouchEvent};
+use smithay::input::touch::{
+ DownEvent, GrabStartData as TouchGrabStartData, MotionEvent as TouchMotionEvent, UpEvent,
+};
+use smithay::utils::SERIAL_COUNTER;
+
+use super::backend_ext::{NiriInputBackend as InputBackend, NiriInputDevice as _};
+use super::move_grab::MoveGrab;
+use super::touch_overview_grab::TouchOverviewGrab;
+use super::{modifiers_from_state, PointerOrTouchStartData};
+use crate::niri::{PointerVisibility, State};
+
+impl State {
+ pub(super) fn on_touch_down(&mut self, evt: I::TouchDownEvent) {
+ let Some(handle) = self.niri.seat.get_touch() else {
+ return;
+ };
+ let Some(pos) = self.compute_touch_location(&evt) else {
+ return;
+ };
+ let slot = evt.slot();
+
+ // Track touch point for multi-finger gesture detection.
+ let was_single = self.niri.touch_gesture_points.len() == 1;
+ self.niri.touch_gesture_points.insert(Some(slot), pos);
+
+ // When second finger arrives, start cumulative tracking for gesture recognition.
+ // Actual gestures (workspace/view/scroll) require 3+ fingers and are processed
+ // in on_touch_motion once the third finger arrives and moves.
+ if was_single && self.niri.touch_gesture_points.len() == 2 {
+ self.niri.touch_gesture_cumulative = Some((0., 0.));
+ }
+
+ // Check if we're tracking a multi-finger gesture (2+ fingers).
+ // If so, we should not forward events to clients.
+ let tracking_gesture = self.niri.touch_gesture_points.len() > 2;
+
+ let serial = SERIAL_COUNTER.next_serial();
+
+ let under = self.niri.contents_under(pos);
+
+ let mod_key = self.backend.mod_key(&self.niri.config.borrow());
+
+ if self.niri.screenshot_ui.is_open() {
+ if let Some(output) = under.output.clone() {
+ let geom = self.niri.global_space.output_geometry(&output).unwrap();
+ let mut point = (pos - geom.loc.to_f64())
+ .to_physical(output.current_scale().fractional_scale())
+ .to_i32_round();
+
+ let size = output.current_mode().unwrap().size;
+ let transform = output.current_transform();
+ let size = transform.transform_size(size);
+ point.x = min(size.w - 1, point.x);
+ point.y = min(size.h - 1, point.y);
+
+ if self
+ .niri
+ .screenshot_ui
+ .pointer_down(output, point, Some(slot))
+ {
+ self.niri.queue_redraw_all();
+ }
+ }
+ } else if let Some(mru_output) = self.niri.window_mru_ui.output() {
+ if let Some((output, pos_within_output)) = self.niri.output_under(pos) {
+ if mru_output == output {
+ let id = self.niri.window_mru_ui.pointer_motion(pos_within_output);
+ if id.is_some() {
+ self.confirm_mru();
+ } else {
+ self.niri.cancel_mru();
+ }
+ } else {
+ self.niri.cancel_mru();
+ }
+ }
+ } else if !handle.is_grabbed() {
+ let mods = self.niri.seat.get_keyboard().unwrap().modifier_state();
+ let mods = modifiers_from_state(mods);
+ let mod_down = mods.contains(mod_key.to_modifiers());
+
+ if self.niri.layout.is_overview_open()
+ && !mod_down
+ && under.layer.is_none()
+ && under.output.is_some()
+ {
+ let (output, pos_within_output) = self.niri.output_under(pos).unwrap();
+ let output = output.clone();
+
+ let mut matched_narrow = true;
+ let mut ws = self.niri.workspace_under(false, pos);
+ if ws.is_none() {
+ matched_narrow = false;
+ ws = self.niri.workspace_under(true, pos);
+ }
+ let ws_id = ws.map(|(_, ws)| ws.id());
+
+ let mapped = self.niri.window_under(pos);
+ let window = mapped.map(|mapped| mapped.window.clone());
+
+ let start_data = TouchGrabStartData {
+ focus: None,
+ slot,
+ location: pos,
+ };
+ let start_timestamp = Duration::from_micros(evt.time());
+ let grab = TouchOverviewGrab::new(
+ start_data,
+ start_timestamp,
+ output,
+ pos_within_output,
+ ws_id,
+ matched_narrow,
+ window,
+ );
+ handle.set_grab(self, grab, serial);
+ } else if let Some((window, _)) = under.window {
+ self.niri.layout.activate_window(&window);
+
+ // Check if we need to start a touch move grab.
+ if mod_down {
+ let start_data = TouchGrabStartData {
+ focus: None,
+ slot,
+ location: pos,
+ };
+ let start_data = PointerOrTouchStartData::Touch(start_data);
+ if let Some(grab) = MoveGrab::new(self, start_data, window.clone(), true, None)
+ {
+ handle.set_grab(self, grab, serial);
+ }
+ }
+
+ // FIXME: granular.
+ self.niri.queue_redraw_all();
+ } else if let Some(output) = under.output {
+ self.niri.layout.focus_output(&output);
+
+ // FIXME: granular.
+ self.niri.queue_redraw_all();
+ }
+ self.niri.focus_layer_surface_if_on_demand(under.layer);
+ };
+
+ // Only forward to client if not tracking a multi-finger gesture.
+ if !tracking_gesture {
+ handle.down(
+ self,
+ under.surface,
+ &DownEvent {
+ slot,
+ location: pos,
+ serial,
+ time: evt.time_msec(),
+ },
+ );
+ }
+
+ // We're using touch, hide the pointer.
+ self.niri.pointer_visibility = PointerVisibility::Disabled;
+ }
+
+ pub(super) fn on_touch_up(&mut self, evt: I::TouchUpEvent) {
+ let Some(handle) = self.niri.seat.get_touch() else {
+ return;
+ };
+ let slot = evt.slot();
+
+ // Check if we're tracking a multi-finger gesture before removing this touch point.
+ let tracking_gesture = self.niri.touch_gesture_points.len() > 2;
+
+ // Remove touch point from gesture tracking.
+ self.niri.touch_gesture_points.remove(&Some(slot));
+
+ // End gesture when fewer than 2 fingers remain.
+ if self.niri.touch_gesture_points.len() < 2 {
+ self.niri.touch_gesture_cumulative = None;
+
+ // End any ongoing gesture animations.
+ if let Some(output) = self.niri.layout.workspace_switch_gesture_end(Some(true)) {
+ self.niri.queue_redraw(&output);
+ }
+ if let Some(output) = self.niri.layout.view_offset_gesture_end(Some(true)) {
+ self.niri.queue_redraw(&output);
+ }
+ self.niri.layout.overview_gesture_end();
+ }
+
+ if let Some(capture) = self.niri.screenshot_ui.pointer_up(Some(slot)) {
+ if capture {
+ self.confirm_screenshot(true);
+ } else {
+ self.niri.queue_redraw_all();
+ }
+ }
+
+ // Only forward to client if not tracking a multi-finger gesture.
+ if !tracking_gesture {
+ let serial = SERIAL_COUNTER.next_serial();
+ handle.up(
+ self,
+ &UpEvent {
+ slot,
+ serial,
+ time: evt.time_msec(),
+ },
+ )
+ }
+ }
+
+ pub(super) fn on_touch_motion(&mut self, evt: I::TouchMotionEvent) {
+ let Some(handle) = self.niri.seat.get_touch() else {
+ return;
+ };
+ let Some(pos) = self.compute_touch_location(&evt) else {
+ return;
+ };
+ let slot = evt.slot();
+
+ // Track touch gesture with 2+ fingers.
+ let mut gesture_handled = false;
+ if let Some(old_pos) = self.niri.touch_gesture_points.get(&Some(slot)).copied() {
+ // Calculate delta from this finger's movement.
+ let delta_x = pos.x - old_pos.x;
+ let delta_y = pos.y - old_pos.y;
+
+ // Update stored position.
+ self.niri.touch_gesture_points.insert(Some(slot), pos);
+
+ // Process gesture if we're tracking (3+ fingers).
+ if self.niri.touch_gesture_points.len() >= 3 {
+ let timestamp = Duration::from_micros(evt.time());
+ gesture_handled = true;
+
+ // Check if we're still in recognition phase.
+ if let Some((cx, cy)) = &mut self.niri.touch_gesture_cumulative {
+ *cx += delta_x;
+ *cy += delta_y;
+
+ // Extract config values upfront to avoid borrow conflicts.
+ let (threshold, ov_enabled, ov_fingers, ws_enabled, ws_fingers,
+ vs_enabled, vs_fingers) = {
+ let config = self.niri.config.borrow();
+ let touch = &config.input.touch;
+ (
+ touch.gesture_threshold(),
+ touch.overview_toggle_enabled(),
+ touch.overview_toggle_fingers(),
+ touch.workspace_switch_enabled(),
+ touch.workspace_switch_fingers(),
+ touch.view_scroll_enabled(),
+ touch.view_scroll_fingers(),
+ )
+ };
+
+ // Check if gesture moved far enough to decide direction.
+ let (cx, cy) = (*cx, *cy);
+ if cx * cx + cy * cy >= threshold * threshold {
+ self.niri.touch_gesture_cumulative = None;
+
+ let finger_count = self.niri.touch_gesture_points.len();
+
+ if ov_enabled && finger_count >= ov_fingers {
+ // Overview toggle gesture.
+ self.niri.layout.overview_gesture_begin();
+ } else if let Some((output, _pos_within_output)) =
+ self.niri.output_under(pos)
+ {
+ let output = output.clone();
+ let is_overview_open = self.niri.layout.is_overview_open();
+
+ if cx.abs() > cy.abs() {
+ // Horizontal gesture: view offset (scroll within workspace).
+ if vs_enabled && finger_count >= vs_fingers {
+ let output_ws = if is_overview_open {
+ self.niri.workspace_under(true, pos)
+ } else {
+ self.niri
+ .layout
+ .monitor_for_output(&output)
+ .map(|mon| {
+ (output.clone(), mon.active_workspace_ref())
+ })
+ };
+
+ if let Some((output, ws)) = output_ws {
+ let ws_idx = self
+ .niri
+ .layout
+ .find_workspace_by_id(ws.id())
+ .unwrap()
+ .0;
+ self.niri.layout.view_offset_gesture_begin(
+ &output,
+ Some(ws_idx),
+ true,
+ );
+ }
+ }
+ } else {
+ // Vertical gesture: workspace switch.
+ if ws_enabled && finger_count >= ws_fingers {
+ self.niri
+ .layout
+ .workspace_switch_gesture_begin(&output, true);
+ }
+ }
+ }
+ }
+ }
+
+ // Read config for per-gesture natural scroll and sensitivity.
+ // Extract all values upfront to drop the borrow before mutable calls.
+ let (ws_natural, ws_sensitivity, vs_natural, vs_sensitivity,
+ ov_natural, ov_sensitivity) = {
+ let config = self.niri.config.borrow();
+ let touch = &config.input.touch;
+ (
+ touch.workspace_switch_natural_scroll(),
+ touch.workspace_switch_sensitivity(),
+ touch.view_scroll_natural_scroll(),
+ touch.view_scroll_sensitivity(),
+ touch.overview_toggle_natural_scroll(),
+ touch.overview_toggle_sensitivity(),
+ )
+ };
+
+ // Continue ongoing gesture animations with per-gesture sensitivity
+ // and per-gesture natural scroll.
+ let ws_delta_y = if ws_natural { -delta_y } else { delta_y };
+ if self
+ .niri
+ .layout
+ .workspace_switch_gesture_update(
+ ws_delta_y * ws_sensitivity,
+ timestamp,
+ true,
+ )
+ .is_some()
+ {
+ self.niri.queue_redraw_all();
+ }
+
+ let vs_delta_x = if vs_natural { -delta_x } else { delta_x };
+ if self
+ .niri
+ .layout
+ .view_offset_gesture_update(
+ vs_delta_x * vs_sensitivity,
+ timestamp,
+ true,
+ )
+ .is_some()
+ {
+ self.niri.queue_redraw_all();
+ }
+
+ // Overview gesture uses vertical delta like touchpad.
+ let ov_delta_y = if ov_natural { delta_y } else { -delta_y };
+ if let Some(redraw) = self
+ .niri
+ .layout
+ .overview_gesture_update(ov_delta_y * ov_sensitivity, timestamp)
+ {
+ if redraw {
+ self.niri.queue_redraw_all();
+ }
+ }
+ }
+ }
+
+ if let Some(output) = self.niri.screenshot_ui.selection_output().cloned() {
+ let geom = self.niri.global_space.output_geometry(&output).unwrap();
+ let mut point = (pos - geom.loc.to_f64())
+ .to_physical(output.current_scale().fractional_scale())
+ .to_i32_round::();
+
+ let size = output.current_mode().unwrap().size;
+ let transform = output.current_transform();
+ let size = transform.transform_size(size);
+ point.x = point.x.clamp(0, size.w - 1);
+ point.y = point.y.clamp(0, size.h - 1);
+
+ self.niri.screenshot_ui.pointer_motion(point, Some(slot));
+ self.niri.queue_redraw(&output);
+ }
+
+ // Only forward to client if not handling a multi-finger gesture.
+ if !gesture_handled {
+ let under = self.niri.contents_under(pos);
+ handle.motion(
+ self,
+ under.surface,
+ &TouchMotionEvent {
+ slot,
+ location: pos,
+ time: evt.time_msec(),
+ },
+ );
+ }
+
+ // Inform the layout of an ongoing DnD operation.
+ let is_dnd_grab = handle
+ .with_grab(|_, grab| Self::is_dnd_grab(grab.as_any()))
+ .unwrap_or(false);
+ if is_dnd_grab {
+ if let Some((output, pos_within_output)) = self.niri.output_under(pos) {
+ let output = output.clone();
+ self.niri.layout.dnd_update(output, pos_within_output);
+ }
+ }
+ }
+
+ pub(super) fn on_touch_frame(&mut self, _evt: I::TouchFrameEvent) {
+ let Some(handle) = self.niri.seat.get_touch() else {
+ return;
+ };
+ handle.frame(self);
+ }
+
+ pub(super) fn on_touch_cancel(&mut self, _evt: I::TouchCancelEvent) {
+ let Some(handle) = self.niri.seat.get_touch() else {
+ return;
+ };
+
+ // Clear all touch gesture state.
+ self.niri.touch_gesture_points.clear();
+ self.niri.touch_gesture_cumulative = None;
+
+ // Cancel any ongoing gesture animations.
+ self.niri.layout.workspace_switch_gesture_end(Some(false));
+ self.niri.layout.view_offset_gesture_end(Some(false));
+ self.niri.layout.overview_gesture_end();
+
+ handle.cancel(self);
+ }
+}
diff --git a/src/niri.rs b/src/niri.rs
index d84c390abf..e4cec46fda 100644
--- a/src/niri.rs
+++ b/src/niri.rs
@@ -19,7 +19,7 @@ use niri_config::{
WorkspaceReference, Xkb,
};
use smithay::backend::allocator::Fourcc;
-use smithay::backend::input::Keycode;
+use smithay::backend::input::{Keycode, TouchSlot};
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
@@ -364,6 +364,12 @@ pub struct Niri {
pub pointer_inside_hot_corner: bool,
pub tablet_cursor_location: Option>,
pub gesture_swipe_3f_cumulative: Option<(f64, f64)>,
+ /// Active touch points for multi-finger gesture detection.
+ pub touch_gesture_points: HashMap