use std::sync::Arc;
use gpui::{
Animation, AnimationExt, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
ParentElement, Pixels, Bounds, RenderOnce, SharedString, StatefulInteractiveElement, Styled, div,
prelude::FluentBuilder, px,
};
use crate::{
animation::constants::duration,
component::{
ArrowDirection, BoundsTrackerElement, ChangeCallback, ChangeWithEventCallback, IconName,
compute_input_style, create_internal_state, icon, use_internal_state,
},
i18n::{I18n, I18nContext, TextDirection, defaults::DefaultPlaceholders},
theme::ActiveTheme,
};
use crate::rtl;
fn desired_menu_left(
trigger_bounds: Bounds<Pixels>,
menu_width: Pixels,
direction: TextDirection,
window: &gpui::Window,
) -> Pixels {
let desired_left = if direction.is_rtl() {
trigger_bounds.right() - menu_width
} else {
trigger_bounds.left()
};
let window_bounds = window.bounds();
let min_left = window_bounds.left();
let max_left = (window_bounds.right() - menu_width).max(min_left);
desired_left.clamp(min_left, max_left)
}
use crate::animation::ease_out_quint_clamped;
pub fn select_option() -> SelectOption {
SelectOption::new()
}
#[derive(Clone, Debug)]
pub struct SelectOption {
pub value: Option<String>,
pub label: Option<SharedString>,
pub disabled: bool,
}
impl Default for SelectOption {
fn default() -> Self {
Self::new()
}
}
impl SelectOption {
pub fn new() -> Self {
Self {
value: None,
label: None,
disabled: false,
}
}
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
pub fn select(id: impl Into<ElementId>) -> Select {
Select::new().id(id)
}
#[derive(IntoElement)]
pub struct Select {
element_id: ElementId,
base: Div,
options: Vec<SelectOption>,
value: Option<String>,
placeholder: SharedString,
localized: bool,
disabled: bool,
bg: Option<Hsla>,
border: Option<Hsla>,
focus_border: Option<Hsla>,
text_color: Option<Hsla>,
height: Option<gpui::AbsoluteLength>,
menu_width: Option<gpui::Pixels>,
on_change: Option<ChangeCallback<String>>,
on_change_simple: Option<Arc<dyn Fn(String)>>,
on_change_with_event: Option<ChangeWithEventCallback<String>>,
}
impl Default for Select {
fn default() -> Self {
Self::new()
}
}
impl Select {
pub fn new() -> Self {
Self {
element_id: "ui:select".into(),
base: div(),
options: Vec::new(),
value: None,
placeholder: "Select…".into(),
localized: false,
disabled: false,
bg: None,
border: None,
focus_border: None,
text_color: None,
height: None,
menu_width: None,
on_change: None,
on_change_simple: None,
on_change_with_event: None,
}
}
pub fn localized(mut self) -> Self {
self.localized = true;
self
}
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.element_id = id.into();
self
}
pub fn key(self, key: impl Into<ElementId>) -> Self {
self.id(key)
}
pub fn option(mut self, option: SelectOption) -> Self {
self.options.push(option);
self
}
pub fn options(mut self, options: impl IntoIterator<Item = SelectOption>) -> Self {
self.options.extend(options);
self
}
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_change<F>(mut self, handler: F) -> Self
where
F: 'static + Fn(String, &mut gpui::Window, &mut gpui::App),
{
self.on_change = Some(Arc::new(handler));
self
}
pub fn on_change_simple<F>(mut self, handler: F) -> Self
where
F: 'static + Fn(String),
{
self.on_change_simple = Some(Arc::new(handler));
self
}
pub fn on_change_with_event<F>(mut self, handler: F) -> Self
where
F: 'static + Fn(String, &ClickEvent, &mut gpui::Window, &mut gpui::App),
{
self.on_change_with_event = Some(Arc::new(handler));
self
}
pub fn bg(mut self, color: impl Into<Hsla>) -> Self {
self.bg = Some(color.into());
self
}
pub fn border(mut self, color: impl Into<Hsla>) -> Self {
self.border = Some(color.into());
self
}
pub fn focus_border(mut self, color: impl Into<Hsla>) -> Self {
self.focus_border = Some(color.into());
self
}
pub fn text_color(mut self, color: impl Into<Hsla>) -> Self {
self.text_color = Some(color.into());
self
}
pub fn height(mut self, height: gpui::AbsoluteLength) -> Self {
self.height = Some(height);
self
}
pub fn menu_width(mut self, width: gpui::Pixels) -> Self {
self.menu_width = Some(width);
self
}
}
impl ParentElement for Select {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.base.extend(elements);
}
}
impl Styled for Select {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl InteractiveElement for Select {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Select {}
#[allow(clippy::too_many_arguments)]
fn call_on_change(
option_value: String,
on_change_with_event: Option<&ChangeWithEventCallback<String>>,
on_change: Option<&ChangeCallback<String>>,
on_change_simple: Option<&Arc<dyn Fn(String)>>,
ev: Option<&ClickEvent>,
window: &mut gpui::Window,
cx: &mut gpui::App,
) {
if let Some(handler) = on_change_with_event
&& let Some(ev) = ev
{
handler(option_value.clone(), ev, window, cx);
return;
}
if let Some(handler) = on_change {
handler(option_value.clone(), window, cx);
} else if let Some(handler) = on_change_simple {
handler(option_value);
}
}
impl RenderOnce for Select {
fn render(self, window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let disabled = self.disabled;
let height = self.height.unwrap_or_else(|| px(36.).into());
let menu_width = self.menu_width;
let options = self.options;
let localized = self.localized;
let placeholder = if localized {
DefaultPlaceholders::select_placeholder(cx.i18n().locale()).into()
} else {
self.placeholder
};
let on_change = self.on_change;
let on_change_simple = self.on_change_simple;
let on_change_with_event = self.on_change_with_event;
let id = self.element_id;
let trigger_bounds_state = window.use_keyed_state(
(id.clone(), "ui:select:trigger-bounds"),
cx,
|_, _| Bounds::default(),
);
let menu_open = window.use_keyed_state((id.clone(), "ui:select:open"), cx, |_, _| false);
let is_open = *menu_open.read(cx);
let has_on_change =
on_change.is_some() || on_change_simple.is_some() || on_change_with_event.is_some();
let use_internal = use_internal_state(self.value.is_some(), has_on_change);
let default_value = options
.first()
.and_then(|opt| opt.value.clone())
.unwrap_or_default();
let internal_value = create_internal_state(
window,
cx,
&id,
format!("{}:value", id),
default_value,
use_internal,
);
let value = if use_internal {
internal_value
.as_ref()
.expect("internal state should exist")
.read(cx)
.clone()
} else {
self.value
.clone()
.or_else(|| options.first().and_then(|opt| opt.value.clone()))
.unwrap_or_default()
};
let selected_label = options
.iter()
.find(|opt| opt.value.as_ref() == Some(&value))
.and_then(|opt| opt.label.clone());
let theme = cx.theme().clone();
let input_style = compute_input_style(
&theme,
disabled,
self.bg,
self.border,
self.focus_border,
self.text_color,
);
let hint = theme.content.tertiary;
let menu_open_for_button = menu_open.clone();
let menu_open_for_outside = menu_open.clone();
let menu_open_for_select = menu_open.clone();
let internal_value_for_select = internal_value.clone();
let on_change_for_select = on_change.clone();
let on_change_simple_for_select = on_change_simple.clone();
let on_change_with_event_for_select = on_change_with_event.clone();
let trigger_bounds_state_for_menu = trigger_bounds_state.clone();
let trigger = self
.base
.id(id.clone())
.relative()
.flex()
.items_center()
.justify_between()
.gap_2()
.h(height)
.px_3()
.rounded_md()
.bg(input_style.bg)
.border_1()
.border_color(input_style.border)
.text_color(input_style.text_color)
.focusable()
.focus_visible(|style| style.border_2().border_color(input_style.focus_border))
.when(disabled, |this| this.opacity(0.6).cursor_not_allowed())
.when(!disabled, |this| this.cursor_pointer())
.when(is_open, |this| this.bg(theme.surface.hover))
.on_click(move |_ev, _window, cx| {
if disabled {
return;
}
menu_open_for_button.update(cx, |open, _| *open = !*open);
})
.child(
div()
.flex_1()
.min_w(px(0.))
.truncate()
.text_color(
selected_label
.as_ref()
.map(|_| input_style.text_color)
.unwrap_or(hint),
)
.child(selected_label.unwrap_or(placeholder)),
)
.child(
icon(IconName::Arrow(ArrowDirection::Down))
.size(px(14.))
.color(hint),
)
.when(is_open, move |this| {
let options = options.clone();
let value = value.clone();
let on_change = on_change_for_select.clone();
let on_change_simple = on_change_simple_for_select.clone();
let on_change_with_event = on_change_with_event_for_select.clone();
let internal_value = internal_value_for_select.clone();
let text_color = input_style.text_color;
let direction = cx
.try_global::<I18n>()
.map(|i18n| i18n.text_direction())
.unwrap_or(TextDirection::Ltr);
let trigger_bounds = *trigger_bounds_state_for_menu.read(cx);
let menu_width_px = menu_width.unwrap_or_else(|| trigger_bounds.size.width);
let menu_left = desired_menu_left(trigger_bounds, menu_width_px, direction, window);
let relative_left = menu_left - trigger_bounds.left();
let menu = div()
.id((id.clone(), "select-menu"))
.absolute()
.top_full()
.left_0()
.when(relative_left != Pixels::ZERO, |this| this.left(relative_left))
.mt(px(10.))
.rounded_md()
.border_1()
.border_color(theme.border.default)
.bg(theme.surface.raised)
.shadow_md()
.py_1()
.w(menu_width_px)
.occlude()
.text_align(rtl::text_align_start(direction))
.on_mouse_down_out(move |_ev, _window, cx| {
menu_open_for_outside.update(cx, |open, _cx| *open = false);
})
.children(options.into_iter().map(move |opt| {
let is_selected = opt.value.as_ref() == Some(&value);
let is_disabled = disabled || opt.disabled;
let option_value =
opt.value.clone().expect("SelectOption value is required");
let menu_open_for_select = menu_open_for_select.clone();
let on_change = on_change.clone();
let on_change_simple = on_change_simple.clone();
let on_change_with_event = on_change_with_event.clone();
let internal_value = internal_value.clone();
let row_fg = if is_disabled {
theme.content.disabled
} else {
text_color
};
div()
.id((ElementId::from("ui:select:option"), option_value.clone()))
.px_3()
.py_2()
.flex()
.items_center()
.justify_between()
.gap_2()
.text_color(row_fg)
.when(!is_disabled, |this| {
this.cursor_pointer()
.hover(|this| this.bg(theme.surface.hover))
})
.when(is_disabled, |this| this.cursor_not_allowed().opacity(0.6))
.child(opt.label.expect("SelectOption label is required"))
.when(is_selected, |this| {
this.child(
icon(IconName::Check)
.size(px(12.))
.color(theme.action.primary.bg),
)
})
.on_click(move |ev, window, cx| {
if is_disabled {
return;
}
if let Some(internal_value) = &internal_value {
internal_value.update(cx, |state, _| {
*state = option_value.clone();
});
}
call_on_change(
option_value.clone(),
on_change_with_event.as_ref(),
on_change.as_ref(),
on_change_simple.as_ref(),
Some(ev),
window,
cx,
);
menu_open_for_select.update(cx, |open, _| *open = false);
})
}));
let animated_menu = menu.with_animation(
format!("select-menu-{}", is_open),
Animation::new(duration::MENU_OPEN).with_easing(ease_out_quint_clamped),
|this, value| this.opacity(value).mt(px(10.0 - 6.0 * value)),
);
this.child(gpui::deferred(animated_menu).with_priority(100))
});
let trigger = BoundsTrackerElement {
bounds_state: trigger_bounds_state,
inner: trigger.into_any_element(),
};
trigger
}
}