1use 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
93pub const DEFAULT_MIN_SCALE: f64 = 0.25;
96
97pub const DEFAULT_MAX_SCALE: f64 = 8.0;
100
101const GLIDE_DRAG: f64 = 0.000_013_5;
109
110const GLIDE_TOLERANCE: Tolerance = Tolerance {
113 velocity: FLING_STOP,
114 distance: 0.5,
115};
116
117#[derive(Clone, Copy, Debug, PartialEq)]
123pub struct PanZoomTransform {
124 pub scale: f64,
126 pub offset: Vec2,
128}
129
130impl PanZoomTransform {
131 pub const IDENTITY: PanZoomTransform = PanZoomTransform {
133 scale: 1.0,
134 offset: Vec2::ZERO,
135 };
136
137 pub fn affine(&self) -> Affine {
139 Affine::translate(self.offset) * Affine::scale(self.scale)
140 }
141
142 pub fn to_content(&self, point: Point) -> Point {
144 ((point.to_vec2() - self.offset) / self.scale).to_point()
145 }
146
147 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#[derive(Clone, Copy, Debug, PartialEq)]
161enum Command {
162 JumpTo { scale: f64, offset: Vec2 },
163 FitContent,
164 FitRect(Rect),
165}
166
167#[derive(Debug, Default)]
169struct Shared {
170 transform: PanZoomTransform,
171 viewport: Size,
172 content: Size,
173 pending: Option<Command>,
174}
175
176#[derive(Clone, Debug, Default)]
187pub struct PanZoomController {
188 shared: Rc<RefCell<Shared>>,
189}
190
191impl PanZoomController {
192 pub fn new() -> Self {
194 Self::default()
195 }
196
197 pub fn transform(&self) -> PanZoomTransform {
200 self.shared.borrow().transform
201 }
202
203 pub fn viewport_size(&self) -> Size {
205 self.shared.borrow().viewport
206 }
207
208 pub fn content_size(&self) -> Size {
210 self.shared.borrow().content
211 }
212
213 pub fn jump_to(&self, scale: f64, offset: Vec2) {
216 self.record(Command::JumpTo { scale, offset });
217 }
218
219 pub fn fit_to_bounds(&self) {
222 self.record(Command::FitContent);
223 }
224
225 pub fn fit_rect(&self, rect: Rect) {
228 self.record(Command::FitRect(rect));
229 }
230
231 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
263pub 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
273pub 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 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 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 pub fn inertia(mut self, inertia: bool) -> Self {
307 self.inertia = inertia;
308 self
309 }
310
311 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 pub fn controller(mut self, controller: PanZoomController) -> Self {
323 self.controller = Some(controller);
324 self
325 }
326
327 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#[derive(Clone, Copy, Debug, PartialEq)]
338enum Drag {
339 Idle,
341 Child,
343 StealPending,
346 Pan { last: Point },
348}
349
350struct Glide {
352 x: FrictionSimulation,
353 y: FrictionSimulation,
354 start: Option<frust_core::FrameTime>,
356}
357
358pub 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 claimant: Option<PointerId>,
372 drag: Drag,
373 last_focal: Option<Point>,
375 tracker_x: VelocityTracker,
376 tracker_y: VelocityTracker,
377 glide: Option<Glide>,
378 notify_owed: bool,
380 notify_requested: bool,
382 last_frame_ms: f64,
384 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 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 pub fn transform(&self) -> PanZoomTransform {
468 self.transform
469 }
470
471 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 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 fn publish(&self) {
502 if let Some(controller) = &self.controller {
503 controller.publish(self.transform, self.viewport, self.content);
504 }
505 }
506
507 fn owe_notify(&mut self) {
509 self.notify_owed = true;
510 }
511
512 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 fn deliver_owed(&mut self, ctx: &mut EventCtx) {
524 if self.notify_owed {
525 self.changed(ctx);
526 }
527 }
528
529 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 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 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 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 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 self.tracker_x.clear();
616 self.tracker_y.clear();
617 }
618 if p.phase == PointerPhase::Cancel {
619 self.last_focal = None;
621 return;
622 }
623 if self.apply_scale(&scale) {
624 self.changed(ctx);
625 }
626 }
627
628 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 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 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 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 self.child.set_focused(false);
708 }
709 if result == EventResult::Handled {
710 self.drag = Drag::Child;
711 } 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 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 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 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 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 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 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 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 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 self.last_focal = None;
936 route_event_single(&mut self.child, ctx, event)
937 }
938 _ => route_event_single(&mut self.child, ctx, event),
940 }
941 }
942
943 fn semantics(&self, ctx: &mut SemanticsCtx) {
944 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 #[derive(Default)]
959 struct Log {
960 events: Vec<InputEvent>,
961 }
962
963 #[derive(Default)]
964 struct App {
965 transforms: Vec<PanZoomTransform>,
966 scroll_offsets: Vec<f64>,
970 }
971
972 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 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 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 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 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)); 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 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 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)); touch_h.send(touch(0, Move, 270.0, 250.0)); touch_h.send(touch(1, Move, 350.0, 250.0)); touch_h.send(touch(0, Move, 210.0, 250.0)); touch_h.send(touch(1, Move, 370.0, 250.0)); touch_h.send(touch(0, Move, 230.0, 250.0)); 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 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)); h.send(touch(1, Down, 170.0, 150.0));
1323 h.send(touch(1, Move, 210.0, 150.0)); h.send(touch(0, Move, 140.0, 150.0)); h.send(touch(1, Move, 230.0, 150.0)); 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 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 h.frame();
1389 assert_eq!(h.state.transforms.last(), Some(&h.transform()));
1390
1391 h.controller.jump_to(100.0, Vec2::ZERO);
1393 h.frame();
1394 assert_eq!(h.transform().scale, DEFAULT_MAX_SCALE);
1395
1396 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 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(); 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 h.frame();
1442 assert_eq!(h.transform().offset, rest);
1443
1444 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 use crate::flex::Column;
1500 use crate::scroll::{ScrollView, scroll_view};
1501 use crate::sized::SizedBox;
1502
1503 const PAN_H: f64 = 150.0;
1505 const SIBLING_H: f64 = 300.0;
1509
1510 type NestLogic = Box<dyn FnMut(&mut App) -> ScrollView<App>>;
1511
1512 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 h.send(mouse(PointerPhase::Down, 50.0, 50.0));
1578 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 h.send(mouse(PointerPhase::Down, 50.0, 200.0));
1600 h.send(mouse(PointerPhase::Move, 50.0, 170.0)); h.send(mouse(PointerPhase::Move, 50.0, 160.0)); 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 h.send(mouse(PointerPhase::Down, 150.0, 120.0));
1621 h.send(mouse(PointerPhase::Move, 150.0, 150.0)); h.send(mouse(PointerPhase::Move, 150.0, 160.0)); 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 #[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 h.send(scale(ScalePhase::Begin, 1.0, 50.0, 50.0));
1644 h.send(InputEvent::Scroll {
1648 position: Point::new(10.0, 10.0),
1649 delta: ScrollDelta::Lines(0.0, 1.0),
1650 });
1651 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}