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:
1 parent
0134fcd23a
commit
50b7479f17
10 files changed
+987
-34
No files matched your search
+1
-1
@@ -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
@@ -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(×_seconds, &horizontal).map_or_else(
|
||||
|| endpoint_estimate(&horizontal),
|
||||
|velocity| velocity as f32,
|
||||
),
|
||||
quadratic_velocity_at_newest(×_seconds, &vertical)
|
||||
.map_or_else(|| endpoint_estimate(&vertical), |velocity| velocity as f32),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Least-squares fit of `value = a0 + a1·t + a2·t²` returning `a1`: the
|
||||
/// fitted curve's velocity at `t = 0`, which callers place at the newest
|
||||
/// sample. `None` when the samples are too degenerate to fit (all
|
||||
/// simultaneous, for example).
|
||||
fn quadratic_velocity_at_newest(times: &[f64], values: &[f64]) -> Option<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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user