diff --git a/Cargo.toml b/Cargo.toml index a70740ab0f..558afe022b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -454,7 +454,7 @@ search = { path = "crates/search" } session = { path = "crates/session" } settings = { path = "crates/settings" } settings_content = { path = "crates/settings_content" } -settings_json = { path = "crates/settings_json" } +settings_json = { path = "crates/settings_json", default-features = false } settings_macros = { path = "crates/settings_macros" } settings_profile_selector = { path = "crates/settings_profile_selector" } settings_ui = { path = "crates/settings_ui" } diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index 99f0efc4a2..4fd4715c44 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -10,9 +10,17 @@ //! [`PinchEvent`](crate::PinchEvent)s — so components written against //! `on_click` and scroll containers work untouched on mobile. -use std::time::{Duration, Instant}; +use std::collections::VecDeque; +use std::mem; +use std::time::Duration; -use crate::{Axis, IsZero, Pixels, Point, TouchPhase, px}; +use scheduler::Instant; +use smallvec::SmallVec; + +use crate::{ + Axis, IsZero, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Pixels, Point, ScrollDelta, + ScrollWheelEvent, TouchEvent, TouchId, TouchPhase, point, px, +}; const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); @@ -193,6 +201,420 @@ pub struct NullPlatformGestures; impl PlatformGestures for NullPlatformGestures {} +/// Ceiling on recognized fling velocity, in pixels per second (matches +/// Flutter's `kMaxFlingVelocity`). +const MAX_FLING_VELOCITY: f32 = 8000.; + +/// Momentum below this speed, in pixels per second, is imperceptible: the +/// fling stops and the synthetic scroll stream is closed. +const MOMENTUM_STOP_VELOCITY: f32 = 10.; + +/// Upper bound on the time a single momentum tick may integrate, so a stalled +/// frame loop (backgrounded window, long pause) resumes without a huge jump. +/// Must stay well above a plausible worst-case frame interval: clamping a +/// normal slow frame would advance the fling slower than real time, making +/// momentum crawl on exactly the devices that already render slowly. +const MOMENTUM_MAX_TICK: Duration = Duration::from_millis(250); + +/// How far back the release-velocity estimate looks. Samples older than this +/// reflect an earlier part of the gesture, not the speed at release. +const VELOCITY_WINDOW: Duration = Duration::from_millis(100); + +/// A pause between samples longer than this means the finger stopped: +/// anything before the pause describes an earlier motion, not the release +/// (Flutter's `kAssumePointerMoveStoppedMilliseconds`). Touch hardware +/// reports movement every 8–16ms while the finger is in motion. +const VELOCITY_ASSUME_STOPPED_GAP: Duration = Duration::from_millis(40); + +const VELOCITY_MAX_SAMPLES: usize = 20; + +/// The portable recognizer behind raw touch input: it watches the +/// [`TouchEvent`] stream for one touch at a time and resolves it into either +/// a tap or a pan, following the competition model described in the module +/// docs. Pans continue into post-release momentum when the touch lifts at +/// speed; the window drives that phase through [`Self::tick_momentum`]. +/// +/// Taps are currently surfaced as synthesized mouse presses rather than +/// [`ClickEvent::Touch`](crate::ClickEvent), which keeps every existing +/// mouse-driven behavior (click listeners, caret placement, double-tap +/// selection) working before elements grow a direct tap-delivery path. +/// Long-press and pinch recognition are not implemented yet, and additional +/// touches are ignored while one is being recognized. +pub(crate) struct TouchGestureRecognizer { + tuning: GestureTuning, + state: TouchGestureState, + momentum: Option, + last_tap: Option, +} + +/// A semantic event recognized from raw touches, ready to dispatch through +/// the window's existing input paths. +#[derive(Debug)] +pub(crate) enum RecognizedTouchGesture { + /// One step of a pan (or of its post-release momentum), delivered to + /// scroll listeners at the pan's starting position. + Scroll(ScrollWheelEvent), + /// A recognized tap, delivered as a synthesized mouse press and release. + Tap { + down: MouseDownEvent, + up: MouseUpEvent, + }, +} + +enum TouchGestureState { + Idle, + /// The touch is still within `touch_slop` of where it started: it can + /// still resolve into either a tap or a pan. + Pending(ActiveTouch), + /// The touch exceeded `touch_slop`: it is a pan until it ends, and its + /// movement flows out as scroll events. + Panning(ActiveTouch), +} + +struct ActiveTouch { + id: TouchId, + start_position: Point, + last_position: Point, + velocity_tracker: VelocityTracker, +} + +struct CompletedTap { + position: Point, + time: Instant, + count: usize, +} + +struct Momentum { + /// Where the pan started; synthesized scroll events keep hit-testing + /// there so momentum stays with the container the gesture began on. + position: Point, + /// Pixels per second. + velocity: Point, + last_tick: Instant, +} + +impl TouchGestureRecognizer { + pub(crate) fn new(tuning: GestureTuning) -> Self { + Self { + tuning, + state: TouchGestureState::Idle, + momentum: None, + last_tap: None, + } + } + + pub(crate) fn handle_event( + &mut self, + event: &TouchEvent, + ) -> SmallVec<[RecognizedTouchGesture; 2]> { + self.handle_event_at(event, Instant::now()) + } + + fn handle_event_at( + &mut self, + event: &TouchEvent, + now: Instant, + ) -> SmallVec<[RecognizedTouchGesture; 2]> { + let mut recognized = SmallVec::new(); + match event.phase { + TouchPhase::Started => { + if let Some(momentum) = self.momentum.take() { + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + momentum.position, + Point::default(), + TouchPhase::Ended, + ))); + } + if matches!(self.state, TouchGestureState::Idle) { + let mut velocity_tracker = VelocityTracker::default(); + velocity_tracker.push(now, event.position); + self.state = TouchGestureState::Pending(ActiveTouch { + id: event.id, + start_position: event.position, + last_position: event.position, + velocity_tracker, + }); + } + } + TouchPhase::Moved => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending(mut touch) if touch.id == event.id => { + touch.velocity_tracker.push(now, event.position); + let accumulated = event.position - touch.start_position; + if accumulated.magnitude() > f64::from(self.tuning.touch_slop) { + // Carry the full movement so far into the first scroll + // step: the content catches up to the finger instead + // of losing the slop distance. + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + accumulated, + TouchPhase::Started, + ))); + self.state = TouchGestureState::Panning(touch); + } else { + self.state = TouchGestureState::Pending(touch); + } + } + TouchGestureState::Panning(mut touch) if touch.id == event.id => { + touch.velocity_tracker.push(now, event.position); + let delta = event.position - touch.last_position; + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + delta, + TouchPhase::Moved, + ))); + self.state = TouchGestureState::Panning(touch); + } + other => self.state = other, + }, + TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending(touch) if touch.id == event.id => { + let tap_count = match &self.last_tap { + Some(tap) + if now.duration_since(tap.time) <= self.tuning.multi_tap_interval + && (event.position - tap.position).magnitude() + <= f64::from(self.tuning.multi_tap_slop) => + { + tap.count + 1 + } + _ => 1, + }; + self.last_tap = Some(CompletedTap { + position: event.position, + time: now, + count: tap_count, + }); + recognized.push(RecognizedTouchGesture::Tap { + down: MouseDownEvent { + button: MouseButton::Left, + position: event.position, + modifiers: Modifiers::default(), + click_count: tap_count, + first_mouse: false, + }, + up: MouseUpEvent { + button: MouseButton::Left, + position: event.position, + modifiers: Modifiers::default(), + click_count: tap_count, + }, + }); + } + TouchGestureState::Panning(touch) if touch.id == event.id => { + let delta = event.position - touch.last_position; + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + delta, + TouchPhase::Ended, + ))); + // The release deliberately contributes no velocity + // sample: it usually repeats the last movement's position + // with a later timestamp, which would dilute the + // estimate. But a release long after the last movement + // means the finger had already stopped, so nothing + // flings. + let finger_stopped = + touch + .velocity_tracker + .latest_sample_time() + .is_none_or(|latest| { + now.duration_since(latest) > VELOCITY_ASSUME_STOPPED_GAP + }); + let velocity = if finger_stopped { + Point::default() + } else { + touch.velocity_tracker.velocity() + }; + let speed = (velocity.x.powi(2) + velocity.y.powi(2)).sqrt(); + if speed >= self.tuning.min_fling_velocity { + let velocity = if speed > MAX_FLING_VELOCITY { + velocity * (MAX_FLING_VELOCITY / speed) + } else { + velocity + }; + self.momentum = Some(Momentum { + position: touch.start_position, + velocity, + last_tick: now, + }); + } + } + other => self.state = other, + }, + TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending(touch) if touch.id == event.id => {} + TouchGestureState::Panning(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + Point::default(), + TouchPhase::Cancelled, + ))); + } + other => self.state = other, + }, + } + recognized + } + + pub(crate) fn has_momentum(&self) -> bool { + self.momentum.is_some() + } + + /// Advances post-fling momentum by one frame, returning the scroll step + /// to dispatch, or `None` when no momentum is in progress. The final step + /// carries [`TouchPhase::Ended`] to close the synthetic scroll stream. + pub(crate) fn tick_momentum(&mut self) -> Option { + self.tick_momentum_at(Instant::now()) + } + + fn tick_momentum_at(&mut self, now: Instant) -> Option { + let momentum = self.momentum.as_mut()?; + let elapsed = now + .duration_since(momentum.last_tick) + .min(MOMENTUM_MAX_TICK); + momentum.last_tick = now; + let delta = point( + px(momentum.velocity.x * elapsed.as_secs_f32()), + px(momentum.velocity.y * elapsed.as_secs_f32()), + ); + momentum.velocity *= self + .tuning + .momentum_decay_per_ms + .powf(elapsed.as_secs_f32() * 1000.); + let speed = (momentum.velocity.x.powi(2) + momentum.velocity.y.powi(2)).sqrt(); + let position = momentum.position; + if speed < MOMENTUM_STOP_VELOCITY { + self.momentum = None; + Some(RecognizedTouchGesture::Scroll(scroll_event( + position, + delta, + TouchPhase::Ended, + ))) + } else { + Some(RecognizedTouchGesture::Scroll(scroll_event( + position, + delta, + TouchPhase::Moved, + ))) + } + } +} + +fn scroll_event( + position: Point, + delta: Point, + touch_phase: TouchPhase, +) -> ScrollWheelEvent { + ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(delta), + modifiers: Modifiers::default(), + touch_phase, + } +} + +/// Estimates the velocity a touch had at its newest sample. +#[derive(Default)] +struct VelocityTracker { + samples: VecDeque<(Instant, Point)>, +} + +impl VelocityTracker { + fn push(&mut self, time: Instant, position: Point) { + self.samples.push_back((time, position)); + while self.samples.len() > VELOCITY_MAX_SAMPLES { + self.samples.pop_front(); + } + } + + fn latest_sample_time(&self) -> Option { + self.samples.back().map(|(time, _)| *time) + } + + /// The velocity at the newest sample, in pixels per second. + /// + /// Fits a second-degree polynomial by least squares over the trailing + /// [`VELOCITY_WINDOW`] and takes its derivative at the newest sample, + /// like Flutter's `VelocityTracker` and Android's `lsq2` strategy. An + /// endpoint difference over the same window would report the window's + /// *average* speed, which for a flick — still accelerating at lift-off — + /// is roughly half the speed the finger actually had at release. + fn velocity(&self) -> Point { + let Some((newest_time, _)) = self.samples.back() else { + return Point::default(); + }; + let mut times_seconds: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut horizontal: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut vertical: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut previous_time = *newest_time; + for (time, position) in self.samples.iter().rev() { + let age = newest_time.duration_since(*time); + if age > VELOCITY_WINDOW + || previous_time.duration_since(*time) > VELOCITY_ASSUME_STOPPED_GAP + { + break; + } + previous_time = *time; + times_seconds.push(-age.as_secs_f64()); + horizontal.push(f64::from(f32::from(position.x))); + vertical.push(f64::from(f32::from(position.y))); + } + + let endpoint_estimate = |values: &[f64]| -> f32 { + let elapsed = -times_seconds.last().copied().unwrap_or(0.); + if elapsed <= f64::EPSILON { + return 0.; + } + ((values.first().copied().unwrap_or(0.) - values.last().copied().unwrap_or(0.)) + / elapsed) as f32 + }; + if times_seconds.len() < 3 { + return point(endpoint_estimate(&horizontal), endpoint_estimate(&vertical)); + } + point( + quadratic_velocity_at_newest(×_seconds, &horizontal).map_or_else( + || endpoint_estimate(&horizontal), + |velocity| velocity as f32, + ), + quadratic_velocity_at_newest(×_seconds, &vertical) + .map_or_else(|| endpoint_estimate(&vertical), |velocity| velocity as f32), + ) + } +} + +/// Least-squares fit of `value = a0 + a1·t + a2·t²` returning `a1`: the +/// fitted curve's velocity at `t = 0`, which callers place at the newest +/// sample. `None` when the samples are too degenerate to fit (all +/// simultaneous, for example). +fn quadratic_velocity_at_newest(times: &[f64], values: &[f64]) -> Option { + let count = times.len() as f64; + let (mut sum_t1, mut sum_t2, mut sum_t3, mut sum_t4) = (0., 0., 0., 0.); + let (mut sum_v, mut sum_vt, mut sum_vt2) = (0., 0., 0.); + for (&time, &value) in times.iter().zip(values) { + let time_squared = time * time; + sum_t1 += time; + sum_t2 += time_squared; + sum_t3 += time_squared * time; + sum_t4 += time_squared * time_squared; + sum_v += value; + sum_vt += value * time; + sum_vt2 += value * time_squared; + } + // Cramer's rule on the 3×3 normal equations, solved for the linear + // coefficient only. + let determinant = count * (sum_t2 * sum_t4 - sum_t3 * sum_t3) + - sum_t1 * (sum_t1 * sum_t4 - sum_t3 * sum_t2) + + sum_t2 * (sum_t1 * sum_t3 - sum_t2 * sum_t2); + if determinant.abs() < 1e-12 { + return None; + } + let linear_determinant = count * (sum_vt * sum_t4 - sum_t3 * sum_vt2) + - sum_v * (sum_t1 * sum_t4 - sum_t3 * sum_t2) + + sum_t2 * (sum_t1 * sum_vt2 - sum_vt * sum_t2); + Some(linear_determinant / determinant) +} + #[cfg(test)] mod tests { use super::*; @@ -309,4 +731,342 @@ mod tests { assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal)); assert_eq!(horizontal_delta, point(px(10.), px(0.))); } + + #[test] + fn touch_within_slop_resolves_to_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + let recognized = + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 10.), now); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 12., 11.), + now + Duration::from_millis(20), + ); + assert!(recognized.is_empty()); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 12., 11.), + now + Duration::from_millis(60), + ); + let [RecognizedTouchGesture::Tap { down, up }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 1); + assert_eq!(down.position, point(px(12.), px(11.))); + assert_eq!(up.click_count, 1); + } + + #[test] + fn consecutive_taps_accumulate_tap_count() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at(&touch_event(TouchId(1), TouchPhase::Started, 10., 10.), now); + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 10., 10.), + now + Duration::from_millis(40), + ); + + let second_down = now + Duration::from_millis(200); + recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 14., 10.), + second_down, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 14., 10.), + second_down + Duration::from_millis(40), + ); + let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 2); + + let late_down = second_down + Duration::from_secs(2); + recognizer.handle_event_at( + &touch_event(TouchId(3), TouchPhase::Started, 14., 10.), + late_down, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(3), TouchPhase::Ended, 14., 10.), + late_down + Duration::from_millis(40), + ); + let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 1); + } + + #[test] + fn touch_beyond_slop_resolves_to_pan() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 120.), + now + Duration::from_millis(16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Started); + assert_eq!(scroll.position, point(px(100.), px(100.))); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(20.))); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 135.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.position, point(px(100.), px(100.))); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(15.))); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 135.), + now + Duration::from_millis(48), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + } + + #[test] + fn fast_release_starts_momentum_that_decays_to_a_stop() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now); + for step in 1..=5 { + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 300. - step as f32 * 20.), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 200.), + now + Duration::from_millis(6 * 16), + ); + assert!(recognizer.has_momentum()); + + let tick = now + Duration::from_millis(6 * 16 + 16); + let recognized = recognizer.tick_momentum_at(tick); + let Some(RecognizedTouchGesture::Scroll(scroll)) = recognized else { + panic!("expected momentum scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.position, point(px(100.), px(300.))); + let delta = scroll.delta.pixel_delta(px(16.)); + assert!( + delta.y < px(0.), + "momentum should continue upward, got {delta:?}" + ); + // The least-squares fit may leave float residue on the motionless axis. + assert!( + delta.x.abs() < px(0.001), + "expected no x motion, got {delta:?}" + ); + + let mut last_phase = TouchPhase::Moved; + let mut ticks = 0; + let mut time = tick; + while recognizer.has_momentum() { + time += Duration::from_millis(16); + ticks += 1; + assert!(ticks < 1000, "momentum never stopped"); + if let Some(RecognizedTouchGesture::Scroll(scroll)) = recognizer.tick_momentum_at(time) + { + last_phase = scroll.touch_phase; + } + } + assert_eq!(last_phase, TouchPhase::Ended); + assert!(recognizer.tick_momentum_at(time).is_none()); + } + + #[test] + fn slow_release_does_not_start_momentum() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 280.), + now + Duration::from_millis(16), + ); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 279.), + now + Duration::from_millis(500), + ); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 279.), + now + Duration::from_millis(600), + ); + assert!(!recognizer.has_momentum()); + } + + #[test] + fn new_touch_interrupts_momentum() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 300.), + now, + ); + for step in 1..=3 { + recognizer.handle_event_at( + &touch_event( + TouchId(1), + TouchPhase::Moved, + 100., + 300. - step as f32 * 33., + ), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 200.), + now + Duration::from_millis(64), + ); + assert!(recognizer.has_momentum()); + + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 100., 200.), + now + Duration::from_millis(200), + ); + assert!(!recognizer.has_momentum()); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected closing scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + assert!(scroll.delta.pixel_delta(px(16.)).is_zero()); + } + + #[test] + fn cancelled_pan_emits_cancelled_scroll_and_no_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 150.), + now + Duration::from_millis(16), + ); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Cancelled, 100., 150.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected cancelled scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Cancelled); + assert!(!recognizer.has_momentum()); + + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Cancelled, 100., 102.), + now + Duration::from_millis(16), + ); + assert!(recognized.is_empty(), "cancelled tap must not click"); + } + + #[test] + fn concurrent_touches_are_ignored_while_one_is_active() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 100.), + now, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 200., 200.), + now + Duration::from_millis(8), + ); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Moved, 200., 300.), + now + Duration::from_millis(16), + ); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 200., 300.), + now + Duration::from_millis(24), + ); + assert!(recognized.is_empty()); + + // The first touch still resolves normally. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Moved, 100., 150.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Started); + } + + #[test] + fn flick_velocity_reflects_release_speed_not_window_average() { + // A uniformly accelerating flick: position grows quadratically, so + // the speed at the newest sample (2·k·t) is twice the window + // average (k·t). The estimator must report the former. + let mut velocity_tracker = VelocityTracker::default(); + let start = Instant::now(); + for step in 0..=6 { + let t = step as f32 * 0.016; + velocity_tracker.push( + start + Duration::from_millis(step * 16), + point(px(0.), px(1000. * t * t)), + ); + } + let velocity = velocity_tracker.velocity(); + let release_speed = 2. * 1000. * 0.096; + assert!( + (velocity.y - release_speed).abs() < 1., + "expected ≈{release_speed} px/s at release, got {} px/s", + velocity.y + ); + assert_eq!(velocity.x, 0.); + } + + #[test] + fn samples_before_a_pause_do_not_contribute_velocity() { + // Fast motion, then a hold longer than the stopped-finger gap, then + // a slow nudge: only the motion after the pause describes the + // release. + let mut velocity_tracker = VelocityTracker::default(); + let start = Instant::now(); + velocity_tracker.push(start, point(px(0.), px(0.))); + velocity_tracker.push(start + Duration::from_millis(16), point(px(0.), px(50.))); + velocity_tracker.push(start + Duration::from_millis(80), point(px(0.), px(52.))); + velocity_tracker.push(start + Duration::from_millis(96), point(px(0.), px(54.))); + let velocity = velocity_tracker.velocity(); + assert!( + velocity.y < 200., + "pre-pause motion leaked into the estimate: {} px/s", + velocity.y + ); + } + + fn touch_event(id: TouchId, phase: TouchPhase, x: f32, y: f32) -> TouchEvent { + TouchEvent { + id, + phase, + position: point(px(x), px(y)), + force: None, + } + } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index acc79fabb5..048482526f 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -25,6 +25,8 @@ use crate::{ prelude::*, px, rems, size, transparent_black, }; +use crate::gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer}; +use crate::interactive::TouchEvent; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; #[cfg(target_os = "macos")] @@ -1190,6 +1192,7 @@ pub struct Window { #[cfg(feature = "profiler")] window_profiler: profiler::WindowProfiler, last_input_modality: InputModality, + touch_gestures: TouchGestureRecognizer, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -1880,6 +1883,11 @@ impl Window { #[cfg(feature = "profiler")] window_profiler: profiler::WindowProfiler::new(handle.window_id())?, last_input_modality: InputModality::Mouse, + touch_gestures: TouchGestureRecognizer::new( + cx.platform + .gestures() + .map_or_else(GestureTuning::default, |gestures| gestures.tuning()), + ), refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -5021,7 +5029,7 @@ impl Window { .unwrap_or_else(|| action.name().to_string()) } - /// Dispatch a mouse or keyboard event on the window. + /// Dispatch a mouse, keyboard, or touch event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { #[cfg(feature = "profiler")] @@ -5146,6 +5154,8 @@ impl Window { self.dispatch_mouse_event(any_mouse_event, cx); } else if let Some(any_key_event) = event.keyboard_event() { self.dispatch_key_event(any_key_event, cx); + } else if let Some(touch_event) = event.touch_event() { + self.dispatch_touch_event(touch_event, cx); } // Must run after the move is dispatched: the platform owns the gesture afterwards, so this @@ -5195,6 +5205,57 @@ impl Window { } } + /// Runs the portable gesture recognizer over a raw touch event and + /// dispatches whatever it resolves (scroll steps, synthesized taps) + /// through the ordinary mouse-event path. + fn dispatch_touch_event(&mut self, event: &TouchEvent, cx: &mut App) { + let recognized_gestures = self.touch_gestures.handle_event(event); + let mut tapped = false; + for gesture in recognized_gestures { + tapped |= matches!(gesture, RecognizedTouchGesture::Tap { .. }); + self.dispatch_recognized_touch_gesture(gesture, cx); + } + // The platform's touch-release handler may inspect the input handler + // as soon as this dispatch returns (the web platform decides virtual + // keyboard visibility there, inside the user gesture). Input handlers + // are registered during draw, so draw now to make them reflect any + // focus change the tap just caused. + if tapped && self.invalidator.is_dirty() { + self.draw(cx).clear(cx); + } + if self.touch_gestures.has_momentum() { + self.schedule_touch_momentum_tick(); + } + } + + fn dispatch_recognized_touch_gesture(&mut self, gesture: RecognizedTouchGesture, cx: &mut App) { + match gesture { + RecognizedTouchGesture::Scroll(scroll_wheel) => { + self.mouse_position = scroll_wheel.position; + cx.propagate_event = true; + self.dispatch_mouse_event(&scroll_wheel, cx); + } + RecognizedTouchGesture::Tap { down, up } => { + self.mouse_position = up.position; + cx.propagate_event = true; + self.dispatch_mouse_event(&down, cx); + cx.propagate_event = true; + self.dispatch_mouse_event(&up, cx); + } + } + } + + fn schedule_touch_momentum_tick(&mut self) { + self.on_next_frame(|window, cx| { + if let Some(gesture) = window.touch_gestures.tick_momentum() { + window.dispatch_recognized_touch_gesture(gesture, cx); + } + if window.touch_gestures.has_momentum() { + window.schedule_touch_momentum_tick(); + } + }); + } + fn dispatch_mouse_event(&mut self, event: &dyn Any, cx: &mut App) { let hit_test = self.rendered_frame.hit_test(self.mouse_position()); if hit_test != self.mouse_hit_test { diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 73593a4e6a..d9251d69ff 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -1,10 +1,11 @@ use std::rc::Rc; use gpui::{ - Capslock, ClipboardEntry, ClipboardItem, ClipboardString, DispatchEventResult, Image, - ImageFormat, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, + Capslock, ClipboardEntry, ClipboardItem, ClipboardString, DispatchEventResult, GestureTuning, + Image, ImageFormat, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, - Pixels, PlatformInput, Point, ScrollDelta, ScrollWheelEvent, TouchPhase, point, px, + Pixels, PlatformInput, Point, ScrollDelta, ScrollWheelEvent, TouchEvent, TouchId, TouchPhase, + point, px, }; use wasm_bindgen::prelude::*; @@ -123,6 +124,7 @@ impl WebWindowInner { let mut handles = vec![ self.register_pointer_down(), self.register_pointer_up(), + self.register_pointer_cancel(), self.register_touch_end(), self.register_pointer_move(), self.register_pointer_leave(), @@ -206,6 +208,26 @@ impl WebWindowInner { // stuck. The capture is released implicitly on pointerup. this.canvas.set_pointer_capture(event.pointer_id()).ok(); + if pointer_type == "touch" { + this.state.borrow_mut().mouse_position = position; + if this.touch_tap_candidate.get().is_none() { + this.touch_tap_candidate + .set(Some((event.pointer_id(), position))); + } + this.dispatch_input(PlatformInput::Touch(TouchEvent { + id: TouchId(event.pointer_id() as u64), + phase: TouchPhase::Started, + position, + force: None, + })); + // Keyboard and IME focus intentionally do not change here: + // whether this touch is a tap or a pan is only known at + // release, and only a tap may affect them (see + // `touch_tap_candidate`). The release handler still runs + // within a user gesture, as keyboard summoning requires. + return; + } + let button = dom_mouse_button_to_gpui(event.button()); let modifiers = modifiers_from_mouse_event(&event, this.is_mac); let time = js_sys::Date::now(); @@ -227,16 +249,7 @@ impl WebWindowInner { first_mouse: false, })); - // Decide focus after dispatching the MouseDown so text-input - // acceptance reflects the selection produced by this tap rather - // than the previous one. This still runs within the user - // gesture, so focusing here is allowed to show the keyboard. - if pointer_type != "touch" || this.pointer_targets_text_input(position) { - if pointer_type == "touch" { - this.ime_mirror.set_read_only(false); - } - this.ime_mirror.focus(); - } + this.ime_mirror.focus(); }) } @@ -256,8 +269,44 @@ impl WebWindowInner { let event: web_sys::PointerEvent = event.unchecked_into(); event.prevent_default(); - let button = dom_mouse_button_to_gpui(event.button()); let position = pointer_position_in_element(&event); + + if event.pointer_type() == "touch" { + this.state.borrow_mut().mouse_position = position; + let completes_tap = match this.touch_tap_candidate.get() { + Some((pointer_id, _)) if pointer_id == event.pointer_id() => { + this.touch_tap_candidate.set(None); + true + } + _ => false, + }; + // A recognized tap is dispatched synchronously inside this + // call, so the text-input check below sees the state the tap + // produced. + this.dispatch_input(PlatformInput::Touch(TouchEvent { + id: TouchId(event.pointer_id() as u64), + phase: TouchPhase::Ended, + position, + force: None, + })); + + // A keyboard opening or closing mid-gesture reflows the + // layout, so the release position no longer refers to the + // content the user aimed at (a tap that summoned the keyboard + // often ends up below the shrunken layout, which would + // immediately dismiss it again). Skip the sync then, and for + // anything that wasn't a tap: pans and flings must not move + // keyboard or IME focus at all. + let viewport_stable = this.gesture_start_visual_viewport_height.get() + == this.visual_viewport_height(); + if completes_tap && viewport_stable { + this.sync_virtual_keyboard(this.pointer_targets_text_input(position)); + } + this.schedule_ime_mirror_sync(); + return; + } + + let button = dom_mouse_button_to_gpui(event.button()); let modifiers = modifiers_from_mouse_event(&event, this.is_mac); this.pressed_button.set(None); @@ -276,16 +325,6 @@ impl WebWindowInner { click_count, })); - // A keyboard opening or closing mid-gesture reflows the layout, - // so the release position no longer refers to the content the - // user aimed at (a tap that summoned the keyboard often ends up - // below the shrunken layout, which would immediately dismiss it - // again). Let the pointerdown decision stand instead. - let viewport_stable = - this.gesture_start_visual_viewport_height.get() == this.visual_viewport_height(); - if event.pointer_type() == "touch" && viewport_stable { - this.sync_virtual_keyboard(this.pointer_targets_text_input(position)); - } this.schedule_ime_mirror_sync(); }) } @@ -338,6 +377,31 @@ impl WebWindowInner { height >= max_height * 0.85 } + /// The browser or OS took over the pointer (native scrolling, a system + /// gesture, the pointer being removed): no pointerup will follow, so the + /// gesture must unwind rather than complete. + fn register_pointer_cancel(self: &Rc) -> EventListenerHandle { + let this = Rc::clone(self); + self.listen("pointercancel", move |event: JsValue| { + let event: web_sys::PointerEvent = event.unchecked_into(); + if event.pointer_type() == "touch" { + if let Some((pointer_id, _)) = this.touch_tap_candidate.get() + && pointer_id == event.pointer_id() + { + this.touch_tap_candidate.set(None); + } + this.dispatch_input(PlatformInput::Touch(TouchEvent { + id: TouchId(event.pointer_id() as u64), + phase: TouchPhase::Cancelled, + position: pointer_position_in_element(&event), + force: None, + })); + } else { + this.pressed_button.set(None); + } + }) + } + /// Cancels touch default handling separately because iOS does not consistently /// transfer pointer-event cancellation to the corresponding touch event. fn register_touch_end(self: &Rc) -> EventListenerHandle { @@ -439,6 +503,31 @@ impl WebWindowInner { event.prevent_default(); let position = pointer_position_in_element(&event); + + if event.pointer_type() == "touch" { + this.state.borrow_mut().mouse_position = position; + // Mirror the slop rule of gpui's tap recognizer: once the + // touch travels beyond it, its release must not affect the + // keyboard. Only gpui knows what the gesture truly resolved + // to; this platform-side shadow exists because the keyboard + // decision must be made synchronously inside the browser's + // pointerup handler. + if let Some((pointer_id, start_position)) = this.touch_tap_candidate.get() + && pointer_id == event.pointer_id() + && (position - start_position).magnitude() + > f64::from(GestureTuning::default().touch_slop) + { + this.touch_tap_candidate.set(None); + } + this.dispatch_input(PlatformInput::Touch(TouchEvent { + id: TouchId(event.pointer_id() as u64), + phase: TouchPhase::Moved, + position, + force: None, + })); + return; + } + let modifiers = modifiers_from_mouse_event(&event, this.is_mac); let current_pressed = this.pressed_button.get(); diff --git a/crates/gpui_web/src/ime_mirror.rs b/crates/gpui_web/src/ime_mirror.rs index 7a4011aa4e..75ef807113 100644 --- a/crates/gpui_web/src/ime_mirror.rs +++ b/crates/gpui_web/src/ime_mirror.rs @@ -83,6 +83,15 @@ pub(crate) struct ImeMirror { selection_import_rejected: Cell, } +/// Whether the device's primary pointer is coarse (a touch screen). The +/// distinction drives virtual-keyboard policy: touch-first browsers summon +/// the keyboard for any focused editable element on a user gesture. +fn primary_pointer_is_coarse() -> bool { + web_sys::window() + .and_then(|window| window.match_media("(pointer: coarse)").ok().flatten()) + .is_some_and(|media_query_list| media_query_list.matches()) +} + impl ImeMirror { pub(crate) fn new( document: &web_sys::Document, @@ -110,6 +119,14 @@ impl ImeMirror { body.append_child(&element) .map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?; element.focus().ok(); + // The element must stay focused to receive hardware-key and IME + // events, but on touch-first devices a focused *editable* element + // invites the browser to summon the virtual keyboard on the next + // user gesture — including a scroll. Start read-only there; only a + // recognized tap on text input lifts it (`sync_virtual_keyboard`). + if primary_pointer_is_coarse() { + element.set_read_only(true); + } let this = Self { element, diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index a724a97d9c..a1690ac8de 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -73,6 +73,12 @@ pub(crate) struct WebWindowInner { /// reflowed the layout, so the release position no longer refers to what /// the user aimed at. pub(crate) gesture_start_visual_viewport_height: Cell, + /// A touch that may still resolve into a tap: its pointer id and starting + /// position, cleared once it travels beyond touch slop. Virtual keyboard + /// and IME focus may only change when a touch release completes a tap; + /// pans must leave them untouched, or scrolling over editable content + /// flickers the keyboard and drags the caret around. + pub(crate) touch_tap_candidate: Cell)>>, mql_handle: RefCell>, pending_physical_size: Cell>, raf_id: Cell>, @@ -197,6 +203,7 @@ impl WebWindow { suppress_focus_status_events: Cell::new(false), visual_viewport_probe: Cell::new((0.0, 0.0)), gesture_start_visual_viewport_height: Cell::new(0.0), + touch_tap_candidate: Cell::new(None), mql_handle: RefCell::new(None), pending_physical_size: Cell::new(None), raf_id: Cell::new(None), diff --git a/crates/migrator/Cargo.toml b/crates/migrator/Cargo.toml index 162473060a..1ba6f704d0 100644 --- a/crates/migrator/Cargo.toml +++ b/crates/migrator/Cargo.toml @@ -23,7 +23,7 @@ tree-sitter = { workspace = true, features = ["wasm"] } serde_json_lenient.workspace = true serde_json.workspace = true settings_content.workspace = true -settings_json.workspace = true +settings_json = { workspace = true, features = ["editing"] } [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index a0d75e5b76..b4c54af17d 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -33,7 +33,7 @@ serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true settings_content.workspace = true -settings_json.workspace = true +settings_json = { workspace = true, features = ["editing"] } settings_macros.workspace = true smallvec.workspace = true util.workspace = true diff --git a/crates/settings_json/Cargo.toml b/crates/settings_json/Cargo.toml index f5aac0c53c..a780b7fa0d 100644 --- a/crates/settings_json/Cargo.toml +++ b/crates/settings_json/Cargo.toml @@ -12,12 +12,13 @@ workspace = true path = "src/settings_json.rs" [features] -default = [] +default = ["editing"] +editing = ["dep:tree-sitter", "dep:tree-sitter-json"] [dependencies] anyhow.workspace = true -tree-sitter = { workspace = true, features = ["wasm"] } -tree-sitter-json.workspace = true +tree-sitter = { workspace = true, features = ["wasm"], optional = true } +tree-sitter-json = { workspace = true, optional = true } util.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/settings_json/src/settings_json.rs b/crates/settings_json/src/settings_json.rs index f5194d8703..f3458d5333 100644 --- a/crates/settings_json/src/settings_json.rs +++ b/crates/settings_json/src/settings_json.rs @@ -1,10 +1,17 @@ use anyhow::Result; -use serde::{Serialize, de::DeserializeOwned}; +#[cfg(feature = "editing")] +use serde::Serialize; +use serde::de::DeserializeOwned; +#[cfg(feature = "editing")] use serde_json::Value; +#[cfg(feature = "editing")] use std::{ops::Range, sync::LazyLock}; +#[cfg(feature = "editing")] use tree_sitter::{Query, StreamingIterator as _}; +#[cfg(feature = "editing")] use util::RangeExt; +#[cfg(feature = "editing")] pub fn update_value_in_json_text<'a>( text: &mut String, key_path: &mut Vec<&'a str>, @@ -65,6 +72,7 @@ pub fn update_value_in_json_text<'a>( } /// * `replace_key` - When an exact key match according to `key_path` is found, replace the key with `replace_key` if `Some`. +#[cfg(feature = "editing")] pub fn replace_value_in_json_text>( text: &str, key_path: &[T], @@ -284,6 +292,7 @@ pub fn replace_value_in_json_text>( } } +#[cfg(feature = "editing")] fn construct_json_value( key_path: &[impl AsRef], new_value: Option<&serde_json::Value>, @@ -300,10 +309,12 @@ fn construct_json_value( return new_value; } +#[cfg(feature = "editing")] fn parse_index_key(index_key: &str) -> Option { index_key.strip_prefix('#')?.parse().ok() } +#[cfg(feature = "editing")] fn handle_possible_array_value( key_node: &tree_sitter::Node, value_node: &tree_sitter::Node, @@ -371,10 +382,14 @@ fn handle_possible_array_value( return Some((replace_range, replace_value)); } +#[cfg(feature = "editing")] const TS_DOCUMENT_KIND: &str = "document"; +#[cfg(feature = "editing")] const TS_ARRAY_KIND: &str = "array"; +#[cfg(feature = "editing")] const TS_COMMENT_KIND: &str = "comment"; +#[cfg(feature = "editing")] pub fn replace_top_level_array_value_in_json_text( text: &str, key_path: &[impl AsRef], @@ -497,6 +512,7 @@ pub fn replace_top_level_array_value_in_json_text( } } +#[cfg(feature = "editing")] pub fn append_top_level_array_value_in_json_text( text: &str, new_value: &Value, @@ -621,6 +637,7 @@ pub fn append_top_level_array_value_in_json_text( /// Infers the indentation size used in JSON text by analyzing the tree structure. /// Returns the detected indent size, or a default of 2 if no indentation is found. +#[cfg(feature = "editing")] pub fn infer_json_indent_size(text: &str) -> usize { const MAX_INDENT_SIZE: usize = 64; @@ -713,6 +730,7 @@ pub fn infer_json_indent_size(text: &str) -> usize { if max_count == 0 { 2 } else { max_indent } } +#[cfg(feature = "editing")] pub fn to_pretty_json( value: &impl Serialize, indent_size: usize,