зеркало из
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.
Этот коммит содержится в:
2
Cargo.lock
сгенерированный
2
Cargo.lock
сгенерированный
@@ -1871,7 +1871,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tui-textarea-2"
|
name = "tui-textarea-2"
|
||||||
version = "0.12.1"
|
version = "0.12.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
|
|||||||
@@ -225,18 +225,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn assert_round_trips(textarea: &TextArea<'_>) {
|
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() {
|
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() {
|
for col in 0..=line.chars().count() {
|
||||||
let dc = DataCursor(row, col);
|
let dc = DataCursor(row, col);
|
||||||
let sc = textarea.data_to_screen_cursor(dc);
|
let sc = textarea.data_to_screen_cursor(dc);
|
||||||
assert_eq!(textarea.screen_to_data_cursor(sc), dc);
|
assert_eq!(textarea.screen_to_data_cursor(sc), dc);
|
||||||
|
|
||||||
let detached = ScreenCursor { dc: None, ..sc };
|
let detached = ScreenCursor { dc: None, ..sc };
|
||||||
assert_eq!(textarea.screen_to_data_cursor(detached), dc);
|
let back = textarea.screen_to_data_cursor(detached);
|
||||||
assert_eq!(
|
let visible = rows_for_line.iter().any(|w| {
|
||||||
textarea.data_to_screen_cursor(textarea.screen_to_data_cursor(detached)),
|
let (lo, hi) = if w.last_in_row {
|
||||||
sc
|
(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
285
src/wrap.rs
285
src/wrap.rs
@@ -57,8 +57,12 @@ pub(crate) fn wrapped_rows(
|
|||||||
|
|
||||||
for (row, line) in lines.iter().enumerate() {
|
for (row, line) in lines.iter().enumerate() {
|
||||||
let ranges = line_ranges(line, mode, width, tab_len);
|
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() {
|
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();
|
let end_col = start_col + line[start_byte..end_byte].chars().count();
|
||||||
rows.push(WrappedLine {
|
rows.push(WrappedLine {
|
||||||
row,
|
row,
|
||||||
@@ -69,7 +73,6 @@ pub(crate) fn wrapped_rows(
|
|||||||
first_in_row: i == 0,
|
first_in_row: i == 0,
|
||||||
last_in_row: i + 1 == ranges.len(),
|
last_in_row: i + 1 == ranges.len(),
|
||||||
});
|
});
|
||||||
start_col = end_col;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +146,54 @@ fn wrap_word_chunks(
|
|||||||
|
|
||||||
if seg_end > seg_start {
|
if seg_end > seg_start {
|
||||||
out.push((seg_start, seg_end));
|
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_start = seg_end;
|
||||||
seg_width = 0;
|
seg_width = 0;
|
||||||
continue;
|
continue;
|
||||||
@@ -217,6 +268,29 @@ fn chunk_text(line: &str, chunk: Chunk) -> &str {
|
|||||||
&line[chunk.start..chunk.end]
|
&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 {
|
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)
|
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);
|
let have = segments("\tX", WrapMode::WordOrGlyph, 2);
|
||||||
assert_eq!(have, vec!["\t", "X"]);
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user