Files
iris/src/rsc/sense.rs
T

2314 lines
75 KiB
Rust

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<CursorSense>,
drag_axis: Option<Axis>,
}
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<Self::Data<'a>> {
// 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<Item = (CursorButton, &ActivationState)> {
[
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<Ctx: HasEvents, Data> {
pub senses: CursorSenses,
pub f: Rc<dyn EventFn<Ctx, Data>>,
}
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<Axis>,
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<WidgetId>,
pressed: Vec<WidgetId>,
press_origin: Option<Vec2>,
drag_axis: Option<Axis>,
}
impl PointerInput {
/// Which widget holds exclusive pointer input between dispatches.
pub fn holder(&self) -> Option<WidgetId> {
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<WidgetId>) {
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: HasEvents>(rsc: &mut Rsc) -> &mut PointerInput {
&mut rsc.events_mut().get_type::<CursorSense>().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<Option<WidgetId>>,
}
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<WidgetId> {
self.holder.get()
}
}
pub trait SensorUi {
fn run_sensors<Rsc: HasEvents>(
&self,
rsc: &mut Rsc,
state: &mut Rsc::State,
cursor: CursorState,
window_size: Vec2,
);
}
impl SensorUi for UiRenderState {
fn run_sensors<Rsc: HasEvents>(
&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::<CursorSense>().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::<CursorSense>().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::<CursorSense>().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::<CursorSense>(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::<CursorSense>().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::<CursorSense>().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::<CursorSense>()
.registered(*id)
.any(|senses| senses.consumes(&data, momentary_active));
if consumes {
sensed = true;
}
rsc.run_event::<CursorSense>(*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::<CursorSense>().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::<CursorSense>().global = pointer;
}
}
fn deliver_cancel<Rsc: HasEvents>(
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::<CursorSense>(id, data, state);
}
pub fn should_run(
senses: &CursorSenses,
cursor: &CursorState,
hover: ActivationState,
drag_axis: Option<Axis>,
captured: bool,
) -> Option<CursorSense> {
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<CursorSense>;
fn deref(&self) -> &Self::Target {
&self.senses
}
}
impl DerefMut for CursorSenses {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.senses
}
}
impl From<CursorSense> 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<CursorSense> 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<f32>),
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::<Vec<_>>()
.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<InFlight>,
}
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<Instant>,
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<f32> {
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<Instant> = 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<Instant> = 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"
);
}
}