1use crate::{
19 input::{self, TextField},
20 popover,
21 widgets::Controls,
22};
23use gpui::{
24 App, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, Pixels, SharedString,
25 Window, actions, canvas, div, prelude::*, px,
26};
27use theme::Theme;
28
29actions!(
30 bezel_combobox,
31 [SelectNext, SelectPrevious, Confirm, Dismiss]
32);
33
34pub const KEY_CONTEXT: &str = "Combobox";
37
38pub fn init(cx: &mut App) {
41 let ctx = Some(KEY_CONTEXT);
42 cx.bind_keys([
43 KeyBinding::new("down", SelectNext, ctx),
44 KeyBinding::new("up", SelectPrevious, ctx),
45 KeyBinding::new("enter", Confirm, ctx),
46 KeyBinding::new("escape", Dismiss, ctx),
47 KeyBinding::new("ctrl-n", SelectNext, ctx),
48 KeyBinding::new("ctrl-p", SelectPrevious, ctx),
49 ]);
50}
51
52#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum ComboboxEvent {
56 Selected(usize),
57}
58
59pub struct Combobox {
60 query: Entity<TextField>,
61 filter: popover::Filter,
62 menu: popover::Popup<()>,
63 chosen: Option<usize>,
64 placeholder: SharedString,
65 trigger_width: Option<Pixels>,
69 focus_handle: FocusHandle,
70}
71
72impl EventEmitter<ComboboxEvent> for Combobox {}
73
74impl Combobox {
75 pub fn new(
76 items: Vec<SharedString>,
77 placeholder: impl Into<SharedString>,
78 cx: &mut Context<Self>,
79 ) -> Self {
80 let query = cx.new(|cx| TextField::new(cx).with_placeholder("Search…"));
81 cx.observe(&query, |combobox, _, cx| {
82 let query = combobox.query.read(cx).content().clone();
83 combobox.filter.refilter(&query);
84 cx.notify();
85 })
86 .detach();
87 Self {
88 query,
89 filter: popover::Filter::new(items),
90 menu: popover::Popup::default(),
91 chosen: None,
92 placeholder: placeholder.into(),
93 trigger_width: None,
94 focus_handle: cx.focus_handle().tab_stop(true),
97 }
98 }
99
100 pub fn with_selection(mut self, item: usize) -> Self {
102 self.chosen = (item < self.filter.items().len()).then_some(item);
103 self
104 }
105
106 pub fn selection(&self) -> Option<usize> {
107 self.chosen
108 }
109
110 fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
111 if self.menu.take_press_was_open() {
114 self.close(cx);
115 } else {
116 self.query.update(cx, |query, cx| query.clear(cx));
118 self.filter.refilter("");
119 self.menu.open(());
120 window.focus(&self.query.focus_handle(cx), cx);
121 cx.notify();
122 }
123 }
124
125 fn close(&mut self, cx: &mut Context<Self>) {
126 if self.menu.begin_close() {
127 popover::reap_popup(cx, |combobox: &mut Self| &mut combobox.menu);
128 }
129 cx.notify();
130 }
131
132 fn choose(&mut self, item: usize, cx: &mut Context<Self>) {
133 self.chosen = Some(item);
134 cx.emit(ComboboxEvent::Selected(item));
135 self.close(cx);
136 }
137
138 fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
139 self.filter.step(1);
140 cx.notify();
141 }
142
143 fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
144 self.filter.step(-1);
145 cx.notify();
146 }
147
148 fn confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
149 if let Some(item) = self.filter.active_item() {
150 self.choose(item, cx);
151 }
152 }
153
154 fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
155 self.close(cx);
156 }
157
158 fn menu_card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
159 let view = cx.entity_id();
162 let rows: Vec<gpui::AnyElement> = self
163 .filter
164 .filtered()
165 .iter()
166 .enumerate()
167 .map(|(position, &item)| {
168 popover::menu_row_nav(
169 theme,
170 Some(item) == self.chosen,
171 Some(position) == self.filter.active(),
172 SharedString::from(format!("combobox-{view}-row-{item}")),
173 )
174 .id(SharedString::from(format!("row-{item}")))
175 .on_click(cx.listener(move |combobox, _, _, cx| combobox.choose(item, cx)))
176 .child(self.filter.items()[item].clone())
177 .into_any_element()
178 })
179 .collect();
180
181 popover::popover_card(theme)
182 .w(self.trigger_width.unwrap_or(px(200.0)))
183 .on_mouse_down_out(cx.listener(|combobox, _, _, cx| combobox.close(cx)))
184 .child(popover::search_input_frame(
185 theme,
186 self.query.clone().into_any_element(),
187 ))
188 .child(if rows.is_empty() {
189 div()
190 .px(px(10.0))
191 .py(px(8.0))
192 .text_size(px(13.0))
193 .text_color(theme.text_muted)
194 .child("No matches")
195 .into_any_element()
196 } else {
197 div().flex().flex_col().children(rows).into_any_element()
198 })
199 .into_any_element()
200 }
201}
202
203impl Focusable for Combobox {
204 fn focus_handle(&self, _: &App) -> FocusHandle {
206 self.focus_handle.clone()
207 }
208}
209
210impl Render for Combobox {
211 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
212 let theme = Theme::of(cx).clone();
213 let open = self.menu.is_open() || self.menu.is_closing();
214 let label = match self.chosen {
215 Some(item) => self.filter.items()[item].clone(),
216 None => self.placeholder.clone(),
217 };
218 let card = open.then(|| self.menu_card(&theme, cx));
219 let combobox = cx.entity().downgrade();
220
221 div()
222 .key_context(KEY_CONTEXT)
223 .track_focus(&self.focus_handle)
224 .on_action(cx.listener(Self::select_next))
225 .on_action(cx.listener(Self::select_previous))
226 .on_action(cx.listener(Self::confirm))
227 .on_action(cx.listener(Self::dismiss))
228 .relative()
229 .w_full()
230 .child(
234 canvas(
235 move |bounds, _, cx| {
236 combobox
237 .update(cx, |combobox, _| {
238 combobox.trigger_width = Some(bounds.size.width);
239 })
240 .ok();
241 },
242 |_, _, _, _| {},
243 )
244 .absolute()
245 .size_full(),
246 )
247 .child(
248 div()
249 .id("combobox-trigger")
250 .on_mouse_down(
251 gpui::MouseButton::Left,
252 cx.listener(|combobox, _, _, _| combobox.menu.note_trigger_press()),
253 )
254 .on_click(cx.listener(|combobox, _, window, cx| combobox.toggle(window, cx)))
255 .child(theme.select_trigger(label, open)),
256 )
257 .when_some(card, |trigger, card| {
258 trigger.child(popover::anchored_menu_below(
259 "combobox-menu",
260 card,
261 self.menu.closing_since(),
262 ))
263 })
264 }
265}
266
267pub use input::KEY_CONTEXT as FIELD_KEY_CONTEXT;