gpui: Platform-specific scroll physics (#63505)

Adds platform-specific scroll behaviour, and also exposes predicted
events for better latency

---

Release Notes:

- N/A or Added/Fixed/Improved ...
This commit is contained in:
Cameron Mcloughlin authored and GitHub committed 2026-08-31 17:08:14 +00:00
1 parent 29a2ce8d46
commit 87222d1d29
5 files changed
+726 -63

No files matched your search

+589 -58
View File
@@ -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<Pixels>,
last_position: Point<Pixels>,
/// 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<Pixels>,
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>,
/// Pixels per second.
velocity: Point<f32>,
last_tick: Instant,
/// Unit vector of the release velocity.
direction: Point<f32>,
/// 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<RecognizedTouchGesture> {
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,
}
}
+9
View File
@@ -123,6 +123,15 @@ pub struct TouchEvent {
pub phase: TouchPhase,
/// The position of the touch in window coordinates.
pub position: Point<Pixels>,
/// 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<Point<Pixels>>,
/// Normalized touch force in `0.0..=1.0`, if the hardware reports it.
pub force: Option<f32>,
}
+21 -1
View File
@@ -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<FocusId>,
@@ -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 { .. });
+61
View File
@@ -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<Pixels> {
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<Pixels>,
) -> Option<Point<Pixels>> {
let method = js_sys::Reflect::get(event, &JsValue::from_str("getPredictedEvents")).ok()?;
let method = method.dyn_ref::<js_sys::Function>()?;
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::<web_sys::PointerEvent>() 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<Pixels> {
// 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))
+46 -4
View File
@@ -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<Cell<WebWindowLifecycle>>,
cursor_visible: Rc<Cell<bool>>,
last_cursor_css: Rc<Cell<&'static str>>,
gestures: Rc<WebGestures>,
_cursor_restore_listeners: Vec<EventListenerHandle>,
}
/// 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<Rc<dyn PlatformGestures>> {
Some(self.gestures.clone())
}
fn foreground_executor(&self) -> ForegroundExecutor {
self.foreground_executor.clone()
}