Skip to main content

frust_widgets/
pinch.rs

1//! Touch pinch recognition: [`PinchRecognizer`], a pure two-contact state
2//! machine, and [`pinch_detector`], a transparent wrapper view that feeds it.
3//!
4//! The recogniser reduces a gesture's contacts to the same
5//! [`ScaleEvent`]/[`ScalePhase`] stream a desktop shell's ctrl/⌘+wheel and
6//! trackpad-pinch mapping produce, so a consumer reacts to every scale source
7//! through one type.
8//!
9//! # Recognition
10//!
11//! Contacts are fed as `(PointerId, PointerEvent, time_ms)` triples. The
12//! **pair** is the first two tracked contacts, in `Down` order; any further
13//! contact is tracked but contributes nothing until a pair member lifts.
14//!
15//! - **One contact is never a pinch.** Every event of a single-contact gesture
16//!   returns `None`, so the owner's drag/scroll logic proceeds untouched.
17//! - **Begin** once a pair exists and the inter-contact distance has changed by
18//!   more than [`PINCH_SLOP`] from where it stood when the pair formed. Begin
19//!   carries `scale_delta == 1.0`: the slop is consumed, so the content does not
20//!   jump by the slop's worth at recognition.
21//! - **Update** on every pair-member move after that: `scale_delta` is the
22//!   multiplicative change in inter-contact distance since the previous event,
23//!   `focal` the pair's midpoint, `velocity` the smoothed rate (below).
24//! - **End** when a pair member's `Up`/`Cancel` arrives. End carries
25//!   `scale_delta == 1.0`; an `Up` reports the release velocity (or `0.0` when the
26//!   last sample is older than [`VELOCITY_WINDOW_MS`]), a `Cancel` always `0.0`.
27//!   With one contact still down nothing further is a pinch, so the owner may
28//!   carry on with a pan; a fresh second contact can begin a new pinch.
29//!
30//! The **first** contact of a gesture (the one that went down with nothing
31//! tracked) mirrors the root's claimant: its `Up`/`Cancel` ends the pinch *and*
32//! drops every other tracked contact, because once the claimant's capture ends
33//! the root stops delivering the others (`InputEvent::PointerContact`'s
34//! multi-contact contract, `frust_core::event`).
35//!
36//! # Velocity
37//!
38//! The rate is logarithmic: an instantaneous sample is
39//! `ln(scale change) / elapsed seconds` (positive while spreading, negative while
40//! pinching, and symmetric — doubling then halving sums to zero), smoothed by an
41//! exponential moving average over roughly the last [`PINCH_VELOCITY_SAMPLES`]
42//! samples. A consumer extrapolating with inertia scales by `exp(velocity · t)`.
43//! Several events sharing one timestamp (the event pass carries no clock; the
44//! wrapper stamps events with the last painted frame time) accumulate into the
45//! next sample rather than dividing by zero.
46//!
47//! # The wrapper
48//!
49//! [`pinch_detector`] captures the pointer **and** opts into the gesture's other
50//! contacts ([`EventCtx::capture_contacts`]) on the first primary `Down`, which is
51//! what makes the root route a second finger to it at all. The first contact's
52//! events are forwarded to the child unchanged, so a single-finger drag or scroll
53//! inside it works as if the wrapper were absent; the other contacts are consumed
54//! by the recogniser and never reach the child. Once a pinch begins the child's
55//! gesture is **stolen**: on the first-contact event at or after Begin the child
56//! receives a synthesized `Cancel` (exactly what a platform gesture steal is) and
57//! nothing more of that gesture. The steal waits for a first-contact event
58//! because only that pass may clear the child's recorded capture path.
59//!
60//! `on_scale` never runs from a `Cancel` (the Cancel-never-mutates-state
61//! convention, `docs/CODE_STANDARDS.md`), so a cancelled pinch delivers no End;
62//! a consumer simply sees the next Begin. An [`InputEvent::Scale`] from a
63//! desktop source is offered to the child first and reaches `on_scale` only when
64//! the child ignores it, so one callback covers touch and desktop alike. Nesting
65//! two detectors is unsupported: the outer one consumes the extra contacts.
66//!
67//! # Surviving an enclosing scroll surface
68//!
69//! The detector always captures contacts on the claimant's `Down`, whether or
70//! not that `Down` sits inside a `ScrollView`/`ListView` — so a second finger
71//! can start a pinch over content the claimant's own finger is dragging. The
72//! claimant's own `Move`s still take the ordinary captured path up through
73//! every container on it, which means the enclosing surface's slop/takeover
74//! logic still runs on them. To stop that surface stealing the claimant's
75//! finger out from under a live pinch, the detector captures
76//! [`crate::scroll::ambient_scroll_veto`]'s cell on the claiming `Down` (while
77//! it is still ambient) and raises it for as long as a second contact is
78//! tracked, clearing it the moment the pair breaks back down to one
79//! (`PinchDetectorWidget::sync_scroll_veto`) — see `crate::scroll`'s module
80//! docs' *Multi-contact veto*. Outside a scroll surface this is a no-op: the
81//! ambient cell is simply absent.
82
83use frust_core::event::{PointerId, ScaleEvent, ScalePhase};
84use frust_core::{
85    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
86    LayoutCtx, PaintCtx, PaintScene, PointerEvent, PointerPhase, SemanticsCtx, TOUCH_SLOP,
87    VELOCITY_WINDOW_MS, View, Widget, any,
88};
89use kurbo::{Point, Size};
90use std::cell::Cell;
91use std::rc::Rc;
92
93use crate::authoring::presses;
94use crate::scroll::ambient_scroll_veto;
95
96/// How far (logical px) the distance between the two pinch contacts must change
97/// from its value when the pair formed before the pinch begins.
98///
99/// **Community-approximate**: neither platform publishes one number for touch.
100/// Android's `ScaleGestureDetector` uses a span slop of twice its touch slop
101/// (≈16dp) and Flutter's scale recogniser a span slop of `kPanSlop` (36 logical
102/// px); this reuses [`TOUCH_SLOP`] (18 logical px), inside that range, so the
103/// input thresholds keep their single source in `frust_core::input`.
104pub const PINCH_SLOP: f64 = TOUCH_SLOP;
105
106/// The window, in samples, of the exponential moving average smoothing a pinch's
107/// velocity: the smoothing factor is `2 / (N + 1)` (0.4 for `N = 4`), the usual
108/// N-sample EMA weighting.
109///
110/// **Community-approximate**: a hand-tuned value — a few frames of history damps
111/// a jittery two-finger sample without lagging a deliberate flick.
112pub const PINCH_VELOCITY_SAMPLES: f64 = 4.0;
113
114/// The most contacts one [`PinchRecognizer`] tracks; further `Down`s are ignored
115/// until a tracked contact lifts.
116pub const MAX_PINCH_CONTACTS: usize = 10;
117
118/// The smallest inter-contact distance (logical px) a scale ratio is computed
119/// from. Two contacts reported at the same point would otherwise produce a
120/// `0.0` delta, which a consumer multiplying its running scale could never
121/// recover from.
122const MIN_SPAN: f64 = 1.0;
123
124/// Where the recogniser stands with its current pair.
125#[derive(Clone, Copy, Debug, PartialEq)]
126enum Phase {
127    /// Fewer than two contacts are tracked.
128    Idle,
129    /// A pair exists; waiting for its distance to leave the slop.
130    Armed { start_distance: f64 },
131    /// A pinch is in progress.
132    Active { last_distance: f64 },
133}
134
135/// A pure two-contact pinch recogniser. See the [module docs](self).
136///
137/// Feed every contact's event through [`handle`](Self::handle); positions must
138/// share one coordinate space (every contact routed down one captured path
139/// does).
140#[derive(Debug)]
141pub struct PinchRecognizer {
142    /// Tracked contacts in `Down` order; the first two are the pair.
143    contacts: Vec<(PointerId, Point)>,
144    phase: Phase,
145    /// Smoothed logarithmic scale rate, per second.
146    velocity: f64,
147    /// Samples folded into `velocity` since the pinch began (`0` seeds the
148    /// average with the first sample instead of decaying it from zero).
149    samples: u32,
150    /// Timestamp of the last velocity sample.
151    last_sample_ms: f64,
152    /// Log scale accumulated since `last_sample_ms` by events sharing it.
153    pending_log: f64,
154}
155
156impl Default for PinchRecognizer {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162impl PinchRecognizer {
163    /// A recogniser tracking no contacts.
164    pub fn new() -> Self {
165        Self {
166            contacts: Vec::with_capacity(MAX_PINCH_CONTACTS),
167            phase: Phase::Idle,
168            velocity: 0.0,
169            samples: 0,
170            last_sample_ms: 0.0,
171            pending_log: 0.0,
172        }
173    }
174
175    /// Whether a pinch is in progress (between its Begin and its End).
176    pub fn is_pinching(&self) -> bool {
177        matches!(self.phase, Phase::Active { .. })
178    }
179
180    /// How many contacts are currently tracked.
181    pub fn contact_count(&self) -> usize {
182        self.contacts.len()
183    }
184
185    /// Forget every contact without emitting anything — for a gesture whose end
186    /// the owner learns of some other way (a teardown, a fresh claimant `Down`).
187    pub fn reset(&mut self) {
188        self.contacts.clear();
189        self.phase = Phase::Idle;
190        self.reset_velocity(0.0);
191    }
192
193    /// Feed one contact's event, stamped `time_ms` (any monotonic millisecond
194    /// clock). Returns the scale event it produces, if any — always `None` while
195    /// fewer than two contacts are down.
196    pub fn handle(
197        &mut self,
198        id: PointerId,
199        event: &PointerEvent,
200        time_ms: f64,
201    ) -> Option<ScaleEvent> {
202        let index = self.contacts.iter().position(|(c, _)| *c == id);
203        match event.phase {
204            PointerPhase::Down => match index {
205                // A repeated `Down` for a tracked contact is a move.
206                Some(i) => self.moved(i, event.position, time_ms),
207                None => {
208                    if self.contacts.len() < MAX_PINCH_CONTACTS {
209                        self.contacts.push((id, event.position));
210                        if self.contacts.len() == 2 {
211                            self.arm();
212                        }
213                    }
214                    None
215                }
216            },
217            PointerPhase::Move => self.moved(index?, event.position, time_ms),
218            PointerPhase::Up | PointerPhase::Cancel => {
219                let i = index?;
220                self.contacts[i].1 = event.position;
221                let cancelled = event.phase == PointerPhase::Cancel;
222                let end = if i < 2 {
223                    self.end(time_ms, cancelled)
224                } else {
225                    None
226                };
227                if i == 0 {
228                    // The claimant left: the others are no longer delivered.
229                    self.contacts.clear();
230                    self.phase = Phase::Idle;
231                } else {
232                    self.contacts.remove(i);
233                    if i < 2 {
234                        if self.contacts.len() >= 2 {
235                            self.arm();
236                        } else {
237                            self.phase = Phase::Idle;
238                        }
239                    }
240                }
241                end
242            }
243        }
244    }
245
246    /// The pair's distance and midpoint. Only meaningful with two contacts.
247    fn pair(&self) -> (f64, Point) {
248        let (a, b) = (self.contacts[0].1, self.contacts[1].1);
249        ((b - a).hypot(), a.midpoint(b))
250    }
251
252    /// Start a fresh slop measurement for the current pair.
253    fn arm(&mut self) {
254        let (distance, _) = self.pair();
255        self.phase = Phase::Armed {
256            start_distance: distance,
257        };
258    }
259
260    fn reset_velocity(&mut self, time_ms: f64) {
261        self.velocity = 0.0;
262        self.samples = 0;
263        self.last_sample_ms = time_ms;
264        self.pending_log = 0.0;
265    }
266
267    /// Fold `log_delta` into the smoothed rate at `time_ms`.
268    fn sample_velocity(&mut self, log_delta: f64, time_ms: f64) {
269        self.pending_log += log_delta;
270        let dt_ms = time_ms - self.last_sample_ms;
271        if dt_ms <= 0.0 {
272            // Same timestamp as the last sample: accumulate until time moves.
273            return;
274        }
275        if dt_ms > VELOCITY_WINDOW_MS {
276            // A pause longer than the window says nothing about the rate now.
277            self.samples = 0;
278        }
279        let instant = self.pending_log / (dt_ms / 1000.0);
280        self.velocity = if self.samples == 0 {
281            instant
282        } else {
283            let alpha = 2.0 / (PINCH_VELOCITY_SAMPLES + 1.0);
284            alpha * instant + (1.0 - alpha) * self.velocity
285        };
286        self.samples = self.samples.saturating_add(1);
287        self.last_sample_ms = time_ms;
288        self.pending_log = 0.0;
289    }
290
291    /// Contact `i` moved to `position`.
292    fn moved(&mut self, i: usize, position: Point, time_ms: f64) -> Option<ScaleEvent> {
293        self.contacts[i].1 = position;
294        if i >= 2 || self.contacts.len() < 2 {
295            return None;
296        }
297        let (distance, focal) = self.pair();
298        match self.phase {
299            Phase::Idle => None,
300            Phase::Armed { start_distance } => {
301                if (distance - start_distance).abs() <= PINCH_SLOP {
302                    return None;
303                }
304                self.phase = Phase::Active {
305                    last_distance: distance,
306                };
307                self.reset_velocity(time_ms);
308                Some(ScaleEvent {
309                    phase: ScalePhase::Begin,
310                    scale_delta: 1.0,
311                    focal,
312                    velocity: 0.0,
313                })
314            }
315            Phase::Active { last_distance } => {
316                let scale_delta = distance.max(MIN_SPAN) / last_distance.max(MIN_SPAN);
317                self.phase = Phase::Active {
318                    last_distance: distance,
319                };
320                self.sample_velocity(scale_delta.ln(), time_ms);
321                Some(ScaleEvent {
322                    phase: ScalePhase::Update,
323                    scale_delta,
324                    focal,
325                    velocity: self.velocity,
326                })
327            }
328        }
329    }
330
331    /// A pair member lifted (or was cancelled): End an active pinch.
332    fn end(&mut self, time_ms: f64, cancelled: bool) -> Option<ScaleEvent> {
333        if !self.is_pinching() || self.contacts.len() < 2 {
334            return None;
335        }
336        let (_, focal) = self.pair();
337        let stale = time_ms - self.last_sample_ms > VELOCITY_WINDOW_MS;
338        let velocity = if cancelled || stale || self.samples == 0 {
339            0.0
340        } else {
341            self.velocity
342        };
343        self.phase = Phase::Idle;
344        Some(ScaleEvent {
345            phase: ScalePhase::End,
346            scale_delta: 1.0,
347            focal,
348            velocity,
349        })
350    }
351}
352
353/// A declarative pinch wrapper. See the [module docs](self#the-wrapper).
354pub struct PinchDetectorView<State: 'static> {
355    child: AnyView<State>,
356    on_scale: Option<crate::authoring::TypedArgCallback<State, ScaleEvent>>,
357}
358
359/// Wrap `child` in a pinch detector (inert until
360/// [`on_scale`](PinchDetectorView::on_scale) is attached, apart from capturing
361/// the gesture it would recognise).
362pub fn pinch_detector<State: 'static, V: View<State>>(child: V) -> PinchDetectorView<State> {
363    PinchDetectorView {
364        child: any(child),
365        on_scale: None,
366    }
367}
368
369impl<State: 'static> PinchDetectorView<State> {
370    /// Receive every Begin/Update/End of a two-finger pinch over the child, and
371    /// every desktop [`InputEvent::Scale`] the child leaves unhandled.
372    pub fn on_scale<F: Fn(&mut State, ScaleEvent) + 'static>(mut self, on_scale: F) -> Self {
373        self.on_scale = Some(std::rc::Rc::new(on_scale));
374        self
375    }
376}
377
378/// Where the child stands in the current gesture.
379#[derive(Clone, Copy, Debug, PartialEq, Eq)]
380enum ChildRoute {
381    /// The first contact's events reach the child.
382    Forwarding,
383    /// A pinch began; the child is cancelled on the next first-contact event.
384    StealPending,
385    /// The child was cancelled; the rest of the gesture is withheld from it.
386    Stolen,
387}
388
389/// The retained widget for a [`PinchDetectorView`].
390pub struct PinchDetectorWidget {
391    child: ChildPod,
392    recognizer: PinchRecognizer,
393    on_scale: Option<crate::authoring::ErasedArgCallback<ScaleEvent>>,
394    /// The contact whose `Down` this widget captured on, while its gesture lives.
395    claimant: Option<PointerId>,
396    route: ChildRoute,
397    /// The last painted frame time in ms — the event pass's timestamp source.
398    last_frame_ms: f64,
399    /// The enclosing scroll surface's live multi-contact veto, captured from
400    /// [`ambient_scroll_veto`] on the claiming `Down` while it is still
401    /// reachable — `None` outside a scroll surface, which simply has nothing
402    /// to raise (see the [module docs](self#surviving-an-enclosing-scroll-surface)).
403    scroll_veto: Option<Rc<Cell<bool>>>,
404}
405
406impl<State: 'static> View<State> for PinchDetectorView<State> {
407    type Element = PinchDetectorWidget;
408
409    fn build(&self, ctx: &mut BuildCtx<'_>) -> PinchDetectorWidget {
410        PinchDetectorWidget {
411            child: crate::authoring::build_child(&self.child, ctx),
412            recognizer: PinchRecognizer::new(),
413            on_scale: self
414                .on_scale
415                .as_ref()
416                .map(crate::authoring::erase_callback_arg),
417            claimant: None,
418            route: ChildRoute::Forwarding,
419            last_frame_ms: 0.0,
420            scroll_veto: None,
421        }
422    }
423
424    fn rebuild(
425        &self,
426        prev: &Self,
427        element: &mut PinchDetectorWidget,
428        ctx: &mut BuildCtx<'_>,
429    ) -> ChangeFlags {
430        element.on_scale = self
431            .on_scale
432            .as_ref()
433            .map(crate::authoring::erase_callback_arg);
434        crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
435    }
436
437    fn teardown(&self, element: &mut PinchDetectorWidget, ctx: &mut BuildCtx<'_>) {
438        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
439    }
440}
441
442impl PinchDetectorWidget {
443    /// Hand a recognised scale event to `on_scale`. Never called for a `Cancel`.
444    fn fire(&mut self, ctx: &mut EventCtx, scale: ScaleEvent) {
445        if let Some(cb) = self.on_scale.as_mut() {
446            cb(ctx, scale);
447            ctx.request_redraw();
448        }
449    }
450
451    /// Feed one contact to the recogniser, firing what it produces (except from
452    /// a `Cancel`) and marking the child for a steal when a pinch begins.
453    fn recognize(&mut self, ctx: &mut EventCtx, id: PointerId, p: &PointerEvent) {
454        let Some(scale) = self.recognizer.handle(id, p, self.last_frame_ms) else {
455            return;
456        };
457        if scale.phase == ScalePhase::Begin && self.route == ChildRoute::Forwarding {
458            self.route = ChildRoute::StealPending;
459        }
460        if p.phase != PointerPhase::Cancel {
461            self.fire(ctx, scale);
462        }
463    }
464
465    /// Raise or clear the captured [`PinchDetectorWidget::scroll_veto`] to
466    /// match whether the recogniser is tracking more than one contact right
467    /// now — called after every [`PinchRecognizer::handle`], so an enclosing
468    /// scroll surface sees the flip before its own next `Move` decides
469    /// whether to take the claimant's finger over (`crate::scroll`'s module
470    /// docs' *Multi-contact veto*).
471    fn sync_scroll_veto(&self) {
472        if let Some(veto) = &self.scroll_veto {
473            veto.set(self.recognizer.contact_count() >= 2);
474        }
475    }
476
477    /// Route a first-contact event to the child according to [`ChildRoute`].
478    fn route_claimant(&mut self, ctx: &mut EventCtx, event: &InputEvent, p: &PointerEvent) {
479        let ends = matches!(p.phase, PointerPhase::Up | PointerPhase::Cancel);
480        match self.route {
481            ChildRoute::Forwarding => {
482                self.child.event_child(ctx, event);
483                if ends {
484                    self.child.set_active(false);
485                }
486            }
487            ChildRoute::StealPending => {
488                let cancel = InputEvent::Pointer(PointerEvent {
489                    phase: PointerPhase::Cancel,
490                    ..*p
491                });
492                self.child.event_child(ctx, &cancel);
493                self.child.set_active(false);
494                self.route = ChildRoute::Stolen;
495            }
496            ChildRoute::Stolen => {}
497        }
498        if ends {
499            self.claimant = None;
500            self.route = ChildRoute::Forwarding;
501            self.recognizer.reset();
502            self.scroll_veto = None;
503        }
504    }
505}
506
507impl Widget for PinchDetectorWidget {
508    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
509        let size = self.child.layout_child(ctx, bc);
510        self.child.set_origin(Point::ZERO);
511        bc.constrain(size)
512    }
513
514    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
515        // The event pass carries no clock; stamp velocity samples with the last
516        // painted frame instead (the `ScrollView` precedent).
517        self.last_frame_ms = ctx.frame_time().as_secs_f64() * 1000.0;
518        self.child.paint_child(ctx, scene);
519    }
520
521    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
522        if event.is_broadcast() {
523            self.child.event_child(ctx, event);
524            return EventResult::Ignored;
525        }
526        match event {
527            InputEvent::Pointer(p) => {
528                let id = ctx.pointer_id();
529                let starts = p.phase == PointerPhase::Down && presses(p);
530                if self.claimant.is_none() || (starts && self.claimant == Some(id)) {
531                    if !starts {
532                        // Hover moves, non-primary presses, stray releases.
533                        return self.child.event_child(ctx, event);
534                    }
535                    // A fresh gesture: claim it and every contact that joins it.
536                    self.recognizer.reset();
537                    self.claimant = Some(id);
538                    self.route = ChildRoute::Forwarding;
539                    // Capture the enclosing scroll surface's live veto now,
540                    // while the ambient cell from its `Down` forward is still
541                    // reachable — a `None` snapshot here would be a surface's
542                    // own `Down`-time claim read too early to see a second
543                    // contact that has not arrived yet (see the module docs'
544                    // *Surviving an enclosing scroll surface*).
545                    self.scroll_veto = ambient_scroll_veto();
546                    ctx.capture_pointer();
547                    ctx.capture_contacts();
548                    self.recognize(ctx, id, p);
549                    self.sync_scroll_veto();
550                    self.child.event_child(ctx, event);
551                    return EventResult::Handled;
552                }
553                self.recognize(ctx, id, p);
554                self.sync_scroll_veto();
555                if self.claimant == Some(id) {
556                    self.route_claimant(ctx, event, p);
557                }
558                // Another contact is the recogniser's alone.
559                EventResult::Handled
560            }
561            InputEvent::Scale(scale) => {
562                let result = self.child.event_child(ctx, event);
563                if result == EventResult::Ignored && self.on_scale.is_some() {
564                    self.fire(ctx, *scale);
565                    return EventResult::Handled;
566                }
567                result
568            }
569            _ => self.child.event_child(ctx, event),
570        }
571    }
572
573    fn semantics(&self, ctx: &mut SemanticsCtx) {
574        // Transparent wrapper: forward to the single child.
575        self.child.semantics_child(ctx);
576    }
577
578    crate::authoring::visit_children!(child);
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use frust_core::{FrameTime, PointerButton, RenderRoot};
585    use std::cell::RefCell;
586    use std::rc::Rc;
587
588    fn pe(phase: PointerPhase, x: f64, y: f64) -> PointerEvent {
589        PointerEvent {
590            phase,
591            position: Point::new(x, y),
592            button: PointerButton::Primary,
593        }
594    }
595
596    const T0: PointerId = PointerId::touch(0);
597    const T1: PointerId = PointerId::touch(1);
598    const T2: PointerId = PointerId::touch(2);
599
600    /// A recogniser with two contacts down 20 px apart on y = 50, at t = 0.
601    fn paired() -> PinchRecognizer {
602        let mut r = PinchRecognizer::new();
603        assert_eq!(r.handle(T0, &pe(PointerPhase::Down, 40.0, 50.0), 0.0), None);
604        assert_eq!(r.handle(T1, &pe(PointerPhase::Down, 60.0, 50.0), 0.0), None);
605        r
606    }
607
608    /// `paired()` spread past the slop: begun at distance 40, focal (60, 50).
609    fn begun() -> PinchRecognizer {
610        let mut r = paired();
611        let begin = r
612            .handle(T1, &pe(PointerPhase::Move, 80.0, 50.0), 16.0)
613            .expect("past the slop");
614        assert_eq!(begin.phase, ScalePhase::Begin);
615        r
616    }
617
618    #[test]
619    fn two_finger_spread_begins_updates_and_ends_at_the_midpoint() {
620        let mut r = paired();
621        let begin = r
622            .handle(T1, &pe(PointerPhase::Move, 80.0, 50.0), 16.0)
623            .unwrap();
624        assert_eq!(begin.phase, ScalePhase::Begin);
625        assert_eq!(begin.scale_delta, 1.0, "the slop is consumed, not jumped");
626        assert_eq!(begin.focal, Point::new(60.0, 50.0));
627        assert!(r.is_pinching());
628
629        let update = r
630            .handle(T1, &pe(PointerPhase::Move, 100.0, 50.0), 32.0)
631            .unwrap();
632        assert_eq!(update.phase, ScalePhase::Update);
633        assert!((update.scale_delta - 1.5).abs() < 1e-12, "40 → 60 px");
634        assert_eq!(update.focal, Point::new(70.0, 50.0));
635
636        let end = r
637            .handle(T1, &pe(PointerPhase::Up, 100.0, 50.0), 48.0)
638            .unwrap();
639        assert_eq!(end.phase, ScalePhase::End);
640        assert_eq!(end.scale_delta, 1.0);
641        assert_eq!(end.focal, Point::new(70.0, 50.0));
642        assert!(!r.is_pinching());
643    }
644
645    #[test]
646    fn spreading_the_first_contact_counts_too() {
647        let mut r = paired();
648        let begin = r
649            .handle(T0, &pe(PointerPhase::Move, 20.0, 50.0), 16.0)
650            .unwrap();
651        assert_eq!(begin.phase, ScalePhase::Begin);
652        assert_eq!(begin.focal, Point::new(40.0, 50.0));
653    }
654
655    #[test]
656    fn constant_distance_drag_reports_unit_scale_and_a_moving_focal() {
657        let mut r = begun(); // T0 at 40, T1 at 80
658        let mut product = 1.0;
659        let mut last_focal_x = 60.0;
660        let mut t = 16.0;
661        for step in 1..=10 {
662            t += 8.0;
663            let x = 40.0 + step as f64;
664            // Each finger steps 1 px to the right in turn.
665            for (id, pos) in [(T1, x + 40.0), (T0, x)] {
666                let u = r.handle(id, &pe(PointerPhase::Move, pos, 50.0), t).unwrap();
667                assert_eq!(u.phase, ScalePhase::Update);
668                assert!((u.scale_delta - 1.0).abs() < 0.03, "{u:?}");
669                assert!(u.focal.x > last_focal_x, "focal moves with the drag");
670                last_focal_x = u.focal.x;
671                product *= u.scale_delta;
672            }
673        }
674        assert!(
675            (product - 1.0).abs() < 1e-9,
676            "net scale is 1, got {product}"
677        );
678        assert_eq!(last_focal_x, 70.0);
679    }
680
681    #[test]
682    fn a_single_finger_never_pinches() {
683        let mut r = PinchRecognizer::new();
684        assert_eq!(r.handle(T0, &pe(PointerPhase::Down, 10.0, 10.0), 0.0), None);
685        for i in 1..20 {
686            let x = 10.0 + 15.0 * i as f64;
687            assert_eq!(
688                r.handle(T0, &pe(PointerPhase::Move, x, 10.0), i as f64 * 16.0),
689                None
690            );
691        }
692        assert_eq!(
693            r.handle(T0, &pe(PointerPhase::Up, 300.0, 10.0), 400.0),
694            None
695        );
696        assert_eq!(r.contact_count(), 0);
697    }
698
699    #[test]
700    fn lifting_the_second_finger_ends_and_the_first_pans_on_unrecognised() {
701        let mut r = begun();
702        let end = r.handle(T1, &pe(PointerPhase::Up, 80.0, 50.0), 32.0);
703        assert_eq!(end.map(|e| e.phase), Some(ScalePhase::End));
704        assert_eq!(r.contact_count(), 1);
705        // The remaining finger is the owner's pan again.
706        assert_eq!(
707            r.handle(T0, &pe(PointerPhase::Move, 140.0, 90.0), 48.0),
708            None
709        );
710        assert_eq!(r.handle(T0, &pe(PointerPhase::Up, 140.0, 90.0), 64.0), None);
711    }
712
713    #[test]
714    fn lifting_the_first_finger_ends_and_drops_every_other_contact() {
715        let mut r = begun();
716        let end = r.handle(T0, &pe(PointerPhase::Up, 40.0, 50.0), 32.0);
717        assert_eq!(end.map(|e| e.phase), Some(ScalePhase::End));
718        assert_eq!(r.contact_count(), 0, "the claimant took the others with it");
719        assert_eq!(
720            r.handle(T1, &pe(PointerPhase::Move, 120.0, 50.0), 48.0),
721            None
722        );
723    }
724
725    #[test]
726    fn cancel_ends_the_pinch_with_no_velocity() {
727        for id in [T0, T1] {
728            let mut r = begun();
729            r.handle(T1, &pe(PointerPhase::Move, 100.0, 50.0), 24.0);
730            let end = r
731                .handle(id, &pe(PointerPhase::Cancel, 0.0, 0.0), 32.0)
732                .unwrap();
733            assert_eq!(end.phase, ScalePhase::End);
734            assert_eq!(end.velocity, 0.0);
735            assert!(!r.is_pinching());
736        }
737    }
738
739    #[test]
740    fn velocity_sign_follows_spread_and_pinch() {
741        let mut spread = begun();
742        let mut v = 0.0;
743        for (i, x) in [90.0, 100.0, 110.0].into_iter().enumerate() {
744            let t = 16.0 * (i + 2) as f64;
745            v = spread
746                .handle(T1, &pe(PointerPhase::Move, x, 50.0), t)
747                .unwrap()
748                .velocity;
749        }
750        assert!(v > 0.0, "spreading is positive, got {v}");
751        let end = spread
752            .handle(T1, &pe(PointerPhase::Up, 110.0, 50.0), 64.0)
753            .unwrap();
754        assert!(end.velocity > 0.0, "a prompt release keeps the rate");
755
756        let mut pinch = begun();
757        for (i, x) in [75.0, 70.0, 65.0].into_iter().enumerate() {
758            let t = 16.0 * (i + 2) as f64;
759            v = pinch
760                .handle(T1, &pe(PointerPhase::Move, x, 50.0), t)
761                .unwrap()
762                .velocity;
763        }
764        assert!(v < 0.0, "pinching is negative, got {v}");
765    }
766
767    #[test]
768    fn velocity_is_a_log_rate_and_survives_shared_timestamps() {
769        let mut r = begun(); // distance 40 at t = 16
770        // Two events on one frame time: no division by zero, no sample yet.
771        let a = r
772            .handle(T1, &pe(PointerPhase::Move, 90.0, 50.0), 16.0)
773            .unwrap();
774        assert_eq!(a.velocity, 0.0);
775        // 100 ms later the distance has doubled since the last sample (40 → 80).
776        let b = r
777            .handle(T1, &pe(PointerPhase::Move, 120.0, 50.0), 116.0)
778            .unwrap();
779        let expected = 2.0_f64.ln() / 0.1;
780        assert!((b.velocity - expected).abs() < 1e-9, "{b:?}");
781    }
782
783    #[test]
784    fn a_stale_release_reports_no_velocity() {
785        let mut r = begun();
786        r.handle(T1, &pe(PointerPhase::Move, 100.0, 50.0), 32.0);
787        let end = r
788            .handle(
789                T1,
790                &pe(PointerPhase::Up, 100.0, 50.0),
791                32.0 + VELOCITY_WINDOW_MS + 1.0,
792            )
793            .unwrap();
794        assert_eq!(end.velocity, 0.0, "held still before lifting");
795    }
796
797    #[test]
798    fn slop_gates_begin() {
799        let mut r = paired(); // distance 20
800        // 17 px of change: inside the slop.
801        assert_eq!(
802            r.handle(T1, &pe(PointerPhase::Move, 77.0, 50.0), 16.0),
803            None
804        );
805        // Translating the pair without changing distance never begins either.
806        assert_eq!(
807            r.handle(T0, &pe(PointerPhase::Move, 57.0, 50.0), 32.0),
808            None
809        );
810        assert_eq!(
811            r.handle(T1, &pe(PointerPhase::Move, 77.0, 80.0), 48.0),
812            None
813        );
814        assert!(!r.is_pinching());
815        // Back to 20 apart at (57,50)/(77,50), then 19 px of change: begins.
816        assert_eq!(
817            r.handle(T1, &pe(PointerPhase::Move, 77.0, 50.0), 64.0),
818            None
819        );
820        let begin = r.handle(T1, &pe(PointerPhase::Move, 96.0, 50.0), 80.0);
821        assert_eq!(begin.map(|e| e.phase), Some(ScalePhase::Begin));
822        const { assert!(PINCH_SLOP > 17.0 && PINCH_SLOP < 19.0) };
823    }
824
825    #[test]
826    fn a_third_contact_waits_for_a_pair_member_to_lift() {
827        let mut r = begun();
828        assert_eq!(
829            r.handle(T2, &pe(PointerPhase::Down, 200.0, 50.0), 20.0),
830            None
831        );
832        assert_eq!(
833            r.handle(T2, &pe(PointerPhase::Move, 300.0, 50.0), 24.0),
834            None
835        );
836        let end = r.handle(T1, &pe(PointerPhase::Up, 80.0, 50.0), 32.0);
837        assert_eq!(end.map(|e| e.phase), Some(ScalePhase::End));
838        // T0 and T2 form a new pair, re-armed at their current distance.
839        assert_eq!(r.contact_count(), 2);
840        assert_eq!(
841            r.handle(T2, &pe(PointerPhase::Move, 310.0, 50.0), 48.0),
842            None
843        );
844        let begin = r.handle(T2, &pe(PointerPhase::Move, 340.0, 50.0), 64.0);
845        assert_eq!(begin.map(|e| e.phase), Some(ScalePhase::Begin));
846    }
847
848    #[test]
849    fn coincident_contacts_never_produce_a_zero_scale() {
850        let mut r = begun();
851        let collapse = r
852            .handle(T1, &pe(PointerPhase::Move, 40.0, 50.0), 32.0)
853            .unwrap();
854        assert!(collapse.scale_delta > 0.0 && collapse.scale_delta.is_finite());
855        let reopen = r
856            .handle(T1, &pe(PointerPhase::Move, 80.0, 50.0), 48.0)
857            .unwrap();
858        assert!((collapse.scale_delta * reopen.scale_delta - 1.0).abs() < 1e-12);
859    }
860
861    // --- The wrapper, driven through a real `RenderRoot` -----------------------
862
863    #[derive(Default)]
864    struct PinchState {
865        scales: Vec<ScaleEvent>,
866    }
867
868    /// A full-bleed child that captures on `Down` (like a scroll view) and logs
869    /// every pointer phase it receives into a shared log — never into app state,
870    /// so its `Cancel` arm stays state-free.
871    struct Recorder {
872        log: Rc<RefCell<Vec<PointerPhase>>>,
873        handles_scale: bool,
874    }
875    struct RecorderWidget {
876        log: Rc<RefCell<Vec<PointerPhase>>>,
877        handles_scale: bool,
878    }
879    impl View<PinchState> for Recorder {
880        type Element = RecorderWidget;
881        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RecorderWidget {
882            RecorderWidget {
883                log: self.log.clone(),
884                handles_scale: self.handles_scale,
885            }
886        }
887        fn rebuild(
888            &self,
889            _p: &Self,
890            _e: &mut RecorderWidget,
891            _c: &mut BuildCtx<'_>,
892        ) -> ChangeFlags {
893            ChangeFlags::NONE
894        }
895    }
896    impl Widget for RecorderWidget {
897        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
898            bc.max()
899        }
900        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
901        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
902            match event {
903                InputEvent::Pointer(p) => {
904                    self.log.borrow_mut().push(p.phase);
905                    if p.phase == PointerPhase::Down {
906                        ctx.capture_pointer();
907                    }
908                    EventResult::Handled
909                }
910                InputEvent::Scale(_) if self.handles_scale => EventResult::Handled,
911                _ => EventResult::Ignored,
912            }
913        }
914    }
915
916    struct NullScene;
917    impl PaintScene for NullScene {
918        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
919        fn draw_text(&mut self, _o: Point, _t: &str) {}
920    }
921
922    type Root = RenderRoot<PinchState, PinchDetectorView<PinchState>>;
923
924    fn detector_root(handles_scale: bool) -> (Root, Rc<RefCell<Vec<PointerPhase>>>) {
925        let log = Rc::new(RefCell::new(Vec::new()));
926        let child_log = log.clone();
927        let mut logic = move |_: &mut PinchState| {
928            pinch_detector(Recorder {
929                log: child_log.clone(),
930                handles_scale,
931            })
932            .on_scale(|s: &mut PinchState, e| s.scales.push(e))
933        };
934        let mut root: Root = RenderRoot::new();
935        root.rebuild(&mut logic, &mut PinchState::default());
936        root.layout(Size::new(400.0, 400.0));
937        (root, log)
938    }
939
940    fn touch(slot: u32, phase: PointerPhase, x: f64, y: f64) -> InputEvent {
941        InputEvent::PointerContact {
942            pointer_id: PointerId::touch(slot),
943            event: pe(phase, x, y),
944        }
945    }
946
947    fn phases(state: &PinchState) -> Vec<ScalePhase> {
948        state.scales.iter().map(|e| e.phase).collect()
949    }
950
951    #[test]
952    fn wrapper_receives_the_second_finger_and_reports_the_pinch() {
953        use PointerPhase::{Down, Move, Up};
954        let (mut root, child) = detector_root(false);
955        let mut state = PinchState::default();
956        let mut sink = NullScene;
957        root.paint(&mut sink, FrameTime::from_nanos(0));
958        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
959        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
960        root.paint(&mut sink, FrameTime::from_nanos(16_000_000));
961        root.event(&mut state, &touch(1, Move, 160.0, 100.0)); // 20 → 60: Begin
962        root.paint(&mut sink, FrameTime::from_nanos(32_000_000));
963        root.event(&mut state, &touch(1, Move, 220.0, 100.0)); // 60 → 120
964        root.event(&mut state, &touch(1, Up, 220.0, 100.0));
965        assert_eq!(
966            phases(&state),
967            [ScalePhase::Begin, ScalePhase::Update, ScalePhase::End]
968        );
969        let update = state.scales[1];
970        assert!((update.scale_delta - 2.0).abs() < 1e-12);
971        assert_eq!(update.focal, Point::new(160.0, 100.0));
972        assert!(update.velocity > 0.0);
973        assert!(
974            root.is_pointer_captured(),
975            "the first finger still holds it"
976        );
977
978        // The rest of the gesture: the first finger pans; nothing more scales,
979        // and the stolen child hears nothing until a fresh gesture.
980        root.event(&mut state, &touch(0, Move, 50.0, 50.0));
981        root.event(&mut state, &touch(0, Up, 50.0, 50.0));
982        assert_eq!(state.scales.len(), 3);
983        assert!(!root.is_pointer_captured());
984        assert_eq!(*child.borrow(), [Down, PointerPhase::Cancel]);
985
986        // A fresh single-finger gesture reaches the child again.
987        root.event(&mut state, &touch(0, Down, 10.0, 10.0));
988        root.event(&mut state, &touch(0, Up, 10.0, 10.0));
989        assert_eq!(
990            *child.borrow(),
991            [Down, PointerPhase::Cancel, Down, Up],
992            "the steal lasted one gesture"
993        );
994    }
995
996    #[test]
997    fn wrapper_single_finger_drag_passes_through_to_the_child() {
998        use PointerPhase::{Down, Move, Up};
999        let (mut root, child) = detector_root(false);
1000        let mut state = PinchState::default();
1001        for event in [
1002            touch(0, Down, 10.0, 10.0),
1003            touch(0, Move, 200.0, 10.0),
1004            touch(0, Move, 300.0, 200.0),
1005            touch(0, Up, 300.0, 200.0),
1006        ] {
1007            root.event(&mut state, &event);
1008        }
1009        assert!(state.scales.is_empty());
1010        assert_eq!(*child.borrow(), [Down, Move, Move, Up]);
1011        assert!(!root.is_pointer_captured());
1012    }
1013
1014    #[test]
1015    fn wrapper_second_finger_inside_the_slop_leaves_the_child_alone() {
1016        use PointerPhase::{Down, Move, Up};
1017        let (mut root, child) = detector_root(false);
1018        let mut state = PinchState::default();
1019        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
1020        root.event(&mut state, &touch(1, Down, 140.0, 100.0));
1021        root.event(&mut state, &touch(1, Move, 145.0, 100.0));
1022        root.event(&mut state, &touch(1, Up, 145.0, 100.0));
1023        root.event(&mut state, &touch(0, Move, 110.0, 100.0));
1024        root.event(&mut state, &touch(0, Up, 110.0, 100.0));
1025        assert!(state.scales.is_empty());
1026        assert_eq!(
1027            *child.borrow(),
1028            [Down, Move, Up],
1029            "the extra finger never reaches the child"
1030        );
1031    }
1032
1033    #[test]
1034    fn wrapper_cancel_ends_without_calling_on_scale() {
1035        use PointerPhase::{Cancel, Down, Move};
1036        let (mut root, child) = detector_root(false);
1037        let mut state = PinchState::default();
1038        root.event(&mut state, &touch(0, Down, 100.0, 100.0));
1039        root.event(&mut state, &touch(1, Down, 120.0, 100.0));
1040        root.event(&mut state, &touch(0, Move, 60.0, 100.0)); // claimant spreads: Begin
1041        root.event(&mut state, &touch(0, Cancel, 60.0, 100.0));
1042        assert_eq!(phases(&state), [ScalePhase::Begin]);
1043        assert!(!root.is_pointer_captured());
1044        // The claimant's own Move both began the pinch and carried the steal.
1045        assert_eq!(*child.borrow(), [Down, Cancel]);
1046    }
1047
1048    #[test]
1049    fn wrapper_offers_a_desktop_scale_to_the_child_first() {
1050        let event = InputEvent::Scale(ScaleEvent {
1051            phase: ScalePhase::Update,
1052            scale_delta: 1.1,
1053            focal: Point::new(50.0, 50.0),
1054            velocity: 0.0,
1055        });
1056        let (mut root, _) = detector_root(false);
1057        let mut state = PinchState::default();
1058        root.event(&mut state, &event);
1059        assert_eq!(state.scales.len(), 1, "an ignored scale reaches on_scale");
1060        assert_eq!(state.scales[0].scale_delta, 1.1);
1061
1062        let (mut root, _) = detector_root(true);
1063        let mut state = PinchState::default();
1064        root.event(&mut state, &event);
1065        assert!(state.scales.is_empty(), "the child handled it");
1066    }
1067
1068    #[test]
1069    fn wrapper_mouse_press_is_an_ordinary_single_pointer_gesture() {
1070        use PointerPhase::{Down, Up};
1071        let (mut root, child) = detector_root(false);
1072        let mut state = PinchState::default();
1073        root.event(&mut state, &InputEvent::Pointer(pe(Down, 10.0, 10.0)));
1074        root.event(&mut state, &InputEvent::Pointer(pe(Up, 10.0, 10.0)));
1075        assert!(state.scales.is_empty());
1076        assert_eq!(*child.borrow(), [Down, Up]);
1077    }
1078}