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
105 .options
106 .iter()
107 .enumerate()
108 .filter(|(_, o)| q.is_empty() || o.to_lowercase().contains(&q))
109 .map(|(i, _)| i)
110 .collect()
111 }
112
113 fn choose(&mut self, index: usize, cx: &mut Context<Self>) {
114 if self.multiple {
115 if let Some(pos) = self.selected.iter().position(|x| *x == index) {
116 self.selected.remove(pos);
117 } else {
118 self.selected.push(index);
119 self.selected.sort_unstable();
120 }
121 } else {
122 self.selected = vec![index];
123 self.open = false;
124 self.query.set_text("");
125 }
126 cx.emit(ComboboxEvent(index));
127 cx.notify();
128 }
129
130 fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
131 if self.disabled {
132 return;
133 }
134 let ks = &event.keystroke;
135 if !ks.modifiers.platform && !ks.modifiers.control {
138 match ks.key.as_str() {
139 "escape" if self.open => {
140 self.open = false;
141 cx.notify();
142 cx.stop_propagation();
143 return;
144 }
145 "enter" => {
146 if let Some(&first) = self.filtered().first() {
147 self.choose(first, cx);
148 }
149 cx.notify();
150 cx.stop_propagation();
151 return;
152 }
153 "down" if !self.open => {
154 self.open = true;
155 cx.notify();
156 cx.stop_propagation();
157 return;
158 }
159 _ => {}
160 }
161 }
162 match line::keys(self, event, window, cx) {
163 KeyOutcome::Edited => {
164 self.line_changed(cx);
165 cx.stop_propagation();
166 }
167 KeyOutcome::Submit | KeyOutcome::Cancel | KeyOutcome::Pass => {}
168 }
169 }
170
171 fn value_text(&self) -> SharedString {
172 match (self.multiple, self.selected.len()) {
173 (_, 0) => self.placeholder.clone(),
174 (true, n) => SharedString::from(format!("{n} selected")),
175 (false, _) => self
176 .selected
177 .first()
178 .and_then(|i| self.options.get(*i))
179 .cloned()
180 .unwrap_or_else(|| self.placeholder.clone()),
181 }
182 }
183}
184
185impl LineEditor for Combobox {
186 fn edit(&self) -> &TextEdit {
187 &self.query
188 }
189
190 fn edit_mut(&mut self) -> &mut TextEdit {
191 &mut self.query
192 }
193
194 fn line(&self) -> &LineState {
195 &self.state
196 }
197
198 fn line_mut(&mut self) -> &mut LineState {
199 &mut self.state
200 }
201
202 fn line_focus(&self) -> &FocusHandle {
203 &self.focus
204 }
205
206 fn line_read_only(&self) -> bool {
207 self.disabled
208 }
209
210 fn line_changed(&mut self, cx: &mut Context<Self>) {
212 self.open = true;
213 cx.notify();
214 }
215}
216
217line::line_input_handler!(Combobox);
218line::line_focus_builders!(Combobox);
219
220impl Render for Combobox {
221 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
222 let t = theme(cx);
223 let (height, pad_x, font) = control_metrics(self.size);
224 let radius = t.radius(t.default_radius);
225 let focused = self.focus.is_focused(window) && !self.disabled;
226 let surface = t.surface().hsla();
227 let surface_hover = t.surface_hover().hsla();
228 let border = if focused { t.primary() } else { t.border() }.hsla();
229 let text_color = t.text().hsla();
230 let dimmed = t.dimmed().hsla();
231 let selected_bg = t.primary().alpha(0.12);
232
233 let has_value = !self.selected.is_empty();
234 let interior = Line::new(cx.entity()).placeholder(
238 self.value_text(),
239 if has_value { text_color } else { dimmed },
240 );
241
242 let trigger = line::wire(div().id("guise-combobox-trigger"), &self.focus, cx)
243 .on_key_down(cx.listener(Self::on_key))
244 .on_mouse_down(
247 MouseButton::Left,
248 cx.listener(|this, _event, _window, cx| {
249 if !this.disabled {
250 this.open = true;
251 cx.notify();
252 }
253 }),
254 )
255 .flex()
256 .items_center()
257 .justify_between()
258 .gap(px(8.0))
259 .h(px(height))
260 .px(px(pad_x))
261 .rounded(px(radius))
262 .border_1()
263 .border_color(border)
264 .bg(surface)
265 .text_size(px(font))
266 .line_height(px(font * 1.3))
267 .child(div().flex_1().min_w(px(0.0)).child(interior))
268 .child(
271 div()
272 .id("guise-combobox-chevron")
273 .flex_none()
274 .cursor_pointer()
275 .child(
276 Icon::new(IconName::ChevronDown)
277 .size(Size::Xs)
278 .color(crate::theme::ColorName::Gray),
279 )
280 .on_click(cx.listener(|this, _ev, window, cx| {
281 if !this.disabled {
282 this.open = !this.open;
283 window.focus(&this.focus);
284 cx.notify();
285 }
286 })),
287 );
288
289 let mut wrap = div().relative().child(trigger);
290
291 if self.open && !self.disabled {
292 let filtered = self.filtered();
293 let mut menu = div()
294 .absolute()
295 .top(px(height + 6.0))
296 .left(px(0.0))
297 .right(px(0.0))
298 .flex()
299 .flex_col()
300 .gap(px(2.0))
301 .p(px(4.0))
302 .rounded(px(radius))
303 .border_1()
304 .border_color(border)
305 .bg(surface)
306 .shadow_md();
307
308 if filtered.is_empty() {
309 menu = menu.child(
310 div()
311 .px(px(10.0))
312 .py(px(6.0))
313 .text_size(px(font))
314 .text_color(dimmed)
315 .child(SharedString::new_static("No matches")),
316 );
317 }
318 for i in filtered {
319 let is_selected = self.selected.contains(&i);
320 let option = self.options[i].clone();
321 let mut row = div()
322 .id(("guise-combobox-option", i))
323 .flex()
324 .items_center()
325 .justify_between()
326 .px(px(10.0))
327 .py(px(6.0))
328 .rounded(px(4.0))
329 .text_size(px(font))
330 .text_color(text_color)
331 .hover(move |s| s.bg(surface_hover))
332 .child(option)
333 .on_click(cx.listener(move |this, _ev, _window, cx| this.choose(i, cx)));
334 if is_selected {
335 row = row
336 .bg(selected_bg)
337 .child(Icon::new(IconName::Check).size(Size::Xs));
338 }
339 menu = menu.child(row);
340 }
341
342 wrap = wrap.child(deferred(menu));
343 }
344
345 let mut chrome = Field::new().child(if self.disabled {
346 wrap.opacity(0.6)
347 } else {
348 wrap
349 });
350 if let Some(label) = self.label.clone() {
351 chrome = chrome.label(label);
352 }
353 chrome.probe_any("Combobox")
354 }
355}