use crate::a11y::{A11y, Announce};
use crate::element_id::scoped;
use crate::elements::listbox::{LISTBOX_GAP, Listbox, ListboxFocus, matches_query};
use crate::elements::text_field::{Adornment, text_field};
use crate::icons::Icons;
use crate::input::{InputState, InputStateEvent};
use crate::theme::{ActiveTheme, ControlSize, Themeable};
use crate::traits::accessible::Accessible;
use crate::traits::control_sized::ControlSized;
use crate::traits::disableable::Disableable;
use gpui::{
App, Context, DismissEvent, ElementId, Entity, EventEmitter, IntoElement, KeyBinding,
ParentElement, Render, Role, SharedString, Styled, Window, actions, anchored, deferred, div,
point, prelude::*, px,
};
use std::rc::Rc;
actions!(
combobox,
[
ComboboxHighlightNext,
ComboboxHighlightPrevious,
ComboboxChoose,
ComboboxDismiss,
]
);
pub const COMBOBOX_CONTEXT: &str = "Combobox";
pub fn bind_combobox_keys(cx: &mut App) {
let under_input = Some("Combobox > Input");
cx.bind_keys([
KeyBinding::new("down", ComboboxHighlightNext, under_input),
KeyBinding::new("up", ComboboxHighlightPrevious, under_input),
KeyBinding::new("enter", ComboboxChoose, under_input),
KeyBinding::new("escape", ComboboxDismiss, under_input),
KeyBinding::new("down", ComboboxHighlightNext, Some(COMBOBOX_CONTEXT)),
KeyBinding::new("up", ComboboxHighlightPrevious, Some(COMBOBOX_CONTEXT)),
KeyBinding::new("enter", ComboboxChoose, Some(COMBOBOX_CONTEXT)),
KeyBinding::new("escape", ComboboxDismiss, Some(COMBOBOX_CONTEXT)),
]);
}
pub struct ComboboxChanged;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum UnmatchedText {
#[default]
Revert,
Keep,
Create,
}
type Filter<T> = Rc<dyn Fn(&str, &T, &SharedString) -> bool>;
pub struct Combobox<T: Clone + PartialEq + 'static> {
id: ElementId,
label: SharedString,
options: Vec<(T, SharedString)>,
selected: Option<T>,
placeholder: SharedString,
on_change: Option<Rc<dyn Fn(Option<T>, &mut Window, &mut App)>>,
on_create: Option<Rc<dyn Fn(SharedString, &mut Window, &mut App)>>,
filter: Filter<T>,
unmatched: UnmatchedText,
full_width: bool,
disabled: bool,
size: ControlSize,
}
pub fn combobox<T: Clone + PartialEq + 'static>(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
options: Vec<(T, impl Into<SharedString>)>,
) -> Combobox<T> {
Combobox::new(id, name, options)
}
impl<T: Clone + PartialEq + 'static> Combobox<T> {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
options: Vec<(T, impl Into<SharedString>)>,
) -> Self {
Self {
id: id.into(),
label: name.into(),
options: options
.into_iter()
.map(|(value, label)| (value, label.into()))
.collect(),
selected: None,
placeholder: "Search…".into(),
on_change: None,
on_create: None,
filter: Rc::new(|query, _value, label| matches_query(query, label)),
unmatched: UnmatchedText::default(),
full_width: false,
disabled: false,
size: ControlSize::default(),
}
}
pub fn selected(mut self, value: T) -> Self {
self.selected = Some(value);
self
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn on_change(
mut self,
handler: impl Fn(Option<T>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn filter(mut self, filter: impl Fn(&str, &T, &SharedString) -> bool + 'static) -> Self {
self.filter = Rc::new(filter);
self
}
pub fn keep_unmatched_text(mut self) -> Self {
self.unmatched = UnmatchedText::Keep;
self
}
pub fn on_create(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_create = Some(Rc::new(handler));
self.unmatched = UnmatchedText::Create;
self
}
pub fn full_width(mut self, full_width: bool) -> Self {
self.full_width = full_width;
self
}
}
impl<T: Clone + PartialEq + 'static> Disableable for Combobox<T> {
fn is_disabled(&self) -> bool {
self.disabled
}
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl<T: Clone + PartialEq + 'static> ControlSized for Combobox<T> {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
pub struct ComboboxState<T: Clone + PartialEq + 'static> {
id: ElementId,
label: SharedString,
options: Vec<(T, SharedString)>,
pub selected: Option<T>,
input: Entity<InputState>,
listbox: Option<Entity<Listbox>>,
chevron_found_open: bool,
visible: Vec<usize>,
on_change: Option<Rc<dyn Fn(Option<T>, &mut Window, &mut App)>>,
on_create: Option<Rc<dyn Fn(SharedString, &mut Window, &mut App)>>,
filter: Filter<T>,
unmatched: UnmatchedText,
full_width: bool,
disabled: bool,
size: ControlSize,
}
impl<T: Clone + PartialEq + 'static> EventEmitter<ComboboxChanged> for ComboboxState<T> {}
impl<T: Clone + PartialEq + 'static> ComboboxState<T> {
pub fn new(combobox: Combobox<T>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let placeholder = combobox.placeholder.clone();
let input = cx.new(|cx| {
let mut state = InputState::new_singleline(cx);
state.set_placeholder(placeholder, cx);
state
});
if let Some(selected) = &combobox.selected {
if let Some((_, label)) = combobox.options.iter().find(|(v, _)| v == selected) {
let label = label.to_string();
input.update(cx, |state, cx| state.set_content_silent(label, cx));
}
}
cx.subscribe_in(
&input,
window,
|this, _input, event, window, cx| match event {
InputStateEvent::TextChanged => this.text_changed(window, cx),
InputStateEvent::Blur => this.blurred(window, cx),
_ => {}
},
)
.detach();
let visible = (0..combobox.options.len()).collect();
Self {
id: combobox.id,
label: combobox.label,
options: combobox.options,
selected: combobox.selected,
input,
listbox: None,
chevron_found_open: false,
visible,
on_change: combobox.on_change,
on_create: combobox.on_create,
filter: combobox.filter,
unmatched: combobox.unmatched,
full_width: combobox.full_width,
disabled: combobox.disabled,
size: combobox.size,
}
}
pub fn text(&self, cx: &App) -> SharedString {
self.input.read(cx).content().to_string().into()
}
pub fn is_open(&self) -> bool {
self.listbox.is_some()
}
fn selected_label(&self) -> Option<SharedString> {
let selected = self.selected.as_ref()?;
self.options
.iter()
.find(|(value, _)| value == selected)
.map(|(_, label)| label.clone())
}
fn refilter(&mut self, query: &str) -> Vec<SharedString> {
let filter = self.filter.clone();
self.visible = self
.options
.iter()
.enumerate()
.filter(|(_, (value, label))| filter(query, value, label))
.map(|(index, _)| index)
.collect();
self.visible
.iter()
.map(|index| self.options[*index].1.clone())
.collect()
}
fn selected_row(&self) -> Option<usize> {
let selected = self.selected.as_ref()?;
let option_index = self
.options
.iter()
.position(|(value, _)| value == selected)?;
self.visible.iter().position(|index| *index == option_index)
}
fn set_value(&mut self, value: Option<T>, window: &mut Window, cx: &mut Context<Self>) {
if self.selected == value {
return;
}
self.selected = value.clone();
if let Some(on_change) = self.on_change.clone() {
on_change(value, window, cx);
}
cx.emit(ComboboxChanged);
cx.notify();
}
fn choose_row(&mut self, row: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(option_index) = self.visible.get(row).copied() else {
return;
};
let Some((value, label)) = self.options.get(option_index).cloned() else {
return;
};
let label_text = label.to_string();
self.input
.update(cx, |state, cx| state.set_content_silent(label_text, cx));
self.set_value(Some(value), window, cx);
self.listbox = None;
self.refilter("");
cx.notify();
}
fn text_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
let query = self.text(cx).to_string();
let labels = self.refilter(&query);
self.set_value(None, window, cx);
match self.listbox.clone() {
Some(listbox) => {
listbox.update(cx, |listbox, cx| listbox.set_options(labels, None, cx));
}
None => self.open(window, cx),
}
cx.notify();
}
fn blurred(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.listbox = None;
let text = self.text(cx);
if self.selected_label().as_ref() == Some(&text) {
cx.notify();
return;
}
match self.unmatched {
UnmatchedText::Revert => {
let restored = self.selected_label().unwrap_or_default().to_string();
self.input
.update(cx, |state, cx| state.set_content_silent(restored, cx));
}
UnmatchedText::Keep => {}
UnmatchedText::Create => {
if let Some(on_create) = self.on_create.clone() {
if !text.is_empty() {
on_create(text, window, cx);
}
}
}
}
cx.notify();
}
fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled || self.listbox.is_some() {
return;
}
let labels: Vec<SharedString> = self
.visible
.iter()
.map(|index| self.options[*index].1.clone())
.collect();
let selected_row = self.selected_row();
let entity = cx.entity().downgrade();
let listbox = Listbox::build(
self.label.clone(),
labels,
selected_row,
self.size,
ListboxFocus::Caller,
move |row, window, cx| {
if let Some(entity) = entity.upgrade() {
entity.update(cx, |this, cx| this.choose_row(row, window, cx));
}
},
window,
cx,
);
cx.subscribe_in(
&listbox,
window,
|this, _, _event: &DismissEvent, _window, cx| {
this.listbox = None;
cx.notify();
},
)
.detach();
self.listbox = Some(listbox);
cx.notify();
}
fn move_highlight(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
cx.propagate();
return;
}
match self.listbox.clone() {
Some(listbox) => listbox.update(cx, |listbox, cx| listbox.move_highlight(delta, cx)),
None => self.open(window, cx),
}
}
fn choose_highlighted(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(listbox) = self.listbox.clone() else {
cx.propagate();
return;
};
let row = listbox.read(cx).highlighted;
match row {
Some(row) => self.choose_row(row, window, cx),
None => cx.propagate(),
}
}
fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if self.listbox.take().is_none() {
cx.propagate();
return;
}
cx.notify();
}
}
impl<T: Clone + PartialEq + 'static> Accessible for ComboboxState<T> {
fn a11y(&self) -> A11y {
let a11y = A11y::new(Role::ComboBox)
.name(self.label.clone())
.expanded(self.listbox.is_some());
if self.disabled {
a11y.not_focusable("a disabled combobox has nothing for a keyboard to choose between")
} else {
a11y.focusable()
}
}
}
impl<T: Clone + PartialEq + 'static> Render for ComboboxState<T> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let a11y = self.a11y().text_value(self.text(cx));
let theme = cx.theme();
let metrics = theme.control(self.size);
let full_width = self.full_width;
let gap = LISTBOX_GAP.to_pixels(window.rem_size());
let chevron = div().id(scoped(&self.id, "chevron")).flex().items_center();
#[cfg(test)]
let chevron = chevron.debug_selector(|| "gpuikit-combobox-chevron".into());
let chevron = chevron
.when(!self.disabled, |this| {
this.cursor_pointer()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|this, _, _window, cx| {
this.chevron_found_open = this.listbox.is_some();
cx.stop_propagation();
}),
)
.on_click(cx.listener(|this, _, window, cx| {
window.focus(&gpui::Focusable::focus_handle(this.input.read(cx), cx), cx);
if this.chevron_found_open {
this.listbox = None;
cx.notify();
} else {
this.open(window, cx);
}
}))
})
.child(
Icons::chevron_down()
.size(metrics.text_size)
.text_color(theme.fg_muted()),
);
let field = text_field(&self.input, cx)
.control_size(self.size)
.disabled(self.disabled)
.full_width(full_width)
.suffix(Adornment::element(chevron));
let wrapper = div()
.id(self.id.clone())
.announce(a11y)
.key_context(COMBOBOX_CONTEXT)
.on_action(cx.listener(|this, _: &ComboboxHighlightNext, window, cx| {
this.move_highlight(1, window, cx);
}))
.on_action(
cx.listener(|this, _: &ComboboxHighlightPrevious, window, cx| {
this.move_highlight(-1, window, cx);
}),
)
.on_action(cx.listener(|this, _: &ComboboxChoose, window, cx| {
this.choose_highlighted(window, cx);
}))
.on_action(cx.listener(|this, _: &ComboboxDismiss, window, cx| {
this.dismiss(window, cx);
}))
.relative()
.when(full_width, |this| this.w_full())
.child(field);
#[cfg(test)]
let wrapper = wrapper.debug_selector(|| "gpuikit-combobox".into());
wrapper.when_some(self.listbox.clone(), |this, listbox| {
let popup = div().occlude().child(listbox);
#[cfg(test)]
let popup = popup.debug_selector(|| "gpuikit-combobox-popup".into());
this.child(
deferred(anchored().offset(point(px(0.), gap)).child(popup)).with_priority(1),
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, TestAppContext, VisualTestContext, px, size};
use std::cell::RefCell;
use std::ops::Deref;
fn options() -> Vec<(usize, &'static str)> {
vec![(0, "Apple"), (1, "Apricot"), (2, "Banana"), (3, "Cherry")]
}
struct TestView {
combobox: Entity<ComboboxState<usize>>,
}
impl Render for TestView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.combobox.clone())
}
}
fn open(
cx: &mut TestAppContext,
build: impl FnOnce(Combobox<usize>) -> Combobox<usize>,
) -> (Entity<ComboboxState<usize>>, &'static mut VisualTestContext) {
cx.update(crate::init);
let window = cx.open_window(size(px(400.), px(300.)), |window, cx| {
let combobox = cx.new(|cx| {
ComboboxState::new(build(combobox("test", "Fruit", options())), window, cx)
});
TestView { combobox }
});
let state = window
.read_with(cx, |view, _cx| view.combobox.clone())
.expect("the window's root view is the test view");
let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
cx.run_until_parked();
(state, cx)
}
#[gpui::test]
fn clicking_the_chevron_opens_the_popup(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder.selected(0));
state.read_with(cx, |this, _| assert!(!this.is_open()));
let bounds = cx
.debug_bounds("gpuikit-combobox-chevron")
.expect("the chevron should have been laid out");
cx.simulate_click(bounds.center(), gpui::Modifiers::default());
state.read_with(cx, |this, _| {
assert!(
this.is_open(),
"clicking the chevron did not open the popup"
);
});
}
#[gpui::test]
fn clicking_the_chevron_again_closes_the_popup(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder.selected(0));
let bounds = cx
.debug_bounds("gpuikit-combobox-chevron")
.expect("the chevron should have been laid out");
cx.simulate_click(bounds.center(), gpui::Modifiers::default());
state.read_with(cx, |this, _| assert!(this.is_open()));
cx.simulate_click(bounds.center(), gpui::Modifiers::default());
cx.run_until_parked();
state.read_with(cx, |this, _| {
assert!(
!this.is_open(),
"a second press on the chevron left the popup open"
);
});
}
#[gpui::test]
fn clicking_the_chevron_of_a_disabled_combobox_does_nothing(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder.selected(0).disabled(true));
let bounds = cx
.debug_bounds("gpuikit-combobox-chevron")
.expect("the chevron should have been laid out");
cx.simulate_click(bounds.center(), gpui::Modifiers::default());
state.read_with(cx, |this, _| assert!(!this.is_open()));
}
fn type_into(state: &Entity<ComboboxState<usize>>, text: &str, cx: &mut VisualTestContext) {
let text = text.to_string();
cx.update(|_window, cx| {
state.update(cx, |this, cx| {
let input = this.input.clone();
input.update(cx, |input, cx| input.set_content(text, cx));
});
});
cx.run_until_parked();
}
#[gpui::test]
fn a_row_index_under_a_filter_is_not_an_option_index(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder);
type_into(&state, "an", cx);
cx.update(|window, cx| {
state.update(cx, |this, cx| {
assert_eq!(this.visible, vec![2]);
this.choose_row(0, window, cx);
assert_eq!(this.selected, Some(2));
assert_eq!(this.text(cx), SharedString::from("Banana"));
});
});
}
#[gpui::test]
fn typing_clears_the_value(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder.selected(0));
cx.update(|_window, cx| {
state.update(cx, |this, cx| {
assert_eq!(this.selected, Some(0));
assert_eq!(this.text(cx), SharedString::from("Apple"));
});
});
type_into(&state, "Ap", cx);
cx.update(|_window, cx| {
state.update(cx, |this, _cx| {
assert_eq!(this.selected, None, "typing must clear the value");
assert!(this.is_open(), "typing opens the popup");
assert_eq!(this.visible, vec![0, 1]);
});
});
}
#[gpui::test]
fn blur_reverts_to_the_value_which_typing_cleared(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder.selected(0));
type_into(&state, "nonsense", cx);
cx.update(|window, cx| {
state.update(cx, |this, cx| {
this.blurred(window, cx);
assert_eq!(this.text(cx), SharedString::from(""));
assert!(!this.is_open());
});
});
}
#[gpui::test]
fn blur_leaves_a_committed_value_alone(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder.selected(0));
cx.update(|window, cx| {
state.update(cx, |this, cx| {
this.choose_row(2, window, cx);
this.blurred(window, cx);
assert_eq!(this.text(cx), SharedString::from("Banana"));
assert_eq!(this.selected, Some(2));
});
});
}
#[gpui::test]
fn a_committed_value_survives_the_effect_flush(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder);
type_into(&state, "an", cx);
cx.update(|window, cx| {
state.update(cx, |this, cx| {
assert_eq!(this.visible, vec![2], "\"an\" matches only Banana");
this.choose_row(0, window, cx);
});
});
cx.run_until_parked();
cx.update(|_window, cx| {
state.update(cx, |this, cx| {
assert_eq!(
this.selected,
Some(2),
"the committed value must survive the effect flush"
);
assert_eq!(this.text(cx), SharedString::from("Banana"));
assert!(
!this.is_open(),
"committing must not reopen the popup after the flush"
);
});
});
}
#[gpui::test]
fn blur_revert_stays_closed_across_the_flush(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder.selected(0));
type_into(&state, "nonsense", cx);
cx.update(|window, cx| {
state.update(cx, |this, cx| this.blurred(window, cx));
});
cx.run_until_parked();
cx.update(|_window, cx| {
state.update(cx, |this, cx| {
assert!(
!this.is_open(),
"reverting on blur must not reopen the popup after the flush"
);
assert_eq!(this.text(cx), SharedString::from(""));
});
});
}
#[gpui::test]
fn blur_reverts_to_empty_with_no_value(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder);
type_into(&state, "nonsense", cx);
cx.update(|window, cx| {
state.update(cx, |this, cx| {
this.blurred(window, cx);
assert_eq!(this.text(cx), SharedString::from(""));
});
});
}
#[gpui::test]
fn keep_leaves_unmatched_text_alone(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder.keep_unmatched_text());
type_into(&state, "nonsense", cx);
cx.update(|window, cx| {
state.update(cx, |this, cx| {
this.blurred(window, cx);
assert_eq!(this.text(cx), SharedString::from("nonsense"));
assert_eq!(this.selected, None);
});
});
}
#[gpui::test]
fn create_hands_the_text_to_the_handler(cx: &mut TestAppContext) {
let seen = Rc::new(RefCell::new(Vec::<SharedString>::new()));
let recorder = seen.clone();
let (state, cx) = open(cx, move |builder| {
builder.on_create(move |text, _window, _cx| recorder.borrow_mut().push(text))
});
type_into(&state, "Durian", cx);
cx.update(|window, cx| {
state.update(cx, |this, cx| {
assert_eq!(this.unmatched, UnmatchedText::Create);
this.blurred(window, cx);
});
});
assert_eq!(seen.borrow().as_slice(), &[SharedString::from("Durian")]);
}
#[gpui::test]
fn the_default_filter_is_a_case_insensitive_substring(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder);
cx.update(|_window, cx| {
state.update(cx, |this, _cx| {
assert_eq!(this.refilter("AP").len(), 2);
assert_eq!(this.refilter("rr").len(), 1);
assert_eq!(this.refilter("").len(), 4);
});
});
}
#[gpui::test]
fn no_row_claims_active_descendant(cx: &mut TestAppContext) {
let (state, cx) = open(cx, |builder| builder);
type_into(&state, "a", cx);
cx.update(|_window, cx| {
state.update(cx, |this, cx| {
let listbox = this.listbox.clone().expect("typing opened the popup");
let listbox = listbox.read(cx);
assert!(listbox.highlighted.is_some(), "a row is highlighted");
for index in 0..listbox.options.len() {
assert!(
!listbox.row_a11y(index).is_active_descendant(),
"row {index} claims an active descendant a focused ancestor \
could never honour"
);
}
});
});
}
}