use ratatui::{
style::Style,
text::{Line, Span},
};
use unicode_width::UnicodeWidthChar;
#[must_use]
pub fn wrap_lines(text: &str, width: usize) -> Vec<Line<'static>> {
wrap_paragraphs(text.split('\n'), width, Style::default(), false)
.into_iter()
.map(styled_to_unstyled)
.collect()
}
#[must_use]
pub fn wrap_lines_styled(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
let styled = wrap_paragraphs(text.split('\n'), width, style, true);
styled.into_iter().map(|p| Line::from(p.spans)).collect()
}
fn styled_to_unstyled(p: StyledParagraph) -> Line<'static> {
if p.is_styled {
let spans: Vec<Span<'static>> = p
.spans
.into_iter()
.map(|s| Span::raw(s.content.into_owned()))
.collect();
Line::from(spans)
} else {
Line::from(p.spans)
}
}
struct StyledParagraph {
spans: Vec<Span<'static>>,
is_styled: bool,
}
fn wrap_paragraphs<'a, I>(
paragraphs: I,
width: usize,
style: Style,
is_styled: bool,
) -> Vec<StyledParagraph>
where
I: IntoIterator<Item = &'a str>,
{
let mut out: Vec<StyledParagraph> = Vec::new();
for paragraph in paragraphs {
if width == 0 {
let span = if is_styled {
Span::styled(paragraph.to_owned(), style)
} else {
Span::raw(paragraph.to_owned())
};
out.push(StyledParagraph {
spans: vec![span],
is_styled,
});
continue;
}
let wrapped = wrap_single_paragraph(paragraph, width, style, is_styled);
out.extend(wrapped);
}
if out.is_empty() {
out.push(empty_paragraph_row(style, is_styled));
}
out
}
fn wrap_single_paragraph(
paragraph: &str,
width: usize,
style: Style,
is_styled: bool,
) -> Vec<StyledParagraph> {
if paragraph.is_empty() {
return vec![empty_paragraph_row(style, is_styled)];
}
let mut state = WrapState::new(width, style, is_styled);
for token in tokenize(paragraph) {
state.feed_token(&token);
}
state.finish()
}
fn empty_paragraph_row(style: Style, is_styled: bool) -> StyledParagraph {
let span = if is_styled {
Span::styled(String::new(), style)
} else {
Span::raw(String::new())
};
StyledParagraph {
spans: vec![span],
is_styled,
}
}
struct WrapState {
width: usize,
style: Style,
is_styled: bool,
lines: Vec<StyledParagraph>,
current: String,
current_width: usize,
pending_space_width: usize,
pending_space_active: bool,
}
impl WrapState {
fn new(width: usize, style: Style, is_styled: bool) -> Self {
Self {
width,
style,
is_styled,
lines: Vec::new(),
current: String::new(),
current_width: 0,
pending_space_width: 0,
pending_space_active: false,
}
}
fn feed_token(&mut self, token: &Token) {
match token {
Token::Whitespace(w) => self.feed_whitespace(w),
Token::Cjk(c) => self.feed_cjk(*c),
Token::Word(word) => self.feed_word(word),
}
}
fn feed_whitespace(&mut self, w: &str) {
self.pending_space_width = w.width();
self.pending_space_active = true;
}
fn feed_cjk(&mut self, c: char) {
let cw = char_width(c);
let needed = self.pending_space_width_if_active(cw);
if self.fits(needed) {
self.commit_inline(cw, |s| s.current.push(c));
} else if cw > self.width {
self.flush_current();
self.discard_pending_space();
self.current.push(c);
self.current_width += cw;
} else {
self.flush_current();
self.discard_pending_space();
self.current.push(c);
self.current_width += cw;
}
}
fn feed_word(&mut self, word: &str) {
let word_width = word.width();
let needed = self.pending_space_width_if_active(word_width);
if self.fits(needed) {
self.commit_inline(word_width, |s| s.current.push_str(word));
} else if word_width > self.width {
self.flush_current();
self.discard_pending_space();
let mut chunk = String::new();
let mut chunk_width: usize = 0;
for ch in word.chars() {
let cw = char_width(ch);
if chunk_width + cw > self.width && !chunk.is_empty() {
let line = std::mem::take(&mut chunk);
self.push_line(line);
chunk_width = 0;
}
chunk.push(ch);
chunk_width += cw;
}
if !chunk.is_empty() {
self.current = chunk;
self.current_width = chunk_width;
}
} else {
self.flush_current();
self.discard_pending_space();
self.current.push_str(word);
self.current_width += word_width;
}
}
fn flush_current(&mut self) {
if !self.current.is_empty() {
let line = std::mem::take(&mut self.current);
self.push_line(line);
self.current_width = 0;
}
}
fn push_line(&mut self, content: String) {
let is_styled = self.is_styled;
let style = self.style;
let span = if is_styled {
Span::styled(content, style)
} else {
Span::raw(content)
};
self.lines.push(StyledParagraph {
spans: vec![span],
is_styled,
});
}
fn discard_pending_space(&mut self) {
self.pending_space_active = false;
self.pending_space_width = 0;
}
fn commit_inline<F>(&mut self, token_width: usize, append: F)
where
F: FnOnce(&mut Self),
{
if self.pending_space_active {
self.current.push(' ');
self.current_width += self.pending_space_width;
self.pending_space_active = false;
}
append(self);
self.current_width += token_width;
}
fn fits(&self, needed: usize) -> bool {
self.current_width + needed <= self.width
}
fn pending_space_width_if_active(&self, base: usize) -> usize {
if self.pending_space_active {
self.pending_space_width + base
} else {
base
}
}
fn finish(mut self) -> Vec<StyledParagraph> {
if !self.current.is_empty() {
let line = std::mem::take(&mut self.current);
self.push_line(line);
}
self.lines
}
}
enum Token {
Whitespace(String),
Cjk(char),
Word(String),
}
fn char_width(c: char) -> usize {
UnicodeWidthChar::width(c).unwrap_or(0)
}
trait StrWidth {
fn width(&self) -> usize;
}
impl StrWidth for str {
fn width(&self) -> usize {
self.chars().map(char_width).sum()
}
}
impl StrWidth for String {
fn width(&self) -> usize {
self.as_str().width()
}
}
fn tokenize(paragraph: &str) -> Vec<Token> {
let mut tokens: Vec<Token> = Vec::new();
let mut chars = paragraph.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
let mut buf = String::new();
buf.push(c);
while let Some(&next) = chars.peek() {
if next.is_whitespace() {
buf.push(next);
chars.next();
} else {
break;
}
}
tokens.push(Token::Whitespace(buf));
} else if is_cjk_breakable(c) {
tokens.push(Token::Cjk(c));
} else {
let mut buf = String::new();
buf.push(c);
while let Some(&next) = chars.peek() {
if next.is_whitespace() || is_cjk_breakable(next) {
break;
}
buf.push(next);
chars.next();
}
tokens.push(Token::Word(buf));
}
}
tokens
}
fn is_cjk_breakable(ch: char) -> bool {
matches!(ch,
'\u{2E80}'..='\u{9FFF}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7AF}' | '\u{D7B0}'..='\u{D7FF}' | '\u{F900}'..='\u{FAFF}' | '\u{FE30}'..='\u{FE4F}' | '\u{FF65}'..='\u{FFDC}' | '\u{20000}'..='\u{2A6DF}' | '\u{2A700}'..='\u{2B73F}' | '\u{2B740}'..='\u{2B81F}' | '\u{2F800}'..='\u{2FA1F}' )
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::{Color, Style};
fn per_line(lines: &[Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
fn joined(lines: &[Line<'_>]) -> String {
per_line(lines).join("\n")
}
#[test]
fn wraps_long_line_at_word_boundary() {
let lines = wrap_lines("the quick brown fox jumps", 10);
assert_eq!(
per_line(&lines),
vec![
"the quick".to_owned(),
"brown fox".to_owned(),
"jumps".to_owned(),
],
);
}
#[test]
fn preserves_hard_breaks() {
let lines = wrap_lines("alpha\nbeta\ngamma", 80);
assert_eq!(
per_line(&lines),
vec!["alpha".to_owned(), "beta".to_owned(), "gamma".to_owned()],
);
}
#[test]
fn handles_cjk_double_width() {
let lines = wrap_lines("你好世界", 4);
assert_eq!(per_line(&lines), vec!["你好".to_owned(), "世界".to_owned()],);
let lines = wrap_lines("你好世界", 2);
assert_eq!(
per_line(&lines),
vec![
"你".to_owned(),
"好".to_owned(),
"世".to_owned(),
"界".to_owned()
],
);
}
#[test]
fn empty_string_returns_one_empty_line() {
let lines = wrap_lines("", 80);
assert_eq!(per_line(&lines), vec![String::new()]);
}
#[test]
fn wrap_lines_returns_no_styling() {
let lines = wrap_lines("hello world", 80);
for line in &lines {
for span in &line.spans {
assert_eq!(span.style, Style::default());
}
}
}
#[test]
fn wrap_lines_styled_applies_style_to_every_span() {
let style = Style::default().fg(Color::Red);
let lines = wrap_lines_styled("hello world", 80, style);
assert!(!lines.is_empty());
for span in &lines[0].spans {
assert_eq!(span.style, style);
}
}
#[test]
fn wrap_lines_styled_splits_cjk_with_style() {
let style = Style::default().fg(Color::Blue);
let lines = wrap_lines_styled("你好世界", 2, style);
assert_eq!(lines.len(), 4);
for line in &lines {
assert_eq!(line.spans.len(), 1);
assert_eq!(line.spans[0].style, style);
}
assert_eq!(joined(&lines), "你\n好\n世\n界");
}
#[test]
fn hard_break_then_blank_paragraph_yields_three_lines() {
let lines = wrap_lines("a\n\nb", 80);
assert_eq!(
per_line(&lines),
vec!["a".to_owned(), String::new(), "b".to_owned()],
);
}
#[test]
fn pending_whitespace_dropped_on_line_wrap() {
let lines = wrap_lines("ab cd", 2);
assert_eq!(per_line(&lines), vec!["ab".to_owned(), "cd".to_owned()]);
}
#[test]
fn long_word_broken_at_char_boundary() {
let lines = wrap_lines("abcdefghijkl", 5);
assert_eq!(
per_line(&lines),
vec!["abcde".to_owned(), "fghij".to_owned(), "kl".to_owned(),],
);
}
#[test]
fn zero_width_returns_paragraphs_unchanged() {
let lines = wrap_lines("alpha\nbeta", 0);
assert_eq!(
per_line(&lines),
vec!["alpha".to_owned(), "beta".to_owned()],
);
}
#[test]
fn trailing_newline_preserves_trailing_empty_line() {
let lines = wrap_lines("a\n", 80);
assert_eq!(per_line(&lines), vec!["a".to_owned(), String::new()]);
}
#[test]
fn is_cjk_breakable_covers_common_ranges() {
assert!(is_cjk_breakable('中')); assert!(is_cjk_breakable('한')); assert!(is_cjk_breakable('字')); assert!(!is_cjk_breakable('a')); assert!(!is_cjk_breakable(' '));
assert!(!is_cjk_breakable('1'));
}
}