Skip to main content

ui/components/
combobox.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui::{
4    AnyElement, Bounds, Context, Entity, Pixels, Render, anchored, canvas, deferred, point,
5};
6
7use crate::TextInput;
8use crate::prelude::*;
9use crate::utils::fuzzy_subsequence_score;
10
11/// A typeahead-filtered select: a `TextInput` (typed filter) plus a
12/// `DropdownMenu`-style popover list of options filtered by
13/// **case-insensitive substring match** by default, or optional subsequence
14/// fuzzy filter via [`Combobox::fuzzy_filter`]. Selecting an option sets the input's
15/// display text via `TextInput::set_text`.
16///
17/// Stateful view — create with `cx.new(|cx| Combobox::new(cx, options))`.
18pub struct Combobox {
19    options: Vec<SharedString>,
20    selected: Option<usize>,
21    open: bool,
22    fuzzy_filter: bool,
23    input: Entity<TextInput>,
24    /// Real screen bounds of the trigger row, captured via an invisible
25    /// `canvas()` measurement child every render and read back on the
26    /// *next* render to position the floating option list. See
27    /// `Select::trigger_bounds` for the full rationale.
28    trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
29}
30
31impl Combobox {
32    pub fn new(
33        cx: &mut Context<Self>,
34        options: impl IntoIterator<Item = impl Into<SharedString>>,
35    ) -> Self {
36        let input = cx.new(|cx| TextInput::new(cx).placeholder("Search…"));
37        cx.observe(&input, |_, _, cx| cx.notify()).detach();
38        Self {
39            options: options.into_iter().map(Into::into).collect(),
40            selected: None,
41            open: false,
42            fuzzy_filter: false,
43            input,
44            trigger_bounds: Rc::new(Cell::new(None)),
45        }
46    }
47
48    /// The currently selected option, if any.
49    pub fn value(&self) -> Option<&SharedString> {
50        self.selected.and_then(|i| self.options.get(i))
51    }
52
53    /// Enables subsequence fuzzy filtering (hand-rolled, no external crate).
54    pub fn fuzzy_filter(mut self, fuzzy_filter: bool) -> Self {
55        self.fuzzy_filter = fuzzy_filter;
56        self
57    }
58
59    /// Options matching the current filter text.
60    fn filtered(&self, cx: &App) -> Vec<(usize, SharedString)> {
61        let query = self.input.read(cx).text();
62        if query.is_empty() {
63            return self
64                .options
65                .iter()
66                .enumerate()
67                .map(|(i, option)| (i, option.clone()))
68                .collect();
69        }
70
71        if self.fuzzy_filter {
72            let mut matches: Vec<(usize, usize, SharedString)> = self
73                .options
74                .iter()
75                .enumerate()
76                .filter_map(|(i, option)| {
77                    fuzzy_subsequence_score(query, option).map(|score| (i, score, option.clone()))
78                })
79                .collect();
80            matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
81            matches
82                .into_iter()
83                .map(|(i, _, option)| (i, option))
84                .collect()
85        } else {
86            let query_lower = query.to_lowercase();
87            self.options
88                .iter()
89                .enumerate()
90                .filter(|(_, option)| {
91                    query_lower.is_empty() || option.to_lowercase().contains(&query_lower)
92                })
93                .map(|(i, option)| (i, option.clone()))
94                .collect()
95        }
96    }
97}
98
99impl Render for Combobox {
100    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
101        let open = self.open;
102        let filtered = self.filtered(cx);
103
104        let trigger = h_flex()
105            .id("combobox-trigger")
106            // Test-only (no-op in release builds, per `debug_selector`'s own
107            // doc comment): lets integration tests locate the trigger's real
108            // rendered pixel bounds via `VisualTestContext::debug_bounds`.
109            .debug_selector(|| "COMBOBOX-TRIGGER".into())
110            .w_full()
111            .items_center()
112            .justify_between()
113            .px_3()
114            .py_2()
115            .rounded_md()
116            .bg(semantic::surface(cx))
117            .border_1()
118            .border_color(if open {
119                palette::primary(500)
120            } else {
121                semantic::border(cx)
122            })
123            .child(div().flex_1().min_w_0().child(self.input.clone()))
124            .child(
125                div()
126                    .id("combobox-toggle")
127                    // Test-only (no-op in release builds): the actual click
128                    // target for opening/closing the list — narrower than
129                    // the whole trigger row (which is mostly the embedded
130                    // `TextInput`).
131                    .debug_selector(|| "COMBOBOX-TOGGLE".into())
132                    .cursor_pointer()
133                    .child(Icon::new(IconName::ChevronDown).size(IconSize::Small))
134                    .on_click(cx.listener(|this, _, _, cx| {
135                        this.open = !this.open;
136                        cx.notify();
137                    })),
138            )
139            .child({
140                let trigger_bounds = self.trigger_bounds.clone();
141                canvas(
142                    move |bounds, _window, _cx| trigger_bounds.set(Some(bounds)),
143                    |_bounds, _state, _window, _cx| {},
144                )
145                .absolute()
146                .top_0()
147                .left_0()
148                .size_full()
149            });
150
151        let trigger_width = px(240.);
152
153        v_flex()
154            .w(trigger_width)
155            .gap_1()
156            .child(trigger)
157            .when(open, |this| {
158                let hover = semantic::hover_bg(cx);
159                let mut list = v_flex()
160                    .w(trigger_width)
161                    .p_1()
162                    .rounded_md()
163                    .bg(semantic::elevated_surface(cx))
164                    .border_1()
165                    .border_color(semantic::border(cx))
166                    .shadow_level(Shadow::Lg);
167
168                if filtered.is_empty() {
169                    list = list.child(
170                        div()
171                            .px_3()
172                            .py_2()
173                            .child(Label::new("No matches").color(Color::Muted)),
174                    );
175                }
176
177                for (i, option) in filtered {
178                    let label = option.clone();
179                    list = list.child(
180                        h_flex()
181                            .id(("combobox-option", i))
182                            .w_full()
183                            .px_3()
184                            .py_2()
185                            .rounded_md()
186                            .cursor_pointer()
187                            .hover(move |s| s.bg(hover))
188                            .on_click(cx.listener(move |this, _, _, cx| {
189                                this.selected = Some(i);
190                                this.open = false;
191                                let label = label.clone();
192                                this.input
193                                    .update(cx, |input, cx| input.set_text(label.to_string(), cx));
194                                cx.notify();
195                            }))
196                            .child(Label::new(option)),
197                    );
198                }
199
200                // Float the list in a `deferred` overlay pass, anchored just
201                // below the trigger's real (previous-frame) bounds, instead
202                // of an inline flow child — so it never pushes sibling
203                // content down. Same idiom as `PopoverMenu`/`ContextMenu`
204                // (`crates/ui/src/components/popover_menu.rs`,
205                // `crates/ui/src/components/context_menu.rs`).
206                let mut anchor = anchored().snap_to_window_with_margin(px(8.));
207                if let Some(bounds) = self.trigger_bounds.get() {
208                    anchor = anchor.position(point(
209                        bounds.origin.x,
210                        bounds.origin.y + bounds.size.height + px(4.),
211                    ));
212                }
213                let floating_list = deferred(
214                    anchor.child(
215                        div()
216                            .occlude()
217                            .debug_selector(|| "COMBOBOX-LIST".into())
218                            .child(list),
219                    ),
220                )
221                .with_priority(1);
222                this.child(floating_list)
223            })
224    }
225}
226
227/// Standalone gallery preview for `Combobox` (not registered in the
228/// `Component` catalog since it is a stateful `Entity`, matching `Select`'s
229/// existing convention in this crate).
230pub fn combobox_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
231    v_flex()
232        .gap_4()
233        .child(cx.new(|cx| Combobox::new(cx, ["Apple", "Banana", "Cherry", "Date", "Elderberry"])))
234        .into_any_element()
235}