feat: add combobox filter

This commit is contained in:
2026-11-06 21:27:31 +07:00
parent 265d2d129b
commit 0ad5c3fe90
8 changed files with 256 additions and 65 deletions
+128 -13
View File
@@ -34,6 +34,25 @@ struct ComboHost {
}
fn combo_host(keep_unmatched: bool, create: bool) -> ComboHost {
combo_host_with(|builder, created| {
let builder = if keep_unmatched {
builder.keep_unmatched_text()
} else {
builder
};
if create {
builder.on_create(move |text, _, _| created.borrow_mut().push(text))
} else {
builder
}
})
}
/// Builds a host whose combobox the caller configures; the created-recorder
/// is handed over for `on_create` wiring.
fn combo_host_with(
configure: impl FnOnce(Combobox<SharedString>, CreatedRecorder) -> Combobox<SharedString>,
) -> ComboHost {
let cx = TestAppContext::single();
// `init` registers the input bindings and then the combobox's, in the
// load-bearing order.
@@ -47,19 +66,15 @@ fn combo_host(keep_unmatched: bool, create: bool) -> ComboHost {
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 builder = configure(
Combobox::new(
"test-combo",
"Fruit",
vec![option("main"), option("maintain"), option("release")],
)
.on_change(move |value, _, _| changes_for_builder.borrow_mut().push(value)),
created_for_builder,
);
let combobox = cx.new(|cx| ComboboxState::new(builder, window, cx));
cell.borrow_mut().replace(combobox.clone());
cx.new(|_| Host { combobox })
@@ -118,6 +133,7 @@ fn enter_commits_the_highlighted_option() {
type_text(&mut host, "mai");
host.visual.simulate_keystrokes("enter");
host.visual.run_until_parked();
draw(&mut host.visual);
assert_eq!(
@@ -143,6 +159,7 @@ fn typing_clears_the_value() {
// Typing again first clears the value it no longer names.
host.visual.simulate_keystrokes("r");
host.visual.run_until_parked();
draw(&mut host.visual);
assert_eq!(
*host.changes.borrow(),
@@ -197,6 +214,7 @@ fn create_hands_unmatched_text_to_the_handler() {
type_text(&mut host, "feature");
host.visual.update(|window, cx| window.blur(cx));
host.visual.run_until_parked();
draw(&mut host.visual);
assert_eq!(
@@ -217,6 +235,7 @@ fn clicking_a_row_commits_it() {
.expect("release row missing");
host.visual
.simulate_click(row.center(), gpui::Modifiers::default());
host.visual.run_until_parked();
draw(&mut host.visual);
assert_eq!(
@@ -225,3 +244,99 @@ fn clicking_a_row_commits_it() {
);
assert!(host.visual.debug_bounds("test-combo-panel").is_none());
}
/// Regression: handlers used to run inside an update of the combobox
/// entity, so a handler that touched its own state re-entered and panicked.
/// Both handlers now run on the executor and may update the state freely.
#[test]
fn handlers_may_update_their_own_combobox() {
let cx = TestAppContext::single();
cx.update(init);
let state_cell: Rc<RefCell<Option<Entity<ComboboxState<SharedString>>>>> =
Rc::new(RefCell::new(None));
let cell = state_cell.clone();
let window = cx.update(|cx| {
cx.open_window(gpui::WindowOptions::default(), |window, cx| {
let builder = Combobox::new(
"self-updating-combo",
"Label",
vec![option("bug"), option("docs")],
)
.on_create(move |text: SharedString, window, cx| {
let state = cell.borrow().clone().expect("state captured");
state.update(cx, |state, cx| {
let mut options = state.options_snapshot();
if !options.iter().any(|(value, _)| value == &text) {
options.push((text.clone(), text.clone()));
state.set_options(options, cx);
}
state.set_selected(Some(text), window, cx);
});
});
let combobox = cx.new(|cx| ComboboxState::new(builder, window, cx));
state_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);
visual.update(|window, cx| window.draw(cx).clear(cx));
// Typing an unmatched name and blurring hands it to `on_create`, which
// updates this very combobox — the crash the deferral fixes.
visual.update(|window, cx| {
state.update(cx, |state, cx| state.focus(window, cx));
});
for letter in "feature".chars() {
visual.simulate_keystrokes(&letter.to_string());
}
visual.update(|window, cx| window.blur(cx));
visual.run_until_parked();
visual.update(|window, cx| window.draw(cx).clear(cx));
let selected = cx.update(|cx| state.read(cx).selected().cloned());
assert_eq!(selected, Some(SharedString::from("feature")));
let options = cx.update(|cx| state.read(cx).options_snapshot());
assert!(options.iter().any(|(value, _)| value == "feature"));
}
/// Matches when the query's characters appear in the label in order, so
/// "mn" finds "main" though the substring matcher would find nothing.
fn subsequence(query: &str, _value: &SharedString, label: &SharedString) -> bool {
let label = label.to_lowercase();
let mut letters = label.chars();
query
.to_lowercase()
.chars()
.all(|letter| letters.any(|found| found == letter))
}
#[test]
fn a_custom_filter_decides_which_options_survive() {
let mut host = combo_host_with(|builder, _| builder.filter(subsequence));
// "mn" is no substring of any option, so only the custom matcher finds
// "main" and "maintain"; "release" has no "n" and drops out.
type_text(&mut host, "mn");
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());
}
#[test]
fn replacing_the_options_reruns_the_custom_filter() {
let mut host = combo_host_with(|builder, _| builder.filter(subsequence));
type_text(&mut host, "mn");
host.cx.update(|cx| {
host.state.update(cx, |state, cx| {
state.set_options(vec![option("metro"), option("mainline")], cx)
})
});
draw(&mut host.visual);
// "metro" has no "n" after its "m"; "mainline" survives as the only row.
assert!(host.visual.debug_bounds("test-combo-option-0").is_some());
assert!(host.visual.debug_bounds("test-combo-option-1").is_none());
}