diff --git a/README.md b/README.md index c48eb0dc..684b3daf 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ cargo run There have a part of UI components from [Zed](https://github.com/zed-industries/zed/tree/main/crates/ui), that are under GPL v3.0 license. - title_bar +- picker > I think we can discuss them with Zed team to change the license to MIT or Apache License for share to community. diff --git a/crates/ui-story/src/lib.rs b/crates/ui-story/src/lib.rs index c0f8c564..bf496d65 100644 --- a/crates/ui-story/src/lib.rs +++ b/crates/ui-story/src/lib.rs @@ -6,6 +6,7 @@ use gpui::{ RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, View, ViewContext, VisualContext, WindowContext, }; +use picker_story::PickerStory; use switch_story::SwitchStory; mod button_story; @@ -74,6 +75,7 @@ enum StoryType { Input, Checkbox, Switch, + Picker, } impl Display for StoryType { @@ -83,6 +85,7 @@ impl Display for StoryType { Self::Input => write!(f, "Input"), Self::Checkbox => write!(f, "Checkbox"), Self::Switch => write!(f, "Switch"), + Self::Picker => write!(f, "Picker"), } } } @@ -93,6 +96,7 @@ pub struct Stories { button_story: View, input_story: View, switch_story: View, + picker_story: View, } impl Stories { @@ -102,6 +106,7 @@ impl Stories { button_story: cx.new_view(|cx| ButtonStory {}), input_story: cx.new_view(|cx| InputStory::new(cx)), switch_story: cx.new_view(|cx| SwitchStory::new(cx)), + picker_story: cx.new_view(|cx| PickerStory::new(cx)), } } @@ -125,6 +130,7 @@ impl Stories { self.swith_button("story-input", StoryType::Input, cx), self.swith_button("story-checkbox", StoryType::Checkbox, cx), self.swith_button("story-switch", StoryType::Switch, cx), + self.swith_button("story-picker", StoryType::Picker, cx), ])) } @@ -158,6 +164,7 @@ impl Render for Stories { StoryType::Input => this.child(self.input_story.clone()), StoryType::Checkbox => this.child(CheckboxStory::new(cx).into_any_element()), StoryType::Switch => this.child(self.switch_story.clone()), + StoryType::Picker => this.child(self.picker_story.clone()), }) } } diff --git a/crates/ui-story/src/picker_story.rs b/crates/ui-story/src/picker_story.rs index e69de29b..a7647625 100644 --- a/crates/ui-story/src/picker_story.rs +++ b/crates/ui-story/src/picker_story.rs @@ -0,0 +1,205 @@ +use gpui::{ + div, prelude::FluentBuilder as _, px, Div, Empty, Entity, InteractiveElement as _, IntoElement, + ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext as _, + WindowContext, +}; + +use ui::{ + button::Button, + h_flex, + label::Label, + picker::{Picker, PickerDelegate}, + switch::{LabelSide, Switch}, + theme::{ActiveTheme, Colorize}, + v_flex, Clickable as _, Disableable as _, StyledExt, +}; + +use super::story_case; + +pub struct ListItemDeletegate { + selected_index: usize, + items: Vec, + matches: Vec, +} + +impl PickerDelegate for ListItemDeletegate { + type ListItem = Div; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, index: usize, _cx: &mut ViewContext>) { + self.selected_index = index + } + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option { + if let Some(item) = self.matches.get(ix) { + let list_item = div() + .py_1() + .px_3() + .when(!selected, |this| { + this.hover(|this| this.bg(cx.theme().card)) + }) + .child(item.clone()) + .text_base() + .text_color(cx.theme().foreground) + .when(selected, |this| this.bg(cx.theme().card.lighten(0.1))); + Some(list_item) + } else { + None + } + } + + fn update_matches( + &mut self, + query: &str, + cx: &mut ViewContext>, + ) -> gpui::Task<()> { + let matched_items = self + .items + .iter() + .filter(|item| item.contains(query)) + .cloned() + .collect(); + + self.matches = matched_items; + + Task::ready(()) + } +} + +pub struct PickerStory { + picker: View>, + open: bool, + selected_value: Option, +} + +impl PickerStory { + pub(crate) fn new(cx: &mut WindowContext) -> Self { + let items = [ + "Baguette (France)", + "Baklava (Turkey)", + "Beef Wellington (UK)", + "Biryani (India)", + "Borscht (Ukraine)", + "Bratwurst (Germany)", + "Bulgogi (Korea)", + "Burrito (USA)", + "Ceviche (Peru)", + "Chicken Tikka Masala (India)", + "Churrasco (Brazil)", + "Couscous (North Africa)", + "Croissant (France)", + "Dim Sum (China)", + "Empanada (Argentina)", + "Fajitas (Mexico)", + "Falafel (Middle East)", + "Feijoada (Brazil)", + "Fish and Chips (UK)", + "Fondue (Switzerland)", + "Goulash (Hungary)", + "Haggis (Scotland)", + "Kebab (Middle East)", + "Kimchi (Korea)", + "Lasagna (Italy)", + "Maple Syrup Pancakes (Canada)", + "Moussaka (Greece)", + "Pad Thai (Thailand)", + "Paella (Spain)", + "Pancakes (USA)", + "Pasta Carbonara (Italy)", + "Pavlova (Australia)", + "Peking Duck (China)", + "Pho (Vietnam)", + "Pierogi (Poland)", + "Pizza (Italy)", + "Poutine (Canada)", + "Pretzel (Germany)", + "Ramen (Japan)", + "Rendang (Indonesia)", + "Sashimi (Japan)", + "Satay (Indonesia)", + "Shepherd's Pie (Ireland)", + "Sushi (Japan)", + "Tacos (Mexico)", + "Tandoori Chicken (India)", + "Tortilla (Spain)", + "Tzatziki (Greece)", + "Wiener Schnitzel (Austria)", + ]; + + let picker = cx.new_view(|cx| { + let items: Vec = items.iter().map(|s| s.to_string()).collect(); + + let mut picker = Picker::uniform_list( + ListItemDeletegate { + selected_index: 0, + matches: items.clone(), + items, + }, + cx, + ) + .modal(true) + .max_height(Some(px(350.0).into())); + picker.focus(cx); + picker.set_query("c", cx); + picker + }); + + Self { + picker, + open: false, + selected_value: None, + } + } +} + +impl Render for PickerStory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + story_case("Picker", "Picker is a list of items that can be selected.") + .child(v_flex().items_start().child( + Button::new("show-picker", "Show Picker...").on_click(cx.listener( + |this, _, cx| { + this.open = !this.open; + cx.notify(); + }, + )), + )) + .when_some(self.selected_value.clone(), |this, selected_value| { + this.child("Selected: ").child(Label::new(selected_value)) + }) + .when(self.open, |this| { + this.child( + div().absolute().size_full().top_0().left_0().child( + v_flex() + // .h(px(0.0)) + .top_10() + .flex() + .flex_col() + .items_center() + .track_focus(&self.picker.focus_handle(cx)) + .child( + h_flex() + .w(px(450.)) + .occlude() + .child(self.picker.clone()) + .on_mouse_down_out(cx.listener(|this, _, cx| { + this.open = false; + cx.notify(); + })), + ), + ), + ) + }) + } +} diff --git a/crates/ui/src/divider.rs b/crates/ui/src/divider.rs new file mode 100644 index 00000000..010a8a95 --- /dev/null +++ b/crates/ui/src/divider.rs @@ -0,0 +1,43 @@ +use gpui::IntoElement; +use gpui::{div, prelude::FluentBuilder as _, Div, RenderOnce, Styled as _}; + +use crate::theme::ActiveTheme; +use crate::StyledExt as _; + +enum Orientation { + Vertical, + Horizontal, +} + +#[derive(IntoElement)] +pub struct Divider { + orientation: Orientation, +} + +impl Divider { + pub fn vertical() -> Self { + Self { + orientation: Orientation::Vertical, + } + } + + pub fn horizontal() -> Self { + Self { + orientation: Orientation::Horizontal, + } + } +} + +impl RenderOnce for Divider { + fn render(self, cx: &mut gpui::WindowContext) -> impl gpui::IntoElement { + let theme = cx.theme(); + + div() + .map(|this| match self.orientation { + Orientation::Vertical => this.v_flex().w_0().h_full(), + Orientation::Horizontal => this.h_flex().h_0().w_full(), + }) + .border_1() + .border_color(theme.border) + } +} diff --git a/crates/ui/src/empty.rs b/crates/ui/src/empty.rs new file mode 100644 index 00000000..fc2a9813 --- /dev/null +++ b/crates/ui/src/empty.rs @@ -0,0 +1,29 @@ +use gpui::{ + div, AppContext, FocusHandle, FocusableView, InteractiveElement, IntoElement, Render, + ViewContext, +}; + +/// An invisible element that can hold focus. +pub(crate) struct Empty { + focus_handle: FocusHandle, +} + +impl Empty { + pub(crate) fn new(cx: &mut ViewContext) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } +} + +impl Render for Empty { + fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { + div().track_focus(&self.focus_handle) + } +} + +impl FocusableView for Empty { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 08508f30..26708e8e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -16,7 +16,6 @@ pub mod theme; pub mod title_bar; pub use styled_ext::StyledExt; pub mod divider; -pub mod dropdown; pub mod picker; pub mod switch; pub mod tab; diff --git a/crates/ui/src/picker.rs b/crates/ui/src/picker.rs new file mode 100644 index 00000000..ace04ccf --- /dev/null +++ b/crates/ui/src/picker.rs @@ -0,0 +1,490 @@ +use std::time::Duration; + +use anyhow::Result; +use gpui::{ + actions, div, list, prelude::FluentBuilder as _, px, rems, uniform_list, AppContext, + ClickEvent, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, + InteractiveElement as _, IntoElement, Length, ListSizingBehavior, ListState, MouseButton, + MouseUpEvent, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, + Styled as _, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, + WindowContext, +}; + +actions!( + picker, + [ + UseSelectedQuery, + Cancel, + Confirm, + SecondaryConfirm, + SelectPrev, + SelectNext, + SelectFirst, + SelectLast, + ] +); + +use crate::{ + divider::Divider, empty::Empty, label::Label, stock::*, text_field::TextField, + theme::ActiveTheme, StyledExt as _, +}; + +enum ElementContainer { + List(ListState), + UniformList(UniformListScrollHandle), +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +enum ContainerKind { + List, + UniformList, +} + +pub trait PickerDelegate: Sized + 'static { + type ListItem: IntoElement; + + fn match_count(&self) -> usize; + fn selected_index(&self) -> usize; + fn set_selected_index(&mut self, index: usize, cx: &mut ViewContext>); + fn selected_index_changed( + &self, + _ix: usize, + _cx: &mut ViewContext>, + ) -> Option> { + None + } + + fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>) {} + fn dismissed(&mut self, cx: &mut ViewContext>) {} + fn should_dismiss(&self) -> bool { + return true; + } + fn render_query(&self, input: &View, _cx: &mut ViewContext>) -> Div { + v_flex() + .child( + h_flex() + .overflow_hidden() + .flex_none() + .h_9() + .px_4() + .child(input.clone()), + ) + .child(Divider::horizontal()) + } + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option; + + fn separators_after_indices(&self) -> Vec { + Vec::new() + } + fn update_matches(&mut self, query: &str, cx: &mut ViewContext>) -> Task<()>; + fn confirm_update_query(&mut self, _cx: &mut ViewContext>) -> Option { + None + } + fn finalize_update_matches( + &mut self, + _query: String, + _duration: Duration, + _cx: &mut ViewContext>, + ) -> bool { + false + } + fn selected_as_query(&self) -> Option { + None + } +} + +impl FocusableView for Picker { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + if let Some(input) = &self.query_input { + input.focus_handle(cx) + } else { + self.head.focus_handle(cx) + } + } +} + +impl EventEmitter for Picker {} + +struct PendingUpdateMatches { + delegate_update_matches: Option>, + _task: Task>, +} + +pub struct Picker { + delegate: D, + element_container: ElementContainer, + query_input: Option>, + width: Option, + max_height: Option, + is_modal: bool, + head: View, + pending_update_matches: Option, + confirm_on_update: Option, +} + +impl Picker { + fn new( + delegate: D, + kind: ContainerKind, + query_input: Option>, + cx: &mut ViewContext, + ) -> Self { + let element_container = match kind { + ContainerKind::List => { + let view = cx.view().downgrade(); + ElementContainer::List(ListState::new( + 0, + gpui::ListAlignment::Top, + px(1000.), + move |ix, cx| { + view.upgrade() + .map(|view| { + view.update(cx, |this, cx| { + this.render_element(cx, ix).into_any_element() + }) + }) + .unwrap_or_else(|| div().into_any_element()) + }, + )) + } + ContainerKind::UniformList => { + ElementContainer::UniformList(UniformListScrollHandle::new()) + } + }; + + Self { + delegate, + query_input: None, + head: cx.new_view(Empty::new), + width: None, + is_modal: false, + max_height: Some(rems(20.).into()), + element_container, + pending_update_matches: None, + confirm_on_update: None, + } + } + + fn new_query_input( + placehoder: impl Into, + cx: &mut ViewContext, + ) -> View { + cx.new_view(|cx| { + let mut input = TextField::new(cx); + input.set_placeholder(placehoder, cx); + input + }) + } + + 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) + } + + pub fn uniform_list(delegate: D, cx: &mut ViewContext) -> Self { + let query_input = Self::new_query_input("Search...", cx); + + Self::new(delegate, ContainerKind::UniformList, Some(query_input), cx) + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } + + pub fn max_height(mut self, max_height: Option) -> Self { + self.max_height = max_height; + self + } + + pub fn modal(mut self, modal: bool) -> Self { + self.is_modal = modal; + self + } + + pub fn focus(&self, cx: &mut WindowContext) { + self.focus_handle(cx).focus(cx); + } + + pub fn set_query(&mut self, query: &str, cx: &mut ViewContext) { + if let Some(input) = &self.query_input { + input.update(cx, |this, cx| this.set_text(query, cx)); + } + } + + /// Return the query input string. + pub fn query(&self, cx: &AppContext) -> String { + if let Some(input) = &self.query_input { + input.read(cx).text(cx) + } else { + String::new() + } + } + + fn render_element_container(&self, cx: &mut ViewContext) -> impl IntoElement { + let sizing_behavior = if self.max_height.is_some() { + ListSizingBehavior::Infer + } else { + ListSizingBehavior::Auto + }; + + match &self.element_container { + ElementContainer::UniformList(scroll_handle) => uniform_list( + cx.view().clone(), + "candidates", + self.delegate.match_count(), + move |picker, visible_range, cx| { + visible_range + .map(|ix| picker.render_element(cx, ix)) + .collect() + }, + ) + .with_sizing_behavior(sizing_behavior) + .flex_grow() + .py_2() + .track_scroll(scroll_handle.clone()) + .into_any_element(), + ElementContainer::List(state) => list(state.clone()) + .with_sizing_behavior(sizing_behavior) + .flex_grow() + .py_2() + .into_any_element(), + } + } + + fn render_element(&self, cx: &mut ViewContext, ix: usize) -> impl IntoElement { + div() + .id(("item", ix)) + .cursor_pointer() + .on_click(cx.listener(move |this, event: &ClickEvent, cx| { + this.on_click(ix, event.down.modifiers.secondary(), cx) + })) + // As of this writing, GPUI intercepts `ctrl-[mouse-event]`s on macOS + // and produces right mouse button events. This matches platforms norms + // but means that UIs which depend on holding ctrl down (such as the tab + // switcher) can't be clicked on. Hence, this handler. + .on_mouse_up( + MouseButton::Right, + cx.listener(move |this, event: &MouseUpEvent, cx| { + // We specficially want to use the platform key here, as + // ctrl will already be held down for the tab switcher. + this.on_click(ix, event.modifiers.platform, cx) + }), + ) + .children( + self.delegate + .render_match(ix, ix == self.delegate.selected_index(), cx), + ) + .when( + self.delegate.separators_after_indices().contains(&ix), + |picker| { + picker + .border_color(cx.theme().border) + .border_b_1() + .pb(px(-1.0)) + }, + ) + } + + fn on_click(&mut self, ix: usize, secondary: bool, cx: &mut ViewContext) { + cx.stop_propagation(); + cx.prevent_default(); + self.set_selected_index(ix, false, cx); + self.do_confirm(secondary, cx) + } + + fn do_confirm(&mut self, secondary: bool, cx: &mut ViewContext) { + // if let Some(update_query) = self.delegate.confirm_update_query(cx) { + // self.set_query(update_query, cx); + // self.delegate.set_selected_index(0, cx); + // } else { + self.delegate.confirm(secondary, cx) + // } + } + + pub fn set_selected_index( + &mut self, + ix: usize, + scroll_to_index: bool, + cx: &mut ViewContext, + ) { + let previous_index = self.delegate.selected_index(); + self.delegate.set_selected_index(ix, cx); + let current_index = self.delegate.selected_index(); + + if previous_index != current_index { + if let Some(action) = self.delegate.selected_index_changed(ix, cx) { + action(cx); + } + if scroll_to_index { + self.scroll_to_item_index(ix); + } + } + } + + fn scroll_to_item_index(&mut self, ix: usize) { + match &mut self.element_container { + ElementContainer::List(state) => state.scroll_to_reveal_item(ix), + ElementContainer::UniformList(scroll_handle) => scroll_handle.scroll_to_item(ix), + } + } + + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + if self.pending_update_matches.is_some() + && !self + .delegate + .finalize_update_matches(self.query(cx), Duration::from_millis(16), cx) + { + self.confirm_on_update = Some(false) + } else { + self.pending_update_matches.take(); + self.do_confirm(false, cx); + } + } + + pub fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + let count = self.delegate.match_count(); + if count > 0 { + let index = self.delegate.selected_index(); + let ix = if index == count - 1 { 0 } else { index + 1 }; + self.set_selected_index(ix, true, cx); + cx.notify(); + } + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + let count = self.delegate.match_count(); + if count > 0 { + let index = self.delegate.selected_index(); + let ix = if index == 0 { count - 1 } else { index - 1 }; + self.set_selected_index(ix, true, cx); + cx.notify(); + } + } + + fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { + let count = self.delegate.match_count(); + if count > 0 { + self.set_selected_index(0, true, cx); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + let count = self.delegate.match_count(); + if count > 0 { + self.delegate.set_selected_index(count - 1, cx); + self.set_selected_index(count - 1, true, cx); + cx.notify(); + } + } + + pub fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + if self.delegate.should_dismiss() { + self.delegate.dismissed(cx); + cx.emit(DismissEvent); + } + } + + fn use_selected_query(&mut self, _: &UseSelectedQuery, cx: &mut ViewContext) { + if let Some(new_query) = self.delegate.selected_as_query() { + self.set_query(&new_query, cx); + cx.stop_propagation(); + } + } + + pub fn refresh(&mut self, cx: &mut ViewContext) { + let query = self.query(cx); + self.update_matches(&query, cx); + } + + pub fn update_matches(&mut self, query: &str, cx: &mut ViewContext) { + let delegate_pending_update_matches = self.delegate.update_matches(query, cx); + + self.matches_updated(cx); + // This struct ensures that we can synchronously drop the task returned by the + // delegate's `update_matches` method and the task that the picker is spawning. + // If we simply capture the delegate's task into the picker's task, when the picker's + // task gets synchronously dropped, the delegate's task would keep running until + // the picker's task has a chance of being scheduled, because dropping a task happens + // asynchronously. + self.pending_update_matches = Some(PendingUpdateMatches { + delegate_update_matches: Some(delegate_pending_update_matches), + _task: cx.spawn(|this, mut cx| async move { + let delegate_pending_update_matches = this.update(&mut cx, |this, _| { + this.pending_update_matches + .as_mut() + .unwrap() + .delegate_update_matches + .take() + .unwrap() + })?; + delegate_pending_update_matches.await; + this.update(&mut cx, |this, cx| { + this.matches_updated(cx); + }) + }), + }); + } + + fn matches_updated(&mut self, cx: &mut ViewContext) { + if let ElementContainer::List(state) = &mut self.element_container { + state.reset(self.delegate.match_count()); + } + + let index = self.delegate.selected_index(); + self.scroll_to_item_index(index); + // self.pending_update_matches = None; + // if let Some(secondary) = self.confirm_on_update.take() { + // self.confirm(secondary, cx); + // } + cx.notify(); + } +} + +impl Render for Picker { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .key_context("Picker") + .size_full() + .when_some(self.width, |el, width| el.w(width)) + .overflow_hidden() + .when(self.is_modal, |this| this.elevation_3(cx)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + // .on_action(cx.listener(Self::secondary_confirm)) + .on_action(cx.listener(Self::use_selected_query)) + // .on_action(cx.listener(Self::confirm_input)) + .child(match self.query_input { + Some(ref input) => self.delegate.render_query(input, cx), + None => div().child(self.head.clone()), + }) + .when(self.delegate.match_count() > 0, |el| { + el.child( + v_flex() + .flex_grow() + .when_some(self.max_height, |div, max_h| div.max_h(max_h)) + .overflow_hidden() + // .children(self.delegate.render_header(cx)) + .child(self.render_element_container(cx)), + ) + }) + .when(self.delegate.match_count() == 0, |el| { + el.child( + v_flex() + .flex_grow() + .py_2() + .child(div().child(Label::new("No matched.").text_color(cx.theme().muted))), + ) + }) + } +} diff --git a/crates/ui/src/text_field/mod.rs b/crates/ui/src/text_field/mod.rs index 8691fe97..6a1d8eba 100644 --- a/crates/ui/src/text_field/mod.rs +++ b/crates/ui/src/text_field/mod.rs @@ -6,7 +6,7 @@ use crate::theme::ActiveTheme; use gpui::{ div, prelude::FluentBuilder as _, AppContext, ClipboardItem, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, - Render, RenderOnce, Styled, View, ViewContext, WindowContext, + Render, RenderOnce, SharedString, Styled, View, ViewContext, WindowContext, }; use std::time::Duration; use text_view::TextView; @@ -31,7 +31,11 @@ impl TextField { cx.focus(&self.focus_handle); } - pub fn set_placeholder(&mut self, placeholder: &str, cx: &mut WindowContext) { + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + cx: &mut WindowContext, + ) { self.view.update(cx, |text_view, cx| { text_view.set_placeholder(placeholder, cx) }); diff --git a/crates/ui/src/text_field/text_view.rs b/crates/ui/src/text_field/text_view.rs index 35f3c997..305a2a24 100644 --- a/crates/ui/src/text_field/text_view.rs +++ b/crates/ui/src/text_field/text_view.rs @@ -6,7 +6,7 @@ use super::{ use crate::theme::{ActiveTheme, Colorize as _}; use gpui::{ px, relative, ContentMask, Context, Element, EventEmitter, FocusHandle, HighlightStyle, Hsla, - InteractiveText, IntoElement, Model, Point, Render, Style, StyledText, TextStyle, + InteractiveText, IntoElement, Model, Point, Render, SharedString, Style, StyledText, TextStyle, TextStyleRefinement, View, ViewContext, VisualContext, WindowContext, }; @@ -19,7 +19,7 @@ pub struct TextFieldStyle { pub struct TextView { pub text: String, pub style: TextFieldStyle, - pub placeholder: String, + pub placeholder: SharedString, pub word_click: (usize, u16), pub selection: Range, pub disabled: bool, @@ -49,7 +49,7 @@ impl TextView { let m = Self { text: String::new(), style, - placeholder: "".to_string(), + placeholder: "".into(), word_click: (0, 0), selection: 0..0, blink_manager: blink_manager.clone(), @@ -187,8 +187,12 @@ impl TextView { cx.notify(); } - pub fn set_placeholder(&mut self, placeholder: impl ToString, cx: &mut ViewContext) { - self.placeholder = placeholder.to_string(); + pub fn set_placeholder( + &mut self, + placeholder: impl Into, + cx: &mut ViewContext, + ) { + self.placeholder = placeholder.into(); cx.notify(); }