From 8eff75d3577c3f52551e4ae60bf2e49d88daeac4 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 17 Oct 2024 17:31:59 +0800 Subject: [PATCH] accordion: Add Accordion. (#355) image --- README.md | 1 + crates/app/src/story_workspace.rs | 9 +- crates/story/src/accordion_story.rs | 158 +++++++++++++++ crates/story/src/lib.rs | 3 + crates/ui/src/accordion.rs | 300 ++++++++++++++++++++++++++++ crates/ui/src/lib.rs | 1 + crates/ui/src/theme.rs | 9 + 7 files changed, 477 insertions(+), 4 deletions(-) create mode 100644 crates/story/src/accordion_story.rs create mode 100644 crates/ui/src/accordion.rs diff --git a/README.md b/README.md index 2d8dc684..85f57cca 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs - [x] Modal - [x] Notification - [x] WebView +- [x] Accordion ## Showcase diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index 056aefb1..d1e4b52c 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -4,9 +4,9 @@ use prelude::FluentBuilder as _; use serde::Deserialize; use std::{sync::Arc, time::Duration}; use story::{ - ButtonStory, CalendarStory, DropdownStory, IconStory, ImageStory, InputStory, ListStory, - ModalStory, PopupStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, - SwitchStory, TableStory, TextStory, TooltipStory, + AccordionStory, ButtonStory, CalendarStory, DropdownStory, IconStory, ImageStory, InputStory, + ListStory, ModalStory, PopupStory, ProgressStory, ResizableStory, ScrollableStory, + StoryContainer, SwitchStory, TableStory, TextStory, TooltipStory, }; use ui::{ button::{Button, ButtonStyled as _}, @@ -22,7 +22,7 @@ use crate::app_state::AppState; const MAIN_DOCK_AREA: DockAreaTab = DockAreaTab { id: "main-dock", - version: 4, + version: 5, }; #[derive(Clone, PartialEq, Eq, Deserialize)] @@ -242,6 +242,7 @@ impl StoryWorkspace { Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), Arc::new(StoryContainer::panel::(cx)), + Arc::new(StoryContainer::panel::(cx)), // Arc::new(StoryContainer::panel::(cx)), ], None, diff --git a/crates/story/src/accordion_story.rs b/crates/story/src/accordion_story.rs new file mode 100644 index 00000000..8b1e11ca --- /dev/null +++ b/crates/story/src/accordion_story.rs @@ -0,0 +1,158 @@ +use gpui::{ + FocusHandle, IntoElement, ParentElement as _, Render, Styled as _, View, ViewContext, + VisualContext as _, WindowContext, +}; +use ui::{ + accordion::Accordion, button::Button, button_group::ButtonGroup, checkbox::Checkbox, h_flex, + switch::Switch, v_flex, IconName, Selectable, Sizable, Size, +}; + +pub struct AccordionStory { + open_ixs: Vec, + size: Size, + bordered: bool, + disabled: bool, + focus_handle: FocusHandle, +} + +impl super::Story for AccordionStory { + fn title() -> &'static str { + "Accordion" + } + + fn new_view(cx: &mut WindowContext) -> View { + Self::view(cx) + } +} + +impl AccordionStory { + pub fn view(cx: &mut WindowContext) -> View { + cx.new_view(Self::new) + } + + fn new(cx: &mut ViewContext) -> Self { + Self { + bordered: true, + open_ixs: Vec::new(), + size: Size::default(), + disabled: false, + focus_handle: cx.focus_handle(), + } + } + + fn toggle_accordion(&mut self, open_ixs: Vec, cx: &mut ViewContext) { + self.open_ixs = open_ixs; + cx.notify(); + } + + fn set_size(&mut self, size: Size, cx: &mut ViewContext) { + self.size = size; + cx.notify(); + } +} + +impl gpui::FocusableView for AccordionStory { + fn focus_handle(&self, _: &gpui::AppContext) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for AccordionStory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .gap_3() + .child( + h_flex() + .items_center() + .justify_between() + .gap_2() + .child( + ButtonGroup::new("toggle-size") + .child( + Button::new("xsmall") + .label("XSmall") + .selected(self.size == Size::XSmall), + ) + .child( + Button::new("small") + .label("Small") + .selected(self.size == Size::Small), + ) + .child( + Button::new("medium") + .label("Medium") + .selected(self.size == Size::Medium), + ) + .child( + Button::new("large") + .label("Large") + .selected(self.size == Size::Large), + ) + .on_click(cx.listener(|this, selecteds: &Vec, cx| { + let size = match selecteds[0] { + 0 => Size::XSmall, + 1 => Size::Small, + 2 => Size::Medium, + 3 => Size::Large, + _ => unreachable!(), + }; + this.set_size(size, cx); + })), + ) + .child( + Checkbox::new("disabled") + .label("Disabled") + .checked(self.disabled) + .on_click(cx.listener(|this, checked, cx| { + this.disabled = *checked; + cx.notify(); + })), + ) + .child( + Checkbox::new("bordered") + .label("Bordered") + .checked(self.bordered) + .on_click(cx.listener(|this, checked, cx| { + this.bordered = *checked; + cx.notify(); + })), + ), + ) + .child( + Accordion::new("test") + .bordered(self.bordered) + .with_size(self.size) + .disabled(self.disabled) + .item(|this| + this.open(self.open_ixs.contains(&0)) + .icon(IconName::Info) + .title("This is first accordion") + .content("Hello") + ) + .item(|this| + this.open(self.open_ixs.contains(&1)) + .icon(IconName::Inbox) + .title("This is second accordion") + .content( + v_flex() + .gap_2() + .child( + "We can put any view here, like a v_flex with a text view", + ) + .child(Switch::new("switch1").label("Switch")) + .child(Checkbox::new("checkbox1").label("Or a Checkbox")), + ) + ) + .item(|this| + this.open(self.open_ixs.contains(&2)) + .icon(IconName::Moon) + .title("This is third accordion") + .content( + "This is the third accordion content. It can be any view, like a text view or a button." + ) + ) .on_toggle_click(cx.listener(|this, open_ixs: &[usize], cx| { + this.toggle_accordion(open_ixs.to_vec(), cx); + })), + ) + } +} diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 0253f695..33a8aae7 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -1,3 +1,4 @@ +mod accordion_story; mod button_story; mod calendar_story; mod dropdown_story; @@ -16,6 +17,7 @@ mod text_story; mod tooltip_story; mod webview_story; +pub use accordion_story::AccordionStory; pub use button_story::ButtonStory; pub use calendar_story::CalendarStory; pub use dropdown_story::DropdownStory; @@ -253,6 +255,7 @@ impl StoryState { "TextStory" => story!(TextStory), "TooltipStory" => story!(TooltipStory), "WebViewStory" => story!(WebViewStory), + "AccordionStory" => story!(AccordionStory), _ => { unreachable!("Invalid story klass: {}", self.story_klass) } diff --git a/crates/ui/src/accordion.rs b/crates/ui/src/accordion.rs new file mode 100644 index 00000000..bbf9d2db --- /dev/null +++ b/crates/ui/src/accordion.rs @@ -0,0 +1,300 @@ +use std::{cell::Cell, rc::Rc, sync::Arc}; + +use gpui::{ + div, prelude::FluentBuilder as _, rems, AnyElement, Div, ElementId, InteractiveElement as _, + IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled, + WindowContext, +}; + +use crate::{h_flex, theme::ActiveTheme as _, v_flex, Icon, IconName, Sizable, Size}; + +/// An AccordionGroup is a container for multiple Accordion elements. +#[derive(IntoElement)] +pub struct Accordion { + id: ElementId, + base: Div, + multiple: bool, + size: Size, + bordered: bool, + disabled: bool, + children: Vec, + on_toggle_click: Option>, +} + +impl Accordion { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + base: v_flex().gap_1(), + multiple: false, + size: Size::default(), + bordered: true, + children: Vec::new(), + disabled: false, + on_toggle_click: None, + } + } + + pub fn multiple(mut self, multiple: bool) -> Self { + self.multiple = multiple; + self + } + + pub fn bordered(mut self, bordered: bool) -> Self { + self.bordered = bordered; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn item(mut self, child: F) -> Self + where + F: FnOnce(AccordionItem) -> AccordionItem, + { + let item = child(AccordionItem::new()); + self.children.push(item); + self + } + + /// Sets the on_toggle_click callback for the AccordionGroup. + /// + /// The first argument `Vec` is the indices of the open accordions. + pub fn on_toggle_click( + mut self, + on_toggle_click: impl Fn(&[usize], &mut WindowContext) + Send + Sync + 'static, + ) -> Self { + self.on_toggle_click = Some(Arc::new(on_toggle_click)); + self + } +} + +impl Sizable for Accordion { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl RenderOnce for Accordion { + fn render(self, _: &mut WindowContext) -> impl IntoElement { + let mut open_ixs: Vec = Vec::new(); + let multiple = self.multiple; + let state = Rc::new(Cell::new(None)); + + self.children + .iter() + .enumerate() + .for_each(|(ix, accordion)| { + if accordion.open { + open_ixs.push(ix); + } + }); + + self.base + .id(self.id) + .children( + self.children + .into_iter() + .enumerate() + .map(|(ix, accordion)| { + let state = Rc::clone(&state); + accordion + .with_size(self.size) + .bordered(self.bordered) + .when(self.disabled, |this| this.disabled(true)) + .on_toggle_click(move |_, _| { + state.set(Some(ix)); + }) + }), + ) + .when_some( + self.on_toggle_click.filter(|_| !self.disabled), + move |this, on_toggle_click| { + this.on_click(move |_, cx| { + let mut open_ixs = open_ixs.clone(); + if let Some(ix) = state.get() { + if multiple { + if let Some(pos) = open_ixs.iter().position(|&i| i == ix) { + open_ixs.remove(pos); + } else { + open_ixs.push(ix); + } + } else { + let was_open = open_ixs.iter().any(|&i| i == ix); + open_ixs.clear(); + if !was_open { + open_ixs.push(ix); + } + } + } + + on_toggle_click(&open_ixs, cx); + }) + }, + ) + } +} + +/// An Accordion is a vertically stacked list of items, each of which can be expanded to reveal the content associated with it. +#[derive(IntoElement)] +pub struct AccordionItem { + icon: Option, + title: AnyElement, + content: AnyElement, + open: bool, + size: Size, + bordered: bool, + disabled: bool, + on_toggle_click: Option>, +} + +impl AccordionItem { + pub fn new() -> Self { + Self { + icon: None, + title: SharedString::default().into_any_element(), + content: SharedString::default().into_any_element(), + open: false, + disabled: false, + on_toggle_click: None, + size: Size::default(), + bordered: true, + } + } + + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn title(mut self, title: impl IntoElement) -> Self { + self.title = title.into_any_element(); + self + } + + pub fn content(mut self, content: impl IntoElement) -> Self { + self.content = content.into_any_element(); + self + } + + pub fn bordered(mut self, bordered: bool) -> Self { + self.bordered = bordered; + self + } + + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + fn on_toggle_click( + mut self, + on_toggle_click: impl Fn(&bool, &mut WindowContext) + 'static, + ) -> Self { + self.on_toggle_click = Some(Arc::new(on_toggle_click)); + self + } +} + +impl Sizable for AccordionItem { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl RenderOnce for AccordionItem { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let text_size = match self.size { + Size::XSmall => rems(0.875), + Size::Small => rems(0.875), + _ => rems(1.0), + }; + + v_flex() + .bg(cx.theme().accordion) + .overflow_hidden() + .when(self.bordered, |this| { + this.border_1().border_color(cx.theme().border).rounded_md() + }) + .text_size(text_size) + .child( + h_flex() + .id("accordion-title") + .justify_between() + .map(|this| match self.size { + Size::XSmall => this.py_0().px_1p5(), + Size::Small => this.py_0p5().px_2(), + Size::Large => this.py_1p5().px_4(), + _ => this.py_1().px_3(), + }) + .when(self.open, |this| { + this.when(self.bordered, |this| { + this.bg(cx.theme().accordion_active) + .text_color(cx.theme().foreground) + .border_b_1() + .border_color(cx.theme().border) + }) + }) + .child( + h_flex() + .items_center() + .map(|this| match self.size { + Size::XSmall => this.gap_1(), + Size::Small => this.gap_1(), + _ => this.gap_2(), + }) + .when_some(self.icon, |this, icon| { + this.child( + icon.with_size(self.size) + .text_color(cx.theme().muted_foreground), + ) + }) + .child(self.title), + ) + .when(!self.disabled, |this| { + this.cursor_pointer() + .hover(|this| this.bg(cx.theme().accordion_hover)) + .child( + Icon::new(if self.open { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + }) + .when_some( + self.on_toggle_click.filter(|_| !self.disabled), + |this, on_toggle_click| { + this.on_click({ + move |_, cx| { + on_toggle_click(&!self.open, cx); + } + }) + }, + ), + ) + .when(self.open, |this| { + this.child( + div() + .map(|this| match self.size { + Size::XSmall => this.p_1p5(), + Size::Small => this.p_2(), + Size::Large => this.p_4(), + _ => this.p_3(), + }) + .child(self.content), + ) + }) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 1d00c124..3086a919 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -8,6 +8,7 @@ mod svg_img; mod time; mod title_bar; +pub mod accordion; pub mod animation; pub mod button; pub mod button_group; diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 0f1830fb..e0078957 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -334,6 +334,9 @@ pub struct Theme { pub link_hover: Hsla, pub link_active: Hsla, pub skeleton: Hsla, + pub accordion: Hsla, + pub accordion_hover: Hsla, + pub accordion_active: Hsla, } impl Global for Theme {} @@ -399,6 +402,9 @@ impl Theme { self.link_hover = self.link_hover.apply(mask_color); self.link_active = self.link_active.apply(mask_color); self.skeleton = self.skeleton.apply(mask_color); + self.accordion = self.accordion.apply(mask_color); + self.accordion_hover = self.accordion_hover.apply(mask_color); + self.accordion_active = self.accordion_active.apply(mask_color); } } @@ -473,6 +479,9 @@ impl From for Theme { link_hover: colors.link.lighten(0.2), link_active: colors.link.darken(0.2), skeleton: hsla(colors.primary.h, colors.primary.s, colors.primary.l, 0.1), + accordion: colors.background, + accordion_hover: colors.tab_bar.opacity(0.7), + accordion_active: colors.tab_bar, } } }