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    /// Reads the layout relative to the content `frame` origin.
220    ///
221    /// Window-space bounds move whenever an ancestor scrolls or shifts the
222    /// carousel, while every snap and loop calculation uses only differences
223    /// and sizes. Storing frame-relative bounds keeps those moves from being
224    /// treated as layout changes.
225    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    /// Compares layouts with a sub-pixel tolerance so float noise from the
247    /// frame-relative subtraction does not register as a layout change.
248    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/// The clipped viewport and snap track for Carousel items.
270#[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    /// Creates content bound to `state`.
280    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    /// Sets style overrides for the inner flex track.
290    ///
291    /// Use this for paired Carousel spacing such as a negative leading margin.
292    /// The [`Styled`] implementation applies to the clipped viewport itself.
293    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
311/// A layout-transparent proxy that paints one real item in the closest loop
312/// cycle. The child keeps its original layout id, so ScrollHandle geometry
313/// continues to address logical items without cloning their elements.
314struct 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                // As a flex child the track grows by its negative leading
484                // margin, so the padded items fill the frame on both edges.
485                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/// One logical slide in a [`CarouselContent`].
537#[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    /// Creates the item at the zero-based `index` used by `state`.
549    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    /// Replaces the generated "Slide N of M" accessibility label.
561    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/// A previous-slide control positioned around the Carousel viewport.
610#[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    /// Creates a previous-slide control bound to `state`.
621    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    /// Replaces the generated previous-slide accessibility label and tooltip.
632    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/// A next-slide control positioned around the Carousel viewport.
672#[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    /// Creates a next-slide control bound to `state`.
683    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    /// Replaces the generated next-slide accessibility label and tooltip.
694    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/// A composable container for Carousel pagination items.
810#[derive(IntoElement)]
811pub struct CarouselPagination {
812    id: ElementId,
813    style: StyleRefinement,
814    accessibility_label: SharedString,
815    children: Vec<AnyElement>,
816}
817
818impl CarouselPagination {
819    /// Creates an empty pagination container.
820    #[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    /// Sets the name announced for the pagination group.
831    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/// One application-styled pagination control for a Carousel item.
865#[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    /// Creates a pagination item for the zero-based `index`.
878    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    /// Replaces the generated "Go to slide N" accessibility label.
891    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
950/// Moves keyboard focus to the carousel after a pointer click on one of its
951/// controls, so the arrow keys keep working without drawing the ring. A
952/// keyboard activation leaves focus on the control.
953fn 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        // A pointer click on Next moves focus to the carousel.
1269        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        // Keyboard activation of a control leaves focus on the control.
1276        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}