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::from(label), SharedString::from(label)) } struct Host { combobox: Entity>, } impl Render for Host { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().p_4().child(self.combobox.clone()) } } type Recorder = Rc>>>; type CreatedRecorder = Rc>>; struct ComboHost { cx: TestAppContext, state: Entity>, 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, CreatedRecorder) -> Combobox, ) -> 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>> = Rc::new(RefCell::new(Vec::new())); let state_cell: Rc>>>> = 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) { 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>>>> = 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()); }