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 { 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)); }