1use std::borrow::Cow;
2use std::iter::Sum;
3use std::ops::{Add, AddAssign, Range};
4
5use super::line::Line;
6use crossterm::style::Color;
7use unicode_width::UnicodeWidthChar;
8
9pub fn soft_wrap_text_position(text: &str, offset: usize, max_width: usize) -> (usize, usize) {
12 let byte_offset = clamp_to_char_boundary(text, offset);
13 let max_width = Col(max_width);
14 if max_width.0 == 0 {
15 return (0, display_width_text(text.slice_to(byte_offset)));
16 }
17
18 let mut row_index = Row::ZERO;
19 let mut row_start = ByteOffset::ZERO;
20
21 loop {
22 let (row, next_row_start) = next_text_row(text, row_start, max_width);
23 if byte_offset < row.end
24 || byte_offset == row.end && next_row_start != Some(row.end)
25 || next_row_start.is_none()
26 {
27 return (row_index.0, display_width_text_until(text, row, byte_offset).0);
28 }
29
30 let Some(next_row_start) = next_row_start else {
31 return (row_index.0, 0);
32 };
33 row_start = next_row_start;
34 row_index += 1;
35 }
36}
37
38pub(crate) fn soft_wrap_text_byte_offset(text: &str, target_row: usize, target_col: usize, max_width: usize) -> usize {
39 let target_row = Row(target_row);
40 let target_col = Col(target_col);
41 let max_width = Col(max_width);
42 if max_width.0 == 0 {
43 return byte_offset_for_col(text, ByteOffset::ZERO..ByteOffset::end_of(text), target_col).0;
44 }
45
46 let mut row_index = Row::ZERO;
47 let mut row_start = ByteOffset::ZERO;
48
49 loop {
50 let (row, next_row_start) = next_text_row(text, row_start, max_width);
51 if row_index == target_row {
52 return byte_offset_for_col(text, row, target_col).0;
53 }
54
55 let Some(next_row_start) = next_row_start else {
56 return text.len();
57 };
58 row_start = next_row_start;
59 row_index += 1;
60 }
61}
62
63pub fn truncate_text(text: &str, max_width: usize) -> Cow<'_, str> {
66 const ELLIPSIS: &str = "...";
67 const ELLIPSIS_WIDTH: usize = 3;
68
69 if max_width == 0 {
70 return Cow::Borrowed("");
71 }
72
73 let use_ellipsis = max_width >= ELLIPSIS_WIDTH;
74 let budget = if use_ellipsis { max_width - ELLIPSIS_WIDTH } else { max_width };
75
76 let mut width = 0;
77 let mut fit_end = 0; for (i, ch) in text.char_indices() {
80 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
81 if width + cw > max_width {
82 return if use_ellipsis {
83 Cow::Owned(format!("{}{ELLIPSIS}", &text[..fit_end]))
84 } else {
85 Cow::Owned(text[..fit_end].to_owned())
86 };
87 }
88 width += cw;
89 if width <= budget {
90 fit_end = i + ch.len_utf8();
91 }
92 }
93
94 Cow::Borrowed(text)
95}
96
97pub fn pad_text_to_width(text: &str, target_width: usize) -> Cow<'_, str> {
100 let current = display_width_text(text);
101 if current >= target_width {
102 Cow::Borrowed(text)
103 } else {
104 let padding = target_width - current;
105 Cow::Owned(format!("{text}{}", " ".repeat(padding)))
106 }
107}
108
109pub fn display_width_text(s: &str) -> usize {
110 s.chars().map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0)).sum()
111}
112
113pub fn display_width_line(line: &Line) -> usize {
114 line.spans().iter().map(|span| display_width_text(span.text())).sum()
115}
116
117pub fn truncate_line(line: &Line, max_width: usize) -> Line {
125 if max_width == 0 {
126 let mut empty = Line::default();
127 empty.set_fill(line.fill());
128 return empty;
129 }
130
131 let mut result = Line::default();
132 let mut remaining = max_width;
133
134 for span in line.spans() {
135 if remaining == 0 {
136 break;
137 }
138
139 let text = span.text();
140 let style = span.style();
141 let mut byte_end = 0;
142 let mut col = 0;
143
144 for (i, ch) in text.char_indices() {
145 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
146 if col + cw > remaining {
147 break;
148 }
149 col += cw;
150 byte_end = i + ch.len_utf8();
151 }
152
153 if byte_end > 0 {
154 result.push_with_style(&text[..byte_end], style);
155 }
156 remaining -= col;
157 }
158
159 result.set_fill(line.fill());
160 result
161}
162
163pub fn soft_wrap_line(line: &Line, width: u16) -> Vec<Line> {
164 if line.is_empty() {
165 let mut empty = Line::new("");
166 empty.set_fill(line.fill());
167 return vec![empty];
168 }
169
170 let max_width = Col(width as usize);
171 if max_width.0 == 0 {
172 return vec![line.clone()];
173 }
174
175 let text = line.plain_text();
176 let mut rows = Vec::new();
177 let mut row_start = ByteOffset::ZERO;
178 loop {
179 let (range, next_row_start) = next_text_row(&text, row_start, max_width);
180 rows.push(slice_line(line, range, line.fill()));
181 let Some(next_row_start) = next_row_start else {
182 return rows;
183 };
184 row_start = next_row_start;
185 }
186}
187
188pub fn soft_wrap_lines_with_map(lines: &[Line], width: u16) -> (Vec<Line>, Vec<usize>) {
189 let mut out = Vec::new();
190 let mut starts = Vec::with_capacity(lines.len());
191
192 for line in lines {
193 starts.push(out.len());
194 out.extend(soft_wrap_line(line, width));
195 }
196
197 (out, starts)
198}
199
200fn slice_line(line: &Line, range: Range<ByteOffset>, fill: Option<Color>) -> Line {
201 let mut result = Line::default();
202 let mut cursor = ByteOffset::ZERO;
203
204 for span in line.spans() {
205 let span_start = cursor;
206 let span_end = cursor + span.text().len();
207 cursor = span_end;
208
209 let start = range.start.max(span_start);
210 let end = range.end.min(span_end);
211 if start < end {
212 result.push_with_style(&span.text()[start.0 - span_start.0..end.0 - span_start.0], span.style());
213 }
214 }
215
216 result.set_fill(fill);
217 result
218}
219
220fn next_text_row(text: &str, row_start: ByteOffset, max_width: Col) -> (Range<ByteOffset>, Option<ByteOffset>) {
221 if row_start.0 >= text.len() {
222 let end = ByteOffset::end_of(text);
223 return (end..end, None);
224 }
225
226 let mut row_width = Col::ZERO;
227 let mut last_ws: Option<Range<ByteOffset>> = None;
228
229 for (offset, ch) in text.slice_from(row_start).char_indices() {
230 let byte_start = row_start + offset;
231 let byte_end = byte_start + ch.len_utf8();
232
233 if ch == '\n' {
234 return (row_start..byte_start, Some(byte_end));
235 }
236
237 let width = col_of(ch);
238 if width.0 > 0 && row_width + width > max_width && row_width.0 > 0 {
239 return if ch.is_whitespace() {
240 (row_start..byte_start, Some(byte_end))
241 } else if let Some(ws) = last_ws {
242 (row_start..ws.start, Some(ws.end))
243 } else {
244 (row_start..byte_start, Some(byte_start))
245 };
246 }
247
248 row_width += width;
249 if ch.is_whitespace() {
250 last_ws = Some(byte_start..byte_end);
251 }
252 }
253
254 (row_start..ByteOffset::end_of(text), None)
255}
256
257fn byte_offset_for_col(text: &str, range: Range<ByteOffset>, target_col: Col) -> ByteOffset {
258 let mut col = Col::ZERO;
259 let mut byte = range.start;
260 for ch in text.slice(range.clone()).chars() {
261 if col >= target_col {
262 return byte;
263 }
264 col += col_of(ch);
265 byte += ch.len_utf8();
266 if col >= target_col {
267 return byte;
268 }
269 }
270 range.end
271}
272
273fn clamp_to_char_boundary(text: &str, byte_offset: usize) -> ByteOffset {
274 let mut byte_offset = byte_offset.min(text.len());
275 while !text.is_char_boundary(byte_offset) {
276 byte_offset = byte_offset.saturating_sub(1);
277 }
278 ByteOffset(byte_offset)
279}
280
281fn display_width_text_until(text: &str, range: Range<ByteOffset>, byte_offset: ByteOffset) -> Col {
282 text.slice(range.clone())
283 .char_indices()
284 .take_while(|(offset, ch)| range.start + *offset + ch.len_utf8() <= byte_offset)
285 .map(|(_, ch)| col_of(ch))
286 .sum()
287}
288
289fn col_of(ch: char) -> Col {
290 Col(UnicodeWidthChar::width(ch).unwrap_or(0))
291}
292
293#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
295struct ByteOffset(usize);
296
297impl ByteOffset {
298 const ZERO: Self = Self(0);
299 fn end_of(text: &str) -> Self {
300 Self(text.len())
301 }
302}
303
304impl Add<usize> for ByteOffset {
305 type Output = Self;
306 fn add(self, rhs: usize) -> Self {
307 Self(self.0 + rhs)
308 }
309}
310
311impl AddAssign<usize> for ByteOffset {
312 fn add_assign(&mut self, rhs: usize) {
313 self.0 += rhs;
314 }
315}
316
317#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
319struct Col(usize);
320
321impl Col {
322 const ZERO: Self = Self(0);
323}
324
325impl Add for Col {
326 type Output = Self;
327 fn add(self, rhs: Self) -> Self {
328 Self(self.0 + rhs.0)
329 }
330}
331
332impl AddAssign for Col {
333 fn add_assign(&mut self, rhs: Self) {
334 self.0 += rhs.0;
335 }
336}
337
338impl Sum for Col {
339 fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
340 Self(iter.map(|c| c.0).sum())
341 }
342}
343
344#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
346struct Row(usize);
347
348impl Row {
349 const ZERO: Self = Self(0);
350}
351
352impl AddAssign<usize> for Row {
353 fn add_assign(&mut self, rhs: usize) {
354 self.0 += rhs;
355 }
356}
357
358trait ByteSlice {
360 fn slice(&self, range: Range<ByteOffset>) -> &str;
361 fn slice_from(&self, start: ByteOffset) -> &str;
362 fn slice_to(&self, end: ByteOffset) -> &str;
363}
364
365impl ByteSlice for str {
366 fn slice(&self, range: Range<ByteOffset>) -> &str {
367 &self[range.start.0..range.end.0]
368 }
369 fn slice_from(&self, start: ByteOffset) -> &str {
370 &self[start.0..]
371 }
372 fn slice_to(&self, end: ByteOffset) -> &str {
373 &self[..end.0]
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crossterm::style::Color;
381
382 #[test]
383 fn wraps_ascii_to_width() {
384 let rows = soft_wrap_line(&Line::new("abcdef"), 3);
385 assert_eq!(rows, vec![Line::new("abc"), Line::new("def")]);
386 }
387
388 #[test]
389 fn display_width_ignores_style() {
390 let mut line = Line::default();
391 line.push_styled("he", Color::Red);
392 line.push_text("llo");
393 assert_eq!(display_width_line(&line), 5);
394 }
395
396 #[test]
397 fn wraps_preserving_style_spans() {
398 let line = Line::styled("abcdef", Color::Red);
399 let rows = soft_wrap_line(&line, 3);
400 assert_eq!(rows.len(), 2);
401 assert_eq!(rows[0].plain_text(), "abc");
402 assert_eq!(rows[1].plain_text(), "def");
403 assert_eq!(rows[0].spans().len(), 1);
404 assert_eq!(rows[1].spans().len(), 1);
405 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
406 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
407 }
408
409 #[test]
410 fn counts_wide_unicode() {
411 assert_eq!(display_width_text("中a"), 3);
412 let rows = soft_wrap_line(&Line::new("中ab"), 3);
413 assert_eq!(rows, vec![Line::new("中a"), Line::new("b")]);
414 }
415
416 #[test]
417 fn wraps_multi_span_line_mid_span() {
418 let mut line = Line::default();
419 line.push_styled("ab", Color::Red);
420 line.push_styled("cd", Color::Blue);
421 line.push_styled("ef", Color::Green);
422 let rows = soft_wrap_line(&line, 3);
423 assert_eq!(rows.len(), 2);
424 assert_eq!(rows[0].plain_text(), "abc");
425 assert_eq!(rows[1].plain_text(), "def");
426 assert_eq!(rows[0].spans().len(), 2);
428 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
429 assert_eq!(rows[0].spans()[1].style().fg, Some(Color::Blue));
430 assert_eq!(rows[1].spans().len(), 2);
432 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
433 assert_eq!(rows[1].spans()[1].style().fg, Some(Color::Green));
434 }
435
436 #[test]
437 fn wraps_line_with_embedded_newlines() {
438 let line = Line::new("abc\ndef");
439 let rows = soft_wrap_line(&line, 80);
440 assert_eq!(rows.len(), 2);
441 assert_eq!(rows[0].plain_text(), "abc");
442 assert_eq!(rows[1].plain_text(), "def");
443 }
444
445 #[test]
446 fn pad_text_pads_ascii_to_target_width() {
447 let result = pad_text_to_width("hello", 10);
448 assert_eq!(result, "hello ");
449 assert_eq!(display_width_text(&result), 10);
450 }
451
452 #[test]
453 fn pad_text_returns_borrowed_when_already_wide_enough() {
454 let result = pad_text_to_width("hello", 5);
455 assert!(matches!(result, Cow::Borrowed(_)));
456 assert_eq!(result, "hello");
457
458 let result = pad_text_to_width("hello", 3);
459 assert!(matches!(result, Cow::Borrowed(_)));
460 assert_eq!(result, "hello");
461 }
462
463 #[test]
464 fn pad_text_handles_wide_unicode() {
465 let result = pad_text_to_width("中a", 6);
467 assert_eq!(display_width_text(&result), 6);
468 assert_eq!(result, "中a "); }
470
471 #[test]
472 fn truncate_text_fits_within_width() {
473 assert_eq!(truncate_text("hello", 10), "hello");
474 assert_eq!(truncate_text("hello world", 8), "hello...");
475 assert_eq!(truncate_text("hello", 5), "hello");
476 assert_eq!(truncate_text("hello", 4), "h...");
477 }
478
479 #[test]
480 fn truncate_text_handles_wide_unicode() {
481 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..."); }
487
488 #[test]
489 fn truncate_text_handles_zero_width() {
490 assert_eq!(truncate_text("hello", 0), "");
491 }
492
493 #[test]
494 fn truncate_text_max_width_1() {
495 let result = truncate_text("hello", 1);
496 assert!(
497 display_width_text(&result) <= 1,
498 "Expected width <= 1, got '{}' (width {})",
499 result,
500 display_width_text(&result),
501 );
502 assert_eq!(result, "h");
503 }
504
505 #[test]
506 fn truncate_text_max_width_2() {
507 let result = truncate_text("hello", 2);
508 assert!(
509 display_width_text(&result) <= 2,
510 "Expected width <= 2, got '{}' (width {})",
511 result,
512 display_width_text(&result),
513 );
514 assert_eq!(result, "he");
515 }
516
517 #[test]
518 fn truncate_line_returns_short_lines_unchanged() {
519 let line = Line::new("short");
520 let result = truncate_line(&line, 20);
521 assert_eq!(result.plain_text(), "short");
522 }
523
524 #[test]
525 fn truncate_line_trims_long_styled_lines() {
526 let mut line = Line::default();
527 line.push_styled("hello", Color::Red);
528 line.push_styled(" world", Color::Blue);
529 let result = truncate_line(&line, 7);
530 assert_eq!(result.plain_text(), "hello w");
531 assert_eq!(result.spans().len(), 2);
532 assert_eq!(result.spans()[0].style().fg, Some(Color::Red));
533 assert_eq!(result.spans()[1].style().fg, Some(Color::Blue));
534 }
535
536 #[test]
537 fn truncate_line_handles_mid_span_cut() {
538 let line = Line::styled("abcdefgh", Color::Green);
539 let result = truncate_line(&line, 4);
540 assert_eq!(result.plain_text(), "abcd");
541 assert_eq!(result.spans()[0].style().fg, Some(Color::Green));
542 }
543
544 #[test]
545 fn truncate_line_handles_wide_unicode_at_boundary() {
546 let line = Line::new("中文x");
549 let result = truncate_line(&line, 3);
550 assert_eq!(result.plain_text(), "中");
551
552 let result = truncate_line(&line, 4);
554 assert_eq!(result.plain_text(), "中文");
555
556 let result = truncate_line(&line, 5);
558 assert_eq!(result.plain_text(), "中文x");
559 }
560
561 #[test]
562 fn truncate_line_zero_width_returns_empty() {
563 let line = Line::new("hello");
564 let result = truncate_line(&line, 0);
565 assert!(result.is_empty());
566 }
567
568 #[test]
569 fn soft_wrap_text_position_uses_word_boundaries() {
570 assert_eq!(soft_wrap_text_position("hello world", "hello ".len(), 7), (1, 0));
571 assert_eq!(soft_wrap_text_position("hello world", "hello world".len(), 7), (1, 5));
572 }
573
574 #[test]
575 fn soft_wrap_text_byte_offset_uses_word_boundaries() {
576 assert_eq!(soft_wrap_text_byte_offset("hello world", 0, 5, 7), 5);
577 assert_eq!(soft_wrap_text_byte_offset("hello world", 1, 3, 7), 9);
578 }
579
580 #[test]
581 fn soft_wrap_text_position_handles_trailing_newline() {
582 assert_eq!(soft_wrap_text_position("hello\n", "hello\n".len(), 10), (1, 0));
583 }
584
585 #[test]
586 fn soft_wrap_text_position_places_hard_wrap_boundary_on_next_row() {
587 assert_eq!(soft_wrap_text_position("abcdef", 3, 3), (1, 0));
588 }
589
590 #[test]
591 fn wraps_at_word_boundary() {
592 let rows = soft_wrap_line(&Line::new("hello world"), 7);
593 assert_eq!(rows.len(), 2);
594 assert_eq!(rows[0].plain_text(), "hello");
595 assert_eq!(rows[1].plain_text(), "world");
596 }
597
598 #[test]
599 fn wraps_multiple_words() {
600 let rows = soft_wrap_line(&Line::new("hello world foo"), 12);
601 assert_eq!(rows.len(), 2);
602 assert_eq!(rows[0].plain_text(), "hello world");
603 assert_eq!(rows[1].plain_text(), "foo");
604 }
605
606 #[test]
607 fn falls_back_to_char_break_without_whitespace() {
608 let rows = soft_wrap_line(&Line::new("superlongword next"), 5);
609 assert_eq!(rows[0].plain_text(), "super");
610 assert_eq!(rows[1].plain_text(), "longw");
611 assert_eq!(rows[2].plain_text(), "ord");
612 assert_eq!(rows[3].plain_text(), "next");
613 }
614
615 #[test]
616 fn wraps_at_word_boundary_with_styled_spans() {
617 let line = Line::styled("hello world", Color::Red);
618 let rows = soft_wrap_line(&line, 7);
619 assert_eq!(rows.len(), 2);
620 assert_eq!(rows[0].plain_text(), "hello");
621 assert_eq!(rows[1].plain_text(), "world");
622 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
623 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Red));
624 }
625
626 #[test]
627 fn wraps_at_whitespace_across_span_boundaries() {
628 let mut line = Line::default();
629 line.push_styled("@aaaaa", Color::Red);
630 line.push_text(" ");
631 line.push_styled("@bbbbbb", Color::Blue);
632
633 let rows = soft_wrap_line(&line, 10);
634
635 assert_eq!(rows.len(), 2);
636 assert_eq!(rows[0].plain_text(), "@aaaaa");
637 assert_eq!(rows[1].plain_text(), "@bbbbbb");
638 assert_eq!(rows[0].spans()[0].style().fg, Some(Color::Red));
639 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
640 }
641
642 #[test]
643 fn hard_wraps_long_styled_token_without_whitespace() {
644 let line = Line::styled("@abcdefghijk", Color::Green);
645 let rows = soft_wrap_line(&line, 5);
646
647 assert_eq!(rows.len(), 3);
648 assert_eq!(rows[0].plain_text(), "@abcd");
649 assert_eq!(rows[1].plain_text(), "efghi");
650 assert_eq!(rows[2].plain_text(), "jk");
651 for row in &rows {
652 assert_eq!(row.spans()[0].style().fg, Some(Color::Green));
653 }
654 }
655
656 #[test]
657 fn drops_whitespace_when_new_span_starts_at_wrap_boundary() {
658 let mut line = Line::default();
659 line.push_styled("abcdefghij", Color::Red);
660 line.push_styled(" klm", Color::Blue);
661 let rows = soft_wrap_line(&line, 10);
662
663 assert_eq!(rows.len(), 2);
664 assert_eq!(rows[0].plain_text(), "abcdefghij");
665 assert_eq!(rows[1].plain_text(), "klm");
666 assert_eq!(rows[1].spans()[0].style().fg, Some(Color::Blue));
667 }
668
669 #[test]
670 fn soft_wrap_propagates_fill_to_each_wrapped_row() {
671 let line = Line::new("abcdef").with_fill(Color::Red);
672 let rows = soft_wrap_line(&line, 3);
673 assert_eq!(rows.len(), 2);
674 for row in &rows {
675 assert_eq!(row.fill(), Some(Color::Red));
676 }
677 }
678
679 #[test]
680 fn soft_wrap_preserves_fill_on_empty_line() {
681 let line = Line::default().with_fill(Color::Red);
682 let rows = soft_wrap_line(&line, 10);
683 assert_eq!(rows.len(), 1);
684 assert_eq!(rows[0].fill(), Some(Color::Red));
685 }
686
687 #[test]
688 fn truncate_line_preserves_fill_metadata() {
689 let line = Line::new("abcdef").with_fill(Color::Blue);
690 let truncated = truncate_line(&line, 3);
691 assert_eq!(truncated.plain_text(), "abc");
692 assert_eq!(truncated.fill(), Some(Color::Blue));
693 }
694
695 #[test]
696 fn wraps_across_spans_without_panic() {
697 let mut line = Line::default();
698 line.push_styled("hello ", Color::Red);
699 line.push_styled("world this is long", Color::Blue);
700 let rows = soft_wrap_line(&line, 10);
701 assert_eq!(rows[0].plain_text(), "hello");
702 assert_eq!(rows[1].plain_text(), "world this");
703 assert_eq!(rows[2].plain_text(), "is long");
704 }
705}