use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::widgets::Widget as _; use ratatui::widgets::{Block, Borders}; use std::cmp; use std::fmt::Debug; use tui_textarea::{CursorMove, CursorRenderMode, TextArea, WrapMode}; fn assert_undo_redo( before_pos: (usize, usize), before_buf: &[&str], after_buf: &[&str], t: &mut TextArea<'_>, context: T, ) { let after_pos = t.cursor(); let modified = before_buf != after_buf; assert_eq!(t.cursor(), after_pos, "pos before undo: {context:?}"); assert_eq!(t.undo(), modified, "undo modification: {context:?}"); assert_eq!(t.lines(), before_buf, "buf after undo: {context:?}"); assert_eq!(t.cursor(), before_pos, "pos after undo: {context:?}"); assert_eq!(t.redo(), modified, "redo modification: {context:?}"); assert_eq!(t.lines(), after_buf, "buf after redo: {context:?}"); assert_eq!(t.cursor(), after_pos, "pos after redo: {context:?}"); } fn assert_no_undo_redo(t: &mut TextArea<'_>, context: T) { let pos = t.cursor(); let buf: Vec<_> = t.lines().to_vec(); assert!(!t.undo(), "undo modification: {context:?}"); assert_eq!(t.lines(), &buf, "buf after undo: {context:?}"); assert_eq!(t.cursor(), pos, "pos after undo: {context:?}"); assert!(!t.redo(), "redo modification: {context:?}"); assert_eq!(t.lines(), &buf, "buf after redo: {context:?}"); assert_eq!(t.cursor(), pos, "pos after redo: {context:?}"); } fn render_textarea(textarea: &TextArea<'_>, area: Rect) -> Buffer { let mut buf = Buffer::empty(area); textarea.render(area, &mut buf); buf } #[test] fn test_insert_soft_tab() { for test in [ ("", 0, " ", 4), ("a", 1, "a ", 3), ("abcd", 4, "abcd ", 4), ("a", 0, " a", 4), ("ab", 1, "a b", 3), ("abcdefgh", 4, "abcd efgh", 4), ("恂", 1, "恂 ", 2), ("🐶", 1, "🐶 ", 2), ("恂", 0, " 恂", 4), ("恂恄", 1, "恂 恄", 2), ] { let (input, col, expected, width) = test; let mut t = TextArea::from([input.to_string()]); t.move_cursor(CursorMove::Jump(0, col)); assert!(t.insert_tab(), "{test:?}"); assert_eq!(t.lines(), [expected], "{test:?}"); assert_eq!(t.cursor(), (0, col as usize + width), "{test:?}"); assert_undo_redo((0, col as _), &[input], &[expected], &mut t, test); } } #[test] fn test_insert_hard_tab() { let mut t = TextArea::default(); t.set_hard_tab_indent(true); assert!(t.insert_tab()); assert_eq!(t.cursor(), (0, 1)); assert_undo_redo((0, 0), &[""], &["\t"], &mut t, ""); let mut t = TextArea::default(); t.set_hard_tab_indent(true); t.set_tab_length(0); t.insert_tab(); assert!(!t.insert_tab()); assert_eq!(t.lines(), [""]); assert_eq!(t.cursor(), (0, 0)); } #[test] fn test_insert_char() { let tests = [ (0, 'x', &["xab"][..]), (1, 'x', &["axb"][..]), (2, 'x', &["abx"][..]), (1, '恂', &["a恂b"][..]), (1, '\n', &["a", "b"][..]), ]; for test in tests { let (col, ch, want) = test; let mut t = TextArea::from(["ab"]); t.move_cursor(CursorMove::Jump(0, col)); t.insert_char(ch); assert_eq!(t.lines(), want, "{test:?}"); let pos = if ch == '\n' { (1, 0) } else { (0, col as usize + 1) }; assert_eq!(t.cursor(), pos, "{test:?}"); assert_undo_redo((0, col as _), &["ab"], want, &mut t, test); } } #[test] fn test_insert_str_one_line() { for i in 0..="ab".len() { let mut t = TextArea::from(["ab"]); t.move_cursor(CursorMove::Jump(0, i as u16)); assert!(t.insert_str("x"), "{i}"); let mut want = "ab".to_string(); want.insert(i, 'x'); let want = want.as_str(); assert_eq!(t.lines(), [want], "{i}"); assert_eq!(t.cursor(), (0, i + 1)); assert_undo_redo((0, i), &["ab"], &[want], &mut t, i); } let mut t = TextArea::default(); assert!(t.insert_str("x")); assert_eq!(t.cursor(), (0, 1)); assert_undo_redo((0, 0), &[""], &["x"], &mut t, ""); } #[test] fn test_insert_str_empty_line() { let mut t = TextArea::from(["ab"]); assert!(!t.insert_str("")); assert_eq!(t.lines(), ["ab"]); assert_eq!(t.cursor(), (0, 0)); assert_no_undo_redo(&mut t, ""); } #[test] fn test_insert_str_multiple_lines() { #[rustfmt::skip] let tests = [ // Positions ( // Text before edit &[ "ab", "cd", "ef", ][..], // (row, offset) position before edit (0, 0), // String to be inserted "x\ny", // (row, offset) position after edit (1, 1), // Text after edit &[ "x", "yab", "cd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 1), "x\ny", (1, 1), &[ "ax", "yb", "cd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 2), "x\ny", (1, 1), &[ "abx", "y", "cd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (1, 0), "x\ny", (2, 1), &[ "ab", "x", "ycd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (1, 1), "x\ny", (2, 1), &[ "ab", "cx", "yd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (1, 2), "x\ny", (2, 1), &[ "ab", "cdx", "y", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (2, 0), "x\ny", (3, 1), &[ "ab", "cd", "x", "yef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (2, 1), "x\ny", (3, 1), &[ "ab", "cd", "ex", "yf", ][..], ), ( &[ "ab", "cd", "ef", ][..], (2, 2), "x\ny", (3, 1), &[ "ab", "cd", "efx", "y", ][..], ), // More than 2 lines ( &[ "ab", "cd", "ef", ][..], (1, 1), "x\ny\nz\nw", (4, 1), &[ "ab", "cx", "y", "z", "wd", "ef", ][..], ), // Newline at end of line ( &[ "ab", "cd", "ef", ][..], (1, 1), "x\ny\n", (3, 0), &[ "ab", "cx", "y", "d", "ef", ][..], ), // Empty lines ( &[ "", "", "", ][..], (0, 0), "x\ny\nz", (2, 1), &[ "x", "y", "z", "", "", ][..], ), ( &[ "", "", "", ][..], (1, 0), "x\ny\nz", (3, 1), &[ "", "x", "y", "z", "", ][..], ), ( &[ "", "", "", ][..], (2, 0), "x\ny\nz", (4, 1), &[ "", "", "x", "y", "z", ][..], ), // Empty buffer ( &[ "", ][..], (0, 0), "x\ny\nz", (2, 1), &[ "x", "y", "z", ][..], ), // Insert empty lines ( &[ "ab", "cd", "ef", ][..], (0, 0), "\n\n\n", (3, 0), &[ "", "", "", "ab", "cd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (1, 0), "\n\n\n", (4, 0), &[ "ab", "", "", "", "cd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (1, 1), "\n\n\n", (4, 0), &[ "ab", "c", "", "", "d", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (1, 2), "\n\n\n", (4, 0), &[ "ab", "cd", "", "", "", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (2, 2), "\n\n\n", (5, 0), &[ "ab", "cd", "ef", "", "", "", ][..], ), // Multi-byte characters ( &[ "🐶🐱", "🐮🐰", "🐧🐭", ][..], (0, 0), "🐷\n🐼\n🐓", (2, 1), &[ "🐷", "🐼", "🐓🐶🐱", "🐮🐰", "🐧🐭", ][..], ), ( &[ "🐶🐱", "🐮🐰", "🐧🐭", ][..], (0, 2), "🐷\n🐼\n🐓", (2, 1), &[ "🐶🐱🐷", "🐼", "🐓", "🐮🐰", "🐧🐭", ][..], ), ( &[ "🐶🐱", "🐮🐰", "🐧🐭", ][..], (1, 0), "🐷\n🐼\n🐓", (3, 1), &[ "🐶🐱", "🐷", "🐼", "🐓🐮🐰", "🐧🐭", ][..], ), ( &[ "🐶🐱", "🐮🐰", "🐧🐭", ][..], (1, 1), "🐷\n🐼\n🐓", (3, 1), &[ "🐶🐱", "🐮🐷", "🐼", "🐓🐰", "🐧🐭", ][..], ), ( &[ "🐶🐱", "🐮🐰", "🐧🐭", ][..], (2, 2), "🐷\n🐼\n🐓", (4, 1), &[ "🐶🐱", "🐮🐰", "🐧🐭🐷", "🐼", "🐓", ][..], ), // Handle \r\n as newlines ( &[ "ab", "cd", "ef", ][..], (1, 1), "x\r\ny\r\nz", (3, 1), &[ "ab", "cx", "y", "zd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (1, 1), "x\ny\r\nz", (3, 1), &[ "ab", "cx", "y", "zd", "ef", ][..], ), ]; for test in tests { let (before, before_pos, input, after_pos, expected) = test; let mut t = TextArea::from(before.iter().map(|s| s.to_string())); let (row, col) = before_pos; t.move_cursor(CursorMove::Jump(row as _, col as _)); assert!(t.insert_str(input), "{test:?}"); assert_eq!(t.lines(), expected, "{test:?}"); assert_eq!(t.cursor(), after_pos, "{test:?}"); assert_undo_redo(before_pos, before, expected, &mut t, test); } } #[test] fn test_delete_str_nothing() { for i in 0..="ab".len() { let mut t = TextArea::from(["ab"]); assert!(!t.delete_str(0), "{i}"); assert_eq!(t.cursor(), (0, 0)); } let mut t = TextArea::default(); assert!(!t.delete_str(0)); assert_eq!(t.cursor(), (0, 0)); } #[test] fn test_delete_str_within_line() { for i in 0.."abc".len() { for j in 1..="abc".len() - i { let mut t = TextArea::from(["abc"]); t.move_cursor(CursorMove::Jump(0, i as _)); assert!(t.delete_str(j), "at {i}, size={j}"); let mut want = "abc".to_string(); want.drain(i..i + j); let want = want.as_str(); assert_eq!(t.lines(), [want], "at {i}, size={j}"); assert_eq!(t.cursor(), (0, i)); // delete_str deletes string as if moving cursor at the end of the deleted string assert_undo_redo((0, i + j), &["abc"], &[want], &mut t, (i, j)); } } } #[test] fn test_delete_str_multiple_lines() { #[rustfmt::skip] let tests = [ // Length ( // Text before edit &[ "ab", "cd", "ef", ][..], // (row, offset) cursor position (0, 0), // Chars to be deleted 3, // Deleted text "ab\n", // Text after edit &[ "cd", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 0), 4, "ab\nc", &[ "d", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 0), 5, "ab\ncd", &[ "", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 0), 6, "ab\ncd\n", &[ "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 0), 7, "ab\ncd\ne", &[ "f", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 0), 8, "ab\ncd\nef", &[ "", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 0), 9, "ab\ncd\nef", &[ "", ][..], ), // Positions ( &[ "ab", "cd", "ef", ][..], (0, 1), 3, "b\nc", &[ "ad", "ef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (0, 2), 4, "\ncd\n", &[ "abef", ][..], ), ( &[ "ab", "cd", "ef", ][..], (1, 0), 4, "cd\ne", &[ "ab", "f", ][..], ), ( &[ "ab", "cd", "ef", ][..], (2, 0), 3, "ef", &[ "ab", "cd", "", ][..], ), ( &[ "ab", "cd", "ef", ][..], (2, 1), 2, "f", &[ "ab", "cd", "e", ][..], ), ( &[ "ab", "cd", "ef", ][..], (2, 2), 1, "", &[ "ab", "cd", "ef", ][..], ), // Empty lines ( &[ "", "", "", ][..], (0, 0), 1, "\n", &[ "", "", ][..], ), ( &[ "", "", "", ][..], (0, 0), 2, "\n\n", &[ "", ][..], ), ( &[ "", "", "", ][..], (0, 0), 3, "\n\n", &[ "", ][..], ), ( &[ "", "", "", ][..], (1, 0), 1, "\n", &[ "", "", ][..], ), ( &[ "", "", "", ][..], (2, 0), 1, "", &[ "", "", "", ][..], ), // Empty buffer ( &[ "", ][..], (0, 0), 1, "", &[ "", ][..], ), ]; for test in tests { let (before, (row, col), chars, deleted, after) = test; let mut t = TextArea::from(before.iter().map(|s| s.to_string())); t.move_cursor(CursorMove::Jump(row as _, col as _)); assert!(t.delete_str(chars), "{test:?}"); assert_eq!(t.cursor(), (row, col), "{test:?}"); assert_eq!(t.lines(), after, "{test:?}"); assert_eq!(t.yank_text(), deleted, "{test:?}"); let pos = t.cursor(); assert!(t.undo(), "{test:?}"); assert_eq!(t.lines(), before, "{test:?}"); assert!(t.redo(), "{test:?}"); assert_eq!(t.lines(), after, "{test:?}"); assert_eq!(t.cursor(), pos, "{test:?}"); } } #[test] fn test_copy_single_line() { for i in 0..="abc".len() { for j in i.."abc".len() { let mut t = TextArea::from(["abc"]); t.move_cursor(CursorMove::Jump(0, i as u16)); t.start_selection(); t.move_cursor(CursorMove::Jump(0, j as u16)); t.copy(); assert_eq!(t.yank_text(), &"abc"[i..j], "from {i} to {j}"); assert_eq!(t.lines(), ["abc"], "from {i} to {j}"); assert_no_undo_redo(&mut t, (i, j)); } } } #[test] fn test_cut_single_line() { for i in 0.."abc".len() { for j in i + 1.."abc".len() { let mut t = TextArea::from(["abc"]); t.move_cursor(CursorMove::Jump(0, i as u16)); t.start_selection(); t.move_cursor(CursorMove::Jump(0, j as u16)); t.cut(); assert_eq!(t.yank_text(), &"abc"[i..j], "from {i} to {j}"); let mut after = "abc".to_string(); after.replace_range(i..j, ""); let after = after.as_str(); assert_eq!(t.lines(), [after], "from {i} to {j}"); assert_eq!(t.cursor(), (0, i)); assert_undo_redo((0, j), &["abc"], &[after], &mut t, (i, j)); t.paste(); assert_eq!(t.lines(), ["abc"], "from {i} to {j}"); assert_undo_redo((0, i), &[after], &["abc"], &mut t, (i, j)); } } } #[test] fn test_copy_cut_empty() { for row in 0..=2 { for col in 0..=2 { let check = |f: fn(&mut TextArea<'_>)| { let mut t = TextArea::from(["ab", "cd", "ef"]); t.move_cursor(CursorMove::Jump(row, col)); t.start_selection(); t.move_cursor(CursorMove::Jump(row, col)); f(&mut t); assert!(!t.is_selecting()); assert_eq!(t.cursor(), (row as _, col as _)); assert_eq!(t.lines(), ["ab", "cd", "ef"]); assert_no_undo_redo(&mut t, ""); }; check(|t| { assert!(!t.cut()); }); check(|t| t.copy()); } } } #[test] fn test_copy_cut_paste_multi_lines() { #[rustfmt::skip] let tests = [ ( // Initial text &[ "ab", "cd", "ef", ][..], // Start position of selection (0, 0), // End position of selection (1, 0), // Expected yanked text "ab\n", // Text buffer after cut &[ "cd", "ef", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 0), (1, 1), "ab\nc", &[ "d", "ef", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 0), (1, 2), "ab\ncd", &[ "", "ef", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 0), (2, 0), "ab\ncd\n", &[ "ef", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 0), (2, 1), "ab\ncd\ne", &[ "f", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 0), (2, 2), "ab\ncd\nef", &[ "", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 1), (1, 1), "b\nc", &[ "ad", "ef", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 2), (1, 1), "\nc", &[ "abd", "ef", ][..] ), ( &[ "ab", "cd", "ef", ][..], (1, 0), (2, 1), "cd\ne", &[ "ab", "f", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 2), (1, 0), "\n", &[ "abcd", "ef", ][..] ), ( &[ "ab", "cd", "ef", ][..], (0, 2), (2, 0), "\ncd\n", &[ "abef", ][..] ), // Multi-byte characters ( &[ "恂恄", "恆恈", "恊恋", ][..], (0, 0), (2, 2), "恂恄\n恆恈\n恊恋", &[ "", ][..] ), ( &[ "恂恄", "恆恈", "恊恋", ][..], (0, 1), (2, 1), "恄\n恆恈\nお", &[ "恂恋", ][..] ), ( &[ "恂恄", "恆恈", "恊恋", ][..], (0, 2), (2, 0), "\n恆恈\n", &[ "恂恄恊恋", ][..] ), ( &[ "恂恄", "恆恈", "恊恋", ][..], (0, 2), (1, 2), "\n恆恈", &[ "恂恄", "恊恋", ][..] ), ( &[ "恂恄", "恆恈", "恊恋", ][..], (0, 2), (1, 1), "\n恆", &[ "恂恄恈", "恊恋", ][..] ), ( &[ "恂恄", "恆恈", "恊恋", ][..], (0, 2), (1, 0), "\n", &[ "恂恄恆恈", "恊恋", ][..] ), ]; for test in tests { let (init_text, (srow, scol), (erow, ecol), yanked, after_cut) = test; { let mut t = TextArea::from(init_text.iter().map(|s| s.to_string())); t.move_cursor(CursorMove::Jump(srow as _, scol as _)); t.start_selection(); t.move_cursor(CursorMove::Jump(erow as _, ecol as _)); t.copy(); assert_eq!(t.cursor(), (erow, ecol), "{test:?}"); assert_eq!(t.yank_text(), yanked, "{test:?}"); assert_eq!(t.lines(), init_text, "{test:?}"); assert_no_undo_redo(&mut t, test); } { let mut t = TextArea::from(init_text.iter().map(|s| s.to_string())); t.move_cursor(CursorMove::Jump(srow as _, scol as _)); t.start_selection(); t.move_cursor(CursorMove::Jump(erow as _, ecol as _)); t.cut(); assert_eq!(t.cursor(), (srow, scol), "{test:?}"); assert_eq!(t.yank_text(), yanked, "{test:?}"); assert_eq!(t.lines(), after_cut, "{test:?}"); assert_undo_redo((erow, ecol), init_text, after_cut, &mut t, test); t.paste(); assert_eq!(t.lines(), init_text, "{test:?}"); assert_undo_redo((srow, scol), after_cut, init_text, &mut t, test); } // Reverse positions { let mut t = TextArea::from(init_text.iter().map(|s| s.to_string())); t.move_cursor(CursorMove::Jump(erow as _, ecol as _)); t.start_selection(); t.move_cursor(CursorMove::Jump(srow as _, scol as _)); t.copy(); assert_eq!(t.cursor(), (srow, scol), "{test:?}"); assert_eq!(t.yank_text(), yanked, "{test:?}"); assert_eq!(t.lines(), init_text, "{test:?}"); assert_no_undo_redo(&mut t, test); } { let mut t = TextArea::from(init_text.iter().map(|s| s.to_string())); t.move_cursor(CursorMove::Jump(erow as _, ecol as _)); t.start_selection(); t.move_cursor(CursorMove::Jump(srow as _, scol as _)); t.cut(); assert_eq!(t.cursor(), (srow, scol), "{test:?}"); assert_eq!(t.yank_text(), yanked, "{test:?}"); assert_eq!(t.lines(), after_cut, "{test:?}"); assert_undo_redo((erow, ecol), init_text, after_cut, &mut t, test); t.paste(); assert_eq!(t.lines(), init_text, "{test:?}"); assert_undo_redo((srow, scol), after_cut, init_text, &mut t, test); } } } #[test] fn test_delete_selection_on_delete_operations() { macro_rules! test_case { ($name:ident($($args:expr),*)) => { ( stringify!($name), (|t| t.$name($($args),*)) as fn(&mut TextArea) -> bool, ) }; } let tests = [ test_case!(delete_char()), test_case!(delete_next_char()), test_case!(delete_line_by_end()), test_case!(delete_line_by_head()), test_case!(delete_word()), test_case!(delete_next_word()), test_case!(delete_str(3)), ]; for (n, f) in tests { let mut t = TextArea::from(["ab", "cd", "ef"]); t.move_cursor(CursorMove::Jump(0, 1)); t.start_selection(); t.move_cursor(CursorMove::Jump(2, 1)); let modified = f(&mut t); assert!(modified, "{n}"); assert_eq!(t.lines(), ["af"], "{n}"); assert_eq!(t.cursor(), (0, 1), "{n}"); assert_undo_redo((2, 1), &["ab", "cd", "ef"], &["af"], &mut t, n); } } #[test] fn test_delete_selection_on_delete_edge_cases() { macro_rules! test_case { ($name:ident($($args:expr),*), $pos:expr) => { ( stringify!($name), (|t| t.$name($($args),*)) as fn(&mut TextArea) -> bool, $pos, ) }; } // When deleting nothing and deleting newline let tests = [ test_case!(delete_char(), (0, 0)), test_case!(delete_char(), (1, 0)), test_case!(delete_next_char(), (2, 2)), test_case!(delete_next_char(), (1, 2)), test_case!(delete_line_by_end(), (0, 2)), test_case!(delete_line_by_end(), (2, 2)), test_case!(delete_line_by_head(), (0, 0)), test_case!(delete_line_by_head(), (1, 0)), test_case!(delete_word(), (0, 0)), test_case!(delete_word(), (1, 0)), test_case!(delete_next_word(), (2, 2)), test_case!(delete_next_word(), (1, 2)), test_case!(delete_str(0), (0, 0)), test_case!(delete_str(100), (2, 2)), ]; for (n, f, pos) in tests { let mut t = TextArea::from(["ab", "cd", "ef"]); t.move_cursor(CursorMove::Jump(1, 1)); t.start_selection(); t.move_cursor(CursorMove::Jump(pos.0 as _, pos.1 as _)); assert!(f(&mut t), "{n}, {pos:?}"); assert_eq!(t.cursor(), cmp::min(pos, (1, 1)), "{n}, {pos:?}"); t.undo(); assert_eq!(t.lines(), ["ab", "cd", "ef"], "{n}, {pos:?}"); } } #[test] fn test_delete_selection_before_insert() { macro_rules! test_case { ($name:ident($($args:expr),*), $want:expr) => { ( stringify!($name), (|t| { t.$name($($args),*); }) as fn(&mut TextArea), &$want as &[_], ) }; } let tests = [ test_case!(insert_newline(), ["a", "f"]), test_case!(insert_char('x'), ["axf"]), test_case!(insert_tab(), ["a f"]), // Default tab is 4 spaces test_case!(insert_str("xyz"), ["axyzf"]), ]; for (n, f, after) in tests { let mut t = TextArea::from(["ab", "cd", "ef"]); t.move_cursor(CursorMove::Jump(0, 1)); t.start_selection(); t.move_cursor(CursorMove::Jump(2, 1)); f(&mut t); assert_eq!(t.lines(), after, "{n}"); // XXX: Deleting selection and inserting text are separate undo units for now t.undo(); t.undo(); assert_eq!(t.lines(), ["ab", "cd", "ef"], "{n}"); } } #[test] fn test_clear_on_empty_text() { let mut t = TextArea::default(); let was_cleared = t.clear(); assert!(!was_cleared); } #[test] fn test_clear_text() { let mut t = TextArea::default(); let input = " Hello world Again "; t.insert_str(input); let was_cleared = t.clear(); assert!( t.lines().join("\n").is_empty(), "Textarea should be empty after clearing the text" ); assert!(was_cleared); t.undo(); assert_eq!( t.lines().join("\n"), input, "Textare should have the previous content before the reset. Orginal input: {input}", ); t.redo(); assert!( t.lines().join("\n").is_empty(), "Textarea should be empty after clearing the text again" ); } #[test] fn test_undo_redo_stop_selection() { fn check(t: &mut TextArea, f: fn(&mut TextArea) -> bool) { t.move_cursor(CursorMove::Jump(0, 0)); t.start_selection(); t.move_cursor(CursorMove::Jump(0, 1)); assert!(t.is_selecting()); assert!(f(t)); assert!(!t.is_selecting()); } let mut t = TextArea::default(); t.insert_char('a'); check(&mut t, |t| t.undo()); assert_eq!(t.lines(), [""]); check(&mut t, |t| t.redo()); assert_eq!(t.lines(), ["a"]); } #[test] fn test_set_yank_paste_text() { let tests = [ ("", &[""][..], (0, 0)), ("abc", &["abc"][..], (0, 3)), ("abc\ndef", &["abc", "def"][..], (1, 3)), ("\n\n", &["", "", ""][..], (2, 0)), ]; for test in tests { let (text, want, pos) = test; let mut t = TextArea::default(); t.set_yank_text(text); t.paste(); assert_eq!(t.lines(), want, "{test:?}"); assert_eq!(t.yank_text(), text, "{test:?}"); assert_eq!(t.cursor(), pos, "{test:?}"); assert_undo_redo((0, 0), &[""], want, &mut t, test); } } #[test] fn test_set_yank_crlf() { let tests = [ ("\r\n", &["", ""][..], "\n"), ("\r\n\r\n", &["", "", ""][..], "\n\n"), ("a\r\nb", &["a", "b"][..], "a\nb"), ("a\r\nb\r\n", &["a", "b", ""][..], "a\nb\n"), ]; for test in tests { let (pasted, lines, yanked) = test; let mut t = TextArea::default(); t.set_yank_text(pasted); t.paste(); assert_eq!(t.lines(), lines, "{test:?}"); assert_eq!(t.yank_text(), yanked, "{test:?}"); } } #[test] fn test_select_all() { let mut t = TextArea::from(["aaa", "bbb", "ccc"]); t.select_all(); assert!(t.is_selecting()); assert_eq!(t.cursor(), (2, 3)); t.cut(); assert_eq!(t.lines(), [""]); assert_eq!(t.yank_text(), "aaa\nbbb\nccc"); assert_undo_redo((2, 3), &["aaa", "bbb", "ccc"], &[""], &mut t, ""); } #[test] fn test_paste_while_selection() { let mut t = TextArea::from(["ab", "cd"]); t.move_cursor(CursorMove::Jump(0, 1)); t.start_selection(); t.move_cursor(CursorMove::Jump(1, 1)); t.set_yank_text("x\ny"); assert!(t.paste()); assert_eq!(t.lines(), ["ax", "yd"]); assert_eq!(t.cursor(), (1, 1)); assert!(!t.is_selecting()); let mut t = TextArea::from(["ab", "cd"]); t.select_all(); t.set_yank_text("xy\nzw"); assert!(t.paste()); assert_eq!(t.lines(), ["xy", "zw"]); assert_eq!(t.cursor(), (1, 2)); assert!(!t.is_selecting()); } #[test] fn test_selection_range() { #[rustfmt::skip] let mut t = TextArea::from([ "恂恄恆恈恊", "Hello", "🐶🐱🐰🐮🐹", ]); assert_eq!(t.selection_range(), None); for (from, to) in [ ((0, 0), (0, 0)), ((2, 5), (2, 5)), ((0, 2), (2, 3)), ((2, 1), (0, 4)), ((0, 0), (2, 5)), ((2, 5), (0, 0)), ] { let (x, y) = from; t.move_cursor(CursorMove::Jump(x as _, y as _)); t.start_selection(); let (x, y) = to; t.move_cursor(CursorMove::Jump(x as _, y as _)); let have = t.selection_range().unwrap(); let want = if from <= to { (from, to) } else { (to, from) }; assert_eq!(have, want, "selection from {from:?} to {to:?}"); t.cancel_selection(); let range = t.selection_range(); assert_eq!(range, None, "selection from {from:?} to {to:?}"); } } #[test] fn test_set_lines_preserves_non_content_configuration() { let mut t = TextArea::from(["hello"]); let base_style = Style::default().fg(Color::Green); let cursor_style = Style::default().bg(Color::Red); let cursor_line_style = Style::default().add_modifier(Modifier::BOLD); let selection_style = Style::default().bg(Color::Blue); let line_number_style = Style::default().fg(Color::Yellow); let placeholder_style = Style::default().fg(Color::Cyan); t.set_wrap_mode(tui_textarea::WrapMode::WordOrGlyph); t.set_block(Block::default().borders(Borders::ALL).title("Input")); t.set_style(base_style); t.set_cursor_style(cursor_style); t.set_cursor_render_mode(CursorRenderMode::Hidden); t.set_cursor_line_style(cursor_line_style); t.set_selection_style(selection_style); t.set_tab_length(8); t.set_alignment(Alignment::Right); t.set_line_number_style(line_number_style); t.set_placeholder_text("placeholder"); t.set_placeholder_style(placeholder_style); t.set_mask_char('*'); t.set_yank_text("copied"); t.set_min_rows(2); t.set_max_rows(6); t.set_max_histories(3); t.set_lines(vec!["abc".to_string()], (0, 1)); assert_eq!(t.lines(), ["abc"]); assert_eq!(t.cursor(), (0, 1)); assert_eq!(t.wrap_mode(), tui_textarea::WrapMode::WordOrGlyph); assert!(t.block().is_some()); assert_eq!(t.style(), base_style); assert_eq!(t.cursor_style(), cursor_style); assert_eq!(t.cursor_render_mode(), CursorRenderMode::Hidden); assert_eq!(t.cursor_line_style(), cursor_line_style); assert_eq!(t.selection_style(), selection_style); assert_eq!(t.tab_length(), 8); assert_eq!(t.alignment(), Alignment::Right); assert_eq!(t.line_number_style(), Some(line_number_style)); assert_eq!(t.placeholder_text(), "placeholder"); assert_eq!(t.placeholder_style(), Some(placeholder_style)); assert_eq!(t.mask_char(), Some('*')); assert_eq!(t.yank_text(), "copied"); assert_eq!(t.min_rows(), 2); assert_eq!(t.max_rows(), 6); assert_eq!(t.max_histories(), 3); } #[test] fn cursor_render_mode_defaults_to_cell() { assert_eq!( TextArea::default().cursor_render_mode(), CursorRenderMode::Cell ); } #[test] fn set_cursor_render_mode_round_trips() { let mut textarea = TextArea::default(); textarea.set_cursor_render_mode(CursorRenderMode::Hidden); assert_eq!(textarea.cursor_render_mode(), CursorRenderMode::Hidden); textarea.set_cursor_render_mode(CursorRenderMode::Cell); assert_eq!(textarea.cursor_render_mode(), CursorRenderMode::Cell); } #[test] fn cell_mode_keeps_existing_cursor_rendering() { let mut textarea = TextArea::from(["abc"]); let cursor_style = Style::default().bg(Color::Red); textarea.set_cursor_style(cursor_style); textarea.set_cursor_line_style(Style::default()); textarea.move_cursor(CursorMove::Jump(0, 1)); let buf = render_textarea(&textarea, Rect::new(0, 0, 5, 1)); assert_eq!(buf[(1, 0)].symbol(), "b"); assert_eq!(buf[(1, 0)].style().bg, cursor_style.bg); } #[test] fn hidden_mode_does_not_draw_cursor_cell_inside_text() { let mut textarea = TextArea::from(["abc"]); textarea.set_cursor_style(Style::default().bg(Color::Red)); textarea.set_cursor_line_style(Style::default()); textarea.set_cursor_render_mode(CursorRenderMode::Hidden); textarea.move_cursor(CursorMove::Jump(0, 1)); let buf = render_textarea(&textarea, Rect::new(0, 0, 5, 1)); assert_eq!(buf[(1, 0)].symbol(), "b"); assert_ne!(buf[(1, 0)].style().bg, Some(Color::Red)); } #[test] fn hidden_mode_does_not_append_cursor_space_at_line_end() { let mut textarea = TextArea::from(["abc"]); let cursor_style = Style::default().bg(Color::Red); textarea.set_cursor_style(cursor_style); textarea.set_cursor_line_style(Style::default()); textarea.set_cursor_render_mode(CursorRenderMode::Hidden); textarea.move_cursor(CursorMove::End); let buf = render_textarea(&textarea, Rect::new(0, 0, 5, 1)); assert_eq!(buf[(0, 0)].symbol(), "a"); assert_eq!(buf[(1, 0)].symbol(), "b"); assert_eq!(buf[(2, 0)].symbol(), "c"); assert_eq!(buf[(3, 0)].symbol(), " "); assert_ne!(buf[(3, 0)].style().bg, cursor_style.bg); } #[test] fn hidden_mode_placeholder_does_not_draw_cursor_cell() { let mut textarea = TextArea::default(); textarea.set_placeholder_text("Type here"); textarea.set_cursor_style(Style::default().bg(Color::Red)); textarea.set_cursor_render_mode(CursorRenderMode::Hidden); let buf = render_textarea(&textarea, Rect::new(0, 0, 12, 1)); assert_eq!(buf[(0, 0)].symbol(), "T"); assert_eq!( textarea.rendered_cursor_position(), Some(Position { x: 0, y: 0 }) ); } #[test] fn rendered_cursor_position_returns_none_before_first_render() { let textarea = TextArea::default(); assert_eq!(textarea.rendered_cursor_position(), None); } #[test] fn rendered_cursor_position_after_default_render() { let mut textarea = TextArea::from(["abc"]); textarea.move_cursor(CursorMove::Jump(0, 2)); render_textarea(&textarea, Rect::new(10, 5, 20, 3)); assert_eq!( textarea.rendered_cursor_position(), Some(Position { x: 12, y: 5 }) ); } #[test] fn rendered_cursor_position_accounts_for_block() { let mut textarea = TextArea::from(["abc"]); textarea.set_block(Block::default().borders(Borders::ALL)); textarea.move_cursor(CursorMove::Jump(0, 2)); render_textarea(&textarea, Rect::new(10, 5, 20, 5)); assert_eq!( textarea.rendered_cursor_position(), Some(Position { x: 13, y: 6 }) ); } #[test] fn rendered_cursor_position_accounts_for_line_numbers() { let mut textarea = TextArea::from(["abc"]); textarea.set_line_number_style(Style::default()); textarea.move_cursor(CursorMove::Jump(0, 1)); render_textarea(&textarea, Rect::new(10, 5, 20, 3)); assert_eq!( textarea.rendered_cursor_position(), Some(Position { x: 14, y: 5 }) ); } #[test] fn rendered_cursor_position_accounts_for_horizontal_scroll() { let mut textarea = TextArea::from(["abcdefghi"]); textarea.move_cursor(CursorMove::End); render_textarea(&textarea, Rect::new(0, 0, 5, 1)); assert_eq!( textarea.rendered_cursor_position(), Some(Position { x: 4, y: 0 }) ); } #[test] fn rendered_cursor_position_accounts_for_wrap() { let mut textarea = TextArea::from(["abcdefghij"]); textarea.set_wrap_mode(WrapMode::WordOrGlyph); textarea.move_cursor(CursorMove::Jump(0, 7)); render_textarea(&textarea, Rect::new(4, 3, 5, 3)); assert_eq!( textarea.rendered_cursor_position(), Some(Position { x: 6, y: 4 }) ); } #[test] fn rendered_cursor_position_returns_none_for_empty_area() { let textarea = TextArea::default(); render_textarea(&textarea, Rect::new(0, 0, 0, 1)); assert_eq!(textarea.rendered_cursor_position(), None); } #[test] fn rendered_cursor_position_handles_wide_unicode() { let mut textarea = TextArea::from(["a恂b"]); textarea.move_cursor(CursorMove::Jump(0, 2)); render_textarea(&textarea, Rect::new(0, 0, 10, 1)); assert_eq!( textarea.rendered_cursor_position(), Some(Position { x: 3, y: 0 }) ); } #[test] fn rendered_cursor_position_handles_tabs() { let mut textarea = TextArea::from(["a\tb"]); textarea.move_cursor(CursorMove::Jump(0, 2)); render_textarea(&textarea, Rect::new(0, 0, 10, 1)); assert_eq!( textarea.rendered_cursor_position(), Some(Position { x: 4, y: 0 }) ); } #[test] fn test_set_lines_clamps_cursor_and_clears_selection_and_history() { let mut t = TextArea::from(["hello", "world"]); assert!(t.insert_str("!")); assert!(t.undo()); assert!(t.redo()); t.move_cursor(CursorMove::Jump(0, 1)); t.start_selection(); t.move_cursor(CursorMove::Jump(1, 3)); assert!(t.is_selecting()); t.set_lines(vec!["ab".to_string(), "c".to_string()], (9, 9)); assert_eq!(t.lines(), ["ab", "c"]); assert_eq!(t.cursor(), (1, 1)); assert!(!t.is_selecting()); assert!(!t.undo()); } #[test] #[should_panic(expected = "lines must not be empty; use vec![String::new()] for empty content")] fn test_set_lines_panics_on_empty_lines() { let mut t = TextArea::default(); t.set_lines(vec![], (0, 0)); } struct DeleteTester(&'static [&'static str], fn(&mut TextArea) -> bool); impl DeleteTester { fn test(&self, before: (usize, usize), after: (usize, usize, &[&str], &str)) { let Self(buf_before, op) = *self; let (row, col) = before; let mut t = TextArea::from(buf_before.iter().map(|s| s.to_string())); t.move_cursor(CursorMove::Jump(row as _, col as _)); let modified = op(&mut t); let (row, col, buf_after, yank) = after; assert_eq!(t.lines(), buf_after); assert_eq!(t.cursor(), (row, col)); assert_eq!(modified, buf_before != buf_after); assert_eq!(t.yank_text(), yank); if modified { t.undo(); assert_eq!(t.lines(), buf_before); t.redo(); assert_eq!(t.lines(), buf_after); } else { assert_no_undo_redo(&mut t, ""); } } } #[test] fn test_delete_newline() { let t = DeleteTester(&["a", "b", "c"], |t| t.delete_newline()); t.test((0, 0), (0, 0, t.0, "")); t.test((1, 0), (0, 1, &["ab", "c"], "")); t.test((2, 0), (1, 1, &["a", "bc"], "")); } #[test] fn test_delete_char() { let t = DeleteTester(&["ab", "c"], |t| t.delete_char()); t.test((0, 0), (0, 0, t.0, "")); t.test((0, 1), (0, 0, &["b", "c"], "")); t.test((0, 2), (0, 1, &["a", "c"], "")); t.test((1, 0), (0, 2, &["abc"], "")); } #[test] fn test_delete_next_char() { let t = DeleteTester(&["ab", "c"], |t| t.delete_next_char()); t.test((0, 0), (0, 0, &["b", "c"], "")); t.test((0, 1), (0, 1, &["a", "c"], "")); t.test((0, 2), (0, 2, &["abc"], "")); t.test((1, 1), (1, 1, t.0, "")); } #[test] fn test_delete_line_by_end() { let t = DeleteTester(&["aaa bbb", "d"], |t| t.delete_line_by_end()); t.test((0, 0), (0, 0, &["", "d"], "aaa bbb")); t.test((0, 3), (0, 3, &["aaa", "d"], " bbb")); t.test((0, 6), (0, 6, &["aaa bb", "d"], "b")); t.test((0, 7), (0, 7, &["aaa bbbd"], "")); // Newline is not yanked t.test((1, 1), (1, 1, t.0, "")); } #[test] fn test_delete_line_by_head() { let t = DeleteTester(&["aaa bbb", "d"], |t| t.delete_line_by_head()); t.test((0, 0), (0, 0, t.0, "")); t.test((0, 3), (0, 0, &[" bbb", "d"], "aaa")); t.test((0, 7), (0, 0, &["", "d"], "aaa bbb")); t.test((1, 0), (0, 7, &["aaa bbbd"], "")); // Newline is not yanked } #[test] fn test_delete_word() { let t = DeleteTester(&["word ことば 🐶", " x"], |t| t.delete_word()); t.test((0, 0), (0, 0, t.0, "")); t.test((0, 2), (0, 0, &["rd ことば 🐶", " x"], "wo")); t.test((0, 4), (0, 0, &[" ことば 🐶", " x"], "word")); t.test((0, 5), (0, 0, &[" ことば 🐶", " x"], "word ")); t.test((0, 6), (0, 0, &["ことば 🐶", " x"], "word ")); t.test((0, 7), (0, 6, &["word とば 🐶", " x"], "恓")); t.test((0, 9), (0, 6, &["word 🐶", " x"], "ことば")); t.test((0, 10), (0, 6, &["word 🐶", " x"], "ことば ")); t.test((0, 11), (0, 10, &["word ことば ", " x"], "🐶")); t.test((1, 0), (0, 11, &["word ことば 🐶 x"], "")); t.test((1, 1), (1, 0, &["word ことば 🐶", "x"], " ")); t.test((1, 2), (1, 1, &["word ことば 🐶", " "], "x")); } #[test] fn test_delete_next_word() { let t = DeleteTester(&["word ことば 🐶", " x"], |t| t.delete_next_word()); t.test((0, 0), (0, 0, &[" ことば 🐶", " x"], "word")); t.test((0, 2), (0, 2, &["wo ことば 🐶", " x"], "rd")); t.test((0, 4), (0, 4, &["word 🐶", " x"], " ことば")); t.test((0, 5), (0, 5, &["word 🐶", " x"], " ことば")); t.test((0, 6), (0, 6, &["word 🐶", " x"], "ことば")); t.test((0, 9), (0, 9, &["word ことば", " x"], " 🐶")); t.test((0, 10), (0, 10, &["word ことば ", " x"], "🐶")); t.test((0, 11), (0, 11, &["word ことば 🐶 x"], "")); t.test((1, 0), (1, 0, &["word ことば 🐶", ""], " x")); t.test((1, 2), (1, 2, t.0, "")); }