Skip to main content

repose_core/
gesture.rs

1use crate::animation::{AnimatedValue, AnimationSpec};
2use crate::frame_clock::request_frame;
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5use web_time::Instant;
6
7/// Axis for drag/swipe gestures.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub enum DragOrientation {
10    Horizontal,
11    Vertical,
12}
13
14/// Configuration for a [`SwipeableState`].
15#[derive(Clone)]
16pub struct SwipeableConfig {
17    /// How the offset animates to the target anchor on release (default: spring_gentle).
18    pub animation_spec: AnimationSpec,
19    /// Fractional threshold between anchors for positional snapping (0.0–1.0).
20    /// 0.3 means the swipe must cross 30% of the gap to snap to the next anchor.
21    pub positional_threshold: f32,
22    /// Velocity threshold in px/s. If the release velocity exceeds this,
23    /// the offset snaps to the anchor in the direction of movement regardless
24    /// of positional threshold. Set to `f32::MAX` to disable velocity-based snapping.
25    pub velocity_threshold: f32,
26    /// Maximum offset past the first/last anchor (resistance). 0 = no limit.
27    pub max_overshoot: f32,
28}
29
30impl Default for SwipeableConfig {
31    fn default() -> Self {
32        Self {
33            animation_spec: AnimationSpec::spring_gentle(),
34            positional_threshold: 0.3,
35            velocity_threshold: f32::MAX,
36            max_overshoot: 0.0,
37        }
38    }
39}
40
41/// A generic swipeable state that tracks an animated offset and snaps to
42/// the nearest anchor point on release.
43///
44/// # Type Parameters
45///
46/// * `T` - The anchor value type (e.g. `DismissValue`, `usize` for pager pages).
47///
48/// # Usage
49///
50/// ```ignore
51/// use repose_core::*;
52///
53/// let state = SwipeableState::new(
54///     vec![(0.0, "start"), (-200.0, "end")],
55///     SwipeableConfig::default(),
56/// );
57///
58/// // Wire pointer events using on_pointer_down/move/up modifier callbacks:
59/// // state.on_pointer_down(e.position.x)
60/// // state.on_pointer_move(e.position.x)
61/// // state.on_pointer_up()
62///
63/// // Read offset each frame:
64/// let offset = state.offset();
65/// ```
66pub struct SwipeableState<T: Clone + PartialEq + 'static> {
67    anim: Rc<RefCell<AnimatedValue<f32>>>,
68    anchors: Rc<Vec<(f32, T)>>,
69    config: Rc<SwipeableConfig>,
70    drag_start: Rc<Cell<Option<f32>>>,
71    drag_base: Rc<Cell<f32>>,
72    last_move_time: Rc<Cell<Option<Instant>>>,
73    last_move_pos: Rc<Cell<Option<f32>>>,
74    release_velocity: Rc<Cell<f32>>,
75}
76
77impl<T: Clone + PartialEq + 'static> SwipeableState<T> {
78    /// Create a new swipeable state.
79    ///
80    /// `anchors` - pairs of `(offset_px, value)` sorted by offset. The first anchor
81    /// is the initial position. At least one anchor is required.
82    pub fn new(anchors: Vec<(f32, T)>, config: SwipeableConfig) -> Self {
83        assert!(
84            !anchors.is_empty(),
85            "SwipeableState requires at least one anchor"
86        );
87        let initial_offset = anchors[0].0;
88        Self {
89            anim: Rc::new(RefCell::new(AnimatedValue::new(
90                initial_offset,
91                config.animation_spec,
92            ))),
93            anchors: Rc::new(anchors),
94            config: Rc::new(config),
95            drag_start: Rc::new(Cell::new(None)),
96            drag_base: Rc::new(Cell::new(initial_offset)),
97            last_move_time: Rc::new(Cell::new(None)),
98            last_move_pos: Rc::new(Cell::new(None)),
99            release_velocity: Rc::new(Cell::new(0.0)),
100        }
101    }
102
103    /// Current animated offset in pixels. Call every frame that needs the value.
104    /// Requests a re-draw while the spring is still running.
105    pub fn offset(&self) -> f32 {
106        let mut anim = self.anim.borrow_mut();
107        if anim.update() {
108            request_frame();
109        }
110        *anim.get()
111    }
112
113    /// Resolve the nearest anchor value from the current offset.
114    pub fn current_value(&self) -> T {
115        let off = *self.anim.borrow().get();
116        self.nearest_anchor(off).map(|(_, v)| v).unwrap_or_else(|| {
117            self.anchors
118                .first()
119                .map(|(_, v)| v.clone())
120                .unwrap_or_else(|| panic!("SwipeableState has no anchors"))
121        })
122    }
123
124    /// All registered `(offset, value)` anchors in order.
125    pub fn anchors(&self) -> Vec<(f32, T)> {
126        self.anchors.iter().cloned().collect()
127    }
128
129    /// Offset registered for an anchor value, if present.
130    pub fn anchor_offset(&self, value: &T) -> Option<f32> {
131        self.anchors
132            .iter()
133            .find(|(_, v)| v == value)
134            .map(|(o, _)| *o)
135    }
136
137    /// Snap to an absolute offset instantly (used during active drag).
138    pub fn snap_to(&self, off: f32) {
139        let clamped = self.clamp_offset(off);
140        self.anim.borrow_mut().snap_to(clamped);
141        request_frame();
142    }
143
144    /// Animate to the offset corresponding to a target value.
145    pub fn animate_to(&self, value: &T) {
146        if let Some((offset, _)) = self.anchors.iter().find(|(_, v)| v == value) {
147            self.anim.borrow_mut().set_target(*offset);
148            request_frame();
149        }
150    }
151
152    /// Returns true if the spring is still running.
153    pub fn is_animating(&self) -> bool {
154        self.anim.borrow().is_animating()
155    }
156
157    /// Call from `on_pointer_down` with the position along the drag axis.
158    pub fn on_pointer_down(&self, axis_pos: f32) {
159        self.drag_start.set(Some(axis_pos));
160        self.drag_base.set(*self.anim.borrow().get());
161        self.last_move_time.set(Some(Instant::now()));
162        self.last_move_pos.set(Some(axis_pos));
163        self.release_velocity.set(0.0);
164    }
165
166    /// Call from `on_pointer_move` with the position along the drag axis.
167    pub fn on_pointer_move(&self, axis_pos: f32) {
168        if let Some(start) = self.drag_start.get() {
169            let now = Instant::now();
170            let delta = axis_pos - start;
171            let new_offset = self.drag_base.get() + delta;
172
173            // Compute velocity from the last frame
174            if let (Some(last_pos), Some(last_time)) =
175                (self.last_move_pos.get(), self.last_move_time.get())
176            {
177                let dt = (now - last_time).as_secs_f32().max(1.0 / 240.0);
178                let vel = (axis_pos - last_pos) / dt;
179                self.release_velocity.set(vel);
180            }
181
182            self.snap_to(new_offset);
183            self.last_move_pos.set(Some(axis_pos));
184            self.last_move_time.set(Some(now));
185        }
186    }
187
188    /// Call from `on_pointer_up`. Snaps to the appropriate anchor based on
189    /// position and velocity.
190    pub fn on_pointer_up(&self) {
191        self.drag_start.set(None);
192        let off = *self.anim.borrow().get();
193        let vel = self.release_velocity.get();
194        let config = &*self.config;
195
196        // If velocity exceeds threshold, snap in the direction of movement.
197        if vel.abs() > config.velocity_threshold {
198            let target_off = if vel < 0.0 {
199                self.next_anchor_down(off)
200            } else {
201                self.next_anchor_up(off)
202            };
203            self.anim.borrow_mut().set_target(target_off);
204            request_frame();
205            return;
206        }
207
208        // Otherwise, find the anchor pair surrounding the current offset and
209        // check the fractional threshold.
210        let target_off = self.snap_target(off, config.positional_threshold);
211        self.anim.borrow_mut().set_target(target_off);
212        request_frame();
213    }
214
215    /// Clamp offset to the anchor range (with optional overshoot).
216    fn clamp_offset(&self, off: f32) -> f32 {
217        let min = self.anchors.first().map(|(o, _)| *o).unwrap_or(0.0);
218        let max = self.anchors.last().map(|(o, _)| *o).unwrap_or(0.0);
219        let lo = if min <= max {
220            min - self.config.max_overshoot
221        } else {
222            max - self.config.max_overshoot
223        };
224        let hi = if min <= max {
225            max + self.config.max_overshoot
226        } else {
227            min + self.config.max_overshoot
228        };
229        off.clamp(lo.min(hi), lo.max(hi))
230    }
231
232    /// Find the anchor value nearest to a given offset.
233    fn nearest_anchor(&self, off: f32) -> Option<(f32, T)> {
234        self.anchors
235            .iter()
236            .min_by(|(a, _), (b, _)| (a - off).abs().partial_cmp(&(b - off).abs()).unwrap())
237            .map(|(o, v)| (*o, v.clone()))
238    }
239
240    /// Next anchor to the left (more negative).
241    fn next_anchor_down(&self, off: f32) -> f32 {
242        self.anchors
243            .iter()
244            .rev()
245            .find(|(a, _)| *a < off)
246            .map(|(o, _)| *o)
247            .unwrap_or_else(|| self.anchors.last().map(|(o, _)| *o).unwrap_or(off))
248    }
249
250    /// Next anchor to the right (more positive).
251    fn next_anchor_up(&self, off: f32) -> f32 {
252        self.anchors
253            .iter()
254            .find(|(a, _)| *a > off)
255            .map(|(o, _)| *o)
256            .unwrap_or_else(|| self.anchors.first().map(|(o, _)| *o).unwrap_or(off))
257    }
258
259    /// Determine the snap target based on positional fraction between anchors.
260    fn snap_target(&self, off: f32, threshold: f32) -> f32 {
261        // Find the surrounding anchor pair
262        let upper = self.anchors.iter().find(|(a, _)| *a >= off);
263        let lower = self.anchors.iter().rev().find(|(a, _)| *a <= off);
264
265        match (lower, upper) {
266            (Some((lo, _)), Some((hi, _))) if lo != hi => {
267                // Fraction between lo and hi
268                let range = hi - lo;
269                if range == 0.0 {
270                    *lo
271                } else {
272                    let fraction = (off - lo) / range;
273                    if fraction >= threshold { *hi } else { *lo }
274                }
275            }
276            (Some((lo, _)), _) => {
277                // Past the minimum anchor - snap to it
278                *lo
279            }
280            (_, Some((hi, _))) => {
281                // Past the maximum anchor - snap to it
282                *hi
283            }
284            _ => off,
285        }
286    }
287}
288
289pub struct DraggableConfig {
290    pub orientation: Option<DragOrientation>,
291    pub enabled: bool,
292    pub reverse_direction: bool,
293    pub start_drag_immediately: bool,
294    pub slop_px: f32,
295}
296
297impl Default for DraggableConfig {
298    fn default() -> Self {
299        Self {
300            orientation: None,
301            enabled: true,
302            reverse_direction: false,
303            start_drag_immediately: false,
304            slop_px: 18.0,
305        }
306    }
307}
308
309impl DragOrientation {
310    pub fn horizontal() -> DraggableConfig {
311        DraggableConfig {
312            orientation: Some(DragOrientation::Horizontal),
313            ..Default::default()
314        }
315    }
316
317    pub fn vertical() -> DraggableConfig {
318        DraggableConfig {
319            orientation: Some(DragOrientation::Vertical),
320            ..Default::default()
321        }
322    }
323}
324
325pub struct TransformableState {
326    on_transform: Rc<dyn Fn(f32, crate::Vec2, f32)>,
327    enabled: Cell<bool>,
328    lock_rotation_on_zoom_pan: Cell<bool>,
329    last_pinch: Cell<Option<Instant>>,
330}
331
332impl TransformableState {
333    pub fn new(on_transform: impl Fn(f32, crate::Vec2, f32) + 'static) -> Self {
334        Self {
335            on_transform: Rc::new(on_transform),
336            enabled: Cell::new(true),
337            lock_rotation_on_zoom_pan: Cell::new(false),
338            last_pinch: Cell::new(None),
339        }
340    }
341
342    pub fn set_enabled(&self, enabled: bool) {
343        self.enabled.set(enabled);
344    }
345
346    pub fn set_lock_rotation_on_zoom_pan(&self, lock: bool) {
347        self.lock_rotation_on_zoom_pan.set(lock);
348    }
349
350    pub fn handle_gesture(&self, gesture: &crate::shortcuts::Gesture) -> bool {
351        if !self.enabled.get() {
352            return false;
353        }
354        match gesture {
355            crate::shortcuts::Gesture::PinchWithCenter { delta_scale, .. } => {
356                self.last_pinch.set(Some(Instant::now()));
357                (self.on_transform)(*delta_scale, crate::Vec2::ZERO, 0.0);
358                request_frame();
359                true
360            }
361            crate::shortcuts::Gesture::Rotate { delta_rotation, .. } => {
362                if self.lock_rotation_on_zoom_pan.get()
363                    && let Some(t) = self.last_pinch.get()
364                    && t.elapsed().as_millis() < 120
365                {
366                    return true;
367                }
368                (self.on_transform)(1.0, crate::Vec2::ZERO, *delta_rotation);
369                request_frame();
370                true
371            }
372            crate::shortcuts::Gesture::Pan { delta, .. } => {
373                (self.on_transform)(1.0, *delta, 0.0);
374                request_frame();
375                true
376            }
377            _ => false,
378        }
379    }
380}
381
382impl Clone for TransformableState {
383    fn clone(&self) -> Self {
384        Self {
385            on_transform: self.on_transform.clone(),
386            enabled: Cell::new(self.enabled.get()),
387            lock_rotation_on_zoom_pan: Cell::new(self.lock_rotation_on_zoom_pan.get()),
388            last_pinch: Cell::new(self.last_pinch.get()),
389        }
390    }
391}
392
393pub type ConfirmValueChange<T> = Rc<dyn Fn(T) -> bool>;
394
395pub struct AnchoredDraggableState<T: Clone + PartialEq + 'static> {
396    inner: SwipeableState<T>,
397    config: SwipeableConfig,
398    confirm_value_change: Rc<RefCell<Option<ConfirmValueChange<T>>>>,
399    target: Rc<RefCell<Option<T>>>,
400}
401
402impl<T: Clone + PartialEq + 'static> AnchoredDraggableState<T> {
403    pub fn new(
404        anchors: Vec<(f32, T)>,
405        config: SwipeableConfig,
406        confirm_value_change: Option<ConfirmValueChange<T>>,
407    ) -> Self {
408        let target = anchors.first().map(|(_, v)| v.clone());
409        Self {
410            inner: SwipeableState::new(anchors, config.clone()),
411            config,
412            confirm_value_change: Rc::new(RefCell::new(confirm_value_change)),
413            target: Rc::new(RefCell::new(target)),
414        }
415    }
416
417    pub fn update_anchors(&mut self, anchors: Vec<(f32, T)>) {
418        let current = self.inner.offset();
419        self.inner = SwipeableState::new(anchors, self.config.clone());
420        self.inner.snap_to(current);
421    }
422
423    pub fn anchors(&self) -> Vec<(f32, T)> {
424        self.inner.anchors()
425    }
426
427    pub fn anchor_offset_for(&self, value: &T) -> Option<f32> {
428        self.inner.anchor_offset(value)
429    }
430
431    pub fn offset(&self) -> f32 {
432        self.inner.offset()
433    }
434
435    pub fn current_value(&self) -> T {
436        self.inner.current_value()
437    }
438
439    pub fn target_value(&self) -> Option<T> {
440        self.target.borrow().clone()
441    }
442
443    pub fn is_animating(&self) -> bool {
444        self.inner.is_animating()
445    }
446
447    pub fn snap_to(&self, value: &T) {
448        if self
449            .confirm_value_change
450            .borrow()
451            .as_ref()
452            .is_some_and(|f| !f(value.clone()))
453        {
454            return;
455        }
456        if let Some(offset) = self.inner.anchor_offset(value) {
457            *self.target.borrow_mut() = Some(value.clone());
458            self.inner.snap_to(offset);
459        }
460    }
461
462    pub fn animate_to(&self, value: &T) {
463        if self
464            .confirm_value_change
465            .borrow()
466            .as_ref()
467            .is_some_and(|f| !f(value.clone()))
468        {
469            return;
470        }
471        *self.target.borrow_mut() = Some(value.clone());
472        self.inner.animate_to(value);
473    }
474
475    pub fn on_pointer_down(&self, axis_pos: f32) {
476        self.inner.on_pointer_down(axis_pos);
477    }
478
479    pub fn on_pointer_move(&self, axis_pos: f32) {
480        self.inner.on_pointer_move(axis_pos);
481    }
482
483    pub fn on_pointer_up(&self) {
484        let candidate = self.inner.current_value();
485        if self
486            .confirm_value_change
487            .borrow()
488            .as_ref()
489            .is_some_and(|f| !f(candidate.clone()))
490        {
491            let settled = self.target.borrow().clone();
492            if let Some(settled) = settled {
493                self.inner.animate_to(&settled);
494            }
495            return;
496        }
497        *self.target.borrow_mut() = Some(candidate);
498        self.inner.on_pointer_up();
499    }
500}
501
502impl<T: Clone + PartialEq + 'static> Clone for AnchoredDraggableState<T> {
503    fn clone(&self) -> Self {
504        Self {
505            inner: self.inner.clone(),
506            config: self.config.clone(),
507            confirm_value_change: self.confirm_value_change.clone(),
508            target: self.target.clone(),
509        }
510    }
511}
512
513impl<T: Clone + PartialEq + 'static> Clone for SwipeableState<T> {
514    fn clone(&self) -> Self {
515        Self {
516            anim: self.anim.clone(),
517            anchors: self.anchors.clone(),
518            config: self.config.clone(),
519            drag_start: self.drag_start.clone(),
520            drag_base: self.drag_base.clone(),
521            last_move_time: self.last_move_time.clone(),
522            last_move_pos: self.last_move_pos.clone(),
523            release_velocity: self.release_velocity.clone(),
524        }
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::runtime::HitRegion;
532    use std::cell::RefCell;
533    use std::rc::Rc;
534
535    #[test]
536    fn test_transformable_forwards_deltas() {
537        let seen = Rc::new(RefCell::new(Vec::new()));
538        let seen_pan = seen.clone();
539        let state = TransformableState::new(move |zoom, pan, rotation| {
540            seen_pan.borrow_mut().push((zoom, pan.x, rotation));
541        });
542        assert!(
543            state.handle_gesture(&crate::shortcuts::Gesture::PinchWithCenter {
544                delta_scale: 2.0,
545                center: crate::Vec2 { x: 1.0, y: 1.0 },
546            })
547        );
548        assert!(state.handle_gesture(&crate::shortcuts::Gesture::Pan {
549            delta: crate::Vec2 { x: 3.0, y: 4.0 },
550            center: crate::Vec2 { x: 0.0, y: 0.0 },
551        }));
552        assert!(!state.handle_gesture(&crate::shortcuts::Gesture::SwipeLeft));
553        assert_eq!(seen.borrow().len(), 2);
554        assert_eq!(seen.borrow()[0].0, 2.0);
555        assert_eq!(seen.borrow()[1].1, 3.0);
556    }
557
558    #[test]
559    fn test_anchored_draggable_vetoes() {
560        let state = AnchoredDraggableState::new(
561            vec![(0.0, "a"), (100.0, "b")],
562            SwipeableConfig::default(),
563            Some(Rc::new(|v: &'static str| v != "b")),
564        );
565        state.animate_to(&"b");
566        assert_eq!(state.target_value(), Some("a"));
567        state.animate_to(&"a");
568        assert_eq!(state.target_value(), Some("a"));
569        assert_eq!(state.anchors().len(), 2);
570        assert_eq!(state.anchor_offset_for(&"b"), Some(100.0));
571    }
572
573    #[test]
574    fn test_route_action_to_point_walks_up() {
575        let fired = Rc::new(RefCell::new(Vec::new()));
576        let fired_child = fired.clone();
577        let child = HitRegion {
578            id: 2,
579            parent: Some(1),
580            rect: crate::Rect {
581                x: 0.0,
582                y: 0.0,
583                w: 10.0,
584                h: 10.0,
585            },
586            z_index: 2.0,
587            on_action: Some(Rc::new(move |_| {
588                fired_child.borrow_mut().push(2u64);
589                false
590            })),
591            ..Default::default()
592        };
593        let parent = HitRegion {
594            id: 1,
595            rect: crate::Rect {
596                x: 0.0,
597                y: 0.0,
598                w: 100.0,
599                h: 100.0,
600            },
601            z_index: 1.0,
602            on_action: Some(Rc::new(move |_| true)),
603            ..Default::default()
604        };
605        let hits = vec![parent, child];
606        let action =
607            crate::shortcuts::Action::Gesture(crate::shortcuts::Gesture::PinchWithCenter {
608                delta_scale: 1.0,
609                center: crate::Vec2 { x: 5.0, y: 5.0 },
610            });
611        assert!(crate::runtime::route_action_to_point(
612            &hits,
613            &action,
614            crate::Vec2 { x: 5.0, y: 5.0 }
615        ));
616        assert_eq!(*fired.borrow(), vec![2u64]);
617        assert!(!crate::runtime::route_action_to_point(
618            &hits,
619            &action,
620            crate::Vec2 { x: 500.0, y: 500.0 }
621        ));
622    }
623}