use fluidattacks_designs_domain::icon::Style as IconStyle;
use fluidattacks_designs_domain::inputs::{Size, State, Validation, PLACEHOLDER};
use fluidattacks_designs_domain::navigation::{choosable, label_of, position_of, step, Step};
use fluidattacks_designs_domain::select::{
compare, grouped, header_at, matches, menu_height, Item, CHEVRON, CHEVRON_CLOSED,
CHEVRON_DISABLED, CHEVRON_GLYPH, CHEVRON_OPEN, EMPTY_MESSAGE, EMPTY_TEXT, GROUP_BORDER,
GROUP_HEIGHT, GROUP_MARGIN_Y, GROUP_TEXT, GROUP_WEIGHT, MENU_BG, MENU_BORDER, MENU_MIN_WIDTH,
MENU_OFFSET, MENU_PADDING_Y, MENU_RADIUS, MENU_SHADOW, OPTION_DISABLED_BG,
OPTION_DISABLED_TEXT, OPTION_GAP, OPTION_HEIGHT, OPTION_HOVER_BG, OPTION_LINE_HEIGHT,
OPTION_PADDING, OPTION_SELECTED_BG, OPTION_TEXT, OPTION_TEXT_SIZE, SELECTED_GLYPH,
SELECTED_ICON,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
anchored, canvas, deferred, div, point, px, rgb, uniform_list, App, AppContext as _, Context,
Div, Entity, FocusHandle, Focusable, FontWeight, InteractiveElement as _, IntoElement,
KeyBinding, MouseButton, ParentElement as _, Pixels, Point, RenderOnce, ScrollHandle,
SharedString, Stateful, StatefulInteractiveElement as _, Styled as _, UniformListScrollHandle,
Window,
};
use crate::icon::Icon;
use crate::text_input::{sized_field_frame, TextInputState};
use crate::theme::{ActiveTheme as _, Theme};
use crate::widget::{elevation, report, scrolling, text, ValueHandler};
const CONTEXT: &str = "DesignsSelect";
const SCROLLBAR: &str = "designs-select-scrollbar";
#[allow(
clippy::derive_partial_eq_without_eq,
reason = "the actions! expansion derives PartialEq without Eq"
)]
mod action_defs {
gpui::actions!(designs_select, [Next, Previous, Confirm, Dismiss]);
}
use action_defs::{Confirm, Dismiss, Next, Previous};
pub fn bind_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("down", Next, Some(CONTEXT)),
KeyBinding::new("up", Previous, Some(CONTEXT)),
KeyBinding::new("enter", Confirm, Some(CONTEXT)),
KeyBinding::new("escape", Dismiss, Some(CONTEXT)),
]);
}
#[derive(Clone, Debug)]
pub struct SelectOption {
label: SharedString,
value: SharedString,
group: Option<SharedString>,
disabled: bool,
}
impl SelectOption {
#[must_use]
pub fn new(label: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
value: value.into(),
group: None,
disabled: false,
}
}
#[must_use]
pub fn group(mut self, group: impl Into<SharedString>) -> Self {
self.group = Some(group.into());
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
fn as_item(&self) -> Item<'_> {
Item {
label: &self.label,
value: &self.value,
group: self.group.as_ref().map(text),
disabled: self.disabled,
}
}
}
pub struct SelectState {
field: Entity<TextInputState>,
options: Vec<SelectOption>,
shown: Vec<usize>,
value: Option<SharedString>,
highlight: Option<usize>,
open: bool,
empty_message: SharedString,
placeholder: SharedString,
scroll: UniformListScrollHandle,
group_scroll: ScrollHandle,
field_width: Option<Pixels>,
field_origin: Option<Point<Pixels>>,
anchor: Option<Point<Pixels>>,
}
impl SelectState {
pub fn new(
placeholder: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let prompt: SharedString = placeholder.into();
let shown = prompt.clone();
let field = cx.new(|inner| TextInputState::new(window, inner).placeholder(shown));
cx.observe(&field, |state, _, inner| state.field_changed(inner))
.detach();
let focus = field.read(cx).focus_handle(cx);
cx.on_focus(&focus, window, |state, _, inner| state.open_menu(inner))
.detach();
Self {
field,
options: Vec::new(),
shown: Vec::new(),
value: None,
highlight: None,
open: false,
empty_message: SharedString::new_static(EMPTY_MESSAGE),
placeholder: prompt,
scroll: UniformListScrollHandle::new(),
group_scroll: ScrollHandle::new(),
field_width: None,
field_origin: None,
anchor: None,
}
}
#[must_use]
pub fn empty_message(mut self, message: impl Into<SharedString>) -> Self {
self.empty_message = message.into();
self
}
pub fn set_options(&mut self, options: Vec<SelectOption>, cx: &mut Context<Self>) {
self.options = options;
self.recompute("");
self.sync_field(cx);
self.highlight = self.selected_position();
cx.notify();
}
pub fn set_value(&mut self, value: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
self.value = value.map(Into::into);
self.sync_field(cx);
cx.notify();
}
#[must_use]
pub const fn value(&self) -> Option<&SharedString> {
self.value.as_ref()
}
#[must_use]
pub const fn is_open(&self) -> bool {
self.open
}
fn take(&mut self, position: usize, cx: &mut Context<Self>) -> Option<SharedString> {
if !choosable(&self.shown_items(), position) {
return None;
}
let value = self
.shown
.get(position)
.and_then(|&index| self.options.get(index))
.map(|option| option.value.clone())?;
self.choose(value.clone(), cx);
Some(value)
}
fn choose(&mut self, value: SharedString, cx: &mut Context<Self>) {
self.value = Some(value);
self.close(cx);
}
fn open_menu(&mut self, cx: &mut Context<Self>) {
self.open = true;
self.highlight = self.selected_position();
self.anchor = self.field_origin;
cx.notify();
}
fn close(&mut self, cx: &mut Context<Self>) {
self.open = false;
self.highlight = None;
self.anchor = None;
self.recompute("");
self.sync_field(cx);
cx.notify();
}
fn drifted(&self) -> bool {
match (self.anchor, self.field_origin) {
(Some(anchor), Some(origin)) => anchor != origin,
_ => false,
}
}
fn walk(&mut self, direction: Step, cx: &mut Context<Self>) {
self.open = true;
if let Some(next) = step(&self.shown_items(), self.highlight, direction) {
self.highlight = Some(next);
self.scroll.scroll_to_item(next, gpui::ScrollStrategy::Top);
}
cx.notify();
}
fn query(&self, cx: &App) -> String {
let typed = self.field.read(cx).value();
if Some(typed) == self.selected_label() {
return String::new();
}
typed.to_owned()
}
fn field_changed(&mut self, cx: &mut Context<Self>) {
let query = self.query(cx);
if query.is_empty() {
self.recompute("");
self.highlight = self.selected_position();
cx.notify();
return;
}
self.open = true;
self.recompute(&query);
self.highlight = step(&self.shown_items(), None, Step::Next);
cx.notify();
}
fn sync_field(&self, cx: &mut Context<Self>) {
let label = self.selected_label().unwrap_or_default().to_owned();
self.field
.update(cx, |field, inner| field.set_value(label, inner));
}
fn selected_label(&self) -> Option<&str> {
label_of(&self.all_items(), self.value.as_ref().map(text)?)
}
fn selected_position(&self) -> Option<usize> {
position_of(&self.shown_items(), self.value.as_ref().map(text)?)
}
fn all_items(&self) -> Vec<Item<'_>> {
self.options.iter().map(SelectOption::as_item).collect()
}
fn header_count(&self) -> u16 {
let items = self.shown_items();
let opened = (0..items.len())
.filter(|&position| header_at(&items, position).is_some())
.count();
u16::try_from(opened).unwrap_or(u16::MAX)
}
fn shown_items(&self) -> Vec<Item<'_>> {
self.shown
.iter()
.filter_map(|&index| self.options.get(index))
.map(SelectOption::as_item)
.collect()
}
fn recompute(&mut self, query: &str) {
let shown = {
let items = self.all_items();
let has_groups = grouped(&items);
let mut order: Vec<usize> = (0..items.len()).collect();
order.sort_by(
|&first, &second| match (items.get(first), items.get(second)) {
(Some(left), Some(right)) => compare(left, right, has_groups),
_ => core::cmp::Ordering::Equal,
},
);
order
.into_iter()
.filter(|&index| items.get(index).is_some_and(|item| matches(item, query)))
.collect()
};
self.shown = shown;
}
}
impl Focusable for SelectState {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.field.read(cx).focus_handle(cx)
}
}
#[derive(IntoElement)]
pub struct Select {
state: Entity<SelectState>,
disabled: bool,
size: Size,
on_change: Option<ValueHandler>,
}
impl Select {
#[must_use]
pub fn new(state: &Entity<SelectState>) -> Self {
Self {
state: state.clone(),
disabled: false,
size: Size::default(),
on_change: None,
}
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub const fn size(mut self, size: Size) -> Self {
self.size = size;
self
}
#[must_use]
pub fn on_change(mut self, handler: impl Fn(&str, &mut App) + 'static) -> Self {
self.on_change = Some(std::rc::Rc::new(handler));
self
}
}
impl RenderOnce for Select {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
state,
disabled,
size,
on_change,
} = self;
let focused = state.read(cx).focus_handle(cx).is_focused(window);
let open = state.read(cx).open && !disabled;
let standing = State::resolve(focused, Validation::None, disabled);
let element = div()
.key_context(CONTEXT)
.relative()
.w_full()
.child(measure(&state))
.child(field_at(&state, standing, disabled, cx, size))
.when(open, |el| {
el.child(deferred(hung(menu(&state, on_change.as_ref(), cx), size)))
});
if disabled {
element
} else {
actions(element, &state, on_change)
}
}
}
fn actions(element: Div, state: &Entity<SelectState>, on_change: Option<ValueHandler>) -> Div {
let (walking, stepping, confirming, dismissing, outside) = (
state.clone(),
state.clone(),
state.clone(),
state.clone(),
state.clone(),
);
element
.on_action(move |_: &Next, _win, app| {
walking.update(app, |select, inner| select.walk(Step::Next, inner));
})
.on_action(move |_: &Previous, _win, app| {
stepping.update(app, |select, inner| select.walk(Step::Previous, inner));
})
.on_action(move |_: &Dismiss, _win, app| {
dismissing.update(app, SelectState::close);
})
.on_action(move |_: &Confirm, _win, app| {
let Some(position) = confirming.read(app).highlight else {
return;
};
let chosen = confirming.update(app, |select, inner| select.take(position, inner));
report(chosen, on_change.as_ref(), app);
})
.on_mouse_down_out(move |_, _win, app| {
outside.update(app, SelectState::close);
})
}
fn hung(menu: Div, size: Size) -> Div {
div().absolute().top_0().left_0().w_full().h(px(0.0)).child(
anchored()
.offset(point(px(0.0), px(size.height() + MENU_OFFSET)))
.child(menu),
)
}
fn measure(state: &Entity<SelectState>) -> impl IntoElement {
let measuring = state.clone();
canvas(
move |bounds, _win, app| {
measuring.update(app, |select, inner| {
select.field_width = Some(bounds.size.width);
select.field_origin = Some(bounds.origin);
if select.open && select.drifted() {
select.close(inner);
}
});
},
|_, (), _, _| {},
)
.absolute()
.size_full()
}
fn field_at(
state: &Entity<SelectState>,
standing: State,
disabled: bool,
cx: &App,
size: Size,
) -> Div {
let read = state.read(cx);
let theme = cx.theme();
let open = read.open && !disabled;
sized_field_frame(standing, size, theme)
.map(|el| {
if disabled {
el.cursor(gpui::CursorStyle::OperationNotAllowed)
.child(static_text(read, theme))
} else {
el.child(editable(state, read.field.clone()))
}
})
.child(chevron(state, open, disabled, theme))
}
fn editable(state: &Entity<SelectState>, field: Entity<TextInputState>) -> Stateful<Div> {
let opening = state.clone();
div()
.id("designs-select-field")
.flex_1()
.overflow_hidden()
.child(field)
.on_mouse_down(MouseButton::Left, move |_, _win, app| {
opening.update(app, SelectState::open_menu);
})
}
fn static_text(read: &SelectState, theme: &Theme) -> Div {
let chosen = read.selected_label().map(str::to_owned);
let empty = chosen.is_none();
div()
.flex_1()
.overflow_hidden()
.when(empty, |el| el.text_color(rgb(theme.shade(PLACEHOLDER))))
.child(chosen.map_or_else(
|| read.placeholder.clone(),
SharedString::from,
))
}
fn chevron(
state: &Entity<SelectState>,
open: bool,
disabled: bool,
theme: &Theme,
) -> Stateful<Div> {
let toggling = state.clone();
div()
.id("designs-select-chevron")
.flex()
.flex_none()
.items_center()
.child(
Icon::new(if open { CHEVRON_OPEN } else { CHEVRON_CLOSED })
.style(IconStyle::Light)
.size(CHEVRON_GLYPH)
.color(theme.shade(if disabled { CHEVRON_DISABLED } else { CHEVRON })),
)
.when(!disabled, |el| {
el.cursor_pointer().on_click(move |_, win, app| {
toggling.update(app, |select, inner| toggle(select, win, inner));
})
})
}
fn toggle(select: &mut SelectState, window: &mut Window, cx: &mut Context<SelectState>) {
if select.open {
select.close(cx);
return;
}
select.open_menu(cx);
select.focus_handle(cx).focus(window, cx);
}
fn menu(state: &Entity<SelectState>, on_change: Option<&ValueHandler>, cx: &App) -> Div {
let read = state.read(cx);
let theme = cx.theme();
let rows = read.shown.len();
let slots = u16::try_from(rows.max(1)).unwrap_or(u16::MAX);
div()
.w(read.field_width.unwrap_or(px(MENU_MIN_WIDTH)))
.h(px(menu_height(slots, read.header_count())))
.flex()
.flex_col()
.overflow_hidden()
.py(px(MENU_PADDING_Y))
.bg(rgb(theme.shade(MENU_BG)))
.border_1()
.border_color(rgb(theme.shade(MENU_BORDER)))
.rounded(px(MENU_RADIUS))
.shadow(elevation(MENU_SHADOW))
.occlude()
.font_family(FONT_PRIMARY)
.text_size(px(OPTION_TEXT_SIZE.px()))
.map(|el| {
if rows == 0 {
el.child(empty_row(read.empty_message.clone(), theme))
} else {
el.child(rows_slot(state, rows, on_change, cx))
}
})
}
fn rows_slot(
state: &Entity<SelectState>,
rows: usize,
on_change: Option<&ValueHandler>,
cx: &App,
) -> gpui::AnyElement {
let read = state.read(cx);
if grouped(&read.shown_items()) {
return scrolling(
SCROLLBAR,
div()
.id("designs-select-grouped-rows")
.size_full()
.flex()
.flex_col()
.overflow_y_scroll()
.track_scroll(&read.group_scroll)
.children((0..rows).map(|position| row(state, position, on_change, cx)))
.into_any_element(),
&read.group_scroll,
);
}
let listing = state.clone();
let handler = on_change.cloned();
let bar = read.scroll.0.borrow().base_handle.clone();
scrolling(
SCROLLBAR,
uniform_list("designs-select-rows", rows, move |range, _win, app| {
range
.map(|position| row(&listing, position, handler.as_ref(), app))
.collect::<Vec<_>>()
})
.track_scroll(&read.scroll)
.size_full()
.into_any_element(),
&bar,
)
}
fn row(
state: &Entity<SelectState>,
position: usize,
on_change: Option<&ValueHandler>,
cx: &App,
) -> gpui::AnyElement {
let read = state.read(cx);
let theme = cx.theme();
let items = read.shown_items();
let Some(item) = items.get(position) else {
return div().h(px(OPTION_HEIGHT)).into_any_element();
};
let chosen = read.value.as_ref().map(text) == Some(item.value);
let highlighted = read.highlight == Some(position);
let option = option_row(
state,
position,
(item, chosen, highlighted),
on_change,
theme,
);
match header_at(&items, position) {
Some(group) => div()
.flex()
.flex_col()
.w_full()
.child(group_header(SharedString::from(group.to_owned()), theme))
.child(option)
.into_any_element(),
None => option.into_any_element(),
}
}
fn option_row(
state: &Entity<SelectState>,
position: usize,
(item, chosen, highlighted): (&Item<'_>, bool, bool),
on_change: Option<&ValueHandler>,
theme: &Theme,
) -> Stateful<Div> {
let choosing = state.clone();
let handler = on_change.cloned();
let live = !item.disabled;
let picked = SharedString::from(item.value.to_owned());
div()
.id(("designs-select-option", position))
.flex()
.flex_none()
.w_full()
.items_center()
.justify_between()
.gap(px(OPTION_GAP))
.h(px(OPTION_HEIGHT))
.py(px(OPTION_PADDING.0))
.px(px(OPTION_PADDING.1))
.line_height(px(OPTION_LINE_HEIGHT))
.map(|el| paint(el, (live, chosen, highlighted), theme))
.child(div().overflow_hidden().child(item.label.to_owned()))
.when(chosen && live, |el| {
el.child(
Icon::new(SELECTED_ICON)
.style(IconStyle::Light)
.size(SELECTED_GLYPH),
)
})
.when(live, |el| {
el.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, _win, app| {
choosing.update(app, |select, inner| select.choose(picked.clone(), inner));
report(Some(picked.clone()), handler.as_ref(), app);
})
})
}
fn paint(
element: Stateful<Div>,
(live, chosen, highlighted): (bool, bool, bool),
theme: &Theme,
) -> Stateful<Div> {
if !live {
return element
.bg(rgb(theme.shade(OPTION_DISABLED_BG)))
.text_color(rgb(theme.shade(OPTION_DISABLED_TEXT)))
.cursor(gpui::CursorStyle::OperationNotAllowed);
}
let hover_bg = theme.shade(OPTION_HOVER_BG);
element
.text_color(rgb(theme.shade(OPTION_TEXT)))
.when(highlighted && !chosen, |el| el.bg(rgb(hover_bg)))
.when(chosen, |el| el.bg(rgb(theme.shade(OPTION_SELECTED_BG))))
.hover(move |style| style.bg(rgb(hover_bg)))
}
fn group_header(group: SharedString, theme: &Theme) -> Div {
div()
.flex()
.flex_none()
.w_full()
.items_center()
.h(px(GROUP_HEIGHT))
.pt(px(GROUP_MARGIN_Y))
.px(px(OPTION_PADDING.1))
.border_t_1()
.border_color(rgb(theme.shade(GROUP_BORDER)))
.line_height(px(OPTION_LINE_HEIGHT))
.text_color(rgb(theme.shade(GROUP_TEXT)))
.font_weight(FontWeight(f32::from(GROUP_WEIGHT.value())))
.child(group)
}
fn empty_row(message: SharedString, theme: &Theme) -> Div {
div()
.flex()
.flex_none()
.w_full()
.items_center()
.h(px(OPTION_HEIGHT))
.px(px(OPTION_PADDING.1))
.line_height(px(OPTION_LINE_HEIGHT))
.text_color(rgb(theme.shade(EMPTY_TEXT)))
.child(message)
}