diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index c106208396..ec611576ef 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -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, + /// The touch's current position. + pub position: Point, +} + +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 { - 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 { + 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()); diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index cfbf2bc82f..913cf1ba79 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -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", } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 0950071c3f..9f2bfea411 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -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>>, + } + + impl Render for TouchDragListener { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> 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, + fn dispatch_touch( + window: crate::WindowHandle, cx: &mut TestAppContext, id: TouchId, phase: TouchPhase,