gpui: Add TouchDragEvent API (#63645)

Adds `TouchDragEvent` and coresponding APIs. This allows web/touch-based
code to capture long press events without having to go through
scroll-like APIs

---

Release Notes:

- N/A or Added/Fixed/Improved ...
This commit is contained in:
Cameron Mcloughlin authored and GitHub committed 2026-09-02 20:28:07 +00:00
1 parent 8482496a16
commit 81808cb5db
3 files changed
+234 -14

No files matched your search

+157 -9
View File
@@ -380,6 +380,27 @@ impl GestureKinds {
};
}
/// A direct touch drag claimed by an element before touch input becomes a tap,
/// long press, or scrolling gesture.
#[derive(Clone, Debug)]
pub struct TouchDragEvent {
/// The phase of the touch drag.
pub phase: TouchPhase,
/// The position where the touch started.
pub start_position: Point<Pixels>,
/// The touch's current position.
pub position: Point<Pixels>,
}
impl Sealed for TouchDragEvent {}
impl InputEvent for TouchDragEvent {
fn to_platform_input(self) -> PlatformInput {
PlatformInput::TouchDrag(self)
}
}
impl GestureEvent for TouchDragEvent {}
impl MouseEvent for TouchDragEvent {}
/// A phased long-press gesture recognized from a touch.
#[derive(Clone, Debug)]
pub struct LongPressEvent {
@@ -485,6 +506,7 @@ pub(crate) enum RecognizedTouchGesture {
down: MouseDownEvent,
up: MouseUpEvent,
},
TouchDrag(TouchDragEvent),
LongPress(LongPressEvent),
}
@@ -495,7 +517,8 @@ enum TouchGestureState {
Pending {
touch: ActiveTouch,
deadline: Instant,
offered: bool,
long_press_offered: bool,
touch_drag_offered: bool,
},
/// The touch exceeded `touch_slop`: it is a pan until it ends, and its
/// movement flows out as scroll events.
@@ -504,6 +527,7 @@ enum TouchGestureState {
axis: Axis,
},
LongPressing(ActiveTouch),
TouchDragging(ActiveTouch),
}
struct ActiveTouch {
@@ -609,7 +633,8 @@ impl TouchGestureRecognizer {
self.state = TouchGestureState::Pending {
touch,
deadline: now + self.tuning.long_press_duration,
offered: false,
long_press_offered: false,
touch_drag_offered: false,
};
}
}
@@ -618,7 +643,8 @@ impl TouchGestureRecognizer {
TouchGestureState::Pending {
mut touch,
deadline,
offered,
long_press_offered,
touch_drag_offered,
} if touch.id == event.id => {
touch.velocity_tracker.push(now, event.position);
touch.last_position = event.position;
@@ -649,7 +675,8 @@ impl TouchGestureRecognizer {
self.state = TouchGestureState::Pending {
touch,
deadline,
offered,
long_press_offered,
touch_drag_offered,
};
}
}
@@ -693,6 +720,15 @@ impl TouchGestureRecognizer {
}));
self.state = TouchGestureState::LongPressing(touch);
}
TouchGestureState::TouchDragging(mut touch) if touch.id == event.id => {
touch.last_position = event.position;
recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent {
phase: TouchPhase::Moved,
start_position: touch.start_position,
position: event.position,
}));
self.state = TouchGestureState::TouchDragging(touch);
}
other => self.state = other,
},
TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) {
@@ -801,6 +837,13 @@ impl TouchGestureRecognizer {
position: event.position,
}));
}
TouchGestureState::TouchDragging(touch) if touch.id == event.id => {
recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent {
phase: TouchPhase::Ended,
start_position: touch.start_position,
position: event.position,
}));
}
other => self.state = other,
},
TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) {
@@ -819,6 +862,13 @@ impl TouchGestureRecognizer {
position: event.position,
}));
}
TouchGestureState::TouchDragging(touch) if touch.id == event.id => {
recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent {
phase: TouchPhase::Cancelled,
start_position: touch.start_position,
position: event.position,
}));
}
other => self.state = other,
},
}
@@ -829,7 +879,8 @@ impl TouchGestureRecognizer {
let TouchGestureState::Pending {
touch,
deadline,
offered: false,
long_press_offered: false,
..
} = &self.state
else {
return None;
@@ -838,13 +889,18 @@ impl TouchGestureRecognizer {
}
pub(crate) fn offer_long_press(&mut self, id: TouchId) -> Option<RecognizedTouchGesture> {
let TouchGestureState::Pending { touch, offered, .. } = &mut self.state else {
let TouchGestureState::Pending {
touch,
long_press_offered,
..
} = &mut self.state
else {
return None;
};
if touch.id != id || *offered {
if touch.id != id || *long_press_offered {
return None;
}
*offered = true;
*long_press_offered = true;
Some(RecognizedTouchGesture::LongPress(LongPressEvent {
phase: TouchPhase::Started,
start_position: touch.start_position,
@@ -860,13 +916,48 @@ impl TouchGestureRecognizer {
self.state = match state {
TouchGestureState::Pending {
touch,
offered: true,
long_press_offered: true,
..
} => TouchGestureState::LongPressing(touch),
other => other,
};
}
pub(crate) fn offer_touch_drag(&mut self, id: TouchId) -> Option<RecognizedTouchGesture> {
let TouchGestureState::Pending {
touch,
touch_drag_offered,
..
} = &mut self.state
else {
return None;
};
if touch.id != id || *touch_drag_offered {
return None;
}
*touch_drag_offered = true;
Some(RecognizedTouchGesture::TouchDrag(TouchDragEvent {
phase: TouchPhase::Started,
start_position: touch.start_position,
position: touch.last_position,
}))
}
pub(crate) fn resolve_touch_drag(&mut self, claimed: bool) {
if !claimed {
return;
}
let state = mem::replace(&mut self.state, TouchGestureState::Idle);
self.state = match state {
TouchGestureState::Pending {
touch,
touch_drag_offered: true,
..
} => TouchGestureState::TouchDragging(touch),
other => other,
};
}
pub(crate) fn has_momentum(&self) -> bool {
self.momentum.is_some()
}
@@ -1894,6 +1985,63 @@ mod tests {
);
}
#[test]
fn claimed_touch_drag_emits_phased_stream_without_pan_or_tap() {
let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
let touch = TouchId(1);
let now = Instant::now();
recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now);
let Some(RecognizedTouchGesture::TouchDrag(started)) = recognizer.offer_touch_drag(touch)
else {
panic!("expected touch drag");
};
assert_eq!(started.phase, TouchPhase::Started);
assert_eq!(started.start_position, point(px(10.), px(20.)));
recognizer.resolve_touch_drag(true);
let moved = recognizer.handle_event_at(
&touch_event(touch, TouchPhase::Moved, 40., 50.),
now + Duration::from_millis(10),
);
let [RecognizedTouchGesture::TouchDrag(moved)] = moved.as_slice() else {
panic!("expected moved touch drag, got {moved:?}");
};
assert_eq!(moved.phase, TouchPhase::Moved);
assert_eq!(moved.position, point(px(40.), px(50.)));
let ended = recognizer.handle_event_at(
&touch_event(touch, TouchPhase::Ended, 45., 55.),
now + Duration::from_millis(20),
);
let [RecognizedTouchGesture::TouchDrag(ended)] = ended.as_slice() else {
panic!("expected ended touch drag, got {ended:?}");
};
assert_eq!(ended.phase, TouchPhase::Ended);
assert_eq!(ended.position, point(px(45.), px(55.)));
}
#[test]
fn unclaimed_touch_drag_remains_a_pan_candidate() {
let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
let touch = TouchId(1);
let now = Instant::now();
recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now);
assert!(recognizer.offer_touch_drag(touch).is_some());
recognizer.resolve_touch_drag(false);
let moved = recognizer.handle_event_at(
&touch_event(touch, TouchPhase::Moved, 20., 0.),
now + Duration::from_millis(10),
);
assert!(matches!(
moved.as_slice(),
[RecognizedTouchGesture::Scroll(ScrollWheelEvent {
touch_phase: TouchPhase::Started,
..
})]
));
}
#[test]
fn claimed_long_press_emits_phased_stream_without_tap() {
let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default());
+6 -1
View File
@@ -1,6 +1,6 @@
use crate::{
Bounds, Capslock, Context, Empty, IntoElement, Keystroke, LongPressEvent, Modifiers, Pixels,
Point, Render, Window, point, seal::Sealed,
Point, Render, TouchDragEvent, Window, point, seal::Sealed,
};
use smallvec::SmallVec;
use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf};
@@ -791,6 +791,8 @@ pub enum PlatformInput {
Pinch(PinchEvent),
/// A long-press gesture recognized from touch input.
LongPress(LongPressEvent),
/// A direct touch drag claimed by an element.
TouchDrag(TouchDragEvent),
/// Files were dragged and dropped onto the window.
FileDrop(FileDropEvent),
/// A raw touch event on a touch screen.
@@ -811,6 +813,7 @@ impl PlatformInput {
PlatformInput::ScrollWheel(event) => Some(event),
PlatformInput::Pinch(event) => Some(event),
PlatformInput::LongPress(event) => Some(event),
PlatformInput::TouchDrag(event) => Some(event),
PlatformInput::FileDrop(event) => Some(event),
PlatformInput::Touch(_) => None,
}
@@ -829,6 +832,7 @@ impl PlatformInput {
PlatformInput::ScrollWheel(_) => None,
PlatformInput::Pinch(_) => None,
PlatformInput::LongPress(_) => None,
PlatformInput::TouchDrag(_) => None,
PlatformInput::FileDrop(_) => None,
PlatformInput::Touch(_) => None,
}
@@ -849,6 +853,7 @@ impl PlatformInput {
PlatformInput::ScrollWheel(_) => "scroll_wheel",
PlatformInput::Pinch(_) => "pinch",
PlatformInput::LongPress(_) => "long_press",
PlatformInput::TouchDrag(_) => "touch_drag",
PlatformInput::FileDrop(_) => "file_drop",
PlatformInput::Touch(_) => "touch",
}
+71 -4
View File
@@ -5197,6 +5197,10 @@ impl Window {
}
PlatformInput::LongPress(long_press)
}
PlatformInput::TouchDrag(touch_drag) => {
self.mouse_position = touch_drag.start_position;
PlatformInput::TouchDrag(touch_drag)
}
PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event,
};
@@ -5289,6 +5293,11 @@ impl Window {
event.predicted_position = None;
}
let recognized_gestures = self.touch_gestures.handle_event(&event);
if event.phase == crate::TouchPhase::Started
&& let Some(touch_drag) = self.touch_gestures.offer_touch_drag(event.id)
{
self.dispatch_recognized_touch_gesture(touch_drag, cx);
}
if event.phase == crate::TouchPhase::Started
&& self.touch_gestures.pending_long_press().is_some()
{
@@ -5331,6 +5340,17 @@ impl Window {
cx.propagate_event = true;
self.dispatch_mouse_event(&up, cx);
}
RecognizedTouchGesture::TouchDrag(touch_drag) => {
self.mouse_position = touch_drag.start_position;
cx.propagate_event = true;
self.default_prevented = false;
let started = touch_drag.phase == crate::TouchPhase::Started;
self.dispatch_mouse_event(&touch_drag, cx);
if started {
self.touch_gestures
.resolve_touch_drag(self.default_prevented);
}
}
RecognizedTouchGesture::LongPress(long_press) => {
self.mouse_position = if long_press.phase == crate::TouchPhase::Started {
long_press.start_position
@@ -7126,8 +7146,8 @@ mod tests {
ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle,
InputEvent as _, InteractiveElement as _, IntoElement, LongPressEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions,
StatefulInteractiveElement as _, Styled, TestAppContext, TouchEvent, TouchId, TouchPhase,
Window, WindowAppearance, WindowOptions, canvas, div, point, px, size,
StatefulInteractiveElement as _, Styled, TestAppContext, TouchDragEvent, TouchEvent,
TouchId, TouchPhase, Window, WindowAppearance, WindowOptions, canvas, div, point, px, size,
};
struct EmptyView;
@@ -7830,6 +7850,53 @@ mod tests {
assert_eq!(b_focus_count.get(), 1);
}
#[gpui::test]
fn claimed_touch_drag_receives_movement_and_release(cx: &mut TestAppContext) {
let events = Rc::new(RefCell::new(Vec::new()));
let window = cx.add_window({
let events = events.clone();
move |_, _| TouchDragListener { events }
});
let touch = TouchId(1);
dispatch_touch(window, cx, touch, TouchPhase::Started, 10.);
dispatch_touch(window, cx, touch, TouchPhase::Moved, 30.);
dispatch_touch(window, cx, touch, TouchPhase::Ended, 40.);
assert_eq!(
events.borrow().as_slice(),
[
(TouchPhase::Started, px(10.)),
(TouchPhase::Moved, px(30.)),
(TouchPhase::Ended, px(40.)),
]
);
}
struct TouchDragListener {
events: Rc<RefCell<Vec<(TouchPhase, Pixels)>>>,
}
impl Render for TouchDragListener {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let events = self.events.clone();
canvas(
|_, _, _| {},
move |_, _, window, _| {
window.on_mouse_event(move |event: &TouchDragEvent, phase, window, _cx| {
if phase != DispatchPhase::Bubble {
return;
}
events.borrow_mut().push((event.phase, event.position.x));
if event.phase == TouchPhase::Started {
window.prevent_default();
}
});
},
)
}
}
#[gpui::test]
fn long_press_is_claimed_only_when_started_prevents_default(cx: &mut TestAppContext) {
for response in [
@@ -7985,8 +8052,8 @@ mod tests {
}
}
fn dispatch_touch(
window: crate::WindowHandle<LongPressListener>,
fn dispatch_touch<T: 'static>(
window: crate::WindowHandle<T>,
cx: &mut TestAppContext,
id: TouchId,
phase: TouchPhase,