1use ratatui::text::Line;
35use ratatui::text::Span;
36use std::borrow::Cow;
37use std::ops::Range;
38use textwrap::core::display_width;
39use textwrap::core::Word;
40use textwrap::Options;
41use textwrap::WordSeparator;
42
43fn push_owned_lines(src: &[Line<'_>], out: &mut Vec<Line<'static>>) {
44 out.extend(src.iter().map(|line| {
45 Line {
46 style: line.style,
47 alignment: line.alignment,
48 spans: line
49 .spans
50 .iter()
51 .map(|span| Span::styled(span.content.to_string(), span.style))
52 .collect(),
53 }
54 }));
55}
56
57pub fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
61where
62 O: Into<Options<'a>>,
63{
64 let opts = width_or_options.into();
65 let mut lines: Vec<Range<usize>> = Vec::new();
66 let mut cursor = 0usize;
67 for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
68 match line {
69 std::borrow::Cow::Borrowed(slice) => {
70 let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
71 let synthetic_prefix = if line_index == 0 {
72 opts.initial_indent
73 } else {
74 opts.subsequent_indent
75 };
76 map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
77 });
78 let start = range.start;
79 let end = range.end;
80 let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
81 lines.push(start..end + trailing_spaces + 1);
82 cursor = end + trailing_spaces;
83 }
84 std::borrow::Cow::Owned(slice) => {
85 let synthetic_prefix = if line_index == 0 {
86 opts.initial_indent
87 } else {
88 opts.subsequent_indent
89 };
90 let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
91 let trailing_spaces = text[mapped.end..].chars().take_while(|c| *c == ' ').count();
92 lines.push(mapped.start..mapped.end + trailing_spaces + 1);
93 cursor = mapped.end + trailing_spaces;
94 }
95 }
96 }
97 lines
98}
99
100pub fn wrap_ranges_trim<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
104where
105 O: Into<Options<'a>>,
106{
107 let opts = width_or_options.into();
108 let mut lines: Vec<Range<usize>> = Vec::new();
109 let mut cursor = 0usize;
110 for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
111 match line {
112 std::borrow::Cow::Borrowed(slice) => {
113 let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
114 let synthetic_prefix = if line_index == 0 {
115 opts.initial_indent
116 } else {
117 opts.subsequent_indent
118 };
119 map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
120 });
121 cursor = range.end;
122 lines.push(range);
123 }
124 std::borrow::Cow::Owned(slice) => {
125 let synthetic_prefix = if line_index == 0 {
126 opts.initial_indent
127 } else {
128 opts.subsequent_indent
129 };
130 let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
131 lines.push(mapped.clone());
132 cursor = mapped.end;
133 }
134 }
135 }
136 lines
137}
138
139fn borrowed_slice_range(text: &str, slice: &str) -> Option<Range<usize>> {
140 let text_start = text.as_ptr() as usize;
141 let text_end = text_start.checked_add(text.len())?;
142 let slice_start = slice.as_ptr() as usize;
143 let slice_end = slice_start.checked_add(slice.len())?;
144
145 if slice_start < text_start || slice_end > text_end {
146 return None;
147 }
148
149 Some((slice_start - text_start)..(slice_end - text_start))
150}
151
152fn map_owned_wrapped_line_to_range(
160 text: &str,
161 cursor: usize,
162 wrapped: &str,
163 synthetic_prefix: &str,
164) -> Range<usize> {
165 let wrapped = if synthetic_prefix.is_empty() {
166 wrapped
167 } else {
168 wrapped.strip_prefix(synthetic_prefix).unwrap_or(wrapped)
169 };
170
171 let mut start = cursor;
172 while start < text.len() && !wrapped.starts_with(' ') {
173 let Some(ch) = text[start..].chars().next() else {
174 break;
175 };
176 if ch != ' ' {
177 break;
178 }
179 start += ch.len_utf8();
180 }
181
182 let mut end = start;
183 let mut saw_source_char = false;
184 let mut chars = wrapped.chars().peekable();
185 while let Some(ch) = chars.next() {
186 if end < text.len() {
187 let Some(src) = text[end..].chars().next() else {
188 unreachable!("checked end < text.len()");
189 };
190 if ch == src {
191 end += src.len_utf8();
192 saw_source_char = true;
193 continue;
194 }
195 }
196
197 if ch == '-' && chars.peek().is_none() {
201 continue;
202 }
203
204 if !saw_source_char {
208 continue;
209 }
210
211 tracing::warn!(
212 wrapped = %wrapped,
213 cursor,
214 end,
215 "wrap_ranges: could not fully map owned line; returning partial source range"
216 );
217 break;
218 }
219
220 start..end
221}
222
223pub fn line_contains_url_like(line: &Line<'_>) -> bool {
227 let text: String = line
228 .spans
229 .iter()
230 .map(|span| span.content.as_ref())
231 .collect();
232 text_contains_url_like(&text)
233}
234
235pub fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
241 let text: String = line
242 .spans
243 .iter()
244 .map(|span| span.content.as_ref())
245 .collect();
246 text_has_mixed_url_and_non_url_tokens(&text)
247}
248
249pub fn text_contains_url_like(text: &str) -> bool {
261 text.split_ascii_whitespace().any(is_url_like_token)
262}
263
264fn text_has_mixed_url_and_non_url_tokens(text: &str) -> bool {
267 let mut saw_url = false;
268 let mut saw_non_url = false;
269
270 for raw_token in text.split_ascii_whitespace() {
271 if is_url_like_token(raw_token) {
272 saw_url = true;
273 } else if is_substantive_non_url_token(raw_token) {
274 saw_non_url = true;
275 }
276
277 if saw_url && saw_non_url {
278 return true;
279 }
280 }
281
282 false
283}
284
285fn is_url_like_token(raw_token: &str) -> bool {
290 let token = trim_url_token(raw_token);
291 !token.is_empty() && (is_absolute_url_like(token) || is_bare_url_like(token))
292}
293
294fn is_substantive_non_url_token(raw_token: &str) -> bool {
295 let token = trim_url_token(raw_token);
296 if token.is_empty() || is_decorative_marker_token(raw_token, token) {
297 return false;
298 }
299
300 token.chars().any(char::is_alphanumeric)
301}
302
303fn is_decorative_marker_token(raw_token: &str, token: &str) -> bool {
304 let raw = raw_token.trim();
305 matches!(
306 raw,
307 "-" | "*"
308 | "+"
309 | "•"
310 | "◦"
311 | "▪"
312 | ">"
313 | "|"
314 | "│"
315 | "┆"
316 | "└"
317 | "├"
318 | "┌"
319 | "┐"
320 | "┘"
321 | "┼"
322 ) || is_ordered_list_marker(raw, token)
323}
324
325fn is_ordered_list_marker(raw_token: &str, token: &str) -> bool {
326 token.chars().all(|c| c.is_ascii_digit())
327 && (raw_token.ends_with('.') || raw_token.ends_with(')'))
328}
329
330fn trim_url_token(token: &str) -> &str {
331 token.trim_matches(|c: char| {
332 matches!(
333 c,
334 '(' | ')'
335 | '['
336 | ']'
337 | '{'
338 | '}'
339 | '<'
340 | '>'
341 | ','
342 | '.'
343 | ';'
344 | ':'
345 | '!'
346 | '\''
347 | '"'
348 )
349 })
350}
351
352fn is_absolute_url_like(token: &str) -> bool {
356 if !token.contains("://") {
357 return false;
358 }
359
360 if let Ok(url) = url::Url::parse(token) {
361 let scheme = url.scheme().to_ascii_lowercase();
362 if matches!(
363 scheme.as_str(),
364 "http" | "https" | "ftp" | "ftps" | "ws" | "wss"
365 ) {
366 return url.host_str().is_some();
367 }
368 return true;
369 }
370
371 has_valid_scheme_prefix(token)
372}
373
374fn has_valid_scheme_prefix(token: &str) -> bool {
375 let Some((scheme, rest)) = token.split_once("://") else {
376 return false;
377 };
378 if scheme.is_empty() || rest.is_empty() {
379 return false;
380 }
381
382 let mut chars = scheme.chars();
383 let Some(first) = chars.next() else {
384 return false;
385 };
386 first.is_ascii_alphabetic()
387 && chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
388}
389
390fn is_bare_url_like(token: &str) -> bool {
399 let (host_port, has_trailer) = split_host_port_and_trailer(token);
400 if host_port.is_empty() {
401 return false;
402 }
403
404 if !has_trailer && !host_port.to_ascii_lowercase().starts_with("www.") {
406 return false;
407 }
408
409 let (host, port) = split_host_and_port(host_port);
410 if host.is_empty() {
411 return false;
412 }
413 if let Some(port) = port {
414 if !is_valid_port(port) {
415 return false;
416 }
417 }
418
419 host.eq_ignore_ascii_case("localhost") || is_ipv4(host) || is_domain_name(host)
420}
421
422fn split_host_port_and_trailer(token: &str) -> (&str, bool) {
423 if let Some(idx) = token.find(['/', '?', '#']) {
424 (&token[..idx], true)
425 } else {
426 (token, false)
427 }
428}
429
430fn split_host_and_port(host_port: &str) -> (&str, Option<&str>) {
431 if host_port.starts_with('[') {
433 return (host_port, None);
434 }
435
436 if let Some((host, port)) = host_port.rsplit_once(':') {
437 if !host.is_empty() && !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
438 return (host, Some(port));
439 }
440 }
441
442 (host_port, None)
443}
444
445fn is_valid_port(port: &str) -> bool {
446 if port.is_empty() || port.len() > 5 || !port.chars().all(|c| c.is_ascii_digit()) {
447 return false;
448 }
449
450 port.parse::<u16>().is_ok()
451}
452
453fn is_ipv4(host: &str) -> bool {
454 let parts: Vec<&str> = host.split('.').collect();
455 if parts.len() != 4 {
456 return false;
457 }
458
459 parts
460 .iter()
461 .all(|part| !part.is_empty() && part.parse::<u8>().is_ok())
462}
463
464fn is_domain_name(host: &str) -> bool {
465 let host = host.to_ascii_lowercase();
466 if !host.contains('.') {
467 return false;
468 }
469
470 let mut labels = host.split('.');
471 let Some(tld) = labels.next_back() else {
472 return false;
473 };
474 if !is_tld(tld) {
475 return false;
476 }
477
478 labels.all(is_domain_label)
479}
480
481fn is_tld(label: &str) -> bool {
482 (2..=63).contains(&label.len()) && label.chars().all(|c| c.is_ascii_alphabetic())
483}
484
485fn is_domain_label(label: &str) -> bool {
486 if label.is_empty() || label.len() > 63 {
487 return false;
488 }
489
490 let mut chars = label.chars();
491 let Some(first) = chars.next() else {
492 return false;
493 };
494 let Some(last) = label.chars().next_back() else {
495 return false;
496 };
497
498 first.is_ascii_alphanumeric()
499 && last.is_ascii_alphanumeric()
500 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
501}
502
503pub fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<'a> {
510 opts.word_separator(textwrap::WordSeparator::AsciiSpace)
511 .word_splitter(textwrap::WordSplitter::NoHyphenation)
512 .break_words(false)
513}
514
515#[must_use]
524pub fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec<Line<'a>> {
525 if !line_contains_url_like(line) {
526 return word_wrap_line(line, base);
527 }
528
529 if line_has_mixed_url_and_non_url_tokens(line) {
530 mixed_url_wrap_line(line, base)
531 } else {
532 word_wrap_line(line, url_preserving_wrap_options(base))
533 }
534}
535
536#[allow(private_bounds)]
544pub fn adaptive_wrap_lines<'a, I, L>(
545 lines: I,
546 width_or_options: RtOptions<'a>,
547) -> Vec<Line<'static>>
548where
549 I: IntoIterator<Item = L>,
550 L: IntoLineInput<'a>,
551{
552 let base_opts = width_or_options;
553 let mut out: Vec<Line<'static>> = Vec::new();
554
555 for (idx, line) in lines.into_iter().enumerate() {
556 let line_input = line.into_line_input();
557 let opts = if idx == 0 {
558 base_opts.clone()
559 } else {
560 base_opts
561 .clone()
562 .initial_indent(base_opts.subsequent_indent.clone())
563 };
564
565 let wrapped = adaptive_wrap_line(line_input.as_ref(), opts);
566 push_owned_lines(&wrapped, &mut out);
567 }
568
569 out
570}
571
572#[derive(Debug, Clone)]
573pub struct RtOptions<'a> {
574 pub width: usize,
576 pub line_ending: textwrap::LineEnding,
578 pub initial_indent: Line<'a>,
581 pub subsequent_indent: Line<'a>,
584 pub break_words: bool,
588 pub wrap_algorithm: textwrap::WrapAlgorithm,
591 pub word_separator: textwrap::WordSeparator,
594 pub word_splitter: textwrap::WordSplitter,
598}
599impl From<usize> for RtOptions<'_> {
600 fn from(width: usize) -> Self {
601 RtOptions::new(width)
602 }
603}
604
605#[allow(dead_code)]
606impl<'a> RtOptions<'a> {
607 pub fn new(width: usize) -> Self {
608 RtOptions {
609 width,
610 line_ending: textwrap::LineEnding::LF,
611 initial_indent: Line::default(),
612 subsequent_indent: Line::default(),
613 break_words: true,
614 word_separator: textwrap::WordSeparator::new(),
615 wrap_algorithm: textwrap::WrapAlgorithm::FirstFit,
616 word_splitter: textwrap::WordSplitter::HyphenSplitter,
617 }
618 }
619
620 pub fn line_ending(self, line_ending: textwrap::LineEnding) -> Self {
621 RtOptions {
622 line_ending,
623 ..self
624 }
625 }
626
627 pub fn width(self, width: usize) -> Self {
628 RtOptions { width, ..self }
629 }
630
631 pub fn initial_indent(self, initial_indent: Line<'a>) -> Self {
632 RtOptions {
633 initial_indent,
634 ..self
635 }
636 }
637
638 pub fn subsequent_indent(self, subsequent_indent: Line<'a>) -> Self {
639 RtOptions {
640 subsequent_indent,
641 ..self
642 }
643 }
644
645 pub fn break_words(self, break_words: bool) -> Self {
646 RtOptions {
647 break_words,
648 ..self
649 }
650 }
651
652 pub fn word_separator(self, word_separator: textwrap::WordSeparator) -> RtOptions<'a> {
653 RtOptions {
654 word_separator,
655 ..self
656 }
657 }
658
659 pub fn wrap_algorithm(self, wrap_algorithm: textwrap::WrapAlgorithm) -> RtOptions<'a> {
660 RtOptions {
661 wrap_algorithm,
662 ..self
663 }
664 }
665
666 pub fn word_splitter(self, word_splitter: textwrap::WordSplitter) -> RtOptions<'a> {
667 RtOptions {
668 word_splitter,
669 ..self
670 }
671 }
672}
673
674#[must_use]
675pub fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
676where
677 O: Into<RtOptions<'a>>,
678{
679 let (flat, span_bounds) = flatten_line(line);
680
681 let rt_opts: RtOptions<'a> = width_or_options.into();
682 let opts = Options::new(rt_opts.width)
683 .line_ending(rt_opts.line_ending)
684 .break_words(rt_opts.break_words)
685 .wrap_algorithm(rt_opts.wrap_algorithm)
686 .word_separator(rt_opts.word_separator)
687 .word_splitter(rt_opts.word_splitter);
688
689 let mut out: Vec<Line<'a>> = Vec::new();
690
691 let initial_width_available = opts
693 .width
694 .saturating_sub(rt_opts.initial_indent.width())
695 .max(1);
696 let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
697 let Some(first_line_range) = initial_wrapped.first() else {
698 return vec![rt_opts.initial_indent.clone()];
699 };
700
701 let mut first_line = rt_opts.initial_indent.clone().style(line.style);
703 {
704 let sliced = slice_line_spans(line, &span_bounds, first_line_range);
705 let mut spans = first_line.spans;
706 spans.append(
707 &mut sliced
708 .spans
709 .into_iter()
710 .map(|s| s.patch_style(line.style))
711 .collect(),
712 );
713 first_line.spans = spans;
714 out.push(first_line);
715 }
716
717 let base = first_line_range.end;
719 let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
720 let base = base + skip_leading_spaces;
721 let subsequent_width_available = opts
722 .width
723 .saturating_sub(rt_opts.subsequent_indent.width())
724 .max(1);
725 let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
726 for r in &remaining_wrapped {
727 if r.is_empty() {
728 continue;
729 }
730 let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
731 let offset_range = (r.start + base)..(r.end + base);
732 let sliced = slice_line_spans(line, &span_bounds, &offset_range);
733 let mut spans = subsequent_line.spans;
734 spans.append(
735 &mut sliced
736 .spans
737 .into_iter()
738 .map(|s| s.patch_style(line.style))
739 .collect(),
740 );
741 subsequent_line.spans = spans;
742 out.push(subsequent_line);
743 }
744
745 out
746}
747
748#[derive(Clone, Debug)]
749struct MixedUrlWord {
750 range: Range<usize>,
751 is_url: bool,
752}
753
754impl MixedUrlWord {
755 fn width(&self, text: &str) -> usize {
756 display_width(&text[self.range.clone()])
757 }
758}
759
760fn mixed_url_wrap_line<'a>(line: &'a Line<'a>, rt_opts: RtOptions<'a>) -> Vec<Line<'a>> {
761 let (flat, span_bounds) = flatten_line(line);
762 let initial_width_available = rt_opts
763 .width
764 .saturating_sub(rt_opts.initial_indent.width())
765 .max(1);
766 let subsequent_width_available = rt_opts
767 .width
768 .saturating_sub(rt_opts.subsequent_indent.width())
769 .max(1);
770 let ranges = mixed_url_wrap_ranges(&flat, initial_width_available, subsequent_width_available);
771
772 let mut out = Vec::new();
773 for (idx, range) in ranges.iter().enumerate() {
774 let mut wrapped_line = if idx == 0 {
775 rt_opts.initial_indent.clone()
776 } else {
777 rt_opts.subsequent_indent.clone()
778 }
779 .style(line.style);
780 let sliced = slice_line_spans(line, &span_bounds, range);
781 let mut spans = wrapped_line.spans;
782 spans.extend(
783 sliced
784 .spans
785 .into_iter()
786 .map(|span| span.patch_style(line.style)),
787 );
788 wrapped_line.spans = spans;
789 out.push(wrapped_line);
790 }
791
792 if out.is_empty() {
793 vec![rt_opts.initial_indent.clone()]
794 } else {
795 out
796 }
797}
798
799fn mixed_url_wrap_ranges(
800 text: &str,
801 initial_width: usize,
802 subsequent_width: usize,
803) -> Vec<Range<usize>> {
804 let leading_space_width = text.chars().take_while(|ch| *ch == ' ').count();
805 let mut words = Vec::new();
806 let mut cursor = 0usize;
807 for word in WordSeparator::AsciiSpace.find_words(text) {
808 let word_start = cursor;
809 let word_end = word_start + word.word.len();
810 let trailing_space_end = word_end + word.whitespace.len();
811 if !word.word.is_empty() {
812 words.push(MixedUrlWord {
813 range: word_start..word_end,
814 is_url: is_url_like_token(word.word),
815 });
816 }
817 cursor = trailing_space_end;
818 }
819
820 let mut lines = Vec::new();
821 let mut line_start = None;
822 let mut line_end = 0usize;
823 let mut line_width = 0usize;
824 let mut line_limit = initial_width.max(1);
825
826 for word in words {
827 let mut pending = split_mixed_url_word(text, word, line_limit);
828 let mut pending_idx = 0usize;
829
830 while let Some(piece) = pending.get(pending_idx).cloned() {
831 let empty_line_prefix_width = if line_start.is_none() && lines.is_empty() {
832 leading_space_width
833 } else {
834 0
835 };
836 let empty_line_piece_limit = line_limit.saturating_sub(empty_line_prefix_width).max(1);
837 if line_start.is_none() && !piece.is_url && piece.width(text) > empty_line_piece_limit {
838 pending.splice(
839 pending_idx..=pending_idx,
840 split_mixed_url_word(text, piece, empty_line_piece_limit),
841 );
842 continue;
843 }
844
845 let piece_width = piece.width(text);
846 let inter_word_space = line_start
847 .map(|_| text[line_end..piece.range.start].len())
848 .unwrap_or(0);
849 let fits = if line_start.is_none() {
850 piece.is_url
851 || empty_line_prefix_width + piece_width <= line_limit
852 || empty_line_prefix_width >= line_limit
853 } else {
854 line_width + inter_word_space + piece_width <= line_limit
855 };
856
857 if fits {
858 if line_start.is_none() {
859 let is_first_output_line = lines.is_empty();
860 let start = if is_first_output_line {
861 0
862 } else {
863 piece.range.start
864 };
865 line_start = Some(start);
866 line_width = if is_first_output_line {
867 leading_space_width + piece_width
868 } else {
869 piece_width
870 };
871 } else {
872 line_width += inter_word_space + piece_width;
873 }
874 line_end = piece.range.end;
875 pending_idx += 1;
876 continue;
877 }
878
879 if let Some(start) = line_start.take() {
880 lines.push(start..line_end);
881 }
882 line_end = 0;
883 line_width = 0;
884 line_limit = subsequent_width.max(1);
885 }
886 }
887
888 if let Some(start) = line_start {
889 lines.push(start..line_end);
890 }
891
892 lines
893}
894
895fn split_mixed_url_word(text: &str, word: MixedUrlWord, line_limit: usize) -> Vec<MixedUrlWord> {
896 if word.is_url || word.width(text) <= line_limit {
897 return vec![word];
898 }
899
900 let source = Word::from(&text[word.range.clone()]);
901 let mut offset = word.range.start;
902 let mut pieces = Vec::new();
903 for piece in source.break_apart(line_limit.max(1)) {
904 let end = offset + piece.word.len();
905 pieces.push(MixedUrlWord {
906 range: offset..end,
907 is_url: false,
908 });
909 offset = end;
910 }
911 pieces
912}
913
914fn flatten_line(line: &Line<'_>) -> (String, Vec<(Range<usize>, ratatui::style::Style)>) {
915 let mut flat = String::new();
916 let mut span_bounds = Vec::new();
917 let mut acc = 0usize;
918 for span in &line.spans {
919 let text = span.content.as_ref();
920 let start = acc;
921 flat.push_str(text);
922 acc += text.len();
923 span_bounds.push((start..acc, span.style));
924 }
925 (flat, span_bounds)
926}
927
928#[derive(Debug)]
930enum LineInput<'a> {
931 Borrowed(&'a Line<'a>),
932 Owned(Line<'a>),
933}
934
935impl<'a> LineInput<'a> {
936 fn as_ref(&self) -> &Line<'a> {
937 match self {
938 LineInput::Borrowed(line) => line,
939 LineInput::Owned(line) => line,
940 }
941 }
942}
943
944trait IntoLineInput<'a> {
946 fn into_line_input(self) -> LineInput<'a>;
947}
948
949impl<'a> IntoLineInput<'a> for &'a Line<'a> {
950 fn into_line_input(self) -> LineInput<'a> {
951 LineInput::Borrowed(self)
952 }
953}
954
955impl<'a> IntoLineInput<'a> for &'a mut Line<'a> {
956 fn into_line_input(self) -> LineInput<'a> {
957 LineInput::Borrowed(self)
958 }
959}
960
961impl<'a> IntoLineInput<'a> for Line<'a> {
962 fn into_line_input(self) -> LineInput<'a> {
963 LineInput::Owned(self)
964 }
965}
966
967impl<'a> IntoLineInput<'a> for String {
968 fn into_line_input(self) -> LineInput<'a> {
969 LineInput::Owned(Line::from(self))
970 }
971}
972
973impl<'a> IntoLineInput<'a> for &'a str {
974 fn into_line_input(self) -> LineInput<'a> {
975 LineInput::Owned(Line::from(self))
976 }
977}
978
979impl<'a> IntoLineInput<'a> for Cow<'a, str> {
980 fn into_line_input(self) -> LineInput<'a> {
981 LineInput::Owned(Line::from(self))
982 }
983}
984
985impl<'a> IntoLineInput<'a> for Span<'a> {
986 fn into_line_input(self) -> LineInput<'a> {
987 LineInput::Owned(Line::from(self))
988 }
989}
990
991impl<'a> IntoLineInput<'a> for Vec<Span<'a>> {
992 fn into_line_input(self) -> LineInput<'a> {
993 LineInput::Owned(Line::from(self))
994 }
995}
996
997#[allow(private_bounds)] pub fn word_wrap_lines<'a, I, O, L>(lines: I, width_or_options: O) -> Vec<Line<'static>>
1001where
1002 I: IntoIterator<Item = L>,
1003 L: IntoLineInput<'a>,
1004 O: Into<RtOptions<'a>>,
1005{
1006 let base_opts: RtOptions<'a> = width_or_options.into();
1007 let mut out: Vec<Line<'static>> = Vec::new();
1008
1009 for (idx, line) in lines.into_iter().enumerate() {
1010 let line_input = line.into_line_input();
1011 let opts = if idx == 0 {
1012 base_opts.clone()
1013 } else {
1014 let mut o = base_opts.clone();
1015 let sub = o.subsequent_indent.clone();
1016 o = o.initial_indent(sub);
1017 o
1018 };
1019 let wrapped = word_wrap_line(line_input.as_ref(), opts);
1020 push_owned_lines(&wrapped, &mut out);
1021 }
1022
1023 out
1024}
1025
1026#[allow(dead_code)]
1027pub fn word_wrap_lines_borrowed<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'a>>
1028where
1029 I: IntoIterator<Item = &'a Line<'a>>,
1030 O: Into<RtOptions<'a>>,
1031{
1032 let base_opts: RtOptions<'a> = width_or_options.into();
1033 let mut out: Vec<Line<'a>> = Vec::new();
1034 let mut first = true;
1035 for line in lines.into_iter() {
1036 let opts = if first {
1037 base_opts.clone()
1038 } else {
1039 base_opts
1040 .clone()
1041 .initial_indent(base_opts.subsequent_indent.clone())
1042 };
1043 out.extend(word_wrap_line(line, opts));
1044 first = false;
1045 }
1046 out
1047}
1048
1049fn slice_line_spans<'a>(
1050 original: &'a Line<'a>,
1051 span_bounds: &[(Range<usize>, ratatui::style::Style)],
1052 range: &Range<usize>,
1053) -> Line<'a> {
1054 let start_byte = range.start;
1055 let end_byte = range.end;
1056 let mut acc: Vec<Span<'a>> = Vec::new();
1057 for (i, (range, style)) in span_bounds.iter().enumerate() {
1058 let s = range.start;
1059 let e = range.end;
1060 if e <= start_byte {
1061 continue;
1062 }
1063 if s >= end_byte {
1064 break;
1065 }
1066 let seg_start = start_byte.max(s);
1067 let seg_end = end_byte.min(e);
1068 if seg_end > seg_start {
1069 let local_start = seg_start - s;
1070 let local_end = seg_end - s;
1071 let content = original.spans[i].content.as_ref();
1072 let slice = &content[local_start..local_end];
1073 acc.push(Span {
1074 style: *style,
1075 content: std::borrow::Cow::Borrowed(slice),
1076 });
1077 }
1078 if e >= end_byte {
1079 break;
1080 }
1081 }
1082 Line {
1083 style: original.style,
1084 alignment: original.alignment,
1085 spans: acc,
1086 }
1087}