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(
220 state: &CarouselState,
221 frame: Bounds<Pixels>,
222 has_runway: bool,
223 rendered_item_count: usize,
224 ) -> Self {
225 let handle = state.scroll_handle();
226 let item_offset = usize::from(has_runway);
227 Self {
228 viewport: handle.bounds(),
229 frame,
230 items: (0..state.item_count().min(rendered_item_count))
231 .filter_map(|ix| handle.bounds_for_item(ix + item_offset))
232 .collect(),
233 has_runway,
234 revision: 0,
235 }
236 }
237
238 fn same_layout(&self, other: &Self) -> bool {
239 self.viewport == other.viewport
240 && self.frame == other.frame
241 && self.items == other.items
242 && self.has_runway == other.has_runway
243 }
244}
245
246#[derive(IntoElement)]
248pub struct CarouselContent {
249 state: Entity<CarouselState>,
250 style: StyleRefinement,
251 track_style: StyleRefinement,
252 children: Vec<AnyElement>,
253}
254
255impl CarouselContent {
256 pub fn new(state: &Entity<CarouselState>) -> Self {
258 Self {
259 state: state.clone(),
260 style: StyleRefinement::default(),
261 track_style: StyleRefinement::default(),
262 children: Vec::new(),
263 }
264 }
265
266 pub fn track_style(mut self, style: StyleRefinement) -> Self {
271 self.track_style = style;
272 self
273 }
274}
275
276impl ParentElement for CarouselContent {
277 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
278 self.children.extend(elements);
279 }
280}
281
282impl Styled for CarouselContent {
283 fn style(&mut self) -> &mut StyleRefinement {
284 &mut self.style
285 }
286}
287
288struct CarouselLoopItem {
292 child: AnyElement,
293 index: usize,
294 state: Entity<CarouselState>,
295}
296
297impl IntoElement for CarouselLoopItem {
298 type Element = Self;
299
300 fn into_element(self) -> Self::Element {
301 self
302 }
303}
304
305impl Element for CarouselLoopItem {
306 type RequestLayoutState = ();
307 type PrepaintState = Point<Pixels>;
308
309 fn id(&self) -> Option<ElementId> {
310 None
311 }
312
313 fn source_location(&self) -> Option<&'static Location<'static>> {
314 None
315 }
316
317 fn request_layout(
318 &mut self,
319 _: Option<&GlobalElementId>,
320 _: Option<&InspectorElementId>,
321 window: &mut Window,
322 cx: &mut App,
323 ) -> (LayoutId, Self::RequestLayoutState) {
324 (self.child.request_layout(window, cx), ())
325 }
326
327 fn prepaint(
328 &mut self,
329 _: Option<&GlobalElementId>,
330 _: Option<&InspectorElementId>,
331 _: Bounds<Pixels>,
332 _: &mut Self::RequestLayoutState,
333 window: &mut Window,
334 cx: &mut App,
335 ) -> Self::PrepaintState {
336 let offset = self.state.read(cx).loop_item_offset(self.index);
337 window.with_element_offset(offset, |window| {
338 self.child.prepaint(window, cx);
339 });
340 offset
341 }
342
343 fn paint(
344 &mut self,
345 _: Option<&GlobalElementId>,
346 _: Option<&InspectorElementId>,
347 _: Bounds<Pixels>,
348 _: &mut Self::RequestLayoutState,
349 _: &mut Self::PrepaintState,
350 window: &mut Window,
351 cx: &mut App,
352 ) {
353 self.child.paint(window, cx);
354 }
355}
356
357impl RenderOnce for CarouselContent {
358 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
359 let entity_id = self.state.entity_id();
360 let snapshot = self.state.read(cx);
361 let axis = snapshot.axis();
362 let selected_ix = snapshot.selected_index();
363 let item_count = snapshot.item_count();
364 let handle = snapshot.scroll_handle().clone();
365 let interacting = snapshot.is_interacting();
366 let motion_revision = snapshot.motion_revision();
367 let loop_runway = snapshot.loop_runway();
368 let loop_layout_transitioning = snapshot.is_loop_layout_transitioning();
369 let state_snap_target = selected_ix.and_then(|ix| snapshot.motion_target_for(ix));
370
371 let geometry = window.use_keyed_state(
372 ElementId::NamedChild(
373 Arc::new(("carousel-geometry", entity_id).into()),
374 "content".into(),
375 ),
376 cx,
377 |_, _| CarouselGeometry::default(),
378 );
379 let geometry_revision = geometry.read(cx).revision;
380
381 let current = axis_value(handle.offset(), axis);
382 let target = if loop_layout_transitioning {
383 current
384 } else {
385 state_snap_target
386 .map(|target| axis_value(target, axis))
387 .or_else(|| {
388 selected_ix.and_then(|ix| {
389 snap_offset(&handle, axis, ix + usize::from(loop_runway.is_some()))
390 })
391 })
392 .unwrap_or(current)
393 };
394 let target = if interacting { current } else { target };
395 let snap_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5);
396 let animated = spring(
397 (
398 ("carousel-content", entity_id),
399 SharedString::from(format!("offset-{motion_revision}-{geometry_revision}")),
400 ),
401 target.as_f32(),
402 snap_spring.with_travel(!interacting),
403 window,
404 cx,
405 );
406 let mut offset = handle.offset();
407 set_axis_value(&mut offset, axis, px(animated));
408 set_axis_value(
409 &mut offset,
410 if axis.is_horizontal() {
411 Axis::Vertical
412 } else {
413 Axis::Horizontal
414 },
415 Pixels::ZERO,
416 );
417 handle.set_offset(offset);
418 if !interacting {
419 let rendered = offset;
420 if let Some(rebased) = self
421 .state
422 .update(cx, |state, cx| state.settle_loop_motion(rendered, cx))
423 {
424 offset = rebased;
425 handle.set_offset(offset);
426 }
427 }
428
429 let geometry_state = self.state.clone();
430 let viewport_id: ElementId = ("carousel-content", entity_id).into();
431
432 let rendered_item_count = self.children.len();
433 let loop_state = self.state.clone();
434 let children = self
435 .children
436 .into_iter()
437 .enumerate()
438 .map(move |(index, child)| CarouselLoopItem {
439 child,
440 index,
441 state: loop_state.clone(),
442 });
443 let runway_spacer = |runway: Pixels| {
444 div()
445 .flex_none()
446 .when(axis.is_horizontal(), |this| this.w(runway))
447 .when(axis.is_vertical(), |this| this.h(runway))
448 };
449 let has_runway = loop_runway.is_some();
450
451 div()
452 .relative()
453 .w_full()
454 .flex()
455 .when(axis.is_horizontal(), |this| this.flex_row())
456 .when(axis.is_vertical(), |this| this.flex_col())
457 .refine_style(&self.style)
458 .overflow_hidden()
459 .child(
460 div()
463 .id(viewport_id.clone())
464 .flex_1()
465 .flex()
466 .when(axis.is_horizontal(), |this| {
467 this.flex_row().min_w_0().ml_neg_4()
468 })
469 .when(axis.is_vertical(), |this| {
470 this.flex_col().min_h_0().mt_neg_4()
471 })
472 .track_scroll(&handle)
473 .when_some(loop_runway, |this, runway| {
474 this.child(runway_spacer(runway))
475 })
476 .children(children)
477 .when_some(loop_runway, |this, runway| {
478 this.child(runway_spacer(runway))
479 })
480 .refine_style(&self.track_style),
481 )
482 .child(CarouselScrollMask::new(axis, &self.state).id(viewport_id))
483 .on_prepaint(move |frame, _, cx| {
484 let next = CarouselGeometry::read(
485 geometry_state.read(cx),
486 frame,
487 has_runway,
488 rendered_item_count,
489 );
490 if !geometry.read(cx).same_layout(&next) {
491 geometry_state.update(cx, |state, _| {
492 state.set_geometry_with_runway(
493 next.viewport,
494 next.frame,
495 next.items.clone(),
496 next.has_runway,
497 );
498 });
499 geometry.update(cx, |current, cx| {
500 current.viewport = next.viewport;
501 current.frame = next.frame;
502 current.items = next.items;
503 current.has_runway = next.has_runway;
504 current.revision = current.revision.wrapping_add(1);
505 cx.notify();
506 });
507 }
508 })
509 .when(item_count == 0, |this| this.invisible())
510 }
511}
512
513#[derive(IntoElement)]
515pub struct CarouselItem {
516 id: ElementId,
517 index: usize,
518 state: Entity<CarouselState>,
519 style: StyleRefinement,
520 accessibility_label: Option<SharedString>,
521 children: Vec<AnyElement>,
522}
523
524impl CarouselItem {
525 pub fn new(id: impl Into<ElementId>, index: usize, state: &Entity<CarouselState>) -> Self {
527 Self {
528 id: id.into(),
529 index,
530 state: state.clone(),
531 style: StyleRefinement::default(),
532 accessibility_label: None,
533 children: Vec::new(),
534 }
535 }
536
537 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
539 self.accessibility_label = Some(label.into());
540 self
541 }
542}
543
544impl ParentElement for CarouselItem {
545 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
546 self.children.extend(elements);
547 }
548}
549
550impl Styled for CarouselItem {
551 fn style(&mut self) -> &mut StyleRefinement {
552 &mut self.style
553 }
554}
555
556impl RenderOnce for CarouselItem {
557 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
558 let state = self.state.read(cx);
559 let axis = state.axis();
560 let count = state.item_count();
561 let label = self.accessibility_label.unwrap_or_else(|| {
562 t!(
563 "Carousel.slide",
564 current = self.index.saturating_add(1),
565 total = count
566 )
567 .into()
568 });
569
570 div()
571 .id(self.id)
572 .role(Role::Group)
573 .aria_label(label)
574 .aria_position_in_set(self.index.saturating_add(1))
575 .aria_size_of_set(count)
576 .min_w_0()
577 .min_h_0()
578 .flex_none()
579 .when(axis.is_horizontal(), |this| this.w_full().pl_4())
580 .when(axis.is_vertical(), |this| this.h_full().pt_4())
581 .children(self.children)
582 .refine_style(&self.style)
583 }
584}
585
586#[derive(IntoElement)]
588pub struct CarouselPrevious {
589 state: Entity<CarouselState>,
590 size: Size,
591 style: StyleRefinement,
592 accessibility_label: Option<SharedString>,
593 children: Vec<AnyElement>,
594}
595
596impl CarouselPrevious {
597 pub fn new(state: &Entity<CarouselState>) -> Self {
599 Self {
600 state: state.clone(),
601 size: Size::Medium,
602 style: StyleRefinement::default(),
603 accessibility_label: None,
604 children: Vec::new(),
605 }
606 }
607
608 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
610 self.accessibility_label = Some(label.into());
611 self
612 }
613}
614
615impl crate::Sizable for CarouselPrevious {
616 fn with_size(mut self, size: impl Into<Size>) -> Self {
617 self.size = size.into();
618 self
619 }
620}
621
622impl Styled for CarouselPrevious {
623 fn style(&mut self) -> &mut StyleRefinement {
624 &mut self.style
625 }
626}
627
628impl ParentElement for CarouselPrevious {
629 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
630 self.children.extend(elements);
631 }
632}
633
634impl RenderOnce for CarouselPrevious {
635 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
636 carousel_control(
637 self.state,
638 self.size,
639 self.style,
640 self.accessibility_label,
641 self.children,
642 false,
643 cx,
644 )
645 }
646}
647
648#[derive(IntoElement)]
650pub struct CarouselNext {
651 state: Entity<CarouselState>,
652 size: Size,
653 style: StyleRefinement,
654 accessibility_label: Option<SharedString>,
655 children: Vec<AnyElement>,
656}
657
658impl CarouselNext {
659 pub fn new(state: &Entity<CarouselState>) -> Self {
661 Self {
662 state: state.clone(),
663 size: Size::Medium,
664 style: StyleRefinement::default(),
665 accessibility_label: None,
666 children: Vec::new(),
667 }
668 }
669
670 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
672 self.accessibility_label = Some(label.into());
673 self
674 }
675}
676
677impl crate::Sizable for CarouselNext {
678 fn with_size(mut self, size: impl Into<Size>) -> Self {
679 self.size = size.into();
680 self
681 }
682}
683
684impl Styled for CarouselNext {
685 fn style(&mut self) -> &mut StyleRefinement {
686 &mut self.style
687 }
688}
689
690impl ParentElement for CarouselNext {
691 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
692 self.children.extend(elements);
693 }
694}
695
696impl RenderOnce for CarouselNext {
697 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
698 carousel_control(
699 self.state,
700 self.size,
701 self.style,
702 self.accessibility_label,
703 self.children,
704 true,
705 cx,
706 )
707 }
708}
709
710fn carousel_control(
711 state: Entity<CarouselState>,
712 size: Size,
713 style: StyleRefinement,
714 accessibility_label: Option<SharedString>,
715 children: Vec<AnyElement>,
716 next: bool,
717 cx: &mut App,
718) -> impl IntoElement {
719 let snapshot = state.read(cx);
720 let axis = snapshot.axis();
721 let frame_size = snapshot.frame_size();
722 let focus_handle = snapshot.focus_handle(cx);
723 let disabled = if next {
724 !snapshot.has_next()
725 } else {
726 !snapshot.has_previous()
727 };
728 let (name, default_label, icon) = match (axis, next) {
729 (Axis::Horizontal, false) => ("previous", t!("Carousel.previous"), IconName::ChevronLeft),
730 (Axis::Horizontal, true) => ("next", t!("Carousel.next"), IconName::ChevronRight),
731 (Axis::Vertical, false) => ("previous", t!("Carousel.previous"), IconName::ChevronUp),
732 (Axis::Vertical, true) => ("next", t!("Carousel.next"), IconName::ChevronDown),
733 };
734 let label = accessibility_label.unwrap_or_else(|| default_label.into());
735 let has_custom_content = !children.is_empty();
736 let id = ElementId::NamedChild(
737 Arc::new(("carousel-control", state.entity_id()).into()),
738 name.into(),
739 );
740
741 div()
742 .absolute()
743 .top_0()
744 .left_0()
745 .when_some(frame_size, |this, size| this.w(size.width).h(size.height))
746 .when(frame_size.is_none(), |this| this.right_0().bottom_0())
747 .child(
748 Button::new(id)
749 .outline()
750 .with_size(size)
751 .when(!has_custom_content, |this| this.icon(icon))
752 .accessibility_label(label.clone())
753 .tooltip(label)
754 .disabled(disabled)
755 .absolute()
756 .rounded_full_style(cx)
757 .when(axis.is_horizontal() && !next, |this| {
758 this.right_full().mr_4().top_0().bottom_0().my_auto()
759 })
760 .when(axis.is_horizontal() && next, |this| {
761 this.left_full().ml_4().top_0().bottom_0().my_auto()
762 })
763 .when(axis.is_vertical() && !next, |this| {
764 this.bottom_full().mb_4().left_0().right_0().mx_auto()
765 })
766 .when(axis.is_vertical() && next, |this| {
767 this.top_full().mt_4().left_0().right_0().mx_auto()
768 })
769 .when(!disabled, |this| {
770 this.on_click(move |event, window, cx| {
771 state.update(cx, |state, cx| {
772 if next {
773 state.select_next(cx);
774 } else {
775 state.select_previous(cx);
776 }
777 });
778 focus_after_pointer_click(&state, &focus_handle, event, window, cx);
779 })
780 })
781 .children(children)
782 .refine_style(&style),
783 )
784}
785
786#[derive(IntoElement)]
788pub struct CarouselPagination {
789 id: ElementId,
790 style: StyleRefinement,
791 accessibility_label: SharedString,
792 children: Vec<AnyElement>,
793}
794
795impl CarouselPagination {
796 #[track_caller]
798 pub fn new() -> Self {
799 Self {
800 id: ElementId::CodeLocation(*Location::caller()),
801 style: StyleRefinement::default(),
802 accessibility_label: t!("Carousel.pagination").into(),
803 children: Vec::new(),
804 }
805 }
806
807 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
809 self.accessibility_label = label.into();
810 self
811 }
812}
813
814impl ParentElement for CarouselPagination {
815 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
816 self.children.extend(elements);
817 }
818}
819
820impl Styled for CarouselPagination {
821 fn style(&mut self) -> &mut StyleRefinement {
822 &mut self.style
823 }
824}
825
826impl RenderOnce for CarouselPagination {
827 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
828 div()
829 .id(self.id)
830 .role(Role::Group)
831 .aria_label(self.accessibility_label)
832 .flex()
833 .items_center()
834 .justify_center()
835 .gap_2()
836 .children(self.children)
837 .refine_style(&self.style)
838 }
839}
840
841#[derive(IntoElement)]
843pub struct CarouselPaginationItem {
844 id: ElementId,
845 index: usize,
846 state: Entity<CarouselState>,
847 size: Size,
848 style: StyleRefinement,
849 accessibility_label: Option<SharedString>,
850 children: Vec<AnyElement>,
851}
852
853impl CarouselPaginationItem {
854 pub fn new(id: impl Into<ElementId>, index: usize, state: &Entity<CarouselState>) -> Self {
856 Self {
857 id: id.into(),
858 index,
859 state: state.clone(),
860 size: Size::XSmall,
861 style: StyleRefinement::default(),
862 accessibility_label: None,
863 children: Vec::new(),
864 }
865 }
866
867 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
869 self.accessibility_label = Some(label.into());
870 self
871 }
872}
873
874impl crate::Sizable for CarouselPaginationItem {
875 fn with_size(mut self, size: impl Into<Size>) -> Self {
876 self.size = size.into();
877 self
878 }
879}
880
881impl ParentElement for CarouselPaginationItem {
882 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
883 self.children.extend(elements);
884 }
885}
886
887impl Styled for CarouselPaginationItem {
888 fn style(&mut self) -> &mut StyleRefinement {
889 &mut self.style
890 }
891}
892
893impl RenderOnce for CarouselPaginationItem {
894 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
895 let selected = self.state.read(cx).selected_index() == Some(self.index);
896 let disabled = self.index >= self.state.read(cx).item_count();
897 let focus_handle = self.state.read(cx).focus_handle(cx);
898 let label = self.accessibility_label.unwrap_or_else(|| {
899 t!(
900 "Carousel.go_to_slide",
901 current = self.index.saturating_add(1)
902 )
903 .into()
904 });
905 let state = self.state;
906 let index = self.index;
907
908 Button::new(self.id)
909 .compact()
910 .with_size(self.size)
911 .selected(selected)
912 .accessibility_label(label)
913 .disabled(disabled)
914 .children(self.children)
915 .when(!disabled, |this| {
916 this.on_click(move |event, window, cx| {
917 state.update(cx, |state, cx| {
918 state.select_index(index, cx);
919 });
920 focus_after_pointer_click(&state, &focus_handle, event, window, cx);
921 })
922 })
923 .refine_style(&self.style)
924 }
925}
926
927fn focus_after_pointer_click(
931 state: &Entity<CarouselState>,
932 focus_handle: &FocusHandle,
933 event: &ClickEvent,
934 window: &mut Window,
935 cx: &mut App,
936) {
937 if event.is_keyboard() || focus_handle.contains_focused(window, cx) {
938 return;
939 }
940 state.update(cx, |state, _| state.suppress_focus_ring(true));
941 window.focus(focus_handle, cx);
942}
943
944fn axis_value(point: Point<Pixels>, axis: Axis) -> Pixels {
945 if axis.is_horizontal() {
946 point.x
947 } else {
948 point.y
949 }
950}
951
952fn set_axis_value(point: &mut Point<Pixels>, axis: Axis, value: Pixels) {
953 if axis.is_horizontal() {
954 point.x = value;
955 } else {
956 point.y = value;
957 }
958}
959
960fn snap_offset(handle: &gpui::ScrollHandle, axis: Axis, index: usize) -> Option<Pixels> {
961 let viewport = handle.bounds();
962 let item = handle.bounds_for_item(index)?;
963 let target = if axis.is_horizontal() {
964 viewport.left() - item.left()
965 } else {
966 viewport.top() - item.top()
967 };
968 let max = axis_value(handle.max_offset(), axis).max(Pixels::ZERO);
969 Some(target.clamp(-max, Pixels::ZERO))
970}
971
972#[cfg(test)]
973mod tests {
974 use std::{cell::Cell, rc::Rc};
975
976 use super::*;
977 use gpui::{AppContext as _, Context, Render, VisualTestContext, point};
978 use gpui_base::FocusableExt as _;
979
980 #[test]
981 fn axis_helpers_only_change_the_requested_coordinate() {
982 let mut value = point(px(3.), px(7.));
983 set_axis_value(&mut value, Axis::Horizontal, px(11.));
984 assert_eq!(value, point(px(11.), px(7.)));
985 set_axis_value(&mut value, Axis::Vertical, px(-5.));
986 assert_eq!(value, point(px(11.), px(-5.)));
987 }
988
989 #[gpui::test]
990 fn carousel_controls_accept_semantic_sizes(cx: &mut gpui::TestAppContext) {
991 let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
992
993 assert_eq!(CarouselPrevious::new(&state).size, Size::Medium);
994 assert_eq!(CarouselNext::new(&state).size, Size::Medium);
995 assert_eq!(CarouselPrevious::new(&state).large().size, Size::Large);
996 assert_eq!(CarouselNext::new(&state).xsmall().size, Size::XSmall);
997 assert_eq!(
998 CarouselPaginationItem::new("pagination", 0, &state)
999 .small()
1000 .size,
1001 Size::Small
1002 );
1003 }
1004
1005 #[gpui::test]
1006 fn carousel_focus_ring_is_configurable(cx: &mut gpui::TestAppContext) {
1007 let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
1008
1009 assert!(Carousel::new("carousel", &state).is_focus_ring_enabled());
1010 assert!(
1011 !Carousel::new("carousel", &state)
1012 .focus_ring(false)
1013 .is_focus_ring_enabled()
1014 );
1015 }
1016
1017 struct KeyboardHarness {
1018 state: Entity<CarouselState>,
1019 }
1020
1021 impl Render for KeyboardHarness {
1022 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1023 div().tab_group().child(
1024 Carousel::new("carousel", &self.state)
1025 .w(px(100.))
1026 .h(px(100.))
1027 .child(
1028 CarouselContent::new(&self.state)
1029 .h(px(100.))
1030 .children((0..3).map(|index| {
1031 CarouselItem::new(("carousel-item", index), index, &self.state)
1032 .child(index.to_string())
1033 })),
1034 ),
1035 )
1036 }
1037 }
1038
1039 fn assert_contextual_navigation_keys(cx: &mut gpui::TestAppContext, axis: Axis) {
1040 cx.update(crate::init);
1041 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3).with_axis(axis)));
1042 let (_, cx) = cx.add_window_view({
1043 let state = state.clone();
1044 move |_, _| KeyboardHarness { state }
1045 });
1046 cx.update(|window, cx| window.draw(cx).clear(cx));
1047
1048 cx.update(|window, cx| window.focus_next(cx));
1049 let (primary, secondary) = if axis.is_horizontal() {
1050 ("right", "down")
1051 } else {
1052 ("down", "right")
1053 };
1054 cx.simulate_keystrokes(primary);
1055 assert_eq!(
1056 state.read_with(cx, |state, _| state.selected_index()),
1057 Some(1)
1058 );
1059 cx.simulate_keystrokes(secondary);
1060 assert_eq!(
1061 state.read_with(cx, |state, _| state.selected_index()),
1062 Some(1)
1063 );
1064 cx.simulate_keystrokes("end");
1065 assert_eq!(
1066 state.read_with(cx, |state, _| state.selected_index()),
1067 Some(2)
1068 );
1069 cx.simulate_keystrokes("home");
1070 assert_eq!(
1071 state.read_with(cx, |state, _| state.selected_index()),
1072 Some(0)
1073 );
1074 }
1075
1076 #[gpui::test]
1077 fn horizontal_carousel_dispatches_contextual_navigation_keys(cx: &mut gpui::TestAppContext) {
1078 assert_contextual_navigation_keys(cx, Axis::Horizontal);
1079 }
1080
1081 #[gpui::test]
1082 fn vertical_carousel_dispatches_contextual_navigation_keys(cx: &mut gpui::TestAppContext) {
1083 assert_contextual_navigation_keys(cx, Axis::Vertical);
1084 }
1085
1086 struct PropagationHarness {
1087 state: Entity<CarouselState>,
1088 outer_actions: Rc<Cell<usize>>,
1089 }
1090
1091 impl Render for PropagationHarness {
1092 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1093 let down = self.outer_actions.clone();
1094 let left = self.outer_actions.clone();
1095 let right = self.outer_actions.clone();
1096 div()
1097 .tab_group()
1098 .on_action(move |_: &SelectDown, _, _| down.set(down.get() + 1))
1099 .on_action(move |_: &SelectLeft, _, _| left.set(left.get() + 1))
1100 .on_action(move |_: &SelectRight, _, _| right.set(right.get() + 1))
1101 .child(
1102 Carousel::new("carousel", &self.state)
1103 .w(px(100.))
1104 .h(px(100.))
1105 .child(
1106 CarouselContent::new(&self.state)
1107 .h(px(100.))
1108 .children((0..3).map(|index| {
1109 CarouselItem::new(("carousel-item", index), index, &self.state)
1110 .child(index.to_string())
1111 })),
1112 ),
1113 )
1114 }
1115 }
1116
1117 #[gpui::test]
1118 fn unhandled_navigation_keys_reach_ancestors(cx: &mut gpui::TestAppContext) {
1119 cx.update(crate::init);
1120 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1121 let outer_actions = Rc::new(Cell::new(0));
1122 let (_, cx) = cx.add_window_view({
1123 let state = state.clone();
1124 let outer_actions = outer_actions.clone();
1125 move |_, _| PropagationHarness {
1126 state,
1127 outer_actions,
1128 }
1129 });
1130 cx.update(|window, cx| window.draw(cx).clear(cx));
1131 cx.update(|window, cx| window.focus_next(cx));
1132
1133 cx.simulate_keystrokes("down");
1134 assert_eq!(outer_actions.get(), 1);
1135
1136 cx.simulate_keystrokes("left");
1137 assert_eq!(outer_actions.get(), 2);
1138 assert_eq!(
1139 state.read_with(cx, |state, _| state.selected_index()),
1140 Some(0)
1141 );
1142
1143 cx.simulate_keystrokes("right");
1144 assert_eq!(
1145 state.read_with(cx, |state, _| state.selected_index()),
1146 Some(1)
1147 );
1148 assert_eq!(outer_actions.get(), 2);
1149 }
1150
1151 #[gpui::test]
1152 fn track_grows_by_its_leading_margin_so_items_fill_the_frame(cx: &mut gpui::TestAppContext) {
1153 cx.update(crate::init);
1154 for axis in [Axis::Horizontal, Axis::Vertical] {
1155 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3).with_axis(axis)));
1156 let (_, cx) = cx.add_window_view({
1157 let state = state.clone();
1158 move |_, _| KeyboardHarness { state }
1159 });
1160 cx.update(|window, cx| window.draw(cx).clear(cx));
1161
1162 let (track, first_item, frame_size) = state.read_with(cx, |state, _| {
1163 let handle = state.scroll_handle();
1164 (
1165 handle.bounds(),
1166 handle.bounds_for_item(0).unwrap(),
1167 state.frame_size().unwrap(),
1168 )
1169 });
1170 assert_eq!(frame_size, gpui::size(px(100.), px(100.)), "{axis:?}");
1171 let expected = if axis.is_horizontal() {
1172 Bounds::new(point(px(-16.), px(0.)), gpui::size(px(116.), px(100.)))
1173 } else {
1174 Bounds::new(point(px(0.), px(-16.)), gpui::size(px(100.), px(116.)))
1175 };
1176 assert_eq!(track, expected, "{axis:?}");
1177 assert_eq!(first_item, expected, "{axis:?}");
1178 }
1179 }
1180
1181 #[gpui::test]
1182 fn clicking_a_slide_focuses_the_carousel_for_keyboard_navigation(
1183 cx: &mut gpui::TestAppContext,
1184 ) {
1185 cx.update(crate::init);
1186 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1187 let (_, cx) = cx.add_window_view({
1188 let state = state.clone();
1189 move |_, _| KeyboardHarness { state }
1190 });
1191 cx.update(|window, cx| window.draw(cx).clear(cx));
1192 assert!(cx.update(|window, cx| window.focused(cx).is_none()));
1193
1194 cx.simulate_click(point(px(50.), px(50.)), gpui::Modifiers::default());
1195 assert!(cx.update(|window, cx| window.focused(cx).is_some()));
1196
1197 cx.simulate_keystrokes("right");
1198 assert_eq!(
1199 state.read_with(cx, |state, _| state.selected_index()),
1200 Some(1)
1201 );
1202 }
1203
1204 struct ControlsHarness {
1205 state: Entity<CarouselState>,
1206 }
1207
1208 impl Render for ControlsHarness {
1209 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1210 div().tab_group().child(
1211 Carousel::new("carousel", &self.state)
1212 .w(px(100.))
1213 .h(px(100.))
1214 .child(
1215 CarouselContent::new(&self.state)
1216 .h(px(100.))
1217 .children((0..3).map(|index| {
1218 CarouselItem::new(("carousel-item", index), index, &self.state)
1219 .child(index.to_string())
1220 })),
1221 )
1222 .child(CarouselPrevious::new(&self.state))
1223 .child(CarouselNext::new(&self.state)),
1224 )
1225 }
1226 }
1227
1228 #[gpui::test]
1229 fn clicking_a_control_focuses_the_carousel_for_keyboard_navigation(
1230 cx: &mut gpui::TestAppContext,
1231 ) {
1232 cx.update(crate::init);
1233 let state = cx.update(|cx| cx.new(|_| CarouselState::new(3)));
1234 let (_, cx) = cx.add_window_view({
1235 let state = state.clone();
1236 move |_, _| ControlsHarness { state }
1237 });
1238 cx.update(|window, cx| window.draw(cx).clear(cx));
1239 let selected =
1240 |cx: &mut VisualTestContext| state.read_with(cx, |state, _| state.selected_index());
1241 let root_focused = |cx: &mut VisualTestContext| {
1242 cx.update(|window, cx| state.read(cx).focus_handle(cx).is_focused(window))
1243 };
1244
1245 cx.simulate_click(point(px(134.), px(50.)), gpui::Modifiers::default());
1247 assert_eq!(selected(cx), Some(1));
1248 assert!(root_focused(cx));
1249 cx.simulate_keystrokes("right");
1250 assert_eq!(selected(cx), Some(2));
1251
1252 cx.simulate_keystrokes("left");
1254 cx.update(|window, cx| window.focus_next(cx));
1255 cx.update(|window, cx| window.focus_next(cx));
1256 cx.update(|window, cx| window.draw(cx).clear(cx));
1257 let keystroke = gpui::Keystroke::parse("enter").unwrap();
1258 cx.simulate_event(gpui::KeyDownEvent {
1259 keystroke: keystroke.clone(),
1260 is_held: false,
1261 prefer_character_input: false,
1262 });
1263 cx.simulate_event(gpui::KeyUpEvent { keystroke });
1264 assert_eq!(selected(cx), Some(2));
1265 assert!(!root_focused(cx));
1266 assert!(cx.update(|window, cx| window.focused(cx).is_some()));
1267 }
1268
1269 #[gpui::test]
1270 fn carousel_controls_accept_accessibility_labels_and_children(cx: &mut gpui::TestAppContext) {
1271 let state = cx.update(|cx| cx.new(|_| CarouselState::new(2)));
1272 let previous = CarouselPrevious::new(&state)
1273 .accessibility_label("Previous project")
1274 .child("Back");
1275 let next = CarouselNext::new(&state)
1276 .accessibility_label("Next project")
1277 .child("Forward");
1278
1279 assert_eq!(
1280 previous.accessibility_label.as_deref(),
1281 Some("Previous project")
1282 );
1283 assert_eq!(next.accessibility_label.as_deref(), Some("Next project"));
1284 assert_eq!(previous.children.len(), 1);
1285 assert_eq!(next.children.len(), 1);
1286 }
1287}