Skip to main content

frust_widgets/
pan_zoom.rs

1//! The `PanZoomView`/`PanZoomWidget` container: one child laid out at its
2//! intrinsic size and placed under a scale-then-translate transform the user
3//! drives — drag to pan, pinch (touch) or ctrl/⌘+wheel and trackpad pinch
4//! (desktop) to zoom.
5//!
6//! The child sits in a [`ChildPod`] whose
7//! [`set_transform`](ChildPod::set_transform) is
8//! `Affine::translate(offset) * Affine::scale(scale)` ([`PanZoomTransform`]),
9//! so the framework paints it transformed and inverse-maps hit tests and every
10//! positioned event it routes down — a child sees its own unscaled local
11//! coordinates at any zoom, with no help from this widget.
12//!
13//! # Input
14//!
15//! - **Pan.** A primary `Down` is offered to the child first. If the child
16//!   handles it (a draggable node, a button) the gesture is the child's and the
17//!   view does not pan; if the child ignores it (empty canvas, a press outside
18//!   the content) the claimant contact's moves translate the content. When the
19//!   view is about to pan it also joins `scroll.rs`'s innermost-wins
20//!   nested-scroll claim seam (the same one `ListView` uses), reporting itself
21//!   into an enclosing `ScrollView`/`ListView`'s ambient claim cell so that
22//!   surface defers to the pan past touch-slop instead of taking the gesture
23//!   over — see [`PanZoomWidget::begin_gesture`].
24//! - **Zoom.** An [`InputEvent::Scale`] — the desktop ctrl/⌘+wheel and
25//!   trackpad-pinch mapping — is offered to the child first and applied here
26//!   when the child ignores it. A touch pinch is recognised in-widget by a
27//!   [`PinchRecognizer`] fed from the gesture's contacts (the view calls
28//!   [`EventCtx::capture_contacts`] on every primary `Down`, so a second finger
29//!   routes here even when the child owns the first). Both produce the same
30//!   [`ScaleEvent`] stream and the same zoom: the content point under the
31//!   focal stays under it, and while a bracketed gesture's focal moves the
32//!   content follows it (two-finger pan). The scale clamps to
33//!   [`min_scale`](PanZoomView::min_scale)/[`max_scale`](PanZoomView::max_scale).
34//!   A pinch that begins over a child-owned gesture **steals** it: the child
35//!   receives a synthesized `Cancel` on the claimant's next event (the
36//!   [`crate::pinch`] wrapper's rule) and the rest of the gesture belongs to the
37//!   view. When the child owns the gesture this view reports nothing into the
38//!   nested-scroll claim above (the child's press, not a pan, owns it), so a
39//!   second finger forming a pair here while nested in a `ScrollView`/`ListView`
40//!   raises that surface's live multi-contact veto instead
41//!   ([`crate::scroll::ambient_scroll_veto`], `crate::scroll`'s module docs'
42//!   *Multi-contact veto*) — the same seam `pinch_detector` raises — so the
43//!   enclosing surface does not steal the claimant's finger out from under the
44//!   nascent pinch.
45//! - **Wheel.** A plain [`InputEvent::Scroll`] (no modifier — the shell maps a
46//!   modified wheel to `Scale` instead) is routed to the child, so a scrollable
47//!   inside still scrolls; the view never pans on it.
48//! - **Inertia.** With [`inertia(true)`](PanZoomView::inertia) a pan released
49//!   faster than [`MIN_FLING_VELOCITY`] glides to rest on a per-axis
50//!   [`FrictionSimulation`], pumped from the paint clock. Any new press, scale
51//!   or controller command stops it.
52//!
53//! Panning is unbounded: the content may be dragged fully out of view (the
54//! graph-editor convention); [`PanZoomController::fit_to_bounds`] brings it
55//! back.
56//!
57//! # Notification and control
58//!
59//! [`on_transform`](PanZoomView::on_transform) runs with the new
60//! [`PanZoomTransform`] after every change. A change made during the event pass
61//! (a drag, a scale) notifies at once; one made where no `&mut State` exists (an
62//! inertia frame, a controller command applied at layout/paint) is delivered by
63//! the next [`InputEvent::Housekeeping`] flush, which the widget requests with
64//! [`frust_core::mark_pending_result_flush`] — ordinarily the next frame. A
65//! `Cancel` never notifies (the Cancel-never-mutates-state convention,
66//! `docs/CODE_STANDARDS.md`).
67//!
68//! A [`PanZoomController`] attached with [`controller`](PanZoomView::controller)
69//! is a cloneable, reactive-free handle: [`jump_to`](PanZoomController::jump_to)
70//! / [`fit_to_bounds`](PanZoomController::fit_to_bounds) /
71//! [`fit_rect`](PanZoomController::fit_rect) record a command the widget
72//! applies at its next layout or paint (whichever comes first), and
73//! [`transform`](PanZoomController::transform) reads the transform the widget
74//! last published.
75
76use std::cell::{Cell, RefCell};
77use std::rc::Rc;
78
79use frust_core::event::{PointerId, ScaleEvent, ScalePhase};
80use frust_core::{
81    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FLING_STOP,
82    InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerEvent, PointerPhase, SemanticsCtx,
83    VelocityTracker, View, Widget, any,
84};
85use kurbo::{Affine, Point, Rect, Size, Vec2};
86
87use crate::authoring::{presses, route_event_single};
88use crate::physics::simulation::FrictionSimulation;
89use crate::physics::{MAX_FLING_VELOCITY, MIN_FLING_VELOCITY, Simulation, Tolerance};
90use crate::pinch::PinchRecognizer;
91use crate::scroll::{InnerScrollState, ambient_scroll_claim, ambient_scroll_veto};
92
93/// The smallest scale a [`PanZoomView`] allows unless
94/// [`min_scale`](PanZoomView::min_scale) says otherwise.
95pub const DEFAULT_MIN_SCALE: f64 = 0.25;
96
97/// The largest scale a [`PanZoomView`] allows unless
98/// [`max_scale`](PanZoomView::max_scale) says otherwise.
99pub const DEFAULT_MAX_SCALE: f64 = 8.0;
100
101/// The fraction of a pan glide's velocity surviving one second — the
102/// [`FrictionSimulation`] drag the inertia runs on.
103///
104/// **Community-approximate**: no platform publishes a pan/zoom glide curve;
105/// this is the drag Flutter's `InteractiveViewer` uses for its pan inertia
106/// (`_kDrag`), which settles a typical flick within roughly a third of a
107/// second.
108const GLIDE_DRAG: f64 = 0.000_013_5;
109
110/// The scroll-surface fling threshold doubles as the glide's settle speed, so
111/// the input constants keep their single source in `frust_core::input`.
112const GLIDE_TOLERANCE: Tolerance = Tolerance {
113    velocity: FLING_STOP,
114    distance: 0.5,
115};
116
117/// Where the content sits: `view = offset + scale · content`.
118///
119/// The child's pod carries exactly [`affine`](Self::affine); a content-space
120/// point (the child's local coordinates) maps to the view's local space
121/// through it and back through [`to_content`](Self::to_content).
122#[derive(Clone, Copy, Debug, PartialEq)]
123pub struct PanZoomTransform {
124    /// The zoom factor: `1.0` is the child's natural size.
125    pub scale: f64,
126    /// Where the child's local origin sits in the view, in view-local px.
127    pub offset: Vec2,
128}
129
130impl PanZoomTransform {
131    /// Unscaled, unpanned: the child at the view's top-left at natural size.
132    pub const IDENTITY: PanZoomTransform = PanZoomTransform {
133        scale: 1.0,
134        offset: Vec2::ZERO,
135    };
136
137    /// The child pod's transform: `translate(offset) · scale(scale)`.
138    pub fn affine(&self) -> Affine {
139        Affine::translate(self.offset) * Affine::scale(self.scale)
140    }
141
142    /// The content-space point drawn at view-local `point`.
143    pub fn to_content(&self, point: Point) -> Point {
144        ((point.to_vec2() - self.offset) / self.scale).to_point()
145    }
146
147    /// Where content-space `point` is drawn, in view-local px.
148    pub fn to_view(&self, point: Point) -> Point {
149        (self.offset + point.to_vec2() * self.scale).to_point()
150    }
151}
152
153impl Default for PanZoomTransform {
154    fn default() -> Self {
155        Self::IDENTITY
156    }
157}
158
159/// A command recorded on a [`PanZoomController`], applied by the widget.
160#[derive(Clone, Copy, Debug, PartialEq)]
161enum Command {
162    JumpTo { scale: f64, offset: Vec2 },
163    FitContent,
164    FitRect(Rect),
165}
166
167/// The state a controller shares with its widget.
168#[derive(Debug, Default)]
169struct Shared {
170    transform: PanZoomTransform,
171    viewport: Size,
172    content: Size,
173    pending: Option<Command>,
174}
175
176/// A cloneable handle onto a [`PanZoomView`]: drive it with
177/// [`jump_to`](Self::jump_to)/[`fit_to_bounds`](Self::fit_to_bounds)/
178/// [`fit_rect`](Self::fit_rect) and read where it stands with
179/// [`transform`](Self::transform). Every clone shares one state, so the handle
180/// an app keeps in its state and the one attached to the view are the same.
181///
182/// Commands are *recorded*, not applied: the widget applies the latest one at
183/// its next layout or paint (a later command replaces an unapplied earlier
184/// one), clamping the scale to the view's bounds. One controller drives one
185/// view; attaching it to two makes them race for its commands.
186#[derive(Clone, Debug, Default)]
187pub struct PanZoomController {
188    shared: Rc<RefCell<Shared>>,
189}
190
191impl PanZoomController {
192    /// A controller attached to nothing yet, reading [`PanZoomTransform::IDENTITY`].
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// The transform the attached widget last published (identity until one
198    /// attaches and lays out).
199    pub fn transform(&self) -> PanZoomTransform {
200        self.shared.borrow().transform
201    }
202
203    /// The attached widget's last laid-out size (zero until it lays out).
204    pub fn viewport_size(&self) -> Size {
205        self.shared.borrow().viewport
206    }
207
208    /// The child's last laid-out natural size (zero until it lays out).
209    pub fn content_size(&self) -> Size {
210        self.shared.borrow().content
211    }
212
213    /// Move to `scale` (clamped to the view's bounds) with the child's origin
214    /// at view-local `offset`.
215    pub fn jump_to(&self, scale: f64, offset: Vec2) {
216        self.record(Command::JumpTo { scale, offset });
217    }
218
219    /// Fit the child's whole laid-out bounds into the view, centred, at the
220    /// largest scale that shows all of it (clamped to the view's bounds).
221    pub fn fit_to_bounds(&self) {
222        self.record(Command::FitContent);
223    }
224
225    /// Fit `rect`, in the child's content space, into the view, centred, at the
226    /// largest scale that shows all of it (clamped to the view's bounds).
227    pub fn fit_rect(&self, rect: Rect) {
228        self.record(Command::FitRect(rect));
229    }
230
231    /// Record `command`, then raise [`frust_core::mark_pending_result_flush`] so
232    /// a frame runs to apply it even when the command came from outside any
233    /// input path (the `NavigatorController` precedent).
234    fn record(&self, command: Command) {
235        self.shared.borrow_mut().pending = Some(command);
236        frust_core::mark_pending_result_flush();
237    }
238
239    fn take_pending(&self) -> Option<Command> {
240        self.shared.borrow_mut().pending.take()
241    }
242
243    fn has_pending(&self) -> bool {
244        self.shared.borrow().pending.is_some()
245    }
246
247    fn publish(&self, transform: PanZoomTransform, viewport: Size, content: Size) {
248        let mut shared = self.shared.borrow_mut();
249        shared.transform = transform;
250        shared.viewport = viewport;
251        shared.content = content;
252    }
253
254    fn same(a: &Option<PanZoomController>, b: &Option<PanZoomController>) -> bool {
255        match (a, b) {
256            (Some(a), Some(b)) => Rc::ptr_eq(&a.shared, &b.shared),
257            (None, None) => true,
258            _ => false,
259        }
260    }
261}
262
263/// A declarative pan/zoom container. See the [module docs](self).
264pub struct PanZoomView<State: 'static> {
265    child: AnyView<State>,
266    min_scale: f64,
267    max_scale: f64,
268    inertia: bool,
269    on_transform: Option<crate::authoring::TypedArgCallback<State, PanZoomTransform>>,
270    controller: Option<PanZoomController>,
271}
272
273/// Wrap `child` in a pan/zoom view: identity transform, scale bounds
274/// [`DEFAULT_MIN_SCALE`]..=[`DEFAULT_MAX_SCALE`], no inertia.
275pub fn pan_zoom<State: 'static, V: View<State>>(child: V) -> PanZoomView<State> {
276    PanZoomView {
277        child: any(child),
278        min_scale: DEFAULT_MIN_SCALE,
279        max_scale: DEFAULT_MAX_SCALE,
280        inertia: false,
281        on_transform: None,
282        controller: None,
283    }
284}
285
286impl<State: 'static> PanZoomView<State> {
287    /// The smallest scale a zoom may reach (default [`DEFAULT_MIN_SCALE`]).
288    /// Non-positive or non-finite values are ignored.
289    pub fn min_scale(mut self, scale: f64) -> Self {
290        if scale.is_finite() && scale > 0.0 {
291            self.min_scale = scale;
292        }
293        self
294    }
295
296    /// The largest scale a zoom may reach (default [`DEFAULT_MAX_SCALE`]).
297    /// Non-positive or non-finite values are ignored.
298    pub fn max_scale(mut self, scale: f64) -> Self {
299        if scale.is_finite() && scale > 0.0 {
300            self.max_scale = scale;
301        }
302        self
303    }
304
305    /// Let a released pan glide to rest (default `false`).
306    pub fn inertia(mut self, inertia: bool) -> Self {
307        self.inertia = inertia;
308        self
309    }
310
311    /// Run `on_transform(state, transform)` after every change to the
312    /// transform (see the [module docs](self#notification-and-control)).
313    pub fn on_transform<F: Fn(&mut State, PanZoomTransform) + 'static>(
314        mut self,
315        on_transform: F,
316    ) -> Self {
317        self.on_transform = Some(Rc::new(on_transform));
318        self
319    }
320
321    /// Attach a [`PanZoomController`].
322    pub fn controller(mut self, controller: PanZoomController) -> Self {
323        self.controller = Some(controller);
324        self
325    }
326
327    /// The scale bounds, ordered so an inverted pair cannot invert the clamp.
328    fn bounds(&self) -> (f64, f64) {
329        (
330            self.min_scale.min(self.max_scale),
331            self.max_scale.max(self.min_scale),
332        )
333    }
334}
335
336/// Who the claimant contact's gesture belongs to.
337#[derive(Clone, Copy, Debug, PartialEq)]
338enum Drag {
339    /// No gesture.
340    Idle,
341    /// The child handled the claimant's `Down`; its events go to the child.
342    Child,
343    /// A pinch began over a child-owned gesture; the child is cancelled on the
344    /// claimant's next event.
345    StealPending,
346    /// The claimant pans the content (suspended while a pinch is live).
347    Pan { last: Point },
348}
349
350/// A pan glide in flight.
351struct Glide {
352    x: FrictionSimulation,
353    y: FrictionSimulation,
354    /// The first frame the glide was painted on (its `t = 0`).
355    start: Option<frust_core::FrameTime>,
356}
357
358/// The retained widget for a [`PanZoomView`].
359pub struct PanZoomWidget {
360    child: ChildPod,
361    transform: PanZoomTransform,
362    min_scale: f64,
363    max_scale: f64,
364    inertia: bool,
365    on_transform: Option<crate::authoring::ErasedArgCallback<PanZoomTransform>>,
366    controller: Option<PanZoomController>,
367    viewport: Size,
368    content: Size,
369    recognizer: PinchRecognizer,
370    /// The contact whose primary `Down` opened the current gesture.
371    claimant: Option<PointerId>,
372    drag: Drag,
373    /// The previous focal of an open scale bracket (`Begin` seen, `End` not).
374    last_focal: Option<Point>,
375    tracker_x: VelocityTracker,
376    tracker_y: VelocityTracker,
377    glide: Option<Glide>,
378    /// The transform changed where `on_transform` could not run.
379    notify_owed: bool,
380    /// A Housekeeping flush was already requested for the owed notification.
381    notify_requested: bool,
382    /// The last painted frame time in ms — the event pass's timestamp source.
383    last_frame_ms: f64,
384    /// The enclosing scroll surface's live multi-contact veto, captured from
385    /// [`ambient_scroll_veto`] on the claiming `Down` while it is still
386    /// reachable — `None` outside a scroll surface. See the
387    /// [module docs](self#input)'s child-owned-gesture paragraph.
388    scroll_veto: Option<Rc<Cell<bool>>>,
389}
390
391impl<State: 'static> View<State> for PanZoomView<State> {
392    type Element = PanZoomWidget;
393
394    fn build(&self, ctx: &mut BuildCtx<'_>) -> PanZoomWidget {
395        let (min_scale, max_scale) = self.bounds();
396        PanZoomWidget {
397            child: crate::authoring::build_child(&self.child, ctx),
398            transform: PanZoomTransform::IDENTITY,
399            min_scale,
400            max_scale,
401            inertia: self.inertia,
402            on_transform: self
403                .on_transform
404                .as_ref()
405                .map(crate::authoring::erase_callback_arg),
406            controller: self.controller.clone(),
407            viewport: Size::ZERO,
408            content: Size::ZERO,
409            recognizer: PinchRecognizer::new(),
410            claimant: None,
411            drag: Drag::Idle,
412            last_focal: None,
413            tracker_x: VelocityTracker::new(),
414            tracker_y: VelocityTracker::new(),
415            glide: None,
416            notify_owed: false,
417            notify_requested: false,
418            last_frame_ms: 0.0,
419            scroll_veto: None,
420        }
421    }
422
423    fn rebuild(
424        &self,
425        prev: &Self,
426        element: &mut PanZoomWidget,
427        ctx: &mut BuildCtx<'_>,
428    ) -> ChangeFlags {
429        let mut flags =
430            crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
431        element.on_transform = self
432            .on_transform
433            .as_ref()
434            .map(crate::authoring::erase_callback_arg);
435        element.inertia = self.inertia;
436        if !self.inertia {
437            element.glide = None;
438        }
439        let (min_scale, max_scale) = self.bounds();
440        if (min_scale, max_scale) != (element.min_scale, element.max_scale) {
441            element.min_scale = min_scale;
442            element.max_scale = max_scale;
443            // Re-clamp about the viewport centre so the visible middle stays put.
444            let centre = element.viewport.to_rect().center();
445            if element.zoom(centre, centre, 1.0) {
446                element.owe_notify();
447                flags |= ChangeFlags::PAINT;
448            }
449        }
450        if !PanZoomController::same(&element.controller, &self.controller) {
451            element.controller = self.controller.clone();
452            element.publish();
453        }
454        if element.controller.as_ref().is_some_and(|c| c.has_pending()) {
455            flags |= ChangeFlags::PAINT;
456        }
457        flags
458    }
459
460    fn teardown(&self, element: &mut PanZoomWidget, ctx: &mut BuildCtx<'_>) {
461        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
462    }
463}
464
465impl PanZoomWidget {
466    /// The transform the child is currently placed under.
467    pub fn transform(&self) -> PanZoomTransform {
468        self.transform
469    }
470
471    /// Map the content point under view-local `from` to `to`, multiplying the
472    /// scale by `delta` (clamped). Returns whether the transform changed.
473    fn zoom(&mut self, from: Point, to: Point, delta: f64) -> bool {
474        let delta = if delta.is_finite() && delta > 0.0 {
475            delta
476        } else {
477            1.0
478        };
479        let content = self.transform.to_content(from);
480        let scale = (self.transform.scale * delta).clamp(self.min_scale, self.max_scale);
481        let next = PanZoomTransform {
482            scale,
483            offset: to.to_vec2() - content.to_vec2() * scale,
484        };
485        self.set(next)
486    }
487
488    /// Install `next` if it differs and is finite. Returns whether it changed.
489    fn set(&mut self, next: PanZoomTransform) -> bool {
490        let finite =
491            next.scale.is_finite() && next.offset.x.is_finite() && next.offset.y.is_finite();
492        if !finite || next == self.transform {
493            return false;
494        }
495        self.transform = next;
496        self.publish();
497        true
498    }
499
500    /// Publish the current geometry to the attached controller.
501    fn publish(&self) {
502        if let Some(controller) = &self.controller {
503            controller.publish(self.transform, self.viewport, self.content);
504        }
505    }
506
507    /// Record a change no `EventCtx` was present for.
508    fn owe_notify(&mut self) {
509        self.notify_owed = true;
510    }
511
512    /// A change made during the event pass: notify now and repaint.
513    fn changed(&mut self, ctx: &mut EventCtx) {
514        self.notify_owed = false;
515        self.notify_requested = false;
516        if let Some(cb) = self.on_transform.as_mut() {
517            cb(ctx, self.transform);
518        }
519        ctx.request_redraw();
520    }
521
522    /// Deliver a notification owed from a layout/paint-time change.
523    fn deliver_owed(&mut self, ctx: &mut EventCtx) {
524        if self.notify_owed {
525            self.changed(ctx);
526        }
527    }
528
529    /// Apply the controller's pending command, if any, against the current
530    /// viewport and content sizes. Returns whether the transform changed.
531    fn apply_pending(&mut self) -> bool {
532        let Some(command) = self.controller.as_ref().and_then(|c| c.take_pending()) else {
533            return false;
534        };
535        self.glide = None;
536        self.last_focal = None;
537        let next = match command {
538            Command::JumpTo { scale, offset } => PanZoomTransform {
539                scale: if scale.is_finite() && scale > 0.0 {
540                    scale.clamp(self.min_scale, self.max_scale)
541                } else {
542                    self.transform.scale
543                },
544                offset,
545            },
546            Command::FitContent => match self.fit(self.content.to_rect()) {
547                Some(next) => next,
548                None => return false,
549            },
550            Command::FitRect(rect) => match self.fit(rect) {
551                Some(next) => next,
552                None => return false,
553            },
554        };
555        let changed = self.set(next);
556        if changed {
557            self.owe_notify();
558        }
559        changed
560    }
561
562    /// The transform that centres content-space `rect` in the viewport at the
563    /// largest in-bounds scale showing all of it; `None` for an empty rect or
564    /// viewport.
565    fn fit(&self, rect: Rect) -> Option<PanZoomTransform> {
566        let rect = rect.abs();
567        if rect.width() <= 0.0
568            || rect.height() <= 0.0
569            || self.viewport.width <= 0.0
570            || self.viewport.height <= 0.0
571        {
572            return None;
573        }
574        let scale = (self.viewport.width / rect.width())
575            .min(self.viewport.height / rect.height())
576            .clamp(self.min_scale, self.max_scale);
577        let centre = self.viewport.to_rect().center();
578        Some(PanZoomTransform {
579            scale,
580            offset: centre.to_vec2() - rect.center().to_vec2() * scale,
581        })
582    }
583
584    /// Apply one scale event (either source). Returns whether the transform
585    /// changed. A bracketed gesture's moving focal pans the content with it.
586    fn apply_scale(&mut self, scale: &ScaleEvent) -> bool {
587        self.glide = None;
588        match scale.phase {
589            ScalePhase::Begin => {
590                self.last_focal = Some(scale.focal);
591                self.zoom(scale.focal, scale.focal, scale.scale_delta)
592            }
593            ScalePhase::Update => {
594                let from = match self.last_focal.as_mut() {
595                    Some(last) => std::mem::replace(last, scale.focal),
596                    // A lone update (a wheel notch) has no bracket to track.
597                    None => scale.focal,
598                };
599                self.zoom(from, scale.focal, scale.scale_delta)
600            }
601            ScalePhase::End => {
602                let anchor = self.last_focal.take().unwrap_or(scale.focal);
603                self.zoom(anchor, anchor, scale.scale_delta)
604            }
605        }
606    }
607
608    /// A recognised touch-pinch event from contact event `p`.
609    fn on_pinch(&mut self, ctx: &mut EventCtx, scale: ScaleEvent, p: &PointerEvent) {
610        if scale.phase == ScalePhase::Begin {
611            if self.drag == Drag::Child {
612                self.drag = Drag::StealPending;
613            }
614            // A pinch's motion is not a pan's: no glide from it.
615            self.tracker_x.clear();
616            self.tracker_y.clear();
617        }
618        if p.phase == PointerPhase::Cancel {
619            // Never notify from a Cancel; just close the bracket.
620            self.last_focal = None;
621            return;
622        }
623        if self.apply_scale(&scale) {
624            self.changed(ctx);
625        }
626    }
627
628    /// Start a glide from the claimant's release, if inertia is on and the
629    /// release was fast enough.
630    fn maybe_glide(&mut self) {
631        if !self.inertia {
632            return;
633        }
634        let mut velocity = Vec2::new(self.tracker_x.velocity(), self.tracker_y.velocity());
635        let speed = velocity.hypot();
636        if !speed.is_finite() || speed < MIN_FLING_VELOCITY {
637            return;
638        }
639        if speed > MAX_FLING_VELOCITY {
640            velocity *= MAX_FLING_VELOCITY / speed;
641        }
642        let offset = self.transform.offset;
643        self.glide = Some(Glide {
644            x: FrictionSimulation::new(GLIDE_DRAG, offset.x, velocity.x, GLIDE_TOLERANCE, 0.0),
645            y: FrictionSimulation::new(GLIDE_DRAG, offset.y, velocity.y, GLIDE_TOLERANCE, 0.0),
646            start: None,
647        });
648    }
649
650    /// Advance a glide to this frame. Paint-only: the pod transform moves, the
651    /// layout does not.
652    fn pump_glide(&mut self, ctx: &mut PaintCtx) {
653        let Some(glide) = self.glide.as_mut() else {
654            return;
655        };
656        let now = ctx.frame_time();
657        let start = *glide.start.get_or_insert(now);
658        let t = now.saturating_sub(start).as_secs_f64();
659        let offset = Vec2::new(glide.x.x(t), glide.y.x(t));
660        let done = glide.x.is_done(t) && glide.y.is_done(t);
661        if done {
662            self.glide = None;
663        } else {
664            ctx.request_frame();
665        }
666        let next = PanZoomTransform {
667            offset,
668            ..self.transform
669        };
670        if self.set(next) {
671            self.owe_notify();
672        }
673    }
674
675    /// The opening primary `Down` of a gesture: offer it to the child, then
676    /// claim the gesture (and its other contacts) either way.
677    fn begin_gesture(
678        &mut self,
679        ctx: &mut EventCtx,
680        event: &InputEvent,
681        id: PointerId,
682        p: &PointerEvent,
683    ) {
684        self.glide = None;
685        self.recognizer.reset();
686        self.last_focal = None;
687        self.tracker_x.clear();
688        self.tracker_y.clear();
689        self.claimant = Some(id);
690        // Capture the enclosing scroll surface's live multi-contact veto now,
691        // while the ambient cell from its `Down` forward is still reachable —
692        // needed below only for the child-owned branch, but captured
693        // unconditionally since this is the one point in the gesture the
694        // ambient cell is visible from (`crate::scroll`'s module docs'
695        // *Multi-contact veto*).
696        self.scroll_veto = ambient_scroll_veto();
697        self.recognizer.handle(id, p, self.last_frame_ms);
698        self.sync_scroll_veto();
699        let inside = self.child.contains(p.position);
700        let result = if inside {
701            self.child.event_child(ctx, event)
702        } else {
703            EventResult::Ignored
704        };
705        if !inside && self.child.is_focused() {
706            // Blur-on-outside-tap, as `route_event_single` does.
707            self.child.set_focused(false);
708        }
709        if result == EventResult::Handled {
710            self.drag = Drag::Child;
711            // The child owns the gesture, not this view — publish nothing.
712            // An enclosing `ScrollView`/`ListView` already wrote its own
713            // default (unregistered) `InnerScrollState` into this cell
714            // before forwarding the `Down` that reached here, and that
715            // default is exactly "nothing claims this drag", so leaving it
716            // untouched is the correct report.
717        } else {
718            self.drag = Drag::Pan { last: p.position };
719            self.tracker_x.record(self.last_frame_ms, p.position.x);
720            self.tracker_y.record(self.last_frame_ms, p.position.y);
721            // Innermost-wins nested-scroll arbitration (`scroll.rs` owns the
722            // seam; `ListView` is the other consumer): the child ignored the
723            // `Down`, so this view is about to pan it, and reports that into
724            // whichever cell is ambient — the nearest enclosing scroll
725            // surface's own fresh cell for *this* `Down`
726            // (`with_scroll_claim`), pushed before the forward that reached
727            // this `begin_gesture` and read back synchronously the moment
728            // that forward returns. A single write at `Down` is sufficient
729            // and needs no later reset: the host never reuses a cell across
730            // gestures (a fresh `Rc<Cell<_>>` is made for every `Down`), so
731            // there is nothing stale to clear between gestures. Both
732            // directions are claimed unconditionally — unlike a scrollable,
733            // whose claim depends on remaining content/physics, this view
734            // pans freely on either axis the moment it owns the gesture.
735            if let Some(host) = ambient_scroll_claim() {
736                host.set(InnerScrollState {
737                    registered: true,
738                    can_consume_down_drag: true,
739                    can_consume_up_drag: true,
740                });
741            }
742        }
743        ctx.capture_pointer();
744        ctx.capture_contacts();
745    }
746
747    /// Raise or clear the captured [`PanZoomWidget::scroll_veto`] to match
748    /// whether [`PanZoomWidget::recognizer`] is tracking more than one
749    /// contact right now — called after every [`PinchRecognizer::handle`], so
750    /// an enclosing scroll surface sees the flip before its own next `Move`
751    /// decides whether to take the claimant's finger over. Live-only: the
752    /// `Drag::Pan` branch already claims unconditionally at `Down`
753    /// (`begin_gesture`'s nested-scroll report), so this matters for
754    /// `Drag::Child`, where that report is deliberately left unregistered.
755    fn sync_scroll_veto(&self) {
756        if let Some(veto) = &self.scroll_veto {
757            veto.set(self.recognizer.contact_count() >= 2);
758        }
759    }
760
761    /// A later event of the claimant contact.
762    fn claimant_event(
763        &mut self,
764        ctx: &mut EventCtx,
765        event: &InputEvent,
766        id: PointerId,
767        p: &PointerEvent,
768    ) {
769        if let Some(scale) = self.recognizer.handle(id, p, self.last_frame_ms) {
770            self.on_pinch(ctx, scale, p);
771        }
772        self.sync_scroll_veto();
773        let ends = matches!(p.phase, PointerPhase::Up | PointerPhase::Cancel);
774        match self.drag {
775            Drag::Idle => {}
776            Drag::Child => {
777                route_event_single(&mut self.child, ctx, event);
778            }
779            Drag::StealPending => {
780                let cancel = InputEvent::Pointer(PointerEvent {
781                    phase: PointerPhase::Cancel,
782                    ..*p
783                });
784                self.child.event_child(ctx, &cancel);
785                self.child.set_active(false);
786                self.drag = Drag::Pan { last: p.position };
787            }
788            Drag::Pan { last } => {
789                if p.phase == PointerPhase::Move {
790                    if !self.recognizer.is_pinching() {
791                        let next = PanZoomTransform {
792                            offset: self.transform.offset + (p.position - last),
793                            ..self.transform
794                        };
795                        if self.set(next) {
796                            self.changed(ctx);
797                        }
798                        self.tracker_x.record(self.last_frame_ms, p.position.x);
799                        self.tracker_y.record(self.last_frame_ms, p.position.y);
800                    }
801                    self.drag = Drag::Pan { last: p.position };
802                } else if p.phase == PointerPhase::Up {
803                    self.tracker_x.record(self.last_frame_ms, p.position.x);
804                    self.tracker_y.record(self.last_frame_ms, p.position.y);
805                    self.maybe_glide();
806                    if self.glide.is_some() {
807                        ctx.request_redraw();
808                    }
809                }
810            }
811        }
812        if ends {
813            self.claimant = None;
814            self.drag = Drag::Idle;
815            self.recognizer.reset();
816            self.last_focal = None;
817            self.scroll_veto = None;
818        }
819    }
820}
821
822impl Widget for PanZoomWidget {
823    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
824        let max = bc.max();
825        let unbounded = BoxConstraints::new(Size::ZERO, Size::new(f64::INFINITY, f64::INFINITY));
826        let mut content = self.child.layout_child(ctx, &unbounded);
827        let axis = |limit: f64, natural: f64| {
828            if limit.is_finite() {
829                limit
830            } else if natural.is_finite() {
831                natural
832            } else {
833                0.0
834            }
835        };
836        let viewport = bc.constrain(Size::new(
837            axis(max.width, content.width),
838            axis(max.height, content.height),
839        ));
840        if !(content.width.is_finite() && content.height.is_finite()) {
841            // An expanding child (a default `canvas`) has no natural size of its
842            // own: give it the viewport rather than an infinite transform.
843            content = self
844                .child
845                .layout_child(ctx, &BoxConstraints::loose(viewport));
846        }
847        self.viewport = viewport;
848        self.content = content;
849        self.child.set_origin(Point::ZERO);
850        self.apply_pending();
851        self.publish();
852        self.child.set_transform(Some(self.transform.affine()));
853        viewport
854    }
855
856    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
857        // The event pass carries no clock; stamp velocity samples with the last
858        // painted frame instead (the `ScrollView` precedent).
859        self.last_frame_ms = ctx.frame_time().as_secs_f64() * 1000.0;
860        self.apply_pending();
861        self.pump_glide(ctx);
862        if self.on_transform.is_none() {
863            self.notify_owed = false;
864        } else if self.notify_owed && !self.notify_requested {
865            // Paint has no `&mut State`: owe the notification to the next
866            // Housekeeping flush (the `GestureDetector` long-press precedent).
867            self.notify_requested = true;
868            frust_core::mark_pending_result_flush();
869            ctx.request_frame();
870        }
871        self.child.set_transform(Some(self.transform.affine()));
872        scene.push_clip(ctx.origin(), ctx.size());
873        ctx.constrain_visible_rect(Rect::from_origin_size(ctx.origin(), ctx.size()));
874        self.child.paint_child(ctx, scene);
875        scene.pop_clip();
876    }
877
878    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
879        let cancel = matches!(event, InputEvent::Pointer(p) if p.phase == PointerPhase::Cancel);
880        if !cancel {
881            self.deliver_owed(ctx);
882        }
883        if event.is_broadcast() {
884            self.child.event_child(ctx, event);
885            return EventResult::Ignored;
886        }
887        match event {
888            InputEvent::Pointer(p) => {
889                let id = ctx.pointer_id();
890                let starts = p.phase == PointerPhase::Down && presses(p);
891                if self.claimant.is_none() || (starts && self.claimant == Some(id)) {
892                    if !starts {
893                        // Hover moves, non-primary presses, stray releases.
894                        return route_event_single(&mut self.child, ctx, event);
895                    }
896                    self.begin_gesture(ctx, event, id, p);
897                    return EventResult::Handled;
898                }
899                if self.claimant == Some(id) {
900                    self.claimant_event(ctx, event, id, p);
901                } else {
902                    let scale = self.recognizer.handle(id, p, self.last_frame_ms);
903                    self.sync_scroll_veto();
904                    if let Some(scale) = scale {
905                        // Another contact of the gesture is the recogniser's alone.
906                        self.on_pinch(ctx, scale, p);
907                    }
908                }
909                EventResult::Handled
910            }
911            InputEvent::Scale(scale) => {
912                if self.child.contains(scale.focal)
913                    && self.child.event_child(ctx, event) == EventResult::Handled
914                {
915                    return EventResult::Handled;
916                }
917                if self.apply_scale(scale) {
918                    self.changed(ctx);
919                }
920                EventResult::Handled
921            }
922            InputEvent::Scroll { .. } => {
923                // An open `Scale` bracket (`Begin` seen, no `End` yet) can have
924                // its rest arrive as a plain, unmodified `Scroll` instead — a
925                // macOS trackpad pinch whose ⌘ is released mid-gesture finishes
926                // as wheel events, with no `End` ever delivered. Left set, a
927                // later lone `Update` (an unrelated wheel notch) would read
928                // `last_focal` as that stale bracket's continuation and anchor
929                // its zoom there instead of at its own focal. A `Scroll` can
930                // only reach a view with no bracket genuinely still open —
931                // this widget's own pinch recogniser and the desktop
932                // modified-wheel mapping both route a live bracket's events as
933                // `Scale`, never `Scroll` — so clearing here never cuts off an
934                // in-progress bracket.
935                self.last_focal = None;
936                route_event_single(&mut self.child, ctx, event)
937            }
938            // Focus-routed and any other remaining event belongs to the child.
939            _ => route_event_single(&mut self.child, ctx, event),
940        }
941    }
942
943    fn semantics(&self, ctx: &mut SemanticsCtx) {
944        // Transparent container: forward to the single child.
945        self.child.semantics_child(ctx);
946    }
947
948    crate::authoring::visit_children!(child);
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use frust_core::{FrameTime, PointerButton, RenderRoot, ScrollDelta};
955    use std::cell::RefCell;
956
957    /// What the test content recorded, in its own local space.
958    #[derive(Default)]
959    struct Log {
960        events: Vec<InputEvent>,
961    }
962
963    #[derive(Default)]
964    struct App {
965        transforms: Vec<PanZoomTransform>,
966        /// Every `ScrollInfo::offset` an enclosing `scroll_view` in the
967        /// nested-claim fixtures reported through `on_scroll` — empty means
968        /// that outer surface never scrolled.
969        scroll_offsets: Vec<f64>,
970    }
971
972    /// A 1000 × 800 content leaf with one "node" at (100, 100)–(200, 200) that
973    /// claims a primary press (and captures), and that handles a scroll only
974    /// when `scrolls`. Logs every event into a shared log, never app state, so
975    /// its `Cancel` arm stays state-free.
976    struct Content {
977        log: Rc<RefCell<Log>>,
978        scrolls: bool,
979        size: Option<Size>,
980    }
981    struct ContentWidget {
982        log: Rc<RefCell<Log>>,
983        scrolls: bool,
984        size: Option<Size>,
985        captured: bool,
986    }
987    const NODE: Rect = Rect::new(100.0, 100.0, 200.0, 200.0);
988    const CONTENT: Size = Size::new(1000.0, 800.0);
989
990    impl View<App> for Content {
991        type Element = ContentWidget;
992        fn build(&self, _ctx: &mut BuildCtx<'_>) -> ContentWidget {
993            ContentWidget {
994                log: self.log.clone(),
995                scrolls: self.scrolls,
996                size: self.size,
997                captured: false,
998            }
999        }
1000        fn rebuild(&self, _p: &Self, _e: &mut ContentWidget, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1001            ChangeFlags::NONE
1002        }
1003    }
1004    impl Widget for ContentWidget {
1005        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1006            match self.size {
1007                Some(size) => bc.constrain(size),
1008                None => bc.max(),
1009            }
1010        }
1011        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1012        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1013            self.log.borrow_mut().events.push(event.clone());
1014            match event {
1015                InputEvent::Pointer(p) => match p.phase {
1016                    PointerPhase::Down if NODE.contains(p.position) => {
1017                        self.captured = true;
1018                        ctx.capture_pointer();
1019                        EventResult::Handled
1020                    }
1021                    PointerPhase::Move | PointerPhase::Up | PointerPhase::Cancel
1022                        if self.captured =>
1023                    {
1024                        if p.phase != PointerPhase::Move {
1025                            self.captured = false;
1026                        }
1027                        EventResult::Handled
1028                    }
1029                    _ => EventResult::Ignored,
1030                },
1031                InputEvent::Scroll { .. } if self.scrolls => EventResult::Handled,
1032                _ => EventResult::Ignored,
1033            }
1034        }
1035    }
1036
1037    struct NullScene;
1038    impl PaintScene for NullScene {
1039        fn fill_rect(&mut self, _o: Point, _s: Size, _c: peniko::Color) {}
1040        fn draw_text(&mut self, _o: Point, _t: &str) {}
1041    }
1042
1043    type Root = RenderRoot<App, PanZoomView<App>>;
1044    type Logic = Box<dyn FnMut(&mut App) -> PanZoomView<App>>;
1045
1046    struct Harness {
1047        root: Root,
1048        state: App,
1049        logic: Logic,
1050        log: Rc<RefCell<Log>>,
1051        controller: PanZoomController,
1052        now_ms: u64,
1053    }
1054
1055    impl Harness {
1056        fn new(configure: impl Fn(PanZoomView<App>) -> PanZoomView<App> + 'static) -> Self {
1057            Self::with_content(false, Some(CONTENT), configure)
1058        }
1059
1060        fn with_content(
1061            scrolls: bool,
1062            size: Option<Size>,
1063            configure: impl Fn(PanZoomView<App>) -> PanZoomView<App> + 'static,
1064        ) -> Self {
1065            let log = Rc::new(RefCell::new(Log::default()));
1066            let controller = PanZoomController::new();
1067            let (child_log, child_controller) = (log.clone(), controller.clone());
1068            let logic = Box::new(move |_: &mut App| {
1069                configure(
1070                    pan_zoom(Content {
1071                        log: child_log.clone(),
1072                        scrolls,
1073                        size,
1074                    })
1075                    .controller(child_controller.clone())
1076                    .on_transform(|s: &mut App, t| s.transforms.push(t)),
1077                )
1078            });
1079            let mut h = Harness {
1080                root: RenderRoot::new(),
1081                state: App::default(),
1082                logic,
1083                log,
1084                controller,
1085                now_ms: 0,
1086            };
1087            h.frame();
1088            h
1089        }
1090
1091        /// One shell frame: rebuild (draining any Housekeeping flush), layout,
1092        /// paint at the current clock; then advance the clock by 16 ms.
1093        fn frame(&mut self) -> frust_core::PaintOutcome {
1094            self.root.rebuild(&mut self.logic, &mut self.state);
1095            self.root.layout(Size::new(400.0, 300.0));
1096            let outcome = self.root.paint(
1097                &mut NullScene,
1098                FrameTime::from_nanos(self.now_ms * 1_000_000),
1099            );
1100            self.now_ms += 16;
1101            outcome
1102        }
1103
1104        fn send(&mut self, event: InputEvent) {
1105            self.root.event(&mut self.state, &event);
1106        }
1107
1108        fn transform(&self) -> PanZoomTransform {
1109            self.controller.transform()
1110        }
1111    }
1112
1113    fn pe(phase: PointerPhase, x: f64, y: f64) -> PointerEvent {
1114        PointerEvent {
1115            phase,
1116            position: Point::new(x, y),
1117            button: PointerButton::Primary,
1118        }
1119    }
1120
1121    fn mouse(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1122        InputEvent::Pointer(pe(phase, x, y))
1123    }
1124
1125    fn touch(slot: u32, phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1126        InputEvent::PointerContact {
1127            pointer_id: PointerId::touch(slot),
1128            event: pe(phase, x, y),
1129        }
1130    }
1131
1132    fn scale(phase: ScalePhase, delta: f64, x: f64, y: f64) -> InputEvent {
1133        InputEvent::Scale(ScaleEvent {
1134            phase,
1135            scale_delta: delta,
1136            focal: Point::new(x, y),
1137            velocity: 0.0,
1138        })
1139    }
1140
1141    fn close(a: Point, b: Point) -> bool {
1142        (a - b).hypot() < 1e-9
1143    }
1144
1145    fn same_transform(a: PanZoomTransform, b: PanZoomTransform) -> bool {
1146        (a.scale - b.scale).abs() < 1e-9 && (a.offset - b.offset).hypot() < 1e-9
1147    }
1148
1149    #[test]
1150    fn transform_round_trips_through_its_affine() {
1151        let t = PanZoomTransform {
1152            scale: 2.5,
1153            offset: Vec2::new(-30.0, 12.0),
1154        };
1155        let content = Point::new(17.0, -4.0);
1156        assert!(close(t.affine() * content, t.to_view(content)));
1157        assert!(close(t.to_content(t.to_view(content)), content));
1158        assert!(close(
1159            t.affine().inverse() * Point::new(5.0, 6.0),
1160            t.to_content(Point::new(5.0, 6.0))
1161        ));
1162    }
1163
1164    #[test]
1165    fn zoom_about_a_focal_keeps_the_focal_content_point_fixed() {
1166        let mut h = Harness::new(|v| v);
1167        // Pan first so the transform is not the identity.
1168        h.send(mouse(PointerPhase::Down, 300.0, 50.0));
1169        h.send(mouse(PointerPhase::Move, 280.0, 70.0));
1170        h.send(mouse(PointerPhase::Up, 280.0, 70.0));
1171        let focal = Point::new(150.0, 120.0);
1172        let before = h.transform();
1173        let content = before.affine().inverse() * focal;
1174        for (phase, delta) in [
1175            (ScalePhase::Begin, 1.0),
1176            (ScalePhase::Update, 1.5),
1177            (ScalePhase::Update, 1.2),
1178            (ScalePhase::End, 1.0),
1179        ] {
1180            h.send(scale(phase, delta, focal.x, focal.y));
1181        }
1182        let after = h.transform();
1183        assert!((after.scale - 1.8).abs() < 1e-12, "{after:?}");
1184        assert!(
1185            close(after.affine() * content, focal),
1186            "focal content point moved"
1187        );
1188        h.frame();
1189        // The child pod carries the same affine, so the framework maps the
1190        // focal back to the same content point.
1191        h.send(mouse(PointerPhase::Move, focal.x, focal.y));
1192        let last = h.log.borrow().events.last().cloned().unwrap();
1193        assert!(close(last.position(), content), "{last:?}");
1194    }
1195
1196    #[test]
1197    fn scale_clamps_to_the_bounds_and_stays_focal_correct() {
1198        let mut h = Harness::new(|v| v);
1199        let focal = Point::new(200.0, 150.0);
1200        for _ in 0..40 {
1201            h.send(scale(ScalePhase::Update, 1.5, focal.x, focal.y));
1202        }
1203        assert_eq!(h.transform().scale, DEFAULT_MAX_SCALE);
1204        for _ in 0..80 {
1205            h.send(scale(ScalePhase::Update, 0.5, focal.x, focal.y));
1206        }
1207        assert_eq!(h.transform().scale, DEFAULT_MIN_SCALE);
1208        // The focal's content point survives the clamped steps too.
1209        let content = h.transform().to_content(focal);
1210        h.send(scale(ScalePhase::Update, 0.5, focal.x, focal.y));
1211        assert!(close(h.transform().to_view(content), focal));
1212
1213        let mut h = Harness::new(|v| v.min_scale(0.5).max_scale(2.0));
1214        h.send(scale(ScalePhase::Update, 10.0, 0.0, 0.0));
1215        assert_eq!(h.transform().scale, 2.0);
1216        h.send(scale(ScalePhase::Update, 0.01, 0.0, 0.0));
1217        assert_eq!(h.transform().scale, 0.5);
1218    }
1219
1220    #[test]
1221    fn primary_drag_on_empty_content_pans() {
1222        let mut h = Harness::new(|v| v);
1223        h.send(mouse(PointerPhase::Down, 300.0, 250.0));
1224        h.send(mouse(PointerPhase::Move, 320.0, 240.0));
1225        h.send(mouse(PointerPhase::Move, 350.0, 200.0));
1226        h.send(mouse(PointerPhase::Up, 350.0, 200.0));
1227        assert_eq!(h.transform().offset, Vec2::new(50.0, -50.0));
1228        assert_eq!(h.transform().scale, 1.0);
1229        assert_eq!(
1230            h.state.transforms.len(),
1231            2,
1232            "one notification per moving event"
1233        );
1234        assert_eq!(h.state.transforms[1], h.transform());
1235        assert!(!h.root.is_pointer_captured());
1236    }
1237
1238    #[test]
1239    fn a_child_press_claims_the_gesture_and_suppresses_pan() {
1240        let mut h = Harness::new(|v| v);
1241        h.send(mouse(PointerPhase::Down, 150.0, 150.0)); // on the node
1242        h.send(mouse(PointerPhase::Move, 250.0, 250.0));
1243        h.send(mouse(PointerPhase::Up, 250.0, 250.0));
1244        assert_eq!(h.transform(), PanZoomTransform::IDENTITY);
1245        assert!(h.state.transforms.is_empty());
1246        let phases: Vec<_> = h
1247            .log
1248            .borrow()
1249            .events
1250            .iter()
1251            .filter_map(|e| match e {
1252                InputEvent::Pointer(p) => Some(p.phase),
1253                _ => None,
1254            })
1255            .collect();
1256        assert_eq!(
1257            phases,
1258            [PointerPhase::Down, PointerPhase::Move, PointerPhase::Up]
1259        );
1260    }
1261
1262    #[test]
1263    fn child_events_are_inverse_mapped_under_zoom() {
1264        let mut h = Harness::new(|v| v);
1265        h.controller.jump_to(2.0, Vec2::new(-100.0, -100.0));
1266        h.frame();
1267        // Content (150, 150) — the node — is drawn at (200, 200).
1268        h.send(mouse(PointerPhase::Down, 200.0, 200.0));
1269        h.send(mouse(PointerPhase::Move, 260.0, 200.0));
1270        h.send(mouse(PointerPhase::Up, 260.0, 200.0));
1271        let events: Vec<_> = h
1272            .log
1273            .borrow()
1274            .events
1275            .iter()
1276            .filter(|e| matches!(e, InputEvent::Pointer(_)))
1277            .cloned()
1278            .collect();
1279        assert!(close(events[0].position(), Point::new(150.0, 150.0)));
1280        assert!(close(events[1].position(), Point::new(180.0, 150.0)));
1281        assert_eq!(h.transform().offset, Vec2::new(-100.0, -100.0), "no pan");
1282    }
1283
1284    #[test]
1285    fn touch_pinch_and_desktop_scale_produce_the_same_transform() {
1286        use PointerPhase::{Down, Move, Up};
1287        // Touch: two fingers on empty content, 20 px apart about (300, 250),
1288        // spread to 140 px with the focal wandering and ending back at (300, 250).
1289        let mut touch_h = Harness::new(|v| v);
1290        touch_h.send(touch(0, Down, 290.0, 250.0));
1291        touch_h.send(touch(1, Down, 310.0, 250.0));
1292        touch_h.send(touch(1, Move, 330.0, 250.0)); // 20 → 40: Begin, focal (310, 250)
1293        touch_h.send(touch(0, Move, 270.0, 250.0)); // 40 → 60, focal (300, 250)
1294        touch_h.send(touch(1, Move, 350.0, 250.0)); // 60 → 80, focal (310, 250)
1295        touch_h.send(touch(0, Move, 210.0, 250.0)); // 80 → 140, focal (280, 250)
1296        touch_h.send(touch(1, Move, 370.0, 250.0)); // 140 → 160, focal (290, 250)
1297        touch_h.send(touch(0, Move, 230.0, 250.0)); // 160 → 140, focal (300, 250)
1298        touch_h.send(touch(1, Up, 370.0, 250.0));
1299        touch_h.send(touch(0, Up, 230.0, 250.0));
1300        let touch_t = touch_h.transform();
1301        assert!((touch_t.scale - 140.0 / 40.0).abs() < 1e-9, "{touch_t:?}");
1302
1303        // Desktop: one bracket with the same net factor, opened at the same
1304        // focal and closed at the same focal.
1305        let mut desk_h = Harness::new(|v| v);
1306        desk_h.send(scale(ScalePhase::Begin, 1.0, 310.0, 250.0));
1307        desk_h.send(scale(ScalePhase::Update, 140.0 / 40.0, 300.0, 250.0));
1308        desk_h.send(scale(ScalePhase::End, 1.0, 300.0, 250.0));
1309        let desk_t = desk_h.transform();
1310        assert!(
1311            same_transform(touch_t, desk_t),
1312            "touch {touch_t:?} vs desktop {desk_t:?}"
1313        );
1314        assert!(!touch_h.root.is_pointer_captured());
1315    }
1316
1317    #[test]
1318    fn a_pinch_over_a_child_owned_press_steals_it() {
1319        use PointerPhase::{Cancel, Down, Move, Up};
1320        let mut h = Harness::new(|v| v);
1321        h.send(touch(0, Down, 150.0, 150.0)); // the node claims it
1322        h.send(touch(1, Down, 170.0, 150.0));
1323        h.send(touch(1, Move, 210.0, 150.0)); // 20 → 60: Begin
1324        h.send(touch(0, Move, 140.0, 150.0)); // carries the steal
1325        h.send(touch(1, Move, 230.0, 150.0)); // zooms
1326        h.send(touch(1, Up, 230.0, 150.0));
1327        h.send(touch(0, Up, 140.0, 150.0));
1328        let phases: Vec<_> = h
1329            .log
1330            .borrow()
1331            .events
1332            .iter()
1333            .filter_map(|e| match e {
1334                InputEvent::Pointer(p) => Some(p.phase),
1335                _ => None,
1336            })
1337            .collect();
1338        assert_eq!(
1339            phases,
1340            [Down, Cancel],
1341            "the second finger never reaches the child"
1342        );
1343        assert!(h.transform().scale > 1.0);
1344        assert!(!h.root.is_pointer_captured());
1345    }
1346
1347    #[test]
1348    fn a_child_handled_scale_is_not_applied_and_plain_wheel_reaches_the_child() {
1349        let mut h = Harness::with_content(true, Some(CONTENT), |v| v);
1350        h.send(InputEvent::Scroll {
1351            position: Point::new(50.0, 50.0),
1352            delta: ScrollDelta::Lines(0.0, 3.0),
1353        });
1354        assert!(matches!(
1355            h.log.borrow().events.last(),
1356            Some(InputEvent::Scroll { .. })
1357        ));
1358        assert_eq!(
1359            h.transform(),
1360            PanZoomTransform::IDENTITY,
1361            "a wheel never pans"
1362        );
1363
1364        // The content ignores Scale, so the view zooms; the child saw it first.
1365        h.send(scale(ScalePhase::Update, 2.0, 50.0, 50.0));
1366        assert!(matches!(
1367            h.log.borrow().events.last(),
1368            Some(InputEvent::Scale(_))
1369        ));
1370        assert_eq!(h.transform().scale, 2.0);
1371    }
1372
1373    #[test]
1374    fn controller_jump_and_fit() {
1375        let mut h = Harness::new(|v| v);
1376        h.controller.jump_to(3.0, Vec2::new(10.0, 20.0));
1377        h.frame();
1378        assert_eq!(
1379            h.transform(),
1380            PanZoomTransform {
1381                scale: 3.0,
1382                offset: Vec2::new(10.0, 20.0)
1383            }
1384        );
1385        assert_eq!(h.controller.viewport_size(), Size::new(400.0, 300.0));
1386        assert_eq!(h.controller.content_size(), CONTENT);
1387        // The owed notification arrives through the next frame's flush.
1388        h.frame();
1389        assert_eq!(h.state.transforms.last(), Some(&h.transform()));
1390
1391        // An out-of-bounds jump clamps.
1392        h.controller.jump_to(100.0, Vec2::ZERO);
1393        h.frame();
1394        assert_eq!(h.transform().scale, DEFAULT_MAX_SCALE);
1395
1396        // Fit 1000 × 800 into 400 × 300: limited by height, 0.375, centred.
1397        h.controller.fit_to_bounds();
1398        h.frame();
1399        let fit = h.transform();
1400        assert!((fit.scale - 0.375).abs() < 1e-12);
1401        assert!(close(
1402            fit.to_view(Point::new(500.0, 400.0)),
1403            Point::new(200.0, 150.0)
1404        ));
1405
1406        // Fit the node: 100 × 100 into 400 × 300 → 3.0, node centre at view centre.
1407        h.controller.fit_rect(NODE);
1408        h.frame();
1409        let fit = h.transform();
1410        assert!((fit.scale - 3.0).abs() < 1e-12);
1411        assert!(close(fit.to_view(NODE.center()), Point::new(200.0, 150.0)));
1412    }
1413
1414    #[test]
1415    fn inertia_glides_then_settles() {
1416        let mut h = Harness::new(|v| v.inertia(true));
1417        h.send(mouse(PointerPhase::Down, 300.0, 250.0));
1418        for step in 1..=4 {
1419            h.frame();
1420            h.send(mouse(PointerPhase::Move, 300.0 - 20.0 * step as f64, 250.0));
1421        }
1422        h.send(mouse(PointerPhase::Up, 220.0, 250.0));
1423        let released = h.transform().offset;
1424        assert_eq!(released, Vec2::new(-80.0, 0.0));
1425        let mut frames = 0;
1426        loop {
1427            let outcome = h.frame();
1428            frames += 1;
1429            if !outcome.needs_frame {
1430                break;
1431            }
1432            assert!(frames < 200, "the glide never settled");
1433        }
1434        h.frame(); // deliver the final owed notification
1435        let rest = h.transform().offset;
1436        assert!(rest.x < released.x - 50.0, "glided on: {rest:?}");
1437        assert_eq!(rest.y, 0.0);
1438        assert!(frames > 3, "the glide took several frames");
1439        assert_eq!(h.state.transforms.last().map(|t| t.offset), Some(rest));
1440        // Settled: further frames move nothing.
1441        h.frame();
1442        assert_eq!(h.transform().offset, rest);
1443
1444        // Without inertia the same release stops dead.
1445        let mut h = Harness::new(|v| v);
1446        h.send(mouse(PointerPhase::Down, 300.0, 250.0));
1447        for step in 1..=4 {
1448            h.frame();
1449            h.send(mouse(PointerPhase::Move, 300.0 - 20.0 * step as f64, 250.0));
1450        }
1451        h.send(mouse(PointerPhase::Up, 220.0, 250.0));
1452        assert!(!h.frame().needs_frame);
1453        assert_eq!(h.transform().offset, Vec2::new(-80.0, 0.0));
1454    }
1455
1456    #[test]
1457    fn a_press_stops_a_glide() {
1458        let mut h = Harness::new(|v| v.inertia(true));
1459        h.send(mouse(PointerPhase::Down, 300.0, 250.0));
1460        for step in 1..=4 {
1461            h.frame();
1462            h.send(mouse(PointerPhase::Move, 300.0 - 20.0 * step as f64, 250.0));
1463        }
1464        h.send(mouse(PointerPhase::Up, 220.0, 250.0));
1465        h.frame();
1466        h.frame();
1467        h.send(mouse(PointerPhase::Down, 300.0, 250.0));
1468        let held = h.transform();
1469        assert!(!h.frame().needs_frame);
1470        assert_eq!(h.transform(), held);
1471    }
1472
1473    #[test]
1474    fn an_expanding_child_is_laid_out_at_the_viewport() {
1475        let h = Harness::with_content(false, None, |v| v);
1476        assert_eq!(h.controller.content_size(), Size::new(400.0, 300.0));
1477        assert_eq!(h.transform(), PanZoomTransform::IDENTITY);
1478    }
1479
1480    #[test]
1481    fn rebuilt_bounds_reclamp_about_the_viewport_centre() {
1482        let max = Rc::new(std::cell::Cell::new(8.0));
1483        let bound = max.clone();
1484        let mut h = Harness::new(move |v| v.max_scale(bound.get()));
1485        h.send(scale(ScalePhase::Update, 4.0, 0.0, 0.0));
1486        assert_eq!(h.transform().scale, 4.0);
1487        let centre = Point::new(200.0, 150.0);
1488        let content = h.transform().to_content(centre);
1489        max.set(2.0);
1490        h.frame();
1491        assert_eq!(h.transform().scale, 2.0);
1492        assert!(close(h.transform().to_view(content), centre));
1493    }
1494
1495    // --- Nested-scroll claim: this view joins `scroll.rs`'s innermost-wins
1496    //     seam when it is about to pan. See `begin_gesture` and the module
1497    //     docs' *Pan* bullet. ---
1498
1499    use crate::flex::Column;
1500    use crate::scroll::{ScrollView, scroll_view};
1501    use crate::sized::SizedBox;
1502
1503    /// The fixed height of the pan_zoom row in the nested-claim fixture.
1504    const PAN_H: f64 = 150.0;
1505    /// The fixed height of the plain sibling row — tall enough that, added to
1506    /// [`PAN_H`], the column exceeds the 300px viewport (so the outer
1507    /// `scroll_view` genuinely has somewhere to scroll).
1508    const SIBLING_H: f64 = 300.0;
1509
1510    type NestLogic = Box<dyn FnMut(&mut App) -> ScrollView<App>>;
1511
1512    /// `scroll_view(Column([pan_zoom(Content), plain sibling]))` — a real tree
1513    /// shape (`scroll_view(pan_zoom(canvas))` on a page with other content),
1514    /// laid out at the same 400×300 window every other fixture in this module
1515    /// uses. Exercises `scroll.rs`'s claim seam from the outside rather than
1516    /// reimplementing it.
1517    struct NestFixture {
1518        root: RenderRoot<App, ScrollView<App>>,
1519        state: App,
1520        logic: NestLogic,
1521        controller: PanZoomController,
1522    }
1523
1524    impl NestFixture {
1525        fn new() -> Self {
1526            let log = Rc::new(RefCell::new(Log::default()));
1527            let controller = PanZoomController::new();
1528            let (child_log, child_controller) = (log, controller.clone());
1529            let logic: NestLogic = Box::new(move |_: &mut App| {
1530                scroll_view(Column(vec![
1531                    any(SizedBox(None, Some(PAN_H)).child(
1532                        pan_zoom(Content {
1533                            log: child_log.clone(),
1534                            scrolls: false,
1535                            size: Some(Size::new(400.0, PAN_H)),
1536                        })
1537                        .controller(child_controller.clone()),
1538                    )),
1539                    any(SizedBox(None, Some(SIBLING_H)).child(Content {
1540                        log: child_log.clone(),
1541                        scrolls: false,
1542                        size: Some(Size::new(400.0, SIBLING_H)),
1543                    })),
1544                ]))
1545                .on_scroll(|s: &mut App, info| s.scroll_offsets.push(info.offset))
1546            });
1547            let mut fixture = NestFixture {
1548                root: RenderRoot::new(),
1549                state: App::default(),
1550                logic,
1551                controller,
1552            };
1553            fixture.frame();
1554            fixture
1555        }
1556
1557        fn frame(&mut self) {
1558            self.root.rebuild(&mut self.logic, &mut self.state);
1559            self.root.layout(Size::new(400.0, 300.0));
1560            self.root.paint(&mut NullScene, FrameTime::from_nanos(0));
1561        }
1562
1563        fn send(&mut self, event: InputEvent) {
1564            self.root.event(&mut self.state, &event);
1565        }
1566
1567        fn transform(&self) -> PanZoomTransform {
1568            self.controller.transform()
1569        }
1570    }
1571
1572    #[test]
1573    fn a_pan_inside_a_scroll_view_claims_the_vertical_drag_so_the_outer_defers() {
1574        let mut h = NestFixture::new();
1575        // Down at (50, 50): inside the pan_zoom row (0–150), outside the
1576        // node (x 100–200), so the content ignores it and the view pans.
1577        h.send(mouse(PointerPhase::Down, 50.0, 50.0));
1578        // 30px down, past TOUCH_SLOP (18).
1579        h.send(mouse(PointerPhase::Move, 50.0, 80.0));
1580        assert_eq!(
1581            h.transform().offset,
1582            Vec2::new(0.0, 30.0),
1583            "the pan applied the drag"
1584        );
1585        assert!(
1586            h.state.scroll_offsets.is_empty(),
1587            "the outer scroll_view never scrolled: {:?}",
1588            h.state.scroll_offsets
1589        );
1590        h.send(mouse(PointerPhase::Up, 50.0, 80.0));
1591    }
1592
1593    #[test]
1594    fn a_drag_over_a_sibling_outside_pan_zoom_still_scrolls_the_outer() {
1595        let mut h = NestFixture::new();
1596        // Down at (50, 200): inside the plain sibling row (150–450 in the
1597        // column, i.e. 150–300 of the visible viewport at rest), well clear
1598        // of the pan_zoom row entirely.
1599        h.send(mouse(PointerPhase::Down, 50.0, 200.0));
1600        h.send(mouse(PointerPhase::Move, 50.0, 170.0)); // 30px up, past slop: arms the takeover
1601        h.send(mouse(PointerPhase::Move, 50.0, 160.0)); // the move that actually scrolls
1602        assert!(
1603            !h.state.scroll_offsets.is_empty(),
1604            "the outer scroll_view took the drag as it always has"
1605        );
1606        assert_eq!(
1607            h.transform(),
1608            PanZoomTransform::IDENTITY,
1609            "the pan_zoom row was never touched"
1610        );
1611        h.send(mouse(PointerPhase::Up, 50.0, 170.0));
1612    }
1613
1614    #[test]
1615    fn a_child_claimed_press_inside_pan_zoom_registers_no_claim_and_the_outer_still_takes_over() {
1616        let mut h = NestFixture::new();
1617        // Down at (150, 120): inside the pan_zoom row and inside the node
1618        // (100–200, 100–200) — the content claims it, so the view does not
1619        // publish a claim into the outer's cell.
1620        h.send(mouse(PointerPhase::Down, 150.0, 120.0));
1621        h.send(mouse(PointerPhase::Move, 150.0, 150.0)); // 30px down, past slop: arms the takeover
1622        h.send(mouse(PointerPhase::Move, 150.0, 160.0)); // the move that actually scrolls
1623        assert!(
1624            !h.state.scroll_offsets.is_empty(),
1625            "an unregistered claim leaves the outer free to take over, as before"
1626        );
1627        assert_eq!(
1628            h.transform(),
1629            PanZoomTransform::IDENTITY,
1630            "the pan_zoom row never panned — its child owned (then lost) the gesture"
1631        );
1632        h.send(mouse(PointerPhase::Up, 150.0, 150.0));
1633    }
1634
1635    // --- Stale `last_focal`: a dead `Scale` bracket must not leak into a
1636    //     later lone `Update`. See `apply_scale`'s module doc and the `Scroll`
1637    //     arm of `Widget::event`. ---
1638
1639    #[test]
1640    fn a_scroll_between_scale_events_clears_the_stale_focal_so_a_later_update_anchors_at_itself() {
1641        let mut h = Harness::new(|v| v);
1642        // Open a bracket…
1643        h.send(scale(ScalePhase::Begin, 1.0, 50.0, 50.0));
1644        // …and let the rest of it arrive as a plain, unmodified wheel Scroll
1645        // instead of an `End` — the macOS trackpad-pinch-with-released-⌘
1646        // case — reaching the child since nothing here handles `Scroll`.
1647        h.send(InputEvent::Scroll {
1648            position: Point::new(10.0, 10.0),
1649            delta: ScrollDelta::Lines(0.0, 1.0),
1650        });
1651        // A later lone Update — an ordinary, unrelated wheel notch — must
1652        // anchor at its own focal, not read `last_focal` as the dead
1653        // bracket's continuation.
1654        h.send(scale(ScalePhase::Update, 2.0, 300.0, 200.0));
1655        assert_eq!(h.transform().scale, 2.0);
1656        assert!(
1657            close(
1658                h.transform().to_content(Point::new(300.0, 200.0)),
1659                Point::new(300.0, 200.0)
1660            ),
1661            "the update's own focal content point must stay fixed: {:?}",
1662            h.transform()
1663        );
1664    }
1665}