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