Skip to main content

fret_ui_headless/embla/
scroll_contain.rs

1use crate::embla::limit::Limit;
2use crate::embla::utils::array_last;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ScrollContainOption {
6    None,
7    TrimSnaps,
8    KeepSnaps,
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct ScrollContainLimit {
13    pub min: usize,
14    pub max: usize,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct ScrollContainOutput {
19    pub snaps_contained: Vec<f32>,
20    pub scroll_contain_limit: ScrollContainLimit,
21}
22
23/// Ported from Embla `ScrollContain`.
24///
25/// Upstream: `repo-ref/embla-carousel/packages/embla-carousel/src/components/ScrollContain.ts`
26pub fn scroll_contain(
27    view_size: f32,
28    content_size: f32,
29    snaps_aligned: &[f32],
30    contain_scroll: ScrollContainOption,
31    pixel_tolerance: f32,
32) -> ScrollContainOutput {
33    let view_size = view_size.max(0.0);
34    let content_size = content_size.max(0.0);
35
36    let scroll_bounds = Limit::new(-content_size + view_size, 0.0);
37    let snaps_bounded = snaps_bounded(scroll_bounds, snaps_aligned, pixel_tolerance.max(0.0));
38    let scroll_contain_limit = scroll_contain_limit(&snaps_bounded);
39    let snaps_contained = snaps_contained(
40        scroll_bounds,
41        &snaps_bounded,
42        scroll_contain_limit,
43        view_size,
44        content_size,
45        contain_scroll,
46        pixel_tolerance.max(0.0),
47    );
48
49    ScrollContainOutput {
50        snaps_contained,
51        scroll_contain_limit,
52    }
53}
54
55fn snaps_bounded(scroll_bounds: Limit, snaps_aligned: &[f32], pixel_tolerance: f32) -> Vec<f32> {
56    let len = snaps_aligned.len();
57    if len == 0 {
58        return Vec::new();
59    }
60
61    let use_pixel_tolerance = |bound: f32, snap: f32| -> bool {
62        if pixel_tolerance <= 0.0 {
63            return false;
64        }
65        // Upstream uses `deltaAbs(bound, snap) <= 1` when pixel tolerance is enabled.
66        (bound - snap).abs() <= 1.0
67    };
68
69    let mut out = Vec::with_capacity(len);
70    for (index, snap_aligned) in snaps_aligned.iter().copied().enumerate() {
71        let snap = scroll_bounds.clamp(snap_aligned);
72        let is_first = index == 0;
73        let is_last = index + 1 == len;
74
75        let bounded = if is_first {
76            scroll_bounds.max
77        } else if is_last || use_pixel_tolerance(scroll_bounds.min, snap) {
78            scroll_bounds.min
79        } else if use_pixel_tolerance(scroll_bounds.max, snap) {
80            scroll_bounds.max
81        } else {
82            snap
83        };
84
85        // Embla uses `toFixed(3)` as a last step.
86        out.push((bounded * 1000.0).round() / 1000.0);
87    }
88    out
89}
90
91fn scroll_contain_limit(snaps_bounded: &[f32]) -> ScrollContainLimit {
92    if snaps_bounded.is_empty() {
93        return ScrollContainLimit { min: 0, max: 0 };
94    }
95
96    let start_snap = snaps_bounded[0];
97    let end_snap = array_last(snaps_bounded);
98
99    let mut min = 0usize;
100    for (idx, snap) in snaps_bounded.iter().copied().enumerate() {
101        if snap == start_snap {
102            min = idx;
103        }
104    }
105
106    let mut max = snaps_bounded.len();
107    for (idx, snap) in snaps_bounded.iter().copied().enumerate() {
108        if snap == end_snap {
109            max = idx + 1;
110            break;
111        }
112    }
113
114    ScrollContainLimit { min, max }
115}
116
117fn snaps_contained(
118    scroll_bounds: Limit,
119    snaps_bounded: &[f32],
120    contain_limit: ScrollContainLimit,
121    view_size: f32,
122    content_size: f32,
123    contain_scroll: ScrollContainOption,
124    pixel_tolerance: f32,
125) -> Vec<f32> {
126    if content_size <= view_size + pixel_tolerance {
127        return vec![scroll_bounds.max];
128    }
129
130    if contain_scroll == ScrollContainOption::KeepSnaps {
131        return snaps_bounded.to_vec();
132    }
133
134    snaps_bounded[contain_limit.min..contain_limit.max].to_vec()
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn scroll_contain_short_circuits_when_content_fits_view_with_tolerance() {
143        let out = scroll_contain(
144            320.0,
145            321.0,
146            &[0.0, -100.0, -200.0],
147            ScrollContainOption::TrimSnaps,
148            2.0,
149        );
150        assert_eq!(out.snaps_contained, vec![0.0]);
151    }
152
153    #[test]
154    fn scroll_contain_keep_snaps_preserves_bounded_list() {
155        let out = scroll_contain(
156            320.0,
157            520.0,
158            &[0.0, -10.0, -200.0],
159            ScrollContainOption::KeepSnaps,
160            2.0,
161        );
162
163        assert_eq!(out.snaps_contained.len(), 3);
164        assert_eq!(out.snaps_contained[0], 0.0);
165        assert_eq!(out.snaps_contained[2], -200.0);
166    }
167
168    #[test]
169    fn scroll_contain_trim_snaps_slices_between_edge_duplicates() {
170        // `snapsAligned` includes multiple snaps clamped to `max` (0). The contain limit should use
171        // the last `0` as the start of the contained list.
172        let out = scroll_contain(
173            320.0,
174            820.0,
175            &[0.0, 0.1, -0.4, -400.0, -500.0],
176            ScrollContainOption::TrimSnaps,
177            2.0,
178        );
179
180        // First bounded snap is max=0, last bounded snap is min=-500.
181        assert_eq!(out.snaps_contained[0], 0.0);
182        assert_eq!(array_last(&out.snaps_contained), -500.0);
183        assert!(out.scroll_contain_limit.min <= out.scroll_contain_limit.max);
184    }
185}