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.
This commit is contained in:
Jason Lee 2024-07-22 15:30:07 +08:00 committed by GitHub
parent 4687a18ee4
commit 5034152f21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 278 additions and 96 deletions

1
Cargo.lock generated
View file

@ -5699,6 +5699,7 @@ dependencies = [
"serde",
"serde_json",
"smallvec",
"smol",
"taffy",
"unicode-segmentation",
"usvg",

1
assets/icons/inbox.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-inbox"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>

After

Width:  |  Height:  |  Size: 388 B

View file

@ -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",

View file

@ -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<Self> {
cx.new_view(|cx| Self::new(cx))
}
}
impl Render for IconStory {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> 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.)),
),
)
}
}

View file

@ -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.")
});

View file

@ -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;

View file

@ -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<List<Self>>) {
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<List<Self>>) -> 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<List<Self>>) -> Option<Self::Item> {
@ -56,6 +69,22 @@ impl ListDelegate for ListItemDeletegate {
}
}
fn render_empty(&self, cx: &mut ViewContext<List<Self>>) -> 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<List<Self>>) {
if let Some(story) = self.story.upgrade() {
cx.update_view(&story, |story, cx| {

View file

@ -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

View file

@ -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<Hsla>,
size: Size,
size: Option<Size>,
}
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<Size>) -> 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(),

View file

@ -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(

View file

@ -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<BlinkCursor>,
prefix: Option<AnyView>,
suffix: Option<AnyView>,
prefix: Option<Box<dyn Fn(&WindowContext) -> AnyElement + 'static>>,
suffix: Option<Box<dyn Fn(&WindowContext) -> AnyElement + 'static>>,
loading: bool,
placeholder: SharedString,
selected_range: Range<usize>,
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<F, E>(&mut self, builder: F, cx: &mut ViewContext<Self>)
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<F, E>(&mut self, builder: F, cx: &mut ViewContext<Self>)
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<AnyView>) -> Self {
self.prefix = Some(prefix.into());
pub fn prefix<F, E>(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<F, E>(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<AnyView>) -> Self {
self.suffix = Some(suffix.into());
self
}
/// Set the size of the input field.
pub fn size(mut self, size: impl Into<Size>) -> 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>) {
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<Self>) -> 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)
}
}

View file

@ -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<List<Self>>) {}
fn perform_search(&mut self, query: &str, cx: &mut ViewContext<List<Self>>) -> 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<List<Self>>) -> Option<Self::Item>;
/// Return a Element to show when list is empty.
fn render_empty(&self, cx: &mut ViewContext<List<Self>>) -> impl IntoElement {
div()
}
/// Return the confirmed index of the selected item.
fn confirmed_index(&self) -> Option<usize> {
None
@ -63,6 +70,8 @@ pub struct List<D: ListDelegate> {
delegate: D,
max_height: Option<Length>,
query_input: Option<View<TextInput>>,
last_query: Option<String>,
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>) {
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>) {
self.delegate.cancel(cx);
cx.notify();
}
fn action_confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
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<Self>) {
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<Self>) {
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::<Vec<_>>()
}
})
.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::<Vec<_>>()
}
})
.flex_grow()
.with_sizing_behavior(sizing_behavior)
.track_scroll(vertical_scroll_handle)
.into_any_element(),
)
})
.children(self.render_scrollbar(cx)),
)
}