use crate::a11y::{A11y, Announce, FocusNext, FocusPrevious};
use crate::element_id::for_entity;
use crate::icons::Icons;
use crate::theme::{ActiveTheme, ControlSize, Themeable};
use crate::traits::accessible::Accessible;
use gpui::{
actions, div, prelude::*, px, App, Context, DismissEvent, ElementId, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, KeyBinding, KeyDownEvent, ParentElement, Rems, Render,
Role, ScrollHandle, SharedString, Styled, Window,
};
use std::rc::Rc;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ListboxFocus {
Popup,
Caller,
}
pub(crate) use crate::selection::wrap_index as wrapped_index;
pub(crate) fn matches_query(query: &str, haystack: &str) -> bool {
if query.is_empty() {
return true;
}
haystack.to_lowercase().contains(&query.to_lowercase())
}
actions!(
select,
[
HighlightNext,
HighlightPrevious,
HighlightFirst,
HighlightLast,
ChooseHighlighted,
DismissListbox,
]
);
pub(crate) const LISTBOX_CONTEXT: &str = "Listbox";
pub fn bind_listbox_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("down", HighlightNext, Some(LISTBOX_CONTEXT)),
KeyBinding::new("up", HighlightPrevious, Some(LISTBOX_CONTEXT)),
KeyBinding::new("home", HighlightFirst, Some(LISTBOX_CONTEXT)),
KeyBinding::new("end", HighlightLast, Some(LISTBOX_CONTEXT)),
KeyBinding::new("enter", ChooseHighlighted, Some(LISTBOX_CONTEXT)),
KeyBinding::new("space", ChooseHighlighted, Some(LISTBOX_CONTEXT)),
KeyBinding::new("escape", DismissListbox, Some(LISTBOX_CONTEXT)),
]);
}
pub(crate) const LISTBOX_GAP: Rems = Rems(0.25);
pub(crate) struct Listbox {
pub(crate) label: SharedString,
pub(crate) options: Vec<SharedString>,
pub(crate) selected_index: Option<usize>,
pub(crate) highlighted: Option<usize>,
pub(crate) size: ControlSize,
pub(crate) focus: ListboxFocus,
pub(crate) focus_handle: FocusHandle,
pub(crate) scroll_handle: ScrollHandle,
pub(crate) restore_focus: Option<FocusHandle>,
pub(crate) on_select: Option<Rc<dyn Fn(usize, &mut Window, &mut App)>>,
}
impl EventEmitter<DismissEvent> for Listbox {}
impl Focusable for Listbox {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Listbox {
#[allow(clippy::too_many_arguments)]
pub(crate) fn build(
label: SharedString,
options: Vec<SharedString>,
selected_index: Option<usize>,
size: ControlSize,
focus: ListboxFocus,
on_select: impl Fn(usize, &mut Window, &mut App) + 'static,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx| {
let restore_focus = match focus {
ListboxFocus::Popup => window.focused(cx),
ListboxFocus::Caller => None,
};
let focus_handle = cx.focus_handle();
if focus == ListboxFocus::Popup {
window.focus(&focus_handle, cx);
}
let highlighted = selected_index.or(if options.is_empty() { None } else { Some(0) });
Self {
label,
options,
selected_index,
highlighted,
size,
focus,
focus_handle,
scroll_handle: ScrollHandle::new(),
restore_focus,
on_select: Some(Rc::new(on_select)),
}
})
}
pub(crate) fn set_options(
&mut self,
options: Vec<SharedString>,
selected_index: Option<usize>,
cx: &mut Context<Self>,
) {
self.options = options;
self.selected_index = selected_index;
self.highlighted = if self.options.is_empty() {
None
} else {
Some(0)
};
self.scroll_handle.scroll_to_item(0);
cx.notify();
}
pub(crate) fn select(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
if let Some(on_select) = &self.on_select {
let on_select = on_select.clone();
on_select(index, window, cx);
}
self.dismiss(true, window, cx);
}
pub(crate) fn dismiss(
&mut self,
restore_focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if restore_focus {
if let Some(handle) = self.restore_focus.clone() {
window.focus(&handle, cx);
}
}
cx.emit(DismissEvent);
}
pub(crate) fn highlight(&mut self, index: usize, cx: &mut Context<Self>) {
self.highlighted = Some(index);
self.scroll_handle.scroll_to_item(index);
cx.notify();
}
pub(crate) fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
if let Some(next) = wrapped_index(self.highlighted, delta, self.options.len()) {
self.highlight(next, cx);
}
}
pub(crate) fn highlight_edge(&mut self, last: bool, cx: &mut Context<Self>) {
let count = self.options.len();
if count == 0 {
return;
}
self.highlight(if last { count - 1 } else { 0 }, cx);
}
pub(crate) fn choose_highlighted(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.highlighted {
self.select(index, window, cx);
}
}
pub(crate) fn type_ahead(&mut self, character: char, cx: &mut Context<Self>) {
let count = self.options.len();
if count == 0 {
return;
}
let start = self.highlighted.map_or(0, |current| current + 1);
for offset in 0..count {
let index = (start + offset) % count;
let starts_with = self.options[index]
.chars()
.next()
.is_some_and(|first| first.to_lowercase().eq(character.to_lowercase()));
if starts_with {
self.highlight(index, cx);
return;
}
}
}
pub(crate) fn row_a11y(&self, index: usize) -> A11y {
option_a11y(
self.options[index].clone(),
self.selected_index == Some(index),
self.highlighted == Some(index),
self.focus == ListboxFocus::Popup,
index,
self.options.len(),
)
}
pub(crate) fn typed_character(event: &KeyDownEvent) -> Option<char> {
let modifiers = event.keystroke.modifiers;
if modifiers.control || modifiers.alt || modifiers.platform || modifiers.function {
return None;
}
let mut characters = event.keystroke.key_char.as_ref()?.chars();
let character = characters.next()?;
if characters.next().is_some() || character.is_whitespace() || character.is_control() {
return None;
}
Some(character)
}
}
impl Accessible for Listbox {
fn a11y(&self) -> A11y {
A11y::new(Role::ListBox).name(self.label.clone())
}
}
pub(crate) fn option_a11y(
label: SharedString,
is_selected: bool,
is_highlighted: bool,
may_claim_active_descendant: bool,
index: usize,
count: usize,
) -> A11y {
A11y::new(Role::ListBoxOption)
.name(label)
.selected(is_selected)
.active_descendant(is_highlighted && may_claim_active_descendant)
.position_in_set(index + 1)
.size_of_set(count)
}
impl Render for Listbox {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle.clone();
let theme = cx.theme();
let metrics = theme.control(self.size);
div()
.id(for_entity("gpuikit-listbox", cx.entity_id()))
.announce(self.a11y())
.when(self.focus == ListboxFocus::Popup, |this| {
this.track_focus(&focus_handle).key_context(LISTBOX_CONTEXT)
})
.on_action(cx.listener(|this, _: &HighlightNext, _window, cx| {
this.move_highlight(1, cx);
}))
.on_action(cx.listener(|this, _: &HighlightPrevious, _window, cx| {
this.move_highlight(-1, cx);
}))
.on_action(cx.listener(|this, _: &HighlightFirst, _window, cx| {
this.highlight_edge(false, cx);
}))
.on_action(cx.listener(|this, _: &HighlightLast, _window, cx| {
this.highlight_edge(true, cx);
}))
.on_action(cx.listener(|this, _: &ChooseHighlighted, window, cx| {
this.choose_highlighted(window, cx);
}))
.on_action(cx.listener(|this, _: &DismissListbox, window, cx| {
this.dismiss(true, window, cx);
}))
.on_action(cx.listener(|this, _: &FocusNext, window, cx| {
this.dismiss(true, window, cx);
window.focus_next(cx);
}))
.on_action(cx.listener(|this, _: &FocusPrevious, window, cx| {
this.dismiss(true, window, cx);
window.focus_prev(cx);
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
if let Some(character) = Self::typed_character(event) {
this.type_ahead(character, cx);
}
}))
.on_mouse_down_out(cx.listener(|this, _, window, cx| {
this.dismiss(false, window, cx);
}))
.min_w(px(120.))
.max_h(px(480.))
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.on_scroll_wheel(|_, _, cx| {
cx.stop_propagation();
})
.bg(theme.surface())
.border_1()
.border_color(theme.border())
.rounded(metrics.radius)
.shadow_lg()
.py(metrics.padding_y())
.flex()
.flex_col()
.children(self.options.iter().enumerate().map(|(index, label)| {
let is_selected = self.selected_index == Some(index);
let is_highlighted = self.highlighted == Some(index);
let label = label.clone();
let theme = cx.theme();
let a11y = self.row_a11y(index);
let (fg, check_color) = if is_highlighted {
(theme.bg(), theme.bg())
} else {
(theme.fg(), theme.accent())
};
let row =
div()
.id(ElementId::NamedInteger(
"listbox-option".into(),
index as u64,
))
.announce(a11y)
.flex()
.items_center()
.gap(metrics.gap)
.h(metrics.height)
.px(metrics.padding_x * 1.5)
.text_size(metrics.text_size)
.line_height(metrics.line_height)
.cursor_pointer()
.text_color(fg)
.when(is_highlighted, |this| this.bg(theme.accent()))
.on_hover(cx.listener(move |this, hovered: &bool, _window, cx| {
if *hovered {
this.highlight(index, cx);
}
}))
.on_click(cx.listener(move |this, _, window, cx| {
this.select(index, window, cx);
}))
.child(div().w(metrics.text_size).flex_shrink_0().when(
is_selected,
|this| {
this.child(
Icons::check()
.size(metrics.text_size)
.text_color(check_color),
)
},
))
.child(label);
#[cfg(test)]
let row = row.debug_selector(move || format!("gpuikit-select-option-{index}"));
row
}))
}
}