Skip to main content

gpui_component/searchable_list/
state.rs

1use gpui::{
2    AnyElement, App, AppContext as _, Bounds, Context, Entity, FocusHandle, Focusable as _, Length,
3    Pixels, StyleRefinement, Subscription, Window,
4};
5
6use gpui_base::DeferredPopover;
7
8use crate::{IndexPath, Size, list::ListState, searchable_list::adapter::SearchableListAdapter};
9
10use super::delegate::{SearchableListDelegate, SearchableListItem};
11
12/// Shared infrastructure for all searchable-list-based components (`SelectState`, `ComboBoxState`).
13///
14/// This struct is a plain nested value inside a GPUI entity — it has no entity context of its
15/// own and cannot call `cx.notify()` or `cx.emit()`. Callers are responsible for those after
16/// calling mutable methods.
17pub struct SearchableListState<D: SearchableListDelegate + 'static>
18where
19    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
20{
21    pub focus_handle: FocusHandle,
22    pub(crate) list: Entity<ListState<SearchableListAdapter<D>>>,
23    pub(crate) selection: Vec<(IndexPath, D::Item)>,
24    pub(crate) open: bool,
25    /// Held while the popup is open, so that a component dropped without
26    /// closing it first takes its registration with it.
27    pub(crate) deferred_context: Option<DeferredPopover>,
28    pub(crate) bounds: Bounds<Pixels>,
29
30    // Shared options
31    pub(crate) size: Size,
32    pub(crate) style: StyleRefinement,
33    pub(crate) cleanable: bool,
34    pub(crate) placeholder: Option<gpui::SharedString>,
35    pub(crate) search_placeholder: Option<gpui::SharedString>,
36    pub(crate) menu_width: Length,
37    pub(crate) menu_max_h: Length,
38    pub(crate) scroll_bounce: Option<bool>,
39    pub(crate) disabled: bool,
40    pub(crate) appearance: bool,
41    pub(crate) empty: Option<Box<dyn Fn(&mut Window, &App) -> AnyElement + 'static>>,
42
43    pub(crate) _subscriptions: Vec<Subscription>,
44}
45
46#[allow(private_bounds)]
47impl<D: SearchableListDelegate + 'static> SearchableListState<D>
48where
49    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
50{
51    /// Create a new `SearchableListState`, creating the list entity in the given parent context.
52    ///
53    /// `on_confirm`, `on_cancel`, and `on_render_empty` are forwarded to the underlying adapter.
54    /// `on_blur` is a function pointer invoked on the parent entity when focus leaves any of the
55    /// list's focus handles.
56    #[allow(clippy::too_many_arguments)]
57    pub fn new<P: 'static>(
58        delegate: D,
59        selected_indices: Vec<IndexPath>,
60        on_confirm: impl Fn(
61            Option<IndexPath>,
62            bool,
63            &mut Window,
64            &mut Context<ListState<SearchableListAdapter<D>>>,
65        ) + 'static,
66        on_cancel: impl Fn(
67            Option<IndexPath>,
68            &mut Window,
69            &mut Context<ListState<SearchableListAdapter<D>>>,
70        ) + 'static,
71        on_render_empty: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
72        on_blur: fn(&mut P, &mut Window, &mut Context<P>),
73        window: &mut Window,
74        cx: &mut Context<P>,
75    ) -> Self {
76        let focus_handle = cx.focus_handle();
77
78        let adapter = SearchableListAdapter::new(delegate, on_confirm, on_cancel, on_render_empty);
79        let list = cx.new(|cx| ListState::new(adapter, window, cx).reset_on_cancel(false));
80
81        let list_focus_handle = list.read(cx).focus_handle.clone();
82        let list_search_focus_handle = list.read(cx).query_input.focus_handle(cx);
83
84        let selection = {
85            let delegate = &list.read(cx).delegate().delegate;
86
87            selected_indices
88                .iter()
89                .copied()
90                .filter_map(|ix| delegate.item(ix).map(|i| (ix, i.clone())))
91                .collect::<Vec<_>>()
92        };
93
94        if let Some(cursor) = selected_indices.first().copied() {
95            list.update(cx, |l, cx| {
96                l.set_selected_index(Some(cursor), window, cx);
97            });
98        }
99
100        // Prime the adapter's snapshot so the very first render pass sees correct check state.
101        let initial_snapshot = selection.clone();
102        list.update(cx, |l, _| {
103            l.delegate_mut().update_selection_snapshot(initial_snapshot);
104        });
105
106        let _subscriptions = vec![
107            cx.on_blur(&list_focus_handle, window, on_blur),
108            cx.on_blur(&list_search_focus_handle, window, on_blur),
109            cx.on_blur(&focus_handle, window, on_blur),
110        ];
111
112        Self {
113            focus_handle,
114            list,
115            selection,
116            open: false,
117            deferred_context: None,
118            bounds: Bounds::default(),
119            size: Size::default(),
120            style: StyleRefinement::default(),
121            cleanable: false,
122            placeholder: None,
123            search_placeholder: None,
124            menu_width: Length::Auto,
125            menu_max_h: gpui::rems(20.).into(),
126            scroll_bounce: None,
127            disabled: false,
128            appearance: true,
129            empty: None,
130            _subscriptions,
131        }
132    }
133
134    // MARK: Read-only accessors
135
136    pub fn selection(&self) -> &[(IndexPath, D::Item)] {
137        &self.selection
138    }
139
140    pub fn selected_values(&self) -> Vec<<D::Item as SearchableListItem>::Value> {
141        self.selection
142            .iter()
143            .map(|(_ix, i)| i.value().clone())
144            .collect()
145    }
146
147    pub fn is_open(&self) -> bool {
148        self.open
149    }
150
151    pub fn focus_handle(&self) -> &FocusHandle {
152        &self.focus_handle
153    }
154
155    /// Drop the search query, if there is one, so that a value lookup resolves
156    /// against every item rather than the filtered view.
157    ///
158    /// A delegate's `position` answers for the list as it is currently
159    /// displayed, which is what a click or a keyboard cursor needs. Projecting
160    /// an authoritative value in from outside is not a position in that view:
161    /// the value's item may be filtered out, and dropping it would let whatever
162    /// the user last typed decide which values can be selected at all.
163    ///
164    /// Search runs synchronously for a delegate holding its own items, so a
165    /// lookup right after this call sees the full set. One that fetches its
166    /// items cannot answer for a value it has not fetched, and this cannot make
167    /// it.
168    pub(crate) fn clear_query(&mut self, window: &mut Window, cx: &mut App) {
169        self.list.update(cx, |list, cx| {
170            if !list.query_input.read(cx).value().is_empty() {
171                list.set_query("", window, cx);
172            }
173        });
174    }
175
176    // MARK: Mutation (no cx — callers emit events and notify)
177
178    /// Add an index+item pair to the selection; no-op if already present.
179    pub(crate) fn add_by_item(&mut self, index: IndexPath, item: D::Item) {
180        if self.selection.iter().any(|(ix, _)| ix == &index) {
181            return;
182        }
183
184        self.selection.push((index, item));
185    }
186
187    /// Remove an index from the selection by index path.
188    pub(crate) fn remove_by_index(&mut self, index: &IndexPath) -> bool {
189        if let Some(pos) = self.selection.iter().position(|(ix, _)| ix == index) {
190            self.selection.remove(pos);
191
192            return true;
193        }
194
195        false
196    }
197
198    /// Add a single index to the selection by looking up the item in the list.
199    ///
200    /// Requires `cx` only to read the list entity; does not notify.
201    pub fn add_selected_index(&mut self, index: IndexPath, cx: &App) -> bool {
202        if self.selection.iter().any(|(ix, _)| ix == &index) {
203            return false;
204        }
205
206        let Some(item) = self.list.read(cx).delegate().delegate.item(index) else {
207            return false;
208        };
209
210        self.add_by_item(index, item.clone());
211
212        true
213    }
214
215    /// Remove a single index from the selection.
216    pub fn remove_selected_index(&mut self, index: IndexPath) -> bool {
217        self.remove_by_index(&index)
218    }
219
220    /// Replace the entire selection, looking up items from the list.
221    pub fn set_selected_indices(&mut self, indices: impl IntoIterator<Item = IndexPath>, cx: &App) {
222        let indices: Vec<IndexPath> = indices.into_iter().collect();
223
224        self.selection = indices
225            .into_iter()
226            .filter_map(|ix| {
227                self.list
228                    .read(cx)
229                    .delegate()
230                    .delegate
231                    .item(ix)
232                    .map(|i| (ix, i.clone()))
233            })
234            .collect();
235    }
236
237    /// Push the current selection into the adapter's snapshot so the next render pass sees
238    /// up-to-date check state. Call after every mutation that changes `self.selection`.
239    pub(crate) fn sync_snapshot<P: 'static>(&self, cx: &mut Context<P>) {
240        let snapshot = self.selection.clone();
241        self.list.update(cx, |l, _| {
242            l.delegate_mut().update_selection_snapshot(snapshot);
243        });
244    }
245}