use gpui::prelude::FluentBuilder;
use gpui::*;
use crate::theme::*;
use crate::components::basic::icon::{Icon, IconName};
use crate::components::form::select::{SelectOption, DropdownDirection, DropdownAlignment, DropdownWidth};
use crate::components::form::text_input::{TextInput, TextInputEvent};
#[derive(Clone, Debug)]
pub enum ComboboxEvent {
Changed(String),
InputChanged(String),
}
impl EventEmitter<ComboboxEvent> for Combobox {}
pub struct Combobox {
options: Vec<SelectOption>,
selected_value: Option<String>,
input_value: String,
placeholder: String,
is_open: bool,
disabled: bool,
size: ComponentSize,
dropdown_direction: DropdownDirection,
dropdown_alignment: DropdownAlignment,
dropdown_width: DropdownWidth,
show_border: bool,
show_shadow: bool,
clicking_menu: bool,
text_input: Option<Entity<TextInput>>,
is_user_typing: bool,
}
impl Combobox {
pub fn new(cx: &mut Context<Self>) -> Self {
let placeholder = "Search or select...".to_string();
let text_input = cx.new(|cx| {
TextInput::new(cx)
.placeholder(placeholder.clone())
.no_border()
.transparent()
});
Self {
options: Vec::new(),
selected_value: None,
input_value: String::new(),
placeholder: "Search or select...".to_string(),
is_open: false,
disabled: false,
size: ComponentSize::Medium,
dropdown_direction: DropdownDirection::Down,
dropdown_alignment: DropdownAlignment::Left,
dropdown_width: DropdownWidth::MatchTrigger,
show_border: true,
show_shadow: true,
clicking_menu: false,
text_input: Some(text_input),
is_user_typing: false,
}
}
pub fn options(mut self, options: Vec<SelectOption>) -> Self {
self.options = options;
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 {
let value_str = value.into();
self.selected_value = Some(value_str.clone());
if let Some(option) = self.options.iter().find(|opt| opt.value == value_str) {
self.input_value = option.label.clone();
}
self
}
pub fn input_value(mut self, value: impl Into<String>) -> Self {
self.input_value = value.into();
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 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 dropdown_width(mut self, width: DropdownWidth) -> Self {
self.dropdown_width = 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
}
fn filtered_options(&self) -> Vec<SelectOption> {
if !self.is_user_typing {
return self.options.clone();
}
if self.input_value.is_empty() {
return self.options.clone();
}
let input_lower = self.input_value.to_lowercase();
self.options
.iter()
.filter(|opt| {
opt.label.to_lowercase().contains(&input_lower) ||
opt.value.to_lowercase().contains(&input_lower)
})
.cloned()
.collect()
}
fn toggle_dropdown(&mut self) {
if !self.disabled {
self.is_open = !self.is_open;
if self.is_open {
self.is_user_typing = false;
}
}
}
fn close_dropdown(&mut self, cx: &mut Context<Self>) {
if self.clicking_menu {
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 let Some(option) = self.options.iter().find(|opt| opt.value == value) {
self.selected_value = Some(value.clone());
self.input_value = option.label.clone();
self.is_open = false;
self.is_user_typing = false;
if let Some(text_input) = &self.text_input {
text_input.update(cx, |input, cx| {
input.set_value(option.label.clone(), cx);
});
}
cx.emit(ComboboxEvent::Changed(value));
cx.notify();
}
}
fn render_dropdown_overlay(&self, cx: &Context<Self>) -> impl IntoElement {
let theme = Theme::default();
let filtered_options = self.filtered_options();
div()
.absolute()
.map(|this| match self.dropdown_direction {
DropdownDirection::Down | DropdownDirection::Auto => {
this.top_full().mt(px(0.)) }
DropdownDirection::Up => {
this.bottom_full().mb(px(0.))
}
})
.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(
div()
.occlude() .id("combobox-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_bl(px(BorderRadius::LG))
.rounded_br(px(BorderRadius::LG))
.border_1()
.border_color(theme.colors.border)
.border_t_0()
.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.))
.children(filtered_options.iter().enumerate().map(|(idx, option)| {
self.render_option(option, ("combobox-item", idx), &theme, cx)
}))
)
}
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 size = self.size;
let is_selected = if let Some(ref selected_value) = self.selected_value {
selected_value == &value && self.input_value == label
} else {
false
};
div()
.id(id)
.relative()
.flex()
.items_center()
.justify_between()
.w_full()
.px(px(12.))
.py(px(8.))
.cursor(CursorStyle::PointingHand)
.text_size(size.font_size())
.rounded(px(BorderRadius::SM))
.map(|this| {
if is_selected {
this.bg(theme.colors.primary)
.text_color(rgb(0xFFFFFF))
} else {
this.text_color(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(label)
.when(is_selected, |this| {
this.child(
div()
.text_xs()
.child("✓")
)
})
}
}
impl Render for Combobox {
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 text_input = self.text_input.clone();
if let Some(text_input_entity) = &text_input {
let _ = cx.subscribe_in(text_input_entity, window, |this: &mut Self, _input, event: &TextInputEvent, _window, cx| {
match event {
TextInputEvent::Change(value) => {
this.input_value = value.clone();
this.selected_value = None;
this.is_user_typing = true; if !this.is_open && !value.is_empty() {
this.is_open = true;
}
if value.is_empty() && this.is_open {
this.is_open = false;
}
cx.emit(ComboboxEvent::InputChanged(value.clone()));
cx.notify();
}
TextInputEvent::Focus => {
if !this.is_open && !this.disabled {
this.is_open = true;
this.is_user_typing = false;
cx.notify();
}
}
TextInputEvent::Blur => {
}
_ => {}
}
});
}
div()
.id("combobox-wrapper")
.w_full()
.when(is_open, |this| {
this.on_mouse_down_out(cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
this.close_dropdown(cx);
}))
})
.child(
div()
.id("combobox-container")
.relative()
.w_full()
.child(
div()
.id("combobox-trigger")
.relative()
.flex()
.w_full()
.items_center()
.gap_0()
.when(is_open, |this| {
this.rounded_tl(px(BorderRadius::LG))
.rounded_tr(px(BorderRadius::LG))
})
.when(!is_open, |this| {
this.rounded(px(BorderRadius::LG))
})
.when(self.show_border, |this| {
this.border_1()
.border_color(theme.colors.border)
.when(is_open, |this| {
this.border_b_0()
})
})
.bg(theme.colors.background)
.min_h(px(36.)) .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.opacity(0.64)
})
.child(
div()
.flex_1()
.overflow_hidden()
.when_some(text_input.clone(), |this, input| {
this.child(
div()
.w_full()
.h_full()
.bg(theme.colors.background)
.child(input.clone())
)
})
)
.child(
div()
.flex()
.items_center()
.justify_center()
.px(px(8.))
.flex_none()
.cursor(CursorStyle::PointingHand)
.on_mouse_down(MouseButton::Left, cx.listener(|this, _event: &MouseDownEvent, _window, cx| {
this.toggle_dropdown();
cx.notify();
}))
.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
})
)
}
}