Files
gpui-native-kit/crates/core/tests/combobox.rs
T
2026-11-08 11:00:39 +07:00

368 lines
12 KiB
Rust

use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
AppContext as _, Context, Entity, IntoElement, MouseButton, ParentElement as _, Render,
SharedString, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
};
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 {
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.
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 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 })
})
.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 click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
visual.simulate_mouse_down(position, MouseButton::Left, gpui::Modifiers::default());
visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default());
}
/// Clicking into a valueless combobox field opens the options right away:
/// no keystroke is needed to see the choices.
#[test]
fn clicking_the_empty_field_opens_the_popup() {
let mut host = combo_host(false, false);
// Inside the field, which sits at the top of the padded host.
click(&mut host.visual, point(px(100.), px(27.)));
draw(&mut host.visual);
assert!(
host.state.read_with(&host.cx, |state, _| state.is_open()),
"clicking the empty field should show the options"
);
assert!(
host.visual.debug_bounds("test-combo-panel").is_some(),
"the options panel should be rendered"
);
}
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");
host.visual.run_until_parked();
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");
host.visual.run_until_parked();
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));
host.visual.run_until_parked();
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());
host.visual.run_until_parked();
draw(&mut host.visual);
assert_eq!(
*host.changes.borrow(),
vec![Some(SharedString::from("release"))]
);
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());
}