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