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