use fluidattacks_designs_domain::container::Scroll;
use fluidattacks_designs_domain::icon::Style as IconStyle;
use fluidattacks_designs_domain::list::{
right_glyph, row_bg, row_text, Item, SelectionMode, BG, BORDER, LEFT_GLYPH, MIN_WIDTH,
PADDING_Y, RADIUS, RIGHT_GLYPH, ROW_GAP, ROW_LINE_HEIGHT, ROW_MIN_HEIGHT, ROW_PADDING,
ROW_TEXT_SIZE, ROW_WEIGHT, SHADOW,
};
use fluidattacks_designs_domain::navigation::{choosable, label_of, position_of, step, Step};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, rgb, App, Context, Div, Entity, FocusHandle, Focusable, FontWeight,
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement as _, RenderOnce,
ScrollHandle, SharedString, Stateful, Styled as _, Window,
};
use crate::container::Container;
use crate::empty_state::EmptyState;
use crate::icon::Icon;
use crate::theme::{ActiveTheme as _, Theme};
use crate::widget::{elevation, report, text, ValueHandler};
const CONTEXT: &str = "DesignsList";
#[allow(
clippy::derive_partial_eq_without_eq,
reason = "the actions! expansion derives PartialEq without Eq"
)]
mod action_defs {
gpui::actions!(designs_list, [Next, Previous, Confirm]);
}
use action_defs::{Confirm, 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)),
]);
}
#[derive(Clone, Debug)]
pub struct ListItem {
label: SharedString,
value: SharedString,
icon: Option<SharedString>,
right_icon: Option<SharedString>,
disabled: bool,
}
impl ListItem {
#[must_use]
pub fn new(label: impl Into<SharedString>, value: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
value: value.into(),
icon: None,
right_icon: None,
disabled: false,
}
}
#[must_use]
pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
self.icon = Some(icon.into());
self
}
#[must_use]
pub fn right_icon(mut self, icon: impl Into<SharedString>) -> Self {
self.right_icon = Some(icon.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,
icon: self.icon.as_ref().map(text),
right_icon: self.right_icon.as_ref().map(text),
disabled: self.disabled,
}
}
}
pub struct ListState {
items: Vec<ListItem>,
value: Option<SharedString>,
selection: SelectionMode,
focus: FocusHandle,
scroll: ScrollHandle,
}
impl ListState {
#[must_use]
pub fn new(cx: &Context<Self>) -> Self {
Self {
items: Vec::new(),
value: None,
selection: SelectionMode::default(),
focus: cx.focus_handle(),
scroll: ScrollHandle::new(),
}
}
#[must_use]
pub const fn selection(mut self, selection: SelectionMode) -> Self {
self.selection = selection;
self
}
pub fn set_items(&mut self, items: Vec<ListItem>, cx: &mut Context<Self>) {
self.items = items;
if self.position().is_none() {
self.value = None;
}
cx.notify();
}
pub fn set_value(&mut self, value: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
self.value = value.map(Into::into).filter(|_| self.selection.choosable());
if self.position().is_none() {
self.value = None;
}
cx.notify();
}
#[must_use]
pub const fn value(&self) -> Option<&SharedString> {
self.value.as_ref()
}
#[must_use]
pub fn label(&self) -> Option<&str> {
label_of(&self.rows(), self.value.as_ref().map(text)?)
}
fn rows(&self) -> Vec<Item<'_>> {
self.items.iter().map(ListItem::as_item).collect()
}
fn position(&self) -> Option<usize> {
position_of(&self.rows(), self.value.as_ref().map(text)?)
}
fn take(&mut self, value: &SharedString, cx: &mut Context<Self>) -> Option<SharedString> {
if !self.selection.choosable() {
return None;
}
let rows = self.rows();
let position = position_of(&rows, value)?;
if !choosable(&rows, position) {
return None;
}
drop(rows);
self.value = Some(value.clone());
cx.notify();
Some(value.clone())
}
fn walk(&mut self, direction: Step, cx: &mut Context<Self>) -> Option<SharedString> {
if !self.selection.choosable() {
return None;
}
let next = step(&self.rows(), self.position(), direction)?;
let value = self.items.get(next)?.value.clone();
self.scroll.scroll_to_item(next);
self.take(&value, cx)
}
}
impl Focusable for ListState {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus.clone()
}
}
#[derive(IntoElement)]
pub struct List {
state: Entity<ListState>,
on_change: Option<ValueHandler>,
on_right_icon: Option<ValueHandler>,
empty: Option<EmptyState>,
max_height: Option<f32>,
}
impl List {
#[must_use]
pub fn new(state: &Entity<ListState>) -> Self {
Self {
state: state.clone(),
on_change: None,
on_right_icon: None,
empty: None,
max_height: None,
}
}
#[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
}
#[must_use]
pub fn on_right_icon(mut self, handler: impl Fn(&str, &mut App) + 'static) -> Self {
self.on_right_icon = Some(std::rc::Rc::new(handler));
self
}
#[must_use]
pub fn empty(mut self, empty: EmptyState) -> Self {
self.empty = Some(empty);
self
}
#[must_use]
pub const fn max_height(mut self, height: f32) -> Self {
self.max_height = Some(height);
self
}
}
impl RenderOnce for List {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
state,
on_change,
on_right_icon,
empty,
max_height,
} = self;
let read = state.read(cx);
let count = read.items.len();
let card = card(cx.theme())
.key_context(CONTEXT)
.tab_index(0)
.track_focus(&read.focus_handle(cx))
.when_some(max_height, |el, height| el.max_h(px(height)));
actions(card, &state, on_change.as_ref()).map(|el| {
if count == 0 {
el.child(
empty.map_or_else(|| div().into_any_element(), IntoElement::into_any_element),
)
} else {
el.child(rows(
&state,
(on_change.as_ref(), on_right_icon.as_ref()),
cx,
))
}
})
}
}
fn card(theme: &Theme) -> Div {
div()
.flex()
.flex_col()
.w_full()
.min_w(px(MIN_WIDTH))
.py(px(PADDING_Y))
.bg(rgb(theme.shade(BG)))
.border_1()
.border_color(rgb(theme.shade(BORDER)))
.rounded(px(RADIUS))
.shadow(elevation(SHADOW))
.font_family(FONT_PRIMARY)
.text_size(px(ROW_TEXT_SIZE.px()))
.font_weight(FontWeight(f32::from(ROW_WEIGHT.value())))
}
fn actions(element: Div, state: &Entity<ListState>, on_change: Option<&ValueHandler>) -> Div {
let (walking, stepping, confirming) = (state.clone(), state.clone(), state.clone());
let (down, up, enter) = (on_change.cloned(), on_change.cloned(), on_change.cloned());
element
.on_action(move |_: &Next, _win, app| {
let chosen = walking.update(app, |list, inner| list.walk(Step::Next, inner));
report(chosen, down.as_ref(), app);
})
.on_action(move |_: &Previous, _win, app| {
let chosen = stepping.update(app, |list, inner| list.walk(Step::Previous, inner));
report(chosen, up.as_ref(), app);
})
.on_action(move |_: &Confirm, _win, app| {
let Some(value) = confirming.read(app).value().cloned() else {
return;
};
let chosen = confirming.update(app, |list, inner| list.take(&value, inner));
report(chosen, enter.as_ref(), app);
})
}
fn rows(
state: &Entity<ListState>,
handlers: (Option<&ValueHandler>, Option<&ValueHandler>),
cx: &App,
) -> gpui::AnyElement {
let read = state.read(cx);
let theme = cx.theme();
let items = read.rows();
let standing = (read.position(), read.selection.choosable());
Container::new()
.scroll("designs-list-rows", Scroll::Y, &read.scroll)
.flex_grow(1.0)
.min_h_0()
.children(
items
.iter()
.enumerate()
.map(|(position, item)| row(state, (position, item), standing, (handlers, theme))),
)
.into_any_element()
}
fn row(
state: &Entity<ListState>,
(position, item): (usize, &Item<'_>),
(current, answering): (Option<usize>, bool),
((on_change, on_right_icon), theme): ((Option<&ValueHandler>, Option<&ValueHandler>), &Theme),
) -> gpui::AnyElement {
let chosen = current == Some(position);
let live = answering && !item.disabled;
let value = SharedString::from(item.value.to_owned());
div()
.id(("designs-list-row", position))
.flex()
.flex_none()
.items_center()
.justify_between()
.gap(px(ROW_GAP))
.min_h(px(ROW_MIN_HEIGHT))
.py(px(ROW_PADDING.0))
.px(px(ROW_PADDING.1))
.line_height(px(ROW_LINE_HEIGHT))
.map(|el| paint(el, (item.disabled, chosen, live), theme))
.child(label(item, theme))
.children(far_glyph(
item,
(position, chosen, answering),
&value,
(on_right_icon, theme),
))
.when(live, |el| {
let choosing = state.clone();
let handler = on_change.cloned();
el.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, window, app| {
choosing.read(app).focus_handle(app).focus(window, app);
let picked = choosing.update(app, |list, inner| list.take(&value, inner));
report(picked, handler.as_ref(), app);
})
})
.into_any_element()
}
fn label(item: &Item<'_>, theme: &Theme) -> Div {
div()
.flex()
.items_center()
.gap(px(ROW_GAP))
.min_w_0()
.overflow_hidden()
.children(item.icon.map(|glyph| {
Icon::new(glyph.to_owned())
.style(IconStyle::Light)
.size(LEFT_GLYPH)
.color(theme.shade(row_text(item.disabled)))
}))
.child(item.label.to_owned())
}
fn far_glyph(
item: &Item<'_>,
(position, chosen, answering): (usize, bool, bool),
value: &SharedString,
(on_right_icon, theme): (Option<&ValueHandler>, &Theme),
) -> Option<Stateful<Div>> {
let glyph = right_glyph(item, chosen);
if glyph.is_none() && !answering {
return None;
}
let handler = on_right_icon.cloned();
let clicked = value.clone();
Some(
div()
.id(("designs-list-row-icon", position))
.flex()
.flex_none()
.w(px(RIGHT_GLYPH.px()))
.items_center()
.children(glyph.map(|name| {
Icon::new(name.to_owned())
.style(IconStyle::Light)
.size(RIGHT_GLYPH)
.color(theme.shade(row_text(item.disabled)))
}))
.when(
handler.is_some() && !item.disabled && glyph.is_some(),
|el| {
el.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, _win, app| {
app.stop_propagation();
if let Some(handler) = handler.as_ref() {
handler(&clicked, app);
}
})
},
),
)
}
fn paint(
element: Stateful<Div>,
(disabled, chosen, live): (bool, bool, bool),
theme: &Theme,
) -> Stateful<Div> {
let element = element
.text_color(rgb(theme.shade(row_text(disabled))))
.when_some(row_bg(chosen, false), |el, bg| el.bg(rgb(theme.shade(bg))));
if !live {
return element.when(disabled, |el| {
el.cursor(gpui::CursorStyle::OperationNotAllowed)
});
}
element.when_some(row_bg(chosen, true), |el, bg| {
let hovered = rgb(theme.shade(bg));
el.hover(move |style| style.bg(hovered))
})
}