1use crate::{
26 color::ColorRole,
27 motion::{self, MotionToken},
28 theme::color,
29 tokens::typography::{BASE_PX, TypeRole, rem},
30};
31use gpui::{prelude::*, *};
32use std::time::Duration;
33
34#[cfg(not(target_family = "wasm"))]
35use std::time::Instant;
36#[cfg(target_family = "wasm")]
37use web_time::Instant;
38
39pub const CAROUSEL_ITEM_RADIUS: Pixels = px(28.);
41pub const CAROUSEL_SMALL_ITEM: f32 = 56.;
43const GAP: f32 = 8.;
44const PAD_X: f32 = 16.;
45const PAD_Y: f32 = 8.;
46const SNAP_IDLE: Duration = Duration::from_millis(160);
48
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub enum CarouselLayout {
52 #[default]
54 MultiBrowse,
55 Uncontained,
57 Hero,
59 CenteredHero,
61 FullScreen,
63}
64
65type ChangeHandler = std::rc::Rc<dyn Fn(&usize, &mut Window, &mut App)>;
67
68#[derive(Clone, Copy, Debug, PartialEq)]
70pub struct Keyline {
71 pub x: f32,
72 pub width: f32,
73}
74
75#[derive(Clone, Debug, PartialEq)]
77pub struct Keylines {
78 pub lines: Vec<Keyline>,
79 pub focal: usize,
80 pub step: f32,
82 pub gap: f32,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct CarouselSpacing {
90 pub gap: f32,
91 pub padding: f32,
92}
93
94impl Default for CarouselSpacing {
95 fn default() -> Self {
97 Self {
98 gap: GAP,
99 padding: PAD_X,
100 }
101 }
102}
103
104fn lay_out(widths: &[f32], start: f32, gap: f32) -> Vec<Keyline> {
105 let mut x = start;
106 widths
107 .iter()
108 .map(|&width| {
109 let line = Keyline { x, width };
110 x += width + gap;
111 line
112 })
113 .collect()
114}
115
116#[cfg(test)]
119pub(crate) fn keylines(layout: CarouselLayout, width: f32, large: f32) -> Keylines {
120 spaced_keylines(layout, width, large, CarouselSpacing::default())
121}
122
123pub(crate) fn spaced_keylines(
125 layout: CarouselLayout,
126 width: f32,
127 large: f32,
128 spacing: CarouselSpacing,
129) -> Keylines {
130 let (pad, gap) = if layout == CarouselLayout::FullScreen {
131 (0., 0.)
132 } else {
133 (spacing.padding.max(0.), spacing.gap.max(0.))
134 };
135 let available = (width - 2. * pad).max(CAROUSEL_SMALL_ITEM);
136 let small = CAROUSEL_SMALL_ITEM;
137 let (widths, focal, large) = match layout {
138 CarouselLayout::FullScreen => (vec![available], 1, available),
139 CarouselLayout::Hero => {
140 let large = (available - small - gap).max(small);
141 (vec![large, small], 1, large)
142 }
143 CarouselLayout::CenteredHero => {
144 let large = (available - 2. * (small + gap)).max(small);
145 (vec![small, large, small], 1, large)
146 }
147 CarouselLayout::Uncontained => {
148 let large = large.min(available);
149 let count = ((width - pad + gap) / (large + gap)).ceil().max(1.) as usize;
152 (vec![large; count], count, large)
153 }
154 CarouselLayout::MultiBrowse => {
155 let large = large.min(available).max(small);
156 let mut count = ((available - 2. * (small + gap) + gap) / (large + gap))
159 .floor()
160 .max(1.) as usize;
161 let medium = |count: usize| available - count as f32 * (large + gap) - small - gap;
162 while count > 1 && medium(count) < small {
163 count -= 1;
164 }
165 let medium = medium(count).clamp(small, large);
166 let mut widths = vec![large; count];
167 widths.extend([medium, small]);
168 (widths, count, large)
169 }
170 };
171 Keylines {
172 lines: lay_out(&widths, pad, gap),
173 focal,
174 step: large + gap,
175 gap,
176 }
177}
178
179fn end_keylines(start: &Keylines, layout: CarouselLayout) -> Keylines {
181 if matches!(
182 layout,
183 CarouselLayout::Uncontained | CarouselLayout::FullScreen | CarouselLayout::CenteredHero
184 ) {
185 return start.clone();
186 }
187 let widths: Vec<f32> = start.lines.iter().rev().map(|line| line.width).collect();
188 let first = start.lines.first().map_or(0., |line| line.x);
189 Keylines {
190 lines: lay_out(&widths, first, start.gap),
191 focal: start.focal,
192 step: start.step,
193 gap: start.gap,
194 }
195}
196
197pub(crate) fn max_scroll(
199 keylines: &Keylines,
200 count: usize,
201 layout: CarouselLayout,
202 width: f32,
203) -> f32 {
204 let on_screen = match layout {
205 CarouselLayout::CenteredHero => return (count.saturating_sub(1)) as f32 * keylines.step,
207 CarouselLayout::Uncontained => {
211 let pad = keylines.lines.first().map_or(0., |line| line.x);
212 let content = count as f32 * keylines.step - keylines.gap;
213 return (content - (width - 2. * pad)).max(0.);
214 }
215 _ => keylines.lines.len(),
216 };
217 count.saturating_sub(on_screen) as f32 * keylines.step
218}
219
220pub(crate) fn place(
224 lines: &[Keyline],
225 step: f32,
226 p: f32,
227 leading_index: usize,
228 gap: f32,
229) -> Keyline {
230 let j = p / step + leading_index as f32;
232 let last = lines.len() - 1;
233 if j <= 0. {
234 let edge = lines[0];
235 let small = CAROUSEL_SMALL_ITEM.min(edge.width);
236 let t = (-j).min(1.);
238 let width = edge.width + (small - edge.width) * t;
239 let x = edge.x + j * (small + gap + edge.x);
241 return Keyline { x, width };
242 }
243 if j >= last as f32 {
244 let edge = lines[last];
245 let x = edge.x + (j - last as f32) * (edge.width + gap + lines[0].x);
248 return Keyline {
249 x,
250 width: edge.width,
251 };
252 }
253 let i = j.floor() as usize;
254 let t = j - i as f32;
255 let (a, b) = (lines[i], lines[i + 1]);
256 Keyline {
257 x: a.x + (b.x - a.x) * t,
258 width: a.width + (b.width - a.width) * t,
259 }
260}
261
262fn blend_into(out: &mut Vec<Keyline>, a: &[Keyline], b: &[Keyline], t: f32) {
264 out.clear();
265 out.extend(a.iter().zip(b).map(|(a, b)| Keyline {
266 x: a.x + (b.x - a.x) * t,
267 width: a.width + (b.width - a.width) * t,
268 }));
269}
270
271pub struct CarouselItem {
274 content: Option<AnyElement>,
275 label: Option<SharedString>,
276 supporting: Option<SharedString>,
277}
278
279impl CarouselItem {
280 pub fn new() -> Self {
281 Self {
282 content: None,
283 label: None,
284 supporting: None,
285 }
286 }
287 pub fn content(mut self, content: impl IntoElement) -> Self {
288 self.content = Some(content.into_any_element());
289 self
290 }
291 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
292 self.label = Some(label.into());
293 self
294 }
295 pub fn supporting(mut self, supporting: impl Into<SharedString>) -> Self {
296 self.supporting = Some(supporting.into());
297 self
298 }
299}
300
301impl Default for CarouselItem {
302 fn default() -> Self {
303 Self::new()
304 }
305}
306
307#[derive(Clone, Copy)]
308struct ScrollAnimation {
309 from: f32,
310 to: f32,
311 started: Instant,
312}
313
314struct KeylineCache {
316 key: (CarouselLayout, u32, u32, u32, u32),
317 start: Keylines,
318 end: Keylines,
319}
320
321#[derive(Default)]
322struct CarouselState {
323 started: bool,
325 scroll: f32,
326 width: Option<f32>,
327 animation: Option<ScrollAnimation>,
328 keylines: Option<KeylineCache>,
331 lines: Vec<Keyline>,
333 snap_due: Option<Instant>,
335 snap_to: (f32, f32),
337 snap_pending: bool,
339 applied: Option<usize>,
341 reported: Option<usize>,
342 on_change: Option<ChangeHandler>,
344 drag: Option<(Pixels, f32)>,
346}
347
348#[cfg(test)]
349thread_local! {
350 static SNAP_TASKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
352}
353
354impl CarouselState {
355 fn destination(&self) -> f32 {
357 self.animation.map_or(self.scroll, |animation| animation.to)
358 }
359
360 fn item_at(scroll: f32, step: f32) -> usize {
362 if step <= 0. {
363 0
364 } else {
365 (scroll / step).round().max(0.) as usize
366 }
367 }
368
369 fn animate_to(&mut self, to: f32, reduced: bool) {
370 if reduced || (to - self.scroll).abs() < 0.5 {
371 self.scroll = to;
372 self.animation = None;
373 } else {
374 self.animation = Some(ScrollAnimation {
375 from: self.scroll,
376 to,
377 started: Instant::now(),
378 });
379 }
380 }
381}
382
383fn report(state: &Entity<CarouselState>, to: f32, step: f32, window: &mut Window, cx: &mut App) {
385 let item = CarouselState::item_at(to, step);
386 let handler = state.update(cx, |state, _| {
387 if state.reported == Some(item) {
388 return None;
389 }
390 state.reported = Some(item);
391 state.applied = Some(item);
392 state.on_change.clone()
393 });
394 if let Some(handler) = handler {
395 handler(&item, window, cx);
396 }
397}
398
399pub(crate) fn snap(scroll: f32, step: f32, max: f32) -> f32 {
402 let max = max.max(0.);
403 if step <= 0. {
404 return 0.;
405 }
406 let boundary = ((scroll / step).round() * step).clamp(0., max);
407 if (max - scroll).abs() < (boundary - scroll).abs() {
408 max
409 } else {
410 boundary
411 }
412}
413
414pub(crate) fn neighbor(current: f32, step: f32, max: f32, forward: bool) -> f32 {
417 let max = max.max(0.);
418 if step <= 0. {
419 return 0.;
420 }
421 let to = if forward {
422 (((current + 0.5) / step).floor() + 1.) * step
423 } else {
424 (((current - 0.5) / step).ceil() - 1.) * step
425 };
426 to.clamp(0., max)
427}
428
429const LABEL_ON_IMAGERY: Rgba = Rgba {
433 r: 1.,
434 g: 1.,
435 b: 1.,
436 a: 1.,
437};
438
439#[derive(gpui::IntoElement)]
441pub struct MaterialCarousel {
442 id: ElementId,
443 label: SharedString,
444 layout: CarouselLayout,
445 items: Vec<CarouselItem>,
446 large: f32,
447 height: f32,
448 width: Option<f32>,
449 spacing: CarouselSpacing,
450 radius: Pixels,
451 snap: bool,
452 current: Option<usize>,
453 on_change: Option<ChangeHandler>,
454}
455
456impl MaterialCarousel {
457 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
458 Self {
459 id: id.into(),
460 label: label.into(),
461 layout: CarouselLayout::MultiBrowse,
462 items: Vec::new(),
463 large: 188.,
464 height: 205.,
465 width: None,
466 spacing: CarouselSpacing::default(),
467 radius: CAROUSEL_ITEM_RADIUS,
468 snap: true,
469 current: None,
470 on_change: None,
471 }
472 }
473 pub fn gap(mut self, gap: f32) -> Self {
475 self.spacing.gap = gap.max(0.);
476 self
477 }
478 pub fn padding(mut self, padding: f32) -> Self {
480 self.spacing.padding = padding.max(0.);
481 self
482 }
483 pub fn corner_radius(mut self, radius: Pixels) -> Self {
485 self.radius = radius.max(px(0.));
486 self
487 }
488 pub fn snap(mut self, snap: bool) -> Self {
490 self.snap = snap;
491 self
492 }
493 pub fn current(mut self, index: usize) -> Self {
496 self.current = Some(index);
497 self
498 }
499 pub fn on_change(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
502 self.on_change = Some(std::rc::Rc::new(handler));
503 self
504 }
505 pub fn layout(mut self, layout: CarouselLayout) -> Self {
506 self.layout = layout;
507 self
508 }
509 pub fn items(mut self, items: impl IntoIterator<Item = CarouselItem>) -> Self {
510 self.items.extend(items);
511 self
512 }
513 pub fn large_item_width(mut self, width: f32) -> Self {
515 self.large = width.max(CAROUSEL_SMALL_ITEM);
516 self
517 }
518 pub fn item_height(mut self, height: f32) -> Self {
520 self.height = height;
521 self
522 }
523 pub fn width(mut self, width: f32) -> Self {
526 self.width = Some(width);
527 self
528 }
529}
530
531fn typed<E: Styled>(element: E, role: TypeRole) -> E {
532 let scale = role.scale();
533 element
534 .font_family(scale.font_family)
535 .text_size(scale.font_size)
536 .line_height(scale.line_height)
537 .font_weight(FontWeight(scale.font_weight as f32))
538}
539
540fn schedule_snap(
543 state: &Entity<CarouselState>,
544 step: f32,
545 max: f32,
546 window: &mut Window,
547 cx: &mut App,
548) {
549 let due = cx.background_executor().now() + SNAP_IDLE;
550 let spawn = state.update(cx, |state, _| {
551 state.snap_due = Some(due);
552 state.snap_to = (step, max);
553 !std::mem::replace(&mut state.snap_pending, true)
554 });
555 if !spawn {
556 return;
557 }
558 #[cfg(test)]
559 SNAP_TASKS.with(|tasks| tasks.set(tasks.get() + 1));
560 let state = state.downgrade();
561 window
562 .spawn(cx, async move |cx| {
563 loop {
564 let wait = cx.update(|_, cx| {
565 let due = state.upgrade()?.read(cx).snap_due?;
566 Some(due.saturating_duration_since(cx.background_executor().now()))
567 });
568 match wait {
569 Ok(Some(wait)) if !wait.is_zero() => cx.background_executor().timer(wait).await,
570 Ok(Some(_)) => break,
571 _ => return,
572 }
573 }
574 let _ = cx.update(|window, cx| {
575 let Some(state) = state.upgrade() else { return };
576 let reduced = cx.reduce_motion();
577 let (to, step) = state.update(cx, |state, _| {
578 state.snap_pending = false;
579 state.snap_due = None;
580 let (step, max) = state.snap_to;
581 let to = snap(state.scroll, step, max);
582 state.animate_to(to, reduced);
583 (to, step)
584 });
585 report(&state, to, step, window, cx);
586 window.refresh();
587 });
588 })
589 .detach();
590}
591
592impl RenderOnce for MaterialCarousel {
593 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
594 let id = self.id.clone();
595 let state = window.use_keyed_state((id.clone(), "carousel"), cx, |_, _| {
596 CarouselState::default()
597 });
598 let focus = window
599 .use_keyed_state((id.clone(), "carousel-focus"), cx, |_, cx| {
600 cx.focus_handle().tab_stop(true)
601 })
602 .read(cx)
603 .clone();
604 let layout = self.layout;
605 let rem_size = f32::from(window.rem_size()) / BASE_PX;
607 let width = self
608 .width
609 .or(state.read(cx).width.map(|w| w / rem_size))
610 .unwrap_or(412.);
611 let spacing = self.spacing;
612 let key = (
613 layout,
614 width.to_bits(),
615 self.large.to_bits(),
616 spacing.gap.to_bits(),
617 spacing.padding.to_bits(),
618 );
619 if state.read(cx).keylines.as_ref().map(|cache| cache.key) != Some(key) {
620 let start = spaced_keylines(layout, width, self.large, spacing);
621 let end = end_keylines(&start, layout);
622 state.update(cx, |state, _| {
623 state.keylines = Some(KeylineCache { key, start, end })
624 });
625 }
626 let count = self.items.len();
627 let (step, max, shift_len, large, gap) = {
628 let start = &state
629 .read(cx)
630 .keylines
631 .as_ref()
632 .expect("keylines were just cached")
633 .start;
634 let step = start.step;
635 let max = max_scroll(start, count, layout, width);
636 let shift_len = ((start.lines.len() - start.focal) as f32 * step).min(max);
637 let large = start.lines.iter().map(|line| line.width).fold(0., f32::max);
638 (step, max, shift_len, large, start.gap)
639 };
640 let reduced = cx.reduce_motion();
641
642 if !state.read(cx).started {
644 let start = if layout == CarouselLayout::CenteredHero {
647 step.min(max)
648 } else {
649 0.
650 };
651 state.update(cx, |state, _| {
652 state.started = true;
653 state.scroll = start;
654 });
655 }
656 let on_change = self.on_change.clone();
658 state.update(cx, |state, _| state.on_change = on_change);
659 if let Some(current) = self.current
660 && state.read(cx).applied != Some(current)
661 {
662 let to = (current as f32 * step).clamp(0., max);
663 let first = state.read(cx).applied.is_none();
664 state.update(cx, |state, _| {
665 state.applied = Some(current);
666 state.reported = Some(current);
667 if CarouselState::item_at(state.destination(), step) != current {
668 state.animate_to(to, reduced || first);
670 }
671 });
672 if state.read(cx).animation.is_some() {
673 window.request_animation_frame();
674 }
675 }
676 let mut scroll = state.read(cx).scroll.clamp(0., max);
677 if let Some(animation) = state.read(cx).animation {
678 let curve = motion::curve(MotionToken::DefaultSpatial);
679 let duration = curve.duration().as_secs_f32().max(f32::EPSILON);
680 let t = animation.started.elapsed().as_secs_f32() / duration;
681 if t >= 1. || reduced {
682 scroll = animation.to;
683 state.update(cx, |state, _| {
684 state.scroll = animation.to;
685 state.animation = None;
686 });
687 } else {
688 scroll = animation.from + (animation.to - animation.from) * curve.ease()(t);
689 window.request_animation_frame();
690 }
691 }
692
693 let shift = if shift_len > 0. {
695 ((scroll - (max - shift_len)) / shift_len).clamp(0., 1.)
696 } else {
697 0.
698 };
699 let lines = state.update(cx, |state, _| {
702 let mut lines = std::mem::take(&mut state.lines);
703 if let Some(cache) = &state.keylines {
704 blend_into(&mut lines, &cache.start.lines, &cache.end.lines, shift);
705 }
706 lines
707 });
708 let leading = if layout == CarouselLayout::CenteredHero {
709 1
710 } else {
711 0
712 };
713 let pad_y = if layout == CarouselLayout::FullScreen {
714 0.
715 } else {
716 PAD_Y
717 };
718
719 let radius = self.radius;
720 let items = self
721 .items
722 .into_iter()
723 .enumerate()
724 .filter_map(|(index, item)| {
725 let p = index as f32 * step - scroll;
726 let line = place(&lines, step, p, leading, gap);
727 if line.x + line.width < 0. || line.x > width {
728 return None;
729 }
730 let text = ((line.width - CAROUSEL_SMALL_ITEM)
732 / (large - CAROUSEL_SMALL_ITEM).max(1.))
733 .clamp(0., 1.);
734 let text = (text - 0.5).max(0.) * 2.;
735 let has_text = item.label.is_some() || item.supporting.is_some();
736 let name = item.label.clone();
738 let description = item.supporting.clone();
739 Some(
740 div()
741 .id(("carousel-item", index))
742 .role(Role::ListItem)
743 .when_some(name, |tile, name| tile.aria_label(name))
744 .when_some(description, |tile, text| tile.aria_description(text))
745 .absolute()
746 .top(rem(pad_y))
747 .left(rem(line.x))
748 .w(rem(line.width))
749 .h(rem(self.height))
750 .overflow_hidden()
751 .rounded(radius)
752 .bg(color(ColorRole::SURFACE_CONTAINER_HIGH))
753 .child(
754 div()
757 .absolute()
758 .top_0()
759 .left(rem((line.width - large) / 2.))
760 .w(rem(large))
761 .h_full()
762 .flex()
763 .items_center()
764 .justify_center()
765 .children(item.content),
766 )
767 .when(has_text && text > 0., |tile| {
768 let ink = LABEL_ON_IMAGERY;
769 tile.child(
770 div()
771 .absolute()
772 .left_0()
773 .right_0()
774 .bottom_0()
775 .flex()
776 .flex_col()
777 .gap(rem(4.))
778 .p(rem(16.))
779 .opacity(text)
780 .bg(linear_gradient(
781 180.,
782 linear_color_stop(color(ColorRole::SCRIM).opacity(0.), 0.),
783 linear_color_stop(color(ColorRole::SCRIM).opacity(0.4), 1.),
784 ))
785 .text_color(ink)
786 .when_some(item.label, |text, label| {
787 text.child(
788 typed(div(), TypeRole::TitleMedium)
789 .truncate()
790 .child(label),
791 )
792 })
793 .when_some(item.supporting, |text, supporting| {
794 text.child(
795 typed(div(), TypeRole::LabelSmall)
796 .truncate()
797 .child(supporting),
798 )
799 }),
800 )
801 }),
802 )
803 });
804
805 let wheel = state.clone();
806 let keys = state.clone();
807 let measure = state.clone();
808 let grab = state.clone();
809 let snaps = self.snap;
810 let dragging = state.read(cx).drag.is_some();
811 let fixed = self.width.is_some();
812 let keyboard_focus = focus.is_focused(window) && window.last_input_was_keyboard();
813 let carousel = div()
814 .id(id)
815 .role(Role::List)
816 .aria_label(self.label)
817 .track_focus(&focus)
818 .relative()
819 .overflow_hidden()
820 .map(|carousel| {
821 if fixed {
822 carousel.w(rem(width))
823 } else {
824 carousel.w_full()
825 }
826 })
827 .h(rem(self.height + 2. * pad_y))
828 .on_scroll(move |event, window, cx| {
829 let delta = event.delta.pixel_delta(px(16.));
830 let horizontal = delta.x.abs() > delta.y.abs();
833 let amount = if horizontal {
834 -f32::from(delta.x)
835 } else {
836 -f32::from(delta.y)
837 } / rem_size;
838 if amount == 0. || max <= 0. {
839 return;
840 }
841 let before = wheel.read(cx).scroll;
842 let after = (before + amount).clamp(0., max);
843 let moved = after != before;
844 if horizontal || moved {
847 cx.stop_propagation();
848 }
849 if !moved {
850 return;
851 }
852 wheel.update(cx, |state, _| {
853 state.animation = None;
854 state.scroll = after;
855 });
856 window.refresh();
857 if snaps {
858 schedule_snap(&wheel, step, max, window, cx);
859 } else {
860 report(&wheel, after, step, window, cx);
861 }
862 })
863 .on_key_down(move |event, window, cx| {
864 let current = keys
865 .read(cx)
866 .animation
867 .map_or(keys.read(cx).scroll, |a| a.to);
868 let to = match event.keystroke.key.as_str() {
869 "right" => neighbor(current, step, max, true),
870 "left" => neighbor(current, step, max, false),
871 "home" => 0.,
872 "end" => max,
873 _ => return,
874 };
875 cx.stop_propagation();
876 let reduced = cx.reduce_motion();
877 keys.update(cx, |state, _| state.animate_to(to, reduced));
878 report(&keys, to, step, window, cx);
879 window.refresh();
880 })
881 .on_mouse_down(MouseButton::Left, move |event, window, cx| {
882 grab.update(cx, |state, _| {
884 state.animation = None;
885 state.drag = Some((event.position.x, state.scroll));
886 });
887 window.refresh();
888 })
889 .when(dragging, |carousel| {
890 let follow = state.clone();
891 carousel.cursor_grabbing().child(
892 canvas(
893 |_, _, _| (),
894 move |_, _, window, _| {
895 let moving = follow.clone();
896 window.on_mouse_event(
897 move |event: &MouseMoveEvent, phase, window, cx| {
898 if phase != DispatchPhase::Capture {
899 return;
900 }
901 moving.update(cx, |state, _| {
902 if let Some((x, from)) = state.drag {
903 let delta = f32::from(event.position.x - x) / rem_size;
904 state.scroll = (from - delta).clamp(0., max);
905 }
906 });
907 window.refresh();
908 },
909 );
910 let dropping = follow.clone();
911 window.on_mouse_event(move |_: &MouseUpEvent, phase, window, cx| {
912 if phase != DispatchPhase::Capture {
913 return;
914 }
915 let reduced = cx.reduce_motion();
916 let to = dropping.update(cx, |state, _| {
917 state.drag = None;
918 let to = if snaps {
919 snap(state.scroll, step, max)
920 } else {
921 state.scroll
922 };
923 state.animate_to(to, reduced);
924 to
925 });
926 report(&dropping, to, step, window, cx);
927 window.refresh();
928 });
929 },
930 )
931 .absolute()
932 .size_0(),
933 )
934 })
935 .children(items)
936 .when(keyboard_focus, |carousel| {
937 carousel.child(
939 div()
940 .absolute()
941 .inset_0()
942 .rounded(radius)
943 .border(px(3.))
944 .border_color(color(ColorRole::SECONDARY)),
945 )
946 })
947 .child(
948 canvas(
949 move |bounds, window, cx| {
950 let width = f32::from(bounds.size.width);
951 if measure.read(cx).width != Some(width) {
952 measure.update(cx, |state, _| state.width = Some(width));
953 if !fixed {
957 window.request_animation_frame();
958 }
959 }
960 },
961 |_, _, _, _| {},
962 )
963 .absolute()
964 .top_0()
965 .left_0()
966 .size_full(),
967 );
968 state.update(cx, |state, _| state.lines = lines);
969 carousel
970 }
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976 use core::prelude::v1::test;
977
978 fn widths(keylines: &Keylines) -> Vec<f32> {
979 keylines
980 .lines
981 .iter()
982 .map(|line| line.width.round())
983 .collect()
984 }
985
986 #[test]
987 fn strategies_match_the_kit_on_mobile_and_tablet() {
988 assert_eq!(
990 widths(&keylines(CarouselLayout::MultiBrowse, 412., 188.)),
991 [188., 120., 56.]
992 );
993 assert_eq!(
995 widths(&keylines(CarouselLayout::MultiBrowse, 600., 184.)),
996 [184., 184., 120., 56.]
997 );
998 assert_eq!(
1000 widths(&keylines(CarouselLayout::Hero, 412., 188.)),
1001 [316., 56.]
1002 );
1003 assert_eq!(
1004 widths(&keylines(CarouselLayout::CenteredHero, 412., 188.)),
1005 [56., 252., 56.]
1006 );
1007 let full = keylines(CarouselLayout::FullScreen, 412., 188.);
1008 assert_eq!(widths(&full), [412.]);
1009 assert_eq!(full.lines[0].x, 0.);
1010 }
1011
1012 #[test]
1013 fn items_morph_between_keylines() {
1014 let start = keylines(CarouselLayout::MultiBrowse, 412., 188.);
1015 let step = start.step;
1016 assert_eq!(place(&start.lines, step, step, 0, GAP).width, 120.);
1018 let half = place(&start.lines, step, 1.5 * step, 0, GAP);
1020 assert!((half.width - 88.).abs() < 0.01);
1021 assert!(place(&start.lines, step, -0.5 * step, 0, GAP).width < 188.);
1023 }
1024
1025 #[test]
1026 fn scrolling_snaps_to_items_within_range() {
1027 assert_eq!(snap(130., 196., 1000.), 196.);
1028 assert_eq!(snap(90., 196., 1000.), 0.);
1029 assert_eq!(snap(5000., 196., 392.), 392.);
1030 let start = keylines(CarouselLayout::MultiBrowse, 412., 188.);
1031 assert_eq!(max_scroll(&start, 3, CarouselLayout::MultiBrowse, 412.), 0.);
1032 assert_eq!(
1033 max_scroll(&start, 5, CarouselLayout::MultiBrowse, 412.),
1034 2. * start.step
1035 );
1036 }
1037
1038 #[test]
1039 fn an_uncontained_carousel_reaches_its_last_item() {
1040 let layout = CarouselLayout::Uncontained;
1041 let start = keylines(layout, 412., 188.);
1042 let max = max_scroll(&start, 6, layout, 412.);
1043 assert_eq!(max, 6. * 196. - 8. - (412. - 2. * PAD_X));
1045 let last = place(&start.lines, start.step, 5. * start.step - max, 0, GAP);
1047 assert!((last.x + last.width - (412. - PAD_X)).abs() < 0.01);
1048 assert_eq!(snap(max - 1., start.step, max), max);
1050 assert_eq!(neighbor(3. * start.step, start.step, max, true), 4. * 196.);
1051 assert_eq!(neighbor(4. * start.step, start.step, max, true), max);
1052 assert_eq!(neighbor(max, start.step, max, false), 4. * 196.);
1053 assert_eq!(max_scroll(&start, 1, layout, 412.), 0.);
1055 }
1056
1057 #[test]
1058 fn blending_reuses_the_keyline_buffer() {
1059 let start = keylines(CarouselLayout::MultiBrowse, 412., 188.);
1060 let end = end_keylines(&start, CarouselLayout::MultiBrowse);
1061 let mut lines = Vec::new();
1062 blend_into(&mut lines, &start.lines, &end.lines, 0.25);
1063 let buffer = lines.as_ptr();
1064 blend_into(&mut lines, &start.lines, &end.lines, 0.75);
1065 assert_eq!(lines.as_ptr(), buffer, "the next frame reallocated");
1066 blend_into(&mut lines, &start.lines, &end.lines, 1.);
1067 assert_eq!(lines, end.lines);
1068 }
1069
1070 #[test]
1071 fn the_end_arrangement_mirrors_the_start() {
1072 let start = keylines(CarouselLayout::MultiBrowse, 412., 188.);
1073 let end = end_keylines(&start, CarouselLayout::MultiBrowse);
1074 assert_eq!(widths(&end), [56., 120., 188.]);
1075 let last = end.lines.last().unwrap();
1076 assert!((last.x + last.width - (412. - PAD_X)).abs() < 0.01);
1077 }
1078
1079 struct Harness;
1080 impl gpui::Render for Harness {
1081 fn render(&mut self, window: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1082 window.set_rem_size(px(16.));
1083 div().child(
1084 MaterialCarousel::new("carousel", "Photos")
1085 .width(412.)
1086 .items((0..6).map(|i| {
1087 CarouselItem::new().content(
1088 div()
1089 .debug_selector(move || format!("item-{i}"))
1090 .size(px(10.)),
1091 )
1092 })),
1093 )
1094 }
1095 }
1096
1097 #[gpui::test]
1098 fn arrow_keys_scroll_one_item(cx: &mut gpui::TestAppContext) {
1099 cx.update(|cx| {
1100 gpui_base::init(cx);
1101 cx.set_reduce_motion(true);
1102 });
1103 let (_, cx) = cx.add_window_view(|_, _| Harness);
1104 let draw = |cx: &mut gpui::VisualTestContext| {
1105 cx.update(|window, cx| {
1106 window.refresh();
1107 window.draw(cx).clear(cx);
1108 })
1109 };
1110 draw(cx);
1111 let before = cx.debug_bounds("item-1").unwrap();
1112 cx.update(|window, cx| window.focus_next(cx));
1113 let keystroke = cx.update(|_, cx| {
1114 gpui::Keystroke::parse_with_os("right", cx.platform_info().os).unwrap()
1115 });
1116 cx.simulate_event(gpui::KeyDownEvent {
1117 keystroke,
1118 is_held: false,
1119 prefer_character_input: false,
1120 });
1121 draw(cx);
1122 let after = cx.debug_bounds("item-1").unwrap();
1123 assert!(
1124 after.left() < before.left(),
1125 "the next item moved toward the start"
1126 );
1127 assert!(
1128 cx.debug_bounds("item-3").is_some(),
1129 "the next item scrolled in"
1130 );
1131 assert!(
1132 cx.debug_bounds("item-4").is_none(),
1133 "items beyond the keylines stay hidden"
1134 );
1135 }
1136
1137 struct Page {
1139 layout: CarouselLayout,
1140 fixed: Option<f32>,
1141 page_scrolls: std::rc::Rc<std::cell::Cell<usize>>,
1142 }
1143 impl gpui::Render for Page {
1144 fn render(&mut self, window: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1145 window.set_rem_size(px(BASE_PX));
1146 let page_scrolls = self.page_scrolls.clone();
1147 let carousel = MaterialCarousel::new("carousel", "Photos")
1148 .layout(self.layout)
1149 .items((0..6).map(|i| {
1150 CarouselItem::new().content(
1151 div()
1152 .debug_selector(move || format!("item-{i}"))
1153 .size_full(),
1154 )
1155 }));
1156 div()
1157 .id("page")
1158 .w(px(600.))
1159 .on_scroll(move |_, _, _| page_scrolls.set(page_scrolls.get() + 1))
1160 .child(match self.fixed {
1161 Some(width) => carousel.width(width),
1162 None => carousel,
1163 })
1164 }
1165 }
1166
1167 fn page(
1168 cx: &mut gpui::TestAppContext,
1169 layout: CarouselLayout,
1170 fixed: Option<f32>,
1171 ) -> (gpui::Entity<Page>, &mut gpui::VisualTestContext) {
1172 cx.update(|cx| {
1173 gpui_base::init(cx);
1174 cx.set_reduce_motion(true);
1175 });
1176 cx.add_window_view(move |_, _| Page {
1177 layout,
1178 fixed,
1179 page_scrolls: Default::default(),
1180 })
1181 }
1182
1183 fn redraw(cx: &mut gpui::VisualTestContext) {
1184 cx.update(|window, cx| {
1185 window.refresh();
1186 window.draw(cx).clear(cx);
1187 })
1188 }
1189
1190 #[gpui::test]
1191 fn the_end_key_brings_the_last_uncontained_item_in(cx: &mut gpui::TestAppContext) {
1192 let (_, cx) = page(cx, CarouselLayout::Uncontained, Some(412.));
1193 redraw(cx);
1194 cx.update(|window, cx| window.focus_next(cx));
1195 cx.simulate_keystrokes("end");
1196 redraw(cx);
1197 let last = cx.debug_bounds("item-5").expect("the last item is shown");
1198 assert!(
1199 (f32::from(last.right()) - (412. - PAD_X)).abs() < 0.5,
1200 "the last item ends at the trailing padding: {last:?}"
1201 );
1202 }
1203
1204 #[gpui::test]
1205 fn a_vertical_wheel_at_an_end_scrolls_the_page(cx: &mut gpui::TestAppContext) {
1206 let (view, cx) = page(cx, CarouselLayout::MultiBrowse, Some(412.));
1207 redraw(cx);
1208 let position = cx.debug_bounds("item-0").unwrap().center();
1209 let scrolls = view.read_with(cx, |page, _| page.page_scrolls.clone());
1210 let wheel = |cx: &mut gpui::VisualTestContext, x: f32, y: f32| {
1211 cx.simulate_event(gpui::ScrollEvent {
1212 position,
1213 delta: gpui::ScrollDelta::Pixels(point(px(x), px(y))),
1214 touch_phase: TouchPhase::Moved,
1215 ..Default::default()
1216 });
1217 };
1218 wheel(cx, 0., 40.);
1220 assert_eq!(scrolls.get(), 1, "the page did not receive the wheel");
1221 wheel(cx, 0., -40.);
1223 assert_eq!(scrolls.get(), 1);
1224 wheel(cx, 400., 0.);
1226 wheel(cx, 400., 0.);
1227 assert_eq!(scrolls.get(), 1);
1228 }
1229
1230 #[gpui::test]
1231 fn wheel_events_share_one_snap_task(cx: &mut gpui::TestAppContext) {
1232 let (_, cx) = page(cx, CarouselLayout::MultiBrowse, Some(412.));
1233 redraw(cx);
1234 let position = cx.debug_bounds("item-0").unwrap().center();
1235 for _ in 0..5 {
1236 cx.simulate_event(gpui::ScrollEvent {
1237 position,
1238 delta: gpui::ScrollDelta::Pixels(point(px(-30.), px(0.))),
1239 touch_phase: TouchPhase::Moved,
1240 ..Default::default()
1241 });
1242 }
1243 assert_eq!(
1244 SNAP_TASKS.with(|tasks| tasks.get()),
1245 1,
1246 "every wheel event spawned a snap task"
1247 );
1248 cx.executor().advance_clock(SNAP_IDLE * 2);
1251 cx.run_until_parked();
1252 redraw(cx);
1253 let second = cx.debug_bounds("item-1").unwrap();
1254 assert!((f32::from(second.left()) - PAD_X).abs() < 0.5, "{second:?}");
1255 }
1256
1257 #[gpui::test]
1258 fn a_new_measured_width_draws_again(cx: &mut gpui::TestAppContext) {
1259 let (_, cx) = page(cx, CarouselLayout::MultiBrowse, None);
1260 cx.update(|window, cx| {
1261 window.refresh();
1262 window.draw(cx).clear(cx);
1263 });
1264 let frames = cx.update(|window, cx| window.simulate_next_frame(cx));
1265 assert!(frames > 0, "the 600px width was stored without a new frame");
1266 redraw(cx);
1267 assert!(cx.debug_bounds("item-3").is_some());
1269 let frames = cx.update(|window, cx| window.simulate_next_frame(cx));
1270 assert_eq!(frames, 0, "an unchanged width requests nothing");
1271 }
1272
1273 struct Controlled {
1275 current: usize,
1276 snap: bool,
1277 reported: std::rc::Rc<std::cell::Cell<Option<usize>>>,
1278 }
1279 impl gpui::Render for Controlled {
1280 fn render(
1281 &mut self,
1282 window: &mut Window,
1283 cx: &mut gpui::Context<Self>,
1284 ) -> impl IntoElement {
1285 window.set_rem_size(px(BASE_PX));
1286 let reported = self.reported.clone();
1287 MaterialCarousel::new("controlled", "Photos")
1288 .width(412.)
1289 .gap(12.)
1290 .padding(24.)
1291 .snap(self.snap)
1292 .current(self.current)
1293 .on_change(cx.listener(move |this, item: &usize, _, cx| {
1294 reported.set(Some(*item));
1295 this.current = *item;
1296 cx.notify();
1297 }))
1298 .items((0..8).map(|i| {
1299 CarouselItem::new().content(
1300 div()
1301 .debug_selector(move || format!("item-{i}"))
1302 .size_full(),
1303 )
1304 }))
1305 }
1306 }
1307
1308 fn controlled(
1309 cx: &mut gpui::TestAppContext,
1310 current: usize,
1311 snap: bool,
1312 ) -> (gpui::Entity<Controlled>, &mut gpui::VisualTestContext) {
1313 cx.update(|cx| {
1314 gpui_base::init(cx);
1315 cx.set_reduce_motion(true);
1316 });
1317 cx.add_window_view(move |_, _| Controlled {
1318 current,
1319 snap,
1320 reported: Default::default(),
1321 })
1322 }
1323
1324 #[test]
1325 fn spacing_sets_the_padding_and_gap_of_the_keylines() {
1326 let spacing = CarouselSpacing {
1327 gap: 12.,
1328 padding: 24.,
1329 };
1330 let lines = spaced_keylines(CarouselLayout::MultiBrowse, 412., 188., spacing);
1331 assert_eq!(lines.lines[0].x, 24.);
1332 assert_eq!(lines.lines[1].x, 24. + lines.lines[0].width + 12.);
1333 assert_eq!(lines.step, 188. + 12.);
1334 let full = spaced_keylines(CarouselLayout::FullScreen, 412., 188., spacing);
1336 assert_eq!((full.lines[0].x, full.gap), (0., 0.));
1337 }
1338
1339 #[gpui::test]
1340 fn the_current_item_starts_and_follows_the_caller(cx: &mut gpui::TestAppContext) {
1341 let (view, cx) = controlled(cx, 2, true);
1342 redraw(cx);
1343 let third = cx.debug_bounds("item-2").unwrap();
1345 assert!((f32::from(third.left()) - 24.).abs() < 0.5, "{third:?}");
1346 view.update(cx, |view, cx| {
1347 view.current = 3;
1348 cx.notify();
1349 });
1350 redraw(cx);
1351 let fourth = cx.debug_bounds("item-3").unwrap();
1353 assert!((f32::from(fourth.left()) - 24.).abs() < 0.5, "{fourth:?}");
1354 cx.update(|window, cx| window.focus_next(cx));
1356 cx.simulate_keystrokes("left");
1357 redraw(cx);
1358 let reported = view.read_with(cx, |view, _| view.reported.get());
1359 assert_eq!(reported, Some(2));
1360 }
1361
1362 #[gpui::test]
1363 fn dragging_swipes_and_snaps_to_the_nearest_item(cx: &mut gpui::TestAppContext) {
1364 use gpui::{Modifiers, MouseButton};
1365 let (view, cx) = controlled(cx, 0, true);
1366 redraw(cx);
1367 let start = cx.debug_bounds("item-0").unwrap().center();
1368 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none());
1369 redraw(cx);
1370 let to = start - point(px(260.), px(0.));
1372 cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::none());
1373 redraw(cx);
1374 cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
1375 redraw(cx);
1376 let reported = view.read_with(cx, |view, _| view.reported.get());
1377 assert_eq!(reported, Some(1), "a 260dp drag settles on item 1");
1378 let second = cx.debug_bounds("item-1").unwrap();
1379 assert!((f32::from(second.left()) - 24.).abs() < 0.5, "{second:?}");
1380 }
1381
1382 #[gpui::test]
1383 fn without_snapping_a_drag_rests_where_released(cx: &mut gpui::TestAppContext) {
1384 use gpui::{Modifiers, MouseButton};
1385 let (_, cx) = controlled(cx, 0, false);
1386 redraw(cx);
1387 let home = cx.debug_bounds("item-1").unwrap();
1388 let start = cx.debug_bounds("item-0").unwrap().center();
1389 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none());
1390 let to = start - point(px(60.), px(0.));
1391 cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::none());
1392 cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
1393 redraw(cx);
1394 let moved = cx.debug_bounds("item-1").unwrap();
1395 assert!(moved.left() < home.left(), "the drag moved the items");
1396 assert!(
1397 (f32::from(moved.left()) - 24.).abs() > 1.,
1398 "it did not snap to an item"
1399 );
1400 }
1401}