use crate::prelude::*; use std::{ collections::VecDeque, ops::{BitOr, Deref, DerefMut}, rc::Rc, time::{Duration, Instant}, }; #[derive(Clone, Copy, PartialEq)] pub enum CursorButton { Left, Right, Middle, } #[derive(Clone, Copy, PartialEq)] pub enum CursorSense { PressStart(CursorButton), Pressing(CursorButton), PressEnd(CursorButton), HoverStart, Hovering, HoverEnd, Scroll(Axis), /// Terminal event replacing `PressEnd` for the pointer captor. Drop, /// Another widget captured this press; abandon it without acting. Cancel, } #[derive(Clone)] pub struct CursorSenses { senses: Vec, drag_axis: Option, } impl Event for CursorSenses { type Data<'a> = CursorData<'a>; type State = SensorState; type Global = PointerInput; fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option> { // Capture routing has already selected these exact terminal events; // deriving again could match PressEnd or Pressing first. if data.sense == CursorSense::Drop || data.sense == CursorSense::Cancel { return self.contains(&data.sense).then(|| data.clone()); } if let Some(sense) = should_run( self, &data.cursor, data.hover, data.drag_axis, data.captured, ) { let mut data = data.clone(); data.sense = sense; Some(data) } else { None } } } impl CursorSenses { fn consumes(&self, data: &CursorData<'_>, momentary_active: bool) -> bool { if !momentary_active { return true; } let Some(sense) = should_run( self, &data.cursor, data.hover, data.drag_axis, data.captured, ) else { return false; }; match (self.drag_axis, sense) { (Some(axis), CursorSense::Pressing(_)) => data.captured || data.drag_axis == Some(axis), (Some(_), CursorSense::PressStart(_) | CursorSense::PressEnd(_)) => false, _ => sense.is_momentary(), } } } impl CursorSense { pub fn click() -> Self { Self::PressStart(CursorButton::Left) } pub fn click_or_drag() -> CursorSenses { Self::click() | Self::Pressing(CursorButton::Left) } pub fn unclick() -> Self { Self::PressEnd(CursorButton::Left) } /// Drag frames and both terminal outcomes: `Drop` and `Cancel`. pub fn drag_senses() -> CursorSenses { Self::click_or_drag() | Self::unclick() | Self::Drop | Self::Cancel } pub fn drag(axis: Axis) -> CursorSenses { let mut senses = Self::drag_senses(); senses.drag_axis = Some(axis); senses } pub fn is_dragging(&self) -> bool { matches!(self, CursorSense::Pressing(CursorButton::Left)) } /// Whether this event may consume input; ambient hover never does. pub fn is_momentary(&self) -> bool { !matches!( self, CursorSense::HoverStart | CursorSense::Hovering | CursorSense::HoverEnd ) } } #[derive(Clone)] pub struct CursorState { pub pos: Vec2, pub exists: bool, pub buttons: CursorButtons, pub scroll_delta: Vec2, pub time: Instant, /// Platform cancellation, not a release; it must never produce a fling. pub cancelled: bool, } impl Default for CursorState { fn default() -> Self { Self { pos: Vec2::ZERO, exists: false, buttons: CursorButtons::default(), scroll_delta: Vec2::ZERO, time: Instant::now(), cancelled: false, } } } #[derive(Default, Clone)] pub struct CursorButtons { pub left: ActivationState, pub middle: ActivationState, pub right: ActivationState, } impl CursorButtons { pub fn select(&self, button: &CursorButton) -> &ActivationState { match button { CursorButton::Left => &self.left, CursorButton::Right => &self.right, CursorButton::Middle => &self.middle, } } pub fn end_frame(&mut self) { self.left.end_frame(); self.middle.end_frame(); self.right.end_frame(); } pub fn iter(&self) -> impl Iterator { [ CursorButton::Left, CursorButton::Middle, CursorButton::Right, ] .into_iter() .map(|b| (b, self.select(&b))) } } impl CursorState { pub fn end_frame(&mut self) { self.buttons.end_frame(); self.scroll_delta = Vec2::ZERO; self.cancelled = false; } } #[derive(Debug, Clone, Copy, Default, PartialEq)] pub enum ActivationState { Start, On, End, #[default] Off, } pub struct Sensor { pub senses: CursorSenses, pub f: Rc>, } pub type SenseShape = UiRegion; #[derive(Default, Debug)] pub struct SensorState { pub hover: ActivationState, } #[derive(Clone)] pub struct CursorData<'a> { pub pos: Vec2, pub size: Vec2, pub scroll_delta: Vec2, pub hover: ActivationState, pub cursor: CursorState, /// The direction selected after this press crossed [`DRAG_SLOP`]. /// `None` while the gesture is still only a press. pub drag_axis: Option, pub captured: bool, pub sense: CursorSense, pub render: &'a UiRenderState, /// Pointer-capture state for this dispatch. pub pointer: &'a PointerRequests, } /// Dispatch-wide capture and in-flight press state. Capture keeps routing a /// gesture to one widget after the pointer leaves its hit region. #[derive(Default)] pub struct PointerInput { captured: Option, pressed: Vec, press_origin: Option, drag_axis: Option, } impl PointerInput { /// Which widget holds exclusive pointer input between dispatches. pub fn holder(&self) -> Option { self.captured } /// Hand the pointer to `id` from outside the sensor pass -- a test /// setting a gesture up, or a backend tearing one down with `None`. /// A handler *inside* the pass uses [`PointerRequests::capture`] /// instead, which is the same state seen through the dispatch. pub fn set_holder(&mut self, id: Option) { self.captured = id; } } /// The pointer state of `rsc`'s cursor dispatch, for a caller outside the /// sensor pass. Inside it, a handler has [`PointerRequests`] on its /// [`CursorData`] and should use that. pub fn pointer_input(rsc: &mut Rsc) -> &mut PointerInput { &mut rsc.events_mut().get_type::().global } /// The pointer, as a handler sees it during one dispatch: what it may ask /// of the capture, and who holds it. Owned by [`SensorUi::run_sensors`] /// for the length of the dispatch and folded back into [`PointerInput`] /// straight after, so a handler's request never races anything and nothing /// global is reachable from a widget. /// /// A `Cell`, not a lock: this is one frame of one thread's dispatch, and /// the interior mutability is only here because a handler is handed /// `CursorData` by shared reference. #[derive(Default)] pub struct PointerRequests { holder: std::cell::Cell>, } impl PointerRequests { /// Give `id` exclusive input. It must outlive the gesture, unlike a /// virtualized row; replacing a holder sends the old one `Cancel`. pub fn capture(&self, id: WidgetId) { self.holder.set(Some(id)); } /// Give up exclusive pointer input. pub fn release(&self) { self.holder.set(None); } /// The current holder, used to avoid releasing another widget's capture. pub fn holder(&self) -> Option { self.holder.get() } } pub trait SensorUi { fn run_sensors( &self, rsc: &mut Rsc, state: &mut Rsc::State, cursor: CursorState, window_size: Vec2, ); } impl SensorUi for UiRenderState { fn run_sensors( &self, rsc: &mut Rsc, state: &mut Rsc::State, cursor: CursorState, window_size: Vec2, ) { self.note_input(cursor.time); let mut pointer: PointerInput = std::mem::take(&mut rsc.events_mut().get_type::().global); let requests = PointerRequests { holder: std::cell::Cell::new(pointer.captured), }; let button_down = cursor.buttons.select(&CursorButton::Left).is_on(); if cursor.buttons.left.is_start() { pointer.press_origin = Some(cursor.pos); pointer.drag_axis = None; } else if button_down && pointer.drag_axis.is_none() && let Some(origin) = pointer.press_origin { let moved = cursor.pos - origin; if moved.x.abs().max(moved.y.abs()) > DRAG_SLOP { pointer.drag_axis = Some(if moved.x.abs() > moved.y.abs() { Axis::X } else { Axis::Y }); } } // Platform cancellation reaches every tracker and produces no other sense. if cursor.cancelled { let captured = pointer.captured.take(); requests.release(); pointer.press_origin = None; pointer.drag_axis = None; if let Some(id) = captured { deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); } for id in pointer.pressed.drain(..) { if Some(id) != captured { deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); } } rsc.events_mut().get_type::().global = pointer; return; } // Capture bypasses hit testing until release, when its holder receives one Drop. if let Some(id) = requests.holder() { let Some(shape) = self.resolved_region(&id, rsc) else { pointer.captured = None; pointer.pressed.clear(); rsc.events_mut().get_type::().global = pointer; return; }; let region = shape.to_px(window_size); let sense = if button_down { CursorSense::Pressing(CursorButton::Left) } else { CursorSense::Drop }; let data = CursorData { pos: cursor.pos - region.top_left, size: region.bot_right - region.top_left, scroll_delta: cursor.scroll_delta, hover: ActivationState::On, cursor: cursor.clone(), drag_axis: pointer.drag_axis, captured: true, sense, render: self, pointer: &requests, }; rsc.run_event::(id, data, state); if !button_down { requests.release(); pointer.pressed.clear(); pointer.press_origin = None; pointer.drag_axis = None; } pointer.captured = requests.holder(); rsc.events_mut().get_type::().global = pointer; return; } let momentary_active = cursor.scroll_delta != Vec2::ZERO || cursor.buttons.iter().any(|(_, a)| !a.is_off()); let mut active = std::mem::take(&mut rsc.events_mut().get_type::().active); for layer in self.layers.indices().rev() { let mut sensed = false; for (id, sensor) in active.get_mut(&layer).into_flat_iter() { let shape = self.resolved_region(id, rsc).unwrap(); let region = shape.to_px(window_size); // A point must be in both the widget's box and its rendered mask chain. let in_shape = cursor.exists && region.contains(cursor.pos) && self .active .get(id) .is_none_or(|a| self.mask_admits(a.mask, cursor.pos, rsc)); sensor.hover.update(in_shape); if sensor.hover == ActivationState::Off { continue; } // Idle hover stops at the topmost widget. Momentary input stops only at a // listener for that input, so click-only children do not block scrolling. let cursor = cursor.clone(); let data = CursorData { pos: cursor.pos - region.top_left, size: region.bot_right - region.top_left, scroll_delta: cursor.scroll_delta, hover: sensor.hover, cursor, drag_axis: pointer.drag_axis, captured: false, sense: CursorSense::Hovering, render: self, pointer: &requests, }; let consumes = rsc .events_mut() .get_type::() .registered(*id) .any(|senses| senses.consumes(&data, momentary_active)); if consumes { sensed = true; } rsc.run_event::(*id, data, state); // Any pressed listener may hold gesture state and is owed Cancel if it loses. if button_down && !pointer.pressed.contains(id) { pointer.pressed.push(*id); } } if sensed || requests.holder().is_some() { break; } } rsc.events_mut().get_type::().active = active; pointer.captured = requests.holder(); match pointer.captured { Some(winner) => { let mut winner_was_pressed = false; for id in pointer.pressed.drain(..) { if id == winner { winner_was_pressed = true; } else { deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); } } if winner_was_pressed { pointer.pressed.push(winner); } } None if !button_down => pointer.pressed.clear(), None => {} } pointer.captured = requests.holder(); if !button_down { pointer.press_origin = None; pointer.drag_axis = None; } rsc.events_mut().get_type::().global = pointer; } } fn deliver_cancel( render: &UiRenderState, rsc: &mut Rsc, state: &mut Rsc::State, id: WidgetId, cursor: &CursorState, window_size: Vec2, pointer: &PointerRequests, ) { let Some(shape) = render.resolved_region(&id, rsc) else { return; }; let region = shape.to_px(window_size); let data = CursorData { pos: cursor.pos - region.top_left, size: region.bot_right - region.top_left, scroll_delta: cursor.scroll_delta, hover: ActivationState::On, cursor: cursor.clone(), drag_axis: None, captured: false, sense: CursorSense::Cancel, render, pointer, }; rsc.run_event::(id, data, state); } pub fn should_run( senses: &CursorSenses, cursor: &CursorState, hover: ActivationState, drag_axis: Option, captured: bool, ) -> Option { let on_this = hover.is_on(); for sense in senses.iter() { if match sense { CursorSense::PressStart(button) => on_this && cursor.buttons.select(button).is_start(), CursorSense::Pressing(button) => on_this && cursor.buttons.select(button).is_on(), CursorSense::PressEnd(button) => on_this && cursor.buttons.select(button).is_end(), CursorSense::HoverStart => hover.is_start(), CursorSense::Hovering => hover.is_on(), CursorSense::HoverEnd => hover.is_end(), CursorSense::Scroll(axis) => { on_this && cursor.scroll_delta.axis(*axis) != 0.0 && cursor.scroll_delta.axis(*axis).abs() >= cursor.scroll_delta.axis(!*axis).abs() } // Capture routing supplies terminal events; raw button state must not derive them. CursorSense::Drop | CursorSense::Cancel => false, } && (captured || !matches!(sense, CursorSense::Pressing(_)) || senses.drag_axis.is_none() || senses.drag_axis == drag_axis) { return Some(*sense); } } None } impl ActivationState { pub fn is_start(&self) -> bool { *self == Self::Start } pub fn is_on(&self) -> bool { *self == Self::Start || *self == Self::On } pub fn is_end(&self) -> bool { *self == Self::End } pub fn is_off(&self) -> bool { *self == Self::End || *self == Self::Off } pub fn update(&mut self, on: bool) { *self = match *self { Self::Start => match on { true => Self::On, false => Self::End, }, Self::On => match on { true => Self::On, false => Self::End, }, Self::End => match on { true => Self::Start, false => Self::Off, }, Self::Off => match on { true => Self::Start, false => Self::Off, }, } } pub fn end_frame(&mut self) { match self { Self::Start => *self = Self::On, Self::End => *self = Self::Off, _ => (), } } } impl EventLike for CursorSense { type Event = CursorSenses; fn into_event(self) -> Self::Event { self.into() } } impl Deref for CursorSenses { type Target = Vec; fn deref(&self) -> &Self::Target { &self.senses } } impl DerefMut for CursorSenses { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.senses } } impl From for CursorSenses { fn from(val: CursorSense) -> Self { CursorSenses { senses: vec![val], drag_axis: None, } } } impl BitOr for CursorSense { type Output = CursorSenses; fn bitor(self, rhs: Self) -> Self::Output { CursorSenses { senses: vec![self, rhs], drag_axis: None, } } } impl BitOr for CursorSenses { type Output = Self; fn bitor(mut self, rhs: CursorSense) -> Self::Output { self.senses.push(rhs); self } } /// Writes the same action vocabulary and relative-millisecond clock that /// [`crate::harness::TouchScript::parse`] reads. Diagnostics explicitly gate it. pub fn log_input_event(action: &str, x: f32, y: f32, t_ms: u64, historical: &[(u64, f32, f32)]) { if !crate::diagnostics::trace_enabled() { return; } let mut hist = String::new(); for (t, hx, hy) in historical { hist.push_str(&format!(" {t}:{hx:.1},{hy:.1}")); } log::debug!( target: "iris::input", "iris input: action={action} x={x:.1} y={y:.1} t={t_ms}ms history={}{hist}", historical.len(), ); } /// Converts Android touch and Choreographer timestamps from their shared /// monotonic clock so gesture velocity and animation use the same timeline. #[derive(Clone, Copy)] pub struct DeviceClock { anchor_at: Instant, anchor_nanos: i64, last_nanos: i64, } impl DeviceClock { /// `oldest` anchors a historical batch; it equals `event_time` without one. pub fn anchored(now: Instant, event_time: i64, oldest: i64) -> Self { let batch_span = Duration::from_nanos(event_time.saturating_sub(oldest).max(0) as u64); Self { // Very early process timestamps may precede the representable Instant range. anchor_at: now.checked_sub(batch_span).unwrap_or(now), anchor_nanos: oldest, last_nanos: oldest, } } pub fn sample(&mut self, nanos: i64) -> Instant { debug_assert!( nanos >= self.last_nanos, "input sample is dated {nanos}ns, before the {}ns sample ahead of it -- the input \ clock is not what this assumes", self.last_nanos, ); self.last_nanos = self.last_nanos.max(nanos); self.at(nanos) } pub fn at(&self, nanos: i64) -> Instant { self.anchor_at + Duration::from_nanos(nanos.saturating_sub(self.anchor_nanos).max(0) as u64) } pub fn ms_since_anchor(&self, nanos: i64) -> u64 { (nanos.saturating_sub(self.anchor_nanos).max(0) as u64) / 1_000_000 } } pub const LONG_PRESS: Duration = Duration::from_millis(500); pub const DRAG_SLOP: f32 = 8.0; /// What a [`DragArbiter`] decided a frame's drag should mean. `Undecided` /// means neither a pan nor a selection has committed yet, so the caller /// should do nothing observable this frame. #[derive(Debug, Clone, Copy, PartialEq)] pub enum DragOutcome { Undecided, Pan(f32), SelectStart, SelectExtend, } /// Target state that determines which gestures a new press may become. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PressState { pub already_selected: bool, pub scrolling: bool, } #[derive(Clone, Copy, PartialEq)] enum ArbiterState { Idle, Undecided { already_selected: bool }, Panning, Selecting, } /// Chooses between axial panning, long-press selection, and immediate /// cross-axis extension of an existing selection. pub struct DragArbiter { state: ArbiterState, axis: Axis, origin: Vec2, origin_at: Instant, last: Vec2, } impl Default for DragArbiter { fn default() -> Self { Self::on(Axis::Y) } } impl DragArbiter { pub fn new() -> Self { Self::default() } pub fn on(axis: Axis) -> Self { Self { state: ArbiterState::Idle, axis, origin: Vec2::ZERO, origin_at: Instant::now(), last: Vec2::ZERO, } } /// Begins a press. Touching moving content commits immediately to panning /// so the press catches the current fling without slop or long-press delay. pub fn press_start(&mut self, pos: Vec2, now: Instant, press: PressState) { self.origin = pos; self.origin_at = now; self.last = pos; self.state = if press.scrolling { ArbiterState::Panning } else { ArbiterState::Undecided { already_selected: press.already_selected, } }; } /// Whether no press is in flight, including after a missed `PressStart`. pub fn is_idle(&self) -> bool { matches!(self.state, ArbiterState::Idle) } /// Idle cannot infer a missed `PressStart`; callers receiving `Pressing` /// while idle must open the press first. pub fn update(&mut self, pos: Vec2, now: Instant) -> DragOutcome { match self.state { ArbiterState::Idle => DragOutcome::Undecided, ArbiterState::Panning => { let along = pos.axis(self.axis) - self.last.axis(self.axis); self.last = pos; DragOutcome::Pan(along) } ArbiterState::Selecting => { self.last = pos; DragOutcome::SelectExtend } ArbiterState::Undecided { already_selected } => { let along = pos.axis(self.axis) - self.origin.axis(self.axis); let across = pos.axis(!self.axis) - self.origin.axis(!self.axis); if already_selected && across.abs() > DRAG_SLOP && across.abs() > along.abs() { self.state = ArbiterState::Selecting; self.last = pos; if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::input", "iris gesture: select extend (early, already selected) across={across:.1}" ); } DragOutcome::SelectExtend } else if along.abs() > DRAG_SLOP && along.abs() >= across.abs() { self.state = ArbiterState::Panning; self.last = pos; if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::input", "iris gesture: drag start axis={:?} along={along:.1}", self.axis, ); } // Consume the slop once; replaying the whole withheld distance visibly jumps. DragOutcome::Pan(along - DRAG_SLOP.copysign(along)) } else if now.duration_since(self.origin_at) >= LONG_PRESS && across.abs() <= DRAG_SLOP && along.abs() <= DRAG_SLOP { self.state = ArbiterState::Selecting; self.last = pos; if crate::diagnostics::trace_enabled() { log::debug!(target: "iris::input", "iris gesture: long press"); } DragOutcome::SelectStart } else { DragOutcome::Undecided } } } } pub fn release(&mut self) { self.state = ArbiterState::Idle; } /// Whether release may turn the tracked velocity into a fling. pub fn is_panning(&self) -> bool { matches!(self.state, ArbiterState::Panning) } pub fn axis(&self) -> Axis { self.axis } pub fn is_undecided(&self) -> bool { matches!(self.state, ArbiterState::Undecided { .. }) } } #[derive(Debug, Clone, Copy, PartialEq)] pub enum GestureOutcome { Undecided, Pan(f32), SelectStart, SelectExtend, /// The press never committed to a pan or selection. Tapped, Released(Option), Cancelled, } pub struct DragGesture { arbiter: DragArbiter, velocity: VelocityTracker, /// A caught fling that has not moved: consumed as a pan, but with no release velocity. catch_unmoved: bool, } impl Default for DragGesture { fn default() -> Self { Self::new() } } impl DragGesture { pub fn new() -> Self { Self::on(Axis::Y) } pub fn on(axis: Axis) -> Self { Self { arbiter: DragArbiter::on(axis), velocity: VelocityTracker::new(), catch_unmoved: false, } } pub fn is_idle(&self) -> bool { self.arbiter.is_idle() } /// Whether this non-terminal frame opens a press. Multiple sensors may /// deliver one `PressStart`, so an in-flight press must not restart. pub fn starts_press(&self, sense: CursorSense) -> bool { match sense { CursorSense::Drop | CursorSense::PressEnd(_) | CursorSense::Cancel => false, _ => self.arbiter.is_idle(), } } /// Feed one frame of a gesture through. `id` is the widget iris should /// give exclusive pointer input to once this gesture commits to /// panning or selecting -- a stable widget that outlives the gesture /// (a `LazySpan`'s own id, not one of its virtualised rows, which can be /// retired mid-drag as content scrolls). `pointer` is `CursorData`'s /// own field, already in hand at every call site. `press` only matters /// on the frames [`Self::starts_press`] answers true for -- see /// `DragArbiter::press_start`'s doc. pub fn handle( &mut self, pointer: &PointerRequests, id: WidgetId, sense: CursorSense, pos_window: Vec2, now: Instant, press: PressState, ) -> GestureOutcome { match sense { CursorSense::Cancel if pointer.holder() == Some(id) => GestureOutcome::Undecided, CursorSense::Cancel => { if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::input", "iris gesture: cancelled (pointer captured elsewhere)", ); } self.arbiter.release(); self.catch_unmoved = false; self.velocity.reset(); GestureOutcome::Cancelled } CursorSense::Drop | CursorSense::PressEnd(_) => { let released = self.velocity.velocity(); debug_assert!( !self.catch_unmoved || self.arbiter.is_panning(), "a caught press that never moved must still be panning at release", ); let outcome = if self.catch_unmoved { GestureOutcome::Released(None) } else if self.arbiter.is_panning() { GestureOutcome::Released(Some(released)) } else if self.arbiter.is_undecided() { GestureOutcome::Tapped } else { GestureOutcome::Released(None) }; log::info!( "iris drag release: samples={} span={:.1}ms v={:.0} outcome={:?}", self.velocity.sample_count(), self.velocity.span().as_secs_f32() * 1000.0, released, outcome, ); if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::input", "iris drag release samples: {}", self.velocity.samples_display() ); } self.arbiter.release(); self.catch_unmoved = false; // Only if this gesture is the one holding it. A widget // that never captured (it stayed `Undecided`, so this // release is a tap) would otherwise drop somebody else's // capture mid-drag, which is the same lost-gesture bug // `CursorSense::Cancel` exists to prevent, in reverse. if pointer.holder() == Some(id) { pointer.release(); } outcome } // A `Pressing` frame can arrive with no matching `PressStart` // if the touch-down landed outside whichever hit region first // noticed it -- `DragArbiter::update`'s own doc. Both that // recovery and an ordinary `PressStart` open a press the same // way, so they are one branch: `starts_press` is the rule, and // it is the same one the caller reads. _ if self.starts_press(sense) => { self.velocity.reset(); self.velocity .add_position(pos_window.axis(self.arbiter.axis()), now); self.arbiter.press_start(pos_window, now, press); self.catch_unmoved = press.scrolling; if crate::diagnostics::trace_enabled() { let how = if matches!(sense, CursorSense::PressStart(_)) { "" } else { " (recovered, no PressStart seen)" }; log::debug!( target: "iris::input", "iris gesture: press start{how} pos=({:.1},{:.1}) scrolling={}", pos_window.x, pos_window.y, press.scrolling, ); } self.dispatch(pointer, id, pos_window, now) } _ => self.dispatch(pointer, id, pos_window, now), } } fn dispatch( &mut self, pointer: &PointerRequests, id: WidgetId, pos: Vec2, now: Instant, ) -> GestureOutcome { match self.arbiter.update(pos, now) { DragOutcome::Undecided => GestureOutcome::Undecided, DragOutcome::Pan(dy) => { pointer.capture(id); if dy != 0.0 { self.catch_unmoved = false; } self.velocity .add_position(pos.axis(self.arbiter.axis()), now); GestureOutcome::Pan(dy) } DragOutcome::SelectStart => { pointer.capture(id); GestureOutcome::SelectStart } DragOutcome::SelectExtend => { pointer.capture(id); GestureOutcome::SelectExtend } } } } const HISTORY_SIZE: usize = 20; const HORIZON_MS: f32 = 100.0; const ASSUME_POINTER_MOVE_STOPPED_MS: f32 = 40.0; const MIN_SAMPLE_SIZE: usize = 3; const DEGENERATE_NORM: f32 = 0.000001; const FIT_DEGREE: usize = 2; const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1; /// `ViewConfiguration.getScaledMaximumFlingVelocity()`, in dp per second /// -- AOSP's `MAXIMUM_FLING_VELOCITY`. Compose applies it at the release /// (`DragGestureNode.sendDragStopped` passes /// `LocalViewConfiguration.maximumFlingVelocity` into /// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it /// in [`crate::widget::ScrollController::fling`] instead, because that is the only /// place that knows the density this has to be multiplied by. There is /// deliberately **no** matching minimum: see `ScrollController::fling`. pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0; /// The numbers its tests assert on come from /// `iris/scripts/reference/velocity_reference.py`, an independent transcription of /// the same Kotlin -- not from this code, for the reason /// `android_fling_spline`'s doc gives at length. #[derive(Default)] pub struct VelocityTracker { /// `(when, position along the axis)`, oldest first, at most /// `HISTORY_SIZE` of them. The horizon is applied in `velocity` /// rather than here, because that is where Compose applies it and /// because a sample outside the horizon still tells `span` and the /// release log what was delivered. samples: VecDeque<(Instant, f32)>, } impl VelocityTracker { pub fn new() -> Self { Self::default() } /// Forget everything -- called on a fresh press, so a new gesture's /// velocity is never contaminated by the tail of the previous one. /// Compose's `resetTracking`, called from the same place (its /// `addPointerInputChange` resets on `changedToDown`). pub fn reset(&mut self) { self.samples.clear(); } pub fn add_position(&mut self, position: f32, at: Instant) { debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last)); self.samples.push_back((at, position)); while self.samples.len() > HISTORY_SIZE { self.samples.pop_front(); } } /// How many samples are currently held, and how long they span. /// Reported beside the velocity in `DragGesture`'s release log, /// because a `v=0` on its own cannot say whether the gesture was slow /// or whether the tracker was simply never fed -- which is exactly the /// distinction the phone's missing fling turned on. pub fn sample_count(&self) -> usize { self.samples.len() } pub fn span(&self) -> Duration { match (self.samples.front(), self.samples.back()) { (Some(&(first, _)), Some(&(last, _))) => last.duration_since(first), _ => Duration::ZERO, } } pub fn samples_display(&self) -> String { let Some(&(first, _)) = self.samples.front() else { return String::new(); }; self.samples .iter() .map(|&(at, position)| { format!( "{:.1}:{position:.1}", at.duration_since(first).as_secs_f32() * 1000.0 ) }) .collect::>() .join(" ") } /// The estimated speed at the newest sample, in units per second -- /// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then /// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum /// itself is applied by the caller that knows the density /// ([`crate::widget::ScrollController::fling`]). pub fn velocity(&self) -> f32 { let mut positions = [0.0f32; HISTORY_SIZE]; let mut ages = [0.0f32; HISTORY_SIZE]; let mut count = 0; let Some(&(newest_at, _)) = self.samples.back() else { return 0.0; }; let mut previous_at = newest_at; for &(at, position) in self.samples.iter().rev() { let age = newest_at.duration_since(at).as_secs_f32() * 1000.0; let gap = previous_at.duration_since(at).as_secs_f32() * 1000.0; previous_at = at; if age > HORIZON_MS || gap > ASSUME_POINTER_MOVE_STOPPED_MS { break; } positions[count] = position; ages[count] = -age; count += 1; if count == HISTORY_SIZE { break; } } if count < MIN_SAMPLE_SIZE { return 0.0; } // The 2nd coefficient is the fitted quadratic's derivative at // x = 0, and x = 0 is the newest sample's own timestamp. ms -> s. // `None` is Compose's "linearly dependent, no solution" -- see // `poly_fit_least_squares`. let Some(fit) = poly_fit_least_squares(&ages[..count], &positions[..count]) else { return 0.0; }; let velocity = fit[1] * 1000.0; // `calculateVelocity(maximumVelocity)`'s first branch, kept as the // outer guard even though the degenerate case is now detected // rather than clamped: a fit can still overflow on inputs nothing // here has produced, and `ScrollController::fling` asserts finiteness. if velocity.is_finite() { velocity } else { 0.0 } } } /// Fixed-size arrays rather than Compose's allocated `Matrix`, since both /// dimensions are constants here -- `FIT_COEFFICIENTS` rows by at most /// `HISTORY_SIZE` columns. Compose truncates the degree when it has fewer /// points than coefficients; [`MIN_SAMPLE_SIZE`] makes that unreachable /// from the only caller, so the truncation is an assert instead of a /// branch that could never be exercised. /// `None` where Compose returns no solution: see `DEGENERATE_NORM`. fn poly_fit_least_squares(x: &[f32], y: &[f32]) -> Option<[f32; FIT_COEFFICIENTS]> { debug_assert_eq!(x.len(), y.len()); debug_assert!( (FIT_COEFFICIENTS..=HISTORY_SIZE).contains(&x.len()), "a degree-{FIT_DEGREE} fit needs {FIT_COEFFICIENTS}..={HISTORY_SIZE} points, got {}", x.len() ); let m = x.len(); let mut a = [[0.0f32; HISTORY_SIZE]; FIT_COEFFICIENTS]; for h in 0..m { a[0][h] = 1.0; for i in 1..FIT_COEFFICIENTS { a[i][h] = a[i - 1][h] * x[h]; } } let mut q = [[0.0f32; HISTORY_SIZE]; FIT_COEFFICIENTS]; let mut r = [[0.0f32; FIT_COEFFICIENTS]; FIT_COEFFICIENTS]; for j in 0..FIT_COEFFICIENTS { q[j][..m].copy_from_slice(&a[j][..m]); for i in 0..j { let (earlier, from_j) = q.split_at_mut(j); let z = &earlier[i]; let w = &mut from_j[0]; let dot = dot(&w[..m], &z[..m]); for h in 0..m { w[h] -= dot * z[h]; } } let norm = dot(&q[j][..m], &q[j][..m]).sqrt(); if norm < DEGENERATE_NORM { return None; } let inverse_norm = 1.0 / norm; for v in &mut q[j][..m] { *v *= inverse_norm; } for i in 0..FIT_COEFFICIENTS { r[j][i] = if i < j { 0.0 } else { dot(&q[j][..m], &a[i][..m]) }; } } let mut coefficients = [0.0f32; FIT_COEFFICIENTS]; for i in (0..FIT_COEFFICIENTS).rev() { let mut c = dot(&q[i][..m], &y[..m]); for j in ((i + 1)..FIT_COEFFICIENTS).rev() { c -= r[i][j] * coefficients[j]; } coefficients[i] = c / r[i][i]; } Some(coefficients) } fn dot(a: &[f32], b: &[f32]) -> f32 { a.iter().zip(b).map(|(x, y)| x * y).sum() } /// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]` /// is the fraction of the total distance covered at time fraction /// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and /// `(index + 1) / N` -- never between table entries. AOSP builds a second /// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling /// whose target moved), which nothing here has; it is deliberately not /// built, so there is one array and one indexing rule rather than two of /// each to pick the wrong one from. mod android_fling_spline { use std::sync::OnceLock; const NB_SAMPLES: usize = 100; pub(super) const INFLEXION: f32 = 0.35; const START_TENSION: f32 = 0.5; const END_TENSION: f32 = 1.0; const P1: f32 = START_TENSION * INFLEXION; const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION); /// What a lookup answers: how far along the fling is, and how fast it /// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's /// `AndroidFlingSpline.FlingResult`. Both are fractions of the fling's /// *total* distance, the second per unit of its *total* duration, so a /// caller scales them by `distance` and `distance / duration`. pub(super) struct SplineSample { pub(super) distance_fraction: f32, pub(super) velocity_fraction: f32, } fn build() -> [f32; NB_SAMPLES + 1] { let mut position = [0.0f32; NB_SAMPLES + 1]; let mut x_min = 0.0f32; for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) { let alpha = i as f32 / NB_SAMPLES as f32; let mut x_max = 1.0f32; let (mut x, mut coef); loop { x = x_min + (x_max - x_min) / 2.0; coef = 3.0 * x * (1.0 - x); let tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x; if (tx - alpha).abs() < 1e-5 { break; } if tx > alpha { x_max = x; } else { x_min = x; } } *slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x; } position[NB_SAMPLES] = 1.0; position } static SPLINE_POSITION: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new(); /// Sample the curve at `time_fraction` (0..=1 of the fling's total /// duration), exactly as AOSP's `SplineOverScroller.update` and /// Compose's `AndroidFlingSpline.flingPosition` do. pub(super) fn sample(time_fraction: f32) -> SplineSample { let position = SPLINE_POSITION.get_or_init(build); let t = time_fraction.clamp(0.0, 1.0); let index = (t * NB_SAMPLES as f32) as usize; if index >= NB_SAMPLES { return SplineSample { distance_fraction: 1.0, velocity_fraction: 0.0, }; } let t_inf = index as f32 / NB_SAMPLES as f32; let t_sup = (index + 1) as f32 / NB_SAMPLES as f32; let velocity_fraction = (position[index + 1] - position[index]) / (t_sup - t_inf); SplineSample { distance_fraction: position[index] + (t - t_inf) * velocity_fraction, velocity_fraction, } } } const FLING_FRICTION: f32 = 0.015; const FLING_TUNING: f32 = 0.84; fn deceleration_rate() -> f32 { (0.78f32.ln()) / (0.9f32.ln()) } const GRAVITY_EARTH: f32 = 9.80665; pub struct FlingCalculator { physical_coefficient: f32, } impl FlingCalculator { pub fn new(density: f32) -> Self { Self { physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_TUNING, } } fn deceleration_for(&self, velocity: f32) -> f32 { (android_fling_spline::INFLEXION * velocity.abs() / (FLING_FRICTION * self.physical_coefficient)) .ln() } pub fn distance(&self, velocity: f32) -> f32 { debug_assert!(velocity.is_finite()); if velocity == 0.0 { return 0.0; } let l = self.deceleration_for(velocity); let rate = deceleration_rate(); let magnitude = FLING_FRICTION * self.physical_coefficient * (rate / (rate - 1.0) * l).exp(); magnitude.copysign(velocity) } pub fn duration(&self, velocity: f32) -> Duration { debug_assert!(velocity.is_finite()); if velocity == 0.0 { return Duration::ZERO; } let l = self.deceleration_for(velocity); let rate = deceleration_rate(); Duration::from_secs_f32((l / (rate - 1.0)).exp()) } /// The signed distance covered by `elapsed` into a fling of this /// `velocity` -- what a scrolling widget uses to find how far it should /// have moved by this frame. Clamped to the full /// `distance()` once `elapsed` reaches `duration()`, so a caller need /// not special-case "past the end." pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 { let duration = self.duration(velocity); if duration.is_zero() { return 0.0; } let fraction = elapsed.as_secs_f32() / duration.as_secs_f32(); self.distance(velocity) * android_fling_spline::sample(fraction).distance_fraction } pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 { let duration = self.duration(velocity); if duration.is_zero() { return 0.0; } let fraction = elapsed.as_secs_f32() / duration.as_secs_f32(); android_fling_spline::sample(fraction).velocity_fraction * self.distance(velocity) / duration.as_secs_f32() } } /// It owns the curve and the clock and nothing else. Which way a positive /// delta moves the content, and whether the content has anywhere left to /// go, are the caller's -- a `LazySpan` scrolls its anchor one way and a /// `ScrollArea` moves its `amt` the other, and a `Flinger` that tried to know /// which would have to be told, which is the same thing as not knowing. /// So a caller applies [`Self::advance`]'s delta in its own convention and /// calls [`Self::stop`] when it runs out of content. pub struct Flinger { fling: Option, } struct InFlight { calc: FlingCalculator, velocity: f32, /// When the curve begins -- **the first [`Flinger::advance`], not the /// release**. Set there so the only clock this reads is the one its /// driver hands it: a caller running frames on an explicit clock /// (`iris::harness`, `bench_client.rs`'s scripted phases) would /// otherwise start every fling at the wall clock and advance it on a /// different one, and a fling released at t=500ms would arrive /// already over. In a running app the difference is at most one /// frame, since that is how soon a fling is first ticked. started_at: Option, applied: f32, } impl Default for Flinger { fn default() -> Self { Self::new() } } impl Flinger { pub fn new() -> Self { Self { fling: None } } /// Start a fling at `velocity_px_per_s`, in whatever pixel space the /// caller applies [`Self::advance`]'s delta in. `density` is physical /// pixels per dp, from the painter -- it does **not** cancel out of /// the spline (see [`FlingCalculator`]), and a hardcoded 1.0 against a /// 2.55-density screen made a one-second coast run for 45. /// /// Cancels any fling already in progress. A widget which owns one advances /// it during `draw` and asks its painter for the following frame. /// /// Compose's two thresholds at a release, and **only** those two. The /// maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()` /// (8000dp/s), which `DragGestureNode.sendDragStopped` passes into /// `VelocityTracker.calculateVelocity(maximumVelocity)`; it is applied /// here rather than in the tracker because the tracker works in pixels /// and has no density. The minimum is 1px/s, from /// `DefaultFlingBehavior.performFling`'s `abs(initialVelocity) > 1f` /// and its own stated reason ("we need it since spline curve gives us /// NaNs") -- **not** /// `ViewConfiguration.getScaledMinimumFlingVelocity()`'s 50dp/s, whose /// single use in either artifact is `NestedScrollInteropConnection`, /// for View interop. A 50dp/s floor would swallow slow, deliberate /// releases that Compose flings. pub fn start(&mut self, velocity_px_per_s: f32, density: f32) -> bool { assert!(velocity_px_per_s.is_finite()); assert!(density.is_finite() && density > 0.0); let max = MAX_FLING_VELOCITY_DP_S * density; let velocity_px_per_s = velocity_px_per_s.clamp(-max, max); if velocity_px_per_s.abs() <= 1.0 { self.fling = None; return false; } self.fling = Some(InFlight { calc: FlingCalculator::new(density), velocity: velocity_px_per_s, started_at: None, applied: 0.0, }); true } /// Whether a fling is in flight. What a caller polls to decide whether /// a fresh press is a *catch* ([`PressState::scrolling`]) and when to /// stop driving [`Self::tick`]. pub fn is_flinging(&self) -> bool { self.fling.is_some() } /// The velocity a fling in progress is coasting at, `None` at rest -- /// what a test reads to see what a release actually measured, at the /// place it landed. pub fn velocity(&self) -> Option { self.fling.as_ref().map(|f| f.velocity) } /// End any fling with no further movement -- the next touch-down's /// job (Android's `Scroller::abortAnimation`, which the view is /// likewise expected to call: the curve has no idea a finger came back /// down), and equally what a caller calls when the content has run out /// underneath it. pub fn stop(&mut self) { self.fling = None; } /// Advance to `now` and answer how far to move the content *this* /// frame, in the caller's own sign convention. `0.0` with nothing /// flinging, so a caller does not need to check first; the fling ends /// itself on the spline's own schedule, after which /// [`Self::is_flinging`] is false and the caller stops asking for /// frames. pub fn advance(&mut self, now: Instant) -> f32 { let Some(f) = &mut self.fling else { return 0.0; }; let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now)); let target = f.calc.position_at(f.velocity, elapsed); let delta = target - f.applied; f.applied = target; if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::frame", "iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px", elapsed.as_secs_f32(), delta, f.calc.velocity_at(f.velocity, elapsed), f.velocity, f.calc.distance(f.velocity) - target, ); } if elapsed >= f.calc.duration(f.velocity) { self.fling = None; } delta } } #[cfg(test)] mod velocity_tracker_tests { use super::*; use std::sync::LazyLock; static BASE: LazyLock = LazyLock::new(Instant::now); fn t(ms: u64) -> Instant { *BASE + Duration::from_millis(ms) } fn tracker(samples: &[(u64, f32)]) -> VelocityTracker { let mut v = VelocityTracker::new(); for &(ms, position) in samples { v.add_position(position, t(ms)); } v } fn assert_velocity(samples: &[(u64, f32)], expected: f32) { let got = tracker(samples).velocity(); let tolerance = expected.abs() * 1e-3 + 1e-3; assert!( (got - expected).abs() <= tolerance, "expected {expected} from velocity_reference.py, got {got}" ); } const FLICK_120HZ: [(u64, f32); 5] = [ (0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0), ]; #[test] fn the_recorded_flick_reads_what_compose_reads() { assert_velocity(&FLICK_120HZ, 15250.0); } #[test] fn a_steady_drag_reports_its_own_speed() { let samples: Vec<(u64, f32)> = (0..=10).map(|i| (i * 10, (i * 5) as f32)).collect(); assert_velocity(&samples, 500.0); } #[test] fn an_accelerating_flick_reads_its_speed_at_the_release() { const ACCELERATING: [(u64, f32); 6] = [ (0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0), ]; assert_velocity(&ACCELERATING, 2445.0); let average: f32 = 54.0 / 0.050; assert!( (average - 1080.0).abs() < 1.0, "the average this is a control against moved: {average}" ); } #[test] fn fewer_than_three_samples_reports_zero() { assert_eq!(VelocityTracker::new().velocity(), 0.0); assert_velocity(&[(0, 0.0)], 0.0); assert_velocity(&[(0, 0.0), (8, 100.0)], 0.0); } #[test] fn only_the_last_100ms_of_samples_count() { let mut samples = vec![(0u64, 0.0f32)]; samples.extend((0..11).map(|i| (10 + i * 10, 1000.0 + i as f32))); assert_velocity(&samples, 100.0); } #[test] fn a_finger_that_stops_before_lifting_does_not_fling() { assert_velocity( &[(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)], 0.0, ); } #[test] fn reset_forgets_prior_samples() { let mut v = tracker(&FLICK_120HZ); assert!(v.velocity() != 0.0); v.reset(); assert_eq!(v.velocity(), 0.0); assert_eq!(v.sample_count(), 0); assert_eq!(v.samples_display(), ""); } #[test] fn only_the_last_twenty_samples_are_held() { let samples: Vec<(u64, f32)> = (0..40).map(|i| (i * 4, (i * 10) as f32)).collect(); let v = tracker(&samples); assert_eq!(v.sample_count(), HISTORY_SIZE); assert_eq!( v.span(), Duration::from_millis(4 * (HISTORY_SIZE as u64 - 1)) ); } #[test] fn a_fit_through_linearly_dependent_points_has_no_solution() { assert_eq!( poly_fit_least_squares(&[0.0, 0.0, 0.0], &[0.0, 40.0, 90.0]), None, ); let fit = poly_fit_least_squares(&[-8.0, -4.0, 0.0], &[1000.0, 1040.0, 1086.0]) .expect("three distinct points describe a quadratic"); assert!(fit.iter().all(|c| c.is_finite())); } #[test] fn the_sample_list_is_reported_relative_to_the_first() { assert_eq!( tracker(&FLICK_120HZ[..3]).samples_display(), "0.0:1000.0 4.0:1040.0 8.0:1086.0" ); } } #[cfg(test)] mod fling_calculator_tests { use super::*; #[test] fn zero_velocity_flings_nowhere() { let calc = FlingCalculator::new(1.0); assert_eq!(calc.distance(0.0), 0.0); assert_eq!(calc.duration(0.0), Duration::ZERO); } #[test] fn distance_grows_with_velocity_and_keeps_its_sign() { let calc = FlingCalculator::new(2.75); // a typical phone's density let d_slow = calc.distance(2000.0); let d_fast = calc.distance(12000.0); assert!(d_slow > 0.0); assert!(d_fast > d_slow); assert_eq!(calc.distance(-12000.0), -d_fast); } #[test] fn integrating_position_at_matches_the_closed_form_distance() { let calc = FlingCalculator::new(1.0); for velocity in [1500.0f32, 5000.0, 12000.0, -12000.0] { let total = calc.distance(velocity); let duration = calc.duration(velocity); let final_position = calc.position_at(velocity, duration); let err = (final_position - total).abs() / total.abs(); assert!( err < 0.01, "velocity {velocity}: position_at(duration)={final_position} vs distance()={total}, err={err}" ); } } #[test] fn a_flick_lasts_what_aosps_own_formula_says_it_does() { let calc = FlingCalculator::new(2.75); let slow = calc.duration(3000.0).as_secs_f32(); assert!( (slow - 0.592).abs() < 0.02, "3000px/s at density 2.75 should settle in ~0.59s, got {slow}s" ); let distance = calc.distance(3000.0); assert!( (distance - 621.5).abs() < 5.0, "3000px/s at density 2.75 should travel ~621px, got {distance}" ); let fast = calc.duration(11444.0).as_secs_f32(); assert!( (fast - 1.586).abs() < 0.05, "11444px/s at density 2.75 should settle in ~1.59s, got {fast}s" ); } #[test] fn position_at_is_monotonic_and_clamped_past_the_end() { let calc = FlingCalculator::new(1.0); let velocity = 12000.0f32; let duration = calc.duration(velocity); let total = calc.distance(velocity); let mut last = 0.0; let mut t = Duration::ZERO; while t < duration { let p = calc.position_at(velocity, t); assert!(p >= last - 0.01, "position went backwards at {t:?}"); last = p; t += Duration::from_millis(16); } assert_eq!( calc.position_at(velocity, duration + Duration::from_secs(5)), total ); } #[test] fn the_spline_matches_aosps_own_table() { for (t, expected) in [ (0.0f32, 0.000023f32), (0.1, 0.274002), (0.25, 0.583811), (0.5, 0.858411), (0.75, 0.971068), (0.9, 0.995811), (1.0, 1.0), ] { let got = android_fling_spline::sample(t).distance_fraction; assert!( (got - expected).abs() < 1e-4, "distance fraction at t={t}: got {got}, AOSP says {expected}" ); } let mut last = f32::INFINITY; for step in 0..=100 { let v = android_fling_spline::sample(step as f32 / 100.0).velocity_fraction; assert!(v <= last + 1e-4, "speed rose at t={step}/100: {v} > {last}"); last = v; } assert_eq!(android_fling_spline::sample(1.0).velocity_fraction, 0.0); } #[test] fn a_flick_decelerates_the_way_aosp_says_it_does() { let calc = FlingCalculator::new(2.55); let velocity = 11064.0f32; let duration = calc.duration(velocity); assert!( (duration.as_secs_f32() - 1.6357).abs() < 0.01, "duration {duration:?}" ); assert!( (calc.distance(velocity) - 6334.2).abs() < 5.0, "distance {}", calc.distance(velocity) ); for (fraction, position, speed) in [ (0.125f32, 2123.3f32, 9202.1f32), (0.335, 4458.3, 4733.0), (0.505, 5459.0, 2649.6), (0.755, 6158.7, 950.9), ] { let at = duration.mul_f32(fraction); let got_position = calc.position_at(velocity, at); let got_speed = calc.velocity_at(velocity, at); assert!( (got_position - position).abs() < 5.0, "position at {fraction} of the fling: got {got_position}, AOSP says {position}" ); assert!( (got_speed - speed).abs() < 20.0, "speed at {fraction} of the fling: got {got_speed}, AOSP says {speed}" ); } assert_eq!(calc.velocity_at(velocity, duration), 0.0); } } #[cfg(test)] mod drag_arbiter_tests { use super::*; fn t(ms: u64) -> Instant { Instant::now() - Duration::from_secs(3600) + Duration::from_millis(ms) } #[test] fn small_jitter_stays_undecided() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!(a.update(Vec2::new(1.0, 1.0), t(10)), DragOutcome::Undecided); } #[test] fn a_vertical_drag_pans_immediately() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( a.update(Vec2::new(0.0, 20.0), t(10)), DragOutcome::Pan(12.0) ); assert_eq!( a.update(Vec2::new(0.0, 35.0), t(20)), DragOutcome::Pan(15.0) ); } #[test] fn crossing_the_slop_by_a_little_pans_by_a_little() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)), DragOutcome::Pan(0.5) ); } #[test] fn a_horizontal_drag_with_nothing_selected_does_not_select() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( a.update(Vec2::new(20.0, 0.0), t(10)), DragOutcome::Undecided ); } #[test] fn a_long_press_without_moving_starts_a_selection() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(5.0, 5.0), t(0), PressState::default()); assert_eq!(a.update(Vec2::new(5.0, 5.0), t(10)), DragOutcome::Undecided); assert_eq!( a.update(Vec2::new(6.0, 5.0), t(LONG_PRESS.as_millis() as u64 + 1)), DragOutcome::SelectStart ); } #[test] fn after_a_long_press_any_further_drag_extends() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( a.update(Vec2::new(0.0, 0.0), t(LONG_PRESS.as_millis() as u64 + 1)), DragOutcome::SelectStart ); assert_eq!( a.update(Vec2::new(0.0, 40.0), t(600)), DragOutcome::SelectExtend ); } #[test] fn a_horizontal_drag_on_already_selected_text_extends_immediately() { let mut a = DragArbiter::new(); a.press_start( Vec2::new(0.0, 0.0), t(0), PressState { already_selected: true, ..Default::default() }, ); assert_eq!( a.update(Vec2::new(20.0, 2.0), t(10)), DragOutcome::SelectExtend ); } #[test] fn a_vertical_drag_still_pans_even_with_a_prior_selection() { let mut a = DragArbiter::new(); a.press_start( Vec2::new(0.0, 0.0), t(0), PressState { already_selected: true, ..Default::default() }, ); assert_eq!( a.update(Vec2::new(0.0, 20.0), t(10)), DragOutcome::Pan(12.0) ); } #[test] fn is_idle_reports_a_press_that_was_never_started() { let a = DragArbiter::new(); assert!(a.is_idle()); } #[test] fn update_on_an_idle_arbiter_stays_undecided_forever_without_recovery() { let mut a = DragArbiter::new(); assert_eq!( a.update(Vec2::new(0.0, 100.0), t(10)), DragOutcome::Undecided ); assert!(a.is_idle()); } #[test] fn a_caller_can_recover_a_missed_press_start_via_is_idle() { let mut a = DragArbiter::new(); assert!(a.is_idle()); a.press_start(Vec2::new(0.0, 700.0), t(0), PressState::default()); assert_eq!( a.update(Vec2::new(0.0, 720.0), t(10)), DragOutcome::Pan(12.0) ); assert!(!a.is_idle()); } #[test] fn a_press_that_never_moved_is_still_undecided_at_release() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); a.update(Vec2::new(1.0, 1.0), t(10)); assert!(a.is_undecided()); assert!(!a.is_panning()); } #[test] fn a_press_that_panned_is_not_undecided_at_release() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); a.update(Vec2::new(0.0, 40.0), t(10)); assert!(a.is_panning()); assert!(!a.is_undecided()); } #[test] fn a_long_press_that_selected_is_not_undecided() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( a.update(Vec2::new(0.0, 1.0), t(LONG_PRESS.as_millis() as u64 + 10)), DragOutcome::SelectStart ); assert!(!a.is_undecided()); } #[test] fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() { let mut across = DragArbiter::on(Axis::X); across.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( across.update(Vec2::new(20.0, 0.0), t(10)), DragOutcome::Pan(12.0) ); let mut down = DragArbiter::new(); down.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( down.update(Vec2::new(20.0, 0.0), t(10)), DragOutcome::Undecided ); let mut across = DragArbiter::on(Axis::X); across.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( across.update(Vec2::new(0.0, 20.0), t(10)), DragOutcome::Undecided ); } #[test] fn release_resets_to_idle() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); a.update(Vec2::new(0.0, 20.0), t(10)); a.release(); assert_eq!( a.update(Vec2::new(0.0, 999.0), t(20)), DragOutcome::Undecided ); } } #[cfg(test)] mod drag_gesture_tests { use super::*; use std::sync::LazyLock; static BASE: LazyLock = LazyLock::new(Instant::now); fn t(ms: u64) -> Instant { *BASE + Duration::from_millis(ms) } fn pointer() -> PointerRequests { PointerRequests::default() } fn some_id(ui: &mut UiData) -> WidgetId { ui.widgets.add_strong(Rect::new(PaintId::WHITE)).id() } #[test] fn a_flick_delivered_as_two_move_frames_releases_with_a_velocity() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), PressState::default(), ); g.handle( &r, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, 100.0), t(8), PressState::default(), ); g.handle( &r, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, 220.0), t(16), PressState::default(), ); let out = g.handle( &r, id, CursorSense::PressEnd(CursorButton::Left), Vec2::new(0.0, 220.0), t(24), PressState::default(), ); match out { GestureOutcome::Released(Some(v)) => { assert!((v - 16250.0).abs() < 20.0, "expected ~16250, got {v}"); } other => panic!("expected a released pan, got {other:?}"), } } #[test] fn a_flick_delivered_as_one_move_frame_carries_no_velocity_to_fit() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), PressState::default(), ); g.handle( &r, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, 100.0), t(8), PressState::default(), ); let out = g.handle( &r, id, CursorSense::PressEnd(CursorButton::Left), Vec2::new(0.0, 100.0), t(16), PressState::default(), ); assert_eq!(out, GestureOutcome::Released(Some(0.0))); } #[test] fn a_tap_is_still_a_tap_and_flings_nothing() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), PressState::default(), ); let out = g.handle( &r, id, CursorSense::PressEnd(CursorButton::Left), Vec2::ZERO, t(20), PressState::default(), ); assert_eq!(out, GestureOutcome::Tapped); } #[test] fn a_selection_release_carries_no_velocity() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), PressState::default(), ); g.handle( &r, id, CursorSense::Pressing(CursorButton::Left), Vec2::ZERO, t(0) + LONG_PRESS, PressState::default(), ); let out = g.handle( &r, id, CursorSense::PressEnd(CursorButton::Left), Vec2::new(0.0, 50.0), t(0) + LONG_PRESS + Duration::from_millis(10), PressState::default(), ); assert_eq!(out, GestureOutcome::Released(None)); } #[test] fn a_press_on_moving_content_pans_from_the_first_sample() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); let caught = PressState { scrolling: true, ..Default::default() }; assert_eq!( g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), caught, ), GestureOutcome::Pan(0.0), ); assert_eq!( g.handle( &r, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, 2.0), t(8), caught, ), GestureOutcome::Pan(2.0), ); } #[test] fn the_same_press_on_settled_content_stays_undecided() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), PressState::default(), ); assert_eq!( g.handle( &r, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, 2.0), t(8), PressState::default(), ), GestureOutcome::Undecided, ); } #[test] fn a_catch_released_without_moving_is_neither_a_tap_nor_a_fling() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); let caught = PressState { scrolling: true, ..Default::default() }; g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), caught, ); let out = g.handle( &r, id, CursorSense::PressEnd(CursorButton::Left), Vec2::ZERO, t(20), caught, ); assert_eq!(out, GestureOutcome::Released(None)); } #[test] fn a_catch_that_then_drags_still_flings() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); let caught = PressState { scrolling: true, ..Default::default() }; g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), caught, ); for (i, y) in [100.0, 220.0].into_iter().enumerate() { g.handle( &r, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, y), t(8 * (i as u64 + 1)), caught, ); } let out = g.handle( &r, id, CursorSense::PressEnd(CursorButton::Left), Vec2::new(0.0, 220.0), t(24), caught, ); assert!( matches!(out, GestureOutcome::Released(Some(v)) if v.abs() > 1.0), "a catch that dragged must release with a velocity, got {out:?}" ); } #[test] fn a_second_delivery_of_one_press_start_does_not_restart_it() { let mut ui = UiData::default(); let id = some_id(&mut ui); let r = pointer(); let mut g = DragGesture::new(); let caught = PressState { scrolling: true, ..Default::default() }; assert!(g.starts_press(CursorSense::PressStart(CursorButton::Left))); g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), caught, ); assert!( !g.starts_press(CursorSense::PressStart(CursorButton::Left)), "the second sensor must be told this press is already in flight" ); g.handle( &r, id, CursorSense::PressStart(CursorButton::Left), Vec2::ZERO, t(0), PressState::default(), ); assert_eq!( g.handle( &r, id, CursorSense::Pressing(CursorButton::Left), Vec2::new(0.0, 2.0), t(8), PressState::default(), ), GestureOutcome::Pan(2.0), "the catch survived only if the second delivery left it panning" ); } }