use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, }; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::common::{Sizable, StyledExt, h_flex}; use crate::menu::{Menu, MenuItem}; /// Reports the value the user chose. `None` means the select holds no value. pub type SelectHandler = Rc, &mut Window, &mut App)>; /// Select option for a [`Select`] control. #[derive(Clone)] pub struct SelectOption { value: T, label: SharedString, icon: Option, } impl SelectOption { /// Creates a choice showing `label`; picking it reports `value`. pub fn new(value: T, label: L) -> Self where L: Into, { Self { value, label: label.into(), icon: None, } } /// Sets a leading icon for the option's row. pub fn icon(mut self, role: IconRole) -> Self { self.icon = Some(role); self } } impl From<(T, L)> for SelectOption where L: Into, { fn from((value, label): (T, L)) -> Self { Self { value, label: label.into(), icon: None, } } } /// A control that shows the chosen value and opens a menu of the choices. #[derive(IntoElement)] pub struct Select where T: Clone + PartialEq + 'static, { id: ElementId, options: Vec>, selected: Option, placeholder: SharedString, a11y_label: Option, size: Size, style: StyleRefinement, on_change: Option>, } impl Select where T: Clone + PartialEq + 'static, { /// Creates a closed select holding no value. pub fn new(id: I) -> Self where I: Into, { Self { id: id.into(), options: Vec::new(), selected: None, placeholder: SharedString::from("Select..."), a11y_label: None, size: Size::default(), style: StyleRefinement::default(), on_change: None, } } /// Sets the choice. pub fn options(mut self, options: I) -> Self where I: IntoIterator, I::Item: Into>, { self.options = options.into_iter().map(Into::into).collect(); self } /// Sets the current value. `None` shows the placeholder. pub fn selected(mut self, selected: impl Into>) -> Self { self.selected = selected.into(); self } /// Sets the text shown while no value is chosen. pub fn placeholder(mut self, placeholder: L) -> Self where L: Into, { self.placeholder = placeholder.into(); self } /// Reports the chosen value, when the user activates an option. pub fn on_change(mut self, handler: F) -> Self where F: Fn(Option, &mut Window, &mut App) + 'static, { self.on_change = Some(Rc::new(handler)); self } /// Overrides the trigger's accessibility label. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } impl Sizable for Select where T: Clone + PartialEq + 'static, { fn apply_size(&mut self, size: Size) { self.size = size; } } impl gpui::Styled for Select where T: Clone + PartialEq + 'static, { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for Select where T: Clone + PartialEq + 'static, { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let metrics = theme.sizes.resolve(self.size); let border = theme.colors.border; let ring = theme.colors.ring; let input = theme.colors.input; let muted = theme.colors.muted; let foreground = theme.colors.foreground; let muted_foreground = theme.colors.muted_foreground; let radius = theme.radius.control; let icon_size = metrics.dense_icon_size; let label = self .a11y_label .clone() .unwrap_or_else(|| self.placeholder.clone()); let open = window.with_element_namespace(self.id.clone(), |window| { window .use_keyed_state("select-open", cx, |_, _| false) .clone() }); let is_open = *open.read(cx); let selected_index = self.selected.as_ref().and_then(|value| { self.options .iter() .position(|option| &option.value == value) }); let trigger_options = self.options.clone(); let trigger_selected = self.selected.clone(); let trigger_placeholder = self.placeholder.clone(); let trigger_id = self.id.clone(); let trigger_label = label.clone(); let trigger_style = self.style; let trigger = move |_window: &mut Window, _cx: &mut App| { let text = trigger_selected .as_ref() .and_then(|value| { trigger_options .iter() .find(|option| &option.value == value) .map(|option| option.label.clone()) }) .unwrap_or_else(|| trigger_placeholder.clone()); h_flex() .id((trigger_id.clone(), "select-trigger")) .role(Role::ComboBox) .aria_label(trigger_label.clone()) .cursor_pointer() .justify_between() .gap_2() .w_full() .h(metrics.input_height) .px(metrics.dense_padding_x) .rounded(radius) .border_1() .border_color(if is_open { ring } else { border }) .bg(input) .text_size(metrics.text_size) .text_color(if trigger_selected.is_some() { foreground } else { muted_foreground }) .hover(|style| style.bg(muted)) .child(text) .child( icon(IconRole::ChevronDown) .size(icon_size) .color(muted_foreground), ) .refine_style(&trigger_style) .into_any_element() }; let open_for_items = open.clone(); let items = move |_window: &mut Window, _cx: &mut App| { self.options .iter() .map(|option| { let is_selected = self.selected.as_ref() == Some(&option.value); let handler = { let open = open_for_items.clone(); let value = option.value.clone(); let on_change = self.on_change.clone(); move |window: &mut Window, cx: &mut App| { open.update(cx, |open, _| *open = false); if let Some(on_change) = &on_change { on_change(Some(value.clone()), window, cx); } } }; let mut row = MenuItem::new(option.label.clone()); if let Some(role) = option.icon { row = row.icon(role); } row.checked(is_selected).handler(handler) }) .collect() }; let menu = Menu::new(self.id) .open(is_open) .on_change(move |next, _window, cx| { open.update(cx, |open, _| *open = next); }) .trigger(trigger) .items(items); menu.when_some(selected_index, |menu, index| menu.initial_selection(index)) } }