use unicode_segmentation::UnicodeSegmentation;
use super::{cluster_width, is_control_cluster};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct WrapOpts {
pub hanging_indent: i32,
pub max_lines: usize,
}
pub fn wrap(s: &str, max_width: i32) -> Vec<String> {
wrap_with(s, max_width, &WrapOpts::default())
}
pub fn wrap_with(s: &str, max_width: i32, opts: &WrapOpts) -> Vec<String> {
let max_width = max_width.max(1);
let hang = opts.hanging_indent.clamp(0, max_width - 1);
let mut out = Vec::new();
for line in split_logical_lines(s) {
let first = out.len();
if hang == 0 {
wrap_line(&line, max_width, &mut out);
} else {
wrap_line_hanging(&line, max_width, hang, &mut out);
}
debug_assert!(out.len() > first, "every logical line yields a row");
if opts.max_lines != 0 && out.len() > opts.max_lines {
break;
}
}
if out.is_empty() {
out.push(String::new());
}
if opts.max_lines != 0 && out.len() > opts.max_lines {
out.truncate(opts.max_lines);
if let Some(last) = out.last_mut() {
*last = super::truncate_ellipsis(&format!("{last}\u{2026}"), max_width);
}
}
out
}
fn wrap_line_hanging(line: &str, max_width: i32, hang: i32, out: &mut Vec<String>) {
let start = out.len();
wrap_line_budgets(line, max_width, max_width - hang, out);
let indent = " ".repeat(hang as usize);
for cont in out[start..].iter_mut().skip(1) {
cont.insert_str(0, &indent);
}
}
fn split_logical_lines(s: &str) -> Vec<String> {
let mut lines = vec![String::new()];
for cluster in s.graphemes(true) {
if is_control_cluster(cluster) {
if cluster.contains('\n') {
lines.push(String::new());
}
continue;
}
lines.last_mut().expect("never empty").push_str(cluster);
}
lines
}
fn wrap_line(line: &str, max_width: i32, out: &mut Vec<String>) {
wrap_line_budgets(line, max_width, max_width, out);
}
fn wrap_line_budgets(line: &str, first_w: i32, rest_w: i32, out: &mut Vec<String>) {
let rows_before = out.len();
let (first_w, rest_w) = (first_w.max(1), rest_w.max(1));
let budget = |out: &Vec<String>| {
if out.len() == rows_before {
first_w
} else {
rest_w
}
};
let mut current = String::new();
let mut current_w = 0i32;
for token in tokenize(line) {
match token {
Token::Space(text, w) => {
if current_w > 0 && current_w + w <= budget(out) {
current.push_str(text);
current_w += w;
} else if current_w > 0 {
flush_trimmed(&mut current, &mut current_w, out);
}
}
Token::Word(text, w) => {
if current_w > 0 && current_w + w > budget(out) {
flush_trimmed(&mut current, &mut current_w, out);
}
if w <= budget(out) {
current.push_str(text);
current_w += w;
} else {
break_long_word(text, &budget, &mut current, &mut current_w, out);
}
}
}
}
flush_trimmed(&mut current, &mut current_w, out);
if out.len() == rows_before {
out.push(String::new());
}
}
fn flush_trimmed(current: &mut String, current_w: &mut i32, out: &mut Vec<String>) {
if *current_w == 0 && current.is_empty() {
return;
}
let trimmed = current.trim_end();
out.push(trimmed.to_string());
current.clear();
*current_w = 0;
}
fn break_long_word(
word: &str,
budget: &impl Fn(&Vec<String>) -> i32,
current: &mut String,
current_w: &mut i32,
out: &mut Vec<String>,
) {
for cluster in word.graphemes(true) {
let w = cluster_width(cluster);
if *current_w > 0 && *current_w + w > budget(out) {
flush_trimmed(current, current_w, out);
}
current.push_str(cluster);
*current_w += w;
}
}
enum Token<'a> {
Word(&'a str, i32),
Space(&'a str, i32),
}
fn tokenize(line: &str) -> impl Iterator<Item = Token<'_>> {
let mut clusters = line.grapheme_indices(true).peekable();
std::iter::from_fn(move || {
let (start, first) = clusters.next()?;
let is_space = first.chars().next().is_some_and(char::is_whitespace);
let mut end = start + first.len();
let mut w = cluster_width(first);
while let Some((_, c)) = clusters.peek() {
let c_space = c.chars().next().is_some_and(char::is_whitespace);
if c_space != is_space {
break;
}
w += cluster_width(c);
let (i, c) = clusters.next().expect("peeked");
end = i + c.len();
}
let text = &line[start..end];
Some(if is_space {
Token::Space(text, w)
} else {
Token::Word(text, w)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_word_wrap() {
assert_eq!(
wrap("the quick brown fox", 10),
vec!["the quick", "brown fox"]
);
assert_eq!(wrap("hello", 10), vec!["hello"]);
assert_eq!(wrap("", 10), vec![""]);
}
#[test]
fn break_consumes_whitespace() {
assert_eq!(wrap("aa bb", 4), vec!["aa", "bb"]);
assert_eq!(wrap("a b", 6), vec!["a b"]);
}
#[test]
fn long_word_breaks_at_grapheme_boundaries() {
assert_eq!(wrap("abcdefgh", 3), vec!["abc", "def", "gh"]);
assert_eq!(wrap("héllo", 2), vec!["hé", "ll", "o"]);
}
#[test]
fn cjk_wraps_by_columns() {
assert_eq!(wrap("城市化进程加快", 6), vec!["城市化", "进程加", "快"]);
assert_eq!(wrap("城市化", 5), vec!["城市", "化"]);
}
#[test]
fn oversized_cluster_emitted_alone() {
assert_eq!(wrap("界", 1), vec!["界"]);
assert_eq!(wrap("a界b", 1), vec!["a", "界", "b"]);
}
#[test]
fn newlines_and_empty_lines() {
assert_eq!(wrap("a\n\nb", 10), vec!["a", "", "b"]);
assert_eq!(wrap("a\r\nb", 10), vec!["a", "b"]);
assert_eq!(wrap("ab\n", 5), vec!["ab", ""]);
}
#[test]
fn zwj_sequences_do_not_crash_or_split() {
let family = "👨\u{200D}👩\u{200D}👧\u{200D}👦";
let text = format!("{family} {family}");
let wrapped = wrap(&text, 2);
assert_eq!(wrapped, vec![family.to_string(), family.to_string()]);
assert_eq!(wrap(family, 1), vec![family.to_string()]);
}
#[test]
fn controls_stripped() {
assert_eq!(wrap("a\tb", 10), vec!["ab"]);
}
#[test]
fn hanging_indent_indents_continuations_only() {
let opts = WrapOpts {
hanging_indent: 2,
max_lines: 0,
};
let lines = wrap_with("alpha beta gamma delta", 8, &opts);
assert_eq!(lines[0], "alpha", "first line unindented at full width");
for cont in &lines[1..] {
assert!(cont.starts_with(" "), "continuation indented: {cont:?}");
assert!(crate::text::width(cont) <= 8, "fits incl. indent: {cont:?}");
}
let lines = wrap_with("one two three\nnew", 8, &opts);
assert!(lines.contains(&"new".to_string()));
let lines = wrap_with(
"abcd",
3,
&WrapOpts {
hanging_indent: 10,
max_lines: 0,
},
);
for l in &lines {
assert!(crate::text::width(l) <= 3);
}
}
#[test]
fn max_lines_caps_with_ellipsis() {
let opts = WrapOpts {
hanging_indent: 0,
max_lines: 2,
};
let lines = wrap_with("aa bb cc dd ee ff", 5, &opts);
assert_eq!(lines.len(), 2);
assert!(lines[1].ends_with('…'), "cut is visible: {lines:?}");
assert!(crate::text::width(&lines[1]) <= 5);
let lines = wrap_with("aa bb", 5, &opts);
assert_eq!(lines, vec!["aa bb"]);
let lines = wrap_with(
"word word word",
6,
&WrapOpts {
hanging_indent: 0,
max_lines: 1,
},
);
assert_eq!(lines.len(), 1);
assert!(lines[0].ends_with('…'));
assert_eq!(wrap_with("a b c", 1, &WrapOpts::default()).len(), 3);
}
#[test]
fn hanging_and_cap_compose_for_chat_bubbles() {
let opts = WrapOpts {
hanging_indent: 3,
max_lines: 3,
};
let lines = wrap_with(
"user: the quick brown fox jumps over the lazy dog",
12,
&opts,
);
assert!(lines.len() <= 3);
assert!(lines.last().unwrap().ends_with('…'));
for l in &lines {
assert!(crate::text::width(l) <= 12, "{l:?}");
}
}
}