use crate::{
input_ui::{Input, InputEvent, InputState},
prelude::*,
*,
};
use std::sync::Arc;
pub struct ComboboxState {
input: Entity<InputState>,
items: Vec<SharedString>,
filtered: Vec<usize>,
multiple: bool,
selected: Vec<usize>,
open: bool,
on_change: Option<Arc<dyn Fn(Vec<usize>, &mut Window, &mut App) + Send + Sync + 'static>>,
pending_emit: bool,
needs_focus: bool,
}
impl ComboboxState {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("搜索…"));
cx.subscribe(&input, |this, _input, event, cx| match event {
InputEvent::Change => {
this.refilter(cx);
}
InputEvent::PressEnter { .. } => {
if let Some(&ix) = this.filtered.first() {
this.toggle_select(ix, cx);
}
}
InputEvent::Focus => {
this.open = true;
cx.notify();
}
_ => {}
})
.detach();
Self {
input,
items: Vec::new(),
filtered: Vec::new(),
multiple: false,
selected: Vec::new(),
open: false,
on_change: None,
pending_emit: false,
needs_focus: false,
}
}
pub fn items(mut self, items: Vec<SharedString>) -> Self {
self.filtered = (0..items.len()).collect();
self.selected.clear();
self.items = items;
self
}
pub fn multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;
self
}
pub fn on_change<F>(mut self, f: F) -> Self
where
F: Fn(Vec<usize>, &mut Window, &mut App) + Send + Sync + 'static,
{
self.on_change = Some(Arc::new(f));
self
}
pub fn input(&self) -> &Entity<InputState> {
&self.input
}
pub fn selected(&self) -> &[usize] {
&self.selected
}
fn refilter(&mut self, cx: &mut Context<Self>) {
let query = self.input.read(cx).text().to_string().to_lowercase();
self.filtered = self
.items
.iter()
.enumerate()
.filter(|(_, item)| item.to_lowercase().contains(&query))
.map(|(ix, _)| ix)
.collect();
self.open = true;
cx.notify();
}
fn toggle_select(&mut self, ix: usize, cx: &mut Context<Self>) {
if self.multiple {
if let Some(pos) = self.selected.iter().position(|&i| i == ix) {
self.selected.remove(pos);
} else {
self.selected.push(ix);
}
} else {
self.selected = vec![ix];
self.open = false;
}
let selected = self.selected.clone();
if let Some(ref cb) = self.on_change.clone() {
let _ = (cb, selected);
self.pending_emit = true;
}
self.needs_focus = true;
cx.notify();
}
}
impl Render for ComboboxState {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let input = self.input.clone();
if self.pending_emit {
self.pending_emit = false;
if let Some(ref cb) = self.on_change.clone() {
cb(self.selected.clone(), window, cx);
}
}
if self.needs_focus {
self.needs_focus = false;
window.focus(&input.focus_handle(cx), cx);
}
let theme = cx.theme();
let border = theme.tokens.border;
let popover = theme.tokens.popover;
let accent = theme.tokens.accent.color;
let muted_foreground = theme.tokens.muted_foreground.color;
let panel = cx.entity();
let open = self.open && !self.filtered.is_empty();
let selected = self.selected.clone();
let items = self.items.clone();
div()
.flex()
.flex_col()
.w(px(240.0))
.child(Input::new(&input).w_full())
.when(open, |this| {
this.child(
div()
.flex()
.flex_col()
.mt(px(4.0))
.max_h(px(200.0))
.overflow_y_scrollbar()
.bg(popover)
.border_1()
.border_color(border)
.rounded_md()
.p(px(4.0))
.gap(px(2.0))
.children(self.filtered.iter().map(|&ix| {
let panel = panel.clone();
let label = items.get(ix).cloned().unwrap_or_default();
let checked = selected.contains(&ix);
div()
.id(ix)
.flex()
.items_center()
.gap(px(6.0))
.px(px(8.0))
.py(px(6.0))
.rounded_sm()
.cursor_pointer()
.when(checked, |this| this.bg(accent.opacity(0.15)))
.when(!checked, |this| {
this.hover(|this| this.bg(accent.opacity(0.08)))
})
.child(
div()
.text_sm()
.text_color(muted_foreground)
.child(if checked { "✓ " } else { "" })
.child(label),
)
.on_click(move |_, _, cx| {
panel.update(cx, |this, cx| this.toggle_select(ix, cx));
})
})),
)
})
}
}
#[cfg(test)]
mod tests {
use super::ComboboxState;
use crate::input_ui::{Backspace, InputState};
use crate::{AppContext as _, Context, Entity, Render, Window};
struct Probe {
state: Entity<ComboboxState>,
}
impl Render for Probe {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl crate::IntoElement {
crate::div()
}
}
fn with_items(
window: &mut Window,
cx: &mut Context<Probe>,
) -> (Entity<ComboboxState>, Entity<InputState>) {
let combo = cx.new(|cx| {
ComboboxState::new(window, cx).items(vec![
"apple".into(),
"apricot".into(),
"banana".into(),
])
});
let input = combo.read(cx).input().clone();
(combo, input)
}
#[rgpui::test]
fn typing_filters_and_opens(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let (combo, _) = with_items(window, cx);
Probe { state: combo }
});
let combo = probe.read_with(cx, |probe, _| probe.state.clone());
let input = combo.read_with(cx, |state, _| state.input().clone());
cx.update(|window, cx| {
input.update(cx, |state, cx| {
crate::EntityInputHandler::replace_text_in_range(state, None, "ap", window, cx);
});
});
let (open, filtered) = combo.read_with(cx, |state, _| (state.open, state.filtered.clone()));
assert!(open);
assert_eq!(filtered, vec![0, 1]);
}
#[rgpui::test]
fn backspace_deletes_and_refilters(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let (combo, _) = with_items(window, cx);
Probe { state: combo }
});
let combo = probe.read_with(cx, |probe, _| probe.state.clone());
let input = combo.read_with(cx, |state, _| state.input().clone());
cx.update(|window, cx| {
input.update(cx, |state, cx| {
crate::EntityInputHandler::replace_text_in_range(state, None, "ap", window, cx);
state.backspace(&Backspace, window, cx);
});
});
let text = input.read_with(cx, |state, _| state.text().to_string());
assert_eq!(text, "a");
let filtered = combo.read_with(cx, |state, _| state.filtered.clone());
assert_eq!(filtered, vec![0, 1, 2]);
}
#[rgpui::test]
fn focus_opens(cx: &mut crate::TestAppContext) {
use crate::input_ui::InputEvent;
let (probe, cx) = cx.add_window_view(|window, cx| {
let (combo, _) = with_items(window, cx);
Probe { state: combo }
});
let combo = probe.read_with(cx, |probe, _| probe.state.clone());
let input = combo.read_with(cx, |state, _| state.input().clone());
assert!(!combo.read_with(cx, |state, _| state.open));
cx.update(|_, cx| {
input.update(cx, |_, cx| {
cx.emit(InputEvent::Focus);
});
});
assert!(combo.read_with(cx, |state, _| state.open));
}
}