use crate::core::style::Style;
use crate::core::text::{Line, Span};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyledGrapheme {
pub grapheme: String,
pub style: Style,
}
impl StyledGrapheme {
pub fn width(&self) -> usize {
self.grapheme.width()
}
}
fn line_to_graphemes(line: &Line, base_style: Style) -> Vec<StyledGrapheme> {
let mut out = Vec::new();
for span in &line.spans {
let style = base_style.patch(span.style);
for g in UnicodeSegmentation::graphemes(span.content.as_ref(), true) {
out.push(StyledGrapheme {
grapheme: g.to_string(),
style,
});
}
}
out
}
fn graphemes_to_line(graphemes: &[StyledGrapheme]) -> Line {
if graphemes.is_empty() {
return Line::default();
}
let mut spans = Vec::new();
let mut current_text = String::new();
let mut current_style = graphemes[0].style;
for sg in graphemes {
if sg.style == current_style {
current_text.push_str(&sg.grapheme);
} else {
if !current_text.is_empty() {
spans.push(Span::styled(current_text.clone(), current_style));
current_text.clear();
}
current_style = sg.style;
current_text.push_str(&sg.grapheme);
}
}
if !current_text.is_empty() {
spans.push(Span::styled(current_text, current_style));
}
Line::from(spans)
}
pub struct WordWrapper;
impl WordWrapper {
pub fn wrap(lines: &[Line], max_width: u16, base_style: Style) -> Vec<Line> {
let max = max_width as usize;
if max == 0 {
return vec![];
}
let mut result = Vec::new();
for line in lines {
let graphemes = line_to_graphemes(line, base_style);
if graphemes.is_empty() {
result.push(Line::default());
continue;
}
let mut current_line: Vec<StyledGrapheme> = Vec::new();
let mut current_width: usize = 0;
let mut word: Vec<StyledGrapheme> = Vec::new();
let mut word_width: usize = 0;
for sg in &graphemes {
let gw = sg.width();
let is_space = sg.grapheme.trim().is_empty();
if is_space {
if word_width + current_width <= max {
current_line.append(&mut word);
current_width += word_width;
word.clear();
word_width = 0;
} else {
if !current_line.is_empty() {
result.push(graphemes_to_line(¤t_line));
current_line.clear();
current_width = 0;
}
if word_width > max {
Self::char_break_word(&mut result, &word, max);
word.clear();
word_width = 0;
continue;
} else {
current_line.append(&mut word);
current_width = word_width;
word.clear();
word_width = 0;
}
}
if current_width + gw <= max {
current_line.push(sg.clone());
current_width += gw;
} else {
result.push(graphemes_to_line(¤t_line));
current_line.clear();
current_width = 0;
}
} else {
word.push(sg.clone());
word_width += gw;
}
}
if !word.is_empty() {
if word_width + current_width <= max {
current_line.append(&mut word);
} else {
if !current_line.is_empty() {
result.push(graphemes_to_line(¤t_line));
current_line.clear();
}
if word_width > max {
Self::char_break_word(&mut result, &word, max);
word.clear();
} else {
current_line = std::mem::take(&mut word);
}
}
}
if !current_line.is_empty() {
result.push(graphemes_to_line(¤t_line));
} else if result.is_empty() || word.is_empty() {
if graphemes.iter().all(|g| g.grapheme.trim().is_empty()) {
result.push(Line::default());
}
}
}
result
}
fn char_break_word(result: &mut Vec<Line>, word: &[StyledGrapheme], max: usize) {
let mut current: Vec<StyledGrapheme> = Vec::new();
let mut w = 0usize;
for sg in word {
let gw = sg.width();
if w + gw > max && !current.is_empty() {
result.push(graphemes_to_line(¤t));
current.clear();
w = 0;
}
current.push(sg.clone());
w += gw;
}
if !current.is_empty() {
result.push(graphemes_to_line(¤t));
}
}
}
pub struct CharWrapper;
impl CharWrapper {
pub fn wrap(lines: &[Line], max_width: u16, base_style: Style) -> Vec<Line> {
let max = max_width as usize;
if max == 0 {
return vec![];
}
let mut result = Vec::new();
for line in lines {
let graphemes = line_to_graphemes(line, base_style);
if graphemes.is_empty() {
result.push(Line::default());
continue;
}
let mut current: Vec<StyledGrapheme> = Vec::new();
let mut w = 0usize;
for sg in &graphemes {
let gw = sg.width();
if w + gw > max && !current.is_empty() {
result.push(graphemes_to_line(¤t));
current.clear();
w = 0;
}
current.push(sg.clone());
w += gw;
}
if !current.is_empty() {
result.push(graphemes_to_line(¤t));
}
}
result
}
}
pub struct LineTruncator;
impl LineTruncator {
pub fn truncate(lines: &[Line], max_width: u16, base_style: Style) -> Vec<Line> {
let max = max_width as usize;
let mut result = Vec::with_capacity(lines.len());
for line in lines {
let graphemes = line_to_graphemes(line, base_style);
let mut kept: Vec<StyledGrapheme> = Vec::new();
let mut w = 0usize;
for sg in &graphemes {
let gw = sg.width();
if w + gw > max {
break;
}
kept.push(sg.clone());
w += gw;
}
result.push(graphemes_to_line(&kept));
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::style::Color;
fn plain_line(s: &str) -> Line {
Line::raw(s.to_string())
}
fn line_text(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn word_wrap_simple() {
let lines = vec![plain_line("hello world foo")];
let wrapped = WordWrapper::wrap(&lines, 11, Style::default());
assert_eq!(wrapped.len(), 2);
assert_eq!(line_text(&wrapped[0]), "hello world");
assert_eq!(line_text(&wrapped[1]), "foo");
}
#[test]
fn word_wrap_long_word_falls_back_to_char() {
let lines = vec![plain_line("abcdefghij")];
let wrapped = WordWrapper::wrap(&lines, 4, Style::default());
assert_eq!(wrapped.len(), 3);
assert_eq!(line_text(&wrapped[0]), "abcd");
assert_eq!(line_text(&wrapped[1]), "efgh");
assert_eq!(line_text(&wrapped[2]), "ij");
}
#[test]
fn word_wrap_preserves_styles() {
let styled = Line::from(vec![
Span::styled("hello ".to_string(), Style::default().fg(Color::Red)),
Span::styled("world".to_string(), Style::default().fg(Color::Blue)),
]);
let wrapped = WordWrapper::wrap(&[styled], 5, Style::default());
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].spans[0].style.fg, Some(Color::Red));
assert_eq!(wrapped[1].spans[0].style.fg, Some(Color::Blue));
}
#[test]
fn word_wrap_empty_line() {
let lines = vec![plain_line("")];
let wrapped = WordWrapper::wrap(&lines, 10, Style::default());
assert_eq!(wrapped.len(), 1);
}
#[test]
fn char_wrap_simple() {
let lines = vec![plain_line("abcdefghij")];
let wrapped = CharWrapper::wrap(&lines, 4, Style::default());
assert_eq!(wrapped.len(), 3);
assert_eq!(line_text(&wrapped[0]), "abcd");
assert_eq!(line_text(&wrapped[1]), "efgh");
assert_eq!(line_text(&wrapped[2]), "ij");
}
#[test]
fn char_wrap_preserves_styles() {
let styled = Line::from(vec![
Span::styled("abc".to_string(), Style::default().fg(Color::Red)),
Span::styled("def".to_string(), Style::default().fg(Color::Blue)),
]);
let wrapped = CharWrapper::wrap(&[styled], 4, Style::default());
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].spans.len(), 2);
assert_eq!(wrapped[0].spans[0].content.as_ref(), "abc");
assert_eq!(wrapped[0].spans[1].content.as_ref(), "d");
}
#[test]
fn truncate_clips_long_lines() {
let lines = vec![plain_line("hello world")];
let truncated = LineTruncator::truncate(&lines, 5, Style::default());
assert_eq!(truncated.len(), 1);
assert_eq!(line_text(&truncated[0]), "hello");
}
#[test]
fn truncate_preserves_short_lines() {
let lines = vec![plain_line("hi")];
let truncated = LineTruncator::truncate(&lines, 10, Style::default());
assert_eq!(truncated.len(), 1);
assert_eq!(line_text(&truncated[0]), "hi");
}
}