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},
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()

View file

@ -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),
)
}
}

View file

@ -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),
),

View file

@ -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),

View file

@ -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),

View file

@ -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<SharedString>,
max_height: Option<Length>,
paddings: Edges<Pixels>,
paddings: EdgesRefinement<DefiniteLength>,
}
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<D: ListDelegate> {
query_input: Option<Entity<InputState>>,
delegate: D,
last_query: Option<String>,
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<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 {
&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>) {
self.querying = querying;
fn set_searching(&mut self, searching: bool, window: &mut Window, cx: &mut Context<Self>) {
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<Self>,
) -> 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::<Vec<_>>()
},
)
.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<D: ListDelegate + 'static> {
state: Entity<ListState<D>>,
style: StyleRefinement,
options: ListOptions,
}
@ -659,25 +653,45 @@ where
pub fn new(state: &Entity<ListState<D>>) -> Self {
Self {
state: state.clone(),
style: StyleRefinement::default(),
options: ListOptions::default(),
}
}
/// Set paddings for the list.
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
}
/// 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<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>
@ -694,11 +708,22 @@ impl<D> RenderOnce for List<D>
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())
}
}

View file

@ -109,10 +109,6 @@ pub trait SelectDelegate: Sized {
Self::Item: SelectItem<Value = V>,
V: PartialEq;
fn searchable(&self) -> bool {
false
}
fn perform_search(
&mut self,
_query: &str,
@ -307,6 +303,8 @@ struct SelectOptions {
cleanable: bool,
placeholder: Option<SharedString>,
title_prefix: Option<SharedString>,
searchable: bool,
search_placeholder: Option<SharedString>,
empty: Option<AnyElement>,
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<I: SelectItem> SelectDelegate for SearchableVec<I> {
None
}
fn searchable(&self) -> bool {
true
}
fn perform_search(
&mut self,
query: &str,
@ -475,10 +471,6 @@ impl<I: SelectItem> SelectDelegate for SearchableVec<SelectGroup<I>> {
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<SharedString>) -> 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;

View file

@ -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