feat: add combobox filter
This commit is contained in:
+128
-13
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user