feat: add combobox component

This commit is contained in:
2026-11-04 08:08:02 +07:00
parent 7d1bceefd5
commit ded23ab2f2
7 changed files with 1126 additions and 3 deletions
+227
View File
@@ -0,0 +1,227 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, VisualTestContext, Window, div,
};
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
use gpui_native_core::init;
fn option(label: &'static str) -> ComboboxOption<SharedString> {
(SharedString::from(label), SharedString::from(label))
}
struct Host {
combobox: Entity<ComboboxState<SharedString>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().p_4().child(self.combobox.clone())
}
}
type Recorder = Rc<RefCell<Vec<Option<SharedString>>>>;
type CreatedRecorder = Rc<RefCell<Vec<SharedString>>>;
struct ComboHost {
cx: TestAppContext,
state: Entity<ComboboxState<SharedString>>,
changes: Recorder,
created: CreatedRecorder,
visual: VisualTestContext,
}
fn combo_host(keep_unmatched: bool, create: bool) -> ComboHost {
let cx = TestAppContext::single();
// `init` registers the input bindings and then the combobox's, in the
// load-bearing order.
cx.update(init);
let changes: Recorder = Rc::new(RefCell::new(Vec::new()));
let created: Rc<RefCell<Vec<SharedString>>> = Rc::new(RefCell::new(Vec::new()));
let state_cell: Rc<RefCell<Option<Entity<ComboboxState<SharedString>>>>> =
Rc::new(RefCell::new(None));
let window = cx.update(|cx| {
let changes_for_builder = changes.clone();
let created_for_builder = created.clone();
let cell = state_cell.clone();
cx.open_window(gpui::WindowOptions::default(), |window, cx| {
let mut builder = Combobox::new(
"test-combo",
"Fruit",
vec![option("main"), option("maintain"), option("release")],
)
.on_change(move |value, _, _| changes_for_builder.borrow_mut().push(value));
if keep_unmatched {
builder = builder.keep_unmatched_text();
}
if create {
builder = builder
.on_create(move |text, _, _| created_for_builder.borrow_mut().push(text));
}
let combobox = cx.new(|cx| ComboboxState::new(builder, window, cx));
cell.borrow_mut().replace(combobox.clone());
cx.new(|_| Host { combobox })
})
.unwrap()
});
let state = state_cell.borrow().clone().expect("state captured");
let mut visual = VisualTestContext::from_window(window.into(), &cx);
draw(&mut visual);
ComboHost {
cx,
state,
changes,
created,
visual,
}
}
fn draw(visual: &mut VisualTestContext) {
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
}
fn focus(host: &mut ComboHost) {
let state = host.state.clone();
host.visual.update(|window, cx| {
state.update(cx, |state, cx| state.focus(window, cx));
});
}
fn type_text(host: &mut ComboHost, text: &str) {
focus(host);
for letter in text.chars() {
host.visual.simulate_keystrokes(&letter.to_string());
}
draw(&mut host.visual);
}
#[test]
fn typing_opens_the_picker_and_filters_the_options() {
let mut host = combo_host(false, false);
type_text(&mut host, "mai");
assert!(host.visual.debug_bounds("test-combo-option-0").is_some());
assert!(host.visual.debug_bounds("test-combo-option-1").is_some());
assert!(
host.visual.debug_bounds("test-combo-option-2").is_none(),
"release must not match \"mai\""
);
}
#[test]
fn enter_commits_the_highlighted_option() {
let mut host = combo_host(false, false);
type_text(&mut host, "mai");
host.visual.simulate_keystrokes("enter");
draw(&mut host.visual);
assert_eq!(
*host.changes.borrow(),
vec![Some(SharedString::from("main"))],
"the first surviving option starts highlighted"
);
assert!(host.visual.debug_bounds("test-combo-panel").is_none());
let text = host.cx.update(|cx| host.state.read(cx).text(cx));
assert_eq!(text, "main");
}
#[test]
fn typing_clears_the_value() {
let mut host = combo_host(false, false);
type_text(&mut host, "main");
host.visual.simulate_keystrokes("enter");
draw(&mut host.visual);
assert_eq!(
*host.changes.borrow(),
vec![Some(SharedString::from("main"))]
);
// Typing again first clears the value it no longer names.
host.visual.simulate_keystrokes("r");
draw(&mut host.visual);
assert_eq!(
*host.changes.borrow(),
vec![Some(SharedString::from("main")), None]
);
}
#[test]
fn escape_closes_without_committing() {
let mut host = combo_host(false, false);
type_text(&mut host, "mai");
host.visual.simulate_keystrokes("escape");
draw(&mut host.visual);
assert!(host.visual.debug_bounds("test-combo-panel").is_none());
assert!(host.changes.borrow().is_empty(), "escape must not commit");
}
#[test]
fn blur_reverts_unmatched_text_to_the_empty_selection() {
let mut host = combo_host(false, false);
type_text(&mut host, "nonsense");
assert!(host.changes.borrow().is_empty());
host.visual.update(|window, cx| window.blur(cx));
draw(&mut host.visual);
assert!(host.changes.borrow().is_empty());
let text = host.cx.update(|cx| host.state.read(cx).text(cx));
assert_eq!(text, "", "revert with no value empties the field");
}
#[test]
fn keep_unmatched_leaves_the_text() {
let mut host = combo_host(true, false);
type_text(&mut host, "nonsense");
host.visual.update(|window, cx| window.blur(cx));
draw(&mut host.visual);
let text = host.cx.update(|cx| host.state.read(cx).text(cx));
assert_eq!(text, "nonsense", "keep leaves the text in the field");
// The picker started with no value, so typing cleared nothing and there
// was nothing to report.
assert!(host.changes.borrow().is_empty());
}
#[test]
fn create_hands_unmatched_text_to_the_handler() {
let mut host = combo_host(false, true);
type_text(&mut host, "feature");
host.visual.update(|window, cx| window.blur(cx));
draw(&mut host.visual);
assert_eq!(
*host.created.borrow(),
vec![SharedString::from("feature")],
"create hands the text over on blur"
);
}
#[test]
fn clicking_a_row_commits_it() {
let mut host = combo_host(false, false);
type_text(&mut host, "rel");
let row = host
.visual
.debug_bounds("test-combo-option-0")
.expect("release row missing");
host.visual
.simulate_click(row.center(), gpui::Modifiers::default());
draw(&mut host.visual);
assert_eq!(
*host.changes.borrow(),
vec![Some(SharedString::from("release"))]
);
assert!(host.visual.debug_bounds("test-combo-panel").is_none());
}
+88
View File
@@ -0,0 +1,88 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, TestAppContext, Window, div,
};
use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState};
use gpui_native_core::init;
fn option(label: &'static str) -> ComboboxOption<SharedString> {
(SharedString::from(label), SharedString::from(label))
}
struct Host {
combobox: Entity<ComboboxState<SharedString>>,
renders: Rc<RefCell<usize>>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
*self.renders.borrow_mut() += 1;
div().size_full().p_4().child(self.combobox.clone())
}
}
#[test]
fn real_keystrokes_are_not_pathologically_slow() {
let cx = TestAppContext::single();
cx.update(init);
let (state, renders, window) = cx.update(|cx| {
let renders = Rc::new(RefCell::new(0));
let state_cell: Rc<RefCell<Option<Entity<ComboboxState<SharedString>>>>> =
Rc::new(RefCell::new(None));
let renders_for_host = renders.clone();
let cell = state_cell.clone();
let window = cx
.open_window(gpui::WindowOptions::default(), |window, cx| {
let state = cx.new(|cx| {
ComboboxState::new(
Combobox::new(
"bench-combo",
"Fruit",
vec![
option("main"),
option("maintain"),
option("release"),
option("banana"),
],
),
window,
cx,
)
});
let state_for_host = state.clone();
cell.borrow_mut().replace(state.clone());
cx.new(|_| Host {
combobox: state_for_host,
renders: renders_for_host,
})
})
.unwrap();
let state = state_cell.borrow().clone().expect("state captured");
(state, renders, window)
});
let mut visual = gpui::VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
state.update(cx, |state, cx| state.focus(window, cx));
window.draw(cx).clear(cx);
});
let started = Instant::now();
for letter in "abcdefghijklmnopqrstuvwxyz".chars() {
visual.simulate_keystrokes(&letter.to_string());
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
}
let elapsed = started.elapsed();
let total_renders = *renders.borrow();
println!(
"26 real keystrokes: {elapsed:?}, host renders: {total_renders} ({} per keystroke)",
total_renders as f32 / 26.
);
assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}");
}