teksilo_core/gesture/sequence.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! One arbitration object per live pointer: who is competing for this press,
5//! and which of them owns it.
6//!
7//! # What this replaces
8//!
9//! Before this module the framework had exactly one piece of cross-widget
10//! arbitration: `drag_observers`, a `Vec<WidgetId>` on the tree holding the
11//! draggable ancestors armed by the current press. It was single-pointer (one
12//! `Vec` for the whole tree), drag-only (a scrollable, a previewer or an
13//! explicit captor could not be a competitor at all), and its decision
14//! procedure was implicit in the order of three helper functions.
15//!
16//! A [`PointerSequence`] is the same idea made explicit and made plural: one
17//! per live [`PointerId`](crate::pointer::PointerId), stored on that pointer's
18//! [`PointerEntry`](crate::pointer::table::PointerEntry), carrying the frozen hit
19//! path, the frozen [`TouchAction`], every enrolled [`SequenceMember`], and the
20//! winner once one is decided.
21//!
22//! # The ordered decision procedure
23//!
24//! Stated once here, implemented in `widget_tree/pointer_router.rs`:
25//!
26//! 1. **At press**: hit-test to a target, freeze the hit path (target → root),
27//! intersect and freeze [`TouchAction`] root-to-target, and record the
28//! innermost `gesture_dead_zone` node on that path as the enrolment
29//! boundary.
30//! 2. **The raw-preview pass runs first, root-first.** The first ancestor whose
31//! `on_pointer_event` answers `Handled` claims the sequence outright as a
32//! [`MemberRole::RawPreview`] winner. This order is load-bearing —
33//! `rich_text/mouse.rs` documents relying on an outer wrapper seeing a press
34//! before an inner one — so previewers are deliberately **not** folded into
35//! the innermost-first member order below.
36//! 3. **An explicit [`capture_pointer`](crate::widget::EventContext::capture_pointer)
37//! from an undecided sequence is an arbitration act**, not plumbing: the
38//! caller is enrolled as [`MemberRole::RawDrag`], and for a precise pointer
39//! with no eligible pan competitor the sequence is decided there and then.
40//! Three shipped widgets drive their whole interaction this way — the
41//! splitter handle, the dock resize handle and the table column grip all
42//! return `Ignored` from `on_pointer_event`, capture, and work from
43//! `PointerMove` with no recognizer at all.
44//! 4. **On move while undecided**: timers before positional thresholds, then
45//! members innermost-first. A `RawDrag` wins past `drag_slop`; a `Gesture`
46//! wins when its own recognizer recognizes; a [`MemberRole::Pan`] wins only
47//! on an axis the frozen `TouchAction` permits and only past `pan_slop`.
48//! 5. **On up**: the release sweep — the innermost still-`Possible` member with
49//! a completable gesture wins, which is the pre-existing
50//! `arena.process(Up) -> Tap`.
51//!
52//! # Why the mouse is unchanged
53//!
54//! [`GestureProfile::pan_slop`] is `None` for a mouse and
55//! [`PanClaim::devices`] defaults to direct pointers, so **no pan member is
56//! ever eligible for a mouse**. Every mouse sequence is therefore either
57//! decided at press (an explicit capture) or arbitrated exactly as
58//! `drag_observers` arbitrated it: ancestors innermost-first, each latching at
59//! its own `drag_slop`, which for the mouse profile is the 5.0 it has always
60//! been. On touch the same widget defers by `drag_slop` (18) and still beats a
61//! scroller, because `pan_slop` (36) is larger.
62//!
63//! Reference: `docs/events-and-gestures.md`.
64
65use teksilo_canvas::Point;
66use teksilo_tokens::{DragActivation, GestureProfile};
67
68use crate::pointer::touch_action::{Axis, PanClaim, TouchAction};
69use crate::pointer::{EventTime, PointerInfo};
70use crate::widget_id::WidgetId;
71
72/// What a member is competing *as*.
73///
74/// The role decides which threshold the member wins on and, for `Pan`, which
75/// axes the frozen [`TouchAction`] has to permit.
76#[non_exhaustive]
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub enum MemberRole {
79 /// A node whose own gesture recognizers (`on_drag` / `on_swipe`) are
80 /// competing. This is what `drag_observers` used to hold, and it is what an
81 /// ancestor of the pressed control is enrolled as.
82 Gesture,
83 /// A scroll container that declared a [`PanClaim`]. Only ever enrolled for
84 /// a pointer kind the claim's `devices` mask admits and only when the
85 /// pointer's profile has a `pan_slop` — so never for a mouse.
86 Pan(PanClaim),
87 /// A node that took the pointer by an explicit
88 /// [`capture_pointer`](crate::widget::EventContext::capture_pointer) while
89 /// the sequence was undecided, and drives its interaction from
90 /// `PointerMove` rather than from a recognizer.
91 RawDrag,
92 /// A node that answered `Handled` from the root-first preview pass. It has
93 /// already won by the time it is enrolled; the role exists so
94 /// [`WidgetTree::sequence_members`](crate::WidgetTree::sequence_members)
95 /// can report *why*.
96 RawPreview,
97}
98
99/// Where one member stands in the arbitration.
100#[non_exhaustive]
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum MemberState {
103 /// Still in the running.
104 Possible,
105 /// Deferring its own decision — see
106 /// [`hold_gesture`](crate::widget::EventContext::hold_gesture). Released
107 /// automatically at `profile.max_hold`; the framework itself never holds.
108 Held,
109 /// Out of the running, either by its own choice
110 /// ([`reject_gesture`](crate::widget::EventContext::reject_gesture)), by a
111 /// threshold it can no longer meet, or because a peer won.
112 Rejected,
113 /// The winner.
114 Won,
115}
116
117/// One competitor for a pointer sequence.
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct SequenceMember {
120 /// The competing node.
121 pub id: WidgetId,
122 /// What it is competing as.
123 pub role: MemberRole,
124 /// The earliest time this member may win, when its activation defers it.
125 /// `None` means "as soon as its threshold is met".
126 pub eligible_at: Option<EventTime>,
127 /// Where it stands.
128 pub state: MemberState,
129 /// When `true`, this member self-rejects the moment the pointer leaves the
130 /// tap boundary — the [`DragActivation::AfterLongPress`] rule.
131 pub(crate) rejects_on_tap_slop: bool,
132 /// When [`state`](Self::state) became [`MemberState::Held`].
133 pub(crate) held_since: Option<EventTime>,
134}
135
136impl SequenceMember {
137 /// A fresh member in the running.
138 pub(crate) fn new(id: WidgetId, role: MemberRole) -> Self {
139 Self {
140 id,
141 role,
142 eligible_at: None,
143 state: MemberState::Possible,
144 rejects_on_tap_slop: false,
145 held_since: None,
146 }
147 }
148
149 /// Whether this member could still win.
150 pub fn is_live(&self) -> bool {
151 matches!(self.state, MemberState::Possible | MemberState::Held)
152 }
153
154 /// Whether this member may win at `now`. A member deferred by
155 /// [`DragActivation::AfterLongPress`] cannot win before its timer, and a
156 /// holding member cannot win at all until it releases.
157 pub fn is_eligible_at(&self, now: EventTime) -> bool {
158 self.state == MemberState::Possible
159 && self.eligible_at.is_none_or(|deadline| now >= deadline)
160 }
161}
162
163/// Where a press stops being a tap.
164///
165/// One predicate, three consumers: it fails a tap, it triggers
166/// [`GestureArenaSet::cancel_taps`](super::GestureArenaSet::cancel_taps), and
167/// it will clear the framework press visual. A coarse pointer uses `Bounds`
168/// because a finger's reported centre wanders several device pixels while
169/// resting inside the control it is pressing; a precise pointer keeps the
170/// radius it always had.
171#[derive(Debug, Clone, Copy, PartialEq)]
172pub enum TapBoundary {
173 /// The press fails once it travels further than this from its origin.
174 Radius(f32),
175 /// The press fails once it leaves the pressed node's bounds.
176 Bounds,
177}
178
179impl TapBoundary {
180 /// The boundary a pointer of this kind uses.
181 ///
182 /// `Radius(profile.tap_slop)` for a precise pointer — the pre-existing
183 /// rule, unchanged — and `Bounds` for a coarse one.
184 pub fn for_pointer(pointer: &PointerInfo, profile: &GestureProfile) -> Self {
185 if pointer.kind.is_coarse() {
186 Self::Bounds
187 } else {
188 Self::Radius(profile.tap_slop)
189 }
190 }
191
192 /// Whether `position` has left the boundary, given the press origin and the
193 /// pressed node's bounds in the same (window-logical) space.
194 ///
195 /// A `Bounds` boundary with no bounds to test against — the pressed node
196 /// went away — falls back to the radius, so the answer is never "the press
197 /// can travel anywhere".
198 ///
199 /// So does a `Bounds` boundary whose press **began outside the node**. A
200 /// press can be accepted for a node it did not land in: a control that
201 /// declares a [`Widget::hit_outset`] is offered the ring around it (a 12 dp
202 /// twist arrow lifted to a 24 dp target, a 16 dp clear affordance inside a
203 /// text field), and the miss-only slop pass re-attributes a near miss the
204 /// same way. For such a press the node's rectangle never contained the
205 /// origin, so testing `position` against it alone would report the press as
206 /// already-left at the instant it arrived — and the tap could never
207 /// complete, silently making the whole outset mechanism useless to every
208 /// coarse-pointer tap. The rule for that case is the pointer's own radius
209 /// around where it landed, unioned with the node's bounds so sliding *onto*
210 /// the control keeps the press alive. Android's `ViewGroup` takes the same
211 /// shape from the other direction (`pointInView(x, y, mTouchSlop)` — the
212 /// view's rect inflated by touch slop).
213 ///
214 /// A press that began inside the node is untouched: the rectangle is the
215 /// boundary, exactly as before.
216 ///
217 /// [`Widget::hit_outset`]: crate::widget::Widget::hit_outset
218 pub fn left(
219 &self,
220 origin: Point,
221 position: Point,
222 bounds: Option<teksilo_canvas::Rect>,
223 profile: &GestureProfile,
224 ) -> bool {
225 match self {
226 Self::Radius(radius) => super::distance(origin, position) > *radius,
227 Self::Bounds => match bounds {
228 Some(rect) if rect.contains(origin) => !rect.contains(position),
229 Some(rect) => {
230 !rect.contains(position) && super::distance(origin, position) > profile.tap_slop
231 }
232 None => super::distance(origin, position) > profile.tap_slop,
233 },
234 }
235 }
236}
237
238/// Everything the tree knows about one press: who is competing for it, and who
239/// won.
240///
241/// Lives in [`PointerEntry::sequence`](crate::pointer::table::PointerEntry::sequence)
242/// for as long as the pointer is down. The ordered decision procedure this
243/// type is the state of is written out in `docs/events-and-gestures.md` §4.2 and
244/// in this module's own header.
245#[derive(Debug, Clone, PartialEq)]
246pub struct PointerSequence {
247 pointer: PointerInfo,
248 path: Vec<WidgetId>,
249 touch_action: TouchAction,
250 dead_zone_boundary: Option<WidgetId>,
251 members: Vec<SequenceMember>,
252 winner: Option<WidgetId>,
253 capture: Option<WidgetId>,
254 press_origin: Point,
255 last_position: Point,
256 started_at: EventTime,
257 pressed_owner: Option<WidgetId>,
258 terminating: bool,
259 taps_cancelled: bool,
260}
261
262impl PointerSequence {
263 /// Open a sequence for `pointer`'s press at `origin`.
264 ///
265 /// `path` runs target → root and is **frozen**: a rebuild mid-gesture
266 /// cannot change who was competing for a press that has already started.
267 pub fn new(
268 pointer: PointerInfo,
269 path: Vec<WidgetId>,
270 touch_action: TouchAction,
271 dead_zone_boundary: Option<WidgetId>,
272 origin: Point,
273 started_at: EventTime,
274 ) -> Self {
275 Self {
276 pointer,
277 path,
278 touch_action,
279 dead_zone_boundary,
280 members: Vec::new(),
281 winner: None,
282 capture: None,
283 press_origin: origin,
284 last_position: origin,
285 started_at,
286 pressed_owner: None,
287 terminating: false,
288 taps_cancelled: false,
289 }
290 }
291
292 /// Which pointer this sequence follows.
293 pub fn pointer(&self) -> PointerInfo {
294 self.pointer
295 }
296
297 /// The frozen hit path, target → root.
298 pub fn path(&self) -> &[WidgetId] {
299 &self.path
300 }
301
302 /// The [`TouchAction`] frozen at press — the intersection of every
303 /// declaration from the root down to the pressed target.
304 pub fn touch_action(&self) -> TouchAction {
305 self.touch_action
306 }
307
308 /// The innermost `gesture_dead_zone` node on the frozen path, if any.
309 /// Nothing at or above it may be enrolled.
310 pub fn dead_zone_boundary(&self) -> Option<WidgetId> {
311 self.dead_zone_boundary
312 }
313
314 /// Every enrolled member, innermost first.
315 pub fn members(&self) -> &[SequenceMember] {
316 &self.members
317 }
318
319 /// The node that owns this press, once one has been decided.
320 pub fn winner(&self) -> Option<WidgetId> {
321 self.winner
322 }
323
324 /// Whether arbitration is over.
325 pub fn is_decided(&self) -> bool {
326 self.winner.is_some()
327 }
328
329 /// The node holding this pointer's capture, as the sequence recorded it.
330 pub fn capture(&self) -> Option<WidgetId> {
331 self.capture
332 }
333
334 /// Record who holds the capture.
335 pub fn set_capture(&mut self, captor: Option<WidgetId>) {
336 self.capture = captor;
337 }
338
339 /// The node whose gesture arena took the press — the tap owner, when the
340 /// press was not claimed by anything else.
341 pub fn pressed_owner(&self) -> Option<WidgetId> {
342 self.pressed_owner
343 }
344
345 /// Record the node whose gesture arena took the press.
346 pub fn set_pressed_owner(&mut self, owner: Option<WidgetId>) {
347 self.pressed_owner = owner;
348 }
349
350 /// Where the press landed.
351 pub fn press_origin(&self) -> Point {
352 self.press_origin
353 }
354
355 /// Where the pointer was at its most recent sample.
356 pub fn last_position(&self) -> Point {
357 self.last_position
358 }
359
360 /// Record the pointer's current position.
361 pub fn set_last_position(&mut self, position: Point) {
362 self.last_position = position;
363 }
364
365 /// When the press landed, on the tree's input timeline.
366 pub fn started_at(&self) -> EventTime {
367 self.started_at
368 }
369
370 /// Whether the press has already been told it is no longer a tap.
371 ///
372 /// The `cancel_taps` revocation fires **once** per press: it resets the
373 /// node's [`TapStreak`](super::TapStreak), and repeating it on every
374 /// subsequent move would keep clearing state a live drag may still want.
375 pub fn taps_cancelled(&self) -> bool {
376 self.taps_cancelled
377 }
378
379 /// Record that the tap family has been revoked for this press.
380 pub fn set_taps_cancelled(&mut self) {
381 self.taps_cancelled = true;
382 }
383
384 /// Whether the sequence is inside its own terminal dispatch — set while the
385 /// `Up` that ends it is being delivered, so a teardown triggered from a
386 /// handler cannot cancel a press that has already completed.
387 pub fn is_terminating(&self) -> bool {
388 self.terminating
389 }
390
391 /// Mark the sequence as inside its terminal dispatch.
392 pub fn set_terminating(&mut self, terminating: bool) {
393 self.terminating = terminating;
394 }
395
396 /// How far the pointer has travelled from the press point.
397 pub fn travel(&self) -> f32 {
398 super::distance(self.press_origin, self.last_position)
399 }
400
401 /// How far the pointer has travelled along one axis.
402 pub fn travel_on(&self, axis: Axis) -> f32 {
403 match axis {
404 Axis::X => (self.last_position.x - self.press_origin.x).abs(),
405 Axis::Y => (self.last_position.y - self.press_origin.y).abs(),
406 }
407 }
408
409 /// The slop a positional member of this sequence latches at.
410 ///
411 /// `profile.drag_slop` in every configuration **except** a direct pointer
412 /// under a frozen [`TouchAction::NONE`], where the subtree has declared
413 /// that a contact does nothing but manipulate it and the jitter floor is
414 /// the right threshold. A precise pointer always uses `drag_slop`: reading
415 /// `slop_precise` for it would silently retune every mouse drag latch from
416 /// 5 dp to 2.
417 pub fn latch_slop(&self, profile: &GestureProfile) -> f32 {
418 if self.pointer.kind.is_direct() && self.touch_action.is_none() {
419 profile.slop_precise
420 } else {
421 profile.drag_slop
422 }
423 }
424
425 /// Whether `id` may be enrolled at all: it must be on the frozen path and
426 /// strictly below the dead-zone boundary.
427 pub fn may_enrol(&self, id: WidgetId) -> bool {
428 let Some(index) = self.path.iter().position(|p| *p == id) else {
429 return false;
430 };
431 match self.dead_zone_boundary {
432 Some(boundary) => match self.path.iter().position(|p| *p == boundary) {
433 Some(boundary_index) => index < boundary_index,
434 None => true,
435 },
436 None => true,
437 }
438 }
439
440 /// Depth of `id` on the frozen path, innermost first. Used to keep
441 /// [`members`](Self::members) sorted no matter what order enrolment
442 /// happened in.
443 fn depth_of(&self, id: WidgetId) -> usize {
444 self.path
445 .iter()
446 .position(|p| *p == id)
447 .unwrap_or(usize::MAX)
448 }
449
450 /// Whether `id` is already enrolled.
451 pub fn has_member(&self, id: WidgetId) -> bool {
452 self.members.iter().any(|m| m.id == id)
453 }
454
455 /// Enrol `id` as a competitor, keeping the member list innermost-first.
456 ///
457 /// Refused — and reported as `false` — when `id` is at or above the
458 /// dead-zone boundary, when it is not on the frozen path, or when it is
459 /// already enrolled.
460 pub fn enrol(&mut self, id: WidgetId, role: MemberRole) -> bool {
461 if self.has_member(id) || !self.may_enrol(id) {
462 return false;
463 }
464 let member = SequenceMember::new(id, role);
465 let depth = self.depth_of(id);
466 let at = self
467 .members
468 .iter()
469 .position(|m| self.depth_of(m.id) > depth)
470 .unwrap_or(self.members.len());
471 self.members.insert(at, member);
472 true
473 }
474
475 /// Enrol a drag member whose [`DragActivation`] defers it.
476 ///
477 /// `AfterLongPress` (and `Auto` resolving to it) sets `eligible_at` to the
478 /// long-press deadline and arms the self-rejection rule: the member is out
479 /// the moment the press travels past the tap boundary, because that travel
480 /// is a pan, not a considered grab.
481 pub fn enrol_drag(
482 &mut self,
483 id: WidgetId,
484 role: MemberRole,
485 activation: DragActivation,
486 profile: &GestureProfile,
487 ) -> bool {
488 if !self.enrol(id, role) {
489 return false;
490 }
491 if self.resolve_activation(activation) == DragActivation::AfterLongPress
492 && let Some(member) = self.members.iter_mut().find(|m| m.id == id)
493 {
494 member.eligible_at = Some(self.started_at + profile.long_press);
495 member.rejects_on_tap_slop = true;
496 }
497 true
498 }
499
500 /// What [`DragActivation::Auto`] means for this sequence.
501 ///
502 /// `Immediate` for a precise pointer or a subtree that has declared
503 /// [`TouchAction::NONE`] (nothing else can want the press); `AfterLongPress`
504 /// for a coarse pointer with an eligible pan competitor, because the axis
505 /// is already spoken for.
506 pub fn resolve_activation(&self, activation: DragActivation) -> DragActivation {
507 match activation {
508 DragActivation::Auto => {
509 if !self.pointer.kind.is_direct() || self.touch_action.is_none() {
510 DragActivation::Immediate
511 } else if self.has_eligible_pan() {
512 DragActivation::AfterLongPress
513 } else {
514 DragActivation::Immediate
515 }
516 }
517 other => other,
518 }
519 }
520
521 /// Whether any live member's activation was **deferred to the long-press
522 /// deadline** — i.e. the hold is what arms that member's grab.
523 ///
524 /// Only [`enrol_drag`](Self::enrol_drag) sets a member's `eligible_at`, and
525 /// only when [`resolve_activation`](Self::resolve_activation) answered
526 /// [`DragActivation::AfterLongPress`], so this is exactly "a grab on this
527 /// sequence is waiting out the hold". A mouse never has one: the resolution
528 /// needs an eligible pan competitor and a mouse enrols none.
529 ///
530 /// Read by the framework to keep one hold from meaning two things — see
531 /// [`WidgetTree::long_press_is_a_grab`](crate::WidgetTree).
532 pub fn has_deferred_grab(&self) -> bool {
533 self.members
534 .iter()
535 .any(|m| m.is_live() && m.eligible_at.is_some())
536 }
537
538 /// Whether any live member is a pan claimant. A mouse never has one:
539 /// [`GestureProfile::pan_slop`] is `None` for it and [`PanClaim::devices`]
540 /// admits only direct pointers.
541 pub fn has_eligible_pan(&self) -> bool {
542 self.members
543 .iter()
544 .any(|m| m.is_live() && matches!(m.role, MemberRole::Pan(_)))
545 }
546
547 /// Whether `claim` is eligible for this sequence's pointer at all: the
548 /// claim must admit the device, the pointer's profile must have a pan slop,
549 /// and the frozen [`TouchAction`] must permit at least one claimed axis.
550 pub fn pan_is_eligible(&self, claim: &PanClaim, profile: &GestureProfile) -> bool {
551 if profile.pan_slop.is_none() {
552 return false;
553 }
554 if !claim.devices.contains(self.pointer.kind) {
555 return false;
556 }
557 [Axis::X, Axis::Y]
558 .into_iter()
559 .any(|axis| claim.axes.contains(axis) && self.touch_action.allows_pan(axis))
560 }
561
562 /// The axis a pan member of this sequence would win on, if its travel has
563 /// passed `pan_slop` on one the claim and the frozen action both permit.
564 ///
565 /// A diagonal tie resolves by **dominant axis** — the one that has moved
566 /// further — so a pan that is mostly vertical scrolls vertically even when
567 /// both axes are claimed.
568 pub fn pan_axis_past_slop(&self, claim: &PanClaim, profile: &GestureProfile) -> Option<Axis> {
569 let slop = profile.pan_slop?;
570 let mut candidates: Vec<(Axis, f32)> = [Axis::X, Axis::Y]
571 .into_iter()
572 .filter(|axis| claim.axes.contains(*axis) && self.touch_action.allows_pan(*axis))
573 .map(|axis| (axis, self.travel_on(axis)))
574 .filter(|(_, travel)| *travel >= slop)
575 .collect();
576 // Dominant axis first; ties keep X, which is the declaration order.
577 candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
578 candidates.first().map(|(axis, _)| *axis)
579 }
580
581 /// Declare `id` the winner and reject every other live member.
582 ///
583 /// Returns the members that were knocked out, so the caller can cancel each
584 /// exactly once.
585 pub fn decide(&mut self, id: WidgetId) -> Vec<WidgetId> {
586 self.winner = Some(id);
587 let mut losers = Vec::new();
588 for member in &mut self.members {
589 if member.id == id {
590 member.state = MemberState::Won;
591 } else if member.is_live() {
592 member.state = MemberState::Rejected;
593 losers.push(member.id);
594 }
595 }
596 losers
597 }
598
599 /// Withdraw `id` from the running.
600 pub fn reject(&mut self, id: WidgetId) {
601 if let Some(member) = self.members.iter_mut().find(|m| m.id == id)
602 && member.is_live()
603 {
604 member.state = MemberState::Rejected;
605 }
606 }
607
608 /// Defer `id`'s decision until it releases or `profile.max_hold` elapses.
609 pub fn hold(&mut self, id: WidgetId, now: EventTime) {
610 if let Some(member) = self.members.iter_mut().find(|m| m.id == id)
611 && member.state == MemberState::Possible
612 {
613 member.state = MemberState::Held;
614 member.held_since = Some(now);
615 }
616 }
617
618 /// End `id`'s hold, putting it back in the running.
619 pub fn release_hold(&mut self, id: WidgetId) {
620 if let Some(member) = self.members.iter_mut().find(|m| m.id == id)
621 && member.state == MemberState::Held
622 {
623 member.state = MemberState::Possible;
624 member.held_since = None;
625 }
626 }
627
628 /// Release every hold older than `profile.max_hold`.
629 ///
630 /// A hold exists so an **application** recognizer can await an
631 /// asynchronous decision; leaving one standing would strand the press, so
632 /// the framework times it out rather than trusting the holder.
633 pub fn expire_holds(&mut self, now: EventTime, profile: &GestureProfile) {
634 for member in &mut self.members {
635 if member.state == MemberState::Held
636 && let Some(since) = member.held_since
637 && now.saturating_since(since) >= profile.max_hold
638 {
639 member.state = MemberState::Possible;
640 member.held_since = None;
641 }
642 }
643 }
644
645 /// Whether any member is holding.
646 pub fn is_held(&self) -> bool {
647 self.members.iter().any(|m| m.state == MemberState::Held)
648 }
649
650 /// When [`expire_holds`](Self::expire_holds) next has work: the earliest
651 /// instant at which a standing hold reaches `profile.max_hold`.
652 ///
653 /// **A deferred member's `eligible_at` is deliberately not a term here.**
654 /// It looks like a sibling deadline and is not one. Nothing happens at that
655 /// instant: eligibility is never *stored*, it is re-derived by
656 /// [`SequenceMember::is_eligible_at`] against whatever instant its caller
657 /// names, and no reader *transitions* anything on reaching it. Two call
658 /// sites read it — the arbitration walk, and the arena gate the ordinary
659 /// bubble and the timer tick share, the one naming the sample being
660 /// dispatched and the other the tick's own instant — and each of them only
661 /// answers a question its caller already had. A press that has sat
662 /// still past its `long_press` is already eligible the moment it moves,
663 /// with no intervening tick, so waking the event loop at `eligible_at`
664 /// would buy an idle frame with nothing to do in it. The expiry of a hold
665 /// is the opposite: it is a stored state transition, and if nobody performs
666 /// it the hold stands past the duration the framework promises to trust it
667 /// for.
668 pub fn next_hold_deadline(&self, profile: &GestureProfile) -> Option<EventTime> {
669 self.members
670 .iter()
671 .filter(|m| m.state == MemberState::Held)
672 .filter_map(|m| m.held_since.map(|since| since + profile.max_hold))
673 .min()
674 }
675
676 /// Drop every member whose node is no longer active, reporting them so the
677 /// caller can cancel each individually.
678 ///
679 /// Run every sample: a rebuild mints fresh ids, and a member left pointing
680 /// at a destroyed node would either be fed events forever or silently win.
681 /// The *sequence* dies only when the winner or the captor dies — see
682 /// [`lost_owner`](Self::lost_owner).
683 pub fn revalidate(&mut self, arena: &crate::arena::WidgetArena) -> Vec<WidgetId> {
684 let mut dead = Vec::new();
685 self.members.retain(|member| {
686 if arena.is_active(member.id) {
687 true
688 } else {
689 dead.push(member.id);
690 false
691 }
692 });
693 dead
694 }
695
696 /// Whether the node that owns this sequence — its winner, or failing that
697 /// its captor — has gone away. The sequence itself must then be cancelled.
698 pub fn lost_owner(&self, arena: &crate::arena::WidgetArena) -> bool {
699 let owner = self.winner.or(self.capture);
700 owner.is_some_and(|id| !arena.is_active(id))
701 }
702
703 /// The role and state of every member, for
704 /// [`WidgetTree::sequence_members`](crate::WidgetTree::sequence_members).
705 pub fn member_report(&self) -> Vec<(WidgetId, MemberRole, MemberState)> {
706 self.members
707 .iter()
708 .map(|m| (m.id, m.role, m.state))
709 .collect()
710 }
711
712 /// Every live member of one role, innermost first.
713 pub(crate) fn live_ids_with<F: Fn(&MemberRole) -> bool>(&self, filter: F) -> Vec<WidgetId> {
714 self.members
715 .iter()
716 .filter(|m| m.is_live() && filter(&m.role))
717 .map(|m| m.id)
718 .collect()
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use crate::pointer::{BackendDeviceKey, PointerIdAllocator};
726 use crate::widget_id::WidgetId;
727 use slotmap::KeyData;
728 use teksilo_tokens::{PointerKind, TargetDensity};
729
730 fn tokens() -> teksilo_tokens::InputTokens {
731 teksilo_tokens::InputTokens::for_density(TargetDensity::Compact)
732 }
733
734 fn mouse() -> PointerInfo {
735 PointerInfo::mouse(EventTime::ZERO)
736 }
737
738 fn finger() -> PointerInfo {
739 let id = PointerIdAllocator::global().begin(BackendDeviceKey::DEFAULT, 7);
740 PointerInfo::touch(id, EventTime::ZERO)
741 }
742
743 fn seq(pointer: PointerInfo, action: TouchAction, path: Vec<WidgetId>) -> PointerSequence {
744 PointerSequence::new(pointer, path, action, None, Point::ZERO, EventTime::ZERO)
745 }
746
747 /// Synthetic ids for the pure-logic tests: the sequence only ever compares
748 /// and orders them, so no arena is needed to make them meaningful.
749 fn ids(n: u64) -> Vec<WidgetId> {
750 (0..n)
751 .map(|i| KeyData::from_ffi((1u64 << 32) | (i + 1)).into())
752 .collect()
753 }
754
755 #[test]
756 fn a_mouse_latches_at_five_in_every_configuration() {
757 // The single most important invariant in the package: no frozen
758 // TouchAction, and no density, may retune the mouse drag latch.
759 let tokens = tokens();
760 let profile = tokens.profile(PointerKind::Mouse);
761 for action in [
762 TouchAction::AUTO,
763 TouchAction::NONE,
764 TouchAction::PAN,
765 TouchAction::PAN_X,
766 TouchAction::PAN_Y,
767 TouchAction::PINCH_ZOOM,
768 TouchAction::MANIPULATION,
769 ] {
770 let s = seq(mouse(), action, ids(1));
771 assert_eq!(
772 s.latch_slop(profile),
773 5.0,
774 "a mouse under {action:?} must latch at 5.0"
775 );
776 }
777 }
778
779 #[test]
780 fn slop_precise_reaches_only_a_direct_pointer_under_a_frozen_none() {
781 let tokens = tokens();
782 let touch_profile = tokens.profile(PointerKind::Touch);
783 let none = seq(finger(), TouchAction::NONE, ids(1));
784 assert_eq!(none.latch_slop(touch_profile), touch_profile.slop_precise);
785 let auto = seq(finger(), TouchAction::AUTO, ids(1));
786 assert_eq!(auto.latch_slop(touch_profile), touch_profile.drag_slop);
787 }
788
789 #[test]
790 fn a_mouse_never_has_an_eligible_pan_member() {
791 let tokens = tokens();
792 let profile = tokens.profile(PointerKind::Mouse);
793 let s = seq(mouse(), TouchAction::AUTO, ids(1));
794 assert!(!s.pan_is_eligible(&PanClaim::both(), profile));
795 }
796
797 #[test]
798 fn members_stay_innermost_first_whatever_order_they_enrol_in() {
799 let path = ids(4);
800 let mut s = seq(mouse(), TouchAction::AUTO, path.clone());
801 assert!(s.enrol(path[3], MemberRole::Gesture));
802 assert!(s.enrol(path[1], MemberRole::RawDrag));
803 assert!(s.enrol(path[2], MemberRole::Gesture));
804 let order: Vec<_> = s.members().iter().map(|m| m.id).collect();
805 assert_eq!(order, vec![path[1], path[2], path[3]]);
806 }
807
808 #[test]
809 fn the_dead_zone_boundary_refuses_everything_at_or_above_it() {
810 let path = ids(4);
811 let mut s = PointerSequence::new(
812 mouse(),
813 path.clone(),
814 TouchAction::AUTO,
815 Some(path[2]),
816 Point::ZERO,
817 EventTime::ZERO,
818 );
819 assert!(s.enrol(path[1], MemberRole::Gesture), "below the boundary");
820 assert!(
821 !s.enrol(path[2], MemberRole::Gesture),
822 "the boundary itself"
823 );
824 assert!(!s.enrol(path[3], MemberRole::Gesture), "above the boundary");
825 }
826
827 #[test]
828 fn deciding_rejects_every_other_live_member_exactly_once() {
829 let path = ids(3);
830 let mut s = seq(mouse(), TouchAction::AUTO, path.clone());
831 s.enrol(path[0], MemberRole::Gesture);
832 s.enrol(path[1], MemberRole::Gesture);
833 s.enrol(path[2], MemberRole::Gesture);
834 let losers = s.decide(path[1]);
835 assert_eq!(losers, vec![path[0], path[2]]);
836 assert_eq!(s.winner(), Some(path[1]));
837 // A second decide reports nothing new: the losers are no longer live.
838 assert!(s.decide(path[1]).is_empty());
839 }
840
841 #[test]
842 fn after_long_press_defers_eligibility_and_arms_self_rejection() {
843 let tokens = tokens();
844 let profile = tokens.profile(PointerKind::Touch);
845 let path = ids(2);
846 let mut s = seq(finger(), TouchAction::PAN_Y, path.clone());
847 s.enrol_drag(
848 path[0],
849 MemberRole::Gesture,
850 DragActivation::AfterLongPress,
851 profile,
852 );
853 let member = s.members()[0];
854 assert_eq!(
855 member.eligible_at,
856 Some(EventTime::ZERO + profile.long_press)
857 );
858 assert!(member.rejects_on_tap_slop);
859 assert!(!member.is_eligible_at(EventTime::ZERO));
860 assert!(member.is_eligible_at(EventTime::ZERO + profile.long_press));
861 }
862
863 #[test]
864 fn auto_activation_defers_only_a_coarse_pointer_facing_a_pan() {
865 let path = ids(2);
866
867 // A mouse is always immediate.
868 let mut m = seq(mouse(), TouchAction::AUTO, path.clone());
869 m.enrol(path[1], MemberRole::Pan(PanClaim::vertical()));
870 assert_eq!(
871 m.resolve_activation(DragActivation::Auto),
872 DragActivation::Immediate
873 );
874
875 // A finger with no pan competitor is immediate too.
876 let bare = seq(finger(), TouchAction::AUTO, path.clone());
877 assert_eq!(
878 bare.resolve_activation(DragActivation::Auto),
879 DragActivation::Immediate
880 );
881
882 // A finger facing a pan claimant defers.
883 let mut contested = seq(finger(), TouchAction::AUTO, path.clone());
884 contested.enrol(path[1], MemberRole::Pan(PanClaim::vertical()));
885 assert_eq!(
886 contested.resolve_activation(DragActivation::Auto),
887 DragActivation::AfterLongPress
888 );
889 }
890
891 #[test]
892 fn a_pan_wins_on_the_dominant_axis_and_only_where_permitted() {
893 let tokens = tokens();
894 let profile = tokens.profile(PointerKind::Touch);
895 let slop = profile.pan_slop.expect("touch pans");
896 let path = ids(1);
897
898 let mut s = seq(finger(), TouchAction::PAN, path);
899 s.set_last_position(Point::new(slop + 10.0, slop + 1.0));
900 assert_eq!(
901 s.pan_axis_past_slop(&PanClaim::both(), profile),
902 Some(Axis::X),
903 "the axis that travelled further wins the diagonal"
904 );
905
906 // The frozen action forbids X, so the same travel resolves to Y.
907 let mut only_y = seq(finger(), TouchAction::PAN_Y, ids(1));
908 only_y.set_last_position(Point::new(slop + 10.0, slop + 1.0));
909 assert_eq!(
910 only_y.pan_axis_past_slop(&PanClaim::both(), profile),
911 Some(Axis::Y)
912 );
913 }
914
915 #[test]
916 fn a_hold_expires_at_max_hold_and_not_before() {
917 let tokens = tokens();
918 let profile = tokens.profile(PointerKind::Mouse);
919 let path = ids(1);
920 let mut s = seq(mouse(), TouchAction::AUTO, path.clone());
921 s.enrol(path[0], MemberRole::Gesture);
922 s.hold(path[0], EventTime::ZERO);
923 assert!(s.is_held());
924
925 s.expire_holds(EventTime::from_duration(profile.max_hold / 2), profile);
926 assert!(s.is_held(), "a hold survives until max_hold");
927
928 s.expire_holds(EventTime::from_duration(profile.max_hold), profile);
929 assert!(!s.is_held(), "and is released at it");
930 assert_eq!(s.members()[0].state, MemberState::Possible);
931 }
932
933 #[test]
934 fn revalidate_drops_dead_members_one_at_a_time() {
935 // The tree-level half — losing the captor cancels the whole sequence —
936 // is pinned in `gesture_dispatch_impl`; in a real tree a member is
937 // always an ancestor of the captor and so cannot die on its own, which
938 // is why the per-member rule is asserted here.
939 let mut arena = crate::arena::WidgetArena::new();
940 let live = arena.insert(Box::new(crate::test_widgets::FillWidget::new()));
941 let doomed = arena.insert(Box::new(crate::test_widgets::FillWidget::new()));
942 let mut s = seq(mouse(), TouchAction::AUTO, vec![doomed, live]);
943 s.enrol(doomed, MemberRole::Gesture);
944 s.enrol(live, MemberRole::Gesture);
945 s.set_capture(Some(live));
946
947 assert!(s.revalidate(&arena).is_empty(), "nothing has died yet");
948 arena.destroy(doomed);
949
950 assert_eq!(s.revalidate(&arena), vec![doomed]);
951 assert_eq!(
952 s.members().iter().map(|m| m.id).collect::<Vec<_>>(),
953 vec![live],
954 "only the dead member is dropped"
955 );
956 assert!(!s.lost_owner(&arena), "the captor is still alive");
957
958 arena.destroy(live);
959 assert!(
960 s.lost_owner(&arena),
961 "losing the captor is what cancels the sequence"
962 );
963 }
964
965 #[test]
966 fn the_tap_boundary_is_a_radius_for_a_mouse_and_bounds_for_a_finger() {
967 let tokens = tokens();
968 let mouse_profile = tokens.profile(PointerKind::Mouse);
969 let touch_profile = tokens.profile(PointerKind::Touch);
970 assert_eq!(
971 TapBoundary::for_pointer(&mouse(), mouse_profile),
972 TapBoundary::Radius(mouse_profile.tap_slop)
973 );
974 assert_eq!(
975 TapBoundary::for_pointer(&finger(), touch_profile),
976 TapBoundary::Bounds
977 );
978
979 // A coarse press well past tap_slop but still inside the control has
980 // NOT left the boundary — that is the whole point of `Bounds`.
981 let bounds = teksilo_canvas::Rect::new(0.0, 0.0, 100.0, 100.0);
982 assert!(!TapBoundary::Bounds.left(
983 Point::new(50.0, 50.0),
984 Point::new(50.0, 80.0),
985 Some(bounds),
986 touch_profile,
987 ));
988 assert!(TapBoundary::Bounds.left(
989 Point::new(50.0, 50.0),
990 Point::new(50.0, 120.0),
991 Some(bounds),
992 touch_profile,
993 ));
994 // With no bounds to test, it falls back to the radius rather than
995 // letting the press travel anywhere.
996 assert!(TapBoundary::Bounds.left(
997 Point::new(50.0, 50.0),
998 Point::new(50.0, 80.0),
999 None,
1000 touch_profile,
1001 ));
1002 }
1003
1004 /// A press accepted through a `Widget::hit_outset` begins outside the node
1005 /// it was accepted for, so the node's rectangle cannot be its boundary:
1006 /// with `Bounds` taken literally the press is "already gone" on arrival and
1007 /// the tap can never complete. It falls back to the pointer's own radius
1008 /// around where it landed, and sliding onto the control keeps it alive.
1009 #[test]
1010 fn a_press_that_began_outside_the_node_is_bounded_by_its_own_radius() {
1011 let tokens = tokens();
1012 let touch_profile = tokens.profile(PointerKind::Touch);
1013 let rect = teksilo_canvas::Rect::new(0.0, 0.0, 12.0, 12.0);
1014 // Landed 4 dp past the trailing edge — inside the outset ring the
1015 // arena offered it, outside the rectangle.
1016 let origin = Point::new(16.0, 6.0);
1017 assert!(
1018 !TapBoundary::Bounds.left(origin, origin, Some(rect), touch_profile),
1019 "a press cannot have left the boundary on the sample that opened it",
1020 );
1021 assert!(
1022 !TapBoundary::Bounds.left(origin, Point::new(6.0, 6.0), Some(rect), touch_profile),
1023 "sliding onto the control keeps the press",
1024 );
1025 assert!(
1026 TapBoundary::Bounds.left(
1027 origin,
1028 Point::new(16.0 + touch_profile.tap_slop + 1.0, 6.0),
1029 Some(rect),
1030 touch_profile,
1031 ),
1032 "and past the radius it is gone, so the abort gesture still works",
1033 );
1034 }
1035
1036 /// The union term, on its own.
1037 ///
1038 /// The radius half of the outside-origin rule is a *travel* allowance, and
1039 /// on a small control it runs out before the finger has finished arriving:
1040 /// a contact that lands in the outset ring of a wide control and then
1041 /// slides well past `tap_slop` **onto** the control is further from its
1042 /// origin than the radius permits and squarely inside the rectangle. Only
1043 /// the union with the node's bounds keeps that press alive; with the
1044 /// `!rect.contains(position)` term gone, the radius alone kills a press
1045 /// that is sitting on the middle of the thing it is pressing.
1046 #[test]
1047 fn sliding_onto_the_control_keeps_a_press_the_radius_alone_would_lose() {
1048 let tokens = tokens();
1049 let touch_profile = tokens.profile(PointerKind::Touch);
1050 let rect = teksilo_canvas::Rect::new(0.0, 0.0, 100.0, 20.0);
1051 // 4 dp past the trailing edge — inside the outset ring, outside the rect.
1052 let origin = Point::new(104.0, 10.0);
1053 // 24 dp of travel, against a Touch `tap_slop` of 18: past the radius,
1054 // and 20 dp inside the control.
1055 let onto = Point::new(80.0, 10.0);
1056 assert!(
1057 super::super::distance(origin, onto) > touch_profile.tap_slop,
1058 "the probe is only discriminating while the travel exceeds tap_slop",
1059 );
1060 assert!(rect.contains(onto), "…and lands inside the control");
1061 assert!(
1062 !TapBoundary::Bounds.left(origin, onto, Some(rect), touch_profile),
1063 "a finger resting on the control it pressed has not left it",
1064 );
1065 }
1066
1067 /// Which rule applies is decided by the **origin**, not by where the
1068 /// pointer is now.
1069 ///
1070 /// The two questions agree on most samples, which is why the distinction
1071 /// has to be pinned on the one geometry where they cannot: a press that
1072 /// began *inside* the node and has moved a short way outside it. The rule
1073 /// for that press is the rectangle — it left the moment it crossed the
1074 /// edge, however little it travelled — while a press that began outside is
1075 /// allowed the pointer's radius around where it landed, so with the same
1076 /// `position` it has not left at all. Reading `position` instead of
1077 /// `origin` collapses both onto the second answer and silently hands every
1078 /// coarse press that starts inside a control a `tap_slop` grace band
1079 /// outside it, which is exactly the slop `Bounds` exists to replace.
1080 #[test]
1081 fn the_boundary_rule_is_chosen_by_where_the_press_began() {
1082 let tokens = tokens();
1083 let touch_profile = tokens.profile(PointerKind::Touch);
1084 let rect = teksilo_canvas::Rect::new(0.0, 0.0, 100.0, 20.0);
1085 // One sample, 4 dp past the trailing edge, reached from two origins —
1086 // both within `tap_slop` of it, so the radius rule cannot fail either.
1087 let position = Point::new(104.0, 10.0);
1088 let from_inside = Point::new(96.0, 10.0);
1089 let from_outside = Point::new(108.0, 10.0);
1090 assert!(rect.contains(from_inside), "the first press began inside");
1091 assert!(
1092 !rect.contains(from_outside) && !rect.contains(position),
1093 "the second began outside, and neither sample is in the rect",
1094 );
1095 for origin in [from_inside, from_outside] {
1096 assert!(
1097 super::super::distance(origin, position) < touch_profile.tap_slop,
1098 "the probe only discriminates while the travel is inside tap_slop",
1099 );
1100 }
1101
1102 assert!(
1103 TapBoundary::Bounds.left(from_inside, position, Some(rect), touch_profile),
1104 "a press that began inside the node is bounded by the node: crossing \
1105 the edge ends it, with no radius grace outside",
1106 );
1107 assert!(
1108 !TapBoundary::Bounds.left(from_outside, position, Some(rect), touch_profile),
1109 "a press that began outside is bounded by its own radius, and this \
1110 one has barely moved",
1111 );
1112 }
1113}