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