1use 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#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum SelectEvent<V> {
21 Changed(Vec<V>),
24 OpenChanged(bool),
26}
27
28pub type SearchHandler<V> =
32 Rc<dyn Fn(SharedString, &mut Context<SelectState<V>>) -> Task<Vec<SelectEntry<V>>>>;
33
34struct SearchField<V: SelectValue> {
37 input: Entity<InputState>,
38 handler: Option<SearchHandler<V>>,
39 query: SharedString,
43 menu: Entity<MenuState>,
46}
47
48pub struct SelectState<V: SelectValue> {
58 entries: Vec<SelectEntry<V>>,
59 selected: Vec<SelectItem<V>>,
60 multiple: bool,
61 open: bool,
62 highlighted: Option<usize>,
64 visible: Vec<usize>,
66 search: Option<SearchField<V>>,
67 searching: bool,
68 search_task: Option<Task<()>>,
71 list: ListState,
72 row_height: Option<Pixels>,
76 menu_placement: Option<Placement>,
80 trigger_focus: FocusHandle,
81 content_focus: FocusHandle,
82 trigger_bounds: Rc<Cell<Bounds<Pixels>>>,
84 deferred: Option<DeferredPopover>,
87 focus_watch: Vec<Subscription>,
88 _subscriptions: Vec<Subscription>,
89}
90
91impl<V: SelectValue> EventEmitter<SelectEvent<V>> for SelectState<V> {}
92
93impl<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 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 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 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 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 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 pub fn with_selected(mut self, values: impl IntoIterator<Item = V>) -> Self {
249 self.selected = self.items_for(values);
250 self
251 }
252
253 pub fn with_row_height(mut self, height: Pixels) -> Self {
260 self.row_height = Some(height);
261 self
262 }
263
264 pub fn entries(&self) -> &[SelectEntry<V>] {
266 &self.entries
267 }
268
269 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 pub fn selected(&self) -> Vec<V> {
290 self.selected
291 .iter()
292 .map(|item| item.value().clone())
293 .collect()
294 }
295
296 pub fn selected_value(&self) -> Option<&V> {
298 self.selected.first().map(SelectItem::value)
299 }
300
301 pub fn selected_items(&self) -> &[SelectItem<V>] {
303 &self.selected
304 }
305
306 pub fn is_selected(&self, value: &V) -> bool {
308 self.selected.iter().any(|item| item.value() == value)
309 }
310
311 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 pub fn is_multiple(&self) -> bool {
325 self.multiple
326 }
327
328 pub fn is_searchable(&self) -> bool {
330 self.search.is_some()
331 }
332
333 pub fn is_searching(&self) -> bool {
335 self.searching
336 }
337
338 pub fn is_open(&self) -> bool {
340 self.open
341 }
342
343 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 pub fn highlighted(&self) -> Option<&SelectItem<V>> {
353 self.entry_at(self.highlighted?)?.item()
354 }
355
356 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 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 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 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 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 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 pub fn choose(&mut self, value: &V, window: &mut Window, cx: &mut Context<Self>) {
460 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 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 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 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 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 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 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 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 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 pub(super) fn search_menu(&self) -> Option<&Entity<MenuState>> {
599 self.search.as_ref().map(|search| &search.menu)
600 }
601
602 pub(super) fn trigger_bounds(&self) -> &Rc<Cell<Bounds<Pixels>>> {
604 &self.trigger_bounds
605 }
606
607 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 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 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 fn rows_changed(&mut self, cx: &mut Context<Self>) {
656 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 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 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}