use gpui::{ Entity, EntityInputHandler as _, KeyBinding, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, Render, ScrollDelta, ScrollEvent, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, }; use gpui_native_core::input::{ INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, bind_input_keys, }; use gpui_native_theme::Theme; use std::cell::RefCell; use std::rc::Rc; use std::time::Duration; struct Harness { state: Entity, disabled: bool, } impl Render for Harness { fn render( &mut self, _window: &mut Window, _cx: &mut gpui::Context, ) -> impl gpui::IntoElement { div().size_full().child( Input::new("test-input", self.state.clone()) .placeholder("Placeholder") .disabled(self.disabled), ) } } fn input_window( initial: &str, disabled: bool, ) -> (TestAppContext, Entity, VisualTestContext) { input_window_with_bindings(initial, disabled, InputBindings::default()) } fn input_window_with_bindings( initial: &str, disabled: bool, bindings: InputBindings, ) -> (TestAppContext, Entity, VisualTestContext) { let mut cx = TestAppContext::single(); cx.update(|cx| { Theme::init(cx); bind_input_keys(cx, bindings); }); let state = cx.update(|cx| InputState::new_with_text(initial, cx)); let state_for_window = state.clone(); let window = cx.add_window(move |_, _| Harness { state: state_for_window, disabled, }); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); (cx, state, visual) } fn value(cx: &TestAppContext, state: &Entity) -> String { cx.update(|cx| state.read(cx).value().to_string()) } fn focus(visual: &mut VisualTestContext, state: &Entity) { // Programmatic focus: a click would move the caret to the click point. visual.update(|window, cx| { let handle = state.read(cx).focus_handle(); window.focus(&handle, cx); }); } #[test] fn typing_inserts_at_the_caret() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); visual.simulate_keystrokes("h e l l o"); assert_eq!(value(&cx, &state), "hello"); visual.simulate_keystrokes("space w o r l d"); assert_eq!(value(&cx, &state), "hello world"); } #[test] fn arrow_left_moves_the_caret_and_typing_splits_the_text() { let (cx, state, mut visual) = input_window("ab", false); focus(&mut visual, &state); visual.simulate_keystrokes("left"); visual.simulate_keystrokes("x"); assert_eq!(value(&cx, &state), "axb"); } #[test] fn backspace_deletes_before_the_caret_and_delete_after() { let (cx, state, mut visual) = input_window("abc", false); focus(&mut visual, &state); visual.simulate_keystrokes("backspace"); assert_eq!(value(&cx, &state), "ab"); // The caret sits between 'a' and 'b'; forward delete removes 'b'. visual.simulate_keystrokes("left"); visual.simulate_keystrokes("delete"); assert_eq!(value(&cx, &state), "a"); } #[test] fn alt_arrow_moves_by_words() { let (cx, state, mut visual) = input_window("hello world", false); focus(&mut visual, &state); visual.simulate_keystrokes("alt-left"); visual.simulate_keystrokes("x"); assert_eq!(value(&cx, &state), "hello xworld"); visual.simulate_keystrokes("alt-left alt-left"); visual.simulate_keystrokes("shift-right shift-right"); visual.simulate_keystrokes("z"); assert_eq!(value(&cx, &state), "zllo xworld"); } #[test] fn select_all_then_type_replaces_the_value() { let (cx, state, mut visual) = input_window("abcd", false); focus(&mut visual, &state); visual.simulate_keystrokes("cmd-a"); visual.simulate_keystrokes("z"); assert_eq!(value(&cx, &state), "z"); } #[cfg(target_os = "macos")] #[test] fn cmd_arrow_jumps_to_the_field_ends() { let (cx, state, mut visual) = input_window("hello world", false); focus(&mut visual, &state); // Caret starts at the end; cmd-left brings it to the start. visual.simulate_keystrokes("cmd-left"); visual.simulate_keystrokes("x"); assert_eq!(value(&cx, &state), "xhello world"); // cmd-shift-right selects from the caret to the end. visual.simulate_keystrokes("cmd-shift-right"); visual.simulate_keystrokes("backspace"); assert_eq!(value(&cx, &state), "x"); // cmd-up / cmd-down mirror the jumps. visual.simulate_keystrokes("cmd-down"); visual.simulate_keystrokes("x"); assert_eq!(value(&cx, &state), "xx"); visual.simulate_keystrokes("cmd-up"); visual.simulate_keystrokes("y"); assert_eq!(value(&cx, &state), "yxx"); } #[cfg(target_os = "macos")] #[test] fn cmd_backspace_deletes_to_the_field_start() { let (cx, state, mut visual) = input_window("hello", false); focus(&mut visual, &state); // The caret starts at the end; cmd-backspace clears the whole field. visual.simulate_keystrokes("cmd-backspace"); assert_eq!(value(&cx, &state), ""); } #[test] fn undo_reverts_a_typing_burst_as_one_step() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); visual.simulate_keystrokes("h e l l o"); visual.simulate_keystrokes("cmd-z"); assert_eq!(value(&cx, &state), ""); visual.simulate_keystrokes("cmd-shift-z"); assert_eq!(value(&cx, &state), "hello"); } #[test] fn undo_steps_through_edits_outside_the_group_interval() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); visual.update(|_, cx| { state.update(cx, |state, _| state.set_group_interval(Duration::ZERO)); }); visual.simulate_keystrokes("a b"); assert_eq!(value(&cx, &state), "ab"); visual.simulate_keystrokes("cmd-z"); assert_eq!(value(&cx, &state), "a"); visual.simulate_keystrokes("cmd-z"); assert_eq!(value(&cx, &state), ""); visual.simulate_keystrokes("cmd-shift-z"); assert_eq!(value(&cx, &state), "a"); visual.simulate_keystrokes("cmd-shift-z"); assert_eq!(value(&cx, &state), "ab"); // A deletion gets its own entry and undoes back to the typed text. visual.simulate_keystrokes("backspace"); assert_eq!(value(&cx, &state), "a"); visual.simulate_keystrokes("cmd-z"); assert_eq!(value(&cx, &state), "ab"); } #[test] fn new_edits_clear_the_redo_stack() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); visual.simulate_keystrokes("a"); visual.simulate_keystrokes("cmd-z"); visual.simulate_keystrokes("b"); visual.simulate_keystrokes("cmd-shift-z"); assert_eq!(value(&cx, &state), "b"); visual.simulate_keystrokes("cmd-z"); assert_eq!(value(&cx, &state), ""); } #[test] fn set_value_clears_the_history() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); visual.simulate_keystrokes("typed"); visual.update(|window, cx| { state.update(cx, |state, cx| state.set_value("preset", window, cx)); }); visual.simulate_keystrokes("cmd-z"); assert_eq!(value(&cx, &state), "preset"); } #[test] fn read_only_refuses_edits_but_keeps_selection_and_copy() { let (cx, state, mut visual) = input_window("hello", false); focus(&mut visual, &state); visual.update(|_, cx| { state.update(cx, |state, cx| state.set_read_only(true, cx)); }); // Typing and backspace are refused. visual.simulate_keystrokes("x backspace"); assert_eq!(value(&cx, &state), "hello"); // Paste is refused. cx.update(|cx| { cx.write_to_clipboard(gpui::ClipboardItem::new_string("Z".to_string())); }); visual.simulate_keystrokes("cmd-v"); assert_eq!(value(&cx, &state), "hello"); // Selection and copy still work. visual.simulate_keystrokes("shift-left shift-left"); visual.simulate_keystrokes("cmd-c"); let clipboard = cx.update(|cx| { cx.read_from_clipboard() .and_then(|item| item.text()) .unwrap_or_default() }); assert_eq!(clipboard, "lo"); // Cut neither changes the field nor touches the clipboard. visual.simulate_keystrokes("cmd-x"); assert_eq!(value(&cx, &state), "hello"); let clipboard = cx.update(|cx| { cx.read_from_clipboard() .and_then(|item| item.text()) .unwrap_or_default() }); assert_eq!(clipboard, "lo"); // The caret is suppressed entirely while read-only. let caret = cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx))); assert!(!caret); } #[test] fn caret_blinks_while_focused_and_holds_solid_while_typing() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); visual.update(|window, cx| { window.draw(cx).clear(cx); }); visual.run_until_parked(); let caret = || cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(true, cx))); // The caret shows on focus, hides after one interval, and reappears // after another. assert!(caret()); cx.executor().advance_clock(Duration::from_millis(500)); visual.run_until_parked(); assert!(!caret()); cx.executor().advance_clock(Duration::from_millis(500)); visual.run_until_parked(); assert!(caret()); // Typing holds the caret solid and restarts the cycle. visual.simulate_keystrokes("a"); assert!(caret()); cx.executor().advance_clock(Duration::from_millis(500)); visual.run_until_parked(); assert!(!caret()); // An unfocused field reports no caret. let caret_unfocused = cx.update(|cx| state.update(cx, |state, cx| state.caret_visible(false, cx))); assert!(!caret_unfocused); } #[test] fn horizontal_scroll_follows_the_caret_and_wheels_back() { let text = "a".repeat(300); let (_cx, state, mut visual) = input_window(&text, false); focus(&mut visual, &state); let draw = |visual: &mut VisualTestContext| { visual.update(|window, cx| { window.draw(cx).clear(cx); }); }; // The text index at the field's left edge, through the last paint. let left_edge_index = |visual: &mut VisualTestContext| -> usize { visual .update(|window, cx| { state.update(cx, |state, cx| { state.character_index_for_point(point(px(2.), px(14.)), window, cx) }) }) .unwrap_or(0) }; // The caret starts at the end; the view scrolls to keep it in view, so // the left edge shows a later character. draw(&mut visual); let scrolled_index = left_edge_index(&mut visual); assert!(scrolled_index > 0, "left edge index: {scrolled_index}"); // Moving to the start scrolls the view back: the left edge is index 0. visual.simulate_keystrokes("home"); draw(&mut visual); assert_eq!(left_edge_index(&mut visual), 0); // A wheel scroll pans the view right without moving the caret. visual.simulate_event(ScrollEvent { position: point(px(200.), px(20.)), delta: ScrollDelta::Pixels(point(px(-20.), px(0.))), ..Default::default() }); draw(&mut visual); let panned_index = left_edge_index(&mut visual); assert!(panned_index > 0, "left edge index: {panned_index}"); } #[test] fn read_only_blocks_undo_redo_but_set_value_still_writes() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); visual.simulate_keystrokes("a"); visual.simulate_keystrokes("cmd-z"); assert_eq!(value(&cx, &state), ""); visual.update(|_, cx| { state.update(cx, |state, cx| state.set_read_only(true, cx)); }); visual.simulate_keystrokes("cmd-shift-z"); assert_eq!(value(&cx, &state), ""); visual.update(|window, cx| { state.update(cx, |state, cx| state.set_value("preset", window, cx)); }); assert_eq!(value(&cx, &state), "preset"); } #[test] fn escape_blurs_the_field() { let (mut cx, state, mut visual) = input_window("", false); let (sink, _subscription) = record_events(&mut cx, &state); focus(&mut visual, &state); visual.update(|window, cx| { window.draw(cx).clear(cx); }); visual.run_until_parked(); assert_eq!(count(&sink, &InputStateEvent::Focused), 1); visual.simulate_keystrokes("escape"); let focused = visual.update(|window, cx| state.read(cx).focus_handle().is_focused(window)); assert!(!focused); visual.update(|window, cx| { window.draw(cx).clear(cx); }); visual.run_until_parked(); assert_eq!(count(&sink, &InputStateEvent::Blurred), 1); } #[test] fn custom_bindings_replace_and_unbind_keys() { // Only submit is bound, and on a custom key. let mut bindings = InputBindings::empty(); bindings.submit = Some(KeyBinding::new("cmd-enter", Submit, Some(INPUT_CONTEXT))); let (mut cx, state, mut visual) = input_window_with_bindings("", false, bindings); let (sink, _subscription) = record_events(&mut cx, &state); focus(&mut visual, &state); // The default enter binding is gone. visual.simulate_keystrokes("enter"); assert_eq!(count(&sink, &InputStateEvent::Submitted), 0); // The override fires. visual.simulate_keystrokes("cmd-enter"); assert_eq!(count(&sink, &InputStateEvent::Submitted), 1); // Unbound actions do nothing; unbound characters still type. visual.simulate_keystrokes("cmd-z"); assert_eq!(value(&cx, &state), ""); visual.simulate_keystrokes("a"); assert_eq!(value(&cx, &state), "a"); } #[test] fn triple_click_selects_all_and_typing_replaces_it() { let (cx, state, mut visual) = input_window("hello world", false); focus(&mut visual, &state); let at = point(px(30.), px(14.)); visual.simulate_event(MouseDownEvent { position: at, modifiers: Modifiers::default(), button: MouseButton::Left, click_count: 3, first_mouse: false, }); visual.simulate_event(MouseUpEvent { position: at, modifiers: Modifiers::default(), button: MouseButton::Left, click_count: 3, }); // The selection spans the whole value: one keystroke replaces all of it. visual.simulate_keystrokes("x"); assert_eq!(value(&cx, &state), "x"); } #[test] fn shift_arrow_selects_and_backspace_removes_the_selection() { let (cx, state, mut visual) = input_window("ab", false); focus(&mut visual, &state); // Caret starts at the end; selecting left twice covers "ab". visual.simulate_keystrokes("shift-left shift-left"); visual.simulate_keystrokes("backspace"); assert_eq!(value(&cx, &state), ""); } #[test] fn disabled_input_ignores_typing_and_clicks() { // No programmatic focus: a disabled field must not take focus, so the // key events fall to nothing and the input handler is never registered. let (cx, state, mut visual) = input_window("", true); visual.simulate_click(point(px(20.), px(14.)), Modifiers::default()); visual.simulate_keystrokes("h"); assert_eq!(value(&cx, &state), ""); } #[test] fn set_value_replaces_the_buffer() { let (cx, state, mut visual) = input_window("", false); visual.update(|window, cx| { state.update(cx, |state, cx| state.set_value("preset", window, cx)); }); assert_eq!(value(&cx, &state), "preset"); } /// Subscribes to `state`'s events, recording each variant into a sink that /// lives for the rest of the test. The returned `Subscription` must stay /// bound for the same lifetime — dropping it unsubscribes. fn record_events( cx: &mut TestAppContext, state: &Entity, ) -> (Rc>>, gpui::Subscription) { let sink = Rc::new(RefCell::new(Vec::new())); let sink_for_sub = sink.clone(); let subscription = cx.update(|cx| { cx.subscribe(state, move |_, event, _| { sink_for_sub.borrow_mut().push(event.clone()); }) }); // Subscriptions activate through a deferred effect. cx.run_until_parked(); (sink, subscription) } fn count(sink: &Rc>>, event: &InputStateEvent) -> usize { sink.borrow().iter().filter(|e| e == &event).count() } #[test] fn text_changed_emits_for_edits_but_not_for_caret_moves() { let (mut cx, state, mut visual) = input_window("", false); let (sink, _subscription) = record_events(&mut cx, &state); focus(&mut visual, &state); visual.simulate_keystrokes("a space b"); assert_eq!(count(&sink, &InputStateEvent::TextChanged), 3); // Caret movement repaints but is not a text change. visual.simulate_keystrokes("left"); assert_eq!(count(&sink, &InputStateEvent::TextChanged), 3); } #[test] fn set_value_no_op_does_not_emit_changed() { let (mut cx, state, mut visual) = input_window("same", false); let (sink, _subscription) = record_events(&mut cx, &state); visual.update(|window, cx| { state.update(cx, |state, cx| state.set_value("same", window, cx)); }); visual.run_until_parked(); assert_eq!(count(&sink, &InputStateEvent::TextChanged), 0); visual.update(|window, cx| { state.update(cx, |state, cx| state.set_value("other", window, cx)); }); visual.run_until_parked(); assert_eq!(count(&sink, &InputStateEvent::TextChanged), 1); } #[test] fn set_value_silent_updates_without_emitting() { let (mut cx, state, mut visual) = input_window("same", false); let (sink, _subscription) = record_events(&mut cx, &state); visual.update(|window, cx| { state.update(cx, |state, cx| state.set_value_silent("other", window, cx)); }); visual.run_until_parked(); assert_eq!(value(&cx, &state), "other"); assert_eq!(count(&sink, &InputStateEvent::TextChanged), 0); } #[test] fn focus_and_blur_emit_events() { let (mut cx, state, mut visual) = input_window("", false); let (sink, _subscription) = record_events(&mut cx, &state); focus(&mut visual, &state); visual.update(|window, cx| { window.draw(cx).clear(cx); }); visual.run_until_parked(); assert_eq!(count(&sink, &InputStateEvent::Focused), 1); visual.update(|window, cx| window.blur(cx)); visual.update(|window, cx| { window.draw(cx).clear(cx); }); visual.run_until_parked(); assert_eq!(count(&sink, &InputStateEvent::Blurred), 1); } #[test] fn pasted_newlines_become_spaces() { let (cx, state, mut visual) = input_window("", false); focus(&mut visual, &state); cx.update(|cx| { cx.write_to_clipboard(gpui::ClipboardItem::new_string("a\nb\rc".to_string())); }); visual.simulate_keystrokes("cmd-v"); // `\r` is dropped and `\n` becomes a space, matching the sanitizer. assert_eq!(value(&cx, &state), "a bc"); } #[test] fn set_value_sanitizes_newlines() { let (cx, state, mut visual) = input_window("", false); visual.update(|window, cx| { state.update(cx, |state, cx| state.set_value("a\nb", window, cx)); }); assert_eq!(value(&cx, &state), "a b"); } #[test] fn enter_emits_submitted() { let (mut cx, state, mut visual) = input_window("draft", false); let (sink, _subscription) = record_events(&mut cx, &state); focus(&mut visual, &state); visual.update(|window, cx| { window.draw(cx).clear(cx); }); visual.simulate_keystrokes("enter"); assert_eq!(count(&sink, &InputStateEvent::Submitted), 1); // The content is left in place for the subscriber to read or clear. assert_eq!(value(&cx, &state), "draft"); } fn password_window(initial: &str) -> (TestAppContext, Entity, VisualTestContext) { let (cx, state, visual) = input_window(initial, false); cx.update(|cx| { state.update(cx, |state, cx| state.set_password(true, cx)); }); (cx, state, visual) } #[test] fn password_fields_keep_the_real_buffer_while_masked() { let (cx, state, mut visual) = password_window(""); focus(&mut visual, &state); visual.simulate_keystrokes("s e c r e t"); // The buffer holds the real secret; masking is paint-only. assert_eq!(value(&cx, &state), "secret"); // Editing still works through the mask. visual.simulate_keystrokes("backspace"); assert_eq!(value(&cx, &state), "secre"); } #[test] fn password_fields_refuse_copy_and_cut() { let (cx, state, mut visual) = password_window("secret"); focus(&mut visual, &state); // Select everything and try to copy. visual.simulate_keystrokes("cmd-a"); visual.simulate_keystrokes("cmd-c"); let clipboard = cx.update(|cx| { cx.read_from_clipboard() .and_then(|item| item.text()) .unwrap_or_default() }); assert_eq!(clipboard, ""); // Cut neither leaks the secret nor deletes it. visual.simulate_keystrokes("cmd-x"); assert_eq!(value(&cx, &state), "secret"); let clipboard = cx.update(|cx| { cx.read_from_clipboard() .and_then(|item| item.text()) .unwrap_or_default() }); assert_eq!(clipboard, ""); } #[test] fn password_fields_still_allow_paste() { let (cx, state, mut visual) = password_window(""); focus(&mut visual, &state); cx.update(|cx| { cx.write_to_clipboard(gpui::ClipboardItem::new_string("hunter2".to_string())); }); visual.simulate_keystrokes("cmd-v"); assert_eq!(value(&cx, &state), "hunter2"); } #[test] fn clicks_in_password_fields_map_to_real_offsets() { let (cx, state, mut visual) = password_window("abcdefgh"); focus(&mut visual, &state); // Clicking at the text origin moves the caret to the start, so typing // lands there: the masked display maps clicks back onto real offsets. click(&mut visual, point(px(12.), px(15.))); visual.simulate_keystrokes("x"); assert_eq!(value(&cx, &state), "xabcdefgh"); // Clicking past the last bullet clamps to the end. click(&mut visual, point(px(500.), px(15.))); visual.simulate_keystrokes("y"); assert_eq!(value(&cx, &state), "xabcdefghy"); } fn click(visual: &mut VisualTestContext, position: gpui::Point) { visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default()); } struct RevealHarness { state: Entity, } impl Render for RevealHarness { fn render( &mut self, _window: &mut Window, _cx: &mut gpui::Context, ) -> impl gpui::IntoElement { div() .w(px(400.)) .p_4() .child(Input::new("reveal-input", self.state.clone()).placeholder("Password")) } } fn reveal_window() -> (TestAppContext, Entity, VisualTestContext) { let mut cx = TestAppContext::single(); cx.update(|cx| { Theme::init(cx); bind_input_keys(cx, InputBindings::default()); }); let state = cx.update(|cx| InputState::new_with_text("", cx)); cx.update(|cx| { state.update(cx, |state, cx| state.set_password(true, cx)); }); let state_for_window = state.clone(); let window = cx.add_window(move |_, _| RevealHarness { state: state_for_window, }); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); (cx, state, visual) } /// Clicking the reveal toggle flips masking on and off, so apps can reveal /// a secret without a custom control. #[test] fn the_reveal_toggle_flips_masking() { let (cx, state, mut visual) = reveal_window(); let bounds = visual .debug_bounds("input-reveal") .expect("reveal toggle missing"); let center = point( bounds.left() + bounds.size.width / 2.0, bounds.top() + bounds.size.height / 2.0, ); // Masked: clicking reveals. click(&mut visual, center); let revealed = cx.update(|cx| state.read(cx).is_revealed()); assert!(revealed, "the toggle should reveal the field"); // Revealed: clicking masks again. click(&mut visual, center); let revealed = cx.update(|cx| state.read(cx).is_revealed()); assert!(!revealed, "the toggle should mask the field again"); }