gpui: Touch events (#63373)

Adds support to GPUI for touch events, important for mobile web
browsers, and any potential native ios/android platforms

---

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>
This commit is contained in:
Cameron McloughlinandJakub Konka authored and GitHub committed 2026-08-28 17:22:25 +00:00
1 parent 0134fcd23a
commit 50b7479f17
10 files changed
+987 -34

No files matched your search

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