1use std::{panic::Location, sync::Arc};
2
3use gpui::{
4 AnyElement, App, Axis, Bounds, ClickEvent, Element, ElementId, Entity, FocusHandle, Focusable,
5 GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId,
6 MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, SharedString,
7 StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window, div,
8 prelude::FluentBuilder as _, px,
9};
10use gpui_base::spring;
11use rust_i18n::t;
12
13use super::{CONTEXT, scroll_mask::CarouselScrollMask, state::CarouselState};
14use crate::{
15 AxisExt as _, Disableable as _, ElementExt as _, Selectable as _, Sizable as _, Size,
16 StyledExt as _, ThemeStyled as _,
17 actions::{SelectDown, SelectFirst, SelectLast, SelectLeft, SelectRight, SelectUp},
18 button::Button,
19 icon::IconName,
20 theme::ActiveTheme as _,
21};
22
23#[derive(IntoElement)]
28pub struct Carousel {
29 id: ElementId,
30 state: Entity<CarouselState>,
31 style: StyleRefinement,
32 accessibility_label: SharedString,
33 focus_ring_enabled: bool,
34 children: Vec<AnyElement>,
35}
36
37struct CarouselStateObserver {
38 _subscription: Subscription,
39}
40
41struct CarouselFocusOut {
44 _subscription: Subscription,
45}
46
47impl Carousel {
48 pub fn new(id: impl Into<ElementId>, state: &Entity<CarouselState>) -> Self {
50 Self {
51 id: id.into(),
52 state: state.clone(),
53 style: StyleRefinement::default(),
54 accessibility_label: t!("Carousel.label").into(),
55 focus_ring_enabled: true,
56 children: Vec::new(),
57 }
58 }
59
60 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
62 self.accessibility_label = label.into();
63 self
64 }
65}
66
67impl ParentElement for Carousel {
68 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
69 self.children.extend(elements);
70 }
71}
72
73impl Styled for Carousel {
74 fn style(&mut self) -> &mut StyleRefinement {
75 &mut self.style
76 }
77}
78
79impl crate::FocusableExt for Carousel {
80 fn focus_ring(mut self, enabled: bool) -> Self {
81 self.focus_ring_enabled = enabled;
82 self
83 }
84
85 fn is_focus_ring_enabled(&self) -> bool {
86 self.focus_ring_enabled
87 }
88}
89
90impl RenderOnce for Carousel {
91 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
92 let observed_state = self.state.clone();
93 let _observer = window.use_keyed_state(
94 ("carousel-state-observer", self.state.entity_id()),
95 cx,
96 move |_, cx| CarouselStateObserver {
97 _subscription: cx.observe(&observed_state, |_, _, cx| cx.notify()),
98 },
99 );
100 let snapshot = self.state.read(cx);
101 let axis = snapshot.axis();
102 let frame_size = snapshot.frame_size();
103 let focus_handle = snapshot.focus_handle(cx);
104 let ring_suppressed = snapshot.is_focus_ring_suppressed();
105 let _focus_out =
106 window.use_keyed_state(("carousel-focus-out", self.state.entity_id()), cx, {
107 let state = self.state.clone();
108 let focus_handle = focus_handle.clone();
109 move |window, cx| CarouselFocusOut {
110 _subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| {
111 state.update(cx, |state, _| state.suppress_focus_ring(false));
112 }),
113 }
114 });
115 let is_focused = focus_handle.is_focused(window);
116 let focus_visible = is_focused && !ring_suppressed && self.focus_ring_enabled;
117 let previous_state = self.state.clone();
118 let next_state = self.state.clone();
119 let first_state = self.state.clone();
120 let last_state = self.state.clone();
121
122 div()
123 .id(self.id)
124 .relative()
125 .flex()
126 .flex_col()
127 .gap_4()
128 .role(Role::Region)
129 .aria_label(self.accessibility_label)
130 .track_focus(&focus_handle.tab_stop(true))
131 .key_context(CONTEXT)
132 .on_mouse_down(MouseButton::Left, {
133 let state = self.state.clone();
134 move |_, window, cx| {
135 if !is_focused && !window.default_prevented() {
138 state.update(cx, |state, _| state.suppress_focus_ring(true));
139 }
140 }
141 })
142 .on_action(
143 window.listener_for(&previous_state, move |state, _: &SelectLeft, _, cx| {
144 let handled = axis.is_horizontal() && state.select_previous(cx);
145 if !handled {
146 cx.propagate();
147 }
148 }),
149 )
150 .on_action(
151 window.listener_for(&next_state, move |state, _: &SelectRight, _, cx| {
152 let handled = axis.is_horizontal() && state.select_next(cx);
153 if !handled {
154 cx.propagate();
155 }
156 }),
157 )
158 .on_action(
159 window.listener_for(&previous_state, move |state, _: &SelectUp, _, cx| {
160 let handled = axis.is_vertical() && state.select_previous(cx);
161 if !handled {
162 cx.propagate();
163 }
164 }),
165 )
166 .on_action(
167 window.listener_for(&next_state, move |state, _: &SelectDown, _, cx| {
168 let handled = axis.is_vertical() && state.select_next(cx);
169 if !handled {
170 cx.propagate();
171 }
172 }),
173 )
174 .on_action(
175 window.listener_for(&first_state, |state, _: &SelectFirst, _, cx| {
176 if !state.select_first(cx) {
177 cx.propagate();
178 }
179 }),
180 )
181 .on_action(
182 window.listener_for(&last_state, |state, _: &SelectLast, _, cx| {
183 if !state.select_last(cx) {
184 cx.propagate();
185 }
186 }),
187 )
188 .children(self.children)
189 .when(focus_visible, |this| {
190 this.when_some(frame_size, |this, size| {
191 this.child(
192 div()
193 .absolute()
194 .top_0()
195 .left_0()
196 .w(size.width)
197 .h(size.height)
198 .border_1()
199 .border_color(cx.theme().transparent)
200 .rounded(cx.theme().radius)
201 .focus_ring_style(window, cx),
202 )
203 })
204 })
205 .refine_style(&self.style)
206 }
207}
208
209#[derive(Default, PartialEq)]
210struct CarouselGeometry {
211 viewport: Bounds<Pixels>,
212 frame: Bounds<Pixels>,
213 items: Vec<Bounds<Pixels>>,
214 has_runway: bool,
215 revision: usize,
216}
217
218impl CarouselGeometry {
219 fn read(
226 state: &CarouselState,
227 frame: Bounds<Pixels>,
228 has_runway: bool,
229 rendered_item_count: usize,
230 ) -> Self {
231 let handle = state.scroll_handle();
232 let item_offset = usize::from(has_runway);
233 let origin = frame.origin;
234 let relative = |bounds: Bounds<Pixels>| Bounds::new(bounds.origin - origin, bounds.size);
235 Self {
236 viewport: relative(handle.bounds()),
237 frame: Bounds::new(Point::default(), frame.size),
238 items: (0..state.item_count().min(rendered_item_count))
239 .filter_map(|ix| handle.bounds_for_item(ix + item_offset).map(&relative))
240 .collect(),
241 has_runway,
242 revision: 0,
243 }
244 }
245
246 fn same_layout(&self, other: &Self) -> bool {
249 self.has_runway == other.has_runway
250 && same_bounds(self.viewport, other.viewport)
251 && same_bounds(self.frame, other.frame)
252 && self.items.len() == other.items.len()
253 && self
254 .items
255 .iter()
256 .zip(&other.items)
257 .all(|(left, right)| same_bounds(*left, *right))
258 }
259}
260
261fn same_bounds(left: Bounds<Pixels>, right: Bounds<Pixels>) -> bool {
262 const TOLERANCE: Pixels = px(0.01);
263 (left.origin.x - right.origin.x).abs() <= TOLERANCE
264 && (left.origin.y - right.origin.y).abs() <= TOLERANCE
265 && (left.size.width - right.size.width).abs() <= TOLERANCE
266 && (left.size.height - right.size.height).abs() <= TOLERANCE
267}
268
269#[derive(IntoElement)]
271pub struct CarouselContent {
272 state: Entity<CarouselState>,
273 style: StyleRefinement,
274 track_style: StyleRefinement,
275 children: Vec<AnyElement>,
276}
277
278impl CarouselContent {
279 pub fn new(state: &Entity<CarouselState>) -> Self {
281 Self {
282 state: state.clone(),
283 style: StyleRefinement::default(),
284 track_style: StyleRefinement::default(),
285 children: Vec::new(),
286 }
287 }
288
289 pub fn track_style(mut self, style: StyleRefinement) -> Self {
294 self.track_style = style;
295 self
296 }
297}
298
299impl ParentElement for CarouselContent {
300 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
301 self.children.extend(elements);
302 }
303}
304
305impl Styled for CarouselContent {
306 fn style(&mut self) -> &mut StyleRefinement {
307 &mut self.style
308 }
309}
310
311struct CarouselLoopItem {
315 child: AnyElement,
316 index: usize,
317 state: Entity<CarouselState>,
318}
319
320impl IntoElement for CarouselLoopItem {
321 type Element = Self;
322
323 fn into_element(self) -> Self::Element {
324 self
325 }
326}
327
328impl Element for CarouselLoopItem {
329 type RequestLayoutState = ();
330 type PrepaintState = Point<Pixels>;
331
332 fn id(&self) -> Option<ElementId> {
333 None
334 }
335
336 fn source_location(&self) -> Option<&'static Location<'static>> {
337 None
338 }
339
340 fn request_layout(
341 &mut self,
342 _: Option<&GlobalElementId>,
343 _: Option<&InspectorElementId>,
344 window: &mut Window,
345 cx: &mut App,
346 ) -> (LayoutId, Self::RequestLayoutState) {
347 (self.child.request_layout(window, cx), ())
348 }
349
350 fn prepaint(
351 &mut self,
352 _: Option<&GlobalElementId>,
353 _: Option<&InspectorElementId>,
354 _: Bounds<Pixels>,
355 _: &mut Self::RequestLayoutState,
356 window: &mut Window,
357 cx: &mut App,
358 ) -> Self::PrepaintState {
359 let offset = self.state.read(cx).loop_item_offset(self.index);
360 window.with_element_offset(offset, |window| {
361 self.child.prepaint(window, cx);
362 });
363 offset
364 }
365
366 fn paint(
367 &mut self,
368 _: Option<&GlobalElementId>,
369 _: Option<&InspectorElementId>,
370 _: Bounds<Pixels>,
371 _: &mut Self::RequestLayoutState,
372 _: &mut Self::PrepaintState,
373 window: &mut Window,
374 cx: &mut App,
375 ) {
376 self.child.paint(window, cx);
377 }
378}
379
380impl RenderOnce for CarouselContent {
381 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
382 let entity_id = self.state.entity_id();
383 let snapshot = self.state.read(cx);
384 let axis = snapshot.axis();
385 let selected_ix = snapshot.selected_index();
386 let item_count = snapshot.item_count();
387 let handle = snapshot.scroll_handle().clone();
388 let interacting = snapshot.is_interacting();
389 let motion_revision = snapshot.motion_revision();
390 let loop_runway = snapshot.loop_runway();
391 let loop_layout_transitioning = snapshot.is_loop_layout_transitioning();
392 let state_snap_target = selected_ix.and_then(|ix| snapshot.motion_target_for(ix));
393
394 let geometry = window.use_keyed_state(
395 ElementId::NamedChild(
396 Arc::new(("carousel-geometry", entity_id).into()),
397 "content".into(),
398 ),
399 cx,
400 |_, _| CarouselGeometry::default(),
401 );
402 let geometry_revision = geometry.read(cx).revision;
403
404 let current = axis_value(handle.offset(), axis);
405 let target = if loop_layout_transitioning {
406 current
407 } else {
408 state_snap_target
409 .map(|target| axis_value(target, axis))
410 .or_else(|| {
411 selected_ix.and_then(|ix| {
412 snap_offset(&handle, axis, ix + usize::from(loop_runway.is_some()))
413 })
414 })
415 .unwrap_or(current)
416 };
417 let target = if interacting { current } else { target };
418 let snap_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5);
419 let animated = spring(
420 (
421 ("carousel-content", entity_id),
422 SharedString::from(format!("offset-{motion_revision}-{geometry_revision}")),
423 ),
424 target.as_f32(),
425 snap_spring.with_travel(!interacting),
426 window,
427 cx,
428 );
429 let mut offset = handle.offset();
430 set_axis_value(&mut offset, axis, px(animated));
431 set_axis_value(
432 &mut offset,
433 if axis.is_horizontal() {
434 Axis::Vertical
435 } else {
436 Axis::Horizontal
437 },
438 Pixels::ZERO,
439 );
440 handle.set_offset(offset);
441 if !interacting {
442 let rendered = offset;
443 if let Some(rebased) = self
444 .state
445 .update(cx, |state, cx| state.settle_loop_motion(rendered, cx))
446 {
447 offset = rebased;
448 handle.set_offset(offset);
449 }
450 }
451
452 let geometry_state = self.state.clone();
453 let viewport_id: ElementId = ("carousel-content", entity_id).into();
454
455 let rendered_item_count = self.children.len();
456 let loop_state = self.state.clone();
457 let children = self
458 .children
459 .into_iter()
460 .enumerate()
461 .map(move |(index, child)| CarouselLoopItem {
462 child,
463 index,
464 state: loop_state.clone(),
465 });
466 let runway_spacer = |runway: Pixels| {
467 div()
468 .flex_none()
469 .when(axis.is_horizontal(), |this| this.w(runway))
470 .when(axis.is_vertical(), |this| this.h(runway))
471 };
472 let has_runway = loop_runway.is_some();
473
474 div()
475 .relative()
476 .w_full()
477 .flex()
478 .when(axis.is_horizontal(), |this| this.flex_row())
479 .when(axis.is_vertical(), |this| this.flex_col())
480 .refine_style(&self.style)
481 .overflow_hidden()
482 .child(
483 div()
486 .id(viewport_id.clone())
487 .flex_1()
488 .flex()
489 .when(axis.is_horizontal(), |this| {
490 this.flex_row().min_w_0().ml_neg_4()
491 })
492 .when(axis.is_vertical(), |this| {
493 this.flex_col().min_h_0().mt_neg_4()
494 })
495 .track_scroll(&handle)
496 .when_some(loop_runway, |this, runway| {
497 this.child(runway_spacer(runway))
498 })
499 .children(children)
500 .when_some(loop_runway, |this, runway| {
501 this.child(runway_spacer(runway))
502 })
503 .refine_style(&self.track_style),
504 )
505 .child(CarouselScrollMask::new(axis, &self.state).id(viewport_id))
506 .on_prepaint(move |frame, _, cx| {
507 let next = CarouselGeometry::read(
508 geometry_state.read(cx),
509 frame,
510 has_runway,
511 rendered_item_count,
512 );
513 if !geometry.read(cx).same_layout(&next) {
514 geometry_state.update(cx, |state, _| {
515 state.set_geometry_with_runway(
516 next.viewport,
517 next.frame,
518 next.items.clone(),
519 next.has_runway,
520 );
521 });
522 geometry.update(cx, |current, cx| {
523 current.viewport = next.viewport;
524 current.frame = next.frame;
525 current.items = next.items;
526 current.has_runway = next.has_runway;
527 current.revision = current.revision.wrapping_add(1);
528 cx.notify();
529 });
530 }
531 })
532 .when(item_count == 0, |this| this.invisible())
533 }
534}
535
536#[derive(IntoElement)]
538pub struct CarouselItem {
539 id: ElementId,
540 index: usize,
541 state: Entity<CarouselState>,
542 style: StyleRefinement,
543 accessibility_label: Option<SharedString>,
544 children: Vec<AnyElement>,
545}
546
547impl CarouselItem {
548 pub fn new(id: impl Into<ElementId>, index: usize, state: &Entity<CarouselState>) -> Self {
550 Self {
551 id: id.into(),
552 index,
553 state: state.clone(),
554 style: StyleRefinement::default(),
555 accessibility_label: None,
556 children: Vec::new(),
557 }
558 }
559
560 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
562 self.accessibility_label = Some(label.into());
563 self
564 }
565}
566
567impl ParentElement for CarouselItem {
568 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
569 self.children.extend(elements);
570 }
571}
572
573impl Styled for CarouselItem {
574 fn style(&mut self) -> &mut StyleRefinement {
575 &mut self.style
576 }
577}
578
579impl RenderOnce for CarouselItem {
580 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
581 let state = self.state.read(cx);
582 let axis = state.axis();
583 let count = state.item_count();
584 let label = self.accessibility_label.unwrap_or_else(|| {
585 t!(
586 "Carousel.slide",
587 current = self.index.saturating_add(1),
588 total = count
589 )
590 .into()
591 });
592
593 div()
594 .id(self.id)
595 .role(Role::Group)
596 .aria_label(label)
597 .aria_position_in_set(self.index.saturating_add(1))
598 .aria_size_of_set(count)
599 .min_w_0()
600 .min_h_0()
601 .flex_none()
602 .when(axis.is_horizontal(), |this| this.w_full().pl_4())
603 .when(axis.is_vertical(), |this| this.h_full().pt_4())
604 .children(self.children)
605 .refine_style(&self.style)
606 }
607}
608
609#[derive(IntoElement)]
611pub struct CarouselPrevious {
612 state: Entity<CarouselState>,
613 size: Size,
614 style: StyleRefinement,
615 accessibility_label: Option<SharedString>,
616 children: Vec<AnyElement>,
617}
618
619impl CarouselPrevious {
620 pub fn new(state: &Entity<CarouselState>) -> Self {
622 Self {
623 state: state.clone(),
624 size: Size::Medium,
625 style: StyleRefinement::default(),
626 accessibility_label: None,
627 children: Vec::new(),
628 }
629 }
630
631 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
633 self.accessibility_label = Some(label.into());
634 self
635 }
636}
637
638impl crate::Sizable for CarouselPrevious {
639 fn with_size(mut self, size: impl Into<Size>) -> Self {
640 self.size = size.into();
641 self
642 }
643}
644
645impl Styled for CarouselPrevious {
646 fn style(&mut self) -> &mut StyleRefinement {
647 &mut self.style
648 }
649}
650
651impl ParentElement for CarouselPrevious {
652 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
653 self.children.extend(elements);
654 }
655}
656
657impl RenderOnce for CarouselPrevious {
658 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
659 carousel_control(
660 self.state,
661 self.size,
662 self.style,
663 self.accessibility_label,
664 self.children,
665 false,
666 cx,
667 )
668 }
669}
670
671#[derive(IntoElement)]
673pub struct CarouselNext {
674 state: Entity<CarouselState>,
675 size: Size,
676 style: StyleRefinement,
677 accessibility_label: Option<SharedString>,
678 children: Vec<AnyElement>,
679}
680
681impl CarouselNext {
682 pub fn new(state: &Entity<CarouselState>) -> Self {
684 Self {
685 state: state.clone(),
686 size: Size::Medium,
687 style: StyleRefinement::default(),
688 accessibility_label: None,
689 children: Vec::new(),
690 }
691 }
692
693 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
695 self.accessibility_label = Some(label.into());
696 self
697 }
698}
699
700impl crate::Sizable for CarouselNext {
701 fn with_size(mut self, size: impl Into<Size>) -> Self {
702 self.size = size.into();
703 self
704 }
705}
706
707impl Styled for CarouselNext {
708 fn style(&mut self) -> &mut StyleRefinement {
709 &mut self.style
710 }
711}
712
713impl ParentElement for CarouselNext {
714 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
715 self.children.extend(elements);
716 }
717}
718
719impl RenderOnce for CarouselNext {
720 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
721 carousel_control(
722 self.state,
723 self.size,
724 self.style,
725 self.accessibility_label,
726 self.children,
727 true,
728 cx,
729 )
730 }
731}
732
733fn carousel_control(
734 state: Entity<CarouselState>,
735 size: Size,
736 style: StyleRefinement,
737 accessibility_label: Option<SharedString>,
738 children: Vec<AnyElement>,
739 next: bool,
740 cx: &mut App,
741) -> impl IntoElement {
742 let snapshot = state.read(cx);
743 let axis = snapshot.axis();
744 let frame_size = snapshot.frame_size();
745 let focus_handle = snapshot.focus_handle(cx);
746 let disabled = if next {
747 !snapshot.has_next()
748 } else {
749 !snapshot.has_previous()
750 };
751 let (name, default_label, icon) = match (axis, next) {
752 (Axis::Horizontal, false) => ("previous", t!("Carousel.previous"), IconName::ChevronLeft),
753 (Axis::Horizontal, true) => ("next", t!("Carousel.next"), IconName::ChevronRight),
754 (Axis::Vertical, false) => ("previous", t!("Carousel.previous"), IconName::ChevronUp),
755 (Axis::Vertical, true) => ("next", t!("Carousel.next"), IconName::ChevronDown),
756 };
757 let label = accessibility_label.unwrap_or_else(|| default_label.into());
758 let has_custom_content = !children.is_empty();
759 let id = ElementId::NamedChild(
760 Arc::new(("carousel-control", state.entity_id()).into()),
761 name.into(),
762 );
763
764 div()
765 .absolute()
766 .top_0()
767 .left_0()
768 .when_some(frame_size, |this, size| this.w(size.width).h(size.height))
769 .when(frame_size.is_none(), |this| this.right_0().bottom_0())
770 .child(
771 Button::new(id)
772 .outline()
773 .with_size(size)
774 .when(!has_custom_content, |this| this.icon(icon))
775 .accessibility_label(label.clone())
776 .tooltip(label)
777 .disabled(disabled)
778 .absolute()
779 .rounded_full_style(cx)
780 .when(axis.is_horizontal() && !next, |this| {
781 this.right_full().mr_4().top_0().bottom_0().my_auto()
782 })
783 .when(axis.is_horizontal() && next, |this| {
784 this.left_full().ml_4().top_0().bottom_0().my_auto()
785 })
786 .when(axis.is_vertical() && !next, |this| {
787 this.bottom_full().mb_4().left_0().right_0().mx_auto()
788 })
789 .when(axis.is_vertical() && next, |this| {
790 this.top_full().mt_4().left_0().right_0().mx_auto()
791 })
792 .when(!disabled, |this| {
793 this.on_click(move |event, window, cx| {
794 state.update(cx, |state, cx| {
795 if next {
796 state.select_next(cx);
797 } else {
798 state.select_previous(cx);
799 }
800 });
801 focus_after_pointer_click(&state, &focus_handle, event, window, cx);
802 })
803 })
804 .children(children)
805 .refine_style(&style),
806 )
807}
808
809#[derive(IntoElement)]
811pub struct CarouselPagination {
812 id: ElementId,
813 style: StyleRefinement,
814 accessibility_label: SharedString,
815 children: Vec<AnyElement>,
816}
817
818impl CarouselPagination {
819 #[track_caller]
821 pub fn new() -> Self {
822 Self {
823 id: ElementId::CodeLocation(*Location::caller()),
824 style: StyleRefinement::default(),
825 accessibility_label: t!("Carousel.pagination").into(),
826 children: Vec::new(),
827 }
828 }
829
830 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
832 self.accessibility_label = label.into();
833 self
834 }
835}
836
837impl ParentElement for CarouselPagination {
838 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
839 self.children.extend(elements);
840 }
841}
842
843impl Styled for CarouselPagination {
844 fn style(&mut self) -> &mut StyleRefinement {
845 &mut self.style
846 }
847}
848
849impl RenderOnce for CarouselPagination {
850 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
851 div()
852 .id(self.id)
853 .role(Role::Group)
854 .aria_label(self.accessibility_label)
855 .flex()
856 .items_center()
857 .justify_center()
858 .gap_2()
859 .children(self.children)
860 .refine_style(&self.style)
861 }
862}
863
864#[derive(IntoElement)]
866pub struct CarouselPaginationItem {
867 id: ElementId,
868 index: usize,
869 state: Entity<CarouselState>,
870 size: Size,
871 style: StyleRefinement,
872 accessibility_label: Option<SharedString>,
873 children: Vec<AnyElement>,
874}
875
876impl CarouselPaginationItem {
877 pub fn new(id: impl Into<ElementId>, index: usize, state: &Entity<CarouselState>) -> Self {
879 Self {
880 id: id.into(),
881 index,
882 state: state.clone(),
883 size: Size::XSmall,
884 style: StyleRefinement::default(),
885 accessibility_label: None,
886 children: Vec::new(),
887 }
888 }
889
890 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
892 self.accessibility_label = Some(label.into());
893 self
894 }
895}
896
897impl crate::Sizable for CarouselPaginationItem {
898 fn with_size(mut self, size: impl Into<Size>) -> Self {
899 self.size = size.into();
900 self
901 }
902}
903
904impl ParentElement for CarouselPaginationItem {
905 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
906 self.children.extend(elements);
907 }
908}
909
910impl Styled for CarouselPaginationItem {
911 fn style(&mut self) -> &mut StyleRefinement {
912 &mut self.style
913 }
914}
915
916impl RenderOnce for CarouselPaginationItem {
917 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
918 let selected = self.state.read(cx).selected_index() == Some(self.index);
919 let disabled = self.index >= self.state.read(cx).item_count();
920 let focus_handle = self.state.read(cx).focus_handle(cx);
921 let label = self.accessibility_label.unwrap_or_else(|| {
922 t!(
923 "Carousel.go_to_slide",
924 current = self.index.saturating_add(1)
925 )
926 .into()
927 });
928 let state = self.state;
929 let index = self.index;
930
931 Button::new(self.id)
932 .compact()
933 .with_size(self.size)
934 .selected(selected)
935 .accessibility_label(label)
936 .disabled(disabled)
937 .children(self.children)
938 .when(!disabled, |this| {
939 this.on_click(move |event, window, cx| {
940 state.update(cx, |state, cx| {
941 state.select_index(index, cx);
942 });
943 focus_after_pointer_click(&state, &focus_handle, event, window, cx);
944 })
945 })
946 .refine_style(&self.style)
947 }
948}
949
950fn focus_after_pointer_click(
954 state: &Entity<CarouselState>,
955 focus_handle: &FocusHandle,
956 event: &ClickEvent,
957 window: &mut Window,
958 cx: &mut App,
959) {
960 if event.is_keyboard() || focus_handle.contains_focused(window, cx) {
961 return;
962 }
963 state.update(cx, |state, _| state.suppress_focus_ring(true));
964 window.focus(focus_handle, cx);
965}
966
967fn axis_value(point: Point<Pixels>, axis: Axis) -> Pixels {
968 if axis.is_horizontal() {
969 point.x
970 } else {
971 point.y
972 }
973}
974
975fn set_axis_value(point: &mut Point<Pixels>, axis: Axis, value: Pixels) {
976 if axis.is_horizontal() {
977 point.x = value;
978 } else {
979 point.y = value;
980 }
981}
982
983fn snap_offset(handle: &gpui::ScrollHandle, axis: Axis, index: usize) -> Option<Pixels> {
984 let viewport = handle.bounds();
985 let item = handle.bounds_for_item(index)?;
986 let target = if axis.is_horizontal() {
987 viewport.left() - item.left()
988 } else {
989 viewport.top() - item.top()
990 };
991 let max = axis_value(handle.max_offset(), axis).max(Pixels::ZERO);
992 Some(target.clamp(-max, Pixels::ZERO))
993}
994
995#[cfg(test)]
996mod tests {
997 use std::{cell::Cell, rc::Rc};
998
999 use super::*;
1000 use gpui::{AppContext as _, Context, Render, VisualTestContext, point};
1001 use gpui_base::FocusableExt as _;
1002
1003 #[test]
1004 fn axis_helpers_only_change_the_requested_coordinate() {
1005 let mut value = point(px(3.), px(7.));
1006 set_axis_value(&mut value, Axis::Horizontal, px(11.));
1007 assert_eq!(value, point(px(11.), px(7.)));
1008 set_axis_value(&mut value, Axis::Vertical, px(-5.));
1009 assert_eq!(value, point(px(11.), px(-5.)));
1010 }
1011
1012 #[gpui::test]
1013 fn carousel_controls_accept_semantic_sizes(cx: &mut gpui::TestAppContext) {
1014 let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
1015
1016 assert_eq!(CarouselPrevious::new(&state).size, Size::Medium);
1017 assert_eq!(CarouselNext::new(&state).size, Size::Medium);
1018 assert_eq!(CarouselPrevious::new(&state).large().size, Size::Large);
1019 assert_eq!(CarouselNext::new(&state).xsmall().size, Size::XSmall);
1020 assert_eq!(
1021 CarouselPaginationItem::new("pagination", 0, &state)
1022 .small()
1023 .size,
1024 Size::Small
1025 );
1026 }
1027
1028 #[gpui::test]
1029 fn carousel_focus_ring_is_configurable(cx: &mut gpui::TestAppContext) {
1030 let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
1031
1032 assert!(Carousel::new("carousel", &state).is_focus_ring_enabled());
1033 assert!(
1034 !Carousel::new("carousel", &state)
1035 .focus_ring(false)
1036 .is_focus_ring_enabled()
1037 );
1038 }
1039
1040 struct KeyboardHarness {
1041 state: Entity<CarouselState>,
1042 }
1043
1044 impl Render for KeyboardHarness {
1045 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1046 div().tab_group().child(
1047 Carousel::new("carousel", &self.state)
1048 .w(px(100.))
1049 .h(px(100.))
1050 .child(
1051 CarouselContent::new(&self.state)
1052 .h(px(100.))
1053 .children((0..3).map(|index| {
1054 CarouselItem::new(("carousel-item", index), index, &self.state)
1055 .child(index.to_string())
1056 })),
1057 ),
1058 )
1059 }
1060 }
1061
1062 fn assert_contextual_navigation_keys(cx: &mut gpui::TestAppContext, axis: Axis) {
1063 cx.update(crate::init);
1064 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3).with_axis(axis)));
1065 let (_, cx) = cx.add_window_view({
1066 let state = state.clone();
1067 move |_, _| KeyboardHarness { state }
1068 });
1069 cx.update(|window, cx| window.draw(cx).clear(cx));
1070
1071 cx.update(|window, cx| window.focus_next(cx));
1072 let (primary, secondary) = if axis.is_horizontal() {
1073 ("right", "down")
1074 } else {
1075 ("down", "right")
1076 };
1077 cx.simulate_keystrokes(primary);
1078 assert_eq!(
1079 state.read_with(cx, |state, _| state.selected_index()),
1080 Some(1)
1081 );
1082 cx.simulate_keystrokes(secondary);
1083 assert_eq!(
1084 state.read_with(cx, |state, _| state.selected_index()),
1085 Some(1)
1086 );
1087 cx.simulate_keystrokes("end");
1088 assert_eq!(
1089 state.read_with(cx, |state, _| state.selected_index()),
1090 Some(2)
1091 );
1092 cx.simulate_keystrokes("home");
1093 assert_eq!(
1094 state.read_with(cx, |state, _| state.selected_index()),
1095 Some(0)
1096 );
1097 }
1098
1099 #[gpui::test]
1100 fn horizontal_carousel_dispatches_contextual_navigation_keys(cx: &mut gpui::TestAppContext) {
1101 assert_contextual_navigation_keys(cx, Axis::Horizontal);
1102 }
1103
1104 #[gpui::test]
1105 fn vertical_carousel_dispatches_contextual_navigation_keys(cx: &mut gpui::TestAppContext) {
1106 assert_contextual_navigation_keys(cx, Axis::Vertical);
1107 }
1108
1109 struct PropagationHarness {
1110 state: Entity<CarouselState>,
1111 outer_actions: Rc<Cell<usize>>,
1112 }
1113
1114 impl Render for PropagationHarness {
1115 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1116 let down = self.outer_actions.clone();
1117 let left = self.outer_actions.clone();
1118 let right = self.outer_actions.clone();
1119 div()
1120 .tab_group()
1121 .on_action(move |_: &SelectDown, _, _| down.set(down.get() + 1))
1122 .on_action(move |_: &SelectLeft, _, _| left.set(left.get() + 1))
1123 .on_action(move |_: &SelectRight, _, _| right.set(right.get() + 1))
1124 .child(
1125 Carousel::new("carousel", &self.state)
1126 .w(px(100.))
1127 .h(px(100.))
1128 .child(
1129 CarouselContent::new(&self.state)
1130 .h(px(100.))
1131 .children((0..3).map(|index| {
1132 CarouselItem::new(("carousel-item", index), index, &self.state)
1133 .child(index.to_string())
1134 })),
1135 ),
1136 )
1137 }
1138 }
1139
1140 #[gpui::test]
1141 fn unhandled_navigation_keys_reach_ancestors(cx: &mut gpui::TestAppContext) {
1142 cx.update(crate::init);
1143 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1144 let outer_actions = Rc::new(Cell::new(0));
1145 let (_, cx) = cx.add_window_view({
1146 let state = state.clone();
1147 let outer_actions = outer_actions.clone();
1148 move |_, _| PropagationHarness {
1149 state,
1150 outer_actions,
1151 }
1152 });
1153 cx.update(|window, cx| window.draw(cx).clear(cx));
1154 cx.update(|window, cx| window.focus_next(cx));
1155
1156 cx.simulate_keystrokes("down");
1157 assert_eq!(outer_actions.get(), 1);
1158
1159 cx.simulate_keystrokes("left");
1160 assert_eq!(outer_actions.get(), 2);
1161 assert_eq!(
1162 state.read_with(cx, |state, _| state.selected_index()),
1163 Some(0)
1164 );
1165
1166 cx.simulate_keystrokes("right");
1167 assert_eq!(
1168 state.read_with(cx, |state, _| state.selected_index()),
1169 Some(1)
1170 );
1171 assert_eq!(outer_actions.get(), 2);
1172 }
1173
1174 #[gpui::test]
1175 fn track_grows_by_its_leading_margin_so_items_fill_the_frame(cx: &mut gpui::TestAppContext) {
1176 cx.update(crate::init);
1177 for axis in [Axis::Horizontal, Axis::Vertical] {
1178 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3).with_axis(axis)));
1179 let (_, cx) = cx.add_window_view({
1180 let state = state.clone();
1181 move |_, _| KeyboardHarness { state }
1182 });
1183 cx.update(|window, cx| window.draw(cx).clear(cx));
1184
1185 let (track, first_item, frame_size) = state.read_with(cx, |state, _| {
1186 let handle = state.scroll_handle();
1187 (
1188 handle.bounds(),
1189 handle.bounds_for_item(0).unwrap(),
1190 state.frame_size().unwrap(),
1191 )
1192 });
1193 assert_eq!(frame_size, gpui::size(px(100.), px(100.)), "{axis:?}");
1194 let expected = if axis.is_horizontal() {
1195 Bounds::new(point(px(-16.), px(0.)), gpui::size(px(116.), px(100.)))
1196 } else {
1197 Bounds::new(point(px(0.), px(-16.)), gpui::size(px(100.), px(116.)))
1198 };
1199 assert_eq!(track, expected, "{axis:?}");
1200 assert_eq!(first_item, expected, "{axis:?}");
1201 }
1202 }
1203
1204 #[gpui::test]
1205 fn clicking_a_slide_focuses_the_carousel_for_keyboard_navigation(
1206 cx: &mut gpui::TestAppContext,
1207 ) {
1208 cx.update(crate::init);
1209 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1210 let (_, cx) = cx.add_window_view({
1211 let state = state.clone();
1212 move |_, _| KeyboardHarness { state }
1213 });
1214 cx.update(|window, cx| window.draw(cx).clear(cx));
1215 assert!(cx.update(|window, cx| window.focused(cx).is_none()));
1216
1217 cx.simulate_click(point(px(50.), px(50.)), gpui::Modifiers::default());
1218 assert!(cx.update(|window, cx| window.focused(cx).is_some()));
1219
1220 cx.simulate_keystrokes("right");
1221 assert_eq!(
1222 state.read_with(cx, |state, _| state.selected_index()),
1223 Some(1)
1224 );
1225 }
1226
1227 struct ControlsHarness {
1228 state: Entity<CarouselState>,
1229 }
1230
1231 impl Render for ControlsHarness {
1232 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1233 div().tab_group().child(
1234 Carousel::new("carousel", &self.state)
1235 .w(px(100.))
1236 .h(px(100.))
1237 .child(
1238 CarouselContent::new(&self.state)
1239 .h(px(100.))
1240 .children((0..3).map(|index| {
1241 CarouselItem::new(("carousel-item", index), index, &self.state)
1242 .child(index.to_string())
1243 })),
1244 )
1245 .child(CarouselPrevious::new(&self.state))
1246 .child(CarouselNext::new(&self.state)),
1247 )
1248 }
1249 }
1250
1251 #[gpui::test]
1252 fn clicking_a_control_focuses_the_carousel_for_keyboard_navigation(
1253 cx: &mut gpui::TestAppContext,
1254 ) {
1255 cx.update(crate::init);
1256 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1257 let (_, cx) = cx.add_window_view({
1258 let state = state.clone();
1259 move |_, _| ControlsHarness { state }
1260 });
1261 cx.update(|window, cx| window.draw(cx).clear(cx));
1262 let selected =
1263 |cx: &mut VisualTestContext| state.read_with(cx, |state, _| state.selected_index());
1264 let root_focused = |cx: &mut VisualTestContext| {
1265 cx.update(|window, cx| state.read(cx).focus_handle(cx).is_focused(window))
1266 };
1267
1268 cx.simulate_click(point(px(134.), px(50.)), gpui::Modifiers::default());
1270 assert_eq!(selected(cx), Some(1));
1271 assert!(root_focused(cx));
1272 cx.simulate_keystrokes("right");
1273 assert_eq!(selected(cx), Some(2));
1274
1275 cx.simulate_keystrokes("left");
1277 cx.update(|window, cx| window.focus_next(cx));
1278 cx.update(|window, cx| window.focus_next(cx));
1279 cx.update(|window, cx| window.draw(cx).clear(cx));
1280 let keystroke = gpui::Keystroke::parse("enter").unwrap();
1281 cx.simulate_event(gpui::KeyDownEvent {
1282 keystroke: keystroke.clone(),
1283 is_held: false,
1284 prefer_character_input: false,
1285 });
1286 cx.simulate_event(gpui::KeyUpEvent { keystroke });
1287 assert_eq!(selected(cx), Some(2));
1288 assert!(!root_focused(cx));
1289 assert!(cx.update(|window, cx| window.focused(cx).is_some()));
1290 }
1291
1292 #[gpui::test]
1293 fn carousel_controls_accept_accessibility_labels_and_children(cx: &mut gpui::TestAppContext) {
1294 let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
1295 let previous = CarouselPrevious::new(&state)
1296 .accessibility_label("Previous project")
1297 .child("Back");
1298 let next = CarouselNext::new(&state)
1299 .accessibility_label("Next project")
1300 .child("Forward");
1301
1302 assert_eq!(
1303 previous.accessibility_label.as_deref(),
1304 Some("Previous project")
1305 );
1306 assert_eq!(next.accessibility_label.as_deref(), Some("Next project"));
1307 assert_eq!(previous.children.len(), 1);
1308 assert_eq!(next.children.len(), 1);
1309 }
1310}