From 86e9ef9d53d686aa61ab841d0dd7d1e2ca447059 Mon Sep 17 00:00:00 2001 From: yuhuahung Date: Wed, 8 Jul 2026 14:14:15 +0800 Subject: [PATCH 1/7] feat(ui): add span-level word wrapper wrap_spans --- src/ui/text_utils.rs | 299 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 298 insertions(+), 1 deletion(-) diff --git a/src/ui/text_utils.rs b/src/ui/text_utils.rs index 371d5ed0..b953d8cf 100644 --- a/src/ui/text_utils.rs +++ b/src/ui/text_utils.rs @@ -1,5 +1,5 @@ use ratatui::{style::Style, text::Span}; -use unicode_width::UnicodeWidthStr; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; pub(super) fn truncate_str(s: &str, max_len: usize) -> String { if s.len() <= max_len { @@ -93,9 +93,171 @@ pub(super) fn truncate_or_pad_spans( } } +pub(super) fn wrap_spans<'a>(spans: &[Span<'a>], width: usize) -> Vec>> { + 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)] + struct Item { + ch: char, + style: Style, + w: usize, + } + + fn flush_unit( + unit: &mut Vec, + unit_w: &mut usize, + current: &mut Vec, + current_w: &mut usize, + rows: &mut Vec>, + 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; + } + while !unit.is_empty() { + let remaining = width.saturating_sub(*current_w); + let mut consumed = 0usize; + let mut consumed_w = 0usize; + for it in unit.iter() { + if consumed_w + it.w > remaining { + break; + } + consumed_w += it.w; + consumed += 1; + } + if consumed == 0 { + if current.is_empty() { + let it = unit.remove(0); + let w = it.w; + current.push(it); + *current_w = w; + rows.push(std::mem::take(current)); + *current_w = 0; + } else { + rows.push(std::mem::take(current)); + *current_w = 0; + } + } else { + let drained: Vec = unit.drain(..consumed).collect(); + current.extend(drained); + *current_w += consumed_w; + if !unit.is_empty() { + rows.push(std::mem::take(current)); + *current_w = 0; + } + } + } + *unit_w = 0; + } + + let mut rows: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut current_w: usize = 0; + let mut word: Vec = 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> = Vec::new(); + let mut buf = String::new(); + let mut cur_style: Option