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::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 { 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 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()); }