use ratatui::{
style::Style,
text::{Line, Span},
};
use unicode_width::UnicodeWidthStr;
pub(super) fn wrap_line_with_padding<'a>(
line: Line<'a>,
max_width: usize,
padding: &'a str,
) -> Vec<Line<'a>> {
if max_width == 0 {
return vec![line];
}
let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
if total_width <= max_width {
return vec![line];
}
let padding_width = padding.width();
let mut segments: Vec<(String, Style)> = Vec::new();
for span in &line.spans {
segments.push((span.content.to_string(), span.style));
}
let mut result: Vec<Line<'a>> = Vec::new();
let mut current_spans: Vec<Span<'a>> = Vec::new();
let mut current_width: usize = 0;
for (text, style) in segments {
let mut remaining = text.as_str();
while !remaining.is_empty() {
let available = max_width.saturating_sub(current_width);
if available == 0 {
result.push(Line::from(current_spans));
current_spans = vec![Span::styled(padding.to_string(), Style::default())];
current_width = padding_width;
continue;
}
let remaining_width = remaining.width();
if remaining_width <= available {
current_spans.push(Span::styled(remaining.to_string(), style));
current_width += remaining_width;
break;
} else {
let byte_limit = char_boundary_at_width(remaining, available);
let break_at = remaining[..byte_limit]
.rfind(' ')
.map(|p| p + 1)
.unwrap_or(byte_limit);
let break_at = if break_at == 0 {
byte_limit.max(remaining.ceil_char_boundary(1))
} else {
break_at
};
let (chunk, rest) = remaining.split_at(break_at);
current_spans.push(Span::styled(chunk.to_string(), style));
remaining = rest.trim_start();
result.push(Line::from(current_spans));
current_spans = vec![Span::styled(padding.to_string(), Style::default())];
current_width = padding_width;
}
}
}
if !current_spans.is_empty() {
result.push(Line::from(current_spans));
}
if result.is_empty() {
result.push(line);
}
result
}
pub(in crate::tui) fn char_boundary_at_width(s: &str, target_width: usize) -> usize {
let mut width = 0;
for (idx, ch) in s.char_indices() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > target_width {
return idx;
}
width += ch_width;
}
s.len()
}
pub(super) fn format_token_count_with_label(tokens: i32, label: &str) -> String {
let tokens = tokens.max(0) as f64;
if tokens >= 1_000_000.0 {
format!("{:.1}M {}", tokens / 1_000_000.0, label)
} else if tokens >= 1_000.0 {
format!("{:.1}K {}", tokens / 1_000.0, label)
} else if tokens > 0.0 {
format!("{} {}", tokens as i32, label)
} else {
"new".to_string()
}
}
pub(super) fn format_token_count_raw(tokens: i32) -> String {
let tokens = tokens.max(0) as f64;
if tokens >= 1_000_000.0 {
format!("{:.1}M", tokens / 1_000_000.0)
} else if tokens >= 1_000.0 {
format!("{:.0}K", tokens / 1_000.0)
} else if tokens > 0.0 {
format!("{}", tokens as i32)
} else {
"0".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_char_boundary_ascii() {
assert_eq!(char_boundary_at_width("hello", 3), 3);
assert_eq!(char_boundary_at_width("hello", 5), 5);
assert_eq!(char_boundary_at_width("hello", 10), 5); }
#[test]
fn test_char_boundary_multibyte() {
let s = "ab█cd";
assert_eq!(char_boundary_at_width(s, 2), 2); assert_eq!(char_boundary_at_width(s, 3), 5); assert_eq!(char_boundary_at_width(s, 4), 6); }
#[test]
fn test_char_boundary_wide_chars() {
let s = "a中b";
assert_eq!(char_boundary_at_width(s, 1), 1); assert_eq!(char_boundary_at_width(s, 2), 1); assert_eq!(char_boundary_at_width(s, 3), 4); }
#[test]
fn test_char_boundary_empty() {
assert_eq!(char_boundary_at_width("", 5), 0);
assert_eq!(char_boundary_at_width("hello", 0), 0);
}
#[test]
fn test_wrap_ascii_fits() {
let line = Line::from("short line");
let result = wrap_line_with_padding(line, 80, " ");
assert_eq!(result.len(), 1);
}
#[test]
fn test_wrap_ascii_wraps() {
let line = Line::from("this is a longer line that should wrap");
let result = wrap_line_with_padding(line, 20, " ");
assert!(
result.len() > 1,
"expected wrapping, got {} lines",
result.len()
);
}
#[test]
fn test_wrap_multibyte_no_panic() {
let text = format!("some text with a block char █ at the end{}", "█");
let line = Line::from(text);
let result = wrap_line_with_padding(line, 30, " ");
assert!(!result.is_empty());
}
#[test]
fn test_wrap_emoji_no_panic() {
let line = Line::from("🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀");
let result = wrap_line_with_padding(line, 10, " ");
assert!(!result.is_empty());
}
#[test]
fn test_wrap_cjk_no_panic() {
let line = Line::from("中文测试字符串需要正确换行处理");
let result = wrap_line_with_padding(line, 10, " ");
assert!(result.len() > 1);
}
#[test]
fn test_wrap_mixed_multibyte_and_spaces() {
let line = Line::from("hello █ world █ test █ more █ text █ end");
let result = wrap_line_with_padding(line, 15, " ");
assert!(result.len() > 1);
for l in &result {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(!joined.is_empty());
}
}
#[test]
fn test_wrap_zero_width() {
let line = Line::from("test");
let result = wrap_line_with_padding(line, 0, " ");
assert_eq!(result.len(), 1); }
#[test]
fn test_wrap_cursor_char() {
let mut input = "next I just noticed something weird like if I keep on this window it is always super fast".to_string();
input.push('\u{2588}'); let line = Line::from(format!(" {}", input));
let result = wrap_line_with_padding(line, 170, " ");
assert!(!result.is_empty());
}
}