This reverts commit 2b7139859e.
This commit is contained in:
@@ -18,8 +18,6 @@ use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use textwrap::Options as TwOptions;
|
||||
use textwrap::WordSplitter;
|
||||
|
||||
/// Insert `lines` above the viewport.
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
@@ -42,10 +40,7 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
|
||||
let mut area = terminal.get_frame().area();
|
||||
|
||||
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
|
||||
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
|
||||
let wrapped = word_wrap_lines(&lines, area.width.max(1));
|
||||
let wrapped_lines = wrapped.len() as u16;
|
||||
let wrapped_lines = wrapped_line_count(&lines, area.width);
|
||||
let cursor_top = if area.bottom() < screen_size.height {
|
||||
// If the viewport is not at the bottom of the screen, scroll it down to make room.
|
||||
// Don't scroll it past the bottom of the screen.
|
||||
@@ -96,7 +91,7 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
|
||||
queue!(writer, MoveTo(0, cursor_top)).ok();
|
||||
|
||||
for line in wrapped {
|
||||
for line in lines {
|
||||
queue!(writer, Print("\r\n")).ok();
|
||||
write_spans(writer, line.iter()).ok();
|
||||
}
|
||||
@@ -109,6 +104,36 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
}
|
||||
}
|
||||
|
||||
fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
|
||||
let mut count = 0;
|
||||
for line in lines {
|
||||
count += line_height(line, width);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn line_height(line: &Line, width: u16) -> u16 {
|
||||
// Use the same visible-width slicing semantics as the live row builder so
|
||||
// our pre-scroll estimation matches how rows will actually wrap.
|
||||
let w = width.max(1) as usize;
|
||||
let mut rows = 0u16;
|
||||
let mut remaining = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
while !remaining.is_empty() {
|
||||
let (_prefix, suffix, taken) = crate::live_wrap::take_prefix_by_width(&remaining, w);
|
||||
rows = rows.saturating_add(1);
|
||||
if taken >= remaining.len() {
|
||||
break;
|
||||
}
|
||||
remaining = suffix.to_string();
|
||||
}
|
||||
rows.max(1)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetScrollRegion(pub std::ops::Range<u16>);
|
||||
|
||||
@@ -257,126 +282,6 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// Word-aware wrapping for a list of `Line`s preserving styles.
|
||||
pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec<Line<'static>> {
|
||||
let mut out = Vec::new();
|
||||
let w = width.max(1) as usize;
|
||||
for line in lines {
|
||||
out.extend(word_wrap_line(line, w));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn word_wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
|
||||
if width == 0 {
|
||||
return vec![to_owned_line(line)];
|
||||
}
|
||||
// Concatenate content and keep span boundaries for later re-slicing.
|
||||
let mut flat = String::new();
|
||||
let mut span_bounds = Vec::new(); // (start_byte, end_byte, style)
|
||||
let mut cursor = 0usize;
|
||||
for s in &line.spans {
|
||||
let text = s.content.as_ref();
|
||||
let start = cursor;
|
||||
flat.push_str(text);
|
||||
cursor += text.len();
|
||||
span_bounds.push((start, cursor, s.style));
|
||||
}
|
||||
|
||||
// Use textwrap for robust word-aware wrapping; no hyphenation, no breaking words.
|
||||
let opts = TwOptions::new(width)
|
||||
.break_words(false)
|
||||
.word_splitter(WordSplitter::NoHyphenation);
|
||||
let wrapped = textwrap::wrap(&flat, &opts);
|
||||
|
||||
if wrapped.len() <= 1 {
|
||||
return vec![to_owned_line(line)];
|
||||
}
|
||||
|
||||
// Map wrapped pieces back to byte ranges in `flat` sequentially.
|
||||
let mut start_cursor = 0usize;
|
||||
let mut out: Vec<Line<'static>> = Vec::with_capacity(wrapped.len());
|
||||
for piece in wrapped {
|
||||
let piece_str: &str = &piece;
|
||||
if piece_str.is_empty() {
|
||||
out.push(Line {
|
||||
style: line.style,
|
||||
alignment: line.alignment,
|
||||
spans: Vec::new(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Find the next occurrence of piece_str at or after start_cursor.
|
||||
// textwrap preserves order, so a linear scan is sufficient.
|
||||
if let Some(rel) = flat[start_cursor..].find(piece_str) {
|
||||
let s = start_cursor + rel;
|
||||
let e = s + piece_str.len();
|
||||
out.push(slice_line_spans(line, &span_bounds, s, e));
|
||||
start_cursor = e;
|
||||
} else {
|
||||
// Fallback: slice by length from cursor.
|
||||
let s = start_cursor;
|
||||
let e = (start_cursor + piece_str.len()).min(flat.len());
|
||||
out.push(slice_line_spans(line, &span_bounds, s, e));
|
||||
start_cursor = e;
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn to_owned_line(l: &Line<'_>) -> Line<'static> {
|
||||
Line {
|
||||
style: l.style,
|
||||
alignment: l.alignment,
|
||||
spans: l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| Span {
|
||||
style: s.style,
|
||||
content: std::borrow::Cow::Owned(s.content.to_string()),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn slice_line_spans(
|
||||
original: &Line<'_>,
|
||||
span_bounds: &[(usize, usize, ratatui::style::Style)],
|
||||
start_byte: usize,
|
||||
end_byte: usize,
|
||||
) -> Line<'static> {
|
||||
let mut acc: Vec<Span<'static>> = Vec::new();
|
||||
for (i, (s, e, style)) in span_bounds.iter().enumerate() {
|
||||
if *e <= start_byte {
|
||||
continue;
|
||||
}
|
||||
if *s >= end_byte {
|
||||
break;
|
||||
}
|
||||
let seg_start = start_byte.max(*s);
|
||||
let seg_end = end_byte.min(*e);
|
||||
if seg_end > seg_start {
|
||||
let local_start = seg_start - *s;
|
||||
let local_end = seg_end - *s;
|
||||
let content = original.spans[i].content.as_ref();
|
||||
let slice = &content[local_start..local_end];
|
||||
acc.push(Span {
|
||||
style: *style,
|
||||
content: std::borrow::Cow::Owned(slice.to_string()),
|
||||
});
|
||||
}
|
||||
if *e >= end_byte {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Line {
|
||||
style: original.style,
|
||||
alignment: original.alignment,
|
||||
spans: acc,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
@@ -413,34 +318,8 @@ mod tests {
|
||||
#[test]
|
||||
fn line_height_counts_double_width_emoji() {
|
||||
let line = Line::from("😀😀😀"); // each emoji ~ width 2
|
||||
assert_eq!(word_wrap_line(&line, 4).len(), 2);
|
||||
assert_eq!(word_wrap_line(&line, 2).len(), 3);
|
||||
assert_eq!(word_wrap_line(&line, 6).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_wrap_does_not_split_words_simple_english() {
|
||||
let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
|
||||
let line = Line::from(sample);
|
||||
// Force small width to exercise wrapping at spaces.
|
||||
let wrapped = word_wrap_lines(&[line], 40);
|
||||
let joined: String = wrapped
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!joined.contains("bo\nth"),
|
||||
"word 'both' should not be split across lines:\n{joined}"
|
||||
);
|
||||
assert!(
|
||||
!joined.contains("Willowm\nere"),
|
||||
"should not split inside words:\n{joined}"
|
||||
);
|
||||
assert_eq!(line_height(&line, 4), 2);
|
||||
assert_eq!(line_height(&line, 2), 3);
|
||||
assert_eq!(line_height(&line, 6), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user