use crate::{
icons,
input::{self, TextField},
popover,
widgets::Controls,
};
use gpui::{
App, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, Pixels, SharedString,
Window, actions, canvas, div, prelude::*, px,
};
use theme::{TextStyle, Theme, Typeset};
actions!(
bezel_combobox,
[SelectNext, SelectPrevious, Confirm, Dismiss]
);
pub const KEY_CONTEXT: &str = "Combobox";
pub fn init(cx: &mut App) {
let ctx = Some(KEY_CONTEXT);
cx.bind_keys([
KeyBinding::new("down", SelectNext, ctx),
KeyBinding::new("up", SelectPrevious, ctx),
KeyBinding::new("enter", Confirm, ctx),
KeyBinding::new("escape", Dismiss, ctx),
KeyBinding::new("ctrl-n", SelectNext, ctx),
KeyBinding::new("ctrl-p", SelectPrevious, ctx),
]);
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ComboboxEvent {
Selected(usize),
}
pub struct Combobox {
query: Entity<TextField>,
filter: popover::Filter,
menu: popover::Popup<()>,
chosen: Option<usize>,
placeholder: SharedString,
trigger_width: Option<Pixels>,
focus_handle: FocusHandle,
}
impl EventEmitter<ComboboxEvent> for Combobox {}
impl Combobox {
pub fn new(
items: Vec<SharedString>,
placeholder: impl Into<SharedString>,
cx: &mut Context<Self>,
) -> Self {
let query = cx.new(|cx| {
TextField::new(cx)
.with_placeholder("Search…")
.with_frame(false)
});
cx.subscribe(&query, |combobox, query, _: &input::FieldEvent, cx| {
let query = query.read(cx).content().clone();
combobox.filter.refilter(&query);
cx.notify();
})
.detach();
Self {
query,
filter: popover::Filter::new(items),
menu: popover::Popup::default(),
chosen: None,
placeholder: placeholder.into(),
trigger_width: None,
focus_handle: cx.focus_handle().tab_stop(true),
}
}
pub fn with_selection(mut self, item: usize) -> Self {
self.chosen = (item < self.filter.items().len()).then_some(item);
self
}
pub fn selection(&self) -> Option<usize> {
self.chosen
}
fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.menu.take_press_was_open() {
self.close(cx);
} else {
self.query.update(cx, |query, cx| query.clear(cx));
self.filter.refilter("");
self.menu.open(());
window.focus(&self.query.focus_handle(cx), cx);
cx.notify();
}
}
fn close(&mut self, cx: &mut Context<Self>) {
if self.menu.begin_close() {
popover::reap_popup(cx, |combobox: &mut Self| &mut combobox.menu);
}
cx.notify();
}
fn choose(&mut self, item: usize, cx: &mut Context<Self>) {
self.chosen = Some(item);
cx.emit(ComboboxEvent::Selected(item));
self.close(cx);
}
fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
self.filter.step(1);
cx.notify();
}
fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
self.filter.step(-1);
cx.notify();
}
fn confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
if let Some(item) = self.filter.active_item() {
self.choose(item, cx);
}
}
fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
self.close(cx);
}
fn menu_card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
let rows: Vec<gpui::AnyElement> = self
.filter
.filtered()
.iter()
.enumerate()
.map(|(position, &item)| {
popover::menu_row(theme, Some(position) == self.filter.active(), None)
.justify_between()
.id(SharedString::from(format!("row-{item}")))
.on_mouse_move(cx.listener(move |combobox: &mut Self, _, _, cx| {
if combobox.filter.active() != Some(position) {
combobox.filter.set_active(position);
cx.notify();
}
}))
.on_click(cx.listener(move |combobox, _, _, cx| combobox.choose(item, cx)))
.child(self.filter.items()[item].clone())
.when(Some(item) == self.chosen, |row| {
row.child(
icons::icon(icons::CHECK)
.size(px(13.0))
.text_color(theme.text),
)
})
.into_any_element()
})
.collect();
popover::popover_card(theme)
.w(self.trigger_width.unwrap_or(px(200.0)))
.on_mouse_down_out(cx.listener(|combobox, _, _, cx| combobox.close(cx)))
.child(popover::search_line(
theme,
self.query.clone().into_any_element(),
))
.child(if rows.is_empty() {
div()
.px(px(10.0))
.py(px(8.0))
.text_style(TextStyle::Body)
.text_color(theme.text_muted)
.child("No matches")
.into_any_element()
} else {
div().flex().flex_col().children(rows).into_any_element()
})
.into_any_element()
}
}
impl Focusable for Combobox {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Combobox {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
let open = self.menu.is_open() || self.menu.is_closing();
let label = match self.chosen {
Some(item) => self.filter.items()[item].clone(),
None => self.placeholder.clone(),
};
let card = open.then(|| self.menu_card(&theme, cx));
let combobox = cx.entity().downgrade();
div()
.key_context(KEY_CONTEXT)
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::dismiss))
.relative()
.w_full()
.child(
canvas(
move |bounds, _, cx| {
combobox
.update(cx, |combobox, _| {
combobox.trigger_width = Some(bounds.size.width);
})
.ok();
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.child(
div()
.id("combobox-trigger")
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|combobox, _, _, _| combobox.menu.note_trigger_press()),
)
.on_click(cx.listener(|combobox, _, window, cx| combobox.toggle(window, cx)))
.child(theme.select_trigger(label)),
)
.when_some(card, |trigger, card| {
trigger.child(popover::anchored_menu_below(
"combobox-menu",
card,
self.menu.closing_since(),
))
})
}
}
pub use input::KEY_CONTEXT as FIELD_KEY_CONTEXT;