use teksilo_canvas::Point;
use crate::pointer::{EventTime, PointerId};
use crate::widget_id::WidgetId;
use super::WidgetTree;
pub const TOUCH_TOOLTIP_DISMISS: std::time::Duration = std::time::Duration::from_secs(5);
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LongPressRole {
#[default]
Auto,
None,
ContextMenu,
Tooltip,
DragHandle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Route {
ContextMenu,
Tooltip(usize),
}
pub(super) struct PendingTouchRoute {
pointer: PointerId,
target: WidgetId,
position: Point,
deadline: EventTime,
route: Route,
}
impl WidgetTree {
pub(super) fn long_press_is_a_grab(&self, pointer: PointerId, id: WidgetId) -> bool {
let Some(entry) = self.pointers.get(pointer) else {
return false;
};
let Some(sequence) = entry.sequence.as_ref() else {
return false;
};
if sequence.has_deferred_grab_for(id) {
return true;
}
if !entry.info.is_direct() {
return false;
}
let path = sequence.path();
let Some(cut) = path.iter().position(|&n| n == id) else {
return false;
};
path[cut..]
.iter()
.any(|&n| self.long_press_role_of(n) == LongPressRole::DragHandle)
}
fn long_press_role_of(&self, id: WidgetId) -> LongPressRole {
self.arena
.get(id)
.map(|node| node.long_press_role)
.unwrap_or_default()
}
pub(super) fn arm_touch_route(&mut self, target: WidgetId, position: Point) {
self.pending_touch_route = None;
let pointer = self.current_input.pointer;
if !pointer.kind.is_coarse() {
return;
}
if !self.effective_theme.input.touch_enabled {
return;
}
let Some(path) = self
.pointers
.get(pointer.id)
.and_then(|entry| entry.sequence.as_ref())
.map(|sequence| sequence.path().to_vec())
else {
return;
};
if path.iter().any(|&id| {
self.arena.is_enabled(id)
&& self
.arena
.get(id)
.is_some_and(|node| node.any_handler(|h| h.on_long_press.is_some()))
}) {
return;
}
let Some((route, owner)) = self.resolve_touch_route(&path) else {
return;
};
if self.hold_is_spent_on_a_grab(pointer.id, target, owner, &path) {
return;
}
let profile = self.effective_theme.input.profile(pointer.kind);
let started = self
.pointers
.get(pointer.id)
.and_then(|entry| entry.sequence.as_ref())
.map(|sequence| sequence.started_at());
let Some(started) = started else {
return;
};
self.pending_touch_route = Some(PendingTouchRoute {
pointer: pointer.id,
target,
position,
deadline: started + profile.long_press,
route,
});
}
fn resolve_touch_route(&self, path: &[WidgetId]) -> Option<(Route, WidgetId)> {
for &id in path {
match self.long_press_role_of(id) {
LongPressRole::Auto => continue,
LongPressRole::None | LongPressRole::DragHandle => return None,
LongPressRole::ContextMenu => return self.context_menu_route(path),
LongPressRole::Tooltip => return self.tooltip_route(path),
}
}
self.context_menu_route(path)
.or_else(|| self.tooltip_route(path))
}
fn context_menu_route(&self, path: &[WidgetId]) -> Option<(Route, WidgetId)> {
path.iter()
.copied()
.find(|&id| {
self.arena
.get(id)
.is_some_and(|node| node.context_menu_factory.is_some())
})
.map(|owner| (Route::ContextMenu, owner))
}
fn tooltip_route(&self, path: &[WidgetId]) -> Option<(Route, WidgetId)> {
let target = *path.first()?;
let index = self.tooltip_index_for(target)?;
let anchor = self.tooltips.get(index)?.anchor_id;
Some((Route::Tooltip(index), anchor))
}
fn hold_is_spent_on_a_grab(
&self,
pointer: PointerId,
target: WidgetId,
owner: WidgetId,
path: &[WidgetId],
) -> bool {
if self.long_press_is_a_grab(pointer, target) {
return true;
}
let Some(sequence) = self
.pointers
.get(pointer)
.and_then(|entry| entry.sequence.as_ref())
else {
return false;
};
let cut = path.iter().position(|&n| n == owner).unwrap_or(0);
path[..=cut]
.iter()
.any(|&id| sequence.has_deferred_grab_for(id))
}
pub(super) fn cancel_touch_route(&mut self, pointer: PointerId) {
if self
.pending_touch_route
.as_ref()
.is_some_and(|pending| pending.pointer == pointer)
{
self.pending_touch_route = None;
}
}
pub(super) fn touch_route_moved(&mut self, pointer: PointerId) {
let Some(pending) = self.pending_touch_route.as_ref() else {
return;
};
if pending.pointer != pointer {
return;
}
let Some(entry) = self.pointers.get(pointer) else {
return;
};
let Some(sequence) = entry.sequence.as_ref() else {
self.pending_touch_route = None;
return;
};
let slop = self.effective_theme.input.profile(entry.info.kind).tap_slop;
if sequence.travel() > slop {
self.pending_touch_route = None;
}
}
pub(super) fn next_touch_route_deadline(&self) -> Option<EventTime> {
self.pending_touch_route
.as_ref()
.map(|pending| pending.deadline)
}
pub(super) fn resolve_touch_routes(
&mut self,
now: EventTime,
ops: &mut dyn crate::window::WindowOps,
) {
let Some(pending) = self.pending_touch_route.as_ref() else {
return;
};
if now < pending.deadline {
return;
}
let Some(pending) = self.pending_touch_route.take() else {
return;
};
if !self.arena.is_active(pending.target) {
return;
}
let fired = match pending.route {
Route::ContextMenu => self.fire_context_menu_route(&pending, ops),
Route::Tooltip(index) => self.fire_tooltip_route(&pending, index),
};
if fired {
if let Some(sequence) = self
.pointers
.get_mut(pending.pointer)
.and_then(|entry| entry.sequence.as_mut())
{
sequence.set_taps_cancelled();
}
}
}
fn fire_context_menu_route(
&mut self,
pending: &PendingTouchRoute,
ops: &mut dyn crate::window::WindowOps,
) -> bool {
if !self.show_context_menu_for(pending.target, pending.position, ops) {
return false;
}
if let Some(wording) = self.context_menu_announcement.clone() {
self.announce_unless_widget_speaks(pending.target, wording());
}
true
}
fn fire_tooltip_route(&mut self, pending: &PendingTouchRoute, index: usize) -> bool {
let index = match self.tooltip_index_for(pending.target) {
Some(fresh) => fresh,
None => index,
};
let Some(entry) = self.tooltips.get_mut(index) else {
return false;
};
if entry.overlay_id.is_some() {
return false;
}
let delay = entry.delay;
entry.hover_start = Some(sub_or(self.sim_clock, delay));
entry.real_hover_start = Some(sub_or(std::time::Instant::now(), delay));
entry.hover_origin = Some(pending.position);
entry.armed_by_focus = false;
entry.armed_by_hold = true;
let anchor = entry.anchor_id;
self.arena.mark_needs_paint(anchor);
true
}
}
fn sub_or(instant: std::time::Instant, duration: std::time::Duration) -> std::time::Instant {
instant.checked_sub(duration).unwrap_or(instant)
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::rc::Rc;
use teksilo_canvas::{Point, SizeProposal};
use super::*;
use crate::test_widgets::{FillWidget, InsetWidget, StackWidget};
use crate::widget::Widget;
use crate::widget_builder::WidgetBuilder;
use crate::widget_tree::WidgetTree;
fn hold() -> std::time::Duration {
teksilo_tokens::GestureProfile::TOUCH.long_press
}
fn menu() -> Box<dyn Widget> {
Box::new(FillWidget::new().label("menu"))
}
fn tree_with_menu() -> (WidgetTree, WidgetId, Rc<Cell<usize>>) {
let opened = Rc::new(Cell::new(0usize));
let counter = opened.clone();
let mut tree = WidgetTree::new();
let id = tree.add(FillWidget::new().context_menu(move |_pos, _ctx| {
counter.set(counter.get() + 1);
Some(menu())
}));
tree.layout(SizeProposal::exact(200.0, 100.0));
(tree, id, opened)
}
#[test]
fn a_hold_opens_the_context_menu_once_and_announces() {
let (mut tree, id, opened) = tree_with_menu();
let spoken = Rc::new(Cell::new(0usize));
let counter = spoken.clone();
tree.set_context_menu_announcement(Some(Rc::new(move || {
counter.set(counter.get() + 1);
"menu opened".to_string()
})));
let at = tree.bounds(id).center();
let f = tree.new_contact();
tree.touch_down(f, at);
assert_eq!(opened.get(), 0, "the factory must not run on the press");
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(opened.get(), 1, "the hold must open the menu");
assert_eq!(spoken.get(), 1, "and announce it once");
assert_eq!(tree.active_overlays().len(), 1);
tree.advance_input_time(hold() * 3);
assert_eq!(opened.get(), 1, "the hold opens exactly one menu");
assert_eq!(spoken.get(), 1);
}
#[test]
fn no_wording_registered_announces_nothing() {
let (mut tree, id, opened) = tree_with_menu();
let at = tree.bounds(id).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(opened.get(), 1);
assert!(
tree.announcements_since(0).is_empty(),
"silence is the default: the framework cannot word this itself",
);
}
#[test]
fn a_mouse_hold_opens_no_context_menu() {
let (mut tree, id, opened) = tree_with_menu();
let at = tree.bounds(id).center();
tree.pointer_move(at);
tree.pointer_down_button(at, crate::event::PointerButton::Primary);
tree.advance_input_time(hold() * 4);
assert_eq!(
opened.get(),
0,
"a mouse has a secondary button; a held primary is not a menu request",
);
}
#[test]
fn a_pen_hold_opens_no_context_menu() {
let (mut tree, id, opened) = tree_with_menu();
let at = tree.bounds(id).center();
tree.pen_down(at, 0.5, (0.0, 0.0));
tree.advance_input_time(hold() * 4);
assert_eq!(
opened.get(),
0,
"a pen hovers and has a barrel button, so the hold stays its own",
);
}
#[test]
fn a_lift_before_the_deadline_opens_nothing() {
let (mut tree, id, opened) = tree_with_menu();
let at = tree.bounds(id).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() / 2);
tree.touch_up(f, at);
tree.advance_input_time(hold() * 2);
assert_eq!(opened.get(), 0);
}
#[test]
fn a_contact_that_travels_past_the_tap_slop_opens_nothing() {
let (mut tree, id, opened) = tree_with_menu();
let at = tree.bounds(id).center();
let slop = teksilo_tokens::GestureProfile::TOUCH.tap_slop;
let f = tree.new_contact();
tree.touch_down(f, at);
tree.touch_move(f, Point::new(at.x, at.y + slop + 2.0));
tree.advance_input_time(hold() * 2);
assert_eq!(
opened.get(),
0,
"that travel is a pan, not a considered hold"
);
}
#[test]
fn a_cancelled_contact_opens_nothing() {
let (mut tree, id, opened) = tree_with_menu();
let at = tree.bounds(id).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.touch_cancel(f, at);
tree.advance_input_time(hold() * 2);
assert_eq!(opened.get(), 0);
}
#[test]
fn the_hold_deadline_is_folded_into_the_one_input_deadline() {
let (mut tree, id, _opened) = tree_with_menu();
let at = tree.bounds(id).center();
assert!(tree.next_input_deadline().is_none());
let f = tree.new_contact();
tree.touch_down(f, at);
assert!(
tree.next_input_deadline().is_some(),
"the loop must be asked back, or a resting finger never ripens",
);
}
#[test]
fn a_widgets_own_long_press_takes_precedence_over_the_route() {
let opened = Rc::new(Cell::new(0usize));
let counter = opened.clone();
let fired = Rc::new(Cell::new(0usize));
let own = fired.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new().on_long_press(move |_e, _c| own.set(own.get() + 1)));
let _outer = tree.add(
StackWidget::new()
.child(inner)
.context_menu(move |_pos, _ctx| {
counter.set(counter.get() + 1);
Some(menu())
}),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(fired.get(), 1, "the widget's own handler runs");
assert_eq!(
opened.get(),
0,
"and the tree route stands down: the widget said what its hold means",
);
}
#[test]
fn a_drag_handle_role_takes_the_hold() {
let opened = Rc::new(Cell::new(0usize));
let counter = opened.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new());
let _outer = tree.add(
StackWidget::new()
.child(inner)
.long_press_role(LongPressRole::DragHandle)
.context_menu(move |_pos, _ctx| {
counter.set(counter.get() + 1);
Some(menu())
}),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(opened.get(), 0, "the hold is the grab, so no menu opens");
}
#[test]
fn a_drag_handle_role_suppresses_the_nodes_own_long_press() {
let fired = Rc::new(Cell::new(0usize));
let counter = fired.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new());
let _outer = tree.add(
StackWidget::new()
.child(inner)
.long_press_role(LongPressRole::DragHandle)
.on_long_press(move |_e, _ctx| counter.set(counter.get() + 1)),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(
fired.get(),
0,
"the hold arms the grab, so the node's own long press must not also fire"
);
}
#[test]
fn a_mouse_hold_under_a_drag_handle_role_still_fires_the_nodes_own_long_press() {
let fired = Rc::new(Cell::new(0usize));
let counter = fired.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new());
let _outer = tree.add(
StackWidget::new()
.child(inner)
.long_press_role(LongPressRole::DragHandle)
.on_long_press(move |_e, _ctx| counter.set(counter.get() + 1)),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
tree.pointer_move(at);
tree.pointer_down_button(at, crate::event::PointerButton::Primary);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(
fired.get(),
1,
"a mouse spends no hold arming a drag, so `DragHandle` takes nothing \
from it and the node's own long press is still heard"
);
}
#[test]
fn a_mouse_hold_under_a_drag_handle_role_still_opens_no_context_menu() {
let opened = Rc::new(Cell::new(0usize));
let counter = opened.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new());
let _outer = tree.add(
StackWidget::new()
.child(inner)
.long_press_role(LongPressRole::DragHandle)
.context_menu(move |_pos, _ctx| {
counter.set(counter.get() + 1);
Some(menu())
}),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
tree.pointer_move(at);
tree.pointer_down_button(at, crate::event::PointerButton::Primary);
tree.advance_input_time(hold() * 4);
assert_eq!(
opened.get(),
0,
"a mouse has a secondary button; a held primary is not a menu request",
);
}
#[test]
fn an_explicitly_deferred_grab_takes_the_hold_from_every_pointer_kind() {
fn tree_with_explicit_deferral() -> (WidgetTree, WidgetId, Rc<Cell<usize>>) {
let fired = Rc::new(Cell::new(0usize));
let counter = fired.clone();
let mut tree = WidgetTree::new();
let row = tree.add(FillWidget::new().on_tap(|_e, _c| {}));
let _list = tree.add(
StackWidget::new()
.child(row)
.drag_activation(teksilo_tokens::DragActivation::AfterLongPress)
.on_drag(|_phase, _c| {})
.on_long_press(move |_e, _ctx| counter.set(counter.get() + 1)),
);
tree.layout(SizeProposal::exact(200.0, 50.0));
(tree, row, fired)
}
let (mut tree, row, fired) = tree_with_explicit_deferral();
let at = tree.bounds(row).center();
tree.pointer_move(at);
tree.pointer_down_button(at, crate::event::PointerButton::Primary);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(
fired.get(),
0,
"an explicit `AfterLongPress` is not synthesised, so it is not \
gated: the mouse's hold arms the declared grab and is spent"
);
let (mut tree, row, fired) = tree_with_explicit_deferral();
let at = tree.bounds(row).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(
fired.get(),
0,
"and a finger reaches the same branch, by the same declaration \
rather than by `Auto`"
);
}
#[test]
fn a_pen_hold_under_a_drag_handle_role_is_taken_like_a_fingers() {
let fired = Rc::new(Cell::new(0usize));
let counter = fired.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new());
let _outer = tree.add(
StackWidget::new()
.child(inner)
.long_press_role(LongPressRole::DragHandle)
.on_long_press(move |_e, _ctx| counter.set(counter.get() + 1)),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
tree.pen_down(at, 0.5, (0.0, 0.0));
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(
fired.get(),
0,
"a pen is a direct pointer, so its hold arms the grab and is spent"
);
}
#[test]
fn an_unclassified_pointer_arms_no_hold_route() {
let opened = Rc::new(Cell::new(0usize));
let counter = opened.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new());
let _outer = tree.add(
StackWidget::new()
.child(inner)
.context_menu(move |_pos, _ctx| {
counter.set(counter.get() + 1);
Some(menu())
}),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
let p = tree.new_contact();
let sample = tree.direct_sample(
p,
teksilo_tokens::PointerKind::Unknown,
crate::pointer::PointerPhase::Down,
at,
true,
);
tree.dispatch_pointer(sample);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(
opened.get(),
0,
"an unclassified pointer is tuned as a mouse, so its hold opens nothing"
);
}
#[test]
fn a_none_role_leaves_the_nodes_own_long_press_alone() {
let fired = Rc::new(Cell::new(0usize));
let counter = fired.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new());
let _outer = tree.add(
StackWidget::new()
.child(inner)
.long_press_role(LongPressRole::None)
.on_long_press(move |_e, _ctx| counter.set(counter.get() + 1)),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(
fired.get(),
1,
"`None` stands the tree route down, not the widget's own handler"
);
}
#[test]
fn a_none_role_stands_the_route_down() {
let opened = Rc::new(Cell::new(0usize));
let counter = opened.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(FillWidget::new().long_press_role(LongPressRole::None));
let _outer = tree.add(
StackWidget::new()
.child(inner)
.context_menu(move |_pos, _ctx| {
counter.set(counter.get() + 1);
Some(menu())
}),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(opened.get(), 0);
}
#[test]
fn a_tooltip_role_wins_over_a_reachable_menu() {
let opened = Rc::new(Cell::new(0usize));
let counter = opened.clone();
let mut tree = WidgetTree::new();
let anchor = tree.add(FillWidget::new().long_press_role(LongPressRole::Tooltip));
let _outer = tree.add(
StackWidget::new()
.child(anchor)
.context_menu(move |_pos, _ctx| {
counter.set(counter.get() + 1);
Some(menu())
}),
);
let tip = tree.add(FillWidget::new().label("tip text"));
tree.layout(SizeProposal::exact(200.0, 100.0));
tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
let at = tree.bounds(anchor).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(opened.get(), 0, "the role named the tooltip, not the menu");
assert!(tree.find_by_label("tip text").is_some());
}
#[test]
fn a_hold_shows_the_tooltip_of_a_disabled_control() {
let mut tree = WidgetTree::new();
let anchor = tree.add(FillWidget::new());
let tip = tree.add(FillWidget::new().label("why it is greyed out"));
tree.layout(SizeProposal::exact(200.0, 100.0));
tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
tree.enabled_when(anchor, false);
let at = tree.bounds(anchor).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert!(
tree.find_by_label("why it is greyed out").is_some(),
"the disabled control's tip is hover-only with no other route; the hold is it",
);
}
#[test]
fn a_tooltip_a_hold_summoned_retires_on_its_own() {
let mut tree = WidgetTree::new();
let anchor = tree.add(FillWidget::new());
let tip = tree.add(FillWidget::new().label("tip text"));
tree.layout(SizeProposal::exact(200.0, 100.0));
tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
let at = tree.bounds(anchor).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
tree.touch_up(f, at);
assert_eq!(
tree.active_overlays().len(),
1,
"the tip is up after the lift"
);
tree.advance_input_time(TOUCH_TOOLTIP_DISMISS / 2);
assert_eq!(
tree.active_overlays().len(),
1,
"and stays up long enough to read",
);
tree.advance_input_time(TOUCH_TOOLTIP_DISMISS);
assert!(
tree.active_overlays().is_empty(),
"nothing else would ever retire it: no pointer left it and no focus moved",
);
}
#[test]
fn a_mouse_hold_shows_no_tooltip_before_its_dwell() {
let mut tree = WidgetTree::new();
let anchor = tree.add(FillWidget::new());
let tip = tree.add(FillWidget::new().label("tip text"));
tree.layout(SizeProposal::exact(200.0, 100.0));
tree.attach_tooltip(anchor, tip, std::time::Duration::from_secs(5));
let at = tree.bounds(anchor).center();
tree.pointer_move(at);
tree.pointer_down_button(at, crate::event::PointerButton::Primary);
tree.advance_input_time(hold() * 4);
assert!(
tree.find_by_label("tip text").is_none(),
"the mouse keeps its own dwell; a held button is not a summons",
);
}
#[test]
fn a_contacts_move_does_not_start_an_overlays_pointer_leave_grace() {
let (mut tree, content, away) = leave_grace_fixture();
let inside = tree.bounds(content).center();
let f = tree.new_contact();
tree.touch_down(f, inside);
tree.touch_move(f, away);
tree.advance_input_time(std::time::Duration::from_millis(400));
assert_eq!(
tree.active_overlays().len(),
1,
"a contact cannot leave what it never hovered",
);
}
#[test]
fn a_mouses_move_off_the_same_overlay_still_starts_the_grace() {
let (mut tree, content, away) = leave_grace_fixture();
let inside = tree.bounds(content).center();
tree.pointer_move(inside);
tree.pointer_move(away);
tree.advance_input_time(std::time::Duration::from_millis(400));
assert_eq!(
tree.active_overlays().len(),
0,
"the pointer that can leave still leaves",
);
}
#[derive(Debug)]
struct SelfDeclaring(LongPressRole);
impl Widget for SelfDeclaring {
fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
ctx.apply_self_handlers(
crate::widget_builder::HandlerSet::new().long_press_role(self.0),
);
vec![]
}
fn layout_response(
&self,
proposal: SizeProposal,
_ctx: &crate::widget::LayoutContext,
) -> crate::widget::LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
}
#[test]
fn a_widget_can_declare_its_own_role_from_build() {
let opened = Rc::new(Cell::new(0usize));
let counter = opened.clone();
let mut tree = WidgetTree::new();
let inner = tree.add(SelfDeclaring(LongPressRole::None));
let _outer = tree.add(
StackWidget::new()
.child(inner)
.context_menu(move |_pos, _ctx| {
counter.set(counter.get() + 1);
Some(menu())
}),
);
tree.layout(SizeProposal::exact(200.0, 100.0));
let at = tree.bounds(inner).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
assert_eq!(
opened.get(),
0,
"the role the widget declared for itself stood the route down",
);
}
#[test]
fn a_mouse_moving_afterwards_does_not_take_away_a_tip_a_hold_summoned() {
let mut tree = WidgetTree::new();
let anchor = tree.add(FillWidget::new());
let tip = tree.add(FillWidget::new().label("tip text"));
let _root = tree.add(InsetWidget::new(60.0).set_child(anchor));
tree.layout(SizeProposal::exact(400.0, 300.0));
tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
let at = tree.bounds(anchor).center();
let f = tree.new_contact();
tree.touch_down(f, at);
tree.advance_input_time(hold() + std::time::Duration::from_millis(10));
tree.touch_up(f, at);
assert_eq!(
tree.active_overlays().len(),
1,
"the tip is up after the lift"
);
tree.pointer_move(Point::new(395.0, 5.0));
tree.advance_input_time(std::time::Duration::from_millis(300));
assert_eq!(
tree.active_overlays().len(),
1,
"a pointer that did not open it does not close it",
);
}
#[derive(Debug)]
struct SizedWidget(teksilo_canvas::Size);
impl Widget for SizedWidget {
fn layout_response(
&self,
_proposal: SizeProposal,
_ctx: &crate::widget::LayoutContext,
) -> crate::widget::LayoutResponse {
self.0.into()
}
}
fn leave_grace_fixture() -> (WidgetTree, WidgetId, Point) {
const WINDOW: (f32, f32) = (400.0, 300.0);
const AWAY: Point = Point { x: 390.0, y: 10.0 };
let mut tree = WidgetTree::new();
let trigger = tree.add(FillWidget::new());
let _root = tree.add(InsetWidget::new(100.0).set_child(trigger));
let content = tree.add(SizedWidget(teksilo_canvas::Size::new(80.0, 60.0)));
tree.layout(SizeProposal::exact(WINDOW.0, WINDOW.1));
tree.show_overlay(crate::overlay::OverlayRequest {
content_id: content,
anchor: trigger,
placement: crate::overlay::OverlayPlacement::AtPointer(Point::new(300.0, 200.0)),
dismiss: crate::overlay::DismissBehavior::PointerLeave {
delay: std::time::Duration::from_millis(150),
},
layer: crate::overlay::OverlayLayer::InTree,
parent_overlay: None,
on_dismiss: None,
fade_duration: None,
});
tree.layout(SizeProposal::exact(WINDOW.0, WINDOW.1));
assert_eq!(tree.active_overlays().len(), 1);
(tree, content, AWAY)
}
}