1use std::rc::Rc;
2
3use crate::motion::{Motion, StyledSlot};
4use crate::theme::{rgb, ActiveTheme, Theme, ThemeKind};
5use gpui::{
6 anchored, deferred, div, point, prelude::*, px, App, ClickEvent, FocusHandle, FontWeight,
7 IntoElement, KeyDownEvent, RenderOnce, SharedString, StyleRefinement, Styled, Window,
8};
9
10use crate::compat::{AccessibilityExt, Role};
11
12use crate::button::ButtonVariant;
13use crate::chrome::{box_shadow, button_chrome, field_chrome, FieldState};
14use crate::icon::{Icon, IconName};
15
16type SelectClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
17type SelectPickHandler = Rc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
18type SelectOpenHandler = Rc<dyn Fn(bool, &mut Window, &mut App) + 'static>;
19
20struct SelectState {
21 focus_handle: FocusHandle,
22 open: bool,
23 value: Option<SharedString>,
24 last_value_prop: Option<SharedString>,
25 highlighted: Option<usize>,
26}
27
28fn initial_highlight(items: &[SelectItem], value: Option<&SharedString>) -> Option<usize> {
29 value
30 .and_then(|value| {
31 items
32 .iter()
33 .position(|item| !item.disabled && item.value.as_ref() == value.as_ref())
34 })
35 .or_else(|| items.iter().position(|item| !item.disabled))
36}
37
38fn next_enabled(items: &[SelectItem], current: Option<usize>, forward: bool) -> Option<usize> {
39 if items.is_empty() {
40 return None;
41 }
42
43 match current {
44 Some(start) => (1..=items.len())
45 .map(|offset| {
46 if forward {
47 (start + offset) % items.len()
48 } else {
49 (start + items.len() - (offset % items.len())) % items.len()
50 }
51 })
52 .find(|index| !items[*index].disabled),
53 None if forward => items.iter().position(|item| !item.disabled),
54 None => items.iter().rposition(|item| !item.disabled),
55 }
56}
57
58fn select_menu_bg(theme: Theme) -> gpui::Hsla {
59 match theme.kind {
60 ThemeKind::Light => rgb(0xF4F5F7),
61 ThemeKind::Dark => rgb(0x1C1D20),
62 }
63}
64
65#[derive(Clone, Debug)]
67pub struct SelectItem {
68 pub value: SharedString,
69 pub disabled: bool,
70}
71
72impl SelectItem {
73 pub fn new(value: impl Into<SharedString>) -> Self {
74 Self {
75 value: value.into(),
76 disabled: false,
77 }
78 }
79
80 pub fn disabled(mut self, disabled: bool) -> Self {
81 self.disabled = disabled;
82 self
83 }
84}
85
86#[derive(IntoElement)]
88pub struct Select {
89 id: SharedString,
90 placeholder: SharedString,
91 value: Option<SharedString>,
92 items: Vec<SelectItem>,
93 disabled: bool,
94 focused: bool,
95 controlled_open: Option<bool>,
96 default_open: bool,
97 style: StyleRefinement,
98 on_click: Option<SelectClickHandler>,
99 on_select: Option<SelectPickHandler>,
100 on_open_change: Option<SelectOpenHandler>,
101}
102
103impl Select {
104 pub fn new(id: impl Into<SharedString>) -> Self {
105 Self {
106 id: id.into(),
107 placeholder: SharedString::from("Choose format"),
108 value: None,
109 items: Vec::new(),
110 disabled: false,
111 focused: false,
112 controlled_open: None,
113 default_open: false,
114 style: StyleRefinement::default(),
115 on_click: None,
116 on_select: None,
117 on_open_change: None,
118 }
119 }
120
121 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
122 self.placeholder = placeholder.into();
123 self
124 }
125
126 pub fn value(mut self, value: impl Into<SharedString>) -> Self {
127 self.value = Some(value.into());
128 self
129 }
130
131 pub fn items(mut self, items: impl IntoIterator<Item = SelectItem>) -> Self {
132 self.items = items.into_iter().collect();
133 self
134 }
135
136 pub fn disabled(mut self, disabled: bool) -> Self {
137 self.disabled = disabled;
138 self
139 }
140
141 pub fn focused(mut self, focused: bool) -> Self {
142 self.focused = focused;
143 self
144 }
145
146 pub fn open(mut self, open: bool) -> Self {
148 self.controlled_open = Some(open);
149 self
150 }
151
152 pub fn default_open(mut self, open: bool) -> Self {
153 self.default_open = open;
154 self
155 }
156
157 pub fn on_click(
158 mut self,
159 listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
160 ) -> Self {
161 self.on_click = Some(Rc::new(listener));
162 self
163 }
164
165 pub fn on_select(
166 mut self,
167 listener: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
168 ) -> Self {
169 self.on_select = Some(Rc::new(listener));
170 self
171 }
172
173 pub fn on_open_change(
174 mut self,
175 listener: impl Fn(bool, &mut Window, &mut App) + 'static,
176 ) -> Self {
177 self.on_open_change = Some(Rc::new(listener));
178 self
179 }
180}
181
182impl Styled for Select {
183 fn style(&mut self) -> &mut StyleRefinement {
184 &mut self.style
185 }
186}
187
188fn set_open(
189 state: &gpui::Entity<SelectState>,
190 open: bool,
191 items: &[SelectItem],
192 on_open_change: Option<&SelectOpenHandler>,
193 window: &mut Window,
194 cx: &mut App,
195) {
196 if state.read(cx).open == open {
197 return;
198 }
199 state.update(cx, |select, cx| {
200 select.open = open;
201 select.highlighted = open
202 .then(|| initial_highlight(items, select.value.as_ref()))
203 .flatten();
204 if !open {
205 select.highlighted = None;
206 }
207 cx.notify();
208 });
209 if let Some(on_open_change) = on_open_change {
210 on_open_change(open, window, cx);
211 }
212}
213
214impl RenderOnce for Select {
215 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
216 let initial_open = self.controlled_open.unwrap_or(self.default_open);
217 let initial_value = self.value.clone();
218 let initial_items = self.items.clone();
219 let state = window.use_keyed_state(self.id.clone(), cx, move |_, cx| SelectState {
220 focus_handle: cx.focus_handle().tab_stop(true),
221 open: initial_open,
222 highlighted: initial_open
223 .then(|| initial_highlight(&initial_items, initial_value.as_ref()))
224 .flatten(),
225 value: initial_value.clone(),
226 last_value_prop: initial_value,
227 });
228
229 if let Some(controlled_open) = self.controlled_open {
230 if state.read(cx).open != controlled_open {
231 state.update(cx, |select, _| {
232 select.open = controlled_open;
233 select.highlighted = controlled_open
234 .then(|| initial_highlight(&self.items, select.value.as_ref()))
235 .flatten();
236 });
237 }
238 }
239 if state.read(cx).last_value_prop != self.value {
240 state.update(cx, |select, _| {
241 select.value = self.value.clone();
242 select.last_value_prop = self.value.clone();
243 });
244 }
245
246 let open = state.read(cx).open;
247 let value = state.read(cx).value.clone();
248 let highlighted = state.read(cx).highlighted;
249 let focus_handle = state.read(cx).focus_handle.clone();
250 let theme = cx.theme();
251 let field_state = if self.disabled {
252 FieldState::Disabled
253 } else if self.focused || open || focus_handle.is_focused(window) {
254 FieldState::Focus
255 } else {
256 FieldState::Rest
257 };
258 let chrome = field_chrome(theme, field_state);
259 let list_chrome = button_chrome(theme, ButtonVariant::Secondary);
260 let list_bg = select_menu_bg(theme);
261 let ghost = button_chrome(theme, ButtonVariant::Ghost);
262
263 let mut shadows = vec![box_shadow(0., 1., chrome.inset, 0., 0.)];
264 if chrome.shadow_blur > 0.0 {
265 shadows.push(box_shadow(
266 0.,
267 chrome.shadow_y,
268 chrome.shadow,
269 chrome.shadow_blur,
270 0.,
271 ));
272 }
273 if let Some(ring) = chrome.ring {
274 shadows.push(box_shadow(0., 0., ring, 0., 3.));
275 }
276
277 let has_value = value.is_some();
278 let label = value.clone().unwrap_or_else(|| self.placeholder.clone());
279 let label_color = if self.disabled || !has_value {
280 chrome.placeholder
281 } else {
282 chrome.fg
283 };
284
285 let interactive = !self.disabled;
286 let on_click = self.on_click.clone();
287 let on_open_change = self.on_open_change.clone();
288 let trigger_state = state.clone();
289 let trigger_items = self.items.clone();
290 let trigger_focus = focus_handle.clone();
291 let trigger_open_change = on_open_change.clone();
292 let next_open = !open;
293 let trigger_debug_selector = format!("{}-trigger", self.id);
294 let mut trigger = div()
295 .id(self.id.clone())
296 .debug_selector(move || trigger_debug_selector)
297 .role(Role::ComboBox)
298 .aria_expanded(open)
299 .flex()
300 .items_center()
301 .gap(px(8.))
302 .w(px(280.))
303 .h(px(36.))
304 .flex_shrink_0()
305 .px(px(14.))
306 .rounded(px(6.))
307 .border_1()
308 .border_color(chrome.border)
309 .bg(chrome.bg)
310 .shadow(shadows)
311 .when(interactive, |el| el.cursor_pointer())
312 .when(!interactive, |el| el.cursor_default())
313 .child(
314 div()
315 .flex_1()
316 .min_w(px(0.))
317 .font_family(theme.font_family)
318 .font_weight(FontWeight::NORMAL)
319 .text_size(px(14.))
320 .line_height(px(18.))
321 .text_color(label_color)
322 .child(label),
323 )
324 .child(
325 Icon::new(IconName::ChevronDown)
326 .px(px(16.))
327 .color(theme.label),
328 );
329
330 if interactive {
331 trigger = trigger.on_click(move |event, window, cx| {
332 trigger_focus.focus(window);
333 set_open(
334 &trigger_state,
335 next_open,
336 &trigger_items,
337 trigger_open_change.as_ref(),
338 window,
339 cx,
340 );
341 if let Some(on_click) = &on_click {
342 on_click(event, window, cx);
343 }
344 });
345 }
346
347 let mut list_shadows = vec![box_shadow(0., 1., list_chrome.inset, 0., 0.)];
348 if list_chrome.shadow_blur > 0.0 {
349 list_shadows.push(box_shadow(
350 0.,
351 list_chrome.shadow_y,
352 list_chrome.shadow,
353 list_chrome.shadow_blur,
354 0.,
355 ));
356 }
357
358 let selected = value.clone();
359 let on_select = self.on_select.clone();
360 let keyboard_items = self.items.clone();
361 let keyboard_on_select = self.on_select.clone();
362 let ghost_bg = ghost.bg;
363 let ghost_hover = ghost.hover_bg;
364 let select_id = self.id.clone();
365 let popup_debug_selector = format!("{select_id}-popup");
366 let dismiss_state = state.clone();
367 let dismiss_items = self.items.clone();
368 let dismiss_open_change = on_open_change.clone();
369 let list = div()
370 .id(SharedString::from(format!("{select_id}-popup")))
371 .debug_selector(move || popup_debug_selector)
372 .flex()
373 .flex_col()
374 .w(px(280.))
375 .flex_shrink_0()
376 .p(px(4.))
377 .rounded(px(6.))
378 .border_1()
379 .border_color(list_chrome.border)
380 .bg(list_bg)
381 .shadow(list_shadows)
382 .occlude()
383 .on_mouse_down_out(move |_, window, cx| {
384 set_open(
385 &dismiss_state,
386 false,
387 &dismiss_items,
388 dismiss_open_change.as_ref(),
389 window,
390 cx,
391 );
392 })
393 .children(self.items.into_iter().enumerate().map(|(index, item)| {
394 let is_selected = selected
395 .as_ref()
396 .is_some_and(|value| value.as_ref() == item.value.as_ref());
397 let is_highlighted = highlighted == Some(index);
398 let enabled = !item.disabled;
399 let text_color = if item.disabled {
400 theme.label
401 } else {
402 theme.ink
403 };
404 let item_value = item.value.clone();
405 let on_select = on_select.clone();
406 let row_open_change = on_open_change.clone();
407 let row_state = state.clone();
408 let row_id = SharedString::from(format!("{select_id}-{index}"));
409 let selected_motion_id = format!("{select_id}-{index}-selected");
410
411 let mut row = div()
412 .id(row_id)
413 .flex()
414 .items_center()
415 .gap(px(8.))
416 .h(px(32.))
417 .flex_shrink_0()
418 .px(px(8.))
419 .rounded(px(6.))
420 .when(is_selected || is_highlighted, |el| el.bg(ghost_bg))
421 .when(enabled, |el| {
422 el.cursor_pointer().hover(move |s| s.bg(ghost_hover))
423 })
424 .when(!enabled, |el| el.cursor_default())
425 .child(div().size(px(16.)).flex_shrink_0().when(is_selected, |el| {
426 el.child(
427 Motion::new()
428 .id(selected_motion_id)
429 .selection_in()
430 .child(Icon::new(IconName::Check).px(px(16.)).color(theme.ink)),
431 )
432 }))
433 .child(
434 div()
435 .font_family(theme.font_family)
436 .font_weight(FontWeight::NORMAL)
437 .text_size(px(14.))
438 .line_height(px(18.))
439 .text_color(text_color)
440 .child(item.value.clone()),
441 );
442
443 if enabled {
444 row = row.on_click(move |_, window, cx| {
445 row_state.update(cx, |select, cx| {
446 select.value = Some(item_value.clone());
447 cx.notify();
448 });
449 set_open(&row_state, false, &[], row_open_change.as_ref(), window, cx);
450 if let Some(on_select) = &on_select {
451 on_select(&item_value, window, cx);
452 }
453 });
454 }
455
456 row
457 }));
458
459 let list = Motion::new()
460 .id(format!("{select_id}-surface"))
461 .surface_in()
462 .child(list);
463 let popup = deferred(anchored().offset(point(px(0.), px(8.))).child(list)).with_priority(1);
464
465 let keyboard_state = state.clone();
466 let keyboard_open_change = on_open_change.clone();
467
468 div()
469 .relative()
470 .w(px(280.))
471 .h(px(36.))
472 .flex_shrink_0()
473 .refine_style(&self.style)
474 .track_focus(&focus_handle)
475 .tab_stop(interactive)
476 .on_key_down(move |event: &KeyDownEvent, window, cx| {
477 if !interactive || event.keystroke.modifiers.modified() {
478 return;
479 }
480
481 let key = event.keystroke.key.as_str();
482 let (was_open, current) = {
483 let snapshot = keyboard_state.read(cx);
484 (snapshot.open, snapshot.highlighted)
485 };
486
487 match key {
488 "down" | "up" => {
489 let next = next_enabled(&keyboard_items, current, key == "down");
490 if !was_open {
491 set_open(
492 &keyboard_state,
493 true,
494 &keyboard_items,
495 keyboard_open_change.as_ref(),
496 window,
497 cx,
498 );
499 }
500 keyboard_state.update(cx, |select, cx| {
501 select.highlighted = next;
502 cx.notify();
503 });
504 cx.stop_propagation();
505 }
506 "enter" | "space" if !was_open => {
507 set_open(
508 &keyboard_state,
509 true,
510 &keyboard_items,
511 keyboard_open_change.as_ref(),
512 window,
513 cx,
514 );
515 cx.stop_propagation();
516 }
517 "enter" | "space" => {
518 let picked = current
519 .and_then(|index| keyboard_items.get(index))
520 .filter(|item| !item.disabled)
521 .map(|item| item.value.clone());
522 if let Some(picked) = picked {
523 keyboard_state.update(cx, |select, cx| {
524 select.value = Some(picked.clone());
525 cx.notify();
526 });
527 set_open(
528 &keyboard_state,
529 false,
530 &keyboard_items,
531 keyboard_open_change.as_ref(),
532 window,
533 cx,
534 );
535 if let Some(on_select) = &keyboard_on_select {
536 on_select(&picked, window, cx);
537 }
538 }
539 cx.stop_propagation();
540 }
541 "escape" if was_open => {
542 set_open(
543 &keyboard_state,
544 false,
545 &keyboard_items,
546 keyboard_open_change.as_ref(),
547 window,
548 cx,
549 );
550 cx.stop_propagation();
551 }
552 _ => {}
553 }
554 })
555 .child(trigger)
556 .when(open && !self.disabled, |el| el.child(popup))
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
565 fn menu_backdrop_is_opaque_and_matches_the_secondary_composite() {
566 let light = select_menu_bg(Theme::light());
567 let dark = select_menu_bg(Theme::dark());
568
569 assert_eq!(light, rgb(0xF4F5F7));
570 assert_eq!(dark, rgb(0x1C1D20));
571 assert_eq!(light.a, 1.0);
572 assert_eq!(dark.a, 1.0);
573 }
574}