use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar;
use super::style_tokens::CODE_BG;
fn is_code_line(line: &Line<'_>) -> bool {
line.spans
.iter()
.any(|s| s.style.bg.is_some_and(|bg| bg == CODE_BG))
}
fn span_width(s: &Span<'_>) -> usize {
s.content
.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum()
}
fn spans_width(spans: &[Span<'_>]) -> usize {
spans.iter().map(|s| span_width(s)).sum()
}
fn split_structural_prefix<'a>(
spans: &[Span<'a>],
strip_indent: usize,
) -> (Vec<Span<'a>>, Vec<Span<'a>>, usize) {
if spans.is_empty() {
return (vec![], vec![], 0);
}
let first_text = spans[0].content.as_ref();
let trimmed = first_text.trim_start();
let is_bullet =
trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
let is_ordered = !is_bullet
&& trimmed.find(". ").is_some_and(|dot_pos| {
dot_pos > 0 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit())
});
if is_bullet || is_ordered {
let leading_ws = first_text.len() - trimmed.len();
let strip = leading_ws.min(strip_indent);
let stripped_text = &first_text[strip..];
let stripped_span = Span::styled(stripped_text.to_string(), spans[0].style);
let prefix_w: usize = stripped_text
.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum();
(vec![stripped_span], spans[1..].to_vec(), prefix_w)
} else {
(vec![], spans.to_vec(), 0)
}
}
pub fn wrap_spans_to_lines<'a>(
md_lines: Vec<Line<'a>>,
first_prefix: Vec<Span<'a>>,
cont_prefix: Vec<Span<'a>>,
max_width: usize,
) -> Vec<Line<'a>> {
if max_width == 0 {
return Vec::new();
}
let first_prefix_w = spans_width(&first_prefix);
let cont_prefix_w = spans_width(&cont_prefix);
let mut output: Vec<Line<'a>> = Vec::new();
let mut leading_consumed = false;
for md_line in md_lines {
let line_text: String = md_line.spans.iter().map(|s| s.content.as_ref()).collect();
let has_content = !line_text.trim().is_empty();
let (prefix, prefix_w) = if !leading_consumed && has_content {
leading_consumed = true;
(first_prefix.clone(), first_prefix_w)
} else {
(cont_prefix.clone(), cont_prefix_w)
};
if is_code_line(&md_line) {
let mut spans = prefix;
spans.extend(md_line.spans);
output.push(Line::from(spans));
continue;
}
if !has_content {
output.push(Line::from(prefix));
continue;
}
let (struct_prefix, content_spans, struct_prefix_w) =
split_structural_prefix(&md_line.spans, cont_prefix_w);
let content_avail = max_width.saturating_sub(prefix_w + struct_prefix_w).max(1);
let wrapped = if content_spans.is_empty() {
vec![vec![]]
} else {
wrap_spans(content_spans, content_avail)
};
for (i, chunk) in wrapped.into_iter().enumerate() {
let mut spans = if i == 0 {
let mut s = prefix.clone();
s.extend(struct_prefix.clone());
s
} else if struct_prefix_w > 0 {
let mut s = cont_prefix.clone();
s.push(Span::raw(" ".repeat(struct_prefix_w)));
s
} else {
cont_prefix.clone()
};
spans.extend(chunk);
output.push(Line::from(spans));
}
}
output
}
fn wrap_spans(spans: Vec<Span<'_>>, max_width: usize) -> Vec<Vec<Span<'_>>> {
if max_width == 0 {
return vec![spans];
}
let mut result: Vec<Vec<Span>> = Vec::new();
let mut current_line: Vec<Span> = Vec::new();
let mut line_width: usize = 0;
let tokens = tokenize_spans(&spans);
for (token, byte_offset) in tokens {
let token_w = token
.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum::<usize>();
let style = style_at_byte_offset(&spans, byte_offset);
if line_width + token_w <= max_width {
if let Some(last) = current_line.last_mut()
&& last.style == style
{
let mut s = last.content.to_string();
s.push_str(&token);
*last = Span::styled(s, last.style);
} else {
current_line.push(Span::styled(token, style));
}
line_width += token_w;
} else if token.trim().is_empty() {
if !current_line.is_empty() {
result.push(std::mem::take(&mut current_line));
}
line_width = 0;
} else if token_w > max_width {
if !current_line.is_empty() {
result.push(std::mem::take(&mut current_line));
line_width = 0;
}
let mut chunk = String::new();
let mut chunk_w = 0;
for c in token.chars() {
let cw = UnicodeWidthChar::width(c).unwrap_or(0);
if chunk_w + cw > max_width && !chunk.is_empty() {
current_line.push(Span::styled(std::mem::take(&mut chunk), style));
result.push(std::mem::take(&mut current_line));
chunk_w = 0;
}
chunk.push(c);
chunk_w += cw;
}
if !chunk.is_empty() {
current_line.push(Span::styled(chunk, style));
line_width = chunk_w;
}
} else {
if !current_line.is_empty() {
result.push(std::mem::take(&mut current_line));
}
current_line.push(Span::styled(token, style));
line_width = token_w;
}
}
if !current_line.is_empty() {
result.push(current_line);
}
if result.is_empty() {
result.push(Vec::new());
}
result
}
fn style_at_byte_offset(spans: &[Span<'_>], offset: usize) -> Style {
let mut span_start = 0usize;
for span in spans {
let span_end = span_start + span.content.len();
if offset < span_end {
return span.style;
}
span_start = span_end;
}
spans.last().map(|s| s.style).unwrap_or_default()
}
fn tokenize_spans(spans: &[Span<'_>]) -> Vec<(String, usize)> {
let full_text: String = spans.iter().map(|s| s.content.as_ref()).collect();
let mut tokens = Vec::new();
let mut current = String::new();
let mut current_start = 0usize;
let mut byte_pos = 0usize;
let mut in_space = false;
for c in full_text.chars() {
let is_space = c == ' ';
if is_space != in_space && !current.is_empty() {
tokens.push((std::mem::take(&mut current), current_start));
current_start = byte_pos;
}
current.push(c);
byte_pos += c.len_utf8();
in_space = is_space;
}
if !current.is_empty() {
tokens.push((current, current_start));
}
tokens
}
#[cfg(test)]
#[path = "wrap_tests.rs"]
mod tests;