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)),
)
}