diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index 4fd4715c44..edf78957a6 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -108,10 +108,8 @@ pub struct GestureTuning { /// How long a touch must remain within [`Self::touch_slop`] to be /// recognized as a long press. pub long_press_duration: Duration, - /// Per-millisecond decay factor applied to scroll momentum after a fling. - /// (`UIScrollView` uses `0.998` per millisecond for its normal - /// deceleration rate.) - pub momentum_decay_per_ms: f32, + /// How scroll momentum decelerates after a fling. + pub scroll_physics: ScrollPhysics, /// Minimum release velocity, in pixels per second, required to start /// scroll momentum. pub min_fling_velocity: f32, @@ -124,12 +122,214 @@ impl Default for GestureTuning { multi_tap_interval: Duration::from_millis(400), multi_tap_slop: px(16.), long_press_duration: Duration::from_millis(500), - momentum_decay_per_ms: 0.998, + scroll_physics: ScrollPhysics::ios(), min_fling_velocity: 50., } } } +/// How free scrolling decelerates after a fling. +/// +/// This models deceleration only. Boundary behavior — bouncing, edge glow, +/// clamping — is the scroll container's policy: the container is the one that +/// knows its extents. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScrollPhysics { + /// Exponential velocity decay, the `UIScrollView` model: + /// `velocity(t) = v₀ · decay_per_msᵐˢ`. + Exponential { + /// Per-millisecond velocity decay factor. `UIScrollView`'s normal + /// deceleration rate is `0.998`. + decay_per_ms: f32, + }, + /// The friction spline of Android's `OverScroller`: fling duration and + /// distance follow a logarithmic deceleration law, and progress along + /// the fling follows a cubic-Bezier ease-out curve. Transcribed from + /// AOSP's `SplineOverScroller` (Apache-2.0). + FrictionSpline { + /// The scroll friction coefficient; + /// `ViewConfiguration.getScrollFriction()` is `0.015` on Android. + friction: f32, + /// Pixels per physical inch of the display, in the coordinate space + /// the fling runs in. Android folds display density into its + /// deceleration coefficient, so the same finger speed flings + /// further in pixels on a denser screen. + pixels_per_inch: f32, + }, +} + +impl ScrollPhysics { + /// iOS scroll feel: `UIScrollView`'s normal deceleration rate. + pub fn ios() -> Self { + Self::Exponential { + decay_per_ms: 0.998, + } + } + + /// Android scroll feel: `OverScroller` with stock friction, at Android's + /// nominal density of 160 density-independent pixels per inch — the + /// right pairing when fling distances are in logical pixels. Platforms + /// that fling in physical pixels, or know the display's true density in + /// their logical space, should construct + /// [`ScrollPhysics::FrictionSpline`] directly. + pub fn android() -> Self { + Self::FrictionSpline { + friction: 0.015, + pixels_per_inch: 160., + } + } + + /// How long a fling released at `speed` pixels per second coasts before + /// it stops. + fn fling_duration(self, speed: f32) -> Duration { + match self { + Self::Exponential { decay_per_ms } => { + if speed <= MOMENTUM_STOP_VELOCITY { + return Duration::ZERO; + } + let milliseconds = (MOMENTUM_STOP_VELOCITY / speed).ln() / decay_per_ms.ln(); + Duration::from_secs_f32(milliseconds / 1000.) + } + Self::FrictionSpline { + friction, + pixels_per_inch, + } => { + if speed <= 0. { + return Duration::ZERO; + } + let deceleration = friction_spline::deceleration(speed, friction, pixels_per_inch); + let seconds = (deceleration / (friction_spline::deceleration_rate() - 1.)).exp(); + Duration::from_secs_f64(seconds) + } + } + } + + /// Distance traveled `elapsed` into a fling released at `speed` pixels + /// per second, in pixels along the fling direction. Evaluated in closed + /// form so the trajectory is independent of tick timing. + fn fling_distance(self, speed: f32, elapsed: Duration) -> f32 { + let duration = self.fling_duration(speed); + if duration.is_zero() { + return 0.; + } + let elapsed = elapsed.min(duration); + match self { + Self::Exponential { decay_per_ms } => { + // ∫₀ᵗ v₀·kᵐˢ dms, with speed converted to pixels per + // millisecond. + let milliseconds = elapsed.as_secs_f32() * 1000.; + (speed / 1000.) * (decay_per_ms.powf(milliseconds) - 1.) / decay_per_ms.ln() + } + Self::FrictionSpline { + friction, + pixels_per_inch, + } => { + let deceleration = friction_spline::deceleration(speed, friction, pixels_per_inch); + let rate = friction_spline::deceleration_rate(); + let total_distance = friction as f64 + * friction_spline::physical_coefficient(pixels_per_inch) + * (rate / (rate - 1.) * deceleration).exp(); + let progress = elapsed.as_secs_f64() / duration.as_secs_f64(); + total_distance as f32 * friction_spline::distance_coefficient(progress as f32) + } + } + } +} + +/// The fling model of Android's `OverScroller.SplineOverScroller`, +/// transcribed from AOSP (Apache-2.0). `SPLINE_TIME`, which AOSP uses for +/// programmatic scroll animations rather than flings, is intentionally not +/// transcribed. +mod friction_spline { + use std::sync::LazyLock; + + const NB_SAMPLES: usize = 100; + const INFLEXION: f32 = 0.35; + const START_TENSION: f32 = 0.5; + const END_TENSION: f32 = 1.0; + const P1: f32 = START_TENSION * INFLEXION; + const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION); + + /// Android's `DECELERATION_RATE`: `ln(0.78) / ln(0.9)`. + pub(super) fn deceleration_rate() -> f64 { + 0.78f64.ln() / 0.9f64.ln() + } + + /// `SPLINE_POSITION` from AOSP's static initializer: fractional fling + /// distance sampled at 100 evenly spaced fractions of the fling + /// duration, from a cubic Bezier with control points shaped by + /// `INFLEXION` and the start/end tensions. + static SPLINE_POSITION: LazyLock<[f32; NB_SAMPLES + 1]> = LazyLock::new(|| { + let mut spline_position = [0f32; NB_SAMPLES + 1]; + let mut x_min = 0f32; + for (i, sample) in spline_position.iter_mut().take(NB_SAMPLES).enumerate() { + let alpha = i as f32 / NB_SAMPLES as f32; + let mut x_max = 1f32; + let (x, coefficient) = loop { + let x = x_min + (x_max - x_min) / 2.; + let coefficient = 3. * x * (1. - x); + let time = coefficient * ((1. - x) * P1 + x * P2) + x * x * x; + if (time - alpha).abs() < 1e-5 { + break (x, coefficient); + } + if time > alpha { + x_max = x; + } else { + x_min = x; + } + }; + *sample = coefficient * ((1. - x) * START_TENSION + x) + x * x * x; + } + spline_position[NB_SAMPLES] = 1.; + spline_position + }); + + /// `SensorManager.GRAVITY_EARTH · 39.37 in/m · ppi · 0.84`, AOSP's + /// `mPhysicalCoeff`: gravity expressed in pixels, times an empirical + /// "look and feel" tuning factor. + pub(super) fn physical_coefficient(pixels_per_inch: f32) -> f64 { + 9.80665 * 39.37 * pixels_per_inch as f64 * 0.84 + } + + /// AOSP's `getSplineDeceleration`. + pub(super) fn deceleration(speed: f32, friction: f32, pixels_per_inch: f32) -> f64 { + (INFLEXION as f64 * speed as f64 + / (friction as f64 * physical_coefficient(pixels_per_inch))) + .ln() + } + + /// Fraction of the total fling distance covered at fraction `time` of + /// the fling duration: table lookup plus linear interpolation, as in + /// `SplineOverScroller.update`. + pub(super) fn distance_coefficient(time: f32) -> f32 { + if time >= 1. { + return 1.; + } + let index = ((NB_SAMPLES as f32 * time) as usize).min(NB_SAMPLES - 1); + let time_lower = index as f32 / NB_SAMPLES as f32; + let time_upper = (index + 1) as f32 / NB_SAMPLES as f32; + let distance_lower = SPLINE_POSITION[index]; + let distance_upper = SPLINE_POSITION[index + 1]; + let velocity_coefficient = (distance_upper - distance_lower) / (time_upper - time_lower); + distance_lower + (time - time_lower) * velocity_coefficient + } + + #[cfg(test)] + pub(super) fn bezier_time_and_position(parameter: f32) -> (f32, f32) { + let coefficient = 3. * parameter * (1. - parameter); + let cubed = parameter * parameter * parameter; + ( + coefficient * ((1. - parameter) * P1 + parameter * P2) + cubed, + coefficient * ((1. - parameter) * START_TENSION + parameter) + cubed, + ) + } + + #[cfg(test)] + pub(super) fn spline_position_samples() -> &'static [f32; NB_SAMPLES + 1] { + &SPLINE_POSITION + } +} + /// The set of gesture kinds that participate in recognition. /// /// Used by [`PlatformGestures::native_recognizers`] to declare which gestures @@ -205,17 +405,12 @@ impl PlatformGestures for NullPlatformGestures {} /// 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. +/// Momentum below this speed, in pixels per second, is imperceptible. The +/// exponential model, which never mathematically stops, treats reaching this +/// speed as the end of the fling. (The friction spline has a finite duration +/// of its own.) 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); @@ -274,7 +469,11 @@ enum TouchGestureState { struct ActiveTouch { id: TouchId, start_position: Point, - last_position: Point, + /// The position pan output has scrolled to so far. While panning this + /// may run ahead of the raw touch by the event's predicted position; + /// the release event targets the raw position again, so the total + /// scrolled distance always converges to the finger's actual travel. + emitted_position: Point, velocity_tracker: VelocityTracker, } @@ -284,13 +483,22 @@ struct CompletedTap { count: usize, } +/// One fling in progress. The trajectory is a closed-form curve of elapsed +/// time — each tick evaluates it and emits the increment — so the fling is +/// exactly frame-rate independent: a stalled frame simply resumes further +/// along the same curve. 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, + /// Unit vector of the release velocity. + direction: Point, + /// Release speed in pixels per second. + speed: f32, + started_at: Instant, + duration: Duration, + /// Distance already emitted along `direction`, in pixels. + emitted_distance: f32, } impl TouchGestureRecognizer { @@ -318,22 +526,40 @@ impl TouchGestureRecognizer { let mut recognized = SmallVec::new(); match event.phase { TouchPhase::Started => { - if let Some(momentum) = self.momentum.take() { + let caught_fling = if let Some(momentum) = self.momentum.take() { recognized.push(RecognizedTouchGesture::Scroll(scroll_event( momentum.position, Point::default(), TouchPhase::Ended, ))); - } + true + } else { + false + }; if matches!(self.state, TouchGestureState::Idle) { let mut velocity_tracker = VelocityTracker::default(); velocity_tracker.push(now, event.position); - self.state = TouchGestureState::Pending(ActiveTouch { + let touch = ActiveTouch { id: event.id, start_position: event.position, - last_position: event.position, + emitted_position: event.position, velocity_tracker, - }); + }; + if caught_fling { + // A touch that catches a fling is a drag from the + // first pixel: waiting out the slop would freeze the + // content mid-scroll and then jump. It can also never + // be a tap; releasing it just leaves the content + // stopped, as on Android and iOS. + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + Point::default(), + TouchPhase::Started, + ))); + self.state = TouchGestureState::Panning(touch); + } else { + self.state = TouchGestureState::Pending(touch); + } } } TouchPhase::Moved => match mem::replace(&mut self.state, TouchGestureState::Idle) { @@ -344,10 +570,11 @@ impl TouchGestureRecognizer { // 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; + let target = event.predicted_position.unwrap_or(event.position); + touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, - accumulated, + target - touch.start_position, TouchPhase::Started, ))); self.state = TouchGestureState::Panning(touch); @@ -357,8 +584,9 @@ impl TouchGestureRecognizer { } 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; + let target = event.predicted_position.unwrap_or(event.position); + let delta = target - touch.emitted_position; + touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, delta, @@ -402,12 +630,6 @@ impl TouchGestureRecognizer { }); } 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 @@ -427,18 +649,45 @@ impl TouchGestureRecognizer { touch.velocity_tracker.velocity() }; let speed = (velocity.x.powi(2) + velocity.y.powi(2)).sqrt(); + let mut release_delta = event.position - touch.emitted_position; 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, - }); + let direction = point(velocity.x / speed, velocity.y / speed); + let speed = speed.min(MAX_FLING_VELOCITY); + let duration = self.tuning.scroll_physics.fling_duration(speed); + if !duration.is_zero() { + let total_distance = + self.tuning.scroll_physics.fling_distance(speed, duration); + // Prediction may have left the content ahead of + // the raw release position. Emitting that + // correction here would visibly snap the content + // backwards just as the fling launches, so fold + // it into the fling instead: start the curve + // already advanced by the overshoot, keeping the + // total travel exact while staying monotonic. + let overshoot = -(f32::from(release_delta.x) * direction.x + + f32::from(release_delta.y) * direction.y); + let emitted_distance = if overshoot > 0. && overshoot < total_distance { + release_delta += + point(px(direction.x * overshoot), px(direction.y * overshoot)); + overshoot + } else { + 0. + }; + self.momentum = Some(Momentum { + position: touch.start_position, + direction, + speed, + started_at: now, + duration, + emitted_distance, + }); + } } + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + release_delta, + TouchPhase::Ended, + ))); } other => self.state = other, }, @@ -470,21 +719,19 @@ impl TouchGestureRecognizer { 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 + let elapsed = now.duration_since(momentum.started_at); + let distance = 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(); + .scroll_physics + .fling_distance(momentum.speed, elapsed); + let step = distance - momentum.emitted_distance; + momentum.emitted_distance = distance; + let delta = point( + px(momentum.direction.x * step), + px(momentum.direction.y * step), + ); let position = momentum.position; - if speed < MOMENTUM_STOP_VELOCITY { + if elapsed >= momentum.duration { self.momentum = None; Some(RecognizedTouchGesture::Scroll(scroll_event( position, @@ -839,6 +1086,116 @@ mod tests { assert_eq!(scroll.touch_phase, TouchPhase::Ended); } + #[test] + fn predicted_positions_lead_the_pan_but_totals_converge_on_release() { + 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); + + // The first pan step scrolls to the predicted position, not the raw one. + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.); + moved.predicted_position = Some(point(px(100.), px(128.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(28.))); + + // The next step is measured from where the previous prediction left + // the content, so an overshoot is paid back here. + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 130.); + moved.predicted_position = Some(point(px(100.), px(134.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.))); + + // A release without a fling (the finger stopped long before lifting) + // targets the raw position: the total scrolled distance equals the + // finger's actual travel despite the predictions. + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 130.), + now + Duration::from_millis(120), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + assert!(!recognizer.has_momentum()); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-4.))); + } + + #[test] + fn predicted_overshoot_folds_into_the_fling_without_scrolling_backwards() { + let now = Instant::now(); + let mut total_with_prediction = 0f32; + let mut total_without_prediction = 0f32; + for use_prediction in [true, false] { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let mut total = 0f32; + let mut drain = |recognized: &[RecognizedTouchGesture], upward_only: bool| { + for gesture in recognized { + let RecognizedTouchGesture::Scroll(scroll) = gesture else { + panic!("expected scroll, got {gesture:?}"); + }; + let delta = scroll.delta.pixel_delta(px(16.)).y; + if upward_only { + assert!( + delta <= px(0.), + "content moved backwards by {delta:?} during an upward gesture" + ); + } + total += f32::from(delta); + } + }; + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 500.), + now, + ); + for step in 1..=5u64 { + let raw_y = 500. - step as f32 * 40.; + let mut moved = touch_event(TouchId(1), TouchPhase::Moved, 100., raw_y); + if use_prediction { + moved.predicted_position = Some(point(px(100.), px(raw_y - 25.))); + } + let recognized = + recognizer.handle_event_at(&moved, now + Duration::from_millis(step * 16)); + drain(&recognized, use_prediction); + } + // The release leaves the emitted position 25px ahead of the raw + // one; with prediction the correction must not scroll backwards. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 300.), + now + Duration::from_millis(90), + ); + drain(&recognized, use_prediction); + assert!(recognizer.has_momentum()); + let mut tick = now + Duration::from_millis(90); + while recognizer.has_momentum() { + tick += Duration::from_millis(16); + if let Some(gesture) = recognizer.tick_momentum_at(tick) { + drain(&[gesture], use_prediction); + } + } + + if use_prediction { + total_with_prediction = total; + } else { + total_without_prediction = total; + } + } + // Folding the overshoot into the fling redistributes the travel but + // must not change where the content comes to rest. + assert!( + (total_with_prediction - total_without_prediction).abs() < 0.01, + "totals diverged: {total_with_prediction} vs {total_without_prediction}" + ); + } + #[test] fn fast_release_starts_momentum_that_decays_to_a_stop() { let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); @@ -945,11 +1302,70 @@ mod tests { now + Duration::from_millis(200), ); assert!(!recognizer.has_momentum()); + let [ + RecognizedTouchGesture::Scroll(closing), + RecognizedTouchGesture::Scroll(opening), + ] = recognized.as_slice() + else { + panic!("expected closing and opening scrolls, got {recognized:?}"); + }; + assert_eq!(closing.touch_phase, TouchPhase::Ended); + assert!(closing.delta.pixel_delta(px(16.)).is_zero()); + assert_eq!(opening.touch_phase, TouchPhase::Started); + assert!(opening.delta.pixel_delta(px(16.)).is_zero()); + } + + #[test] + fn catching_a_fling_pans_immediately_and_never_taps() { + 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()); + + recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 100., 200.), + now + Duration::from_millis(200), + ); + + // A movement well within the slop scrolls immediately. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Moved, 100., 197.), + now + Duration::from_millis(216), + ); let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { - panic!("expected closing scroll, got {recognized:?}"); + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-3.))); + + // Releasing the catch is not a tap. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 100., 197.), + now + Duration::from_millis(232), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); }; assert_eq!(scroll.touch_phase, TouchPhase::Ended); - assert!(scroll.delta.pixel_delta(px(16.)).is_zero()); } #[test] @@ -1018,6 +1434,120 @@ mod tests { assert_eq!(scroll.touch_phase, TouchPhase::Started); } + #[test] + fn spline_position_table_matches_the_bezier_curve() { + let samples = friction_spline::spline_position_samples(); + // AOSP's initializer solves sample 0 numerically like every other + // sample, so it lands within solver tolerance of zero, not at zero. + assert!(samples[0].abs() < 1e-4); + assert_eq!(samples[100], 1.); + for window in samples.windows(2) { + assert!(window[0] < window[1], "table must be strictly increasing"); + } + // Each table entry must lie on the defining parametric Bezier: for + // sample i there must be a curve parameter whose time component is + // i/100 and whose position component is the stored value. + for (i, &stored_position) in samples.iter().enumerate().take(100) { + let alpha = i as f32 / 100.; + let (mut lower, mut upper) = (0f32, 1f32); + for _ in 0..50 { + let middle = (lower + upper) / 2.; + let (time, _) = friction_spline::bezier_time_and_position(middle); + if time > alpha { + upper = middle; + } else { + lower = middle; + } + } + let (time, position) = friction_spline::bezier_time_and_position((lower + upper) / 2.); + assert!( + (time - alpha).abs() < 1e-4, + "sample {i}: time {time} != {alpha}" + ); + assert!( + (position - stored_position).abs() < 1e-3, + "sample {i}: position {position} != stored {stored_position}" + ); + } + } + + #[test] + fn fling_curves_are_sane_for_both_physics() { + for physics in [ScrollPhysics::ios(), ScrollPhysics::android()] { + let slow = physics.fling_duration(500.); + let fast = physics.fling_duration(4000.); + assert!(slow > Duration::ZERO, "{physics:?}"); + assert!(fast > slow, "faster flings must coast longer: {physics:?}"); + + let halfway = physics.fling_distance(4000., fast / 2); + let total = physics.fling_distance(4000., fast); + assert!(halfway > 0. && halfway < total, "{physics:?}"); + assert!( + physics.fling_distance(4000., fast * 2) == total, + "distance must not grow past the fling duration: {physics:?}" + ); + assert!( + physics.fling_distance(4000., fast) > physics.fling_distance(500., slow), + "faster flings must travel further: {physics:?}" + ); + } + } + + #[test] + fn momentum_is_frame_rate_independent() { + // The same fling ticked at 60Hz and as one huge stalled frame must + // cover identical ground. + let total_distance_with_tick_length = |tick: Duration| -> f32 { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning { + scroll_physics: ScrollPhysics::android(), + ..GestureTuning::default() + }); + let now = Instant::now(); + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 500.), + now, + ); + for step in 1..=3 { + recognizer.handle_event_at( + &touch_event( + TouchId(1), + TouchPhase::Moved, + 100., + 500. - step as f32 * 40., + ), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 380.), + now + Duration::from_millis(64), + ); + assert!(recognizer.has_momentum()); + + let mut total = 0f32; + let mut time = now + Duration::from_millis(64); + let mut guard = 0; + while recognizer.has_momentum() { + time += tick; + guard += 1; + assert!(guard < 10_000, "momentum never stopped"); + if let Some(RecognizedTouchGesture::Scroll(scroll)) = + recognizer.tick_momentum_at(time) + { + total += f32::from(scroll.delta.pixel_delta(px(16.)).y); + } + } + total + }; + + let smooth = total_distance_with_tick_length(Duration::from_millis(16)); + let stalled = total_distance_with_tick_length(Duration::from_secs(10)); + assert!( + (smooth - stalled).abs() < 0.01, + "expected identical fling distance, got {smooth} vs {stalled}" + ); + } + #[test] fn flick_velocity_reflects_release_speed_not_window_average() { // A uniformly accelerating flick: position grows quadratically, so @@ -1066,6 +1596,7 @@ mod tests { id, phase, position: point(px(x), px(y)), + predicted_position: None, force: None, } } diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 790593c463..be77a1aff7 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -123,6 +123,15 @@ pub struct TouchEvent { pub phase: TouchPhase, /// The position of the touch in window coordinates. pub position: Point, + /// Where the platform predicts the touch will be roughly one frame from + /// now, in the same coordinate space as `position`, when the platform + /// offers a prediction for a [`TouchPhase::Moved`] event. + /// + /// Best-effort latency compensation only: it may influence how far a + /// recognized pan scrolls within a frame, but never hit testing, gesture + /// classification, or velocity estimation, and any error it introduces + /// must be corrected by later events for the same touch. + pub predicted_position: Option>, /// Normalized touch force in `0.0..=1.0`, if the hardware reports it. pub force: Option, } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 048482526f..c2fa5ebedd 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1193,6 +1193,7 @@ pub struct Window { window_profiler: profiler::WindowProfiler, last_input_modality: InputModality, touch_gestures: TouchGestureRecognizer, + touch_prediction_enabled: bool, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -1888,6 +1889,7 @@ impl Window { .gestures() .map_or_else(GestureTuning::default, |gestures| gestures.tuning()), ), + touch_prediction_enabled: true, refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -5205,11 +5207,29 @@ impl Window { } } + /// Whether recognized touch pans may use the platform's predicted touch + /// positions ([`TouchEvent::predicted_position`]) to compensate for input + /// latency. Defaults to true. + pub fn touch_prediction_enabled(&self) -> bool { + self.touch_prediction_enabled + } + + /// Sets whether recognized touch pans may use the platform's predicted + /// touch positions. Disabling drops [`TouchEvent::predicted_position`] + /// before gesture recognition, so pans track only raw touch positions. + pub fn set_touch_prediction_enabled(&mut self, enabled: bool) { + self.touch_prediction_enabled = enabled; + } + /// 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 event = event.clone(); + if !self.touch_prediction_enabled { + event.predicted_position = None; + } + let recognized_gestures = self.touch_gestures.handle_event(&event); let mut tapped = false; for gesture in recognized_gestures { tapped |= matches!(gesture, RecognizedTouchGesture::Tap { .. }); diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index d9251d69ff..97b7ad24ed 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -218,6 +218,7 @@ impl WebWindowInner { id: TouchId(event.pointer_id() as u64), phase: TouchPhase::Started, position, + predicted_position: None, force: None, })); // Keyboard and IME focus intentionally do not change here: @@ -287,6 +288,7 @@ impl WebWindowInner { id: TouchId(event.pointer_id() as u64), phase: TouchPhase::Ended, position, + predicted_position: None, force: None, })); @@ -394,6 +396,7 @@ impl WebWindowInner { id: TouchId(event.pointer_id() as u64), phase: TouchPhase::Cancelled, position: pointer_position_in_element(&event), + predicted_position: None, force: None, })); } else { @@ -523,6 +526,7 @@ impl WebWindowInner { id: TouchId(event.pointer_id() as u64), phase: TouchPhase::Moved, position, + predicted_position: predicted_pointer_position(&event, position), force: None, })); return; @@ -1299,6 +1303,63 @@ fn pointer_position_in_element(event: &web_sys::PointerEvent) -> Point { mouse_position_in_element(mouse_event) } +/// How far ahead of the raw pointer position predictions may reach. +/// +/// Browsers predict much further (Chrome offers samples out to 25ms), but +/// prediction error grows with the horizon and surfaces as jitter: the +/// emitted pan deltas gain a term proportional to lead x change in velocity, +/// which at long leads visibly reverses direction mid-drag. Measurements +/// show leads up to ~10ms track at or below the raw stream's frame-to-frame +/// variation; AOSP similarly caps touch resampling extrapolation at 8ms +/// (`RESAMPLE_MAX_PREDICTION` in `InputTransport.cpp`). +const MAX_PREDICTION_LEAD_MS: f64 = 10.; + +/// The predicted pointer position closest to [`MAX_PREDICTION_LEAD_MS`] +/// ahead of `event`, from `getPredictedEvents()`, or `None` when the browser +/// offers no prediction (Safari lacks the method, Firefox returns an empty +/// array). A prediction further out than the cap is linearly scaled back to +/// it. +/// +/// Accessed through `Reflect` because calling a missing method through the +/// web-sys binding would throw, and predicted events' `offsetX`/`offsetY` +/// are unreliable across browsers (their target may be detached), so the +/// position is derived from the client-coordinate delta against the parent +/// event, anchored to the parent's element-relative `position`. +fn predicted_pointer_position( + event: &web_sys::PointerEvent, + position: Point, +) -> Option> { + let method = js_sys::Reflect::get(event, &JsValue::from_str("getPredictedEvents")).ok()?; + let method = method.dyn_ref::()?; + let predicted_events: js_sys::Array = method.call0(event).ok()?.dyn_into().ok()?; + let mut best: Option<(f64, web_sys::PointerEvent)> = None; + for predicted in predicted_events.iter() { + let Ok(predicted) = predicted.dyn_into::() else { + continue; + }; + let lead = predicted.time_stamp() - event.time_stamp(); + if lead <= 0. { + continue; + } + let distance_to_cap = (lead - MAX_PREDICTION_LEAD_MS).abs(); + if best + .as_ref() + .is_none_or(|(best_distance, _)| distance_to_cap < *best_distance) + { + best = Some((distance_to_cap, predicted)); + } + } + let (_, predicted) = best?; + let lead = predicted.time_stamp() - event.time_stamp(); + let scale = (MAX_PREDICTION_LEAD_MS / lead).min(1.) as f32; + let event: &web_sys::MouseEvent = event.as_ref(); + let predicted: &web_sys::MouseEvent = predicted.as_ref(); + Some(point( + position.x + px((predicted.client_x() - event.client_x()) as f32 * scale), + position.y + px((predicted.client_y() - event.client_y()) as f32 * scale), + )) +} + fn mouse_position_in_element(event: &web_sys::MouseEvent) -> Point { // offset_x/offset_y give position relative to the target element's padding edge point(px(event.offset_x() as f32), px(event.offset_y() as f32)) diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index 70d643e542..af8cc8584d 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -8,10 +8,11 @@ use anyhow::Result; use futures::channel::oneshot; use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardReadError, - ClipboardString, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, Image, ImageFormat, - Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, - PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, ThermalState, - WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError, + ClipboardString, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, GestureTuning, Image, + ImageFormat, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, + PlatformGestures, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, ScrollPhysics, Task, ThermalState, WindowAppearance, WindowKind, WindowParams, + popup::PopupNotSupportedError, }; use gpui_wgpu::{PreparedWebGraphics, WebBackendPreference, WgpuContext, wgpu}; use std::{ @@ -41,9 +42,42 @@ pub struct WebPlatform { window_lifecycle: Rc>, cursor_visible: Rc>, last_cursor_css: Rc>, + gestures: Rc, _cursor_restore_listeners: Vec, } +/// Gesture feel for the browser, chosen so touch scrolling matches the host +/// OS's native applications. +struct WebGestures { + tuning: GestureTuning, +} + +impl WebGestures { + fn from_user_agent(user_agent: &str) -> Self { + let scroll_physics = if user_agent.contains("Android") { + ScrollPhysics::android() + } else { + // iOS, and also desktops: the default exponential decay. Desktop + // browsers rarely reach the portable fling at all (trackpads + // deliver their own momentum events), so the distinction only + // matters for touch screens. + ScrollPhysics::ios() + }; + Self { + tuning: GestureTuning { + scroll_physics, + ..GestureTuning::default() + }, + } + } +} + +impl PlatformGestures for WebGestures { + fn tuning(&self) -> GestureTuning { + self.tuning + } +} + struct PreparedWebWindow { canvas: web_sys::HtmlCanvasElement, surface: wgpu::Surface<'static>, @@ -138,6 +172,9 @@ impl WebPlatform { cursor_visible.clone(), last_cursor_css.clone(), ); + let gestures = Rc::new(WebGestures::from_user_agent( + &browser_window.navigator().user_agent().unwrap_or_default(), + )); Self { browser_window, @@ -154,6 +191,7 @@ impl WebPlatform { window_lifecycle: Rc::new(Cell::new(WebWindowLifecycle::Available)), cursor_visible, last_cursor_css, + gestures, _cursor_restore_listeners: cursor_restore_listeners, } } @@ -254,6 +292,10 @@ impl Platform for WebPlatform { self.background_executor.clone() } + fn gestures(&self) -> Option> { + Some(self.gestures.clone()) + } + fn foreground_executor(&self) -> ForegroundExecutor { self.foreground_executor.clone() }