Skip to main content

gpui_component/carousel/
carousel.rs

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/// A composable carousel root.
24///
25/// Add one [`CarouselContent`] and any optional controls as children. Every
26/// part must share the same [`CarouselState`].
27#[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
41/// Restores the focus ring once the carousel loses focus, so the next
42/// keyboard focus draws it again.
43struct CarouselFocusOut {
44    _subscription: Subscription,
45}
46
47impl Carousel {
48    /// Creates a Carousel bound to `state`.
49    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    /// Sets the name announced for the carousel region.
61    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                    // Runs before GPUI moves focus here. A child such as
136                    // Button that keeps focus has already prevented the default.
137                    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/// The clipped viewport and snap track for Carousel items.
247#[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    /// Creates content bound to `state`.
257    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    /// Sets style overrides for the inner flex track.
267    ///
268    /// Use this for paired Carousel spacing such as a negative leading margin.
269    /// The [`Styled`] implementation applies to the clipped viewport itself.
270    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
288/// A layout-transparent proxy that paints one real item in the closest loop
289/// cycle. The child keeps its original layout id, so ScrollHandle geometry
290/// continues to address logical items without cloning their elements.
291struct 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                // As a flex child the track grows by its negative leading
461                // margin, so the padded items fill the frame on both edges.
462                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/// One logical slide in a [`CarouselContent`].
514#[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    /// Creates the item at the zero-based `index` used by `state`.
526    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    /// Replaces the generated "Slide N of M" accessibility label.
538    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/// A previous-slide control positioned around the Carousel viewport.
587#[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    /// Creates a previous-slide control bound to `state`.
598    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    /// Replaces the generated previous-slide accessibility label and tooltip.
609    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/// A next-slide control positioned around the Carousel viewport.
649#[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    /// Creates a next-slide control bound to `state`.
660    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    /// Replaces the generated next-slide accessibility label and tooltip.
671    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/// A composable container for Carousel pagination items.
787#[derive(IntoElement)]
788pub struct CarouselPagination {
789    id: ElementId,
790    style: StyleRefinement,
791    accessibility_label: SharedString,
792    children: Vec<AnyElement>,
793}
794
795impl CarouselPagination {
796    /// Creates an empty pagination container.
797    #[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    /// Sets the name announced for the pagination group.
808    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/// One application-styled pagination control for a Carousel item.
842#[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    /// Creates a pagination item for the zero-based `index`.
855    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    /// Replaces the generated "Go to slide N" accessibility label.
868    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
927/// Moves keyboard focus to the carousel after a pointer click on one of its
928/// controls, so the arrow keys keep working without drawing the ring. A
929/// keyboard activation leaves focus on the control.
930fn 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        // A pointer click on Next moves focus to the carousel.
1246        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        // Keyboard activation of a control leaves focus on the control.
1253        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}