1
0
зеркало из https://github.com/glebtv/tui-textarea.git synced 2026-08-28 11:36:17 +03:00

fix(wrap): drop phantom whitespace rows for inputs that exactly fill a row

Word-wrap previously emitted a phantom whitespace-only row when a word
exactly filled a row and the input continued (e.g. "abcde fghij" at
width 5 wrapped to 3 rows instead of 2). Separator whitespace that does
not fit the remaining space is now handled like ratatui's WordWrapper
(trim: false): whitespace filling the remaining cells is dropped,
whitespace after an exactly full row is dropped one grapheme at a time,
and any leftover whitespace leads the next row.

wrapped_rows now keeps byte-accurate column bookkeeping so screen_map
round-trips hold for all visible positions; positions inside dropped
whitespace have no screen representation and are treated as such.

Verified differentially against ratatui 0.30's WordWrapper (a faithful
reference port cross-checked against the real Paragraph renderer):
row counts now match for single- and multi-space separators, trailing
whitespace, CJK and mixed-width inputs.
Этот коммит содержится в:
Gleb Tv
2026-08-19 13:36:28 +03:00
родитель 0b93c88729
Коммит 6aa9ec0b87
3 изменённых файлов: 311 добавлений и 8 удалений

2
Cargo.lock сгенерированный
Просмотреть файл

