1use crate::{
19 icons,
20 input::{self, TextField},
21 popover,
22 widgets::Controls,
23};
24use gpui::{
25 App, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, Pixels, SharedString,
26 Window, actions, canvas, div, prelude::*, px,
27};
28use theme::{TextStyle, Theme, Typeset};
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| {
82 TextField::new(cx)
83 .with_placeholder("Search…")
84 .with_frame(false)
85 });
86 cx.subscribe(&query, |combobox, query, _: &input::FieldEvent, cx| {
87 let query = query.read(cx).content().clone();
88 combobox.filter.refilter(&query);
89 cx.notify();
90 })
91 .detach();
92 Self {
93 query,
94 filter: popover::Filter::new(items),
95 menu: popover::Popup::default(),
96 chosen: None,
97 placeholder: placeholder.into(),
98 trigger_width: None,
99 focus_handle: cx.focus_handle().tab_stop(true),
102 }
103 }
104
105 pub fn with_selection(mut self, item: usize) -> Self {
107 self.chosen = (item < self.filter.items().len()).then_some(item);
108 self
109 }
110
111 pub fn selection(&self) -> Option<usize> {
112 self.chosen
113 }
114
115 fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
116 if self.menu.take_press_was_open() {
119 self.close(cx);
120 } else {
121 self.query.update(cx, |query, cx| query.clear(cx));
123 self.filter.refilter("");
124 self.menu.open(());
125 window.focus(&self.query.focus_handle(cx), cx);
126 cx.notify();
127 }
128 }
129
130 fn close(&mut self, cx: &mut Context<Self>) {
131 if self.menu.begin_close() {
132 popover::reap_popup(cx, |combobox: &mut Self| &mut combobox.menu);
133 }
134 cx.notify();
135 }
136
137 fn choose(&mut self, item: usize, cx: &mut Context<Self>) {
138 self.chosen = Some(item);
139 cx.emit(ComboboxEvent::Selected(item));
140 self.close(cx);
141 }
142
143 fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
144 self.filter.step(1);
145 cx.notify();
146 }
147
148 fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
149 self.filter.step(-1);
150 cx.notify();
151 }
152
153 fn confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
154 if let Some(item) = self.filter.active_item() {
155 self.choose(item, cx);
156 }
157 }
158
159 fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
160 self.close(cx);
161 }
162
163 fn menu_card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
164 let rows: Vec<gpui::AnyElement> = self
165 .filter
166 .filtered()
167 .iter()
168 .enumerate()
169 .map(|(position, &item)| {
170 popover::menu_row(theme, Some(position) == self.filter.active(), None)
171 .justify_between()
172 .id(SharedString::from(format!("row-{item}")))
173 .on_mouse_move(cx.listener(move |combobox: &mut Self, _, _, cx| {
174 if combobox.filter.active() != Some(position) {
175 combobox.filter.set_active(position);
176 cx.notify();
177 }
178 }))
179 .on_click(cx.listener(move |combobox, _, _, cx| combobox.choose(item, cx)))
180 .child(self.filter.items()[item].clone())
181 .when(Some(item) == self.chosen, |row| {
182 row.child(
183 icons::icon(icons::CHECK)
184 .size(px(13.0))
185 .text_color(theme.text),
186 )
187 })
188 .into_any_element()
189 })
190 .collect();
191
192 popover::popover_card(theme)
193 .w(self.trigger_width.unwrap_or(px(200.0)))
194 .on_mouse_down_out(cx.listener(|combobox, _, _, cx| combobox.close(cx)))
195 .child(popover::search_line(
196 theme,
197 self.query.clone().into_any_element(),
198 ))
199 .child(if rows.is_empty() {
200 div()
201 .px(px(10.0))
202 .py(px(8.0))
203 .text_style(TextStyle::Body)
204 .text_color(theme.text_muted)
205 .child("No matches")
206 .into_any_element()
207 } else {
208 div().flex().flex_col().children(rows).into_any_element()
209 })
210 .into_any_element()
211 }
212}
213
214impl Focusable for Combobox {
215 fn focus_handle(&self, _: &App) -> FocusHandle {
217 self.focus_handle.clone()
218 }
219}
220
221impl Render for Combobox {
222 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
223 let theme = Theme::of(cx).clone();
224 let open = self.menu.is_open() || self.menu.is_closing();
225 let label = match self.chosen {
226 Some(item) => self.filter.items()[item].clone(),
227 None => self.placeholder.clone(),
228 };
229 let card = open.then(|| self.menu_card(&theme, cx));
230 let combobox = cx.entity().downgrade();
231
232 div()
233 .key_context(KEY_CONTEXT)
234 .track_focus(&self.focus_handle)
235 .on_action(cx.listener(Self::select_next))
236 .on_action(cx.listener(Self::select_previous))
237 .on_action(cx.listener(Self::confirm))
238 .on_action(cx.listener(Self::dismiss))
239 .relative()
240 .w_full()
241 .child(
245 canvas(
246 move |bounds, _, cx| {
247 combobox
248 .update(cx, |combobox, _| {
249 combobox.trigger_width = Some(bounds.size.width);
250 })
251 .ok();
252 },
253 |_, _, _, _| {},
254 )
255 .absolute()
256 .size_full(),
257 )
258 .child(
259 div()
260 .id("combobox-trigger")
261 .on_mouse_down(
262 gpui::MouseButton::Left,
263 cx.listener(|combobox, _, _, _| combobox.menu.note_trigger_press()),
264 )
265 .on_click(cx.listener(|combobox, _, window, cx| combobox.toggle(window, cx)))
266 .child(theme.select_trigger(label)),
267 )
268 .when_some(card, |trigger, card| {
269 trigger.child(popover::anchored_menu_below(
270 "combobox-menu",
271 card,
272 self.menu.closing_since(),
273 ))
274 })
275 }
276}
277
278pub use input::KEY_CONTEXT as FIELD_KEY_CONTEXT;