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:
1 parent
29a2ce8d46
commit
87222d1d29
5 files changed
+726
-63
No files matched your search
+589
-58
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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 { .. });
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user