use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;
pub fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
pub fn truncate_to_width(s: &str, max_width: usize) -> String {
if display_width(s) <= max_width {
return s.to_string();
}
if max_width == 0 {
return String::new();
}
if max_width == 1 {
return "…".to_string();
}
let target = max_width - 1; let mut col = 0;
let mut end = 0;
for (i, ch) in s.char_indices() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if col + w > target {
break;
}
col += w;
end = i + ch.len_utf8();
}
let mut out = s[..end].to_string();
out.push('…');
out
}
pub fn exceeds_width(s: &str, max_width: usize) -> bool {
display_width(s) > max_width
}
pub fn scroll_display(s: &str, max_width: usize, scroll_pos: usize) -> (String, usize) {
if max_width == 0 {
return (String::new(), 0);
}
let total = display_width(s);
if total <= max_width {
return (s.to_string(), 0);
}
if max_width == 1 {
return ("…".to_string(), total);
}
let max_scroll = total.saturating_sub(max_width.saturating_sub(1));
let eff = scroll_pos.min(max_scroll);
if eff == 0 {
let (text, actual_vis) = take_columns(s, max_width - 1);
let right_pad = (max_width - 1).saturating_sub(actual_vis);
(format!("{}{}…", text, " ".repeat(right_pad)), max_scroll)
} else if eff >= max_scroll {
let skip_cols = total.saturating_sub(max_width - 1);
let (remainder, actual_skipped) = skip_columns(s, skip_cols);
let overshoot = actual_skipped.saturating_sub(skip_cols);
(
format!("…{}{}", " ".repeat(overshoot), remainder),
max_scroll,
)
} else {
let inner_cols = max_width.saturating_sub(2);
let (after_skip, actual_skipped) = skip_columns(s, eff);
let overshoot = actual_skipped.saturating_sub(eff).min(inner_cols);
let content_cols = inner_cols.saturating_sub(overshoot);
let (visible, actual_vis) = take_columns(&after_skip, content_cols);
let right_pad = inner_cols.saturating_sub(overshoot + actual_vis);
(
format!(
"…{}{}{}…",
" ".repeat(overshoot),
visible,
" ".repeat(right_pad)
),
max_scroll,
)
}
}
pub fn pad_to_width(s: &str, width: usize) -> String {
let w = display_width(s);
if w >= width {
let (text, actual) = take_columns(s, width);
if actual < width {
format!("{}{}", text, " ".repeat(width - actual))
} else {
text
}
} else {
format!("{}{}", s, " ".repeat(width - w))
}
}
#[cfg(test)]
fn wrap_spans(
spans: &[Span<'static>],
max_width: usize,
continuation_indent: usize,
) -> Vec<Vec<Span<'static>>> {
wrap_spans_with_ranges(spans, max_width, continuation_indent)
.into_iter()
.map(|line| line.spans)
.collect()
}
pub(super) struct WrappedLine {
pub spans: Vec<Span<'static>>,
pub source_range: std::ops::Range<usize>,
pub indent: usize,
}
impl WrappedLine {
pub fn unwrapped(spans: Vec<Span<'static>>) -> Self {
let len = spans.iter().map(|span| span.content.len()).sum();
Self {
spans,
source_range: 0..len,
indent: 0,
}
}
}
pub(super) fn wrap_spans_with_ranges(
spans: &[Span<'static>],
max_width: usize,
continuation_indent: usize,
) -> Vec<WrappedLine> {
use ratatui::style::Style;
use unicode_width::UnicodeWidthChar;
if max_width == 0 {
return vec![WrappedLine::unwrapped(spans.to_vec())];
}
let total_width: usize = spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if total_width <= max_width {
return vec![WrappedLine::unwrapped(spans.to_vec())];
}
struct CI {
ch: char,
width: usize,
style: Style,
is_space: bool,
is_cjk: bool,
byte_offset: usize,
}
fn build_line(chars: &[CI], is_continuation: bool, indent: usize) -> WrappedLine {
let source_start = chars.first().map_or(0, |ch| ch.byte_offset);
let source_end = chars
.last()
.map_or(source_start, |ch| ch.byte_offset + ch.ch.len_utf8());
let indent = if is_continuation { indent } else { 0 };
let mut spans: Vec<Span<'static>> = Vec::new();
if indent > 0 {
spans.push(Span::raw(" ".repeat(indent)));
}
if chars.is_empty() {
return WrappedLine {
spans,
source_range: source_start..source_end,
indent,
};
}
let mut cur_style = chars[0].style;
let mut cur_text = String::new();
for ci in chars {
if ci.style == cur_style {
cur_text.push(ci.ch);
} else {
if !cur_text.is_empty() {
spans.push(Span::styled(cur_text, cur_style));
cur_text = String::new();
}
cur_style = ci.style;
cur_text.push(ci.ch);
}
}
if !cur_text.is_empty() {
spans.push(Span::styled(cur_text, cur_style));
}
WrappedLine {
spans,
source_range: source_start..source_end,
indent,
}
}
let mut chars: Vec<CI> = Vec::new();
let mut byte_offset = 0;
for span in spans {
let style = span.style;
for ch in span.content.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
chars.push(CI {
ch,
width: w,
style,
is_space: ch == ' ',
is_cjk: w == 2,
byte_offset,
});
byte_offset += ch.len_utf8();
}
}
let mut result: Vec<WrappedLine> = Vec::new();
let mut pos = 0;
while pos < chars.len() {
let is_continuation = !result.is_empty();
let line_width = if is_continuation {
max_width.saturating_sub(continuation_indent)
} else {
max_width
};
if line_width == 0 {
result.push(build_line(&chars[pos..pos + 1], false, 0));
pos += 1;
continue;
}
let mut col = 0;
let mut end = pos;
let mut last_break = None;
while end < chars.len() {
let ci = &chars[end];
if col + ci.width > line_width {
break;
}
col += ci.width;
end += 1;
if ci.is_space || (ci.is_cjk && end < chars.len()) {
last_break = Some(end);
}
}
if end == chars.len() {
result.push(build_line(
&chars[pos..end],
is_continuation,
continuation_indent,
));
break;
}
let split = if let Some(bp) = last_break {
if bp > pos { bp } else { end.max(pos + 1) }
} else {
end.max(pos + 1)
};
let mut trim_end = split;
while trim_end > pos && chars[trim_end - 1].is_space {
trim_end -= 1;
}
result.push(build_line(
&chars[pos..trim_end],
is_continuation,
continuation_indent,
));
pos = split;
while pos < chars.len() && chars[pos].is_space {
pos += 1;
}
}
if result.is_empty() {
vec![WrappedLine::unwrapped(spans.to_vec())]
} else {
result
}
}
fn take_columns(s: &str, cols: usize) -> (String, usize) {
let mut col = 0;
let mut end = 0;
for (i, ch) in s.char_indices() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if col + w > cols {
break;
}
col += w;
end = i + ch.len_utf8();
}
(s[..end].to_string(), col)
}
fn skip_columns(s: &str, cols: usize) -> (String, usize) {
let mut col = 0;
for (i, ch) in s.char_indices() {
if col >= cols {
return (s[i..].to_string(), col);
}
col += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
}
(String::new(), col)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_width() {
assert_eq!(display_width("hello"), 5);
}
#[test]
fn cjk_width() {
assert_eq!(display_width("日本語"), 6);
}
#[test]
fn mixed_width() {
assert_eq!(display_width("hi日本"), 6); }
#[test]
fn empty_width() {
assert_eq!(display_width(""), 0);
}
#[test]
fn truncate_ascii_no_op() {
assert_eq!(truncate_to_width("hello", 10), "hello");
assert_eq!(truncate_to_width("hello", 5), "hello");
}
#[test]
fn truncate_ascii() {
assert_eq!(truncate_to_width("hello world", 8), "hello w…");
}
#[test]
fn truncate_cjk() {
assert_eq!(truncate_to_width("日本語テスト", 7), "日本語…");
}
#[test]
fn truncate_cjk_boundary() {
assert_eq!(truncate_to_width("日本語テスト", 6), "日本…");
}
#[test]
fn truncate_edge_min() {
assert_eq!(truncate_to_width("hello", 1), "…");
}
#[test]
fn truncate_zero_width() {
assert_eq!(truncate_to_width("hello", 0), "");
}
#[test]
fn truncate_empty() {
assert_eq!(truncate_to_width("", 5), "");
}
#[test]
fn exceeds_ascii() {
assert!(!exceeds_width("hello", 10));
assert!(exceeds_width("hello world", 8));
}
#[test]
fn exceeds_cjk() {
assert!(exceeds_width("日本語", 5)); assert!(!exceeds_width("日本語", 6));
}
#[test]
fn scroll_fits() {
let (r, m) = scroll_display("hello", 10, 0);
assert_eq!(r, "hello");
assert_eq!(m, 0);
}
#[test]
fn scroll_start_ascii() {
let (r, m) = scroll_display("hello world", 8, 0);
assert_eq!(r, "hello w…");
assert_eq!(m, 4); }
#[test]
fn scroll_end_ascii() {
let (r, _) = scroll_display("hello world", 8, 100);
assert_eq!(r, "…o world");
}
#[test]
fn scroll_middle_ascii() {
let (r, _) = scroll_display("hello world", 8, 2);
assert_eq!(r, "…llo wo…");
}
#[test]
fn scroll_cjk_start() {
let (r, m) = scroll_display("日本語テスト", 7, 0);
assert_eq!(r, "日本語…");
assert_eq!(m, 6); }
#[test]
fn scroll_cjk_end() {
let (r, _) = scroll_display("日本語テスト", 7, 100);
assert_eq!(r, "…テスト");
}
#[test]
fn pad_ascii() {
assert_eq!(pad_to_width("hi", 5), "hi ");
}
#[test]
fn pad_exact() {
assert_eq!(pad_to_width("hello", 5), "hello");
}
#[test]
fn pad_cjk() {
assert_eq!(pad_to_width("日本", 6), "日本 ");
}
#[test]
fn pad_truncate() {
assert_eq!(pad_to_width("hello world", 5), "hello");
}
#[test]
fn pad_truncate_cjk_boundary() {
assert_eq!(pad_to_width("日本語", 5), "日本 ");
}
#[test]
fn take_columns_basic() {
let (s, c) = take_columns("hello", 3);
assert_eq!(s, "hel");
assert_eq!(c, 3);
}
#[test]
fn take_columns_cjk_boundary() {
let (s, c) = take_columns("日本語", 3);
assert_eq!(s, "日");
assert_eq!(c, 2);
}
#[test]
fn skip_columns_basic() {
let (s, actual) = skip_columns("hello world", 6);
assert_eq!(s, "world");
assert_eq!(actual, 6);
}
#[test]
fn skip_columns_cjk() {
let (s, actual) = skip_columns("日本語テスト", 4);
assert_eq!(s, "語テスト");
assert_eq!(actual, 4);
}
#[test]
fn skip_columns_cjk_odd_boundary() {
let (s, actual) = skip_columns("日本語テスト", 3);
assert_eq!(s, "語テスト");
assert_eq!(actual, 4);
}
#[test]
fn scroll_cjk_middle_odd_pos() {
let (r, _) = scroll_display("日本語テスト", 8, 3);
assert_eq!(r, "…\u{0020}語テ\u{0020}…");
assert_eq!(display_width(&r), 8);
}
#[test]
fn scroll_cjk_end_odd_boundary() {
let (r, _) = scroll_display("日本語テスト", 8, 100);
assert_eq!(display_width(&r), 8);
assert!(r.starts_with('…'));
}
#[test]
fn emoji_display_width() {
assert_eq!(display_width("🎉"), 2);
}
#[test]
fn truncate_emoji() {
assert_eq!(truncate_to_width("🎉🎊🎁", 3), "🎉…");
}
#[test]
fn truncate_copied_message_cjk() {
let cmd = "変数名 <- read.csv('非常に長いファイルパス/データセット.csv')";
let result = truncate_to_width(cmd, 60);
assert!(display_width(&result) <= 60);
assert!(result.ends_with('…'));
}
#[test]
fn truncate_copied_message_short_no_op() {
let cmd = "print('hello')";
assert_eq!(truncate_to_width(cmd, 60), cmd);
}
#[test]
fn scroll_zero_width() {
let (r, m) = scroll_display("hello world", 0, 0);
assert_eq!(r, "");
assert_eq!(m, 0);
let (r2, m2) = scroll_display("hello world", 0, 5);
assert_eq!(r2, "");
assert_eq!(m2, 0);
}
#[test]
fn scroll_width_one() {
let (r, m) = scroll_display("hello world", 1, 0);
assert_eq!(r, "…");
assert_eq!(display_width(&r), 1);
assert_eq!(m, 11);
let (r2, _) = scroll_display("hello world", 1, 5);
assert_eq!(r2, "…");
assert_eq!(display_width(&r2), 1);
}
#[test]
fn scroll_width_two_cjk() {
let (r, _) = scroll_display("日本語", 2, 0);
assert_eq!(r, " …");
assert_eq!(display_width(&r), 2);
let (r2, _) = scroll_display("日本語", 2, 1);
assert_eq!(r2, "……");
assert_eq!(display_width(&r2), 2);
let (r3, _) = scroll_display("日本語", 2, 100);
assert_eq!(r3, "… ");
assert_eq!(display_width(&r3), 2);
}
#[test]
fn scroll_start_cjk_boundary_padding() {
let (r, _) = scroll_display("日本語テスト", 5, 0);
assert_eq!(r, "日本…");
let (r2, _) = scroll_display("日本語テスト", 4, 0);
assert_eq!(display_width(&r2), 4);
assert_eq!(r2, "日 …");
}
fn wrap_plain(text: &str, max_width: usize, indent: usize) -> Vec<String> {
let spans = vec![Span::raw(text.to_string())];
wrap_spans(&spans, max_width, indent)
.into_iter()
.map(|line| line.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
#[test]
fn wrap_no_op_when_fits() {
assert_eq!(wrap_plain("hello world", 20, 0), vec!["hello world"]);
}
#[test]
fn wrap_at_word_boundary() {
assert_eq!(
wrap_plain("hello world foo", 12, 0),
vec!["hello world", "foo"]
);
}
#[test]
fn wrap_continuation_indent() {
assert_eq!(
wrap_plain("hello world foo", 12, 2),
vec!["hello world", " foo"]
);
}
#[test]
fn wrap_long_word_char_break() {
assert_eq!(wrap_plain("abcdefghij", 5, 0), vec!["abcde", "fghij"]);
}
#[test]
fn wrap_cjk_break() {
let result = wrap_plain("日本語テスト", 8, 0);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "日本語テ");
assert_eq!(result[1], "スト");
}
#[test]
fn wrap_preserves_styles() {
use ratatui::style::Style;
let bold = Style::new().bold();
let spans = vec![
Span::raw("hello ".to_string()),
Span::styled("world foo".to_string(), bold),
];
let lines = wrap_spans(&spans, 10, 0);
assert_eq!(lines.len(), 2);
let line1_text: String = lines[0].iter().map(|s| s.content.as_ref()).collect();
let line2_text: String = lines[1].iter().map(|s| s.content.as_ref()).collect();
assert_eq!(line1_text, "hello");
assert_eq!(line2_text, "world foo");
let styled_span = lines[1]
.iter()
.find(|s| s.content.as_ref().contains("world"));
assert!(styled_span.is_some());
assert!(
styled_span
.unwrap()
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
}
#[test]
fn wrap_zero_width_no_panic() {
let result = wrap_plain("hello", 0, 0);
assert_eq!(result, vec!["hello"]);
}
#[test]
fn wrap_empty_input() {
let spans: Vec<Span<'static>> = vec![];
let result = wrap_spans(&spans, 10, 0);
assert_eq!(result.len(), 1);
assert!(result[0].is_empty());
}
#[test]
fn wrap_multiple_spaces_collapsed() {
let result = wrap_plain("aaa bbb", 4, 0);
assert_eq!(result, vec!["aaa", "bbb"]);
}
#[test]
fn wrap_spans_line_width_zero_fallback() {
let spans = vec![Span::raw("abc")];
let result = wrap_spans(&spans, 2, 3); assert_eq!(result.len(), 2); let text: String = result
.iter()
.map(|line| line.iter().map(|s| s.content.as_ref()).collect::<String>())
.collect::<Vec<_>>()
.join("|");
assert_eq!(text, "ab|c");
}
}