@@ -1871,7 +1871,7 @@ dependencies = [
[[package]]
name = "tui-textarea-2"
version = "0.12.1"
version = "0.12.2"
dependencies = [
"arbitrary",
"crossterm 0.28.1",

Просмотреть файл

@@ -225,18 +225,40 @@ mod tests {
}
fn assert_round_trips(textarea: &TextArea<'_>) {
// Word-wrap drops separator whitespace that never fits a row (matching ratatui).
// Such positions have no screen representation, so a detached coordinate
// round-trip is impossible for them — only the attached (explicit dc) round-trip
// is asserted, plus stability of the detached mapping.
let screen_lines = textarea.screen_lines.borrow().clone();
for (row, line) in textarea.lines.iter().enumerate() {
let rows_for_line: Vec<WrappedLine> = screen_lines
.iter()
.filter(|sl| sl.wrapped.row == row)
.map(|sl| sl.wrapped)
.collect();
for col in 0..=line.chars().count() {
let dc = DataCursor(row, col);
let sc = textarea.data_to_screen_cursor(dc);
assert_eq!(textarea.screen_to_data_cursor(sc), dc);
let detached = ScreenCursor { dc: None, ..sc };
assert_eq!(textarea.screen_to_data_cursor(detached), dc);
assert_eq!(
textarea.data_to_screen_cursor(textarea.screen_to_data_cursor(detached)),
sc
);
let back = textarea.screen_to_data_cursor(detached);
let visible = rows_for_line.iter().any(|w| {
let (lo, hi) = if w.last_in_row {
(w.start_col, w.end_col)
} else {
(w.start_col, w.end_col.saturating_sub(1))
};
lo <= col && col <= hi
});
if visible {
assert_eq!(back, dc, "detached round-trip failed for {dc:?}");
assert_eq!(textarea.data_to_screen_cursor(back), sc);
} else {
// Position inside dropped whitespace: the detached mapping resolves
// to the nearest representable position and must be stable.
assert_eq!(textarea.data_to_screen_cursor(back), sc);
}
}
}
}

Просмотреть файл

@@ -57,8 +57,12 @@ pub(crate) fn wrapped_rows(
for (row, line) in lines.iter().enumerate() {
let ranges = line_ranges(line, mode, width, tab_len);
let mut start_col = 0usize;
for (i, (start_byte, end_byte)) in ranges.iter().copied().enumerate() {
// Column bookkeeping is byte-accurate: `start_col` is the data column of the
// fragment's first char and `end_col` the column just past its last char.
// Whitespace that word-wrap dropped (it never fits a row) is not claimed by
// any row, so positions inside it have no screen representation.
let start_col = line[..start_byte].chars().count();
let end_col = start_col + line[start_byte..end_byte].chars().count();
rows.push(WrappedLine {
row,
@@ -69,7 +73,6 @@ pub(crate) fn wrapped_rows(
first_in_row: i == 0,
last_in_row: i + 1 == ranges.len(),
});
start_col = end_col;
}
}
@@ -143,6 +146,54 @@ fn wrap_word_chunks(
if seg_end > seg_start {
out.push((seg_start, seg_end));
// Separator whitespace that does not fit on the current segment is
// handled like ratatui's WordWrapper (trim: false): when the line
// is exactly full the first whitespace grapheme is dropped,
// otherwise whitespace filling the remaining space is dropped;
// any leftover whitespace leads the next row. This prevents
// whitespace-only phantom rows (e.g. "abcde fghij" at width 5 must
// wrap to 2 rows, not 3).
let text = chunk_text(line, chunk);
if !text.is_empty() && text.chars().all(char::is_whitespace) {
let drop_cells = if seg_width == width {
1
} else {
width - seg_width
};
let kept_start = drop_leading_whitespace(line, chunk, drop_cells, tab_len);
if kept_start >= chunk.end {
// Entire separator dropped: start the next row fresh.
seg_start = chunk.end;
seg_end = chunk.end;
seg_width = 0;
i += 1;
continue;
}
// Leftover whitespace starts the next row.
let kept_width = display_width_from(&line[kept_start..chunk.end], 0, tab_len);
if kept_width <= width {
seg_start = kept_start;
seg_end = chunk.end;
seg_width = kept_width;
i += 1;
continue;
}
// Pathological: leftover whitespace wider than the row.
if fallback_to_glyph {
split_range_by_grapheme_width(
line, kept_start, chunk.end, width, tab_len, &mut out,
);
} else {
out.push((kept_start, chunk.end));
}
i += 1;
seg_start = chunk.end;
seg_end = chunk.end;
seg_width = 0;
continue;
}
seg_start = seg_end;
seg_width = 0;
continue;
@@ -217,6 +268,29 @@ fn chunk_text(line: &str, chunk: Chunk) -> &str {
&line[chunk.start..chunk.end]
}
/// Return the byte offset inside `chunk` after dropping up to `drop_cells`
/// display cells of leading whitespace (grapheme-atomic). Used to trim
/// separator whitespace at line breaks like ratatui's `WordWrapper` does.
fn drop_leading_whitespace(line: &str, chunk: Chunk, drop_cells: usize, tab_len: u8) -> usize {
let text = &line[chunk.start..chunk.end];
let mut col = 0usize;
let mut cells = 0usize;
let mut offset = 0usize;
for (grapheme_offset, grapheme) in UnicodeSegmentation::grapheme_indices(text, true) {
let width = display_width_to(grapheme, col, tab_len).saturating_sub(col);
if width == 0 {
continue;
}
if cells + width > drop_cells {
break;
}
cells += width;
col += width;
offset = grapheme_offset + grapheme.len();
}
chunk.start + offset
}
fn display_width_from(text: &str, start_width: usize, tab_len: u8) -> usize {
display_width_to(text, start_width, tab_len).saturating_sub(start_width)
}
@@ -276,4 +350,211 @@ mod tests {
let have = segments("\tX", WrapMode::WordOrGlyph, 2);
assert_eq!(have, vec!["\t", "X"]);
}
#[test]
fn separator_whitespace_dropped_when_line_exactly_full() {
// One space after an exactly full word must not produce a phantom row.
let have = segments("1234567890 ", WrapMode::WordOrGlyph, 10);
assert_eq!(have, vec!["1234567890"]);
let have = segments("abcde fghij", WrapMode::WordOrGlyph, 5);
assert_eq!(have, vec!["abcde", "fghij"]);
let have = segments("abcde fghij", WrapMode::Word, 5);
assert_eq!(have, vec!["abcde", "fghij"]);
let have = segments("abcde fghi", WrapMode::WordOrGlyph, 5);
assert_eq!(have, vec!["abcde", "fghi"]);
}
#[test]
fn separator_whitespace_overflowing_remaining_space_is_dropped() {
// " " after a 4/5 segment: 1 cell fits the remaining space and is
// kept (leading the next row), the second cell is dropped — no
// phantom whitespace-only row.
let have = segments("abcd efgh", WrapMode::WordOrGlyph, 5);
assert_eq!(have, vec!["abcd", " efgh"]);
let have = segments("abcd efgh", WrapMode::Word, 5);
assert_eq!(have, vec!["abcd", " efgh"]);
}
#[test]
fn leftover_separator_whitespace_leads_next_row() {
// After an exactly full word the first separator grapheme is dropped,
// the rest stays visible at the start of the next row.
let have = segments("1234567890 ", WrapMode::WordOrGlyph, 10);
assert_eq!(have, vec!["1234567890", " "]);
let have = segments("abcde fghij", WrapMode::WordOrGlyph, 5);
assert_eq!(have, vec!["abcde", " ", "fghij"]);
}
#[test]
fn wrap_matches_ratatui_row_counts() {
// Row counts must match ratatui's WordWrapper (trim: false) — the same
// algorithm the TUI renders with. `ratatui_reference_rows` is a faithful
// port of ratatui's WordWrapper::process_input; `ratatui_buffer_rows`
// cross-checks it against the real Paragraph renderer for inputs that do
// not end in a whitespace-only row (those are indistinguishable from
// empty buffer space).
let cases: &[(&str, usize)] = &[
("1234567890", 10),
("1234567890 ", 10),
("1234567890 x", 10),
("1234567890 ", 10),
("1234567890 ", 10),
("hello world", 10),
("hello worldx", 10),
(" padded text ", 8),
("abcde fghij", 5),
("abcde fghi", 5),
("abcde fgh", 5),
("abcde fghij", 5),
("abcd efgh", 5),
("abcd efgh", 5),
("a b c d e f g h i j k l", 5),
("ab cd ef gh", 5),
("x y z", 2),
("word word word", 8),
("12345678901234567890", 10),
("123456789012345678901", 10),
("第二行 mixed-width 内容 with English words.", 18),
("ASCII and русский текст can share one buffer.", 18),
(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
24,
),
];
for (text, width) in cases {
let ours = segments(text, WrapMode::WordOrGlyph, *width).len();
let reference = ratatui_reference_rows(text, *width);
assert_eq!(
ours, reference,
"row count mismatch for {text:?} at width {width}: fork={ours} \
ratatui(reference)={reference}"
);
if !text.ends_with(|c: char| c.is_whitespace()) {
// Buffer rendering cannot expose whitespace-only trailing rows.
let buffer_rows = ratatui_buffer_rows(text, *width);
assert_eq!(
reference, buffer_rows,
"reference diverges from real ratatui render for {text:?} at {width}"
);
}
}
}
/// Faithful port of ratatui's `WordWrapper::process_input` (trim: false).
/// Returns the number of wrapped lines ratatui would render.
fn ratatui_reference_rows(text: &str, width: usize) -> usize {
let width = width as u16;
let mut wrapped: Vec<Vec<String>> = Vec::new();
let mut line: Vec<String> = Vec::new();
let mut line_width = 0u16;
let mut pending_word: Vec<String> = Vec::new();
let mut word_width = 0u16;
let mut pending_ws: Vec<String> = Vec::new();
let mut ws_width = 0u16;
let mut non_ws_prev = true;
let symbols: Vec<(String, u16)> =
unicode_segmentation::UnicodeSegmentation::graphemes(text, true)
.map(|s| {
(
s.to_string(),
unicode_width::UnicodeWidthStr::width(s) as u16,
)
})
.collect();
for (symbol, symbol_width) in symbols {
let is_whitespace = symbol.trim().is_empty() && symbol_width > 0;
if symbol_width > width {
continue;
}
let word_found = non_ws_prev && is_whitespace;
let trimmed_overflow = line.is_empty() && word_width + symbol_width > width;
let whitespace_overflow = line.is_empty() && ws_width + symbol_width > width;
let untrimmed_overflow =
line.is_empty() && word_width + ws_width + symbol_width > width;
if word_found || trimmed_overflow || whitespace_overflow || untrimmed_overflow {
// Port is used with trim: false only, where ratatui always extends
// pending whitespace into the line (`!line.is_empty() || !trim`).
line.append(&mut pending_ws);
line_width += ws_width;
line.append(&mut pending_word);
line_width += word_width;
pending_ws.clear();
ws_width = 0;
word_width = 0;
}
let line_full = line_width >= width;
let pending_word_overflow =
symbol_width > 0 && line_width + ws_width + word_width >= width;
if line_full || pending_word_overflow {
let mut remaining = width.saturating_sub(line_width);
if !line.is_empty() {
wrapped.push(std::mem::take(&mut line));
}
line_width = 0;
while let Some(first) = pending_ws.first() {
let w = unicode_width::UnicodeWidthStr::width(first.as_str()) as u16;
if w > remaining {
break;
}
ws_width -= w;
remaining -= w;
pending_ws.remove(0);
}
if is_whitespace && pending_ws.is_empty() {
non_ws_prev = !is_whitespace;
continue;
}
}
if is_whitespace {
ws_width += symbol_width;
pending_ws.push(symbol);
} else {
word_width += symbol_width;
pending_word.push(symbol);
}
non_ws_prev = !is_whitespace;
}
// End of input: ratatui appends pending whitespace (and the pending word)
// to the line even when trim is false and the line is empty.
line.append(&mut pending_ws);
line.append(&mut pending_word);
if !line.is_empty() {
wrapped.push(line);
}
wrapped.len().max(1)
}
fn ratatui_buffer_rows(text: &str, width: usize) -> usize {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::text::Text;
use ratatui::widgets::{Paragraph, Wrap};
let backend = TestBackend::new(width as u16, 100);
let mut terminal = Terminal::new(backend).expect("terminal should initialize");
terminal
.draw(|f| {
f.render_widget(
Paragraph::new(Text::from(text.to_string())).wrap(Wrap { trim: false }),
f.area(),
);
})
.expect("draw should succeed");
let buffer = terminal.backend().buffer().clone();
let mut last_non_blank = None;
for y in 0..buffer.area.height {
let mut line = String::new();
for x in 0..buffer.area.width {
line.push_str(buffer[(x, y)].symbol());
}
if !line.trim().is_empty() {
last_non_blank = Some(y);
}
}
match last_non_blank {
Some(y) => y as usize + 1,
None => 1,
}
}
}