Skip to main content

fret_ui_headless/
carousel.rs

1//! Headless carousel drag/snap state machine.
2//!
3//! This module intentionally does not depend on `fret-ui` so it can be reused across composition
4//! layers without pulling in runtime/rendering contracts.
5
6use fret_core::{Axis, LayoutDirection, Point, Px};
7
8use crate::snap_points as headless_snap_points;
9
10pub const DEFAULT_DRAG_THRESHOLD_PX: f32 = 10.0;
11pub const DEFAULT_SNAP_THRESHOLD_FRACTION: f32 = 0.25;
12pub const DEFAULT_TOUCH_SCROLL_LOCK_THRESHOLD_PX: f32 = 2.0;
13pub const DEFAULT_SCROLL_CONTAIN_PIXEL_TOLERANCE_PX: f32 = 2.0;
14
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct CarouselDragConfig {
17    pub drag_threshold_px: f32,
18    pub snap_threshold_fraction: f32,
19    pub touch_prevent_scroll: bool,
20    pub touch_scroll_lock_threshold_px: f32,
21}
22
23impl Default for CarouselDragConfig {
24    fn default() -> Self {
25        Self {
26            drag_threshold_px: DEFAULT_DRAG_THRESHOLD_PX,
27            snap_threshold_fraction: DEFAULT_SNAP_THRESHOLD_FRACTION,
28            touch_prevent_scroll: true,
29            touch_scroll_lock_threshold_px: DEFAULT_TOUCH_SCROLL_LOCK_THRESHOLD_PX,
30        }
31    }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum CarouselDragInputKind {
36    Mouse,
37    Touch,
38}
39
40#[derive(Debug, Clone, Copy, Default, PartialEq)]
41pub struct CarouselDragState {
42    pub armed: bool,
43    pub dragging: bool,
44    pub start: Point,
45    pub start_offset: Px,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct CarouselDragMoveOutput {
50    pub steal_capture: bool,
51    pub consumed: bool,
52    pub next_offset: Option<Px>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct CarouselDragReleaseOutput {
57    pub next_index: usize,
58    pub target_offset: Px,
59}
60
61#[inline]
62fn axis_delta(axis: Axis, from: Point, to: Point) -> f32 {
63    match axis {
64        Axis::Horizontal => to.x.0 - from.x.0,
65        Axis::Vertical => to.y.0 - from.y.0,
66    }
67}
68
69#[inline]
70fn axis_direction_sign(axis: Axis, direction: LayoutDirection) -> f32 {
71    match (axis, direction) {
72        (Axis::Horizontal, LayoutDirection::Rtl) => -1.0,
73        _ => 1.0,
74    }
75}
76
77#[inline]
78fn axis_delta_with_direction(
79    axis: Axis,
80    direction: LayoutDirection,
81    from: Point,
82    to: Point,
83) -> f32 {
84    axis_delta(axis, from, to) * axis_direction_sign(axis, direction)
85}
86
87#[inline]
88fn cross_axis(axis: Axis) -> Axis {
89    match axis {
90        Axis::Horizontal => Axis::Vertical,
91        Axis::Vertical => Axis::Horizontal,
92    }
93}
94
95pub fn on_pointer_down(
96    state: &mut CarouselDragState,
97    button_left: bool,
98    position: Point,
99    start_offset: Px,
100) {
101    if !button_left {
102        return;
103    }
104
105    state.armed = true;
106    state.dragging = false;
107    state.start = position;
108    state.start_offset = start_offset;
109}
110
111pub fn on_pointer_move(
112    config: CarouselDragConfig,
113    state: &mut CarouselDragState,
114    axis: Axis,
115    direction: LayoutDirection,
116    position: Point,
117    buttons_left: bool,
118    input_kind: CarouselDragInputKind,
119    max_offset: Px,
120) -> CarouselDragMoveOutput {
121    if !state.armed && !state.dragging {
122        return CarouselDragMoveOutput {
123            steal_capture: false,
124            consumed: false,
125            next_offset: None,
126        };
127    }
128
129    if !buttons_left {
130        *state = CarouselDragState::default();
131        return CarouselDragMoveOutput {
132            steal_capture: false,
133            consumed: false,
134            next_offset: None,
135        };
136    }
137
138    if config.touch_prevent_scroll
139        && input_kind == CarouselDragInputKind::Touch
140        && state.armed
141        && !state.dragging
142    {
143        let primary_abs = axis_delta_with_direction(axis, direction, state.start, position).abs();
144        let cross_abs = axis_delta(cross_axis(axis), state.start, position).abs();
145        if primary_abs.max(cross_abs) >= config.touch_scroll_lock_threshold_px
146            && primary_abs <= cross_abs
147        {
148            *state = CarouselDragState::default();
149            return CarouselDragMoveOutput {
150                steal_capture: false,
151                consumed: false,
152                next_offset: None,
153            };
154        }
155    }
156
157    let delta = axis_delta_with_direction(axis, direction, state.start, position);
158    // Embla sets `preventClick` when `diffScroll > dragThreshold` (strictly greater).
159    // Mirror that boundary by starting a drag only once we exceed the threshold.
160    if !state.dragging && state.armed && delta.abs() <= config.drag_threshold_px {
161        return CarouselDragMoveOutput {
162            steal_capture: false,
163            consumed: false,
164            next_offset: None,
165        };
166    }
167
168    let mut steal_capture = false;
169    if !state.dragging && state.armed {
170        steal_capture = true;
171        state.armed = false;
172        state.dragging = true;
173    }
174
175    let next = Px((state.start_offset.0 - delta).clamp(0.0, max_offset.0));
176    CarouselDragMoveOutput {
177        steal_capture,
178        consumed: true,
179        next_offset: Some(next),
180    }
181}
182
183pub fn on_pointer_cancel(state: &mut CarouselDragState) -> bool {
184    if !state.armed && !state.dragging {
185        return false;
186    }
187
188    *state = CarouselDragState::default();
189    true
190}
191
192pub fn on_pointer_up(
193    config: CarouselDragConfig,
194    state: &mut CarouselDragState,
195    axis: Axis,
196    direction: LayoutDirection,
197    position: Point,
198    extent: Px,
199    items_len: usize,
200) -> Option<CarouselDragReleaseOutput> {
201    if !state.dragging {
202        state.armed = false;
203        state.dragging = false;
204        return None;
205    }
206
207    let max_index = items_len.saturating_sub(1);
208    let start_index = if extent.0 > 0.0 {
209        (state.start_offset.0 / extent.0)
210            .round()
211            .clamp(0.0, max_index as f32) as usize
212    } else {
213        0
214    };
215
216    let delta = axis_delta_with_direction(axis, direction, state.start, position);
217    let mut next_index = start_index;
218    if extent.0 > 0.0 {
219        let threshold = extent.0 * config.snap_threshold_fraction;
220        if delta.abs() > threshold {
221            if delta > 0.0 {
222                next_index = start_index.saturating_sub(1);
223            } else {
224                next_index = (start_index + 1).min(max_index);
225            }
226        }
227    }
228
229    let target_offset = if extent.0 > 0.0 {
230        Px((next_index as f32) * extent.0)
231    } else {
232        Px(0.0)
233    };
234
235    *state = CarouselDragState::default();
236    Some(CarouselDragReleaseOutput {
237        next_index,
238        target_offset,
239    })
240}
241
242pub fn on_pointer_up_with_snaps(
243    config: CarouselDragConfig,
244    state: &mut CarouselDragState,
245    axis: Axis,
246    direction: LayoutDirection,
247    position: Point,
248    snaps: &[Px],
249) -> Option<CarouselDragReleaseOutput> {
250    if !state.dragging {
251        state.armed = false;
252        state.dragging = false;
253        return None;
254    }
255
256    if snaps.is_empty() {
257        *state = CarouselDragState::default();
258        return Some(CarouselDragReleaseOutput {
259            next_index: 0,
260            target_offset: Px(0.0),
261        });
262    }
263
264    let start_offset = state.start_offset;
265    let (start_index, start_snap) = snaps
266        .iter()
267        .copied()
268        .enumerate()
269        .min_by(|(_, a), (_, b)| {
270            (a.0 - start_offset.0)
271                .abs()
272                .total_cmp(&(b.0 - start_offset.0).abs())
273        })
274        .expect("non-empty snaps");
275
276    let delta = axis_delta_with_direction(axis, direction, state.start, position);
277    let mut next_index = start_index;
278
279    if snaps.len() > 1 {
280        let neighbor = if delta > 0.0 {
281            start_index.checked_sub(1)
282        } else if delta < 0.0 {
283            Some((start_index + 1).min(snaps.len().saturating_sub(1)))
284        } else {
285            None
286        };
287
288        if let Some(neighbor_index) = neighbor {
289            let neighbor_snap = snaps[neighbor_index];
290            let distance = (neighbor_snap.0 - start_snap.0).abs();
291            let threshold = distance * config.snap_threshold_fraction;
292            if distance > 0.0 && delta.abs() > threshold {
293                next_index = neighbor_index;
294            }
295        }
296    }
297
298    let target_offset = snaps[next_index];
299
300    *state = CarouselDragState::default();
301    Some(CarouselDragReleaseOutput {
302        next_index,
303        target_offset,
304    })
305}
306
307pub fn on_pointer_up_with_snaps_options(
308    config: CarouselDragConfig,
309    state: &mut CarouselDragState,
310    axis: Axis,
311    direction: LayoutDirection,
312    position: Point,
313    snaps: &[Px],
314    max_offset: Px,
315    loop_enabled: bool,
316    skip_snaps: bool,
317    drag_free: bool,
318) -> Option<CarouselDragReleaseOutput> {
319    if !state.dragging {
320        state.armed = false;
321        state.dragging = false;
322        return None;
323    }
324
325    if snaps.is_empty() {
326        *state = CarouselDragState::default();
327        return Some(CarouselDragReleaseOutput {
328            next_index: 0,
329            target_offset: Px(0.0),
330        });
331    }
332
333    let start_offset = state.start_offset;
334    let start_index = headless_snap_points::closest_index_px(snaps, start_offset).unwrap_or(0);
335    let start_index = start_index.min(snaps.len().saturating_sub(1));
336    let start_snap = snaps[start_index];
337
338    let delta = axis_delta_with_direction(axis, direction, state.start, position);
339    let projected_offset = Px(start_offset.0 - delta);
340    let projected_offset = clamp_px(projected_offset, Px(0.0), max_offset);
341    let projected_offset = round_3(projected_offset);
342
343    let (next_index, target_offset) = if drag_free {
344        let ix =
345            headless_snap_points::closest_index_px(snaps, projected_offset).unwrap_or(start_index);
346        (ix.min(snaps.len().saturating_sub(1)), projected_offset)
347    } else if skip_snaps {
348        let ix =
349            headless_snap_points::closest_index_px(snaps, projected_offset).unwrap_or(start_index);
350        let ix = ix.min(snaps.len().saturating_sub(1));
351        (ix, snaps[ix])
352    } else {
353        let mut next_index = start_index;
354        if snaps.len() > 1 {
355            let neighbor = if delta > 0.0 {
356                if loop_enabled {
357                    headless_snap_points::step_index_wrapped(snaps.len(), start_index, -1)
358                } else {
359                    start_index.checked_sub(1)
360                }
361            } else if delta < 0.0 {
362                if loop_enabled {
363                    headless_snap_points::step_index_wrapped(snaps.len(), start_index, 1)
364                } else {
365                    Some((start_index + 1).min(snaps.len().saturating_sub(1)))
366                }
367            } else {
368                None
369            };
370
371            if let Some(neighbor_index) = neighbor {
372                let neighbor_snap = snaps[neighbor_index];
373                let distance = (neighbor_snap.0 - start_snap.0).abs();
374                let threshold = distance * config.snap_threshold_fraction;
375                if distance > 0.0 && delta.abs() > threshold {
376                    next_index = neighbor_index;
377                }
378            }
379        }
380
381        (next_index, snaps[next_index])
382    };
383
384    *state = CarouselDragState::default();
385    Some(CarouselDragReleaseOutput {
386        next_index,
387        target_offset,
388    })
389}
390
391// -------------------------------------------------------------------------------------------------
392// Snap / contain-scroll helpers (Embla-aligned, headless, deterministic)
393// -------------------------------------------------------------------------------------------------
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum CarouselSlidesToScrollOption {
397    Auto,
398    Fixed(usize),
399}
400
401#[derive(Debug, Clone, Copy)]
402pub enum CarouselSnapAlign {
403    Start,
404    Center,
405    End,
406    Custom(fn(view_size: Px, snap_size: Px, index: usize) -> Px),
407}
408
409impl CarouselSnapAlign {
410    fn measure(self, view_size: Px, snap_size: Px, index: usize) -> Px {
411        match self {
412            CarouselSnapAlign::Start => Px(0.0),
413            CarouselSnapAlign::Center => Px((view_size.0 - snap_size.0) / 2.0),
414            CarouselSnapAlign::End => Px(view_size.0 - snap_size.0),
415            CarouselSnapAlign::Custom(f) => f(view_size, snap_size, index),
416        }
417    }
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub enum CarouselContainScroll {
422    KeepSnaps,
423    TrimSnaps,
424}
425
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum CarouselContainScrollOption {
428    None,
429    KeepSnaps,
430    TrimSnaps,
431}
432
433#[derive(Debug, Clone, Copy, PartialEq)]
434pub struct CarouselContainScrollConfig {
435    pub contain_scroll: CarouselContainScroll,
436    pub pixel_tolerance_px: f32,
437}
438
439impl Default for CarouselContainScrollConfig {
440    fn default() -> Self {
441        Self {
442            contain_scroll: CarouselContainScroll::TrimSnaps,
443            pixel_tolerance_px: DEFAULT_SCROLL_CONTAIN_PIXEL_TOLERANCE_PX,
444        }
445    }
446}
447
448#[derive(Debug, Clone, Copy, PartialEq)]
449pub struct CarouselSlide1D {
450    pub start: Px,
451    pub size: Px,
452}
453
454impl CarouselSlide1D {
455    #[inline]
456    pub fn end(self) -> Px {
457        Px(self.start.0 + self.size.0)
458    }
459}
460
461#[inline]
462fn round_3(px: Px) -> Px {
463    Px((px.0 * 1000.0).round() / 1000.0)
464}
465
466#[inline]
467fn clamp_px(v: Px, min: Px, max: Px) -> Px {
468    Px(v.0.clamp(min.0, max.0))
469}
470
471#[derive(Debug, Clone, PartialEq)]
472pub struct CarouselSnapModel1D {
473    pub snaps_px: Vec<Px>,
474    pub slides_by_snap: Vec<Vec<usize>>,
475    pub snap_by_slide: Vec<usize>,
476    pub max_offset_px: Px,
477}
478
479fn group_by_number<T: Clone>(items: &[T], group_size: usize) -> Vec<Vec<T>> {
480    let group_size = group_size.max(1);
481    let mut groups = Vec::new();
482    let mut i = 0usize;
483    while i < items.len() {
484        groups.push(items[i..items.len().min(i + group_size)].to_vec());
485        i += group_size;
486    }
487    groups
488}
489
490fn group_slide_indexes_auto(
491    view_size: Px,
492    slides: &[CarouselSlide1D],
493    start_gap: Px,
494    end_gap: Px,
495    loop_enabled: bool,
496    pixel_tolerance_px: f32,
497) -> Vec<Vec<usize>> {
498    if slides.is_empty() {
499        return Vec::new();
500    }
501
502    let mut boundaries: Vec<usize> = Vec::new();
503    for (index, rect_b) in (0..slides.len()).enumerate() {
504        let rect_a = boundaries.last().copied().unwrap_or(0);
505        let is_first = rect_a == 0;
506        let is_last = rect_b + 1 == slides.len();
507
508        let a = slides[rect_a];
509        let b = slides[rect_b];
510        let gap_a = if !loop_enabled && is_first {
511            start_gap
512        } else {
513            Px(0.0)
514        };
515        let gap_b = if !loop_enabled && is_last {
516            end_gap
517        } else {
518            Px(0.0)
519        };
520
521        let chunk_size = Px((b.end().0 + gap_b.0) - (a.start.0 + gap_a.0)).0.abs();
522
523        if index != 0 && chunk_size > view_size.0 + pixel_tolerance_px {
524            boundaries.push(rect_b);
525        }
526        if is_last {
527            boundaries.push(slides.len());
528        }
529    }
530
531    let mut groups = Vec::new();
532    for (index, &end) in boundaries.iter().enumerate() {
533        let start = boundaries.get(index.wrapping_sub(1)).copied().unwrap_or(0);
534        let start = start.min(end);
535        groups.push((start..end).collect::<Vec<_>>());
536    }
537    groups
538}
539
540fn group_slide_indexes(
541    view_size: Px,
542    slides: &[CarouselSlide1D],
543    slides_to_scroll: CarouselSlidesToScrollOption,
544    start_gap: Px,
545    end_gap: Px,
546    loop_enabled: bool,
547    pixel_tolerance_px: f32,
548) -> Vec<Vec<usize>> {
549    match slides_to_scroll {
550        CarouselSlidesToScrollOption::Fixed(n) => {
551            group_by_number(&(0..slides.len()).collect::<Vec<_>>(), n)
552        }
553        CarouselSlidesToScrollOption::Auto => group_slide_indexes_auto(
554            view_size,
555            slides,
556            start_gap,
557            end_gap,
558            loop_enabled,
559            pixel_tolerance_px,
560        ),
561    }
562}
563
564fn array_from_range(end: usize, start: usize) -> Vec<usize> {
565    (start..=end).collect()
566}
567
568/// Headless, deterministic snap model inspired by Embla's `ScrollSnaps`, `SlidesToScroll`,
569/// and `ScrollContain`, expressed using Fret's preferred positive-offset convention.
570///
571/// Coordinate / sign conventions:
572///
573/// - All `Px` values are in the carousel's **main axis** (X for horizontal, Y for vertical).
574/// - `slides[i].start` is measured from the viewport's start edge at rest.
575/// - `snaps_px[k]` is the **positive** offset you apply when rendering, e.g.:
576///   `transform: translate(-snaps_px[k])`.
577/// - `0` means "unshifted track" (content starts at the viewport start edge).
578///
579/// Inputs:
580///
581/// - `start_gap` / `end_gap` represent extra blank space before/after slides (e.g. margins).
582/// - `slides_to_scroll` controls snap grouping:
583///   - `Fixed(n)`: group slides in fixed-size chunks.
584///   - `Auto`: group as many slides as fit into `view_size` (with tolerance).
585/// - `align` computes the within-viewport alignment for each group (start/center/end/custom).
586/// - `contain_scroll`:
587///   - `None`: do not clamp/contain snaps (may return snap values outside `[0, max_offset_px]`).
588///   - `KeepSnaps`: clamp snaps, but preserve the original snap count (duplicates allowed).
589///   - `TrimSnaps`: clamp snaps, then trim duplicates at the ends and expand edge slide groups so
590///     every slide maps to a valid snap.
591/// - `pixel_tolerance_px`:
592///   - short-circuits to a single snap when `content_size <= view_size + tolerance`.
593///   - participates in auto-grouping boundaries and containment "near edge" snapping.
594///
595/// Outputs / invariants:
596///
597/// - `snaps_px` is never empty. (Empty inputs return `[0]`.)
598/// - `max_offset_px` is `max(content_size - view_size, 0)`.
599/// - When containment is enabled (`contain_scroll != None` and `!loop_enabled`):
600///   - `snaps_px[0] == 0`, and the last snap is `max_offset_px`.
601///   - Middle snaps are clamped and rounded to 3 decimals (Embla-style determinism).
602/// - `slides_by_snap.len() == snaps_px.len()` (after containment trimming/adjustments).
603/// - For every slide index `i`, `snap_by_slide[i]` is a valid index into `snaps_px`.
604pub fn snap_model_1d(
605    view_size: Px,
606    slides: &[CarouselSlide1D],
607    start_gap: Px,
608    end_gap: Px,
609    slides_to_scroll: CarouselSlidesToScrollOption,
610    loop_enabled: bool,
611    align: CarouselSnapAlign,
612    contain_scroll: CarouselContainScrollOption,
613    pixel_tolerance_px: f32,
614) -> CarouselSnapModel1D {
615    let slide_count = slides.len();
616    if slide_count == 0 {
617        return CarouselSnapModel1D {
618            snaps_px: vec![Px(0.0)],
619            slides_by_snap: vec![Vec::new()],
620            snap_by_slide: Vec::new(),
621            max_offset_px: Px(0.0),
622        };
623    }
624
625    let content_size = slides
626        .iter()
627        .map(|s| s.end())
628        .fold(Px(0.0), |a, b| Px(a.0.max(b.0)));
629    let content_size = Px(content_size.0 + end_gap.0.max(0.0));
630    let max_offset_px = Px((content_size.0 - view_size.0).max(0.0));
631
632    if content_size.0 <= view_size.0 + pixel_tolerance_px {
633        let all = (0..slide_count).collect::<Vec<_>>();
634        return CarouselSnapModel1D {
635            snaps_px: vec![Px(0.0)],
636            slides_by_snap: vec![all.clone()],
637            snap_by_slide: vec![0; slide_count],
638            max_offset_px,
639        };
640    }
641
642    let contain_snaps =
643        !loop_enabled && !matches!(contain_scroll, CarouselContainScrollOption::None);
644
645    let slide_groups = group_slide_indexes(
646        view_size,
647        slides,
648        slides_to_scroll,
649        start_gap,
650        end_gap,
651        loop_enabled,
652        pixel_tolerance_px,
653    );
654
655    let mut group_sizes = Vec::with_capacity(slide_groups.len());
656    for group in &slide_groups {
657        if group.is_empty() {
658            group_sizes.push(Px(0.0));
659            continue;
660        }
661        let first = slides[group[0]];
662        let last = slides[*group.last().expect("group last")];
663        group_sizes.push(Px((last.end().0 - first.start.0).abs()));
664    }
665
666    let mut alignments = Vec::with_capacity(group_sizes.len());
667    for (i, size) in group_sizes.iter().copied().enumerate() {
668        alignments.push(align.measure(view_size, size, i));
669    }
670
671    let snaps_unaligned = slides.iter().map(|s| s.start).collect::<Vec<_>>();
672    let mut snaps_aligned = Vec::with_capacity(slide_groups.len());
673    for (group_index, group) in slide_groups.iter().enumerate() {
674        let first_slide_ix = group.first().copied().unwrap_or(0);
675        let snap = snaps_unaligned[first_slide_ix];
676        snaps_aligned.push(Px(snap.0 - alignments[group_index].0));
677    }
678
679    let (snaps_px, contain_limit_min, contain_limit_max) = if contain_snaps {
680        let mut snaps_bounded = Vec::with_capacity(snaps_aligned.len());
681        for (i, snap) in snaps_aligned.iter().copied().enumerate() {
682            let is_first = i == 0;
683            let is_last = i + 1 == snaps_aligned.len();
684            if is_first {
685                snaps_bounded.push(Px(0.0));
686                continue;
687            }
688            if is_last {
689                snaps_bounded.push(max_offset_px);
690                continue;
691            }
692
693            let mut clamped = clamp_px(snap, Px(0.0), max_offset_px);
694            if pixel_tolerance_px > 0.0 {
695                if (clamped.0 - 0.0).abs() <= 1.0 {
696                    clamped = Px(0.0);
697                } else if (clamped.0 - max_offset_px.0).abs() <= 1.0 {
698                    clamped = max_offset_px;
699                }
700            }
701            snaps_bounded.push(round_3(clamped));
702        }
703
704        let start_snap = snaps_bounded[0];
705        let end_snap = *snaps_bounded.last().expect("snaps_bounded non-empty");
706        let mut min_ix = 0usize;
707        for (i, snap) in snaps_bounded.iter().copied().enumerate() {
708            if snap == start_snap {
709                min_ix = i;
710            }
711        }
712        let mut max_ix = snaps_bounded.len();
713        for (i, snap) in snaps_bounded.iter().copied().enumerate() {
714            if snap == end_snap {
715                max_ix = i + 1;
716                break;
717            }
718        }
719
720        let snaps_contained = match contain_scroll {
721            CarouselContainScrollOption::KeepSnaps => snaps_bounded.clone(),
722            CarouselContainScrollOption::TrimSnaps => snaps_bounded[min_ix..max_ix].to_vec(),
723            CarouselContainScrollOption::None => snaps_bounded.clone(),
724        };
725        (snaps_contained, min_ix, max_ix)
726    } else {
727        (snaps_aligned, 0usize, slide_groups.len())
728    };
729
730    let mut slides_by_snap = if snaps_px.len() == 1 {
731        vec![(0..slide_count).collect::<Vec<_>>()]
732    } else if !contain_snaps || matches!(contain_scroll, CarouselContainScrollOption::KeepSnaps) {
733        slide_groups.clone()
734    } else {
735        let groups = slide_groups[contain_limit_min..contain_limit_max].to_vec();
736        groups
737            .iter()
738            .enumerate()
739            .map(|(index, group)| {
740                let is_first = index == 0;
741                let is_last = index + 1 == groups.len();
742                if is_first {
743                    let range_end = *group.last().expect("first group last");
744                    return array_from_range(range_end, 0);
745                }
746                if is_last {
747                    let range_end = slide_count - 1;
748                    return array_from_range(range_end, group[0]);
749                }
750                group.clone()
751            })
752            .collect::<Vec<_>>()
753    };
754
755    if slides_by_snap.is_empty() {
756        slides_by_snap.push((0..slide_count).collect::<Vec<_>>());
757    }
758
759    let mut snap_by_slide = vec![0usize; slide_count];
760    for (snap_index, group) in slides_by_snap.iter().enumerate() {
761        for &slide_index in group {
762            if slide_index < snap_by_slide.len() {
763                snap_by_slide[slide_index] = snap_index;
764            }
765        }
766    }
767
768    CarouselSnapModel1D {
769        snaps_px,
770        slides_by_snap,
771        snap_by_slide,
772        max_offset_px,
773    }
774}
775
776/// Compute contained scroll snaps (1D) using Embla's `ScrollSnaps` + `ScrollContain` semantics,
777/// expressed in Fret's preferred positive-offset convention:
778///
779/// - `0` means the track is unshifted (first slide starts at the viewport start edge).
780/// - increasing values shift content left (e.g. `transform: translate(-offset)`).
781pub fn contained_scroll_snaps_1d(
782    view_size: Px,
783    slides: &[CarouselSlide1D],
784    align: CarouselSnapAlign,
785    config: CarouselContainScrollConfig,
786) -> Vec<Px> {
787    contained_scroll_snaps_1d_with_end_gap(view_size, slides, Px(0.0), align, config)
788}
789
790/// `contained_scroll_snaps_1d` with an explicit `end_gap` (e.g. trailing margin).
791pub fn contained_scroll_snaps_1d_with_end_gap(
792    view_size: Px,
793    slides: &[CarouselSlide1D],
794    end_gap: Px,
795    align: CarouselSnapAlign,
796    config: CarouselContainScrollConfig,
797) -> Vec<Px> {
798    let model = snap_model_1d(
799        view_size,
800        slides,
801        Px(0.0),
802        end_gap,
803        CarouselSlidesToScrollOption::Fixed(1),
804        false,
805        align,
806        match config.contain_scroll {
807            CarouselContainScroll::KeepSnaps => CarouselContainScrollOption::KeepSnaps,
808            CarouselContainScroll::TrimSnaps => CarouselContainScrollOption::TrimSnaps,
809        },
810        config.pixel_tolerance_px,
811    );
812    model.snaps_px
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    #[test]
820    fn drag_threshold_arms_then_starts_drag() {
821        let mut state = CarouselDragState::default();
822        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(0.0));
823        assert!(state.armed);
824        assert!(!state.dragging);
825
826        let out = on_pointer_move(
827            CarouselDragConfig::default(),
828            &mut state,
829            Axis::Horizontal,
830            LayoutDirection::Ltr,
831            Point::new(Px(9.0), Px(0.0)),
832            true,
833            CarouselDragInputKind::Mouse,
834            Px(100.0),
835        );
836        assert_eq!(
837            out,
838            CarouselDragMoveOutput {
839                steal_capture: false,
840                consumed: false,
841                next_offset: None
842            }
843        );
844
845        let out = on_pointer_move(
846            CarouselDragConfig::default(),
847            &mut state,
848            Axis::Horizontal,
849            LayoutDirection::Ltr,
850            Point::new(Px(10.0), Px(0.0)),
851            true,
852            CarouselDragInputKind::Mouse,
853            Px(100.0),
854        );
855        assert_eq!(
856            out,
857            CarouselDragMoveOutput {
858                steal_capture: false,
859                consumed: false,
860                next_offset: None
861            }
862        );
863
864        let out = on_pointer_move(
865            CarouselDragConfig::default(),
866            &mut state,
867            Axis::Horizontal,
868            LayoutDirection::Ltr,
869            Point::new(Px(11.0), Px(0.0)),
870            true,
871            CarouselDragInputKind::Mouse,
872            Px(100.0),
873        );
874        assert!(out.steal_capture);
875        assert!(out.consumed);
876        assert!(out.next_offset.is_some());
877        assert!(!state.armed);
878        assert!(state.dragging);
879    }
880
881    #[test]
882    fn move_clamps_offset_to_bounds() {
883        let mut state = CarouselDragState::default();
884        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(50.0));
885        let out = on_pointer_move(
886            CarouselDragConfig::default(),
887            &mut state,
888            Axis::Horizontal,
889            LayoutDirection::Ltr,
890            Point::new(Px(-50.0), Px(0.0)),
891            true,
892            CarouselDragInputKind::Mouse,
893            Px(60.0),
894        );
895        assert_eq!(out.next_offset, Some(Px(60.0)));
896    }
897
898    #[test]
899    fn move_mirrors_horizontal_delta_in_rtl() {
900        let mut state = CarouselDragState::default();
901        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(50.0));
902
903        // In RTL, dragging right should increase the offset (mirror Embla's `direction` sign).
904        let out = on_pointer_move(
905            CarouselDragConfig {
906                drag_threshold_px: 0.0,
907                ..Default::default()
908            },
909            &mut state,
910            Axis::Horizontal,
911            LayoutDirection::Rtl,
912            Point::new(Px(20.0), Px(0.0)),
913            true,
914            CarouselDragInputKind::Mouse,
915            Px(100.0),
916        );
917        assert_eq!(out.next_offset, Some(Px(70.0)));
918    }
919
920    #[test]
921    fn release_snaps_by_fractional_threshold() {
922        let mut state = CarouselDragState::default();
923        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(100.0));
924        let _ = on_pointer_move(
925            CarouselDragConfig::default(),
926            &mut state,
927            Axis::Horizontal,
928            LayoutDirection::Ltr,
929            Point::new(Px(-20.0), Px(0.0)),
930            true,
931            CarouselDragInputKind::Mouse,
932            Px(400.0),
933        );
934
935        let release = on_pointer_up(
936            CarouselDragConfig::default(),
937            &mut state,
938            Axis::Horizontal,
939            LayoutDirection::Ltr,
940            Point::new(Px(-30.0), Px(0.0)),
941            Px(100.0),
942            5,
943        )
944        .expect("release");
945        assert_eq!(release.next_index, 2usize);
946        assert_eq!(release.target_offset, Px(200.0));
947    }
948
949    #[test]
950    fn release_snaps_by_fractional_threshold_with_snaps() {
951        let mut state = CarouselDragState::default();
952        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(100.0));
953
954        let _ = on_pointer_move(
955            CarouselDragConfig {
956                drag_threshold_px: 0.0,
957                ..Default::default()
958            },
959            &mut state,
960            Axis::Horizontal,
961            LayoutDirection::Ltr,
962            Point::new(Px(-40.0), Px(0.0)),
963            true,
964            CarouselDragInputKind::Mouse,
965            Px(200.0),
966        );
967
968        let snaps = [Px(0.0), Px(100.0), Px(180.0)];
969        let release = on_pointer_up_with_snaps(
970            CarouselDragConfig {
971                drag_threshold_px: 0.0,
972                snap_threshold_fraction: 0.3,
973                ..Default::default()
974            },
975            &mut state,
976            Axis::Horizontal,
977            LayoutDirection::Ltr,
978            Point::new(Px(-40.0), Px(0.0)),
979            &snaps,
980        )
981        .expect("release");
982
983        assert_eq!(release.next_index, 2usize);
984        assert_eq!(release.target_offset, Px(180.0));
985    }
986
987    #[test]
988    fn release_with_skip_snaps_allows_skipping_multiple_snaps() {
989        let mut state = CarouselDragState::default();
990        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(100.0));
991
992        let _ = on_pointer_move(
993            CarouselDragConfig {
994                drag_threshold_px: 0.0,
995                ..Default::default()
996            },
997            &mut state,
998            Axis::Horizontal,
999            LayoutDirection::Ltr,
1000            Point::new(Px(-12.0), Px(0.0)),
1001            true,
1002            CarouselDragInputKind::Mouse,
1003            Px(300.0),
1004        );
1005
1006        let snaps = [Px(0.0), Px(100.0), Px(200.0), Px(300.0)];
1007
1008        let release_neighbor_only = on_pointer_up_with_snaps_options(
1009            CarouselDragConfig {
1010                drag_threshold_px: 0.0,
1011                ..Default::default()
1012            },
1013            &mut state,
1014            Axis::Horizontal,
1015            LayoutDirection::Ltr,
1016            Point::new(Px(-240.0), Px(0.0)),
1017            &snaps,
1018            Px(300.0),
1019            false,
1020            false,
1021            false,
1022        )
1023        .expect("release");
1024
1025        assert_eq!(release_neighbor_only.next_index, 2);
1026        assert_eq!(release_neighbor_only.target_offset, Px(200.0));
1027
1028        // Re-arm the drag state for a second release with skipSnaps enabled.
1029        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(100.0));
1030        let _ = on_pointer_move(
1031            CarouselDragConfig {
1032                drag_threshold_px: 0.0,
1033                ..Default::default()
1034            },
1035            &mut state,
1036            Axis::Horizontal,
1037            LayoutDirection::Ltr,
1038            Point::new(Px(-12.0), Px(0.0)),
1039            true,
1040            CarouselDragInputKind::Mouse,
1041            Px(300.0),
1042        );
1043
1044        let release_skipping = on_pointer_up_with_snaps_options(
1045            CarouselDragConfig {
1046                drag_threshold_px: 0.0,
1047                ..Default::default()
1048            },
1049            &mut state,
1050            Axis::Horizontal,
1051            LayoutDirection::Ltr,
1052            Point::new(Px(-240.0), Px(0.0)),
1053            &snaps,
1054            Px(300.0),
1055            false,
1056            true,
1057            false,
1058        )
1059        .expect("release");
1060
1061        assert_eq!(release_skipping.next_index, 3);
1062        assert_eq!(release_skipping.target_offset, Px(300.0));
1063    }
1064
1065    #[test]
1066    fn release_with_drag_free_settles_to_projected_offset() {
1067        let mut state = CarouselDragState::default();
1068        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(100.0));
1069
1070        let _ = on_pointer_move(
1071            CarouselDragConfig {
1072                drag_threshold_px: 0.0,
1073                ..Default::default()
1074            },
1075            &mut state,
1076            Axis::Horizontal,
1077            LayoutDirection::Ltr,
1078            Point::new(Px(-12.0), Px(0.0)),
1079            true,
1080            CarouselDragInputKind::Mouse,
1081            Px(300.0),
1082        );
1083
1084        let snaps = [Px(0.0), Px(100.0), Px(200.0), Px(300.0)];
1085
1086        let release = on_pointer_up_with_snaps_options(
1087            CarouselDragConfig {
1088                drag_threshold_px: 0.0,
1089                ..Default::default()
1090            },
1091            &mut state,
1092            Axis::Horizontal,
1093            LayoutDirection::Ltr,
1094            Point::new(Px(-160.0), Px(0.0)),
1095            &snaps,
1096            Px(300.0),
1097            false,
1098            false,
1099            true,
1100        )
1101        .expect("release");
1102
1103        assert_eq!(release.next_index, 3);
1104        assert_eq!(release.target_offset, Px(260.0));
1105    }
1106
1107    #[test]
1108    fn touch_cross_axis_movement_cancels_armed_drag() {
1109        let mut state = CarouselDragState::default();
1110        on_pointer_down(&mut state, true, Point::new(Px(0.0), Px(0.0)), Px(0.0));
1111
1112        let out = on_pointer_move(
1113            CarouselDragConfig::default(),
1114            &mut state,
1115            Axis::Horizontal,
1116            LayoutDirection::Ltr,
1117            Point::new(Px(1.0), Px(5.0)),
1118            true,
1119            CarouselDragInputKind::Touch,
1120            Px(100.0),
1121        );
1122        assert_eq!(
1123            out,
1124            CarouselDragMoveOutput {
1125                steal_capture: false,
1126                consumed: false,
1127                next_offset: None
1128            }
1129        );
1130        assert_eq!(state, CarouselDragState::default());
1131    }
1132
1133    fn fixture_contain_scroll_ltr_1() -> (Px, Vec<CarouselSlide1D>) {
1134        let view = Px(1000.0);
1135        let slides = vec![
1136            CarouselSlide1D {
1137                start: Px(0.0),
1138                size: Px(100.0),
1139            },
1140            CarouselSlide1D {
1141                start: Px(100.0),
1142                size: Px(200.0),
1143            },
1144            CarouselSlide1D {
1145                start: Px(300.0),
1146                size: Px(150.0),
1147            },
1148            CarouselSlide1D {
1149                start: Px(450.0),
1150                size: Px(250.0),
1151            },
1152            CarouselSlide1D {
1153                start: Px(700.0),
1154                size: Px(130.0),
1155            },
1156            CarouselSlide1D {
1157                start: Px(830.0),
1158                size: Px(100.0),
1159            },
1160            CarouselSlide1D {
1161                start: Px(930.0),
1162                size: Px(200.0),
1163            },
1164            CarouselSlide1D {
1165                start: Px(1130.0),
1166                size: Px(150.0),
1167            },
1168            CarouselSlide1D {
1169                start: Px(1280.0),
1170                size: Px(250.0),
1171            },
1172            CarouselSlide1D {
1173                start: Px(1530.0),
1174                size: Px(130.0),
1175            },
1176        ];
1177        (view, slides)
1178    }
1179
1180    fn align_10pct(view_size: Px, _snap_size: Px, _index: usize) -> Px {
1181        Px(view_size.0 * 0.1)
1182    }
1183
1184    #[test]
1185    fn snap_model_short_circuits_when_content_fits_view_with_tolerance() {
1186        let view = Px(100.0);
1187        let slides = vec![
1188            CarouselSlide1D {
1189                start: Px(0.0),
1190                size: Px(50.0),
1191            },
1192            CarouselSlide1D {
1193                start: Px(50.0),
1194                size: Px(50.0),
1195            },
1196        ];
1197
1198        // Exact fit.
1199        let model = snap_model_1d(
1200            view,
1201            &slides,
1202            Px(0.0),
1203            Px(0.0),
1204            CarouselSlidesToScrollOption::Fixed(1),
1205            false,
1206            CarouselSnapAlign::Start,
1207            CarouselContainScrollOption::TrimSnaps,
1208            0.0,
1209        );
1210        assert_eq!(model.snaps_px, vec![Px(0.0)]);
1211        assert_eq!(model.slides_by_snap, vec![vec![0, 1]]);
1212        assert_eq!(model.snap_by_slide, vec![0, 0]);
1213        assert_eq!(model.max_offset_px, Px(0.0));
1214
1215        // Slightly exceeds view, but within tolerance.
1216        let model = snap_model_1d(
1217            view,
1218            &slides,
1219            Px(0.0),
1220            Px(0.5),
1221            CarouselSlidesToScrollOption::Fixed(1),
1222            false,
1223            CarouselSnapAlign::Start,
1224            CarouselContainScrollOption::TrimSnaps,
1225            1.0,
1226        );
1227        assert_eq!(model.snaps_px, vec![Px(0.0)]);
1228        assert_eq!(model.slides_by_snap, vec![vec![0, 1]]);
1229        assert_eq!(model.snap_by_slide, vec![0, 0]);
1230        assert_eq!(model.max_offset_px, Px(0.5));
1231    }
1232
1233    #[test]
1234    fn snap_model_fixed_slides_to_scroll_groups_slides_by_n() {
1235        let view = Px(150.0);
1236        let slides = (0..5)
1237            .map(|i| CarouselSlide1D {
1238                start: Px((i as f32) * 100.0),
1239                size: Px(100.0),
1240            })
1241            .collect::<Vec<_>>();
1242
1243        let model = snap_model_1d(
1244            view,
1245            &slides,
1246            Px(0.0),
1247            Px(0.0),
1248            CarouselSlidesToScrollOption::Fixed(2),
1249            false,
1250            CarouselSnapAlign::Start,
1251            CarouselContainScrollOption::None,
1252            0.0,
1253        );
1254
1255        assert_eq!(model.snaps_px, vec![Px(0.0), Px(200.0), Px(400.0)]);
1256        assert_eq!(model.slides_by_snap, vec![vec![0, 1], vec![2, 3], vec![4]]);
1257        assert_eq!(model.snap_by_slide, vec![0, 0, 1, 1, 2]);
1258        assert_eq!(model.max_offset_px, Px(350.0));
1259    }
1260
1261    #[test]
1262    fn snap_model_auto_slides_to_scroll_groups_by_view_size() {
1263        // Three slides of 40px each => first two fit in a 100px view, third becomes its own group.
1264        let view = Px(100.0);
1265        let slides = (0..3)
1266            .map(|i| CarouselSlide1D {
1267                start: Px((i as f32) * 40.0),
1268                size: Px(40.0),
1269            })
1270            .collect::<Vec<_>>();
1271
1272        let model = snap_model_1d(
1273            view,
1274            &slides,
1275            Px(0.0),
1276            Px(0.0),
1277            CarouselSlidesToScrollOption::Auto,
1278            false,
1279            CarouselSnapAlign::Start,
1280            CarouselContainScrollOption::None,
1281            0.0,
1282        );
1283
1284        assert_eq!(model.snaps_px, vec![Px(0.0), Px(80.0)]);
1285        assert_eq!(model.slides_by_snap, vec![vec![0, 1], vec![2]]);
1286        assert_eq!(model.snap_by_slide, vec![0, 0, 1]);
1287        assert_eq!(model.max_offset_px, Px(20.0));
1288    }
1289
1290    fn fixture_keep_trim_none_1() -> (Px, Vec<CarouselSlide1D>) {
1291        let view = Px(250.0);
1292        let slides = vec![
1293            CarouselSlide1D {
1294                start: Px(0.0),
1295                size: Px(100.0),
1296            },
1297            CarouselSlide1D {
1298                start: Px(100.0),
1299                size: Px(100.0),
1300            },
1301            CarouselSlide1D {
1302                start: Px(200.0),
1303                size: Px(100.0),
1304            },
1305            CarouselSlide1D {
1306                start: Px(300.0),
1307                size: Px(100.0),
1308            },
1309        ];
1310        (view, slides)
1311    }
1312
1313    #[test]
1314    fn snap_model_contain_scroll_keep_snaps_preserves_count_and_duplicates() {
1315        let (view, slides) = fixture_keep_trim_none_1();
1316        let model = snap_model_1d(
1317            view,
1318            &slides,
1319            Px(0.0),
1320            Px(0.0),
1321            CarouselSlidesToScrollOption::Fixed(1),
1322            false,
1323            CarouselSnapAlign::Start,
1324            CarouselContainScrollOption::KeepSnaps,
1325            0.0,
1326        );
1327
1328        // max_offset = 400 - 250 = 150. Slides at 200/300 clamp to 150, keeping duplicates.
1329        assert_eq!(
1330            model.snaps_px,
1331            vec![Px(0.0), Px(100.0), Px(150.0), Px(150.0)]
1332        );
1333        assert_eq!(
1334            model.slides_by_snap,
1335            vec![vec![0], vec![1], vec![2], vec![3]]
1336        );
1337        assert_eq!(model.snap_by_slide, vec![0, 1, 2, 3]);
1338        assert_eq!(model.max_offset_px, Px(150.0));
1339    }
1340
1341    #[test]
1342    fn snap_model_contain_scroll_trim_snaps_trims_and_expands_edge_groups() {
1343        let (view, slides) = fixture_keep_trim_none_1();
1344        let model = snap_model_1d(
1345            view,
1346            &slides,
1347            Px(0.0),
1348            Px(0.0),
1349            CarouselSlidesToScrollOption::Fixed(1),
1350            false,
1351            CarouselSnapAlign::Start,
1352            CarouselContainScrollOption::TrimSnaps,
1353            0.0,
1354        );
1355
1356        assert_eq!(model.snaps_px, vec![Px(0.0), Px(100.0), Px(150.0)]);
1357        assert_eq!(model.slides_by_snap, vec![vec![0], vec![1], vec![2, 3]]);
1358        assert_eq!(model.snap_by_slide, vec![0, 1, 2, 2]);
1359        assert_eq!(model.max_offset_px, Px(150.0));
1360    }
1361
1362    #[test]
1363    fn snap_model_contain_scroll_none_does_not_clamp_snaps() {
1364        let (view, slides) = fixture_keep_trim_none_1();
1365        let model = snap_model_1d(
1366            view,
1367            &slides,
1368            Px(0.0),
1369            Px(0.0),
1370            CarouselSlidesToScrollOption::Fixed(1),
1371            false,
1372            CarouselSnapAlign::Start,
1373            CarouselContainScrollOption::None,
1374            0.0,
1375        );
1376
1377        assert_eq!(
1378            model.snaps_px,
1379            vec![Px(0.0), Px(100.0), Px(200.0), Px(300.0)]
1380        );
1381        assert_eq!(
1382            model.slides_by_snap,
1383            vec![vec![0], vec![1], vec![2], vec![3]]
1384        );
1385        assert_eq!(model.snap_by_slide, vec![0, 1, 2, 3]);
1386        assert_eq!(model.max_offset_px, Px(150.0));
1387    }
1388
1389    #[test]
1390    fn embla_fixture_ltr_1_trim_snaps_align_start_matches() {
1391        let (view, slides) = fixture_contain_scroll_ltr_1();
1392        let snaps = contained_scroll_snaps_1d(
1393            view,
1394            &slides,
1395            CarouselSnapAlign::Start,
1396            CarouselContainScrollConfig::default(),
1397        );
1398        assert_eq!(
1399            snaps,
1400            vec![Px(0.0), Px(100.0), Px(300.0), Px(450.0), Px(660.0)]
1401        );
1402    }
1403
1404    #[test]
1405    fn embla_fixture_ltr_1_trim_snaps_align_center_matches() {
1406        let (view, slides) = fixture_contain_scroll_ltr_1();
1407        let snaps = contained_scroll_snaps_1d(
1408            view,
1409            &slides,
1410            CarouselSnapAlign::Center,
1411            CarouselContainScrollConfig::default(),
1412        );
1413        assert_eq!(
1414            snaps,
1415            vec![
1416                Px(0.0),
1417                Px(75.0),
1418                Px(265.0),
1419                Px(380.0),
1420                Px(530.0),
1421                Px(660.0)
1422            ]
1423        );
1424    }
1425
1426    #[test]
1427    fn embla_fixture_ltr_1_trim_snaps_align_end_matches() {
1428        let (view, slides) = fixture_contain_scroll_ltr_1();
1429        let snaps = contained_scroll_snaps_1d(
1430            view,
1431            &slides,
1432            CarouselSnapAlign::End,
1433            CarouselContainScrollConfig::default(),
1434        );
1435        assert_eq!(
1436            snaps,
1437            vec![Px(0.0), Px(130.0), Px(280.0), Px(530.0), Px(660.0)]
1438        );
1439    }
1440
1441    #[test]
1442    fn embla_fixture_ltr_1_trim_snaps_align_custom_matches() {
1443        let (view, slides) = fixture_contain_scroll_ltr_1();
1444        let snaps = contained_scroll_snaps_1d(
1445            view,
1446            &slides,
1447            CarouselSnapAlign::Custom(align_10pct),
1448            CarouselContainScrollConfig::default(),
1449        );
1450        assert_eq!(
1451            snaps,
1452            vec![Px(0.0), Px(200.0), Px(350.0), Px(600.0), Px(660.0)]
1453        );
1454    }
1455
1456    #[test]
1457    fn embla_fixture_content_size_within_pixel_tolerance_collapses_to_zero() {
1458        let view = Px(1000.0);
1459        let slides = vec![
1460            CarouselSlide1D {
1461                start: Px(0.0),
1462                size: Px(501.0),
1463            },
1464            CarouselSlide1D {
1465                start: Px(501.0),
1466                size: Px(501.0),
1467            },
1468        ];
1469
1470        let snaps = contained_scroll_snaps_1d(
1471            view,
1472            &slides,
1473            CarouselSnapAlign::Center,
1474            CarouselContainScrollConfig::default(),
1475        );
1476        assert_eq!(snaps, vec![Px(0.0)]);
1477    }
1478
1479    #[test]
1480    fn embla_fixture_content_size_just_outside_pixel_tolerance_keeps_small_edge_snap() {
1481        let view = Px(1000.0);
1482        let slides = vec![
1483            CarouselSlide1D {
1484                start: Px(0.0),
1485                size: Px(502.0),
1486            },
1487            CarouselSlide1D {
1488                start: Px(502.0),
1489                size: Px(501.0),
1490            },
1491        ];
1492
1493        let snaps = contained_scroll_snaps_1d(
1494            view,
1495            &slides,
1496            CarouselSnapAlign::Center,
1497            CarouselContainScrollConfig::default(),
1498        );
1499        assert_eq!(snaps, vec![Px(0.0), Px(3.0)]);
1500    }
1501
1502    fn fixture_contain_scroll_ltr_2() -> (Px, Px, Vec<CarouselSlide1D>) {
1503        let view = Px(1000.0);
1504        let end_gap = Px(10.0);
1505        let slides = vec![
1506            CarouselSlide1D {
1507                start: Px(10.0),
1508                size: Px(100.0),
1509            },
1510            CarouselSlide1D {
1511                start: Px(130.0),
1512                size: Px(200.0),
1513            },
1514            CarouselSlide1D {
1515                start: Px(350.0),
1516                size: Px(150.0),
1517            },
1518            CarouselSlide1D {
1519                start: Px(520.0),
1520                size: Px(250.0),
1521            },
1522            CarouselSlide1D {
1523                start: Px(790.0),
1524                size: Px(130.0),
1525            },
1526            CarouselSlide1D {
1527                start: Px(940.0),
1528                size: Px(100.0),
1529            },
1530            CarouselSlide1D {
1531                start: Px(1060.0),
1532                size: Px(200.0),
1533            },
1534            CarouselSlide1D {
1535                start: Px(1280.0),
1536                size: Px(150.0),
1537            },
1538            CarouselSlide1D {
1539                start: Px(1450.0),
1540                size: Px(250.0),
1541            },
1542            CarouselSlide1D {
1543                start: Px(1720.0),
1544                size: Px(130.0),
1545            },
1546        ];
1547        (view, end_gap, slides)
1548    }
1549
1550    #[test]
1551    fn embla_fixture_ltr_2_trim_snaps_align_start_matches() {
1552        let (view, end_gap, slides) = fixture_contain_scroll_ltr_2();
1553        let snaps = contained_scroll_snaps_1d_with_end_gap(
1554            view,
1555            &slides,
1556            end_gap,
1557            CarouselSnapAlign::Start,
1558            CarouselContainScrollConfig::default(),
1559        );
1560        assert_eq!(
1561            snaps,
1562            vec![
1563                Px(0.0),
1564                Px(130.0),
1565                Px(350.0),
1566                Px(520.0),
1567                Px(790.0),
1568                Px(860.0)
1569            ]
1570        );
1571    }
1572
1573    #[test]
1574    fn embla_fixture_ltr_2_trim_snaps_align_center_matches() {
1575        let (view, end_gap, slides) = fixture_contain_scroll_ltr_2();
1576        let snaps = contained_scroll_snaps_1d_with_end_gap(
1577            view,
1578            &slides,
1579            end_gap,
1580            CarouselSnapAlign::Center,
1581            CarouselContainScrollConfig::default(),
1582        );
1583        assert_eq!(
1584            snaps,
1585            vec![
1586                Px(0.0),
1587                Px(145.0),
1588                Px(355.0),
1589                Px(490.0),
1590                Px(660.0),
1591                Px(855.0),
1592                Px(860.0)
1593            ]
1594        );
1595    }
1596
1597    #[test]
1598    fn embla_fixture_ltr_2_trim_snaps_align_end_matches() {
1599        let (view, end_gap, slides) = fixture_contain_scroll_ltr_2();
1600        let snaps = contained_scroll_snaps_1d_with_end_gap(
1601            view,
1602            &slides,
1603            end_gap,
1604            CarouselSnapAlign::End,
1605            CarouselContainScrollConfig::default(),
1606        );
1607        assert_eq!(
1608            snaps,
1609            vec![
1610                Px(0.0),
1611                Px(40.0),
1612                Px(260.0),
1613                Px(430.0),
1614                Px(700.0),
1615                Px(860.0)
1616            ]
1617        );
1618    }
1619
1620    #[test]
1621    fn embla_fixture_ltr_2_trim_snaps_align_custom_matches() {
1622        let (view, end_gap, slides) = fixture_contain_scroll_ltr_2();
1623        let snaps = contained_scroll_snaps_1d_with_end_gap(
1624            view,
1625            &slides,
1626            end_gap,
1627            CarouselSnapAlign::Custom(align_10pct),
1628            CarouselContainScrollConfig::default(),
1629        );
1630        assert_eq!(
1631            snaps,
1632            vec![
1633                Px(0.0),
1634                Px(30.0),
1635                Px(250.0),
1636                Px(420.0),
1637                Px(690.0),
1638                Px(840.0),
1639                Px(860.0)
1640            ]
1641        );
1642    }
1643}