Skip to main content

teksilo_core/widget_tree/
pan_arbiter.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Pan delivery along a claimant-only chain, the tree's fling pump, the single
5//! pinch ingress, and the palm fallback.
6//!
7//! # A pan is a `Scroll`
8//!
9//! A finger dragging a scrollable produces a synthesised
10//! [`WidgetEvent::Scroll`] — the very event a mouse wheel produces — pushed
11//! through [`dispatch_scroll`](WidgetTree::dispatch_scroll), the same ingress
12//! door a backend uses, with
13//! [`ScrollSource::TouchPan`] on it. No
14//! widget gains an `on_pan`, no widget gains a second delta path, and every
15//! surface that already implements `on_scroll` scrolls under a finger without
16//! being touched.
17//!
18//! What the source changes is only the **route**: [`ScrollDelivery::for_source`]
19//! answers [`ClaimantChain`](ScrollDelivery::ClaimantChain) for a pan and
20//! [`Bubble`](ScrollDelivery::Bubble) for everything else, so wheel and
21//! trackpad keep the route they have always had.
22//!
23//! # The boundary rule, which is the point of this module
24//!
25//! **The claim stays with the inner container for the whole gesture.** Per
26//! event, delivery is all-or-nothing exactly as the wheel is today:
27//! `scroll_response` answers `Handled` if the axis absorbed anything and
28//! `Ignored` at a hard boundary. On `Ignored` the router re-delivers **the same
29//! whole event** to the next container outward.
30//!
31//! There is **no fractional residual and no back-channel**. Both are tempting
32//! and both are wrong here. [`EventResponse`](crate::event::EventResponse) is
33//! binary — it carries no "I took 30 of your 50 pixels" — so a residual would
34//! mean a new return type on every scroll handler in the workspace, i.e. a
35//! delta-path change in every scrollable, to buy a difference the user sees
36//! for one frame of one gesture. The chain hands over events, never remainders.
37//!
38//! Three consequences follow, and each is a test:
39//!
40//! * an [`OverscrollBehavior::Contain`] claimant **stops** the chain, even
41//!   having absorbed nothing;
42//! * a claimant the chain moves past receives **no `PointerCancel` and no
43//!   pan-ended** — it did not lose the gesture, it declined one event, and the
44//!   next event is offered to it first again;
45//! * a fling crossing a boundary chains identically, because it is dispatched
46//!   through the same door and walks the same list.
47//!
48//! # The chain visits only the claimants
49//!
50//! [`ScrollDelivery::ClaimantChain`] walks the frozen `pan_candidates` list and
51//! **never the generic bubble**. This is not an optimisation. The bubble path
52//! from a nested list to the window root passes through nodes that handle
53//! `on_scroll` without being scroll containers at all — a `SpinBox` increments
54//! its value on wheel, a `TabBar` remaps wheel to horizontal tab scrolling. A
55//! boundary pan that reached either would change a number or switch a tab,
56//! silently, because the user ran out of list.
57//! [`PanClaim`] is the declaration that
58//! distinguishes them, and the chain reads nothing else.
59//!
60//! # One pinch ingress
61//!
62//! [`dispatch_os_gesture`](WidgetTree::dispatch_os_gesture) is the only way a
63//! [`GestureEvent::PinchStarted`] / `PinchChanged` / `PinchEnded` reaches a
64//! widget. The OS trackpad stream and the two-contact
65//! [`TouchPinchRecognizer`] both go through it, so `on_pinch` cannot be
66//! reachable on one input and dead on the other.
67//!
68//! Reference: `docs/kinetic-scrolling.md`.
69
70use std::collections::HashMap;
71
72use teksilo_canvas::{Point, Vec2};
73
74use crate::WidgetId;
75use crate::event::{Modifiers, ScrollDelta, WidgetEvent};
76use crate::gesture::{GestureEvent, PalmWatch, PanRecognizer, TouchPinchRecognizer};
77use crate::kinetic::FlingDriver;
78use crate::overscroll::OverscrollBehavior;
79use crate::pointer::touch_action::PanClaim;
80use crate::pointer::{CancelReason, PointerId, ScrollPhase, ScrollSample, ScrollSource};
81
82use super::WidgetTree;
83
84/// How a scroll event finds its receivers.
85///
86/// Decided from the sample's [`ScrollSource`], so nothing has to remember which
87/// producer gets which route: the wheel and the trackpad keep
88/// [`Bubble`](Self::Bubble) — what every scroll did before the touch programme
89/// — and a pan synthesised from a direct pointer takes
90/// [`ClaimantChain`](Self::ClaimantChain).
91#[derive(Copy, Clone, PartialEq, Eq, Debug)]
92pub enum ScrollDelivery {
93    /// Hit-test, then walk the ancestor chain, offering the event to every
94    /// handler on the way up. What a wheel notch does.
95    Bubble,
96    /// Walk **only** the frozen list of
97    /// [`PanClaim`] holders, from the
98    /// claimant outward, offering the whole event to each in turn until one
99    /// answers `Handled` or a [`Contain`](OverscrollBehavior::Contain)
100    /// claimant stops the walk. What a finger does.
101    ClaimantChain,
102}
103
104impl ScrollDelivery {
105    /// The route a sample from `source` takes.
106    ///
107    /// Only [`ScrollSource::TouchPan`] chains along claimants — a
108    /// programmatic scroll, a wheel notch and a trackpad stream all bubble,
109    /// because none of them belongs to a pan claimant and all three have
110    /// always reached ordinary `on_scroll` handlers.
111    pub fn for_source(source: ScrollSource) -> Self {
112        match source {
113            ScrollSource::TouchPan => Self::ClaimantChain,
114            _ => Self::Bubble,
115        }
116    }
117}
118
119/// One direct pointer's live pan.
120#[derive(Debug)]
121struct PanSession {
122    /// The competitor: the innermost eligible claim, its slop, its tracker.
123    recognizer: PanRecognizer,
124    /// Every claimant from the hit target outward, innermost first, frozen at
125    /// the press — a mid-gesture rebuild must not silently re-route a pan that
126    /// is already under way.
127    candidates: Vec<(WidgetId, PanClaim)>,
128    /// The claimant, once the arbitration has decided for one. `None` while the
129    /// press could still turn out to be a tap.
130    owner: Option<WidgetId>,
131    /// Whether a [`ScrollPhase::Began`] has gone out, so the samples after it
132    /// are [`Changed`](ScrollPhase::Changed).
133    began: bool,
134    /// Modifiers at the press, carried onto every synthesised sample so a
135    /// Ctrl-held pan reads the same as a Ctrl-held wheel.
136    modifiers: Modifiers,
137}
138
139impl PanSession {
140    /// The chain to walk: the claimant and everything outward of it.
141    ///
142    /// The whole candidate list before a claimant is decided — which never
143    /// reaches a delivery, since nothing is delivered until one is.
144    fn chain(&self) -> &[(WidgetId, PanClaim)] {
145        match self
146            .owner
147            .and_then(|owner| self.candidates.iter().position(|(id, _)| *id == owner))
148        {
149            Some(index) => &self.candidates[index..],
150            None => &self.candidates,
151        }
152    }
153}
154
155/// Everything the touch-motion layer owns for one tree: live pans, live
156/// coasts, the pinch, and the palm watches.
157///
158/// One struct rather than seven fields on [`WidgetTree`] — the tree is already
159/// wide, and these seven have one lifetime and one owner between them.
160#[derive(Debug)]
161pub(crate) struct TouchMotion {
162    /// One entry per direct pointer whose press found an eligible pan claim.
163    pans: HashMap<PointerId, PanSession>,
164    /// The tree's fling pump.
165    driver: FlingDriver,
166    /// The chain a coasting target chains along, frozen when the fling started
167    /// — a coast must chain exactly as the pan that launched it did.
168    fling_chains: HashMap<WidgetId, Vec<(WidgetId, PanClaim)>>,
169    /// Whether the last claimant-chain walk found anyone able to move.
170    ///
171    /// A one-slot result, written by the walk and read by the fling pump
172    /// immediately after its dispatch has drained (the pump runs at dispatch
173    /// depth zero, so "immediately after" is exact). It exists because
174    /// [`dispatch_scroll`](WidgetTree::dispatch_scroll) returns nothing — the
175    /// alternative was a second, synchronous delivery path for flings, and one
176    /// delivery path is the whole point.
177    last_chain_absorbed: bool,
178    /// The one pinch recognizer for the window. Not per node: a pinch is
179    /// arbitrated by contact count, not by which widget each finger landed on.
180    pinch: TouchPinchRecognizer,
181    /// The widget the live pinch is addressed to, resolved at `PinchStarted`
182    /// and held, so `Changed` / `Ended` cannot drift to another node as the
183    /// fingers move.
184    pinch_target: Option<WidgetId>,
185    /// One watch per live direct pointer, for the palm fallback.
186    palms: HashMap<PointerId, PalmWatch>,
187    /// Whether the backend classifies palms itself. When it does the fallback
188    /// heuristic is off: the digitiser's answer is better than a guess, and
189    /// `PointerTable::would_admit` has already refused what it flagged.
190    backend_reports_palm: bool,
191}
192
193impl TouchMotion {
194    /// A layer wired to `scheduler`, so a coast keeps waking the event loop
195    /// through the tree's existing per-frame path rather than a second one.
196    pub(crate) fn new(scheduler: crate::frame_tick_scheduler::FrameTickScheduler) -> Self {
197        Self {
198            pans: HashMap::new(),
199            driver: FlingDriver::with_scheduler(scheduler),
200            fling_chains: HashMap::new(),
201            last_chain_absorbed: false,
202            pinch: TouchPinchRecognizer::new(),
203            pinch_target: None,
204            palms: HashMap::new(),
205            backend_reports_palm: false,
206        }
207    }
208}
209
210impl WidgetTree {
211    // -----------------------------------------------------------------
212    // Pan sessions
213    // -----------------------------------------------------------------
214
215    /// Open a pan session for the press being dispatched, if any claimant along
216    /// the hit path is eligible.
217    ///
218    /// Called from the `PointerDown` arm straight after
219    /// [`begin_sequence`](Self::begin_sequence), so the frozen `TouchAction`
220    /// and the enrolled pan members are already in hand and the chain recorded
221    /// here is exactly the one the arbitration picks its winner from.
222    ///
223    /// Also the point at which a press **stops a coast**: catching a flying
224    /// list is a reflex, and a list that ignored the catch would read as
225    /// broken.
226    pub(super) fn begin_pan(&mut self, target: WidgetId, position: Point, modifiers: Modifiers) {
227        let pointer = self.current_input.pointer;
228        let action = self.effective_touch_action(target);
229        let candidates = self.pan_candidates(target, action);
230
231        // Catching the list: every claimant under this press stops coasting.
232        // Before the pointer-kind gate on purpose — a *mouse* click on a
233        // coasting list must stop it too, and a mouse never opens a pan
234        // session.
235        for (id, _) in &candidates {
236            self.stop_fling(*id);
237        }
238
239        if !pointer.kind.is_direct() {
240            // A mouse has no `pan_slop` at all, so it could never arm a pan.
241            // Leaving here keeps that a fact about the router rather than an
242            // accident of the profile.
243            return;
244        }
245        let profile = self.current_profile();
246        if profile.pan_slop.is_none() {
247            return;
248        }
249        let Some((_, claim)) = candidates
250            .iter()
251            .copied()
252            .find(|(_, claim)| claim.devices.contains(pointer.kind))
253        else {
254            return;
255        };
256
257        let mut recognizer = PanRecognizer::new(claim);
258        recognizer.press(position, self.sequence_now());
259        self.touch_motion.pans.insert(
260            pointer.id,
261            PanSession {
262                recognizer,
263                candidates,
264                owner: None,
265                began: false,
266                modifiers,
267            },
268        );
269    }
270
271    /// Whether `pointer`'s press opened a pan session — i.e. whether a claimant
272    /// along its hit path accepts this pointer kind.
273    ///
274    /// Read by the press-feedback delay: a press that nothing can scroll out
275    /// from under has no ambiguity to wait out, so only a press *inside a
276    /// claimant* withholds its visual. See
277    /// `WidgetTree::begin_press`.
278    pub(crate) fn pan_session_open(&self, pointer: PointerId) -> bool {
279        self.touch_motion.pans.contains_key(&pointer)
280    }
281
282    /// The arbitration decided for a pan claimant. Record it: from here on
283    /// every sample for this pointer is delivered as a synthesised scroll.
284    pub(super) fn note_pan_claimed(&mut self, pointer: PointerId, owner: WidgetId) {
285        if let Some(session) = self.touch_motion.pans.get_mut(&pointer) {
286            session.owner = Some(owner);
287        }
288    }
289
290    /// Feed one move into this pointer's pan session and, once the claim has
291    /// been decided, deliver the movement as a scroll.
292    ///
293    /// A no-op for a pointer with no session, which is every mouse.
294    pub(super) fn advance_pan(&mut self, position: Point, ops: &mut dyn crate::window::WindowOps) {
295        let pointer = self.current_pointer_id();
296        let now = self.sequence_now();
297        let coalesced = self.current_input.coalesced.clone();
298
299        let Some(session) = self.touch_motion.pans.get_mut(&pointer) else {
300            return;
301        };
302        let delta = session.recognizer.feed_coalesced(&coalesced, position, now);
303        if session.owner.is_none() {
304            // Undecided: the tracker is still fed, so a claim taken on the very
305            // next sample already has a velocity behind it.
306            return;
307        }
308        if delta.x == 0.0 && delta.y == 0.0 {
309            // Nothing moved on a claimed axis. Spending a whole chain walk to
310            // move nothing would be waste, and it would burn the `Began` phase
311            // on a sample the receiver cannot act on.
312            return;
313        }
314        let phase = if session.began {
315            ScrollPhase::Changed
316        } else {
317            session.began = true;
318            ScrollPhase::Began
319        };
320        self.deliver_pan(pointer, delta, phase, position, ops);
321    }
322
323    /// The press ended. Hand off to a fling if the claim is kinetic and the
324    /// release was fast enough, then close the session.
325    pub(super) fn end_pan(&mut self, position: Point, ops: &mut dyn crate::window::WindowOps) {
326        let pointer = self.current_pointer_id();
327        let now = self.sequence_now();
328        let profile = self.current_profile();
329        let Some(mut session) = self.touch_motion.pans.remove(&pointer) else {
330            return;
331        };
332        let (Some(owner), true) = (session.owner, session.began) else {
333            // The press never became a pan — a tap, or a drag someone else won.
334            return;
335        };
336        session.recognizer.feed(position, now);
337        let velocity = session.recognizer.velocity(&profile);
338        let chain: Vec<(WidgetId, PanClaim)> = session.chain().to_vec();
339
340        // One `Ended` closes the gesture for the receiver whether or not a
341        // fling follows, so a scrollable can release its rubber band. The
342        // session is already out of the map, so the chain travels with the
343        // event rather than being looked up.
344        self.dispatch_chained_scroll(
345            chain.clone(),
346            Vec2::ZERO,
347            ScrollPhase::Ended,
348            Some(position),
349            session.modifiers,
350            ops,
351        );
352
353        if !session.recognizer.should_fling(velocity, &profile) {
354            return;
355        }
356        // A fling is the same scroll the pan was, so it carries the same sign
357        // convention: the content follows the finger, so the offset moves
358        // against it — which `deliver_pan` does by negating, and which the
359        // driver's own deltas therefore have to arrive pre-negated for.
360        self.start_fling(owner, Vec2::new(-velocity.x, -velocity.y), chain);
361    }
362
363    /// The claimants `pointer`'s live pan would chain along, or an empty list
364    /// when it has no session. Read by the cancel funnel, which stops every
365    /// coast the revoked gesture could have started.
366    pub(super) fn pan_chain_ids(&self, pointer: PointerId) -> Vec<WidgetId> {
367        self.touch_motion
368            .pans
369            .get(&pointer)
370            .map(|session| session.chain().iter().map(|(id, _)| *id).collect())
371            .unwrap_or_default()
372    }
373
374    /// Forget this pointer's pan session without delivering anything.
375    ///
376    /// The cancel funnel's path: the interaction was taken away, so there is no
377    /// release to hand a velocity to.
378    pub(super) fn abandon_pan(&mut self, pointer: PointerId) {
379        self.touch_motion.pans.remove(&pointer);
380    }
381
382    // -----------------------------------------------------------------
383    // Delivery
384    // -----------------------------------------------------------------
385
386    /// Deliver one synthesised scroll for `pointer`'s live pan.
387    ///
388    /// `delta` is the movement of the **contact**; the scroll offset moves
389    /// against it, so this negates — a finger dragging down moves the content
390    /// down, which is the offset going up, which is exactly what
391    /// `event_translation` already does for the wheel.
392    pub(super) fn deliver_pan(
393        &mut self,
394        pointer: PointerId,
395        delta: Vec2,
396        phase: ScrollPhase,
397        position: Point,
398        ops: &mut dyn crate::window::WindowOps,
399    ) {
400        let Some(session) = self.touch_motion.pans.get(&pointer) else {
401            return;
402        };
403        let chain: Vec<(WidgetId, PanClaim)> = session.chain().to_vec();
404        let modifiers = session.modifiers;
405        self.dispatch_chained_scroll(
406            chain,
407            Vec2::new(-delta.x, -delta.y),
408            phase,
409            Some(position),
410            modifiers,
411            ops,
412        );
413    }
414
415    /// Push one [`ScrollSource::TouchPan`] sample through the ordinary scroll
416    /// door with `chain` armed as its route.
417    ///
418    /// The chain is parked for the duration of the dispatch rather than looked
419    /// up inside it, because the two producers know different things: a pan
420    /// has a live session to read, a fling has only the frozen chain it was
421    /// launched with.
422    fn dispatch_chained_scroll(
423        &mut self,
424        chain: Vec<(WidgetId, PanClaim)>,
425        offset_delta: Vec2,
426        phase: ScrollPhase,
427        position: Option<Point>,
428        modifiers: Modifiers,
429        ops: &mut dyn crate::window::WindowOps,
430    ) {
431        let pointer = self
432            .pointers
433            .get(self.current_pointer_id())
434            .map(|e| e.info)
435            .unwrap_or(self.current_input.pointer);
436        let sample = ScrollSample {
437            delta: ScrollDelta::Pixels {
438                x: offset_delta.x,
439                y: offset_delta.y,
440            },
441            position,
442            phase,
443            source: ScrollSource::TouchPan,
444            pointer,
445            modifiers,
446        };
447        self.armed_chain = Some(chain);
448        self.dispatch_scroll_with_ops(sample, ops);
449    }
450
451    /// Route a scroll along [`ScrollDelivery::ClaimantChain`], and record
452    /// whether anyone took it.
453    ///
454    /// The chain is whatever the producer armed; failing that — an
455    /// externally-produced `TouchPan` sample, which is what a platform backend
456    /// that recognises pans itself would send — it is derived from the sample's
457    /// own position, which is the same list the press would have frozen.
458    pub(super) fn route_scroll_along_chain(
459        &mut self,
460        event: &WidgetEvent,
461        position: Option<Point>,
462        ops: &mut dyn crate::window::WindowOps,
463    ) {
464        let chain = match self.armed_chain.take() {
465            Some(chain) => chain,
466            None => {
467                let pointer = self.current_input.pointer;
468                let Some(target) = position.and_then(|p| self.hit_test_for(p, &pointer)) else {
469                    return;
470                };
471                let action = self.effective_touch_action(target);
472                self.pan_candidates(target, action)
473            }
474        };
475        let absorbed = self.walk_claimant_chain(&chain, event, ops);
476        self.touch_motion.last_chain_absorbed = absorbed;
477    }
478
479    /// Offer `event` to each claimant in turn, innermost first, and report
480    /// whether one took it.
481    ///
482    /// The one place the boundary rule is written down:
483    ///
484    /// * `Handled` — the claimant absorbed something; stop.
485    /// * `Ignored` + [`Contain`](OverscrollBehavior::Contain) — the claimant
486    ///   absorbed nothing but refuses to let the event out; stop, and report
487    ///   the event as consumed, because a contained scroll is not the next
488    ///   container's business.
489    /// * `Ignored` + [`Chain`](OverscrollBehavior::Chain) — re-deliver the
490    ///   **same whole event** to the next claimant outward.
491    ///
492    /// A claimant the chain moves past is told nothing at all. It has not lost
493    /// the gesture — the next sample is offered to it first again — so a cancel
494    /// or a pan-ended here would be a lie, and would tear down the scrollable's
495    /// own state in the middle of a drag it is still winning.
496    fn walk_claimant_chain(
497        &mut self,
498        chain: &[(WidgetId, PanClaim)],
499        event: &WidgetEvent,
500        ops: &mut dyn crate::window::WindowOps,
501    ) -> bool {
502        for &(id, _) in chain {
503            if !self.arena.is_active(id) {
504                // A claimant destroyed mid-gesture is skipped rather than
505                // ending the chain: the containers outward of it are still
506                // there and still entitled to the event.
507                continue;
508            }
509            if self.dispatch_to_widget_direct_returning_handled(id, event, &mut *ops) {
510                crate::trace_input!(Samples, "pan absorbed by {id:?}");
511                return true;
512            }
513            if self.overscroll_behavior_of(id) == OverscrollBehavior::Contain {
514                crate::trace_input!(Samples, "pan contained at {id:?}: the chain stops here");
515                return true;
516            }
517        }
518        false
519    }
520
521    /// What `id` declared about letting a boundary scroll out.
522    fn overscroll_behavior_of(&self, id: WidgetId) -> OverscrollBehavior {
523        self.arena
524            .get(id)
525            .map(|n| n.overscroll_behavior)
526            .unwrap_or(OverscrollBehavior::Chain)
527    }
528
529    // -----------------------------------------------------------------
530    // The fling pump
531    // -----------------------------------------------------------------
532
533    /// Begin coasting `target` at `velocity` (in scroll-offset space),
534    /// chaining along `chain` when it runs out.
535    ///
536    /// Public so a surface that drives its own release — a `SceneView`, a
537    /// custom canvas — can hand the tree a coast instead of integrating one.
538    /// `prefers_reduced_motion` collapses it to nothing: a re-dispatched fling
539    /// has no settle to fall back to, because the target already has the
540    /// content where the finger left it.
541    pub fn start_fling(
542        &mut self,
543        target: WidgetId,
544        velocity: Vec2,
545        chain: Vec<(WidgetId, PanClaim)>,
546    ) {
547        let physics = self.effective_theme.input.scroll_physics.physics;
548        self.touch_motion
549            .driver
550            .set_tokens(&self.effective_theme.input.scroll_physics);
551        self.touch_motion
552            .driver
553            .set_reduced_motion(self.prefers_reduced_motion);
554        self.touch_motion
555            .driver
556            .start(target, velocity, physics, self.input_now());
557        if self.touch_motion.driver.is_flinging(target) {
558            self.touch_motion.fling_chains.insert(target, chain);
559        } else {
560            self.touch_motion.fling_chains.remove(&target);
561        }
562    }
563
564    /// Stop `target`'s coast, if it has one. Idempotent.
565    pub fn stop_fling(&mut self, target: WidgetId) {
566        self.touch_motion.driver.stop(target);
567        self.touch_motion.fling_chains.remove(&target);
568    }
569
570    /// Whether `target` is coasting.
571    pub fn is_flinging(&self, target: WidgetId) -> bool {
572        self.touch_motion.driver.is_flinging(target)
573    }
574
575    /// Advance every coast to `now` and dispatch what it produced.
576    ///
577    /// Each delta goes through the same door and along the same frozen chain a
578    /// pan does, so a flick that runs out of inner list scrolls the outer one.
579    /// A coast the whole chain declines is **stopped**: it has nothing left to
580    /// move, and spinning a simulation against a wall is a frame budget spent
581    /// on nothing.
582    pub fn tick_flings(&mut self, now: std::time::Instant) {
583        let mut noop = crate::window::NoopWindowOps;
584        self.tick_flings_with_ops(now, &mut noop);
585    }
586
587    /// [`tick_flings`](Self::tick_flings) with the caller's
588    /// [`WindowOps`](crate::window::WindowOps) sink.
589    pub fn tick_flings_with_ops(
590        &mut self,
591        now: std::time::Instant,
592        ops: &mut dyn crate::window::WindowOps,
593    ) {
594        if self.touch_motion.driver.is_empty() {
595            return;
596        }
597        let now = self.event_time_for(now);
598        let steps = self.touch_motion.driver.tick(now);
599        for (target, delta) in steps {
600            let Some(chain) = self.touch_motion.fling_chains.get(&target).cloned() else {
601                continue;
602            };
603            // A coast has no contact behind it, so it is addressed by the chain
604            // alone; the position is the target's own centre, which is what a
605            // receiver that reads one (a zoom-at-cursor handler) should see.
606            let position = self
607                .arena
608                .is_active(target)
609                .then(|| self.bounds(target).center());
610            self.dispatch_chained_scroll(
611                chain,
612                delta,
613                ScrollPhase::Fling,
614                position,
615                Modifiers::NONE,
616                &mut *ops,
617            );
618            if !self.touch_motion.last_chain_absorbed {
619                // Nobody on the chain could move: the coast has reached the
620                // outermost boundary and is over.
621                self.stop_fling(target);
622            }
623        }
624    }
625
626    // -----------------------------------------------------------------
627    // Pinch
628    // -----------------------------------------------------------------
629
630    /// Route a pre-recognized gesture that carries no position of its own.
631    ///
632    /// **The single ingress for pinch.** The OS trackpad stream
633    /// (`PinchGesture` / `RotationGesture`, which winit reports without a
634    /// position) and the two-contact [`TouchPinchRecognizer`] both arrive here,
635    /// so `on_pinch` cannot be reachable on one input and dead on the other.
636    ///
637    /// `at` is the gesture's own position when it has one — the touch
638    /// recognizer supplies the contact midpoint. With `None` the route is the
639    /// hover owner's last position, then the hovered widget, then the focused
640    /// one, then the root: the OS says only *that* a pinch happened, and the
641    /// pointer that could have said where is a trackpad, whose cursor is the
642    /// hover owner.
643    pub fn dispatch_os_gesture(
644        &mut self,
645        gesture: GestureEvent,
646        at: Option<Point>,
647        ops: &mut dyn crate::window::WindowOps,
648    ) {
649        let Some(target) = self.os_gesture_target(at) else {
650            return;
651        };
652        self.dispatch_to_widget(target, &WidgetEvent::Gesture { gesture }, ops);
653    }
654
655    /// Who a positionless OS gesture is addressed to.
656    fn os_gesture_target(&self, at: Option<Point>) -> Option<WidgetId> {
657        let hover_position = self.pointers.hover_owner().map(|e| e.position);
658        let pointer = self.current_input.pointer;
659        at.or(hover_position)
660            .and_then(|p| self.hit_test_for(p, &pointer))
661            .or_else(|| self.hovered_id())
662            .or(self.focused)
663            .or_else(|| self.roots().first().copied())
664    }
665
666    /// Feed one contact event into the window's pinch recognizer and dispatch
667    /// whatever it produced.
668    ///
669    /// Split from the pan path deliberately: a pinch is arbitrated by contact
670    /// count rather than by the press arbitration, so it neither enrols in a
671    /// [`PointerSequence`](crate::gesture::PointerSequence) nor consults one.
672    pub(super) fn feed_pinch(
673        &mut self,
674        phase: PinchFeed,
675        position: Point,
676        ops: &mut dyn crate::window::WindowOps,
677    ) {
678        let pointer = self.current_input.pointer;
679        if !pointer.kind.is_direct() {
680            return;
681        }
682        let recognized = match phase {
683            PinchFeed::Down => {
684                // The subtree must permit pinch-zoom, read at the contact's own
685                // position and folded from the root down like every other
686                // `TouchAction` question. Asked only on the press: once a pinch
687                // is running, a finger straying over a `touch-action: none`
688                // sibling must not tear it down.
689                let permitted = self
690                    .hit_test_for(position, &pointer)
691                    .is_some_and(|target| self.effective_touch_action(target).allows_pinch());
692                permitted
693                    .then(|| self.touch_motion.pinch.contact_down(pointer.id, position))
694                    .flatten()
695            }
696            PinchFeed::Move => self.touch_motion.pinch.contact_moved(pointer.id, position),
697            PinchFeed::Up => self.touch_motion.pinch.contact_up(pointer.id),
698        };
699        if let Some(gesture) = recognized {
700            self.emit_pinch(gesture, ops);
701        }
702    }
703
704    /// The window's pinch is revoked along with `pointer`.
705    pub(super) fn cancel_pinch(
706        &mut self,
707        pointer: PointerId,
708        reason: CancelReason,
709        ops: &mut dyn crate::window::WindowOps,
710    ) {
711        if !self.touch_motion.pinch.contact_ids().contains(&pointer) {
712            return;
713        }
714        if let Some(gesture) = self.touch_motion.pinch.cancel(reason) {
715            self.emit_pinch(gesture, ops);
716        }
717    }
718
719    /// Send one pinch phase to the node the gesture is addressed to.
720    ///
721    /// The target is resolved once, at `PinchStarted`, and held: resolving it
722    /// per phase would let the addressee drift to another widget as the fingers
723    /// spread across a boundary, and `Ended` would then arrive somewhere that
724    /// never saw a `Started`.
725    fn emit_pinch(&mut self, gesture: GestureEvent, ops: &mut dyn crate::window::WindowOps) {
726        if let GestureEvent::PinchStarted { center } = gesture {
727            self.touch_motion.pinch_target = self.os_gesture_target(Some(center));
728        }
729        let Some(target) = self.touch_motion.pinch_target else {
730            return;
731        };
732        self.dispatch_to_widget(target, &WidgetEvent::Gesture { gesture }, ops);
733        if matches!(
734            gesture,
735            GestureEvent::PinchEnded | GestureEvent::PinchCancelled { .. }
736        ) {
737            self.touch_motion.pinch_target = None;
738        }
739    }
740
741    /// Whether a two-contact pinch is in progress.
742    pub fn touch_pinch_active(&self) -> bool {
743        self.touch_motion.pinch.is_active()
744    }
745
746    // -----------------------------------------------------------------
747    // Palm
748    // -----------------------------------------------------------------
749
750    /// Declare whether the backend classifies palms itself.
751    ///
752    /// `false` — the default, and what every `BackendCaps` row Teksilo ships
753    /// today reports — turns on the conservative fallback in
754    /// [`PalmWatch`]. Set it `true` from a backend that advertises
755    /// `reports_palm`, where the digitiser's own answer is better than any
756    /// heuristic and has already been applied at `PointerTable::would_admit`.
757    pub fn set_backend_reports_palm(&mut self, reports: bool) {
758        self.touch_motion.backend_reports_palm = reports;
759        if reports {
760            self.touch_motion.palms.clear();
761        }
762    }
763
764    /// Whether the palm fallback is running.
765    pub fn palm_fallback_active(&self) -> bool {
766        !self.touch_motion.backend_reports_palm
767    }
768
769    /// Start watching the contact being pressed.
770    pub(super) fn begin_palm_watch(&mut self, position: Point) {
771        let pointer = self.current_input.pointer;
772        if self.touch_motion.backend_reports_palm || !pointer.kind.is_direct() {
773            return;
774        }
775        self.touch_motion
776            .palms
777            .insert(pointer.id, PalmWatch::press(position, &pointer.axes));
778    }
779
780    /// Fold one sample into the watch.
781    pub(super) fn note_palm_sample(&mut self, position: Point) {
782        let pointer = self.current_input.pointer;
783        let profile = self.current_profile();
784        if let Some(watch) = self.touch_motion.palms.get_mut(&pointer.id) {
785            watch.sample(position, &pointer.axes, &profile);
786        }
787    }
788
789    /// Whether the contact that is releasing should be revoked as a palm.
790    ///
791    /// Consumes the watch either way: the contact is over.
792    pub(super) fn take_palm_verdict(&mut self, pointer: PointerId) -> bool {
793        self.touch_motion
794            .palms
795            .remove(&pointer)
796            .is_some_and(|watch| watch.is_palm())
797    }
798
799    /// Drop a watch without a verdict — the contact was cancelled, so there is
800    /// no release to judge.
801    pub(super) fn forget_palm_watch(&mut self, pointer: PointerId) {
802        self.touch_motion.palms.remove(&pointer);
803    }
804
805    // -----------------------------------------------------------------
806    // Deadlines
807    // -----------------------------------------------------------------
808
809    /// The earliest wall-clock instant at which the **input** layer wants the
810    /// event loop back: a pending gesture deadline (a long press), a press
811    /// whose feedback delay has not elapsed, a standing hold about to reach
812    /// `max_hold`, a tree-owned long-press route waiting out its hold
813    /// ([`super::touch_route`]), or a live fling simulation.
814    ///
815    /// Folded into [`next_timer_deadline`](Self::next_timer_deadline) beside
816    /// the tooltip, overlay and animation terms, so there is one
817    /// `ControlFlow::WaitUntil` over the one clock rather than a second timer
818    /// path for input.
819    pub fn next_input_deadline(&self) -> Option<std::time::Instant> {
820        let fling = self
821            .touch_motion
822            .driver
823            .next_deadline()
824            .map(|t| self.instant_for(t));
825        let hold = self
826            .next_sequence_hold_deadline()
827            .map(|t| self.instant_for(t));
828        let touch_route = self
829            .next_touch_route_deadline()
830            .map(|t| self.instant_for(t));
831        [
832            self.next_gesture_deadline(),
833            fling,
834            hold,
835            touch_route,
836            self.next_press_deadline(),
837        ]
838        .into_iter()
839        .flatten()
840        .min()
841    }
842}
843
844/// Which contact phase [`WidgetTree::feed_pinch`] is being told about.
845#[derive(Copy, Clone, PartialEq, Eq, Debug)]
846pub(super) enum PinchFeed {
847    /// A contact went down.
848    Down,
849    /// A contact moved.
850    Move,
851    /// A contact lifted.
852    Up,
853}
854
855#[cfg(test)]
856mod tests;