1use crate::{input, popover, search::SearchList, widgets::Controls};
19use gpui::{
20 App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, Pixels, SharedString, Window,
21 actions, canvas, div, prelude::*, px,
22};
23use theme::Theme;
24
25actions!(
26 bezel_combobox,
27 [SelectNext, SelectPrevious, Confirm, Dismiss]
28);
29
30pub const KEY_CONTEXT: &str = "Combobox";
33
34pub fn init(cx: &mut App) {
37 cx.bind_keys(bindings());
38}
39
40pub fn bindings() -> Vec<KeyBinding> {
44 let mut bindings = Vec::new();
45 let ctx = Some(KEY_CONTEXT);
46 bindings.extend([
47 KeyBinding::new("down", SelectNext, ctx),
48 KeyBinding::new("up", SelectPrevious, ctx),
49 KeyBinding::new("enter", Confirm, ctx),
50 KeyBinding::new("escape", Dismiss, ctx),
51 KeyBinding::new("ctrl-n", SelectNext, ctx),
52 KeyBinding::new("ctrl-p", SelectPrevious, ctx),
53 ]);
54
55 bindings
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
61pub enum ComboboxEvent {
62 Selected(usize),
63}
64
65pub struct Combobox {
66 search: SearchList,
67 menu: popover::Popup<()>,
68 chosen: Option<usize>,
69 placeholder: SharedString,
70 trigger_width: Option<Pixels>,
74 focus_handle: FocusHandle,
75}
76
77impl EventEmitter<ComboboxEvent> for Combobox {}
78
79impl Combobox {
80 pub fn new(
81 items: Vec<SharedString>,
82 placeholder: impl Into<SharedString>,
83 cx: &mut Context<Self>,
84 ) -> Self {
85 Self {
86 search: SearchList::new(items, "Search…", |view: &mut Self| &mut view.search, cx),
87 menu: popover::Popup::default(),
88 chosen: None,
89 placeholder: placeholder.into(),
90 trigger_width: None,
91 focus_handle: cx.focus_handle().tab_stop(true),
94 }
95 }
96
97 pub fn with_selection(mut self, item: usize) -> Self {
99 self.chosen = (item < self.search.filter.items().len()).then_some(item);
100 self
101 }
102
103 pub fn selection(&self) -> Option<usize> {
104 self.chosen
105 }
106
107 fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
108 self.search.clear(cx);
109 if let Some(chosen) = self.chosen {
110 self.search.filter.set_active(chosen);
111 }
112 self.menu.open(());
113 window.focus(&self.search.query.focus_handle(cx), cx);
114 cx.notify();
115 }
116
117 fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
118 if self.menu.take_press_was_open() {
119 self.close(window, cx);
120 } else {
121 self.open(window, cx);
122 }
123 }
124
125 fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
126 if self.search.query.focus_handle(cx).is_focused(window) {
129 window.focus(&self.focus_handle, cx);
130 }
131 popover::close_popup(self, cx, |view| &mut view.menu);
132 }
133
134 fn choose(&mut self, item: usize, window: &mut Window, cx: &mut Context<Self>) {
135 if !self.menu.is_open() {
136 return;
137 }
138 self.chosen = Some(item);
139 cx.emit(ComboboxEvent::Selected(item));
140 self.close(window, cx);
141 }
142
143 fn step(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
144 if self.menu.is_open() {
145 self.search.filter.step(delta);
146 cx.notify();
147 } else if !self.menu.is_closing() {
148 self.open(window, cx);
149 }
150 }
151
152 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
153 self.step(1, window, cx);
154 }
155
156 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
157 self.step(-1, window, cx);
158 }
159
160 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
161 if self.menu.is_open() {
162 if let Some(item) = self.search.filter.active_item() {
163 self.choose(item, window, cx);
164 }
165 } else if !self.menu.is_closing() {
166 self.open(window, cx);
167 }
168 }
169
170 fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
171 if self.menu.is_open() {
172 self.close(window, cx);
173 } else {
174 cx.propagate();
175 }
176 }
177
178 fn menu_card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
179 popover::popover_card(theme)
180 .w(self.trigger_width.unwrap_or(px(200.0)))
181 .on_mouse_down_out(cx.listener(|view, _, window, cx| view.close(window, cx)))
182 .child(self.search.body(
183 theme,
184 self.chosen,
185 |view| &mut view.search,
186 Self::choose,
187 cx,
188 ))
189 .into_any_element()
190 }
191}
192
193impl Focusable for Combobox {
194 fn focus_handle(&self, _: &App) -> FocusHandle {
196 self.focus_handle.clone()
197 }
198}
199
200impl Render for Combobox {
201 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
202 let theme = Theme::of(cx).clone();
203 let open = self.menu.is_open() || self.menu.is_closing();
204 let label = match self.chosen {
205 Some(item) => self.search.filter.items()[item].clone(),
206 None => self.placeholder.clone(),
207 };
208 let card = open.then(|| self.menu_card(&theme, cx));
209 let combobox = cx.entity().downgrade();
210
211 div()
212 .key_context(KEY_CONTEXT)
213 .track_focus(&self.focus_handle)
214 .on_action(cx.listener(Self::select_next))
215 .on_action(cx.listener(Self::select_previous))
216 .on_action(cx.listener(Self::confirm))
217 .on_action(cx.listener(Self::dismiss))
218 .relative()
219 .w_full()
220 .child(
224 canvas(
225 move |bounds, _, cx| {
226 combobox
227 .update(cx, |combobox, _| {
228 combobox.trigger_width = Some(bounds.size.width);
229 })
230 .ok();
231 },
232 |_, _, _, _| {},
233 )
234 .absolute()
235 .size_full(),
236 )
237 .child(popover::trigger_press(
238 div()
239 .id("combobox-trigger")
240 .on_click(cx.listener(|combobox, _, window, cx| combobox.toggle(window, cx)))
241 .child(theme.select_trigger(label)),
242 |combobox: &mut Self| &mut combobox.menu,
243 cx,
244 ))
245 .when_some(card, |trigger, card| {
246 trigger.child(popover::anchored_menu_below(
247 "combobox-menu",
248 card,
249 self.menu.closing_since(),
250 ))
251 })
252 }
253}
254
255pub use input::KEY_CONTEXT as FIELD_KEY_CONTEXT;