use ratatui::{style::Style, text::Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub(super) fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
let truncate_at = max_len.saturating_sub(3);
let end = s
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= truncate_at)
.last()
.unwrap_or(0);
format!("{}...", &s[..end])
}
}
pub(super) fn truncate_or_pad(s: &str, width: usize) -> String {
let char_count = s.chars().count();
if char_count > width {
s.chars().take(width.saturating_sub(3)).collect::<String>() + "..."
} else {
format!("{s:width$}")
}
}
pub(super) fn truncate_or_pad_spans(
spans: &[(Style, String)],
width: usize,
base_style: Style,
) -> Vec<Span<'static>> {
let total_width: usize = spans.iter().map(|(_, text)| text.width()).sum();
if total_width > width {
let mut result = Vec::new();
let mut remaining = width.saturating_sub(3);
for (style, text) in spans {
if remaining == 0 {
break;
}
let text_width = text.width();
if text_width <= remaining {
result.push(Span::styled(text.clone(), *style));
remaining -= text_width;
} else {
let mut truncated = String::new();
let mut current_width = 0;
for c in text.chars() {
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > remaining {
break;
}
truncated.push(c);
current_width += char_width;
}
if !truncated.is_empty() {
result.push(Span::styled(truncated, *style));
}
remaining = 0;
}
}
result.push(Span::styled("...".to_string(), base_style));
result
} else if total_width < width {
let mut result: Vec<Span> = spans
.iter()
.map(|(style, text)| Span::styled(text.clone(), *style))
.collect();
let padding = " ".repeat(width - total_width);
result.push(Span::styled(padding, base_style));
result
} else {
spans
.iter()
.map(|(style, text)| Span::styled(text.clone(), *style))
.collect()
}
}
pub(super) fn wrap_spans<'a>(spans: &[Span<'a>], width: usize) -> Vec<Vec<Span<'a>>> {
if width == 0 {
return vec![spans.to_vec()];
}
if spans.is_empty() || spans.iter().all(|s| s.content.is_empty()) {
return vec![vec![]];
}
let total: usize = spans.iter().map(|s| s.content.width()).sum();
if total <= width {
return vec![spans.to_vec()];
}
#[derive(Clone, Copy)]
struct Item {
ch: char,
style: Style,
w: usize,
}
fn flush_unit(
unit: &mut Vec<Item>,
unit_w: &mut usize,
current: &mut Vec<Item>,
current_w: &mut usize,
rows: &mut Vec<Vec<Item>>,
width: usize,
) {
if unit.is_empty() {
return;
}
if *current_w + *unit_w <= width {
current.append(unit);
*current_w += *unit_w;
*unit_w = 0;
return;
}
if *unit_w <= width {
rows.push(std::mem::take(current));
*current_w = *unit_w;
current.append(unit);
*unit_w = 0;
return;
}
let mut pos = 0;
while pos < unit.len() {
let remaining = width.saturating_sub(*current_w);
let mut consumed = 0usize;
let mut consumed_w = 0usize;
for it in &unit[pos..] {
if consumed_w + it.w > remaining {
break;
}
consumed_w += it.w;
consumed += 1;
}
if consumed == 0 {
if current.is_empty() {
current.push(unit[pos]);
pos += 1;
}
rows.push(std::mem::take(current));
*current_w = 0;
} else {
current.extend_from_slice(&unit[pos..pos + consumed]);
pos += consumed;
*current_w += consumed_w;
if pos < unit.len() {
rows.push(std::mem::take(current));
*current_w = 0;
}
}
}
unit.clear();
*unit_w = 0;
}
let mut rows: Vec<Vec<Item>> = Vec::new();
let mut current: Vec<Item> = Vec::new();
let mut current_w: usize = 0;
let mut word: Vec<Item> = Vec::new();
let mut word_w: usize = 0;
for span in spans {
for ch in span.content.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(if ch == '\t' { 1 } else { 0 });
let item = Item {
ch,
style: span.style,
w,
};
if ch == ' ' || ch == '\t' {
flush_unit(
&mut word,
&mut word_w,
&mut current,
&mut current_w,
&mut rows,
width,
);
let mut ws = vec![item];
let mut ws_w = w;
flush_unit(
&mut ws,
&mut ws_w,
&mut current,
&mut current_w,
&mut rows,
width,
);
} else {
word.push(item);
word_w += w;
}
}
}
flush_unit(
&mut word,
&mut word_w,
&mut current,
&mut current_w,
&mut rows,
width,
);
if !current.is_empty() || rows.is_empty() {
rows.push(current);
}
rows.into_iter()
.map(|row| {
let mut out: Vec<Span<'a>> = Vec::new();
let mut buf = String::new();
let mut cur_style: Option<Style> = None;
for it in row {
match cur_style {
Some(s) if s == it.style => buf.push(it.ch),
_ => {
if let Some(s) = cur_style {
out.push(Span::styled(std::mem::take(&mut buf), s));
}
cur_style = Some(it.style);
buf.push(it.ch);
}
}
}
if let Some(s) = cur_style {
out.push(Span::styled(buf, s));
}
out
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
fn flatten<'a>(row: &[Span<'a>]) -> Vec<(Style, String)> {
row.iter()
.map(|s| (s.style, s.content.to_string()))
.collect()
}
#[test]
fn should_return_string_unchanged_when_within_max_len() {
let s = "hello";
let result = truncate_str(s, 10);
assert_eq!(result, "hello");
}
#[test]
fn should_truncate_ascii_string_with_ellipsis() {
let s = "hello world this is long";
let result = truncate_str(s, 10);
assert_eq!(result, "hello w...");
}
#[test]
fn should_truncate_without_panicking_on_multibyte_chars() {
let s = "Resolve \"SD : Envoi en validation manuelle après 3 rejet de la fiche employé\"";
let result = truncate_str(s, 47);
assert!(result.ends_with("..."));
assert!(result.len() <= 47);
}
#[test]
fn should_handle_string_of_only_multibyte_chars() {
let s = "ééééééééé";
let result = truncate_str(s, 5);
assert!(result.ends_with("..."));
assert!(result.is_char_boundary(result.len()));
}
#[test]
fn should_pad_highlighted_spans_to_exact_width() {
let highlighter = crate::syntax::SyntaxHighlighter::default();
let lines = vec!["let x = 1;".to_string()];
let highlighted = highlighter
.highlight_file_lines(std::path::Path::new("test.rs"), &lines)
.unwrap();
let spans = highlighted[0].as_ref().unwrap();
let width = 80;
let result = truncate_or_pad_spans(spans, width, Style::default());
let total_chars: usize = result.iter().map(|s| s.content.chars().count()).sum();
assert_eq!(
total_chars, width,
"padded spans should have exactly {width} chars, got {total_chars}"
);
}
#[test]
fn should_return_single_row_when_input_fits() {
let spans = vec![Span::raw("hello")];
let rows = wrap_spans(&spans, 10);
assert_eq!(rows.len(), 1);
assert_eq!(flatten(&rows[0]), vec![(Style::default(), "hello".into())]);
}
#[test]
fn should_return_single_empty_row_for_empty_input() {
let spans: Vec<Span> = vec![];
let rows = wrap_spans(&spans, 10);
assert_eq!(rows.len(), 1);
assert!(rows[0].is_empty());
}
#[test]
fn should_return_single_empty_row_for_all_empty_span_contents() {
let red = Style::default().fg(Color::Red);
let spans = vec![Span::styled("", red), Span::raw("")];
let rows = wrap_spans(&spans, 10);
assert_eq!(rows.len(), 1);
assert!(rows[0].is_empty());
}
#[test]
fn should_return_input_unchanged_when_width_is_zero() {
let spans = vec![Span::raw("hello world")];
let rows = wrap_spans(&spans, 0);
assert_eq!(rows.len(), 1);
assert_eq!(
flatten(&rows[0]),
vec![(Style::default(), "hello world".into())]
);
}
#[test]
fn should_wrap_at_whitespace_word_boundary() {
let spans = vec![Span::raw("hello world foo")];
let rows = wrap_spans(&spans, 8);
assert_eq!(rows.len(), 3);
assert_eq!(flatten(&rows[0]), vec![(Style::default(), "hello ".into())]);
assert_eq!(flatten(&rows[1]), vec![(Style::default(), "world ".into())]);
assert_eq!(flatten(&rows[2]), vec![(Style::default(), "foo".into())]);
}
#[test]
fn should_hard_split_a_word_longer_than_width() {
let spans = vec![Span::raw("aaaaaaaaaa")];
let rows = wrap_spans(&spans, 4);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0][0].content, "aaaa");
assert_eq!(rows[1][0].content, "aaaa");
assert_eq!(rows[2][0].content, "aa");
}
#[test]
fn should_preserve_styles_across_wrap_boundary() {
let red = Style::default().fg(Color::Red);
let blue = Style::default().fg(Color::Blue);
let spans = vec![Span::styled("hello ", red), Span::styled("world", blue)];
let rows = wrap_spans(&spans, 6);
assert_eq!(rows.len(), 2);
assert_eq!(flatten(&rows[0]), vec![(red, "hello ".into())]);
assert_eq!(flatten(&rows[1]), vec![(blue, "world".into())]);
}
#[test]
fn should_split_a_span_that_crosses_a_wrap_point() {
let red = Style::default().fg(Color::Red);
let spans = vec![Span::styled("hello world", red)];
let rows = wrap_spans(&spans, 6);
assert_eq!(rows.len(), 2);
assert_eq!(flatten(&rows[0]), vec![(red, "hello ".into())]);
assert_eq!(flatten(&rows[1]), vec![(red, "world".into())]);
}
#[test]
fn should_merge_same_style_spans_into_one_when_wrapping() {
let red = Style::default().fg(Color::Red);
let spans = vec![Span::styled("he", red), Span::styled("llo", red)];
let rows = wrap_spans(&spans, 3);
assert_eq!(rows.len(), 2);
assert_eq!(flatten(&rows[0]), vec![(red, "hel".into())]);
assert_eq!(flatten(&rows[1]), vec![(red, "lo".into())]);
}
#[test]
fn should_wrap_cjk_chars_by_display_width() {
let spans = vec![Span::raw("中文測試")];
let rows = wrap_spans(&spans, 5);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0].content, "中文");
assert_eq!(rows[1][0].content, "測試");
}
#[test]
fn should_emit_oversized_char_alone_when_width_is_one_and_char_is_wide() {
let spans = vec![Span::raw("中")];
let rows = wrap_spans(&spans, 1);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0][0].content, "中");
}
#[test]
fn should_treat_tab_as_whitespace_break() {
let spans = vec![Span::raw("hello\tworld")];
let rows = wrap_spans(&spans, 6);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0].content, "hello\t");
assert_eq!(rows[1][0].content, "world");
}
#[test]
fn should_preserve_leading_whitespace_on_continuation_rows() {
let spans = vec![Span::raw("foo bar baz")];
let rows = wrap_spans(&spans, 7);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][0].content, "foo ");
assert_eq!(rows[1][0].content, "bar baz");
}
#[test]
fn should_handle_multibyte_utf8_char_boundaries_safely() {
let spans = vec![Span::raw("ab中文cd")];
let rows = wrap_spans(&spans, 4);
assert_eq!(rows.len(), 2);
for row in &rows {
let w: usize = row.iter().map(|s| s.content.width()).sum();
assert!(w <= 4, "row width {w} exceeds 4");
}
assert_eq!(rows[0][0].content, "ab中");
assert_eq!(rows[1][0].content, "文cd");
}
}