Skip to main content

gpui_component/carousel/
state.rs

1use std::{cell::OnceCell, time::Duration};
2
3use gpui::{
4    Along, App, Axis, Bounds, Context, EventEmitter, FocusHandle, Focusable, Pixels, Point,
5    ScrollHandle, TouchPhase, px,
6};
7
8const POINTER_AXIS_LOCK_THRESHOLD: Pixels = px(2.);
9// Keep this aligned with GPUI's OngoingScroll timeout. Some platforms only
10// emit `Moved`, so a quiet period is the only signal that a new gesture began.
11pub(super) const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28);
12
13/// An event emitted when user interaction selects another carousel item.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum CarouselEvent {
16    /// The newly selected item index.
17    Change(usize),
18}
19
20/// Bounds collected by [`super::CarouselContent`] after layout.
21///
22/// The bounds are kept in the content's unscrolled coordinate space.  Keeping
23/// this geometry in the behavior state lets pointer, wheel, and keyboard
24/// input all resolve to the same snap points without making the content own a
25/// second copy of the selection state.
26#[derive(Clone, Debug, Default, PartialEq)]
27pub(super) struct CarouselGeometry {
28    viewport: Option<Bounds<Pixels>>,
29    frame: Option<Bounds<Pixels>>,
30    items: Vec<Bounds<Pixels>>,
31}
32
33#[derive(Clone, Copy, Debug)]
34struct PointerGesture {
35    start_position: Point<Pixels>,
36    start_offset: Point<Pixels>,
37    start_index: Option<usize>,
38    total_delta: Pixels,
39    axis_locked: bool,
40}
41
42#[derive(Clone, Copy, Debug)]
43struct ScrollGesture {
44    start_offset: Point<Pixels>,
45    start_index: Option<usize>,
46    total_delta: Pixels,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq)]
50struct LoopLayout {
51    cycle_extent: Pixels,
52    track_gap: Pixels,
53    runway_extent: Pixels,
54    runway_ready: bool,
55}
56
57/// Shared behavior state for every part of a [`super::Carousel`].
58///
59/// `CarouselState` intentionally owns behavior only.  The content and its
60/// items provide presentation and layout, while this state owns selection,
61/// orientation, looping, the shared scroll handle, and the input snapshots
62/// used to settle gestures.  Programmatic setters are silent; user-facing
63/// selection methods emit one [`CarouselEvent::Change`] for a successful
64/// selection.
65pub struct CarouselState {
66    item_count: usize,
67    selected_index: Option<usize>,
68    axis: Axis,
69    looping: bool,
70    scroll_handle: ScrollHandle,
71    geometry: CarouselGeometry,
72    pointer_gesture: Option<PointerGesture>,
73    scroll_gesture: Option<ScrollGesture>,
74    ignore_scroll_until_quiet: bool,
75    scroll_settle_epoch: usize,
76    suppress_pointer_click: bool,
77    motion_revision: usize,
78    loop_layout: Option<LoopLayout>,
79    geometry_has_runway: bool,
80    loop_layout_removal_pending: bool,
81    loop_motion_target: Option<Point<Pixels>>,
82    wheel_burst_active: bool,
83    wheel_burst_epoch: usize,
84    focus_handle: OnceCell<FocusHandle>,
85    focus_ring_suppressed: bool,
86}
87
88impl CarouselState {
89    /// Creates state for `item_count` items, initially selecting the first
90    /// item when at least one item exists.
91    pub fn new(item_count: usize) -> Self {
92        Self {
93            item_count,
94            selected_index: (item_count > 0).then_some(0),
95            axis: Axis::Horizontal,
96            looping: false,
97            scroll_handle: ScrollHandle::new(),
98            geometry: CarouselGeometry::default(),
99            pointer_gesture: None,
100            scroll_gesture: None,
101            ignore_scroll_until_quiet: false,
102            scroll_settle_epoch: 0,
103            suppress_pointer_click: false,
104            motion_revision: 0,
105            loop_layout: None,
106            geometry_has_runway: false,
107            loop_layout_removal_pending: false,
108            loop_motion_target: None,
109            wheel_burst_active: false,
110            wheel_burst_epoch: 0,
111            focus_handle: OnceCell::new(),
112            focus_ring_suppressed: false,
113        }
114    }
115
116    /// Sets the initially selected item.
117    pub fn with_selected_index(mut self, index: usize) -> Self {
118        self.selected_index = self.clamp_index(index);
119        self
120    }
121
122    /// Sets the carousel orientation.
123    pub fn with_axis(mut self, axis: Axis) -> Self {
124        self.axis = axis;
125        self
126    }
127
128    /// Enables or disables wrapping at the first and last items.
129    pub fn with_looping(mut self, looping: bool) -> Self {
130        self.looping = looping;
131        self
132    }
133
134    /// Returns the number of logical items.
135    pub fn item_count(&self) -> usize {
136        self.item_count
137    }
138
139    /// Returns the selected logical item, or `None` when the carousel is
140    /// empty.
141    pub fn selected_index(&self) -> Option<usize> {
142        self.selected_index
143    }
144
145    /// Returns the configured orientation.
146    pub fn axis(&self) -> Axis {
147        self.axis
148    }
149
150    /// Returns whether navigation wraps at the carousel edges.
151    pub fn is_looping(&self) -> bool {
152        self.looping
153    }
154
155    /// Returns whether a previous item can be selected.
156    pub fn has_previous(&self) -> bool {
157        self.selected_index
158            .and_then(|index| self.navigation_index(index, false))
159            .is_some()
160    }
161
162    /// Returns whether a next item can be selected.
163    pub fn has_next(&self) -> bool {
164        self.selected_index
165            .and_then(|index| self.navigation_index(index, true))
166            .is_some()
167    }
168
169    /// Silently changes the selected item for controlled/programmatic use.
170    ///
171    /// The value is clamped to the available item range.  This method does
172    /// not emit [`CarouselEvent::Change`].
173    pub fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
174        let rebased_loop_motion = self.rebase_pending_loop_motion();
175        let next_index = self.clamp_index(index);
176        let changed =
177            self.selected_index != next_index || self.is_interacting() || rebased_loop_motion;
178        let current = self.selected_index;
179        let wrapped = current
180            .zip(next_index)
181            .is_some_and(|(current, next)| self.is_loop_wrap(current, next));
182        let loop_target = current
183            .filter(|_| wrapped)
184            .zip(next_index)
185            .and_then(|(current, next)| self.adjacent_loop_target(current, next));
186        let seamless_wrap = loop_target.is_some();
187        self.loop_motion_target = loop_target.filter(|target| {
188            next_index
189                .and_then(|index| self.snap_target_for(index))
190                .is_some_and(|real| self.primary_offset(real) != self.primary_offset(*target))
191        });
192        if wrapped && !seamless_wrap {
193            self.motion_revision = self.motion_revision.wrapping_add(1);
194        }
195        self.selected_index = next_index;
196        self.cancel_interactions(cx);
197        if changed {
198            cx.notify();
199        }
200    }
201
202    /// Silently changes the number of logical items and clamps the selection.
203    pub fn set_item_count(&mut self, item_count: usize, cx: &mut Context<Self>) {
204        if self.item_count == item_count {
205            return;
206        }
207        self.item_count = item_count;
208        self.selected_index = self
209            .selected_index
210            .and_then(|index| (item_count > 0).then_some(index.min(item_count.saturating_sub(1))));
211        if self.selected_index.is_none() && item_count > 0 {
212            self.selected_index = Some(0);
213        }
214        if self.loop_layout.is_some() {
215            self.scroll_handle.set_offset(Point::default());
216            self.motion_revision = self.motion_revision.wrapping_add(1);
217        }
218        self.geometry.items.clear();
219        self.loop_layout = None;
220        self.loop_layout_removal_pending = self.geometry_has_runway;
221        self.loop_motion_target = None;
222        self.cancel_interactions(cx);
223        cx.notify();
224    }
225
226    /// Silently changes the carousel orientation.
227    pub fn set_axis(&mut self, axis: Axis, cx: &mut Context<Self>) {
228        if self.axis != axis {
229            self.axis = axis;
230            self.scroll_handle.set_offset(Point::default());
231            self.loop_layout = None;
232            self.loop_layout_removal_pending = self.geometry_has_runway;
233            self.loop_motion_target = None;
234            self.cancel_interactions(cx);
235            self.motion_revision = self.motion_revision.wrapping_add(1);
236            cx.notify();
237        }
238    }
239
240    /// Silently changes whether edge navigation wraps.
241    pub fn set_looping(&mut self, looping: bool, cx: &mut Context<Self>) {
242        if self.looping != looping {
243            self.looping = looping;
244            self.update_loop_layout();
245            self.loop_motion_target = None;
246            self.cancel_interactions(cx);
247            cx.notify();
248        }
249    }
250
251    /// Selects an item through a user-facing path and emits one change event
252    /// when the index is valid and differs from the current selection.
253    pub fn select_index(&mut self, index: usize, cx: &mut Context<Self>) -> bool {
254        if index >= self.item_count {
255            self.cancel_user_interaction(cx);
256            return false;
257        }
258        let wrapped = self
259            .selected_index
260            .zip(Some(index))
261            .is_some_and(|(current, next)| self.is_loop_wrap(current, next));
262        self.select_index_with_wrap(index, wrapped, cx)
263    }
264
265    /// Selects the previous item, wrapping when looping is enabled.
266    pub fn select_previous(&mut self, cx: &mut Context<Self>) -> bool {
267        let Some(current) = self.selected_index else {
268            return false;
269        };
270
271        let Some(index) = self.navigation_index(current, false) else {
272            self.cancel_user_interaction(cx);
273            return false;
274        };
275        let wrapped = self.is_loop_wrap(current, index);
276        self.select_index_with_wrap(index, wrapped, cx)
277    }
278
279    /// Selects the next item, wrapping when looping is enabled.
280    pub fn select_next(&mut self, cx: &mut Context<Self>) -> bool {
281        let Some(current) = self.selected_index else {
282            return false;
283        };
284
285        let Some(index) = self.navigation_index(current, true) else {
286            self.cancel_user_interaction(cx);
287            return false;
288        };
289        let wrapped = self.is_loop_wrap(current, index);
290        self.select_index_with_wrap(index, wrapped, cx)
291    }
292
293    /// Selects the first item through the user-facing path.
294    pub fn select_first(&mut self, cx: &mut Context<Self>) -> bool {
295        self.select_index(0, cx)
296    }
297
298    /// Selects the last item through the user-facing path.
299    pub fn select_last(&mut self, cx: &mut Context<Self>) -> bool {
300        let Some(index) = self.item_count.checked_sub(1) else {
301            self.cancel_user_interaction(cx);
302            return false;
303        };
304        self.select_index(index, cx)
305    }
306
307    /// Returns the shared scroll handle used by the content viewport.
308    pub(super) fn scroll_handle(&self) -> &ScrollHandle {
309        &self.scroll_handle
310    }
311
312    /// Hides or restores the focus ring. Focus that arrives through the
313    /// pointer keeps the arrow keys working without drawing the ring.
314    pub(super) fn suppress_focus_ring(&mut self, suppressed: bool) {
315        self.focus_ring_suppressed = suppressed;
316    }
317
318    pub(super) fn is_focus_ring_suppressed(&self) -> bool {
319        self.focus_ring_suppressed
320    }
321
322    /// Returns whether pointer or trackpad input is currently active.
323    pub(super) fn is_interacting(&self) -> bool {
324        self.pointer_gesture.is_some() || self.scroll_gesture.is_some()
325    }
326
327    /// Returns whether a trackpad gesture is being tracked.
328    pub(super) fn has_scroll_gesture(&self) -> bool {
329        self.scroll_gesture.is_some()
330    }
331
332    /// Returns whether the active pointer gesture has committed to this axis.
333    pub(super) fn is_pointer_drag_locked(&self) -> bool {
334        self.pointer_gesture
335            .is_some_and(|gesture| gesture.axis_locked)
336    }
337
338    /// Returns whether a cross-axis move cancelled this pointer sequence.
339    ///
340    /// The event surface uses this to stop the release during its bubble phase.
341    /// Capture-phase handlers still run first, so descendant controls clear
342    /// their pending press and ancestor gesture surfaces can finish.
343    pub(super) fn should_suppress_pointer_click(&self) -> bool {
344        self.suppress_pointer_click
345    }
346
347    /// Returns a monotonic key for motion that must be rebased immediately.
348    ///
349    /// Ordinary adjacent selection and a loop wrap leave this value unchanged.
350    /// It advances only when the scroll coordinate is silently rebased to an
351    /// equivalent cycle, letting the content start a fresh spring at the same
352    /// visual position.
353    pub(super) fn motion_revision(&self) -> usize {
354        self.motion_revision
355    }
356
357    /// Returns the size of the clipped content frame that controls and the
358    /// focus ring are positioned around.
359    pub(super) fn frame_size(&self) -> Option<gpui::Size<Pixels>> {
360        self.geometry.frame.map(|frame| frame.size)
361    }
362
363    /// Records the viewport and item bounds used for gesture snapping.
364    #[cfg(test)]
365    fn set_geometry(&mut self, viewport: Bounds<Pixels>, items: Vec<Bounds<Pixels>>) {
366        let has_runway = self.loop_layout.is_some();
367        self.set_geometry_with_runway(viewport, viewport, items, has_runway);
368    }
369
370    /// Records the scroll track (`viewport`), the clipped content `frame`, and
371    /// the item bounds after layout.
372    pub(super) fn set_geometry_with_runway(
373        &mut self,
374        viewport: Bounds<Pixels>,
375        frame: Bounds<Pixels>,
376        items: Vec<Bounds<Pixels>>,
377        has_runway: bool,
378    ) {
379        self.geometry = CarouselGeometry {
380            viewport: Some(viewport),
381            frame: Some(frame),
382            items,
383        };
384        self.geometry_has_runway = has_runway;
385        self.update_loop_layout();
386    }
387
388    /// Returns the runway reserved on both sides of the real item cycle.
389    pub(super) fn loop_runway(&self) -> Option<Pixels> {
390        self.loop_layout.map(|layout| layout.cycle_extent)
391    }
392
393    /// Returns whether content layout is moving into or out of its runway.
394    pub(super) fn is_loop_layout_transitioning(&self) -> bool {
395        self.loop_layout_removal_pending
396            || self.loop_layout.is_some_and(|layout| !layout.runway_ready)
397    }
398
399    /// Returns the visual offset applied to one item in the circular track.
400    pub(super) fn loop_item_offset(&self, index: usize) -> Point<Pixels> {
401        let Some(layout) = self.loop_layout else {
402            return Point::default();
403        };
404
405        if !layout.runway_ready {
406            return self.axis_point(-layout.runway_extent);
407        }
408
409        let Some(viewport) = self.geometry.viewport else {
410            return Point::default();
411        };
412        let Some(item) = self.geometry.items.get(index) else {
413            return Point::default();
414        };
415        let viewport_center = self.primary_start(viewport) + viewport.size.along(self.axis) / 2.
416            - self.primary_offset(self.scroll_handle.offset());
417        let item_center = self.primary_start(*item) + item.size.along(self.axis) / 2.;
418        let cycles = ((viewport_center - item_center) / layout.cycle_extent)
419            .round()
420            .clamp(-1., 1.);
421        self.axis_point(layout.cycle_extent * cycles)
422    }
423
424    /// Returns the target used by content motion. During a boundary wrap this
425    /// is the equivalent snap in the adjacent runway cycle.
426    pub(super) fn motion_target_for(&self, index: usize) -> Option<Point<Pixels>> {
427        self.loop_motion_target
428            .filter(|_| self.selected_index == Some(index))
429            .or_else(|| self.snap_target_for(index))
430    }
431
432    /// Silently returns a settled virtual target to the middle cycle.
433    pub(super) fn settle_loop_motion(
434        &mut self,
435        rendered: Point<Pixels>,
436        cx: &mut Context<Self>,
437    ) -> Option<Point<Pixels>> {
438        let target = self.loop_motion_target?;
439        if (self.primary_offset(rendered) - self.primary_offset(target)).abs() > px(0.01) {
440            return None;
441        }
442
443        let selected = self.selected_index?;
444        self.loop_motion_target = None;
445        let real_target = self.snap_target_for(selected)?;
446        self.scroll_handle.set_offset(real_target);
447        self.motion_revision = self.motion_revision.wrapping_add(1);
448        cx.notify();
449        Some(real_target)
450    }
451
452    /// Returns the geometry-derived snap offset for `index`.
453    pub(super) fn snap_target_for(&self, index: usize) -> Option<Point<Pixels>> {
454        let viewport = self.geometry.viewport?;
455        let item = self.geometry.items.get(index)?;
456        Some(self.snap_offset(viewport, *item))
457    }
458
459    /// Returns the nearest item index for a scroll offset.
460    pub(super) fn nearest_index(&self, offset: Point<Pixels>) -> Option<usize> {
461        let viewport = self.geometry.viewport?;
462        self.geometry
463            .items
464            .iter()
465            .enumerate()
466            .min_by(|(_, left), (_, right)| {
467                let left_distance = self.looping_distance(offset, viewport, **left);
468                let right_distance = self.looping_distance(offset, viewport, **right);
469                left_distance
470                    .partial_cmp(&right_distance)
471                    .unwrap_or(std::cmp::Ordering::Equal)
472            })
473            .map(|(index, _)| index)
474    }
475
476    /// Begins a pointer drag and notifies the owning entity.
477    pub(super) fn begin_drag(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) -> bool {
478        let cancelled_scroll = self.scroll_gesture.is_some();
479        let started = self.begin_drag_snapshot(position);
480        if started {
481            if cancelled_scroll {
482                self.schedule_ignored_scroll_recovery(cx);
483            }
484            cx.notify();
485        }
486        started
487    }
488
489    /// Updates the active pointer drag after locking it to the carousel axis.
490    /// Cross-axis drags cancel this gesture so an ancestor can handle them.
491    pub(super) fn update_drag(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) -> bool {
492        let Some(mut gesture) = self.pointer_gesture else {
493            return false;
494        };
495
496        let delta = position - gesture.start_position;
497        let primary_delta = self.primary_delta(delta);
498        if !gesture.axis_locked {
499            let cross_axis_delta = self.cross_axis_delta(delta);
500            if primary_delta.abs().max(cross_axis_delta.abs()) <= POINTER_AXIS_LOCK_THRESHOLD {
501                return false;
502            }
503            if cross_axis_delta.abs() > primary_delta.abs() {
504                self.pointer_gesture = None;
505                self.suppress_pointer_click = true;
506                cx.notify();
507                return false;
508            }
509            gesture.axis_locked = true;
510        }
511
512        gesture.total_delta = primary_delta;
513        self.pointer_gesture = Some(gesture);
514        let mut offset = gesture.start_offset;
515        let next = self.clamped_offset(self.primary_offset(offset) + primary_delta);
516        self.set_primary_offset(&mut offset, next);
517        let previous = self.scroll_handle.offset();
518        self.scroll_handle.set_offset(offset);
519        self.normalize_loop_coordinate();
520        let changed = self.scroll_handle.offset() != previous;
521        if changed {
522            cx.notify();
523        }
524        true
525    }
526
527    /// Finishes a pointer drag by selecting the nearest item and settling the
528    /// handle to its snap point.
529    pub(super) fn finish_drag(&mut self, cx: &mut Context<Self>) -> bool {
530        let suppressed_click = std::mem::take(&mut self.suppress_pointer_click);
531        let Some(gesture) = self.pointer_gesture.take() else {
532            if suppressed_click {
533                cx.notify();
534            }
535            return false;
536        };
537        self.finish_snapshot(
538            gesture.start_offset,
539            gesture.start_index,
540            gesture.total_delta,
541            cx,
542        )
543    }
544
545    /// Applies a precise trackpad delta and remembers the gesture's start.
546    /// Returns whether the delta moved the handle or can be consumed by a
547    /// looping carousel.
548    pub(super) fn handle_scroll_delta(
549        &mut self,
550        axis: Axis,
551        delta: Pixels,
552        phase: TouchPhase,
553        cx: &mut Context<Self>,
554    ) -> bool {
555        if axis != self.axis || self.item_count < 2 {
556            return false;
557        }
558
559        self.rebase_pending_loop_motion();
560
561        if self.ignore_scroll_until_quiet {
562            match phase {
563                TouchPhase::Started => {
564                    self.ignore_scroll_until_quiet = false;
565                    self.invalidate_scroll_settle();
566                }
567                TouchPhase::Ended | TouchPhase::Cancelled => {
568                    self.ignore_scroll_until_quiet = false;
569                    self.invalidate_scroll_settle();
570                    return false;
571                }
572                TouchPhase::Moved => {
573                    self.schedule_ignored_scroll_recovery(cx);
574                    return false;
575                }
576            }
577        }
578
579        if matches!(phase, TouchPhase::Started) && self.scroll_gesture.is_some() {
580            self.finish_scroll(false, cx);
581        }
582
583        if self.scroll_gesture.is_none() {
584            self.pointer_gesture = None;
585            self.scroll_gesture = Some(ScrollGesture {
586                start_offset: self.scroll_handle.offset(),
587                start_index: self.selected_index,
588                total_delta: px(0.),
589            });
590        }
591
592        if let Some(gesture) = self.scroll_gesture.as_mut() {
593            gesture.total_delta += delta;
594        } else {
595            return false;
596        }
597
598        let mut offset = self.scroll_handle.offset();
599        let previous = offset;
600        let next = self.clamped_offset(self.primary_offset(offset) + delta);
601        self.set_primary_offset(&mut offset, next);
602        self.scroll_handle.set_offset(offset);
603        self.normalize_loop_coordinate();
604        let moved = self.scroll_handle.offset() != previous;
605        if moved {
606            cx.notify();
607        }
608        if matches!(phase, TouchPhase::Started | TouchPhase::Moved) {
609            self.schedule_scroll_settle(cx);
610        }
611        moved || self.runtime_looping()
612    }
613
614    /// Finishes a precise trackpad gesture.  Cancelled gestures restore the
615    /// original offset and never emit a selection event.
616    pub(super) fn finish_scroll(&mut self, cancelled: bool, cx: &mut Context<Self>) -> bool {
617        self.invalidate_scroll_settle();
618        if self.ignore_scroll_until_quiet {
619            self.ignore_scroll_until_quiet = false;
620            return false;
621        }
622        let Some(gesture) = self.scroll_gesture.take() else {
623            return false;
624        };
625        if cancelled {
626            self.scroll_handle.set_offset(gesture.start_offset);
627            cx.notify();
628            return false;
629        }
630
631        self.finish_snapshot(
632            gesture.start_offset,
633            gesture.start_index,
634            gesture.total_delta,
635            cx,
636        )
637    }
638
639    /// Hands a trackpad gesture that began at an edge to an ancestor scroller
640    /// until it ends or goes quiet.
641    pub(super) fn defer_scroll_to_ancestor(&mut self, cx: &mut Context<Self>) {
642        self.scroll_gesture = None;
643        self.invalidate_scroll_settle();
644        self.ignore_scroll_until_quiet = true;
645        self.schedule_ignored_scroll_recovery(cx);
646    }
647
648    /// Applies one mouse-wheel notch.  Line deltas carry no touch phases, so
649    /// the events within one quiet period form a burst that the first event
650    /// assigns: a step keeps the whole burst here, while a burst that cannot
651    /// step belongs to an ancestor scroller.
652    pub(super) fn handle_wheel_step(
653        &mut self,
654        axis: Axis,
655        delta: Pixels,
656        cx: &mut Context<Self>,
657    ) -> bool {
658        if axis != self.axis || delta == px(0.) {
659            return false;
660        }
661        if self.ignore_scroll_until_quiet {
662            self.schedule_ignored_scroll_recovery(cx);
663            return false;
664        }
665        if self.wheel_burst_active {
666            self.schedule_wheel_burst_end(cx);
667            return true;
668        }
669
670        let stepped = if delta > px(0.) {
671            self.select_previous(cx)
672        } else {
673            self.select_next(cx)
674        };
675        if stepped {
676            self.wheel_burst_active = true;
677            self.schedule_wheel_burst_end(cx);
678        } else {
679            self.ignore_scroll_until_quiet = true;
680            self.schedule_ignored_scroll_recovery(cx);
681        }
682        stepped
683    }
684
685    fn select_index_with_wrap(
686        &mut self,
687        index: usize,
688        wrapped: bool,
689        cx: &mut Context<Self>,
690    ) -> bool {
691        let Some(index) = self.clamp_index(index) else {
692            return false;
693        };
694        let rebased_loop_motion = self.rebase_pending_loop_motion();
695        let was_interacting = self.is_interacting();
696        self.cancel_interactions(cx);
697        if self.selected_index == Some(index) {
698            if was_interacting || rebased_loop_motion {
699                cx.notify();
700            }
701            return false;
702        }
703
704        let current = self.selected_index;
705        let loop_target = current
706            .filter(|_| wrapped)
707            .and_then(|current| self.adjacent_loop_target(current, index));
708        let seamless_wrap = loop_target.is_some();
709        self.loop_motion_target = loop_target.filter(|target| {
710            self.snap_target_for(index)
711                .is_some_and(|real| self.primary_offset(real) != self.primary_offset(*target))
712        });
713        self.selected_index = Some(index);
714        if wrapped && !seamless_wrap {
715            self.motion_revision = self.motion_revision.wrapping_add(1);
716        }
717        cx.emit(CarouselEvent::Change(index));
718        cx.notify();
719        true
720    }
721
722    fn clamp_index(&self, index: usize) -> Option<usize> {
723        (self.item_count > 0).then_some(index.min(self.item_count - 1))
724    }
725
726    fn is_loop_wrap(&self, current: usize, next: usize) -> bool {
727        self.runtime_looping()
728            && self.item_count > 1
729            && ((current == 0 && next + 1 == self.item_count)
730                || (current + 1 == self.item_count && next == 0))
731    }
732
733    /// Returns the next logical item that has a distinct physical snap point.
734    ///
735    /// When layout has not populated the geometry yet, navigation falls back
736    /// to logical item indices.  Once geometry is available, adjacent items
737    /// that clamp to the same physical endpoint are treated as one snap
738    /// point.  Keeping the first item in a duplicate group as the canonical
739    /// index preserves the existing nearest-index tie break at the end of the
740    /// track, while allowing navigation from a controlled duplicate index to
741    /// skip back over that group.
742    fn navigation_index(&self, current: usize, next: bool) -> Option<usize> {
743        if self.runtime_looping() || !self.geometry_is_ready() {
744            return self.logical_navigation_index(current, next);
745        }
746
747        let current_target = self.snap_target_for(current)?;
748        let current_target = self.primary_offset(current_target);
749        if next {
750            (current.saturating_add(1)..self.item_count).find(|index| {
751                self.snap_target_for(*index)
752                    .map(|target| self.primary_offset(target) != current_target)
753                    .unwrap_or(false)
754            })
755        } else {
756            (0..current).rev().find(|index| {
757                self.snap_target_for(*index)
758                    .map(|target| self.primary_offset(target) != current_target)
759                    .unwrap_or(false)
760            })
761        }
762    }
763
764    fn logical_navigation_index(&self, current: usize, next: bool) -> Option<usize> {
765        if next {
766            if current + 1 < self.item_count {
767                Some(current + 1)
768            } else if self.runtime_looping() && self.item_count > 1 {
769                Some(0)
770            } else {
771                None
772            }
773        } else if current > 0 {
774            Some(current - 1)
775        } else if self.runtime_looping() && self.item_count > 1 {
776            Some(self.item_count - 1)
777        } else {
778            None
779        }
780    }
781
782    fn geometry_is_ready(&self) -> bool {
783        self.geometry.viewport.is_some() && self.geometry.items.len() == self.item_count
784    }
785
786    fn runtime_looping(&self) -> bool {
787        self.looping && (!self.geometry_is_ready() || self.loop_layout.is_some())
788    }
789
790    fn adjacent_loop_target(&self, current: usize, next: usize) -> Option<Point<Pixels>> {
791        let layout = self.loop_layout.filter(|layout| layout.runway_ready)?;
792        let mut target = self.snap_target_for(next)?;
793        let cycle = if current + 1 == self.item_count && next == 0 {
794            -layout.cycle_extent
795        } else if current == 0 && next + 1 == self.item_count {
796            layout.cycle_extent
797        } else {
798            return None;
799        };
800        let primary = self.primary_offset(target) + cycle;
801        self.set_primary_offset(&mut target, primary);
802        Some(target)
803    }
804
805    fn update_loop_layout(&mut self) {
806        let previous = self.loop_layout;
807        let next_metrics = self.measured_cycle_metrics().filter(|(extent, _)| {
808            self.looping
809                && self.item_count > 1
810                && *extent > px(0.)
811                && self
812                    .geometry
813                    .viewport
814                    .is_some_and(|viewport| *extent >= viewport.size.along(self.axis))
815        });
816
817        let Some((next_extent, next_gap)) = next_metrics else {
818            if let Some(previous) = previous.filter(|layout| layout.runway_ready) {
819                self.shift_scroll_coordinate(previous.runway_extent);
820                self.motion_revision = self.motion_revision.wrapping_add(1);
821            }
822            if previous.is_some() {
823                self.loop_layout_removal_pending = self.geometry_has_runway;
824            } else if self.loop_layout_removal_pending && !self.geometry_has_runway {
825                self.loop_layout_removal_pending = false;
826            }
827            self.loop_layout = None;
828            self.loop_motion_target = None;
829            return;
830        };
831
832        let same_extent = previous.is_some_and(|layout| {
833            (layout.cycle_extent - next_extent).abs() <= px(0.5)
834                && (layout.track_gap - next_gap).abs() <= px(0.5)
835        });
836        if !same_extent {
837            if let Some(previous) = previous.filter(|layout| layout.runway_ready) {
838                self.shift_scroll_coordinate(previous.runway_extent);
839                self.pointer_gesture = None;
840                self.scroll_gesture = None;
841                self.loop_motion_target = None;
842                self.motion_revision = self.motion_revision.wrapping_add(1);
843            }
844            self.loop_layout_removal_pending = false;
845            self.loop_layout = Some(LoopLayout {
846                cycle_extent: next_extent,
847                track_gap: next_gap,
848                runway_extent: next_extent + next_gap,
849                runway_ready: false,
850            });
851            return;
852        }
853
854        let Some(mut layout) = previous else {
855            return;
856        };
857        if !layout.runway_ready && self.geometry_has_runway {
858            self.shift_scroll_coordinate(-layout.runway_extent);
859            layout.runway_ready = true;
860            self.loop_layout = Some(layout);
861            self.motion_revision = self.motion_revision.wrapping_add(1);
862        }
863    }
864
865    fn measured_cycle_metrics(&self) -> Option<(Pixels, Pixels)> {
866        let first = *self.geometry.items.first()?;
867        let last = *self.geometry.items.last()?;
868        let gap = self
869            .geometry
870            .items
871            .get(1)
872            .map(|second| (self.primary_start(*second) - self.primary_end(first)).max(px(0.)))
873            .unwrap_or(px(0.));
874        Some((
875            (self.primary_end(last) - self.primary_start(first) + gap).max(px(0.)),
876            gap,
877        ))
878    }
879
880    fn shift_scroll_coordinate(&mut self, delta: Pixels) {
881        let mut offset = self.scroll_handle.offset();
882        let primary = self.primary_offset(offset) + delta;
883        self.set_primary_offset(&mut offset, primary);
884        self.scroll_handle.set_offset(offset);
885        if let Some(gesture) = self.pointer_gesture.as_mut() {
886            Self::shift_point_for_axis(&mut gesture.start_offset, self.axis, delta);
887        }
888        if let Some(gesture) = self.scroll_gesture.as_mut() {
889            Self::shift_point_for_axis(&mut gesture.start_offset, self.axis, delta);
890        }
891        if let Some(target) = self.loop_motion_target.as_mut() {
892            Self::shift_point_for_axis(target, self.axis, delta);
893        }
894    }
895
896    fn rebase_pending_loop_motion(&mut self) -> bool {
897        let Some(virtual_target) = self.loop_motion_target else {
898            return false;
899        };
900        let Some(real_target) = self
901            .selected_index
902            .and_then(|index| self.snap_target_for(index))
903        else {
904            self.loop_motion_target = None;
905            return false;
906        };
907        let delta = self.primary_offset(real_target) - self.primary_offset(virtual_target);
908        self.shift_scroll_coordinate(delta);
909        self.loop_motion_target = None;
910        self.motion_revision = self.motion_revision.wrapping_add(1);
911        true
912    }
913
914    /// Keeps an active pointer or trackpad gesture inside the middle runway.
915    /// Moving by one full cycle is visually identical because every item is
916    /// painted in the closest cycle, so the gesture snapshots move with the
917    /// handle and continuous input never reaches the finite runway edge.
918    fn normalize_loop_coordinate(&mut self) -> bool {
919        let Some(layout) = self
920            .loop_layout
921            .filter(|layout| layout.runway_ready && layout.cycle_extent > px(0.))
922        else {
923            return false;
924        };
925        let Some(first) = self.snap_target_for(0) else {
926            return false;
927        };
928        let Some(last_ix) = self.item_count.checked_sub(1) else {
929            return false;
930        };
931        let Some(last) = self.snap_target_for(last_ix) else {
932            return false;
933        };
934
935        let first = self.primary_offset(first);
936        let last = self.primary_offset(last);
937        let mut current = self.primary_offset(self.scroll_handle.offset());
938        let mut delta = px(0.);
939        while current <= first - layout.cycle_extent {
940            current += layout.cycle_extent;
941            delta += layout.cycle_extent;
942        }
943        while current >= last + layout.cycle_extent {
944            current -= layout.cycle_extent;
945            delta -= layout.cycle_extent;
946        }
947        if delta == px(0.) {
948            return false;
949        }
950        self.shift_scroll_coordinate(delta);
951        true
952    }
953
954    fn shift_point_for_axis(point: &mut Point<Pixels>, axis: Axis, delta: Pixels) {
955        match axis {
956            Axis::Horizontal => point.x += delta,
957            Axis::Vertical => point.y += delta,
958        }
959    }
960
961    fn cancel_interactions(&mut self, cx: &mut Context<Self>) {
962        let cancelled_scroll = self.scroll_gesture.is_some();
963        self.pointer_gesture = None;
964        self.scroll_gesture = None;
965        self.invalidate_scroll_settle();
966        if cancelled_scroll {
967            self.ignore_scroll_until_quiet = true;
968            self.schedule_ignored_scroll_recovery(cx);
969        }
970    }
971
972    fn cancel_user_interaction(&mut self, cx: &mut Context<Self>) {
973        let was_interacting = self.is_interacting();
974        self.cancel_interactions(cx);
975        if was_interacting {
976            cx.notify();
977        }
978    }
979
980    fn begin_drag_snapshot(&mut self, position: Point<Pixels>) -> bool {
981        self.suppress_pointer_click = false;
982        if self.item_count < 2 {
983            return false;
984        }
985        self.rebase_pending_loop_motion();
986        self.pointer_gesture = Some(PointerGesture {
987            start_position: position,
988            start_offset: self.scroll_handle.offset(),
989            start_index: self.selected_index,
990            total_delta: px(0.),
991            axis_locked: false,
992        });
993        if self.scroll_gesture.is_some() {
994            self.ignore_scroll_until_quiet = true;
995        }
996        self.scroll_gesture = None;
997        self.invalidate_scroll_settle();
998        true
999    }
1000
1001    fn schedule_scroll_settle(&mut self, cx: &mut Context<Self>) {
1002        self.scroll_settle_epoch = self.scroll_settle_epoch.wrapping_add(1);
1003        let epoch = self.scroll_settle_epoch;
1004        cx.spawn(async move |this, cx| {
1005            cx.background_executor()
1006                .timer(SCROLL_EVENT_SEPARATION)
1007                .await;
1008            if let Some(this) = this.upgrade() {
1009                this.update(cx, |state, cx| {
1010                    if state.scroll_settle_epoch == epoch && state.scroll_gesture.is_some() {
1011                        state.finish_scroll(false, cx);
1012                    }
1013                });
1014            }
1015        })
1016        .detach();
1017    }
1018
1019    fn schedule_ignored_scroll_recovery(&mut self, cx: &mut Context<Self>) {
1020        self.scroll_settle_epoch = self.scroll_settle_epoch.wrapping_add(1);
1021        let epoch = self.scroll_settle_epoch;
1022        cx.spawn(async move |this, cx| {
1023            cx.background_executor()
1024                .timer(SCROLL_EVENT_SEPARATION)
1025                .await;
1026            if let Some(this) = this.upgrade() {
1027                this.update(cx, |state, _| {
1028                    if state.scroll_settle_epoch == epoch {
1029                        state.ignore_scroll_until_quiet = false;
1030                    }
1031                });
1032            }
1033        })
1034        .detach();
1035    }
1036
1037    fn schedule_wheel_burst_end(&mut self, cx: &mut Context<Self>) {
1038        self.wheel_burst_epoch = self.wheel_burst_epoch.wrapping_add(1);
1039        let epoch = self.wheel_burst_epoch;
1040        cx.spawn(async move |this, cx| {
1041            cx.background_executor()
1042                .timer(SCROLL_EVENT_SEPARATION)
1043                .await;
1044            if let Some(this) = this.upgrade() {
1045                this.update(cx, |state, _| {
1046                    if state.wheel_burst_epoch == epoch {
1047                        state.wheel_burst_active = false;
1048                    }
1049                });
1050            }
1051        })
1052        .detach();
1053    }
1054
1055    fn invalidate_scroll_settle(&mut self) {
1056        self.scroll_settle_epoch = self.scroll_settle_epoch.wrapping_add(1);
1057    }
1058
1059    fn finish_snapshot(
1060        &mut self,
1061        start_offset: Point<Pixels>,
1062        start_index: Option<usize>,
1063        total_delta: Pixels,
1064        cx: &mut Context<Self>,
1065    ) -> bool {
1066        let current_offset = self.scroll_handle.offset();
1067        let selected = if self.runtime_looping() {
1068            self.loop_boundary_index(start_index, total_delta)
1069                .or_else(|| self.nearest_index(current_offset))
1070        } else {
1071            self.nearest_index(current_offset)
1072        };
1073
1074        let changed = selected.is_some_and(|index| {
1075            if self.selected_index == Some(index) {
1076                false
1077            } else {
1078                let wrapped = self
1079                    .selected_index
1080                    .zip(selected)
1081                    .is_some_and(|(current, next)| self.is_loop_wrap(current, next));
1082                self.select_index_with_wrap(index, wrapped, cx)
1083            }
1084        });
1085
1086        if selected.is_none() {
1087            self.scroll_handle.set_offset(start_offset);
1088        }
1089        if !changed {
1090            cx.notify();
1091        }
1092        changed
1093    }
1094
1095    fn loop_boundary_index(
1096        &self,
1097        start_index: Option<usize>,
1098        total_delta: Pixels,
1099    ) -> Option<usize> {
1100        let start_index = start_index?;
1101        let threshold = self.snap_extent() * 0.25;
1102        if total_delta.abs() < threshold.max(px(1.)) {
1103            return None;
1104        }
1105        if start_index == 0 && total_delta > px(0.) {
1106            self.item_count.checked_sub(1)
1107        } else if start_index + 1 == self.item_count && total_delta < px(0.) {
1108            Some(0)
1109        } else {
1110            None
1111        }
1112    }
1113
1114    fn snap_extent(&self) -> Pixels {
1115        self.geometry
1116            .viewport
1117            .map(|bounds| bounds.size.along(self.axis))
1118            .or_else(|| {
1119                self.geometry
1120                    .items
1121                    .first()
1122                    .map(|bounds| bounds.size.along(self.axis))
1123            })
1124            .unwrap_or(px(1.))
1125    }
1126
1127    fn snap_offset(&self, viewport: Bounds<Pixels>, item: Bounds<Pixels>) -> Point<Pixels> {
1128        let mut offset = self.scroll_handle.offset();
1129        let target = match self.axis {
1130            Axis::Horizontal => viewport.left() - item.left(),
1131            Axis::Vertical => viewport.top() - item.top(),
1132        };
1133        let target = if let Some(layout) = self.loop_layout.filter(|layout| layout.runway_ready) {
1134            let content_inset = self
1135                .geometry
1136                .items
1137                .first()
1138                .map(|first| {
1139                    self.primary_start(*first) - self.primary_start(viewport) - layout.runway_extent
1140                })
1141                .unwrap_or(px(0.));
1142            target + content_inset
1143        } else {
1144            target.clamp(-self.max_snap_offset(), px(0.))
1145        };
1146        self.set_primary_offset(&mut offset, target);
1147        offset
1148    }
1149
1150    fn max_snap_offset(&self) -> Pixels {
1151        let handle_max = self
1152            .primary_offset(self.scroll_handle.max_offset())
1153            .max(px(0.));
1154        let Some(viewport) = self.geometry.viewport else {
1155            return handle_max;
1156        };
1157        let Some(first) = self.geometry.items.first() else {
1158            return handle_max;
1159        };
1160
1161        let (mut content_start, mut content_end) = match self.axis {
1162            Axis::Horizontal => (first.left(), first.right()),
1163            Axis::Vertical => (first.top(), first.bottom()),
1164        };
1165        for item in &self.geometry.items[1..] {
1166            let (start, end) = match self.axis {
1167                Axis::Horizontal => (item.left(), item.right()),
1168                Axis::Vertical => (item.top(), item.bottom()),
1169            };
1170            content_start = content_start.min(start);
1171            content_end = content_end.max(end);
1172        }
1173        let geometry_max =
1174            (content_end - content_start - viewport.size.along(self.axis)).max(px(0.));
1175        handle_max.max(geometry_max)
1176    }
1177
1178    fn primary_distance(
1179        &self,
1180        offset: Point<Pixels>,
1181        viewport: Bounds<Pixels>,
1182        item: Bounds<Pixels>,
1183    ) -> Pixels {
1184        let target = self.snap_offset(viewport, item);
1185        (self.primary_offset(offset) - self.primary_offset(target)).abs()
1186    }
1187
1188    fn looping_distance(
1189        &self,
1190        offset: Point<Pixels>,
1191        viewport: Bounds<Pixels>,
1192        item: Bounds<Pixels>,
1193    ) -> Pixels {
1194        let distance = self.primary_distance(offset, viewport, item);
1195        let Some(layout) = self.loop_layout.filter(|layout| layout.runway_ready) else {
1196            return distance;
1197        };
1198        let target = self.primary_offset(self.snap_offset(viewport, item));
1199        let offset = self.primary_offset(offset);
1200        distance
1201            .min((offset - (target - layout.cycle_extent)).abs())
1202            .min((offset - (target + layout.cycle_extent)).abs())
1203    }
1204
1205    fn primary_offset(&self, offset: Point<Pixels>) -> Pixels {
1206        match self.axis {
1207            Axis::Horizontal => offset.x,
1208            Axis::Vertical => offset.y,
1209        }
1210    }
1211
1212    fn primary_start(&self, bounds: Bounds<Pixels>) -> Pixels {
1213        match self.axis {
1214            Axis::Horizontal => bounds.left(),
1215            Axis::Vertical => bounds.top(),
1216        }
1217    }
1218
1219    fn primary_end(&self, bounds: Bounds<Pixels>) -> Pixels {
1220        match self.axis {
1221            Axis::Horizontal => bounds.right(),
1222            Axis::Vertical => bounds.bottom(),
1223        }
1224    }
1225
1226    fn axis_point(&self, value: Pixels) -> Point<Pixels> {
1227        match self.axis {
1228            Axis::Horizontal => Point::new(value, px(0.)),
1229            Axis::Vertical => Point::new(px(0.), value),
1230        }
1231    }
1232
1233    fn set_primary_offset(&self, offset: &mut Point<Pixels>, value: Pixels) {
1234        match self.axis {
1235            Axis::Horizontal => offset.x = value,
1236            Axis::Vertical => offset.y = value,
1237        }
1238    }
1239
1240    fn primary_delta(&self, delta: Point<Pixels>) -> Pixels {
1241        match self.axis {
1242            Axis::Horizontal => delta.x,
1243            Axis::Vertical => delta.y,
1244        }
1245    }
1246
1247    fn cross_axis_delta(&self, delta: Point<Pixels>) -> Pixels {
1248        match self.axis {
1249            Axis::Horizontal => delta.y,
1250            Axis::Vertical => delta.x,
1251        }
1252    }
1253
1254    fn clamped_offset(&self, value: Pixels) -> Pixels {
1255        let max_offset = self.scroll_handle.max_offset();
1256        let bound = self.primary_offset(max_offset).max(px(0.));
1257        value.clamp(-bound, px(0.))
1258    }
1259}
1260
1261impl EventEmitter<CarouselEvent> for CarouselState {}
1262
1263impl Focusable for CarouselState {
1264    /// The keyboard focus shared by every part: the root tracks it, and a
1265    /// clicked control moves focus here so the arrow keys keep working.
1266    fn focus_handle(&self, cx: &App) -> FocusHandle {
1267        self.focus_handle.get_or_init(|| cx.focus_handle()).clone()
1268    }
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273    use std::{cell::RefCell, rc::Rc};
1274
1275    use gpui::{AppContext as _, TestAppContext, point, px};
1276
1277    use super::*;
1278
1279    #[test]
1280    fn constructors_and_programmatic_setters_clamp_without_events() {
1281        let state = CarouselState::new(3)
1282            .with_selected_index(99)
1283            .with_axis(Axis::Vertical)
1284            .with_looping(true);
1285        assert_eq!(state.item_count(), 3);
1286        assert_eq!(state.selected_index(), Some(2));
1287        assert_eq!(state.axis(), Axis::Vertical);
1288        assert!(state.is_looping());
1289        assert!(state.has_previous());
1290        assert!(state.has_next());
1291
1292        let empty = CarouselState::new(0);
1293        assert_eq!(empty.selected_index(), None);
1294        assert!(!empty.has_previous());
1295        assert!(!empty.has_next());
1296    }
1297
1298    #[test]
1299    fn geometry_produces_axis_specific_snap_points() {
1300        let mut state = CarouselState::new(2).with_axis(Axis::Horizontal);
1301        state.set_geometry(
1302            Bounds::new(point(px(10.), px(20.)), gpui::size(px(100.), px(40.))),
1303            vec![
1304                Bounds::new(point(px(10.), px(20.)), gpui::size(px(100.), px(40.))),
1305                Bounds::new(point(px(110.), px(20.)), gpui::size(px(100.), px(40.))),
1306            ],
1307        );
1308        assert_eq!(state.snap_target_for(0), Some(point(px(0.), px(0.))));
1309        assert_eq!(state.snap_target_for(1), Some(point(px(-100.), px(0.))));
1310        let mut vertical = CarouselState::new(2).with_axis(Axis::Vertical);
1311        vertical.set_geometry(
1312            Bounds::new(point(px(10.), px(20.)), gpui::size(px(40.), px(100.))),
1313            vec![
1314                Bounds::new(point(px(10.), px(20.)), gpui::size(px(40.), px(100.))),
1315                Bounds::new(point(px(10.), px(120.)), gpui::size(px(40.), px(100.))),
1316            ],
1317        );
1318        assert_eq!(vertical.snap_target_for(1), Some(point(px(0.), px(-100.))));
1319
1320        let mut narrow = CarouselState::new(3);
1321        narrow.set_geometry(
1322            Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1323            vec![
1324                Bounds::new(point(px(0.), px(0.)), gpui::size(px(50.), px(40.))),
1325                Bounds::new(point(px(50.), px(0.)), gpui::size(px(50.), px(40.))),
1326                Bounds::new(point(px(100.), px(0.)), gpui::size(px(50.), px(40.))),
1327            ],
1328        );
1329        assert_eq!(narrow.snap_target_for(2), Some(point(px(-50.), px(0.))));
1330    }
1331
1332    #[test]
1333    fn nearest_index_keeps_the_first_trailing_duplicate_as_canonical() {
1334        let mut state = CarouselState::new(3);
1335        state.set_geometry(
1336            Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1337            vec![
1338                Bounds::new(point(px(0.), px(0.)), gpui::size(px(50.), px(40.))),
1339                Bounds::new(point(px(50.), px(0.)), gpui::size(px(50.), px(40.))),
1340                Bounds::new(point(px(100.), px(0.)), gpui::size(px(50.), px(40.))),
1341            ],
1342        );
1343
1344        assert_eq!(state.snap_target_for(1), Some(point(px(-50.), px(0.))));
1345        assert_eq!(state.snap_target_for(2), Some(point(px(-50.), px(0.))));
1346        assert_eq!(state.nearest_index(point(px(-50.), px(0.))), Some(1));
1347    }
1348
1349    #[gpui::test]
1350    fn geometry_navigation_skips_duplicate_snap_points(cx: &mut TestAppContext) {
1351        let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1352
1353        cx.update(|cx| {
1354            state.update(cx, |state, cx| {
1355                state.set_geometry(
1356                    Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1357                    vec![
1358                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(50.), px(40.))),
1359                        Bounds::new(point(px(50.), px(0.)), gpui::size(px(50.), px(40.))),
1360                        Bounds::new(point(px(100.), px(0.)), gpui::size(px(50.), px(40.))),
1361                    ],
1362                );
1363                state.set_selected_index(1, cx);
1364                assert!(!state.has_next());
1365                assert!(state.has_previous());
1366
1367                assert!(state.select_previous(cx));
1368                assert_eq!(state.selected_index(), Some(0));
1369                assert!(state.select_next(cx));
1370                assert_eq!(state.selected_index(), Some(1));
1371
1372                state.set_selected_index(2, cx);
1373                assert!(!state.has_next());
1374                assert!(state.select_previous(cx));
1375                assert_eq!(state.selected_index(), Some(0));
1376            });
1377        });
1378    }
1379
1380    #[gpui::test]
1381    fn non_looping_navigation_stops_at_both_boundaries(cx: &mut TestAppContext) {
1382        let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1383        let events = Rc::new(RefCell::new(Vec::new()));
1384        let _subscription = cx.update(|cx| {
1385            let events = events.clone();
1386            cx.subscribe(&state, move |_, event: &CarouselEvent, _| {
1387                let CarouselEvent::Change(index) = event;
1388                events.borrow_mut().push(*index);
1389            })
1390        });
1391
1392        cx.update(|cx| {
1393            state.update(cx, |state, cx| {
1394                assert!(!state.select_previous(cx));
1395                assert!(!state.select_first(cx));
1396                assert!(state.select_last(cx));
1397                assert!(!state.select_next(cx));
1398                assert!(!state.select_last(cx));
1399            });
1400        });
1401
1402        assert_eq!(events.borrow().as_slice(), &[2]);
1403    }
1404
1405    #[gpui::test]
1406    fn user_navigation_emits_once_and_programmatic_changes_stay_silent(cx: &mut TestAppContext) {
1407        let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1408        let events = Rc::new(RefCell::new(Vec::new()));
1409        let _subscription = cx.update(|cx| {
1410            let events = events.clone();
1411            cx.subscribe(&state, move |_, event: &CarouselEvent, _| {
1412                let CarouselEvent::Change(index) = event;
1413                events.borrow_mut().push(*index);
1414            })
1415        });
1416
1417        cx.update(|cx| {
1418            state.update(cx, |state, cx| {
1419                assert!(state.select_next(cx));
1420                assert!(!state.select_index(1, cx));
1421                assert!(!state.select_index(99, cx));
1422                state.set_selected_index(2, cx);
1423                state.set_axis(Axis::Vertical, cx);
1424                state.set_looping(true, cx);
1425            });
1426        });
1427
1428        assert_eq!(events.borrow().as_slice(), &[1]);
1429        assert_eq!(
1430            state.read_with(cx, |state, _| state.selected_index()),
1431            Some(2)
1432        );
1433        assert_eq!(state.read_with(cx, |state, _| state.axis()), Axis::Vertical);
1434        assert!(state.read_with(cx, |state, _| state.is_looping()));
1435
1436        let empty = cx.update(|cx| cx.new(|_| CarouselState::new(0)));
1437        cx.update(|cx| {
1438            empty.update(cx, |state, cx| {
1439                state.set_item_count(2, cx);
1440                state.set_selected_index(99, cx);
1441            });
1442        });
1443        assert_eq!(
1444            empty.read_with(cx, |state, _| state.selected_index()),
1445            Some(1)
1446        );
1447    }
1448
1449    #[gpui::test]
1450    fn pointer_drag_locks_to_the_primary_axis(cx: &mut TestAppContext) {
1451        let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1452
1453        cx.update(|cx| {
1454            state.update(cx, |state, cx| {
1455                assert!(state.begin_drag(point(px(0.), px(0.)), cx));
1456                assert!(!state.update_drag(point(px(1.), px(1.)), cx));
1457                assert!(state.is_interacting());
1458                assert!(!state.is_pointer_drag_locked());
1459                assert!(!state.update_drag(point(px(4.), px(20.)), cx));
1460                assert!(!state.is_interacting());
1461                assert!(state.should_suppress_pointer_click());
1462                assert!(!state.finish_drag(cx));
1463                assert!(!state.should_suppress_pointer_click());
1464
1465                assert!(state.begin_drag(point(px(0.), px(0.)), cx));
1466                assert!(state.update_drag(point(px(20.), px(4.)), cx));
1467                assert!(state.is_interacting());
1468                assert!(state.is_pointer_drag_locked());
1469                state.finish_drag(cx);
1470                assert!(!state.is_interacting());
1471            });
1472        });
1473    }
1474
1475    #[gpui::test]
1476    fn finishing_a_drag_keeps_the_offset_as_the_snap_animation_origin(cx: &mut TestAppContext) {
1477        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
1478
1479        cx.update(|cx| {
1480            state.update(cx, |state, cx| {
1481                state.set_geometry(
1482                    Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1483                    vec![
1484                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1485                        Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))),
1486                    ],
1487                );
1488                assert!(state.begin_drag(point(px(0.), px(0.)), cx));
1489                state.scroll_handle.set_offset(point(px(-60.), px(0.)));
1490                assert!(state.finish_drag(cx));
1491                assert_eq!(state.selected_index(), Some(1));
1492                assert_eq!(state.scroll_handle.offset(), point(px(-60.), px(0.)));
1493            });
1494        });
1495    }
1496
1497    #[gpui::test]
1498    fn user_navigation_invalidates_an_active_trackpad_gesture(cx: &mut TestAppContext) {
1499        let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1500        let events = Rc::new(RefCell::new(Vec::new()));
1501        let _subscription = cx.update(|cx| {
1502            let events = events.clone();
1503            cx.subscribe(&state, move |_, event: &CarouselEvent, _| {
1504                let CarouselEvent::Change(index) = event;
1505                events.borrow_mut().push(*index);
1506            })
1507        });
1508
1509        cx.update(|cx| {
1510            state.update(cx, |state, cx| {
1511                state.handle_scroll_delta(Axis::Horizontal, px(-20.), TouchPhase::Started, cx);
1512                assert!(state.is_interacting());
1513                assert!(state.select_next(cx));
1514                assert!(!state.is_interacting());
1515                assert!(!state.handle_scroll_delta(
1516                    Axis::Horizontal,
1517                    px(-20.),
1518                    TouchPhase::Moved,
1519                    cx,
1520                ));
1521                assert!(!state.is_interacting());
1522                assert!(state.ignore_scroll_until_quiet);
1523            });
1524        });
1525
1526        cx.run_until_parked();
1527        cx.executor().advance_clock(SCROLL_EVENT_SEPARATION);
1528        cx.run_until_parked();
1529
1530        cx.update(|cx| {
1531            state.update(cx, |state, cx| {
1532                assert!(!state.ignore_scroll_until_quiet);
1533                assert!(!state.handle_scroll_delta(
1534                    Axis::Horizontal,
1535                    px(-20.),
1536                    TouchPhase::Moved,
1537                    cx,
1538                ));
1539                assert!(state.is_interacting());
1540                assert!(!state.finish_scroll(true, cx));
1541                assert!(!state.is_interacting());
1542
1543                assert!(!state.handle_scroll_delta(
1544                    Axis::Horizontal,
1545                    px(-20.),
1546                    TouchPhase::Started,
1547                    cx,
1548                ));
1549                assert!(state.is_interacting());
1550                assert!(!state.finish_scroll(true, cx));
1551                assert!(!state.is_interacting());
1552            });
1553        });
1554
1555        assert_eq!(events.borrow().as_slice(), &[1]);
1556    }
1557
1558    #[gpui::test]
1559    fn moved_only_trackpad_gesture_settles_after_the_quiet_period(cx: &mut TestAppContext) {
1560        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
1561        let events = Rc::new(RefCell::new(Vec::new()));
1562        let _subscription = cx.update(|cx| {
1563            let events = events.clone();
1564            cx.subscribe(&state, move |_, event: &CarouselEvent, _| {
1565                let CarouselEvent::Change(index) = event;
1566                events.borrow_mut().push(*index);
1567            })
1568        });
1569
1570        cx.update(|cx| {
1571            state.update(cx, |state, cx| {
1572                state.set_geometry(
1573                    Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1574                    vec![
1575                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1576                        Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))),
1577                    ],
1578                );
1579                state.handle_scroll_delta(Axis::Horizontal, px(-60.), TouchPhase::Moved, cx);
1580                state.scroll_handle.set_offset(point(px(-60.), px(0.)));
1581                assert!(state.is_interacting());
1582            });
1583        });
1584
1585        cx.run_until_parked();
1586        cx.executor().advance_clock(SCROLL_EVENT_SEPARATION);
1587        cx.run_until_parked();
1588
1589        assert_eq!(
1590            state.read_with(cx, |state, _| state.selected_index()),
1591            Some(1)
1592        );
1593        assert!(!state.read_with(cx, |state, _| state.is_interacting()));
1594        assert_eq!(events.borrow().as_slice(), &[1]);
1595    }
1596
1597    #[gpui::test]
1598    fn invalid_boundary_navigation_still_cancels_the_active_gesture(cx: &mut TestAppContext) {
1599        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
1600
1601        cx.update(|cx| {
1602            state.update(cx, |state, cx| {
1603                state.handle_scroll_delta(Axis::Horizontal, px(20.), TouchPhase::Started, cx);
1604                assert!(state.is_interacting());
1605                assert!(!state.select_previous(cx));
1606                assert!(!state.is_interacting());
1607                assert!(!state.handle_scroll_delta(
1608                    Axis::Horizontal,
1609                    px(20.),
1610                    TouchPhase::Moved,
1611                    cx,
1612                ));
1613                assert!(!state.finish_scroll(false, cx));
1614
1615                assert!(state.select_last(cx));
1616                state.handle_scroll_delta(Axis::Horizontal, px(-20.), TouchPhase::Started, cx);
1617                assert!(state.is_interacting());
1618                assert!(!state.select_next(cx));
1619                assert!(!state.is_interacting());
1620                assert!(!state.finish_scroll(false, cx));
1621            });
1622        });
1623    }
1624
1625    #[gpui::test]
1626    fn looping_boundaries_emit_one_event_and_advance_motion_revision(cx: &mut TestAppContext) {
1627        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true)));
1628        let events = Rc::new(RefCell::new(Vec::new()));
1629        let _subscription = cx.update(|cx| {
1630            let events = events.clone();
1631            cx.subscribe(&state, move |_, event: &CarouselEvent, _| {
1632                let CarouselEvent::Change(index) = event;
1633                events.borrow_mut().push(*index);
1634            })
1635        });
1636
1637        cx.update(|cx| {
1638            state.update(cx, |state, cx| {
1639                assert!(state.select_previous(cx));
1640                assert_eq!(state.selected_index(), Some(1));
1641                assert_eq!(state.motion_revision(), 1);
1642                assert!(state.select_next(cx));
1643                assert_eq!(state.selected_index(), Some(0));
1644                assert_eq!(state.motion_revision(), 2);
1645            });
1646        });
1647        assert_eq!(events.borrow().as_slice(), &[1, 0]);
1648    }
1649
1650    #[gpui::test]
1651    fn looping_uses_adjacent_cycle_targets_and_rebases_without_an_extra_event(
1652        cx: &mut TestAppContext,
1653    ) {
1654        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true)));
1655        let events = Rc::new(RefCell::new(Vec::new()));
1656        let _subscription = cx.update(|cx| {
1657            let events = events.clone();
1658            cx.subscribe(&state, move |_, event: &CarouselEvent, _| {
1659                let CarouselEvent::Change(index) = event;
1660                events.borrow_mut().push(*index);
1661            })
1662        });
1663
1664        cx.update(|cx| {
1665            state.update(cx, |state, cx| {
1666                let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.)));
1667                state.set_geometry(
1668                    viewport,
1669                    vec![
1670                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1671                        Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))),
1672                    ],
1673                );
1674                assert_eq!(state.loop_runway(), Some(px(200.)));
1675
1676                state.set_geometry(
1677                    viewport,
1678                    vec![
1679                        Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))),
1680                        Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))),
1681                    ],
1682                );
1683                assert_eq!(state.scroll_handle.offset(), point(px(-200.), px(0.)));
1684
1685                assert!(state.select_previous(cx));
1686                assert_eq!(state.selected_index(), Some(1));
1687                let previous_target = state.motion_target_for(1).unwrap();
1688                assert_eq!(previous_target, point(px(-100.), px(0.)));
1689                assert_eq!(previous_target.x - state.scroll_handle.offset().x, px(100.));
1690                assert_eq!(
1691                    state.settle_loop_motion(previous_target, cx),
1692                    Some(point(px(-300.), px(0.)))
1693                );
1694
1695                assert!(state.select_next(cx));
1696                assert_eq!(state.selected_index(), Some(0));
1697                let next_target = state.motion_target_for(0).unwrap();
1698                assert_eq!(next_target, point(px(-400.), px(0.)));
1699                assert_eq!(next_target.x - state.scroll_handle.offset().x, px(-100.));
1700                assert_eq!(
1701                    state.settle_loop_motion(next_target, cx),
1702                    Some(point(px(-200.), px(0.)))
1703                );
1704            });
1705        });
1706
1707        assert_eq!(events.borrow().as_slice(), &[1, 0]);
1708    }
1709
1710    #[gpui::test]
1711    fn programmatic_loop_wrap_uses_the_adjacent_cycle_without_emitting(cx: &mut TestAppContext) {
1712        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true)));
1713        let events = Rc::new(RefCell::new(Vec::new()));
1714        let _subscription = cx.update(|cx| {
1715            let events = events.clone();
1716            cx.subscribe(&state, move |_, event: &CarouselEvent, _| {
1717                let CarouselEvent::Change(index) = event;
1718                events.borrow_mut().push(*index);
1719            })
1720        });
1721
1722        cx.update(|cx| {
1723            state.update(cx, |state, cx| {
1724                let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.)));
1725                state.set_geometry(
1726                    viewport,
1727                    vec![
1728                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1729                        Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))),
1730                    ],
1731                );
1732                state.set_geometry(
1733                    viewport,
1734                    vec![
1735                        Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))),
1736                        Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))),
1737                    ],
1738                );
1739
1740                state.set_selected_index(1, cx);
1741                state
1742                    .scroll_handle
1743                    .set_offset(state.snap_target_for(1).unwrap());
1744                state.set_selected_index(0, cx);
1745
1746                assert_eq!(state.selected_index(), Some(0));
1747                assert_eq!(state.motion_target_for(0), Some(point(px(-400.), px(0.))));
1748                assert_eq!(
1749                    state.settle_loop_motion(point(px(-400.), px(0.)), cx),
1750                    Some(point(px(-200.), px(0.)))
1751                );
1752            });
1753        });
1754
1755        assert!(events.borrow().is_empty());
1756    }
1757
1758    #[gpui::test]
1759    fn vertical_loop_wrap_uses_the_adjacent_cycle(cx: &mut TestAppContext) {
1760        let state = cx.update(|cx| {
1761            cx.new(|_| {
1762                CarouselState::new(2)
1763                    .with_axis(Axis::Vertical)
1764                    .with_looping(true)
1765            })
1766        });
1767
1768        cx.update(|cx| {
1769            state.update(cx, |state, cx| {
1770                let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(40.), px(100.)));
1771                state.set_geometry(
1772                    viewport,
1773                    vec![
1774                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(40.), px(100.))),
1775                        Bounds::new(point(px(0.), px(100.)), gpui::size(px(40.), px(100.))),
1776                    ],
1777                );
1778                state.set_geometry(
1779                    viewport,
1780                    vec![
1781                        Bounds::new(point(px(0.), px(200.)), gpui::size(px(40.), px(100.))),
1782                        Bounds::new(point(px(0.), px(300.)), gpui::size(px(40.), px(100.))),
1783                    ],
1784                );
1785
1786                assert!(state.select_previous(cx));
1787                assert_eq!(state.motion_target_for(1), Some(point(px(0.), px(-100.))));
1788                assert_eq!(
1789                    state.settle_loop_motion(point(px(0.), px(-100.)), cx),
1790                    Some(point(px(0.), px(-300.)))
1791                );
1792            });
1793        });
1794    }
1795
1796    #[gpui::test]
1797    fn unequal_items_keep_the_requested_direction_across_a_loop_boundary(cx: &mut TestAppContext) {
1798        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true)));
1799
1800        cx.update(|cx| {
1801            state.update(cx, |state, cx| {
1802                let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.)));
1803                state.set_geometry(
1804                    viewport,
1805                    vec![
1806                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(80.), px(40.))),
1807                        Bounds::new(point(px(96.), px(0.)), gpui::size(px(200.), px(40.))),
1808                    ],
1809                );
1810                state.set_geometry(
1811                    viewport,
1812                    vec![
1813                        Bounds::new(point(px(328.), px(0.)), gpui::size(px(80.), px(40.))),
1814                        Bounds::new(point(px(424.), px(0.)), gpui::size(px(200.), px(40.))),
1815                    ],
1816                );
1817
1818                state.set_selected_index(1, cx);
1819                state.scroll_handle.set_offset(point(px(-424.), px(0.)));
1820                assert!(state.select_next(cx));
1821                assert_eq!(state.motion_target_for(0), Some(point(px(-640.), px(0.))));
1822                assert_eq!(
1823                    state.settle_loop_motion(point(px(-640.), px(0.)), cx),
1824                    Some(point(px(-328.), px(0.)))
1825                );
1826
1827                assert!(state.select_previous(cx));
1828                assert_eq!(state.motion_target_for(1), Some(point(px(-112.), px(0.))));
1829            });
1830        });
1831    }
1832
1833    #[gpui::test]
1834    fn active_loop_gesture_rebases_before_reaching_a_runway_edge(cx: &mut TestAppContext) {
1835        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true)));
1836
1837        cx.update(|cx| {
1838            state.update(cx, |state, cx| {
1839                let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.)));
1840                state.set_geometry(
1841                    viewport,
1842                    vec![
1843                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1844                        Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))),
1845                    ],
1846                );
1847                state.set_geometry(
1848                    viewport,
1849                    vec![
1850                        Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))),
1851                        Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))),
1852                    ],
1853                );
1854
1855                assert!(state.begin_drag(point(px(0.), px(0.)), cx));
1856                state.scroll_handle.set_offset(point(px(-420.), px(0.)));
1857                assert!(state.normalize_loop_coordinate());
1858                assert_eq!(state.scroll_handle.offset(), point(px(-220.), px(0.)));
1859                assert_eq!(
1860                    state.pointer_gesture.map(|gesture| gesture.start_offset),
1861                    Some(point(px(0.), px(0.)))
1862                );
1863
1864                assert_eq!(state.nearest_index(point(px(-110.), px(0.))), Some(1));
1865                assert_eq!(state.nearest_index(point(px(-390.), px(0.))), Some(0));
1866            });
1867        });
1868    }
1869
1870    #[test]
1871    fn looping_falls_back_when_one_cycle_cannot_cover_the_viewport() {
1872        let mut state = CarouselState::new(2).with_looping(true);
1873        state.set_geometry(
1874            Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1875            vec![
1876                Bounds::new(point(px(0.), px(0.)), gpui::size(px(40.), px(40.))),
1877                Bounds::new(point(px(40.), px(0.)), gpui::size(px(40.), px(40.))),
1878            ],
1879        );
1880
1881        assert_eq!(state.loop_runway(), None);
1882        assert_eq!(state.loop_item_offset(0), Point::default());
1883        assert!(!state.has_previous());
1884        assert!(!state.has_next());
1885
1886        state.selected_index = Some(1);
1887        assert!(!state.has_previous());
1888        assert!(!state.has_next());
1889    }
1890
1891    #[test]
1892    fn loop_runway_accounts_for_the_track_gap_and_content_inset() {
1893        let mut state = CarouselState::new(2).with_looping(true);
1894        let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.)));
1895        state.set_geometry(
1896            viewport,
1897            vec![
1898                Bounds::new(point(px(16.), px(0.)), gpui::size(px(100.), px(40.))),
1899                Bounds::new(point(px(132.), px(0.)), gpui::size(px(100.), px(40.))),
1900            ],
1901        );
1902
1903        assert_eq!(state.loop_runway(), Some(px(232.)));
1904        state.set_geometry(
1905            viewport,
1906            vec![
1907                Bounds::new(point(px(264.), px(0.)), gpui::size(px(100.), px(40.))),
1908                Bounds::new(point(px(380.), px(0.)), gpui::size(px(100.), px(40.))),
1909            ],
1910        );
1911        assert_eq!(state.scroll_handle.offset(), point(px(-248.), px(0.)));
1912        assert_eq!(state.snap_target_for(0), Some(point(px(-248.), px(0.))));
1913        assert_eq!(state.snap_target_for(1), Some(point(px(-364.), px(0.))));
1914    }
1915
1916    #[gpui::test]
1917    fn loop_runway_resize_and_removal_preserve_the_visible_coordinate(cx: &mut TestAppContext) {
1918        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true)));
1919
1920        cx.update(|cx| {
1921            state.update(cx, |state, cx| {
1922                let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.)));
1923                state.set_geometry(
1924                    viewport,
1925                    vec![
1926                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1927                        Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))),
1928                    ],
1929                );
1930                state.set_geometry(
1931                    viewport,
1932                    vec![
1933                        Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))),
1934                        Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))),
1935                    ],
1936                );
1937
1938                state.set_geometry(
1939                    viewport,
1940                    vec![
1941                        Bounds::new(point(px(200.), px(0.)), gpui::size(px(150.), px(40.))),
1942                        Bounds::new(point(px(350.), px(0.)), gpui::size(px(150.), px(40.))),
1943                    ],
1944                );
1945                assert_eq!(state.loop_runway(), Some(px(300.)));
1946                assert!(state.is_loop_layout_transitioning());
1947                assert_eq!(state.scroll_handle.offset(), point(px(0.), px(0.)));
1948
1949                state.set_geometry(
1950                    viewport,
1951                    vec![
1952                        Bounds::new(point(px(300.), px(0.)), gpui::size(px(150.), px(40.))),
1953                        Bounds::new(point(px(450.), px(0.)), gpui::size(px(150.), px(40.))),
1954                    ],
1955                );
1956                assert!(!state.is_loop_layout_transitioning());
1957                assert_eq!(state.scroll_handle.offset(), point(px(-300.), px(0.)));
1958
1959                state.set_looping(false, cx);
1960                assert_eq!(state.loop_runway(), None);
1961                assert!(state.is_loop_layout_transitioning());
1962                assert_eq!(state.scroll_handle.offset(), point(px(0.), px(0.)));
1963                state.set_geometry(
1964                    viewport,
1965                    vec![
1966                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(150.), px(40.))),
1967                        Bounds::new(point(px(150.), px(0.)), gpui::size(px(150.), px(40.))),
1968                    ],
1969                );
1970                assert!(!state.is_loop_layout_transitioning());
1971            });
1972        });
1973    }
1974
1975    #[gpui::test]
1976    fn laying_out_the_runway_normalizes_an_active_drag_snapshot(cx: &mut TestAppContext) {
1977        let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true)));
1978
1979        cx.update(|cx| {
1980            state.update(cx, |state, cx| {
1981                let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.)));
1982                state.set_geometry(
1983                    viewport,
1984                    vec![
1985                        Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))),
1986                        Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))),
1987                    ],
1988                );
1989                assert!(state.begin_drag(point(px(0.), px(0.)), cx));
1990                state.set_geometry(
1991                    viewport,
1992                    vec![
1993                        Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))),
1994                        Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))),
1995                    ],
1996                );
1997
1998                assert_eq!(state.scroll_handle.offset(), point(px(-200.), px(0.)));
1999                assert_eq!(
2000                    state.pointer_gesture.map(|gesture| gesture.start_offset),
2001                    Some(point(px(-200.), px(0.)))
2002                );
2003            });
2004        });
2005    }
2006}