diff --git a/Cargo.lock b/Cargo.lock index bf486ce6..d0649715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1146,6 +1146,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "deunicode" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" + [[package]] name = "digest" version = "0.10.7" @@ -1421,6 +1427,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fake" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25829bde82205da46e1823b2259db6273379f626fc211f126f65654a2669be" +dependencies = [ + "deunicode", + "rand 0.8.5", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -5115,7 +5131,10 @@ dependencies = [ name = "ui-story" version = "0.1.0" dependencies = [ + "anyhow", + "fake", "gpui", + "serde", "ui", ] diff --git a/README.md b/README.md index c5b09506..85c3bbb2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GPUI App Example +# GPUI Compoment This is an example of build app by using GPUI. diff --git a/assets/icons/ellipsis-vertical.svg b/assets/icons/ellipsis-vertical.svg new file mode 100644 index 00000000..c75a25a4 --- /dev/null +++ b/assets/icons/ellipsis-vertical.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/ellipsis.svg b/assets/icons/ellipsis.svg new file mode 100644 index 00000000..07dcab51 --- /dev/null +++ b/assets/icons/ellipsis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/info.svg b/assets/icons/info.svg new file mode 100644 index 00000000..8f9c59ad --- /dev/null +++ b/assets/icons/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 00000000..651bbd49 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/ui-story/Cargo.toml b/crates/ui-story/Cargo.toml index c9ee1ca9..fda3363d 100644 --- a/crates/ui-story/Cargo.toml +++ b/crates/ui-story/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" [dependencies] ui.workspace = true gpui.workspace = true +serde.workspace = true +fake = "2.9.2" +anyhow = "1" [lints] workspace = true diff --git a/crates/ui-story/src/lib.rs b/crates/ui-story/src/lib.rs index 0aa3f562..15aea79a 100644 --- a/crates/ui-story/src/lib.rs +++ b/crates/ui-story/src/lib.rs @@ -2,6 +2,7 @@ mod button_story; mod checkbox_story; mod dropdown_story; mod input_story; +mod list_story; mod picker_story; mod popover_story; mod switch_story; @@ -25,6 +26,7 @@ use button_story::ButtonStory; use checkbox_story::CheckboxStory; use dropdown_story::DropdownStory; use input_story::InputStory; +use list_story::ListStory; use picker_story::PickerStory; use popover_story::PopoverStory; use switch_story::SwitchStory; @@ -84,6 +86,7 @@ enum StoryType { Checkbox, Switch, Picker, + List, Dropdown, Tooltip, Popover, @@ -97,6 +100,7 @@ impl Display for StoryType { Self::Checkbox => write!(f, "Checkbox"), Self::Switch => write!(f, "Switch"), Self::Picker => write!(f, "Picker"), + Self::List => write!(f, "List"), Self::Dropdown => write!(f, "Dropdown"), Self::Tooltip => write!(f, "Tooltip"), Self::Popover => write!(f, "Popover"), @@ -112,6 +116,7 @@ pub struct Stories { checkbox_story: View, switch_story: View, picker_story: View, + list_story: View, dropdown_story: View, tooltip_story: View, popover_story: View, @@ -127,6 +132,7 @@ impl Stories { switch_story: cx.new_view(|cx| SwitchStory::new(cx)), tooltip_story: cx.new_view(|_| TooltipStory), picker_story: cx.new_view(PickerStory::new), + list_story: cx.new_view(|cx| ListStory::new(cx)), dropdown_story: cx.new_view(DropdownStory::new), popover_story: cx.new_view(PopoverStory::new), } @@ -158,6 +164,7 @@ impl Stories { ), self.tab("story-switch", StoryType::Switch, None, cx), self.tab("story-picker", StoryType::Picker, None, cx), + self.tab("story-list", StoryType::List, None, cx), self.tab("story-dropdown", StoryType::Dropdown, None, cx), self.tab("story-tooltip", StoryType::Tooltip, None, cx), self.tab("story-popover", StoryType::Popover, None, cx), @@ -202,6 +209,7 @@ impl Render for Stories { StoryType::Checkbox => this.child(self.checkbox_story.clone()), StoryType::Switch => this.child(self.switch_story.clone()), StoryType::Picker => this.child(self.picker_story.clone()), + StoryType::List => this.child(self.list_story.clone()), StoryType::Dropdown => this.child(self.dropdown_story.clone()), StoryType::Tooltip => this.child(self.tooltip_story.clone()), StoryType::Popover => this.child(self.popover_story.clone()), diff --git a/crates/ui-story/src/list_story.rs b/crates/ui-story/src/list_story.rs new file mode 100644 index 00000000..194acad9 --- /dev/null +++ b/crates/ui-story/src/list_story.rs @@ -0,0 +1,291 @@ +use core::time; +use std::{cell::RefCell, fmt::format, rc::Rc}; + +use fake::Fake; +use gpui::{ + actions, div, impl_actions, prelude::FluentBuilder as _, px, Action, ElementId, + InteractiveElement, IntoElement, ParentElement, Render, RenderOnce, Styled, Timer, View, + ViewContext, VisualContext, WindowContext, +}; + +use ui::{ + h_flex, + label::Label, + list::ListItem, + picker::{Picker, PickerDelegate}, + theme::{hsl, ActiveTheme, Colorize as _}, + v_flex, +}; + +use super::story_case; + +actions!(list_story, [SelectedCompany]); + +#[derive(Clone)] +struct Company { + name: String, + industry: String, + last_done: f64, + prev_close: f64, + description: String, +} + +impl Company { + fn random_update(&mut self) { + self.last_done = self.prev_close * (1.0 + (-0.2..0.2).fake::()); + } + + fn change_percent(&self) -> f64 { + (self.last_done - self.prev_close) / self.prev_close + } +} + +#[derive(IntoElement)] +struct CompanyListItem { + base: ListItem, + ix: usize, + copmpany: Company, + selected: bool, +} + +impl CompanyListItem { + pub fn new(id: impl Into, copmpany: Company, ix: usize, selected: bool) -> Self { + CompanyListItem { + copmpany, + ix, + base: ListItem::new(id), + selected, + } + } +} + +impl RenderOnce for CompanyListItem { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let text_color = if self.selected { + cx.theme().accent_foreground + } else { + cx.theme().foreground + }; + + let trend_color = match self.copmpany.change_percent() { + change if change > 0.0 => hsl(0.0, 79.0, 53.0), + change if change < 0.0 => hsl(100.0, 79.0, 53.0), + _ => cx.theme().foreground, + }; + + let bg_color = if self.selected { + cx.theme().accent + } else { + if self.ix % 2 == 0 { + cx.theme().background + } else { + cx.theme().accent.opacity(0.3) + } + }; + + self.base + .px_3() + .py_1() + .rounded_lg() + .overflow_x_hidden() + .bg(bg_color) + .child( + h_flex() + .items_center() + .justify_between() + .gap_2() + .text_color(text_color) + .child( + v_flex() + .gap_2() + .max_w(px(500.)) + .overflow_x_hidden() + .flex_nowrap() + .child(Label::new(self.copmpany.name.clone()).whitespace_nowrap()) + .child( + div().text_sm().overflow_x_hidden().child( + Label::new(self.copmpany.industry.clone()) + .whitespace_nowrap() + .text_color(text_color.opacity(0.5)), + ), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .justify_end() + .child( + div() + .w(px(65.)) + .text_color(text_color) + .child(format!("{:.2}", self.copmpany.last_done)), + ) + .child( + h_flex().w(px(65.)).justify_end().child( + div() + .rounded_md() + .whitespace_nowrap() + .text_size(px(12.)) + .px_1() + .text_color(trend_color) + .child(format!("{:.2}%", self.copmpany.change_percent())), + ), + ), + ), + ) + } +} + +struct CompanyListDelegate { + companies: Vec, + selected_index: usize, +} + +impl PickerDelegate for CompanyListDelegate { + type ListItem = CompanyListItem; + + fn match_count(&self) -> usize { + self.companies.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { + self.selected_index = ix; + cx.dispatch_action(Box::new(SelectedCompany)); + } + + fn render_item( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + if let Some(company) = self.companies.get(ix) { + return Some(CompanyListItem::new(ix, company.clone(), ix, selected)); + } + + None + } +} + +impl CompanyListDelegate { + fn selected_company(&self) -> Option { + self.companies.get(self.selected_index).cloned() + } +} + +pub struct ListStory { + company_list: View>, + selected_company: Option, +} + +fn random_company() -> Company { + let last_done = (0.0..999.0).fake::(); + let prev_close = last_done * (-0.1..0.1).fake::(); + Company { + name: fake::faker::company::en::CompanyName().fake(), + industry: fake::faker::company::en::Industry().fake(), + description: fake::faker::lorem::en::Paragraph(3..5).fake(), + last_done, + prev_close, + } +} + +impl ListStory { + pub(crate) fn new(cx: &mut ViewContext) -> Self { + let companies = (0..10_000) + .into_iter() + .map(|_| random_company()) + .collect::>(); + + let company_list = cx.new_view(|cx| { + Picker::uniform_list( + CompanyListDelegate { + companies, + selected_index: 0, + }, + cx, + ) + .no_query() + }); + + // Spawn a background to random refresh the list + cx.spawn(move |this, mut cx| async move { + loop { + Timer::after(time::Duration::from_secs_f64(0.5)).await; + this.update(&mut cx, |this, cx| { + this.company_list.update(cx, |picker, cx| { + picker + .delegate_mut() + .companies + .iter_mut() + .for_each(|company| { + company.random_update(); + }); + }); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + + Self { + company_list, + selected_company: None, + } + } + + fn selected_company(&mut self, _: &SelectedCompany, cx: &mut ViewContext) { + let picker = self.company_list.read(cx); + if let Some(company) = picker.delegate().selected_company() { + self.selected_company = Some(company); + } + } +} + +impl Render for ListStory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + story_case("List", "A list for container 10K ListItems.").child( + h_flex() + .size_full() + .on_action(cx.listener(Self::selected_company)) + .gap_4() + .mb_4() + .child( + div() + .h_full() + .border_1() + .border_color(cx.theme().border) + .p_1() + .rounded_md() + .w(px(400.)) + .child(self.company_list.clone()), + ) + .child( + div() + .flex_1() + .size_full() + .border_1() + .border_color(cx.theme().border) + .py_1() + .px_4() + .rounded_md() + .when_some(self.selected_company.clone(), |this, company| { + this.child( + div() + .flex_1() + .gap_2() + .child(div().text_3xl().mb_6().child(company.name.clone())) + .child(company.industry.clone()) + .child(company.description.clone()), + ) + }), + ), + ) + } +} diff --git a/crates/ui-story/src/switch_story.rs b/crates/ui-story/src/switch_story.rs index e9887fe5..2c419b26 100644 --- a/crates/ui-story/src/switch_story.rs +++ b/crates/ui-story/src/switch_story.rs @@ -64,7 +64,7 @@ impl Render for SwitchStory { card(cx) .child( title("Marketing emails").child( - Label::new("Receive emails about new products, features, and more.").text_color(theme.muted) + Label::new("Receive emails about new products, features, and more.").text_color(theme.muted_foreground) ) ) .child( @@ -82,7 +82,7 @@ impl Render for SwitchStory { card(cx) .child( title("Security emails").child( - Label::new("Receive emails about your account security. When turn off, you never recive email again.").text_color(theme.muted) + Label::new("Receive emails about your account security. When turn off, you never recive email again.").text_color(theme.muted_foreground) ) ) .child( diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 9ef0ecf8..a4f949d6 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -14,6 +14,10 @@ pub enum IconName { Close, ChevronDown, ChevronsUpDown, + Plus, + Info, + Ellipsis, + EllipsisVertical, } impl IconName { @@ -27,6 +31,10 @@ impl IconName { IconName::Close => "icons/close.svg", IconName::ChevronDown => "icons/chevron-down.svg", IconName::ChevronsUpDown => "icons/chevrons-up-down.svg", + IconName::Plus => "icons/plus.svg", + IconName::Info => "icons/info.svg", + IconName::Ellipsis => "icons/ellipsis.svg", + IconName::EllipsisVertical => "icons/ellipsis-vertical.svg", } .into() } diff --git a/crates/ui/src/label.rs b/crates/ui/src/label.rs index f874fa3a..ec4030fe 100644 --- a/crates/ui/src/label.rs +++ b/crates/ui/src/label.rs @@ -38,6 +38,8 @@ impl RenderOnce for Label { self.label }; - self.base.text_color(cx.theme().foreground).child(text) + div() + .text_color(cx.theme().foreground) + .child(self.base.child(text)) } } diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index db8da89d..389be727 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -1,5 +1,5 @@ use gpui::{ - prelude::FluentBuilder as _, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, + div, prelude::FluentBuilder as _, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, WindowContext, }; @@ -19,7 +19,7 @@ pub struct ListItem { impl ListItem { pub fn new(id: impl Into) -> Self { Self { - base: h_flex().id(id.into()), + base: div().id(id.into()), disabled: false, selected: false, on_click: None, @@ -85,9 +85,9 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, cx: &mut WindowContext) -> impl IntoElement { - h_flex() + div() .id("item-group") - .flex_none() + .w_full() .relative() .gap_x_2() .text_base() @@ -99,19 +99,22 @@ impl RenderOnce for ListItem { .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| { this.on_mouse_down(MouseButton::Right, move |ev, cx| (on_mouse_down)(ev, cx)) }) - .when(!self.selected, |this| { - this.hover(|this| this.bg(cx.theme().accent)) - }) .when(self.selected, |this| this.bg(cx.theme().accent)) - .child(self.base.w_full().items_center().justify_between().when( - self.selected, - |this| { - if let Some(icon) = self.check_icon { - this.child(icon.text_color(cx.theme().muted)) - } else { - this - } - }, - )) + .child( + self.base + .when(!self.selected, |this| { + this.hover(|this| this.bg(cx.theme().accent)) + }) + .w_full() + .items_center() + .justify_between() + .when(self.selected, |this| { + if let Some(icon) = self.check_icon { + this.child(icon.text_color(cx.theme().muted)) + } else { + this + } + }), + ) } } diff --git a/crates/ui/src/list/mod.rs b/crates/ui/src/list/mod.rs index e51e8f56..b40fb082 100644 --- a/crates/ui/src/list/mod.rs +++ b/crates/ui/src/list/mod.rs @@ -1,3 +1,2 @@ mod list_item; - pub use list_item::*; diff --git a/crates/ui/src/picker.rs b/crates/ui/src/picker.rs index b54e418f..1b4777f3 100644 --- a/crates/ui/src/picker.rs +++ b/crates/ui/src/picker.rs @@ -187,7 +187,7 @@ impl Picker { head: cx.new_view(Empty::new), width: None, is_modal: false, - max_height: Some(rems(20.).into()), + max_height: None, element_container, pending_update_matches: None, } @@ -206,6 +206,14 @@ impl Picker { input } + pub fn delegate(&self) -> &D { + &self.delegate + } + + pub fn delegate_mut(&mut self) -> &mut D { + &mut self.delegate + } + pub fn list(delegate: D, cx: &mut ViewContext) -> Self { let query_input = Self::new_query_input("Search...", cx); Self::new(delegate, ContainerKind::List, Some(query_input), cx) diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index cb263972..e5d10be3 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -10,10 +10,12 @@ impl ActiveTheme for AppContext { } } +/// Make a [gpui::Hsla] color. +/// /// h - 0 - 360.0 /// s - 0.0 - 100.0 /// l - 0.0 - 100.0 -fn hsl(h: f32, s: f32, l: f32) -> Hsla { +pub fn hsl(h: f32, s: f32, l: f32) -> Hsla { hsla(h / 360., s / 100.0, l / 100.0, 1.0) }