From 5034152f212bf64a22ed564760ae6af1a7deafd8 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 22 Jul 2024 15:30:07 +0800 Subject: [PATCH] Improved Input prefix, suffix with a builder to support Element. (#54) - Add loading to List query input and changed perform_search now return a Task. --- Cargo.lock | 1 + assets/icons/inbox.svg | 1 + crates/app/src/story_workspace.rs | 15 +++- crates/story/src/icon_story.rs | 38 ++++++++ crates/story/src/input_story.rs | 8 +- crates/story/src/lib.rs | 2 + crates/story/src/picker_story.rs | 53 ++++++++--- crates/ui/Cargo.toml | 1 + crates/ui/src/icon.rs | 19 ++-- crates/ui/src/indicator.rs | 2 +- crates/ui/src/input/input.rs | 94 ++++++++++++++------ crates/ui/src/list/list.rs | 140 ++++++++++++++++++++---------- 12 files changed, 278 insertions(+), 96 deletions(-) create mode 100644 assets/icons/inbox.svg create mode 100644 crates/story/src/icon_story.rs diff --git a/Cargo.lock b/Cargo.lock index 0f07a288..d5f55fde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5699,6 +5699,7 @@ dependencies = [ "serde", "serde_json", "smallvec", + "smol", "taffy", "unicode-segmentation", "usvg", diff --git a/assets/icons/inbox.svg b/assets/icons/inbox.svg new file mode 100644 index 00000000..15d6fae4 --- /dev/null +++ b/assets/icons/inbox.svg @@ -0,0 +1 @@ + diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index 27ebbd25..1829ed62 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -1,9 +1,9 @@ use gpui::*; use prelude::FluentBuilder as _; use story::{ - ButtonStory, CheckboxStory, DropdownStory, ImageStory, InputStory, ListStory, PickerStory, - PopoverStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, SwitchStory, - TableStory, TooltipStory, WebViewStory, + ButtonStory, CheckboxStory, DropdownStory, IconStory, ImageStory, InputStory, ListStory, + PickerStory, PopoverStory, ProgressStory, ResizableStory, ScrollableStory, StoryContainer, + SwitchStory, TableStory, TooltipStory, WebViewStory, }; use workspace::{dock::DockPosition, TitleBar, Workspace}; @@ -121,6 +121,15 @@ impl StoryWorkspace { cx, ); + StoryContainer::add_pane( + "Icon", + "Icon use examples", + IconStory::view(cx).into(), + workspace.clone(), + cx, + ) + .detach(); + StoryContainer::add_pane( "Image", "Render SVG image and Chart", diff --git a/crates/story/src/icon_story.rs b/crates/story/src/icon_story.rs new file mode 100644 index 00000000..548db4af --- /dev/null +++ b/crates/story/src/icon_story.rs @@ -0,0 +1,38 @@ +use gpui::{px, rems, ParentElement, Render, Styled, View, VisualContext as _, WindowContext}; +use ui::{h_flex, theme::ActiveTheme as _, v_flex, Icon, IconName}; + +pub struct IconStory {} + +impl IconStory { + pub fn new(_: &WindowContext) -> Self { + Self {} + } + + pub fn view(cx: &mut WindowContext) -> View { + cx.new_view(|cx| Self::new(cx)) + } +} + +impl Render for IconStory { + fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { + v_flex().gap_3().child( + h_flex() + .gap_4() + .child(IconName::Info) + .child( + Icon::new(IconName::Maximize) + .size_6() + .text_color(ui::green_500()), + ) + .child(Icon::new(IconName::Maximize).size(px(55.))) + .child( + Icon::new(IconName::Plus) + .w(rems(3.)) + .h(rems(3.)) + .bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .rounded(px(32.)), + ), + ) + } +} diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index 1a46a27b..ff8c2209 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -68,20 +68,20 @@ impl InputStory { let prefix_input1 = cx.new_view(|cx| { TextInput::new(cx) - .prefix(IconName::Search.view(cx)) + .prefix(|_| IconName::Search) .placeholder("Search some thing...") .cleanable(true) }); let suffix_input1 = cx.new_view(|cx| { TextInput::new(cx) - .suffix(IconName::Info.view(cx)) + .suffix(|_| IconName::Info) .placeholder("Info here...") .cleanable(true) }); let both_input1 = cx.new_view(|cx| { TextInput::new(cx) - .prefix(IconName::Search.view(cx)) - .suffix(IconName::Info.view(cx)) + .prefix(|_| IconName::Search) + .suffix(|_| IconName::Info) .cleanable(true) .placeholder("This input have prefix and suffix.") }); diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index d248f1ec..56f8978a 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -1,6 +1,7 @@ mod button_story; mod checkbox_story; mod dropdown_story; +mod icon_story; mod image_story; mod input_story; mod list_story; @@ -17,6 +18,7 @@ mod webview_story; pub use button_story::ButtonStory; pub use checkbox_story::CheckboxStory; pub use dropdown_story::DropdownStory; +pub use icon_story::IconStory; pub use image_story::ImageStory; pub use input_story::InputStory; pub use list_story::ListStory; diff --git a/crates/story/src/picker_story.rs b/crates/story/src/picker_story.rs index bf62e30c..11dfb041 100644 --- a/crates/story/src/picker_story.rs +++ b/crates/story/src/picker_story.rs @@ -1,16 +1,18 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; +use fake::Fake; use gpui::{ deferred, div, prelude::FluentBuilder as _, px, FocusHandle, FocusableView, - InteractiveElement as _, IntoElement, ParentElement, Render, Styled, View, ViewContext, - VisualContext as _, WeakView, WindowContext, + InteractiveElement as _, IntoElement, ParentElement, Render, Styled, Task, Timer, View, + ViewContext, VisualContext as _, WeakView, WindowContext, }; use ui::{ button::Button, h_flex, list::{List, ListDelegate, ListItem}, - v_flex, Clickable as _, IconName, StyledExt, + theme::ActiveTheme as _, + v_flex, Clickable as _, Icon, IconName, StyledExt, }; pub struct ListItemDeletegate { @@ -31,14 +33,25 @@ impl ListDelegate for ListItemDeletegate { Some(self.selected_index) } - fn perform_search(&mut self, query: &str, cx: &mut ViewContext>) { - self.matches = self - .items - .iter() - .filter(|item| item.to_lowercase().contains(&query.to_lowercase())) - .cloned() - .collect(); - cx.notify(); + fn perform_search(&mut self, query: &str, cx: &mut ViewContext>) -> Task<()> { + let query = query.to_string(); + cx.spawn(move |this, mut cx| async move { + // Simulate a slow search. + let sleep = (0.15..0.3).fake(); + Timer::after(Duration::from_secs_f64(sleep)).await; + + this.update(&mut cx, |this, cx| { + this.delegate_mut().matches = this + .delegate() + .items + .iter() + .filter(|item| item.to_lowercase().contains(&query.to_lowercase())) + .cloned() + .collect(); + cx.notify(); + }) + .ok(); + }) } fn render_item(&self, ix: usize, _cx: &mut ViewContext>) -> Option { @@ -56,6 +69,22 @@ impl ListDelegate for ListItemDeletegate { } } + fn render_empty(&self, cx: &mut ViewContext>) -> impl IntoElement { + v_flex() + .size_full() + .child( + Icon::new(IconName::Inbox) + .size(px(50.)) + .text_color(cx.theme().muted_foreground), + ) + .child("No matches found") + .items_center() + .justify_center() + .p_3() + .bg(cx.theme().muted) + .text_color(cx.theme().muted_foreground) + } + fn cancel(&mut self, cx: &mut ViewContext>) { if let Some(story) = self.story.upgrade() { cx.update_view(&story, |story, cx| { diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index df45e34a..60c9af0d 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -27,6 +27,7 @@ once_cell = "1.19.0" raw-window-handle = "0.6.2" winit = "0.30.3" wry = "0" +smol = "1" [lints] workspace = true diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index de3323e6..ff4d56ce 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -29,6 +29,7 @@ pub enum IconName { ChevronRight, Eye, EyeOff, + Inbox, } impl IconName { @@ -57,6 +58,7 @@ impl IconName { IconName::ChevronRight => "icons/chevron-right.svg", IconName::Eye => "icons/eye.svg", IconName::EyeOff => "icons/eye-off.svg", + IconName::Inbox => "icons/inbox.svg", } .into() } @@ -90,7 +92,7 @@ pub struct Icon { base: Svg, path: SharedString, text_color: Option, - size: Size, + size: Option, } impl Default for Icon { @@ -99,14 +101,18 @@ impl Default for Icon { base: svg().flex_none().size_4(), path: "".into(), text_color: None, - size: Size::Medium, + size: None, } } } impl Clone for Icon { fn clone(&self) -> Self { - Self::default().path(self.path.clone()).size(self.size) + let mut this = Self::default().path(self.path.clone()); + if let Some(size) = self.size { + this = this.size(size); + } + this } } @@ -128,7 +134,7 @@ impl Icon { /// Also can receive a `ButtonSize` to convert to `IconSize`, /// Or a `Pixels` to set a custom size: `px(30.)` pub fn size(mut self, size: impl Into) -> Self { - self.size = size.into(); + self.size = Some(size.into()); self } @@ -161,7 +167,7 @@ impl RenderOnce for Icon { self.base .text_color(text_color) - .map(|this| match self.size { + .when_some(self.size, |this, size| match size { Size::Size(px) => this.size(px), Size::XSmall => this.size_3(), Size::Small => this.size_3p5(), @@ -184,9 +190,8 @@ impl Render for Icon { svg() .flex_none() - .size_4() .text_color(text_color) - .map(|this| match self.size { + .when_some(self.size, |this, size| match size { Size::Size(px) => this.size(px), Size::XSmall => this.size_3(), Size::Small => this.size_3p5(), diff --git a/crates/ui/src/indicator.rs b/crates/ui/src/indicator.rs index 8131d4f3..f6b95492 100644 --- a/crates/ui/src/indicator.rs +++ b/crates/ui/src/indicator.rs @@ -45,7 +45,7 @@ impl RenderOnce for Indicator { let color = self.color.unwrap_or_else(|| cx.theme().indicator); div() .child( - Icon::new(self.icon) + Icon::new(self.icon.clone()) .size(self.size) .text_color(color) .with_animation( diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 0819d713..3ee7553e 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -6,16 +6,17 @@ use std::ops::Range; use super::blink_cursor::BlinkCursor; -use super::history::{Change, History}; +use super::history::History; use crate::button::{Button, ButtonStyle}; +use crate::indicator::Indicator; use crate::styled_ext::Sizeful; use crate::theme::ActiveTheme; use crate::{event::InterativeElementExt as _, Size}; use crate::{Clickable, IconName, StyledExt as _}; use gpui::prelude::FluentBuilder as _; use gpui::{ - actions, div, fill, point, px, relative, rems, size, AnyView, AppContext, Bounds, ClickEvent, - ClipboardItem, Context as _, Element, ElementId, ElementInputHandler, EventEmitter, + actions, div, fill, point, px, relative, rems, size, AnyElement, AppContext, Bounds, + ClickEvent, ClipboardItem, Context as _, Element, ElementId, ElementInputHandler, EventEmitter, FocusHandle, FocusableView, GlobalElementId, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, LayoutId, Model, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement as _, Pixels, Point, Render, ShapedLine, SharedString, Style, @@ -110,8 +111,9 @@ pub struct TextInput { text: SharedString, history: History, blink_cursor: Model, - prefix: Option, - suffix: Option, + prefix: Option AnyElement + 'static>>, + suffix: Option AnyElement + 'static>>, + loading: bool, placeholder: SharedString, selected_range: Range, selection_reversed: bool, @@ -151,6 +153,7 @@ impl TextInput { masked: false, appearance: true, cleanable: false, + loading: false, prefix: None, suffix: None, size: Size::Medium, @@ -201,6 +204,26 @@ impl TextInput { cx.notify(); } + /// Set the prefix element of the input field. + pub fn set_prefix(&mut self, builder: F, cx: &mut ViewContext) + where + F: Fn(&WindowContext) -> E + 'static, + E: IntoElement, + { + self.prefix = Some(Box::new(move |cx| builder(cx).into_any_element())); + cx.notify(); + } + + /// Set the suffix element of the input field. + pub fn set_suffix(&mut self, builder: F, cx: &mut ViewContext) + where + F: Fn(&WindowContext) -> E + 'static, + E: IntoElement, + { + self.suffix = Some(Box::new(move |cx| builder(cx).into_any_element())); + cx.notify(); + } + /// Set the appearance of the input field. pub fn appearance(mut self, appearance: bool) -> Self { self.appearance = appearance; @@ -208,8 +231,22 @@ impl TextInput { } /// Set the prefix element of the input field, for example a search Icon. - pub fn prefix(mut self, prefix: impl Into) -> Self { - self.prefix = Some(prefix.into()); + pub fn prefix(mut self, builder: F) -> Self + where + F: Fn(&WindowContext) -> E + 'static, + E: IntoElement, + { + self.prefix = Some(Box::new(move |cx| builder(cx).into_any_element())); + self + } + + /// Set the suffix element of the input field, for example a clear button. + pub fn suffix(mut self, builder: F) -> Self + where + F: Fn(&WindowContext) -> E + 'static, + E: IntoElement, + { + self.suffix = Some(Box::new(move |cx| builder(cx).into_any_element())); self } @@ -219,12 +256,6 @@ impl TextInput { self } - /// Set the suffix element of the input field, for example a clear button. - pub fn suffix(mut self, suffix: impl Into) -> Self { - self.suffix = Some(suffix.into()); - self - } - /// Set the size of the input field. pub fn size(mut self, size: impl Into) -> Self { self.size = size.into(); @@ -237,6 +268,12 @@ impl TextInput { self } + /// Set true to show indicator at the input right. + pub fn set_loading(&mut self, loading: bool, cx: &mut ViewContext) { + self.loading = loading; + cx.notify(); + } + /// Return the text of the input field. pub fn text(&self) -> SharedString { self.text.clone() @@ -908,6 +945,9 @@ impl Render for TextInput { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let focused = self.focus_handle.is_focused(cx); + let prefix = self.prefix.as_ref().map(|build| build(cx)); + let suffix = self.suffix.as_ref().map(|build| build(cx)); + div() .flex() .key_context(CONTEXT) @@ -961,7 +1001,7 @@ impl Render for TextInput { cx.theme().background }) }) - .when_some(self.prefix.clone(), |this, prefix| this.child(prefix)) + .children(prefix) .gap_1() .items_center() .child( @@ -974,16 +1014,20 @@ impl Render for TextInput { input: cx.view().clone(), }), ) - .when(self.cleanable && !self.text.is_empty(), |this| { - this.child( - Button::new("clean-text", cx) - .icon(IconName::Close) - .style(ButtonStyle::Ghost) - .size(px(15.)) - .cursor_pointer() - .on_click(cx.listener(Self::clean)), - ) - }) - .when_some(self.suffix.clone(), |this, suffix| this.child(suffix)) + .when(self.loading, |this| this.child(Indicator::new())) + .when( + self.cleanable && !self.loading && !self.text.is_empty(), + |this| { + this.child( + Button::new("clean-text", cx) + .icon(IconName::Close) + .style(ButtonStyle::Ghost) + .size(px(15.)) + .cursor_pointer() + .on_click(cx.listener(Self::clean)), + ) + }, + ) + .children(suffix) } } diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index 63f283bd..809cdc82 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -1,18 +1,18 @@ +use std::time::Duration; use std::{cell::Cell, rc::Rc}; -use gpui::prelude::FluentBuilder as _; - use crate::input::{InputEvent, TextInput}; use crate::scroll::ScrollbarState; use crate::theme::{ActiveTheme, Colorize as _}; +use crate::IconName; use crate::{scroll::Scrollbar, v_flex}; -use crate::{Icon, IconName}; use gpui::{ - actions, div, px, uniform_list, AppContext, FocusHandle, FocusableView, - InteractiveElement as _, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton, - ParentElement as _, Render, Styled as _, UniformListScrollHandle, View, ViewContext, - VisualContext as _, + actions, div, prelude::FluentBuilder as _, px, uniform_list, AppContext, FocusHandle, + FocusableView, InteractiveElement as _, IntoElement, KeyBinding, Length, ListSizingBehavior, + MouseButton, ParentElement as _, Render, Styled as _, Task, UniformListScrollHandle, View, + ViewContext, VisualContext as _, }; +use smol::Timer; actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]); @@ -33,7 +33,9 @@ pub trait ListDelegate: Sized + 'static { /// When Query Input change, this method will be called. /// You can perform search here. - fn perform_search(&mut self, query: &str, cx: &mut ViewContext>) {} + fn perform_search(&mut self, query: &str, cx: &mut ViewContext>) -> Task<()> { + Task::Ready(Some(())) + } /// Return the number of items in the list. fn items_count(&self) -> usize; @@ -43,6 +45,11 @@ pub trait ListDelegate: Sized + 'static { /// Return None will skip the item. fn render_item(&self, ix: usize, cx: &mut ViewContext>) -> Option; + /// Return a Element to show when list is empty. + fn render_empty(&self, cx: &mut ViewContext>) -> impl IntoElement { + div() + } + /// Return the confirmed index of the selected item. fn confirmed_index(&self) -> Option { None @@ -63,6 +70,8 @@ pub struct List { delegate: D, max_height: Option, query_input: Option>, + last_query: Option, + loading: bool, enable_scrollbar: bool, vertical_scroll_handle: UniformListScrollHandle, @@ -79,7 +88,7 @@ where let query_input = cx.new_view(|cx| { TextInput::new(cx) .appearance(false) - .prefix(Icon::new(IconName::Search).view(cx)) + .prefix(|_| IconName::Search) .placeholder("Search...") .cleanable(true) }); @@ -91,11 +100,13 @@ where focus_handle: cx.focus_handle(), delegate, query_input: Some(query_input), + last_query: None, selected_index: None, vertical_scroll_handle: UniformListScrollHandle::new(), scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())), max_height: None, enable_scrollbar: true, + loading: false, } } @@ -162,25 +173,57 @@ where ) { match event { InputEvent::Change(text) => { - self.delegate.perform_search(&text.trim(), cx); - cx.notify() + let text = text.trim().to_string(); + if Some(&text) == self.last_query.as_ref() { + return; + } + + self.set_loading(true, cx); + let search = self.delegate.perform_search(&text, cx); + + cx.spawn(|this, mut cx| async move { + search.await; + // Always wait 100ms to avoid flicker + Timer::after(Duration::from_millis(100)).await; + this.update(&mut cx, |this, cx| { + this.last_query = Some(text); + this.set_loading(false, cx); + }) + }) + .detach(); } InputEvent::PressEnter => self.action_confirm(&Confirm, cx), _ => {} } } + fn set_loading(&mut self, loading: bool, cx: &mut ViewContext) { + self.loading = loading; + if let Some(input) = &self.query_input { + input.update(cx, |input, cx| input.set_loading(loading, cx)) + } + cx.notify(); + } + fn action_cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { self.delegate.cancel(cx); cx.notify(); } fn action_confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + if self.delegate.items_count() == 0 { + return; + } + self.delegate.confirm(self.selected_index, cx); cx.notify(); } fn action_select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + if self.delegate.items_count() == 0 { + return; + } + let selected_index = self.selected_index.unwrap_or(0); if selected_index > 0 { self.selected_index = Some(selected_index - 1); @@ -193,6 +236,10 @@ where } fn action_select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + if self.delegate.items_count() == 0 { + return; + } + let selected_index = self.selected_index.unwrap_or(0); if selected_index < self.delegate.items_count() - 1 { self.selected_index = Some(selected_index + 1); @@ -261,39 +308,44 @@ where .min_h(px(100.)) .when_some(self.max_height, |this, h| this.max_h(h)) .overflow_hidden() - .child( - uniform_list(view, "uniform-list", items_count, { - move |list, visible_range, cx| { - visible_range - .map(|ix| { - div() - .id("list-item") - .w_full() - .children(list.delegate.render_item(ix, cx)) - .when_some( - list.selected_index, - |this, selected_index| { - this.when(ix == selected_index, |this| { - this.bg(selected_bg) - }) - }, - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, cx| { - this.selected_index = Some(ix); - this.action_confirm(&Confirm, cx); - }), - ) - }) - .collect::>() - } - }) - .flex_grow() - .with_sizing_behavior(sizing_behavior) - .track_scroll(vertical_scroll_handle) - .into_any_element(), - ) + .when(items_count == 0, |this| { + this.child(self.delegate().render_empty(cx)) + }) + .when(items_count > 0, |this| { + this.child( + uniform_list(view, "uniform-list", items_count, { + move |list, visible_range, cx| { + visible_range + .map(|ix| { + div() + .id("list-item") + .w_full() + .children(list.delegate.render_item(ix, cx)) + .when_some( + list.selected_index, + |this, selected_index| { + this.when(ix == selected_index, |this| { + this.bg(selected_bg) + }) + }, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, cx| { + this.selected_index = Some(ix); + this.action_confirm(&Confirm, cx); + }), + ) + }) + .collect::>() + } + }) + .flex_grow() + .with_sizing_behavior(sizing_behavior) + .track_scroll(vertical_scroll_handle) + .into_any_element(), + ) + }) .children(self.render_scrollbar(cx)), ) }