From 2870bc6e36809742aa96438bc6b958d2913931cc Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 4 Nov 2025 19:06:50 +0800 Subject: [PATCH] list: Add `searchable`, `search_placeholder` option to List and Select. (#1503) ## Break Change - Removed `set_query_input`, `query_input` and `no_query` method from List. Now use `List::new(&state).searchable(false)` to intead of `no_query`. - Removed `searchable` method from `SelectDelegate`, use `Select::searchable` instead. --- crates/story/src/drawer_story.rs | 12 +- crates/story/src/list_story.rs | 8 +- crates/story/src/select_story.rs | 1 + .../ui/src/input/popovers/code_action_menu.rs | 8 +- .../ui/src/input/popovers/completion_menu.rs | 8 +- crates/ui/src/list/list.rs | 139 +++++++++++------- crates/ui/src/select.rs | 47 +++--- docs/docs/components/list.md | 3 +- 8 files changed, 130 insertions(+), 96 deletions(-) diff --git a/crates/story/src/drawer_story.rs b/crates/story/src/drawer_story.rs index fb174c9a..dba97668 100644 --- a/crates/story/src/drawer_story.rs +++ b/crates/story/src/drawer_story.rs @@ -15,7 +15,7 @@ use gpui_component::{ date_picker::{DatePicker, DatePickerState}, h_flex, input::{Input, InputState}, - list::{ListDelegate, ListItem, ListState}, + list::{List, ListDelegate, ListItem, ListState}, v_flex, webview::WebView, wry, @@ -245,11 +245,6 @@ impl DrawerStory { let list = cx.new(|cx| { let mut list = ListState::new(delegate, window, cx); list.focus(window, cx); - if let Some(query_input) = list.query_input() { - query_input.update(cx, |input, cx| { - input.set_placeholder("Pickup your country...", window, cx); - }) - } list }); @@ -308,14 +303,13 @@ impl DrawerStory { }), ) .child( - div() + List::new(&list) .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) .size_full() .flex_1() - .h(list_h) - .child(list.clone()), + .h(list_h), ) .footer( h_flex() diff --git a/crates/story/src/list_story.rs b/crates/story/src/list_story.rs index 0cb0dd8e..ace79ba1 100644 --- a/crates/story/src/list_story.rs +++ b/crates/story/src/list_story.rs @@ -2,7 +2,7 @@ use std::{rc::Rc, time::Duration}; use fake::Fake; use gpui::{ - App, AppContext, Context, Edges, ElementId, Entity, FocusHandle, Focusable, InteractiveElement, + App, AppContext, Context, ElementId, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, Styled, Subscription, Task, Timer, Window, actions, div, prelude::FluentBuilder as _, px, }; @@ -535,13 +535,13 @@ impl Render for ListStory { ), ) .child( - div() + List::new(&self.company_list) + .p(px(8.)) .flex_1() .w_full() .border_1() .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .child(List::new(&self.company_list).paddings(Edges::all(px(8.)))), + .rounded(cx.theme().radius), ) } } diff --git a/crates/story/src/select_story.rs b/crates/story/src/select_story.rs index 10f3e24b..4ef94054 100644 --- a/crates/story/src/select_story.rs +++ b/crates/story/src/select_story.rs @@ -191,6 +191,7 @@ impl Render for SelectStory { .child( section("Select").max_w_128().child( Select::new(&self.country_select) + .search_placeholder("Search country by name or code") .cleanable() .disabled(self.disabled), ), diff --git a/crates/ui/src/input/popovers/code_action_menu.rs b/crates/ui/src/input/popovers/code_action_menu.rs index b8949940..45e3dd72 100644 --- a/crates/ui/src/input/popovers/code_action_menu.rs +++ b/crates/ui/src/input/popovers/code_action_menu.rs @@ -164,7 +164,7 @@ impl CodeActionMenu { selected_ix: 0, }; - let list = cx.new(|cx| ListState::new(menu, window, cx).no_query()); + let list = cx.new(|cx| ListState::new(menu, window, cx)); let _subscriptions = vec![ @@ -327,7 +327,11 @@ impl Render for CodeActionMenu { .top(pos.y) .max_w(max_width) .min_w(px(120.)) - .child(List::new(&self.list).max_h(MAX_MENU_HEIGHT)) + .child( + List::new(&self.list) + .searchable(false) + .max_h(MAX_MENU_HEIGHT), + ) .child( canvas( move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs index 01a1102f..b05597ea 100644 --- a/crates/ui/src/input/popovers/completion_menu.rs +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -195,7 +195,7 @@ impl CompletionMenu { selected_ix: 0, }; - let list = cx.new(|cx| ListState::new(menu, window, cx).no_query()); + let list = cx.new(|cx| ListState::new(menu, window, cx)); let _subscriptions = vec![ @@ -428,7 +428,11 @@ impl Render for CompletionMenu { editor_popover("completion-menu", cx) .max_w(max_width) .min_w(px(120.)) - .child(List::new(&self.list).max_h(MAX_MENU_HEIGHT)) + .child( + List::new(&self.list) + .searchable(false) + .max_h(MAX_MENU_HEIGHT), + ) .child( canvas( move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index 38b88f26..1238569b 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -16,8 +16,9 @@ use gpui::{ IntoElement, KeyBinding, Length, MouseButton, ParentElement, Render, Styled, Task, Window, }; use gpui::{ - px, size, App, AvailableSpace, ClickEvent, Context, Edges, EventEmitter, ListSizingBehavior, - Pixels, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, Subscription, + px, size, App, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, + EventEmitter, ListSizingBehavior, RenderOnce, ScrollStrategy, SharedString, + StatefulInteractiveElement, StyleRefinement, Subscription, }; use rust_i18n::t; use smol::Timer; @@ -46,8 +47,11 @@ pub enum ListEvent { struct ListOptions { size: Size, scrollbar_visible: bool, + selectable: bool, + searchable: bool, + search_placeholder: Option, max_height: Option, - paddings: Edges, + paddings: EdgesRefinement, } impl Default for ListOptions { @@ -56,7 +60,10 @@ impl Default for ListOptions { size: Size::default(), scrollbar_visible: true, max_height: None, - paddings: Edges::default(), + selectable: true, + searchable: true, + search_placeholder: None, + paddings: EdgesRefinement::default(), } } } @@ -68,8 +75,6 @@ pub struct ListState { query_input: Option>, delegate: D, last_query: Option, - selectable: bool, - querying: bool, scroll_handle: VirtualListScrollHandle, scroll_state: ScrollbarState, rows_cache: RowsCache, @@ -107,8 +112,6 @@ where mouse_right_clicked_index: None, scroll_handle: VirtualListScrollHandle::new(), scroll_state: ScrollbarState::default(), - selectable: true, - querying: false, reset_on_cancel: true, _search_task: Task::ready(()), _load_more_task: Task::ready(()), @@ -116,33 +119,6 @@ where } } - pub fn no_query(mut self) -> Self { - self.query_input = None; - self - } - - /// Sets whether the list is selectable, default is true. - pub fn selectable(mut self, selectable: bool) -> Self { - self.selectable = selectable; - self - } - - pub fn set_query_input( - &mut self, - query_input: Entity, - window: &mut Window, - cx: &mut Context, - ) { - self._query_input_subscription = - cx.subscribe_in(&query_input, window, Self::on_query_input_event); - self.query_input = Some(query_input); - } - - /// Get the query input entity. - pub fn query_input(&self) -> Option<&Entity> { - self.query_input.as_ref() - } - pub fn delegate(&self) -> &D { &self.delegate } @@ -242,7 +218,7 @@ where return; } - self.set_querying(true, window, cx); + self.set_searching(true, window, cx); let search = self.delegate.perform_search(&text, window, cx); if self.rows_cache.len() > 0 { @@ -262,7 +238,7 @@ where // Always wait 100ms to avoid flicker Timer::after(Duration::from_millis(100)).await; _ = this.update_in(window, |this, window, cx| { - this.set_querying(false, window, cx); + this.set_searching(false, window, cx); }); }); } @@ -277,10 +253,9 @@ where } } - fn set_querying(&mut self, querying: bool, window: &mut Window, cx: &mut Context) { - self.querying = querying; + fn set_searching(&mut self, searching: bool, window: &mut Window, cx: &mut Context) { if let Some(input) = &self.query_input { - input.update(cx, |input, cx| input.set_loading(querying, window, cx)) + input.update(cx, |input, cx| input.set_loading(searching, window, cx)) } cx.notify(); } @@ -424,8 +399,7 @@ where window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let selectable = self.selectable; - + let selectable = self.options.selectable; let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false); let mouse_right_clicked = self .mouse_right_clicked_index @@ -529,7 +503,7 @@ where .collect::>() }, ) - .paddings(self.options.paddings) + .paddings(self.options.paddings.clone()) .when(self.options.max_height.is_some(), |this| { this.with_sizing_behavior(ListSizingBehavior::Infer) }) @@ -549,6 +523,10 @@ where D: ListDelegate, { fn focus_handle(&self, cx: &App) -> FocusHandle { + if !self.options.searchable { + return self.focus_handle.clone(); + } + if let Some(query_input) = &self.query_input { query_input.focus_handle(cx) } else { @@ -572,7 +550,22 @@ where } let loading = self.delegate().loading(cx); - let query_input = self.query_input.clone(); + let query_input = if self.options.searchable { + // sync placeholder + if let Some(query_input) = &self.query_input { + if let Some(placeholder) = &self.options.search_placeholder { + query_input.update(cx, |input, cx| { + input.set_placeholder(placeholder.clone(), window, cx); + }); + } + Some(query_input.clone()) + } else { + None + } + } else { + None + }; + let loading_view = if loading { Some(self.delegate.render_loading(window, cx).into_any_element()) } else { @@ -648,6 +641,7 @@ where #[derive(IntoElement)] pub struct List { state: Entity>, + style: StyleRefinement, options: ListOptions, } @@ -659,25 +653,45 @@ where pub fn new(state: &Entity>) -> Self { Self { state: state.clone(), + style: StyleRefinement::default(), options: ListOptions::default(), } } - /// Set paddings for the list. - pub fn paddings(mut self, paddings: Edges) -> Self { - self.options.paddings = paddings; - self - } - - pub fn max_h(mut self, max_height: impl Into) -> Self { - self.options.max_height = Some(max_height.into()); - self - } - + /// Set whether the scrollbar is visible, default is `true`. pub fn scrollbar_visible(mut self, visible: bool) -> Self { self.options.scrollbar_visible = visible; self } + + /// Sets whether the list is selectable, default is true. + pub fn selectable(mut self, selectable: bool) -> Self { + self.options.selectable = selectable; + self + } + + /// Sets whether the list is searchable, default is `true`. + /// + /// When `true`, there will be a search input at the top of the list. + pub fn searchable(mut self, searchable: bool) -> Self { + self.options.searchable = searchable; + self + } + + /// Sets the placeholder text for the search input. + pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { + self.options.search_placeholder = Some(placeholder.into()); + self + } +} + +impl Styled for List +where + D: ListDelegate + 'static, +{ + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } } impl Sizable for List @@ -694,11 +708,22 @@ impl RenderOnce for List where D: ListDelegate + 'static, { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { + // Take paddings, max_height to options, and clear them from style, + // because they would be applied to the inner virtual list. + self.options.paddings = self.style.padding.clone(); + self.options.max_height = self.style.max_size.height; + self.style.padding = EdgesRefinement::default(); + self.style.max_size.height = None; + self.state.update(cx, |state, _| { state.options = self.options; }); - div().id("list").size_full().child(self.state.clone()) + div() + .id("list") + .size_full() + .refine_style(&self.style) + .child(self.state.clone()) } } diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index 34f597d3..c6ce542b 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -109,10 +109,6 @@ pub trait SelectDelegate: Sized { Self::Item: SelectItem, V: PartialEq; - fn searchable(&self) -> bool { - false - } - fn perform_search( &mut self, _query: &str, @@ -307,6 +303,8 @@ struct SelectOptions { cleanable: bool, placeholder: Option, title_prefix: Option, + searchable: bool, + search_placeholder: Option, empty: Option, menu_width: Length, disabled: bool, @@ -326,6 +324,8 @@ impl Default for SelectOptions { menu_width: Length::Auto, disabled: false, appearance: true, + searchable: true, + search_placeholder: None, } } } @@ -409,10 +409,6 @@ impl SelectDelegate for SearchableVec { None } - fn searchable(&self) -> bool { - true - } - fn perform_search( &mut self, query: &str, @@ -475,10 +471,6 @@ impl SelectDelegate for SearchableVec> { None } - fn searchable(&self) -> bool { - true - } - fn perform_search( &mut self, query: &str, @@ -566,15 +558,7 @@ where selected_index, }; - let searchable = delegate.delegate.searchable(); - - let list = cx.new(|cx| { - let mut list = ListState::new(delegate, window, cx).reset_on_cancel(false); - if !searchable { - list = list.no_query(); - } - list - }); + let list = cx.new(|cx| ListState::new(delegate, window, cx).reset_on_cancel(false)); let _subscriptions = vec![ cx.on_blur(&list.focus_handle(cx), window, Self::on_blur), @@ -901,6 +885,13 @@ where .shadow_md() .child( List::new(&self.list) + .searchable(self.options.searchable) + .when_some( + self.options.search_placeholder.clone(), + |this, placeholder| { + this.search_placeholder(placeholder) + }, + ) .with_size(self.options.size) .max_h(rems(20.)) .paddings(Edges::all(px(4.))), @@ -963,6 +954,20 @@ where self } + /// Sets whether the dropdown menu is searchable, default is `true`. + /// + /// When `true`, there will be a search input at the top of the dropdown menu. + pub fn searchable(mut self, searchable: bool) -> Self { + self.options.searchable = searchable; + self + } + + /// Sets the placeholder text for the search input. + pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { + self.options.search_placeholder = Some(placeholder.into()); + self + } + /// Set the disable state for the select. pub fn disabled(mut self, disabled: bool) -> Self { self.options.disabled = disabled; diff --git a/docs/docs/components/list.md b/docs/docs/components/list.md index 2d65edae..3fc3e78d 100644 --- a/docs/docs/components/list.md +++ b/docs/docs/components/list.md @@ -191,7 +191,8 @@ impl ListDelegate for MyListDelegate { } // Create list without search input -let state = cx.new(|cx| ListState::new(delegate, window, cx).no_query()); +let state = cx.new(|cx| ListState::new(delegate, window, cx)); +List::new(&state).searchable(false) ``` ### List with Loading State