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.
This commit is contained in:
Jason Lee 2025-11-04 19:06:50 +08:00 committed by GitHub
parent 27f1f960a0
commit 2870bc6e36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 130 additions and 96 deletions

View file

@ -15,7 +15,7 @@ use gpui_component::{
date_picker::{DatePicker, DatePickerState}, date_picker::{DatePicker, DatePickerState},
h_flex, h_flex,
input::{Input, InputState}, input::{Input, InputState},
list::{ListDelegate, ListItem, ListState}, list::{List, ListDelegate, ListItem, ListState},
v_flex, v_flex,
webview::WebView, webview::WebView,
wry, wry,
@ -245,11 +245,6 @@ impl DrawerStory {
let list = cx.new(|cx| { let list = cx.new(|cx| {
let mut list = ListState::new(delegate, window, cx); let mut list = ListState::new(delegate, window, cx);
list.focus(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 list
}); });
@ -308,14 +303,13 @@ impl DrawerStory {
}), }),
) )
.child( .child(
div() List::new(&list)
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.size_full() .size_full()
.flex_1() .flex_1()
.h(list_h) .h(list_h),
.child(list.clone()),
) )
.footer( .footer(
h_flex() h_flex()

View file

@ -2,7 +2,7 @@ use std::{rc::Rc, time::Duration};
use fake::Fake; use fake::Fake;
use gpui::{ 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, IntoElement, ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, Styled,
Subscription, Task, Timer, Window, actions, div, prelude::FluentBuilder as _, px, Subscription, Task, Timer, Window, actions, div, prelude::FluentBuilder as _, px,
}; };
@ -535,13 +535,13 @@ impl Render for ListStory {
), ),
) )
.child( .child(
div() List::new(&self.company_list)
.p(px(8.))
.flex_1() .flex_1()
.w_full() .w_full()
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(cx.theme().radius) .rounded(cx.theme().radius),
.child(List::new(&self.company_list).paddings(Edges::all(px(8.)))),
) )
} }
} }

View file

