1use std::borrow::Cow;
2
3use super::line::Line;
4use unicode_width::UnicodeWidthChar;
5
6pub fn truncate_text(text: &str, max_width: usize) -> Cow<'_, str> {
9 const ELLIPSIS: &str = "...";
10 const ELLIPSIS_WIDTH: usize = 3;
11
12 if max_width == 0 {
13 return Cow::Borrowed("");
14 }
15
16 let use_ellipsis = max_width >= ELLIPSIS_WIDTH;
17 let budget = if use_ellipsis {
18 max_width - ELLIPSIS_WIDTH
19 } else {
20 max_width
21 };
22
23 let mut width = 0;
24 let mut fit_end = 0; for (i, ch) in text.char_indices() {
27 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
28 if width + cw > max_width {
29 return if use_ellipsis {
30 Cow::Owned(format!("{}{ELLIPSIS}", &text[..fit_end]))
31 } else {
32 Cow::Owned(text[..fit_end].to_owned())
33 };
34 }
35 width += cw;
36 if width <= budget {
37 fit_end = i + ch.len_utf8();
38 }
39 }
40
41 Cow::Borrowed(text)
42}
43
44pub fn pad_text_to_width(text: &str, target_width: usize) -> Cow<'_, str> {
47 let current = display_width_text(text);
48 if current >= target_width {
49 Cow::Borrowed(text)
50 } else {
51 let padding = target_width - current;
52 Cow::Owned(format!("{text}{}", " ".repeat(padding)))
53 }
54}
55
56pub fn display_width_text(s: &str) -> usize {
57 s.chars()
58 .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
59 .sum()
60}
61
62pub fn display_width_line(line: &Line) -> usize {
63 line.spans()
64 .iter()
65 .map(|span| display_width_text(span.text()))
66 .sum()
67}
68
69pub fn truncate_line(line: &Line, max_width: usize) -> Line {
75 if max_width == 0 {
76 return Line::default();
77 }
78
79 let mut result = Line::default();
80 let mut remaining = max_width;
81
82 for span in line.spans() {
83 if remaining == 0 {
84 break;
85 }
86
87 let text = span.text();
88 let style = span.style();
89 let mut byte_end = 0;
90 let mut col = 0;
91
92 for (i, ch) in text.char_indices() {
93 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
94 if col + cw > remaining {
95 break;
96 }
97 col += cw;
98 byte_end = i + ch.len_utf8();
99 }
100
101 if byte_end > 0 {
102 result.push_with_style(&text[..byte_end], style);
103 }
104 remaining -= col;
105 }
106
107 result
108}
109
110pub fn soft_wrap_line(line: &Line, width: u16) -> Vec<Line> {
111 if line.is_empty() {
112 return vec![Line::new("")];
113 }
114
115 let max_width = width as usize;
116 if max_width == 0 {
117 return vec![line.clone()];
118 }
119
120 let mut rows = Vec::new();
121 let mut current = Line::default();
122 let mut current_width = 0usize;
123
124 for span in line.spans() {
125 let text = span.text();
126 let style = span.style();
127 let mut start = 0;
128
129 for (i, ch) in text.char_indices() {
130 if ch == '\n' {
131 if start < i {
132 current.push_with_style(&text[start..i], style);
133 }
134 rows.push(current);
135 current = Line::default();
136 current_width = 0;
137 start = i + ch.len_utf8();
138 continue;
139 }
140
141 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
142 if ch_width > 0 && current_width + ch_width > max_width && current_width > 0 {
143 if start < i {
144 current.push_with_style(&text[start..i], style);
145 }
146 rows.push(current);
147 current = Line::default();
148 current_width = 0;
149 start = i;
150 }
151 current_width += ch_width;
152 }
153
154 if start < text.len() {
155 current.push_with_style(&text[start..], style);
156 }
157 }
158
159 rows.push(current);
160 if rows.is_empty() {
161 rows.push(Line::new(""));
162 }
163 rows
164}
165
166pub fn soft_wrap_lines_with_map(lines: &[Line], width: u16) -> (Vec<Line>, Vec<usize>) {
167 let mut out = Vec::new();
168 let mut starts = Vec::with_capacity(lines.len());
169
170 for line in lines {
171 starts.push(out.len());
172 out.extend(soft_wrap_line(line, width));
173 }
174
175 (out, starts)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crossterm::style::Color;
182
183 #[test]
184 fn wraps_ascii_to_width() {
185 let rows = soft_wrap_line(&Line::new("abcdef"), 3);
186 assert_eq!(rows, vec![Line::new("abc"), Line::new("def")]);
187 }
188
189 #[test]
190 fn display_width_ignores_style() {
191 let mut line = Line::default();
192 line.push_styled("he", Color::Red);
193 line.push_text("llo");
194 assert_eq!(display_width_line(&line), 5);
195 }
196
197 #[test]
198 fn wraps_preserving_style_spans() {
199 let line = Line::styled("abcdef", Color::Red);
200 let rows = soft_wrap_line(&line, 3);
201 assert_eq!(rows.len(), 2);
202 assert_eq!(rows[0].plain_text(), "abc");
203 assert_eq!(rows[1].plain_text(), "def");
204 assert_eq!(rows[0].spans().len(), 1);
205 assert_eq!(rows[1].spans().len(), 1);
206 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
207 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
208 }
209
210 #[test]
211 fn counts_wide_unicode() {
212 assert_eq!(display_width_text("中a"), 3);
213 let rows = soft_wrap_line(&Line::new("中ab"), 3);
214 assert_eq!(rows, vec![Line::new("中a"), Line::new("b")]);
215 }
216
217 #[test]
218 fn wraps_multi_span_line_mid_span() {
219 let mut line = Line::default();
220 line.push_styled("ab", Color::Red);
221 line.push_styled("cd", Color::Blue);
222 line.push_styled("ef", Color::Green);
223 let rows = soft_wrap_line(&line, 3);
224 assert_eq!(rows.len(), 2);
225 assert_eq!(rows[0].plain_text(), "abc");
226 assert_eq!(rows[1].plain_text(), "def");
227 assert_eq!(rows[0].spans().len(), 2);
229 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
230 assert_eq!(rows[0].spans()[1].style().fg, Some(Color::Blue));
231 assert_eq!(rows[1].spans().len(), 2);
233 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
234 assert_eq!(rows[1].spans()[1].style().fg, Some(Color::Green));
235 }
236
237 #[test]
238 fn wraps_line_with_embedded_newlines() {
239 let line = Line::new("abc\ndef");
240 let rows = soft_wrap_line(&line, 80);
241 assert_eq!(rows.len(), 2);
242 assert_eq!(rows[0].plain_text(), "abc");
243 assert_eq!(rows[1].plain_text(), "def");
244 }
245
246 #[test]
247 fn pad_text_pads_ascii_to_target_width() {
248 let result = pad_text_to_width("hello", 10);
249 assert_eq!(result, "hello ");
250 assert_eq!(display_width_text(&result), 10);
251 }
252
253 #[test]
254 fn pad_text_returns_borrowed_when_already_wide_enough() {
255 let result = pad_text_to_width("hello", 5);
256 assert!(matches!(result, Cow::Borrowed(_)));
257 assert_eq!(result, "hello");
258
259 let result = pad_text_to_width("hello", 3);
260 assert!(matches!(result, Cow::Borrowed(_)));
261 assert_eq!(result, "hello");
262 }
263
264 #[test]
265 fn pad_text_handles_wide_unicode() {
266 let result = pad_text_to_width("中a", 6);
268 assert_eq!(display_width_text(&result), 6);
269 assert_eq!(result, "中a "); }
271
272 #[test]
273 fn truncate_text_fits_within_width() {
274 assert_eq!(truncate_text("hello", 10), "hello");
275 assert_eq!(truncate_text("hello world", 8), "hello...");
276 assert_eq!(truncate_text("hello", 5), "hello");
277 assert_eq!(truncate_text("hello", 4), "h...");
278 }
279
280 #[test]
281 fn truncate_text_handles_wide_unicode() {
282 assert_eq!(truncate_text("中文字", 5), "中..."); assert_eq!(truncate_text("中ab", 4), "中ab"); assert_eq!(truncate_text("中abc", 4), "..."); assert_eq!(truncate_text("中abcde", 6), "中a..."); }
288
289 #[test]
290 fn truncate_text_handles_zero_width() {
291 assert_eq!(truncate_text("hello", 0), "");
292 }
293
294 #[test]
295 fn truncate_text_max_width_1() {
296 let result = truncate_text("hello", 1);
297 assert!(
298 display_width_text(&result) <= 1,
299 "Expected width <= 1, got '{}' (width {})",
300 result,
301 display_width_text(&result),
302 );
303 assert_eq!(result, "h");
304 }
305
306 #[test]
307 fn truncate_text_max_width_2() {
308 let result = truncate_text("hello", 2);
309 assert!(
310 display_width_text(&result) <= 2,
311 "Expected width <= 2, got '{}' (width {})",
312 result,
313 display_width_text(&result),
314 );
315 assert_eq!(result, "he");
316 }
317
318 #[test]
319 fn truncate_line_returns_short_lines_unchanged() {
320 let line = Line::new("short");
321 let result = truncate_line(&line, 20);
322 assert_eq!(result.plain_text(), "short");
323 }
324
325 #[test]
326 fn truncate_line_trims_long_styled_lines() {
327 let mut line = Line::default();
328 line.push_styled("hello", Color::Red);
329 line.push_styled(" world", Color::Blue);
330 let result = truncate_line(&line, 7);
331 assert_eq!(result.plain_text(), "hello w");
332 assert_eq!(result.spans().len(), 2);
333 assert_eq!(result.spans()[0].style().fg, Some(Color::Red));
334 assert_eq!(result.spans()[1].style().fg, Some(Color::Blue));
335 }
336
337 #[test]
338 fn truncate_line_handles_mid_span_cut() {
339 let line = Line::styled("abcdefgh", Color::Green);
340 let result = truncate_line(&line, 4);
341 assert_eq!(result.plain_text(), "abcd");
342 assert_eq!(result.spans()[0].style().fg, Some(Color::Green));
343 }
344
345 #[test]
346 fn truncate_line_handles_wide_unicode_at_boundary() {
347 let line = Line::new("中文x");
350 let result = truncate_line(&line, 3);
351 assert_eq!(result.plain_text(), "中");
352
353 let result = truncate_line(&line, 4);
355 assert_eq!(result.plain_text(), "中文");
356
357 let result = truncate_line(&line, 5);
359 assert_eq!(result.plain_text(), "中文x");
360 }
361
362 #[test]
363 fn truncate_line_zero_width_returns_empty() {
364 let line = Line::new("hello");
365 let result = truncate_line(&line, 0);
366 assert!(result.is_empty());
367 }
368}