use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub(crate) fn hard_break_plain_token(
token: &str,
out: &mut Vec<String>,
current_line: &mut String,
current_length: &mut usize,
width: usize,
continuation_indent: usize,
initial_budget: usize,
) {
let cont_budget = width.saturating_sub(continuation_indent).max(1);
let mut line_budget = initial_budget.max(1);
if *current_length > 0 {
out.push(std::mem::take(current_line));
current_line.push_str(&" ".repeat(continuation_indent));
*current_length = 0;
line_budget = cont_budget;
}
for ch in token.chars() {
let cw = ch.width().unwrap_or(0);
if *current_length + cw > line_budget && *current_length > 0 {
out.push(std::mem::take(current_line));
current_line.push_str(&" ".repeat(continuation_indent));
*current_length = 0;
line_budget = cont_budget;
}
current_line.push(ch);
*current_length += cw;
}
}
pub(crate) fn wrap_text_with_indent(
text: &str,
width: usize,
first_line_indent: usize,
continuation_indent: usize,
) -> Vec<String> {
let mut wrapped_lines = Vec::new();
for (line_idx, line) in text.lines().enumerate() {
if line.is_empty() {
wrapped_lines.push(String::new());
continue;
}
let current_indent = if line_idx == 0 {
first_line_indent
} else {
continuation_indent
};
let available_width = width.saturating_sub(current_indent);
if available_width == 0 {
wrapped_lines.push(" ".repeat(current_indent));
continue;
}
let words: Vec<&str> = line.split_whitespace().collect();
if words.is_empty() {
wrapped_lines.push(" ".repeat(current_indent));
continue;
}
let mut current_line = String::with_capacity(width);
current_line.push_str(&" ".repeat(current_indent));
let mut current_length = 0;
for (word_idx, word) in words.iter().enumerate() {
let word_width = word.width();
if word_idx == 0 {
if word_width <= available_width {
current_line.push_str(word);
current_length = word_width;
} else {
hard_break_plain_token(
word,
&mut wrapped_lines,
&mut current_line,
&mut current_length,
width,
continuation_indent,
available_width,
);
}
} else if current_length + 1 + word_width <= available_width {
current_line.push(' ');
current_line.push_str(word);
current_length += 1 + word_width;
} else if word_width <= available_width {
wrapped_lines.push(current_line);
current_line = String::with_capacity(width);
current_line.push_str(&" ".repeat(continuation_indent));
current_line.push_str(word);
current_length = word_width;
} else {
hard_break_plain_token(
word,
&mut wrapped_lines,
&mut current_line,
&mut current_length,
width,
continuation_indent,
available_width,
);
}
}
if !current_line.trim().is_empty() {
wrapped_lines.push(current_line);
}
}
wrapped_lines
}
pub(crate) fn hard_break_styled_word(
fragments: &[(String, Style)],
result_lines: &mut Vec<Line<'static>>,
current_line_spans: &mut Vec<Span<'static>>,
current_line_width: &mut usize,
continuation_indent: usize,
continuation_capacity: usize,
mut line_capacity: usize,
) {
for (text, style) in fragments {
let mut buf = String::new();
for ch in text.chars() {
let cw = ch.width().unwrap_or(0);
if *current_line_width + cw > line_capacity && *current_line_width > 0 {
if !buf.is_empty() {
current_line_spans.push(Span::styled(std::mem::take(&mut buf), *style));
}
result_lines.push(Line::from(std::mem::take(current_line_spans)));
current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
*current_line_width = 0;
line_capacity = continuation_capacity.max(1);
}
buf.push(ch);
*current_line_width += cw;
}
if !buf.is_empty() {
current_line_spans.push(Span::styled(buf, *style));
}
}
}
#[expect(
clippy::too_many_lines,
reason = "predates the lint; see .github/baselines/expect_budget.txt"
)]
pub(crate) fn wrap_styled_line(
line: Line<'static>,
width: usize,
continuation_indent: usize,
) -> Vec<Line<'static>> {
let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
if total_width <= width {
return vec![line];
}
let mut result_lines = Vec::new();
let mut current_line_spans: Vec<Span<'static>> = Vec::new();
let mut current_line_width = 0usize;
let available_width = width.saturating_sub(continuation_indent);
let leading_indent: usize = {
let mut n = 0;
for span in &line.spans {
let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
n += spaces;
if spaces < span.content.len() {
break; }
}
n
};
struct Word {
fragments: Vec<(String, Style)>,
separator: Style,
}
let mut words: Vec<Word> = Vec::new();
let mut current_word: Vec<(String, Style)> = Vec::new();
let mut separator = Style::default();
for span in &line.spans {
let mut frag = String::new();
for ch in span.content.chars() {
if ch.is_whitespace() {
if !frag.is_empty() {
current_word.push((std::mem::take(&mut frag), span.style));
}
if !current_word.is_empty() {
words.push(Word {
fragments: std::mem::take(&mut current_word),
separator,
});
}
separator = span.style;
} else {
frag.push(ch);
}
}
if !frag.is_empty() {
current_word.push((frag, span.style));
}
}
if !current_word.is_empty() {
words.push(Word {
fragments: current_word,
separator,
});
}
fn emit_word(spans: &mut Vec<Span<'static>>, word: Vec<(String, Style)>) {
for (text, style) in word {
spans.push(Span::styled(text, style));
}
}
for Word {
fragments: word,
separator,
} in words
{
let word_width: usize = word.iter().map(|(text, _)| text.width()).sum();
if current_line_width == 0 && result_lines.is_empty() {
if leading_indent > 0 {
current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
current_line_width += leading_indent;
}
if word_width <= available_width {
current_line_width += word_width;
emit_word(&mut current_line_spans, word);
} else {
hard_break_styled_word(
&word,
&mut result_lines,
&mut current_line_spans,
&mut current_line_width,
continuation_indent,
available_width,
width,
);
}
continue;
}
let sep = usize::from(current_line_width > 0);
if current_line_width + sep + word_width <= available_width {
if sep == 1 {
current_line_spans.push(Span::styled(" ", separator));
}
current_line_width += sep + word_width;
emit_word(&mut current_line_spans, word);
} else if word_width <= available_width {
result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
current_line_width = word_width;
emit_word(&mut current_line_spans, word);
} else {
result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
current_line_width = 0;
hard_break_styled_word(
&word,
&mut result_lines,
&mut current_line_spans,
&mut current_line_width,
continuation_indent,
available_width,
available_width,
);
}
}
if !current_line_spans.is_empty() {
result_lines.push(Line::from(current_line_spans));
}
if result_lines.is_empty() {
vec![line]
} else {
result_lines
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_styled_line_uses_display_width_for_cjk() {
let line = Line::from(Span::raw("你好世界".to_string()));
let wrapped = wrap_styled_line(line, 10, 2);
assert_eq!(
wrapped.len(),
1,
"CJK input fitting in display-width should NOT be wrapped; got {} lines",
wrapped.len()
);
}
#[test]
fn wrap_styled_line_ascii_wraps_when_too_long() {
let line = Line::from(Span::raw(
"the quick brown fox jumps over the lazy dog".to_string(),
));
let wrapped = wrap_styled_line(line, 15, 2);
assert!(
wrapped.len() >= 2,
"long ASCII input should wrap to multiple lines; got {}",
wrapped.len()
);
}
fn first_segment_text(wrapped: &[Line<'static>]) -> String {
wrapped[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
#[test]
fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
let line = Line::from(vec![
Span::raw(" "), Span::raw(
"No source files, no config, no docs, no build system and more words to wrap"
.to_string(),
),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "should wrap");
let first = first_segment_text(&wrapped);
assert!(
first.starts_with(" ") && first.trim_start().starts_with("No source"),
"first wrapped segment must keep the 2-space gutter; got {first:?}"
);
}
#[test]
fn wrap_styled_line_keeps_inline_code_background_across_its_spaces() {
let code = Style::default().bg(ratatui::style::Color::Rgb(40, 40, 40));
let line = Line::from(vec![
Span::raw("read_image_bytes bails with ".to_string()),
Span::styled("No image data found in clipboard".to_string(), code),
Span::raw(" and the effect routes it onward".to_string()),
]);
let wrapped = wrap_styled_line(line, 40, 2);
assert!(wrapped.len() >= 2, "should wrap");
let spans: Vec<_> = wrapped.iter().flat_map(|l| l.spans.iter()).collect();
let interior_gaps = spans
.windows(3)
.filter(|w| {
w[1].content.as_ref() == " " && w[0].style.bg.is_some() && w[2].style.bg.is_some()
})
.count();
assert!(
interior_gaps >= 3,
"the 5-word code span should keep its background on interior gaps; got \
{interior_gaps} in {:?}",
spans
.iter()
.map(|s| (s.content.as_ref(), s.style.bg))
.collect::<Vec<_>>()
);
assert!(
spans.windows(2).all(|w| {
!(w[0].content.as_ref() == " "
&& w[0].style.bg.is_some()
&& w[1].style.bg.is_none())
}),
"no highlighted space may leak onto the plain prose that follows"
);
}
#[test]
fn wrap_styled_line_hangs_list_continuation_under_marker() {
let line = Line::from(vec![
Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
]);
let wrapped = wrap_styled_line(line, 24, 6);
assert!(wrapped.len() >= 2, "should wrap");
assert!(
first_segment_text(&wrapped).starts_with(" • "),
"first segment keeps gutter + nesting + marker"
);
for cont in &wrapped[1..] {
let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
"continuation hangs under the item text at col 6; got {t:?}"
);
}
}
#[test]
fn wrap_styled_line_keeps_bullet_at_column_zero() {
let line = Line::from(vec![
Span::raw("● "),
Span::raw(
"a fairly long first line of a message that definitely needs to wrap".to_string(),
),
]);
let wrapped = wrap_styled_line(line, 25, 2);
assert!(wrapped.len() >= 2, "should wrap");
assert!(
first_segment_text(&wrapped).starts_with('●'),
"bullet must stay at column 0"
);
}
#[test]
fn wrap_text_with_indent_uses_display_width_for_cjk() {
let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
assert_eq!(
wrapped.len(),
1,
"CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
wrapped.len(),
wrapped
);
assert_eq!(wrapped[0].trim_start(), "你好世界");
}
#[test]
fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
assert!(
wrapped.len() >= 2,
"mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
wrapped.len(),
wrapped
);
}
#[test]
fn wrap_text_with_indent_hard_breaks_overlong_token() {
let token = "x".repeat(100);
let width = 20;
let wrapped = wrap_text_with_indent(&token, width, 2, 2);
assert!(
wrapped.len() >= 5,
"a 100-cell token at width 20 must span many rows; got {}",
wrapped.len()
);
for line in &wrapped {
assert!(
line.chars().count() <= width,
"no wrapped row may exceed the width; got {:?} ({} cells)",
line,
line.chars().count()
);
}
let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
assert_eq!(
joined, token,
"hard-break must preserve the token's content"
);
}
#[test]
fn wrap_styled_line_hard_breaks_overlong_token() {
let token = "y".repeat(90);
let style = Style::new().fg(ratatui::style::Color::Red);
let line = Line::from(vec![Span::raw(" "), Span::styled(token.clone(), style)]);
let width = 24;
let wrapped = wrap_styled_line(line, width, 2);
assert!(
wrapped.len() >= 4,
"must hard-break across rows; got {}",
wrapped.len()
);
let mut reconstructed = String::new();
for l in &wrapped {
let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
assert!(
row_cells <= width,
"row exceeds width: {row_cells} > {width}"
);
for s in &l.spans {
if s.content.trim().is_empty() {
continue;
}
assert_eq!(
s.style.fg,
Some(ratatui::style::Color::Red),
"hard-break must preserve the span style"
);
reconstructed.push_str(s.content.as_ref());
}
}
assert_eq!(reconstructed, token, "hard-break must preserve the token");
}
#[test]
fn wrap_styled_line_separator_before_styled_span_is_unstyled() {
let underlined = Style::new().add_modifier(ratatui::style::Modifier::UNDERLINED);
let line = Line::from(vec![
Span::raw(" "),
Span::raw("some filler words long enough to force a wrap here "),
Span::styled("underlined-link-text", underlined),
Span::raw(" and a bit more trailing filler after the link"),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "fixture must actually wrap");
for l in &wrapped {
for s in &l.spans {
if s.content.chars().all(|c| c == ' ') {
assert_eq!(
s.style,
Style::default(),
"whitespace span {:?} must be unstyled",
s.content
);
}
}
}
}
#[test]
fn wrap_styled_line_no_phantom_space_at_span_boundary() {
let dim = Style::new().fg(ratatui::style::Color::DarkGray);
let line = Line::from(vec![
Span::raw(" "),
Span::raw("filler text that pushes the line well past the width limit "),
Span::styled("(https://example.com)".to_string(), dim),
Span::raw("."),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "fixture must actually wrap");
let text: String = wrapped
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
text.contains("(https://example.com)."),
"period must stay glued to the URL suffix; got {text:?}"
);
assert!(
!text.contains("(https://example.com) ."),
"no phantom space before the period; got {text:?}"
);
}
#[test]
fn wrap_styled_line_keeps_mid_word_style_change_glued() {
let bold = Style::new().add_modifier(ratatui::style::Modifier::BOLD);
let line = Line::from(vec![
Span::raw(" "),
Span::raw("leading filler words to force wrapping "),
Span::styled("bold", bold),
Span::raw("suffix"),
Span::raw(" trailing filler words to force more wrapping"),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "fixture must actually wrap");
let rows: Vec<String> = wrapped
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(
rows.iter().filter(|r| r.contains("boldsuffix")).count(),
1,
"glued token must land whole on exactly one row; rows: {rows:?}"
);
for l in &wrapped {
for s in &l.spans {
if s.content.as_ref() == "bold" {
assert_eq!(s.style, bold, "bold fragment keeps its modifier");
}
if s.content.as_ref() == "suffix" {
assert_eq!(s.style, Style::default(), "suffix fragment stays plain");
}
}
}
}
#[test]
fn wrap_styled_line_hard_breaks_multi_fragment_token_preserving_styles() {
let red = Style::new().fg(ratatui::style::Color::Red);
let blue = Style::new().fg(ratatui::style::Color::Blue);
let line = Line::from(vec![
Span::raw(" "),
Span::styled("a".repeat(40), red),
Span::styled("b".repeat(40), blue),
]);
let width = 24;
let wrapped = wrap_styled_line(line, width, 2);
assert!(
wrapped.len() >= 4,
"80-cell token at width 24 must span >= 4 rows; got {}",
wrapped.len()
);
let mut reconstructed = String::new();
for l in &wrapped {
let row_cells: usize = l.spans.iter().map(|s| s.content.width()).sum();
assert!(
row_cells <= width,
"row exceeds width: {row_cells} > {width}"
);
for s in &l.spans {
if s.content.trim().is_empty() {
continue;
}
let expected = if s.content.contains('a') { red } else { blue };
assert!(
!(s.content.contains('a') && s.content.contains('b')),
"fragments must not merge across the style boundary"
);
assert_eq!(s.style, expected, "fragment style preserved across break");
reconstructed.push_str(s.content.as_ref());
}
}
assert_eq!(
reconstructed,
format!("{}{}", "a".repeat(40), "b".repeat(40)),
"hard-break must preserve the whole glued token"
);
}
#[test]
fn wrap_styled_line_whitespace_only_span_is_word_boundary() {
let line = Line::from(vec![
Span::raw(" "),
Span::raw("filler words that push this line past the wrap width "),
Span::raw("foo"),
Span::raw(" "),
Span::raw("bar"),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "fixture must actually wrap");
let text: String = wrapped
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
text.contains("foo bar") || text.contains("foo\n bar"),
"whitespace-only span must keep the words apart; got {text:?}"
);
assert!(
!text.contains("foobar"),
"words must not glue; got {text:?}"
);
}
}