@ -191,6 +191,7 @@ impl Render for SelectStory {
.child( .child(
section("Select").max_w_128().child( section("Select").max_w_128().child(
Select::new(&self.country_select) Select::new(&self.country_select)
.search_placeholder("Search country by name or code")
.cleanable() .cleanable()
.disabled(self.disabled), .disabled(self.disabled),
), ),

View file

@ -164,7 +164,7 @@ impl CodeActionMenu {
selected_ix: 0, 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 = let _subscriptions =
vec![ vec![
@ -327,7 +327,11 @@ impl Render for CodeActionMenu {
.top(pos.y) .top(pos.y)
.max_w(max_width) .max_w(max_width)
.min_w(px(120.)) .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( .child(
canvas( canvas(
move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),

View file

@ -195,7 +195,7 @@ impl CompletionMenu {
selected_ix: 0, 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 = let _subscriptions =
vec![ vec![
@ -428,7 +428,11 @@ impl Render for CompletionMenu {
editor_popover("completion-menu", cx) editor_popover("completion-menu", cx)
.max_w(max_width) .max_w(max_width)
.min_w(px(120.)) .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( .child(
canvas( canvas(
move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),

View file

@ -16,8 +16,9 @@ use gpui::{
IntoElement, KeyBinding, Length, MouseButton, ParentElement, Render, Styled, Task, Window, IntoElement, KeyBinding, Length, MouseButton, ParentElement, Render, Styled, Task, Window,
}; };
use gpui::{ use gpui::{
px, size, App, AvailableSpace, ClickEvent, Context, Edges, EventEmitter, ListSizingBehavior, px, size, App, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement,
Pixels, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, Subscription, EventEmitter, ListSizingBehavior, RenderOnce, ScrollStrategy, SharedString,
StatefulInteractiveElement, StyleRefinement, Subscription,
}; };
use rust_i18n::t; use rust_i18n::t;
use smol::Timer; use smol::Timer;
@ -46,8 +47,11 @@ pub enum ListEvent {
struct ListOptions { struct ListOptions {
size: Size, size: Size,
scrollbar_visible: bool, scrollbar_visible: bool,
selectable: bool,
searchable: bool,
search_placeholder: Option<SharedString>,
max_height: Option<Length>, max_height: Option<Length>,
paddings: Edges<Pixels>, paddings: EdgesRefinement<DefiniteLength>,
} }
impl Default for ListOptions { impl Default for ListOptions {
@ -56,7 +60,10 @@ impl Default for ListOptions {
size: Size::default(), size: Size::default(),
scrollbar_visible: true, scrollbar_visible: true,
max_height: None, max_height: None,
paddings: Edges::default(), selectable: true,
searchable: true,
search_placeholder: None,
paddings: EdgesRefinement::default(),
} }
} }
} }
@ -68,8 +75,6 @@ pub struct ListState<D: ListDelegate> {
query_input: Option<Entity<InputState>>, query_input: Option<Entity<InputState>>,
delegate: D, delegate: D,
last_query: Option<String>, last_query: Option<String>,
selectable: bool,
querying: bool,
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
scroll_state: ScrollbarState, scroll_state: ScrollbarState,
rows_cache: RowsCache, rows_cache: RowsCache,
@ -107,8 +112,6 @@ where
mouse_right_clicked_index: None, mouse_right_clicked_index: None,
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
scroll_state: ScrollbarState::default(), scroll_state: ScrollbarState::default(),
selectable: true,
querying: false,
reset_on_cancel: true, reset_on_cancel: true,
_search_task: Task::ready(()), _search_task: Task::ready(()),
_load_more_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<InputState>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<InputState>> {
self.query_input.as_ref()
}
pub fn delegate(&self) -> &D { pub fn delegate(&self) -> &D {
&self.delegate &self.delegate
} }
@ -242,7 +218,7 @@ where
return; return;
} }
self.set_querying(true, window, cx); self.set_searching(true, window, cx);
let search = self.delegate.perform_search(&text, window, cx); let search = self.delegate.perform_search(&text, window, cx);
if self.rows_cache.len() > 0 { if self.rows_cache.len() > 0 {
@ -262,7 +238,7 @@ where
// Always wait 100ms to avoid flicker // Always wait 100ms to avoid flicker
Timer::after(Duration::from_millis(100)).await; Timer::after(Duration::from_millis(100)).await;
_ = this.update_in(window, |this, window, cx| { _ = 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>) { fn set_searching(&mut self, searching: bool, window: &mut Window, cx: &mut Context<Self>) {
self.querying = querying;
if let Some(input) = &self.query_input { 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(); cx.notify();
} }
@ -424,8 +399,7 @@ where
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> 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 selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false);
let mouse_right_clicked = self let mouse_right_clicked = self
.mouse_right_clicked_index .mouse_right_clicked_index
@ -529,7 +503,7 @@ where
.collect::<Vec<_>>() .collect::<Vec<_>>()
}, },
) )
.paddings(self.options.paddings) .paddings(self.options.paddings.clone())
.when(self.options.max_height.is_some(), |this| { .when(self.options.max_height.is_some(), |this| {
this.with_sizing_behavior(ListSizingBehavior::Infer) this.with_sizing_behavior(ListSizingBehavior::Infer)
}) })
@ -549,6 +523,10 @@ where
D: ListDelegate, D: ListDelegate,
{ {
fn focus_handle(&self, cx: &App) -> FocusHandle { fn focus_handle(&self, cx: &App) -> FocusHandle {
if !self.options.searchable {
return self.focus_handle.clone();
}
if let Some(query_input) = &self.query_input { if let Some(query_input) = &self.query_input {
query_input.focus_handle(cx) query_input.focus_handle(cx)
} else { } else {
@ -572,7 +550,22 @@ where
} }
let loading = self.delegate().loading(cx); 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 { let loading_view = if loading {
Some(self.delegate.render_loading(window, cx).into_any_element()) Some(self.delegate.render_loading(window, cx).into_any_element())
} else { } else {
@ -648,6 +641,7 @@ where
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct List<D: ListDelegate + 'static> { pub struct List<D: ListDelegate + 'static> {
state: Entity<ListState<D>>, state: Entity<ListState<D>>,
style: StyleRefinement,
options: ListOptions, options: ListOptions,
} }
@ -659,25 +653,45 @@ where
pub fn new(state: &Entity<ListState<D>>) -> Self { pub fn new(state: &Entity<ListState<D>>) -> Self {
Self { Self {
state: state.clone(), state: state.clone(),
style: StyleRefinement::default(),
options: ListOptions::default(), options: ListOptions::default(),
} }
} }
/// Set paddings for the list. /// Set whether the scrollbar is visible, default is `true`.
pub fn paddings(mut self, paddings: Edges<Pixels>) -> Self {
self.options.paddings = paddings;
self
}
pub fn max_h(mut self, max_height: impl Into<Length>) -> Self {
self.options.max_height = Some(max_height.into());
self
}
pub fn scrollbar_visible(mut self, visible: bool) -> Self { pub fn scrollbar_visible(mut self, visible: bool) -> Self {
self.options.scrollbar_visible = visible; self.options.scrollbar_visible = visible;
self 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<SharedString>) -> Self {
self.options.search_placeholder = Some(placeholder.into());
self
}
}
impl<D> Styled for List<D>
where
D: ListDelegate + 'static,
{
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
} }
impl<D> Sizable for List<D> impl<D> Sizable for List<D>
@ -694,11 +708,22 @@ impl<D> RenderOnce for List<D>
where where
D: ListDelegate + 'static, 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, _| { self.state.update(cx, |state, _| {
state.options = self.options; 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())
} }
} }

View file

@ -109,10 +109,6 @@ pub trait SelectDelegate: Sized {
Self::Item: SelectItem<Value = V>, Self::Item: SelectItem<Value = V>,
V: PartialEq; V: PartialEq;
fn searchable(&self) -> bool {
false
}
fn perform_search( fn perform_search(
&mut self, &mut self,
_query: &str, _query: &str,
@ -307,6 +303,8 @@ struct SelectOptions {
cleanable: bool, cleanable: bool,
placeholder: Option<SharedString>, placeholder: Option<SharedString>,
title_prefix: Option<SharedString>, title_prefix: Option<SharedString>,
searchable: bool,
search_placeholder: Option<SharedString>,
empty: Option<AnyElement>, empty: Option<AnyElement>,
menu_width: Length, menu_width: Length,
disabled: bool, disabled: bool,
@ -326,6 +324,8 @@ impl Default for SelectOptions {
menu_width: Length::Auto, menu_width: Length::Auto,
disabled: false, disabled: false,
appearance: true, appearance: true,
searchable: true,
search_placeholder: None,
} }
} }
} }
@ -409,10 +409,6 @@ impl<I: SelectItem> SelectDelegate for SearchableVec<I> {
None None
} }
fn searchable(&self) -> bool {
true
}
fn perform_search( fn perform_search(
&mut self, &mut self,
query: &str, query: &str,
@ -475,10 +471,6 @@ impl<I: SelectItem> SelectDelegate for SearchableVec<SelectGroup<I>> {
None None
} }
fn searchable(&self) -> bool {
true
}
fn perform_search( fn perform_search(
&mut self, &mut self,
query: &str, query: &str,
@ -566,15 +558,7 @@ where
selected_index, selected_index,
}; };
let searchable = delegate.delegate.searchable(); let list = cx.new(|cx| ListState::new(delegate, window, cx).reset_on_cancel(false));
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 _subscriptions = vec![ let _subscriptions = vec![
cx.on_blur(&list.focus_handle(cx), window, Self::on_blur), cx.on_blur(&list.focus_handle(cx), window, Self::on_blur),
@ -901,6 +885,13 @@ where
.shadow_md() .shadow_md()
.child( .child(
List::new(&self.list) 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) .with_size(self.options.size)
.max_h(rems(20.)) .max_h(rems(20.))
.paddings(Edges::all(px(4.))), .paddings(Edges::all(px(4.))),
@ -963,6 +954,20 @@ where
self 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<SharedString>) -> Self {
self.options.search_placeholder = Some(placeholder.into());
self
}
/// Set the disable state for the select. /// Set the disable state for the select.
pub fn disabled(mut self, disabled: bool) -> Self { pub fn disabled(mut self, disabled: bool) -> Self {
self.options.disabled = disabled; self.options.disabled = disabled;

View file

@ -191,7 +191,8 @@ impl ListDelegate for MyListDelegate {
} }
// Create list without search input // 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 ### List with Loading State