Skip to main content

fret_ui_headless/embla/
scroll_target.rs

1use crate::embla::limit::Limit;
2use crate::embla::utils::{DIRECTION_NONE, Direction, math_abs, math_sign};
3
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct Target {
6    pub distance: f32,
7    pub index: usize,
8}
9
10/// Ported from Embla `ScrollTarget`.
11///
12/// Upstream: `repo-ref/embla-carousel/packages/embla-carousel/src/components/ScrollTarget.ts`
13#[derive(Debug, Clone, PartialEq)]
14pub struct ScrollTarget {
15    loop_enabled: bool,
16    scroll_snaps: Vec<f32>,
17    content_size: f32,
18    limit: Limit,
19    target_vector: f32,
20}
21
22impl ScrollTarget {
23    pub fn new(
24        loop_enabled: bool,
25        scroll_snaps: Vec<f32>,
26        content_size: f32,
27        limit: Limit,
28        target_vector: f32,
29    ) -> Self {
30        Self {
31            loop_enabled,
32            scroll_snaps,
33            content_size,
34            limit,
35            target_vector,
36        }
37    }
38
39    pub fn set_target_vector(&mut self, target_vector: f32) {
40        self.target_vector = target_vector;
41    }
42
43    pub fn target_vector(&self) -> f32 {
44        self.target_vector
45    }
46
47    pub fn loop_enabled(&self) -> bool {
48        self.loop_enabled
49    }
50
51    pub fn max_index(&self) -> usize {
52        self.scroll_snaps.len().saturating_sub(1)
53    }
54
55    fn min_distance(distances: &mut [f32]) -> f32 {
56        distances.sort_by(|a, b| math_abs(*a).total_cmp(&math_abs(*b)));
57        distances[0]
58    }
59
60    pub fn shortcut(&self, target: f32, direction: Direction) -> f32 {
61        if !self.loop_enabled {
62            return target;
63        }
64
65        let targets = [
66            target,
67            target + self.content_size,
68            target - self.content_size,
69        ];
70        if direction == DIRECTION_NONE {
71            let mut tmp = targets;
72            return Self::min_distance(&mut tmp);
73        }
74
75        let mut valid = targets
76            .into_iter()
77            .filter(|t| math_sign(*t) == direction as f32)
78            .collect::<Vec<_>>();
79        if !valid.is_empty() {
80            return Self::min_distance(&mut valid);
81        }
82        targets[2] - self.content_size
83    }
84
85    fn get_closest_snap(&self, target: f32) -> Target {
86        let distance = if self.loop_enabled {
87            self.limit.remove_offset(target)
88        } else {
89            self.limit.clamp(target)
90        };
91
92        let mut smallest = f32::INFINITY;
93        let mut index = 0usize;
94        for (snap_index, snap) in self.scroll_snaps.iter().copied().enumerate() {
95            let displacement_abs = math_abs(self.shortcut(snap - distance, DIRECTION_NONE));
96            if displacement_abs >= smallest {
97                continue;
98            }
99            smallest = displacement_abs;
100            index = snap_index;
101        }
102
103        Target { index, distance }
104    }
105
106    pub fn by_index(&self, index: usize, direction: Direction) -> Target {
107        let index = index.min(self.scroll_snaps.len().saturating_sub(1));
108        let diff_to_snap = self.scroll_snaps[index] - self.target_vector;
109        let distance = self.shortcut(diff_to_snap, direction);
110        Target { index, distance }
111    }
112
113    pub fn by_distance(&self, distance: f32, snap_to_closest: bool) -> Target {
114        let target = self.target_vector + distance;
115        let Target {
116            index,
117            distance: target_snap_distance,
118        } = self.get_closest_snap(target);
119        let is_past_any_bound = !self.loop_enabled && self.limit.past_any_bound(target);
120
121        if !snap_to_closest || is_past_any_bound {
122            return Target { index, distance };
123        }
124
125        let diff_to_snap = self.scroll_snaps[index] - target_snap_distance;
126        let snap_distance = distance + self.shortcut(diff_to_snap, DIRECTION_NONE);
127        Target {
128            index,
129            distance: snap_distance,
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::embla::scroll_limit::scroll_limit;
138
139    #[test]
140    fn by_distance_snaps_to_closest_when_enabled() {
141        // Embla scroll snaps are measured as non-increasing offsets (0, -x, -2x, ...).
142        let snaps = vec![0.0, -100.0, -200.0, -300.0];
143        let content_size = 300.0;
144        let limit = scroll_limit(content_size, &snaps, false);
145        let target = ScrollTarget::new(false, snaps, content_size, limit, 0.0);
146
147        let out = target.by_distance(-130.0, true);
148        assert_eq!(out.index, 1);
149        assert!((out.distance - -100.0).abs() < 0.001);
150    }
151
152    #[test]
153    fn by_distance_does_not_snap_when_flag_is_false() {
154        let snaps = vec![0.0, -100.0, -200.0, -300.0];
155        let content_size = 300.0;
156        let limit = scroll_limit(content_size, &snaps, false);
157        let target = ScrollTarget::new(false, snaps, content_size, limit, 0.0);
158
159        let out = target.by_distance(-130.0, false);
160        assert_eq!(out.index, 1);
161        assert!((out.distance - -130.0).abs() < 0.001);
162    }
163}