1use gpui::prelude::*;
9use gpui::{
10 deferred, div, px, Context, EventEmitter, FocusHandle, IntoElement, KeyDownEvent, MouseButton,
11 SharedString, Window,
12};
13
14use super::line::{self, Line, LineEditor, LineState};
15use super::{control_metrics, Field, KeyOutcome, TextEdit};
16use crate::devtools::ProbedAny;
17use crate::icon::{Icon, IconName};
18use crate::theme::{theme, Size};
19
20#[derive(Debug, Clone, Copy)]
22pub struct ComboboxEvent(pub usize);
23
24pub struct Combobox {
26 options: Vec<SharedString>,
27 selected: Vec<usize>,
28 query: TextEdit,
29 state: LineState,
30 open: bool,
31 multiple: bool,
32 focus: FocusHandle,
33 placeholder: SharedString,
34 label: Option<SharedString>,
35 size: Size,
36 disabled: bool,
37}
38
39impl EventEmitter<ComboboxEvent> for Combobox {}
40
41impl Combobox {
42 pub fn new(cx: &mut Context<Self>) -> Self {
43 Combobox {
44 options: Vec::new(),
45 selected: Vec::new(),
46 query: TextEdit::new(""),
47 state: LineState::new(),
48 open: false,
49 multiple: false,
50 focus: cx.focus_handle().tab_stop(true),
51 placeholder: SharedString::new_static("Search…"),
52 label: None,
53 size: Size::Sm,
54 disabled: false,
55 }
56 }
57
58 pub fn data<I, S>(mut self, options: I) -> Self
59 where
60 I: IntoIterator<Item = S>,
61 S: Into<SharedString>,
62 {
63 self.options = options.into_iter().map(Into::into).collect();
64 self
65 }
66
67 pub fn multiple(mut self, multiple: bool) -> Self {
68 self.multiple = multiple;
69 self
70 }
71
72 pub fn selected(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
73 self.selected = indices.into_iter().collect();
74 self
75 }
76
77 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
78 self.placeholder = placeholder.into();
79 self
80 }
81
82 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
83 self.label = Some(label.into());
84 self
85 }
86
87 pub fn size(mut self, size: Size) -> Self {
88 self.size = size;
89 self
90 }
91
92 pub fn disabled(mut self, disabled: bool) -> Self {
93 self.disabled = disabled;
94 self
95 }
96
97 pub fn selected_indices(&self) -> &[usize] {
98 &self.selected
99 }
100
101 fn filtered(&self) -> Vec<usize> {
103 let q = self.query.text().to_lowercase();
104 self.options
105 .iter()
106 .enumerate()
107 .filter(|(_, o)| q.is_empty() || o.to_lowercase().contains(&q))
108 .map(|(i, _)| i)
109 .collect()
110 }
111
112 fn choose(&mut self, index: usize, cx: &mut Context<Self>) {
113 if self.multiple {
114 if let Some(pos) = self.selected.iter().position(|x| *x == index) {
115 self.selected.remove(pos);
116 } else {
117 self.selected.push(index);
118 self.selected.sort_unstable();
119 }
120 } else {
121 self.selected = vec![index];
122 self.open = false;
123 self.query.set_text("");
124 }
125 cx.emit(ComboboxEvent(index));
126 cx.notify();
127 }
128
129 fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
130 if self.disabled {
131 return;
132 }
133 let ks = &event.keystroke;
134 if !ks.modifiers.platform && !ks.modifiers.control {
137 match ks.key.as_str() {
138 "escape" if self.open => {
139 self.open = false;
140 cx.notify();
141 cx.stop_propagation();
142 return;
143 }
144 "enter" => {
145 if let Some(&first) = self.filtered().first() {
146 self.choose(first, cx);
147 }
148 cx.notify();
149 cx.stop_propagation();
150 return;
151 }
152 "down" if !self.open => {
153 self.open = true;
154 cx.notify();
155 cx.stop_propagation();
156 return;
157 }
158 _ => {}
159 }
160 }
161 match line::keys(self, event, window, cx) {
162 KeyOutcome::Edited => {
163 self.line_changed(cx);
164 cx.stop_propagation();
165 }
166 KeyOutcome::Submit | KeyOutcome::Cancel | KeyOutcome::Pass => {}
167 }
168 }
169
170 fn value_text(&self) -> SharedString {
171 match (self.multiple, self.selected.len()) {
172 (_, 0) => self.placeholder.clone(),
173 (true, n) => SharedString::from(format!("{n} selected")),
174 (false, _) => self
175 .selected
176 .first()
177 .and_then(|i| self.options.get(*i))
178 .cloned()
179 .unwrap_or_else(|| self.placeholder.clone()),
180 }
181 }
182}
183
184impl LineEditor for Combobox {
185 fn edit(&self) -> &TextEdit {
186 &self.query
187 }
188
189 fn edit_mut(&mut self) -> &mut TextEdit {
190 &mut self.query
191 }
192
193 fn line(&self) -> &LineState {
194 &self.state
195 }
196
197 fn line_mut(&mut self) -> &mut LineState {
198 &mut self.state
199 }
200
201 fn line_focus(&self) -> &FocusHandle {
202 &self.focus
203 }
204
205 fn line_read_only(&self) -> bool {
206 self.disabled
207 }
208
209 fn line_changed(&mut self, cx: &mut Context<Self>) {
211 self.open = true;
212 cx.notify();
213 }
214}
215
216line::line_input_handler!(Combobox);
217line::line_focus_builders!(Combobox);
218
219impl Render for Combobox {
220 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
221 let t = theme(cx);
222 let (height, pad_x, font) = control_metrics(self.size);
223 let radius = t.radius(t.default_radius);
224 let focused = self.focus.is_focused(window) && !self.disabled;
225 let surface = t.surface().hsla();
226 let surface_hover = t.surface_hover().hsla();
227 let border = if focused { t.primary() } else { t.border() }.hsla();
228 let text_color = t.text().hsla();
229 let dimmed = t.dimmed().hsla();
230 let selected_bg = t.primary().alpha(0.12);
231
232 let has_value = !self.selected.is_empty();
233 let interior = Line::new(cx.entity()).placeholder(
237 self.value_text(),
238 if has_value { text_color } else { dimmed },
239 );
240
241 let trigger = line::wire(div().id("guise-combobox-trigger"), &self.focus, cx)
242 .on_key_down(cx.listener(Self::on_key))
243 .on_mouse_down(
246 MouseButton::Left,
247 cx.listener(|this, _event, _window, cx| {
248 if !this.disabled {
249 this.open = true;
250 cx.notify();
251 }
252 }),
253 )
254 .flex()
255 .items_center()
256 .justify_between()
257 .gap(px(8.0))
258 .h(px(height))
259 .px(px(pad_x))
260 .rounded(px(radius))
261 .border_1()
262 .border_color(border)
263 .bg(surface)
264 .text_size(px(font))
265 .line_height(px(font * 1.3))
266 .child(div().flex_1().min_w(px(0.0)).child(interior))
267 .child(
270 div()
271 .id("guise-combobox-chevron")
272 .flex_none()
273 .cursor_pointer()
274 .child(
275 Icon::new(IconName::ChevronDown)
276 .size(Size::Xs)
277 .color(crate::theme::ColorName::Gray),
278 )
279 .on_click(cx.listener(|this, _ev, window, cx| {
280 if !this.disabled {
281 this.open = !this.open;
282 window.focus(&this.focus);
283 cx.notify();
284 }
285 })),
286 );
287
288 let mut wrap = div().relative().child(trigger);
289
290 if self.open && !self.disabled {
291 let filtered = self.filtered();
292 let mut menu = div()
293 .absolute()
294 .top(px(height + 6.0))
295 .left(px(0.0))
296 .right(px(0.0))
297 .flex()
298 .flex_col()
299 .gap(px(2.0))
300 .p(px(4.0))
301 .rounded(px(radius))
302 .border_1()
303 .border_color(border)
304 .bg(surface)
305 .shadow_md();
306
307 if filtered.is_empty() {
308 menu = menu.child(
309 div()
310 .px(px(10.0))
311 .py(px(6.0))
312 .text_size(px(font))
313 .text_color(dimmed)
314 .child(SharedString::new_static("No matches")),
315 );
316 }
317 for i in filtered {
318 let is_selected = self.selected.contains(&i);
319 let option = self.options[i].clone();
320 let mut row = div()
321 .id(("guise-combobox-option", i))
322 .flex()
323 .items_center()
324 .justify_between()
325 .px(px(10.0))
326 .py(px(6.0))
327 .rounded(px(4.0))
328 .text_size(px(font))
329 .text_color(text_color)
330 .hover(move |s| s.bg(surface_hover))
331 .child(option)
332 .on_click(cx.listener(move |this, _ev, _window, cx| this.choose(i, cx)));
333 if is_selected {
334 row = row
335 .bg(selected_bg)
336 .child(Icon::new(IconName::Check).size(Size::Xs));
337 }
338 menu = menu.child(row);
339 }
340
341 wrap = wrap.child(deferred(menu));
342 }
343
344 let mut chrome = Field::new().child(if self.disabled {
345 wrap.opacity(0.6)
346 } else {
347 wrap
348 });
349 if let Some(label) = self.label.clone() {
350 chrome = chrome.label(label);
351 }
352 chrome.probe_any("Combobox")
353 }
354}