use std::rc::Rc;
use fluidattacks_designs_domain::icon::Style as IconStyle;
use fluidattacks_designs_domain::inputs::{LINE_HEIGHT, TEXT_SIZE};
use fluidattacks_designs_domain::search;
use fluidattacks_designs_domain::search_dropdown::{
visible, BAR_BG, BAR_BORDER, BAR_BORDER_WIDTH, BAR_FOCUS_BORDER, BAR_FOCUS_BORDER_WIDTH,
BAR_GAP, BAR_HEIGHT, BAR_HOVER_BORDER, BAR_PADDING, BAR_RADIUS, CLEAR_COLOR, CLEAR_ICON,
CLEAR_PADDING, CLEAR_SIZE, EMPTY_MESSAGE, EMPTY_PADDING, EMPTY_TEXT, GLYPH_COLOR, GLYPH_SIZE,
LIST_BG, LIST_BORDER, LIST_MAX_HEIGHT, LIST_OFFSET, LIST_PADDING_Y, LIST_RADIUS, LIST_SHADOW,
OPEN_ON_FOCUS, ROW_HOVER_BG, ROW_PADDING,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
deferred, div, px, rgb, AnyElement, App, AppContext as _, Context, Div, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, ParentElement as _,
RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Window,
};
use crate::icon::Icon;
use crate::text_input::TextInputState;
use crate::theme::{ActiveTheme as _, Theme};
use crate::widget::{elevation, ghost_icon_button, scrolling};
const CONTEXT: &str = "DesignsSearchDropdown";
#[allow(clippy::derive_partial_eq_without_eq)]
mod action_defs {
gpui::actions!(designs_search_dropdown, [Dismiss]);
}
use action_defs::Dismiss;
pub fn bind_keys(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Dismiss, Some(CONTEXT))]);
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SearchDropdownEvent<T> {
Query(SharedString),
Chosen(T),
Cleared,
}
type RenderItem<T> = Rc<dyn Fn(&T, &App) -> AnyElement>;
pub struct SearchDropdownState<T> {
field: Entity<TextInputState>,
results: Vec<T>,
render_item: RenderItem<T>,
open: bool,
open_on_focus: bool,
empty_text: SharedString,
query: SharedString,
refocusing: bool,
scroll: ScrollHandle,
}
impl<T: Clone + 'static> SearchDropdownState<T> {
pub fn new(
render_item: impl Fn(&T, &App) -> AnyElement + 'static,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let field =
cx.new(|inner| TextInputState::new(window, inner).max_length(search::MAX_LENGTH));
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.focused(inner))
.detach();
Self {
field,
results: Vec::new(),
render_item: Rc::new(render_item),
open: false,
open_on_focus: OPEN_ON_FOCUS,
empty_text: SharedString::new_static(EMPTY_MESSAGE),
query: SharedString::default(),
refocusing: false,
scroll: ScrollHandle::new(),
}
}
#[must_use]
pub fn empty_text(mut self, text: impl Into<SharedString>) -> Self {
self.empty_text = text.into();
self
}
#[must_use]
pub const fn open_on_focus(mut self, open: bool) -> Self {
self.open_on_focus = open;
self
}
pub fn set_placeholder(&self, placeholder: impl Into<SharedString>, cx: &mut Context<Self>) {
self.field
.update(cx, |field, inner| field.set_placeholder(placeholder, inner));
}
pub fn set_max_length(&self, max_length: usize, cx: &mut Context<Self>) {
self.field
.update(cx, |field, _| field.set_max_length(max_length));
}
pub fn set_results(&mut self, results: Vec<T>, cx: &mut Context<Self>) {
self.results = results;
cx.notify();
}
pub fn set_query(&mut self, query: impl Into<SharedString>, cx: &mut Context<Self>) {
self.query = query.into();
let shown = self.query.clone();
self.field
.update(cx, |field, inner| field.set_value(shown, inner));
cx.notify();
}
#[must_use]
pub fn query(&self) -> &str {
&self.query
}
#[must_use]
pub fn results(&self) -> &[T] {
&self.results
}
#[must_use]
pub const fn is_open(&self) -> bool {
self.open
}
fn visible(&self) -> bool {
visible(self.open, &self.query, self.results.len())
}
fn field_changed(&mut self, cx: &mut Context<Self>) {
let typed = self.field.read(cx).value();
if typed == &*self.query {
return;
}
self.query = SharedString::from(typed.to_owned());
cx.emit(SearchDropdownEvent::Query(self.query.clone()));
self.open = true;
cx.notify();
}
fn focused(&mut self, cx: &mut Context<Self>) {
if self.refocusing {
self.refocusing = false;
return;
}
if self.open_on_focus {
self.open = true;
cx.notify();
}
}
fn choose(&mut self, chosen: T, cx: &mut Context<Self>) {
cx.emit(SearchDropdownEvent::Chosen(chosen));
self.close(cx);
}
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.emit(SearchDropdownEvent::Cleared);
self.query = SharedString::default();
cx.emit(SearchDropdownEvent::Query(self.query.clone()));
self.field
.update(cx, |field, inner| field.set_value("", inner));
let focus = self.field.read(cx).focus_handle(cx);
self.refocusing = !focus.is_focused(window);
focus.focus(window, cx);
self.close(cx);
}
fn close(&mut self, cx: &mut Context<Self>) {
self.open = false;
cx.notify();
}
}
impl<T: 'static> EventEmitter<SearchDropdownEvent<T>> for SearchDropdownState<T> {}
impl<T: 'static> Focusable for SearchDropdownState<T> {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.field.read(cx).focus_handle(cx)
}
}
#[derive(IntoElement)]
pub struct SearchDropdown<T: Clone + 'static> {
state: Entity<SearchDropdownState<T>>,
}
impl<T: Clone + 'static> SearchDropdown<T> {
#[must_use]
pub fn new(state: &Entity<SearchDropdownState<T>>) -> Self {
Self {
state: state.clone(),
}
}
}
impl<T: Clone + 'static> RenderOnce for SearchDropdown<T> {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state;
let focused = state.read(cx).focus_handle(cx).is_focused(window);
let shown = state.read(cx).visible();
let dismissing = state.clone();
div()
.key_context(CONTEXT)
.relative()
.w_full()
.on_action(move |_: &Dismiss, _win, app| {
dismissing.update(app, SearchDropdownState::close);
})
.child(bar(&state, focused, cx))
.when(shown, |el| el.child(deferred(list(&state, cx))))
}
}
fn bar<T: Clone + 'static>(state: &Entity<SearchDropdownState<T>>, focused: bool, cx: &App) -> Div {
let read = state.read(cx);
let theme = cx.theme();
let has_query = !read.field.read(cx).value().is_empty();
let hover = theme.shade(BAR_HOVER_BORDER);
div()
.flex()
.items_center()
.w_full()
.h(px(BAR_HEIGHT))
.gap(px(BAR_GAP))
.p(px(BAR_PADDING))
.bg(rgb(theme.shade(BAR_BG)))
.rounded(px(BAR_RADIUS))
.map(|el| {
if focused {
bordered(el, BAR_FOCUS_BORDER_WIDTH)
.border_color(rgb(theme.shade(BAR_FOCUS_BORDER)))
} else {
bordered(el, BAR_BORDER_WIDTH)
.border_color(rgb(theme.shade(BAR_BORDER)))
.hover(move |style| style.border_color(rgb(hover)))
}
})
.child(
Icon::new(search::GLYPH)
.style(IconStyle::Light)
.size(GLYPH_SIZE)
.color(theme.shade(GLYPH_COLOR)),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.font_family(FONT_PRIMARY)
.text_size(px(TEXT_SIZE.px()))
.line_height(px(LINE_HEIGHT))
.child(read.field.clone()),
)
.when(has_query, |el| el.child(clear_button(state, cx)))
}
fn bordered(mut element: Div, width: f32) -> Div {
let edges = &mut element.style().border_widths;
edges.top = Some(px(width).into());
edges.right = Some(px(width).into());
edges.bottom = Some(px(width).into());
edges.left = Some(px(width).into());
element
}
fn clear_button<T: Clone + 'static>(
state: &Entity<SearchDropdownState<T>>,
cx: &App,
) -> impl IntoElement {
let clearing = state.clone();
let glyph = Icon::new(CLEAR_ICON)
.style(IconStyle::Light)
.size(CLEAR_SIZE)
.color(cx.theme().shade(CLEAR_COLOR));
ghost_icon_button("designs-search-dropdown-clear", glyph, CLEAR_PADDING, cx)
.debug_selector(|| "search-dropdown-clear".to_owned())
.flex_none()
.on_click(move |_, window, app| {
clearing.update(app, |dropdown, inner| dropdown.clear(window, inner));
})
}
fn list<T: Clone + 'static>(state: &Entity<SearchDropdownState<T>>, cx: &App) -> Div {
let read = state.read(cx);
let theme = cx.theme();
let outside = state.clone();
div()
.debug_selector(|| "search-dropdown-list".to_owned())
.absolute()
.top(px(BAR_HEIGHT + LIST_OFFSET))
.left_0()
.right_0()
.bg(rgb(theme.shade(LIST_BG)))
.border_1()
.border_color(rgb(theme.shade(LIST_BORDER)))
.rounded(px(LIST_RADIUS))
.shadow(elevation(LIST_SHADOW))
.overflow_hidden()
.occlude()
.on_mouse_down_out(move |_, _win, app| {
outside.update(app, SearchDropdownState::close);
})
.map(|el| {
if read.results.is_empty() {
el.child(empty(read.empty_text.clone(), theme))
} else {
el.child(rows(state, cx))
}
})
}
fn rows<T: Clone + 'static>(state: &Entity<SearchDropdownState<T>>, cx: &App) -> AnyElement {
let read = state.read(cx);
let hover = cx.theme().shade(ROW_HOVER_BG);
let children = read.results.iter().enumerate().map(|(position, result)| {
let choosing = state.clone();
let picked = result.clone();
div()
.id(("designs-search-dropdown-row", position))
.debug_selector(|| format!("search-dropdown-row-{position}"))
.w_full()
.py(px(ROW_PADDING.0))
.px(px(ROW_PADDING.1))
.cursor_pointer()
.hover(move |style| style.bg(rgb(hover)))
.child((read.render_item)(result, cx))
.on_click(move |_, _win, app| {
choosing.update(app, |dropdown, inner| {
dropdown.choose(picked.clone(), inner);
});
})
});
scrolling(
"designs-search-dropdown-scrollbar",
div()
.id("designs-search-dropdown-rows")
.flex()
.flex_col()
.py(px(LIST_PADDING_Y))
.max_h(px(LIST_MAX_HEIGHT))
.overflow_y_scroll()
.track_scroll(&read.scroll)
.children(children)
.into_any_element(),
&read.scroll,
)
}
fn empty(text: SharedString, theme: &Theme) -> Div {
div()
.debug_selector(|| "search-dropdown-empty".to_owned())
.p(px(EMPTY_PADDING))
.text_color(rgb(theme.shade(EMPTY_TEXT)))
.child(text)
}
#[cfg(test)]
mod tests {
use fluidattacks_designs_domain::search_dropdown::EMPTY_MESSAGE;
use fluidattacks_designs_domain::theme::Mode;
use gpui::{
div, point, px, AppContext as _, ClipboardItem, Context, Entity, Focusable as _,
IntoElement, Modifiers, ParentElement as _, Render, SharedString, Styled as _,
TestAppContext, VisualTestContext, Window,
};
use super::{SearchDropdown, SearchDropdownEvent, SearchDropdownState};
use crate::text::Text;
use crate::theme::Theme;
type Dropdown = Entity<SearchDropdownState<&'static str>>;
type Heard = Entity<Vec<SearchDropdownEvent<&'static str>>>;
struct Host {
dropdown: Dropdown,
heard: Heard,
}
impl Render for Host {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(
div()
.w(px(300.0))
.child(SearchDropdown::new(&self.dropdown)),
)
}
}
fn mount(
cx: &mut TestAppContext,
configure: fn(SearchDropdownState<&'static str>) -> SearchDropdownState<&'static str>,
) -> (Dropdown, Heard, &mut VisualTestContext) {
cx.update(|app| {
app.set_global(Theme {
mode: Mode::Light,
colors: Mode::Light.colors(),
});
crate::text_input::bind_keys(app);
super::bind_keys(app);
});
let (host, cx) = cx.add_window_view(|window, inner| {
let dropdown = inner.new(|state| {
configure(SearchDropdownState::new(
|row: &&'static str, _| Text::new(*row).into_any_element(),
window,
state,
))
});
inner
.subscribe(&dropdown, |host: &mut Host, _, event, listening| {
host.heard
.update(listening, |log, _| log.push(event.clone()));
})
.detach();
Host {
dropdown,
heard: inner.new(|_| Vec::new()),
}
});
cx.update(|window, _| window.activate_window());
cx.run_until_parked();
let (dropdown, heard) = cx.read(|app| {
let host = host.read(app);
(host.dropdown.clone(), host.heard.clone())
});
(dropdown, heard, cx)
}
fn said(heard: &Heard, cx: &VisualTestContext) -> Vec<SearchDropdownEvent<&'static str>> {
cx.read(|app| heard.read(app).clone())
}
fn forget(heard: &Heard, cx: &mut VisualTestContext) {
heard.update(cx, |log, _| log.clear());
}
fn focus(dropdown: &Dropdown, cx: &mut VisualTestContext) {
cx.simulate_click(point(px(150.0), px(20.0)), Modifiers::none());
cx.run_until_parked();
let focused =
cx.update(|window, app| dropdown.read(app).focus_handle(app).is_focused(window));
assert!(focused, "the click did not reach the field");
}
fn type_in(dropdown: &Dropdown, text: &str, cx: &mut VisualTestContext) {
focus(dropdown, cx);
cx.simulate_input(text);
cx.run_until_parked();
}
fn click(selector: &'static str, cx: &mut VisualTestContext) {
let bounds = cx
.debug_bounds(selector)
.unwrap_or_else(|| unreachable_bounds(selector));
cx.simulate_click(bounds.center(), Modifiers::none());
cx.run_until_parked();
}
#[allow(
clippy::panic,
reason = "a test that cannot find its target has failed"
)]
fn unreachable_bounds(selector: &str) -> gpui::Bounds<gpui::Pixels> {
panic!("{selector} was not drawn")
}
fn query(dropdown: &Dropdown, cx: &VisualTestContext) -> String {
cx.read(|app| dropdown.read(app).query().to_owned())
}
fn is_open(dropdown: &Dropdown, cx: &VisualTestContext) -> bool {
cx.read(|app| dropdown.read(app).is_open())
}
fn query_event(text: &str) -> SearchDropdownEvent<&'static str> {
SearchDropdownEvent::Query(SharedString::from(text.to_owned()))
}
const fn same(state: SearchDropdownState<&'static str>) -> SearchDropdownState<&'static str> {
state
}
#[gpui::test]
fn the_field_shows_the_query_the_app_writes(cx: &mut TestAppContext) {
let (dropdown, heard, cx) = mount(cx, same);
dropdown.update(cx, |state, inner| state.set_query("initial", inner));
assert_eq!(query(&dropdown, cx), "initial");
dropdown.update(cx, |state, inner| state.set_query("second", inner));
cx.run_until_parked();
assert_eq!(query(&dropdown, cx), "second");
let shown = cx.read(|app| dropdown.read(app).field.read(app).value().to_owned());
assert_eq!(shown, "second");
assert!(said(&heard, cx).is_empty());
}
#[gpui::test]
fn the_clear_button_shows_only_while_there_is_a_query(cx: &mut TestAppContext) {
let (dropdown, _, cx) = mount(cx, same);
assert!(cx.debug_bounds("search-dropdown-clear").is_none());
dropdown.update(cx, |state, inner| state.set_query("cve", inner));
cx.run_until_parked();
assert!(cx.debug_bounds("search-dropdown-clear").is_some());
}
#[gpui::test]
fn focus_opens_the_list_by_default(cx: &mut TestAppContext) {
let (dropdown, _, cx) = mount(cx, same);
focus(&dropdown, cx);
assert!(is_open(&dropdown, cx));
}
#[gpui::test]
fn focus_leaves_the_list_closed_when_open_on_focus_is_off(cx: &mut TestAppContext) {
let (dropdown, _, cx) = mount(cx, |state| state.open_on_focus(false));
focus(&dropdown, cx);
assert!(!is_open(&dropdown, cx));
}
#[gpui::test]
fn typing_reports_each_query_and_opens_the_list(cx: &mut TestAppContext) {
let (dropdown, heard, cx) = mount(cx, |state| state.open_on_focus(false));
type_in(&dropdown, "c", cx);
cx.simulate_input("v");
cx.simulate_input("e");
cx.run_until_parked();
assert!(is_open(&dropdown, cx));
assert_eq!(query(&dropdown, cx), "cve");
assert_eq!(
said(&heard, cx),
[query_event("c"), query_event("cv"), query_event("cve")]
);
assert!(cx.debug_bounds("search-dropdown-list").is_some());
}
#[gpui::test]
fn escape_closes_the_list(cx: &mut TestAppContext) {
let (dropdown, _, cx) = mount(cx, same);
type_in(&dropdown, "cve", cx);
assert!(is_open(&dropdown, cx));
cx.simulate_keystrokes("escape");
cx.run_until_parked();
assert!(!is_open(&dropdown, cx));
assert!(cx.debug_bounds("search-dropdown-list").is_none());
}
#[gpui::test]
fn clearing_reports_the_clearing_then_an_empty_query(cx: &mut TestAppContext) {
let (dropdown, heard, cx) = mount(cx, same);
type_in(&dropdown, "cve", cx);
forget(&heard, cx);
click("search-dropdown-clear", cx);
assert_eq!(
said(&heard, cx),
[SearchDropdownEvent::Cleared, query_event("")]
);
assert_eq!(query(&dropdown, cx), "");
assert!(!is_open(&dropdown, cx));
let focused =
cx.update(|window, app| dropdown.read(app).focus_handle(app).is_focused(window));
assert!(focused);
}
#[gpui::test]
fn a_paste_past_the_cap_is_cut_and_reported_cut(cx: &mut TestAppContext) {
let (dropdown, heard, cx) = mount(cx, same);
dropdown.update(cx, |state, inner| state.set_max_length(5, inner));
focus(&dropdown, cx);
cx.write_to_clipboard(ClipboardItem::new_string("abcdefg".to_owned()));
cx.simulate_keystrokes("cmd-v");
cx.run_until_parked();
assert_eq!(query(&dropdown, cx), "abcde");
assert_eq!(said(&heard, cx), [query_event("abcde")]);
}
#[gpui::test]
fn typing_stops_at_the_cap(cx: &mut TestAppContext) {
let (dropdown, heard, cx) = mount(cx, same);
dropdown.update(cx, |state, inner| state.set_max_length(3, inner));
type_in(&dropdown, "abcdefg", cx);
assert_eq!(query(&dropdown, cx), "abc");
assert_eq!(
said(&heard, cx),
[query_event("a"), query_event("ab"), query_event("abc")]
);
}
#[gpui::test]
fn setting_results_reports_nothing_and_fills_the_open_list(cx: &mut TestAppContext) {
let (dropdown, heard, cx) = mount(cx, same);
type_in(&dropdown, "a", cx);
forget(&heard, cx);
dropdown.update(cx, |state, inner| {
state.set_results(vec!["alpha", "beta"], inner);
});
cx.run_until_parked();
assert!(said(&heard, cx).is_empty());
assert!(cx.debug_bounds("search-dropdown-row-0").is_some());
assert!(cx.debug_bounds("search-dropdown-row-1").is_some());
assert!(cx.debug_bounds("search-dropdown-empty").is_none());
}
#[gpui::test]
fn clicking_a_row_reports_that_result_and_closes_the_list(cx: &mut TestAppContext) {
let (dropdown, heard, cx) = mount(cx, same);
type_in(&dropdown, "a", cx);
dropdown.update(cx, |state, inner| {
state.set_results(vec!["alpha", "beta"], inner);
});
cx.run_until_parked();
forget(&heard, cx);
click("search-dropdown-row-1", cx);
assert_eq!(said(&heard, cx), [SearchDropdownEvent::Chosen("beta")]);
assert!(!is_open(&dropdown, cx));
}
#[gpui::test]
fn a_query_with_no_results_shows_the_default_empty_text(cx: &mut TestAppContext) {
let (dropdown, _, cx) = mount(cx, same);
type_in(&dropdown, "zzz", cx);
assert!(cx.debug_bounds("search-dropdown-empty").is_some());
let text = cx.read(|app| dropdown.read(app).empty_text.clone());
assert_eq!(text, EMPTY_MESSAGE);
}
#[gpui::test]
fn a_custom_empty_text_replaces_the_default(cx: &mut TestAppContext) {
let (dropdown, _, cx) = mount(cx, |state| state.empty_text("Nothing found"));
type_in(&dropdown, "zzz", cx);
assert!(cx.debug_bounds("search-dropdown-empty").is_some());
let text = cx.read(|app| dropdown.read(app).empty_text.clone());
assert_eq!(text, "Nothing found");
}
#[gpui::test]
fn a_press_outside_the_list_closes_it(cx: &mut TestAppContext) {
let (dropdown, _, cx) = mount(cx, same);
type_in(&dropdown, "zzz", cx);
assert!(cx.debug_bounds("search-dropdown-list").is_some());
cx.simulate_click(point(px(600.0), px(600.0)), Modifiers::none());
cx.run_until_parked();
assert!(!is_open(&dropdown, cx));
}
#[gpui::test]
fn an_open_list_with_only_blanks_and_no_results_stays_hidden(cx: &mut TestAppContext) {
let (dropdown, _, cx) = mount(cx, same);
type_in(&dropdown, " ", cx);
assert!(is_open(&dropdown, cx));
assert!(cx.debug_bounds("search-dropdown-list").is_none());
}
}