use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use super::*;
use crate::WidgetId;
use crate::event::{EventResponse, Modifiers, PointerButton, ScrollDelta, WidgetEvent};
use crate::gesture::{GestureEvent, PinchPhase};
use crate::pointer::clock::ManualClock;
use crate::pointer::touch_action::{PanAxes, TouchAction};
use crate::pointer::{
BackendDeviceKey, EventTime, PointerAxes, PointerId, PointerIdAllocator, PointerInfo,
PointerPhase, PointerSample,
};
use crate::test_widgets::{FillWidget, StackWidget};
use crate::widget_builder::WidgetBuilder;
fn contact_id(raw: u64) -> PointerId {
let alloc = PointerIdAllocator::global();
let device = BackendDeviceKey::new(0x9A11);
let id = alloc.begin(device, raw);
alloc.end(device, raw);
id
}
fn contact(id: PointerId, phase: PointerPhase, at: Point) -> PointerSample {
PointerSample {
pointer: PointerInfo::touch(id, EventTime::ZERO),
phase,
position: at,
button: None,
modifiers: Modifiers::NONE,
coalesced: Vec::new(),
}
}
#[derive(Default, Debug)]
struct Log {
absorbed: Vec<f32>,
declined: Vec<f32>,
phases: Vec<ScrollPhase>,
cancels: Vec<CancelReason>,
offset: f32,
}
type Shared = Rc<RefCell<Log>>;
fn scrollable(
log: Shared,
max: f32,
children: Vec<WidgetId>,
) -> impl crate::widget::Widget + 'static {
scrollable_with(log, max, children, OverscrollBehavior::Chain)
}
fn scrollable_with(
log: Shared,
max: f32,
children: Vec<WidgetId>,
overscroll: OverscrollBehavior,
) -> impl crate::widget::Widget + 'static {
scrollable_options(log, max, children, overscroll, PanAxes::Y, false)
}
fn scrollable_options(
log: Shared,
max: f32,
children: Vec<WidgetId>,
overscroll: OverscrollBehavior,
axes: PanAxes,
takes_press: bool,
) -> impl crate::widget::Widget + 'static {
scrollable_full(log, max, children, overscroll, axes, takes_press, None)
}
#[allow(clippy::too_many_arguments)]
fn scrollable_full(
log: Shared,
max: f32,
children: Vec<WidgetId>,
overscroll: OverscrollBehavior,
axes: PanAxes,
takes_press: bool,
drags: Option<Rc<std::cell::Cell<Option<WidgetId>>>>,
) -> impl crate::widget::Widget + 'static {
let scroll_log = log.clone();
let cancel_log = log;
let mut stack = StackWidget::new();
for child in children {
stack = stack.child(child);
}
let widget = stack
.scroll_container(axes)
.overscroll_behavior(overscroll)
.on_scroll(move |event, _ctx| {
let WidgetEvent::Scroll { delta, phase, .. } = event else {
return EventResponse::Ignored;
};
let dy = match delta {
ScrollDelta::Pixels { y, .. } => *y,
ScrollDelta::Lines { y, .. } => *y * 16.0,
};
let mut log = scroll_log.borrow_mut();
log.phases.push(*phase);
let before = log.offset;
log.offset = (before + dy).clamp(0.0, max);
let moved = (log.offset - before).abs() > crate::overscroll::SCROLL_MOVE_EPSILON;
if moved {
log.absorbed.push(dy);
EventResponse::Handled
} else {
log.declined.push(dy);
EventResponse::Ignored
}
})
.on_pointer_cancel(move |_info, reason, _ctx| {
cancel_log.borrow_mut().cancels.push(reason);
});
let widget = if takes_press {
widget.on_tap(|_event, _ctx| {})
} else {
widget
};
match drags {
None => widget,
Some(source) => {
let raised = std::cell::Cell::new(false);
widget.on_pointer_event(move |event, ctx| {
if matches!(event, WidgetEvent::PointerMove { .. })
&& !raised.get()
&& let Some(id) = source.get()
{
raised.set(true);
ctx.start_drag(id, crate::drag_payload::DragPayload::typed(RaisedDrag));
}
EventResponse::Ignored
})
}
}
}
#[derive(Debug)]
struct RaisedDrag;
struct Nested {
tree: WidgetTree,
inner: WidgetId,
outer: WidgetId,
inner_log: Shared,
outer_log: Shared,
}
fn nested(inner_max: f32, outer_max: f32) -> Nested {
nested_inner_takes_press(inner_max, outer_max, false)
}
fn nested_inner_takes_press(inner_max: f32, outer_max: f32, takes_press: bool) -> Nested {
let inner_log = Shared::default();
let outer_log = Shared::default();
let mut tree = WidgetTree::new();
let inner = tree.add(scrollable_options(
inner_log.clone(),
inner_max,
vec![],
OverscrollBehavior::Chain,
PanAxes::Y,
takes_press,
));
let outer = tree.add(scrollable(outer_log.clone(), outer_max, vec![inner]));
tree.layout(SizeProposal::exact(200.0, 200.0));
Nested {
tree,
inner,
outer,
inner_log,
outer_log,
}
}
fn nested_inner_drags(inner_max: f32, outer_max: f32) -> Nested {
let inner_log = Shared::default();
let outer_log = Shared::default();
let mut tree = WidgetTree::new();
let source = Rc::new(std::cell::Cell::new(None));
let inner = tree.add(scrollable_full(
inner_log.clone(),
inner_max,
vec![],
OverscrollBehavior::Chain,
PanAxes::Y,
true,
Some(source.clone()),
));
source.set(Some(inner));
let outer = tree.add(scrollable(outer_log.clone(), outer_max, vec![inner]));
tree.layout(SizeProposal::exact(200.0, 200.0));
Nested {
tree,
inner,
outer,
inner_log,
outer_log,
}
}
fn pan_slop() -> f32 {
crate::gesture::default_profile(teksilo_tokens::PointerKind::Touch)
.pan_slop
.expect("touch pans")
}
fn drag(tree: &mut WidgetTree, id: PointerId, from: Point, dy: f32) -> Point {
tree.dispatch_pointer(contact(id, PointerPhase::Down, from));
let arm = Point::new(from.x, from.y + pan_slop().copysign(dy) + dy.signum());
tree.dispatch_pointer(contact(id, PointerPhase::Move, arm));
let mut at = arm;
let remaining = dy - (arm.y - from.y);
if remaining.abs() > 0.0 {
at = Point::new(from.x, arm.y + remaining);
tree.dispatch_pointer(contact(id, PointerPhase::Move, at));
}
at
}
#[test]
fn the_inner_container_keeps_the_claim_while_it_can_absorb() {
let mut n = nested(1000.0, 1000.0);
let finger = contact_id(1);
drag(&mut n.tree, finger, Point::new(100.0, 100.0), -60.0);
assert!(
n.inner_log.borrow().offset > 0.0,
"the inner list scrolled: {:?}",
n.inner_log.borrow()
);
assert_eq!(
n.outer_log.borrow().phases.len(),
0,
"the outer container was never offered an event the inner absorbed"
);
}
#[test]
fn a_boundary_pan_hands_the_whole_event_outward_with_no_residual() {
let mut n = nested(0.0, 1000.0);
let finger = contact_id(2);
drag(&mut n.tree, finger, Point::new(100.0, 100.0), -60.0);
let inner = n.inner_log.borrow();
let outer = n.outer_log.borrow();
assert!(
!inner.declined.is_empty(),
"the inner list should have declined at its boundary"
);
assert!(
!outer.absorbed.is_empty(),
"the outer container should have taken what the inner declined"
);
assert_eq!(
inner.declined, outer.absorbed,
"the outer container receives the SAME whole deltas, never a residual"
);
}
#[test]
fn a_chained_past_claimant_gets_no_cancel_and_no_pan_ended() {
let mut n = nested(0.0, 1000.0);
let finger = contact_id(3);
let at = drag(&mut n.tree, finger, Point::new(100.0, 100.0), -60.0);
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Up, at));
let inner = n.inner_log.borrow();
assert!(
inner.cancels.is_empty(),
"the inner container must not be cancelled for declining an event: {:?}",
inner.cancels
);
assert!(
!inner.phases.contains(&ScrollPhase::Cancelled),
"…and it must not be told the gesture was cancelled either"
);
assert!(
inner.phases.contains(&ScrollPhase::Ended),
"the claimant is still first in line on the release"
);
}
#[test]
fn the_claim_does_not_move_outward_after_one_declined_event() {
let mut n = nested(4.0, 1000.0);
let finger = contact_id(4);
let from = Point::new(100.0, 100.0);
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Down, from));
let mut y = from.y;
for _ in 0..4 {
y -= pan_slop() + 10.0;
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, y)));
}
let inner = n.inner_log.borrow();
let outer = n.outer_log.borrow();
assert!(
inner.phases.len() > outer.phases.len(),
"the inner container saw every sample ({}) and the outer only the ones \
the inner could not use ({})",
inner.phases.len(),
outer.phases.len()
);
assert!(
!inner.absorbed.is_empty() && !inner.declined.is_empty(),
"the fixture must actually reach the inner boundary mid-gesture"
);
}
#[test]
fn contain_stops_the_chain() {
let inner_log = Shared::default();
let outer_log = Shared::default();
let mut tree = WidgetTree::new();
let inner = tree.add(scrollable_with(
inner_log.clone(),
0.0,
vec![],
OverscrollBehavior::Contain,
));
let _outer = tree.add(scrollable(outer_log.clone(), 1000.0, vec![inner]));
tree.layout(SizeProposal::exact(200.0, 200.0));
let finger = contact_id(5);
drag(&mut tree, finger, Point::new(100.0, 100.0), -60.0);
assert!(
!inner_log.borrow().declined.is_empty(),
"the contained container still declined the event"
);
assert_eq!(
outer_log.borrow().phases.len(),
0,
"…and `Contain` stopped the chain before the outer container saw it"
);
}
#[test]
fn a_boundary_pan_never_reaches_a_spin_box_shaped_on_scroll_handler() {
let inner_log = Shared::default();
let outer_log = Shared::default();
let spin_box_value = Rc::new(RefCell::new(0i32));
let value = spin_box_value.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(scrollable(inner_log.clone(), 0.0, vec![]));
let spin_box = tree.add(
StackWidget::new()
.child(inner)
.on_scroll(move |_event, _ctx| {
*value.borrow_mut() += 1;
EventResponse::Handled
}),
);
let _outer = tree.add(scrollable(outer_log.clone(), 1000.0, vec![spin_box]));
tree.layout(SizeProposal::exact(200.0, 200.0));
let finger = contact_id(6);
drag(&mut tree, finger, Point::new(100.0, 100.0), -60.0);
assert_eq!(
*spin_box_value.borrow(),
0,
"a boundary pan must never reach a non-claimant `on_scroll` handler"
);
assert!(
!outer_log.borrow().absorbed.is_empty(),
"…and it must reach the next real claimant outward"
);
}
#[test]
fn a_boundary_pan_never_reaches_a_tab_bar_shaped_wheel_remap() {
let inner_log = Shared::default();
let outer_log = Shared::default();
let selected_tab = Rc::new(RefCell::new(0i32));
let tab = selected_tab.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(scrollable(inner_log.clone(), 0.0, vec![]));
let tab_bar = tree.add(
StackWidget::new()
.child(inner)
.on_scroll(move |event, _ctx| {
if let WidgetEvent::Scroll { delta, .. } = event {
let dy = match delta {
ScrollDelta::Pixels { y, .. } => *y,
ScrollDelta::Lines { y, .. } => *y,
};
*tab.borrow_mut() += dy.signum() as i32;
}
EventResponse::Handled
}),
);
let _outer = tree.add(scrollable(outer_log.clone(), 1000.0, vec![tab_bar]));
tree.layout(SizeProposal::exact(200.0, 200.0));
let finger = contact_id(7);
drag(&mut tree, finger, Point::new(100.0, 100.0), -60.0);
assert_eq!(
*selected_tab.borrow(),
0,
"a boundary pan must never reach a TabBar's wheel remap"
);
}
#[test]
fn a_mouse_wheel_still_reaches_a_non_claimant_on_scroll_handler() {
let value = Rc::new(RefCell::new(0i32));
let counter = value.clone();
let mut tree = WidgetTree::new();
let spin_box = tree.add(FillWidget::new().on_scroll(move |_event, _ctx| {
*counter.borrow_mut() += 1;
EventResponse::Handled
}));
let _root = tree.add(StackWidget::new().child(spin_box));
tree.layout(SizeProposal::exact(200.0, 200.0));
tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 100.0)));
tree.dispatch_event(WidgetEvent::scroll(
ScrollDelta::Lines { x: 0.0, y: -1.0 },
Modifiers::NONE,
));
assert_eq!(*value.borrow(), 1, "the wheel bubbles, unchanged");
}
#[test]
fn a_pan_claimant_that_owns_the_press_arena_pans_itself() {
let mut n = nested_inner_takes_press(1000.0, 1000.0, true);
let finger = contact_id(20);
drag(&mut n.tree, finger, Point::new(100.0, 100.0), -60.0);
assert_eq!(
n.tree.sequence_winner(finger),
Some(n.inner),
"the press owner's own pan claim wins the arbitration"
);
assert!(
n.inner_log.borrow().offset > 0.0,
"…and the surface scrolls itself: {:?}",
n.inner_log.borrow()
);
assert_eq!(
n.outer_log.borrow().phases.len(),
0,
"the container behind it is never offered an event the surface absorbed"
);
}
#[test]
fn an_editing_surface_at_its_boundary_still_hands_the_pan_outward() {
let mut n = nested_inner_takes_press(0.0, 1000.0, true);
let finger = contact_id(21);
drag(&mut n.tree, finger, Point::new(100.0, 100.0), -60.0);
let inner = n.inner_log.borrow();
let outer = n.outer_log.borrow();
assert!(
!inner.declined.is_empty(),
"the surface declined at its boundary"
);
assert_eq!(
inner.declined, outer.absorbed,
"and the container behind it took the same whole deltas"
);
}
#[test]
fn a_drag_raised_by_the_press_owner_takes_the_press_out_of_the_arbitration() {
use crate::gesture::MemberState;
let mut n = nested_inner_drags(1000.0, 1000.0);
let finger = contact_id(22);
drag(&mut n.tree, finger, Point::new(100.0, 100.0), -60.0);
assert!(
n.tree
.active_drag
.as_ref()
.is_some_and(|d| d.payload.has_typed::<RaisedDrag>()),
"the surface's own drag is the one in flight"
);
assert_eq!(
n.tree.sequence_winner(finger),
None,
"and it left the arbitration undecided rather than winning it"
);
assert!(
n.tree
.sequence_members(finger)
.iter()
.all(|(_, _, state)| *state == MemberState::Possible),
"no member was decided against: {:?}",
n.tree.sequence_members(finger)
);
assert_eq!(
n.outer_log.borrow().cancels.len(),
0,
"so the container behind it was never cancelled"
);
assert_eq!(
n.inner_log.borrow().phases.len(),
0,
"and nothing scrolled — the contact is dragging, not panning"
);
assert_eq!(n.outer_log.borrow().phases.len(), 0, "on either node");
}
#[test]
fn mouse_wheel_boundary_chaining_is_unchanged() {
let mut n = nested(0.0, 1000.0);
n.tree
.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 100.0)));
n.tree.dispatch_event(WidgetEvent::scroll(
ScrollDelta::Pixels { x: 0.0, y: 30.0 },
Modifiers::NONE,
));
assert_eq!(
n.inner_log.borrow().declined,
vec![30.0],
"the inner container declined at its boundary"
);
assert_eq!(
n.outer_log.borrow().absorbed,
vec![30.0],
"…and the wheel bubbled to the outer container unchanged"
);
}
#[test]
fn a_release_flings_and_advance_time_moves_it() {
let mut n = nested(10_000.0, 10_000.0);
let clock = Rc::new(ManualClock::new(EventTime::ZERO));
n.tree.set_input_clock(clock.clone());
let finger = contact_id(8);
let from = Point::new(100.0, 180.0);
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Down, from));
let mut y = from.y;
for step in 1..=5 {
clock.set(EventTime::from_millis(step * 4));
y -= 20.0;
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, y)));
}
clock.set(EventTime::from_millis(24));
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, y)));
assert!(
n.tree.is_flinging(n.inner),
"a fast release hands off to a coast"
);
let after_release = n.inner_log.borrow().offset;
n.tree.advance_time(Duration::from_millis(100));
let coasting = n.inner_log.borrow().offset;
assert!(
coasting > after_release,
"one `advance_time` moves the fling: {after_release} -> {coasting}"
);
let mut advanced = Duration::ZERO;
while n.tree.is_flinging(n.inner) && advanced < Duration::from_secs(10) {
n.tree.advance_time(Duration::from_millis(50));
advanced += Duration::from_millis(50);
}
assert!(
!n.tree.is_flinging(n.inner),
"the coast finishes rather than running for ever"
);
}
#[test]
fn one_advance_time_moves_an_animation_and_a_fling_to_the_same_virtual_time() {
let mut n = nested(10_000.0, 10_000.0);
let signal = crate::signal::Signal::<f32>::new_animated(0.0);
n.tree.register_animated_signal(&signal, n.inner);
let finger = contact_id(21);
let from = Point::new(100.0, 180.0);
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Down, from));
let mut y = from.y;
for _ in 0..5 {
n.tree.advance_input_time(Duration::from_millis(4));
y -= 20.0;
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, y)));
}
n.tree.advance_input_time(Duration::from_millis(4));
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, y)));
assert!(
n.tree.is_flinging(n.inner),
"the flick handed off to a coast"
);
signal.animate_to(
100.0,
Duration::from_millis(200),
teksilo_tokens::Easing::Linear,
);
let before = n.inner_log.borrow().offset;
n.tree.advance_time(Duration::from_millis(100));
assert!(
(signal.get() - 50.0).abs() < 2.0,
"half of a 200 ms linear tween: {}",
signal.get()
);
assert!(
n.inner_log.borrow().offset > before,
"…and the coast moved in the same call: {before} -> {}",
n.inner_log.borrow().offset
);
}
#[test]
fn a_fling_chains_at_a_boundary_exactly_as_a_pan_does() {
let mut n = nested(30.0, 10_000.0);
let clock = Rc::new(ManualClock::new(EventTime::ZERO));
n.tree.set_input_clock(clock.clone());
let finger = contact_id(9);
let from = Point::new(100.0, 180.0);
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Down, from));
let mut y = from.y;
for step in 1..=5 {
clock.set(EventTime::from_millis(step * 4));
y -= 20.0;
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, y)));
}
clock.set(EventTime::from_millis(24));
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, y)));
assert!(n.tree.is_flinging(n.inner));
let outer_before = n.outer_log.borrow().offset;
for _ in 0..20 {
n.tree.advance_time(Duration::from_millis(16));
}
assert_eq!(
n.inner_log.borrow().offset,
30.0,
"the inner list is pinned at its end"
);
assert!(
n.outer_log.borrow().offset > outer_before,
"…and the coast chained outward"
);
assert!(
n.inner_log.borrow().cancels.is_empty(),
"chaining a fling cancels nobody either"
);
}
#[test]
fn a_pointer_down_on_a_flinging_target_stops_the_fling() {
let mut n = nested(10_000.0, 10_000.0);
let clock = Rc::new(ManualClock::new(EventTime::ZERO));
n.tree.set_input_clock(clock.clone());
let finger = contact_id(10);
let from = Point::new(100.0, 180.0);
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Down, from));
let mut y = from.y;
for step in 1..=5 {
clock.set(EventTime::from_millis(step * 4));
y -= 20.0;
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Move, Point::new(from.x, y)));
}
clock.set(EventTime::from_millis(24));
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Up, Point::new(from.x, y)));
assert!(n.tree.is_flinging(n.inner));
let catcher = contact_id(11);
n.tree.dispatch_pointer(contact(
catcher,
PointerPhase::Down,
Point::new(100.0, 100.0),
));
assert!(
!n.tree.is_flinging(n.inner),
"pressing a coasting list catches it"
);
}
#[test]
fn macos_momentum_does_not_stack_a_second_fling() {
let mut n = nested(10_000.0, 10_000.0);
n.tree
.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 100.0)));
for _ in 0..5 {
n.tree.dispatch_scroll(crate::pointer::ScrollSample {
delta: ScrollDelta::Pixels { x: 0.0, y: 20.0 },
position: Some(Point::new(100.0, 100.0)),
phase: ScrollPhase::Momentum,
source: crate::pointer::ScrollSource::Trackpad,
pointer: PointerInfo::mouse(EventTime::ZERO),
modifiers: Modifiers::NONE,
});
}
assert!(
n.inner_log.borrow().offset > 0.0,
"the OS momentum still scrolls the list"
);
assert!(
!n.tree.is_flinging(n.inner) && !n.tree.is_flinging(n.outer),
"…and no Teksilo coast is started on top of it"
);
}
#[test]
fn next_input_deadline_folds_a_live_simulation() {
let mut n = nested(10_000.0, 10_000.0);
assert_eq!(
n.tree.next_input_deadline(),
None,
"nothing pending, nothing to wake for"
);
let before = n.tree.next_timer_deadline();
n.tree.start_fling(
n.inner,
Vec2::new(0.0, 2000.0),
vec![(n.inner, PanClaim::vertical())],
);
assert!(n.tree.is_flinging(n.inner));
let deadline = n
.tree
.next_input_deadline()
.expect("a live simulation wants the loop back");
assert_eq!(
n.tree.next_timer_deadline(),
Some(deadline),
"…and the tree's one `WaitUntil` IS the input deadline — it was \
{before:?} before the fling started"
);
n.tree.stop_fling(n.inner);
assert_eq!(n.tree.next_input_deadline(), None);
}
#[test]
fn reduced_motion_starts_no_coast() {
let mut n = nested(10_000.0, 10_000.0);
n.tree.set_accessibility_preferences(false, true, 1.0);
n.tree.start_fling(
n.inner,
Vec2::new(0.0, 4000.0),
vec![(n.inner, PanClaim::vertical())],
);
assert!(!n.tree.is_flinging(n.inner));
}
#[derive(Clone, Debug, PartialEq)]
enum PinchNote {
Started,
Changed { scale: i32, rotation: i32 },
Ended,
}
fn pinch_recorder(notes: Rc<RefCell<Vec<PinchNote>>>) -> impl crate::widget::Widget + 'static {
FillWidget::new()
.touch_action(TouchAction::MANIPULATION)
.on_pinch(move |phase, _ctx| {
let note = match phase {
PinchPhase::Started { .. } => PinchNote::Started,
PinchPhase::Changed {
scale, rotation, ..
} => PinchNote::Changed {
scale: (scale * 1000.0).round() as i32,
rotation: (rotation * 1000.0).round() as i32,
},
PinchPhase::Ended { .. } => PinchNote::Ended,
_ => return,
};
notes.borrow_mut().push(note);
})
}
#[test]
fn the_two_pinch_ingresses_produce_the_same_stream() {
let start_span = 100.0f32;
let spans = [140.0f32, 200.0f32];
let steps: Vec<f32> = std::iter::once(start_span)
.chain(spans)
.collect::<Vec<_>>()
.windows(2)
.map(|w| w[1] / w[0])
.collect();
let touch_notes = Rc::new(RefCell::new(Vec::new()));
let mut tree = WidgetTree::new();
tree.add(pinch_recorder(touch_notes.clone()));
tree.layout(SizeProposal::exact(400.0, 400.0));
let a = contact_id(20);
let b = contact_id(21);
let centre = 200.0f32;
tree.dispatch_pointer(contact(
a,
PointerPhase::Down,
Point::new(centre - 50.0, 200.0),
));
tree.dispatch_pointer(contact(
b,
PointerPhase::Down,
Point::new(centre + 50.0, 200.0),
));
for span in spans {
tree.dispatch_pointer(contact(
b,
PointerPhase::Move,
Point::new(centre - 50.0 + span, 200.0),
));
}
tree.dispatch_pointer(contact(
a,
PointerPhase::Up,
Point::new(centre - 50.0, 200.0),
));
let os_notes = Rc::new(RefCell::new(Vec::new()));
let mut os_tree = WidgetTree::new();
os_tree.add(pinch_recorder(os_notes.clone()));
os_tree.layout(SizeProposal::exact(400.0, 400.0));
let mut ops = crate::window::NoopWindowOps;
os_tree.dispatch_os_gesture(
GestureEvent::PinchStarted {
center: Point::new(centre, 200.0),
},
Some(Point::new(centre, 200.0)),
&mut ops,
);
for (span, step) in spans.iter().zip(&steps) {
let center = Point::new(centre - 50.0 + span / 2.0, 200.0);
os_tree.dispatch_os_gesture(
GestureEvent::PinchChanged {
center,
scale: *step,
rotation: 0.0,
},
Some(center),
&mut ops,
);
}
os_tree.dispatch_os_gesture(GestureEvent::PinchEnded, None, &mut ops);
assert_eq!(
*touch_notes.borrow(),
vec![
PinchNote::Started,
PinchNote::Changed {
scale: 1400,
rotation: 0
},
PinchNote::Changed {
scale: 1429,
rotation: 0
},
PinchNote::Ended,
],
"the touchscreen stream is Started / Changed × 2 / Ended, carrying \
per-sample steps"
);
let folded: f32 = touch_notes
.borrow()
.iter()
.filter_map(|n| match n {
PinchNote::Changed { scale, .. } => Some(*scale as f32 / 1000.0),
_ => None,
})
.product();
assert!(
(folded - 2.0).abs() < 1e-2,
"and folding them in reaches the ×2 spread the fingers described, got {folded}"
);
assert_eq!(
*touch_notes.borrow(),
*os_notes.borrow(),
"the two ingresses must produce the same stream — that is the whole point"
);
}
#[test]
fn a_third_contact_is_ignored_by_the_tree() {
let notes = Rc::new(RefCell::new(Vec::new()));
let mut tree = WidgetTree::new();
tree.add(pinch_recorder(notes.clone()));
tree.layout(SizeProposal::exact(400.0, 400.0));
let a = contact_id(22);
let b = contact_id(23);
let c = contact_id(24);
tree.dispatch_pointer(contact(a, PointerPhase::Down, Point::new(150.0, 200.0)));
tree.dispatch_pointer(contact(b, PointerPhase::Down, Point::new(250.0, 200.0)));
let after_start = notes.borrow().len();
tree.dispatch_pointer(contact(c, PointerPhase::Down, Point::new(200.0, 350.0)));
tree.dispatch_pointer(contact(c, PointerPhase::Move, Point::new(200.0, 380.0)));
assert_eq!(
notes.borrow().len(),
after_start,
"the third contact produced no pinch phase at all"
);
assert!(tree.touch_pinch_active(), "…and the pinch is still running");
}
#[test]
fn a_contact_leaving_mid_pinch_ends_it() {
let notes = Rc::new(RefCell::new(Vec::new()));
let mut tree = WidgetTree::new();
tree.add(pinch_recorder(notes.clone()));
tree.layout(SizeProposal::exact(400.0, 400.0));
let a = contact_id(25);
let b = contact_id(26);
tree.dispatch_pointer(contact(a, PointerPhase::Down, Point::new(150.0, 200.0)));
tree.dispatch_pointer(contact(b, PointerPhase::Down, Point::new(250.0, 200.0)));
tree.dispatch_pointer(contact(a, PointerPhase::Up, Point::new(150.0, 200.0)));
assert_eq!(notes.borrow().last(), Some(&PinchNote::Ended));
assert!(!tree.touch_pinch_active());
}
#[test]
fn touch_action_none_permits_no_pinch() {
let notes = Rc::new(RefCell::new(Vec::new()));
let mut tree = WidgetTree::new();
let inner = tree.add(pinch_recorder(notes.clone()));
let _root = tree.add(
StackWidget::new()
.child(inner)
.touch_action(TouchAction::NONE),
);
tree.layout(SizeProposal::exact(400.0, 400.0));
let a = contact_id(27);
let b = contact_id(28);
tree.dispatch_pointer(contact(a, PointerPhase::Down, Point::new(150.0, 200.0)));
tree.dispatch_pointer(contact(b, PointerPhase::Down, Point::new(250.0, 200.0)));
assert!(notes.borrow().is_empty());
assert!(!tree.touch_pinch_active());
}
#[test]
fn a_large_stationary_contact_is_rejected_as_a_palm() {
let taps = Rc::new(RefCell::new(0i32));
let cancels = Rc::new(RefCell::new(Vec::new()));
let tap_count = taps.clone();
let cancel_log = cancels.clone();
let mut tree = WidgetTree::new();
tree.add(
FillWidget::new()
.on_tap(move |_e, _c| *tap_count.borrow_mut() += 1)
.on_pointer_cancel(move |_info, reason, _c| cancel_log.borrow_mut().push(reason)),
);
tree.layout(SizeProposal::exact(200.0, 200.0));
assert!(
tree.palm_fallback_active(),
"the fallback is on for a backend that reports no palms"
);
let palm = contact_id(30);
let at = Point::new(100.0, 100.0);
let mut down = contact(palm, PointerPhase::Down, at);
down.pointer.axes = PointerAxes {
contact: Some(Size::new(70.0, 70.0)),
..down.pointer.axes
};
let mut up = contact(palm, PointerPhase::Up, at);
up.pointer.axes = down.pointer.axes;
tree.dispatch_pointer(down);
tree.dispatch_pointer(up);
assert_eq!(*taps.borrow(), 0, "a palm fires no tap");
assert_eq!(
*cancels.borrow(),
vec![CancelReason::PalmRejected],
"…and is revoked with the reason that says why"
);
}
#[test]
fn a_fingertip_and_a_travelling_large_contact_both_tap() {
for (extent, travel) in [(12.0f32, 0.0f32), (70.0, 40.0)] {
let taps = Rc::new(RefCell::new(0i32));
let cancels = Rc::new(RefCell::new(Vec::new()));
let counter = taps.clone();
let cancel_log = cancels.clone();
let mut tree = WidgetTree::new();
tree.add(
FillWidget::new()
.on_tap(move |_e, _c| *counter.borrow_mut() += 1)
.on_pointer_cancel(move |_i, reason, _c| cancel_log.borrow_mut().push(reason)),
);
tree.layout(SizeProposal::exact(200.0, 200.0));
let finger = contact_id(31);
let from = Point::new(100.0, 100.0);
let axes = PointerAxes {
contact: Some(Size::new(extent, extent)),
..PointerAxes::default()
};
let mut down = contact(finger, PointerPhase::Down, from);
down.pointer.axes = axes;
tree.dispatch_pointer(down);
let to = Point::new(from.x + travel, from.y);
if travel > 0.0 {
let mut moved = contact(finger, PointerPhase::Move, to);
moved.pointer.axes = axes;
tree.dispatch_pointer(moved);
}
let mut up = contact(finger, PointerPhase::Up, to);
up.pointer.axes = axes;
tree.dispatch_pointer(up);
assert_eq!(
*taps.borrow(),
1,
"extent {extent}, travel {travel}: the tap must stand"
);
assert!(
cancels.borrow().is_empty(),
"extent {extent}, travel {travel}: and nothing may be revoked — \
got {:?}",
cancels.borrow()
);
}
}
#[test]
fn a_palm_reporting_backend_disables_the_fallback() {
let taps = Rc::new(RefCell::new(0i32));
let counter = taps.clone();
let mut tree = WidgetTree::new();
tree.add(FillWidget::new().on_tap(move |_e, _c| *counter.borrow_mut() += 1));
tree.layout(SizeProposal::exact(200.0, 200.0));
tree.set_backend_reports_palm(true);
assert!(!tree.palm_fallback_active());
let big = contact_id(32);
let at = Point::new(100.0, 100.0);
let axes = PointerAxes {
contact: Some(Size::new(70.0, 70.0)),
..PointerAxes::default()
};
let mut down = contact(big, PointerPhase::Down, at);
down.pointer.axes = axes;
let mut up = contact(big, PointerPhase::Up, at);
up.pointer.axes = axes;
tree.dispatch_pointer(down);
tree.dispatch_pointer(up);
assert_eq!(
*taps.borrow(),
1,
"with the digitiser answering, the guess is off and the tap stands"
);
}
#[test]
fn only_a_touch_pan_takes_the_claimant_chain() {
use crate::pointer::ScrollSource;
assert_eq!(
ScrollDelivery::for_source(ScrollSource::TouchPan),
ScrollDelivery::ClaimantChain
);
for source in [
ScrollSource::Wheel,
ScrollSource::Trackpad,
ScrollSource::Programmatic,
] {
assert_eq!(
ScrollDelivery::for_source(source),
ScrollDelivery::Bubble,
"{source:?} keeps the route it has always had"
);
}
}
#[test]
fn an_external_touch_pan_sample_derives_its_own_chain() {
let mut n = nested(0.0, 1000.0);
n.tree.dispatch_scroll(crate::pointer::ScrollSample {
delta: ScrollDelta::Pixels { x: 0.0, y: 40.0 },
position: Some(Point::new(100.0, 100.0)),
phase: ScrollPhase::Changed,
source: crate::pointer::ScrollSource::TouchPan,
pointer: PointerInfo::touch(contact_id(40), EventTime::ZERO),
modifiers: Modifiers::NONE,
});
assert_eq!(
n.inner_log.borrow().declined,
vec![40.0],
"the innermost claimant was offered it first"
);
assert_eq!(
n.outer_log.borrow().absorbed,
vec![40.0],
"…and it chained outward whole"
);
}
#[test]
fn a_destroyed_claimant_is_skipped_rather_than_ending_the_chain() {
let mut n = nested(0.0, 1000.0);
let finger = contact_id(41);
n.tree.dispatch_pointer(contact(
finger,
PointerPhase::Down,
Point::new(100.0, 100.0),
));
n.tree.destroy_subtree(n.inner);
n.tree.dispatch_pointer(contact(
finger,
PointerPhase::Move,
Point::new(100.0, 100.0 - pan_slop() - 20.0),
));
assert!(
!n.outer_log.borrow().absorbed.is_empty(),
"the outer container still received the pan"
);
}
#[test]
fn a_cancel_abandons_the_pan_and_stops_the_coast() {
let mut n = nested(10_000.0, 10_000.0);
let finger = contact_id(42);
let from = Point::new(100.0, 180.0);
drag(&mut n.tree, finger, from, -60.0);
n.tree.start_fling(
n.inner,
Vec2::new(0.0, 3000.0),
vec![(n.inner, PanClaim::vertical())],
);
assert!(n.tree.is_flinging(n.inner));
let mut ops = crate::window::NoopWindowOps;
n.tree
.cancel_pointer(finger, CancelReason::Platform, &mut ops);
assert!(
!n.tree.is_flinging(n.inner),
"the coast the revoked gesture owned is stopped"
);
let before = n.inner_log.borrow().phases.len();
n.tree.dispatch_pointer(contact(
finger,
PointerPhase::Move,
Point::new(100.0, 100.0),
));
assert_eq!(
n.inner_log.borrow().phases.len(),
before,
"and the abandoned pan delivers nothing more"
);
}
#[test]
fn a_pan_carries_the_wheel_sign_convention() {
let mut n = nested(1000.0, 1000.0);
let finger = contact_id(43);
drag(&mut n.tree, finger, Point::new(100.0, 150.0), -60.0);
assert!(
n.inner_log.borrow().offset > 0.0,
"dragging up increases the offset, exactly as a wheel-down does"
);
let mut m = nested(1000.0, 1000.0);
m.inner_log.borrow_mut().offset = 500.0;
let other = contact_id(44);
drag(&mut m.tree, other, Point::new(100.0, 50.0), 60.0);
assert!(
m.inner_log.borrow().offset < 500.0,
"and dragging down decreases it"
);
}
#[test]
fn the_synthesised_phases_read_as_one_continuous_gesture() {
let mut n = nested(10_000.0, 10_000.0);
let finger = contact_id(45);
let at = drag(&mut n.tree, finger, Point::new(100.0, 180.0), -60.0);
n.tree
.dispatch_pointer(contact(finger, PointerPhase::Up, at));
let phases = n.inner_log.borrow().phases.clone();
assert_eq!(phases.first(), Some(&ScrollPhase::Began));
assert_eq!(phases.last(), Some(&ScrollPhase::Ended));
assert_eq!(
phases.iter().filter(|p| **p == ScrollPhase::Began).count(),
1,
"exactly one Began"
);
assert_eq!(
phases.iter().filter(|p| **p == ScrollPhase::Ended).count(),
1,
"exactly one Ended"
);
}
#[test]
fn the_nested_fixture_really_nests() {
let n = nested(100.0, 100.0);
let inner = n.tree.bounds(n.inner);
let outer = n.tree.bounds(n.outer);
assert_eq!(outer, Rect::new(0.0, 0.0, 200.0, 200.0));
assert_eq!(inner, outer, "the inner container fills the outer one");
assert!(
n.tree
.pan_candidates(n.inner, TouchAction::AUTO)
.iter()
.map(|(id, _)| *id)
.eq([n.inner, n.outer]),
"…and the claimant chain is inner-then-outer"
);
}
#[test]
fn a_contact_presses_as_the_primary_button() {
let seen = Rc::new(RefCell::new(None));
let button = seen.clone();
let mut tree = WidgetTree::new();
tree.add(FillWidget::new().on_tap(move |event, _c| {
*button.borrow_mut() = Some(event.button);
}));
tree.layout(SizeProposal::exact(100.0, 100.0));
let finger = contact_id(46);
let at = Point::new(50.0, 50.0);
tree.dispatch_pointer(contact(finger, PointerPhase::Down, at));
tree.dispatch_pointer(contact(finger, PointerPhase::Up, at));
assert_eq!(*seen.borrow(), Some(PointerButton::Primary));
}