use super::*;
use rand::prelude::*;
fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String {
let r: u8 = rng.random_range(0..100);
match r {
0..=4 => "\n".to_string(),
5..=12 => " ".to_string(),
13..=35 => (rng.random_range(b'a'..=b'z') as char).to_string(),
36..=45 => (rng.random_range(b'A'..=b'Z') as char).to_string(),
46..=52 => (rng.random_range(b'0'..=b'9') as char).to_string(),
53..=65 => {
let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"];
choices[rng.random_range(0..choices.len())].to_string()
}
66..=75 => {
let choices = ["漢", "字", "測", "試", "你", "好", "界", "编", "码"];
choices[rng.random_range(0..choices.len())].to_string()
}
76..=85 => {
let base = ["e", "a", "o", "n", "u"][rng.random_range(0..5)];
let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"];
format!("{base}{}", marks[rng.random_range(0..marks.len())])
}
86..=92 => {
let choices = ["Ω", "β", "Ж", "ю", "ש", "م", "ह"];
choices[rng.random_range(0..choices.len())].to_string()
}
_ => {
let choices = [
"👩\u{200D}💻", "👨\u{200D}💻", "🏳️\u{200D}🌈", ];
choices[rng.random_range(0..choices.len())].to_string()
}
}
}
fn ta_with(text: &str) -> TextArea {
let mut t = TextArea::new();
t.insert_str(text);
t
}
#[test]
fn canonical_adapter_matches_standalone_edit_buffer() {
let cases = [
(
"hello-world",
"hello-world".len(),
EditCommand::MoveWordLeft(WordStyle::Small),
),
(
"hello-world",
0,
EditCommand::MoveWordRight(WordStyle::Small),
),
(
"foo bar",
"foo bar".len(),
EditCommand::DeleteWordBackward(WordStyle::Small),
),
(
"foo bar",
0,
EditCommand::DeleteWordForward(WordStyle::Small),
),
("one\ntwo", 4, EditCommand::MoveLogicalLineStart),
("one\ntwo", 3, EditCommand::MoveLogicalLineEnd),
("abc", 2, EditCommand::DeleteGraphemeBackward),
("abc", 1, EditCommand::DeleteGraphemeForward),
];
for (text, cursor, command) in cases {
let mut textarea = TextArea::new();
textarea.set_text(text);
textarea.clear_history();
textarea.set_cursor(cursor);
let mut buffer = EditBuffer::from_parts(text, cursor);
textarea.apply_classified_command(command);
let _ = buffer.apply(command);
assert_eq!(textarea.text(), buffer.text());
assert_eq!(textarea.cursor(), buffer.cursor_byte());
}
}
#[test]
fn canonical_adapter_updates_selection_from_applied_delta() {
let mut textarea = ta_with("abcdef");
textarea.set_selection(4, 6);
textarea.replace_range(0..2, "X");
assert_eq!(textarea.text(), "Xcdef");
assert_eq!(textarea.selection_range(), Some(3..5));
}
#[test]
fn canonical_adapter_applies_same_byte_metadata_edits_with_history() {
let mut textarea = TextArea::new();
let id = textarea.insert_element("TOKEN", ElementKind(1), None);
textarea.set_selection(0, 5);
textarea.clear_history();
textarea.replace_range(0..5, "TOKEN");
assert_eq!(textarea.text(), "TOKEN");
assert!(textarea.elements().is_empty());
assert!(textarea.selection.is_none());
assert!(textarea.can_undo());
assert!(textarea.undo());
assert_eq!(textarea.text(), "TOKEN");
assert_eq!(textarea.elements().len(), 1);
assert_eq!(textarea.elements()[0].id, id);
assert!(textarea.redo());
assert_eq!(textarea.text(), "TOKEN");
assert!(textarea.elements().is_empty());
}
#[test]
fn replace_element_forces_cursor_end_and_restores_metadata() {
let mut before = ta_with("left TOKEN right");
before.clear_history();
before.set_cursor(0);
let id = before.replace_range_with_element(5..10, "NODE", ElementKind(1), None);
let end = 5 + "NODE".len();
assert_eq!(before.cursor(), end);
assert_eq!(before.elements()[0].id, id);
assert!(before.undo());
assert_eq!(before.text(), "left TOKEN right");
assert!(before.elements().is_empty());
assert_eq!(before.cursor(), 0);
assert!(before.redo());
assert_eq!(before.text(), "left NODE right");
assert_eq!(before.elements()[0].id, id);
assert_eq!(before.cursor(), end);
let mut after = ta_with("left TOKEN right");
after.clear_history();
after.set_cursor(after.text().len());
after.replace_range_with_element(5..10, "NODE", ElementKind(1), None);
assert_eq!(after.cursor(), end);
}
#[test]
fn empty_set_text_invalidates_redo_and_is_undoable() {
let mut textarea = TextArea::new();
textarea.insert_str("x");
assert!(textarea.undo());
assert!(textarea.can_redo());
textarea.set_text("");
assert!(!textarea.can_redo());
assert!(textarea.can_undo());
assert_eq!(textarea.cursor(), 0);
}
#[test]
fn set_text_preserves_cursor_clamped_across_grow_and_shrink() {
let mut grow = ta_with("abcd");
grow.set_cursor(2);
grow.set_text("abcdefgh");
assert_eq!(grow.cursor(), 2);
let mut shrink = ta_with("abcdefgh");
shrink.set_cursor(6);
shrink.set_text("abc");
assert_eq!(shrink.cursor(), 3);
}
#[test]
fn set_text_restores_zero_length_element_metadata_through_history() {
let mut textarea = TextArea::new();
let id = textarea.insert_element("", ElementKind(7), None);
textarea.clear_history();
textarea.set_text("");
assert!(textarea.elements().is_empty());
assert!(textarea.undo());
assert_eq!(textarea.text(), "");
assert_eq!(textarea.elements().len(), 1);
assert_eq!(textarea.elements()[0].id, id);
assert_eq!(textarea.elements()[0].range, 0..0);
assert!(textarea.redo());
assert!(textarea.elements().is_empty());
}
#[test]
fn rejected_adapter_plan_has_no_side_effects() {
let mut textarea = TextArea::new();
let id = textarea.insert_element("TOKEN", ElementKind(1), None);
textarea.set_selection(0, 5);
textarea.kill_buffer = "sentinel".to_owned();
textarea.preferred_col = Some(3);
textarea.scroll_override = Some(2);
let _ = textarea.desired_height(20);
textarea.clear_history();
let plan = textarea.plan_edit_replacement(0..5, "X");
let _ = textarea.text.set_cursor_byte(0);
let result = textarea.try_apply_edit_plan(plan, Some(MutationKind::Replace));
assert_eq!(result, Err(ApplyEditPlanError::StalePlan));
assert_eq!(textarea.text(), "TOKEN");
assert_eq!(textarea.elements().len(), 1);
assert_eq!(textarea.elements()[0].id, id);
assert_eq!(textarea.selection_range(), Some(0..5));
assert_eq!(textarea.kill_buffer, "sentinel");
assert_eq!(textarea.preferred_col, Some(3));
assert_eq!(textarea.scroll_override, Some(2));
assert!(textarea.wrap_cache.borrow().is_some());
assert!(!textarea.can_undo());
}
#[test]
fn handled_boundary_navigation_clears_vertical_affinity() {
let mut textarea = ta_with("ab\nwxyz");
textarea.set_cursor(0);
textarea.preferred_col = Some(3);
textarea.scroll_override = Some(2);
textarea.move_cursor_left();
assert_eq!(textarea.cursor(), 0);
assert_eq!(textarea.preferred_col, None);
assert_eq!(textarea.scroll_override, None);
textarea.move_cursor_down();
assert_eq!(textarea.cursor(), 3);
}
#[test]
fn insert_str_at_inside_element_clamps_to_an_atomic_boundary() {
let mut textarea = TextArea::new();
textarea.insert_str("a");
textarea.insert_element("TOKEN", ElementKind(1), None);
textarea.insert_str("b");
textarea.clear_history();
textarea.insert_str_at(3, "X");
assert_eq!(textarea.text(), "aXTOKENb");
assert_eq!(textarea.cursor(), 8);
assert_eq!(textarea.elements()[0].range, 2..7);
assert!(textarea.undo());
assert_eq!(textarea.text(), "aTOKENb");
assert_eq!(textarea.elements()[0].range, 1..6);
}
#[test]
fn canonical_adapter_keeps_elements_atomic_for_motion_and_deletion() {
let mut backward = TextArea::new();
backward.insert_str("a");
let id = backward.insert_element("TOKEN", ElementKind(1), None);
backward.insert_str("b");
let range = backward.elements()[0].range.clone();
backward.set_cursor(range.end);
backward.move_cursor_left();
assert_eq!(backward.cursor(), range.start);
backward.move_cursor_right();
assert_eq!(backward.cursor(), range.end);
backward.delete_backward(1);
assert_eq!(backward.text(), "ab");
assert!(backward.elements().iter().all(|element| element.id != id));
let mut forward = TextArea::new();
forward.insert_str("a");
forward.insert_element("TOKEN", ElementKind(1), None);
forward.insert_str("b");
let range = forward.elements()[0].range.clone();
forward.set_cursor(range.start);
forward.delete_forward(1);
assert_eq!(forward.text(), "ab");
assert!(forward.elements().is_empty());
}
#[test]
fn canonical_adapter_ignores_element_newlines_and_restores_kills() {
let mut textarea = TextArea::new();
textarea.insert_str("a");
textarea.insert_element("X\nY", ElementKind(1), None);
textarea.insert_str("b\nc");
textarea.clear_history();
textarea.set_cursor(0);
textarea.move_cursor_to_end_of_line(true);
assert_eq!(textarea.cursor(), 5);
textarea.set_cursor(0);
textarea.kill_to_end_of_line();
assert_eq!(textarea.text(), "\nc");
assert_eq!(textarea.kill_buffer, "aX\nYb");
assert!(textarea.undo());
assert_eq!(textarea.text(), "aX\nYb\nc");
assert_eq!(textarea.elements().len(), 1);
assert!(textarea.redo());
assert_eq!(textarea.text(), "\nc");
assert!(textarea.elements().is_empty());
}
#[test]
fn canonical_adapter_preserves_right_affinity_through_undo_redo() {
let woman = "👩";
let tail = "👩🏽\u{200d}💻";
let original = format!("{woman}{tail}");
let mut textarea = ta_with(&original);
textarea.clear_history();
textarea.set_cursor(woman.len());
textarea.insert_str("\u{200d}");
assert_eq!(textarea.text().graphemes(true).count(), 1);
assert_eq!(textarea.cursor(), textarea.text().len());
assert!(textarea.undo());
assert_eq!(textarea.text(), original);
assert_eq!(textarea.cursor(), woman.len());
assert!(textarea.redo());
assert_eq!(textarea.text().graphemes(true).count(), 1);
assert_eq!(textarea.cursor(), textarea.text().len());
}
#[test]
fn is_undo_input_accepts_ctrl_and_cmd_z() {
assert!(is_undo_input(&KeyEvent::new(
KeyCode::Char('z'),
KeyModifiers::CONTROL
)));
assert!(is_undo_input(&KeyEvent::new(
KeyCode::Char('z'),
KeyModifiers::SUPER
)));
}
#[test]
fn is_undo_input_rejects_redo_and_plain_z() {
assert!(!is_undo_input(&KeyEvent::new(
KeyCode::Char('Z'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT
)));
assert!(!is_undo_input(&KeyEvent::new(
KeyCode::Char('z'),
KeyModifiers::NONE
)));
assert!(!is_undo_input(&KeyEvent::new(
KeyCode::Char('z'),
KeyModifiers::SHIFT
)));
}
#[test]
fn insert_and_replace_update_cursor_and_text() {
let mut t = ta_with("hello");
t.set_cursor(5);
t.insert_str("!");
assert_eq!(t.text(), "hello!");
assert_eq!(t.cursor(), 6);
t.insert_str_at(0, "X");
assert_eq!(t.text(), "Xhello!");
assert_eq!(t.cursor(), 7);
t.set_cursor(1);
let end = t.text().len();
t.insert_str_at(end, "Y");
assert_eq!(t.text(), "Xhello!Y");
assert_eq!(t.cursor(), 1);
let mut t = ta_with("abcd");
t.set_cursor(1);
t.replace_range(2..3, "Z");
assert_eq!(t.text(), "abZd");
assert_eq!(t.cursor(), 1);
let mut t = ta_with("abcd");
t.set_cursor(2);
t.replace_range(1..3, "Q");
assert_eq!(t.text(), "aQd");
assert_eq!(t.cursor(), 2);
let mut t = ta_with("abcd");
t.set_cursor(4);
t.replace_range(0..1, "AA");
assert_eq!(t.text(), "AAbcd");
assert_eq!(t.cursor(), 5);
}
#[test]
fn delete_backward_and_forward_edges() {
let mut t = ta_with("abc");
t.set_cursor(1);
t.delete_backward(1);
assert_eq!(t.text(), "bc");
assert_eq!(t.cursor(), 0);
t.set_cursor(0);
t.delete_backward(1);
assert_eq!(t.text(), "bc");
assert_eq!(t.cursor(), 0);
t.set_cursor(1);
t.delete_forward(1);
assert_eq!(t.text(), "b");
assert_eq!(t.cursor(), 1);
t.set_cursor(t.text().len());
t.delete_forward(1);
assert_eq!(t.text(), "b");
}
#[test]
fn delete_backward_word_and_kill_line_variants() {
let mut t = ta_with("hello world ");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 8);
let mut t = ta_with("foo bar");
t.set_cursor(6); t.delete_backward_word();
assert_eq!(t.text(), "foo r");
assert_eq!(t.cursor(), 4);
let mut t = ta_with("foo bar");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "foo ");
assert_eq!(t.cursor(), 4);
let mut t = ta_with("hello-world");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "hello-");
assert_eq!(t.cursor(), "hello-".len());
let mut t = ta_with("abc\ndef");
t.set_cursor(1); t.kill_to_end_of_line();
assert_eq!(t.text(), "a\ndef");
assert_eq!(t.cursor(), 1);
let mut t = ta_with("abc\ndef");
t.set_cursor(3); t.kill_to_end_of_line();
assert_eq!(t.text(), "abcdef");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("abc\ndef");
t.set_cursor(5); t.kill_to_beginning_of_line();
assert_eq!(t.text(), "abc\nef");
let mut t = ta_with("abc\ndef");
t.set_cursor(4); t.kill_to_beginning_of_line();
assert_eq!(t.text(), "abcdef");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("hello world");
t.set_cursor(5);
t.kill_current_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("abc\ndef\nghi");
t.set_cursor(5);
t.kill_current_line();
assert_eq!(t.text(), "abc\n\nghi");
assert_eq!(t.cursor(), 4);
let mut t = ta_with("abc\n\nghi");
t.set_cursor(4);
t.kill_current_line();
assert_eq!(t.text(), "abc\nghi");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("hello");
t.set_cursor(0);
t.kill_current_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
}
#[test]
fn delete_forward_word_variants() {
let mut t = ta_with("hello world ");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), " world ");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("hello world ");
t.set_cursor(1);
t.delete_forward_word();
assert_eq!(t.text(), "h world ");
assert_eq!(t.cursor(), 1);
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
t.delete_forward_word();
assert_eq!(t.text(), "hello world");
assert_eq!(t.cursor(), t.text().len());
let mut t = ta_with("foo \nbar");
t.set_cursor(3);
t.delete_forward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("foo\nbar");
t.set_cursor(3);
t.delete_forward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("hello-world");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), "-world");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("hello world ");
t.set_cursor(t.text().len() + 10);
t.delete_forward_word();
assert_eq!(t.text(), "hello world ");
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn super_right_moves_to_end_of_line() {
let mut t = ta_with("hello world\nsecond line");
t.set_cursor(3); t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::SUPER));
assert_eq!(t.cursor(), 11);
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::SUPER));
assert_eq!(t.cursor(), 11);
}
#[test]
fn super_left_moves_to_beginning_of_line() {
let mut t = ta_with("hello world\nsecond line");
let second_line_start = t.text().find("second").unwrap();
t.set_cursor(second_line_start + 4); t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::SUPER));
assert_eq!(t.cursor(), second_line_start);
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::SUPER));
assert_eq!(t.cursor(), second_line_start);
}
#[test]
fn super_backspace_kills_to_beginning_of_line() {
let mut t = ta_with("hello world\nsecond line");
let second_line_start = t.text().find("second").unwrap();
t.set_cursor(second_line_start + 7); t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER));
assert_eq!(t.text(), "hello world\nline");
assert_eq!(t.cursor(), second_line_start);
}
#[test]
fn ctrl_u_kills_to_beginning_of_line_keeps_text_after_cursor() {
let mut t = ta_with("hello world");
t.set_cursor(5);
t.input(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert_eq!(t.text(), " world");
assert_eq!(t.cursor(), 0);
}
#[test]
fn delete_forward_word_handles_atomic_elements() {
let kind = ElementKind(0);
let mut t = TextArea::new();
t.insert_element("<element>", kind, None);
t.insert_str(" tail");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), " tail");
assert_eq!(t.cursor(), 0);
let mut t = TextArea::new();
t.insert_str(" ");
t.insert_element("<element>", kind, None);
t.insert_str(" tail");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), " tail");
assert_eq!(t.cursor(), 0);
let mut t = TextArea::new();
t.insert_str("prefix ");
t.insert_element("<element>", kind, None);
t.insert_str(" tail");
let elem_range = t.elements()[0].range.clone();
let _ = t
.text
.set_cursor_byte(elem_range.start + (elem_range.len() / 2));
t.delete_forward_word();
assert_eq!(t.text(), "prefix tail");
assert_eq!(t.cursor(), elem_range.start);
}
#[test]
fn element_id_is_unique_and_stable() {
let mut t = TextArea::new();
let kind = ElementKind(1);
let id1 = t.insert_element("aaa", kind, None);
let id2 = t.insert_element("bbb", kind, None);
assert_ne!(id1, id2);
t.set_cursor(0);
t.delete_forward(1); assert_eq!(t.elements().len(), 1);
assert_eq!(t.elements()[0].id, id2);
}
#[test]
fn element_kind_preserved() {
let mut t = TextArea::new();
let kind_paste = ElementKind(1);
let kind_file = ElementKind(2);
t.insert_element("paste", kind_paste, None);
t.insert_element("file", kind_file, None);
assert_eq!(t.elements()[0].kind, kind_paste);
assert_eq!(t.elements()[1].kind, kind_file);
}
#[test]
fn element_at_cursor_returns_element() {
let mut t = TextArea::new();
let kind = ElementKind(0);
t.insert_str("before ");
let id = t.insert_element("[paste]", kind, None);
t.insert_str(" after");
t.set_cursor(7); let elem = t.element_at_cursor().expect("should find element");
assert_eq!(elem.id, id);
assert_eq!(elem.kind, kind);
t.set_cursor(0);
assert!(t.element_at_cursor().is_none());
t.set_cursor(t.text().len());
assert!(t.element_at_cursor().is_none());
}
#[test]
fn element_text_returns_buffer_text() {
let mut t = TextArea::new();
let id = t.insert_element("raw buffer content", ElementKind(0), None);
assert_eq!(t.element_text(id), Some("raw buffer content"));
let fake_id = ElementId(9999);
assert_eq!(t.element_text(fake_id), None);
}
#[test]
fn element_display_can_be_set_and_updated() {
let mut t = TextArea::new();
let display = Line::from("[Pasted 5 lines]");
let id = t.insert_element("lots of raw text here", ElementKind(1), Some(display));
let elem = &t.elements()[0];
assert!(elem.display.is_some());
assert_eq!(
elem.display.as_ref().unwrap().to_string(),
"[Pasted 5 lines]"
);
let new_display = Line::from("[Pasted 5 lines, 200 chars]");
t.set_element_display(id, Some(new_display));
let elem = &t.elements()[0];
assert_eq!(
elem.display.as_ref().unwrap().to_string(),
"[Pasted 5 lines, 200 chars]"
);
t.set_element_display(id, None);
assert!(t.elements()[0].display.is_none());
assert_eq!(t.element_text(id), Some("lots of raw text here"));
}
#[test]
fn insert_element_returns_id_for_metadata_tracking() {
let mut t = TextArea::new();
let mut metadata: std::collections::HashMap<ElementId, String> =
std::collections::HashMap::new();
let id1 = t.insert_element("paste1", ElementKind(1), None);
metadata.insert(id1, "First paste".to_string());
let id2 = t.insert_element("paste2", ElementKind(1), None);
metadata.insert(id2, "Second paste".to_string());
assert_eq!(metadata.get(&id1), Some(&"First paste".to_string()));
assert_eq!(metadata.get(&id2), Some(&"Second paste".to_string()));
t.set_cursor(0);
t.delete_forward(1);
let remaining = &t.elements()[0];
assert_eq!(remaining.id, id2);
assert_eq!(
metadata.get(&remaining.id),
Some(&"Second paste".to_string())
);
}
#[test]
fn elements_returns_sorted_slice() {
let mut t = TextArea::new();
let kind = ElementKind(0);
t.insert_str("aaa ");
t.insert_element("BBB", kind, None);
t.insert_str(" ccc ");
t.insert_element("DDD", kind, None);
let elems = t.elements();
assert_eq!(elems.len(), 2);
assert!(elems[0].range.start < elems[1].range.start);
assert_eq!(&t.text()[elems[0].range.clone()], "BBB");
assert_eq!(&t.text()[elems[1].range.clone()], "DDD");
}
#[test]
fn render_element_with_display_shows_display_text() {
use ratatui::style::Stylize;
let mut t = TextArea::new();
let display = Line::from("[Pasted]".cyan());
t.insert_element("raw content here", ElementKind(0), Some(display));
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
let rendered: String = (0..area.width)
.map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
.collect::<String>();
let rendered = rendered.trim_end();
assert_eq!(rendered, "[Pasted]");
}
#[test]
fn render_element_without_display_shows_buffer_text_cyan() {
let mut t = TextArea::new();
t.insert_element("hello", ElementKind(0), None);
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
let cell = buf.cell((0, 0)).unwrap();
assert_eq!(cell.symbol(), "h");
assert_eq!(cell.fg, Color::Cyan);
}
#[test]
fn truncate_line_display_no_truncation_needed() {
let line = Line::from("[Short]");
let result = truncate_line_display(&line, 20);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "[Short]");
}
#[test]
fn truncate_line_display_with_bracket_preservation() {
let line: Line<'static> = Line::from("[Pasted ~100 lines]");
let result = truncate_line_display(&line, 12);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.ends_with(']'), "should preserve ]: got {text:?}");
assert!(text.contains('…'), "should contain ellipsis: got {text:?}");
assert_eq!(text, "[Pasted ~1…]");
}
#[test]
fn truncate_line_display_without_bracket() {
let line: Line<'static> = Line::from("very long display text");
let result = truncate_line_display(&line, 10);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('…'));
assert!(!text.ends_with(']'));
assert_eq!(text, "very long…");
}
#[test]
fn truncate_line_display_zero_width() {
let line: Line<'static> = Line::from("[Pasted]");
let result = truncate_line_display(&line, 0);
assert!(result.spans.is_empty() || result.width() == 0);
}
#[test]
fn truncate_line_display_width_1() {
let line: Line<'static> = Line::from("[Pasted]");
let result = truncate_line_display(&line, 1);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "…");
}
#[test]
fn truncate_preserves_multi_span_styles() {
use ratatui::text::Span;
let line: Line<'static> = Line::from(vec![
Span::styled("[", Style::default().fg(Color::Yellow)),
Span::styled("Pasted ~100 lines", Style::default().fg(Color::Cyan)),
Span::styled("]", Style::default().fg(Color::Yellow)),
]);
let result = truncate_line_display(&line, 10);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.ends_with(']'));
assert!(text.contains('…'));
assert!(
result.width() <= 10,
"width should be <= 10, got {}",
result.width()
);
}
#[test]
fn render_element_with_prefix_text() {
let mut t = TextArea::new();
t.insert_str("hi ");
let display = Line::from("[P]");
t.insert_element("raw", ElementKind(0), Some(display));
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
let rendered: String = (0..area.width)
.map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
.collect::<String>();
let rendered = rendered.trim_end();
assert_eq!(rendered, "hi [P]");
}
#[test]
fn render_text_after_element_uses_display_width() {
let mut t = TextArea::new();
t.insert_str("foo ");
let display = Line::from(vec![
ratatui::text::Span::raw("["),
ratatui::text::Span::raw("📎 "),
ratatui::text::Span::raw("Pasted 1 line, 11 chars"),
ratatui::text::Span::raw("]"),
]);
t.insert_element("Clean build", ElementKind(0), Some(display));
t.insert_str(" abcde");
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "f");
assert_eq!(buf.cell((3, 0)).unwrap().symbol(), " ");
assert_eq!(buf.cell((4, 0)).unwrap().symbol(), "[");
assert_eq!(buf.cell((5, 0)).unwrap().symbol(), "📎");
assert_eq!(buf.cell((7, 0)).unwrap().symbol(), " ");
assert_eq!(buf.cell((8, 0)).unwrap().symbol(), "P");
assert_eq!(buf.cell((31, 0)).unwrap().symbol(), "]");
assert_eq!(buf.cell((32, 0)).unwrap().symbol(), " ");
assert_eq!(buf.cell((33, 0)).unwrap().symbol(), "a");
assert_eq!(buf.cell((34, 0)).unwrap().symbol(), "b");
assert_eq!(buf.cell((35, 0)).unwrap().symbol(), "c");
assert_eq!(buf.cell((36, 0)).unwrap().symbol(), "d");
assert_eq!(buf.cell((37, 0)).unwrap().symbol(), "e");
}
#[test]
fn render_text_after_wider_display_element_simple() {
let mut t = TextArea::new();
let display = Line::from("[LONG]");
t.insert_element("x", ElementKind(0), Some(display));
t.insert_str("!");
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
let rendered: String = (0..area.width)
.map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
.collect::<String>();
let rendered = rendered.trim_end();
assert_eq!(rendered, "[LONG]!");
}
#[test]
fn display_width_of_range_plain_text() {
let t = ta_with("hello world");
assert_eq!(t.display_width_of_range(0, 5), 5); assert_eq!(t.display_width_of_range(0, 11), 11); assert_eq!(t.display_width_of_range(6, 11), 5); }
#[test]
fn insert_expands_tabs_to_spaces() {
let mut t = TextArea::new();
assert_eq!(t.tab_width(), 4);
t.insert_str("a\tb");
assert_eq!(t.text(), "a b");
assert_eq!(t.cursor(), 6);
assert_eq!(t.display_width_of_range(0, t.text().len()), 6);
let mut t2 = TextArea::new();
t2.set_tab_width(8);
t2.insert_str("x\ty");
assert_eq!(t2.text(), "x y");
assert_eq!(t2.cursor(), 10);
assert_eq!(t2.display_width_of_range(0, t2.text().len()), 10);
let mut t3 = TextArea::new();
t3.insert_str("\ta");
assert_eq!(t3.text(), " a");
t3.set_text("");
t3.insert_str("a\t");
assert_eq!(t3.text(), "a ");
assert_eq!(t3.cursor(), 5);
t3.set_text("");
t3.insert_str("\t\t");
assert_eq!(t3.text(), " ");
assert_eq!(t3.display_width_of_range(0, 8), 8);
t3.insert_str("");
assert_eq!(t3.text(), " ");
t3.insert_str_at(0, "z\t");
assert_eq!(t3.text(), "z ");
}
#[test]
fn set_text_and_replace_expand_tabs() {
let mut t = TextArea::new();
t.set_text("col1\tcol2");
assert_eq!(t.text(), "col1 col2");
t.replace_range(4..8, "\t");
assert_eq!(t.text(), "col1 col2");
t.replace_range(4..4, "\tx");
assert_eq!(t.text(), "col1 x col2");
assert_eq!(&t.text()[4..9], " x");
let mut t0 = TextArea::new();
t0.set_tab_width(0);
t0.insert_str("a\tb");
assert_eq!(t0.text(), "a\tb");
t0.set_text("x\ty");
assert_eq!(t0.text(), "x\ty");
assert_eq!(
t0.display_width_of_range(0, t0.text().len()),
"x\ty".width()
);
t0.set_cursor(t0.text().len());
let area = Rect::new(0, 0, 80, 1);
let (x, _y) = t0.cursor_pos(area).unwrap();
assert_eq!(x as usize, "x\ty".width());
}
#[test]
fn remaining_tabs_count_in_display_width_and_cursor() {
let mut t = TextArea::new();
t.set_tab_width(0);
t.set_text("a\tb\tc");
assert!(t.text().contains('\t'));
t.set_tab_width(4);
assert_eq!(t.display_width_of_range(0, t.text().len()), 11);
t.set_cursor(t.text().len());
let area = Rect::new(0, 0, 80, 1);
let (x, _y) = t.cursor_pos(area).unwrap();
assert_eq!(x, 11);
}
#[test]
fn set_tab_width_does_not_rewrite_existing_spaces() {
let mut t = TextArea::new();
t.insert_str("a\tb");
assert_eq!(t.text(), "a b");
t.set_tab_width(8);
assert_eq!(t.text(), "a b");
t.insert_str("\tc");
assert_eq!(t.text(), "a b c");
}
#[test]
fn multi_column_paste_tabs_readable() {
let mut t = TextArea::new();
t.insert_str("Name\tAge\tCity\nAda\t36\tLondon");
assert_eq!(t.text(), "Name Age City\nAda 36 London");
assert_eq!(t.cursor(), t.text().len());
let end = t.text().len();
let area = Rect::new(0, 0, 80, 3);
let (x, _y) = t.cursor_pos(area).unwrap();
assert_eq!(x, 19);
let bol = t.text().rfind('\n').map(|i| i + 1).unwrap_or(0);
assert_eq!(x as usize, t.display_width_of_range(bol, end));
let last_line = &t.text()[bol..];
let (paint, paint_w) = paint_plain_for_display(last_line, 80, 4);
assert_eq!(paint.as_ref(), last_line);
assert_eq!(paint_w, 19);
}
#[test]
fn insert_element_expands_tabs_and_covers_full_range() {
let mut t = TextArea::new();
t.insert_element("a\tb", ElementKind(0), None);
assert_eq!(t.text(), "a b");
assert_eq!(t.elements().len(), 1);
assert_eq!(t.elements()[0].range, 0..6);
assert_eq!(t.cursor(), 6);
let mut t2 = TextArea::new();
t2.insert_element("a\tb\nc\td", ElementKind(1), Some(Line::from("[P]")));
assert_eq!(t2.text(), "a b\nc d");
assert_eq!(t2.elements()[0].range, 0..t2.text().len());
assert_eq!(t2.cursor(), t2.text().len());
assert!(!t2.text().contains('\t'));
}
#[test]
fn replace_range_with_element_expands_tabs() {
let mut t = TextArea::new();
t.insert_str("xx");
t.replace_range_with_element(0..2, "a\tb", ElementKind(0), None);
assert_eq!(t.text(), "a b");
assert_eq!(t.elements()[0].range, 0..6);
assert_eq!(t.cursor(), 6);
}
#[test]
fn unicode_plus_tabs_expansion_and_residual() {
let mut t = TextArea::new();
t.insert_str("名\tAge");
assert_eq!(t.text(), "名 Age");
assert_eq!(
t.display_width_of_range(0, t.text().len()),
"名".width() + 4 + 3
);
t.set_cursor(t.text().len());
let area = Rect::new(0, 0, 80, 1);
let (x, _) = t.cursor_pos(area).unwrap();
assert_eq!(x as usize, "名".width() + 4 + 3);
let mut t2 = TextArea::new();
t2.set_tab_width(0);
t2.set_text("😀\tb");
t2.set_tab_width(4);
let expected = "😀".width() + 4 + 1;
assert_eq!(t2.display_width_of_range(0, t2.text().len()), expected);
t2.set_cursor(t2.text().len());
let (x, _) = t2.cursor_pos(area).unwrap();
assert_eq!(x as usize, expected);
}
#[test]
fn tab_helpers_clip_and_paint() {
assert_eq!(expand_tabs_with_width("a\tb", 4).as_ref(), "a b");
assert!(matches!(
expand_tabs_with_width("a\tb", 0),
std::borrow::Cow::Borrowed("a\tb")
));
assert!(matches!(
expand_tabs_with_width("ab", 4),
std::borrow::Cow::Borrowed("ab")
));
assert_eq!(plain_display_width_with_tab("a\tb\tc", 4), 11);
assert_eq!(
plain_display_width_with_tab("a\tb\tc", 0),
"a\tb\tc".width()
);
assert_eq!(clip_str_to_display_width_with_tab("a\tb", 3, 4), "a");
let (paint, w) = paint_plain_for_display("a\tb", 80, 4);
assert_eq!(paint.as_ref(), "a b");
assert_eq!(w, 6);
let (paint2, w2) = paint_plain_for_display("a\tb", 3, 4);
assert_eq!(paint2.as_ref(), "a");
assert_eq!(w2, 1);
}
#[test]
fn display_width_of_range_with_display_element() {
let mut t = TextArea::new();
t.insert_str("ab");
let buffer_text = "x".repeat(100);
let display = Line::from("[P]");
t.insert_element(&buffer_text, ElementKind(0), Some(display));
t.insert_str("cd");
assert_eq!(t.display_width_of_range(0, 2), 2);
assert_eq!(t.display_width_of_range(0, 102), 5);
assert_eq!(t.display_width_of_range(0, 104), 7);
assert_eq!(t.display_width_of_range(2, 102), 3);
assert_eq!(t.display_width_of_range(102, 104), 2);
}
#[test]
fn cursor_pos_uses_display_width() {
let mut t = TextArea::new();
t.insert_str("ab");
let buffer_text = "x".repeat(50);
let display = Line::from("[P]");
t.insert_element(&buffer_text, ElementKind(0), Some(display));
t.insert_str("cd");
t.set_cursor(52);
let area = Rect::new(0, 0, 80, 1);
let (x, _y) = t.cursor_pos(area).unwrap();
assert_eq!(x, 5);
t.set_cursor(54);
let (x, _y) = t.cursor_pos(area).unwrap();
assert_eq!(x, 7);
t.set_cursor(0);
let (x, _y) = t.cursor_pos(area).unwrap();
assert_eq!(x, 0);
}
#[test]
fn display_width_no_elements() {
let t = ta_with("abc");
assert_eq!(t.display_width_of_range(0, 3), 3);
assert_eq!(t.display_width_of_range(1, 2), 1);
assert_eq!(t.display_width_of_range(3, 3), 0);
}
#[test]
fn display_width_element_without_display() {
let mut t = TextArea::new();
t.insert_element("elem", ElementKind(0), None);
assert_eq!(t.display_width_of_range(0, 4), 4);
}
#[test]
fn display_width_with_wide_unicode_display() {
let mut t = TextArea::new();
t.insert_str("ab");
let display = Line::from("📎漢字"); t.insert_element("raw", ElementKind(0), Some(display));
t.insert_str("cd");
assert_eq!(t.display_width_of_range(0, 2), 2);
assert_eq!(t.display_width_of_range(2, 5), 6); assert_eq!(t.display_width_of_range(0, 7), 10); }
#[test]
fn cursor_pos_with_wide_unicode_display() {
let mut t = TextArea::new();
t.insert_str("a");
let display = Line::from("🚀");
t.insert_element("xyz", ElementKind(0), Some(display));
t.insert_str("b");
let area = Rect::new(0, 0, 40, 1);
t.set_cursor(1);
let (x, _) = t.cursor_pos(area).unwrap();
assert_eq!(x, 1);
t.set_cursor(4);
let (x, _) = t.cursor_pos(area).unwrap();
assert_eq!(x, 3);
t.set_cursor(5);
let (x, _) = t.cursor_pos(area).unwrap();
assert_eq!(x, 4); }
#[test]
fn truncate_display_with_wide_unicode() {
let line: Line<'static> = Line::from("📎paste");
let result = truncate_line_display(&line, 5);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('…'));
assert!(
result.width() <= 5,
"width should be <= 5, got {}",
result.width()
);
}
#[test]
fn clip_str_to_display_width_preserves_zwj_graphemes() {
let s = "👩\u{200D}💻a";
assert_eq!(clip_str_to_display_width(s, 0), "");
assert_eq!(clip_str_to_display_width(s, 1), "");
assert_eq!(clip_str_to_display_width(s, 2), "👩\u{200D}💻");
assert_eq!(clip_str_to_display_width(s, 3), s);
}
#[test]
fn truncate_display_preserves_zwj_graphemes() {
let line: Line<'static> = Line::from("👩\u{200D}💻abc");
let result = truncate_line_display(&line, 3);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "👩\u{200D}💻…");
assert_eq!(result.width(), 3);
}
#[test]
fn truncate_display_wide_char_at_boundary() {
let line: Line<'static> = Line::from("ab🚀cd");
let result = truncate_line_display(&line, 4);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "ab…");
assert!(result.width() <= 4);
}
#[test]
fn truncate_display_bracket_with_wide_chars() {
let line: Line<'static> = Line::from("[📎 pasted]");
let result = truncate_line_display(&line, 7);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.ends_with(']'), "should preserve ]: got {text:?}");
assert!(text.contains('…'));
assert!(result.width() <= 7, "got {}", result.width());
}
#[test]
fn render_element_with_wide_unicode_display() {
let mut t = TextArea::new();
let display = Line::from("📎漢");
t.insert_element("hidden text", ElementKind(0), Some(display));
let area = Rect::new(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
let cell0 = buf.cell((0, 0)).unwrap();
assert_eq!(cell0.symbol(), "📎");
let cell2 = buf.cell((2, 0)).unwrap();
assert_eq!(cell2.symbol(), "漢");
}
#[test]
fn backspace_at_element_end_deletes_entire_element() {
let mut t = TextArea::new();
t.insert_str("before ");
t.insert_element("[paste]", ElementKind(0), None);
assert_eq!(t.cursor(), 14);
t.delete_backward(1);
assert_eq!(t.text(), "before ");
assert_eq!(t.cursor(), 7);
assert!(t.elements().is_empty());
}
#[test]
fn delete_at_element_start_deletes_entire_element() {
let mut t = TextArea::new();
t.insert_element("[paste]", ElementKind(0), None);
t.insert_str(" after");
t.set_cursor(0);
t.delete_forward(1);
assert_eq!(t.text(), " after");
assert_eq!(t.cursor(), 0);
assert!(t.elements().is_empty());
}
#[test]
fn left_right_navigation_jumps_over_element() {
let mut t = TextArea::new();
t.insert_str("a");
t.insert_element("[elem]", ElementKind(0), None);
t.insert_str("b");
t.set_cursor(8);
t.move_cursor_left(); assert_eq!(t.cursor(), 7);
t.move_cursor_left(); assert_eq!(t.cursor(), 1);
t.move_cursor_left(); assert_eq!(t.cursor(), 0);
t.move_cursor_right(); assert_eq!(t.cursor(), 1);
t.move_cursor_right(); assert_eq!(t.cursor(), 7);
t.move_cursor_right(); assert_eq!(t.cursor(), 8);
}
#[test]
fn word_delete_backward_removes_element_atomically() {
let mut t = TextArea::new();
t.insert_str("prefix ");
t.insert_element("[pasted content]", ElementKind(0), None);
assert_eq!(t.cursor(), 23);
t.delete_backward_word();
assert_eq!(t.text(), "prefix ");
assert!(t.elements().is_empty());
}
#[test]
fn word_delete_forward_removes_element_atomically() {
let mut t = TextArea::new();
t.insert_element("[element]", ElementKind(0), None);
t.insert_str(" suffix");
t.set_cursor(0);
t.delete_forward_word();
assert_eq!(t.text(), " suffix");
assert!(t.elements().is_empty());
}
#[test]
fn kill_to_eol_removes_element_in_range() {
let mut t = TextArea::new();
t.insert_str("start ");
t.insert_element("[elem]", ElementKind(0), None);
t.insert_str(" end");
t.set_cursor(6);
t.kill_to_end_of_line();
assert_eq!(t.text(), "start ");
assert!(t.elements().is_empty());
}
#[test]
fn ctrl_e_skips_newline_inside_element() {
let mut t = TextArea::new();
t.insert_str("foo ");
t.insert_element("line1\nline2", ElementKind(1), None);
t.insert_str(" bar");
t.set_cursor(0);
t.move_cursor_to_end_of_line(false);
assert_eq!(t.cursor(), t.text().len()); }
#[test]
fn ctrl_a_skips_newline_inside_element() {
let mut t = TextArea::new();
t.insert_str("foo ");
t.insert_element("line1\nline2", ElementKind(1), None);
t.insert_str(" bar");
t.set_cursor(t.text().len());
t.move_cursor_to_beginning_of_line(false);
assert_eq!(t.cursor(), 0); }
#[test]
fn ctrl_e_from_element_boundary_skips_to_real_eol() {
let mut t = TextArea::new();
t.insert_str("foo ");
t.insert_element("a\nb\nc", ElementKind(1), None);
t.insert_str(" bar");
t.set_cursor(4);
t.move_cursor_to_end_of_line(false);
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn ctrl_a_from_after_element_skips_to_real_bol() {
let mut t = TextArea::new();
t.insert_str("foo ");
t.insert_element("a\nb\nc", ElementKind(1), None);
t.insert_str(" bar");
t.set_cursor(10);
t.move_cursor_to_beginning_of_line(false);
assert_eq!(t.cursor(), 0);
}
#[test]
fn kill_to_eol_with_multiline_element() {
let mut t = TextArea::new();
t.insert_str("foo ");
t.insert_element("x\ny\nz", ElementKind(1), None);
t.insert_str(" bar");
t.set_cursor(0);
t.kill_to_end_of_line();
assert_eq!(t.text(), "");
}
#[test]
fn kill_to_bol_with_multiline_element() {
let mut t = TextArea::new();
t.insert_str("foo ");
t.insert_element("x\ny\nz", ElementKind(1), None);
t.insert_str(" bar");
t.set_cursor(t.text().len()); t.kill_to_beginning_of_line();
assert_eq!(t.text(), "");
}
#[test]
fn bol_eol_with_real_newline_and_element() {
let mut t = TextArea::new();
t.insert_str("hello\nfoo ");
t.insert_element("a\nb", ElementKind(1), None);
t.insert_str(" bar");
t.set_cursor(6);
t.move_cursor_to_end_of_line(false);
assert_eq!(t.cursor(), t.text().len());
t.move_cursor_to_beginning_of_line(false);
assert_eq!(t.cursor(), 6);
}
#[test]
fn bol_eol_no_element_unchanged() {
let mut t = TextArea::new();
t.insert_str("line1\nline2\nline3");
t.set_cursor(6); t.move_cursor_to_end_of_line(false);
assert_eq!(t.cursor(), 11);
t.move_cursor_to_beginning_of_line(false);
assert_eq!(t.cursor(), 6);
}
#[test]
fn wrapping_uses_element_display_width() {
let mut t = TextArea::new();
t.insert_str("foo bar ");
let display = Line::from("[📎 Pasted 1 line, 11 chars]"); t.insert_element("Clean build", ElementKind(0), Some(display));
let lines = t.wrapped_lines(20);
assert!(
lines.len() >= 2,
"Expected wrapping to produce at least 2 lines, got {} lines. \
Line ranges: {:?}",
lines.len(),
&*lines,
);
}
#[test]
fn wrapping_element_fits_on_next_line() {
let mut t = TextArea::new();
t.insert_str("hello ");
let display = Line::from("[Pasted!]"); t.insert_element("xy", ElementKind(0), Some(display));
t.insert_str(" z");
let lines = t.wrapped_lines(12);
assert_eq!(
lines.len(),
2,
"Expected 2 wrapped lines, got {}. Ranges: {:?}",
lines.len(),
&*lines,
);
}
#[test]
fn wrapping_element_without_display_uses_buffer_width() {
let mut t = TextArea::new();
t.insert_str("hello ");
t.insert_element("xy", ElementKind(0), None);
t.insert_str(" z");
let lines = t.wrapped_lines(12);
assert_eq!(lines.len(), 1);
}
#[test]
fn wrapping_element_display_renders_on_correct_lines() {
let mut t = TextArea::new();
t.insert_str("abc ");
let display = Line::from("[ELEM]");
t.insert_element("xy", ElementKind(0), Some(display));
t.insert_str(" d");
{
let lines = t.wrapped_lines(8);
assert_eq!(
lines.len(),
2,
"Should wrap into 2 lines, got {:?}",
&*lines
);
}
let area = Rect::new(0, 0, 8, 2);
let mut buf = Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "a");
assert_eq!(buf.cell((1, 0)).unwrap().symbol(), "b");
assert_eq!(buf.cell((2, 0)).unwrap().symbol(), "c");
assert_eq!(buf.cell((3, 0)).unwrap().symbol(), " ");
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "[");
assert_eq!(buf.cell((1, 1)).unwrap().symbol(), "E");
assert_eq!(buf.cell((5, 1)).unwrap().symbol(), "]");
assert_eq!(buf.cell((6, 1)).unwrap().symbol(), " ");
assert_eq!(buf.cell((7, 1)).unwrap().symbol(), "d");
}
#[test]
fn wrapping_element_with_newlines_stays_single_line() {
let mut t = TextArea::new();
t.insert_str("hello ");
let display = Line::from("[paste]"); t.insert_element("line1\nline2\nline3", ElementKind(0), Some(display));
t.insert_str(" world");
let lines = t.wrapped_lines(40);
assert_eq!(
lines.len(),
1,
"Element with internal \\n should NOT create extra visual lines. \
Got {} lines: {:?}",
lines.len(),
&*lines,
);
}
#[test]
fn cursor_pos_after_multiline_element() {
let mut t = TextArea::new();
t.insert_str("hello ");
let display = Line::from("[paste]"); t.insert_element("line1\nline2", ElementKind(0), Some(display));
t.insert_str(" world");
let area = Rect::new(0, 0, 80, 10);
let pos = t.cursor_pos(area);
assert_eq!(
pos,
Some((19, 0)), "Cursor should be at col 19, row 0 after multiline element. \
Got {:?}. Buffer: {:?}, cursor byte: {}",
pos,
t.text(),
t.cursor(),
);
}
#[test]
fn yank_restores_last_kill() {
let mut t = ta_with("hello");
t.set_cursor(0);
t.kill_to_end_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
t.yank();
assert_eq!(t.text(), "hello world");
assert_eq!(t.cursor(), 11);
let mut t = ta_with("hello");
t.set_cursor(5);
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
}
#[test]
fn no_op_kill_preserves_the_kill_buffer() {
let mut textarea = ta_with("hello");
textarea.set_cursor(0);
textarea.kill_to_end_of_line();
assert_eq!(textarea.kill_buffer, "hello");
textarea.set_text("world");
textarea.set_cursor(textarea.text().len());
textarea.kill_to_end_of_line();
textarea.yank();
assert_eq!(textarea.text(), "worldhello");
}
#[test]
fn kill_buffer_survives_set_text() {
let mut t = ta_with("hello");
t.set_cursor(0);
t.kill_to_end_of_line();
assert_eq!(t.text(), "");
t.set_text(""); assert_eq!(t.text(), "");
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
}
#[test]
fn cursor_left_and_right_handle_graphemes() {
let mut t = ta_with("a👍b");
t.set_cursor(t.text().len());
t.move_cursor_left(); let after_first_left = t.cursor();
t.move_cursor_left(); let after_second_left = t.cursor();
t.move_cursor_left(); let after_third_left = t.cursor();
assert!(after_first_left < t.text().len());
assert!(after_second_left < after_first_left);
assert!(after_third_left < after_second_left);
t.move_cursor_right();
t.move_cursor_right();
t.move_cursor_right();
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn control_b_and_f_move_cursor() {
let mut t = ta_with("abcd");
t.set_cursor(1);
t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert_eq!(t.cursor(), 2);
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
assert_eq!(t.cursor(), 1);
}
#[test]
fn control_b_f_fallback_control_chars_move_cursor() {
let mut t = ta_with("abcd");
t.set_cursor(2);
t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE));
assert_eq!(t.cursor(), 1);
t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE));
assert_eq!(t.cursor(), 2);
}
#[test]
fn ctrl_w_unix_word_rubout_deletes_to_whitespace() {
let mut t = ta_with("git commit -m hello-world");
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "git commit -m ");
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "git commit ");
}
#[test]
fn unix_word_rubout_whitespace_runs_paths_and_edges() {
let mut t = ta_with("cat path/to/file.rs ");
t.set_cursor(t.text().len());
t.delete_backward_unix_word();
assert_eq!(t.text(), "cat ");
assert_eq!(t.cursor(), 4);
let mut t = ta_with("foo bar-baz");
t.set_cursor(7); t.delete_backward_unix_word();
assert_eq!(t.text(), "foo -baz");
assert_eq!(t.cursor(), 4);
let mut t = ta_with("line1\nword ");
t.set_cursor(t.text().len());
t.delete_backward_unix_word();
assert_eq!(t.text(), "line1\n");
let mut t = ta_with("");
t.delete_backward_unix_word();
assert_eq!(t.text(), "");
let mut t = ta_with(" ");
t.set_cursor(3);
t.delete_backward_unix_word();
assert_eq!(t.text(), "");
}
#[test]
fn alt_backspace_keeps_word_chunk_semantics() {
let mut t = ta_with("hello-world");
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
assert_eq!(t.text(), "hello-");
let mut t = ta_with("hello-world");
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
assert_eq!(t.text(), "hello-");
}
#[test]
fn delete_backward_word_alt_keys() {
let mut t = ta_with("hello world");
t.set_cursor(t.text().len()); t.input(KeyEvent::new(
KeyCode::Char('h'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
));
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
let mut t = ta_with("hello world");
t.set_cursor(t.text().len()); t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
}
#[test]
fn ctrl_backspace_deletes_backward_word() {
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
let mut t = ta_with("foo bar baz");
t.set_cursor(7); t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
assert_eq!(t.text(), "foo baz");
assert_eq!(t.cursor(), 4);
}
#[test]
fn ctrl_delete_deletes_forward_word() {
let mut t = ta_with("hello world");
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::CONTROL));
assert_eq!(t.text(), " world");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("foo bar baz");
t.set_cursor(4); t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::CONTROL));
assert_eq!(t.text(), "foo baz");
assert_eq!(t.cursor(), 4);
}
#[test]
fn delete_backward_word_handles_narrow_no_break_space() {
let mut t = ta_with("32\u{202F}AM");
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT));
pretty_assertions::assert_eq!(t.text(), "32\u{202F}");
pretty_assertions::assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn delete_forward_word_with_without_alt_modifier() {
let mut t = ta_with("hello world");
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT));
assert_eq!(t.text(), " world");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("hello");
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(t.text(), "ello");
assert_eq!(t.cursor(), 0);
}
#[test]
fn alt_d_deletes_forward_word() {
let mut t = ta_with("hello world foo");
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT));
assert_eq!(t.text(), " world foo");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("hello world");
t.set_cursor(5); t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT));
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
let mut t = ta_with("hello world");
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::SUPER));
assert_eq!(t.text(), " world");
assert_eq!(t.cursor(), 0);
}
#[test]
fn ctrl_p_moves_cursor_up() {
let mut t = ta_with("first\nsecond\nthird");
let second_line_start = 6; t.set_cursor(second_line_start + 2); t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert!(t.cursor() < second_line_start);
}
#[test]
fn ctrl_n_moves_cursor_down() {
let mut t = ta_with("first\nsecond\nthird");
t.set_cursor(2); t.input(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
let second_line_start = 6;
assert!(t.cursor() >= second_line_start);
}
#[test]
fn control_h_backspace() {
let mut t = ta_with("12345");
t.set_cursor(3); t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "1245");
assert_eq!(t.cursor(), 2);
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "1245");
assert_eq!(t.cursor(), 0);
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "124");
assert_eq!(t.cursor(), 3);
}
#[test]
fn char_bs_backspace() {
let mut t = ta_with("12345");
t.set_cursor(3); t.input(KeyEvent::new(KeyCode::Char('\x08'), KeyModifiers::NONE));
assert_eq!(t.text(), "1245");
assert_eq!(t.cursor(), 2);
}
#[test]
fn char_del_deletes_backward() {
let mut t = ta_with("12345");
t.set_cursor(2); t.input(KeyEvent::new(KeyCode::Char('\x7f'), KeyModifiers::NONE));
assert_eq!(t.text(), "1345");
assert_eq!(t.cursor(), 1);
}
#[test]
fn raw_delete_chars_ignore_stray_modifiers() {
for raw in ['\u{0008}', '\u{007f}'] {
for modifiers in [
KeyModifiers::ALT,
KeyModifiers::CONTROL,
KeyModifiers::SUPER,
KeyModifiers::ALT | KeyModifiers::CONTROL,
] {
let mut t = ta_with("alpha beta");
t.input(KeyEvent::new(KeyCode::Char(raw), modifiers));
assert_eq!(
t.text(),
"alpha bet",
"raw {raw:?} with {modifiers:?} must delete one grapheme",
);
}
}
}
#[test]
fn del_char_treated_as_backspace() {
let mut t = ta_with("hello");
t.set_cursor(3);
t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE));
assert_eq!(t.text(), "helo");
assert_eq!(t.cursor(), 2);
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE));
assert_eq!(t.text(), "helo");
assert_eq!(t.cursor(), 0);
t.set_cursor(t.text().len());
t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::ALT));
assert_eq!(t.text(), "hel");
assert_eq!(t.cursor(), 3);
}
#[test]
fn bs_char_treated_as_backspace() {
let mut t = ta_with("abcde");
t.set_cursor(4);
t.input(KeyEvent::new(KeyCode::Char('\u{0008}'), KeyModifiers::NONE));
assert_eq!(t.text(), "abce");
assert_eq!(t.cursor(), 3);
}
#[test]
fn del_char_with_selection_deletes_selection() {
let mut t = ta_with("hello world");
t.set_selection(0, 5);
t.input(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE));
assert_eq!(t.text(), " world");
assert_eq!(t.cursor(), 0);
assert!(t.selection_range().is_none());
}
#[test]
fn cursor_vertical_movement_across_lines_and_bounds() {
let mut t = ta_with("short\nloooooooooong\nmid");
let second_line_start = 6; t.set_cursor(second_line_start + 5);
t.move_cursor_up();
assert_eq!(t.cursor(), 5);
t.move_cursor_up();
assert_eq!(t.cursor(), 0);
t.move_cursor_down();
let pos_after_down = t.cursor();
assert!(pos_after_down >= second_line_start);
t.move_cursor_down();
let third_line_start = t.text().find("mid").unwrap();
let third_line_end = third_line_start + 3;
assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end);
t.move_cursor_down();
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn home_end_and_emacs_style_home_end() {
let mut t = ta_with("one\ntwo\nthree");
let second_line_start = t.text().find("two").unwrap();
t.set_cursor(second_line_start + 1);
t.move_cursor_to_beginning_of_line(false);
assert_eq!(t.cursor(), second_line_start);
t.move_cursor_to_beginning_of_line(true);
assert_eq!(t.cursor(), 0);
t.move_cursor_to_end_of_line(false);
assert_eq!(t.cursor(), 3);
t.move_cursor_to_end_of_line(true);
let end_second_nl = t.text().find("\nthree").unwrap();
assert_eq!(t.cursor(), end_second_nl);
}
#[test]
fn home_end_use_logical_line_when_soft_wrapped() {
let mut t = ta_with("abcdefghij");
let _ = t.desired_height(4);
t.set_cursor(6);
t.input(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
assert_eq!(t.cursor(), 0);
t.input(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
assert_eq!(t.cursor(), t.text().len());
t.set_cursor(6);
t.move_cursor_to_beginning_of_line(false);
assert_eq!(t.cursor(), 4);
t.move_cursor_to_end_of_line(false);
assert_eq!(t.cursor(), 7);
let mut multi = ta_with("abcdefghij\nxyz");
let _ = multi.desired_height(4);
multi.set_cursor(6);
multi.input(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
assert_eq!(multi.cursor(), 0);
multi.set_cursor(6);
multi.input(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
assert_eq!(multi.cursor(), "abcdefghij".len());
multi.set_cursor("abcdefghij\nxy".len());
multi.input(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
assert_eq!(multi.cursor(), "abcdefghij\n".len());
multi.input(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
assert_eq!(multi.cursor(), multi.text().len());
t.set_cursor(6);
t.move_cursor_to_beginning_of_line(true);
assert_eq!(t.cursor(), 0);
t.set_cursor(6);
t.move_cursor_to_end_of_line(true);
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn end_of_line_or_down_at_end_of_text() {
let mut t = ta_with("one\ntwo");
t.set_cursor(t.text().len());
t.move_cursor_to_end_of_line(true);
assert_eq!(t.cursor(), t.text().len());
let eol_first_line = 3; t.set_cursor(eol_first_line);
t.move_cursor_to_end_of_line(true);
assert_eq!(t.cursor(), t.text().len()); }
#[test]
fn word_navigation_helpers() {
let t = ta_with(" alpha beta gamma");
let mut t = t; let after_alpha = t.text().find("alpha").unwrap() + "alpha".len();
t.set_cursor(after_alpha);
assert_eq!(t.beginning_of_previous_word(), 2);
let beta_start = t.text().find("beta").unwrap();
t.set_cursor(beta_start);
assert_eq!(t.end_of_next_word(), beta_start + "beta".len());
t.set_cursor(t.text().len());
assert_eq!(t.end_of_next_word(), t.text().len());
}
#[test]
fn word_navigation_splits_on_hyphen() {
let mut t = ta_with("hello-world");
let hyphen = t.text().find('-').unwrap();
let after_hyphen = hyphen + 1;
t.set_cursor(t.text().len());
assert_eq!(t.beginning_of_previous_word(), after_hyphen);
t.set_cursor(after_hyphen);
assert_eq!(t.beginning_of_previous_word(), hyphen);
t.set_cursor(hyphen);
assert_eq!(t.beginning_of_previous_word(), 0);
t.set_cursor(0);
assert_eq!(t.end_of_next_word(), hyphen);
t.set_cursor(hyphen);
assert_eq!(t.end_of_next_word(), after_hyphen);
t.set_cursor(after_hyphen);
assert_eq!(t.end_of_next_word(), t.text().len());
}
#[test]
fn alt_arrow_navigation_splits_on_hyphen() {
let mut t = ta_with("hello-world");
let hyphen = t.text().find('-').unwrap();
let after_hyphen = hyphen + 1;
let end = t.text().len();
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));
assert_eq!(t.cursor(), hyphen);
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));
assert_eq!(t.cursor(), after_hyphen);
t.input(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));
assert_eq!(t.cursor(), end);
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT));
assert_eq!(t.cursor(), after_hyphen);
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT));
assert_eq!(t.cursor(), hyphen);
t.input(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT));
assert_eq!(t.cursor(), 0);
}
#[test]
fn cursor_at_wrap_boundary_shows_on_next_line() {
let mut t = ta_with("abcde");
let area = Rect::new(0, 0, 5, 3); t.set_cursor(5);
let (x, y) = t.cursor_pos(area).unwrap();
assert_eq!(x, 0, "cursor x should be 0 (start of virtual next line)");
assert_eq!(y, 1, "cursor y should be 1 (next line)");
let mut t = ta_with("abcdefgh");
let area = Rect::new(0, 0, 5, 3); t.set_cursor(5);
let (x, y) = t.cursor_pos(area).unwrap();
assert_eq!(x, 0, "cursor at wrap point should be col 0 of next line");
assert_eq!(y, 1, "cursor at wrap point should be on second visual line");
}
#[test]
fn wrapping_and_cursor_positions() {
let mut t = ta_with("hello world here");
let area = Rect::new(0, 0, 6, 10); assert!(t.desired_height(area.width) >= 3);
let world_start = t.text().find("world").unwrap();
t.set_cursor(world_start + 3);
let (_x, y) = t.cursor_pos(area).unwrap();
assert_eq!(y, 1);
let mut state = TextAreaState::default();
let small_area = Rect::new(0, 0, 6, 1);
let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
assert_eq!(y, 0);
let mut buf = Buffer::empty(small_area);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state);
let effective_lines = t.desired_height(small_area.width);
assert!(state.scroll < effective_lines);
}
#[test]
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
let mut t = ta_with("hello world");
t.set_cursor(3);
let area = Rect::new(2, 5, 20, 3);
let bad_state = TextAreaState { scroll: 999 };
let (x1, y1) = t.cursor_pos(area).unwrap();
let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap();
assert_eq!((x2, y2), (x1, y1));
let mut t = ta_with("one two three four five six");
let wrap_width = 4;
let _ = t.desired_height(wrap_width);
t.set_cursor(t.text().len().saturating_sub(2));
let small_area = Rect::new(0, 0, wrap_width, 2);
let state = TextAreaState { scroll: 0 };
let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
assert_eq!(y, small_area.y + small_area.height - 1);
let mut t = ta_with("alpha beta gamma delta epsilon zeta");
let wrap_width = 5;
let lines = t.desired_height(wrap_width);
t.set_cursor(1);
let area = Rect::new(0, 0, wrap_width, 3);
let state = TextAreaState {
scroll: lines.saturating_mul(2),
};
let (_x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!(y, area.y);
}
#[test]
fn screen_spans_of_range_single_row() {
let t = ta_with("xy /model tail");
let area = Rect::new(2, 1, 40, 3);
let state = TextAreaState::default();
let spans = t.screen_spans_of_range(3..9, area, state);
assert_eq!(spans, vec![Rect::new(5, 1, 6, 1)]);
assert!(t.screen_spans_of_range(4..4, area, state).is_empty());
assert!(t.screen_spans_of_range(4..999, area, state).is_empty());
}
#[test]
fn screen_spans_of_range_rejects_non_char_boundaries() {
let t = ta_with("héllo");
let area = Rect::new(0, 0, 10, 2);
let state = TextAreaState::default();
assert!(t.screen_spans_of_range(2..5, area, state).is_empty());
assert!(t.screen_spans_of_range(0..2, area, state).is_empty());
}
#[test]
fn screen_spans_of_range_covers_wrapped_rows() {
let mut t = ta_with("aa /pr-workflow");
t.show_scrollbar = false;
let area = Rect::new(0, 0, 8, 4);
let state = TextAreaState::default();
let spans = t.screen_spans_of_range(3..15, area, state);
assert!(
spans.len() >= 2,
"token must cover multiple rows: {spans:?}"
);
assert!(spans.iter().all(|r| r.height == 1));
for pair in spans.windows(2) {
assert_eq!(pair[1].y, pair[0].y + 1, "rows must be consecutive");
}
for r in &spans[1..] {
assert_eq!(r.x, area.x, "continuation rows start at the left edge");
assert!(r.right() <= area.x + area.width);
}
let total: u16 = spans.iter().map(|r| r.width).sum();
assert_eq!(total, 12);
}
#[test]
fn screen_spans_of_range_skips_offscreen_rows() {
let mut t = ta_with("/pr-workflow abc");
t.show_scrollbar = false;
let area = Rect::new(0, 0, 8, 2);
let state = TextAreaState::default();
let spans = t.screen_spans_of_range(0..12, area, state);
assert!(!spans.is_empty(), "visible token tail must be reported");
for r in &spans {
assert!((area.y..area.y + area.height).contains(&r.y));
assert!(r.width > 0 && r.right() <= area.x + area.width);
}
let total: u16 = spans.iter().map(|r| r.width).sum();
assert!(
total < 12,
"off-screen head must not be reported: {spans:?}"
);
}
#[test]
fn screen_spans_of_range_uses_display_width() {
let mut t = ta_with("日本語");
t.show_scrollbar = false;
let area = Rect::new(1, 0, 4, 3);
let state = TextAreaState::default();
let spans = t.screen_spans_of_range(0..9, area, state);
assert_eq!(spans, vec![Rect::new(1, 0, 4, 1), Rect::new(1, 1, 2, 1)]);
}
#[test]
fn screen_spans_of_range_clamps_to_content_edge() {
let mut t = ta_with("ab cd ef gh");
t.set_cursor(0);
let area = Rect::new(0, 0, 5, 2);
let state = TextAreaState::default();
let spans = t.screen_spans_of_range(0..5, area, state);
assert_eq!(spans, vec![Rect::new(0, 0, 4, 1)]);
}
#[test]
fn wrapped_navigation_across_visual_lines() {
let mut t = ta_with("abcdefghij");
t.show_scrollbar = false;
let _ = t.desired_height(4);
t.set_cursor(0);
t.move_cursor_down();
assert_eq!(t.cursor(), 4);
t.set_cursor(4);
let area = Rect::new(0, 0, 4, 10);
let (x, y) = t.cursor_pos(area).unwrap();
assert_eq!((x, y), (0, 1));
let small_area = Rect::new(0, 0, 4, 1);
let state = TextAreaState::default();
let (x, y) = t.cursor_pos_with_state(small_area, state).unwrap();
assert_eq!((x, y), (0, 0));
t.set_cursor(6);
t.move_cursor_up();
assert_eq!(t.cursor(), 2);
t.move_cursor_down();
assert_eq!(t.cursor(), 6);
t.move_cursor_down();
assert_eq!(t.cursor(), t.text().len());
}
#[test]
fn cursor_pos_with_state_after_movements() {
let mut t = ta_with("abcdefghij");
let _ = t.desired_height(4);
let area = Rect::new(0, 0, 4, 2);
let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
t.set_cursor(0);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 0));
t.move_cursor_down();
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 1));
t.move_cursor_down();
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 1));
t.move_cursor_up();
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 0));
t.set_cursor(2);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x0, y0), (2, 0));
t.move_cursor_down();
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x1, y1) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x1, y1), (2, 1));
}
#[test]
fn wrapped_navigation_with_newlines_and_spaces() {
let mut t = ta_with("word1 word2\nword3");
let _ = t.desired_height(6);
let start_word2 = t.text().find("word2").unwrap();
t.set_cursor(start_word2 + 1);
t.move_cursor_up();
assert_eq!(t.cursor(), 1);
t.move_cursor_down();
assert_eq!(t.cursor(), start_word2 + 1);
t.move_cursor_down();
let start_word3 = t.text().find("word3").unwrap();
assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len());
}
#[test]
fn wrapped_navigation_with_wide_graphemes() {
let mut t = ta_with("👍👍👍👍");
let _ = t.desired_height(3);
t.set_cursor("👍👍".len());
t.move_cursor_down();
let pos_after_down = t.cursor();
assert!(pos_after_down >= "👍👍".len());
t.move_cursor_up();
assert_eq!(t.cursor(), "👍👍".len());
}
#[test]
fn wrapped_navigation_with_zwj_graphemes() {
let grapheme = "👩\u{200D}💻";
let mut t = ta_with(&format!("{grapheme}{grapheme}{grapheme}"));
let _ = t.desired_height(4);
t.set_cursor(grapheme.len() * 2);
t.move_cursor_down();
let pos_after_down = t.cursor();
assert!(pos_after_down >= grapheme.len() * 2);
t.move_cursor_up();
assert_eq!(t.cursor(), grapheme.len() * 2);
}
#[test]
fn element_aware_wrap_ranges_preserve_zwj_graphemes() {
let grapheme = "👩\u{200D}💻";
let mut t = TextArea::new();
t.insert_str(&format!("{grapheme}{grapheme}"));
t.insert_element("raw", ElementKind(0), Some(Line::from("[P]")));
let ranges = {
let lines = t.wrapped_lines(2);
lines.iter().cloned().collect::<Vec<_>>()
};
assert_eq!(ranges.len(), 3);
assert_eq!(&t.text()[ranges[0].clone()], grapheme);
assert_eq!(&t.text()[ranges[1].clone()], grapheme);
}
#[test]
fn fuzz_textarea_randomized() {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let pst_today_seed: u64 = now / 86_400; let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed);
for _case in 0..500 {
let mut ta = TextArea::new();
let mut state = TextAreaState::default();
let mut elem_texts: Vec<String> = Vec::new();
let mut next_elem_id: usize = 0;
let base_len = rng.random_range(0..30);
let mut base = String::new();
for _ in 0..base_len {
base.push_str(&rand_grapheme(&mut rng));
}
ta.set_text(&base);
let mut boundaries: Vec<usize> = vec![0];
boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1));
boundaries.push(ta.text().len());
let init = boundaries[rng.random_range(0..boundaries.len())];
ta.set_cursor(init);
let mut width: u16 = rng.random_range(1..=12);
let mut height: u16 = rng.random_range(1..=4);
for _step in 0..60 {
if rng.random_bool(0.1) {
width = rng.random_range(1..=12);
}
if rng.random_bool(0.1) {
height = rng.random_range(1..=4);
}
match rng.random_range(0..18) {
0 => {
let len = rng.random_range(0..6);
let mut s = String::new();
for _ in 0..len {
s.push_str(&rand_grapheme(&mut rng));
}
ta.insert_str(&s);
}
1 => {
let mut b: Vec<usize> = vec![0];
b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1));
b.push(ta.text().len());
let i1 = rng.random_range(0..b.len());
let i2 = rng.random_range(0..b.len());
let (start, end) = if b[i1] <= b[i2] {
(b[i1], b[i2])
} else {
(b[i2], b[i1])
};
let insert_len = rng.random_range(0..=4);
let mut s = String::new();
for _ in 0..insert_len {
s.push_str(&rand_grapheme(&mut rng));
}
let before = ta.text().len();
let atomic_ranges = ta.element_ranges();
let plan = ta
.text
.plan_replace_byte_range(start..end, &s, &atomic_ranges);
let normalized_len = plan.replaced_byte_range().len();
ta.replace_range(start..end, &s);
let after = ta.text().len();
assert_eq!(
after as isize,
before as isize + (s.len() as isize) - (normalized_len as isize)
);
}
2 => ta.delete_backward(rng.random_range(0..=3)),
3 => ta.delete_forward(rng.random_range(0..=3)),
4 => ta.delete_backward_word(),
5 => ta.kill_to_beginning_of_line(),
6 => ta.kill_to_end_of_line(),
7 => ta.move_cursor_left(),
8 => ta.move_cursor_right(),
9 => ta.move_cursor_up(),
10 => ta.move_cursor_down(),
11 => ta.move_cursor_to_beginning_of_line(true),
12 => ta.move_cursor_to_end_of_line(true),
13 => {
let payload =
format!("[[EL#{}:{}]]", next_elem_id, rng.random_range(1000..9999));
next_elem_id += 1;
ta.insert_element(&payload, ElementKind(0), None);
elem_texts.push(payload);
}
14 => {
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
&& let Some(start) = ta.text().find(&payload)
{
let end = start + payload.len();
if end - start > 2 {
let pos = rng.random_range(start + 1..end - 1);
let ins = rand_grapheme(&mut rng);
ta.insert_str_at(pos, &ins);
}
}
}
15 => {
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
&& let Some(start) = ta.text().find(&payload)
{
let end = start + payload.len();
let mut s = start.saturating_sub(rng.random_range(0..=2));
let mut e = (end + rng.random_range(0..=2)).min(ta.text().len());
let txt = ta.text();
while s > 0 && !txt.is_char_boundary(s) {
s -= 1;
}
while e < txt.len() && !txt.is_char_boundary(e) {
e += 1;
}
if s < e {
let mut srep = String::new();
for _ in 0..rng.random_range(0..=2) {
srep.push_str(&rand_grapheme(&mut rng));
}
ta.replace_range(s..e, &srep);
}
}
}
16 => {
if let Some(payload) = elem_texts.choose(&mut rng).cloned()
&& let Some(start) = ta.text().find(&payload)
{
let end = start + payload.len();
if end - start > 2 {
let pos = rng.random_range(start + 1..end - 1);
ta.set_cursor(pos);
}
}
}
_ => {
if rng.random_bool(0.5) {
let p = ta.beginning_of_previous_word();
ta.set_cursor(p);
} else {
let p = ta.end_of_next_word();
ta.set_cursor(p);
}
}
}
assert!(ta.cursor() <= ta.text().len());
for payload in &elem_texts {
if let Some(start) = ta.text().find(payload) {
let end = start + payload.len();
assert_eq!(&ta.text()[start..end], payload);
let c = ta.cursor();
assert!(
c <= start || c >= end,
"cursor inside element: {start}..{end} at {c}"
);
}
}
let area = Rect::new(0, 0, width, height);
let total_lines = ta.desired_height(width);
let full_area = Rect::new(0, 0, width, total_lines.max(1));
let mut buf = Buffer::empty(full_area);
ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf);
let _ = ta.cursor_pos(area);
let (_x, _y) = ta
.cursor_pos_with_state(area, state)
.unwrap_or((area.x, area.y));
let mut sbuf = Buffer::empty(area);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut sbuf, &mut state);
let total_lines = total_lines as usize;
if (height as usize) >= total_lines {
assert_eq!(state.scroll, 0);
}
}
}
}
#[test]
fn buffer_pos_at_screen_plain_text_start() {
let t = ta_with("hello");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), Some(0));
}
#[test]
fn buffer_pos_at_screen_plain_text_middle() {
let t = ta_with("hello");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(3, 0, area, state), Some(3));
}
#[test]
fn buffer_pos_at_screen_past_end_of_line() {
let t = ta_with("hello");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(10, 0, area, state), Some(5));
}
#[test]
fn buffer_pos_at_screen_below_text() {
let t = ta_with("hello");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(0, 3, area, state), Some(5));
}
#[test]
fn buffer_pos_at_screen_outside_area() {
let t = ta_with("hello");
let area = Rect::new(5, 5, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), None);
assert_eq!(t.buffer_pos_at_screen(4, 5, area, state), None);
assert_eq!(t.buffer_pos_at_screen(5, 4, area, state), None);
}
#[test]
fn buffer_pos_at_screen_with_area_offset() {
let t = ta_with("hello");
let area = Rect::new(10, 5, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(13, 5, area, state), Some(3));
}
#[test]
fn buffer_pos_at_screen_multiline() {
let t = ta_with("hello\nworld");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(2, 0, area, state), Some(2));
assert_eq!(t.buffer_pos_at_screen(1, 1, area, state), Some(7));
}
#[test]
fn buffer_pos_at_screen_wrapped_text() {
let t = ta_with("abcdefghij");
let area = Rect::new(0, 0, 5, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(2, 0, area, state), Some(2));
assert_eq!(t.buffer_pos_at_screen(0, 1, area, state), Some(5));
assert_eq!(t.buffer_pos_at_screen(3, 1, area, state), Some(8));
}
#[test]
fn buffer_pos_at_screen_scrolled() {
let mut t = ta_with("aaa\nbbb\nccc");
t.set_cursor(t.text().len()); let area = Rect::new(0, 0, 20, 2);
let mut state = TextAreaState::default();
let mut buf = ratatui::buffer::Buffer::empty(area);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
assert_eq!(state.scroll, 1);
assert_eq!(t.buffer_pos_at_screen(1, 0, area, state), Some(5));
assert_eq!(t.buffer_pos_at_screen(2, 1, area, state), Some(10));
}
#[test]
fn buffer_pos_at_screen_wide_unicode() {
let t = ta_with("a🦀b");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), Some(0));
assert_eq!(t.buffer_pos_at_screen(1, 0, area, state), Some(1));
assert_eq!(t.buffer_pos_at_screen(2, 0, area, state), Some(1));
assert_eq!(t.buffer_pos_at_screen(3, 0, area, state), Some(5));
}
#[test]
fn buffer_pos_at_screen_element_with_display() {
let mut t = TextArea::new();
t.insert_str("ab");
let display = Line::from("[X]");
t.insert_element("raw_text", ElementKind(0), Some(display));
t.insert_str("cd");
assert_eq!(t.text(), "abraw_textcd");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), Some(0));
assert_eq!(t.buffer_pos_at_screen(1, 0, area, state), Some(1));
assert_eq!(t.buffer_pos_at_screen(2, 0, area, state), Some(2));
assert_eq!(t.buffer_pos_at_screen(3, 0, area, state), Some(2));
assert_eq!(t.buffer_pos_at_screen(4, 0, area, state), Some(10));
assert_eq!(t.buffer_pos_at_screen(5, 0, area, state), Some(10));
assert_eq!(t.buffer_pos_at_screen(6, 0, area, state), Some(11));
}
#[test]
fn element_at_screen_hit_and_miss() {
let mut t = TextArea::new();
t.insert_str("ab");
let display = Line::from("[File]");
let id = t.insert_element("file.rs", ElementKind(1), Some(display));
t.insert_str("cd");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert!(t.element_at_screen(0, 0, area, state).is_none());
assert!(t.element_at_screen(1, 0, area, state).is_none());
let elem = t.element_at_screen(2, 0, area, state);
assert!(elem.is_some());
assert_eq!(elem.unwrap().id, id);
assert_eq!(t.element_at_screen(3, 0, area, state).unwrap().id, id);
assert!(t.element_at_screen(8, 0, area, state).is_none());
}
#[test]
fn buffer_pos_at_screen_empty_textarea() {
let t = TextArea::new();
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
assert_eq!(t.buffer_pos_at_screen(0, 0, area, state), Some(0));
assert_eq!(t.buffer_pos_at_screen(5, 0, area, state), Some(0));
}
#[test]
fn selection_range_normalizes_anchor_head() {
let mut t = ta_with("hello world");
t.set_selection(8, 3);
let range = t.selection_range().unwrap();
assert_eq!(range, 3..8);
}
#[test]
fn selection_range_anchor_equals_head_is_none() {
let mut t = ta_with("hello");
t.set_selection(3, 3);
assert!(t.selection_range().is_none());
}
#[test]
fn selected_text_returns_buffer_substring() {
let mut t = ta_with("hello world");
t.set_selection(6, 11);
assert_eq!(t.selected_text().unwrap(), "world");
}
#[test]
fn selection_expands_to_element_boundaries() {
let mut t = TextArea::new();
t.insert_str("ab");
t.insert_element("element_text", ElementKind(0), None);
t.insert_str("cd");
assert_eq!(t.text(), "abelement_textcd");
t.set_selection(5, 10);
let range = t.selection_range().unwrap();
assert_eq!(range.start, 2); assert_eq!(range.end, 14); assert_eq!(t.selected_text().unwrap(), "element_text");
}
#[test]
fn clear_selection_clears() {
let mut t = ta_with("hello");
t.set_selection(0, 3);
assert!(t.selection_range().is_some());
t.clear_selection();
assert!(t.selection_range().is_none());
}
#[test]
fn take_clipboard_returns_and_clears() {
let mut t = TextArea::new();
t.set_clipboard_text("copied text".to_string());
let text = t.take_clipboard();
assert_eq!(text, Some("copied text".to_string()));
assert_eq!(t.take_clipboard(), None);
}
#[test]
fn no_selection_returns_none() {
let t = ta_with("hello");
assert!(t.selection_range().is_none());
assert!(t.selected_text().is_none());
}
#[test]
fn selection_rendering_applies_default_selection_style() {
let mut t = ta_with("hello");
t.set_selection(1, 4);
let area = Rect::new(0, 0, 10, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
let default_bg = Color::Rgb(49, 62, 115);
let default_fg = Color::Rgb(192, 202, 245);
for col in 1..4u16 {
let cell = &buf[(col, 0)];
assert_eq!(
cell.bg, default_bg,
"cell at col {col} should have default selection bg"
);
assert_eq!(
cell.fg, default_fg,
"cell at col {col} should have default selection fg"
);
}
assert_ne!(buf[(0, 0)].bg, default_bg);
assert_ne!(buf[(4, 0)].bg, default_bg);
}
#[test]
fn undo_insert_chars_one_at_a_time() {
let mut ta = TextArea::new();
ta.insert_str("a");
ta.insert_str("b");
ta.insert_str("c");
assert_eq!(ta.text(), "abc");
assert_eq!(ta.cursor(), 3);
assert!(ta.undo());
assert_eq!(ta.text(), "");
assert_eq!(ta.cursor(), 0);
assert!(!ta.undo());
}
#[test]
fn redo_after_undo_restores() {
let mut ta = TextArea::new();
ta.insert_str("hello");
ta.insert_str(" ");
ta.insert_str("world");
assert_eq!(ta.text(), "hello world");
ta.undo(); assert_eq!(ta.text(), "hello ");
ta.undo(); assert_eq!(ta.text(), "hello");
ta.undo(); assert_eq!(ta.text(), "");
ta.redo();
assert_eq!(ta.text(), "hello");
ta.redo();
assert_eq!(ta.text(), "hello ");
ta.redo();
assert_eq!(ta.text(), "hello world");
assert_eq!(ta.cursor(), 11);
}
#[test]
fn undo_via_super_modifier() {
let mut ta = TextArea::new();
ta.insert_str("hello");
assert_eq!(ta.text(), "hello");
ta.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::SUPER));
assert_eq!(ta.text(), "");
}
#[test]
fn redo_via_super_modifier() {
let mut ta = TextArea::new();
ta.insert_str("hello");
ta.undo();
assert_eq!(ta.text(), "");
ta.input(KeyEvent::new(
KeyCode::Char('Z'),
KeyModifiers::SUPER | KeyModifiers::SHIFT,
));
assert_eq!(ta.text(), "hello");
}
#[test]
fn redo_cleared_by_new_mutation() {
let mut ta = TextArea::new();
ta.insert_str("abc");
ta.undo(); assert_eq!(ta.text(), "");
assert!(ta.can_redo());
ta.insert_str("x"); assert!(!ta.can_redo());
assert_eq!(ta.text(), "x");
}
#[test]
fn undo_delete_backward_restores_char() {
let mut ta = TextArea::new();
ta.insert_str("hello");
ta.delete_backward(1); assert_eq!(ta.text(), "hell");
ta.undo(); assert_eq!(ta.text(), "hello");
assert_eq!(ta.cursor(), 5);
}
#[test]
fn undo_redo_preserves_cursor() {
let mut ta = TextArea::new();
ta.insert_str("abc");
ta.set_cursor(1);
ta.insert_str("X"); assert_eq!(ta.text(), "aXbc");
assert_eq!(ta.cursor(), 2);
ta.undo(); assert_eq!(ta.text(), "abc");
assert_eq!(ta.cursor(), 1);
ta.redo(); assert_eq!(ta.text(), "aXbc");
assert_eq!(ta.cursor(), 2);
}
#[test]
fn can_undo_can_redo_reflect_state() {
let mut ta = TextArea::new();
assert!(!ta.can_undo());
assert!(!ta.can_redo());
ta.insert_str("a");
assert!(ta.can_undo());
assert!(!ta.can_redo());
ta.undo();
assert!(!ta.can_undo());
assert!(ta.can_redo());
ta.redo();
assert!(ta.can_undo());
assert!(!ta.can_redo());
}
#[test]
fn undo_stack_depth_capped() {
let mut ta = TextArea::new();
ta.undo.max_depth = 5;
for i in 0..10 {
ta.set_text(&format!("v{i}"));
}
assert_eq!(ta.text(), "v9");
assert_eq!(ta.undo.stack.len(), 5);
let mut count = 0;
while ta.undo() {
count += 1;
}
assert_eq!(count, 5);
assert_eq!(ta.text(), "v4");
}
#[test]
fn undo_set_text_restores_previous() {
let mut ta = TextArea::new();
ta.insert_str("hello");
ta.set_text("new");
assert_eq!(ta.text(), "new");
ta.undo(); assert_eq!(ta.text(), "hello");
}
#[test]
fn undo_redo_multiple_round_trips() {
let mut ta = TextArea::new();
ta.insert_str("hello");
ta.delete_backward(2); assert_eq!(ta.text(), "hel");
ta.undo(); assert_eq!(ta.text(), "hello");
ta.undo(); assert_eq!(ta.text(), "");
ta.redo(); assert_eq!(ta.text(), "hello");
ta.redo(); assert_eq!(ta.text(), "hel");
ta.undo(); assert_eq!(ta.text(), "hello");
ta.insert_str("z"); assert_eq!(ta.text(), "helloz");
assert!(!ta.can_redo());
ta.undo();
assert_eq!(ta.text(), "hello");
}
#[test]
fn batch_consecutive_inserts_into_one_undo_step() {
let mut ta = TextArea::new();
ta.insert_str("h");
ta.insert_str("e");
ta.insert_str("l");
ta.insert_str("l");
ta.insert_str("o");
assert_eq!(ta.text(), "hello");
assert_eq!(ta.undo.stack.len(), 1);
ta.undo();
assert_eq!(ta.text(), "");
assert!(!ta.undo());
}
#[test]
fn multi_grapheme_delete_calls_are_single_undo_steps() {
for forward in [false, true] {
let mut ta = ta_with("hello");
if forward {
ta.set_cursor(0);
ta.delete_forward(2);
assert_eq!(ta.text(), "llo");
} else {
ta.delete_backward(2);
assert_eq!(ta.text(), "hel");
}
assert!(ta.undo());
assert_eq!(ta.text(), "hello");
}
}
#[test]
fn multi_count_deletes_cross_atomic_element_boundaries() {
let mut backward = TextArea::new();
backward.insert_str("a");
backward.insert_element("TOKEN", ElementKind(1), None);
backward.insert_str("b");
backward.delete_backward(2);
assert_eq!(backward.text(), "a");
assert!(backward.elements().is_empty());
assert!(backward.undo());
assert_eq!(backward.text(), "aTOKENb");
let mut forward = TextArea::new();
forward.insert_str("a");
forward.insert_element("TOKEN", ElementKind(1), None);
forward.insert_str("b");
forward.set_cursor(0);
forward.delete_forward(2);
assert_eq!(forward.text(), "b");
assert!(forward.elements().is_empty());
assert!(forward.undo());
assert_eq!(forward.text(), "aTOKENb");
}
#[test]
fn batch_consecutive_deletes_into_one_undo_step() {
let mut ta = TextArea::new();
ta.insert_str("hello");
ta.delete_backward(1); ta.delete_backward(1); ta.delete_backward(1); ta.delete_backward(1); ta.delete_backward(1); assert_eq!(ta.text(), "");
ta.undo(); assert_eq!(ta.text(), "hello");
ta.undo(); assert_eq!(ta.text(), "");
assert!(!ta.undo());
}
#[test]
fn kind_change_breaks_batch() {
let mut ta = TextArea::new();
ta.insert_str("hello");
ta.delete_backward(1); assert_eq!(ta.text(), "hell");
ta.undo(); assert_eq!(ta.text(), "hello");
ta.undo(); assert_eq!(ta.text(), "");
}
#[test]
fn cursor_jump_breaks_insert_batch() {
let mut ta = TextArea::new();
ta.insert_str("he"); ta.set_cursor(0); ta.insert_str("X"); assert_eq!(ta.text(), "Xhe");
ta.undo(); assert_eq!(ta.text(), "he");
ta.undo(); assert_eq!(ta.text(), "");
}
#[test]
fn kill_always_discrete() {
let mut ta = TextArea::new();
ta.insert_str("hello world");
ta.set_cursor(5);
ta.kill_to_end_of_line(); assert_eq!(ta.text(), "hello");
ta.kill_to_end_of_line();
ta.undo(); assert_eq!(ta.text(), "hello world");
}
#[test]
fn kill_consecutive_each_own_step() {
let mut ta = TextArea::new();
ta.insert_str("aaa bbb ccc");
ta.set_cursor(7); ta.kill_to_end_of_line(); assert_eq!(ta.text(), "aaa bbb");
ta.set_cursor(3);
ta.kill_to_end_of_line(); assert_eq!(ta.text(), "aaa");
ta.undo(); assert_eq!(ta.text(), "aaa bbb");
ta.undo(); assert_eq!(ta.text(), "aaa bbb ccc");
}
#[test]
fn insert_str_multi_char_is_one_step() {
let mut ta = TextArea::new();
ta.insert_str("hello world");
assert_eq!(ta.undo.stack.len(), 1);
ta.undo();
assert_eq!(ta.text(), "");
}
#[test]
fn set_text_always_discrete() {
let mut ta = TextArea::new();
ta.set_text("first");
ta.set_text("second");
assert_eq!(ta.text(), "second");
ta.undo();
assert_eq!(ta.text(), "first");
ta.undo();
assert_eq!(ta.text(), "");
}
#[test]
fn insert_then_undo_then_insert_fresh_batch() {
let mut ta = TextArea::new();
ta.insert_str("ab");
ta.undo(); ta.insert_str("cd");
ta.insert_str("ef"); assert_eq!(ta.text(), "cdef");
ta.undo(); assert_eq!(ta.text(), "");
}
#[test]
fn delete_forward_batches() {
let mut ta = TextArea::new();
ta.insert_str("abcde");
ta.set_cursor(0);
ta.delete_forward(1); ta.delete_forward(1); ta.delete_forward(1); assert_eq!(ta.text(), "de");
ta.undo(); assert_eq!(ta.text(), "abcde");
}
#[test]
fn word_boundary_breaks_insert_batch() {
let mut ta = TextArea::new();
ta.insert_str("f");
ta.insert_str("o");
ta.insert_str("o");
ta.insert_str(" ");
ta.insert_str("b");
ta.insert_str("a");
ta.insert_str("r");
assert_eq!(ta.text(), "foo bar");
ta.undo(); assert_eq!(ta.text(), "foo ");
ta.undo(); assert_eq!(ta.text(), "foo");
ta.undo(); assert_eq!(ta.text(), "");
assert!(!ta.undo());
}
#[test]
fn word_boundary_whitespace_runs_batch_together() {
let mut ta = TextArea::new();
ta.insert_str("a");
ta.insert_str(" ");
ta.insert_str(" ");
ta.insert_str(" ");
ta.insert_str("b");
assert_eq!(ta.text(), "a b");
ta.undo(); assert_eq!(ta.text(), "a ");
ta.undo(); assert_eq!(ta.text(), "a");
ta.undo(); assert_eq!(ta.text(), "");
}
#[test]
fn word_boundary_newlines_are_whitespace() {
let mut ta = TextArea::new();
ta.insert_str("foo");
ta.insert_str("\n");
ta.insert_str("\n");
ta.insert_str(" ");
ta.insert_str(" ");
ta.insert_str("bar");
assert_eq!(ta.text(), "foo\n\n bar");
ta.undo(); assert_eq!(ta.text(), "foo\n\n ");
ta.undo(); assert_eq!(ta.text(), "foo");
ta.undo(); assert_eq!(ta.text(), "");
}
#[test]
fn word_boundary_multi_char_insert_str_is_one_step() {
let mut ta = TextArea::new();
ta.insert_str("hello world");
assert_eq!(ta.undo.stack.len(), 1);
ta.undo();
assert_eq!(ta.text(), "");
}
#[test]
fn word_boundary_after_undo_starts_fresh() {
let mut ta = TextArea::new();
ta.insert_str("abc");
ta.insert_str(" ");
ta.undo(); assert_eq!(ta.text(), "abc");
ta.insert_str("d");
ta.insert_str("e");
assert_eq!(ta.text(), "abcde");
ta.undo(); assert_eq!(ta.text(), "abc");
}
#[test]
fn element_insert_always_discrete() {
let mut ta = TextArea::new();
ta.insert_str("hi ");
ta.insert_element("@file.rs", ElementKind(0), None);
assert_eq!(ta.text(), "hi @file.rs");
ta.undo(); assert_eq!(ta.text(), "hi ");
assert!(ta.elements().is_empty());
ta.undo(); assert_eq!(ta.text(), "");
}
#[test]
fn undo_insert_element_redo_preserves_element_id() {
let mut ta = TextArea::new();
let id = ta.insert_element("@foo", ElementKind(1), None);
assert_eq!(ta.elements().len(), 1);
assert_eq!(ta.elements()[0].id, id);
assert_eq!(ta.cursor(), "@foo".len());
ta.undo(); assert!(ta.elements().is_empty());
assert_eq!(ta.text(), "");
assert_eq!(ta.cursor(), 0);
ta.redo(); assert_eq!(ta.elements().len(), 1);
assert_eq!(ta.elements()[0].id, id);
assert_eq!(ta.text(), "@foo");
assert_eq!(ta.cursor(), "@foo".len());
}
#[test]
fn undo_redo_zero_length_element_preserves_metadata_and_cursor() {
let mut ta = TextArea::new();
let id = ta.insert_element("", ElementKind(9), None);
assert_eq!(ta.cursor(), 0);
assert_eq!(ta.elements()[0].range, 0..0);
assert!(ta.undo());
assert!(ta.elements().is_empty());
assert_eq!(ta.cursor(), 0);
assert!(ta.redo());
assert_eq!(ta.elements().len(), 1);
assert_eq!(ta.elements()[0].id, id);
assert_eq!(ta.elements()[0].range, 0..0);
assert_eq!(ta.cursor(), 0);
}
#[test]
fn undo_replace_range_with_element_restores_original() {
let mut ta = TextArea::new();
ta.insert_str("hello @foo world");
let id = ta.replace_range_with_element(6..10, "@bar.rs", ElementKind(2), None);
assert_eq!(ta.text(), "hello @bar.rs world");
assert_eq!(ta.elements().len(), 1);
assert_eq!(ta.elements()[0].id, id);
ta.undo(); assert_eq!(ta.text(), "hello @foo world");
assert!(ta.elements().is_empty());
ta.redo(); assert_eq!(ta.text(), "hello @bar.rs world");
assert_eq!(ta.elements().len(), 1);
assert_eq!(ta.elements()[0].id, id);
}
#[test]
fn undo_element_display_preserved() {
let mut ta = TextArea::new();
let display = Line::from(vec![
ratatui::text::Span::styled("[", Style::default().fg(Color::Green)),
ratatui::text::Span::raw("file.rs"),
ratatui::text::Span::styled("]", Style::default().fg(Color::Green)),
]);
let id = ta.insert_element("@file.rs", ElementKind(0), Some(display));
assert!(ta.elements()[0].display.is_some());
ta.undo();
assert!(ta.elements().is_empty());
ta.redo();
assert_eq!(ta.elements().len(), 1);
assert_eq!(ta.elements()[0].id, id);
let restored = ta.elements()[0].display.as_ref().unwrap();
assert_eq!(restored.spans.len(), 3);
let text: String = restored.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "[file.rs]");
}
#[test]
fn next_element_id_never_decreases_after_undo() {
let mut ta = TextArea::new();
let id1 = ta.insert_element("a", ElementKind(0), None);
let id2 = ta.insert_element("b", ElementKind(0), None);
ta.undo(); ta.undo(); assert!(ta.elements().is_empty());
let id3 = ta.insert_element("c", ElementKind(0), None);
assert_ne!(id3, id1);
assert_ne!(id3, id2);
assert!(id3.0 > id2.0);
}
#[test]
fn backspace_on_element_undo_restores_element() {
let mut ta = TextArea::new();
ta.insert_str("before ");
let id = ta.insert_element("[paste]", ElementKind(0), None);
assert_eq!(ta.text(), "before [paste]");
assert_eq!(ta.cursor(), 14);
ta.delete_backward(1);
assert_eq!(ta.text(), "before ");
assert!(ta.elements().is_empty());
ta.undo();
assert_eq!(ta.text(), "before [paste]");
assert_eq!(ta.elements().len(), 1);
assert_eq!(ta.elements()[0].id, id);
assert_eq!(ta.elements()[0].range, 7..14);
}
#[test]
fn undo_group_collapses_multiple_mutations() {
let mut ta = TextArea::new();
ta.insert_str("hello @fo");
assert_eq!(ta.text(), "hello @fo");
ta.begin_undo_group();
ta.replace_range_with_element(6..9, "@foo.rs", ElementKind(1), None);
ta.insert_str(" "); ta.end_undo_group();
assert_eq!(ta.text(), "hello @foo.rs ");
assert_eq!(ta.elements().len(), 1);
ta.undo();
assert_eq!(ta.text(), "hello @fo");
assert!(ta.elements().is_empty());
}
#[test]
fn cancel_undo_group_restores_original() {
let mut ta = TextArea::new();
ta.insert_str("original");
let stack_before = ta.undo.stack.len();
ta.begin_undo_group();
ta.set_text("modified once");
ta.set_text("modified twice");
ta.cancel_undo_group();
assert_eq!(ta.text(), "original");
assert_eq!(ta.undo.stack.len(), stack_before);
}
#[test]
fn nested_groups_only_outermost_pushes() {
let mut ta = TextArea::new();
ta.insert_str("start");
ta.begin_undo_group(); ta.insert_str(" A");
ta.begin_undo_group(); ta.insert_str(" B");
ta.end_undo_group(); assert_eq!(ta.text(), "start A B");
ta.insert_str(" C");
ta.end_undo_group();
assert_eq!(ta.text(), "start A B C");
ta.undo();
assert_eq!(ta.text(), "start");
}
#[test]
fn group_with_no_mutations_creates_no_entry() {
let mut ta = TextArea::new();
ta.insert_str("hello");
let stack_len = ta.undo.stack.len();
ta.begin_undo_group();
ta.end_undo_group();
assert_eq!(ta.undo.stack.len(), stack_len);
}
#[test]
fn redo_cleared_by_end_undo_group() {
let mut ta = TextArea::new();
ta.insert_str("hello");
ta.undo(); assert!(ta.can_redo());
ta.begin_undo_group();
ta.insert_str("world");
ta.end_undo_group();
assert!(!ta.can_redo());
assert_eq!(ta.text(), "world");
}
#[test]
fn cancel_nested_group_restores_outermost() {
let mut ta = TextArea::new();
ta.insert_str("original");
ta.begin_undo_group();
ta.insert_str(" X");
ta.begin_undo_group();
ta.insert_str(" Y");
ta.cancel_undo_group();
assert_eq!(ta.text(), "original");
assert_eq!(ta.undo.group_depth, 0);
}
#[test]
fn mutations_after_group_work_normally() {
let mut ta = TextArea::new();
ta.begin_undo_group();
ta.insert_str("grouped");
ta.end_undo_group();
ta.insert_str("X");
ta.insert_str("Y");
ta.undo(); assert_eq!(ta.text(), "grouped");
ta.undo(); assert_eq!(ta.text(), "");
}
fn mouse_down(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn mouse_up(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn click_places_cursor_at_correct_position() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_down(3, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert_eq!(ta.cursor(), 3);
let action = ta.handle_mouse(mouse_down(0, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert_eq!(ta.cursor(), 0);
}
#[test]
fn click_on_element_returns_clicked_element() {
let mut ta = TextArea::new();
ta.insert_str("hi ");
let id = ta.insert_element("elem", ElementKind(0), None);
ta.insert_str(" bye");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_down(4, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
let ev = ta.poll_element_event().expect("should emit element click");
assert_eq!(ev.id, id);
assert_eq!(ev.kind, TextElementEventKind::Click);
}
#[test]
fn click_past_end_of_line_snaps_to_line_end() {
let mut ta = ta_with("hi");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_down(20, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert_eq!(ta.cursor(), 2); }
#[test]
fn click_below_text_snaps_to_text_end() {
let mut ta = ta_with("hello");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_down(0, 3), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert_eq!(ta.cursor(), 5); }
#[test]
fn click_clears_existing_selection() {
let mut ta = ta_with("hello world");
ta.set_selection(0, 5);
assert!(ta.selection_range().is_some());
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(8, 0), area, state);
assert!(ta.selection_range().is_none());
}
#[test]
fn click_outside_area_returns_nothing() {
let mut ta = ta_with("hello");
let area = Rect::new(5, 5, 20, 3);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_down(0, 0), area, state);
assert_eq!(action, MouseAction::Nothing);
}
#[test]
fn mouse_up_clears_down_pos() {
let mut ta = ta_with("hello");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(2, 0), area, state);
assert!(ta.mouse_down_pos.is_some());
ta.handle_mouse(mouse_up(2, 0), area, state);
assert!(ta.mouse_down_pos.is_none());
}
#[test]
fn click_on_second_line_multiline_text() {
let mut ta = ta_with("hello\nworld");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_down(2, 1), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert_eq!(ta.cursor(), 8); }
fn mouse_drag(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn drag_selects_text() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
let action = ta.handle_mouse(mouse_drag(5, 0), area, state);
assert_eq!(action, MouseAction::SelectionUpdated);
assert_eq!(ta.selection_range(), Some(0..5));
assert_eq!(ta.selected_text(), Some("hello".to_string()));
}
#[test]
fn drag_across_element_expands_to_element_boundaries() {
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let mut ta = TextArea::new();
ta.insert_str("ab");
ta.insert_element("ELEM", ElementKind(0), None);
ta.insert_str("cd");
ta.handle_mouse(mouse_down(1, 0), area, state);
ta.handle_mouse(mouse_drag(7, 0), area, state);
let range = ta.selection_range().unwrap();
assert_eq!(range, 1..7);
let mut ta = TextArea::new();
ta.insert_str("ab");
ta.insert_element("ELEM", ElementKind(0), None);
ta.insert_str("cd");
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(3, 0), area, state);
let range = ta.selection_range().unwrap();
assert_eq!(range, 0..2);
let mut ta = TextArea::new();
ta.insert_str("ab");
ta.insert_element("ELEM", ElementKind(0), None);
ta.insert_str("cd");
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(5, 0), area, state);
let range = ta.selection_range().unwrap();
assert_eq!(range, 0..6);
}
#[test]
fn mouse_up_after_drag_copies_to_clipboard() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(6, 0), area, state);
ta.handle_mouse(mouse_drag(11, 0), area, state);
let action = ta.handle_mouse(mouse_up(11, 0), area, state);
assert_eq!(action, MouseAction::SelectionFinished);
assert_eq!(ta.take_clipboard(), Some("world".to_string()));
assert_eq!(ta.take_clipboard(), None);
}
#[test]
fn selection_persists_after_mouseup_by_default() {
let mut ta = ta_with("hello");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(3, 0), area, state);
ta.handle_mouse(mouse_up(3, 0), area, state);
assert!(ta.selection_range().is_some());
assert_eq!(ta.selected_text(), Some("hel".to_string()));
}
#[test]
fn selection_clears_after_mouseup_when_configured() {
let mut ta = ta_with("hello");
ta.keep_selection_after_mouseup = false;
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(3, 0), area, state);
ta.handle_mouse(mouse_up(3, 0), area, state);
assert_eq!(ta.take_clipboard(), Some("hel".to_string()));
assert!(ta.selection_range().is_none());
}
#[test]
fn backspace_deletes_selection_only() {
let mut ta = ta_with("hello world");
ta.set_selection(0, 5);
assert_eq!(ta.selected_text(), Some("hello".to_string()));
ta.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(ta.text(), " world");
assert_eq!(ta.cursor(), 0);
assert!(ta.selection_range().is_none());
}
#[test]
fn typing_replaces_selection() {
let mut ta = ta_with("hello world");
ta.set_selection(0, 5);
ta.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
assert_eq!(ta.text(), "X world");
assert_eq!(ta.cursor(), 1);
assert!(ta.selection_range().is_none());
}
#[test]
fn arrow_clears_selection() {
let mut ta = ta_with("hello world");
ta.set_selection(0, 5);
assert!(ta.selection_range().is_some());
ta.input(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert!(ta.selection_range().is_none());
}
#[test]
fn undo_after_delete_selection_restores() {
let mut ta = ta_with("hello world");
ta.set_cursor(ta.text().len());
ta.set_selection(0, 5);
ta.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(ta.text(), " world");
assert_eq!(ta.cursor(), 0);
assert_eq!(ta.undo.last_cursor, 0);
ta.undo();
assert_eq!(ta.text(), "hello world");
}
#[test]
fn undo_after_type_replace_selection_restores() {
let mut ta = ta_with("hello world");
ta.set_selection(0, 5);
ta.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
assert_eq!(ta.text(), "X world");
ta.undo();
assert_eq!(ta.text(), "hello world");
}
#[test]
fn backspace_works_with_zero_width_selection() {
let mut ta = ta_with("hello");
ta.set_cursor(5);
ta.set_selection(5, 5);
assert!(ta.selection_range().is_none());
ta.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(ta.text(), "hell");
assert_eq!(ta.cursor(), 4);
assert!(ta.selection.is_none());
}
#[test]
fn delete_forward_works_with_zero_width_selection() {
let mut ta = ta_with("hello");
ta.set_cursor(2);
ta.set_selection(2, 2);
ta.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(ta.text(), "helo");
assert_eq!(ta.cursor(), 2);
assert!(ta.selection.is_none());
}
#[test]
fn ctrl_x_with_zero_width_selection_falls_through() {
let mut ta = ta_with("hello");
ta.set_cursor(5);
ta.set_selection(5, 5);
ta.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
assert!(ta.selection.is_none());
}
#[test]
fn mouse_up_discards_zero_width_drag_selection() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(3, 0), area, state);
let action = ta.handle_mouse(mouse_drag(3, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
let action = ta.handle_mouse(mouse_up(3, 0), area, state);
assert_eq!(action, MouseAction::Nothing);
assert!(ta.selection.is_none());
}
#[test]
fn drag_backward_selects_correctly() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(8, 0), area, state);
ta.handle_mouse(mouse_drag(3, 0), area, state);
assert_eq!(ta.selection_range(), Some(3..8));
assert_eq!(ta.selected_text(), Some("lo wo".to_string()));
}
#[test]
fn click_after_drag_clears_selection() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(5, 0), area, state);
ta.handle_mouse(mouse_up(5, 0), area, state);
assert!(ta.selection_range().is_some());
ta.handle_mouse(mouse_down(8, 0), area, state);
assert!(ta.selection_range().is_none());
}
#[test]
fn set_text_clears_selection_and_drag_state() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(5, 0), area, state);
assert!(ta.selection_range().is_some());
assert!(ta.drag_anchor.is_some());
assert!(ta.drag_active);
assert!(ta.mouse_down_pos.is_some());
ta.set_text("reset");
assert!(ta.selection.is_none());
assert!(ta.selection_range().is_none());
assert!(ta.drag_anchor.is_none());
assert!(!ta.drag_active);
assert!(ta.mouse_down_pos.is_none());
assert!(ta.pending_drag_scroll.is_none());
}
#[test]
fn same_cell_drag_does_not_create_selection() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(3, 0), area, state);
let action = ta.handle_mouse(mouse_drag(3, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert!(ta.selection.is_none());
assert!(ta.selection_range().is_none());
assert!(!ta.drag_active);
}
#[test]
fn typing_with_zero_width_selection_inserts_character() {
let mut ta = ta_with("hello");
ta.set_cursor(5);
ta.set_selection(5, 5);
ta.input(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT));
assert_eq!(ta.text(), "hello!");
assert_eq!(ta.cursor(), 6);
assert!(ta.selection.is_none());
}
#[test]
fn mouse_up_after_drag_clears_drag_anchor() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(5, 0), area, state);
assert!(ta.drag_anchor.is_some());
ta.handle_mouse(mouse_up(5, 0), area, state);
assert!(ta.drag_anchor.is_none());
assert!(!ta.drag_active);
}
#[test]
fn double_click_selects_word() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(2, 0), area, state);
let action = ta.handle_mouse(mouse_down(2, 0), area, state);
assert_eq!(action, MouseAction::SelectionFinished);
assert_eq!(ta.selection_range(), Some(0..5));
assert_eq!(ta.selected_text(), Some("hello".to_string()));
assert_eq!(ta.take_clipboard(), Some("hello".to_string()));
}
#[test]
fn double_click_cursor_on_last_char() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(2, 0), area, state);
ta.handle_mouse(mouse_down(2, 0), area, state);
assert_eq!(ta.selection_range(), Some(0..5));
assert_eq!(
ta.cursor(),
4,
"double-click cursor should be on last char 'o' (byte 4), got {}",
ta.cursor()
);
}
#[test]
fn double_click_cursor_on_last_char_unicode() {
let mut ta = ta_with("café bar");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(1, 0), area, state);
ta.handle_mouse(mouse_down(1, 0), area, state);
assert_eq!(ta.selected_text(), Some("café".to_string()));
assert_eq!(
ta.cursor(),
3,
"cursor should be on 'é' (byte 3), got {}",
ta.cursor()
);
}
#[test]
fn double_click_on_second_word() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(8, 0), area, state);
let action = ta.handle_mouse(mouse_down(8, 0), area, state);
assert_eq!(action, MouseAction::SelectionFinished);
assert_eq!(ta.selection_range(), Some(6..11));
assert_eq!(ta.selected_text(), Some("world".to_string()));
}
#[test]
fn double_click_stops_at_punctuation() {
let mut ta = ta_with("hello, world,");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(2, 0), area, state);
let action = ta.handle_mouse(mouse_down(2, 0), area, state);
assert_eq!(action, MouseAction::SelectionFinished);
assert_eq!(
ta.selected_text(),
Some("hello".to_string()),
"should select only 'hello', not include trailing comma"
);
assert_eq!(ta.selection_range(), Some(0..5));
}
#[test]
fn double_click_on_punctuation_selects_punctuation_run() {
let mut ta = ta_with("hello... world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(6, 0), area, state);
let action = ta.handle_mouse(mouse_down(6, 0), area, state);
assert_eq!(action, MouseAction::SelectionFinished);
assert_eq!(
ta.selected_text(),
Some("...".to_string()),
"double-click on punctuation should select the punctuation run"
);
}
#[test]
fn double_click_word_with_underscore() {
let mut ta = ta_with("hello_world foo");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(3, 0), area, state);
let action = ta.handle_mouse(mouse_down(3, 0), area, state);
assert_eq!(action, MouseAction::SelectionFinished);
assert_eq!(
ta.selected_text(),
Some("hello_world".to_string()),
"underscore should be part of the word"
);
}
#[test]
fn double_click_on_element_snaps_like_single_click() {
let mut ta = TextArea::new();
ta.insert_str("hi ");
let display = Line::from("[chip]");
let id = ta.insert_element("hidden\ntext", ElementKind(0), Some(display));
ta.insert_str(" bye");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(4, 0), area, state);
assert!(ta.poll_element_event().is_some());
let action = ta.handle_mouse(mouse_down(4, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert_eq!(ta.cursor(), 3); assert!(ta.selection_range().is_none());
assert_eq!(ta.take_clipboard(), None);
let ev = ta
.poll_element_event()
.expect("double-click re-emits Click");
assert_eq!(ev.id, id);
assert_eq!(ev.kind, TextElementEventKind::Click);
}
#[test]
fn triple_click_selects_line() {
let mut ta = ta_with("hello world\nsecond line\nthird");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(3, 0), area, state);
ta.handle_mouse(mouse_down(3, 0), area, state);
let action = ta.handle_mouse(mouse_down(3, 0), area, state);
assert_eq!(action, MouseAction::SelectionFinished);
assert_eq!(ta.selection_range(), Some(0..12));
assert_eq!(ta.selected_text(), Some("hello world\n".to_string()));
}
#[test]
fn triple_click_on_last_line_selects_to_end() {
let mut ta = ta_with("hello\nworld");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(2, 1), area, state);
ta.handle_mouse(mouse_down(2, 1), area, state);
let action = ta.handle_mouse(mouse_down(2, 1), area, state);
assert_eq!(action, MouseAction::SelectionFinished);
assert_eq!(ta.selection_range(), Some(6..11));
assert_eq!(ta.selected_text(), Some("world".to_string()));
}
#[test]
fn triple_click_cursor_stays_at_click_pos() {
let mut ta = ta_with("hello world\nsecond line\nthird");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(3, 0), area, state);
ta.handle_mouse(mouse_down(3, 0), area, state);
ta.handle_mouse(mouse_down(3, 0), area, state);
assert_eq!(ta.selection_range(), Some(0..12));
assert_eq!(
ta.cursor(),
3,
"triple-click cursor should stay at click pos (3), got {}",
ta.cursor()
);
}
#[test]
fn selection_uses_custom_style_override() {
let mut t = ta_with("hello");
t.selection_style = Style::default().bg(Color::Blue);
t.set_selection(1, 4);
let area = Rect::new(0, 0, 10, 1);
let mut buf = ratatui::buffer::Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);
for col in 1..4u16 {
let cell = &buf[(col, 0)];
assert_eq!(
cell.bg,
Color::Blue,
"cell at col {col} should have Blue bg"
);
}
assert_ne!(buf[(0, 0)].bg, Color::Blue);
assert_ne!(buf[(4, 0)].bg, Color::Blue);
}
#[test]
fn double_click_on_whitespace_places_cursor() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(6, 0), area, state);
let action = ta.handle_mouse(mouse_down(6, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert!(ta.selection_range().is_none());
}
#[test]
fn click_tracker_resets_on_position_change() {
let mut ta = ta_with("hello world");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(2, 0), area, state);
let action = ta.handle_mouse(mouse_down(8, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert!(ta.selection_range().is_none());
}
#[test]
fn drag_below_area_scrolls_down_and_extends_selection() {
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee");
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 3);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
assert_eq!(ta.cursor(), 0);
let action = ta.handle_mouse(mouse_drag(0, 5), area, state);
assert_eq!(action, MouseAction::SelectionUpdated);
assert!(ta.cursor() >= 12);
let range = ta.selection_range().unwrap();
assert_eq!(range.start, 0);
assert!(range.end >= 12);
}
#[test]
fn drag_above_area_scrolls_up_and_extends_selection() {
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee");
let area = Rect::new(0, 0, 40, 3);
ta.set_cursor(ta.text().len());
let state = TextAreaState { scroll: 2 };
ta.handle_mouse(mouse_down(1, 2), area, state);
let area2 = Rect::new(0, 5, 40, 3); ta.handle_mouse(mouse_down(1, 7), area2, state);
let action = ta.handle_mouse(mouse_drag(0, 3), area2, state);
assert_eq!(action, MouseAction::SelectionUpdated);
let range = ta.selection_range().unwrap();
assert!(range.start < range.end);
}
#[test]
fn drag_below_area_moves_cursor_past_last_visible_line() {
let text = "L0\nL1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9";
let mut ta = ta_with(text);
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 2);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
assert_eq!(ta.cursor(), 0);
let action = ta.handle_mouse(mouse_drag(1, 10), area, state);
assert_eq!(action, MouseAction::SelectionUpdated);
assert!(ta.cursor() >= 6, "cursor={} should be >= 6", ta.cursor());
}
#[test]
fn drag_above_wide_column_still_scrolls_up() {
let text = "ab\nab\nab\nab\nab\nab\nab\nab\nab\nab";
let mut ta = ta_with(text);
let area = Rect::new(0, 2, 40, 3); let state = TextAreaState { scroll: 5 };
ta.set_cursor(18);
ta.handle_mouse(mouse_down(1, 3), area, state);
let action = ta.handle_mouse(mouse_drag(50, 1), area, state);
assert_eq!(action, MouseAction::SelectionUpdated);
let cursor = ta.cursor();
assert!(
(12..15).contains(&cursor),
"cursor={cursor} should be in [12, 15) (on line 4), \
not at 15 (line 5 start)"
);
}
#[test]
fn drag_below_wide_column_still_scrolls_down() {
let text = "ab\nab\nab\nab\nab\nab\nab\nab\nab\nab";
let mut ta = ta_with(text);
let area = Rect::new(0, 0, 40, 3);
let state = TextAreaState::default();
ta.set_cursor(0);
ta.handle_mouse(mouse_down(1, 0), area, state);
let action = ta.handle_mouse(mouse_drag(50, 5), area, state);
assert_eq!(action, MouseAction::SelectionUpdated);
let cursor = ta.cursor();
assert!(
(12..15).contains(&cursor),
"cursor={cursor} should be in [12, 15) (on line 4), \
not at 15 (line 5 start)"
);
}
#[test]
fn drag_above_with_multibyte_line_end_does_not_panic() {
let text = "aaa│\nbbb│\nccc│\nddd│\neee│\nfff│\nggg│";
let mut ta = ta_with(text);
let area = Rect::new(0, 0, 40, 3);
let state = TextAreaState { scroll: 3 };
ta.set_cursor(20);
ta.handle_mouse(mouse_down(1, 1), area, state);
let action = ta.handle_mouse(mouse_drag(50, 0), area, state);
assert!(
matches!(action, MouseAction::SelectionUpdated),
"drag above should create selection, got {action:?}"
);
let cursor = ta.cursor();
assert!(
ta.text().is_char_boundary(cursor),
"cursor at byte {cursor} is not a char boundary"
);
}
#[test]
fn selection_across_element_with_multibyte_chars_does_not_panic() {
let mut ta = TextArea::new();
ta.insert_str("before ");
let backing = "│ Ctrl+Shift+Z/Y redo │\n│ Ctrl+C clear │";
ta.insert_element(backing, ElementKind(0), None);
ta.insert_str(" after");
let area = Rect::new(0, 0, 30, 5);
ta.set_selection(0, ta.text().len());
let mut buf = ratatui::buffer::Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&ta), area, &mut buf);
}
#[test]
fn click_on_text_with_multibyte_chars_does_not_panic() {
let text = "│ Ctrl+Shift+Z/Y redo │\n│ Ctrl+C clear │";
let mut ta = ta_with(text);
let area = Rect::new(0, 0, 30, 5);
let state = TextAreaState::default();
for col in 0..25u16 {
ta.handle_mouse(mouse_down(col, 0), area, state);
}
ta.handle_mouse(mouse_down(5, 0), area, state);
ta.handle_mouse(mouse_down(5, 0), area, state);
}
#[test]
fn selecting_wrapped_line_ending_with_multibyte_char_does_not_panic() {
let text = format!("{}│", " ".repeat(29)); let mut ta = ta_with(&text);
let area = Rect::new(0, 0, 30, 5); let _state = TextAreaState::default();
ta.set_selection(0, ta.text().len());
let mut buf = ratatui::buffer::Buffer::empty(area);
ratatui::widgets::WidgetRef::render_ref(&(&ta), area, &mut buf);
}
#[test]
fn clicking_on_wrapped_multibyte_line_does_not_panic() {
for extra_spaces in 28..33 {
let text = format!("{}│end", " ".repeat(extra_spaces));
let mut ta = ta_with(&text);
let area = Rect::new(0, 0, 30, 5);
let state = TextAreaState::default();
for row in 0..2u16 {
for col in 0..30u16 {
ta.handle_mouse(mouse_down(col, row), area, state);
}
}
}
}
#[test]
fn inline_element_replaces_element_with_text() {
let mut ta = TextArea::new();
ta.insert_str("before ");
let id = ta.insert_element("pasted\ncontent\nhere", ElementKind(1), None);
ta.insert_str(" after");
let inlined = ta.inline_element(id);
assert!(inlined);
assert_eq!(ta.text(), "before pasted\ncontent\nhere after");
assert!(ta.elements().is_empty());
assert_eq!(ta.cursor(), 26); }
#[test]
fn inline_element_is_undoable() {
let mut ta = TextArea::new();
ta.insert_str("A ");
let id = ta.insert_element("multi\nline", ElementKind(1), None);
ta.insert_str(" B");
assert_eq!(ta.elements().len(), 1);
ta.inline_element(id);
assert!(ta.elements().is_empty());
assert_eq!(ta.text(), "A multi\nline B");
assert!(ta.undo());
assert_eq!(ta.elements().len(), 1);
assert_eq!(ta.element_text(id), Some("multi\nline"));
}
#[test]
fn inline_nonexistent_element_returns_false() {
let mut ta = ta_with("hello");
let fake_id = ElementId(9999);
assert!(!ta.inline_element(fake_id));
}
#[test]
fn inline_element_cursor_at_element_start() {
let mut ta = TextArea::new();
let id = ta.insert_element("elem", ElementKind(0), None);
ta.insert_str(" tail");
ta.set_cursor(0);
ta.inline_element(id);
assert!(ta.elements().is_empty());
assert_eq!(ta.cursor(), 4);
}
#[test]
fn click_on_element_second_half_snaps_to_start() {
let mut ta = TextArea::new();
ta.insert_str("ab");
let display = Line::from("ELEM");
let id = ta.insert_element("xy", ElementKind(0), Some(display));
ta.insert_str("cd");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.set_cursor(0);
let action = ta.handle_mouse(mouse_down(5, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
let ev = ta.poll_element_event().expect("should emit element click");
assert_eq!(ev.id, id);
assert_eq!(ev.kind, TextElementEventKind::Click);
assert_eq!(ta.cursor(), 2); }
#[test]
fn click_on_element_first_half_snaps_to_start() {
let mut ta = TextArea::new();
ta.insert_str("ab");
let display = Line::from("ELEM");
let id = ta.insert_element("xy", ElementKind(0), Some(display));
ta.insert_str("cd");
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_down(2, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
let ev = ta.poll_element_event().expect("should emit element click");
assert_eq!(ev.id, id);
assert_eq!(ev.kind, TextElementEventKind::Click);
assert_eq!(ta.cursor(), 2);
}
#[test]
fn click_after_element_places_cursor_not_element() {
let mut ta = TextArea::new();
ta.insert_str("ab");
let display = Line::from("EL");
ta.insert_element("xy", ElementKind(0), Some(display));
ta.insert_str("cd");
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_down(4, 0), area, state);
assert_eq!(action, MouseAction::CursorPlaced);
assert_eq!(ta.cursor(), 4); }
fn mouse_scroll_down(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn mouse_scroll_up(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::ScrollUp,
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn scroll_down_returns_scrolled() {
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee");
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 3);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_scroll_down(5, 1), area, state);
assert_eq!(action, MouseAction::Scrolled);
}
#[test]
fn scroll_up_returns_scrolled() {
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee");
ta.set_cursor(ta.text().len());
let area = Rect::new(0, 0, 40, 3);
let state = TextAreaState { scroll: 2 };
let action = ta.handle_mouse(mouse_scroll_up(5, 1), area, state);
assert_eq!(action, MouseAction::Scrolled);
}
#[test]
fn scroll_down_when_content_fits_returns_nothing() {
let mut ta = ta_with("short");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(mouse_scroll_down(5, 1), area, state);
assert_eq!(action, MouseAction::Nothing);
}
#[test]
fn mousewheel_scrolls_viewport_not_cursor() {
let text = (0..20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let mut ta = ta_with(&text);
let area = Rect::new(0, 0, 40, 5); let state = TextAreaState::default();
let line2_start = text.find("line 2").unwrap();
ta.set_cursor(line2_start);
let cursor_before = ta.cursor();
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
assert_eq!(
ta.cursor(),
cursor_before,
"mousewheel should not move cursor"
);
}
#[test]
fn click_after_scroll_places_cursor_at_clicked_line() {
let text = (0..40)
.map(|i| format!("line {:02}", i))
.collect::<Vec<_>>()
.join("\n");
let mut ta = ta_with(&text);
let area = Rect::new(0, 0, 40, 20);
let mut state = TextAreaState::default();
ta.set_cursor(0);
let mut buf = Buffer::empty(area);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
for _ in 0..3 {
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
}
assert!(
state.scroll >= 9,
"viewport should have scrolled; scroll={}",
state.scroll
);
ta.handle_mouse(mouse_down(0, 0), area, state);
let cursor = ta.cursor();
let lines = ta.wrapped_lines(area.width);
let cursor_line = TextArea::wrapped_line_index_by_start(&lines, cursor).unwrap();
assert!(
cursor_line >= state.scroll as usize && cursor_line < (state.scroll + area.height) as usize,
"click on visible row 0 should place cursor on a visible line; \
cursor_line={cursor_line}, scroll={}, visible=[{}..{})",
state.scroll,
state.scroll,
state.scroll as usize + area.height as usize,
);
}
#[test]
fn drag_select_after_scroll_selects_visible_text() {
let text = (0..20)
.map(|i| format!("line {:02}", i))
.collect::<Vec<_>>()
.join("\n");
let mut ta = ta_with(&text);
let area = Rect::new(0, 0, 40, 5);
let mut state = TextAreaState::default();
ta.set_cursor(0);
let mut buf = Buffer::empty(area);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
for _ in 0..3 {
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
}
let scroll_after = state.scroll;
ta.handle_mouse(mouse_down(0, 1), area, state);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
ta.handle_mouse(mouse_drag(5, 3), area, state);
let sel = ta.selection_range().expect("drag should create selection");
let lines = ta.wrapped_lines(area.width);
let sel_start_line = TextArea::wrapped_line_index_by_start(&lines, sel.start).unwrap();
assert!(
sel_start_line >= scroll_after as usize,
"selection start should be in scrolled region; \
sel_start_line={sel_start_line}, scroll={scroll_after}"
);
}
#[test]
fn drag_outside_after_mousewheel_still_scrolls() {
let text = (0..30)
.map(|i| format!("line {:02}", i))
.collect::<Vec<_>>()
.join("\n");
let mut ta = ta_with(&text);
let area = Rect::new(0, 0, 40, 5);
let mut state = TextAreaState::default();
ta.set_cursor(0);
let mut buf = Buffer::empty(area);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(0, 1), area, state);
assert!(ta.selection_range().is_some());
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
let scroll_after_wheel = state.scroll;
ta.last_drag_scroll = None;
ta.drag_scroll_steps = 0;
ta.handle_mouse(mouse_drag(0, area.y + area.height), area, state);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&ta), area, &mut buf, &mut state);
assert!(
state.scroll > scroll_after_wheel,
"drag-below after mousewheel should continue scrolling; \
scroll={}, expected > {scroll_after_wheel}",
state.scroll
);
}
#[test]
fn scroll_during_drag_preserves_selection_anchor() {
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee\nfff\nggg");
ta.set_cursor(0); let area = Rect::new(0, 0, 40, 3);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(1, 1), area, state);
let anchor = ta.cursor();
assert_eq!(
anchor, 5,
"click on bbb col 1 should place cursor at byte 5"
);
ta.handle_mouse(mouse_drag(2, 1), area, state);
assert!(
ta.selection_range().is_some(),
"drag should create selection"
);
let action = ta.handle_mouse(mouse_scroll_down(1, 1), area, state);
assert_eq!(action, MouseAction::Scrolled);
let sel = ta
.selection_range()
.expect("selection should survive scroll");
assert!(
sel.contains(&anchor),
"anchor byte {anchor} should still be inside selection {sel:?}"
);
}
#[test]
fn scroll_during_drag_extends_selection_head() {
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee\nfff\nggg");
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 3);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(1, 0), area, state);
ta.handle_mouse(mouse_drag(2, 0), area, state);
let sel_before = ta.selection_range().unwrap();
ta.handle_mouse(mouse_scroll_down(1, 1), area, state);
let sel_after = ta.selection_range().unwrap();
assert!(
sel_after.end > sel_before.end,
"scroll-down during drag should extend selection: before={sel_before:?} after={sel_after:?}"
);
assert_eq!(sel_after.start, sel_before.start);
}
#[test]
fn down_during_active_drag_does_not_reset_anchor() {
let mut ta = ta_with("aaa\nbbb\nccc\nddd\neee\nfff\nggg");
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 3);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(1, 0), area, state);
ta.handle_mouse(mouse_drag(2, 0), area, state);
let anchor_before = ta.selection_range().unwrap().start;
ta.handle_mouse(mouse_scroll_down(1, 1), area, state);
assert!(
ta.selection_range().is_some(),
"selection must survive scroll"
);
ta.handle_mouse(mouse_down(1, 2), area, state);
let sel = ta
.selection_range()
.expect("Down during drag must not kill selection");
assert_eq!(
sel.start, anchor_before,
"anchor must not reset: expected {anchor_before}, got {}",
sel.start
);
}
#[test]
fn drag_scroll_interval_ramps_up() {
assert_eq!(TextArea::drag_scroll_interval(0), 80);
assert_eq!(TextArea::drag_scroll_interval(1), 60);
assert_eq!(TextArea::drag_scroll_interval(2), 40);
assert_eq!(TextArea::drag_scroll_interval(100), 40);
}
#[test]
fn drag_scroll_lines_for_distance_tiers() {
assert_eq!(TextArea::drag_scroll_lines_for_distance(1), 1);
assert_eq!(TextArea::drag_scroll_lines_for_distance(2), 1);
assert_eq!(TextArea::drag_scroll_lines_for_distance(3), 2);
assert_eq!(TextArea::drag_scroll_lines_for_distance(5), 2);
assert_eq!(TextArea::drag_scroll_lines_for_distance(6), 3);
assert_eq!(TextArea::drag_scroll_lines_for_distance(10), 3);
assert_eq!(TextArea::drag_scroll_lines_for_distance(20), 5);
}
#[test]
fn scrollbar_not_shown_when_content_fits() {
let mut ta = TextArea::new();
ta.insert_str("aaa\nbbb\nccc");
let area = Rect::new(0, 0, 20, 5);
let (cw, needs) = ta.content_width(area.width, area.height);
assert!(!needs, "should not need scrollbar when content fits");
assert_eq!(cw, 20, "full width when no scrollbar");
}
#[test]
fn scrollbar_shown_when_content_overflows() {
let mut ta = TextArea::new();
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let area = Rect::new(0, 0, 20, 5);
let (cw, needs) = ta.content_width(area.width, area.height);
assert!(needs, "should need scrollbar when content overflows");
assert_eq!(cw, 19, "width reduced by 1 for scrollbar");
}
#[test]
fn scrollbar_respects_show_scrollbar_false() {
let mut ta = TextArea::new();
ta.show_scrollbar = false;
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let area = Rect::new(0, 0, 20, 5);
let (cw, needs) = ta.content_width(area.width, area.height);
assert!(!needs, "scrollbar disabled");
assert_eq!(cw, 20, "full width when scrollbar disabled");
}
#[test]
fn scrollbar_wrapping_uses_narrower_width() {
let mut ta = TextArea::new();
ta.insert_str(&format!("{}\n2\n3\n4\n5\n6", "a".repeat(19)));
let area = Rect::new(0, 0, 20, 5);
let (cw, needs) = ta.content_width(area.width, area.height);
assert!(needs, "overflows");
assert_eq!(cw, 19);
let lines = ta.wrapped_lines(cw);
assert_eq!(&ta.text()[lines[0].clone()], &"a".repeat(19));
}
#[test]
fn click_on_scrollbar_column_scrolls() {
let mut ta = TextArea::new();
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let _ = ta.text.set_cursor_byte(0);
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 19,
row: 4,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
assert_eq!(action, MouseAction::Scrolled);
assert_eq!(ta.cursor(), 0);
assert!(ta.scroll_override.is_some());
}
#[test]
fn click_on_scrollbar_top_scrolls_to_top() {
let mut ta = TextArea::new();
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
ta.scroll_override = Some(5);
ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 19,
row: 0,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
assert_eq!(ta.scroll_override, Some(0));
}
#[test]
fn click_on_text_area_does_not_trigger_scrollbar() {
let mut ta = TextArea::new();
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 18,
row: 0,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
assert_eq!(action, MouseAction::CursorPlaced);
assert!(!ta.scrollbar_dragging);
}
#[test]
fn drag_on_scrollbar_scrolls_proportionally() {
let mut ta = TextArea::new();
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
ta.scroll_override = Some(5);
ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 19,
row: 0,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
assert!(ta.scrollbar_dragging);
let scroll_at_top = ta.scroll_override.unwrap();
assert_eq!(scroll_at_top, 0, "track click at top should jump to 0");
ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 19,
row: 2,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
let scroll_at_mid = ta.scroll_override.unwrap();
assert!(
scroll_at_mid > scroll_at_top,
"dragging down should scroll further"
);
ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 19,
row: 4,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
let scroll_at_bottom = ta.scroll_override.unwrap();
assert!(
scroll_at_bottom > scroll_at_mid,
"dragging to bottom should scroll to max"
);
ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 19,
row: 4,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
assert!(!ta.scrollbar_dragging);
}
#[test]
fn scrollbar_render_produces_track_and_thumb() {
let mut ta = TextArea::new();
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
let mut state = TextAreaState::default();
StatefulWidgetRef::render_ref(&&ta, area, &mut buf, &mut state);
let sb_col = 19u16;
let mut has_thumb = false;
for row in 0..5u16 {
let cell = &buf[(sb_col, row)];
assert!(cell.style().bg.is_some(), "scrollbar cell should have bg");
if cell.symbol() != " " {
has_thumb = true;
}
}
assert!(has_thumb, "should have at least one thumb cell");
}
#[test]
fn no_scrollbar_column_when_content_fits() {
let mut ta = TextArea::new();
ta.insert_str("hello");
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
let mut state = TextAreaState::default();
StatefulWidgetRef::render_ref(&&ta, area, &mut buf, &mut state);
let last_col = 19u16;
let cell = &buf[(last_col, 0u16)];
assert!(
cell.style().bg.is_none() || !matches!(cell.style().bg, Some(Color::Rgb(32, 35, 53))),
"should not have scrollbar bg when content fits"
);
}
#[test]
fn cursor_pos_accounts_for_scrollbar_width() {
let mut ta = TextArea::new();
ta.insert_str(&format!("{}\n2\n3\n4\n5\n6", "x".repeat(18)));
let _ = ta.text.set_cursor_byte(0); let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
let pos = ta.cursor_pos_with_state(area, state);
assert_eq!(pos, Some((0, 0)));
}
#[test]
fn click_on_scrollbar_thumb_does_not_jump() {
let mut ta = TextArea::new();
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 19,
row: 0,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
assert_eq!(action, MouseAction::Scrolled);
assert!(ta.scrollbar_dragging);
assert!(
ta.scroll_override.is_none() || ta.scroll_override == Some(0),
"thumb click should not jump: {:?}",
ta.scroll_override,
);
}
#[test]
fn click_on_scrollbar_track_jumps() {
let mut ta = TextArea::new();
ta.insert_str("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
let area = Rect::new(0, 0, 20, 5);
let state = TextAreaState::default();
let action = ta.handle_mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 19,
row: 4,
modifiers: KeyModifiers::NONE,
},
area,
state,
);
assert_eq!(action, MouseAction::Scrolled);
assert!(
ta.scroll_override.unwrap_or(0) > 0,
"track click should jump"
);
}
#[test]
fn default_clipboard_provider_round_trips() {
let mut ta = TextArea::new();
ta.insert_str("hello world");
ta.set_selection(0, 5);
ta.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
assert_eq!(ta.take_clipboard(), Some("hello".to_string()));
ta.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
assert_eq!(ta.text(), "hello world");
}
#[test]
fn custom_clipboard_provider() {
#[derive(Debug)]
struct TestClip {
stored: Option<String>,
}
impl ClipboardProvider for TestClip {
fn get(&mut self) -> Option<String> {
self.stored.clone()
}
fn set(&mut self, text: &str) {
self.stored = Some(format!("CUSTOM:{text}"));
}
}
let mut ta = TextArea::new();
ta.set_clipboard_provider(Box::new(TestClip { stored: None }));
ta.insert_str("abc");
ta.set_selection(0, 3);
ta.input(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
ta.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
assert_eq!(ta.text(), "CUSTOM:abc");
}
#[test]
fn ctrl_v_pastes_from_provider() {
#[derive(Debug)]
struct PreloadedClip;
impl ClipboardProvider for PreloadedClip {
fn get(&mut self) -> Option<String> {
Some("pasted!".to_string())
}
fn set(&mut self, _text: &str) {}
}
let mut ta = TextArea::new();
ta.set_clipboard_provider(Box::new(PreloadedClip));
ta.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
assert_eq!(ta.text(), "pasted!");
}
#[test]
fn copy_on_selection_finalized_sets_provider() {
#[derive(Debug)]
struct RecordingClip {
last_set: Option<String>,
}
impl ClipboardProvider for RecordingClip {
fn get(&mut self) -> Option<String> {
self.last_set.clone()
}
fn set(&mut self, text: &str) {
self.last_set = Some(text.to_string());
}
}
let mut ta = TextArea::new();
ta.set_clipboard_provider(Box::new(RecordingClip { last_set: None }));
ta.insert_str("hello");
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_down(0, 0), area, state);
ta.handle_mouse(mouse_drag(5, 0), area, state);
ta.handle_mouse(mouse_up(5, 0), area, state);
ta.set_cursor(5);
ta.input(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
assert_eq!(ta.text(), "hellohello");
}
fn mouse_moved(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Moved,
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn hover_enter_on_element() {
let mut ta = TextArea::new();
ta.insert_str("hi ");
let id = ta.insert_element("elem", ElementKind(0), None);
ta.insert_str(" bye");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_moved(0, 0), area, state);
assert!(ta.poll_element_event().is_none());
ta.handle_mouse(mouse_moved(3, 0), area, state);
let ev = ta.poll_element_event().expect("should emit HoverEnter");
assert_eq!(ev.id, id);
assert_eq!(ev.kind, TextElementEventKind::HoverEnter);
}
#[test]
fn hover_leave_on_element() {
let mut ta = TextArea::new();
ta.insert_str("hi ");
let id = ta.insert_element("elem", ElementKind(0), None);
ta.insert_str(" bye");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_moved(3, 0), area, state);
ta.poll_element_event();
ta.handle_mouse(mouse_moved(0, 0), area, state);
let ev = ta.poll_element_event().expect("should emit HoverLeave");
assert_eq!(ev.id, id);
assert_eq!(ev.kind, TextElementEventKind::HoverLeave);
}
#[test]
fn hover_stays_on_same_element_no_event() {
let mut ta = TextArea::new();
ta.insert_str("hi ");
ta.insert_element("elem", ElementKind(0), None);
ta.insert_str(" bye");
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_moved(3, 0), area, state);
ta.poll_element_event();
ta.handle_mouse(mouse_moved(4, 0), area, state);
assert!(ta.poll_element_event().is_none());
}
#[test]
fn hover_between_two_elements() {
let mut ta = TextArea::new();
let id1 = ta.insert_element("AA", ElementKind(0), None);
ta.insert_str(" ");
let id2 = ta.insert_element("BB", ElementKind(0), None);
let area = Rect::new(0, 0, 40, 5);
let state = TextAreaState::default();
ta.handle_mouse(mouse_moved(0, 0), area, state);
let ev = ta.poll_element_event().unwrap();
assert_eq!(ev.id, id1);
assert_eq!(ev.kind, TextElementEventKind::HoverEnter);
ta.handle_mouse(mouse_moved(3, 0), area, state);
let ev = ta.poll_element_event().unwrap();
assert_eq!(ev.id, id2);
assert_eq!(ev.kind, TextElementEventKind::HoverEnter);
}
#[test]
fn scroll_override_getter_setter() {
let mut ta = TextArea::new();
assert_eq!(ta.scroll_override(), None);
ta.set_scroll_override(Some(5));
assert_eq!(ta.scroll_override(), Some(5));
ta.set_scroll_override(None);
assert_eq!(ta.scroll_override(), None);
}
fn render_stateful(ta: &TextArea, area: Rect, buf: &mut Buffer, state: &mut TextAreaState) {
ratatui::widgets::StatefulWidgetRef::render_ref(&ta, area, buf, state);
}
#[test]
fn scroll_override_forces_viewport_ignoring_cursor() {
let text = (0..20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let mut ta = ta_with(&text);
ta.set_cursor(ta.text().len()); let area = Rect::new(0, 0, 40, 5);
let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
render_stateful(&ta, area, &mut buf, &mut state);
assert!(state.scroll > 0, "should scroll to show cursor at end");
let normal_scroll = state.scroll;
ta.set_scroll_override(Some(0));
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(state.scroll, 0, "override should force scroll to 0");
ta.set_scroll_override(None);
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(
state.scroll, normal_scroll,
"clearing override should resume cursor-follow"
);
}
#[test]
fn scroll_override_clamped_to_max() {
let text = "line 0\nline 1\nline 2"; let mut ta = ta_with(text);
ta.set_cursor(0);
let area = Rect::new(0, 0, 40, 2); let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
ta.set_scroll_override(Some(999));
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(state.scroll, 1, "override should be clamped to max_scroll");
}
#[test]
fn scroll_override_survives_render_cycles() {
let text = (0..20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let mut ta = ta_with(&text);
ta.set_cursor(ta.text().len());
let area = Rect::new(0, 0, 40, 5);
let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
ta.set_scroll_override(Some(3));
for _ in 0..5 {
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(state.scroll, 3, "override should persist across renders");
}
}
#[test]
fn scroll_override_save_restore_round_trip() {
let text = (0..30)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let mut ta = ta_with(&text);
ta.set_cursor(ta.text().len()); let area = Rect::new(0, 0, 40, 5);
let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
render_stateful(&ta, area, &mut buf, &mut state);
let original_scroll = state.scroll;
let original_override = ta.scroll_override();
assert!(original_scroll > 0);
assert_eq!(original_override, None);
ta.set_scroll_override(Some(0));
for _ in 0..3 {
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(state.scroll, 0);
}
ta.set_scroll_override(original_override);
state.scroll = original_scroll;
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(
state.scroll, original_scroll,
"restored scroll should match original"
);
}
#[test]
fn scroll_override_save_restore_with_mousewheel() {
let text = (0..30)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let mut ta = ta_with(&text);
ta.set_cursor(0); let area = Rect::new(0, 0, 40, 5);
let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(state.scroll, 0);
for _ in 0..5 {
ta.handle_mouse(mouse_scroll_down(0, 0), area, state);
render_stateful(&ta, area, &mut buf, &mut state);
}
let mousewheel_scroll = state.scroll;
let mousewheel_override = ta.scroll_override();
assert!(mousewheel_scroll > 0, "should have scrolled away");
assert!(mousewheel_override.is_some(), "mousewheel sets override");
let saved_scroll = state.scroll;
let saved_override = ta.scroll_override();
ta.set_scroll_override(Some(0));
for _ in 0..3 {
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(state.scroll, 0);
}
ta.set_scroll_override(saved_override);
state.scroll = saved_scroll;
render_stateful(&ta, area, &mut buf, &mut state);
assert_eq!(
state.scroll, mousewheel_scroll,
"viewport should restore to mousewheel position, not snap to cursor"
);
}
#[test]
fn shifted_character_classification_only_uppercases_letters() {
for (input, expected) in [
('a', 'A'),
('z', 'Z'),
('A', 'A'),
('7', '7'),
('/', '/'),
(';', ';'),
] {
assert_eq!(
classify_key_event(&KeyEvent::new(KeyCode::Char(input), KeyModifiers::SHIFT)),
Some(EditCommand::Insert(expected))
);
}
}
#[test]
fn modified_delete_and_arrow_keys_keep_word_semantics() {
for modifiers in [
KeyModifiers::ALT,
KeyModifiers::CONTROL,
KeyModifiers::ALT | KeyModifiers::CONTROL,
] {
assert_eq!(
classify_key_event(&KeyEvent::new(KeyCode::Delete, modifiers)),
Some(EditCommand::DeleteWordForward(WordStyle::Small)),
);
assert_eq!(
classify_key_event(&KeyEvent::new(KeyCode::Left, modifiers)),
Some(EditCommand::MoveWordLeft(WordStyle::Small)),
);
assert_eq!(
classify_key_event(&KeyEvent::new(KeyCode::Right, modifiers)),
Some(EditCommand::MoveWordRight(WordStyle::Small)),
);
}
for modifiers in [KeyModifiers::ALT, KeyModifiers::SUPER] {
assert_eq!(
classify_key_event(&KeyEvent::new(KeyCode::Char('d'), modifiers)),
Some(EditCommand::DeleteWordForward(WordStyle::Small)),
);
}
}
#[test]
fn modifier_keys_do_not_insert_text() {
let mut t = ta_with("hello");
let len = t.text().len();
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(t.text().len(), len);
}
#[test]
fn alt_word_nav_preserved() {
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
let text = t.text().to_owned();
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT));
assert_eq!(t.text(), text);
assert!(t.cursor() < text.len());
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT));
assert_eq!(t.text(), text);
assert!(t.cursor() > 0);
}
#[test]
fn ctrl_alt_h_deletes_word() {
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
t.input(KeyEvent::new(
KeyCode::Char('h'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
));
assert_eq!(t.text(), "hello ");
}
#[test]
fn plain_and_shifted_chars_insert() {
let mut t = TextArea::new();
for c in ['a', 'z', '1', '/', '@', '{', '!', '~'] {
t.input(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
assert_eq!(t.text(), "az1/@{!~");
let mut t = TextArea::new();
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT));
t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::SHIFT));
assert_eq!(t.text(), "AZ");
}
#[test]
fn altgr_char_insertion_platform_dependent() {
let mut t = TextArea::new();
t.input(KeyEvent::new(
KeyCode::Char('@'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
));
if cfg!(target_os = "windows") {
assert_eq!(t.text(), "@");
} else {
assert_eq!(t.text(), "");
}
}
#[test]
fn shift_number_trusts_terminal_character() {
let mut t = TextArea::new();
t.input(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::SHIFT));
assert_eq!(t.text(), "/");
}
#[test]
fn hangul_compatibility_jamo_width_is_two_cells() {
assert_eq!(unicode_width::UnicodeWidthChar::width('\u{3131}'), Some(2));
assert_eq!(unicode_width::UnicodeWidthChar::width('\u{3132}'), Some(2));
assert_eq!(unicode_width::UnicodeWidthChar::width('\u{314F}'), Some(2));
}