1mod item;
7mod menu;
8mod state;
9
10use std::rc::Rc;
11
12use gpui_kit::{
13 AnyElement, App, ElementId, Entity, Hsla, InteractiveElement as _, IntoElement, KeyBinding,
14 Length, ParentElement as _, Pixels, RenderOnce, SharedString, StatefulInteractiveElement as _,
15 StyleRefinement, Styled, Window,
16 assets::IconName,
17 base::{
18 self, Align, Disableable, ElementExt as _, GlobalState, Placement, Positioner,
19 StyledExt as _, TestSupportExt as _,
20 actions::{Confirm, SelectDown, SelectFirst, SelectLast, SelectUp},
21 h_flex,
22 input::InputContextMenuCapabilities,
23 },
24 deferred, div, point,
25 prelude::FluentBuilder as _,
26 px,
27};
28
29pub use item::{SelectEntry, SelectItem, SelectValue};
30pub use menu::SelectRow;
31pub use state::{SearchHandler, SelectEvent, SelectState};
32
33use menu::{ItemRenderer, LabelRenderer, Menu, PartRenderer, Rows};
34
35use crate::{
36 ActiveTheme as _, ButtonSize, Icon, MenuEntry, Theme, ThemeTokens,
37 button::ControlGeometry,
38 menu::{MenuLook, MenuMotion, TextMenuBuilder, corner},
39 theme::mix,
40};
41
42const CONTEXT: &str = "GpuiCnSelect";
47
48pub(crate) fn init(cx: &mut App) {
49 cx.bind_keys([
50 KeyBinding::new(
51 "space",
52 Confirm { secondary: false },
53 Some("GpuiCnSelect && !Input"),
54 ),
55 KeyBinding::new("home", SelectFirst, Some(CONTEXT)),
56 KeyBinding::new("end", SelectLast, Some(CONTEXT)),
57 ]);
58}
59
60type ValueFormatter<V> = Rc<dyn Fn(&[SelectItem<V>]) -> SharedString>;
61type ValueRenderer<V> = Rc<dyn Fn(&[SelectItem<V>], &mut Window, &mut App) -> AnyElement>;
62
63#[derive(IntoElement)]
113pub struct Select<V: SelectValue> {
114 id: ElementId,
115 state: Entity<SelectState<V>>,
116 style: StyleRefinement,
117 size: ButtonSize,
118 placeholder: Option<SharedString>,
119 empty_text: SharedString,
120 accessibility_label: Option<SharedString>,
121 icon: Option<Icon>,
122 menu_width: Option<Length>,
123 align: Align,
124 format_value: Option<ValueFormatter<V>>,
125 render_value: Option<ValueRenderer<V>>,
126 render_item: Option<ItemRenderer<V>>,
127 render_label: Option<LabelRenderer>,
128 render_empty: Option<PartRenderer>,
129 render_loading: Option<PartRenderer>,
130 search_context_menu: Option<TextMenuBuilder>,
131 search_context_menu_enabled: bool,
132 disabled: bool,
133 tab_index: isize,
134 ghost: bool,
135 chevron: bool,
136}
137
138impl<V: SelectValue> Select<V> {
139 pub fn new(id: impl Into<ElementId>, state: &Entity<SelectState<V>>) -> Self {
141 Self {
142 id: id.into(),
143 state: state.clone(),
144 style: StyleRefinement::default(),
145 size: ButtonSize::Default,
146 placeholder: None,
147 empty_text: "No results".into(),
148 accessibility_label: None,
149 icon: None,
150 menu_width: None,
151 align: Align::End,
152 format_value: None,
153 render_value: None,
154 render_item: None,
155 render_label: None,
156 render_empty: None,
157 render_loading: None,
158 search_context_menu: None,
159 search_context_menu_enabled: true,
160 disabled: false,
161 tab_index: 0,
162 ghost: false,
163 chevron: true,
164 }
165 }
166
167 pub fn ghost(mut self) -> Self {
171 self.ghost = true;
172 self
173 }
174
175 pub fn chevron(mut self, chevron: bool) -> Self {
179 self.chevron = chevron;
180 self
181 }
182
183 pub fn size(mut self, size: ButtonSize) -> Self {
185 self.size = size;
186 self
187 }
188
189 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
192 self.placeholder = Some(placeholder.into());
193 self
194 }
195
196 pub fn empty_text(mut self, text: impl Into<SharedString>) -> Self {
200 self.empty_text = text.into();
201 self
202 }
203
204 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
207 self.accessibility_label = Some(label.into());
208 self
209 }
210
211 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
214 self.icon = Some(icon.into());
215 self
216 }
217
218 pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
223 self.menu_width = Some(width.into());
224 self
225 }
226
227 pub fn align(mut self, align: Align) -> Self {
230 self.align = align;
231 self
232 }
233
234 pub fn format_value(
238 mut self,
239 format: impl Fn(&[SelectItem<V>]) -> SharedString + 'static,
240 ) -> Self {
241 self.format_value = Some(Rc::new(format));
242 self
243 }
244
245 pub fn render_item<E: IntoElement>(
269 mut self,
270 render: impl Fn(&SelectItem<V>, SelectRow, &mut Window, &mut App) -> E + 'static,
271 ) -> Self {
272 self.render_item = Some(Rc::new(move |item, row, window, cx| {
273 render(item, row, window, cx).into_any_element()
274 }));
275 self
276 }
277
278 pub fn render_value<E: IntoElement>(
282 mut self,
283 render: impl Fn(&[SelectItem<V>], &mut Window, &mut App) -> E + 'static,
284 ) -> Self {
285 self.render_value = Some(Rc::new(move |items, window, cx| {
286 render(items, window, cx).into_any_element()
287 }));
288 self
289 }
290
291 pub fn render_label<E: IntoElement>(
293 mut self,
294 render: impl Fn(&SharedString, &mut Window, &mut App) -> E + 'static,
295 ) -> Self {
296 self.render_label = Some(Rc::new(move |text, window, cx| {
297 render(text, window, cx).into_any_element()
298 }));
299 self
300 }
301
302 pub fn render_empty<E: IntoElement>(
306 mut self,
307 render: impl Fn(&mut Window, &mut App) -> E + 'static,
308 ) -> Self {
309 self.render_empty = Some(Rc::new(move |window, cx| {
310 render(window, cx).into_any_element()
311 }));
312 self
313 }
314
315 pub fn render_loading<E: IntoElement>(
320 mut self,
321 render: impl Fn(&mut Window, &mut App) -> E + 'static,
322 ) -> Self {
323 self.render_loading = Some(Rc::new(move |window, cx| {
324 render(window, cx).into_any_element()
325 }));
326 self
327 }
328
329 pub fn search_context_menu(
335 mut self,
336 build: impl Fn(
337 Vec<MenuEntry>,
338 InputContextMenuCapabilities,
339 &mut Window,
340 &mut App,
341 ) -> Vec<MenuEntry>
342 + 'static,
343 ) -> Self {
344 self.search_context_menu = Some(Rc::new(build));
345 self
346 }
347
348 pub fn search_context_menu_enabled(mut self, enabled: bool) -> Self {
351 self.search_context_menu_enabled = enabled;
352 self
353 }
354
355 pub fn tab_index(mut self, tab_index: isize) -> Self {
358 self.tab_index = tab_index;
359 self
360 }
361}
362
363impl<V: SelectValue> Disableable for Select<V> {
364 fn disabled(mut self, disabled: bool) -> Self {
365 self.disabled = disabled;
366 self
367 }
368}
369
370impl<V: SelectValue> Styled for Select<V> {
371 fn style(&mut self) -> &mut StyleRefinement {
372 &mut self.style
373 }
374}
375
376struct TriggerLook {
378 fill: Hsla,
379 fill_hovered: Hsla,
380 ghost_hovered: Hsla,
381 ghost_open: Hsla,
382 border: Hsla,
383 foreground: Hsla,
384 placeholder: Hsla,
385 indicator: Hsla,
386 ring: Hsla,
387 ring_spread: Pixels,
388 geometry: ControlGeometry,
389 disabled_opacity: f32,
390 touch: bool,
391}
392
393impl TriggerLook {
394 fn of(size: ButtonSize, theme: &ThemeTokens) -> Self {
395 Self {
396 fill: theme.select_trigger,
397 fill_hovered: mix(theme.select_trigger, theme.popover_foreground, 0.05),
398 ghost_hovered: theme.secondary(),
399 ghost_open: theme.selected,
400 border: theme.select_trigger_border,
401 foreground: theme.popover_foreground,
402 placeholder: theme.muted_foreground(),
403 indicator: theme.select_indicator,
404 ring: theme.focus_ring(),
405 ring_spread: theme.metrics.focus_ring,
406 geometry: size.geometry(theme),
407 disabled_opacity: theme.disabled_opacity,
408 touch: theme.touch,
409 }
410 }
411}
412
413struct Value {
415 text: SharedString,
416 leading: Option<AnyElement>,
417 is_placeholder: bool,
418}
419
420impl<V: SelectValue> Select<V> {
421 fn value(&self, window: &mut Window, cx: &mut App) -> Value {
424 let items: Vec<SelectItem<V>> = self.state.read(cx).selected_items().to_vec();
425 let text = match &self.format_value {
426 Some(format) => Some(format(&items)),
427 None => match items.as_slice() {
428 [] => None,
429 [item] => Some(item.label().clone()),
430 many => Some(format!("{} selected", many.len()).into()),
431 },
432 };
433 let leading = match (&self.icon, items.as_slice()) {
434 (Some(icon), _) => Some(icon.clone().into_any_element()),
435 (None, [item]) => item.render_leading(window, cx),
436 _ => None,
437 };
438 match text {
439 Some(text) => Value {
440 text,
441 leading,
442 is_placeholder: false,
443 },
444 None => Value {
445 text: self.placeholder.clone().unwrap_or_default(),
446 leading,
447 is_placeholder: true,
448 },
449 }
450 }
451
452 fn trigger(
453 &self,
454 look: &TriggerLook,
455 value: Value,
456 open: bool,
457 pointer_cursors: bool,
458 window: &mut Window,
459 cx: &mut App,
460 ) -> AnyElement {
461 let state = &self.state;
462 let interactive = !self.disabled;
463 let hovered = window.use_keyed_state(
464 ElementId::NamedChild(self.id.clone().into(), "hovered".into()),
465 cx,
466 |_, _| false,
467 );
468 let is_hovered = *hovered.read(cx) && interactive && !look.touch;
469 let focus_visible =
470 state.read(cx).trigger_focus().is_focused(window) && window.last_input_was_keyboard();
471 let mut fill = match (self.ghost, open, is_hovered) {
472 (true, true, _) => look.ghost_open,
473 (true, false, true) => look.ghost_hovered,
474 (true, false, false) => gpui_kit::transparent_black(),
475 (false, false, false) => look.fill,
476 (false, _, _) => look.fill_hovered,
477 };
478 let mut border = if self.ghost {
479 gpui_kit::transparent_black()
480 } else {
481 look.border
482 };
483 let mut foreground = if value.is_placeholder {
484 look.placeholder
485 } else {
486 look.foreground
487 };
488 if self.disabled {
489 fill = fill.opacity(look.disabled_opacity);
490 border = border.opacity(look.disabled_opacity);
491 foreground = look.placeholder;
492 }
493 let trigger_bounds = state.read(cx).trigger_bounds().clone();
494 h_flex()
495 .id(ElementId::NamedChild(
496 self.id.clone().into(),
497 "trigger".into(),
498 ))
499 .test_support()
500 .flex_shrink_0()
501 .items_center()
502 .gap_1p5()
503 .h(look.geometry.height)
504 .px(look.geometry.padding)
505 .rounded(look.geometry.radius)
506 .bg(fill)
507 .border_1()
508 .border_color(border)
509 .text_size(look.geometry.text_size)
510 .text_color(foreground)
511 .whitespace_nowrap()
512 .relative()
513 .when(focus_visible, |this| {
514 this.shadow(vec![gpui_kit::BoxShadow {
515 color: look.ring,
516 offset: point(px(0.), px(0.)),
517 blur_radius: px(0.),
518 spread_radius: look.ring_spread,
519 inset: false,
520 }])
521 })
522 .map(|this| {
523 if interactive && pointer_cursors {
524 this.cursor_pointer()
525 } else {
526 this.cursor_default()
527 }
528 })
529 .map(|this| match &self.render_value {
530 Some(render) => {
531 let items: Vec<SelectItem<V>> = state.read(cx).selected_items().to_vec();
532 this.child(div().min_w_0().child(render(&items, window, cx)))
533 }
534 None => this
535 .when_some(value.leading, |this, leading| {
536 this.child(div().flex_shrink_0().child(leading))
537 })
538 .child(
539 div()
540 .min_w_0()
541 .overflow_hidden()
542 .text_ellipsis()
543 .child(value.text),
544 ),
545 })
546 .when(self.chevron, |this| {
547 this.child(
550 div().ml_auto().flex_none().child(
551 Icon::from(IconName::ChevronDown)
552 .size_4()
553 .text_color(look.indicator),
554 ),
555 )
556 })
557 .on_prepaint(move |bounds, _, _| trigger_bounds.set(bounds.dilate(px(1.))))
560 .on_hover({
561 let hovered = hovered.clone();
562 move |is_hovered, _, cx| {
563 hovered.update(cx, |state, cx| {
564 if *state != *is_hovered {
565 *state = *is_hovered;
566 cx.notify();
567 }
568 });
569 }
570 })
571 .when(interactive, |this| {
572 let state = state.clone();
573 this.on_mouse_down(gpui_kit::MouseButton::Left, move |_, window, cx| {
574 window.prevent_default();
578 cx.stop_propagation();
579 GlobalState::suppress_text_selection(cx);
580 state.update(cx, |state, cx| {
581 if state.is_open() != open {
585 return;
586 }
587 if open {
588 state.close(window, cx);
589 } else {
590 state.open(window, cx);
591 }
592 });
593 })
594 })
595 .into_any_element()
596 }
597
598 fn menu(
602 &self,
603 look: &MenuLook,
604 open: bool,
605 pointer_cursors: bool,
606 window: &mut Window,
607 cx: &mut App,
608 ) -> Option<AnyElement> {
609 let motion = MenuMotion::sample(
610 ElementId::NamedChild(self.id.clone().into(), "presence".into()),
611 open,
612 true,
613 look,
614 window,
615 cx,
616 )?;
617 let state = &self.state;
618 let (bounds, placement, content_focus) = {
619 let state = state.read(cx);
620 (
621 state.trigger_bounds().get(),
622 state.menu_placement().unwrap_or(Placement::Bottom),
623 state.content_focus().clone(),
624 )
625 };
626 let menu = Menu {
627 rows: Rows {
628 id: self.id.clone(),
629 state: state.clone(),
630 look: look.clone(),
631 render_item: self.render_item.clone(),
632 render_label: self.render_label.clone(),
633 pointer_cursors,
634 },
635 content_focus,
636 width: self.menu_width,
637 trigger_width: bounds.size.width,
638 empty_text: self.empty_text.clone(),
639 render_empty: self.render_empty.clone(),
640 render_loading: self.render_loading.clone(),
641 search_menu_builder: self.search_context_menu.clone(),
642 search_menu_enabled: self.search_context_menu_enabled,
643 motion,
644 }
645 .render(window, cx);
646 let (anchor, position) = corner(placement, self.align, bounds, look.gap);
650 Some(
651 deferred(Positioner::corner(anchor, position).occlude().child(menu))
652 .with_priority(base::POPUP_PRIORITY)
653 .into_any_element(),
654 )
655 }
656}
657
658impl<V: SelectValue> RenderOnce for Select<V> {
659 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
660 let pointer_cursors = Theme::global(cx).pointer_cursors;
661 let (trigger_look, menu_look) = {
662 let theme = cx.theme();
663 (
664 TriggerLook::of(self.size, theme),
665 MenuLook::of(theme, window.rem_size()),
666 )
667 };
668 let state = self.state.clone();
669 let (open, trigger_focus, content_focus) = {
670 let state = state.read(cx);
671 (
672 state.is_open(),
673 state.trigger_focus().clone(),
674 state.content_focus_handle(cx),
675 )
676 };
677 let interactive = !self.disabled;
678
679 let value = self.value(window, cx);
680 let accessibility_value = (!value.is_placeholder).then(|| value.text.clone());
681 let trigger = self.trigger(&trigger_look, value, open, pointer_cursors, window, cx);
682 let menu = self.menu(&menu_look, open, pointer_cursors, window, cx);
683
684 let trigger_focus = trigger_focus.tab_index(self.tab_index);
687 let root = base::Select::new(self.id.clone())
688 .open(open)
689 .disabled(self.disabled)
690 .focus_handle(&trigger_focus)
691 .content_focus_handle(&content_focus)
692 .when_some(self.accessibility_label, |this, label| {
693 this.accessibility_label(label)
694 })
695 .when_some(accessibility_value, |this, value| {
696 this.accessibility_value(value)
697 })
698 .on_open_change({
699 let state = state.clone();
700 move |next, window, cx| {
701 state.update(cx, |state, cx| {
702 if next {
703 state.open(window, cx);
704 } else {
705 state.close(window, cx);
706 }
707 });
708 }
709 })
710 .on_confirm({
711 let state = state.clone();
712 move |window, cx| {
713 state.update(cx, |state, cx| state.choose_highlighted(window, cx));
714 let state = state.clone();
718 window.defer(cx, move |window, cx| {
719 let (closed, trigger_focus) = {
720 let state = state.read(cx);
721 (!state.is_open(), state.trigger_focus().clone())
722 };
723 if closed {
724 trigger_focus.focus(window, cx);
725 }
726 });
727 }
728 })
729 .child(trigger)
730 .children(menu);
731
732 div()
736 .id(ElementId::NamedChild(self.id.clone().into(), "root".into()))
737 .key_context(CONTEXT)
738 .flex_shrink_0()
739 .refine_style(&self.style)
740 .when(interactive && open, |this| {
741 this.on_action({
742 let state = state.clone();
743 move |_: &SelectUp, _, cx| {
744 state.update(cx, |state, cx| state.move_highlight(-1, cx));
745 }
746 })
747 .on_action({
748 let state = state.clone();
749 move |_: &SelectDown, _, cx| {
750 state.update(cx, |state, cx| state.move_highlight(1, cx));
751 }
752 })
753 .on_action({
754 let state = state.clone();
755 move |_: &SelectFirst, _, cx| {
756 state.update(cx, |state, cx| state.highlight_first(cx));
757 }
758 })
759 .on_action({
760 let state = state.clone();
761 move |_: &SelectLast, _, cx| {
762 state.update(cx, |state, cx| state.highlight_last(cx));
763 }
764 })
765 })
766 .child(root)
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use crate::theme::{ThemeConfig, to_hex};
774 use gpui_kit::base::ThemeAppearance;
775
776 #[test]
777 fn the_trigger_reads_the_reference_values() {
778 let theme = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
779 let trigger = TriggerLook::of(ButtonSize::Default, &theme);
780 assert_eq!(to_hex(trigger.fill), "#292929");
781 assert_eq!(to_hex(trigger.border), "#3a3a3a");
782 assert_eq!(to_hex(trigger.foreground), "#ffffff");
783 assert_eq!(trigger.geometry.height, px(28.));
784 assert!(trigger.fill_hovered.l > trigger.fill.l);
785 }
786}