Skip to main content

gpui_cn/select/
state.rs

1//! The rows, the selection, the query, and the open menu of a select.
2
3use std::{cell::Cell, rc::Rc};
4
5use gpui_kit::{
6    App, AppContext as _, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable,
7    ListAlignment, ListState, Pixels, SharedString, Subscription, Task, Window,
8    base::{
9        DeferredPopover, GlobalState, Placement,
10        input::{InputEvent, InputState},
11    },
12    px,
13};
14
15use super::item::{self, SelectEntry, SelectItem, SelectValue};
16use crate::{ActiveTheme as _, MenuState, menu::search_style};
17
18/// What a [`SelectState`] reports.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum SelectEvent<V> {
21    /// The selection changed. The payload is every selected value, in
22    /// the order chosen; one or none for a single select.
23    Changed(Vec<V>),
24    /// The menu opened or closed.
25    OpenChanged(bool),
26}
27
28/// A search the application runs itself: given the query, it returns the
29/// rows to show, when it has them. See
30/// [`SelectState::with_search_handler`].
31pub type SearchHandler<V> =
32    Rc<dyn Fn(SharedString, &mut Context<SelectState<V>>) -> Task<Vec<SelectEntry<V>>>>;
33
34/// The search field at the top of the menu, and the handler that answers
35/// its query when the application does rather than the local filter.
36struct SearchField<V: SelectValue> {
37    input: Entity<InputState>,
38    handler: Option<SearchHandler<V>>,
39    /// The query the rows answer to. The field reports a change for an
40    /// edit that leaves its text as it was, such as an Enter; the rows
41    /// answer only to a query that differs.
42    query: SharedString,
43    /// The field's right-click menu. The select's own popup blocks base's
44    /// hook for it, so the menu opens from the search row.
45    menu: Entity<MenuState>,
46}
47
48/// The rows, the selection, and the open menu of a [`Select`](super::Select).
49///
50/// Owned by the view that shows the select, which observes it so a change
51/// renders. Edit the rows and the selection through its methods; the
52/// menu's own state (the query, the highlighted row, the scroll position)
53/// is the state's to keep.
54///
55/// The selection is kept as items, not values alone, so the trigger can
56/// show a chosen item's label after a search has replaced the rows.
57pub struct SelectState<V: SelectValue> {
58    entries: Vec<SelectEntry<V>>,
59    selected: Vec<SelectItem<V>>,
60    multiple: bool,
61    open: bool,
62    /// The row the keyboard is on: an index into `visible`.
63    highlighted: Option<usize>,
64    /// The rows that show for the query: indices into `entries`.
65    visible: Vec<usize>,
66    search: Option<SearchField<V>>,
67    searching: bool,
68    /// The running search. Dropping it cancels it, so a new query cancels
69    /// the search before it and no stale rows arrive.
70    search_task: Option<Task<()>>,
71    list: ListState,
72    /// The height every row is given, when one is fixed; the rows are
73    /// otherwise as tall as their content, with the plain row's height as
74    /// the hint for rows not yet measured.
75    row_height: Option<Pixels>,
76    /// Which side of the trigger the menu opened on, decided once per
77    /// opening from the room the menu's full height needs, so a search
78    /// that shortens the rows does not swing the menu to the other side.
79    menu_placement: Option<Placement>,
80    trigger_focus: FocusHandle,
81    content_focus: FocusHandle,
82    /// Where the trigger was last laid out, which the menu opens from.
83    trigger_bounds: Rc<Cell<Bounds<Pixels>>>,
84    /// Held while open, so the rest of the application knows a popup is
85    /// up, and released with the state if it is dropped open.
86    deferred: Option<DeferredPopover>,
87    focus_watch: Vec<Subscription>,
88    _subscriptions: Vec<Subscription>,
89}
90
91impl<V: SelectValue> EventEmitter<SelectEvent<V>> for SelectState<V> {}
92
93/// The trigger's focus handle: focusing the state focuses the trigger.
94impl<V: SelectValue> Focusable for SelectState<V> {
95    fn focus_handle(&self, _: &App) -> FocusHandle {
96        self.trigger_focus.clone()
97    }
98}
99
100impl<V: SelectValue> SelectState<V> {
101    /// A closed, single select over `entries` with nothing selected.
102    pub fn new(
103        entries: impl IntoIterator<Item = impl Into<SelectEntry<V>>>,
104        cx: &mut Context<Self>,
105    ) -> Self {
106        Self::with_multiple(false, entries, cx)
107    }
108
109    /// A closed select over `entries` in which more than one item can be
110    /// selected: the menu stays open while items are toggled, and the
111    /// trigger counts the selection.
112    pub fn multiple(
113        entries: impl IntoIterator<Item = impl Into<SelectEntry<V>>>,
114        cx: &mut Context<Self>,
115    ) -> Self {
116        Self::with_multiple(true, entries, cx)
117    }
118
119    fn with_multiple(
120        multiple: bool,
121        entries: impl IntoIterator<Item = impl Into<SelectEntry<V>>>,
122        cx: &mut Context<Self>,
123    ) -> Self {
124        let entries: Vec<SelectEntry<V>> = entries.into_iter().map(Into::into).collect();
125        let visible = item::visible_entries(&entries, "");
126        // The list sizes itself from the rows it lays out, and on its
127        // first frame it lays out only one screen of overdraw. With none,
128        // a menu opened one row wide and widened on the next frame; with
129        // a menu's height of overdraw, every row that can show measures
130        // on the first frame.
131        let overdraw = cx.theme().metrics.menu_max_height;
132        let list = ListState::new(visible.len(), ListAlignment::Top, overdraw);
133        Self {
134            entries,
135            selected: Vec::new(),
136            multiple,
137            open: false,
138            highlighted: None,
139            visible,
140            search: None,
141            searching: false,
142            search_task: None,
143            list,
144            row_height: None,
145            menu_placement: None,
146            trigger_focus: cx.focus_handle(),
147            content_focus: cx.focus_handle(),
148            trigger_bounds: Rc::new(Cell::new(Bounds::default())),
149            deferred: None,
150            focus_watch: Vec::new(),
151            _subscriptions: Vec::new(),
152        }
153    }
154
155    /// Adds a search field at the top of the menu, with `placeholder` in it
156    /// while it is empty. Typing narrows the rows to those whose label or
157    /// keywords the query matches as a fuzzy subsequence, ignoring case.
158    pub fn with_search(
159        self,
160        placeholder: impl Into<SharedString>,
161        window: &mut Window,
162        cx: &mut Context<Self>,
163    ) -> Self {
164        self.with_search_field(placeholder, None, window, cx)
165    }
166
167    /// Adds a search field whose query the application answers: for each
168    /// change, and once when the menu opens, `handler` gets the query and
169    /// returns a task with the rows to show. The task can do its work on
170    /// a background thread or over the network; a newer query cancels the
171    /// task before it. The rows given to the state are the ones shown
172    /// until the first answer, and a chosen item stays selected when a
173    /// later answer no longer lists it.
174    ///
175    /// The state keeps no answers: what makes one stale is the
176    /// application's to know. A handler that remembers its answers can
177    /// return `Task::ready` for a query it has seen, and the menu shows
178    /// those rows on the next frame.
179    ///
180    /// ```
181    /// use gpui_cn::{SelectEntry, SelectItem, SelectState};
182    /// use gpui_kit::{AppContext as _, Context, Task, Window};
183    ///
184    /// fn cities(window: &mut Window, cx: &mut Context<SelectState<usize>>) -> SelectState<usize> {
185    ///     SelectState::new([SelectItem::new(1, "Zurich")], cx).with_search_handler(
186    ///         "Search cities",
187    ///         |query, cx| {
188    ///             cx.background_spawn(async move {
189    ///                 // Any search: fuzzy, indexed, or a request.
190    ///                 vec![SelectEntry::from(SelectItem::new(2, format!("{query} City")))]
191    ///             })
192    ///         },
193    ///         window,
194    ///         cx,
195    ///     )
196    /// }
197    /// ```
198    pub fn with_search_handler(
199        self,
200        placeholder: impl Into<SharedString>,
201        handler: impl Fn(SharedString, &mut Context<Self>) -> Task<Vec<SelectEntry<V>>> + 'static,
202        window: &mut Window,
203        cx: &mut Context<Self>,
204    ) -> Self {
205        self.with_search_field(placeholder, Some(Rc::new(handler)), window, cx)
206    }
207
208    fn with_search_field(
209        mut self,
210        placeholder: impl Into<SharedString>,
211        handler: Option<SearchHandler<V>>,
212        window: &mut Window,
213        cx: &mut Context<Self>,
214    ) -> Self {
215        let style = search_style(cx.theme());
216        let input = cx.new(|cx| {
217            let mut input = InputState::new(window, cx).placeholder(placeholder);
218            input.set_editor_style(style);
219            input
220        });
221        self._subscriptions
222            .push(cx.subscribe(&input, |this, input, event: &InputEvent, cx| {
223                if !matches!(event, InputEvent::Change) {
224                    return;
225                }
226                let query = input.read(cx).value();
227                if let Some(search) = &mut this.search
228                    && search.query != query
229                {
230                    search.query = query;
231                    this.query_changed(cx);
232                }
233            }));
234        let menu = cx.new(MenuState::new);
235        self._subscriptions
236            .push(cx.observe(&menu, |_, _, cx| cx.notify()));
237        self.search = Some(SearchField {
238            input,
239            handler,
240            query: SharedString::default(),
241            menu,
242        });
243        self
244    }
245
246    /// Seeds the selection. Values that are not items are dropped, and a
247    /// single select keeps the first.
248    pub fn with_selected(mut self, values: impl IntoIterator<Item = V>) -> Self {
249        self.selected = self.items_for(values);
250        self
251    }
252
253    /// Gives every item row this height. Without it a row is as tall as
254    /// its content, a plain row's height at least, and the list measures
255    /// rows as they come into view. With it the list knows every row's
256    /// height before it is laid out, so a scroll to a far row of a long
257    /// menu lands exactly; rows drawn by
258    /// [`Select::render_item`](super::Select::render_item) clip to it.
259    pub fn with_row_height(mut self, height: Pixels) -> Self {
260        self.row_height = Some(height);
261        self
262    }
263
264    /// The rows, in menu order.
265    pub fn entries(&self) -> &[SelectEntry<V>] {
266        &self.entries
267    }
268
269    /// Replaces the rows. With a local search, a selected value that is
270    /// no longer an item is dropped from the selection; with a handler
271    /// the rows come and go with the query, so the selection stays.
272    pub fn set_entries(
273        &mut self,
274        entries: impl IntoIterator<Item = impl Into<SelectEntry<V>>>,
275        cx: &mut Context<Self>,
276    ) {
277        self.entries = entries.into_iter().map(Into::into).collect();
278        if self.search_handler().is_none() {
279            let selected = self.items_for(self.selected.iter().map(|item| item.value().clone()));
280            if !same_values(&selected, &self.selected) {
281                self.selected = selected;
282                cx.emit(SelectEvent::Changed(self.selected()));
283            }
284        }
285        self.rows_changed(cx);
286    }
287
288    /// The selected values, in the order chosen.
289    pub fn selected(&self) -> Vec<V> {
290        self.selected
291            .iter()
292            .map(|item| item.value().clone())
293            .collect()
294    }
295
296    /// The selected value of a single select.
297    pub fn selected_value(&self) -> Option<&V> {
298        self.selected.first().map(SelectItem::value)
299    }
300
301    /// The selected items, in the order chosen.
302    pub fn selected_items(&self) -> &[SelectItem<V>] {
303        &self.selected
304    }
305
306    /// Whether `value` is selected.
307    pub fn is_selected(&self, value: &V) -> bool {
308        self.selected.iter().any(|item| item.value() == value)
309    }
310
311    /// Replaces the selection and reports the change. Values that are not
312    /// items are dropped, and a single select keeps the first.
313    pub fn set_selected(&mut self, values: impl IntoIterator<Item = V>, cx: &mut Context<Self>) {
314        let selected = self.items_for(values);
315        if same_values(&selected, &self.selected) {
316            return;
317        }
318        self.selected = selected;
319        cx.emit(SelectEvent::Changed(self.selected()));
320        cx.notify();
321    }
322
323    /// Whether more than one item can be selected.
324    pub fn is_multiple(&self) -> bool {
325        self.multiple
326    }
327
328    /// Whether the menu has a search field.
329    pub fn is_searchable(&self) -> bool {
330        self.search.is_some()
331    }
332
333    /// Whether a search handler is still answering the query.
334    pub fn is_searching(&self) -> bool {
335        self.searching
336    }
337
338    /// Whether the menu is open.
339    pub fn is_open(&self) -> bool {
340        self.open
341    }
342
343    /// The search query, or an empty string without a search field.
344    pub fn query(&self, cx: &App) -> SharedString {
345        self.search
346            .as_ref()
347            .map(|search| search.input.read(cx).value())
348            .unwrap_or_default()
349    }
350
351    /// The item the keyboard is on while the menu is open.
352    pub fn highlighted(&self) -> Option<&SelectItem<V>> {
353        self.entry_at(self.highlighted?)?.item()
354    }
355
356    /// Opens the menu, highlights the selected item (or the first), decides
357    /// which side of the trigger the menu takes, and moves focus into the
358    /// menu: to the search field, else to the list. A search handler is
359    /// asked for the rows of the empty query.
360    pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
361        if self.open {
362            return;
363        }
364        self.open = true;
365        self.deferred = Some(GlobalState::register_deferred_popover(cx));
366        // Below when the menu's full height fits, else above when that
367        // fits, else whichever side has more room; and kept while open,
368        // so a search that shortens the rows never flips the menu.
369        let (max_height, gap) = {
370            let metrics = &cx.theme().metrics;
371            (metrics.menu_max_height, metrics.menu_gap)
372        };
373        self.menu_placement = Some(crate::menu::placement(
374            self.trigger_bounds.get(),
375            window.viewport_size().height,
376            max_height,
377            gap,
378        ));
379        if let Some(search) = &self.search {
380            let style = search_style(cx.theme());
381            search
382                .input
383                .update(cx, |input, _| input.set_editor_style(style));
384        }
385        self.rows_changed(cx);
386        // A scroll to the highlighted row needs the list's height, which
387        // it has only once it has been laid out: on the first opening it
388        // has not been, so the row is revealed again after that frame.
389        if self.list.viewport_bounds().size.height <= px(0.) {
390            let this = cx.weak_entity();
391            window.on_next_frame(move |_, cx| {
392                this.update(cx, |this, cx| {
393                    this.reveal_highlighted();
394                    cx.notify();
395                })
396                .ok();
397            });
398        }
399        self.focus_content(window, cx);
400        // Focus that leaves the menu for anywhere but the trigger closes
401        // it: a Tab out, a click on a field. Focus that returns to the
402        // trigger is a close already under way. The list and the search
403        // field each hold focus on their own (GPUI does not see the field
404        // inside the list's node), so both are watched.
405        let mut watched = vec![self.content_focus.clone()];
406        if let Some(search) = &self.search {
407            watched.push(search.input.read(cx).focus_handle(cx));
408        }
409        self.focus_watch = watched
410            .iter()
411            .map(|handle| {
412                cx.on_focus_out(handle, window, |this, _, window, cx| {
413                    if this.open && !this.owns_focus(window, cx) {
414                        this.close(window, cx);
415                    }
416                })
417            })
418            .collect();
419        if self.search_handler().is_some() {
420            self.run_search(cx);
421        }
422        cx.emit(SelectEvent::OpenChanged(true));
423        cx.notify();
424    }
425
426    /// Closes the menu, clears the query, cancels a running search, and
427    /// returns focus to the trigger if the menu had it.
428    pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
429        if !self.open {
430            return;
431        }
432        self.open = false;
433        self.deferred = None;
434        self.menu_placement = None;
435        self.search_task = None;
436        self.searching = false;
437        self.focus_watch.clear();
438        let had_focus = self.owns_focus(window, cx);
439        // The query clears for the next opening, which refreshes the rows.
440        if let Some(search) = &mut self.search {
441            search.menu.update(cx, |menu, cx| menu.close(window, cx));
442            search.query = SharedString::default();
443            if !search.input.read(cx).value().is_empty() {
444                search
445                    .input
446                    .update(cx, |input, cx| input.set_value("", window, cx));
447            }
448        }
449        if had_focus {
450            self.trigger_focus.focus(window, cx);
451        }
452        cx.emit(SelectEvent::OpenChanged(false));
453        cx.notify();
454    }
455
456    /// Chooses `value` while the menu is open: a single select selects it
457    /// and closes, a multiple select toggles it and stays open. A
458    /// disabled item, or a closed menu, ignores it.
459    pub fn choose(&mut self, value: &V, window: &mut Window, cx: &mut Context<Self>) {
460        // A press that reaches a row of a closed menu chooses nothing.
461        if !self.open {
462            return;
463        }
464        let Some(item) = self.item(value).cloned() else {
465            return;
466        };
467        if item.is_disabled() {
468            return;
469        }
470        let mut selected = if self.multiple {
471            self.selected.clone()
472        } else {
473            Vec::new()
474        };
475        match selected
476            .iter()
477            .position(|selected| selected.value() == value)
478        {
479            Some(position) if self.multiple => {
480                selected.remove(position);
481            }
482            Some(_) => {}
483            None => selected.push(item),
484        }
485        if !same_values(&selected, &self.selected) {
486            self.selected = selected;
487            cx.emit(SelectEvent::Changed(self.selected()));
488            cx.notify();
489        }
490        if !self.multiple {
491            self.close(window, cx);
492        }
493    }
494
495    /// Chooses the highlighted item. While a handler is still answering
496    /// there is no row to choose: the highlight is on rows the answer
497    /// will replace.
498    pub fn choose_highlighted(&mut self, window: &mut Window, cx: &mut Context<Self>) {
499        if self.searching {
500            return;
501        }
502        if let Some(value) = self.highlighted().map(|item| item.value().clone()) {
503            self.choose(&value, window, cx);
504        }
505    }
506
507    /// Moves the highlight one selectable row up or down, wrapping at the
508    /// ends, and scrolls it into view.
509    pub fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
510        let count = self.visible.len();
511        if count == 0 {
512            return;
513        }
514        let start = self
515            .highlighted
516            .map(|row| row as isize)
517            .unwrap_or(if delta > 0 { -1 } else { count as isize });
518        let mut row = start;
519        for _ in 0..count {
520            row = (row + delta).rem_euclid(count as isize);
521            if self.is_selectable(row as usize) {
522                self.highlighted = Some(row as usize);
523                self.reveal_highlighted();
524                cx.notify();
525                return;
526            }
527        }
528    }
529
530    /// Highlights the first selectable row.
531    pub fn highlight_first(&mut self, cx: &mut Context<Self>) {
532        self.highlighted = self.first_selectable();
533        self.reveal_highlighted();
534        cx.notify();
535    }
536
537    /// Highlights the last selectable row.
538    pub fn highlight_last(&mut self, cx: &mut Context<Self>) {
539        self.highlighted = (0..self.visible.len())
540            .rev()
541            .find(|row| self.is_selectable(*row));
542        self.reveal_highlighted();
543        cx.notify();
544    }
545
546    /// Highlights the row at `row` from the pointer, without scrolling.
547    pub(super) fn highlight_row(&mut self, row: usize, cx: &mut Context<Self>) {
548        if self.highlighted != Some(row) && self.is_selectable(row) {
549            self.highlighted = Some(row);
550            cx.notify();
551        }
552    }
553
554    pub(super) fn list(&self) -> &ListState {
555        &self.list
556    }
557
558    pub(super) fn row_count(&self) -> usize {
559        self.visible.len()
560    }
561
562    /// The row at `row` of the rows that show.
563    pub(super) fn entry_at(&self, row: usize) -> Option<&SelectEntry<V>> {
564        self.entries.get(*self.visible.get(row)?)
565    }
566
567    pub(super) fn is_highlighted(&self, row: usize) -> bool {
568        self.highlighted == Some(row)
569    }
570
571    pub(super) fn row_height(&self) -> Option<Pixels> {
572        self.row_height
573    }
574
575    /// The height a row not yet measured is taken to have.
576    pub(super) fn row_hint(&self, cx: &App) -> Pixels {
577        self.row_height.unwrap_or(cx.theme().metrics.row_sm)
578    }
579
580    pub(super) fn menu_placement(&self) -> Option<Placement> {
581        self.menu_placement
582    }
583
584    pub(super) fn trigger_focus(&self) -> &FocusHandle {
585        &self.trigger_focus
586    }
587
588    /// The menu's focus handle: the list's, which holds the rows.
589    pub(super) fn content_focus(&self) -> &FocusHandle {
590        &self.content_focus
591    }
592
593    pub(super) fn search_input(&self) -> Option<&Entity<InputState>> {
594        self.search.as_ref().map(|search| &search.input)
595    }
596
597    /// The search field's right-click menu.
598    pub(super) fn search_menu(&self) -> Option<&Entity<MenuState>> {
599        self.search.as_ref().map(|search| &search.menu)
600    }
601
602    /// Where the trigger records its bounds each frame.
603    pub(super) fn trigger_bounds(&self) -> &Rc<Cell<Bounds<Pixels>>> {
604        &self.trigger_bounds
605    }
606
607    /// The focus handle base moves keyboard focus to while the menu is
608    /// open.
609    pub(super) fn content_focus_handle(&self, cx: &App) -> FocusHandle {
610        match &self.search {
611            Some(search) => search.input.read(cx).focus_handle(cx),
612            None => self.content_focus.clone(),
613        }
614    }
615
616    fn search_handler(&self) -> Option<&SearchHandler<V>> {
617        self.search.as_ref()?.handler.as_ref()
618    }
619
620    /// The query changed while the menu is open: a local search narrows
621    /// the rows now, a handler is asked for new ones.
622    fn query_changed(&mut self, cx: &mut Context<Self>) {
623        if self.search_handler().is_some() {
624            self.run_search(cx);
625        } else {
626            self.rows_changed(cx);
627        }
628    }
629
630    /// Asks the handler for the rows of the current query, and shows them
631    /// when they arrive unless a newer query has replaced the search.
632    fn run_search(&mut self, cx: &mut Context<Self>) {
633        let Some(handler) = self.search_handler().cloned() else {
634            return;
635        };
636        let rows = handler(self.query(cx), cx);
637        self.searching = true;
638        self.search_task = Some(cx.spawn(async move |this, cx| {
639            let rows = rows.await;
640            this.update(cx, |this, cx| {
641                this.searching = false;
642                this.search_task = None;
643                this.entries = rows;
644                this.rows_changed(cx);
645            })
646            .ok();
647        }));
648        cx.notify();
649    }
650
651    /// The rows to show changed, by a query, an answer, or a new set:
652    /// filter them, size the list, and put the keyboard
653    /// on the selected item while the query is empty, else on the first
654    /// row that matches.
655    fn rows_changed(&mut self, cx: &mut Context<Self>) {
656        // A handler's rows are the answer to the query already.
657        let query = if self.search_handler().is_some() {
658            SharedString::default()
659        } else {
660            self.query(cx)
661        };
662        self.visible = item::visible_entries(&self.entries, &query);
663        let hint = self.row_hint(cx);
664        self.list
665            .reset_with_uniform_height(self.visible.len(), hint);
666        let selected = self
667            .query(cx)
668            .is_empty()
669            .then(|| self.row_of(self.selected.first().map(SelectItem::value)))
670            .flatten();
671        self.highlighted = selected.or_else(|| self.first_selectable());
672        self.reveal_highlighted();
673        cx.notify();
674    }
675
676    fn item(&self, value: &V) -> Option<&SelectItem<V>> {
677        self.entries
678            .iter()
679            .filter_map(SelectEntry::item)
680            .find(|item| item.value() == value)
681    }
682
683    /// The items for `values`, in the order given, once each, dropping
684    /// values that are not items; one for a single select.
685    fn items_for(&self, values: impl IntoIterator<Item = V>) -> Vec<SelectItem<V>> {
686        let mut selected: Vec<SelectItem<V>> = Vec::new();
687        for value in values {
688            if selected.iter().any(|item| item.value() == &value) {
689                continue;
690            }
691            if let Some(item) = self.item(&value) {
692                selected.push(item.clone());
693            }
694        }
695        if !self.multiple {
696            selected.truncate(1);
697        }
698        selected
699    }
700
701    fn is_selectable(&self, row: usize) -> bool {
702        self.entry_at(row)
703            .and_then(SelectEntry::item)
704            .is_some_and(|item| !item.is_disabled())
705    }
706
707    fn first_selectable(&self) -> Option<usize> {
708        (0..self.visible.len()).find(|row| self.is_selectable(*row))
709    }
710
711    fn row_of(&self, value: Option<&V>) -> Option<usize> {
712        let value = value?;
713        self.visible.iter().position(|index| {
714            self.entries[*index]
715                .item()
716                .is_some_and(|item| item.value() == value)
717        })
718    }
719
720    fn reveal_highlighted(&self) {
721        if let Some(row) = self.highlighted {
722            self.list.scroll_to_reveal_item(row);
723        }
724    }
725
726    /// Whether the list, the search field, or the search field's menu is
727    /// focused.
728    fn owns_focus(&self, window: &Window, cx: &App) -> bool {
729        let Some(focused) = window.focused(cx) else {
730            return false;
731        };
732        focused == self.content_focus
733            || self.search.as_ref().is_some_and(|search| {
734                search.input.read(cx).focus_handle(cx) == focused || search.menu.read(cx).is_open()
735            })
736    }
737
738    fn focus_content(&self, window: &mut Window, cx: &mut Context<Self>) {
739        match &self.search {
740            Some(search) => search.input.update(cx, |input, cx| input.focus(window, cx)),
741            None => self.content_focus.focus(window, cx),
742        }
743    }
744}
745
746fn same_values<V: SelectValue>(a: &[SelectItem<V>], b: &[SelectItem<V>]) -> bool {
747    a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.value() == b.value())
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use gpui_kit::TestAppContext;
754
755    #[derive(Clone, Copy, Debug, PartialEq)]
756    enum Kind {
757        All,
758        Local,
759        Cloud,
760        Max,
761    }
762
763    impl SelectValue for Kind {
764        fn key(&self) -> SharedString {
765            format!("{self:?}").to_lowercase().into()
766        }
767    }
768
769    fn entries() -> Vec<SelectEntry<Kind>> {
770        vec![
771            SelectEntry::label("Type"),
772            SelectItem::new(Kind::All, "All chats").into(),
773            SelectItem::new(Kind::Local, "Local").disabled(true).into(),
774            SelectItem::new(Kind::Cloud, "Cloud").into(),
775            SelectEntry::Separator,
776            SelectItem::new(Kind::Max, "Max").into(),
777        ]
778    }
779
780    fn init(cx: &mut TestAppContext) {
781        cx.update(|cx| {
782            gpui_kit::init(cx);
783            crate::init(cx);
784        });
785    }
786
787    #[gpui_kit::test]
788    fn a_single_select_keeps_one_value_and_a_multiple_select_toggles(cx: &mut TestAppContext) {
789        init(cx);
790        let single =
791            cx.new(|cx| SelectState::new(entries(), cx).with_selected([Kind::Cloud, Kind::Max]));
792        single.read_with(cx, |state, _| {
793            assert_eq!(state.selected(), [Kind::Cloud]);
794            assert_eq!(state.selected_value(), Some(&Kind::Cloud));
795            assert_eq!(state.selected_items()[0].key(), "cloud");
796            assert!(!state.is_multiple());
797        });
798        let multiple = cx.new(|cx| {
799            SelectState::multiple(entries(), cx).with_selected([Kind::Max, Kind::All, Kind::All])
800        });
801        multiple.read_with(cx, |state, _| {
802            assert_eq!(state.selected(), [Kind::Max, Kind::All], "as chosen, once");
803            assert!(state.is_selected(&Kind::Max));
804            assert!(!state.is_selected(&Kind::Cloud));
805            assert!(state.is_multiple());
806        });
807        let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
808        cx.update(|cx| {
809            let events = events.clone();
810            cx.subscribe(&multiple, move |_, event: &SelectEvent<Kind>, _| {
811                events.borrow_mut().push(event.clone());
812            })
813            .detach();
814        });
815        multiple.update(cx, |state, cx| {
816            state.set_selected([Kind::Cloud], cx);
817            state.set_selected([Kind::Cloud], cx);
818        });
819        assert_eq!(
820            &*events.borrow(),
821            &[SelectEvent::Changed(vec![Kind::Cloud])],
822            "the same selection again reports nothing"
823        );
824    }
825
826    #[gpui_kit::test]
827    fn the_highlight_skips_disabled_rows_and_labels_and_wraps(cx: &mut TestAppContext) {
828        init(cx);
829        let state = cx.new(|cx| SelectState::new(entries(), cx));
830        state.update(cx, |state, cx| {
831            let highlighted = |state: &SelectState<Kind>| state.highlighted().map(|i| *i.value());
832            state.highlight_first(cx);
833            assert_eq!(highlighted(state), Some(Kind::All));
834            state.move_highlight(1, cx);
835            assert_eq!(highlighted(state), Some(Kind::Cloud), "Local is disabled");
836            state.move_highlight(1, cx);
837            assert_eq!(highlighted(state), Some(Kind::Max));
838            state.move_highlight(1, cx);
839            assert_eq!(highlighted(state), Some(Kind::All), "wraps");
840            state.highlight_last(cx);
841            assert_eq!(highlighted(state), Some(Kind::Max));
842            state.move_highlight(-1, cx);
843            assert_eq!(highlighted(state), Some(Kind::Cloud));
844        });
845    }
846
847    #[gpui_kit::test]
848    fn replacing_the_rows_drops_a_selection_that_is_gone(cx: &mut TestAppContext) {
849        init(cx);
850        let state = cx.new(|cx| SelectState::new(entries(), cx).with_selected([Kind::Max]));
851        state.update(cx, |state, cx| {
852            state.set_entries([SelectItem::new(Kind::All, "All chats")], cx);
853            assert!(state.selected().is_empty());
854            assert_eq!(state.entries().len(), 1);
855        });
856    }
857
858    #[test]
859    fn the_search_field_paints_in_the_menu_colors() {
860        use crate::theme::{ThemeConfig, to_hex};
861        use gpui_kit::base::ThemeAppearance;
862        let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
863        let style = search_style(&theme);
864        assert_eq!(to_hex(style.foreground), "#ffffff");
865        assert_eq!(style.caret, style.foreground);
866        assert_eq!(style.selection, theme.selection());
867        assert!(style.selection.a > 0.);
868    }
869}