Skip to main content

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}