use unicode_width::UnicodeWidthChar;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WrappedSpan {
pub content: String,
pub style: ratatui::style::Style,
pub width: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WrappedLine {
pub spans: Vec<WrappedSpan>,
pub width: u16,
}
impl WrappedLine {
pub fn to_ratatui_line(&self) -> ratatui::text::Line<'static> {
ratatui::text::Line::from(
self.spans
.iter()
.map(|ws| ratatui::text::Span::styled(ws.content.clone(), ws.style))
.collect::<Vec<_>>(),
)
}
}
pub fn wrap_spans(spans: &[ratatui::text::Span<'_>], max_width: u16) -> Vec<WrappedLine> {
if max_width == 0 {
let n = hard_line_count(spans).max(1);
return (0..n)
.map(|_| WrappedLine {
spans: vec![],
width: 0,
})
.collect();
}
let styled: Vec<(char, usize, ratatui::style::Style)> = spans
.iter()
.flat_map(|span| {
let style = span.style;
span.content.chars().map(move |ch| {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
(ch, w, style)
})
})
.collect();
if styled.is_empty() {
return vec![WrappedLine {
spans: vec![],
width: 0,
}];
}
let max = max_width as usize;
let mut result: Vec<WrappedLine> = Vec::new();
let mut line_start = 0usize;
loop {
let line_end = styled[line_start..]
.iter()
.position(|(ch, _, _)| *ch == '\n')
.map_or(styled.len(), |p| line_start + p);
emit_wrapped_hard_line(&styled[line_start..line_end], max, &mut result);
if line_end >= styled.len() {
break;
}
line_start = line_end + 1;
}
if result.is_empty() {
result.push(WrappedLine {
spans: vec![],
width: 0,
});
}
result
}
pub fn measure(spans: &[ratatui::text::Span<'_>]) -> u16 {
let total: usize = spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum();
crate::cast::u16_sat(total)
}
fn hard_line_count(spans: &[ratatui::text::Span<'_>]) -> usize {
spans
.iter()
.flat_map(|s| s.content.chars())
.filter(|&ch| ch == '\n')
.count()
+ 1
}
fn emit_wrapped_hard_line(
chars: &[(char, usize, ratatui::style::Style)],
max_width: usize,
out: &mut Vec<WrappedLine>,
) {
let mut words: Vec<&[(char, usize, ratatui::style::Style)]> = Vec::new();
let mut word_start: Option<usize> = None;
for (i, (ch, _, _)) in chars.iter().enumerate() {
if ch.is_whitespace() {
if let Some(start) = word_start.take() {
words.push(&chars[start..i]);
}
} else if word_start.is_none() {
word_start = Some(i);
}
}
if let Some(start) = word_start {
words.push(&chars[start..]);
}
if words.is_empty() {
out.push(WrappedLine {
spans: vec![],
width: 0,
});
return;
}
let mut row_buf: Vec<(char, ratatui::style::Style)> = Vec::new();
let mut row_w = 0usize;
for word in &words {
let word_w: usize = word.iter().map(|(_, w, _)| w).sum();
if word_w <= max_width {
if row_w > 0 && row_w + 1 + word_w > max_width {
out.push(pack_row(&row_buf));
row_buf.clear();
row_w = 0;
}
if row_w > 0 {
let space_style = word.first().map(|(_, _, s)| *s).unwrap_or_default();
row_buf.push((' ', space_style));
row_w += 1;
}
for &(ch, cw, style) in *word {
row_buf.push((ch, style));
row_w += cw;
}
} else {
if row_w > 0 {
out.push(pack_row(&row_buf));
row_buf.clear();
}
let mut chunk_w: usize = 0;
for &(ch, cw, style) in *word {
if chunk_w + cw > max_width {
out.push(pack_row(&row_buf));
row_buf.clear();
chunk_w = 0;
}
row_buf.push((ch, style));
chunk_w += cw;
}
row_w = chunk_w;
}
}
if !row_buf.is_empty() {
out.push(pack_row(&row_buf));
}
}
fn pack_row(pairs: &[(char, ratatui::style::Style)]) -> WrappedLine {
let mut spans: Vec<WrappedSpan> = Vec::new();
let mut row_width: u16 = 0;
for &(ch, style) in pairs {
let ch_w = crate::cast::u16_sat(UnicodeWidthChar::width(ch).unwrap_or(0));
row_width = row_width.saturating_add(ch_w);
if let Some(last) = spans.last_mut()
&& last.style == style
{
last.content.push(ch);
last.width = last.width.saturating_add(ch_w);
} else {
spans.push(WrappedSpan {
content: ch.to_string(),
style,
width: ch_w,
});
}
}
WrappedLine {
spans,
width: row_width,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{
style::{Modifier, Style},
text::Span,
};
fn raw(s: impl Into<String>) -> Span<'static> {
Span::raw(s.into())
}
fn styled(s: impl Into<String>, style: Style) -> Span<'static> {
Span::styled(s.into(), style)
}
fn bold() -> Style {
Style::default().add_modifier(Modifier::BOLD)
}
fn italic() -> Style {
Style::default().add_modifier(Modifier::ITALIC)
}
fn assert_invariants(rows: &[WrappedLine], max_width: u16) {
for (i, row) in rows.iter().enumerate() {
let recomputed: u16 = row.spans.iter().map(|s| s.width).sum();
assert_eq!(
recomputed,
row.width,
"row {i}: cached width {w} != recomputed {recomputed}",
w = row.width
);
if max_width > 0 {
assert!(
row.width <= max_width,
"row {i} width {w} exceeds max_width {max_width}",
w = row.width
);
}
}
}
fn at_widths<F>(widths: &[u16], mut f: F)
where
F: FnMut(u16) -> Vec<WrappedLine>,
{
for &w in widths {
let rows = f(w);
assert!(!rows.is_empty(), "wrap_spans must never return empty vec");
assert_invariants(&rows, w);
}
}
fn flatten_soft_wrapped(rows: &[WrappedLine]) -> Vec<Span<'static>> {
let mut out: Vec<Span<'static>> = Vec::new();
for (i, row) in rows.iter().enumerate() {
if i > 0 {
out.push(raw(" "));
}
for ws in &row.spans {
out.push(Span::styled(ws.content.clone(), ws.style));
}
}
out
}
#[test]
fn empty_input_yields_one_empty_row() {
let rows = wrap_spans(&[], 80);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].spans, vec![]);
assert_eq!(rows[0].width, 0);
}
#[test]
fn single_short_word_one_row() {
let rows = wrap_spans(&[raw("hello")], 80);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].width, 5);
}
#[test]
fn oversize_word_hard_split() {
let widths = [20u16, 40, 60, 80, 120, 200];
at_widths(&widths, |w| {
let word = "a".repeat(300);
wrap_spans(&[raw(&word)], w)
});
let rows = wrap_spans(&[raw("abcdefghij")], 4);
assert!(rows.len() >= 2, "long word must hard-split: {rows:?}");
for row in &rows {
assert!(row.width <= 4, "row too wide: {}", row.width);
}
let total: String = rows
.iter()
.flat_map(|r| r.spans.iter())
.map(|s| s.content.as_str())
.collect();
assert_eq!(total, "abcdefghij");
}
#[test]
fn two_words_fit_exactly_one_row() {
let rows = wrap_spans(&[raw("ab cd")], 5);
assert_eq!(rows.len(), 1, "should be one row: {rows:?}");
assert_eq!(rows[0].width, 5);
}
#[test]
fn second_word_forces_wrap() {
let widths = [20u16, 40, 60, 80, 120, 200];
at_widths(&widths, |w| {
let first = "a".repeat(w as usize);
let second = "b".repeat(w as usize);
let input = format!("{first} {second}");
wrap_spans(&[raw(&input)], w)
});
let rows = wrap_spans(&[raw("hello world")], 8);
assert_eq!(rows.len(), 2, "second word must wrap: {rows:?}");
let second_row_text: String = rows[1].spans.iter().map(|s| s.content.as_str()).collect();
assert!(
!second_row_text.starts_with(' '),
"leading space must be consumed"
);
}
#[test]
fn hard_newline_mid_span_forces_break() {
let b = bold();
let spans = [styled("first\nsecond", b)];
let rows = wrap_spans(&spans, 80);
assert_eq!(rows.len(), 2, "\\n must split into two rows: {rows:?}");
for row in &rows {
for ws in &row.spans {
assert_eq!(ws.style, b, "style must be preserved across newline");
}
}
let first_text: String = rows[0].spans.iter().map(|s| s.content.as_str()).collect();
let second_text: String = rows[1].spans.iter().map(|s| s.content.as_str()).collect();
assert_eq!(first_text, "first");
assert_eq!(second_text, "second");
}
#[test]
fn mixed_styles_within_word_preserved_on_split() {
let widths = [20u16, 40, 60, 80, 120, 200];
at_widths(&widths, |w| {
let spans = [styled("AAAA", bold()), styled("BBBB", italic())];
wrap_spans(&spans, w)
});
let spans = [styled("AAA", bold()), styled("BBB", italic())];
let rows = wrap_spans(&spans, 3);
for row in &rows {
for ws in &row.spans {
assert!(
ws.style == bold() || ws.style == italic(),
"unexpected style on row: {:?}",
ws.style
);
}
}
}
#[test]
fn wide_cjk_no_row_exceeds_width() {
let widths = [20u16, 40, 60, 80, 120, 200];
at_widths(&widths, |w| {
let s: String = "你好世界".repeat(20);
wrap_spans(&[raw(&s)], w)
});
}
#[test]
fn combining_marks_stay_glued() {
let composed = "e\u{0301}"; let input: String = composed.repeat(50);
let rows = wrap_spans(&[raw(&input)], 10);
for row in &rows {
let text: String = row.spans.iter().map(|s| s.content.as_str()).collect();
let first_char = text.chars().next().unwrap_or('x');
let first_w = UnicodeWidthChar::width(first_char).unwrap_or(1);
assert!(
first_w > 0,
"row must not start with a combining mark: {:?}",
text
);
}
}
#[test]
fn max_width_zero_one_row_per_hard_line_width_zero() {
let rows = wrap_spans(&[raw("hello\nworld")], 0);
assert_eq!(rows.len(), 2, "one row per hard line: {rows:?}");
for row in &rows {
assert_eq!(row.width, 0);
assert!(row.spans.is_empty());
}
let rows = wrap_spans(&[raw("hello")], 0);
assert_eq!(rows.len(), 1);
}
#[test]
fn soft_wrap_idempotence() {
let input = [raw("the quick brown fox jumps over the lazy dog")];
debug_assert!(
!input.iter().any(|s| s.content.as_ref().contains('\n')),
"soft_wrap_idempotence requires newline-free input",
);
for &w in &[20u16, 40, 80] {
let first = wrap_spans(&input, w);
let flat = flatten_soft_wrapped(&first);
let second = wrap_spans(&flat, w);
assert_eq!(
first.len(),
second.len(),
"width {w}: row count changed after re-wrap (first={}, second={})",
first.len(),
second.len()
);
for (i, (r1, r2)) in first.iter().zip(second.iter()).enumerate() {
assert_eq!(
r1.width, r2.width,
"width {w} row {i}: width changed (first={}, second={})",
r1.width, r2.width
);
}
}
}
#[test]
fn hard_newline_consumed_never_appears_in_output() {
let rows = wrap_spans(&[raw("alpha\nbeta\ngamma")], 80);
assert_eq!(rows.len(), 3);
for row in &rows {
for ws in &row.spans {
assert!(
!ws.content.contains('\n'),
"hard newline leaked into output: {ws:?}",
);
}
}
}
#[test]
fn measure_round_trip() {
let spans = [raw("hello world")];
let m = measure(&spans);
let rows = wrap_spans(&spans, u16::MAX);
assert_eq!(
rows.len(),
1,
"single-line input at max width should produce exactly one row"
);
assert_eq!(
m, rows[0].width,
"measure() must equal wrap_spans(..., u16::MAX)[0].width"
);
}
#[test]
fn width_sweep_long_word() {
at_widths(&[20, 40, 60, 80, 120, 200], |w| {
let s = "superlongwordwithnobreaks".repeat(8);
wrap_spans(&[raw(&s)], w)
});
}
}