ratatui-kit 0.10.2

A framework for building interactive terminal user interfaces with ratatui
Documentation
use ratatui::text::{Line, Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

use super::types::{TableCellAlignment, TableWrapMode};

pub(super) fn wrap_line(
    line: Line<'static>,
    max_width: usize,
    wrap_mode: TableWrapMode,
    alignment: TableCellAlignment,
) -> Vec<Line<'static>> {
    if max_width == 0 {
        return vec![Line::default()];
    }

    let spans = line.spans;
    let full_text = spans
        .iter()
        .map(|span| span.content.as_ref())
        .collect::<String>();
    let style = spans.first().map(|span| span.style).unwrap_or(line.style);

    if full_text.width() <= max_width {
        return vec![align_line(
            Line::styled(full_text, style),
            max_width,
            alignment,
        )];
    }

    if wrap_mode == TableWrapMode::Truncate {
        return vec![align_line(
            Line::styled(truncate_text(&full_text, max_width), style),
            max_width,
            alignment,
        )];
    }

    let mut lines = Vec::new();
    let mut byte_pos = 0;
    while byte_pos < full_text.len() {
        let mut cur_width = 0usize;
        let mut content_end = byte_pos;

        for (i, c) in full_text[byte_pos..].char_indices() {
            let char_width = c.width().unwrap_or(0);
            if content_end > byte_pos && cur_width + char_width > max_width {
                break;
            }
            cur_width += char_width;
            content_end = byte_pos + i + c.len_utf8();
        }

        let mut break_at = content_end;
        for (i, c) in full_text[byte_pos..content_end].char_indices().rev() {
            if c.is_whitespace() {
                break_at = byte_pos + i;
                break;
            }
        }

        if break_at <= byte_pos {
            break_at = content_end;
        }

        let segment = full_text[byte_pos..break_at].trim();
        if !segment.is_empty() {
            lines.push(align_line(
                Line::styled(segment.to_string(), style),
                max_width,
                alignment,
            ));
        }

        byte_pos = break_at;
        while byte_pos < full_text.len() {
            let c = full_text[byte_pos..].chars().next().unwrap();
            if c.is_whitespace() {
                byte_pos += c.len_utf8();
            } else {
                break;
            }
        }
    }

    if lines.is_empty() {
        lines.push(Line::default());
    }
    lines
}

pub(super) fn truncate_text(text: &str, max_width: usize) -> String {
    let mut result = String::new();
    let mut width = 0usize;
    for c in text.chars() {
        let char_width = c.width().unwrap_or(0);
        if width + char_width > max_width {
            break;
        }
        width += char_width;
        result.push(c);
    }
    result
}

fn align_line(
    line: Line<'static>,
    max_width: usize,
    alignment: TableCellAlignment,
) -> Line<'static> {
    let width = line.width();
    let padding = max_width.saturating_sub(width);
    match alignment {
        TableCellAlignment::Left => line,
        TableCellAlignment::Center => {
            let left = padding / 2;
            let right = padding - left;
            let mut spans = vec![Span::raw(" ".repeat(left))];
            spans.extend(line.spans);
            spans.push(Span::raw(" ".repeat(right)));
            Line::from(spans)
        }
        TableCellAlignment::Right => {
            let mut spans = vec![Span::raw(" ".repeat(padding))];
            spans.extend(line.spans);
            Line::from(spans)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn wrap_line_handles_cjk_visual_width() {
        let lines = wrap_line(
            Line::from("中文English混排"),
            6,
            TableWrapMode::Wrap,
            TableCellAlignment::Left,
        );

        assert!(lines.len() > 1);
        assert!(lines.iter().all(|line| line.width() <= 6));
    }

    #[test]
    fn truncate_text_does_not_split_cjk_bytes() {
        assert_eq!(truncate_text("中文English", 3), "");
        assert_eq!(truncate_text("中文English", 4), "中文");
    }
}