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;