зеркало из
https://github.com/glebtv/tui-textarea.git
synced 2026-09-05 07:15:49 +03:00
feat: add unicode-aware soft-wrap modes and wrapped rendering (#8)
* feat(textarea): add unicode-aware soft-wrap modes and wrapped rendering
- add WrapMode API/state: None, Word, Glyph, WordOrGlyph
- keep None behavior unchanged (horizontal scroll)
- implement wrapped render path with logical-to-visual row mapping
- add Unicode-aware wrapping via unicode-segmentation + unicode-width
- make wrap width tab-aware to prevent hidden text
- apply cursor-line style on wrapped continuation rows
- fix selection at soft-wrap boundaries (no synthetic highlighted space)
- add wrap-focused tests for long text, newlines, tabs, tiny widths, and line-number
continuations
* chore(release): bump to v0.9.0 and update README/CHANGELOG
Этот коммит содержится в:
266
tests/wrap.rs
Обычный файл
266
tests/wrap.rs
Обычный файл
@@ -0,0 +1,266 @@
|
||||
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 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(buf[(x, y)].symbol());
|
||||
}
|
||||
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);
|
||||
}
|
||||
Ссылка в новой задаче
Block a user