use gpui::prelude::FluentBuilder;
use gpui::*;
use crate::theme::*;
use crate::components::basic::icon::{Icon, IconName};
#[derive(Clone, Debug)]
pub enum SelectEvent {
Changed(String),
MultiChanged(Vec<String>),
}
impl EventEmitter<SelectEvent> for Select {}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum SelectVariant {
#[default]
Default,
Ghost,
Outline,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum DropdownDirection {
#[default]
Down,
Up,
Auto,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum DropdownAlignment {
#[default]
Left,
Right,
Center,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DropdownWidth {
MatchTrigger,
Fixed(Pixels),
MinWidth(Pixels),
MaxWidth(Pixels),
}
impl Default for DropdownWidth {
fn default() -> Self {
Self::MatchTrigger
}
}
#[derive(Clone, Debug)]
pub struct SelectOption {
pub value: String,
pub label: String,
}
impl SelectOption {
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
}
}
}
#[derive(Clone, Debug)]
pub struct SelectOptionGroup {
pub label: String,
pub options: Vec<SelectOption>,
}
impl SelectOptionGroup {
pub fn new(label: impl Into<String>, options: Vec<SelectOption>) -> Self {
Self {
label: label.into(),
options,
}
}
}
pub struct Select {
options: Vec<SelectOption>,
option_groups: Vec<SelectOptionGroup>,
selected_value: Option<String>,
selected_values: Vec<String>,
placeholder: String,
is_open: bool,
disabled: bool,
size: ComponentSize,
variant: SelectVariant,
dropdown_direction: DropdownDirection,
dropdown_alignment: DropdownAlignment,
dropdown_width: DropdownWidth,
custom_font_size: Option<Pixels>,
custom_bg_color: Option<Rgba>,
custom_text_color: Option<Rgba>,
custom_border_color: Option<Rgba>,
show_border: bool,
show_shadow: bool,
compact: bool,
multiple: bool,
clicking_menu: bool,
}
impl Select {
pub fn new(_cx: &mut Context<Self>) -> Self {
Self {
options: Vec::new(),
option_groups: Vec::new(),
selected_value: None,
selected_values: Vec::new(),
placeholder: "Select...".to_string(),
is_open: false,
disabled: false,
size: ComponentSize::Medium,
variant: SelectVariant::Default,
dropdown_direction: DropdownDirection::Down,
dropdown_alignment: DropdownAlignment::Left,
dropdown_width: DropdownWidth::MatchTrigger,
custom_font_size: None,
custom_bg_color: None,
custom_text_color: None,
custom_border_color: None,
show_border: true,
show_shadow: true,
compact: false,
multiple: false,
clicking_menu: false,
}
}
pub fn options(mut self, options: Vec<SelectOption>) -> Self {
self.options = options;
self
}
pub fn option_groups(mut self, groups: Vec<SelectOptionGroup>) -> Self {
self.option_groups = groups;
self
}
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn value(mut self, value: impl Into<String>) -> Self {
self.selected_value = Some(value.into());
self
}
pub fn values(mut self, values: Vec<String>) -> Self {
self.selected_values = values;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn size(mut self, size: ComponentSize) -> Self {
self.size = size;
self
}
pub fn font_size(mut self, size: Pixels) -> Self {
self.custom_font_size = Some(size);
self
}
pub fn bg_color(mut self, color: Rgba) -> Self {
self.custom_bg_color = Some(color);
self
}
pub fn text_color(mut self, color: Rgba) -> Self {
self.custom_text_color = Some(color);
self
}
pub fn border_color(mut self, color: Rgba) -> Self {
self.custom_border_color = Some(color);
self
}
pub fn variant(mut self, variant: SelectVariant) -> Self {
self.variant = variant;
self
}
pub fn dropdown_direction(mut self, direction: DropdownDirection) -> Self {
self.dropdown_direction = direction;
self
}
pub fn dropdown_alignment(mut self, alignment: DropdownAlignment) -> Self {
self.dropdown_alignment = alignment;
self
}
pub fn align_left(mut self) -> Self {
self.dropdown_alignment = DropdownAlignment::Left;
self
}
pub fn align_right(mut self) -> Self {
self.dropdown_alignment = DropdownAlignment::Right;
self
}
pub fn align_center(mut self) -> Self {
self.dropdown_alignment = DropdownAlignment::Center;
self
}
pub fn dropdown_width(mut self, width: DropdownWidth) -> Self {
self.dropdown_width = width;
self
}
pub fn fixed_width(mut self, width: Pixels) -> Self {
self.dropdown_width = DropdownWidth::Fixed(width);
self
}
pub fn min_width(mut self, width: Pixels) -> Self {
self.dropdown_width = DropdownWidth::MinWidth(width);
self
}
pub fn max_width(mut self, width: Pixels) -> Self {
self.dropdown_width = DropdownWidth::MaxWidth(width);
self
}
pub fn no_border(mut self) -> Self {
self.show_border = false;
self
}
pub fn no_shadow(mut self) -> Self {
self.show_shadow = false;
self
}
pub fn transparent(mut self) -> Self {
self.custom_bg_color = Some(rgba(0x00000000));
self
}
pub fn clean(mut self) -> Self {
self.show_border = false;
self.show_shadow = false;
self.custom_bg_color = Some(rgba(0x00000000));
self
}
pub fn compact(mut self) -> Self {
self.compact = true;
self
}
pub fn multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;
self
}
fn all_options(&self) -> Vec<SelectOption> {
let mut all = self.options.clone();
for group in &self.option_groups {
all.extend(group.options.clone());
}
all
}
fn display_text(&self) -> String {
if self.multiple {
if self.selected_values.is_empty() {
self.placeholder.clone()
} else {
format!("{} selected", self.selected_values.len())
}
} else if let Some(value) = &self.selected_value {
self.all_options()
.iter()
.find(|opt| &opt.value == value)
.map(|opt| opt.label.clone())
.unwrap_or_else(|| self.placeholder.clone())
} else {
self.placeholder.clone()
}
}
fn toggle_dropdown(&mut self) {
if !self.disabled {
self.is_open = !self.is_open;
}
}
fn close_dropdown(&mut self, cx: &mut Context<Self>) {
if self.clicking_menu && self.multiple {
self.clicking_menu = false;
return;
}
if self.is_open {
self.is_open = false;
cx.notify();
}
}
fn select_option(&mut self, value: String, cx: &mut Context<Self>) {
if self.multiple {
if let Some(pos) = self.selected_values.iter().position(|v| v == &value) {
self.selected_values.remove(pos);
} else {
self.selected_values.push(value);
}
cx.emit(SelectEvent::MultiChanged(self.selected_values.clone()));
} else {
self.selected_value = Some(value.clone());
self.is_open = false;
cx.emit(SelectEvent::Changed(value));
}
cx.notify();
}
fn remove_value(&mut self, value: String, cx: &mut Context<Self>) {
if let Some(pos) = self.selected_values.iter().position(|v| v == &value) {
self.selected_values.remove(pos);
cx.emit(SelectEvent::MultiChanged(self.selected_values.clone()));
cx.notify();
}
}
fn render_dropdown_overlay(&self, cx: &Context<Self>) -> impl IntoElement {
let theme = Theme::default();
div()
.absolute()
.map(|this| match self.dropdown_direction {
DropdownDirection::Down | DropdownDirection::Auto => {
this.top_full().mt_1()
}
DropdownDirection::Up => {
this.bottom_full().mb_1()
}
})
.map(|this| match self.dropdown_alignment {
DropdownAlignment::Left => {
this.left_0()
}
DropdownAlignment::Right => {
this.right_0()
}
DropdownAlignment::Center => {
this.left_0().right_0()
}
})
.occlude()
.on_mouse_down(MouseButton::Left, cx.listener(|this, _event: &MouseDownEvent, _window, _cx| {
this.clicking_menu = true;
}))
.child(self.render_dropdown_menu(&theme, cx))
}
fn render_dropdown_menu(&self, theme: &Theme, cx: &Context<Self>) -> impl IntoElement {
let has_groups = !self.option_groups.is_empty();
let mut menu = div()
.occlude()
.id("select-popup")
.map(|this| match self.dropdown_width {
DropdownWidth::MatchTrigger => {
this
}
DropdownWidth::Fixed(width) => {
this.w(width)
}
DropdownWidth::MinWidth(width) => {
this.min_w(width)
}
DropdownWidth::MaxWidth(width) => {
this.max_w(width)
}
})
.when(matches!(self.dropdown_width, DropdownWidth::MatchTrigger), |this| {
this.min_w(px(180.)) })
.max_h(px(300.))
.overflow_y_scroll()
.rounded(px(BorderRadius::LG))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.background)
.when(self.show_shadow, |this| {
this.shadow(vec![
BoxShadow {
color: rgba(0x00000010).into(),
offset: point(px(0.), px(4.)),
blur_radius: px(16.),
spread_radius: px(-2.),
},
BoxShadow {
color: rgba(0x00000008).into(),
offset: point(px(0.), px(2.)),
blur_radius: px(8.),
spread_radius: px(0.),
},
])
})
.p(px(6.));
if has_groups {
let mut item_counter: usize = 0;
menu = menu.children(self.option_groups.iter().enumerate().map(|(group_idx, group)| {
div()
.flex()
.flex_col()
.map(|this| {
if group_idx > 0 {
this.mt(if self.compact { px(2.) } else { px(4.) })
} else {
this
}
})
.child({
let label_py = if self.compact { px(4.) } else { px(8.) };
let label_px = if self.compact { px(8.) } else { px(12.) };
div()
.px(label_px)
.py(label_py)
.text_sm()
.font_weight(FontWeight::BOLD)
.text_color(theme.colors.text)
.child(group.label.clone())
})
.children(group.options.iter().map(|option| {
let id = ("select-group-item", item_counter);
item_counter += 1;
div()
.pl(px(8.))
.child(self.render_option(option, id, theme, cx))
}))
.child(
div()
.h(px(1.))
.bg(theme.colors.border)
.mx(px(12.))
.mt(if self.compact { px(1.) } else { px(2.) })
)
}));
} else {
menu = menu.children(self.options.iter().enumerate().map(|(idx, option)| {
self.render_option(option, ("select-item", idx), theme, cx)
}));
}
menu
}
fn render_option(&self, option: &SelectOption, id: impl Into<ElementId>, theme: &Theme, cx: &Context<Self>) -> impl IntoElement {
let value = option.value.clone();
let label = option.label.clone();
let multiple = self.multiple;
let size = self.size;
let is_selected = if multiple {
self.selected_values.contains(&value)
} else {
self.selected_value.as_ref() == Some(&value)
};
let padding_y = if self.compact { px(3.) } else { px(8.) };
let padding_x = if self.compact { px(8.) } else { px(12.) };
div()
.id(id)
.relative()
.flex()
.items_center()
.justify_between()
.w_full()
.px(padding_x)
.py(padding_y)
.cursor(CursorStyle::PointingHand)
.text_size(self.custom_font_size.unwrap_or(size.font_size()))
.rounded(px(BorderRadius::SM))
.map(|this| {
if is_selected && !multiple {
this.bg(theme.colors.primary)
.text_color(rgb(0xFFFFFF))
} else {
this.text_color(self.custom_text_color.unwrap_or(theme.colors.text))
.hover(|style| style.bg(theme.colors.background_hover))
}
})
.on_mouse_down(MouseButton::Left, cx.listener(move |this, _event: &MouseDownEvent, _window, cx| {
this.select_option(value.clone(), cx);
}))
.child(
div()
.flex()
.items_center()
.gap_2()
.when(multiple, |this| {
this.child(self.render_checkbox(is_selected, theme))
})
.child(label)
)
.when(is_selected && !multiple, |this| {
this.child(
div()
.text_xs()
.child("✓")
)
})
}
fn render_checkbox(&self, checked: bool, theme: &Theme) -> impl IntoElement {
div()
.flex()
.items_center()
.justify_center()
.w(px(16.))
.h(px(16.))
.rounded(px(4.))
.border_1()
.border_color(if checked { theme.colors.primary } else { theme.colors.border })
.bg(if checked { theme.colors.primary } else { rgb(0xFFFFFF) })
.when(checked, |this| {
this.child(
div()
.text_xs()
.text_color(rgb(0xFFFFFF))
.child("✓")
)
})
}
fn render_selected_tags(&self, theme: &Theme, cx: &Context<Self>) -> Vec<impl IntoElement> {
let all_options = self.all_options();
self.selected_values.iter().map(|value| {
let label = all_options
.iter()
.find(|opt| &opt.value == value)
.map(|opt| opt.label.clone())
.unwrap_or_else(|| value.clone());
let value_for_remove = value.clone();
div()
.flex()
.items_center()
.gap_1()
.px(px(8.))
.py(px(4.))
.rounded(px(6.))
.bg(theme.colors.primary)
.text_color(rgb(0xFFFFFF))
.text_xs()
.child(label)
.child(
div()
.flex()
.items_center()
.justify_center()
.w(px(14.))
.h(px(14.))
.rounded(px(7.))
.cursor(CursorStyle::PointingHand)
.hover(|style| style.bg(rgba(0xFFFFFF20)))
.on_mouse_down(MouseButton::Left, cx.listener(move |this, _event: &MouseDownEvent, _window, cx| {
this.remove_value(value_for_remove.clone(), cx);
}))
.child(
div()
.text_xs()
.child("×")
)
)
}).collect()
}
}
impl Render for Select {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::default();
let disabled = self.disabled;
let is_open = self.is_open;
let multiple = self.multiple;
let (padding_y, padding_x) = self.size.padding();
let is_placeholder = if multiple {
self.selected_values.is_empty()
} else {
self.selected_value.is_none()
};
div()
.id("select-wrapper")
.w_full()
.when(is_open && !multiple, |this| {
this.on_mouse_down_out(cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
this.close_dropdown(cx);
}))
})
.child(
div()
.id("select-container")
.relative()
.w_full()
.child(
div()
.id("select-trigger")
.relative()
.flex()
.w_full()
.items_center()
.justify_between()
.gap_2()
.py(padding_y)
.px(padding_x)
.rounded(px(BorderRadius::LG))
.when(self.show_border && self.variant != SelectVariant::Ghost, |this| {
this.border_1()
.border_color(self.custom_border_color.unwrap_or(theme.colors.border))
})
.map(|this| match self.variant {
SelectVariant::Default => {
this.bg(self.custom_bg_color.unwrap_or(theme.colors.background))
}
SelectVariant::Ghost => {
this.bg(self.custom_bg_color.unwrap_or(rgba(0x00000000)))
}
SelectVariant::Outline => {
this.bg(self.custom_bg_color.unwrap_or(rgba(0x00000000)))
.border_1()
.border_color(self.custom_border_color.unwrap_or(theme.colors.border))
}
})
.text_size(self.custom_font_size.unwrap_or(self.size.font_size()))
.when(self.show_shadow, |this| {
this.shadow(vec![BoxShadow {
color: rgba(0x0000000A).into(),
offset: point(px(0.), px(1.)),
blur_radius: px(2.),
spread_radius: px(0.),
}])
})
.when(!disabled, |this| {
this.cursor(CursorStyle::PointingHand)
})
.when(disabled, |this| {
this.opacity(0.64)
})
.on_mouse_down(MouseButton::Left, cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
this.toggle_dropdown();
cx.notify();
}))
.child(
div()
.flex()
.flex_1()
.items_center()
.gap_1()
.overflow_hidden()
.when(multiple && !self.selected_values.is_empty(), |this| {
this.children(self.render_selected_tags(&theme, cx))
})
.when(!multiple || self.selected_values.is_empty(), |this| {
this.child(
div()
.when(is_placeholder, |this| {
this.text_color(self.custom_text_color.unwrap_or(theme.colors.text_secondary))
})
.when(!is_placeholder, |this| {
this.text_color(self.custom_text_color.unwrap_or(theme.colors.text))
})
.child(self.display_text())
)
})
)
.child(
Icon::new(IconName::ChevronUpDown)
.small()
.color(rgb(0x666666))
)
)
.children(if is_open && !disabled {
Some(deferred(
self.render_dropdown_overlay(cx).into_any_element()
))
} else {
None
})
)
}
}