From b45565e93b759c2f34d5c73a6558cf6b5b672efa Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:42:36 +0800 Subject: [PATCH 01/11] carousel: Add Carousel component --- crates/story/src/gallery.rs | 1 + crates/story/src/stories/carousel_story.rs | 217 ++++ crates/story/src/stories/mod.rs | 2 + crates/ui/locales/ui.yml | 31 + crates/ui/src/carousel/carousel.rs | 694 +++++++++++++ crates/ui/src/carousel/mod.rs | 26 + crates/ui/src/carousel/scroll_mask.rs | 262 +++++ crates/ui/src/carousel/state.rs | 1090 ++++++++++++++++++++ crates/ui/src/lib.rs | 2 + website/docs/components/carousel.md | 125 +++ website/docs/components/index.md | 1 + website/zh-CN/docs/components/carousel.md | 125 +++ website/zh-CN/docs/components/index.md | 1 + 13 files changed, 2577 insertions(+) create mode 100644 crates/story/src/stories/carousel_story.rs create mode 100644 crates/ui/src/carousel/carousel.rs create mode 100644 crates/ui/src/carousel/mod.rs create mode 100644 crates/ui/src/carousel/scroll_mask.rs create mode 100644 crates/ui/src/carousel/state.rs create mode 100644 website/docs/components/carousel.md create mode 100644 website/zh-CN/docs/components/carousel.md diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index f31de74506..a82a288940 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -75,6 +75,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/story/src/stories/carousel_story.rs b/crates/story/src/stories/carousel_story.rs new file mode 100644 index 0000000000..7b724f7d84 --- /dev/null +++ b/crates/story/src/stories/carousel_story.rs @@ -0,0 +1,217 @@ +use gpui::{ + App, AppContext as _, Axis, Context, Entity, FocusHandle, Focusable, InteractiveElement as _, + IntoElement, ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, div, + px, +}; + +use gpui_component::{ + Sizable as _, Size, StyledExt as _, + button::Button, + carousel::{ + Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext, CarouselPagination, + CarouselPaginationItem, CarouselPrevious, CarouselState, + }, + h_flex, v_flex, +}; + +use crate::{ChangeStorySize, section, story_toolbar}; + +pub struct CarouselStory { + focus_handle: FocusHandle, + horizontal: Entity, + vertical: Entity, + looped: Entity, + controlled: Entity, + keyboard: Entity, + controlled_index: usize, + size: Size, + _subscriptions: Vec, +} + +impl super::Story for CarouselStory { + fn title() -> &'static str { + "Carousel" + } + + fn description() -> &'static str { + "A carousel for browsing a set of related items with keyboard and pointer navigation." + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } +} + +impl CarouselStory { + pub fn view(_: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| { + let horizontal = cx.new(|_| CarouselState::new(3)); + let vertical = cx.new(|_| CarouselState::new(3).with_axis(Axis::Vertical)); + let looped = cx.new(|_| CarouselState::new(4).with_looping(true)); + let controlled = cx.new(|_| CarouselState::new(3).with_selected_index(1)); + let keyboard = cx.new(|_| CarouselState::new(3)); + + let subscription = cx.subscribe( + &controlled, + move |this: &mut Self, _, event: &CarouselEvent, cx| { + let CarouselEvent::Change(index) = event; + this.controlled_index = *index; + cx.notify(); + }, + ); + + Self { + focus_handle: cx.focus_handle(), + horizontal, + vertical, + looped, + controlled, + keyboard, + controlled_index: 1, + size: Size::default(), + _subscriptions: vec![subscription], + } + }) + } + + fn slide(label: impl Into) -> impl IntoElement { + div() + .h(px(160.)) + .w_full() + .flex() + .items_center() + .justify_center() + .rounded_lg() + .bg(gpui::hsla(0.61, 0.42, 0.45, 1.0)) + .text_color(gpui::white()) + .text_lg() + .child(label.into()) + } + + fn items(state: &Entity, prefix: &'static str, count: usize) -> CarouselContent { + (0..count).fold(CarouselContent::new(state), |content, index| { + let label = format!("{prefix} · {}", index + 1); + content + .child(CarouselItem::new((prefix, index), index, state).child(Self::slide(label))) + }) + } +} + +impl Focusable for CarouselStory { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for CarouselStory { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_4() + .on_action(cx.listener(|this, action: &ChangeStorySize, _, cx| { + this.size = action.0; + cx.notify(); + })) + .child(story_toolbar(self.size)) + .child( + section("Horizontal") + .description("Use Left and Right to move between slides.") + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-horizontal", &self.horizontal) + .child(Self::items(&self.horizontal, "Horizontal", 3)) + .child(CarouselPrevious::new(&self.horizontal).with_size(self.size)) + .child(CarouselNext::new(&self.horizontal).with_size(self.size)) + .child(CarouselPagination::new().children((0..3).map(|index| { + CarouselPaginationItem::new( + ("horizontal-pagination", index), + index, + &self.horizontal, + ) + .with_size(self.size) + .child((index + 1).to_string()) + }))), + ), + ) + .child( + section("Vertical") + .description("Use Up and Down to navigate a vertical carousel.") + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-vertical", &self.vertical) + .child(Self::items(&self.vertical, "Vertical", 3).h(px(160.))) + .child(CarouselPrevious::new(&self.vertical).with_size(self.size)) + .child(CarouselNext::new(&self.vertical).with_size(self.size)), + ), + ) + .child( + section("Looping") + .description("Looping navigation wraps from the last slide to the first.") + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-looped", &self.looped) + .child(Self::items(&self.looped, "Looped", 4)) + .child(CarouselPrevious::new(&self.looped).with_size(self.size)) + .child(CarouselNext::new(&self.looped).with_size(self.size)), + ), + ) + .child( + section("Controlled / Programmatic") + .description("The selected index is owned by application state and can be changed programmatically.") + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-controlled", &self.controlled) + .child(Self::items(&self.controlled, "Controlled", 3)) + .child(CarouselPrevious::new(&self.controlled).with_size(self.size)) + .child(CarouselNext::new(&self.controlled).with_size(self.size)), + ) + .child( + h_flex() + .gap_2() + .child(div().child(format!("Selected slide: {}", self.controlled_index + 1))) + .child( + Button::new("controlled-first") + .label("Go to first") + .on_click(cx.listener(|this, _, _, cx| { + this.controlled_index = 0; + let controlled = this.controlled.clone(); + controlled.update(cx, |state, cx| { + state.set_selected_index(0, cx); + }); + cx.notify(); + })), + ) + .child( + Button::new("controlled-last") + .label("Go to last") + .on_click(cx.listener(|this, _, _, cx| { + this.controlled_index = 2; + let controlled = this.controlled.clone(); + controlled.update(cx, |state, cx| { + state.set_selected_index(2, cx); + }); + cx.notify(); + })), + ), + ), + ) + .child( + section("Keyboard navigation") + .description( + "Tab to the Carousel or either navigation button, then use Left, Right, Home, and End.", + ) + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-keyboard", &self.keyboard) + .child(Self::items(&self.keyboard, "Keyboard", 3)) + .child(CarouselPrevious::new(&self.keyboard).with_size(self.size)) + .child(CarouselNext::new(&self.keyboard).with_size(self.size)), + ), + ) + } +} diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 3ad65d8560..47d4741be5 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -9,6 +9,7 @@ mod badge_story; mod breadcrumb_story; mod button_story; mod calendar_story; +mod carousel_story; mod chart_story; mod checkbox_story; mod clipboard_story; @@ -76,6 +77,7 @@ pub use badge_story::BadgeStory; pub use breadcrumb_story::BreadcrumbStory; pub use button_story::ButtonStory; pub use calendar_story::CalendarStory; +pub use carousel_story::CarouselStory; pub use chart_story::ChartStory; pub use checkbox_story::CheckboxStory; pub use clipboard_story::ClipboardStory; diff --git a/crates/ui/locales/ui.yml b/crates/ui/locales/ui.yml index 5b9e1ef3bb..87cf77e474 100644 --- a/crates/ui/locales/ui.yml +++ b/crates/ui/locales/ui.yml @@ -329,3 +329,34 @@ Pagination: zh-CN: 下一页 zh-HK: 下一頁 zh-TW: 下一頁 +Carousel: + label: + en: Carousel + zh-CN: 轮播 + zh-HK: 輪播 + zh-TW: 輪播 + previous: + en: Previous slide + zh-CN: 上一张 + zh-HK: 上一張 + zh-TW: 上一張 + next: + en: Next slide + zh-CN: 下一张 + zh-HK: 下一張 + zh-TW: 下一張 + slide: + en: Slide %{current} of %{total} + zh-CN: 第 %{current} 张,共 %{total} 张 + zh-HK: 第 %{current} 張,共 %{total} 張 + zh-TW: 第 %{current} 張,共 %{total} 張 + pagination: + en: Carousel pagination + zh-CN: 轮播分页 + zh-HK: 輪播分頁 + zh-TW: 輪播分頁 + go_to_slide: + en: Go to slide %{current} + zh-CN: 前往第 %{current} 张 + zh-HK: 前往第 %{current} 張 + zh-TW: 前往第 %{current} 張 diff --git a/crates/ui/src/carousel/carousel.rs b/crates/ui/src/carousel/carousel.rs new file mode 100644 index 0000000000..41b39bdd06 --- /dev/null +++ b/crates/ui/src/carousel/carousel.rs @@ -0,0 +1,694 @@ +use std::{panic::Location, sync::Arc, time::Duration}; + +use gpui::{ + AnyElement, App, Axis, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement, + ParentElement, Pixels, Point, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, + StyleRefinement, Styled, Subscription, Window, div, prelude::FluentBuilder as _, px, relative, +}; +use gpui_base::{Spring, spring}; +use rust_i18n::t; + +use super::{CONTEXT, scroll_mask::CarouselScrollMask, state::CarouselState}; +use crate::{ + AxisExt as _, Disableable as _, ElementExt as _, Selectable as _, Sizable as _, Size, + StyledExt as _, ThemeStyled as _, + actions::{SelectDown, SelectFirst, SelectLast, SelectLeft, SelectRight, SelectUp}, + button::Button, + icon::IconName, +}; + +const SNAP_SPRING: Spring = Spring::new(Duration::from_millis(250)).with_epsilon(0.5); + +/// A composable carousel root. +/// +/// Add one [`CarouselContent`] and any optional controls as children. Every +/// part must share the same [`CarouselState`]. +#[derive(IntoElement)] +pub struct Carousel { + id: ElementId, + state: Entity, + style: StyleRefinement, + accessibility_label: SharedString, + children: Vec, +} + +struct CarouselStateObserver { + _subscription: Subscription, +} + +impl Carousel { + /// Creates a Carousel bound to `state`. + pub fn new(id: impl Into, state: &Entity) -> Self { + Self { + id: id.into(), + state: state.clone(), + style: StyleRefinement::default(), + accessibility_label: t!("Carousel.label").into(), + children: Vec::new(), + } + } + + /// Sets the name announced for the carousel region. + pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = label.into(); + self + } +} + +impl ParentElement for Carousel { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for Carousel { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Carousel { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let observed_state = self.state.clone(); + let _observer = window.use_keyed_state( + ("carousel-state-observer", self.state.entity_id()), + cx, + move |_, cx| CarouselStateObserver { + _subscription: cx.observe(&observed_state, |_, _, cx| cx.notify()), + }, + ); + let axis = self.state.read(cx).axis(); + let focus_handle = window + .use_keyed_state(("carousel-focus", self.state.entity_id()), cx, |_, cx| { + cx.focus_handle() + }) + .read(cx) + .clone(); + let previous_state = self.state.clone(); + let next_state = self.state.clone(); + let first_state = self.state.clone(); + let last_state = self.state.clone(); + + div() + .id(self.id) + .relative() + .role(Role::Region) + .aria_label(self.accessibility_label) + .track_focus(&focus_handle.tab_stop(true)) + .key_context(CONTEXT) + .on_action( + window.listener_for(&previous_state, move |state, _: &SelectLeft, _, cx| { + if axis.is_horizontal() && state.select_previous(cx) { + cx.stop_propagation(); + } + }), + ) + .on_action( + window.listener_for(&next_state, move |state, _: &SelectRight, _, cx| { + if axis.is_horizontal() && state.select_next(cx) { + cx.stop_propagation(); + } + }), + ) + .on_action( + window.listener_for(&previous_state, move |state, _: &SelectUp, _, cx| { + if axis.is_vertical() && state.select_previous(cx) { + cx.stop_propagation(); + } + }), + ) + .on_action( + window.listener_for(&next_state, move |state, _: &SelectDown, _, cx| { + if axis.is_vertical() && state.select_next(cx) { + cx.stop_propagation(); + } + }), + ) + .on_action( + window.listener_for(&first_state, |state, _: &SelectFirst, _, cx| { + if state.select_first(cx) { + cx.stop_propagation(); + } + }), + ) + .on_action( + window.listener_for(&last_state, |state, _: &SelectLast, _, cx| { + if state.select_last(cx) { + cx.stop_propagation(); + } + }), + ) + .children(self.children) + .refine_style(&self.style) + } +} + +#[derive(Default, PartialEq)] +struct CarouselGeometry { + viewport: Bounds, + items: Vec>, + revision: usize, +} + +impl CarouselGeometry { + fn read(state: &CarouselState) -> Self { + let handle = state.scroll_handle(); + Self { + viewport: handle.bounds(), + items: (0..state.item_count()) + .filter_map(|ix| handle.bounds_for_item(ix)) + .collect(), + revision: 0, + } + } + + fn same_layout(&self, other: &Self) -> bool { + self.viewport == other.viewport && self.items == other.items + } +} + +/// The clipped viewport and snap track for Carousel items. +#[derive(IntoElement)] +pub struct CarouselContent { + state: Entity, + style: StyleRefinement, + children: Vec, +} + +impl CarouselContent { + /// Creates content bound to `state`. + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + children: Vec::new(), + } + } +} + +impl ParentElement for CarouselContent { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for CarouselContent { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for CarouselContent { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let entity_id = self.state.entity_id(); + let snapshot = self.state.read(cx); + let axis = snapshot.axis(); + let selected_ix = snapshot.selected_index(); + let item_count = snapshot.item_count(); + let handle = snapshot.scroll_handle().clone(); + let interacting = snapshot.is_interacting(); + let motion_revision = snapshot.motion_revision(); + let state_snap_target = selected_ix.and_then(|ix| snapshot.snap_target_for(ix)); + + let geometry = window.use_keyed_state( + ElementId::NamedChild( + Arc::new(("carousel-geometry", entity_id).into()), + "content".into(), + ), + cx, + |_, _| CarouselGeometry::default(), + ); + let geometry_revision = geometry.read(cx).revision; + + let current = axis_value(handle.offset(), axis); + let target = state_snap_target + .map(|target| axis_value(target, axis)) + .or_else(|| selected_ix.and_then(|ix| snap_offset(&handle, axis, ix))) + .unwrap_or(current); + let target = if interacting { current } else { target }; + let animated = spring( + ( + ("carousel-content", entity_id), + SharedString::from(format!("offset-{motion_revision}-{geometry_revision}")), + ), + target.as_f32(), + SNAP_SPRING.with_travel(!interacting), + window, + cx, + ); + let mut offset = handle.offset(); + set_axis_value(&mut offset, axis, px(animated)); + set_axis_value( + &mut offset, + if axis.is_horizontal() { + Axis::Vertical + } else { + Axis::Horizontal + }, + Pixels::ZERO, + ); + handle.set_offset(offset); + + let geometry_state = self.state.clone(); + let viewport_id: ElementId = ("carousel-content", entity_id).into(); + + div() + .relative() + .w_full() + .child( + div() + .id(viewport_id.clone()) + .w_full() + .flex() + .gap_4() + .when(axis.is_horizontal(), |this| this.flex_row()) + .when(axis.is_vertical(), |this| this.flex_col()) + .overflow_hidden() + .track_scroll(&handle) + .children(self.children) + .refine_style(&self.style), + ) + .child(CarouselScrollMask::new(axis, &self.state).id(viewport_id)) + .on_prepaint(move |_, _, cx| { + let next = CarouselGeometry::read(geometry_state.read(cx)); + if !geometry.read(cx).same_layout(&next) { + geometry_state.update(cx, |state, _| { + state.set_geometry(next.viewport, next.items.clone()); + }); + geometry.update(cx, |current, cx| { + current.viewport = next.viewport; + current.items = next.items; + current.revision = current.revision.wrapping_add(1); + cx.notify(); + }); + } + }) + .when(item_count == 0, |this| this.invisible()) + } +} + +/// One logical slide in a [`CarouselContent`]. +#[derive(IntoElement)] +pub struct CarouselItem { + id: ElementId, + index: usize, + state: Entity, + style: StyleRefinement, + accessibility_label: Option, + children: Vec, +} + +impl CarouselItem { + /// Creates the item at the zero-based `index` used by `state`. + pub fn new(id: impl Into, index: usize, state: &Entity) -> Self { + Self { + id: id.into(), + index, + state: state.clone(), + style: StyleRefinement::default(), + accessibility_label: None, + children: Vec::new(), + } + } + + /// Replaces the generated "Slide N of M" accessibility label. + pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } +} + +impl ParentElement for CarouselItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for CarouselItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for CarouselItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let state = self.state.read(cx); + let axis = state.axis(); + let count = state.item_count(); + let label = self.accessibility_label.unwrap_or_else(|| { + t!( + "Carousel.slide", + current = self.index.saturating_add(1), + total = count + ) + .into() + }); + + div() + .id(self.id) + .role(Role::Group) + .aria_label(label) + .aria_position_in_set(self.index.saturating_add(1)) + .aria_size_of_set(count) + .min_w_0() + .min_h_0() + .flex_none() + .when(axis.is_horizontal(), |this| this.w_full()) + .when(axis.is_vertical(), |this| this.h_full()) + .children(self.children) + .refine_style(&self.style) + } +} + +/// A previous-slide control positioned around the Carousel viewport. +#[derive(IntoElement)] +pub struct CarouselPrevious { + state: Entity, + size: Size, + style: StyleRefinement, +} + +impl CarouselPrevious { + /// Creates a previous-slide control bound to `state`. + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + size: Size::Small, + style: StyleRefinement::default(), + } + } +} + +impl crate::Sizable for CarouselPrevious { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for CarouselPrevious { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for CarouselPrevious { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + carousel_control(self.state, self.size, self.style, false, cx) + } +} + +/// A next-slide control positioned around the Carousel viewport. +#[derive(IntoElement)] +pub struct CarouselNext { + state: Entity, + size: Size, + style: StyleRefinement, +} + +impl CarouselNext { + /// Creates a next-slide control bound to `state`. + pub fn new(state: &Entity) -> Self { + Self { + state: state.clone(), + size: Size::Small, + style: StyleRefinement::default(), + } + } +} + +impl crate::Sizable for CarouselNext { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for CarouselNext { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for CarouselNext { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + carousel_control(self.state, self.size, self.style, true, cx) + } +} + +fn carousel_control( + state: Entity, + size: Size, + style: StyleRefinement, + next: bool, + cx: &mut App, +) -> impl IntoElement { + let snapshot = state.read(cx); + let axis = snapshot.axis(); + let disabled = if next { + !snapshot.has_next() + } else { + !snapshot.has_previous() + }; + let (name, label, icon) = match (axis, next) { + (Axis::Horizontal, false) => ("previous", t!("Carousel.previous"), IconName::ChevronLeft), + (Axis::Horizontal, true) => ("next", t!("Carousel.next"), IconName::ChevronRight), + (Axis::Vertical, false) => ("previous", t!("Carousel.previous"), IconName::ChevronUp), + (Axis::Vertical, true) => ("next", t!("Carousel.next"), IconName::ChevronDown), + }; + let id = ElementId::NamedChild( + Arc::new(("carousel-control", state.entity_id()).into()), + name.into(), + ); + + Button::new(id) + .outline() + .compact() + .with_size(size) + .icon(icon) + .accessibility_label(label.clone()) + .tooltip(label) + .disabled(disabled) + .absolute() + .rounded_full_style(cx) + .when(axis.is_horizontal() && !next, |this| { + this.left(px(-40.)).top(relative(0.5)).mt(px(-15.)) + }) + .when(axis.is_horizontal() && next, |this| { + this.right(px(-40.)).top(relative(0.5)).mt(px(-15.)) + }) + .when(axis.is_vertical() && !next, |this| { + this.top(px(-40.)).left(relative(0.5)).ml(px(-15.)) + }) + .when(axis.is_vertical() && next, |this| { + this.bottom(px(-40.)).left(relative(0.5)).ml(px(-15.)) + }) + .when(!disabled, |this| { + this.on_click(move |_, _, cx| { + state.update(cx, |state, cx| { + if next { + state.select_next(cx); + } else { + state.select_previous(cx); + } + }); + }) + }) + .refine_style(&style) +} + +/// A composable container for Carousel pagination items. +#[derive(IntoElement)] +pub struct CarouselPagination { + id: ElementId, + style: StyleRefinement, + accessibility_label: SharedString, + children: Vec, +} + +impl CarouselPagination { + /// Creates an empty pagination container. + #[track_caller] + pub fn new() -> Self { + Self { + id: ElementId::CodeLocation(*Location::caller()), + style: StyleRefinement::default(), + accessibility_label: t!("Carousel.pagination").into(), + children: Vec::new(), + } + } + + /// Sets the name announced for the pagination group. + pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = label.into(); + self + } +} + +impl ParentElement for CarouselPagination { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for CarouselPagination { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for CarouselPagination { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .id(self.id) + .role(Role::Group) + .aria_label(self.accessibility_label) + .flex() + .items_center() + .justify_center() + .gap_2() + .children(self.children) + .refine_style(&self.style) + } +} + +/// One application-styled pagination control for a Carousel item. +#[derive(IntoElement)] +pub struct CarouselPaginationItem { + id: ElementId, + index: usize, + state: Entity, + size: Size, + style: StyleRefinement, + accessibility_label: Option, + children: Vec, +} + +impl CarouselPaginationItem { + /// Creates a pagination item for the zero-based `index`. + pub fn new(id: impl Into, index: usize, state: &Entity) -> Self { + Self { + id: id.into(), + index, + state: state.clone(), + size: Size::XSmall, + style: StyleRefinement::default(), + accessibility_label: None, + children: Vec::new(), + } + } + + /// Replaces the generated "Go to slide N" accessibility label. + pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } +} + +impl crate::Sizable for CarouselPaginationItem { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl ParentElement for CarouselPaginationItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for CarouselPaginationItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for CarouselPaginationItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let selected = self.state.read(cx).selected_index() == Some(self.index); + let disabled = self.index >= self.state.read(cx).item_count(); + let label = self.accessibility_label.unwrap_or_else(|| { + t!( + "Carousel.go_to_slide", + current = self.index.saturating_add(1) + ) + .into() + }); + let state = self.state; + let index = self.index; + + Button::new(self.id) + .compact() + .with_size(self.size) + .selected(selected) + .accessibility_label(label) + .disabled(disabled) + .children(self.children) + .when(!disabled, |this| { + this.on_click(move |_, _, cx| { + state.update(cx, |state, cx| { + state.select_index(index, cx); + }); + }) + }) + .refine_style(&self.style) + } +} + +fn axis_value(point: Point, axis: Axis) -> Pixels { + if axis.is_horizontal() { + point.x + } else { + point.y + } +} + +fn set_axis_value(point: &mut Point, axis: Axis, value: Pixels) { + if axis.is_horizontal() { + point.x = value; + } else { + point.y = value; + } +} + +fn snap_offset(handle: &gpui::ScrollHandle, axis: Axis, index: usize) -> Option { + let viewport = handle.bounds(); + let item = handle.bounds_for_item(index)?; + let target = if axis.is_horizontal() { + viewport.left() - item.left() + } else { + viewport.top() - item.top() + }; + let max = axis_value(handle.max_offset(), axis).max(Pixels::ZERO); + Some(target.clamp(-max, Pixels::ZERO)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext as _, point}; + + #[test] + fn axis_helpers_only_change_the_requested_coordinate() { + let mut value = point(px(3.), px(7.)); + set_axis_value(&mut value, Axis::Horizontal, px(11.)); + assert_eq!(value, point(px(11.), px(7.))); + set_axis_value(&mut value, Axis::Vertical, px(-5.)); + assert_eq!(value, point(px(11.), px(-5.))); + } + + #[gpui::test] + fn carousel_controls_accept_semantic_sizes(cx: &mut gpui::TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); + + assert_eq!(CarouselPrevious::new(&state).large().size, Size::Large); + assert_eq!(CarouselNext::new(&state).xsmall().size, Size::XSmall); + assert_eq!( + CarouselPaginationItem::new("pagination", 0, &state) + .small() + .size, + Size::Small + ); + } +} diff --git a/crates/ui/src/carousel/mod.rs b/crates/ui/src/carousel/mod.rs new file mode 100644 index 0000000000..9675584479 --- /dev/null +++ b/crates/ui/src/carousel/mod.rs @@ -0,0 +1,26 @@ +mod carousel; +mod scroll_mask; +mod state; + +pub use carousel::{ + Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPagination, + CarouselPaginationItem, CarouselPrevious, +}; +pub use state::{CarouselEvent, CarouselState}; + +use gpui::{App, KeyBinding}; + +use crate::actions::{SelectDown, SelectFirst, SelectLast, SelectLeft, SelectRight, SelectUp}; + +pub(super) const CONTEXT: &str = "Carousel"; + +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("left", SelectLeft, Some(CONTEXT)), + KeyBinding::new("right", SelectRight, Some(CONTEXT)), + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), + KeyBinding::new("home", SelectFirst, Some(CONTEXT)), + KeyBinding::new("end", SelectLast, Some(CONTEXT)), + ]); +} diff --git a/crates/ui/src/carousel/scroll_mask.rs b/crates/ui/src/carousel/scroll_mask.rs new file mode 100644 index 0000000000..a783d21d2c --- /dev/null +++ b/crates/ui/src/carousel/scroll_mask.rs @@ -0,0 +1,262 @@ +//! The pointer and wheel event surface used by [`super::CarouselContent`]. +//! +//! The mask is deliberately a sibling of the scrolled content. A mask nested +//! inside the content would receive the content's scroll offset and would stop +//! covering the viewport after the first scroll. + +use std::cell::RefCell; +use std::panic::Location; +use std::rc::Rc; + +use gpui::{ + App, Axis, Bounds, ContentMask, Element, ElementId, GlobalElementId, Hitbox, IntoElement, + IsZero as _, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + OngoingScroll, Position, ScrollWheelEvent, Style, TouchPhase, Window, relative, +}; + +use super::state::CarouselState; +use crate::{AxisExt as _, OngoingScrollExt as _, global_state::GlobalState}; + +/// A viewport-sized, invisible event surface for a Carousel. +/// +/// This surface owns pointer, wheel, and trackpad input so that a Carousel +/// nested in a list can lock its own axis while allowing cross-axis gestures +/// to continue to an ancestor. +pub(super) struct CarouselScrollMask { + axis: Axis, + id: ElementId, + state: gpui::Entity, +} + +impl CarouselScrollMask { + #[track_caller] + pub(super) fn new(axis: Axis, state: &gpui::Entity) -> Self { + Self { + axis, + id: caller_id(), + state: state.clone(), + } + } + + pub(super) fn id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } +} + +impl IntoElement for CarouselScrollMask { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for CarouselScrollMask { + type RequestLayoutState = (); + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + let axis = if self.axis.is_horizontal() { + "horizontal" + } else { + "vertical" + }; + Some((self.id.clone(), axis).into()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.position = Position::Absolute; + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + (window.request_layout(style, None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + // The layout node fills the wrapper, while the tracked scroll bounds + // are the actual styled viewport. Using those bounds keeps the input + // surface aligned when CarouselContent has a custom width or margin. + let viewport = self.state.read(cx).scroll_handle().bounds(); + window.insert_hitbox(viewport, gpui::HitboxBehavior::Normal) + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + _: &mut App, + ) { + let axis = self.axis; + let hitbox_id = hitbox.id; + let bounds = hitbox.bounds; + let state = self.state.clone(); + let ongoing_scroll = global_id + .map(|global_id| { + window.with_element_state::>, _>(global_id, |value, _| { + let value = value.unwrap_or_default(); + (value.clone(), value) + }) + }) + .unwrap_or_default(); + + window.with_content_mask(Some(ContentMask { bounds }), |window| { + let pointer_state = state.clone(); + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if phase.capture() + && event.button == MouseButton::Left + && hitbox_id.is_hovered(window) + { + let started = + pointer_state.update(cx, |state, cx| state.begin_drag(event.position, cx)); + if started { + GlobalState::suppress_text_selection(cx); + } + } + }); + + let pointer_state = state.clone(); + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| { + if !phase.capture() || event.pressed_button != Some(MouseButton::Left) { + return; + } + + let handled = + pointer_state.update(cx, |state, cx| state.update_drag(event.position, cx)); + if handled { + cx.stop_propagation(); + } + }); + + let pointer_state = state.clone(); + let mut suppress_release = false; + window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| { + if event.button != MouseButton::Left { + return; + } + + if phase.capture() { + let snapshot = pointer_state.read(cx); + let handled = snapshot.is_pointer_drag_locked(); + suppress_release = handled || snapshot.should_suppress_pointer_click(); + pointer_state.update(cx, |state, cx| { + state.finish_drag(cx); + }); + if suppress_release { + // The release may be stopped before TextSelectionLayer's + // bubble listener. Clear its observable selection state + // here so a Carousel drag cannot leave a stale drag or + // participant-local selection behind. + gpui_base::TextSelection::clear(window, cx); + } + } else if suppress_release { + // All capture handlers have now cleared their pressed + // state. Stop before descendant click handlers run. + suppress_release = false; + cx.stop_propagation(); + } + }); + + window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { + if !phase.capture() || !hitbox_id.should_handle_scroll(window) { + return; + } + + let mut delta = event.delta.pixel_delta(window.line_height()); + if event.delta.precise() { + ongoing_scroll + .borrow_mut() + .lock_axis(&mut delta, event.touch_phase); + } + + if !delta.x.is_zero() && !delta.y.is_zero() { + if delta.x.abs() > delta.y.abs() { + delta.y = gpui::Pixels::ZERO; + } else { + delta.x = gpui::Pixels::ZERO; + } + } + + // Ignore the secondary axis. The lock above keeps this stable + // throughout a precise trackpad gesture. + if axis.is_horizontal() { + delta.y = gpui::Pixels::ZERO; + } else { + delta.x = gpui::Pixels::ZERO; + } + + let precise = event.delta.precise(); + let primary_delta = if axis.is_horizontal() { + delta.x + } else { + delta.y + }; + let consumed = if precise { + if primary_delta.is_zero() + || matches!(event.touch_phase, TouchPhase::Ended | TouchPhase::Cancelled) + { + false + } else { + state.update(cx, |state, cx| { + state.handle_scroll_delta(axis, primary_delta, event.touch_phase, cx) + }) + } + } else { + if primary_delta.is_zero() { + false + } else if primary_delta > gpui::Pixels::ZERO { + state.update(cx, |state, cx| state.select_previous(cx)) + } else { + state.update(cx, |state, cx| state.select_next(cx)) + } + }; + + if precise { + match event.touch_phase { + TouchPhase::Ended => { + state.update(cx, |state, cx| state.finish_scroll(false, cx)); + } + TouchPhase::Cancelled => { + state.update(cx, |state, cx| state.finish_scroll(true, cx)); + } + TouchPhase::Started | TouchPhase::Moved => {} + } + } + + // Horizontal carousels retain the gesture at their edge. A + // vertical carousel chains at an edge so an ancestor can + // continue scrolling the surrounding document. + if consumed || (axis.is_horizontal() && !primary_delta.is_zero()) { + cx.stop_propagation(); + } + }); + }); + } +} + +#[track_caller] +fn caller_id() -> ElementId { + ElementId::CodeLocation(*Location::caller()) +} diff --git a/crates/ui/src/carousel/state.rs b/crates/ui/src/carousel/state.rs new file mode 100644 index 0000000000..2cbc177901 --- /dev/null +++ b/crates/ui/src/carousel/state.rs @@ -0,0 +1,1090 @@ +use std::time::Duration; + +use gpui::{ + Along, Axis, Bounds, Context, EventEmitter, Pixels, Point, ScrollHandle, TouchPhase, px, +}; + +const POINTER_AXIS_LOCK_THRESHOLD: Pixels = px(2.); +// Keep this aligned with GPUI's OngoingScroll timeout. Some platforms only +// emit `Moved`, so a quiet period is the only signal that a new gesture began. +const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); + +/// An event emitted when user interaction selects another carousel item. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CarouselEvent { + /// The newly selected item index. + Change(usize), +} + +/// Bounds collected by [`super::CarouselContent`] after layout. +/// +/// The bounds are kept in the content's unscrolled coordinate space. Keeping +/// this geometry in the behavior state lets pointer, wheel, and keyboard +/// input all resolve to the same snap points without making the content own a +/// second copy of the selection state. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) struct CarouselGeometry { + viewport: Option>, + items: Vec>, +} + +#[derive(Clone, Copy, Debug)] +struct PointerGesture { + start_position: Point, + start_offset: Point, + start_index: Option, + total_delta: Pixels, + axis_locked: bool, +} + +#[derive(Clone, Copy, Debug)] +struct ScrollGesture { + start_offset: Point, + start_index: Option, + total_delta: Pixels, +} + +/// Shared behavior state for every part of a [`super::Carousel`]. +/// +/// `CarouselState` intentionally owns behavior only. The content and its +/// items provide presentation and layout, while this state owns selection, +/// orientation, looping, the shared scroll handle, and the input snapshots +/// used to settle gestures. Programmatic setters are silent; user-facing +/// selection methods emit one [`CarouselEvent::Change`] for a successful +/// selection. +pub struct CarouselState { + item_count: usize, + selected_index: Option, + axis: Axis, + looping: bool, + scroll_handle: ScrollHandle, + geometry: CarouselGeometry, + pointer_gesture: Option, + scroll_gesture: Option, + ignore_scroll_until_quiet: bool, + scroll_settle_epoch: usize, + suppress_pointer_click: bool, + motion_revision: usize, +} + +impl CarouselState { + /// Creates state for `item_count` items, initially selecting the first + /// item when at least one item exists. + pub fn new(item_count: usize) -> Self { + Self { + item_count, + selected_index: (item_count > 0).then_some(0), + axis: Axis::Horizontal, + looping: false, + scroll_handle: ScrollHandle::new(), + geometry: CarouselGeometry::default(), + pointer_gesture: None, + scroll_gesture: None, + ignore_scroll_until_quiet: false, + scroll_settle_epoch: 0, + suppress_pointer_click: false, + motion_revision: 0, + } + } + + /// Sets the initially selected item. + pub fn with_selected_index(mut self, index: usize) -> Self { + self.selected_index = self.clamp_index(index); + self + } + + /// Sets the carousel orientation. + pub fn with_axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + + /// Enables or disables wrapping at the first and last items. + pub fn with_looping(mut self, looping: bool) -> Self { + self.looping = looping; + self + } + + /// Returns the number of logical items. + pub fn item_count(&self) -> usize { + self.item_count + } + + /// Returns the selected logical item, or `None` when the carousel is + /// empty. + pub fn selected_index(&self) -> Option { + self.selected_index + } + + /// Returns the configured orientation. + pub fn axis(&self) -> Axis { + self.axis + } + + /// Returns whether navigation wraps at the carousel edges. + pub fn is_looping(&self) -> bool { + self.looping + } + + /// Returns whether a previous item can be selected. + pub fn has_previous(&self) -> bool { + match self.selected_index { + Some(_) if self.looping => self.item_count > 1, + Some(index) => index > 0, + None => false, + } + } + + /// Returns whether a next item can be selected. + pub fn has_next(&self) -> bool { + match self.selected_index { + Some(_) if self.looping => self.item_count > 1, + Some(index) => index + 1 < self.item_count, + None => false, + } + } + + /// Silently changes the selected item for controlled/programmatic use. + /// + /// The value is clamped to the available item range. This method does + /// not emit [`CarouselEvent::Change`]. + pub fn set_selected_index(&mut self, index: usize, cx: &mut Context) { + let next_index = self.clamp_index(index); + let changed = self.selected_index != next_index || self.is_interacting(); + if self + .selected_index + .zip(next_index) + .is_some_and(|(current, next)| self.is_loop_wrap(current, next)) + { + self.motion_revision = self.motion_revision.wrapping_add(1); + } + self.selected_index = next_index; + self.cancel_interactions(cx); + if changed { + cx.notify(); + } + } + + /// Silently changes the number of logical items and clamps the selection. + pub fn set_item_count(&mut self, item_count: usize, cx: &mut Context) { + if self.item_count == item_count { + return; + } + self.item_count = item_count; + self.selected_index = self + .selected_index + .and_then(|index| (item_count > 0).then_some(index.min(item_count.saturating_sub(1)))); + if self.selected_index.is_none() && item_count > 0 { + self.selected_index = Some(0); + } + self.geometry.items.clear(); + self.cancel_interactions(cx); + cx.notify(); + } + + /// Silently changes the carousel orientation. + pub fn set_axis(&mut self, axis: Axis, cx: &mut Context) { + if self.axis != axis { + self.axis = axis; + self.scroll_handle.set_offset(Point::default()); + self.cancel_interactions(cx); + self.motion_revision = self.motion_revision.wrapping_add(1); + cx.notify(); + } + } + + /// Silently changes whether edge navigation wraps. + pub fn set_looping(&mut self, looping: bool, cx: &mut Context) { + if self.looping != looping { + self.looping = looping; + self.cancel_interactions(cx); + cx.notify(); + } + } + + /// Selects an item through a user-facing path and emits one change event + /// when the index is valid and differs from the current selection. + pub fn select_index(&mut self, index: usize, cx: &mut Context) -> bool { + if index >= self.item_count { + self.cancel_user_interaction(cx); + return false; + } + let wrapped = self + .selected_index + .zip(Some(index)) + .is_some_and(|(current, next)| self.is_loop_wrap(current, next)); + self.select_index_with_wrap(index, wrapped, cx) + } + + /// Selects the previous item, wrapping when looping is enabled. + pub fn select_previous(&mut self, cx: &mut Context) -> bool { + let Some(current) = self.selected_index else { + return false; + }; + + if current > 0 { + self.select_index_with_wrap(current - 1, false, cx) + } else if self.looping && self.item_count > 1 { + self.select_index_with_wrap(self.item_count - 1, true, cx) + } else { + self.cancel_user_interaction(cx); + false + } + } + + /// Selects the next item, wrapping when looping is enabled. + pub fn select_next(&mut self, cx: &mut Context) -> bool { + let Some(current) = self.selected_index else { + return false; + }; + + if current + 1 < self.item_count { + self.select_index_with_wrap(current + 1, false, cx) + } else if self.looping && self.item_count > 1 { + self.select_index_with_wrap(0, true, cx) + } else { + self.cancel_user_interaction(cx); + false + } + } + + /// Selects the first item through the user-facing path. + pub fn select_first(&mut self, cx: &mut Context) -> bool { + self.select_index(0, cx) + } + + /// Selects the last item through the user-facing path. + pub fn select_last(&mut self, cx: &mut Context) -> bool { + let Some(index) = self.item_count.checked_sub(1) else { + self.cancel_user_interaction(cx); + return false; + }; + self.select_index(index, cx) + } + + /// Returns the shared scroll handle used by the content viewport. + pub(super) fn scroll_handle(&self) -> &ScrollHandle { + &self.scroll_handle + } + + /// Returns whether pointer or trackpad input is currently active. + pub(super) fn is_interacting(&self) -> bool { + self.pointer_gesture.is_some() || self.scroll_gesture.is_some() + } + + /// Returns whether the active pointer gesture has committed to this axis. + pub(super) fn is_pointer_drag_locked(&self) -> bool { + self.pointer_gesture + .is_some_and(|gesture| gesture.axis_locked) + } + + /// Returns whether a cross-axis move cancelled this pointer sequence. + /// + /// The event surface uses this to stop the release during its bubble phase. + /// Capture-phase handlers still run first, so descendant controls clear + /// their pending press and ancestor gesture surfaces can finish. + pub(super) fn should_suppress_pointer_click(&self) -> bool { + self.suppress_pointer_click + } + + /// Returns a monotonic key for motion that must be rebased immediately. + /// + /// Ordinary adjacent selection leaves this value unchanged. A logical + /// loop wrap increments it so the content can use a fresh spring key and + /// avoid animating across the entire strip. + pub(super) fn motion_revision(&self) -> usize { + self.motion_revision + } + + /// Records the viewport and item bounds used for gesture snapping. + pub(super) fn set_geometry(&mut self, viewport: Bounds, items: Vec>) { + self.geometry = CarouselGeometry { + viewport: Some(viewport), + items, + }; + } + + /// Returns the geometry-derived snap offset for `index`. + pub(super) fn snap_target_for(&self, index: usize) -> Option> { + let viewport = self.geometry.viewport?; + let item = self.geometry.items.get(index)?; + Some(self.snap_offset(viewport, *item)) + } + + /// Returns the nearest item index for a scroll offset. + pub(super) fn nearest_index(&self, offset: Point) -> Option { + let viewport = self.geometry.viewport?; + self.geometry + .items + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| { + let left_distance = self.primary_distance(offset, viewport, **left); + let right_distance = self.primary_distance(offset, viewport, **right); + left_distance + .partial_cmp(&right_distance) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(index, _)| index) + } + + /// Begins a pointer drag and notifies the owning entity. + pub(super) fn begin_drag(&mut self, position: Point, cx: &mut Context) -> bool { + let cancelled_scroll = self.scroll_gesture.is_some(); + let started = self.begin_drag_snapshot(position); + if started { + if cancelled_scroll { + self.schedule_ignored_scroll_recovery(cx); + } + cx.notify(); + } + started + } + + /// Updates the active pointer drag after locking it to the carousel axis. + /// Cross-axis drags cancel this gesture so an ancestor can handle them. + pub(super) fn update_drag(&mut self, position: Point, cx: &mut Context) -> bool { + let Some(mut gesture) = self.pointer_gesture else { + return false; + }; + + let delta = position - gesture.start_position; + let primary_delta = self.primary_delta(delta); + if !gesture.axis_locked { + let cross_axis_delta = self.cross_axis_delta(delta); + if primary_delta.abs().max(cross_axis_delta.abs()) <= POINTER_AXIS_LOCK_THRESHOLD { + return false; + } + if cross_axis_delta.abs() > primary_delta.abs() { + self.pointer_gesture = None; + self.suppress_pointer_click = true; + cx.notify(); + return false; + } + gesture.axis_locked = true; + } + + gesture.total_delta = primary_delta; + self.pointer_gesture = Some(gesture); + let mut offset = gesture.start_offset; + let next = self.clamped_offset(self.primary_offset(offset) + primary_delta); + self.set_primary_offset(&mut offset, next); + let changed = offset != self.scroll_handle.offset(); + self.scroll_handle.set_offset(offset); + if changed { + cx.notify(); + } + true + } + + /// Finishes a pointer drag by selecting the nearest item and settling the + /// handle to its snap point. + pub(super) fn finish_drag(&mut self, cx: &mut Context) -> bool { + let suppressed_click = std::mem::take(&mut self.suppress_pointer_click); + let Some(gesture) = self.pointer_gesture.take() else { + if suppressed_click { + cx.notify(); + } + return false; + }; + self.finish_snapshot( + gesture.start_offset, + gesture.start_index, + gesture.total_delta, + cx, + ) + } + + /// Applies a precise trackpad delta and remembers the gesture's start. + /// Returns whether the delta moved the handle or can be consumed by a + /// looping carousel. + pub(super) fn handle_scroll_delta( + &mut self, + axis: Axis, + delta: Pixels, + phase: TouchPhase, + cx: &mut Context, + ) -> bool { + if axis != self.axis || self.item_count < 2 { + return false; + } + + if self.ignore_scroll_until_quiet { + match phase { + TouchPhase::Started => { + self.ignore_scroll_until_quiet = false; + self.invalidate_scroll_settle(); + } + TouchPhase::Ended | TouchPhase::Cancelled => { + self.ignore_scroll_until_quiet = false; + self.invalidate_scroll_settle(); + return false; + } + TouchPhase::Moved => { + self.schedule_ignored_scroll_recovery(cx); + return false; + } + } + } + + if matches!(phase, TouchPhase::Started) && self.scroll_gesture.is_some() { + self.finish_scroll(false, cx); + } + + if self.scroll_gesture.is_none() { + self.pointer_gesture = None; + self.scroll_gesture = Some(ScrollGesture { + start_offset: self.scroll_handle.offset(), + start_index: self.selected_index, + total_delta: px(0.), + }); + } + + if let Some(gesture) = self.scroll_gesture.as_mut() { + gesture.total_delta += delta; + } else { + return false; + } + + let mut offset = self.scroll_handle.offset(); + let previous = offset; + let next = self.clamped_offset(self.primary_offset(offset) + delta); + self.set_primary_offset(&mut offset, next); + self.scroll_handle.set_offset(offset); + let moved = offset != previous; + if moved { + cx.notify(); + } + if matches!(phase, TouchPhase::Started | TouchPhase::Moved) { + self.schedule_scroll_settle(cx); + } + moved || self.looping + } + + /// Finishes a precise trackpad gesture. Cancelled gestures restore the + /// original offset and never emit a selection event. + pub(super) fn finish_scroll(&mut self, cancelled: bool, cx: &mut Context) -> bool { + self.invalidate_scroll_settle(); + if self.ignore_scroll_until_quiet { + self.ignore_scroll_until_quiet = false; + return false; + } + let Some(gesture) = self.scroll_gesture.take() else { + return false; + }; + if cancelled { + self.scroll_handle.set_offset(gesture.start_offset); + cx.notify(); + return false; + } + + self.finish_snapshot( + gesture.start_offset, + gesture.start_index, + gesture.total_delta, + cx, + ) + } + + fn select_index_with_wrap( + &mut self, + index: usize, + wrapped: bool, + cx: &mut Context, + ) -> bool { + let Some(index) = self.clamp_index(index) else { + return false; + }; + let was_interacting = self.is_interacting(); + self.cancel_interactions(cx); + if self.selected_index == Some(index) { + if was_interacting { + cx.notify(); + } + return false; + } + + self.selected_index = Some(index); + if wrapped { + self.motion_revision = self.motion_revision.wrapping_add(1); + } + cx.emit(CarouselEvent::Change(index)); + cx.notify(); + true + } + + fn clamp_index(&self, index: usize) -> Option { + (self.item_count > 0).then_some(index.min(self.item_count - 1)) + } + + fn is_loop_wrap(&self, current: usize, next: usize) -> bool { + self.looping + && self.item_count > 1 + && ((current == 0 && next + 1 == self.item_count) + || (current + 1 == self.item_count && next == 0)) + } + + fn cancel_interactions(&mut self, cx: &mut Context) { + let cancelled_scroll = self.scroll_gesture.is_some(); + self.pointer_gesture = None; + self.scroll_gesture = None; + self.invalidate_scroll_settle(); + if cancelled_scroll { + self.ignore_scroll_until_quiet = true; + self.schedule_ignored_scroll_recovery(cx); + } + } + + fn cancel_user_interaction(&mut self, cx: &mut Context) { + let was_interacting = self.is_interacting(); + self.cancel_interactions(cx); + if was_interacting { + cx.notify(); + } + } + + fn begin_drag_snapshot(&mut self, position: Point) -> bool { + self.suppress_pointer_click = false; + if self.item_count < 2 { + return false; + } + self.pointer_gesture = Some(PointerGesture { + start_position: position, + start_offset: self.scroll_handle.offset(), + start_index: self.selected_index, + total_delta: px(0.), + axis_locked: false, + }); + if self.scroll_gesture.is_some() { + self.ignore_scroll_until_quiet = true; + } + self.scroll_gesture = None; + self.invalidate_scroll_settle(); + true + } + + fn schedule_scroll_settle(&mut self, cx: &mut Context) { + self.scroll_settle_epoch = self.scroll_settle_epoch.wrapping_add(1); + let epoch = self.scroll_settle_epoch; + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(SCROLL_EVENT_SEPARATION) + .await; + if let Some(this) = this.upgrade() { + this.update(cx, |state, cx| { + if state.scroll_settle_epoch == epoch && state.scroll_gesture.is_some() { + state.finish_scroll(false, cx); + } + }); + } + }) + .detach(); + } + + fn schedule_ignored_scroll_recovery(&mut self, cx: &mut Context) { + self.scroll_settle_epoch = self.scroll_settle_epoch.wrapping_add(1); + let epoch = self.scroll_settle_epoch; + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(SCROLL_EVENT_SEPARATION) + .await; + if let Some(this) = this.upgrade() { + this.update(cx, |state, _| { + if state.scroll_settle_epoch == epoch { + state.ignore_scroll_until_quiet = false; + } + }); + } + }) + .detach(); + } + + fn invalidate_scroll_settle(&mut self) { + self.scroll_settle_epoch = self.scroll_settle_epoch.wrapping_add(1); + } + + fn finish_snapshot( + &mut self, + start_offset: Point, + start_index: Option, + total_delta: Pixels, + cx: &mut Context, + ) -> bool { + let current_offset = self.scroll_handle.offset(); + let selected = if self.looping { + self.loop_boundary_index(start_index, total_delta) + .or_else(|| self.nearest_index(current_offset)) + } else { + self.nearest_index(current_offset) + }; + + let changed = selected.is_some_and(|index| { + if self.selected_index == Some(index) { + false + } else { + let wrapped = self + .selected_index + .zip(selected) + .is_some_and(|(current, next)| self.is_loop_wrap(current, next)); + self.select_index_with_wrap(index, wrapped, cx) + } + }); + + if selected.is_none() { + self.scroll_handle.set_offset(start_offset); + } + if !changed { + cx.notify(); + } + changed + } + + fn loop_boundary_index( + &self, + start_index: Option, + total_delta: Pixels, + ) -> Option { + let start_index = start_index?; + let threshold = self.snap_extent() * 0.25; + if total_delta.abs() < threshold.max(px(1.)) { + return None; + } + if start_index == 0 && total_delta > px(0.) { + self.item_count.checked_sub(1) + } else if start_index + 1 == self.item_count && total_delta < px(0.) { + Some(0) + } else { + None + } + } + + fn snap_extent(&self) -> Pixels { + self.geometry + .viewport + .map(|bounds| bounds.size.along(self.axis)) + .or_else(|| { + self.geometry + .items + .first() + .map(|bounds| bounds.size.along(self.axis)) + }) + .unwrap_or(px(1.)) + } + + fn snap_offset(&self, viewport: Bounds, item: Bounds) -> Point { + let mut offset = self.scroll_handle.offset(); + let target = match self.axis { + Axis::Horizontal => viewport.left() - item.left(), + Axis::Vertical => viewport.top() - item.top(), + } + .clamp(-self.max_snap_offset(), px(0.)); + self.set_primary_offset(&mut offset, target); + offset + } + + fn max_snap_offset(&self) -> Pixels { + let handle_max = self + .primary_offset(self.scroll_handle.max_offset()) + .max(px(0.)); + let Some(viewport) = self.geometry.viewport else { + return handle_max; + }; + let Some(first) = self.geometry.items.first() else { + return handle_max; + }; + + let (mut content_start, mut content_end) = match self.axis { + Axis::Horizontal => (first.left(), first.right()), + Axis::Vertical => (first.top(), first.bottom()), + }; + for item in &self.geometry.items[1..] { + let (start, end) = match self.axis { + Axis::Horizontal => (item.left(), item.right()), + Axis::Vertical => (item.top(), item.bottom()), + }; + content_start = content_start.min(start); + content_end = content_end.max(end); + } + let geometry_max = + (content_end - content_start - viewport.size.along(self.axis)).max(px(0.)); + handle_max.max(geometry_max) + } + + fn primary_distance( + &self, + offset: Point, + viewport: Bounds, + item: Bounds, + ) -> Pixels { + let target = self.snap_offset(viewport, item); + (self.primary_offset(offset) - self.primary_offset(target)).abs() + } + + fn primary_offset(&self, offset: Point) -> Pixels { + match self.axis { + Axis::Horizontal => offset.x, + Axis::Vertical => offset.y, + } + } + + fn set_primary_offset(&self, offset: &mut Point, value: Pixels) { + match self.axis { + Axis::Horizontal => offset.x = value, + Axis::Vertical => offset.y = value, + } + } + + fn primary_delta(&self, delta: Point) -> Pixels { + match self.axis { + Axis::Horizontal => delta.x, + Axis::Vertical => delta.y, + } + } + + fn cross_axis_delta(&self, delta: Point) -> Pixels { + match self.axis { + Axis::Horizontal => delta.y, + Axis::Vertical => delta.x, + } + } + + fn clamped_offset(&self, value: Pixels) -> Pixels { + let max_offset = self.scroll_handle.max_offset(); + let bound = self.primary_offset(max_offset).max(px(0.)); + value.clamp(-bound, px(0.)) + } +} + +impl EventEmitter for CarouselState {} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc}; + + use gpui::{AppContext as _, TestAppContext, point, px}; + + use super::*; + + #[test] + fn constructors_and_programmatic_setters_clamp_without_events() { + let state = CarouselState::new(3) + .with_selected_index(99) + .with_axis(Axis::Vertical) + .with_looping(true); + assert_eq!(state.item_count(), 3); + assert_eq!(state.selected_index(), Some(2)); + assert_eq!(state.axis(), Axis::Vertical); + assert!(state.is_looping()); + assert!(state.has_previous()); + assert!(state.has_next()); + + let empty = CarouselState::new(0); + assert_eq!(empty.selected_index(), None); + assert!(!empty.has_previous()); + assert!(!empty.has_next()); + } + + #[test] + fn geometry_produces_axis_specific_snap_points() { + let mut state = CarouselState::new(2).with_axis(Axis::Horizontal); + state.set_geometry( + Bounds::new(point(px(10.), px(20.)), gpui::size(px(100.), px(40.))), + vec![ + Bounds::new(point(px(10.), px(20.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(110.), px(20.)), gpui::size(px(100.), px(40.))), + ], + ); + assert_eq!(state.snap_target_for(0), Some(point(px(0.), px(0.)))); + assert_eq!(state.snap_target_for(1), Some(point(px(-100.), px(0.)))); + let mut vertical = CarouselState::new(2).with_axis(Axis::Vertical); + vertical.set_geometry( + Bounds::new(point(px(10.), px(20.)), gpui::size(px(40.), px(100.))), + vec![ + Bounds::new(point(px(10.), px(20.)), gpui::size(px(40.), px(100.))), + Bounds::new(point(px(10.), px(120.)), gpui::size(px(40.), px(100.))), + ], + ); + assert_eq!(vertical.snap_target_for(1), Some(point(px(0.), px(-100.)))); + + let mut narrow = CarouselState::new(3); + narrow.set_geometry( + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(50.), px(40.))), + Bounds::new(point(px(50.), px(0.)), gpui::size(px(50.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(50.), px(40.))), + ], + ); + assert_eq!(narrow.snap_target_for(2), Some(point(px(-50.), px(0.)))); + } + + #[gpui::test] + fn non_looping_navigation_stops_at_both_boundaries(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&state, move |_, event: &CarouselEvent, _| { + let CarouselEvent::Change(index) = event; + events.borrow_mut().push(*index); + }) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + assert!(!state.select_previous(cx)); + assert!(!state.select_first(cx)); + assert!(state.select_last(cx)); + assert!(!state.select_next(cx)); + assert!(!state.select_last(cx)); + }); + }); + + assert_eq!(events.borrow().as_slice(), &[2]); + } + + #[gpui::test] + fn user_navigation_emits_once_and_programmatic_changes_stay_silent(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&state, move |_, event: &CarouselEvent, _| { + let CarouselEvent::Change(index) = event; + events.borrow_mut().push(*index); + }) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + assert!(state.select_next(cx)); + assert!(!state.select_index(1, cx)); + assert!(!state.select_index(99, cx)); + state.set_selected_index(2, cx); + state.set_axis(Axis::Vertical, cx); + state.set_looping(true, cx); + }); + }); + + assert_eq!(events.borrow().as_slice(), &[1]); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(2) + ); + assert_eq!(state.read_with(cx, |state, _| state.axis()), Axis::Vertical); + assert!(state.read_with(cx, |state, _| state.is_looping())); + + let empty = cx.update(|cx| cx.new(|_| CarouselState::new(0))); + cx.update(|cx| { + empty.update(cx, |state, cx| { + state.set_item_count(2, cx); + state.set_selected_index(99, cx); + }); + }); + assert_eq!( + empty.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + } + + #[gpui::test] + fn pointer_drag_locks_to_the_primary_axis(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); + + cx.update(|cx| { + state.update(cx, |state, cx| { + assert!(state.begin_drag(point(px(0.), px(0.)), cx)); + assert!(!state.update_drag(point(px(1.), px(1.)), cx)); + assert!(state.is_interacting()); + assert!(!state.is_pointer_drag_locked()); + assert!(!state.update_drag(point(px(4.), px(20.)), cx)); + assert!(!state.is_interacting()); + assert!(state.should_suppress_pointer_click()); + assert!(!state.finish_drag(cx)); + assert!(!state.should_suppress_pointer_click()); + + assert!(state.begin_drag(point(px(0.), px(0.)), cx)); + assert!(state.update_drag(point(px(20.), px(4.)), cx)); + assert!(state.is_interacting()); + assert!(state.is_pointer_drag_locked()); + state.finish_drag(cx); + assert!(!state.is_interacting()); + }); + }); + } + + #[gpui::test] + fn finishing_a_drag_keeps_the_offset_as_the_snap_animation_origin(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); + + cx.update(|cx| { + state.update(cx, |state, cx| { + state.set_geometry( + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + assert!(state.begin_drag(point(px(0.), px(0.)), cx)); + state.scroll_handle.set_offset(point(px(-60.), px(0.))); + assert!(state.finish_drag(cx)); + assert_eq!(state.selected_index(), Some(1)); + assert_eq!(state.scroll_handle.offset(), point(px(-60.), px(0.))); + }); + }); + } + + #[gpui::test] + fn user_navigation_invalidates_an_active_trackpad_gesture(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&state, move |_, event: &CarouselEvent, _| { + let CarouselEvent::Change(index) = event; + events.borrow_mut().push(*index); + }) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + state.handle_scroll_delta(Axis::Horizontal, px(-20.), TouchPhase::Started, cx); + assert!(state.is_interacting()); + assert!(state.select_next(cx)); + assert!(!state.is_interacting()); + assert!(!state.handle_scroll_delta( + Axis::Horizontal, + px(-20.), + TouchPhase::Moved, + cx, + )); + assert!(!state.is_interacting()); + assert!(state.ignore_scroll_until_quiet); + }); + }); + + cx.run_until_parked(); + cx.executor().advance_clock(SCROLL_EVENT_SEPARATION); + cx.run_until_parked(); + + cx.update(|cx| { + state.update(cx, |state, cx| { + assert!(!state.ignore_scroll_until_quiet); + assert!(!state.handle_scroll_delta( + Axis::Horizontal, + px(-20.), + TouchPhase::Moved, + cx, + )); + assert!(state.is_interacting()); + assert!(!state.finish_scroll(true, cx)); + assert!(!state.is_interacting()); + + assert!(!state.handle_scroll_delta( + Axis::Horizontal, + px(-20.), + TouchPhase::Started, + cx, + )); + assert!(state.is_interacting()); + assert!(!state.finish_scroll(true, cx)); + assert!(!state.is_interacting()); + }); + }); + + assert_eq!(events.borrow().as_slice(), &[1]); + } + + #[gpui::test] + fn moved_only_trackpad_gesture_settles_after_the_quiet_period(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&state, move |_, event: &CarouselEvent, _| { + let CarouselEvent::Change(index) = event; + events.borrow_mut().push(*index); + }) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + state.set_geometry( + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + state.handle_scroll_delta(Axis::Horizontal, px(-60.), TouchPhase::Moved, cx); + state.scroll_handle.set_offset(point(px(-60.), px(0.))); + assert!(state.is_interacting()); + }); + }); + + cx.run_until_parked(); + cx.executor().advance_clock(SCROLL_EVENT_SEPARATION); + cx.run_until_parked(); + + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + assert!(!state.read_with(cx, |state, _| state.is_interacting())); + assert_eq!(events.borrow().as_slice(), &[1]); + } + + #[gpui::test] + fn invalid_boundary_navigation_still_cancels_the_active_gesture(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); + + cx.update(|cx| { + state.update(cx, |state, cx| { + state.handle_scroll_delta(Axis::Horizontal, px(20.), TouchPhase::Started, cx); + assert!(state.is_interacting()); + assert!(!state.select_previous(cx)); + assert!(!state.is_interacting()); + assert!(!state.handle_scroll_delta( + Axis::Horizontal, + px(20.), + TouchPhase::Moved, + cx, + )); + assert!(!state.finish_scroll(false, cx)); + + assert!(state.select_last(cx)); + state.handle_scroll_delta(Axis::Horizontal, px(-20.), TouchPhase::Started, cx); + assert!(state.is_interacting()); + assert!(!state.select_next(cx)); + assert!(!state.is_interacting()); + assert!(!state.finish_scroll(false, cx)); + }); + }); + } + + #[gpui::test] + fn looping_boundaries_emit_one_event_and_advance_motion_revision(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true))); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&state, move |_, event: &CarouselEvent, _| { + let CarouselEvent::Change(index) = event; + events.borrow_mut().push(*index); + }) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + assert!(state.select_previous(cx)); + assert_eq!(state.selected_index(), Some(1)); + assert_eq!(state.motion_revision(), 1); + assert!(state.select_next(cx)); + assert_eq!(state.selected_index(), Some(0)); + assert_eq!(state.motion_revision(), 2); + }); + }); + assert_eq!(events.borrow().as_slice(), &[1, 0]); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 342ef1bb67..bf9cad2c4c 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -28,6 +28,7 @@ pub mod avatar; pub mod badge; pub mod breadcrumb; pub mod button; +pub mod carousel; pub mod chart; pub mod checkbox; pub mod clipboard; @@ -132,6 +133,7 @@ pub fn init(cx: &mut App) { sheet::init(cx); list::init(cx); command::init(cx); + carousel::init(cx); notification::init(cx); popover::init(cx); menu::init(cx); diff --git a/website/docs/components/carousel.md b/website/docs/components/carousel.md new file mode 100644 index 0000000000..439a255434 --- /dev/null +++ b/website/docs/components/carousel.md @@ -0,0 +1,125 @@ +--- +title: Carousel +description: A composable carousel for browsing related content. +--- + +# Carousel + +Carousel displays a set of related items one at a time. It supports horizontal and vertical layouts, keyboard navigation, pointer and trackpad gestures, looping, and controlled selection. + +## Import + +```rust +use gpui::Axis; +use gpui_component::carousel::{ + Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext, + CarouselPagination, CarouselPaginationItem, CarouselPrevious, CarouselState, +}; +``` + +## Usage + +Create one `CarouselState` for the content and pass it to every carousel part. + +```rust +let state = cx.new(|_| CarouselState::new(3)); + +Carousel::new("projects-carousel", &state) + .child( + CarouselContent::new(&state) + .child(CarouselItem::new("project-1", 0, &state).child("Project one")) + .child(CarouselItem::new("project-2", 1, &state).child("Project two")) + .child(CarouselItem::new("project-3", 2, &state).child("Project three")), + ) + .child(CarouselPrevious::new(&state)) + .child(CarouselNext::new(&state)) +``` + +`CarouselContent` owns the viewport and snap layout. `CarouselItem` identifies one logical slide. The previous and next controls automatically become disabled at the corresponding boundary. + +Keep the state's item count equal to the number of direct `CarouselItem` children. A state and its scroll handle belong to one viewport. + +## Orientation + +Use `with_axis` when creating the state: + +```rust +let state = cx.new(|_| { + CarouselState::new(3).with_axis(Axis::Vertical) +}); +``` + +Horizontal carousels use Left and Right. Vertical carousels use Up and Down. +Give vertical `CarouselContent` an explicit height so each full-height item has a viewport to snap within. + +The Carousel root is a tab stop, so keyboard navigation also works when optional controls are omitted. Home and End select the first and last items. + +## Looping + +Enable looping to wrap navigation from the last item to the first: + +```rust +let state = cx.new(|_| CarouselState::new(5).with_looping(true)); +``` + +## Controlled selection + +`CarouselState` can be controlled by application state. Use `with_selected_index` for the initial selection and `set_selected_index` for programmatic changes. + +```rust +let state = cx.new(|_| CarouselState::new(4).with_selected_index(1)); + +state.update(cx, |state, cx| { + state.set_selected_index(3, cx); +}); +``` + +Subscribe to `CarouselEvent::Change` when the application needs to mirror the selected item: + +```rust +cx.subscribe(&state, |this, _, event: &CarouselEvent, cx| { + let CarouselEvent::Change(index) = event; + this.selected_index = *index; + cx.notify(); +}); +``` + +## Events + +| Event | Description | +| --- | --- | +| `CarouselEvent::Change(index)` | Emitted when user navigation selects a new item. | + +Keyboard navigation and previous/next controls use the same state transition and emit the same event. Pointer and trackpad gestures select the nearest snap point when the gesture ends. + +## Pagination indicators + +Pagination is optional and does not impose one visual treatment. Compose indicators with `CarouselPaginationItem`, then style or fill each item as needed: + +```rust +CarouselPagination::new().children((0..3).map(|index| { + CarouselPaginationItem::new(("project-page", index), index, &state) + .child((index + 1).to_string()) +})) +``` + +`CarouselPaginationItem` uses the same selection transition as pointer, keyboard, and previous/next navigation. + +## Control size + +`CarouselPrevious`, `CarouselNext`, and `CarouselPaginationItem` implement `Sizable`. Apply the same semantic size to the controls when they should scale together: + +```rust +use gpui_component::{Sizable as _, Size}; + +CarouselPrevious::new(&state).with_size(Size::Large); +CarouselNext::new(&state).with_size(Size::Large); +``` + +Previous and next controls default to `Size::Small`. Pagination items default to `Size::XSmall`. + +## Accessibility + +The carousel exposes a labelled region and each item reports its position within the set. Use `with_accessibility_label` when the default "Carousel" label does not describe the content. + +Carousel animation follows the application's reduced-motion preference. diff --git a/website/docs/components/index.md b/website/docs/components/index.md index e57c4ff49a..9b07577836 100644 --- a/website/docs/components/index.md +++ b/website/docs/components/index.md @@ -63,6 +63,7 @@ collapsed: false ### Advanced Components - [Calendar](calendar) - Calendar display and navigation +- [Carousel](carousel) - Browse through a set of related items - [Command](command) - Command palette for search and quick actions - [Chart](chart) - Data visualization charts (Line, Bar, Area, Pie, Candlestick) - [List](list) - List display with items diff --git a/website/zh-CN/docs/components/carousel.md b/website/zh-CN/docs/components/carousel.md new file mode 100644 index 0000000000..35aa9025b5 --- /dev/null +++ b/website/zh-CN/docs/components/carousel.md @@ -0,0 +1,125 @@ +--- +title: Carousel +description: 用于浏览相关内容的可组合 Carousel 组件。 +--- + +# Carousel + +Carousel 用于逐项浏览一组相关内容,支持横向和纵向布局、键盘导航、指针与触控板手势、循环以及受控选中项。 + +## 引入 + +```rust +use gpui::Axis; +use gpui_component::carousel::{ + Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext, + CarouselPagination, CarouselPaginationItem, CarouselPrevious, CarouselState, +}; +``` + +## 使用 + +为内容创建一个 `CarouselState`,并将它传给所有 Carousel 部件。 + +```rust +let state = cx.new(|_| CarouselState::new(3)); + +Carousel::new("projects-carousel", &state) + .child( + CarouselContent::new(&state) + .child(CarouselItem::new("project-1", 0, &state).child("项目一")) + .child(CarouselItem::new("project-2", 1, &state).child("项目二")) + .child(CarouselItem::new("project-3", 2, &state).child("项目三")), + ) + .child(CarouselPrevious::new(&state)) + .child(CarouselNext::new(&state)) +``` + +`CarouselContent` 管理 viewport 与吸附布局,`CarouselItem` 标识一个逻辑 slide。到达对应边界时,上一项和下一项按钮会自动禁用。 + +state 的 item 数量应与直接 `CarouselItem` 子元素的数量一致。一个 state 及其 scroll handle 只服务一个 viewport。 + +## 方向 + +创建 state 时使用 `with_axis`: + +```rust +let state = cx.new(|_| { + CarouselState::new(3).with_axis(Axis::Vertical) +}); +``` + +横向 Carousel 使用 Left 和 Right,纵向 Carousel 使用 Up 和 Down。 +纵向 `CarouselContent` 需要设置明确的高度,让每个全高 item 都有可供吸附的 viewport。 + +Carousel 根节点可通过 Tab 获得焦点,因此省略可选控制按钮时仍可使用键盘导航。Home 和 End 用于选择第一项和最后一项。 + +## 循环 + +启用循环后,从最后一项继续向后会回到第一项: + +```rust +let state = cx.new(|_| CarouselState::new(5).with_looping(true)); +``` + +## 受控选中项 + +应用可以控制 `CarouselState`。使用 `with_selected_index` 设置初始选中项,使用 `set_selected_index` 进行程序化切换。 + +```rust +let state = cx.new(|_| CarouselState::new(4).with_selected_index(1)); + +state.update(cx, |state, cx| { + state.set_selected_index(3, cx); +}); +``` + +如果应用需要同步当前 slide,可以监听 `CarouselEvent::Change`: + +```rust +cx.subscribe(&state, |this, _, event: &CarouselEvent, cx| { + let CarouselEvent::Change(index) = event; + this.selected_index = *index; + cx.notify(); +}); +``` + +## 事件 + +| 事件 | 说明 | +| --- | --- | +| `CarouselEvent::Change(index)` | 用户导航选中新的内容时触发。 | + +键盘导航和上一项/下一项按钮使用同一套 state 状态转换,并触发相同事件。指针和触控板手势结束时,会吸附到最近的 snap 点。 + +## 分页指示器 + +分页是可选部件,不会固定一种视觉样式。使用 `CarouselPaginationItem` 组合指示器,再按需要设置每一项的样式或内容: + +```rust +CarouselPagination::new().children((0..3).map(|index| { + CarouselPaginationItem::new(("project-page", index), index, &state) + .child((index + 1).to_string()) +})) +``` + +`CarouselPaginationItem` 与指针、键盘和上一项/下一项导航使用同一套 selection 状态转换。 + +## 控件尺寸 + +`CarouselPrevious`、`CarouselNext` 和 `CarouselPaginationItem` 实现了 `Sizable`。需要让这些控件同步缩放时,为它们设置相同的语义尺寸: + +```rust +use gpui_component::{Sizable as _, Size}; + +CarouselPrevious::new(&state).with_size(Size::Large); +CarouselNext::new(&state).with_size(Size::Large); +``` + +上一项和下一项控件默认使用 `Size::Small`,分页项默认使用 `Size::XSmall`。 + +## 无障碍 + +Carousel 会提供带 label 的区域,每个 item 会报告自己在内容集合中的位置。当默认的“轮播”无法准确描述内容时,使用 `with_accessibility_label` 设置更明确的名称。 + +Carousel 动画会遵循应用的减少动效设置。 diff --git a/website/zh-CN/docs/components/index.md b/website/zh-CN/docs/components/index.md index a591a37fb8..cf8fee94fa 100644 --- a/website/zh-CN/docs/components/index.md +++ b/website/zh-CN/docs/components/index.md @@ -42,6 +42,7 @@ collapsed: false - [Sidebar](sidebar) - 侧边栏导航 - [StatusBar](status-bar) - 底部状态栏,含左/中/右三区 - [Chart](chart) - 图表组件 +- [Carousel](carousel) - 浏览一组相关内容 - [DataTable](data-table) - 高性能数据表格 - [Dock](/zh-CN/docs/dock) - 支持标签、分割与状态持久化的生产级 Dock 布局 - [Tree](tree) - 树形结构组件 From 197acd60bdbc2adb9245c5c24903475cb4ded114 Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:44:54 +0800 Subject: [PATCH 02/11] carousel: Refine scrolling and presentation --- crates/story/src/stories/carousel_story.rs | 215 ++++- crates/ui/src/carousel/carousel.rs | 200 ++++- crates/ui/src/carousel/scroll_mask.rs | 10 +- crates/ui/src/carousel/state.rs | 881 ++++++++++++++++++++- website/docs/components/carousel.md | 58 +- website/zh-CN/docs/components/carousel.md | 58 +- 6 files changed, 1317 insertions(+), 105 deletions(-) diff --git a/crates/story/src/stories/carousel_story.rs b/crates/story/src/stories/carousel_story.rs index 7b724f7d84..8567e2fc44 100644 --- a/crates/story/src/stories/carousel_story.rs +++ b/crates/story/src/stories/carousel_story.rs @@ -1,11 +1,11 @@ use gpui::{ App, AppContext as _, Axis, Context, Entity, FocusHandle, Focusable, InteractiveElement as _, - IntoElement, ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, div, - px, + IntoElement, ParentElement as _, Render, SharedString, StyleRefinement, Styled as _, + Subscription, Window, div, prelude::FluentBuilder as _, relative, rems, }; use gpui_component::{ - Sizable as _, Size, StyledExt as _, + ActiveTheme as _, Sizable as _, Size, StyledExt as _, button::Button, carousel::{ Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext, CarouselPagination, @@ -16,9 +16,18 @@ use gpui_component::{ use crate::{ChangeStorySize, section, story_toolbar}; +#[derive(Clone, Copy)] +enum SlideTypography { + Large, + Medium, + Small, +} + pub struct CarouselStory { focus_handle: FocusHandle, horizontal: Entity, + multiple: Entity, + spacing: Entity, vertical: Entity, looped: Entity, controlled: Entity, @@ -46,6 +55,8 @@ impl CarouselStory { pub fn view(_: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| { let horizontal = cx.new(|_| CarouselState::new(3)); + let multiple = cx.new(|_| CarouselState::new(5)); + let spacing = cx.new(|_| CarouselState::new(5)); let vertical = cx.new(|_| CarouselState::new(3).with_axis(Axis::Vertical)); let looped = cx.new(|_| CarouselState::new(4).with_looping(true)); let controlled = cx.new(|_| CarouselState::new(3).with_selected_index(1)); @@ -63,6 +74,8 @@ impl CarouselStory { Self { focus_handle: cx.focus_handle(), horizontal, + multiple, + spacing, vertical, looped, controlled, @@ -74,25 +87,70 @@ impl CarouselStory { }) } - fn slide(label: impl Into) -> impl IntoElement { + fn slide( + label: impl Into, + typography: SlideTypography, + square: bool, + cx: &App, + ) -> impl IntoElement { div() - .h(px(160.)) .w_full() - .flex() - .items_center() - .justify_center() - .rounded_lg() - .bg(gpui::hsla(0.61, 0.42, 0.45, 1.0)) - .text_color(gpui::white()) - .text_lg() - .child(label.into()) + .when(!square, |this| this.h_full()) + .p_1() + .child( + div() + .w_full() + .when(square, |this| this.aspect_square()) + .when(!square, |this| this.h_full()) + .flex() + .items_center() + .justify_center() + .p_6() + .rounded(cx.theme().radius_tokens().xl) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .font_semibold() + .when(matches!(typography, SlideTypography::Large), |this| { + this.text_size(rems(2.25)) + }) + .when(matches!(typography, SlideTypography::Medium), |this| { + this.text_3xl() + }) + .when(matches!(typography, SlideTypography::Small), |this| { + this.text_2xl() + }) + .child(label.into()), + ) } - fn items(state: &Entity, prefix: &'static str, count: usize) -> CarouselContent { + fn items( + state: &Entity, + prefix: &'static str, + count: usize, + typography: SlideTypography, + square: bool, + cx: &App, + ) -> CarouselContent { + Self::items_with(state, prefix, count, typography, square, |item| item, cx) + } + + fn items_with( + state: &Entity, + prefix: &'static str, + count: usize, + typography: SlideTypography, + square: bool, + configure: impl Fn(CarouselItem) -> CarouselItem, + cx: &App, + ) -> CarouselContent { (0..count).fold(CarouselContent::new(state), |content, index| { - let label = format!("{prefix} · {}", index + 1); - content - .child(CarouselItem::new((prefix, index), index, state).child(Self::slide(label))) + let label = (index + 1).to_string(); + content.child( + configure(CarouselItem::new((prefix, index), index, state)) + .child(Self::slide(label, typography, square, cx)), + ) }) } } @@ -114,13 +172,23 @@ impl Render for CarouselStory { })) .child(story_toolbar(self.size)) .child( - section("Horizontal") - .description("Use Left and Right to move between slides.") + section("Basic") + .description("Browse one full-width item at a time with Left and Right.") .v_flex() .gap_3() .child( Carousel::new("carousel-horizontal", &self.horizontal) - .child(Self::items(&self.horizontal, "Horizontal", 3)) + .w_full() + .max_w_96() + .mx_auto() + .child(Self::items( + &self.horizontal, + "Horizontal", + 3, + SlideTypography::Large, + true, + cx, + )) .child(CarouselPrevious::new(&self.horizontal).with_size(self.size)) .child(CarouselNext::new(&self.horizontal).with_size(self.size)) .child(CarouselPagination::new().children((0..3).map(|index| { @@ -134,6 +202,61 @@ impl Render for CarouselStory { }))), ), ) + .child( + section("Sizes") + .description( + "Set a fractional flex basis on each item to show several at once.", + ) + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-multiple", &self.multiple) + .w_full() + .max_w_96() + .mx_auto() + .child(Self::items_with( + &self.multiple, + "Multiple", + 5, + SlideTypography::Medium, + true, + |item| item.flex_basis(relative(1. / 3.)), + cx, + )) + .child(CarouselPrevious::new(&self.multiple).with_size(self.size)) + .child(CarouselNext::new(&self.multiple).with_size(self.size)), + ), + ) + .child( + section("Spacing") + .description( + "Pair the content's negative margin with matching item padding.", + ) + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-spacing", &self.spacing) + .w_full() + .max_w_96() + .mx_auto() + .child( + Self::items_with( + &self.spacing, + "Spacing", + 5, + SlideTypography::Small, + true, + |item| { + item.flex_basis(relative(1. / 3.)).pl_1() + }, + cx, + ) + .track_style(StyleRefinement::default().ml_neg_1()), + ) + .child(CarouselPrevious::new(&self.spacing).with_size(self.size)) + .child(CarouselNext::new(&self.spacing).with_size(self.size)), + ), + ) .child( section("Vertical") .description("Use Up and Down to navigate a vertical carousel.") @@ -141,7 +264,21 @@ impl Render for CarouselStory { .gap_3() .child( Carousel::new("carousel-vertical", &self.vertical) - .child(Self::items(&self.vertical, "Vertical", 3).h(px(160.))) + .w_full() + .max_w_64() + .mx_auto() + .child( + Self::items_with( + &self.vertical, + "Vertical", + 3, + SlideTypography::Medium, + false, + |item| item.flex_basis(relative(0.5)), + cx, + ) + .h_48(), + ) .child(CarouselPrevious::new(&self.vertical).with_size(self.size)) .child(CarouselNext::new(&self.vertical).with_size(self.size)), ), @@ -153,7 +290,17 @@ impl Render for CarouselStory { .gap_3() .child( Carousel::new("carousel-looped", &self.looped) - .child(Self::items(&self.looped, "Looped", 4)) + .w_full() + .max_w_96() + .mx_auto() + .child(Self::items( + &self.looped, + "Looped", + 4, + SlideTypography::Large, + true, + cx, + )) .child(CarouselPrevious::new(&self.looped).with_size(self.size)) .child(CarouselNext::new(&self.looped).with_size(self.size)), ), @@ -165,7 +312,17 @@ impl Render for CarouselStory { .gap_3() .child( Carousel::new("carousel-controlled", &self.controlled) - .child(Self::items(&self.controlled, "Controlled", 3)) + .w_full() + .max_w_96() + .mx_auto() + .child(Self::items( + &self.controlled, + "Controlled", + 3, + SlideTypography::Large, + true, + cx, + )) .child(CarouselPrevious::new(&self.controlled).with_size(self.size)) .child(CarouselNext::new(&self.controlled).with_size(self.size)), ) @@ -208,7 +365,17 @@ impl Render for CarouselStory { .gap_3() .child( Carousel::new("carousel-keyboard", &self.keyboard) - .child(Self::items(&self.keyboard, "Keyboard", 3)) + .w_full() + .max_w_96() + .mx_auto() + .child(Self::items( + &self.keyboard, + "Keyboard", + 3, + SlideTypography::Large, + true, + cx, + )) .child(CarouselPrevious::new(&self.keyboard).with_size(self.size)) .child(CarouselNext::new(&self.keyboard).with_size(self.size)), ), diff --git a/crates/ui/src/carousel/carousel.rs b/crates/ui/src/carousel/carousel.rs index 41b39bdd06..e713ddde88 100644 --- a/crates/ui/src/carousel/carousel.rs +++ b/crates/ui/src/carousel/carousel.rs @@ -1,9 +1,10 @@ use std::{panic::Location, sync::Arc, time::Duration}; use gpui::{ - AnyElement, App, Axis, Bounds, ElementId, Entity, InteractiveElement as _, IntoElement, - ParentElement, Pixels, Point, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, - StyleRefinement, Styled, Subscription, Window, div, prelude::FluentBuilder as _, px, relative, + AnyElement, App, Axis, Bounds, Element, ElementId, Entity, GlobalElementId, InspectorElementId, + InteractiveElement as _, IntoElement, LayoutId, ParentElement, Pixels, Point, RenderOnce, Role, + SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window, + div, prelude::FluentBuilder as _, px, }; use gpui_base::{Spring, spring}; use rust_i18n::t; @@ -147,23 +148,28 @@ impl RenderOnce for Carousel { struct CarouselGeometry { viewport: Bounds, items: Vec>, + has_runway: bool, revision: usize, } impl CarouselGeometry { - fn read(state: &CarouselState) -> Self { + fn read(state: &CarouselState, has_runway: bool, rendered_item_count: usize) -> Self { let handle = state.scroll_handle(); + let item_offset = usize::from(has_runway); Self { viewport: handle.bounds(), - items: (0..state.item_count()) - .filter_map(|ix| handle.bounds_for_item(ix)) + items: (0..state.item_count().min(rendered_item_count)) + .filter_map(|ix| handle.bounds_for_item(ix + item_offset)) .collect(), + has_runway, revision: 0, } } fn same_layout(&self, other: &Self) -> bool { - self.viewport == other.viewport && self.items == other.items + self.viewport == other.viewport + && self.items == other.items + && self.has_runway == other.has_runway } } @@ -172,6 +178,7 @@ impl CarouselGeometry { pub struct CarouselContent { state: Entity, style: StyleRefinement, + track_style: StyleRefinement, children: Vec, } @@ -181,9 +188,19 @@ impl CarouselContent { Self { state: state.clone(), style: StyleRefinement::default(), + track_style: StyleRefinement::default(), children: Vec::new(), } } + + /// Sets style overrides for the inner flex track. + /// + /// Use this for paired Carousel spacing such as a negative leading margin. + /// The [`Styled`] implementation applies to the clipped viewport itself. + pub fn track_style(mut self, style: StyleRefinement) -> Self { + self.track_style = style; + self + } } impl ParentElement for CarouselContent { @@ -198,6 +215,75 @@ impl Styled for CarouselContent { } } +/// A layout-transparent proxy that paints one real item in the closest loop +/// cycle. The child keeps its original layout id, so ScrollHandle geometry +/// continues to address logical items without cloning their elements. +struct CarouselLoopItem { + child: AnyElement, + index: usize, + state: Entity, +} + +impl IntoElement for CarouselLoopItem { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for CarouselLoopItem { + type RequestLayoutState = (); + type PrepaintState = Point; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (self.child.request_layout(window, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let offset = self.state.read(cx).loop_item_offset(self.index); + window.with_element_offset(offset, |window| { + self.child.prepaint(window, cx); + }); + offset + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + self.child.paint(window, cx); + } +} + impl RenderOnce for CarouselContent { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let entity_id = self.state.entity_id(); @@ -208,7 +294,9 @@ impl RenderOnce for CarouselContent { let handle = snapshot.scroll_handle().clone(); let interacting = snapshot.is_interacting(); let motion_revision = snapshot.motion_revision(); - let state_snap_target = selected_ix.and_then(|ix| snapshot.snap_target_for(ix)); + let loop_runway = snapshot.loop_runway(); + let loop_layout_transitioning = snapshot.is_loop_layout_transitioning(); + let state_snap_target = selected_ix.and_then(|ix| snapshot.motion_target_for(ix)); let geometry = window.use_keyed_state( ElementId::NamedChild( @@ -221,10 +309,18 @@ impl RenderOnce for CarouselContent { let geometry_revision = geometry.read(cx).revision; let current = axis_value(handle.offset(), axis); - let target = state_snap_target - .map(|target| axis_value(target, axis)) - .or_else(|| selected_ix.and_then(|ix| snap_offset(&handle, axis, ix))) - .unwrap_or(current); + let target = if loop_layout_transitioning { + current + } else { + state_snap_target + .map(|target| axis_value(target, axis)) + .or_else(|| { + selected_ix.and_then(|ix| { + snap_offset(&handle, axis, ix + usize::from(loop_runway.is_some())) + }) + }) + .unwrap_or(current) + }; let target = if interacting { current } else { target }; let animated = spring( ( @@ -248,36 +344,81 @@ impl RenderOnce for CarouselContent { Pixels::ZERO, ); handle.set_offset(offset); + if !interacting { + let rendered = offset; + if let Some(rebased) = self + .state + .update(cx, |state, cx| state.settle_loop_motion(rendered, cx)) + { + offset = rebased; + handle.set_offset(offset); + } + } let geometry_state = self.state.clone(); let viewport_id: ElementId = ("carousel-content", entity_id).into(); + let rendered_item_count = self.children.len(); + let loop_state = self.state.clone(); + let children = self + .children + .into_iter() + .enumerate() + .map(move |(index, child)| CarouselLoopItem { + child, + index, + state: loop_state.clone(), + }); + let runway_spacer = |runway: Pixels| { + div() + .flex_none() + .when(axis.is_horizontal(), |this| this.w(runway)) + .when(axis.is_vertical(), |this| this.h(runway)) + }; + let has_runway = loop_runway.is_some(); + div() .relative() .w_full() + .refine_style(&self.style) + .overflow_hidden() .child( div() .id(viewport_id.clone()) .w_full() + .when(axis.is_vertical(), |this| this.h_full()) .flex() - .gap_4() - .when(axis.is_horizontal(), |this| this.flex_row()) - .when(axis.is_vertical(), |this| this.flex_col()) - .overflow_hidden() + .when(axis.is_horizontal(), |this| this.flex_row().ml_neg_4()) + .when(axis.is_vertical(), |this| this.flex_col().mt_neg_4()) .track_scroll(&handle) - .children(self.children) - .refine_style(&self.style), + .when_some(loop_runway, |this, runway| { + this.child(runway_spacer(runway)) + }) + .children(children) + .when_some(loop_runway, |this, runway| { + this.child(runway_spacer(runway)) + }) + .refine_style(&self.track_style), ) .child(CarouselScrollMask::new(axis, &self.state).id(viewport_id)) .on_prepaint(move |_, _, cx| { - let next = CarouselGeometry::read(geometry_state.read(cx)); + let next = CarouselGeometry::read( + geometry_state.read(cx), + has_runway, + rendered_item_count, + ); if !geometry.read(cx).same_layout(&next) { geometry_state.update(cx, |state, _| { - state.set_geometry(next.viewport, next.items.clone()); + state.set_geometry_with_runway( + next.viewport, + next.items.clone(), + next.has_runway, + ); }); geometry.update(cx, |current, cx| { current.viewport = next.viewport; current.items = next.items; + current.has_runway = next.has_runway; current.revision = current.revision.wrapping_add(1); cx.notify(); }); @@ -353,8 +494,8 @@ impl RenderOnce for CarouselItem { .min_w_0() .min_h_0() .flex_none() - .when(axis.is_horizontal(), |this| this.w_full()) - .when(axis.is_vertical(), |this| this.h_full()) + .when(axis.is_horizontal(), |this| this.w_full().pl_4()) + .when(axis.is_vertical(), |this| this.h_full().pt_4()) .children(self.children) .refine_style(&self.style) } @@ -373,7 +514,7 @@ impl CarouselPrevious { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), - size: Size::Small, + size: Size::Medium, style: StyleRefinement::default(), } } @@ -411,7 +552,7 @@ impl CarouselNext { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), - size: Size::Small, + size: Size::Medium, style: StyleRefinement::default(), } } @@ -463,7 +604,6 @@ fn carousel_control( Button::new(id) .outline() - .compact() .with_size(size) .icon(icon) .accessibility_label(label.clone()) @@ -472,16 +612,16 @@ fn carousel_control( .absolute() .rounded_full_style(cx) .when(axis.is_horizontal() && !next, |this| { - this.left(px(-40.)).top(relative(0.5)).mt(px(-15.)) + this.right_full().mr_4().top_0().bottom_0().my_auto() }) .when(axis.is_horizontal() && next, |this| { - this.right(px(-40.)).top(relative(0.5)).mt(px(-15.)) + this.left_full().ml_4().top_0().bottom_0().my_auto() }) .when(axis.is_vertical() && !next, |this| { - this.top(px(-40.)).left(relative(0.5)).ml(px(-15.)) + this.bottom_full().mb_4().left_0().right_0().mx_auto() }) .when(axis.is_vertical() && next, |this| { - this.bottom(px(-40.)).left(relative(0.5)).ml(px(-15.)) + this.top_full().mt_4().left_0().right_0().mx_auto() }) .when(!disabled, |this| { this.on_click(move |_, _, cx| { @@ -682,6 +822,8 @@ mod tests { fn carousel_controls_accept_semantic_sizes(cx: &mut gpui::TestAppContext) { let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); + assert_eq!(CarouselPrevious::new(&state).size, Size::Medium); + assert_eq!(CarouselNext::new(&state).size, Size::Medium); assert_eq!(CarouselPrevious::new(&state).large().size, Size::Large); assert_eq!(CarouselNext::new(&state).xsmall().size, Size::XSmall); assert_eq!( diff --git a/crates/ui/src/carousel/scroll_mask.rs b/crates/ui/src/carousel/scroll_mask.rs index a783d21d2c..4afebcf715 100644 --- a/crates/ui/src/carousel/scroll_mask.rs +++ b/crates/ui/src/carousel/scroll_mask.rs @@ -87,16 +87,12 @@ impl Element for CarouselScrollMask { &mut self, _: Option<&GlobalElementId>, _: Option<&gpui::InspectorElementId>, - _: Bounds, + bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, - cx: &mut App, + _: &mut App, ) -> Self::PrepaintState { - // The layout node fills the wrapper, while the tracked scroll bounds - // are the actual styled viewport. Using those bounds keeps the input - // surface aligned when CarouselContent has a custom width or margin. - let viewport = self.state.read(cx).scroll_handle().bounds(); - window.insert_hitbox(viewport, gpui::HitboxBehavior::Normal) + window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) } fn paint( diff --git a/crates/ui/src/carousel/state.rs b/crates/ui/src/carousel/state.rs index 2cbc177901..c72fafdd1a 100644 --- a/crates/ui/src/carousel/state.rs +++ b/crates/ui/src/carousel/state.rs @@ -44,6 +44,14 @@ struct ScrollGesture { total_delta: Pixels, } +#[derive(Clone, Copy, Debug, PartialEq)] +struct LoopLayout { + cycle_extent: Pixels, + track_gap: Pixels, + runway_extent: Pixels, + runway_ready: bool, +} + /// Shared behavior state for every part of a [`super::Carousel`]. /// /// `CarouselState` intentionally owns behavior only. The content and its @@ -65,6 +73,10 @@ pub struct CarouselState { scroll_settle_epoch: usize, suppress_pointer_click: bool, motion_revision: usize, + loop_layout: Option, + geometry_has_runway: bool, + loop_layout_removal_pending: bool, + loop_motion_target: Option>, } impl CarouselState { @@ -84,6 +96,10 @@ impl CarouselState { scroll_settle_epoch: 0, suppress_pointer_click: false, motion_revision: 0, + loop_layout: None, + geometry_has_runway: false, + loop_layout_removal_pending: false, + loop_motion_target: None, } } @@ -128,20 +144,16 @@ impl CarouselState { /// Returns whether a previous item can be selected. pub fn has_previous(&self) -> bool { - match self.selected_index { - Some(_) if self.looping => self.item_count > 1, - Some(index) => index > 0, - None => false, - } + self.selected_index + .and_then(|index| self.navigation_index(index, false)) + .is_some() } /// Returns whether a next item can be selected. pub fn has_next(&self) -> bool { - match self.selected_index { - Some(_) if self.looping => self.item_count > 1, - Some(index) => index + 1 < self.item_count, - None => false, - } + self.selected_index + .and_then(|index| self.navigation_index(index, true)) + .is_some() } /// Silently changes the selected item for controlled/programmatic use. @@ -149,13 +161,25 @@ impl CarouselState { /// The value is clamped to the available item range. This method does /// not emit [`CarouselEvent::Change`]. pub fn set_selected_index(&mut self, index: usize, cx: &mut Context) { + let rebased_loop_motion = self.rebase_pending_loop_motion(); let next_index = self.clamp_index(index); - let changed = self.selected_index != next_index || self.is_interacting(); - if self - .selected_index + let changed = + self.selected_index != next_index || self.is_interacting() || rebased_loop_motion; + let current = self.selected_index; + let wrapped = current .zip(next_index) - .is_some_and(|(current, next)| self.is_loop_wrap(current, next)) - { + .is_some_and(|(current, next)| self.is_loop_wrap(current, next)); + let loop_target = current + .filter(|_| wrapped) + .zip(next_index) + .and_then(|(current, next)| self.adjacent_loop_target(current, next)); + let seamless_wrap = loop_target.is_some(); + self.loop_motion_target = loop_target.filter(|target| { + next_index + .and_then(|index| self.snap_target_for(index)) + .is_some_and(|real| self.primary_offset(real) != self.primary_offset(*target)) + }); + if wrapped && !seamless_wrap { self.motion_revision = self.motion_revision.wrapping_add(1); } self.selected_index = next_index; @@ -177,7 +201,14 @@ impl CarouselState { if self.selected_index.is_none() && item_count > 0 { self.selected_index = Some(0); } + if self.loop_layout.is_some() { + self.scroll_handle.set_offset(Point::default()); + self.motion_revision = self.motion_revision.wrapping_add(1); + } self.geometry.items.clear(); + self.loop_layout = None; + self.loop_layout_removal_pending = self.geometry_has_runway; + self.loop_motion_target = None; self.cancel_interactions(cx); cx.notify(); } @@ -187,6 +218,9 @@ impl CarouselState { if self.axis != axis { self.axis = axis; self.scroll_handle.set_offset(Point::default()); + self.loop_layout = None; + self.loop_layout_removal_pending = self.geometry_has_runway; + self.loop_motion_target = None; self.cancel_interactions(cx); self.motion_revision = self.motion_revision.wrapping_add(1); cx.notify(); @@ -197,6 +231,8 @@ impl CarouselState { pub fn set_looping(&mut self, looping: bool, cx: &mut Context) { if self.looping != looping { self.looping = looping; + self.update_loop_layout(); + self.loop_motion_target = None; self.cancel_interactions(cx); cx.notify(); } @@ -222,14 +258,12 @@ impl CarouselState { return false; }; - if current > 0 { - self.select_index_with_wrap(current - 1, false, cx) - } else if self.looping && self.item_count > 1 { - self.select_index_with_wrap(self.item_count - 1, true, cx) - } else { + let Some(index) = self.navigation_index(current, false) else { self.cancel_user_interaction(cx); - false - } + return false; + }; + let wrapped = self.is_loop_wrap(current, index); + self.select_index_with_wrap(index, wrapped, cx) } /// Selects the next item, wrapping when looping is enabled. @@ -238,14 +272,12 @@ impl CarouselState { return false; }; - if current + 1 < self.item_count { - self.select_index_with_wrap(current + 1, false, cx) - } else if self.looping && self.item_count > 1 { - self.select_index_with_wrap(0, true, cx) - } else { + let Some(index) = self.navigation_index(current, true) else { self.cancel_user_interaction(cx); - false - } + return false; + }; + let wrapped = self.is_loop_wrap(current, index); + self.select_index_with_wrap(index, wrapped, cx) } /// Selects the first item through the user-facing path. @@ -289,19 +321,97 @@ impl CarouselState { /// Returns a monotonic key for motion that must be rebased immediately. /// - /// Ordinary adjacent selection leaves this value unchanged. A logical - /// loop wrap increments it so the content can use a fresh spring key and - /// avoid animating across the entire strip. + /// Ordinary adjacent selection and a loop wrap leave this value unchanged. + /// It advances only when the scroll coordinate is silently rebased to an + /// equivalent cycle, letting the content start a fresh spring at the same + /// visual position. pub(super) fn motion_revision(&self) -> usize { self.motion_revision } /// Records the viewport and item bounds used for gesture snapping. - pub(super) fn set_geometry(&mut self, viewport: Bounds, items: Vec>) { + #[cfg(test)] + fn set_geometry(&mut self, viewport: Bounds, items: Vec>) { + let has_runway = self.loop_layout.is_some(); + self.set_geometry_with_runway(viewport, items, has_runway); + } + + pub(super) fn set_geometry_with_runway( + &mut self, + viewport: Bounds, + items: Vec>, + has_runway: bool, + ) { self.geometry = CarouselGeometry { viewport: Some(viewport), items, }; + self.geometry_has_runway = has_runway; + self.update_loop_layout(); + } + + /// Returns the runway reserved on both sides of the real item cycle. + pub(super) fn loop_runway(&self) -> Option { + self.loop_layout.map(|layout| layout.cycle_extent) + } + + /// Returns whether content layout is moving into or out of its runway. + pub(super) fn is_loop_layout_transitioning(&self) -> bool { + self.loop_layout_removal_pending + || self.loop_layout.is_some_and(|layout| !layout.runway_ready) + } + + /// Returns the visual offset applied to one item in the circular track. + pub(super) fn loop_item_offset(&self, index: usize) -> Point { + let Some(layout) = self.loop_layout else { + return Point::default(); + }; + + if !layout.runway_ready { + return self.axis_point(-layout.runway_extent); + } + + let Some(viewport) = self.geometry.viewport else { + return Point::default(); + }; + let Some(item) = self.geometry.items.get(index) else { + return Point::default(); + }; + let viewport_center = self.primary_start(viewport) + viewport.size.along(self.axis) / 2. + - self.primary_offset(self.scroll_handle.offset()); + let item_center = self.primary_start(*item) + item.size.along(self.axis) / 2.; + let cycles = ((viewport_center - item_center) / layout.cycle_extent) + .round() + .clamp(-1., 1.); + self.axis_point(layout.cycle_extent * cycles) + } + + /// Returns the target used by content motion. During a boundary wrap this + /// is the equivalent snap in the adjacent runway cycle. + pub(super) fn motion_target_for(&self, index: usize) -> Option> { + self.loop_motion_target + .filter(|_| self.selected_index == Some(index)) + .or_else(|| self.snap_target_for(index)) + } + + /// Silently returns a settled virtual target to the middle cycle. + pub(super) fn settle_loop_motion( + &mut self, + rendered: Point, + cx: &mut Context, + ) -> Option> { + let target = self.loop_motion_target?; + if (self.primary_offset(rendered) - self.primary_offset(target)).abs() > px(0.01) { + return None; + } + + let selected = self.selected_index?; + self.loop_motion_target = None; + let real_target = self.snap_target_for(selected)?; + self.scroll_handle.set_offset(real_target); + self.motion_revision = self.motion_revision.wrapping_add(1); + cx.notify(); + Some(real_target) } /// Returns the geometry-derived snap offset for `index`. @@ -319,8 +429,8 @@ impl CarouselState { .iter() .enumerate() .min_by(|(_, left), (_, right)| { - let left_distance = self.primary_distance(offset, viewport, **left); - let right_distance = self.primary_distance(offset, viewport, **right); + let left_distance = self.looping_distance(offset, viewport, **left); + let right_distance = self.looping_distance(offset, viewport, **right); left_distance .partial_cmp(&right_distance) .unwrap_or(std::cmp::Ordering::Equal) @@ -369,8 +479,10 @@ impl CarouselState { let mut offset = gesture.start_offset; let next = self.clamped_offset(self.primary_offset(offset) + primary_delta); self.set_primary_offset(&mut offset, next); - let changed = offset != self.scroll_handle.offset(); + let previous = self.scroll_handle.offset(); self.scroll_handle.set_offset(offset); + self.normalize_loop_coordinate(); + let changed = self.scroll_handle.offset() != previous; if changed { cx.notify(); } @@ -409,6 +521,8 @@ impl CarouselState { return false; } + self.rebase_pending_loop_motion(); + if self.ignore_scroll_until_quiet { match phase { TouchPhase::Started => { @@ -451,7 +565,8 @@ impl CarouselState { let next = self.clamped_offset(self.primary_offset(offset) + delta); self.set_primary_offset(&mut offset, next); self.scroll_handle.set_offset(offset); - let moved = offset != previous; + self.normalize_loop_coordinate(); + let moved = self.scroll_handle.offset() != previous; if moved { cx.notify(); } @@ -495,17 +610,27 @@ impl CarouselState { let Some(index) = self.clamp_index(index) else { return false; }; + let rebased_loop_motion = self.rebase_pending_loop_motion(); let was_interacting = self.is_interacting(); self.cancel_interactions(cx); if self.selected_index == Some(index) { - if was_interacting { + if was_interacting || rebased_loop_motion { cx.notify(); } return false; } + let current = self.selected_index; + let loop_target = current + .filter(|_| wrapped) + .and_then(|current| self.adjacent_loop_target(current, index)); + let seamless_wrap = loop_target.is_some(); + self.loop_motion_target = loop_target.filter(|target| { + self.snap_target_for(index) + .is_some_and(|real| self.primary_offset(real) != self.primary_offset(*target)) + }); self.selected_index = Some(index); - if wrapped { + if wrapped && !seamless_wrap { self.motion_revision = self.motion_revision.wrapping_add(1); } cx.emit(CarouselEvent::Change(index)); @@ -524,6 +649,230 @@ impl CarouselState { || (current + 1 == self.item_count && next == 0)) } + /// Returns the next logical item that has a distinct physical snap point. + /// + /// When layout has not populated the geometry yet, navigation falls back + /// to logical item indices. Once geometry is available, adjacent items + /// that clamp to the same physical endpoint are treated as one snap + /// point. Keeping the first item in a duplicate group as the canonical + /// index preserves the existing nearest-index tie break at the end of the + /// track, while allowing navigation from a controlled duplicate index to + /// skip back over that group. + fn navigation_index(&self, current: usize, next: bool) -> Option { + if self.looping || !self.geometry_is_ready() { + return self.logical_navigation_index(current, next); + } + + let current_target = self.snap_target_for(current)?; + let current_target = self.primary_offset(current_target); + if next { + (current.saturating_add(1)..self.item_count).find(|index| { + self.snap_target_for(*index) + .map(|target| self.primary_offset(target) != current_target) + .unwrap_or(false) + }) + } else { + (0..current).rev().find(|index| { + self.snap_target_for(*index) + .map(|target| self.primary_offset(target) != current_target) + .unwrap_or(false) + }) + } + } + + fn logical_navigation_index(&self, current: usize, next: bool) -> Option { + if next { + if current + 1 < self.item_count { + Some(current + 1) + } else if self.looping && self.item_count > 1 { + Some(0) + } else { + None + } + } else if current > 0 { + Some(current - 1) + } else if self.looping && self.item_count > 1 { + Some(self.item_count - 1) + } else { + None + } + } + + fn geometry_is_ready(&self) -> bool { + self.geometry.viewport.is_some() && self.geometry.items.len() == self.item_count + } + + fn adjacent_loop_target(&self, current: usize, next: usize) -> Option> { + let layout = self.loop_layout.filter(|layout| layout.runway_ready)?; + let mut target = self.snap_target_for(next)?; + let cycle = if current + 1 == self.item_count && next == 0 { + -layout.cycle_extent + } else if current == 0 && next + 1 == self.item_count { + layout.cycle_extent + } else { + return None; + }; + let primary = self.primary_offset(target) + cycle; + self.set_primary_offset(&mut target, primary); + Some(target) + } + + fn update_loop_layout(&mut self) { + let previous = self.loop_layout; + let next_metrics = self.measured_cycle_metrics().filter(|(extent, _)| { + self.looping + && self.item_count > 1 + && *extent > px(0.) + && self + .geometry + .viewport + .is_some_and(|viewport| *extent >= viewport.size.along(self.axis)) + }); + + let Some((next_extent, next_gap)) = next_metrics else { + if let Some(previous) = previous.filter(|layout| layout.runway_ready) { + self.shift_scroll_coordinate(previous.runway_extent); + self.motion_revision = self.motion_revision.wrapping_add(1); + } + if previous.is_some() { + self.loop_layout_removal_pending = self.geometry_has_runway; + } else if self.loop_layout_removal_pending && !self.geometry_has_runway { + self.loop_layout_removal_pending = false; + } + self.loop_layout = None; + self.loop_motion_target = None; + return; + }; + + let same_extent = previous.is_some_and(|layout| { + (layout.cycle_extent - next_extent).abs() <= px(0.5) + && (layout.track_gap - next_gap).abs() <= px(0.5) + }); + if !same_extent { + if let Some(previous) = previous.filter(|layout| layout.runway_ready) { + self.shift_scroll_coordinate(previous.runway_extent); + self.pointer_gesture = None; + self.scroll_gesture = None; + self.loop_motion_target = None; + self.motion_revision = self.motion_revision.wrapping_add(1); + } + self.loop_layout_removal_pending = false; + self.loop_layout = Some(LoopLayout { + cycle_extent: next_extent, + track_gap: next_gap, + runway_extent: next_extent + next_gap, + runway_ready: false, + }); + return; + } + + let Some(mut layout) = previous else { + return; + }; + if !layout.runway_ready && self.geometry_has_runway { + self.shift_scroll_coordinate(-layout.runway_extent); + layout.runway_ready = true; + self.loop_layout = Some(layout); + self.motion_revision = self.motion_revision.wrapping_add(1); + } + } + + fn measured_cycle_metrics(&self) -> Option<(Pixels, Pixels)> { + let first = *self.geometry.items.first()?; + let last = *self.geometry.items.last()?; + let gap = self + .geometry + .items + .get(1) + .map(|second| (self.primary_start(*second) - self.primary_end(first)).max(px(0.))) + .unwrap_or(px(0.)); + Some(( + (self.primary_end(last) - self.primary_start(first) + gap).max(px(0.)), + gap, + )) + } + + fn shift_scroll_coordinate(&mut self, delta: Pixels) { + let mut offset = self.scroll_handle.offset(); + let primary = self.primary_offset(offset) + delta; + self.set_primary_offset(&mut offset, primary); + self.scroll_handle.set_offset(offset); + if let Some(gesture) = self.pointer_gesture.as_mut() { + Self::shift_point_for_axis(&mut gesture.start_offset, self.axis, delta); + } + if let Some(gesture) = self.scroll_gesture.as_mut() { + Self::shift_point_for_axis(&mut gesture.start_offset, self.axis, delta); + } + if let Some(target) = self.loop_motion_target.as_mut() { + Self::shift_point_for_axis(target, self.axis, delta); + } + } + + fn rebase_pending_loop_motion(&mut self) -> bool { + let Some(virtual_target) = self.loop_motion_target else { + return false; + }; + let Some(real_target) = self + .selected_index + .and_then(|index| self.snap_target_for(index)) + else { + self.loop_motion_target = None; + return false; + }; + let delta = self.primary_offset(real_target) - self.primary_offset(virtual_target); + self.shift_scroll_coordinate(delta); + self.loop_motion_target = None; + self.motion_revision = self.motion_revision.wrapping_add(1); + true + } + + /// Keeps an active pointer or trackpad gesture inside the middle runway. + /// Moving by one full cycle is visually identical because every item is + /// painted in the closest cycle, so the gesture snapshots move with the + /// handle and continuous input never reaches the finite runway edge. + fn normalize_loop_coordinate(&mut self) -> bool { + let Some(layout) = self + .loop_layout + .filter(|layout| layout.runway_ready && layout.cycle_extent > px(0.)) + else { + return false; + }; + let Some(first) = self.snap_target_for(0) else { + return false; + }; + let Some(last_ix) = self.item_count.checked_sub(1) else { + return false; + }; + let Some(last) = self.snap_target_for(last_ix) else { + return false; + }; + + let first = self.primary_offset(first); + let last = self.primary_offset(last); + let mut current = self.primary_offset(self.scroll_handle.offset()); + let mut delta = px(0.); + while current <= first - layout.cycle_extent { + current += layout.cycle_extent; + delta += layout.cycle_extent; + } + while current >= last + layout.cycle_extent { + current -= layout.cycle_extent; + delta -= layout.cycle_extent; + } + if delta == px(0.) { + return false; + } + self.shift_scroll_coordinate(delta); + true + } + + fn shift_point_for_axis(point: &mut Point, axis: Axis, delta: Pixels) { + match axis { + Axis::Horizontal => point.x += delta, + Axis::Vertical => point.y += delta, + } + } + fn cancel_interactions(&mut self, cx: &mut Context) { let cancelled_scroll = self.scroll_gesture.is_some(); self.pointer_gesture = None; @@ -548,6 +897,7 @@ impl CarouselState { if self.item_count < 2 { return false; } + self.rebase_pending_loop_motion(); self.pointer_gesture = Some(PointerGesture { start_position: position, start_offset: self.scroll_handle.offset(), @@ -676,8 +1026,20 @@ impl CarouselState { let target = match self.axis { Axis::Horizontal => viewport.left() - item.left(), Axis::Vertical => viewport.top() - item.top(), - } - .clamp(-self.max_snap_offset(), px(0.)); + }; + let target = if let Some(layout) = self.loop_layout.filter(|layout| layout.runway_ready) { + let content_inset = self + .geometry + .items + .first() + .map(|first| { + self.primary_start(*first) - self.primary_start(viewport) - layout.runway_extent + }) + .unwrap_or(px(0.)); + target + content_inset + } else { + target.clamp(-self.max_snap_offset(), px(0.)) + }; self.set_primary_offset(&mut offset, target); offset } @@ -720,6 +1082,23 @@ impl CarouselState { (self.primary_offset(offset) - self.primary_offset(target)).abs() } + fn looping_distance( + &self, + offset: Point, + viewport: Bounds, + item: Bounds, + ) -> Pixels { + let distance = self.primary_distance(offset, viewport, item); + let Some(layout) = self.loop_layout.filter(|layout| layout.runway_ready) else { + return distance; + }; + let target = self.primary_offset(self.snap_offset(viewport, item)); + let offset = self.primary_offset(offset); + distance + .min((offset - (target - layout.cycle_extent)).abs()) + .min((offset - (target + layout.cycle_extent)).abs()) + } + fn primary_offset(&self, offset: Point) -> Pixels { match self.axis { Axis::Horizontal => offset.x, @@ -727,6 +1106,27 @@ impl CarouselState { } } + fn primary_start(&self, bounds: Bounds) -> Pixels { + match self.axis { + Axis::Horizontal => bounds.left(), + Axis::Vertical => bounds.top(), + } + } + + fn primary_end(&self, bounds: Bounds) -> Pixels { + match self.axis { + Axis::Horizontal => bounds.right(), + Axis::Vertical => bounds.bottom(), + } + } + + fn axis_point(&self, value: Pixels) -> Point { + match self.axis { + Axis::Horizontal => Point::new(value, px(0.)), + Axis::Vertical => Point::new(px(0.), value), + } + } + fn set_primary_offset(&self, offset: &mut Point, value: Pixels) { match self.axis { Axis::Horizontal => offset.x = value, @@ -818,6 +1218,54 @@ mod tests { assert_eq!(narrow.snap_target_for(2), Some(point(px(-50.), px(0.)))); } + #[test] + fn nearest_index_keeps_the_first_trailing_duplicate_as_canonical() { + let mut state = CarouselState::new(3); + state.set_geometry( + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(50.), px(40.))), + Bounds::new(point(px(50.), px(0.)), gpui::size(px(50.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(50.), px(40.))), + ], + ); + + assert_eq!(state.snap_target_for(1), Some(point(px(-50.), px(0.)))); + assert_eq!(state.snap_target_for(2), Some(point(px(-50.), px(0.)))); + assert_eq!(state.nearest_index(point(px(-50.), px(0.))), Some(1)); + } + + #[gpui::test] + fn geometry_navigation_skips_duplicate_snap_points(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); + + cx.update(|cx| { + state.update(cx, |state, cx| { + state.set_geometry( + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(50.), px(40.))), + Bounds::new(point(px(50.), px(0.)), gpui::size(px(50.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(50.), px(40.))), + ], + ); + state.set_selected_index(1, cx); + assert!(!state.has_next()); + assert!(state.has_previous()); + + assert!(state.select_previous(cx)); + assert_eq!(state.selected_index(), Some(0)); + assert!(state.select_next(cx)); + assert_eq!(state.selected_index(), Some(1)); + + state.set_selected_index(2, cx); + assert!(!state.has_next()); + assert!(state.select_previous(cx)); + assert_eq!(state.selected_index(), Some(0)); + }); + }); + } + #[gpui::test] fn non_looping_navigation_stops_at_both_boundaries(cx: &mut TestAppContext) { let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); @@ -1087,4 +1535,355 @@ mod tests { }); assert_eq!(events.borrow().as_slice(), &[1, 0]); } + + #[gpui::test] + fn looping_uses_adjacent_cycle_targets_and_rebases_without_an_extra_event( + cx: &mut TestAppContext, + ) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true))); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&state, move |_, event: &CarouselEvent, _| { + let CarouselEvent::Change(index) = event; + events.borrow_mut().push(*index); + }) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + assert_eq!(state.loop_runway(), Some(px(200.))); + + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + assert_eq!(state.scroll_handle.offset(), point(px(-200.), px(0.))); + + assert!(state.select_previous(cx)); + assert_eq!(state.selected_index(), Some(1)); + let previous_target = state.motion_target_for(1).unwrap(); + assert_eq!(previous_target, point(px(-100.), px(0.))); + assert_eq!(previous_target.x - state.scroll_handle.offset().x, px(100.)); + assert_eq!( + state.settle_loop_motion(previous_target, cx), + Some(point(px(-300.), px(0.))) + ); + + assert!(state.select_next(cx)); + assert_eq!(state.selected_index(), Some(0)); + let next_target = state.motion_target_for(0).unwrap(); + assert_eq!(next_target, point(px(-400.), px(0.))); + assert_eq!(next_target.x - state.scroll_handle.offset().x, px(-100.)); + assert_eq!( + state.settle_loop_motion(next_target, cx), + Some(point(px(-200.), px(0.))) + ); + }); + }); + + assert_eq!(events.borrow().as_slice(), &[1, 0]); + } + + #[gpui::test] + fn programmatic_loop_wrap_uses_the_adjacent_cycle_without_emitting(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true))); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&state, move |_, event: &CarouselEvent, _| { + let CarouselEvent::Change(index) = event; + events.borrow_mut().push(*index); + }) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + + state.set_selected_index(1, cx); + state + .scroll_handle + .set_offset(state.snap_target_for(1).unwrap()); + state.set_selected_index(0, cx); + + assert_eq!(state.selected_index(), Some(0)); + assert_eq!(state.motion_target_for(0), Some(point(px(-400.), px(0.)))); + assert_eq!( + state.settle_loop_motion(point(px(-400.), px(0.)), cx), + Some(point(px(-200.), px(0.))) + ); + }); + }); + + assert!(events.borrow().is_empty()); + } + + #[gpui::test] + fn vertical_loop_wrap_uses_the_adjacent_cycle(cx: &mut TestAppContext) { + let state = cx.update(|cx| { + cx.new(|_| { + CarouselState::new(2) + .with_axis(Axis::Vertical) + .with_looping(true) + }) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| { + let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(40.), px(100.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(40.), px(100.))), + Bounds::new(point(px(0.), px(100.)), gpui::size(px(40.), px(100.))), + ], + ); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(200.)), gpui::size(px(40.), px(100.))), + Bounds::new(point(px(0.), px(300.)), gpui::size(px(40.), px(100.))), + ], + ); + + assert!(state.select_previous(cx)); + assert_eq!(state.motion_target_for(1), Some(point(px(0.), px(-100.)))); + assert_eq!( + state.settle_loop_motion(point(px(0.), px(-100.)), cx), + Some(point(px(0.), px(-300.))) + ); + }); + }); + } + + #[gpui::test] + fn unequal_items_keep_the_requested_direction_across_a_loop_boundary(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true))); + + cx.update(|cx| { + state.update(cx, |state, cx| { + let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(80.), px(40.))), + Bounds::new(point(px(96.), px(0.)), gpui::size(px(200.), px(40.))), + ], + ); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(328.), px(0.)), gpui::size(px(80.), px(40.))), + Bounds::new(point(px(424.), px(0.)), gpui::size(px(200.), px(40.))), + ], + ); + + state.set_selected_index(1, cx); + state.scroll_handle.set_offset(point(px(-424.), px(0.))); + assert!(state.select_next(cx)); + assert_eq!(state.motion_target_for(0), Some(point(px(-640.), px(0.)))); + assert_eq!( + state.settle_loop_motion(point(px(-640.), px(0.)), cx), + Some(point(px(-328.), px(0.))) + ); + + assert!(state.select_previous(cx)); + assert_eq!(state.motion_target_for(1), Some(point(px(-112.), px(0.)))); + }); + }); + } + + #[gpui::test] + fn active_loop_gesture_rebases_before_reaching_a_runway_edge(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true))); + + cx.update(|cx| { + state.update(cx, |state, cx| { + let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + + assert!(state.begin_drag(point(px(0.), px(0.)), cx)); + state.scroll_handle.set_offset(point(px(-420.), px(0.))); + assert!(state.normalize_loop_coordinate()); + assert_eq!(state.scroll_handle.offset(), point(px(-220.), px(0.))); + assert_eq!( + state.pointer_gesture.map(|gesture| gesture.start_offset), + Some(point(px(0.), px(0.))) + ); + + assert_eq!(state.nearest_index(point(px(-110.), px(0.))), Some(1)); + assert_eq!(state.nearest_index(point(px(-390.), px(0.))), Some(0)); + }); + }); + } + + #[test] + fn looping_falls_back_when_one_cycle_cannot_cover_the_viewport() { + let mut state = CarouselState::new(2).with_looping(true); + state.set_geometry( + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(40.), px(40.))), + Bounds::new(point(px(40.), px(0.)), gpui::size(px(40.), px(40.))), + ], + ); + + assert_eq!(state.loop_runway(), None); + assert_eq!(state.loop_item_offset(0), Point::default()); + } + + #[test] + fn loop_runway_accounts_for_the_track_gap_and_content_inset() { + let mut state = CarouselState::new(2).with_looping(true); + let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(16.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(132.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + + assert_eq!(state.loop_runway(), Some(px(232.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(264.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(380.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + assert_eq!(state.scroll_handle.offset(), point(px(-248.), px(0.))); + assert_eq!(state.snap_target_for(0), Some(point(px(-248.), px(0.)))); + assert_eq!(state.snap_target_for(1), Some(point(px(-364.), px(0.)))); + } + + #[gpui::test] + fn loop_runway_resize_and_removal_preserve_the_visible_coordinate(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true))); + + cx.update(|cx| { + state.update(cx, |state, cx| { + let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(200.), px(0.)), gpui::size(px(150.), px(40.))), + Bounds::new(point(px(350.), px(0.)), gpui::size(px(150.), px(40.))), + ], + ); + assert_eq!(state.loop_runway(), Some(px(300.))); + assert!(state.is_loop_layout_transitioning()); + assert_eq!(state.scroll_handle.offset(), point(px(0.), px(0.))); + + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(300.), px(0.)), gpui::size(px(150.), px(40.))), + Bounds::new(point(px(450.), px(0.)), gpui::size(px(150.), px(40.))), + ], + ); + assert!(!state.is_loop_layout_transitioning()); + assert_eq!(state.scroll_handle.offset(), point(px(-300.), px(0.))); + + state.set_looping(false, cx); + assert_eq!(state.loop_runway(), None); + assert!(state.is_loop_layout_transitioning()); + assert_eq!(state.scroll_handle.offset(), point(px(0.), px(0.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(150.), px(40.))), + Bounds::new(point(px(150.), px(0.)), gpui::size(px(150.), px(40.))), + ], + ); + assert!(!state.is_loop_layout_transitioning()); + }); + }); + } + + #[gpui::test] + fn laying_out_the_runway_normalizes_an_active_drag_snapshot(cx: &mut TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_looping(true))); + + cx.update(|cx| { + state.update(cx, |state, cx| { + let viewport = Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(0.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(100.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + assert!(state.begin_drag(point(px(0.), px(0.)), cx)); + state.set_geometry( + viewport, + vec![ + Bounds::new(point(px(200.), px(0.)), gpui::size(px(100.), px(40.))), + Bounds::new(point(px(300.), px(0.)), gpui::size(px(100.), px(40.))), + ], + ); + + assert_eq!(state.scroll_handle.offset(), point(px(-200.), px(0.))); + assert_eq!( + state.pointer_gesture.map(|gesture| gesture.start_offset), + Some(point(px(-200.), px(0.))) + ); + }); + }); + } } diff --git a/website/docs/components/carousel.md b/website/docs/components/carousel.md index 439a255434..308e838795 100644 --- a/website/docs/components/carousel.md +++ b/website/docs/components/carousel.md @@ -5,7 +5,7 @@ description: A composable carousel for browsing related content. # Carousel -Carousel displays a set of related items one at a time. It supports horizontal and vertical layouts, keyboard navigation, pointer and trackpad gestures, looping, and controlled selection. +Carousel displays one or more related items in a snapping viewport. It supports horizontal and vertical layouts, keyboard navigation, pointer and trackpad gestures, looping, and controlled selection. ## Import @@ -39,6 +39,60 @@ Carousel::new("projects-carousel", &state) Keep the state's item count equal to the number of direct `CarouselItem` children. A state and its scroll handle belong to one viewport. +## Composition + +Build a Carousel from one content viewport, its items, and optional controls: + +```text +Carousel +├── CarouselContent +│ ├── CarouselItem +│ └── CarouselItem +├── CarouselPrevious +└── CarouselNext +``` + +Constrain the Carousel with `.w_full().max_w_96()` on its root, or style `CarouselContent` when the viewport itself needs a custom width or height. Use `track_style` only for inner-track adjustments such as spacing. + +## Sizes + +`CarouselItem` implements `Styled`. Set its flex basis to show more than one item in the viewport: + +```rust +use gpui::{ParentElement as _, Styled as _, relative}; + +let state = cx.new(|_| CarouselState::new(6)); + +CarouselContent::new(&state).children((0..6).map(|index| { + CarouselItem::new(("project", index), index, &state) + .flex_basis(relative(1. / 3.)) + .child(format!("Project {}", index + 1)) +})) +``` + +The flex basis controls item geometry; it is separate from the semantic `Size` used by buttons and other controls. + +## Spacing + +Carousel follows the same paired spacing model as shadcn/ui: apply a negative leading margin through `CarouselContent::track_style` and matching leading padding to every `CarouselItem`. + +```rust +use gpui::{ParentElement as _, StyleRefinement, Styled as _, relative}; + +let state = cx.new(|_| CarouselState::new(6)); + +CarouselContent::new(&state) + .track_style(StyleRefinement::default().ml_neg_1()) + .children((0..6).map(|index| { + CarouselItem::new(("project", index), index, &state) + .flex_basis(relative(1. / 3.)) + .pl_1() + .child(format!("Project {}", index + 1)) + })) +``` + +Horizontal carousels default to `.ml_neg_4()` on the content track and `.pl_4()` on items. Vertical carousels use the corresponding `.mt_neg_4()` and `.pt_4()` pair. Override both sides with the same spacing scale so the first item stays aligned with the viewport while the visual gap changes. + ## Orientation Use `with_axis` when creating the state: @@ -116,7 +170,7 @@ CarouselPrevious::new(&state).with_size(Size::Large); CarouselNext::new(&state).with_size(Size::Large); ``` -Previous and next controls default to `Size::Small`. Pagination items default to `Size::XSmall`. +Previous and next controls default to `Size::Medium`. Pagination items default to `Size::XSmall`. ## Accessibility diff --git a/website/zh-CN/docs/components/carousel.md b/website/zh-CN/docs/components/carousel.md index 35aa9025b5..b3e2857737 100644 --- a/website/zh-CN/docs/components/carousel.md +++ b/website/zh-CN/docs/components/carousel.md @@ -5,7 +5,7 @@ description: 用于浏览相关内容的可组合 Carousel 组件。 # Carousel -Carousel 用于逐项浏览一组相关内容,支持横向和纵向布局、键盘导航、指针与触控板手势、循环以及受控选中项。 +Carousel 在可吸附的 viewport 中展示一个或多个相关 item,支持横向和纵向布局、键盘导航、指针与触控板手势、循环以及受控选中项。 ## 引入 @@ -39,6 +39,60 @@ Carousel::new("projects-carousel", &state) state 的 item 数量应与直接 `CarouselItem` 子元素的数量一致。一个 state 及其 scroll handle 只服务一个 viewport。 +## 组合结构 + +Carousel 由一个内容 viewport、其中的 item 和可选控制按钮组成: + +```text +Carousel +├── CarouselContent +│ ├── CarouselItem +│ └── CarouselItem +├── CarouselPrevious +└── CarouselNext +``` + +可以在 `Carousel` 根节点上使用 `.w_full().max_w_96()` 约束整个 Carousel;需要单独设置 viewport 的宽度或高度时,可以直接设置 `CarouselContent` 的样式。`track_style` 仅用于间距等内部 track 调整。 + +## 尺寸 + +`CarouselItem` 实现了 `Styled`。设置 flex basis 可以在 viewport 中同时显示多个 item: + +```rust +use gpui::{ParentElement as _, Styled as _, relative}; + +let state = cx.new(|_| CarouselState::new(6)); + +CarouselContent::new(&state).children((0..6).map(|index| { + CarouselItem::new(("project", index), index, &state) + .flex_basis(relative(1. / 3.)) + .child(format!("项目 {}", index + 1)) +})) +``` + +flex basis 控制的是 item 几何尺寸,与按钮等控件使用的语义 `Size` 相互独立。 + +## 间距 + +Carousel 采用与 shadcn/ui 相同的成对间距模型:通过 `CarouselContent::track_style` 设置负的起始 margin,并为每个 `CarouselItem` 设置数值相同的起始 padding。 + +```rust +use gpui::{ParentElement as _, StyleRefinement, Styled as _, relative}; + +let state = cx.new(|_| CarouselState::new(6)); + +CarouselContent::new(&state) + .track_style(StyleRefinement::default().ml_neg_1()) + .children((0..6).map(|index| { + CarouselItem::new(("project", index), index, &state) + .flex_basis(relative(1. / 3.)) + .pl_1() + .child(format!("项目 {}", index + 1)) + })) +``` + +横向 Carousel 默认在 content track 上使用 `.ml_neg_4()`,在 item 上使用 `.pl_4()`;纵向 Carousel 使用对应的 `.mt_neg_4()` 与 `.pt_4()`。覆盖间距时应同步修改两侧,并使用相同的 spacing scale,这样首个 item 会继续与 viewport 对齐,同时改变可见间距。 + ## 方向 创建 state 时使用 `with_axis`: @@ -116,7 +170,7 @@ CarouselPrevious::new(&state).with_size(Size::Large); CarouselNext::new(&state).with_size(Size::Large); ``` -上一项和下一项控件默认使用 `Size::Small`,分页项默认使用 `Size::XSmall`。 +上一项和下一项控件默认使用 `Size::Medium`,分页项默认使用 `Size::XSmall`。 ## 无障碍 From 5c648b2e0b705e4af3463cab2774483ecb88e02a Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:21:37 +0800 Subject: [PATCH 03/11] carousel: Allow custom navigation controls --- crates/story/src/stories/carousel_story.rs | 37 ++++++++++ crates/ui/src/carousel/carousel.rs | 80 ++++++++++++++++++++-- website/docs/components/carousel.md | 35 ++++++++++ website/zh-CN/docs/components/carousel.md | 35 ++++++++++ 4 files changed, 183 insertions(+), 4 deletions(-) diff --git a/crates/story/src/stories/carousel_story.rs b/crates/story/src/stories/carousel_story.rs index 8567e2fc44..d919ebe76b 100644 --- a/crates/story/src/stories/carousel_story.rs +++ b/crates/story/src/stories/carousel_story.rs @@ -26,6 +26,7 @@ enum SlideTypography { pub struct CarouselStory { focus_handle: FocusHandle, horizontal: Entity, + custom_controls: Entity, multiple: Entity, spacing: Entity, vertical: Entity, @@ -55,6 +56,7 @@ impl CarouselStory { pub fn view(_: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| { let horizontal = cx.new(|_| CarouselState::new(3)); + let custom_controls = cx.new(|_| CarouselState::new(3)); let multiple = cx.new(|_| CarouselState::new(5)); let spacing = cx.new(|_| CarouselState::new(5)); let vertical = cx.new(|_| CarouselState::new(3).with_axis(Axis::Vertical)); @@ -74,6 +76,7 @@ impl CarouselStory { Self { focus_handle: cx.focus_handle(), horizontal, + custom_controls, multiple, spacing, vertical, @@ -202,6 +205,40 @@ impl Render for CarouselStory { }))), ), ) + .child( + section("Custom controls") + .description( + "Replace control content and accessibility labels while retaining navigation behavior.", + ) + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-custom-controls", &self.custom_controls) + .w_full() + .max_w_96() + .mx_auto() + .child(Self::items( + &self.custom_controls, + "Custom Controls", + 3, + SlideTypography::Large, + true, + cx, + )) + .child( + CarouselPrevious::new(&self.custom_controls) + .with_size(self.size) + .with_accessibility_label("Previous project") + .child("Back"), + ) + .child( + CarouselNext::new(&self.custom_controls) + .with_size(self.size) + .with_accessibility_label("Next project") + .child("Forward"), + ), + ), + ) .child( section("Sizes") .description( diff --git a/crates/ui/src/carousel/carousel.rs b/crates/ui/src/carousel/carousel.rs index e713ddde88..c402135ba6 100644 --- a/crates/ui/src/carousel/carousel.rs +++ b/crates/ui/src/carousel/carousel.rs @@ -507,6 +507,8 @@ pub struct CarouselPrevious { state: Entity, size: Size, style: StyleRefinement, + accessibility_label: Option, + children: Vec, } impl CarouselPrevious { @@ -516,8 +518,16 @@ impl CarouselPrevious { state: state.clone(), size: Size::Medium, style: StyleRefinement::default(), + accessibility_label: None, + children: Vec::new(), } } + + /// Replaces the generated previous-slide accessibility label and tooltip. + pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } } impl crate::Sizable for CarouselPrevious { @@ -533,9 +543,23 @@ impl Styled for CarouselPrevious { } } +impl ParentElement for CarouselPrevious { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + impl RenderOnce for CarouselPrevious { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - carousel_control(self.state, self.size, self.style, false, cx) + carousel_control( + self.state, + self.size, + self.style, + self.accessibility_label, + self.children, + false, + cx, + ) } } @@ -545,6 +569,8 @@ pub struct CarouselNext { state: Entity, size: Size, style: StyleRefinement, + accessibility_label: Option, + children: Vec, } impl CarouselNext { @@ -554,8 +580,16 @@ impl CarouselNext { state: state.clone(), size: Size::Medium, style: StyleRefinement::default(), + accessibility_label: None, + children: Vec::new(), } } + + /// Replaces the generated next-slide accessibility label and tooltip. + pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } } impl crate::Sizable for CarouselNext { @@ -571,9 +605,23 @@ impl Styled for CarouselNext { } } +impl ParentElement for CarouselNext { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + impl RenderOnce for CarouselNext { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - carousel_control(self.state, self.size, self.style, true, cx) + carousel_control( + self.state, + self.size, + self.style, + self.accessibility_label, + self.children, + true, + cx, + ) } } @@ -581,6 +629,8 @@ fn carousel_control( state: Entity, size: Size, style: StyleRefinement, + accessibility_label: Option, + children: Vec, next: bool, cx: &mut App, ) -> impl IntoElement { @@ -591,12 +641,14 @@ fn carousel_control( } else { !snapshot.has_previous() }; - let (name, label, icon) = match (axis, next) { + let (name, default_label, icon) = match (axis, next) { (Axis::Horizontal, false) => ("previous", t!("Carousel.previous"), IconName::ChevronLeft), (Axis::Horizontal, true) => ("next", t!("Carousel.next"), IconName::ChevronRight), (Axis::Vertical, false) => ("previous", t!("Carousel.previous"), IconName::ChevronUp), (Axis::Vertical, true) => ("next", t!("Carousel.next"), IconName::ChevronDown), }; + let label = accessibility_label.unwrap_or_else(|| default_label.into()); + let has_custom_content = !children.is_empty(); let id = ElementId::NamedChild( Arc::new(("carousel-control", state.entity_id()).into()), name.into(), @@ -605,7 +657,7 @@ fn carousel_control( Button::new(id) .outline() .with_size(size) - .icon(icon) + .when(!has_custom_content, |this| this.icon(icon)) .accessibility_label(label.clone()) .tooltip(label) .disabled(disabled) @@ -634,6 +686,7 @@ fn carousel_control( }); }) }) + .children(children) .refine_style(&style) } @@ -833,4 +886,23 @@ mod tests { Size::Small ); } + + #[gpui::test] + fn carousel_controls_accept_accessibility_labels_and_children(cx: &mut gpui::TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); + let previous = CarouselPrevious::new(&state) + .with_accessibility_label("Previous project") + .child("Back"); + let next = CarouselNext::new(&state) + .with_accessibility_label("Next project") + .child("Forward"); + + assert_eq!( + previous.accessibility_label.as_deref(), + Some("Previous project") + ); + assert_eq!(next.accessibility_label.as_deref(), Some("Next project")); + assert_eq!(previous.children.len(), 1); + assert_eq!(next.children.len(), 1); + } } diff --git a/website/docs/components/carousel.md b/website/docs/components/carousel.md index 308e838795..d86bdd2f97 100644 --- a/website/docs/components/carousel.md +++ b/website/docs/components/carousel.md @@ -172,6 +172,41 @@ CarouselNext::new(&state).with_size(Size::Large); Previous and next controls default to `Size::Medium`. Pagination items default to `Size::XSmall`. +## Custom controls + +`CarouselPrevious` and `CarouselNext` implement `ParentElement` and `Styled`. Without children they display the direction-appropriate chevron. Add a child to replace that visible content while preserving automatic navigation and disabled boundary states. `with_accessibility_label` also replaces the control's tooltip. + +```rust +use gpui::ParentElement as _; + +CarouselPrevious::new(&state) + .with_accessibility_label("Previous project") + .child("Back"); + +CarouselNext::new(&state) + .with_accessibility_label("Next project") + .child("Forward"); +``` + +For a completely custom control, omit the corresponding Carousel part and compose any control with the public state API: + +```rust +use gpui::ParentElement as _; +use gpui_component::{Disableable as _, button::Button}; + +let previous_state = state.clone(); +let previous_disabled = !state.read(cx).has_previous(); + +Button::new("projects-previous") + .label("Back") + .disabled(previous_disabled) + .on_click(move |_, _, cx| { + previous_state.update(cx, |state, cx| { + state.select_previous(cx); + }); + }) +``` + ## Accessibility The carousel exposes a labelled region and each item reports its position within the set. Use `with_accessibility_label` when the default "Carousel" label does not describe the content. diff --git a/website/zh-CN/docs/components/carousel.md b/website/zh-CN/docs/components/carousel.md index b3e2857737..efc3389485 100644 --- a/website/zh-CN/docs/components/carousel.md +++ b/website/zh-CN/docs/components/carousel.md @@ -172,6 +172,41 @@ CarouselNext::new(&state).with_size(Size::Large); 上一项和下一项控件默认使用 `Size::Medium`,分页项默认使用 `Size::XSmall`。 +## 自定义控制按钮 + +`CarouselPrevious` 和 `CarouselNext` 实现了 `ParentElement` 与 `Styled`。没有子元素时,它们会根据方向显示对应的箭头;添加子元素后,可以替换可见内容,同时保留自动导航和边界禁用状态。`with_accessibility_label` 也会同步替换控件的 tooltip。 + +```rust +use gpui::ParentElement as _; + +CarouselPrevious::new(&state) + .with_accessibility_label("上一个项目") + .child("返回"); + +CarouselNext::new(&state) + .with_accessibility_label("下一个项目") + .child("继续"); +``` + +需要完全自定义控制按钮时,可以省略对应的 Carousel 部件,并使用公开 state API 组合任意控件: + +```rust +use gpui::ParentElement as _; +use gpui_component::{Disableable as _, button::Button}; + +let previous_state = state.clone(); +let previous_disabled = !state.read(cx).has_previous(); + +Button::new("projects-previous") + .label("返回") + .disabled(previous_disabled) + .on_click(move |_, _, cx| { + previous_state.update(cx, |state, cx| { + state.select_previous(cx); + }); + }) +``` + ## 无障碍 Carousel 会提供带 label 的区域,每个 item 会报告自己在内容集合中的位置。当默认的“轮播”无法准确描述内容时,使用 `with_accessibility_label` 设置更明确的名称。 From 2181a6acb97977e41d3c2edae9cf97b1e28647bb Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:00:22 +0800 Subject: [PATCH 04/11] carousel: Address review feedback --- crates/ui/src/carousel/carousel.rs | 203 +++++++++++++++++++++----- crates/ui/src/carousel/scroll_mask.rs | 162 +++++++++++++++++++- crates/ui/src/carousel/state.rs | 26 +++- 3 files changed, 345 insertions(+), 46 deletions(-) diff --git a/crates/ui/src/carousel/carousel.rs b/crates/ui/src/carousel/carousel.rs index c402135ba6..21a0357753 100644 --- a/crates/ui/src/carousel/carousel.rs +++ b/crates/ui/src/carousel/carousel.rs @@ -1,4 +1,4 @@ -use std::{panic::Location, sync::Arc, time::Duration}; +use std::{panic::Location, sync::Arc}; use gpui::{ AnyElement, App, Axis, Bounds, Element, ElementId, Entity, GlobalElementId, InspectorElementId, @@ -6,7 +6,7 @@ use gpui::{ SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window, div, prelude::FluentBuilder as _, px, }; -use gpui_base::{Spring, spring}; +use gpui_base::spring; use rust_i18n::t; use super::{CONTEXT, scroll_mask::CarouselScrollMask, state::CarouselState}; @@ -16,10 +16,9 @@ use crate::{ actions::{SelectDown, SelectFirst, SelectLast, SelectLeft, SelectRight, SelectUp}, button::Button, icon::IconName, + theme::ActiveTheme as _, }; -const SNAP_SPRING: Spring = Spring::new(Duration::from_millis(250)).with_epsilon(0.5); - /// A composable carousel root. /// /// Add one [`CarouselContent`] and any optional controls as children. Every @@ -30,6 +29,7 @@ pub struct Carousel { state: Entity, style: StyleRefinement, accessibility_label: SharedString, + focus_ring_enabled: bool, children: Vec, } @@ -45,6 +45,7 @@ impl Carousel { state: state.clone(), style: StyleRefinement::default(), accessibility_label: t!("Carousel.label").into(), + focus_ring_enabled: true, children: Vec::new(), } } @@ -68,6 +69,17 @@ impl Styled for Carousel { } } +impl crate::FocusableExt for Carousel { + fn focus_ring(mut self, enabled: bool) -> Self { + self.focus_ring_enabled = enabled; + self + } + + fn is_focus_ring_enabled(&self) -> bool { + self.focus_ring_enabled + } +} + impl RenderOnce for Carousel { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let observed_state = self.state.clone(); @@ -78,13 +90,16 @@ impl RenderOnce for Carousel { _subscription: cx.observe(&observed_state, |_, _, cx| cx.notify()), }, ); - let axis = self.state.read(cx).axis(); + let snapshot = self.state.read(cx); + let axis = snapshot.axis(); + let viewport_size = snapshot.viewport_size(); let focus_handle = window .use_keyed_state(("carousel-focus", self.state.entity_id()), cx, |_, cx| { cx.focus_handle() }) .read(cx) .clone(); + let focus_visible = focus_handle.is_focused(window) && self.focus_ring_enabled; let previous_state = self.state.clone(); let next_state = self.state.clone(); let first_state = self.state.clone(); @@ -140,6 +155,22 @@ impl RenderOnce for Carousel { }), ) .children(self.children) + .when(focus_visible, |this| { + this.when_some(viewport_size, |this, size| { + this.child( + div() + .absolute() + .top_0() + .left_0() + .w(size.width) + .h(size.height) + .border_1() + .border_color(cx.theme().transparent) + .rounded(cx.theme().radius) + .focus_ring_style(window, cx), + ) + }) + }) .refine_style(&self.style) } } @@ -322,13 +353,14 @@ impl RenderOnce for CarouselContent { .unwrap_or(current) }; let target = if interacting { current } else { target }; + let snap_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5); let animated = spring( ( ("carousel-content", entity_id), SharedString::from(format!("offset-{motion_revision}-{geometry_revision}")), ), target.as_f32(), - SNAP_SPRING.with_travel(!interacting), + snap_spring.with_travel(!interacting), window, cx, ); @@ -636,6 +668,7 @@ fn carousel_control( ) -> impl IntoElement { let snapshot = state.read(cx); let axis = snapshot.axis(); + let viewport_size = snapshot.viewport_size(); let disabled = if next { !snapshot.has_next() } else { @@ -654,40 +687,50 @@ fn carousel_control( name.into(), ); - Button::new(id) - .outline() - .with_size(size) - .when(!has_custom_content, |this| this.icon(icon)) - .accessibility_label(label.clone()) - .tooltip(label) - .disabled(disabled) + div() .absolute() - .rounded_full_style(cx) - .when(axis.is_horizontal() && !next, |this| { - this.right_full().mr_4().top_0().bottom_0().my_auto() - }) - .when(axis.is_horizontal() && next, |this| { - this.left_full().ml_4().top_0().bottom_0().my_auto() - }) - .when(axis.is_vertical() && !next, |this| { - this.bottom_full().mb_4().left_0().right_0().mx_auto() - }) - .when(axis.is_vertical() && next, |this| { - this.top_full().mt_4().left_0().right_0().mx_auto() + .top_0() + .left_0() + .when_some(viewport_size, |this, size| { + this.w(size.width).h(size.height) }) - .when(!disabled, |this| { - this.on_click(move |_, _, cx| { - state.update(cx, |state, cx| { - if next { - state.select_next(cx); - } else { - state.select_previous(cx); - } - }); - }) - }) - .children(children) - .refine_style(&style) + .when(viewport_size.is_none(), |this| this.right_0().bottom_0()) + .child( + Button::new(id) + .outline() + .with_size(size) + .when(!has_custom_content, |this| this.icon(icon)) + .accessibility_label(label.clone()) + .tooltip(label) + .disabled(disabled) + .absolute() + .rounded_full_style(cx) + .when(axis.is_horizontal() && !next, |this| { + this.right_full().mr_4().top_0().bottom_0().my_auto() + }) + .when(axis.is_horizontal() && next, |this| { + this.left_full().ml_4().top_0().bottom_0().my_auto() + }) + .when(axis.is_vertical() && !next, |this| { + this.bottom_full().mb_4().left_0().right_0().mx_auto() + }) + .when(axis.is_vertical() && next, |this| { + this.top_full().mt_4().left_0().right_0().mx_auto() + }) + .when(!disabled, |this| { + this.on_click(move |_, _, cx| { + state.update(cx, |state, cx| { + if next { + state.select_next(cx); + } else { + state.select_previous(cx); + } + }); + }) + }) + .children(children) + .refine_style(&style), + ) } /// A composable container for Carousel pagination items. @@ -860,7 +903,8 @@ fn snap_offset(handle: &gpui::ScrollHandle, axis: Axis, index: usize) -> Option< #[cfg(test)] mod tests { use super::*; - use gpui::{AppContext as _, point}; + use gpui::{AppContext as _, Context, Render, point}; + use gpui_base::FocusableExt as _; #[test] fn axis_helpers_only_change_the_requested_coordinate() { @@ -887,6 +931,87 @@ mod tests { ); } + #[gpui::test] + fn carousel_focus_ring_is_configurable(cx: &mut gpui::TestAppContext) { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); + + assert!(Carousel::new("carousel", &state).is_focus_ring_enabled()); + assert!( + !Carousel::new("carousel", &state) + .focus_ring(false) + .is_focus_ring_enabled() + ); + } + + struct KeyboardHarness { + state: Entity, + } + + impl Render for KeyboardHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().tab_group().child( + Carousel::new("carousel", &self.state) + .w(px(100.)) + .h(px(100.)) + .child( + CarouselContent::new(&self.state) + .h(px(100.)) + .children((0..3).map(|index| { + CarouselItem::new(("carousel-item", index), index, &self.state) + .child(index.to_string()) + })), + ), + ) + } + } + + fn assert_contextual_navigation_keys(cx: &mut gpui::TestAppContext, axis: Axis) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3).with_axis(axis))); + let (_, cx) = cx.add_window_view({ + let state = state.clone(); + move |_, _| KeyboardHarness { state } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.update(|window, cx| window.focus_next(cx)); + let (primary, secondary) = if axis.is_horizontal() { + ("right", "down") + } else { + ("down", "right") + }; + cx.simulate_keystrokes(primary); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + cx.simulate_keystrokes(secondary); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + cx.simulate_keystrokes("end"); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(2) + ); + cx.simulate_keystrokes("home"); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(0) + ); + } + + #[gpui::test] + fn horizontal_carousel_dispatches_contextual_navigation_keys(cx: &mut gpui::TestAppContext) { + assert_contextual_navigation_keys(cx, Axis::Horizontal); + } + + #[gpui::test] + fn vertical_carousel_dispatches_contextual_navigation_keys(cx: &mut gpui::TestAppContext) { + assert_contextual_navigation_keys(cx, Axis::Vertical); + } + #[gpui::test] fn carousel_controls_accept_accessibility_labels_and_children(cx: &mut gpui::TestAppContext) { let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); diff --git a/crates/ui/src/carousel/scroll_mask.rs b/crates/ui/src/carousel/scroll_mask.rs index 4afebcf715..aa445359d7 100644 --- a/crates/ui/src/carousel/scroll_mask.rs +++ b/crates/ui/src/carousel/scroll_mask.rs @@ -78,6 +78,8 @@ impl Element for CarouselScrollMask { ) -> (LayoutId, Self::RequestLayoutState) { let mut style = Style::default(); style.position = Position::Absolute; + style.inset.top = gpui::px(0.).into(); + style.inset.left = gpui::px(0.).into(); style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); (window.request_layout(style, None, cx), ()) @@ -123,7 +125,7 @@ impl Element for CarouselScrollMask { window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.capture() && event.button == MouseButton::Left - && hitbox_id.is_hovered(window) + && hitbox_id.should_handle_scroll(window) { let started = pointer_state.update(cx, |state, cx| state.begin_drag(event.position, cx)); @@ -256,3 +258,161 @@ impl Element for CarouselScrollMask { fn caller_id() -> ElementId { ElementId::CodeLocation(*Location::caller()) } + +#[cfg(test)] +mod tests { + use std::{cell::Cell, rc::Rc}; + + use gpui::{ + AppContext as _, Axis, Context, Entity, InteractiveElement as _, IntoElement, Modifiers, + MouseButton, ParentElement as _, Render, ScrollDelta, ScrollHandle, ScrollWheelEvent, + StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, Window, + div, point, px, + }; + + use crate::{ + button::Button, + carousel::{Carousel, CarouselContent, CarouselItem, CarouselState}, + }; + + struct ButtonDragHarness { + state: Entity, + clicks: Rc>, + } + + impl Render for ButtonDragHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let clicks = self.clicks.clone(); + Carousel::new("button-drag-carousel", &self.state) + .w(px(100.)) + .h(px(40.)) + .child( + CarouselContent::new(&self.state) + .h(px(40.)) + .child( + CarouselItem::new("button-slide", 0, &self.state) + .h(px(40.)) + .child( + Button::new("slide-button") + .w_full() + .h(px(40.)) + .on_click(move |_, _, _| clicks.set(clicks.get() + 1)), + ), + ) + .child( + CarouselItem::new("plain-slide", 1, &self.state) + .h(px(40.)) + .child("Second"), + ), + ) + } + } + + #[gpui::test] + fn dragging_over_child_button_suppresses_click_without_leaving_it_pending( + cx: &mut TestAppContext, + ) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); + let clicks = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let state = state.clone(); + let clicks = clicks.clone(); + move |_, _| ButtonDragHarness { state, clicks } + }); + let cx: &mut VisualTestContext = cx; + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(clicks.get(), 1); + clicks.set(0); + + cx.simulate_mouse_down( + point(px(30.), px(10.)), + MouseButton::Left, + Modifiers::default(), + ); + assert!(state.read_with(cx, |state, _| state.is_interacting())); + cx.simulate_mouse_move( + point(px(10.), px(10.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + assert!(state.read_with(cx, |state, _| state.is_pointer_drag_locked())); + cx.simulate_mouse_up( + point(px(10.), px(10.)), + MouseButton::Left, + Modifiers::default(), + ); + assert_eq!(clicks.get(), 0); + + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(10.), px(10.)), Modifiers::default()); + assert_eq!(clicks.get(), 1); + } + + struct NestedVerticalCarouselHarness { + state: Entity, + outer_handle: ScrollHandle, + } + + impl Render for NestedVerticalCarouselHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .id("outer-scroll") + .w(px(100.)) + .h(px(100.)) + .overflow_y_scroll() + .track_scroll(&self.outer_handle) + .child( + Carousel::new("vertical-carousel", &self.state) + .w(px(100.)) + .h(px(60.)) + .child( + CarouselContent::new(&self.state) + .h_full() + .children((0..2).map(|index| { + CarouselItem::new(("vertical-slide", index), index, &self.state) + .child(index.to_string()) + })), + ), + ) + .child(div().w_full().h(px(400.))) + } + } + + #[gpui::test] + fn vertical_carousel_hands_scroll_to_parent_at_edge(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| { + cx.new(|_| { + CarouselState::new(2) + .with_axis(Axis::Vertical) + .with_selected_index(1) + }) + }); + let outer_handle = ScrollHandle::new(); + let (_, cx) = cx.add_window_view({ + let state = state.clone(); + let outer_handle = outer_handle.clone(); + move |_, _| NestedVerticalCarouselHarness { + state, + outer_handle, + } + }); + let cx: &mut VisualTestContext = cx; + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(20.), px(20.)), + delta: ScrollDelta::Lines(point(0., -1.)), + ..Default::default() + }); + + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + assert!(outer_handle.offset().y < px(0.)); + } +} diff --git a/crates/ui/src/carousel/state.rs b/crates/ui/src/carousel/state.rs index c72fafdd1a..125f51cb97 100644 --- a/crates/ui/src/carousel/state.rs +++ b/crates/ui/src/carousel/state.rs @@ -329,6 +329,10 @@ impl CarouselState { self.motion_revision } + pub(super) fn viewport_size(&self) -> Option> { + self.geometry.viewport.map(|viewport| viewport.size) + } + /// Records the viewport and item bounds used for gesture snapping. #[cfg(test)] fn set_geometry(&mut self, viewport: Bounds, items: Vec>) { @@ -573,7 +577,7 @@ impl CarouselState { if matches!(phase, TouchPhase::Started | TouchPhase::Moved) { self.schedule_scroll_settle(cx); } - moved || self.looping + moved || self.runtime_looping() } /// Finishes a precise trackpad gesture. Cancelled gestures restore the @@ -643,7 +647,7 @@ impl CarouselState { } fn is_loop_wrap(&self, current: usize, next: usize) -> bool { - self.looping + self.runtime_looping() && self.item_count > 1 && ((current == 0 && next + 1 == self.item_count) || (current + 1 == self.item_count && next == 0)) @@ -659,7 +663,7 @@ impl CarouselState { /// track, while allowing navigation from a controlled duplicate index to /// skip back over that group. fn navigation_index(&self, current: usize, next: bool) -> Option { - if self.looping || !self.geometry_is_ready() { + if self.runtime_looping() || !self.geometry_is_ready() { return self.logical_navigation_index(current, next); } @@ -684,14 +688,14 @@ impl CarouselState { if next { if current + 1 < self.item_count { Some(current + 1) - } else if self.looping && self.item_count > 1 { + } else if self.runtime_looping() && self.item_count > 1 { Some(0) } else { None } } else if current > 0 { Some(current - 1) - } else if self.looping && self.item_count > 1 { + } else if self.runtime_looping() && self.item_count > 1 { Some(self.item_count - 1) } else { None @@ -702,6 +706,10 @@ impl CarouselState { self.geometry.viewport.is_some() && self.geometry.items.len() == self.item_count } + fn runtime_looping(&self) -> bool { + self.looping && (!self.geometry_is_ready() || self.loop_layout.is_some()) + } + fn adjacent_loop_target(&self, current: usize, next: usize) -> Option> { let layout = self.loop_layout.filter(|layout| layout.runway_ready)?; let mut target = self.snap_target_for(next)?; @@ -961,7 +969,7 @@ impl CarouselState { cx: &mut Context, ) -> bool { let current_offset = self.scroll_handle.offset(); - let selected = if self.looping { + let selected = if self.runtime_looping() { self.loop_boundary_index(start_index, total_delta) .or_else(|| self.nearest_index(current_offset)) } else { @@ -1769,6 +1777,12 @@ mod tests { assert_eq!(state.loop_runway(), None); assert_eq!(state.loop_item_offset(0), Point::default()); + assert!(!state.has_previous()); + assert!(!state.has_next()); + + state.selected_index = Some(1); + assert!(!state.has_previous()); + assert!(!state.has_next()); } #[test] From f6c61da055bf24be0eece49c377fbbe6051c2dbf Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:29:58 +0800 Subject: [PATCH 05/11] carousel: Register component shell bindings --- .../component-shell/component-inventory.json | 126 ++++ crates/component-shell/src/shell/carousel.rs | 571 ++++++++++++++++++ crates/component-shell/src/shell/mod.rs | 2 + .../src/shell/typed_compound/mod.rs | 20 + examples/js_story/catalog.js | 1 + examples/js_story/stories/coverage.js | 1 + examples/js_story/stories/layouts.js | 10 + examples/js_story/stories/registered.js | 52 ++ examples/js_story/stories/status.js | 1 + 9 files changed, 784 insertions(+) create mode 100644 crates/component-shell/src/shell/carousel.rs diff --git a/crates/component-shell/component-inventory.json b/crates/component-shell/component-inventory.json index eac3d07502..fa5f347363 100644 --- a/crates/component-shell/component-inventory.json +++ b/crates/component-shell/component-inventory.json @@ -92,6 +92,69 @@ ] } }, + { + "source": "ui", + "name": "carousel", + "classification": "component", + "registration": { + "status": "registered", + "descriptor": "Carousel", + "exports": [ + "Carousel" + ], + "states": [ + { + "export": "CarouselState", + "kind": "CarouselState", + "role": "retained-selection-and-gesture-state" + } + ], + "related": [ + { + "descriptor": "CarouselContent", + "exports": [ + "CarouselContent" + ], + "role": "typed-viewport" + }, + { + "descriptor": "CarouselItem", + "exports": [ + "CarouselItem" + ], + "role": "typed-slide" + }, + { + "descriptor": "CarouselPrevious", + "exports": [ + "CarouselPrevious" + ], + "role": "navigation-control" + }, + { + "descriptor": "CarouselNext", + "exports": [ + "CarouselNext" + ], + "role": "navigation-control" + }, + { + "descriptor": "CarouselPagination", + "exports": [ + "CarouselPagination" + ], + "role": "typed-pagination-container" + }, + { + "descriptor": "CarouselPaginationItem", + "exports": [ + "CarouselPaginationItem" + ], + "role": "typed-pagination-control" + } + ] + } + }, { "source": "ui", "name": "chart", @@ -1082,6 +1145,69 @@ ] } }, + { + "source": "story", + "name": "carousel", + "classification": "component", + "registration": { + "status": "registered", + "descriptor": "Carousel", + "exports": [ + "Carousel" + ], + "states": [ + { + "export": "CarouselState", + "kind": "CarouselState", + "role": "retained-selection-and-gesture-state" + } + ], + "related": [ + { + "descriptor": "CarouselContent", + "exports": [ + "CarouselContent" + ], + "role": "typed-viewport" + }, + { + "descriptor": "CarouselItem", + "exports": [ + "CarouselItem" + ], + "role": "typed-slide" + }, + { + "descriptor": "CarouselPrevious", + "exports": [ + "CarouselPrevious" + ], + "role": "navigation-control" + }, + { + "descriptor": "CarouselNext", + "exports": [ + "CarouselNext" + ], + "role": "navigation-control" + }, + { + "descriptor": "CarouselPagination", + "exports": [ + "CarouselPagination" + ], + "role": "typed-pagination-container" + }, + { + "descriptor": "CarouselPaginationItem", + "exports": [ + "CarouselPaginationItem" + ], + "role": "typed-pagination-control" + } + ] + } + }, { "source": "story", "name": "chart", diff --git a/crates/component-shell/src/shell/carousel.rs b/crates/component-shell/src/shell/carousel.rs new file mode 100644 index 0000000000..b96e3be439 --- /dev/null +++ b/crates/component-shell/src/shell/carousel.rs @@ -0,0 +1,571 @@ +use std::{cell::RefCell, rc::Rc, sync::Arc}; + +use gpui_component::{ + FocusableExt as _, Sizable as _, Size, + carousel::{ + Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext, CarouselPagination, + CarouselPaginationItem, CarouselPrevious, CarouselState, + }, +}; +use gpui_shell::{ + ArgumentDescriptor, ArgumentSchema, ComponentArgument, ComponentCallback, + ComponentCallbackArgument, ComponentDescriptor, ComponentMaterializer, ComponentPayload, + ComponentRegistry, ConstructorDescriptor, MaterializeRequest, MethodDescriptor, RegistryError, + StateDescriptor, anyhow, + gpui::{ + self, App, AppContext as _, Axis, Entity, IntoElement as _, RenderOnce, Subscription, + Window, + }, +}; + +use super::{ + support::{bool_method, string_method}, + typed_compound::{finish_part, finish_typed_children}, +}; + +#[derive(Clone)] +enum Payload { + Root { + id: String, + state: ComponentArgument, + }, + Content { + state: ComponentArgument, + }, + Item { + id: String, + index: usize, + state: ComponentArgument, + }, + Previous { + state: ComponentArgument, + }, + Next { + state: ComponentArgument, + }, + Pagination, + PaginationItem { + id: String, + index: usize, + state: ComponentArgument, + }, +} + +#[derive(Clone)] +enum Op { + AccessibilityLabel(String), + FocusRing(bool), + Size(Size), + OnChange(ComponentArgument), +} + +struct Materializer; + +impl ComponentMaterializer for Materializer { + fn materialize(&self, mut request: MaterializeRequest<'_>) -> anyhow::Result { + let payload = request + .payload() + .downcast_ref::() + .ok_or_else(|| anyhow::anyhow!("Carousel component received an incompatible payload"))? + .clone(); + let operations = request + .methods() + .filter_map(|method| method.payload().downcast_ref::().cloned()) + .collect::>(); + + match payload { + Payload::Root { id, state } => { + let state = request.with_state::, _>(&state, Clone::clone)?; + let callback = operations + .iter() + .filter_map(|operation| match operation { + Op::OnChange(argument) => Some(argument), + _ => None, + }) + .next_back() + .map(|argument| request.resolve_callback(argument)) + .transpose()?; + let mut carousel = Carousel::new(id.clone(), &state); + for operation in operations { + carousel = match operation { + Op::AccessibilityLabel(value) => carousel.with_accessibility_label(value), + Op::FocusRing(value) => carousel.focus_ring(value), + Op::Size(_) | Op::OnChange(_) => carousel, + }; + } + let child = finish_typed_children( + &mut request, + carousel, + "Carousel", + &[ + "CarouselContent", + "CarouselPrevious", + "CarouselNext", + "CarouselPagination", + ], + )?; + Ok(BoundCarousel { + id, + state, + callback, + child, + } + .into_any_element()) + } + Payload::Content { state } => { + let state = request.with_state::, _>(&state, Clone::clone)?; + finish_typed_children( + &mut request, + CarouselContent::new(&state), + "CarouselContent", + &["CarouselItem"], + ) + } + Payload::Item { id, index, state } => { + let state = request.with_state::, _>(&state, Clone::clone)?; + let mut item = CarouselItem::new(id, index, &state); + for operation in operations { + if let Op::AccessibilityLabel(value) = operation { + item = item.with_accessibility_label(value); + } + } + finish_part(&mut request, item) + } + Payload::Previous { state } => { + let state = request.with_state::, _>(&state, Clone::clone)?; + let mut previous = CarouselPrevious::new(&state); + for operation in operations { + previous = match operation { + Op::AccessibilityLabel(value) => previous.with_accessibility_label(value), + Op::Size(value) => previous.with_size(value), + Op::FocusRing(_) | Op::OnChange(_) => previous, + }; + } + finish_part(&mut request, previous) + } + Payload::Next { state } => { + let state = request.with_state::, _>(&state, Clone::clone)?; + let mut next = CarouselNext::new(&state); + for operation in operations { + next = match operation { + Op::AccessibilityLabel(value) => next.with_accessibility_label(value), + Op::Size(value) => next.with_size(value), + Op::FocusRing(_) | Op::OnChange(_) => next, + }; + } + finish_part(&mut request, next) + } + Payload::Pagination => finish_typed_children( + &mut request, + CarouselPagination::new(), + "CarouselPagination", + &["CarouselPaginationItem"], + ), + Payload::PaginationItem { id, index, state } => { + let state = request.with_state::, _>(&state, Clone::clone)?; + let mut item = CarouselPaginationItem::new(id, index, &state); + for operation in operations { + if let Op::Size(value) = operation { + item = item.with_size(value); + } + } + finish_part(&mut request, item) + } + } + } +} + +struct ChangeHost { + callback: Rc>>, + _change: Subscription, +} + +#[derive(gpui::IntoElement)] +struct BoundCarousel { + id: String, + state: Entity, + callback: Option, + child: gpui::AnyElement, +} + +impl RenderOnce for BoundCarousel { + fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { + let initial_callback = self.callback.clone(); + let state = self.state.clone(); + let host: Entity = window.use_keyed_state( + format!("shell-carousel:{}:{:?}", self.id, self.state.entity_id()), + cx, + move |window, cx| { + let callback = Rc::new(RefCell::new(initial_callback)); + let event_callback = callback.clone(); + let change = + window.subscribe(&state, cx, move |_, event: &CarouselEvent, window, cx| { + let CarouselEvent::Change(index) = event; + let callback = event_callback.borrow().clone(); + if let Some(callback) = callback { + callback.invoke_and_report_with( + "Carousel.on_change callback failed", + &[ComponentCallbackArgument::Number(*index as f64)], + window, + cx, + ); + } + }); + ChangeHost { + callback, + _change: change, + } + }, + ); + *host.read(cx).callback.borrow_mut() = self.callback; + self.child + } +} + +fn nonnegative_usize(argument: &ComponentArgument, callable: &str) -> Result { + match argument { + ComponentArgument::Number(value) + if value.is_finite() + && *value >= 0. + && value.fract() == 0. + && *value <= usize::MAX as f64 => + { + Ok(*value as usize) + } + _ => Err(format!("{callable} expects a nonnegative integer")), + } +} + +fn nonempty_id(argument: &ComponentArgument, callable: &str) -> Result { + match argument { + ComponentArgument::String(value) if !value.trim().is_empty() => Ok(value.clone()), + _ => Err(format!("{callable} expects a nonempty string id")), + } +} + +fn entity(argument: &ComponentArgument, callable: &str) -> Result { + match argument { + argument @ ComponentArgument::Entity { .. } => Ok(argument.clone()), + _ => Err(format!("{callable} expects a CarouselState entity")), + } +} + +fn state_constructor( + component: &'static str, + payload: impl Fn(ComponentArgument) -> Payload + Send + Sync + 'static, +) -> ConstructorDescriptor { + ConstructorDescriptor::new( + component, + vec![ArgumentDescriptor::new( + "state", + ArgumentSchema::Entity("CarouselState"), + )], + move |arguments| match arguments { + [state] => entity(state, component) + .map(&payload) + .map(ComponentPayload::new), + _ => Err(format!( + "{component}(state) expects one CarouselState entity" + )), + }, + ) +} + +fn id_state_constructor( + component: &'static str, + payload: impl Fn(String, ComponentArgument) -> Payload + Send + Sync + 'static, +) -> ConstructorDescriptor { + ConstructorDescriptor::new( + component, + vec![ + ArgumentDescriptor::new("id", ArgumentSchema::String), + ArgumentDescriptor::new("state", ArgumentSchema::Entity("CarouselState")), + ], + move |arguments| match arguments { + [id, state] => Ok(ComponentPayload::new(payload( + nonempty_id(id, component)?, + entity(state, component)?, + ))), + _ => Err(format!( + "{component}(id, state) expects a nonempty id and CarouselState entity" + )), + }, + ) +} + +fn indexed_state_constructor( + component: &'static str, + payload: impl Fn(String, usize, ComponentArgument) -> Payload + Send + Sync + 'static, +) -> ConstructorDescriptor { + ConstructorDescriptor::new( + component, + vec![ + ArgumentDescriptor::new("id", ArgumentSchema::String), + ArgumentDescriptor::new("index", ArgumentSchema::Number), + ArgumentDescriptor::new("state", ArgumentSchema::Entity("CarouselState")), + ], + move |arguments| match arguments { + [id, index, state] => Ok(ComponentPayload::new(payload( + nonempty_id(id, component)?, + nonnegative_usize(index, &format!("{component}(id, index, state)"))?, + entity(state, component)?, + ))), + _ => Err(format!( + "{component}(id, index, state) expects an id, nonnegative index, and CarouselState" + )), + }, + ) +} + +fn accessibility_label_method(component: &'static str) -> MethodDescriptor { + string_method( + component, + "accessibility_label", + "Sets the name announced by accessibility clients.", + Op::AccessibilityLabel, + ) +} + +fn size_method(component: &'static str) -> MethodDescriptor { + MethodDescriptor::new( + "size", + vec![ArgumentDescriptor::new( + "size", + ArgumentSchema::Enum(&["xsmall", "small", "medium", "large"]), + )], + move |arguments| match arguments { + [ComponentArgument::Enum(value)] => match value.as_str() { + "xsmall" => Ok(ComponentPayload::new(Op::Size(Size::XSmall))), + "small" => Ok(ComponentPayload::new(Op::Size(Size::Small))), + "medium" => Ok(ComponentPayload::new(Op::Size(Size::Medium))), + "large" => Ok(ComponentPayload::new(Op::Size(Size::Large))), + _ => Err(format!("unsupported {component} size `{value}`")), + }, + _ => Err(format!("{component}.size(size) expects a semantic size")), + }, + ) + .with_documentation("Sets the semantic control size.") +} + +fn descriptor( + name: &'static str, + constructor: ConstructorDescriptor, + methods: Vec, + documentation: &'static str, +) -> ComponentDescriptor { + ComponentDescriptor::new(name, Arc::new(Materializer)) + .with_constructors(vec![constructor]) + .with_methods(methods) + .with_documentation(documentation) +} + +pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryError> { + registry.register_state( + StateDescriptor::new( + "CarouselState", + "CarouselState", + vec![ + ArgumentDescriptor::new("item_count", ArgumentSchema::Number), + ArgumentDescriptor::new( + "selected_index", + ArgumentSchema::Optional(Box::new(ArgumentSchema::Number)), + ), + ArgumentDescriptor::new( + "axis", + ArgumentSchema::Optional(Box::new(ArgumentSchema::Enum(&[ + "horizontal", + "vertical", + ]))), + ), + ArgumentDescriptor::new( + "looping", + ArgumentSchema::Optional(Box::new(ArgumentSchema::Boolean)), + ), + ], + |arguments, _, cx| { + let [item_count, selected_index, axis, looping] = arguments else { + return Err( + "CarouselState expects item_count, selected_index, axis, and looping" + .into(), + ); + }; + let item_count = nonnegative_usize(item_count, "CarouselState(item_count)")?; + let selected_index = match selected_index { + ComponentArgument::Optional(Some(index)) => { + Some(nonnegative_usize(index, "CarouselState selected_index")?) + } + ComponentArgument::Optional(None) => None, + _ => return Err("CarouselState selected_index must be optional".into()), + }; + if selected_index.is_some_and(|index| index >= item_count) { + return Err("CarouselState selected_index must be within item_count".into()); + } + let axis = match axis { + ComponentArgument::Optional(Some(axis)) => match axis.as_ref() { + ComponentArgument::Enum(axis) if axis == "horizontal" => Axis::Horizontal, + ComponentArgument::Enum(axis) if axis == "vertical" => Axis::Vertical, + _ => return Err("CarouselState axis expects horizontal or vertical".into()), + }, + ComponentArgument::Optional(None) => Axis::Horizontal, + _ => return Err("CarouselState axis must be optional".into()), + }; + let looping = match looping { + ComponentArgument::Optional(Some(looping)) => match looping.as_ref() { + ComponentArgument::Boolean(looping) => *looping, + _ => return Err("CarouselState looping expects a boolean".into()), + }, + ComponentArgument::Optional(None) => false, + _ => return Err("CarouselState looping must be optional".into()), + }; + Ok(Box::new(cx.new(|_| { + let mut state = CarouselState::new(item_count) + .with_axis(axis) + .with_looping(looping); + if let Some(index) = selected_index { + state = state.with_selected_index(index); + } + state + }))) + }, + ) + .with_documentation("Retained Carousel selection, axis, looping, and interaction state."), + )?; + + registry.register(descriptor( + "Carousel", + id_state_constructor("Carousel", |id, state| Payload::Root { id, state }), + vec![ + accessibility_label_method("Carousel"), + bool_method( + "Carousel", + "focus_ring", + "Controls whether keyboard focus draws a focus ring.", + Op::FocusRing, + ), + MethodDescriptor::new( + "on_change", + vec![ArgumentDescriptor::new( + "callback", + ArgumentSchema::Callback("(index: number, cx: Context) => void"), + )], + |arguments| match arguments { + [argument @ ComponentArgument::Callback(_)] => { + Ok(ComponentPayload::new(Op::OnChange(argument.clone()))) + } + _ => Err("Carousel.on_change(callback) expects one callback".into()), + }, + ) + .with_documentation("Reports the newly selected zero-based item index."), + ], + "A retained snapping viewport composed from Carousel parts.", + ))?; + registry.register(descriptor( + "CarouselContent", + state_constructor("CarouselContent", |state| Payload::Content { state }), + vec![], + "The clipped Carousel viewport; accepts only CarouselItem children.", + ))?; + registry.register(descriptor( + "CarouselItem", + indexed_state_constructor("CarouselItem", |id, index, state| Payload::Item { + id, + index, + state, + }), + vec![accessibility_label_method("CarouselItem")], + "One indexed Carousel slide that accepts ordinary content children.", + ))?; + registry.register(descriptor( + "CarouselPrevious", + state_constructor("CarouselPrevious", |state| Payload::Previous { state }), + vec![ + accessibility_label_method("CarouselPrevious"), + size_method("CarouselPrevious"), + ], + "The previous-item control for a CarouselState.", + ))?; + registry.register(descriptor( + "CarouselNext", + state_constructor("CarouselNext", |state| Payload::Next { state }), + vec![ + accessibility_label_method("CarouselNext"), + size_method("CarouselNext"), + ], + "The next-item control for a CarouselState.", + ))?; + registry.register(descriptor( + "CarouselPagination", + ConstructorDescriptor::new("CarouselPagination", vec![], |_| { + Ok(ComponentPayload::new(Payload::Pagination)) + }), + vec![], + "A container that accepts only CarouselPaginationItem children.", + ))?; + registry.register(descriptor( + "CarouselPaginationItem", + indexed_state_constructor("CarouselPaginationItem", |id, index, state| { + Payload::PaginationItem { id, index, state } + }), + vec![size_method("CarouselPaginationItem")], + "One indexed Carousel pagination control.", + ))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui_shell::{COMPONENT_REGISTRY_API_VERSION, DEFAULT_COMPONENT_MODULE}; + + #[test] + fn registers_the_closed_carousel_family_and_state() { + let mut registry = + ComponentRegistry::new(COMPONENT_REGISTRY_API_VERSION, DEFAULT_COMPONENT_MODULE) + .unwrap(); + register(&mut registry).unwrap(); + let frozen = registry.freeze().unwrap(); + + assert_eq!( + frozen + .descriptors() + .map(|descriptor| descriptor.name()) + .collect::>(), + [ + "Carousel", + "CarouselContent", + "CarouselItem", + "CarouselPrevious", + "CarouselNext", + "CarouselPagination", + "CarouselPaginationItem", + ] + ); + assert_eq!( + frozen + .states() + .map(|state| (state.export(), state.kind())) + .collect::>(), + [("CarouselState", "CarouselState")] + ); + assert!(frozen.descriptors().all(|descriptor| { + descriptor.documentation().is_some() + && descriptor + .methods() + .iter() + .all(|method| method.documentation().is_some()) + })); + } + + #[test] + fn identifiers_and_indices_are_closed() { + assert!(nonempty_id(&ComponentArgument::String("carousel".into()), "Carousel").is_ok()); + assert!(nonempty_id(&ComponentArgument::String(" ".into()), "Carousel").is_err()); + assert_eq!( + nonnegative_usize(&ComponentArgument::Number(3.), "CarouselItem").unwrap(), + 3 + ); + assert!(nonnegative_usize(&ComponentArgument::Number(-1.), "CarouselItem").is_err()); + assert!(nonnegative_usize(&ComponentArgument::Number(1.5), "CarouselItem").is_err()); + } +} diff --git a/crates/component-shell/src/shell/mod.rs b/crates/component-shell/src/shell/mod.rs index 936536d7dc..62f9e1066b 100644 --- a/crates/component-shell/src/shell/mod.rs +++ b/crates/component-shell/src/shell/mod.rs @@ -29,6 +29,7 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE navigation::register(registry)?; basic::register(registry)?; chart::register(registry)?; + carousel::register(registry)?; Ok(()) } @@ -36,6 +37,7 @@ mod support; mod typed_child; mod basic; +mod carousel; mod chart; mod chat; mod collections; diff --git a/crates/component-shell/src/shell/typed_compound/mod.rs b/crates/component-shell/src/shell/typed_compound/mod.rs index b5d4adabb7..c0c783026a 100644 --- a/crates/component-shell/src/shell/typed_compound/mod.rs +++ b/crates/component-shell/src/shell/typed_compound/mod.rs @@ -214,6 +214,26 @@ where Ok(TypedChildElement::new(element).into_any_element()) } +pub(super) fn finish_typed_children( + request: &mut MaterializeRequest<'_>, + mut element: E, + parent: &str, + allowed: &[&str], +) -> anyhow::Result +where + E: gpui::Styled + gpui::ParentElement + gpui::IntoElement + 'static, +{ + let mut children = request.take_typed_children()?; + for child in &children { + require_child(parent, child.component_name(), allowed)?; + } + for child in &mut children { + element.extend([request.materialize_child(child)?]); + } + element.style().refine(&request.take_style()); + Ok(TypedChildElement::new(element).into_any_element()) +} + fn finish_typed(request: &mut MaterializeRequest<'_>, mut element: E) -> gpui::AnyElement where E: gpui::Styled + gpui::IntoElement + 'static, diff --git a/examples/js_story/catalog.js b/examples/js_story/catalog.js index 99ea234dfc..78c8113467 100644 --- a/examples/js_story/catalog.js +++ b/examples/js_story/catalog.js @@ -40,6 +40,7 @@ const RUST_STORY_ORDER = [ "BubbleStory", "ButtonStory", "CalendarStory", + "CarouselStory", "ChartStory", "CheckboxStory", "ClipboardStory", diff --git a/examples/js_story/stories/coverage.js b/examples/js_story/stories/coverage.js index ac2362e183..eb549e12c9 100644 --- a/examples/js_story/stories/coverage.js +++ b/examples/js_story/stories/coverage.js @@ -12,6 +12,7 @@ export const coveredBy = [ { route: "bubble", registrations: ["Bubble"] }, { route: "button", registrations: ["Button"] }, { route: "calendar", registrations: ["Calendar"] }, + { route: "carousel", registrations: ["Carousel"] }, { route: "chart", registrations: ["BarChart"] }, { route: "checkbox", registrations: ["Checkbox"] }, { route: "clipboard", registrations: ["Clipboard"] }, diff --git a/examples/js_story/stories/layouts.js b/examples/js_story/stories/layouts.js index ad3f53816e..4e7600dc32 100644 --- a/examples/js_story/stories/layouts.js +++ b/examples/js_story/stories/layouts.js @@ -11,6 +11,16 @@ export const stories = [ availability: "pending", api: "Calendar", }), + pendingStory({ + id: "carousel", + title: "Carousel", + group: "Layout & advanced", + rustStory: "CarouselStory", + description: "Snapping navigation through related content.", + states: ["first item", "middle item", "last item"], + availability: "pending", + api: "Carousel", + }), pendingStory({ id: "chart", title: "Chart", diff --git a/examples/js_story/stories/registered.js b/examples/js_story/stories/registered.js index 3fafbfc595..395c5084dc 100644 --- a/examples/js_story/stories/registered.js +++ b/examples/js_story/stories/registered.js @@ -19,6 +19,14 @@ import { Button, Calendar, CalendarState, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPagination, + CarouselPaginationItem, + CarouselPrevious, + CarouselState, Checkbox, Clipboard, Collapsible, @@ -198,6 +206,7 @@ export function initializeRegisteredExamples() { retained("date-picker", () => DatePickerState()); retained("calendar-one", () => CalendarState()); retained("calendar-two", () => CalendarState()); + retained("carousel-basic", () => CarouselState(3)); retained("message-scroller", () => MessageScrollerState(3)); retained("form-account", () => InputState("Acme Cloud")); retained("form-region", () => InputState("us-east-1")); @@ -1365,6 +1374,49 @@ export function registeredExamples(surface, cx) { element: asElement(new Breadcrumb(["Home", "Settings", "Profile"])), }, ]; + case "Carousel": { + const carouselState = retained("carousel-basic", () => CarouselState(3)); + const slide = (index) => + div() + .w_full() + .h(224) + .flex() + .items_center() + .justify_center() + .border(1) + .border_color(cx.theme().colors.border) + .rounded(8) + .bg(cx.theme().colors.background) + .text_size(28) + .font_semibold() + .child(String(index + 1)); + return [ + { + label: "Basic", + description: "Use the controls, pagination, keyboard, pointer, or trackpad to select a slide.", + element: asElement( + new Carousel("story-carousel", carouselState) + .w(384) + .max_w_full() + .on_change((index, cx) => setState("carousel-index", index, cx)) + .child( + new CarouselContent(carouselState) + .child(new CarouselItem("story-slide-1", 0, carouselState).child(slide(0))) + .child(new CarouselItem("story-slide-2", 1, carouselState).child(slide(1))) + .child(new CarouselItem("story-slide-3", 2, carouselState).child(slide(2))), + ) + .child(new CarouselPrevious(carouselState).accessibility_label("Previous slide")) + .child(new CarouselNext(carouselState).accessibility_label("Next slide")) + .child( + new CarouselPagination() + .child(new CarouselPaginationItem("story-page-1", 0, carouselState).child("1")) + .child(new CarouselPaginationItem("story-page-2", 1, carouselState).child("2")) + .child(new CarouselPaginationItem("story-page-3", 2, carouselState).child("3")), + ), + ), + }, + ]; + } case "Pagination": return [ { diff --git a/examples/js_story/stories/status.js b/examples/js_story/stories/status.js index 988b3bee8f..374c5d6c33 100644 --- a/examples/js_story/stories/status.js +++ b/examples/js_story/stories/status.js @@ -12,6 +12,7 @@ export const REGISTERED_SURFACES = [ "Bubble", "Button", "Calendar", + "Carousel", "Checkbox", "Clipboard", "Collapsible", From bd5937953857627f5188c86d8c956fd02b87ac68 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 9 Sep 2026 17:42:33 +0800 Subject: [PATCH 06/11] carousel: Stop swallowing keys and gestures that belong to ancestors Co-Authored-By: Claude Fable 5.1 --- crates/component/src/carousel/carousel.rs | 95 +++++++++++-- crates/component/src/carousel/scroll_mask.rs | 141 ++++++++++++++----- crates/component/src/carousel/state.rs | 75 +++++++++- website/component/carousel.md | 2 +- website/zh-CN/component/carousel.md | 2 +- 5 files changed, 266 insertions(+), 49 deletions(-) diff --git a/crates/component/src/carousel/carousel.rs b/crates/component/src/carousel/carousel.rs index 21a0357753..20caf18c50 100644 --- a/crates/component/src/carousel/carousel.rs +++ b/crates/component/src/carousel/carousel.rs @@ -114,43 +114,47 @@ impl RenderOnce for Carousel { .key_context(CONTEXT) .on_action( window.listener_for(&previous_state, move |state, _: &SelectLeft, _, cx| { - if axis.is_horizontal() && state.select_previous(cx) { - cx.stop_propagation(); + let handled = axis.is_horizontal() && state.select_previous(cx); + if !handled { + cx.propagate(); } }), ) .on_action( window.listener_for(&next_state, move |state, _: &SelectRight, _, cx| { - if axis.is_horizontal() && state.select_next(cx) { - cx.stop_propagation(); + let handled = axis.is_horizontal() && state.select_next(cx); + if !handled { + cx.propagate(); } }), ) .on_action( window.listener_for(&previous_state, move |state, _: &SelectUp, _, cx| { - if axis.is_vertical() && state.select_previous(cx) { - cx.stop_propagation(); + let handled = axis.is_vertical() && state.select_previous(cx); + if !handled { + cx.propagate(); } }), ) .on_action( window.listener_for(&next_state, move |state, _: &SelectDown, _, cx| { - if axis.is_vertical() && state.select_next(cx) { - cx.stop_propagation(); + let handled = axis.is_vertical() && state.select_next(cx); + if !handled { + cx.propagate(); } }), ) .on_action( window.listener_for(&first_state, |state, _: &SelectFirst, _, cx| { - if state.select_first(cx) { - cx.stop_propagation(); + if !state.select_first(cx) { + cx.propagate(); } }), ) .on_action( window.listener_for(&last_state, |state, _: &SelectLast, _, cx| { - if state.select_last(cx) { - cx.stop_propagation(); + if !state.select_last(cx) { + cx.propagate(); } }), ) @@ -902,6 +906,8 @@ fn snap_offset(handle: &gpui::ScrollHandle, axis: Axis, index: usize) -> Option< #[cfg(test)] mod tests { + use std::{cell::Cell, rc::Rc}; + use super::*; use gpui::{AppContext as _, Context, Render, point}; use gpui_base::FocusableExt as _; @@ -1012,6 +1018,71 @@ mod tests { assert_contextual_navigation_keys(cx, Axis::Vertical); } + struct PropagationHarness { + state: Entity, + outer_actions: Rc>, + } + + impl Render for PropagationHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let down = self.outer_actions.clone(); + let left = self.outer_actions.clone(); + let right = self.outer_actions.clone(); + div() + .tab_group() + .on_action(move |_: &SelectDown, _, _| down.set(down.get() + 1)) + .on_action(move |_: &SelectLeft, _, _| left.set(left.get() + 1)) + .on_action(move |_: &SelectRight, _, _| right.set(right.get() + 1)) + .child( + Carousel::new("carousel", &self.state) + .w(px(100.)) + .h(px(100.)) + .child( + CarouselContent::new(&self.state) + .h(px(100.)) + .children((0..3).map(|index| { + CarouselItem::new(("carousel-item", index), index, &self.state) + .child(index.to_string()) + })), + ), + ) + } + } + + #[gpui::test] + fn unhandled_navigation_keys_reach_ancestors(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); + let outer_actions = Rc::new(Cell::new(0)); + let (_, cx) = cx.add_window_view({ + let state = state.clone(); + let outer_actions = outer_actions.clone(); + move |_, _| PropagationHarness { + state, + outer_actions, + } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.update(|window, cx| window.focus_next(cx)); + + cx.simulate_keystrokes("down"); + assert_eq!(outer_actions.get(), 1); + + cx.simulate_keystrokes("left"); + assert_eq!(outer_actions.get(), 2); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(0) + ); + + cx.simulate_keystrokes("right"); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + assert_eq!(outer_actions.get(), 2); + } + #[gpui::test] fn carousel_controls_accept_accessibility_labels_and_children(cx: &mut gpui::TestAppContext) { let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); diff --git a/crates/component/src/carousel/scroll_mask.rs b/crates/component/src/carousel/scroll_mask.rs index aa445359d7..8c3f2c4fde 100644 --- a/crates/component/src/carousel/scroll_mask.rs +++ b/crates/component/src/carousel/scroll_mask.rs @@ -211,24 +211,27 @@ impl Element for CarouselScrollMask { } else { delta.y }; - let consumed = if precise { - if primary_delta.is_zero() - || matches!(event.touch_phase, TouchPhase::Ended | TouchPhase::Cancelled) - { - false - } else { - state.update(cx, |state, cx| { - state.handle_scroll_delta(axis, primary_delta, event.touch_phase, cx) - }) - } + let consumed = if primary_delta.is_zero() { + false + } else if !precise { + state.update(cx, |state, cx| { + state.handle_wheel_step(axis, primary_delta, cx) + }) + } else if matches!(event.touch_phase, TouchPhase::Ended | TouchPhase::Cancelled) { + false } else { - if primary_delta.is_zero() { - false - } else if primary_delta > gpui::Pixels::ZERO { - state.update(cx, |state, cx| state.select_previous(cx)) - } else { - state.update(cx, |state, cx| state.select_next(cx)) + // A gesture this carousel already owns stays here even at + // an edge, so its leftover never scrolls an ancestor. One + // that begins at an edge belongs to the ancestor until it + // ends. + let owned = state.read(cx).has_scroll_gesture(); + let moved = state.update(cx, |state, cx| { + state.handle_scroll_delta(axis, primary_delta, event.touch_phase, cx) + }); + if !moved && !owned { + state.update(cx, |state, cx| state.defer_scroll_to_ancestor(cx)); } + moved || owned }; if precise { @@ -243,9 +246,9 @@ impl Element for CarouselScrollMask { } } - // Horizontal carousels retain the gesture at their edge. A - // vertical carousel chains at an edge so an ancestor can - // continue scrolling the surrounding document. + // Horizontal carousels retain every gesture at their edge. A + // vertical carousel hands a gesture that begins at an edge to + // an ancestor so the surrounding document keeps scrolling. if consumed || (axis.is_horizontal() && !primary_delta.is_zero()) { cx.stop_propagation(); } @@ -266,13 +269,15 @@ mod tests { use gpui::{ AppContext as _, Axis, Context, Entity, InteractiveElement as _, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, ScrollDelta, ScrollHandle, ScrollWheelEvent, - StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, Window, - div, point, px, + StatefulInteractiveElement as _, Styled as _, TestAppContext, TouchPhase, + VisualTestContext, Window, div, point, px, }; use crate::{ button::Button, - carousel::{Carousel, CarouselContent, CarouselItem, CarouselState}, + carousel::{ + Carousel, CarouselContent, CarouselItem, CarouselState, state::SCROLL_EVENT_SEPARATION, + }, }; struct ButtonDragHarness { @@ -381,16 +386,10 @@ mod tests { } } - #[gpui::test] - fn vertical_carousel_hands_scroll_to_parent_at_edge(cx: &mut TestAppContext) { - cx.update(crate::init); - let state = cx.update(|cx| { - cx.new(|_| { - CarouselState::new(2) - .with_axis(Axis::Vertical) - .with_selected_index(1) - }) - }); + fn nested_vertical_carousel<'a>( + cx: &'a mut TestAppContext, + state: &Entity, + ) -> (ScrollHandle, &'a mut VisualTestContext) { let outer_handle = ScrollHandle::new(); let (_, cx) = cx.add_window_view({ let state = state.clone(); @@ -400,19 +399,93 @@ mod tests { outer_handle, } }); - let cx: &mut VisualTestContext = cx; cx.update(|window, cx| window.draw(cx).clear(cx)); + (outer_handle, cx) + } + + fn wheel_line(cx: &mut VisualTestContext, lines: f32) { + cx.simulate_event(ScrollWheelEvent { + position: point(px(20.), px(20.)), + delta: ScrollDelta::Lines(point(0., lines)), + ..Default::default() + }); + } + fn swipe(cx: &mut VisualTestContext, delta: f32, touch_phase: TouchPhase) { cx.simulate_event(ScrollWheelEvent { position: point(px(20.), px(20.)), - delta: ScrollDelta::Lines(point(0., -1.)), + delta: ScrollDelta::Pixels(point(px(0.), px(delta))), + touch_phase, ..Default::default() }); + } + + #[gpui::test] + fn vertical_carousel_hands_scroll_to_parent_at_edge(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| { + cx.new(|_| { + CarouselState::new(2) + .with_axis(Axis::Vertical) + .with_selected_index(1) + }) + }); + let (outer_handle, cx) = nested_vertical_carousel(cx, &state); + + wheel_line(cx, -1.); + + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + assert!(outer_handle.offset().y < px(0.)); + } + + #[gpui::test] + fn vertical_carousel_keeps_a_wheel_burst_away_from_the_parent(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_axis(Axis::Vertical))); + let (outer_handle, cx) = nested_vertical_carousel(cx, &state); + // One notch steps once; the rest of its burst stays here. + wheel_line(cx, -1.); + wheel_line(cx, -1.); assert_eq!( state.read_with(cx, |state, _| state.selected_index()), Some(1) ); + assert_eq!(outer_handle.offset().y, px(0.)); + + // A new notch at the edge scrolls the surrounding document instead. + cx.executor().advance_clock(SCROLL_EVENT_SEPARATION); + cx.run_until_parked(); + wheel_line(cx, -1.); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + assert!(outer_handle.offset().y < px(0.)); + } + + #[gpui::test] + fn vertical_carousel_owns_a_trackpad_gesture_until_it_ends(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|_| CarouselState::new(2).with_axis(Axis::Vertical))); + let (outer_handle, cx) = nested_vertical_carousel(cx, &state); + + // The swipe overshoots the last item; the leftover stays here. + swipe(cx, -50., TouchPhase::Started); + swipe(cx, -50., TouchPhase::Moved); + swipe(cx, -50., TouchPhase::Moved); + swipe(cx, 0., TouchPhase::Ended); + assert_eq!(outer_handle.offset().y, px(0.)); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + + // A gesture that begins at the edge scrolls the document instead. + swipe(cx, -50., TouchPhase::Started); assert!(outer_handle.offset().y < px(0.)); } } diff --git a/crates/component/src/carousel/state.rs b/crates/component/src/carousel/state.rs index 125f51cb97..cccc47334d 100644 --- a/crates/component/src/carousel/state.rs +++ b/crates/component/src/carousel/state.rs @@ -7,7 +7,7 @@ use gpui::{ const POINTER_AXIS_LOCK_THRESHOLD: Pixels = px(2.); // Keep this aligned with GPUI's OngoingScroll timeout. Some platforms only // emit `Moved`, so a quiet period is the only signal that a new gesture began. -const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); +pub(super) const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); /// An event emitted when user interaction selects another carousel item. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -77,6 +77,8 @@ pub struct CarouselState { geometry_has_runway: bool, loop_layout_removal_pending: bool, loop_motion_target: Option>, + wheel_burst_active: bool, + wheel_burst_epoch: usize, } impl CarouselState { @@ -100,6 +102,8 @@ impl CarouselState { geometry_has_runway: false, loop_layout_removal_pending: false, loop_motion_target: None, + wheel_burst_active: false, + wheel_burst_epoch: 0, } } @@ -304,6 +308,11 @@ impl CarouselState { self.pointer_gesture.is_some() || self.scroll_gesture.is_some() } + /// Returns whether a trackpad gesture is being tracked. + pub(super) fn has_scroll_gesture(&self) -> bool { + self.scroll_gesture.is_some() + } + /// Returns whether the active pointer gesture has committed to this axis. pub(super) fn is_pointer_drag_locked(&self) -> bool { self.pointer_gesture @@ -605,6 +614,52 @@ impl CarouselState { ) } + /// Hands a trackpad gesture that began at an edge to an ancestor scroller + /// until it ends or goes quiet. + pub(super) fn defer_scroll_to_ancestor(&mut self, cx: &mut Context) { + self.scroll_gesture = None; + self.invalidate_scroll_settle(); + self.ignore_scroll_until_quiet = true; + self.schedule_ignored_scroll_recovery(cx); + } + + /// Applies one mouse-wheel notch. Line deltas carry no touch phases, so + /// the events within one quiet period form a burst that the first event + /// assigns: a step keeps the whole burst here, while a burst that cannot + /// step belongs to an ancestor scroller. + pub(super) fn handle_wheel_step( + &mut self, + axis: Axis, + delta: Pixels, + cx: &mut Context, + ) -> bool { + if axis != self.axis || delta == px(0.) { + return false; + } + if self.ignore_scroll_until_quiet { + self.schedule_ignored_scroll_recovery(cx); + return false; + } + if self.wheel_burst_active { + self.schedule_wheel_burst_end(cx); + return true; + } + + let stepped = if delta > px(0.) { + self.select_previous(cx) + } else { + self.select_next(cx) + }; + if stepped { + self.wheel_burst_active = true; + self.schedule_wheel_burst_end(cx); + } else { + self.ignore_scroll_until_quiet = true; + self.schedule_ignored_scroll_recovery(cx); + } + stepped + } + fn select_index_with_wrap( &mut self, index: usize, @@ -957,6 +1012,24 @@ impl CarouselState { .detach(); } + fn schedule_wheel_burst_end(&mut self, cx: &mut Context) { + self.wheel_burst_epoch = self.wheel_burst_epoch.wrapping_add(1); + let epoch = self.wheel_burst_epoch; + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(SCROLL_EVENT_SEPARATION) + .await; + if let Some(this) = this.upgrade() { + this.update(cx, |state, _| { + if state.wheel_burst_epoch == epoch { + state.wheel_burst_active = false; + } + }); + } + }) + .detach(); + } + fn invalidate_scroll_settle(&mut self) { self.scroll_settle_epoch = self.scroll_settle_epoch.wrapping_add(1); } diff --git a/website/component/carousel.md b/website/component/carousel.md index b5fbd629ee..95e1332366 100644 --- a/website/component/carousel.md +++ b/website/component/carousel.md @@ -144,7 +144,7 @@ cx.subscribe(&state, |this, _, event: &CarouselEvent, cx| { | --- | --- | | `CarouselEvent::Change(index)` | Emitted when user navigation selects a new item. | -Keyboard navigation and previous/next controls use the same state transition and emit the same event. Pointer and trackpad gestures select the nearest snap point when the gesture ends. +Keyboard navigation and previous/next controls use the same state transition and emit the same event. Pointer and trackpad gestures select the nearest snap point when the gesture ends. A mouse-wheel notch moves one item, and a gesture that begins at an edge scrolls the surrounding container instead. ## Pagination indicators diff --git a/website/zh-CN/component/carousel.md b/website/zh-CN/component/carousel.md index e67793920e..dfe6e62f94 100644 --- a/website/zh-CN/component/carousel.md +++ b/website/zh-CN/component/carousel.md @@ -144,7 +144,7 @@ cx.subscribe(&state, |this, _, event: &CarouselEvent, cx| { | --- | --- | | `CarouselEvent::Change(index)` | 用户导航选中新的内容时触发。 | -键盘导航和上一项/下一项按钮使用同一套 state 状态转换,并触发相同事件。指针和触控板手势结束时,会吸附到最近的 snap 点。 +键盘导航和上一项/下一项按钮使用同一套 state 状态转换,并触发相同事件。指针和触控板手势结束时,会吸附到最近的 snap 点。鼠标滚轮每格移动一项;在边界处开始的手势会交给外层容器滚动。 ## 分页指示器 From 3aad9a038954fa1851c43b8d98d93263dfcc731e Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 9 Sep 2026 17:43:52 +0800 Subject: [PATCH 07/11] carousel: Align items with the content frame and separate the pagination Co-Authored-By: Claude Fable 5.1 --- crates/component/src/carousel/carousel.rs | 76 ++++++++++++++++++---- crates/component/src/carousel/state.rs | 13 +++- crates/story/src/stories/carousel_story.rs | 48 ++++++-------- website/component/carousel.md | 2 +- website/zh-CN/component/carousel.md | 2 +- 5 files changed, 96 insertions(+), 45 deletions(-) diff --git a/crates/component/src/carousel/carousel.rs b/crates/component/src/carousel/carousel.rs index 20caf18c50..8c6b0ce25f 100644 --- a/crates/component/src/carousel/carousel.rs +++ b/crates/component/src/carousel/carousel.rs @@ -92,7 +92,7 @@ impl RenderOnce for Carousel { ); let snapshot = self.state.read(cx); let axis = snapshot.axis(); - let viewport_size = snapshot.viewport_size(); + let frame_size = snapshot.frame_size(); let focus_handle = window .use_keyed_state(("carousel-focus", self.state.entity_id()), cx, |_, cx| { cx.focus_handle() @@ -108,6 +108,9 @@ impl RenderOnce for Carousel { div() .id(self.id) .relative() + .flex() + .flex_col() + .gap_4() .role(Role::Region) .aria_label(self.accessibility_label) .track_focus(&focus_handle.tab_stop(true)) @@ -160,7 +163,7 @@ impl RenderOnce for Carousel { ) .children(self.children) .when(focus_visible, |this| { - this.when_some(viewport_size, |this, size| { + this.when_some(frame_size, |this, size| { this.child( div() .absolute() @@ -182,17 +185,24 @@ impl RenderOnce for Carousel { #[derive(Default, PartialEq)] struct CarouselGeometry { viewport: Bounds, + frame: Bounds, items: Vec>, has_runway: bool, revision: usize, } impl CarouselGeometry { - fn read(state: &CarouselState, has_runway: bool, rendered_item_count: usize) -> Self { + fn read( + state: &CarouselState, + frame: Bounds, + has_runway: bool, + rendered_item_count: usize, + ) -> Self { let handle = state.scroll_handle(); let item_offset = usize::from(has_runway); Self { viewport: handle.bounds(), + frame, items: (0..state.item_count().min(rendered_item_count)) .filter_map(|ix| handle.bounds_for_item(ix + item_offset)) .collect(), @@ -203,6 +213,7 @@ impl CarouselGeometry { fn same_layout(&self, other: &Self) -> bool { self.viewport == other.viewport + && self.frame == other.frame && self.items == other.items && self.has_runway == other.has_runway } @@ -416,16 +427,24 @@ impl RenderOnce for CarouselContent { div() .relative() .w_full() + .flex() + .when(axis.is_horizontal(), |this| this.flex_row()) + .when(axis.is_vertical(), |this| this.flex_col()) .refine_style(&self.style) .overflow_hidden() .child( + // As a flex child the track grows by its negative leading + // margin, so the padded items fill the frame on both edges. div() .id(viewport_id.clone()) - .w_full() - .when(axis.is_vertical(), |this| this.h_full()) + .flex_1() .flex() - .when(axis.is_horizontal(), |this| this.flex_row().ml_neg_4()) - .when(axis.is_vertical(), |this| this.flex_col().mt_neg_4()) + .when(axis.is_horizontal(), |this| { + this.flex_row().min_w_0().ml_neg_4() + }) + .when(axis.is_vertical(), |this| { + this.flex_col().min_h_0().mt_neg_4() + }) .track_scroll(&handle) .when_some(loop_runway, |this, runway| { this.child(runway_spacer(runway)) @@ -437,9 +456,10 @@ impl RenderOnce for CarouselContent { .refine_style(&self.track_style), ) .child(CarouselScrollMask::new(axis, &self.state).id(viewport_id)) - .on_prepaint(move |_, _, cx| { + .on_prepaint(move |frame, _, cx| { let next = CarouselGeometry::read( geometry_state.read(cx), + frame, has_runway, rendered_item_count, ); @@ -447,12 +467,14 @@ impl RenderOnce for CarouselContent { geometry_state.update(cx, |state, _| { state.set_geometry_with_runway( next.viewport, + next.frame, next.items.clone(), next.has_runway, ); }); geometry.update(cx, |current, cx| { current.viewport = next.viewport; + current.frame = next.frame; current.items = next.items; current.has_runway = next.has_runway; current.revision = current.revision.wrapping_add(1); @@ -672,7 +694,7 @@ fn carousel_control( ) -> impl IntoElement { let snapshot = state.read(cx); let axis = snapshot.axis(); - let viewport_size = snapshot.viewport_size(); + let frame_size = snapshot.frame_size(); let disabled = if next { !snapshot.has_next() } else { @@ -695,10 +717,8 @@ fn carousel_control( .absolute() .top_0() .left_0() - .when_some(viewport_size, |this, size| { - this.w(size.width).h(size.height) - }) - .when(viewport_size.is_none(), |this| this.right_0().bottom_0()) + .when_some(frame_size, |this, size| this.w(size.width).h(size.height)) + .when(frame_size.is_none(), |this| this.right_0().bottom_0()) .child( Button::new(id) .outline() @@ -1083,6 +1103,36 @@ mod tests { assert_eq!(outer_actions.get(), 2); } + #[gpui::test] + fn track_grows_by_its_leading_margin_so_items_fill_the_frame(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + for axis in [Axis::Horizontal, Axis::Vertical] { + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3).with_axis(axis))); + let (_, cx) = cx.add_window_view({ + let state = state.clone(); + move |_, _| KeyboardHarness { state } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + let (track, first_item, frame_size) = state.read_with(cx, |state, _| { + let handle = state.scroll_handle(); + ( + handle.bounds(), + handle.bounds_for_item(0).unwrap(), + state.frame_size().unwrap(), + ) + }); + assert_eq!(frame_size, gpui::size(px(100.), px(100.)), "{axis:?}"); + let expected = if axis.is_horizontal() { + Bounds::new(point(px(-16.), px(0.)), gpui::size(px(116.), px(100.))) + } else { + Bounds::new(point(px(0.), px(-16.)), gpui::size(px(100.), px(116.))) + }; + assert_eq!(track, expected, "{axis:?}"); + assert_eq!(first_item, expected, "{axis:?}"); + } + } + #[gpui::test] fn carousel_controls_accept_accessibility_labels_and_children(cx: &mut gpui::TestAppContext) { let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); diff --git a/crates/component/src/carousel/state.rs b/crates/component/src/carousel/state.rs index cccc47334d..398ff1afcf 100644 --- a/crates/component/src/carousel/state.rs +++ b/crates/component/src/carousel/state.rs @@ -25,6 +25,7 @@ pub enum CarouselEvent { #[derive(Clone, Debug, Default, PartialEq)] pub(super) struct CarouselGeometry { viewport: Option>, + frame: Option>, items: Vec>, } @@ -338,25 +339,31 @@ impl CarouselState { self.motion_revision } - pub(super) fn viewport_size(&self) -> Option> { - self.geometry.viewport.map(|viewport| viewport.size) + /// Returns the size of the clipped content frame that controls and the + /// focus ring are positioned around. + pub(super) fn frame_size(&self) -> Option> { + self.geometry.frame.map(|frame| frame.size) } /// Records the viewport and item bounds used for gesture snapping. #[cfg(test)] fn set_geometry(&mut self, viewport: Bounds, items: Vec>) { let has_runway = self.loop_layout.is_some(); - self.set_geometry_with_runway(viewport, items, has_runway); + self.set_geometry_with_runway(viewport, viewport, items, has_runway); } + /// Records the scroll track (`viewport`), the clipped content `frame`, and + /// the item bounds after layout. pub(super) fn set_geometry_with_runway( &mut self, viewport: Bounds, + frame: Bounds, items: Vec>, has_runway: bool, ) { self.geometry = CarouselGeometry { viewport: Some(viewport), + frame: Some(frame), items, }; self.geometry_has_runway = has_runway; diff --git a/crates/story/src/stories/carousel_story.rs b/crates/story/src/stories/carousel_story.rs index c218c8d0b1..6e378f5796 100644 --- a/crates/story/src/stories/carousel_story.rs +++ b/crates/story/src/stories/carousel_story.rs @@ -94,34 +94,28 @@ impl CarouselStory { ) -> impl IntoElement { div() .w_full() + .when(square, |this| this.aspect_square()) .when(!square, |this| this.h_full()) - .p_1() - .child( - div() - .w_full() - .when(square, |this| this.aspect_square()) - .when(!square, |this| this.h_full()) - .flex() - .items_center() - .justify_center() - .p_6() - .rounded(cx.theme().radius_tokens().xl) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .text_color(cx.theme().foreground) - .font_semibold() - .when(matches!(typography, SlideTypography::Large), |this| { - this.text_size(rems(2.25)) - }) - .when(matches!(typography, SlideTypography::Medium), |this| { - this.text_3xl() - }) - .when(matches!(typography, SlideTypography::Small), |this| { - this.text_2xl() - }) - .child(label.into()), - ) + .flex() + .items_center() + .justify_center() + .p_6() + .rounded(cx.theme().radius_tokens().xl) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .font_semibold() + .when(matches!(typography, SlideTypography::Large), |this| { + this.text_size(rems(2.25)) + }) + .when(matches!(typography, SlideTypography::Medium), |this| { + this.text_3xl() + }) + .when(matches!(typography, SlideTypography::Small), |this| { + this.text_2xl() + }) + .child(label.into()) } fn items( diff --git a/website/component/carousel.md b/website/component/carousel.md index 95e1332366..ae6a72b861 100644 --- a/website/component/carousel.md +++ b/website/component/carousel.md @@ -52,7 +52,7 @@ Carousel └── CarouselNext ``` -Constrain the Carousel with `.w_full().max_w_96()` on its root, or style `CarouselContent` when the viewport itself needs a custom width or height. Use `track_style` only for inner-track adjustments such as spacing. +Constrain the Carousel with `.w_full().max_w_96()` on its root, or style `CarouselContent` when the viewport itself needs a custom width or height. Use `track_style` only for inner-track adjustments such as spacing. The root lays out its flow children as a column with a 16px gap, so a `CarouselPagination` placed after the content keeps its distance; restyle the root for another arrangement. ## Sizes diff --git a/website/zh-CN/component/carousel.md b/website/zh-CN/component/carousel.md index dfe6e62f94..6d8f36c779 100644 --- a/website/zh-CN/component/carousel.md +++ b/website/zh-CN/component/carousel.md @@ -52,7 +52,7 @@ Carousel └── CarouselNext ``` -可以在 `Carousel` 根节点上使用 `.w_full().max_w_96()` 约束整个 Carousel;需要单独设置 viewport 的宽度或高度时,可以直接设置 `CarouselContent` 的样式。`track_style` 仅用于间距等内部 track 调整。 +可以在 `Carousel` 根节点上使用 `.w_full().max_w_96()` 约束整个 Carousel;需要单独设置 viewport 的宽度或高度时,可以直接设置 `CarouselContent` 的样式。`track_style` 仅用于间距等内部 track 调整。根节点会把常规子元素按列排布并留出 16px 间距,因此放在内容后面的 `CarouselPagination` 会自然与内容拉开;需要其他排布时直接在根节点上覆盖样式。 ## 尺寸 From 9fc30394518e9bf5436c16e28e833098536b9704 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 9 Sep 2026 17:44:17 +0800 Subject: [PATCH 08/11] carousel: Share one focus handle and keep the ring a keyboard affordance Co-Authored-By: Claude Fable 5.1 --- crates/component/src/carousel/carousel.rs | 161 ++++++++++++++++++++-- crates/component/src/carousel/state.rs | 27 +++- website/component/carousel.md | 2 +- website/zh-CN/component/carousel.md | 2 +- 4 files changed, 174 insertions(+), 18 deletions(-) diff --git a/crates/component/src/carousel/carousel.rs b/crates/component/src/carousel/carousel.rs index 8c6b0ce25f..cf2a83c5ec 100644 --- a/crates/component/src/carousel/carousel.rs +++ b/crates/component/src/carousel/carousel.rs @@ -1,10 +1,11 @@ use std::{panic::Location, sync::Arc}; use gpui::{ - AnyElement, App, Axis, Bounds, Element, ElementId, Entity, GlobalElementId, InspectorElementId, - InteractiveElement as _, IntoElement, LayoutId, ParentElement, Pixels, Point, RenderOnce, Role, - SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window, - div, prelude::FluentBuilder as _, px, + AnyElement, App, Axis, Bounds, ClickEvent, Element, ElementId, Entity, FocusHandle, Focusable, + GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId, + MouseButton, ParentElement, Pixels, Point, RenderOnce, Role, SharedString, + StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, Window, div, + prelude::FluentBuilder as _, px, }; use gpui_base::spring; use rust_i18n::t; @@ -37,6 +38,12 @@ struct CarouselStateObserver { _subscription: Subscription, } +/// Restores the focus ring once the carousel loses focus, so the next +/// keyboard focus draws it again. +struct CarouselFocusOut { + _subscription: Subscription, +} + impl Carousel { /// Creates a Carousel bound to `state`. pub fn new(id: impl Into, state: &Entity) -> Self { @@ -93,13 +100,20 @@ impl RenderOnce for Carousel { let snapshot = self.state.read(cx); let axis = snapshot.axis(); let frame_size = snapshot.frame_size(); - let focus_handle = window - .use_keyed_state(("carousel-focus", self.state.entity_id()), cx, |_, cx| { - cx.focus_handle() - }) - .read(cx) - .clone(); - let focus_visible = focus_handle.is_focused(window) && self.focus_ring_enabled; + let focus_handle = snapshot.focus_handle(cx); + let ring_suppressed = snapshot.is_focus_ring_suppressed(); + let _focus_out = + window.use_keyed_state(("carousel-focus-out", self.state.entity_id()), cx, { + let state = self.state.clone(); + let focus_handle = focus_handle.clone(); + move |window, cx| CarouselFocusOut { + _subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| { + state.update(cx, |state, _| state.suppress_focus_ring(false)); + }), + } + }); + let is_focused = focus_handle.is_focused(window); + let focus_visible = is_focused && !ring_suppressed && self.focus_ring_enabled; let previous_state = self.state.clone(); let next_state = self.state.clone(); let first_state = self.state.clone(); @@ -115,6 +129,16 @@ impl RenderOnce for Carousel { .aria_label(self.accessibility_label) .track_focus(&focus_handle.tab_stop(true)) .key_context(CONTEXT) + .on_mouse_down(MouseButton::Left, { + let state = self.state.clone(); + move |_, window, cx| { + // Runs before GPUI moves focus here. A child such as + // Button that keeps focus has already prevented the default. + if !is_focused && !window.default_prevented() { + state.update(cx, |state, _| state.suppress_focus_ring(true)); + } + } + }) .on_action( window.listener_for(&previous_state, move |state, _: &SelectLeft, _, cx| { let handled = axis.is_horizontal() && state.select_previous(cx); @@ -695,6 +719,7 @@ fn carousel_control( let snapshot = state.read(cx); let axis = snapshot.axis(); let frame_size = snapshot.frame_size(); + let focus_handle = snapshot.focus_handle(cx); let disabled = if next { !snapshot.has_next() } else { @@ -742,7 +767,7 @@ fn carousel_control( this.top_full().mt_4().left_0().right_0().mx_auto() }) .when(!disabled, |this| { - this.on_click(move |_, _, cx| { + this.on_click(move |event, window, cx| { state.update(cx, |state, cx| { if next { state.select_next(cx); @@ -750,6 +775,7 @@ fn carousel_control( state.select_previous(cx); } }); + focus_after_pointer_click(&state, &focus_handle, event, window, cx); }) }) .children(children) @@ -868,6 +894,7 @@ impl RenderOnce for CarouselPaginationItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let selected = self.state.read(cx).selected_index() == Some(self.index); let disabled = self.index >= self.state.read(cx).item_count(); + let focus_handle = self.state.read(cx).focus_handle(cx); let label = self.accessibility_label.unwrap_or_else(|| { t!( "Carousel.go_to_slide", @@ -886,16 +913,34 @@ impl RenderOnce for CarouselPaginationItem { .disabled(disabled) .children(self.children) .when(!disabled, |this| { - this.on_click(move |_, _, cx| { + this.on_click(move |event, window, cx| { state.update(cx, |state, cx| { state.select_index(index, cx); }); + focus_after_pointer_click(&state, &focus_handle, event, window, cx); }) }) .refine_style(&self.style) } } +/// Moves keyboard focus to the carousel after a pointer click on one of its +/// controls, so the arrow keys keep working without drawing the ring. A +/// keyboard activation leaves focus on the control. +fn focus_after_pointer_click( + state: &Entity, + focus_handle: &FocusHandle, + event: &ClickEvent, + window: &mut Window, + cx: &mut App, +) { + if event.is_keyboard() || focus_handle.contains_focused(window, cx) { + return; + } + state.update(cx, |state, _| state.suppress_focus_ring(true)); + window.focus(focus_handle, cx); +} + fn axis_value(point: Point, axis: Axis) -> Pixels { if axis.is_horizontal() { point.x @@ -929,7 +974,7 @@ mod tests { use std::{cell::Cell, rc::Rc}; use super::*; - use gpui::{AppContext as _, Context, Render, point}; + use gpui::{AppContext as _, Context, Render, VisualTestContext, point}; use gpui_base::FocusableExt as _; #[test] @@ -1133,6 +1178,94 @@ mod tests { } } + #[gpui::test] + fn clicking_a_slide_focuses_the_carousel_for_keyboard_navigation( + cx: &mut gpui::TestAppContext, + ) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); + let (_, cx) = cx.add_window_view({ + let state = state.clone(); + move |_, _| KeyboardHarness { state } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert!(cx.update(|window, cx| window.focused(cx).is_none())); + + cx.simulate_click(point(px(50.), px(50.)), gpui::Modifiers::default()); + assert!(cx.update(|window, cx| window.focused(cx).is_some())); + + cx.simulate_keystrokes("right"); + assert_eq!( + state.read_with(cx, |state, _| state.selected_index()), + Some(1) + ); + } + + struct ControlsHarness { + state: Entity, + } + + impl Render for ControlsHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().tab_group().child( + Carousel::new("carousel", &self.state) + .w(px(100.)) + .h(px(100.)) + .child( + CarouselContent::new(&self.state) + .h(px(100.)) + .children((0..3).map(|index| { + CarouselItem::new(("carousel-item", index), index, &self.state) + .child(index.to_string()) + })), + ) + .child(CarouselPrevious::new(&self.state)) + .child(CarouselNext::new(&self.state)), + ) + } + } + + #[gpui::test] + fn clicking_a_control_focuses_the_carousel_for_keyboard_navigation( + cx: &mut gpui::TestAppContext, + ) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|_| CarouselState::new(3))); + let (_, cx) = cx.add_window_view({ + let state = state.clone(); + move |_, _| ControlsHarness { state } + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let selected = + |cx: &mut VisualTestContext| state.read_with(cx, |state, _| state.selected_index()); + let root_focused = |cx: &mut VisualTestContext| { + cx.update(|window, cx| state.read(cx).focus_handle(cx).is_focused(window)) + }; + + // A pointer click on Next moves focus to the carousel. + cx.simulate_click(point(px(134.), px(50.)), gpui::Modifiers::default()); + assert_eq!(selected(cx), Some(1)); + assert!(root_focused(cx)); + cx.simulate_keystrokes("right"); + assert_eq!(selected(cx), Some(2)); + + // Keyboard activation of a control leaves focus on the control. + cx.simulate_keystrokes("left"); + cx.update(|window, cx| window.focus_next(cx)); + cx.update(|window, cx| window.focus_next(cx)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let keystroke = gpui::Keystroke::parse("enter").unwrap(); + cx.simulate_event(gpui::KeyDownEvent { + keystroke: keystroke.clone(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(gpui::KeyUpEvent { keystroke }); + assert_eq!(selected(cx), Some(2)); + assert!(!root_focused(cx)); + assert!(cx.update(|window, cx| window.focused(cx).is_some())); + } + #[gpui::test] fn carousel_controls_accept_accessibility_labels_and_children(cx: &mut gpui::TestAppContext) { let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); diff --git a/crates/component/src/carousel/state.rs b/crates/component/src/carousel/state.rs index 398ff1afcf..6e8e47852b 100644 --- a/crates/component/src/carousel/state.rs +++ b/crates/component/src/carousel/state.rs @@ -1,7 +1,8 @@ -use std::time::Duration; +use std::{cell::OnceCell, time::Duration}; use gpui::{ - Along, Axis, Bounds, Context, EventEmitter, Pixels, Point, ScrollHandle, TouchPhase, px, + Along, App, Axis, Bounds, Context, EventEmitter, FocusHandle, Focusable, Pixels, Point, + ScrollHandle, TouchPhase, px, }; const POINTER_AXIS_LOCK_THRESHOLD: Pixels = px(2.); @@ -80,6 +81,8 @@ pub struct CarouselState { loop_motion_target: Option>, wheel_burst_active: bool, wheel_burst_epoch: usize, + focus_handle: OnceCell, + focus_ring_suppressed: bool, } impl CarouselState { @@ -105,6 +108,8 @@ impl CarouselState { loop_motion_target: None, wheel_burst_active: false, wheel_burst_epoch: 0, + focus_handle: OnceCell::new(), + focus_ring_suppressed: false, } } @@ -304,6 +309,16 @@ impl CarouselState { &self.scroll_handle } + /// Hides or restores the focus ring. Focus that arrives through the + /// pointer keeps the arrow keys working without drawing the ring. + pub(super) fn suppress_focus_ring(&mut self, suppressed: bool) { + self.focus_ring_suppressed = suppressed; + } + + pub(super) fn is_focus_ring_suppressed(&self) -> bool { + self.focus_ring_suppressed + } + /// Returns whether pointer or trackpad input is currently active. pub(super) fn is_interacting(&self) -> bool { self.pointer_gesture.is_some() || self.scroll_gesture.is_some() @@ -1245,6 +1260,14 @@ impl CarouselState { impl EventEmitter for CarouselState {} +impl Focusable for CarouselState { + /// The keyboard focus shared by every part: the root tracks it, and a + /// clicked control moves focus here so the arrow keys keep working. + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.focus_handle.get_or_init(|| cx.focus_handle()).clone() + } +} + #[cfg(test)] mod tests { use std::{cell::RefCell, rc::Rc}; diff --git a/website/component/carousel.md b/website/component/carousel.md index ae6a72b861..3681a3034b 100644 --- a/website/component/carousel.md +++ b/website/component/carousel.md @@ -106,7 +106,7 @@ let state = cx.new(|_| { Horizontal carousels use Left and Right. Vertical carousels use Up and Down. Give vertical `CarouselContent` an explicit height so each full-height item has a viewport to snap within. -The Carousel root is a tab stop, so keyboard navigation also works when optional controls are omitted. Home and End select the first and last items. +The Carousel root is a tab stop, so keyboard navigation also works when optional controls are omitted. Home and End select the first and last items. Clicking inside the carousel or on one of its controls focuses it for keyboard navigation without drawing the focus ring; the ring appears only when focus arrives from the keyboard. ## Looping diff --git a/website/zh-CN/component/carousel.md b/website/zh-CN/component/carousel.md index 6d8f36c779..d18191d50e 100644 --- a/website/zh-CN/component/carousel.md +++ b/website/zh-CN/component/carousel.md @@ -106,7 +106,7 @@ let state = cx.new(|_| { 横向 Carousel 使用 Left 和 Right,纵向 Carousel 使用 Up 和 Down。 纵向 `CarouselContent` 需要设置明确的高度,让每个全高 item 都有可供吸附的 viewport。 -Carousel 根节点可通过 Tab 获得焦点,因此省略可选控制按钮时仍可使用键盘导航。Home 和 End 用于选择第一项和最后一项。 +Carousel 根节点可通过 Tab 获得焦点,因此省略可选控制按钮时仍可使用键盘导航。Home 和 End 用于选择第一项和最后一项。点击 Carousel 内部或它的控制按钮同样会让它获得焦点以便键盘导航,但不会显示焦点环;焦点环只在通过键盘聚焦时出现。 ## 循环 From c5b73c0b5212944d2bc310498aac7fa3ec93dce3 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 9 Sep 2026 13:50:35 +0800 Subject: [PATCH 09/11] carousel: Reject shell indices that round up to `usize::MAX` Co-Authored-By: Claude Fable 5.1 --- crates/component-shell/src/shell/carousel.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/component-shell/src/shell/carousel.rs b/crates/component-shell/src/shell/carousel.rs index b96e3be439..7eb76eb0f4 100644 --- a/crates/component-shell/src/shell/carousel.rs +++ b/crates/component-shell/src/shell/carousel.rs @@ -228,7 +228,8 @@ fn nonnegative_usize(argument: &ComponentArgument, callable: &str) -> Result= 0. && value.fract() == 0. - && *value <= usize::MAX as f64 => + // `usize::MAX as f64` rounds up to 2^64 on 64-bit targets. + && *value < usize::MAX as f64 => { Ok(*value as usize) } @@ -567,5 +568,12 @@ mod tests { ); assert!(nonnegative_usize(&ComponentArgument::Number(-1.), "CarouselItem").is_err()); assert!(nonnegative_usize(&ComponentArgument::Number(1.5), "CarouselItem").is_err()); + assert!( + nonnegative_usize( + &ComponentArgument::Number(usize::MAX as f64), + "CarouselItem" + ) + .is_err() + ); } } From 81b47072f578fc98e9d663be0fd478c86b9eaa39 Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 9 Sep 2026 21:38:31 +0800 Subject: [PATCH 10/11] carousel: Name the accessibility label like its siblings, and let scripts own the selection Co-Authored-By: Claude Fable 5.1 --- crates/component-shell/src/shell/carousel.rs | 174 +++++++++++++++++-- crates/component/src/carousel/carousel.rs | 16 +- crates/story/src/stories/carousel_story.rs | 4 +- examples/js_story/stories/registered.js | 1 + website/component/carousel.md | 8 +- website/zh-CN/component/carousel.md | 8 +- 6 files changed, 175 insertions(+), 36 deletions(-) diff --git a/crates/component-shell/src/shell/carousel.rs b/crates/component-shell/src/shell/carousel.rs index 7eb76eb0f4..96c57eb6e0 100644 --- a/crates/component-shell/src/shell/carousel.rs +++ b/crates/component-shell/src/shell/carousel.rs @@ -56,6 +56,8 @@ enum Op { AccessibilityLabel(String), FocusRing(bool), Size(Size), + SelectedIndex(usize), + ItemCount(usize), OnChange(ComponentArgument), } @@ -85,12 +87,28 @@ impl ComponentMaterializer for Materializer { .next_back() .map(|argument| request.resolve_callback(argument)) .transpose()?; + let selected_index = operations + .iter() + .filter_map(|operation| match operation { + Op::SelectedIndex(index) => Some(*index), + _ => None, + }) + .next_back(); + let item_count = operations + .iter() + .filter_map(|operation| match operation { + Op::ItemCount(count) => Some(*count), + _ => None, + }) + .next_back(); let mut carousel = Carousel::new(id.clone(), &state); for operation in operations { carousel = match operation { - Op::AccessibilityLabel(value) => carousel.with_accessibility_label(value), + Op::AccessibilityLabel(value) => carousel.accessibility_label(value), Op::FocusRing(value) => carousel.focus_ring(value), - Op::Size(_) | Op::OnChange(_) => carousel, + Op::Size(_) | Op::SelectedIndex(_) | Op::ItemCount(_) | Op::OnChange(_) => { + carousel + } }; } let child = finish_typed_children( @@ -108,6 +126,8 @@ impl ComponentMaterializer for Materializer { id, state, callback, + selected_index, + item_count, child, } .into_any_element()) @@ -126,7 +146,7 @@ impl ComponentMaterializer for Materializer { let mut item = CarouselItem::new(id, index, &state); for operation in operations { if let Op::AccessibilityLabel(value) = operation { - item = item.with_accessibility_label(value); + item = item.accessibility_label(value); } } finish_part(&mut request, item) @@ -136,9 +156,12 @@ impl ComponentMaterializer for Materializer { let mut previous = CarouselPrevious::new(&state); for operation in operations { previous = match operation { - Op::AccessibilityLabel(value) => previous.with_accessibility_label(value), + Op::AccessibilityLabel(value) => previous.accessibility_label(value), Op::Size(value) => previous.with_size(value), - Op::FocusRing(_) | Op::OnChange(_) => previous, + Op::FocusRing(_) + | Op::SelectedIndex(_) + | Op::ItemCount(_) + | Op::OnChange(_) => previous, }; } finish_part(&mut request, previous) @@ -148,26 +171,42 @@ impl ComponentMaterializer for Materializer { let mut next = CarouselNext::new(&state); for operation in operations { next = match operation { - Op::AccessibilityLabel(value) => next.with_accessibility_label(value), + Op::AccessibilityLabel(value) => next.accessibility_label(value), Op::Size(value) => next.with_size(value), - Op::FocusRing(_) | Op::OnChange(_) => next, + Op::FocusRing(_) + | Op::SelectedIndex(_) + | Op::ItemCount(_) + | Op::OnChange(_) => next, }; } finish_part(&mut request, next) } - Payload::Pagination => finish_typed_children( - &mut request, - CarouselPagination::new(), - "CarouselPagination", - &["CarouselPaginationItem"], - ), + Payload::Pagination => { + let mut pagination = CarouselPagination::new(); + for operation in operations { + if let Op::AccessibilityLabel(value) = operation { + pagination = pagination.accessibility_label(value); + } + } + finish_typed_children( + &mut request, + pagination, + "CarouselPagination", + &["CarouselPaginationItem"], + ) + } Payload::PaginationItem { id, index, state } => { let state = request.with_state::, _>(&state, Clone::clone)?; let mut item = CarouselPaginationItem::new(id, index, &state); for operation in operations { - if let Op::Size(value) = operation { - item = item.with_size(value); - } + item = match operation { + Op::AccessibilityLabel(value) => item.accessibility_label(value), + Op::Size(value) => item.with_size(value), + Op::FocusRing(_) + | Op::SelectedIndex(_) + | Op::ItemCount(_) + | Op::OnChange(_) => item, + }; } finish_part(&mut request, item) } @@ -185,11 +224,32 @@ struct BoundCarousel { id: String, state: Entity, callback: Option, + selected_index: Option, + item_count: Option, child: gpui::AnyElement, } impl RenderOnce for BoundCarousel { fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { + // A script that passes `item_count` or `selected_index` owns that + // value, so every frame reasserts it. Both setters are silent and only + // run when the value actually differs, which keeps a gesture in + // progress from being cancelled and cannot loop through `on_change`. + if let Some(count) = self.item_count.filter(|count| { + let state = self.state.read(cx); + state.item_count() != *count + }) { + self.state + .update(cx, |state, cx| state.set_item_count(count, cx)); + } + if let Some(index) = self.selected_index.filter(|index| { + let state = self.state.read(cx); + state.item_count() > 0 && state.selected_index() != Some(*index) + }) { + self.state + .update(cx, |state, cx| state.set_selected_index(index, cx)); + } + let initial_callback = self.callback.clone(); let state = self.state.clone(); let host: Entity = window.use_keyed_state( @@ -327,6 +387,26 @@ fn accessibility_label_method(component: &'static str) -> MethodDescriptor { ) } +fn usize_method( + component: &'static str, + name: &'static str, + documentation: &'static str, + make: fn(usize) -> Op, +) -> MethodDescriptor { + MethodDescriptor::new( + name, + vec![ArgumentDescriptor::new("value", ArgumentSchema::Number)], + move |arguments| match arguments { + [argument] => Ok(ComponentPayload::new(make(nonnegative_usize( + argument, + &format!("{component}.{name}(value)"), + )?))), + _ => Err(format!("{component}.{name}(value) expects one number")), + }, + ) + .with_documentation(documentation) +} + fn size_method(component: &'static str) -> MethodDescriptor { MethodDescriptor::new( "size", @@ -443,6 +523,18 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE "Controls whether keyboard focus draws a focus ring.", Op::FocusRing, ), + usize_method( + "Carousel", + "item_count", + "Sets the number of logical items the state tracks.", + Op::ItemCount, + ), + usize_method( + "Carousel", + "selected_index", + "Selects an item without emitting a change event, for a script that owns the selection.", + Op::SelectedIndex, + ), MethodDescriptor::new( "on_change", vec![ArgumentDescriptor::new( @@ -499,7 +591,7 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE ConstructorDescriptor::new("CarouselPagination", vec![], |_| { Ok(ComponentPayload::new(Payload::Pagination)) }), - vec![], + vec![accessibility_label_method("CarouselPagination")], "A container that accepts only CarouselPaginationItem children.", ))?; registry.register(descriptor( @@ -507,7 +599,10 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE indexed_state_constructor("CarouselPaginationItem", |id, index, state| { Payload::PaginationItem { id, index, state } }), - vec![size_method("CarouselPaginationItem")], + vec![ + accessibility_label_method("CarouselPaginationItem"), + size_method("CarouselPaginationItem"), + ], "One indexed Carousel pagination control.", ))?; @@ -558,6 +653,49 @@ mod tests { })); } + #[test] + fn every_part_exposes_its_scriptable_surface() { + let mut registry = + ComponentRegistry::new(COMPONENT_REGISTRY_API_VERSION, DEFAULT_COMPONENT_MODULE) + .unwrap(); + register(&mut registry).unwrap(); + let frozen = registry.freeze().unwrap(); + let methods = |component: &str| { + frozen + .descriptors() + .find(|descriptor| descriptor.name() == component) + .map(|descriptor| { + descriptor + .methods() + .iter() + .map(|method| method.name().to_owned()) + .collect::>() + }) + .unwrap() + }; + + // A script owns the selection and the item count through the root. + let root = methods("Carousel"); + assert!(root.contains(&"item_count".to_owned()), "{root:?}"); + assert!(root.contains(&"selected_index".to_owned()), "{root:?}"); + + // Every part whose Rust builder takes an accessibility label exposes it. + for component in [ + "Carousel", + "CarouselItem", + "CarouselPrevious", + "CarouselNext", + "CarouselPagination", + "CarouselPaginationItem", + ] { + let names = methods(component); + assert!( + names.contains(&"accessibility_label".to_owned()), + "{component}: {names:?}" + ); + } + } + #[test] fn identifiers_and_indices_are_closed() { assert!(nonempty_id(&ComponentArgument::String("carousel".into()), "Carousel").is_ok()); diff --git a/crates/component/src/carousel/carousel.rs b/crates/component/src/carousel/carousel.rs index cf2a83c5ec..26488ccd6d 100644 --- a/crates/component/src/carousel/carousel.rs +++ b/crates/component/src/carousel/carousel.rs @@ -58,7 +58,7 @@ impl Carousel { } /// Sets the name announced for the carousel region. - pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + pub fn accessibility_label(mut self, label: impl Into) -> Self { self.accessibility_label = label.into(); self } @@ -535,7 +535,7 @@ impl CarouselItem { } /// Replaces the generated "Slide N of M" accessibility label. - pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + pub fn accessibility_label(mut self, label: impl Into) -> Self { self.accessibility_label = Some(label.into()); self } @@ -606,7 +606,7 @@ impl CarouselPrevious { } /// Replaces the generated previous-slide accessibility label and tooltip. - pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + pub fn accessibility_label(mut self, label: impl Into) -> Self { self.accessibility_label = Some(label.into()); self } @@ -668,7 +668,7 @@ impl CarouselNext { } /// Replaces the generated next-slide accessibility label and tooltip. - pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + pub fn accessibility_label(mut self, label: impl Into) -> Self { self.accessibility_label = Some(label.into()); self } @@ -805,7 +805,7 @@ impl CarouselPagination { } /// Sets the name announced for the pagination group. - pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + pub fn accessibility_label(mut self, label: impl Into) -> Self { self.accessibility_label = label.into(); self } @@ -865,7 +865,7 @@ impl CarouselPaginationItem { } /// Replaces the generated "Go to slide N" accessibility label. - pub fn with_accessibility_label(mut self, label: impl Into) -> Self { + pub fn accessibility_label(mut self, label: impl Into) -> Self { self.accessibility_label = Some(label.into()); self } @@ -1270,10 +1270,10 @@ mod tests { fn carousel_controls_accept_accessibility_labels_and_children(cx: &mut gpui::TestAppContext) { let state = cx.update(|cx| cx.new(|_| CarouselState::new(2))); let previous = CarouselPrevious::new(&state) - .with_accessibility_label("Previous project") + .accessibility_label("Previous project") .child("Back"); let next = CarouselNext::new(&state) - .with_accessibility_label("Next project") + .accessibility_label("Next project") .child("Forward"); assert_eq!( diff --git a/crates/story/src/stories/carousel_story.rs b/crates/story/src/stories/carousel_story.rs index 6e378f5796..db452b866a 100644 --- a/crates/story/src/stories/carousel_story.rs +++ b/crates/story/src/stories/carousel_story.rs @@ -218,13 +218,13 @@ impl Render for CarouselStory { .child( CarouselPrevious::new(&self.custom_controls) .with_size(self.size) - .with_accessibility_label("Previous project") + .accessibility_label("Previous project") .child("Back"), ) .child( CarouselNext::new(&self.custom_controls) .with_size(self.size) - .with_accessibility_label("Next project") + .accessibility_label("Next project") .child("Forward"), ), ), diff --git a/examples/js_story/stories/registered.js b/examples/js_story/stories/registered.js index 23b71bc8c6..2ed7370547 100644 --- a/examples/js_story/stories/registered.js +++ b/examples/js_story/stories/registered.js @@ -1399,6 +1399,7 @@ export function registeredExamples(surface, cx) { new Carousel("story-carousel", carouselState) .w(384) .max_w_full() + .selected_index(/** @type {number} */ (state("carousel-index", 0))) .on_change((index, cx) => setState("carousel-index", index, cx)) .child( new CarouselContent(carouselState) diff --git a/website/component/carousel.md b/website/component/carousel.md index 3681a3034b..1a44dc6205 100644 --- a/website/component/carousel.md +++ b/website/component/carousel.md @@ -174,17 +174,17 @@ Previous and next controls default to `Size::Medium`. Pagination items default t ## Custom controls -`CarouselPrevious` and `CarouselNext` implement `ParentElement` and `Styled`. Without children they display the direction-appropriate chevron. Add a child to replace that visible content while preserving automatic navigation and disabled boundary states. `with_accessibility_label` also replaces the control's tooltip. +`CarouselPrevious` and `CarouselNext` implement `ParentElement` and `Styled`. Without children they display the direction-appropriate chevron. Add a child to replace that visible content while preserving automatic navigation and disabled boundary states. `accessibility_label` also replaces the control's tooltip. ```rust use gpui_kit::ParentElement as _; CarouselPrevious::new(&state) - .with_accessibility_label("Previous project") + .accessibility_label("Previous project") .child("Back"); CarouselNext::new(&state) - .with_accessibility_label("Next project") + .accessibility_label("Next project") .child("Forward"); ``` @@ -209,6 +209,6 @@ Button::new("projects-previous") ## Accessibility -The carousel exposes a labelled region and each item reports its position within the set. Use `with_accessibility_label` when the default "Carousel" label does not describe the content. +The carousel exposes a labelled region and each item reports its position within the set. Use `accessibility_label` when the default "Carousel" label does not describe the content. Carousel animation follows the application's reduced-motion preference. diff --git a/website/zh-CN/component/carousel.md b/website/zh-CN/component/carousel.md index d18191d50e..fcda28c74f 100644 --- a/website/zh-CN/component/carousel.md +++ b/website/zh-CN/component/carousel.md @@ -174,17 +174,17 @@ CarouselNext::new(&state).with_size(Size::Large); ## 自定义控制按钮 -`CarouselPrevious` 和 `CarouselNext` 实现了 `ParentElement` 与 `Styled`。没有子元素时,它们会根据方向显示对应的箭头;添加子元素后,可以替换可见内容,同时保留自动导航和边界禁用状态。`with_accessibility_label` 也会同步替换控件的 tooltip。 +`CarouselPrevious` 和 `CarouselNext` 实现了 `ParentElement` 与 `Styled`。没有子元素时,它们会根据方向显示对应的箭头;添加子元素后,可以替换可见内容,同时保留自动导航和边界禁用状态。`accessibility_label` 也会同步替换控件的 tooltip。 ```rust use gpui_kit::ParentElement as _; CarouselPrevious::new(&state) - .with_accessibility_label("上一个项目") + .accessibility_label("上一个项目") .child("返回"); CarouselNext::new(&state) - .with_accessibility_label("下一个项目") + .accessibility_label("下一个项目") .child("继续"); ``` @@ -209,6 +209,6 @@ Button::new("projects-previous") ## 无障碍 -Carousel 会提供带 label 的区域,每个 item 会报告自己在内容集合中的位置。当默认的“轮播”无法准确描述内容时,使用 `with_accessibility_label` 设置更明确的名称。 +Carousel 会提供带 label 的区域,每个 item 会报告自己在内容集合中的位置。当默认的“轮播”无法准确描述内容时,使用 `accessibility_label` 设置更明确的名称。 Carousel 动画会遵循应用的减少动效设置。 From b3848ec16b1a366be343ee548cf86411229e161b Mon Sep 17 00:00:00 2001 From: Floyd Wang Date: Wed, 9 Sep 2026 22:02:21 +0800 Subject: [PATCH 11/11] carousel: Fold the redundant story and doc examples together Co-Authored-By: Claude Fable 5.1 --- crates/story/src/stories/carousel_story.rs | 73 +++------------------- website/component/carousel.md | 24 ++----- website/zh-CN/component/carousel.md | 24 ++----- 3 files changed, 16 insertions(+), 105 deletions(-) diff --git a/crates/story/src/stories/carousel_story.rs b/crates/story/src/stories/carousel_story.rs index db452b866a..488ffc5e87 100644 --- a/crates/story/src/stories/carousel_story.rs +++ b/crates/story/src/stories/carousel_story.rs @@ -24,11 +24,9 @@ pub struct CarouselStory { horizontal: Entity, custom_controls: Entity, multiple: Entity, - spacing: Entity, vertical: Entity, looped: Entity, controlled: Entity, - keyboard: Entity, controlled_index: usize, size: Size, _subscriptions: Vec, @@ -54,11 +52,9 @@ impl CarouselStory { let horizontal = cx.new(|_| CarouselState::new(3)); let custom_controls = cx.new(|_| CarouselState::new(3)); let multiple = cx.new(|_| CarouselState::new(5)); - let spacing = cx.new(|_| CarouselState::new(5)); let vertical = cx.new(|_| CarouselState::new(3).with_axis(Axis::Vertical)); let looped = cx.new(|_| CarouselState::new(4).with_looping(true)); let controlled = cx.new(|_| CarouselState::new(3).with_selected_index(1)); - let keyboard = cx.new(|_| CarouselState::new(3)); let subscription = cx.subscribe( &controlled, @@ -74,11 +70,9 @@ impl CarouselStory { horizontal, custom_controls, multiple, - spacing, vertical, looped, controlled, - keyboard, controlled_index: 1, size: Size::default(), _subscriptions: vec![subscription], @@ -230,58 +224,31 @@ impl Render for CarouselStory { ), ) .child( - section("Sizes") + section("Multiple items") .description( - "Set a fractional flex basis on each item to show several at once.", + "A fractional flex basis shows several items at once. Pair the content's negative margin with matching item padding to tune the gap between them.", ) .v_flex() .gap_3() .child( Carousel::new("carousel-multiple", &self.multiple) - .w_full() - .max_w_96() - .mx_auto() - .child(Self::items_with( - &self.multiple, - "Multiple", - 5, - SlideTypography::Medium, - true, - |item| item.flex_basis(relative(1. / 3.)), - cx, - )) - .child(CarouselPrevious::new(&self.multiple).with_size(self.size)) - .child(CarouselNext::new(&self.multiple).with_size(self.size)), - ), - ) - .child( - section("Spacing") - .description( - "Pair the content's negative margin with matching item padding.", - ) - .v_flex() - .gap_3() - .child( - Carousel::new("carousel-spacing", &self.spacing) .w_full() .max_w_96() .mx_auto() .child( Self::items_with( - &self.spacing, - "Spacing", + &self.multiple, + "Multiple", 5, SlideTypography::Small, true, - |item| { - item.flex_basis(relative(1. / 3.)).pl_1() - }, + |item| item.flex_basis(relative(1. / 3.)).pl_1(), cx, ) .track_style(StyleRefinement::default().ml_neg_1()), ) - .child(CarouselPrevious::new(&self.spacing).with_size(self.size)) - .child(CarouselNext::new(&self.spacing).with_size(self.size)), + .child(CarouselPrevious::new(&self.multiple).with_size(self.size)) + .child(CarouselNext::new(&self.multiple).with_size(self.size)), ), ) .child( @@ -333,7 +300,7 @@ impl Render for CarouselStory { ), ) .child( - section("Controlled / Programmatic") + section("Controlled") .description("The selected index is owned by application state and can be changed programmatically.") .v_flex() .gap_3() @@ -383,29 +350,5 @@ impl Render for CarouselStory { ), ), ) - .child( - section("Keyboard navigation") - .description( - "Tab to the Carousel or either navigation button, then use Left, Right, Home, and End.", - ) - .v_flex() - .gap_3() - .child( - Carousel::new("carousel-keyboard", &self.keyboard) - .w_full() - .max_w_96() - .mx_auto() - .child(Self::items( - &self.keyboard, - "Keyboard", - 3, - SlideTypography::Large, - true, - cx, - )) - .child(CarouselPrevious::new(&self.keyboard).with_size(self.size)) - .child(CarouselNext::new(&self.keyboard).with_size(self.size)), - ), - ) } } diff --git a/website/component/carousel.md b/website/component/carousel.md index 1a44dc6205..b51a58a535 100644 --- a/website/component/carousel.md +++ b/website/component/carousel.md @@ -54,27 +54,9 @@ Carousel Constrain the Carousel with `.w_full().max_w_96()` on its root, or style `CarouselContent` when the viewport itself needs a custom width or height. Use `track_style` only for inner-track adjustments such as spacing. The root lays out its flow children as a column with a 16px gap, so a `CarouselPagination` placed after the content keeps its distance; restyle the root for another arrangement. -## Sizes +## Multiple items -`CarouselItem` implements `Styled`. Set its flex basis to show more than one item in the viewport: - -```rust -use gpui_kit::{ParentElement as _, Styled as _, relative}; - -let state = cx.new(|_| CarouselState::new(6)); - -CarouselContent::new(&state).children((0..6).map(|index| { - CarouselItem::new(("project", index), index, &state) - .flex_basis(relative(1. / 3.)) - .child(format!("Project {}", index + 1)) -})) -``` - -The flex basis controls item geometry; it is separate from the semantic `Size` used by buttons and other controls. - -## Spacing - -Carousel follows the same paired spacing model as shadcn/ui: apply a negative leading margin through `CarouselContent::track_style` and matching leading padding to every `CarouselItem`. +`CarouselItem` implements `Styled`. Set its flex basis to show more than one item in the viewport, and pair a negative leading margin on the content track with matching leading padding on every item to tune the gap between them. This is the same paired spacing model shadcn/ui uses. ```rust use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _, relative}; @@ -91,6 +73,8 @@ CarouselContent::new(&state) })) ``` +The flex basis controls item geometry; it is separate from the semantic `Size` used by buttons and other controls. + Horizontal carousels default to `.ml_neg_4()` on the content track and `.pl_4()` on items. Vertical carousels use the corresponding `.mt_neg_4()` and `.pt_4()` pair. Override both sides with the same spacing scale so the first item stays aligned with the viewport while the visual gap changes. ## Orientation diff --git a/website/zh-CN/component/carousel.md b/website/zh-CN/component/carousel.md index fcda28c74f..2079285ac4 100644 --- a/website/zh-CN/component/carousel.md +++ b/website/zh-CN/component/carousel.md @@ -54,27 +54,9 @@ Carousel 可以在 `Carousel` 根节点上使用 `.w_full().max_w_96()` 约束整个 Carousel;需要单独设置 viewport 的宽度或高度时,可以直接设置 `CarouselContent` 的样式。`track_style` 仅用于间距等内部 track 调整。根节点会把常规子元素按列排布并留出 16px 间距,因此放在内容后面的 `CarouselPagination` 会自然与内容拉开;需要其他排布时直接在根节点上覆盖样式。 -## 尺寸 +## 每屏多个 item -`CarouselItem` 实现了 `Styled`。设置 flex basis 可以在 viewport 中同时显示多个 item: - -```rust -use gpui_kit::{ParentElement as _, Styled as _, relative}; - -let state = cx.new(|_| CarouselState::new(6)); - -CarouselContent::new(&state).children((0..6).map(|index| { - CarouselItem::new(("project", index), index, &state) - .flex_basis(relative(1. / 3.)) - .child(format!("项目 {}", index + 1)) -})) -``` - -flex basis 控制的是 item 几何尺寸,与按钮等控件使用的语义 `Size` 相互独立。 - -## 间距 - -Carousel 采用与 shadcn/ui 相同的成对间距模型:通过 `CarouselContent::track_style` 设置负的起始 margin,并为每个 `CarouselItem` 设置数值相同的起始 padding。 +`CarouselItem` 实现了 `Styled`。设置 flex basis 可以在 viewport 中同时显示多个 item;再通过 `CarouselContent::track_style` 设置负的起始 margin,并为每个 item 设置数值相同的起始 padding,即可调整它们之间的间距。这与 shadcn/ui 采用的成对间距模型一致。 ```rust use gpui_kit::{ParentElement as _, StyleRefinement, Styled as _, relative}; @@ -91,6 +73,8 @@ CarouselContent::new(&state) })) ``` +flex basis 控制的是 item 几何尺寸,与按钮等控件使用的语义 `Size` 相互独立。 + 横向 Carousel 默认在 content track 上使用 `.ml_neg_4()`,在 item 上使用 `.pl_4()`;纵向 Carousel 使用对应的 `.mt_neg_4()` 与 `.pt_4()`。覆盖间距时应同步修改两侧,并使用相同的 spacing scale,这样首个 item 会继续与 viewport 对齐,同时改变可见间距。 ## 方向