зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-08-28 11:36:17 +03:00
* feat!: remove tui-rs support and map screen cursors * docs(examples): add fixed-size recording mode * chore(release): bump to v0.11.0
532 строки
16 KiB
Rust
532 строки
16 KiB
Rust
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::{Color, Style};
|
|
use ratatui::widgets::Widget as _;
|
|
use tui_textarea::{CursorMove, TextArea, WrapMode};
|
|
|
|
fn cell_symbol(cell: &ratatui::buffer::Cell) -> &str {
|
|
cell.symbol()
|
|
}
|
|
|
|
fn render_lines(textarea: &TextArea<'_>, width: u16, height: u16) -> Vec<String> {
|
|
let area = Rect {
|
|
x: 0,
|
|
y: 0,
|
|
width,
|
|
height,
|
|
};
|
|
let mut buf = Buffer::empty(area);
|
|
textarea.render(area, &mut buf);
|
|
|
|
(0..height)
|
|
.map(|y| {
|
|
let mut line = String::new();
|
|
for x in 0..width {
|
|
line.push_str(cell_symbol(&buf[(x, y)]));
|
|
}
|
|
line
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn render_buffer(textarea: &TextArea<'_>, width: u16, height: u16) -> Buffer {
|
|
let area = Rect {
|
|
x: 0,
|
|
y: 0,
|
|
width,
|
|
height,
|
|
};
|
|
let mut buf = Buffer::empty(area);
|
|
textarea.render(area, &mut buf);
|
|
buf
|
|
}
|
|
|
|
#[test]
|
|
fn wrap_mode_default_is_none() {
|
|
let textarea = TextArea::default();
|
|
assert_eq!(textarea.wrap_mode(), WrapMode::None);
|
|
}
|
|
|
|
#[test]
|
|
fn none_mode_keeps_horizontal_behavior() {
|
|
let textarea = TextArea::from(["abcdef"]);
|
|
let lines = render_lines(&textarea, 5, 2);
|
|
assert_eq!(lines, vec!["abcde".to_string(), " ".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn word_or_glyph_mode_soft_wraps() {
|
|
let mut textarea = TextArea::from(["abcdef"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
let lines = render_lines(&textarea, 5, 2);
|
|
assert_eq!(lines, vec!["abcde".to_string(), "f ".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn word_mode_does_not_split_long_word() {
|
|
let mut textarea = TextArea::from(["abcdefgh"]);
|
|
textarea.set_wrap_mode(WrapMode::Word);
|
|
let lines = render_lines(&textarea, 5, 2);
|
|
assert_eq!(lines, vec!["abcde".to_string(), " ".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn word_mode_wraps_with_newlines() {
|
|
let mut textarea = TextArea::from(["hello world", "again here"]);
|
|
textarea.set_wrap_mode(WrapMode::Word);
|
|
|
|
let lines = render_lines(&textarea, 6, 4);
|
|
assert_eq!(
|
|
lines,
|
|
vec![
|
|
"hello ".to_string(),
|
|
"world ".to_string(),
|
|
"again ".to_string(),
|
|
"here ".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn none_mode_long_multiline_text_stays_unwrapped() {
|
|
let textarea = TextArea::from(["first very long line", "second very long line"]);
|
|
let lines = render_lines(&textarea, 8, 4);
|
|
assert_eq!(
|
|
lines,
|
|
vec![
|
|
"first ve".to_string(),
|
|
"second v".to_string(),
|
|
" ".to_string(),
|
|
" ".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn word_mode_long_paragraph_wraps_by_words() {
|
|
let mut textarea = TextArea::from(["lorem ipsum dolor sit amet consectetur adipiscing elit"]);
|
|
textarea.set_wrap_mode(WrapMode::Word);
|
|
let lines = render_lines(&textarea, 12, 6);
|
|
assert_eq!(
|
|
lines,
|
|
vec![
|
|
"lorem ipsum ".to_string(),
|
|
"dolor sit ".to_string(),
|
|
"amet ".to_string(),
|
|
"consectetur ".to_string(),
|
|
"adipiscing ".to_string(),
|
|
"elit ".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn glyph_mode_splits_long_token() {
|
|
let mut textarea = TextArea::from(["0123456789abcdefghijklmnopqrstuvwxyz"]);
|
|
textarea.set_wrap_mode(WrapMode::Glyph);
|
|
let lines = render_lines(&textarea, 10, 4);
|
|
assert_eq!(
|
|
lines,
|
|
vec![
|
|
"0123456789".to_string(),
|
|
"abcdefghij".to_string(),
|
|
"klmnopqrst".to_string(),
|
|
"uvwxyz ".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn word_and_word_or_glyph_differ_for_long_words() {
|
|
let text = "alpha supercalifragilisticexpialidocious omega";
|
|
|
|
let mut word = TextArea::from([text]);
|
|
word.set_wrap_mode(WrapMode::Word);
|
|
let word_lines = render_lines(&word, 10, 4);
|
|
assert_eq!(
|
|
word_lines,
|
|
vec![
|
|
"alpha ".to_string(),
|
|
"supercalif".to_string(),
|
|
" omega ".to_string(),
|
|
" ".to_string(),
|
|
]
|
|
);
|
|
|
|
let mut word_or_glyph = TextArea::from([text]);
|
|
word_or_glyph.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
let word_or_glyph_lines = render_lines(&word_or_glyph, 10, 6);
|
|
assert_eq!(
|
|
word_or_glyph_lines,
|
|
vec![
|
|
"alpha ".to_string(),
|
|
"supercalif".to_string(),
|
|
"ragilistic".to_string(),
|
|
"expialidoc".to_string(),
|
|
"ious ".to_string(),
|
|
" omega ".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_mode_line_numbers_on_continuation_rows() {
|
|
let mut textarea = TextArea::from(["abcdefghijk", "xy"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
textarea.set_line_number_style(Style::default());
|
|
|
|
let lines = render_lines(&textarea, 8, 4);
|
|
assert_eq!(
|
|
lines,
|
|
vec![
|
|
" 1 abcde".to_string(),
|
|
" fghij".to_string(),
|
|
" k ".to_string(),
|
|
" 2 xy ".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_mode_handles_tiny_width() {
|
|
let mut textarea = TextArea::from(["abcd"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
|
|
let lines = render_lines(&textarea, 1, 4);
|
|
assert_eq!(
|
|
lines,
|
|
vec![
|
|
"a".to_string(),
|
|
"b".to_string(),
|
|
"c".to_string(),
|
|
"d".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_mode_preserves_empty_logical_lines() {
|
|
let mut textarea = TextArea::from(["ab", "", "cd"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
|
|
let lines = render_lines(&textarea, 2, 3);
|
|
assert_eq!(
|
|
lines,
|
|
vec!["ab".to_string(), " ".to_string(), "cd".to_string(),]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn glyph_mode_combining_grapheme_renders_in_two_rows() {
|
|
let mut textarea = TextArea::from(["e\u{301}x"]);
|
|
textarea.set_wrap_mode(WrapMode::Glyph);
|
|
|
|
let lines = render_lines(&textarea, 1, 2);
|
|
assert_eq!(lines, vec!["e".to_string(), "x".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_mode_scrolls_to_cursor_visual_row() {
|
|
let mut textarea = TextArea::from(["abcdefghijklmnopqrstuvwxyz"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
textarea.move_cursor(CursorMove::End);
|
|
|
|
let lines = render_lines(&textarea, 5, 2);
|
|
assert_eq!(lines, vec!["uvwxy".to_string(), "z ".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_mode_with_tab_does_not_hide_following_text() {
|
|
let mut textarea = TextArea::from(["\tX"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
let lines = render_lines(&textarea, 2, 2);
|
|
assert_eq!(lines, vec![" ".to_string(), "X ".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_line_style_applies_to_wrapped_continuation_rows() {
|
|
let mut textarea = TextArea::from(["abcdefghij"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
let style = Style::default().bg(Color::Red);
|
|
textarea.set_cursor_line_style(style);
|
|
|
|
let buf = render_buffer(&textarea, 5, 2);
|
|
assert_eq!(buf[(1, 1)].style().bg, style.bg);
|
|
}
|
|
|
|
#[test]
|
|
fn selection_does_not_extend_with_synthetic_space_on_wrap_boundary() {
|
|
let mut textarea = TextArea::from(["hello world again"]);
|
|
textarea.set_wrap_mode(WrapMode::Word);
|
|
textarea.set_cursor_line_style(Style::default());
|
|
let select_style = Style::default().bg(Color::Blue);
|
|
textarea.set_selection_style(select_style);
|
|
textarea.start_selection();
|
|
textarea.move_cursor(CursorMove::End);
|
|
|
|
let buf = render_buffer(&textarea, 10, 3);
|
|
assert_eq!(buf[(5, 0)].style().bg, select_style.bg);
|
|
assert_ne!(buf[(6, 0)].style().bg, select_style.bg);
|
|
}
|
|
|
|
// --- Wrapped line cursor navigation tests ---
|
|
|
|
fn render(textarea: &TextArea<'_>, width: u16, height: u16) {
|
|
let area = Rect {
|
|
x: 0,
|
|
y: 0,
|
|
width,
|
|
height,
|
|
};
|
|
let mut buf = Buffer::empty(area);
|
|
textarea.render(area, &mut buf);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_down_moves_within_same_logical_line() {
|
|
// "abcdefghij" at width=5 wraps into 2 visual lines:
|
|
// visual 0: "abcde" (row 0, cols 0..5)
|
|
// visual 1: "fghij" (row 0, cols 5..10)
|
|
let mut textarea = TextArea::from(["abcdefghij"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
render(&textarea, 5, 4);
|
|
|
|
// Cursor starts at (0, 0) — top of visual line 0
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
|
|
// Down should move to the second visual line within the same logical line
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 5));
|
|
|
|
// Down again should not move (already at last visual row)
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 5));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_up_moves_within_same_logical_line() {
|
|
// "abcdefghij" at width=5 wraps into 2 visual lines:
|
|
// visual 0: cols 0..4 (end_col=5 exclusive for non-last)
|
|
// visual 1: cols 5..10
|
|
let mut textarea = TextArea::from(["abcdefghij"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
render(&textarea, 5, 4);
|
|
|
|
// Move to end of text (row 0, col 10) which is on visual line 1
|
|
textarea.move_cursor(CursorMove::End);
|
|
assert_eq!(textarea.cursor(), (0, 10));
|
|
|
|
// Up should move to visual line 0, col clamped to max of visual line 0 (col 4)
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 4));
|
|
|
|
// Up again should not move (already at first visual row)
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 4));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_down_crosses_logical_line_boundary() {
|
|
// Two logical lines, first one wraps:
|
|
// visual 0: "abcde" (row 0, cols 0..4)
|
|
// visual 1: "fghij" (row 0, cols 5..10)
|
|
// visual 2: "xy" (row 1, cols 0..2)
|
|
let mut textarea = TextArea::from(["abcdefghij", "xy"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
render(&textarea, 5, 4);
|
|
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
|
|
// Down: visual 0 → visual 1
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 5));
|
|
|
|
// Down: visual 1 → visual 2 (crosses into second logical line, visual offset 0 preserved)
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (1, 0));
|
|
|
|
// Down: already at bottom
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (1, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_up_crosses_logical_line_boundary() {
|
|
// visual 0: "abcde" (row 0, cols 0..4)
|
|
// visual 1: "fghij" (row 0, cols 5..10)
|
|
// visual 2: "xy" (row 1, cols 0..2)
|
|
let mut textarea = TextArea::from(["abcdefghij", "xy"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
render(&textarea, 5, 4);
|
|
|
|
// Start at second logical line
|
|
textarea.move_cursor(CursorMove::Jump(1, 0));
|
|
assert_eq!(textarea.cursor(), (1, 0));
|
|
|
|
// Up: visual 2 → visual 1 (visual offset 0 → start of visual row 1)
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 5));
|
|
|
|
// Up: visual 1 → visual 0 (visual offset 0 preserved → col 0)
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
|
|
// Up: already at top
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_column_preserved_across_visual_lines() {
|
|
// "abcdefghij" at width=5:
|
|
// visual 0: "abcde" (cols 0..4)
|
|
// visual 1: "fghij" (cols 5..10)
|
|
let mut textarea = TextArea::from(["abcdefghij"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
render(&textarea, 5, 4);
|
|
|
|
// Start at col 2 on visual line 0
|
|
textarea.move_cursor(CursorMove::Jump(0, 2));
|
|
assert_eq!(textarea.cursor(), (0, 2));
|
|
|
|
// Down: visual offset 2 preserved → target col 5+2 = 7
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 7));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_column_clamped_to_shorter_visual_line() {
|
|
// "abcdefgh" + "xy" at width=5:
|
|
// visual 0: "abcde" (row 0, cols 0..4)
|
|
// visual 1: "fgh" (row 0, cols 5..8)
|
|
// visual 2: "xy" (row 1, cols 0..2)
|
|
let mut textarea = TextArea::from(["abcdefgh", "xy"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
render(&textarea, 5, 4);
|
|
|
|
// Start at col 4 on visual line 0
|
|
textarea.move_cursor(CursorMove::Jump(0, 4));
|
|
assert_eq!(textarea.cursor(), (0, 4));
|
|
|
|
// Down: visual offset 4 → target col 5+4 = 9, clamped to line len 8
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 8));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_down_preserves_visual_column_for_mixed_width_same_logical_line() {
|
|
let mut textarea = TextArea::from(["a中bcde"]);
|
|
textarea.set_wrap_mode(WrapMode::Glyph);
|
|
render(&textarea, 4, 4);
|
|
|
|
// After `a中`, the data column is 2 but the visual column is 3.
|
|
textarea.move_cursor(CursorMove::Jump(0, 2));
|
|
assert_eq!(textarea.cursor(), (0, 2));
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 6));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_up_preserves_visual_column_for_mixed_width_same_logical_line() {
|
|
let mut textarea = TextArea::from(["a中bcde"]);
|
|
textarea.set_wrap_mode(WrapMode::Glyph);
|
|
render(&textarea, 4, 4);
|
|
|
|
textarea.move_cursor(CursorMove::Jump(0, 6));
|
|
assert_eq!(textarea.cursor(), (0, 6));
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 2));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_down_preserves_visual_column_when_crossing_into_mixed_width_line() {
|
|
let mut textarea = TextArea::from(["abcd", "a中bcde"]);
|
|
textarea.set_wrap_mode(WrapMode::Glyph);
|
|
render(&textarea, 4, 4);
|
|
|
|
// Visual column 3 in ASCII should land at visual column 3 in the mixed-width row.
|
|
textarea.move_cursor(CursorMove::Jump(0, 3));
|
|
assert_eq!(textarea.cursor(), (0, 3));
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (1, 2));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_up_preserves_visual_column_when_crossing_out_of_mixed_width_line() {
|
|
let mut textarea = TextArea::from(["abcd", "a中bcde"]);
|
|
textarea.set_wrap_mode(WrapMode::Glyph);
|
|
render(&textarea, 4, 4);
|
|
|
|
textarea.move_cursor(CursorMove::Jump(1, 2));
|
|
assert_eq!(textarea.cursor(), (1, 2));
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 3));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_down_up_with_word_wrap_mode() {
|
|
// "hello world" at width=6 with Word wrap:
|
|
// visual 0: "hello " (row 0, cols 0..5 for non-last)
|
|
// visual 1: "world" (row 0, cols 6..11)
|
|
let mut textarea = TextArea::from(["hello world"]);
|
|
textarea.set_wrap_mode(WrapMode::Word);
|
|
render(&textarea, 6, 4);
|
|
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 6));
|
|
|
|
// Up: visual offset 0 preserved → col 0
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_no_wrap_short_lines_behave_normally() {
|
|
// Lines fit within width — no wrapping happens, normal Up/Down
|
|
let mut textarea = TextArea::from(["abc", "def", "ghi"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
render(&textarea, 10, 4);
|
|
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (1, 0));
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (2, 0));
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (1, 0));
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_cursor_three_visual_lines_from_one_logical() {
|
|
// "abcdefghijklmno" at width=5:
|
|
// visual 0: "abcde" (cols 0..4)
|
|
// visual 1: "fghij" (cols 5..9)
|
|
// visual 2: "klmno" (cols 10..15)
|
|
let mut textarea = TextArea::from(["abcdefghijklmno"]);
|
|
textarea.set_wrap_mode(WrapMode::WordOrGlyph);
|
|
render(&textarea, 5, 4);
|
|
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 5));
|
|
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 10));
|
|
|
|
textarea.move_cursor(CursorMove::Down);
|
|
assert_eq!(textarea.cursor(), (0, 10)); // already at bottom
|
|
|
|
// Up: visual offset 0 preserved → col 5
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 5));
|
|
|
|
// Up: visual offset 0 preserved → col 0
|
|
textarea.move_cursor(CursorMove::Up);
|
|
assert_eq!(textarea.cursor(), (0, 0));
|
|
}
|