add multiline support to input

This commit is contained in:
2026-11-01 20:06:43 +07:00
parent 125fa3e62d
commit be05e12d16
4 changed files with 948 additions and 267 deletions
+174 -1
View File
@@ -4,7 +4,8 @@ use gpui::{
TestAppContext, VisualTestContext, Window, div, point, px,
};
use gpui_native_core::input::{
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, bind_input_keys,
INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn,
bind_input_keys,
};
use gpui_native_theme::Theme;
use std::cell::RefCell;
@@ -599,6 +600,34 @@ fn password_window(initial: &str) -> (TestAppContext, Entity<InputState>, Visual
(cx, state, visual)
}
fn multiline_window(initial: &str) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
let mut cx = TestAppContext::single();
cx.update(|cx| {
Theme::init(cx);
bind_input_keys(cx, InputBindings::default());
});
let state = cx.update(InputState::new);
cx.update(|cx| {
state.update(cx, |state, cx| state.set_multiline(true, cx));
});
let state_for_window = state.clone();
let window = cx.add_window(move |_, _| Harness {
state: state_for_window,
disabled: false,
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
if !initial.is_empty() {
// Seeding after the mode flips so newlines survive the sanitizer.
visual.update(|window, cx| {
state.update(cx, |state, cx| state.set_value(initial, window, cx));
});
}
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
(cx, state, visual)
}
#[test]
fn password_fields_keep_the_real_buffer_while_masked() {
let (cx, state, mut visual) = password_window("");
@@ -733,3 +762,147 @@ fn the_reveal_toggle_flips_masking() {
let revealed = cx.update(|cx| state.read(cx).is_revealed());
assert!(!revealed, "the toggle should mask the field again");
}
#[test]
fn the_caret_can_rest_on_a_line_break() {
let (cx, state, mut visual) = multiline_window("ab\ncdef");
focus(&mut visual, &state);
// Five lefts land the caret on the line break at the end of row 1 —
// an offset between the two rows — and painting must not panic there.
visual.simulate_keystrokes("left left left left left");
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
visual.simulate_keystrokes("x");
assert_eq!(value(&cx, &state), "abx\ncdef");
}
#[test]
fn multiline_fields_keep_newlines() {
let (cx, state, mut visual) = multiline_window("");
focus(&mut visual, &state);
visual.update(|window, cx| {
state.update(cx, |state, cx| state.set_value("a\nb", window, cx));
});
assert_eq!(value(&cx, &state), "a\nb");
// Paste keeps line breaks too.
cx.update(|cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string("x\ny".to_string()));
});
visual.simulate_keystrokes("cmd-v");
assert_eq!(value(&cx, &state), "a\nbx\ny");
}
#[test]
fn enter_inserts_a_newline_and_modifier_enter_submits() {
let (mut cx, state, mut visual) = multiline_window("");
let (sink, _subscription) = record_events(&mut cx, &state);
focus(&mut visual, &state);
visual.simulate_keystrokes("a b");
visual.simulate_keystrokes("enter");
assert_eq!(value(&cx, &state), "ab\n");
assert_eq!(count(&sink, &InputStateEvent::Submitted), 0);
visual.simulate_keystrokes("cmd-enter");
assert_eq!(count(&sink, &InputStateEvent::Submitted), 1);
assert_eq!(value(&cx, &state), "ab\n");
}
#[test]
fn submit_on_enter_swaps_the_keys() {
let (mut cx, state, mut visual) = multiline_window("");
let (sink, _subscription) = record_events(&mut cx, &state);
visual.update(|_, cx| {
state.update(cx, |state, cx| state.set_submit_on(SubmitOn::Enter, cx));
});
focus(&mut visual, &state);
// Enter submits; Shift + Enter breaks the line.
visual.simulate_keystrokes("enter");
assert_eq!(count(&sink, &InputStateEvent::Submitted), 1);
assert_eq!(value(&cx, &state), "");
visual.simulate_keystrokes("shift-enter");
assert_eq!(value(&cx, &state), "\n");
}
#[test]
fn up_and_down_move_between_rows() {
let (cx, state, mut visual) = multiline_window("ab\ncdef");
focus(&mut visual, &state);
// From the end of row 1, shift-up reaches the end of the shorter row 0.
visual.simulate_keystrokes("shift-up");
visual.simulate_keystrokes("backspace");
assert_eq!(value(&cx, &state), "ab");
// From the start of row 0, shift-down reaches the start of row 1.
visual.update(|window, cx| {
state.update(cx, |state, cx| state.set_value("ab\ncdef", window, cx));
});
visual.simulate_keystrokes("home");
visual.simulate_keystrokes("shift-down");
visual.simulate_keystrokes("backspace");
assert_eq!(value(&cx, &state), "cdef");
}
#[test]
fn single_line_fields_ignore_up_and_down() {
let (cx, state, mut visual) = input_window("ab", false);
focus(&mut visual, &state);
visual.simulate_keystrokes("up down");
visual.simulate_keystrokes("x");
assert_eq!(value(&cx, &state), "abx");
}
#[test]
fn triple_click_selects_the_clicked_row() {
let (cx, state, mut visual) = multiline_window("ab\ncdef");
focus(&mut visual, &state);
// The second row's center sits one line height below the first's.
let at = point(px(30.), px(44.));
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,
});
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, "cdef");
}
#[test]
fn vertical_scroll_keeps_the_caret_in_view() {
let (_cx, state, mut visual) = multiline_window("l1\nl2\nl3\nl4\nl5\nl6");
focus(&mut visual, &state);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
// The field shows four of the six rows and the caret ends on the last,
// so the view scrolls down and row 1 sits above the field's top.
let first_row_top = visual
.update(|window, cx| {
state.update(cx, |state, cx| {
state.bounds_for_range(0..1, Default::default(), window, cx)
})
})
.map(|bounds| bounds.top())
.unwrap_or_default();
assert!(first_row_top < px(0.), "first row top: {first_row_top:?}");
}