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..96c57eb6e0 --- /dev/null +++ b/crates/component-shell/src/shell/carousel.rs @@ -0,0 +1,717 @@ +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), + SelectedIndex(usize), + ItemCount(usize), + 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 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.accessibility_label(value), + Op::FocusRing(value) => carousel.focus_ring(value), + Op::Size(_) | Op::SelectedIndex(_) | Op::ItemCount(_) | Op::OnChange(_) => { + carousel + } + }; + } + let child = finish_typed_children( + &mut request, + carousel, + "Carousel", + &[ + "CarouselContent", + "CarouselPrevious", + "CarouselNext", + "CarouselPagination", + ], + )?; + Ok(BoundCarousel { + id, + state, + callback, + selected_index, + item_count, + 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.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.accessibility_label(value), + Op::Size(value) => previous.with_size(value), + Op::FocusRing(_) + | Op::SelectedIndex(_) + | Op::ItemCount(_) + | 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.accessibility_label(value), + Op::Size(value) => next.with_size(value), + Op::FocusRing(_) + | Op::SelectedIndex(_) + | Op::ItemCount(_) + | Op::OnChange(_) => next, + }; + } + finish_part(&mut request, next) + } + 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 { + 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) + } + } + } +} + +struct ChangeHost { + callback: Rc>>, + _change: Subscription, +} + +#[derive(gpui::IntoElement)] +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( + 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. + // `usize::MAX as f64` rounds up to 2^64 on 64-bit targets. + && *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 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", + 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, + ), + 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( + "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![accessibility_label_method("CarouselPagination")], + "A container that accepts only CarouselPaginationItem children.", + ))?; + registry.register(descriptor( + "CarouselPaginationItem", + indexed_state_constructor("CarouselPaginationItem", |id, index, state| { + Payload::PaginationItem { id, index, state } + }), + vec![ + accessibility_label_method("CarouselPaginationItem"), + 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 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()); + 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()); + assert!( + nonnegative_usize( + &ComponentArgument::Number(usize::MAX as f64), + "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/crates/component/locales/ui.yml b/crates/component/locales/ui.yml index 5b9e1ef3bb..87cf77e474 100644 --- a/crates/component/locales/ui.yml +++ b/crates/component/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/component/src/carousel/carousel.rs b/crates/component/src/carousel/carousel.rs new file mode 100644 index 0000000000..26488ccd6d --- /dev/null +++ b/crates/component/src/carousel/carousel.rs @@ -0,0 +1,1287 @@ +use std::{panic::Location, sync::Arc}; + +use gpui::{ + 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; + +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, + theme::ActiveTheme as _, +}; + +/// 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, + focus_ring_enabled: bool, + children: Vec, +} + +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 { + Self { + id: id.into(), + state: state.clone(), + style: StyleRefinement::default(), + accessibility_label: t!("Carousel.label").into(), + focus_ring_enabled: true, + children: Vec::new(), + } + } + + /// Sets the name announced for the carousel region. + pub fn 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 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(); + 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 snapshot = self.state.read(cx); + let axis = snapshot.axis(); + let frame_size = snapshot.frame_size(); + 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(); + let last_state = self.state.clone(); + + 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)) + .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); + if !handled { + cx.propagate(); + } + }), + ) + .on_action( + window.listener_for(&next_state, move |state, _: &SelectRight, _, cx| { + let handled = axis.is_horizontal() && state.select_next(cx); + if !handled { + cx.propagate(); + } + }), + ) + .on_action( + window.listener_for(&previous_state, move |state, _: &SelectUp, _, cx| { + let handled = axis.is_vertical() && state.select_previous(cx); + if !handled { + cx.propagate(); + } + }), + ) + .on_action( + window.listener_for(&next_state, move |state, _: &SelectDown, _, cx| { + 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.propagate(); + } + }), + ) + .on_action( + window.listener_for(&last_state, |state, _: &SelectLast, _, cx| { + if !state.select_last(cx) { + cx.propagate(); + } + }), + ) + .children(self.children) + .when(focus_visible, |this| { + this.when_some(frame_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) + } +} + +#[derive(Default, PartialEq)] +struct CarouselGeometry { + viewport: Bounds, + frame: Bounds, + items: Vec>, + has_runway: bool, + revision: usize, +} + +impl CarouselGeometry { + 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(), + has_runway, + revision: 0, + } + } + + 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 + } +} + +/// The clipped viewport and snap track for Carousel items. +#[derive(IntoElement)] +pub struct CarouselContent { + state: Entity, + style: StyleRefinement, + track_style: StyleRefinement, + children: Vec, +} + +impl CarouselContent { + /// Creates content bound to `state`. + pub fn new(state: &Entity) -> Self { + 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 { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Styled for CarouselContent { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +/// 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(); + 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 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( + 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 = 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 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), + 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); + 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() + .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()) + .flex_1() + .flex() + .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)) + }) + .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 |frame, _, cx| { + let next = CarouselGeometry::read( + geometry_state.read(cx), + frame, + has_runway, + rendered_item_count, + ); + if !geometry.read(cx).same_layout(&next) { + 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); + 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 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().pl_4()) + .when(axis.is_vertical(), |this| this.h_full().pt_4()) + .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, + accessibility_label: Option, + children: Vec, +} + +impl CarouselPrevious { + /// Creates a previous-slide control bound to `state`. + pub fn new(state: &Entity) -> Self { + Self { + 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 accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } +} + +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 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, + self.accessibility_label, + self.children, + false, + cx, + ) + } +} + +/// A next-slide control positioned around the Carousel viewport. +#[derive(IntoElement)] +pub struct CarouselNext { + state: Entity, + size: Size, + style: StyleRefinement, + accessibility_label: Option, + children: Vec, +} + +impl CarouselNext { + /// Creates a next-slide control bound to `state`. + pub fn new(state: &Entity) -> Self { + Self { + 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 accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } +} + +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 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, + self.accessibility_label, + self.children, + true, + cx, + ) + } +} + +fn carousel_control( + state: Entity, + size: Size, + style: StyleRefinement, + accessibility_label: Option, + children: Vec, + next: bool, + cx: &mut App, +) -> impl IntoElement { + 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 { + !snapshot.has_previous() + }; + 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(), + ); + + div() + .absolute() + .top_0() + .left_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() + .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 |event, window, cx| { + state.update(cx, |state, cx| { + if next { + state.select_next(cx); + } else { + state.select_previous(cx); + } + }); + focus_after_pointer_click(&state, &focus_handle, event, window, cx); + }) + }) + .children(children) + .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 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 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 focus_handle = self.state.read(cx).focus_handle(cx); + 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 |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 + } 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 std::{cell::Cell, rc::Rc}; + + use super::*; + use gpui::{AppContext as _, Context, Render, VisualTestContext, point}; + use gpui_base::FocusableExt as _; + + #[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).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!( + CarouselPaginationItem::new("pagination", 0, &state) + .small() + .size, + Size::Small + ); + } + + #[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); + } + + 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 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 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))); + let previous = CarouselPrevious::new(&state) + .accessibility_label("Previous project") + .child("Back"); + let next = CarouselNext::new(&state) + .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/crates/component/src/carousel/mod.rs b/crates/component/src/carousel/mod.rs new file mode 100644 index 0000000000..9675584479 --- /dev/null +++ b/crates/component/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/component/src/carousel/scroll_mask.rs b/crates/component/src/carousel/scroll_mask.rs new file mode 100644 index 0000000000..8c3f2c4fde --- /dev/null +++ b/crates/component/src/carousel/scroll_mask.rs @@ -0,0 +1,491 @@ +//! 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.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), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + _: &mut App, + ) -> Self::PrepaintState { + window.insert_hitbox(bounds, 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.should_handle_scroll(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 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 { + // 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 { + 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 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(); + } + }); + }); + } +} + +#[track_caller] +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, TouchPhase, + VisualTestContext, Window, div, point, px, + }; + + use crate::{ + button::Button, + carousel::{ + Carousel, CarouselContent, CarouselItem, CarouselState, state::SCROLL_EVENT_SEPARATION, + }, + }; + + 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.))) + } + } + + 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(); + let outer_handle = outer_handle.clone(); + move |_, _| NestedVerticalCarouselHarness { + state, + outer_handle, + } + }); + 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::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 new file mode 100644 index 0000000000..6e8e47852b --- /dev/null +++ b/crates/component/src/carousel/state.rs @@ -0,0 +1,2006 @@ +use std::{cell::OnceCell, time::Duration}; + +use gpui::{ + Along, App, Axis, Bounds, Context, EventEmitter, FocusHandle, Focusable, 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. +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)] +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>, + frame: 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, +} + +#[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 +/// 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, + loop_layout: Option, + geometry_has_runway: bool, + loop_layout_removal_pending: bool, + loop_motion_target: Option>, + wheel_burst_active: bool, + wheel_burst_epoch: usize, + focus_handle: OnceCell, + focus_ring_suppressed: bool, +} + +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, + loop_layout: None, + geometry_has_runway: false, + loop_layout_removal_pending: false, + loop_motion_target: None, + wheel_burst_active: false, + wheel_burst_epoch: 0, + focus_handle: OnceCell::new(), + focus_ring_suppressed: false, + } + } + + /// 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 { + 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 { + self.selected_index + .and_then(|index| self.navigation_index(index, true)) + .is_some() + } + + /// 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 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() || 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)); + 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; + 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); + } + 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(); + } + + /// 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.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(); + } + } + + /// 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.update_loop_layout(); + self.loop_motion_target = None; + 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; + }; + + let Some(index) = self.navigation_index(current, false) else { + self.cancel_user_interaction(cx); + 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. + pub fn select_next(&mut self, cx: &mut Context) -> bool { + let Some(current) = self.selected_index else { + return false; + }; + + let Some(index) = self.navigation_index(current, true) else { + self.cancel_user_interaction(cx); + 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. + 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 + } + + /// 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() + } + + /// 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 + .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 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 + } + + /// 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, 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; + 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`. + 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.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) + }) + .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 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(); + } + 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; + } + + self.rebase_pending_loop_motion(); + + 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); + self.normalize_loop_coordinate(); + let moved = self.scroll_handle.offset() != previous; + if moved { + cx.notify(); + } + if matches!(phase, TouchPhase::Started | TouchPhase::Moved) { + self.schedule_scroll_settle(cx); + } + moved || self.runtime_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, + ) + } + + /// 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, + wrapped: bool, + cx: &mut Context, + ) -> bool { + 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 || 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 && !seamless_wrap { + 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.runtime_looping() + && self.item_count > 1 + && ((current == 0 && next + 1 == self.item_count) + || (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.runtime_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.runtime_looping() && self.item_count > 1 { + Some(0) + } else { + None + } + } else if current > 0 { + Some(current - 1) + } else if self.runtime_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 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)?; + 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; + 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.rebase_pending_loop_motion(); + 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 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); + } + + 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.runtime_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(), + }; + 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 + } + + 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 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, + Axis::Vertical => offset.y, + } + } + + 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, + 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 {} + +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}; + + 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.)))); + } + + #[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))); + 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]); + } + + #[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()); + assert!(!state.has_previous()); + assert!(!state.has_next()); + + state.selected_index = Some(1); + assert!(!state.has_previous()); + assert!(!state.has_next()); + } + + #[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/crates/component/src/lib.rs b/crates/component/src/lib.rs index 4cd859bc36..89f40960b2 100644 --- a/crates/component/src/lib.rs +++ b/crates/component/src/lib.rs @@ -29,6 +29,7 @@ pub mod badge; pub mod breadcrumb; pub mod bubble; pub mod button; +pub mod carousel; pub mod chart; pub mod checkbox; pub mod clipboard; @@ -138,6 +139,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/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index a0ee82c3d5..033d8ab916 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..488ffc5e87 --- /dev/null +++ b/crates/story/src/stories/carousel_story.rs @@ -0,0 +1,354 @@ +use gpui_kit::component::{ + ActiveTheme as _, Sizable as _, Size, StyledExt as _, + button::Button, + carousel::{ + Carousel, CarouselContent, CarouselEvent, CarouselItem, CarouselNext, CarouselPagination, + CarouselPaginationItem, CarouselPrevious, CarouselState, + }, + h_flex, v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::{ChangeStorySize, section, story_toolbar}; + +#[derive(Clone, Copy)] +enum SlideTypography { + Large, + Medium, + Small, +} + +pub struct CarouselStory { + focus_handle: FocusHandle, + horizontal: Entity, + custom_controls: Entity, + multiple: Entity, + vertical: Entity, + looped: Entity, + controlled: 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 custom_controls = cx.new(|_| CarouselState::new(3)); + let multiple = 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 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, + custom_controls, + multiple, + vertical, + looped, + controlled, + controlled_index: 1, + size: Size::default(), + _subscriptions: vec![subscription], + } + }) + } + + fn slide( + label: impl Into, + typography: SlideTypography, + square: bool, + cx: &App, + ) -> impl IntoElement { + 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, + 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 = (index + 1).to_string(); + content.child( + configure(CarouselItem::new((prefix, index), index, state)) + .child(Self::slide(label, typography, square, cx)), + ) + }) + } +} + +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("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) + .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| { + CarouselPaginationItem::new( + ("horizontal-pagination", index), + index, + &self.horizontal, + ) + .with_size(self.size) + .child((index + 1).to_string()) + }))), + ), + ) + .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) + .accessibility_label("Previous project") + .child("Back"), + ) + .child( + CarouselNext::new(&self.custom_controls) + .with_size(self.size) + .accessibility_label("Next project") + .child("Forward"), + ), + ), + ) + .child( + section("Multiple items") + .description( + "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::Small, + true, + |item| item.flex_basis(relative(1. / 3.)).pl_1(), + cx, + ) + .track_style(StyleRefinement::default().ml_neg_1()), + ) + .child(CarouselPrevious::new(&self.multiple).with_size(self.size)) + .child(CarouselNext::new(&self.multiple).with_size(self.size)), + ), + ) + .child( + section("Vertical") + .description("Use Up and Down to navigate a vertical carousel.") + .v_flex() + .gap_3() + .child( + Carousel::new("carousel-vertical", &self.vertical) + .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)), + ), + ) + .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) + .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)), + ), + ) + .child( + section("Controlled") + .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) + .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)), + ) + .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(); + })), + ), + ), + ) + } +} diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 9e8658a42a..cb1a85e31b 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -11,6 +11,7 @@ mod breadcrumb_story; mod bubble_story; mod button_story; mod calendar_story; +mod carousel_story; mod chart_story; mod checkbox_story; mod clipboard_story; @@ -84,6 +85,7 @@ pub use breadcrumb_story::BreadcrumbStory; pub use bubble_story::BubbleStory; 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/examples/js_story/catalog.js b/examples/js_story/catalog.js index 4df58af416..c60ade2590 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 c27ef26281..2ed7370547 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")); @@ -1366,6 +1375,50 @@ 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() + .selected_index(/** @type {number} */ (state("carousel-index", 0))) + .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", diff --git a/website/component/carousel.md b/website/component/carousel.md new file mode 100644 index 0000000000..b51a58a535 --- /dev/null +++ b/website/component/carousel.md @@ -0,0 +1,198 @@ +--- +title: Carousel +description: A composable carousel for browsing related content. +--- + +# Carousel + +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 + +```rust +use gpui_kit::Axis; +use gpui_kit::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. + +## 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. 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. + +## Multiple items + +`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}; + +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)) + })) +``` + +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 + +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. 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 + +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. A mouse-wheel notch moves one item, and a gesture that begins at an edge scrolls the surrounding container instead. + +## 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_kit::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::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. `accessibility_label` also replaces the control's tooltip. + +```rust +use gpui_kit::ParentElement as _; + +CarouselPrevious::new(&state) + .accessibility_label("Previous project") + .child("Back"); + +CarouselNext::new(&state) + .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_kit::ParentElement as _; +use gpui_kit::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 `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/component/index.md b/website/component/index.md index b7f202f7db..253b08b17f 100644 --- a/website/component/index.md +++ b/website/component/index.md @@ -71,6 +71,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/component/carousel.md b/website/zh-CN/component/carousel.md new file mode 100644 index 0000000000..2079285ac4 --- /dev/null +++ b/website/zh-CN/component/carousel.md @@ -0,0 +1,198 @@ +--- +title: Carousel +description: 用于浏览相关内容的可组合 Carousel 组件。 +--- + +# Carousel + +Carousel 在可吸附的 viewport 中展示一个或多个相关 item,支持横向和纵向布局、键盘导航、指针与触控板手势、循环以及受控选中项。 + +## 引入 + +```rust +use gpui_kit::Axis; +use gpui_kit::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。 + +## 组合结构 + +Carousel 由一个内容 viewport、其中的 item 和可选控制按钮组成: + +```text +Carousel +├── CarouselContent +│ ├── CarouselItem +│ └── CarouselItem +├── CarouselPrevious +└── CarouselNext +``` + +可以在 `Carousel` 根节点上使用 `.w_full().max_w_96()` 约束整个 Carousel;需要单独设置 viewport 的宽度或高度时,可以直接设置 `CarouselContent` 的样式。`track_style` 仅用于间距等内部 track 调整。根节点会把常规子元素按列排布并留出 16px 间距,因此放在内容后面的 `CarouselPagination` 会自然与内容拉开;需要其他排布时直接在根节点上覆盖样式。 + +## 每屏多个 item + +`CarouselItem` 实现了 `Styled`。设置 flex basis 可以在 viewport 中同时显示多个 item;再通过 `CarouselContent::track_style` 设置负的起始 margin,并为每个 item 设置数值相同的起始 padding,即可调整它们之间的间距。这与 shadcn/ui 采用的成对间距模型一致。 + +```rust +use gpui_kit::{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)) + })) +``` + +flex basis 控制的是 item 几何尺寸,与按钮等控件使用的语义 `Size` 相互独立。 + +横向 Carousel 默认在 content track 上使用 `.ml_neg_4()`,在 item 上使用 `.pl_4()`;纵向 Carousel 使用对应的 `.mt_neg_4()` 与 `.pt_4()`。覆盖间距时应同步修改两侧,并使用相同的 spacing scale,这样首个 item 会继续与 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 用于选择第一项和最后一项。点击 Carousel 内部或它的控制按钮同样会让它获得焦点以便键盘导航,但不会显示焦点环;焦点环只在通过键盘聚焦时出现。 + +## 循环 + +启用循环后,从最后一项继续向后会回到第一项: + +```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_kit::component::{Sizable as _, Size}; + +CarouselPrevious::new(&state).with_size(Size::Large); +CarouselNext::new(&state).with_size(Size::Large); +``` + +上一项和下一项控件默认使用 `Size::Medium`,分页项默认使用 `Size::XSmall`。 + +## 自定义控制按钮 + +`CarouselPrevious` 和 `CarouselNext` 实现了 `ParentElement` 与 `Styled`。没有子元素时,它们会根据方向显示对应的箭头;添加子元素后,可以替换可见内容,同时保留自动导航和边界禁用状态。`accessibility_label` 也会同步替换控件的 tooltip。 + +```rust +use gpui_kit::ParentElement as _; + +CarouselPrevious::new(&state) + .accessibility_label("上一个项目") + .child("返回"); + +CarouselNext::new(&state) + .accessibility_label("下一个项目") + .child("继续"); +``` + +需要完全自定义控制按钮时,可以省略对应的 Carousel 部件,并使用公开 state API 组合任意控件: + +```rust +use gpui_kit::ParentElement as _; +use gpui_kit::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 会报告自己在内容集合中的位置。当默认的“轮播”无法准确描述内容时,使用 `accessibility_label` 设置更明确的名称。 + +Carousel 动画会遵循应用的减少动效设置。 diff --git a/website/zh-CN/component/index.md b/website/zh-CN/component/index.md index 3e6bd737f2..0cc737b3e8 100644 --- a/website/zh-CN/component/index.md +++ b/website/zh-CN/component/index.md @@ -50,6 +50,7 @@ collapsed: false - [Sidebar](sidebar) - 侧边栏导航 - [StatusBar](status-bar) - 底部状态栏,含左/中/右三区 - [Chart](chart) - 图表组件 +- [Carousel](carousel) - 浏览一组相关内容 - [DataTable](data-table) - 高性能数据表格 - [Dock](dock) - 支持标签、分割与状态持久化的生产级 Dock 布局 - [Tree](tree) - 树形结构组件