1
0
зеркало из https://github.com/glebtv/tui-textarea.git synced 2026-08-28 11:36:17 +03:00
Files
tui-textarea/tests/textarea.rs
srothgan 475f0beb82 feat(textarea): add native cursor render mode (#26)
* feat(textarea): add native cursor render mode

- Add CursorRenderMode::{Cell, Hidden} and TextArea accessors for render mode plus rendered_cursor_position().
- Store the terminal-relative cursor position from a shared render plan that accounts for blocks, line numbers, scroll, wrapping, tabs, and wide Unicode.
- Let hidden mode suppress only the textarea-owned cursor cell while preserving default Cell rendering and set_cursor_style behavior.
- Document native cursor integration and add focused tests for default rendering, hidden rendering, placeholders, and cursor-position edge cases.

* chore: prepare 0.12.1 release
2026-07-10 20:14:27 +10:00

1987 строки
50 KiB
Rust

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<T: Debug>(
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: Debug>(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, ""));
}