diff --git a/README.md b/README.md index 30eb9ec6..51900971 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,6 @@ This is an example of build app by using GPUI. - [ ] Toggle Animation - [ ] Radio & RadioGroup - [x] Dropdown - - [x] Picker - - [x] Picker List - - [x] Use keyword to select next, prev, enter to select, esc to cancel. - [x] Tabs - [x] Tab - [x] TabBar @@ -74,6 +71,5 @@ There have a part of UI components from [Zed](https://github.com/zed-industries/ - workspace - scrollbar -- picker Other UI components are under Apache License. diff --git a/crates/story/src/list_story.rs b/crates/story/src/list_story.rs index 79b5d262..50692996 100644 --- a/crates/story/src/list_story.rs +++ b/crates/story/src/list_story.rs @@ -11,7 +11,7 @@ use ui::{ h_flex, label::Label, list::ListItem, - picker::{Picker, PickerDelegate}, + list::{List, ListDelegate}, theme::{hsl, ActiveTheme, Colorize as _}, v_flex, }; @@ -138,28 +138,26 @@ struct CompanyListDelegate { selected_index: usize, } -impl PickerDelegate for CompanyListDelegate { - type ListItem = CompanyListItem; +impl ListDelegate for CompanyListDelegate { + type Item = CompanyListItem; - fn match_count(&self) -> usize { + fn items_count(&self) -> usize { self.companies.len() } - fn selected_index(&self) -> usize { - self.selected_index + fn confirmed_index(&self) -> Option { + Some(self.selected_index) } - fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { - self.selected_index = ix; + fn confirm(&mut self, ix: Option, cx: &mut ViewContext>) { + if let Some(ix) = ix { + self.selected_index = ix; + } cx.dispatch_action(Box::new(SelectedCompany)); } - fn render_item( - &self, - ix: usize, - selected: bool, - _cx: &mut ViewContext>, - ) -> Option { + fn render_item(&self, ix: usize, _cx: &mut ViewContext>) -> Option { + let selected = ix == self.selected_index; if let Some(company) = self.companies.get(ix) { return Some(CompanyListItem::new(ix, company.clone(), ix, selected)); } @@ -176,7 +174,7 @@ impl CompanyListDelegate { pub struct ListStory { focus_handle: FocusHandle, - company_list: View>, + company_list: View>, selected_company: Option, } @@ -191,14 +189,13 @@ impl ListStory { .collect::>(); let company_list = cx.new_view(|cx| { - Picker::uniform_list( + List::new( CompanyListDelegate { companies, selected_index: 0, }, cx, ) - // .max_height(Some(px(350.0).into())) .no_query() }); diff --git a/crates/story/src/picker_story.rs b/crates/story/src/picker_story.rs index 18ae49df..385746ef 100644 --- a/crates/story/src/picker_story.rs +++ b/crates/story/src/picker_story.rs @@ -1,44 +1,46 @@ use gpui::{ - deferred, div, prelude::FluentBuilder as _, px, InteractiveElement as _, IntoElement, - ParentElement, Render, Styled, Task, View, ViewContext, VisualContext as _, WeakView, - WindowContext, + deferred, div, prelude::FluentBuilder as _, px, FocusHandle, FocusableView, + InteractiveElement as _, IntoElement, ParentElement, Render, Styled, View, ViewContext, + VisualContext as _, WeakView, WindowContext, }; use ui::{ button::Button, h_flex, - list::ListItem, - picker::{Picker, PickerDelegate}, - v_flex, Clickable as _, IconName, + list::{List, ListDelegate, ListItem}, + v_flex, Clickable as _, IconName, StyledExt, }; pub struct ListItemDeletegate { story: WeakView, selected_index: usize, + items: Vec, matches: Vec, } -impl PickerDelegate for ListItemDeletegate { - type ListItem = ListItem; +impl ListDelegate for ListItemDeletegate { + type Item = ListItem; - fn match_count(&self) -> usize { + fn items_count(&self) -> usize { self.matches.len() } - fn selected_index(&self) -> usize { - self.selected_index + fn confirmed_index(&self) -> Option { + Some(self.selected_index) } - fn set_selected_index(&mut self, index: usize, _cx: &mut ViewContext>) { - self.selected_index = 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())) + .map(|s| s.clone()) + .collect(); + cx.notify(); } - fn render_item( - &self, - ix: usize, - selected: bool, - _cx: &mut ViewContext>, - ) -> Option { + fn render_item(&self, ix: usize, _cx: &mut ViewContext>) -> Option { + let selected = ix == self.selected_index; if let Some(item) = self.matches.get(ix) { let list_item = ListItem::new(("item", ix)) .check_icon(ui::IconName::Check) @@ -52,28 +54,7 @@ impl PickerDelegate for ListItemDeletegate { } } - fn update_matches( - &mut self, - query: &str, - cx: &mut ViewContext>, - ) -> gpui::Task<()> { - if let Some(story) = self.story.upgrade() { - let matched_items = story - .read(cx) - .items - .iter() - .filter(|item| item.contains(query)) - .cloned() - .collect(); - - self.matches = matched_items; - cx.notify(); - } - - Task::ready(()) - } - - fn dismissed(&mut self, cx: &mut ViewContext>) { + fn cancel(&mut self, cx: &mut ViewContext>) { if let Some(story) = self.story.upgrade() { cx.update_view(&story, |story, cx| { story.open = false; @@ -82,11 +63,14 @@ impl PickerDelegate for ListItemDeletegate { } } - fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + fn confirm(&mut self, ix: Option, cx: &mut ViewContext>) { if let Some(story) = self.story.upgrade() { cx.update_view(&story, |story, cx| { - if let Some(item) = self.matches.get(self.selected_index) { - story.selected_value = Some(item.clone()); + if let Some(ix) = ix { + self.selected_index = ix; + if let Some(item) = self.matches.get(ix) { + story.selected_value = Some(item.clone()); + } } story.open = false; cx.notify(); @@ -96,9 +80,8 @@ impl PickerDelegate for ListItemDeletegate { } pub struct PickerStory { - picker: View>, + list: View>, open: bool, - items: Vec, selected_value: Option, } @@ -164,31 +147,32 @@ impl PickerStory { .collect(); let story = cx.view().downgrade(); - let picker = cx.new_view(|cx| { - let mut picker = Picker::uniform_list( - ListItemDeletegate { - story, - selected_index: 0, - matches: items.clone(), - }, - cx, - ) - .modal(true); - - picker.focus(cx); - picker.set_query("c", cx); - picker + let delegate = ListItemDeletegate { + story, + selected_index: 0, + items: items.clone(), + matches: items.clone(), + }; + let list = cx.new_view(|cx| { + let mut list = List::new(delegate, cx); + list.focus(cx); + list }); Self { - items, - picker, + list, open: false, selected_value: None, } } } +impl FocusableView for PickerStory { + fn focus_handle(&self, cx: &gpui::AppContext) -> FocusHandle { + self.list.focus_handle(cx) + } +} + impl Render for PickerStory { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { v_flex() @@ -200,7 +184,7 @@ impl Render for PickerStory { .icon(IconName::Search) .on_click(cx.listener(|this, _, cx| { this.open = !this.open; - this.picker.focus_handle(cx).focus(cx); + this.list.focus_handle(cx).focus(cx); cx.notify(); })), ), @@ -217,10 +201,11 @@ impl Render for PickerStory { this.child(deferred( div().absolute().size_full().top_0().left_0().child( v_flex().flex().flex_col().items_center().child( - div() + v_flex() .w(px(450.)) .h(px(350.)) - .child(self.picker.clone()) + .elevation_3(cx) + .child(self.list.clone()) .on_mouse_down_out(cx.listener(|this, _, cx| { this.open = false; cx.notify(); diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 24e4b066..3280f56a 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -20,8 +20,7 @@ pub fn init(cx: &mut AppContext) { use crate::{ h_flex, - list::ListItem, - picker::{self, Picker, PickerDelegate}, + list::{self, List, ListDelegate, ListItem}, theme::ActiveTheme, Icon, IconName, StyledExt, }; @@ -50,36 +49,32 @@ pub trait DropdownDelegate { fn get(&self, ix: usize) -> Option<&dyn DropdownItem>; } -struct DropdownPickerDelegate { +struct DropdownListDelegate { delegate: D, dropdown: WeakView>, selected_index: usize, } -impl PickerDelegate for DropdownPickerDelegate +impl ListDelegate for DropdownListDelegate where D: DropdownDelegate + 'static, { - type ListItem = ListItem; + type Item = ListItem; - fn match_count(&self) -> usize { + fn items_count(&self) -> usize { self.delegate.len() } - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, index: usize, _cx: &mut gpui::ViewContext>) { - self.selected_index = index; + fn confirmed_index(&self) -> Option { + Some(self.selected_index) } fn render_item( &self, ix: usize, - selected: bool, - _cx: &mut gpui::ViewContext>, - ) -> Option { + _cx: &mut gpui::ViewContext>, + ) -> Option { + let selected = ix == self.selected_index; if let Some(item) = self.delegate.get(ix) { let list_item = ListItem::new(("list-item", ix)) .check_icon(IconName::Check) @@ -93,7 +88,7 @@ where } } - fn dismissed(&mut self, cx: &mut ViewContext>) { + fn cancel(&mut self, cx: &mut ViewContext>) { if let Some(view) = self.dropdown.upgrade() { cx.update_view(&view, |view, _| { view.open = false; @@ -101,13 +96,16 @@ where } } - fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + fn confirm(&mut self, ix: Option, cx: &mut ViewContext>) { + self.selected_index = ix.unwrap_or(0); + if let Some(view) = self.dropdown.upgrade() { cx.update_view(&view, |view, cx| { if let Some(item) = self.delegate.get(self.selected_index) { view.title = Some(item.title().to_string().into()); view.value = Some(item.value().to_string().into()); } + view.open = false; view.focus_handle.focus(cx); }); @@ -118,7 +116,7 @@ where pub struct Dropdown { id: ElementId, focus_handle: FocusHandle, - picker: View>>, + list: View>>, open: bool, /// The value of the selected item. value: Option, @@ -130,22 +128,17 @@ where D: DropdownDelegate + 'static, { pub fn new(id: impl Into, delegate: D, cx: &mut ViewContext) -> Self { - let picker_delegate = DropdownPickerDelegate { + let delegate = DropdownListDelegate { delegate, dropdown: cx.view().downgrade(), selected_index: 0, }; - let picker = cx.new_view(|cx| { - Picker::uniform_list(picker_delegate, cx) - .no_query() - .scrollbar_enable(false) - .max_height(Some(rems(20.).into())) - }); + let list = cx.new_view(|cx| List::new(delegate, cx).no_query().max_h(rems(20.))); Self { id: id.into(), focus_handle: cx.focus_handle(), - picker, + list, open: false, title: None, value: None, @@ -161,8 +154,8 @@ where if !self.open { return; } - self.picker.focus_handle(cx).focus(cx); - cx.dispatch_action(Box::new(picker::SelectPrev)); + self.list.focus_handle(cx).focus(cx); + cx.dispatch_action(Box::new(list::SelectPrev)); } fn down(&mut self, _: &Down, cx: &mut ViewContext) { @@ -170,8 +163,8 @@ where self.open = true; } - self.picker.focus_handle(cx).focus(cx); - cx.dispatch_action(Box::new(picker::SelectNext)); + self.list.focus_handle(cx).focus(cx); + cx.dispatch_action(Box::new(list::SelectNext)); } fn enter(&mut self, _: &Enter, cx: &mut ViewContext) { @@ -179,8 +172,8 @@ where self.open = true; cx.notify(); } else { - self.picker.focus_handle(cx).focus(cx); - cx.dispatch_action(Box::new(picker::Confirm)); + self.list.focus_handle(cx).focus(cx); + cx.dispatch_action(Box::new(list::Confirm)); } } @@ -198,8 +191,8 @@ where .border_color(cx.theme().input) .rounded(px(cx.theme().radius)) .shadow_md() - .track_focus(&self.picker.focus_handle(cx)) - .child(self.picker.clone()) + .track_focus(&self.list.focus_handle(cx)) + .child(self.list.clone()) .on_mouse_down_out(|_, cx| { cx.dispatch_action(Box::new(Escape)); }) diff --git a/crates/ui/src/empty.rs b/crates/ui/src/empty.rs deleted file mode 100644 index fc2a9813..00000000 --- a/crates/ui/src/empty.rs +++ /dev/null @@ -1,29 +0,0 @@ -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 26d5b03b..4f7846a4 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -12,7 +12,6 @@ mod svg_img; pub mod button; pub mod checkbox; -pub mod empty; pub mod label; pub mod prelude; pub mod theme; @@ -21,7 +20,6 @@ pub mod divider; pub mod dropdown; pub mod input; pub mod list; -pub mod picker; pub mod popover; pub mod popup_menu; pub mod switch; @@ -42,7 +40,7 @@ pub use svg_img::*; /// Initialize the UI module. pub fn init(cx: &mut gpui::AppContext) { input::init(cx); - picker::init(cx); + list::init(cx); dropdown::init(cx); popover::init(cx); popup_menu::init(cx); diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs new file mode 100644 index 00000000..f3a7cf9d --- /dev/null +++ b/crates/ui/src/list/list.rs @@ -0,0 +1,322 @@ +use std::{cell::Cell, rc::Rc, time::Duration}; + +use gpui::prelude::FluentBuilder as _; + +use crate::input::{TextEvent, TextInput}; +use crate::theme::{ActiveTheme, Colorize as _}; +use crate::{scrollbar::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, StatefulInteractiveElement as _, Styled as _, Task, + UniformListScrollHandle, View, ViewContext, VisualContext as _, +}; + +actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]); + +pub fn init(cx: &mut AppContext) { + let context: Option<&str> = Some("List"); + cx.bind_keys([ + KeyBinding::new("escape", Cancel, context), + KeyBinding::new("enter", Confirm, context), + KeyBinding::new("up", SelectPrev, context), + KeyBinding::new("down", SelectNext, context), + ]); +} + +#[allow(unused)] +pub trait ListDelegate: Sized + 'static { + type Item: IntoElement; + + fn perform_search(&mut self, query: &str, cx: &mut ViewContext>) {} + + /// Return the number of items in the list. + fn items_count(&self) -> usize; + fn render_item(&self, ix: usize, cx: &mut ViewContext>) -> Option; + + /// Return the confirmed index of the selected item. + fn confirmed_index(&self) -> Option { + None + } + + /// Set the confirm and give the selected index. + fn confirm(&mut self, ix: Option, cx: &mut ViewContext>) {} + fn cancel(&mut self, cx: &mut ViewContext>) {} +} + +pub struct List { + focus_handle: FocusHandle, + delegate: D, + max_height: Option, + query_input: Option>, + + enable_scrollbar: bool, + vertical_scroll_handle: UniformListScrollHandle, + scrollbar_drag_state: Rc>>, + show_scrollbar: bool, + hide_scrollbar_task: Option>, + + selected_index: Option, +} + +impl List +where + D: ListDelegate, +{ + pub fn new(delegate: D, cx: &mut ViewContext) -> Self { + let query_input = cx.new_view(|cx| { + TextInput::new(cx) + .appearance(false) + .prefix(Icon::new(IconName::Search).view(cx)) + .placeholder("Search...") + }); + + cx.subscribe(&query_input, Self::on_query_input_event) + .detach(); + + Self { + focus_handle: cx.focus_handle(), + delegate, + query_input: Some(query_input), + selected_index: None, + vertical_scroll_handle: UniformListScrollHandle::new(), + scrollbar_drag_state: Rc::new(Cell::new(None)), + show_scrollbar: false, + hide_scrollbar_task: None, + max_height: None, + enable_scrollbar: true, + } + } + + pub fn max_h(mut self, height: impl Into) -> Self { + self.max_height = Some(height.into()); + self + } + + pub fn no_scrollbar(mut self) -> Self { + self.enable_scrollbar = false; + self + } + + pub fn no_query(mut self) -> Self { + self.query_input = None; + self + } + + pub fn delegate(&self) -> &D { + &self.delegate + } + + pub fn delegate_mut(&mut self) -> &mut D { + &mut self.delegate + } + + pub fn focus(&mut self, cx: &mut ViewContext) { + cx.focus(&self.focus_handle); + } + + fn render_scrollbar(&self, cx: &mut ViewContext) -> Option { + if !self.enable_scrollbar { + return None; + } + if !self.show_scrollbar { + return None; + } + + Scrollbar::new( + cx.view().clone(), + self.vertical_scroll_handle.clone(), + self.scrollbar_drag_state.clone(), + self.delegate.items_count(), + true, + ) + .map(|bar| { + div() + .occlude() + .absolute() + .h_full() + .left_auto() + .top_0() + .right_0() + .w(px(bar.width())) + .bottom_0() + .child(bar) + }) + } + + fn hide_scrollbar(&mut self, cx: &mut ViewContext) { + self.show_scrollbar = false; + self.hide_scrollbar_task = Some(cx.spawn(|this, mut cx| async move { + cx.background_executor().timer(Duration::from_secs(1)).await; + this.update(&mut cx, |this, cx| { + this.show_scrollbar = false; + cx.notify(); + }) + .ok(); + })) + } + + fn on_hover_to_autohide_scrollbar(&mut self, hovered: &bool, cx: &mut ViewContext) { + if !self.enable_scrollbar { + return; + } + + if *hovered { + self.show_scrollbar = true; + self.hide_scrollbar_task.take(); + cx.notify(); + } else if !self.focus_handle.is_focused(cx) { + self.hide_scrollbar(cx); + } + } + + fn scroll_to_selected_item(&mut self, _cx: &mut ViewContext) { + if let Some(ix) = self.selected_index { + self.vertical_scroll_handle.scroll_to_item(ix); + } + } + + fn on_query_input_event( + &mut self, + _: View, + event: &TextEvent, + cx: &mut ViewContext, + ) { + #[allow(clippy::single_match)] + match event { + TextEvent::Input { text } => { + self.delegate.perform_search(&text.trim(), cx); + cx.notify() + } + TextEvent::PressEnter => self.action_confirm(&Confirm, cx), + } + } + + fn action_cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + self.delegate.cancel(cx); + cx.notify(); + } + + fn action_confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + self.delegate.confirm(self.selected_index, cx); + cx.notify(); + } + + fn action_select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + let selected_index = self.selected_index.unwrap_or(0); + if selected_index > 0 { + self.selected_index = Some(selected_index - 1); + } else { + self.selected_index = Some(self.delegate.items_count() - 1); + } + + self.scroll_to_selected_item(cx); + cx.notify(); + } + + fn action_select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + let selected_index = self.selected_index.unwrap_or(0); + if selected_index < self.delegate.items_count() - 1 { + self.selected_index = Some(selected_index + 1); + } else { + self.selected_index = Some(0); + } + + self.scroll_to_selected_item(cx); + cx.notify(); + } +} + +impl FocusableView for List +where + D: ListDelegate, +{ + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + if let Some(query_input) = &self.query_input { + query_input.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } +} + +impl Render for List +where + D: ListDelegate, +{ + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let view = cx.view().clone(); + let vertical_scroll_handle = self.vertical_scroll_handle.clone(); + let items_count = self.delegate.items_count(); + let sizing_behavior = if self.max_height.is_some() { + ListSizingBehavior::Infer + } else { + ListSizingBehavior::Auto + }; + + let selected_bg = cx.theme().accent.opacity(0.8); + + v_flex() + .key_context("List") + .id("list") + .track_focus(&self.focus_handle) + .size_full() + .overflow_hidden() + .on_action(cx.listener(Self::action_cancel)) + .on_action(cx.listener(Self::action_confirm)) + .on_action(cx.listener(Self::action_select_next)) + .on_action(cx.listener(Self::action_select_prev)) + .on_hover(cx.listener(Self::on_hover_to_autohide_scrollbar)) + .when_some(self.query_input.clone(), |this, input| { + this.child( + div() + .px_2() + .border_b_1() + .border_color(cx.theme().border) + .child(input), + ) + }) + .child( + v_flex() + .flex_grow() + .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(), + ) + .children(self.render_scrollbar(cx)), + ) + } +} diff --git a/crates/ui/src/list/mod.rs b/crates/ui/src/list/mod.rs index b40fb082..91225757 100644 --- a/crates/ui/src/list/mod.rs +++ b/crates/ui/src/list/mod.rs @@ -1,2 +1,5 @@ +mod list; mod list_item; + +pub use list::*; pub use list_item::*; diff --git a/crates/ui/src/picker.rs b/crates/ui/src/picker.rs deleted file mode 100644 index 5f8bfbeb..00000000 --- a/crates/ui/src/picker.rs +++ /dev/null @@ -1,599 +0,0 @@ -use std::{cell::Cell, rc::Rc, time::Duration}; - -use anyhow::Result; -use gpui::{ - actions, div, list, prelude::FluentBuilder as _, px, uniform_list, AppContext, ClickEvent, - DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, - KeyBinding, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, - ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Task, - UniformListScrollHandle, View, ViewContext, VisualContext as _, WindowContext, -}; - -actions!( - picker, - [ - Cancel, - Confirm, - SecondaryConfirm, - SelectPrev, - SelectNext, - SelectFirst, - SelectLast, - ] -); - -pub fn init(cx: &mut AppContext) { - let context = Some("Picker"); - cx.bind_keys([ - KeyBinding::new("enter", Confirm, context), - KeyBinding::new("escape", Cancel, context), - KeyBinding::new("up", SelectPrev, context), - KeyBinding::new("down", SelectNext, context), - ]); -} - -use crate::{ - divider::Divider, - empty::Empty, - input::{TextEvent, TextInput}, - scrollbar::Scrollbar, - stock::*, - 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; - - /// Return the index of the selected item. - fn selected_index(&self) -> usize; - - /// Update the selected index. - fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>); - - fn selected_index_changed( - &self, - _ix: usize, - _cx: &mut ViewContext>, - ) -> Option> { - None - } - - /// Callback when the picker is confirmed. - fn confirm(&mut self, _secondary: bool, _cx: &mut ViewContext>) {} - - /// Callback when the picker is dismissed. - fn dismissed(&mut self, _cx: &mut ViewContext>) {} - - /// Determine if the picker should be dismissed, return true by default. Return false will abort the dismiss action. - fn should_dismiss(&self) -> bool { - true - } - - /// Override this method to customize the query input header container. - 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()) - } - - /// Render the list item at the given index. - fn render_item( - &self, - ix: usize, - selected: bool, - cx: &mut ViewContext>, - ) -> Option; - - fn update_matches(&mut self, _query: &str, _cx: &mut ViewContext>) -> Task<()> { - Task::ready(()) - } - - fn confirm_update_query( - &mut self, - _cx: &mut ViewContext>, - ) -> Option { - None - } - - 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, - /// Just a empty view for holding the focus - head: View, - pending_update_matches: Option, - scrollbar_enable: bool, - show_scrollbar: bool, - hide_scrollbar_task: Option>, - scrollbar_drag_state: Rc>>, -} - -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, - head: cx.new_view(Empty::new), - width: None, - is_modal: false, - max_height: None, - element_container, - pending_update_matches: None, - scrollbar_enable: true, - show_scrollbar: false, - hide_scrollbar_task: None, - scrollbar_drag_state: Rc::new(Cell::new(None)), - } - } - - fn new_query_input( - placehoder: impl Into, - cx: &mut ViewContext, - ) -> View { - let input = cx.new_view(|cx| { - let mut input = TextInput::new(cx).appearance(false); - input.set_placeholder(placehoder, cx); - input - }); - cx.subscribe(&input, Self::on_query_input_event).detach(); - input - } - - pub fn delegate(&self) -> &D { - &self.delegate - } - - pub fn delegate_mut(&mut self) -> &mut D { - &mut self.delegate - } - - 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); - } - - /// Hide the query input. - pub fn no_query(mut self) -> Self { - self.query_input = None; - self - } - - pub fn scrollbar_enable(mut self, enable: bool) -> Self { - self.scrollbar_enable = enable; - self - } - - 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.to_string(), cx)); - } - } - - /// Return the query input string. - pub fn query(&self, cx: &AppContext) -> SharedString { - if let Some(input) = &self.query_input { - input.read(cx).text() - } else { - "".into() - } - } - - 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() - .track_scroll(scroll_handle.clone()) - .into_any_element(), - ElementContainer::List(state) => list(state.clone()) - .with_sizing_behavior(sizing_behavior) - .flex_grow() - .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_item(ix, ix == self.delegate.selected_index(), cx), - ) - } - - fn render_scrollbar(&self, cx: &mut ViewContext) -> Option { - if !self.scrollbar_enable { - return None; - } - if !self.show_scrollbar { - return None; - } - - if let Some(scroll_handle) = match &self.element_container { - ElementContainer::List(_state) => None, - ElementContainer::UniformList(scroll_handle) => Some(scroll_handle.clone()), - } { - Scrollbar::new( - cx.view().clone().into(), - scroll_handle, - self.scrollbar_drag_state.clone(), - self.delegate.match_count(), - true, - ) - } else { - None - } - } - - fn hide_scrollbar(&mut self, cx: &mut ViewContext) { - const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); - self.show_scrollbar = false; - self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move { - cx.background_executor() - .timer(SCROLLBAR_SHOW_INTERVAL) - .await; - panel - .update(&mut cx, |panel, cx| { - panel.show_scrollbar = false; - cx.notify(); - }) - .ok(); - })) - } - - 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) { - 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); - } - } - - 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(); - } - - fn on_query_input_event( - &mut self, - _: View, - event: &TextEvent, - cx: &mut ViewContext, - ) { - #[allow(clippy::single_match)] - match event { - TextEvent::Input { text } => { - self.set_query(text, cx); - self.refresh(cx); - } - _ => {} - } - } -} - -impl Render for Picker { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - v_flex() - .id("picker") - .key_context("Picker") - .group("picker-group") - .track_focus(&focus_handle) - .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_hover(cx.listener(move |this, hovered: &bool, cx| { - if *hovered { - this.show_scrollbar = true; - this.hide_scrollbar_task.take(); - cx.notify(); - } else if !focus_handle.is_focused(cx) { - this.hide_scrollbar(cx); - } - })) - // Render Query Input header - .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, |this| { - this.child( - v_flex() - .flex_grow() - .min_h(px(100.)) - .when_some(self.max_height, |div, max_h| div.max_h(max_h)) - .overflow_hidden() - .child(self.render_element_container(cx)) - .children(self.render_scrollbar(cx).map(|bar| { - // This for let the scrollbar can render on top of the list container. - div() - .occlude() - .absolute() - .h_full() - .left_auto() - .top_0() - .right_0() - .w(px(bar.width())) - .bottom_0() - .child(bar) - })), - ) - }) - .when(self.delegate.match_count() == 0, |el| { - el.child( - v_flex() - .h_full() - .size_full() - .h_16() - .items_center() - .content_center() - .justify_center() - .text_color(cx.theme().muted_foreground) - .child("No matched."), - ) - }) - } -} diff --git a/crates/ui/src/scrollbar.rs b/crates/ui/src/scrollbar.rs index 4d4f763f..1b3afce2 100644 --- a/crates/ui/src/scrollbar.rs +++ b/crates/ui/src/scrollbar.rs @@ -31,7 +31,7 @@ pub struct Scrollbar { impl Scrollbar { pub fn new( - view: AnyView, + view: impl Into, handle: UniformListScrollHandle, drag_state: Rc>>, items_count: usize, @@ -65,7 +65,7 @@ impl Scrollbar { let thumb = percentage as f32..end_offset as f32; Some(Self { - view, + view: view.into(), items_count, width: 12.0, thumb, diff --git a/crates/ui/src/table.rs b/crates/ui/src/table.rs index 53733c6c..159ae286 100644 --- a/crates/ui/src/table.rs +++ b/crates/ui/src/table.rs @@ -143,7 +143,7 @@ where fn render_scrollbar(&self, cx: &mut ViewContext) -> Option { Scrollbar::new( - cx.view().clone().into(), + cx.view().clone(), self.vertical_scroll_handle.clone(), self.scrollbar_drag_state.clone(), self.delegate.rows_count(),