2314 lines
75 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|