From 07e1296916d55f68005e2188fd107e335e294ec4 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 16 Jul 2024 19:44:16 +0800 Subject: [PATCH] Add cleanable to Input (#36) --- crates/story/src/dropdown_story.rs | 22 +++- crates/story/src/input_story.rs | 7 +- crates/story/src/list_story.rs | 7 ++ crates/story/src/picker_story.rs | 7 ++ crates/ui/src/dropdown.rs | 177 ++++++++++++++++++++--------- crates/ui/src/input.rs | 34 +++++- crates/ui/src/list/list.rs | 23 +++- crates/ui/src/styled_ext.rs | 2 +- 8 files changed, 220 insertions(+), 59 deletions(-) diff --git a/crates/story/src/dropdown_story.rs b/crates/story/src/dropdown_story.rs index 7672516f..ae0fa8b5 100644 --- a/crates/story/src/dropdown_story.rs +++ b/crates/story/src/dropdown_story.rs @@ -113,6 +113,8 @@ impl DropdownStory { cx, ) .size(ui::Size::Small) + .placeholder("UI") + .title_prefix("UI: ") }), simple_dropdown2: cx.new_view(|cx| { Dropdown::string_list( @@ -127,6 +129,8 @@ impl DropdownStory { cx, ) .size(ui::Size::Small) + .placeholder("Language") + .title_prefix("Language: ") }), }) } @@ -152,7 +156,7 @@ impl Render for DropdownStory { .child(self.furit_dropdown.clone()), ) .child( - h_flex() + v_flex() .w_full() .items_center() .p_10() @@ -161,6 +165,22 @@ impl Render for DropdownStory { .border_1() .border_color(cx.theme().border) .gap_4() + .child(format!( + "Country: {:?}", + self.country_dropdown.read(cx).selected_value() + )) + .child(format!( + "Furit: {:?}", + self.furit_dropdown.read(cx).selected_value() + )) + .child(format!( + "UI: {:?}", + self.simple_dropdown1.read(cx).selected_value() + )) + .child(format!( + "Language: {:?}", + self.simple_dropdown2.read(cx).selected_value() + )) .child("This is other text."), ) .child( diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index cbfc66cc..47ceed54 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -37,13 +37,13 @@ impl InputStory { fn new(cx: &mut WindowContext) -> Self { let input1 = cx.new_view(|cx| { - let mut input = TextInput::new(cx); + let mut input = TextInput::new(cx).cleanable(true); input.set_text("Hello 世界", cx); input }); let mask_input = cx.new_view(|cx| { - let mut input = TextInput::new(cx); + let mut input = TextInput::new(cx).cleanable(true); input.set_masked(true, cx); input.set_text("this-is-password", cx); input @@ -53,16 +53,19 @@ impl InputStory { TextInput::new(cx) .prefix(IconName::Search.view(cx)) .placeholder("Search some thing...") + .cleanable(true) }); let suffix_input1 = cx.new_view(|cx| { TextInput::new(cx) .suffix(IconName::Info.view(cx)) .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)) + .cleanable(true) .placeholder("This input have prefix and suffix.") }); diff --git a/crates/story/src/list_story.rs b/crates/story/src/list_story.rs index c07b3cd3..990f0dde 100644 --- a/crates/story/src/list_story.rs +++ b/crates/story/src/list_story.rs @@ -154,6 +154,13 @@ impl ListDelegate for CompanyListDelegate { cx.dispatch_action(Box::new(SelectedCompany)); } + fn set_selected_index(&mut self, ix: Option, cx: &mut ViewContext>) { + if let Some(ix) = ix { + self.selected_index = ix; + cx.notify(); + } + } + fn render_item(&self, ix: usize, _cx: &mut ViewContext>) -> Option { let selected = ix == self.selected_index; if let Some(company) = self.companies.get(ix) { diff --git a/crates/story/src/picker_story.rs b/crates/story/src/picker_story.rs index c50bfa31..9e9c7814 100644 --- a/crates/story/src/picker_story.rs +++ b/crates/story/src/picker_story.rs @@ -79,6 +79,13 @@ impl ListDelegate for ListItemDeletegate { }); } } + + fn set_selected_index(&mut self, ix: Option, cx: &mut ViewContext>) { + if let Some(ix) = ix { + self.selected_index = ix; + cx.notify(); + } + } } pub struct PickerStory { diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index cf606b39..9bea8ffd 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -2,10 +2,10 @@ use std::rc::Rc; use gpui::{ actions, deferred, div, prelude::FluentBuilder as _, px, rems, AnyElement, AppContext, - DismissEvent, Element, ElementId, EventEmitter, FocusHandle, FocusableView, InteractiveElement, - IntoElement, KeyBinding, LayoutId, ParentElement as _, Render, SharedString, - StatefulInteractiveElement as _, Styled as _, View, ViewContext, VisualContext as _, WeakView, - WindowContext, + ClickEvent, DismissEvent, Element, ElementId, EventEmitter, FocusHandle, FocusableView, + InteractiveElement, IntoElement, KeyBinding, LayoutId, ParentElement as _, Render, + SharedString, StatefulInteractiveElement as _, Styled as _, View, ViewContext, + VisualContext as _, WeakView, WindowContext, }; actions!(dropdown, [Up, Down, Enter, Escape]); @@ -21,11 +21,12 @@ pub fn init(cx: &mut AppContext) { } use crate::{ + button::{Button, ButtonStyle}, h_flex, list::{self, List, ListDelegate, ListItem}, styled_ext::Sizeful, theme::ActiveTheme, - Icon, IconName, Size, StyledExt, + Clickable as _, Icon, IconName, Size, StyledExt, }; /// A trait for items that can be displayed in a dropdown. @@ -116,19 +117,19 @@ where self.selected_index = ix; if let Some(view) = self.dropdown.upgrade() { - cx.update_view(&view, |view, cx| { - if let Some(ix) = self.selected_index { - if let Some(item) = self.delegate.get(ix) { - view.title = Some(item.title().to_string().into()); - view.value = Some(item.value().to_string().into()); - } - } - + cx.update_view(&view, |view, _| { + view.selected_value = self + .selected_index + .and_then(|ix| self.delegate.get(ix)) + .map(|item| item.value().to_string().into()); view.open = false; - view.focus_handle.focus(cx); }); } } + + fn set_selected_index(&mut self, ix: Option, _: &mut ViewContext>) { + self.selected_index = ix; + } } pub struct StringDropdownDelegate { @@ -151,9 +152,10 @@ pub struct Dropdown { list: View>>, size: Size, open: bool, - /// The value of the selected item. - value: Option, - title: Option, + cleanable: bool, + placeholder: SharedString, + title_prefix: Option, + selected_value: Option, } impl Dropdown @@ -172,30 +174,20 @@ where selected_index, }; - let (title, value) = if let Some(selected_index) = selected_index { - let title: Option = delegate - .delegate - .get(selected_index) - .map(|item| item.title().to_string().into()); - let value: Option = delegate - .delegate - .get(selected_index) - .map(|item| item.value().to_string().into()); - (title, value) - } else { - (None, None) - }; - let list = cx.new_view(|cx| List::new(delegate, cx).no_query().max_h(rems(20.))); - Self { + let mut this = Self { id: id.into(), focus_handle: cx.focus_handle(), + placeholder: "Select...".into(), list, size: Size::Medium, + selected_value: None, open: false, - title, - value, - } + cleanable: true, + title_prefix: None, + }; + this.update_selected_value(cx); + this } pub fn size(mut self, size: Size) -> Self { @@ -203,13 +195,52 @@ where self } - pub fn set_value(&mut self, value: impl Into, cx: &mut ViewContext) { - self.value = Some(value.into()); - cx.notify(); + /// Set the placeholder for display when dropdown value is empty. + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = placeholder.into(); + self } - pub fn value(&self) -> Option { - self.value.clone() + /// Set title prefix for the dropdown. + /// + /// e.g.: Country: United States + /// + /// You should set the label is `Country: ` + pub fn title_prefix(mut self, prefix: impl Into) -> Self { + self.title_prefix = Some(prefix.into()); + self + } + + /// Set true to show the clear button when the input field is not empty. + pub fn cleanable(mut self, cleanable: bool) -> Self { + self.cleanable = cleanable; + self + } + + pub fn set_selected_index( + &mut self, + selected_index: Option, + cx: &mut ViewContext, + ) { + self.list.update(cx, |list, cx| { + list.set_selected_index(selected_index, cx); + }); + self.update_selected_value(cx); + } + + pub fn selected_index(&self, cx: &WindowContext) -> Option { + self.list.read(cx).selected_index() + } + + fn update_selected_value(&mut self, cx: &WindowContext) { + self.selected_value = self + .selected_index(cx) + .and_then(|ix| self.list.read(cx).delegate().delegate.get(ix)) + .map(|item| item.value().to_string().into()); + } + + pub fn selected_value(&self) -> Option { + self.selected_value.clone() } fn up(&mut self, _: &Up, cx: &mut ViewContext) { @@ -239,11 +270,20 @@ where } } + fn toggle_menu(&mut self, _: &ClickEvent, cx: &mut ViewContext) { + self.open = !self.open; + cx.notify(); + } + fn escape(&mut self, _: &Escape, cx: &mut ViewContext) { self.open = false; cx.notify(); } + fn clean(&mut self, _: &ClickEvent, cx: &mut ViewContext) { + self.set_selected_index(None, cx) + } + fn render_menu_content(&self, cx: &WindowContext) -> impl IntoElement { div() .absolute() @@ -259,6 +299,31 @@ where cx.dispatch_action(Box::new(Escape)); }) } + + fn display_title(&self, cx: &WindowContext) -> impl IntoElement { + if let Some(selected_index) = &self.selected_index(cx) { + let title = self + .list + .read(cx) + .delegate() + .delegate + .get(*selected_index) + .map(|item| item.title().to_string()) + .unwrap(); + + h_flex() + .children(self.title_prefix.clone().map(|prefix| { + div() + .text_color(cx.theme().accent_foreground) + .child(prefix.clone()) + })) + .child(title.clone()) + } else { + div() + .text_color(cx.theme().accent_foreground) + .child(self.placeholder.clone()) + } + } } impl Dropdown { @@ -291,10 +356,11 @@ where D: DropdownDelegate + 'static, { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let title = self.title.clone().unwrap_or_else(|| "Select...".into()); let focused = self.focus_handle.is_focused(cx); + let show_clean = self.cleanable && self.selected_index(cx).is_some(); div() + .id(self.id.clone()) .key_context("Dropdown") .group(format!("dropdown-group:{}", self.id)) .track_focus(&self.focus_handle) @@ -321,24 +387,33 @@ where .input_px(self.size) .input_py(self.size) .input_h(self.size) - .on_click(cx.listener(|this, _, cx| { - this.open = !this.open; - cx.notify(); - })) + .on_click(cx.listener(Self::toggle_menu)) .child( h_flex() .w_full() .items_center() .justify_between() - .child(div().flex_1().child(title)) - .child( - Icon::new(IconName::ChevronDown) - .text_color(cx.theme().muted_foreground), - ), + .child(div().flex_1().child(self.display_title(cx))) + .when(show_clean, |this| { + this.child( + Button::new("clean-text", cx) + .icon(IconName::Close) + .style(ButtonStyle::Ghost) + .size(px(14.)) + .cursor_pointer() + .on_click(cx.listener(Self::clean)), + ) + }) + .when(!show_clean, |this| { + this.child( + Icon::new(IconName::ChevronDown) + .text_color(cx.theme().muted_foreground), + ) + }), ), ) .child(DropdownMenuElement { - id: "dropdown-menu".into(), + id: ElementId::Name(format!("dropdown-menu:{}", self.id).into()), dropdown: cx.view().clone(), }) } diff --git a/crates/ui/src/input.rs b/crates/ui/src/input.rs index b63d9701..e9533482 100644 --- a/crates/ui/src/input.rs +++ b/crates/ui/src/input.rs @@ -5,14 +5,15 @@ use std::ops::Range; +use crate::button::{Button, ButtonStyle}; use crate::styled_ext::Sizeful; use crate::theme::ActiveTheme; -use crate::StyledExt as _; use crate::{event::InterativeElementExt as _, Size}; +use crate::{Clickable, IconName, StyledExt as _}; use blink_cursor::BlinkCursor; use gpui::{ actions, div, fill, point, prelude, px, relative, rems, size, AnyView, AppContext, Bounds, - ClipboardItem, Context as _, Element, ElementId, ElementInputHandler, EventEmitter, + 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, @@ -103,6 +104,7 @@ pub struct TextInput { disabled: bool, masked: bool, appearance: bool, + cleanable: bool, size: Size, } @@ -126,6 +128,7 @@ impl TextInput { disabled: false, masked: false, appearance: true, + cleanable: false, prefix: None, suffix: None, size: Size::Medium, @@ -202,12 +205,19 @@ impl TextInput { self } + /// Set true to show the clear button when the input field is not empty. + pub fn cleanable(mut self, cleanable: bool) -> Self { + self.cleanable = cleanable; + self + } + /// Return the text of the input field. pub fn text(&self) -> SharedString { self.text.clone() } fn left(&mut self, _: &Left, cx: &mut ViewContext) { + self.pause_blink_cursor(cx); if self.selected_range.is_empty() { self.move_to(self.previous_boundary(self.cursor_offset()), cx); } else { @@ -216,6 +226,7 @@ impl TextInput { } fn right(&mut self, _: &Right, cx: &mut ViewContext) { + self.pause_blink_cursor(cx); if self.selected_range.is_empty() { self.move_to(self.next_boundary(self.selected_range.end), cx); } else { @@ -237,10 +248,12 @@ impl TextInput { } fn home(&mut self, _: &Home, cx: &mut ViewContext) { + self.pause_blink_cursor(cx); self.move_to(0, cx); } fn end(&mut self, _: &End, cx: &mut ViewContext) { + self.pause_blink_cursor(cx); self.move_to(self.text.len(), cx); } @@ -256,13 +269,18 @@ impl TextInput { if self.selected_range.is_empty() { self.select_to(self.next_boundary(self.cursor_offset()), cx) } - self.replace_text_in_range(None, "", cx) + self.replace_text_in_range(None, "", cx); + self.pause_blink_cursor(cx); } fn enter(&mut self, _: &Enter, cx: &mut ViewContext) { cx.emit(TextEvent::PressEnter); } + fn clean(&mut self, _: &ClickEvent, cx: &mut ViewContext) { + self.set_text("", cx); + } + fn on_mouse_down(&mut self, event: &MouseDownEvent, cx: &mut ViewContext) { self.is_selecting = true; @@ -784,6 +802,16 @@ 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(14.)) + .cursor_pointer() + .on_click(cx.listener(Self::clean)), + ) + }) .when_some(self.suffix.clone(), |this, suffix| this.child(suffix)) } } diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index e19e8923..5000569d 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -26,14 +26,21 @@ pub fn init(cx: &mut AppContext) { ]); } +/// A delegate for the List. #[allow(unused)] pub trait ListDelegate: Sized + 'static { type Item: IntoElement; + /// When Query Input change, this method will be called. + /// You can perform search here. fn perform_search(&mut self, query: &str, cx: &mut ViewContext>) {} /// Return the number of items in the list. fn items_count(&self) -> usize; + + /// Render the item at the given index. + /// + /// Return None will skip the item. fn render_item(&self, ix: usize, cx: &mut ViewContext>) -> Option; /// Return the confirmed index of the selected item. @@ -41,8 +48,13 @@ pub trait ListDelegate: Sized + 'static { None } - /// Set the confirm and give the selected index. + /// Set the selected index, just store the ix, don't confirm. + fn set_selected_index(&mut self, ix: Option, cx: &mut ViewContext>); + + /// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter. fn confirm(&mut self, ix: Option, cx: &mut ViewContext>) {} + + /// Cancel the selection, e.g.: Pressed ESC. fn cancel(&mut self, cx: &mut ViewContext>) {} } @@ -113,6 +125,15 @@ where cx.focus(&self.focus_handle); } + pub fn set_selected_index(&mut self, ix: Option, cx: &mut ViewContext) { + self.selected_index = ix; + self.delegate.set_selected_index(ix, cx); + } + + pub fn selected_index(&self) -> Option { + self.selected_index + } + fn render_scrollbar(&self, cx: &mut ViewContext) -> Option { if !self.enable_scrollbar { return None; diff --git a/crates/ui/src/styled_ext.rs b/crates/ui/src/styled_ext.rs index ddd5a483..97b55e0d 100644 --- a/crates/ui/src/styled_ext.rs +++ b/crates/ui/src/styled_ext.rs @@ -182,7 +182,7 @@ impl Sizeful for T { fn input_h(self, size: Size) -> Self { match size { Size::Large => self.h_11().text_size(rems(1.)), - Size::Medium => self.h_8().text_size(rems(0.85)), + Size::Medium => self.h_8().text_size(rems(0.875)), _ => self.h(px(26.)).text_size(rems(0.8)), } }