Skip to main content

gpui_kit/controls/
combobox.rs

1//! A select you can type into.
2//!
3//! The list, the choice, and whether a typed value is acceptable all belong
4//! to the caller. The combobox owns the query, whether the list is open, and
5//! where the keyboard is — and reports what was picked without moving its own
6//! answer, exactly as `Select` does.
7
8use std::cell::Cell;
9use std::rc::Rc;
10
11use gpui::{
12    AnyElement, App, AppContext as _, Bounds, Context, Entity, EventEmitter, FocusHandle,
13    Focusable, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels,
14    Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
15    div, prelude::FluentBuilder, px,
16};
17use gpui_kit_assets::{Icon, icon};
18use gpui_kit_semantics::{NodeSpec, Role, Semantic};
19use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
20
21use crate::controls::field::{FieldState, field_shell};
22use crate::controls::input::{LineEnd, LineStart, TextInput, TextInputEvent};
23use crate::controls::select::SelectOption;
24use crate::display::empty::{EmptyKind, EmptyState};
25use crate::foundation::{
26    Disableable, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
27};
28use crate::layout::measure;
29use crate::motion;
30use crate::overlay::Placement;
31use crate::overlay::popover::{self, MenuKey};
32use crate::strings::{ActiveStrings, StringKey};
33
34/// How wide the list gets before it stops growing, and how tall before it
35/// scrolls. Both occur once, so they stay next to the component.
36const MENU_MIN_WIDTH: f32 = 200.0;
37const MENU_MAX_HEIGHT: f32 = 320.0;
38
39/// What a combobox reports. The owner decides what any of it means.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ComboboxEvent {
42    QueryChanged(SharedString),
43    /// One of the offered options was taken.
44    Selected(SharedString),
45    /// A value nothing offered answers, reported only when the caller allows
46    /// custom values.
47    Custom(SharedString),
48    Opened,
49    Closed,
50}
51
52impl EventEmitter<ComboboxEvent> for Combobox {}
53
54/// A [`Select`](crate::controls::select::Select) you can type into.
55///
56/// It owns the query and whether the menu is open. Which option holds is the
57/// caller's answer, so escape puts the query back to it and reports nothing.
58pub struct Combobox {
59    ident: Ident,
60    query: Entity<TextInput>,
61    options: Vec<SelectOption>,
62    selected: Option<SharedString>,
63    name: SharedString,
64    placeholder: Option<SharedString>,
65    size: ControlSize,
66    disabled: bool,
67    invalid: bool,
68    open: bool,
69    /// The highlighted option by identity, so filtering does not move the
70    /// highlight onto whatever happens to sit at the same position.
71    active: Option<SharedString>,
72    allow_custom: bool,
73    /// Whether the current answer has been put in the field. The text is the
74    /// typist's afterwards, so it is written once.
75    seeded: bool,
76    scroll: ScrollHandle,
77    trigger_bounds: Rc<Cell<Bounds<Pixels>>>,
78    reveal_active: bool,
79    menu_geometry: Option<popover::MenuGeometry>,
80    /// Held so the query subscription lives as long as the combobox does.
81    _subscriptions: Vec<Subscription>,
82}
83
84impl std::fmt::Debug for Combobox {
85    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        formatter
87            .debug_struct("Combobox")
88            .field("ident", &self.ident)
89            .field("options", &self.options.len())
90            .field("selected", &self.selected)
91            .field("open", &self.open)
92            .field("allow_custom", &self.allow_custom)
93            .finish()
94    }
95}
96
97impl Combobox {
98    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
99        let ident = ident.into();
100        let query = cx.new(|cx| TextInput::new(ident.child("query"), window, cx).bare(true));
101        let subscription = cx.subscribe(&query, |combobox, _query, event, cx| match event {
102            TextInputEvent::Change(text) => combobox.on_query(text.clone(), cx),
103            TextInputEvent::Submit => combobox.commit(cx),
104            TextInputEvent::Cancel => combobox.revert(cx),
105            _ => {}
106        });
107
108        Self {
109            ident,
110            query,
111            options: Vec::new(),
112            selected: None,
113            name: SharedString::default(),
114            placeholder: None,
115            size: ControlSize::Md,
116            disabled: false,
117            invalid: false,
118            open: false,
119            active: None,
120            allow_custom: false,
121            seeded: false,
122            scroll: ScrollHandle::new(),
123            trigger_bounds: Rc::default(),
124            reveal_active: false,
125            menu_geometry: None,
126            _subscriptions: vec![subscription],
127        }
128    }
129
130    pub fn options(mut self, options: impl IntoIterator<Item = SelectOption>) -> Self {
131        self.options = options.into_iter().collect();
132        self
133    }
134
135    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
136        self.selected = Some(id.into());
137        self
138    }
139
140    /// Names both the combobox and its editable query target.
141    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
142        self.name = name.into();
143        self
144    }
145
146    pub fn set_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
147        self.name = name.into();
148        self.query
149            .update(cx, |query, cx| query.set_name(self.name.clone(), cx));
150        cx.notify();
151    }
152
153    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
154        self.placeholder = Some(placeholder.into());
155        self
156    }
157
158    pub fn invalid(mut self, invalid: bool) -> Self {
159        self.invalid = invalid;
160        self
161    }
162
163    /// Whether a query nothing answers may be reported as a new value.
164    ///
165    /// With it off the combobox reports nothing at all for such a query, and
166    /// the list says that this is a closed set rather than drawing an empty
167    /// list that looks like a list with nothing in it.
168    pub fn allow_custom(mut self, allow_custom: bool) -> Self {
169        self.allow_custom = allow_custom;
170        self
171    }
172
173    pub fn set_options(&mut self, options: Vec<SelectOption>, cx: &mut Context<Self>) {
174        let still_offered = self
175            .selected
176            .as_ref()
177            .is_some_and(|id| options.iter().any(|option| &option.id == id));
178        if !still_offered {
179            self.selected = None;
180        }
181        self.options = options;
182        self.active = None;
183        self.reveal_active = true;
184        cx.notify();
185    }
186
187    /// Replaces the choice from the host side, and puts its label back in the
188    /// field so the query and the answer agree again.
189    pub fn set_selected(&mut self, id: Option<SharedString>, cx: &mut Context<Self>) {
190        self.selected = id;
191        self.seeded = true;
192        if self.open {
193            self.active = self.selected.as_ref().and_then(|id| {
194                self.options
195                    .iter()
196                    .find(|option| &option.id == id && !option.disabled)
197                    .map(|option| option.id.clone())
198            });
199        }
200        self.reveal_active = true;
201        let label = self.selected_label().unwrap_or_default();
202        self.query
203            .update(cx, |query, cx| query.set_text_quietly(label, cx));
204        cx.notify();
205    }
206
207    pub fn set_query(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
208        // Once something is being asked, the field is no longer waiting for
209        // the answer's label to be written into it.
210        self.seeded = true;
211        self.query.update(cx, |query, cx| query.set_value(text, cx));
212    }
213
214    pub fn selected_id(&self) -> Option<&SharedString> {
215        self.selected.as_ref()
216    }
217
218    pub fn selected_option(&self) -> Option<&SelectOption> {
219        let id = self.selected.as_ref()?;
220        self.options.iter().find(|option| &option.id == id)
221    }
222
223    fn selected_label(&self) -> Option<SharedString> {
224        self.selected_option().map(|option| option.label.clone())
225    }
226
227    pub fn is_open(&self) -> bool {
228        self.open
229    }
230
231    pub fn query_text(&self, cx: &App) -> SharedString {
232        self.query.read(cx).value().clone()
233    }
234
235    pub fn query_input(&self) -> &Entity<TextInput> {
236        &self.query
237    }
238
239    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
240        self.disabled = disabled;
241        self.query
242            .update(cx, |query, cx| query.set_disabled(disabled, cx));
243        if disabled {
244            self.open = false;
245        }
246        cx.notify();
247    }
248
249    /// The query the list is filtered by.
250    ///
251    /// A query that is exactly the current answer is not a filter: it is what
252    /// the field says while nothing has been typed, and it must not hide the
253    /// rest of the list the moment the control opens.
254    fn filter(&self, cx: &App) -> SharedString {
255        let text = self.query.read(cx).value().clone();
256        match self.selected_label() {
257            Some(label) if label == text => SharedString::default(),
258            _ => text,
259        }
260    }
261
262    /// The options answering the query, best answer first.
263    fn matches(&self, cx: &App) -> Vec<usize> {
264        let labels: Vec<&str> = self
265            .options
266            .iter()
267            .map(|option| option.label.as_ref())
268            .collect();
269        popover::filter_indices(self.filter(cx).as_ref(), &labels)
270    }
271
272    /// The option the highlight sits on: the one the typist put it on while
273    /// it still answers the query, or the best answer that can be taken.
274    fn resolved(&self, matches: &[usize]) -> Option<usize> {
275        if let Some(active) = &self.active
276            && let Some(index) = matches
277                .iter()
278                .copied()
279                .find(|index| &self.options[*index].id == active)
280            && !self.options[index].disabled
281        {
282            return Some(index);
283        }
284        matches
285            .iter()
286            .copied()
287            .find(|index| !self.options[*index].disabled)
288    }
289
290    pub fn open(&mut self, cx: &mut Context<Self>) {
291        if self.disabled || self.open {
292            return;
293        }
294        if self.filter(cx).is_empty() {
295            self.active = self.selected.as_ref().and_then(|id| {
296                self.options
297                    .iter()
298                    .find(|option| &option.id == id && !option.disabled)
299                    .map(|option| option.id.clone())
300            });
301        }
302        self.open = true;
303        self.reveal_active = true;
304        cx.emit(ComboboxEvent::Opened);
305        cx.notify();
306    }
307
308    fn close(&mut self, cx: &mut Context<Self>) {
309        if !self.open {
310            return;
311        }
312        self.open = false;
313        self.active = None;
314        cx.emit(ComboboxEvent::Closed);
315        cx.notify();
316    }
317
318    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
319        if self.open {
320            self.close(cx);
321        } else {
322            self.open(cx);
323            self.query.read(cx).focus_handle(cx).focus(window, cx);
324        }
325    }
326
327    fn on_query(&mut self, text: SharedString, cx: &mut Context<Self>) {
328        // A new query is a new list, so the highlight goes back to the best
329        // answer rather than staying on a row that may be gone.
330        self.active = None;
331        self.reveal_active = true;
332        self.open(cx);
333        cx.emit(ComboboxEvent::QueryChanged(text));
334        cx.notify();
335    }
336
337    /// Puts the query back to the answer that still holds, and closes without
338    /// reporting anything: abandoning an edit is not a choice.
339    fn revert(&mut self, cx: &mut Context<Self>) {
340        let label = self.selected_label().unwrap_or_default();
341        self.query
342            .update(cx, |query, cx| query.set_text_quietly(label, cx));
343        self.close(cx);
344    }
345
346    fn commit(&mut self, cx: &mut Context<Self>) {
347        let matches = self.matches(cx);
348        if let Some(index) = self.resolved(&matches) {
349            self.take(index, cx);
350            return;
351        }
352        let typed = self.query.read(cx).value().clone();
353        if self.allow_custom && !typed.trim().is_empty() {
354            cx.emit(ComboboxEvent::Custom(typed));
355            self.close(cx);
356        }
357    }
358
359    /// Reports the option and closes. The field goes back to the answer the
360    /// host still holds, because a report is not an application.
361    fn take(&mut self, index: usize, cx: &mut Context<Self>) {
362        let Some(option) = self.options.get(index) else {
363            return;
364        };
365        if option.disabled {
366            return;
367        }
368        let id = option.id.clone();
369        let label = self.selected_label().unwrap_or_default();
370        self.query
371            .update(cx, |query, cx| query.set_text_quietly(label, cx));
372        cx.emit(ComboboxEvent::Selected(id));
373        self.close(cx);
374    }
375
376    fn step(&mut self, delta: isize, cx: &mut Context<Self>) {
377        let matches = self.matches(cx);
378        let choosable: Vec<usize> = matches
379            .iter()
380            .copied()
381            .filter(|index| !self.options[*index].disabled)
382            .collect();
383        if choosable.is_empty() {
384            return;
385        }
386        let current = self
387            .resolved(&matches)
388            .and_then(|index| choosable.iter().position(|choice| *choice == index));
389        let Some(next) = popover::step(current, choosable.len(), delta) else {
390            return;
391        };
392        self.active = Some(self.options[choosable[next]].id.clone());
393        self.reveal_active = true;
394        cx.notify();
395    }
396
397    fn edge(&mut self, from_end: bool, cx: &mut Context<Self>) {
398        let matches = self.matches(cx);
399        let index = if from_end {
400            matches
401                .iter()
402                .rev()
403                .copied()
404                .find(|index| !self.options[*index].disabled)
405        } else {
406            matches
407                .iter()
408                .copied()
409                .find(|index| !self.options[*index].disabled)
410        };
411        let next = index.map(|index| self.options[index].id.clone());
412        if next == self.active {
413            return;
414        }
415        self.active = next;
416        self.reveal_active = true;
417        cx.notify();
418    }
419
420    fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
421        if self.disabled {
422            return;
423        }
424        let raw = event.keystroke.key.as_str();
425        let key = popover::classify_key(
426            raw,
427            event.keystroke.modifiers.platform,
428            event.keystroke.modifiers.control,
429        );
430        match key {
431            MenuKey::Down => {
432                self.open(cx);
433                self.step(1, cx);
434                cx.stop_propagation();
435            }
436            MenuKey::Up => {
437                self.open(cx);
438                self.step(-1, cx);
439                cx.stop_propagation();
440            }
441            _ if self.open && raw == "home" => {
442                self.edge(false, cx);
443                cx.stop_propagation();
444            }
445            _ if self.open && raw == "end" => {
446                self.edge(true, cx);
447                cx.stop_propagation();
448            }
449            _ => {}
450        }
451    }
452
453    fn on_line_start(&mut self, _: &LineStart, _: &mut Window, cx: &mut Context<Self>) {
454        if self.open && !self.disabled {
455            self.edge(false, cx);
456            cx.stop_propagation();
457        }
458    }
459
460    fn on_line_end(&mut self, _: &LineEnd, _: &mut Window, cx: &mut Context<Self>) {
461        if self.open && !self.disabled {
462            self.edge(true, cx);
463            cx.stop_propagation();
464        }
465    }
466
467    /// The placeholder the host gave, or the shared default word for a field
468    /// that has not been answered yet.
469    fn resolved_placeholder(&self, cx: &App) -> SharedString {
470        self.placeholder
471            .clone()
472            .unwrap_or_else(|| cx.strings().text(StringKey::SelectPlaceholder))
473    }
474
475    fn menu(&mut self, geometry: popover::MenuGeometry, cx: &mut Context<Self>) -> AnyElement {
476        let theme = cx.theme().clone();
477        let matches = self.matches(cx);
478        let highlighted = self.resolved(&matches);
479        let highlighted_position = highlighted
480            .and_then(|highlighted| matches.iter().position(|index| *index == highlighted));
481        let menu_ident = self.ident.child("menu");
482
483        let rows = if matches.is_empty() {
484            let query = self.filter(cx);
485            vec![
486                EmptyState::new(
487                    self.ident.child("empty"),
488                    cx.strings()
489                        .format(StringKey::ComboboxNoMatch, &[query.as_ref()]),
490                )
491                .kind(EmptyKind::Empty)
492                .detail(cx.strings().text(if self.allow_custom {
493                    StringKey::ComboboxCreateHint
494                } else {
495                    StringKey::ComboboxClosedHint
496                }))
497                .into_any_element(),
498            ]
499        } else {
500            matches
501                .iter()
502                .enumerate()
503                .map(|(position, index)| self.row(*index, highlighted, position, matches.len(), cx))
504                .collect()
505        };
506
507        if self.menu_geometry != Some(geometry) {
508            self.menu_geometry = Some(geometry);
509            self.reveal_active = true;
510        }
511        if self.reveal_active {
512            if let Some(position) = highlighted_position {
513                self.scroll.scroll_to_item(position);
514            }
515            self.reveal_active = false;
516        }
517
518        let viewport = div()
519            .p(px(theme.space(Space::Xs)))
520            .column()
521            .max_h(px(geometry.max_height))
522            .id(self.ident.child("menu.scroll").element_id())
523            .overflow_y_scroll()
524            .track_scroll(&self.scroll)
525            .children(rows);
526        let list = popover::card_flush(&theme)
527            .w(px(geometry.width))
528            .max_h(px(geometry.max_height))
529            .id(menu_ident.element_id())
530            .child(viewport)
531            .semantic_in(
532                cx,
533                NodeSpec::new(menu_ident.semantic_id(), Role::Menu)
534                    .parent(self.ident.semantic_id()),
535            )
536            .into_any_element();
537
538        popover::menu_overlay(
539            &self.ident.child("menu.anchor"),
540            &theme,
541            geometry.placement,
542            list,
543        )
544    }
545
546    fn row(
547        &self,
548        index: usize,
549        highlighted: Option<usize>,
550        position: usize,
551        count: usize,
552        cx: &mut Context<Self>,
553    ) -> AnyElement {
554        let theme = cx.theme().clone();
555        let option = &self.options[index];
556        let selected = self.selected.as_ref() == Some(&option.id);
557        let active = highlighted == Some(index);
558        let ident = self.ident.child(option.id.as_ref());
559        let hover_group = ident.child("hover").semantic_id();
560
561        let row = popover::menu_row(&theme, selected, active)
562            .id(ident.element_id())
563            .group(hover_group.clone())
564            .when(!option.disabled, |element| {
565                element.cursor_pointer().pressable(cx)
566            })
567            .when(option.disabled, |element| {
568                element.opacity(theme.opacity.disabled)
569            })
570            .child(
571                div()
572                    .column()
573                    .flex_1()
574                    .min_w_0()
575                    .gap(px(2.0))
576                    .child(popover::menu_label(
577                        &theme,
578                        option.label.clone(),
579                        selected,
580                        active,
581                        hover_group,
582                    ))
583                    .when_some(option.description.clone(), |element, description| {
584                        element.child(
585                            foundation_text(&theme, TypeScale::Caption, description)
586                                .text_tone(&theme, gpui_kit_theme::TextTone::Muted),
587                        )
588                    }),
589            )
590            .when(selected, |element| {
591                element.child(
592                    div().ml_auto().child(
593                        icon(Icon::Check)
594                            .size(px(14.0))
595                            .text_color(theme.colors.text),
596                    ),
597                )
598            })
599            .when(!option.disabled, |element| {
600                element.on_mouse_down(
601                    MouseButton::Left,
602                    cx.listener(move |combobox, _, _, cx| combobox.take(index, cx)),
603                )
604            })
605            .semantic_in(
606                cx,
607                NodeSpec::new(ident.semantic_id(), Role::Option)
608                    .parent(self.ident.child("menu").semantic_id())
609                    .checked(selected)
610                    .disabled(option.disabled)
611                    .hovered(active)
612                    .text(option.label.clone()),
613            );
614
615        motion::row_in(ident.child("in").element_id(), &theme, position, count, row)
616            .into_any_element()
617    }
618}
619
620impl Disableable for Combobox {
621    fn disabled(mut self, disabled: bool) -> Self {
622        self.disabled = disabled;
623        self
624    }
625}
626
627impl Sizable for Combobox {
628    fn control_size(mut self, size: ControlSize) -> Self {
629        self.size = size;
630        self
631    }
632}
633
634impl Focusable for Combobox {
635    fn focus_handle(&self, cx: &App) -> FocusHandle {
636        self.query.read(cx).focus_handle(cx)
637    }
638}
639
640impl Render for Combobox {
641    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
642        let theme = cx.theme().clone();
643        if !self.seeded {
644            self.seeded = true;
645            if let Some(label) = self.selected_label() {
646                self.query
647                    .update(cx, |query, cx| query.set_text_quietly(label, cx));
648            }
649        }
650        if self.query.read(cx).placeholder_text().is_empty() {
651            let placeholder = self.resolved_placeholder(cx);
652            self.query
653                .update(cx, |query, cx| query.set_placeholder(placeholder, cx));
654        }
655        if self.query.read(cx).accessible_name() != &self.name {
656            let name = self.name.clone();
657            self.query.update(cx, |query, cx| query.set_name(name, cx));
658        }
659        if self.disabled != self.query.read(cx).is_disabled() {
660            let disabled = self.disabled;
661            self.query
662                .update(cx, |query, cx| query.set_disabled(disabled, cx));
663        }
664
665        let focused = self.query.read(cx).focus_handle(cx).is_focused(window);
666        let geometry = self.open.then(|| {
667            popover::menu_geometry(
668                window,
669                self.trigger_bounds.get(),
670                &theme,
671                MENU_MAX_HEIGHT,
672                MENU_MIN_WIDTH,
673            )
674        });
675        let placement = geometry.map_or(Placement::Below, |geometry| geometry.placement);
676        let menu = geometry.map(|geometry| self.menu(geometry, cx));
677        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Combobox)
678            .disabled(self.disabled)
679            .invalid(self.invalid)
680            .expanded(self.open)
681            .text(self.name.clone())
682            .placeholder(self.resolved_placeholder(cx));
683        if !self.disabled {
684            spec = spec.focus(&self.query.read(cx).focus_handle(cx));
685        }
686        if let Some(label) = self.selected_label() {
687            spec = spec.value(label);
688        }
689
690        let shell = field_shell(
691            &theme,
692            self.size,
693            FieldState::default()
694                .focused(focused)
695                .invalid(self.invalid)
696                .disabled(self.disabled),
697        )
698        .id(self.ident.child("shell").element_id())
699        .when(!self.disabled, |element| {
700            element.on_mouse_down(
701                MouseButton::Left,
702                cx.listener(|combobox, _, window, cx| {
703                    if !combobox.open {
704                        combobox.toggle(window, cx);
705                    }
706                }),
707            )
708        })
709        .child(div().flex_1().child(self.query.clone()))
710        .child(
711            icon(Icon::AltArrowDown)
712                .size(px(theme.control.get(self.size).icon_size * 0.9))
713                .text_color(theme.colors.text_muted),
714        );
715        let measured = Rc::clone(&self.trigger_bounds);
716        let trigger = div()
717            .w_full()
718            .on_children_prepainted(move |bounds, window, _| {
719                if let Some(trigger) = bounds.first() {
720                    measure::record(&measured, *trigger, window);
721                }
722            })
723            .child(shell)
724            .into_any_element();
725
726        div()
727            .id(self.ident.element_id())
728            .column()
729            .w_full()
730            // Home and End belong to the query while closed, but to the open
731            // list while it is visible. Capture lets the combobox make that
732            // distinction before TextInput consumes its caret action.
733            .capture_action(cx.listener(Self::on_line_start))
734            .capture_action(cx.listener(Self::on_line_end))
735            .capture_key_down(cx.listener(Self::on_key_down))
736            .child(popover::anchored_slot(placement, trigger, menu))
737            .semantic_in(cx, spec)
738    }
739}