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.);
9pub(super) const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28);
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum CarouselEvent {
16 Change(usize),
18}
19
20#[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
57pub 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 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 pub fn with_selected_index(mut self, index: usize) -> Self {
118 self.selected_index = self.clamp_index(index);
119 self
120 }
121
122 pub fn with_axis(mut self, axis: Axis) -> Self {
124 self.axis = axis;
125 self
126 }
127
128 pub fn with_looping(mut self, looping: bool) -> Self {
130 self.looping = looping;
131 self
132 }
133
134 pub fn item_count(&self) -> usize {
136 self.item_count
137 }
138
139 pub fn selected_index(&self) -> Option<usize> {
142 self.selected_index
143 }
144
145 pub fn axis(&self) -> Axis {
147 self.axis
148 }
149
150 pub fn is_looping(&self) -> bool {
152 self.looping
153 }
154
155 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 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 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 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 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 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 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 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 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 pub fn select_first(&mut self, cx: &mut Context<Self>) -> bool {
295 self.select_index(0, cx)
296 }
297
298 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 pub(super) fn scroll_handle(&self) -> &ScrollHandle {
309 &self.scroll_handle
310 }
311
312 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 pub(super) fn is_interacting(&self) -> bool {
324 self.pointer_gesture.is_some() || self.scroll_gesture.is_some()
325 }
326
327 pub(super) fn has_scroll_gesture(&self) -> bool {
329 self.scroll_gesture.is_some()
330 }
331
332 pub(super) fn is_pointer_drag_locked(&self) -> bool {
334 self.pointer_gesture
335 .is_some_and(|gesture| gesture.axis_locked)
336 }
337
338 pub(super) fn should_suppress_pointer_click(&self) -> bool {
344 self.suppress_pointer_click
345 }
346
347 pub(super) fn motion_revision(&self) -> usize {
354 self.motion_revision
355 }
356
357 pub(super) fn frame_size(&self) -> Option<gpui::Size<Pixels>> {
360 self.geometry.frame.map(|frame| frame.size)
361 }
362
363 #[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 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 pub(super) fn loop_runway(&self) -> Option<Pixels> {
390 self.loop_layout.map(|layout| layout.cycle_extent)
391 }
392
393 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}