1use ratatui::text::Line;
2use ratatui::text::Span;
3use std::ops::Range;
4use textwrap::Options;
5use textwrap::wrap_algorithms::Penalties;
6
7use crate::render::line_utils::push_owned_lines;
8
9#[allow(dead_code)]
10pub(crate) fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
11where
12 O: Into<Options<'a>>,
13{
14 let opts = width_or_options.into();
15 let mut lines: Vec<Range<usize>> = Vec::new();
16 let text_start = text.as_ptr() as usize;
17 let text_end = text_start + text.len();
18 for line in textwrap::wrap(text, opts).iter() {
19 match line {
20 std::borrow::Cow::Borrowed(slice) => {
21 let slice_addr = slice.as_ptr() as usize;
22 if slice_addr < text_start || slice_addr > text_end {
27 continue;
28 }
29 let start = slice_addr - text_start;
30 let end = start + slice.len();
31 let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
32 lines.push(start..end + trailing_spaces);
33 }
34 std::borrow::Cow::Owned(_) => panic!("wrap_ranges: unexpected owned string"),
35 }
36 }
37 lines
38}
39
40pub(crate) fn wrap_ranges_trim<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
44where
45 O: Into<Options<'a>>,
46{
47 let opts = width_or_options.into();
48 let mut lines: Vec<Range<usize>> = Vec::new();
49 let text_start = text.as_ptr() as usize;
50 let text_end = text_start + text.len();
51 for line in textwrap::wrap(text, opts).iter() {
52 match line {
53 std::borrow::Cow::Borrowed(slice) => {
54 let slice_addr = slice.as_ptr() as usize;
55 if slice_addr < text_start || slice_addr > text_end {
56 continue;
57 }
58 let start = slice_addr - text_start;
59 let end = start + slice.len();
60 lines.push(start..end);
61 }
62 std::borrow::Cow::Owned(_) => panic!("wrap_ranges_trim: unexpected owned string"),
63 }
64 }
65 lines
66}
67
68#[derive(Debug, Clone)]
69pub struct RtOptions<'a> {
70 pub width: usize,
72 pub line_ending: textwrap::LineEnding,
74 pub initial_indent: Line<'a>,
77 pub subsequent_indent: Line<'a>,
80 pub break_words: bool,
84 pub wrap_algorithm: textwrap::WrapAlgorithm,
87 pub word_separator: textwrap::WordSeparator,
90 pub word_splitter: textwrap::WordSplitter,
94}
95impl From<usize> for RtOptions<'_> {
96 fn from(width: usize) -> Self {
97 RtOptions::new(width)
98 }
99}
100
101#[allow(dead_code)]
102impl<'a> RtOptions<'a> {
103 pub fn new(width: usize) -> Self {
104 RtOptions {
105 width,
106 line_ending: textwrap::LineEnding::LF,
107 initial_indent: Line::default(),
108 subsequent_indent: Line::default(),
109 break_words: true,
110 word_separator: textwrap::WordSeparator::new(),
111 wrap_algorithm: textwrap::WrapAlgorithm::OptimalFit(Penalties {
112 overflow_penalty: usize::MAX / 4,
114 ..Default::default()
115 }),
116 word_splitter: textwrap::WordSplitter::HyphenSplitter,
117 }
118 }
119
120 pub fn line_ending(self, line_ending: textwrap::LineEnding) -> Self {
121 RtOptions {
122 line_ending,
123 ..self
124 }
125 }
126
127 pub fn width(self, width: usize) -> Self {
128 RtOptions { width, ..self }
129 }
130
131 pub fn initial_indent(self, initial_indent: Line<'a>) -> Self {
132 RtOptions {
133 initial_indent,
134 ..self
135 }
136 }
137
138 pub fn subsequent_indent(self, subsequent_indent: Line<'a>) -> Self {
139 RtOptions {
140 subsequent_indent,
141 ..self
142 }
143 }
144
145 pub fn break_words(self, break_words: bool) -> Self {
146 RtOptions {
147 break_words,
148 ..self
149 }
150 }
151
152 pub fn word_separator(self, word_separator: textwrap::WordSeparator) -> RtOptions<'a> {
153 RtOptions {
154 word_separator,
155 ..self
156 }
157 }
158
159 pub fn wrap_algorithm(self, wrap_algorithm: textwrap::WrapAlgorithm) -> RtOptions<'a> {
160 RtOptions {
161 wrap_algorithm,
162 ..self
163 }
164 }
165
166 pub fn word_splitter(self, word_splitter: textwrap::WordSplitter) -> RtOptions<'a> {
167 RtOptions {
168 word_splitter,
169 ..self
170 }
171 }
172}
173
174#[must_use]
175pub fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
176where
177 O: Into<RtOptions<'a>>,
178{
179 let mut flat = String::new();
181 let mut span_bounds = Vec::new();
182 let mut acc = 0usize;
183 for s in &line.spans {
184 let text = s.content.as_ref();
185 let start = acc;
186 flat.push_str(text);
187 acc += text.len();
188 span_bounds.push((start..acc, s.style));
189 }
190
191 let rt_opts: RtOptions<'a> = width_or_options.into();
192 let opts = Options::new(rt_opts.width)
193 .line_ending(rt_opts.line_ending)
194 .break_words(rt_opts.break_words)
195 .wrap_algorithm(rt_opts.wrap_algorithm)
196 .word_separator(rt_opts.word_separator)
197 .word_splitter(rt_opts.word_splitter);
198
199 let mut out: Vec<Line<'a>> = Vec::new();
200
201 let initial_width_available = opts
203 .width
204 .saturating_sub(rt_opts.initial_indent.width())
205 .max(1);
206 let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
207 let Some(first_line_range) = initial_wrapped.first() else {
208 return vec![rt_opts.initial_indent.clone()];
209 };
210
211 let mut first_line = rt_opts.initial_indent.clone().style(line.style);
213 {
214 let sliced = slice_line_spans(line, &span_bounds, first_line_range);
215 let mut spans = first_line.spans;
216 spans.append(
217 &mut sliced
218 .spans
219 .into_iter()
220 .map(|s| s.patch_style(line.style))
221 .collect(),
222 );
223 first_line.spans = spans;
224 out.push(first_line);
225 }
226
227 let base = first_line_range.end;
229 let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
230 let base = base + skip_leading_spaces;
231 let subsequent_width_available = opts
232 .width
233 .saturating_sub(rt_opts.subsequent_indent.width())
234 .max(1);
235 let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
236 for r in &remaining_wrapped {
237 if r.is_empty() {
238 continue;
239 }
240 let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
241 let offset_range = (r.start + base)..(r.end + base);
242 let sliced = slice_line_spans(line, &span_bounds, &offset_range);
243 let mut spans = subsequent_line.spans;
244 spans.append(
245 &mut sliced
246 .spans
247 .into_iter()
248 .map(|s| s.patch_style(line.style))
249 .collect(),
250 );
251 subsequent_line.spans = spans;
252 out.push(subsequent_line);
253 }
254
255 out
256}
257
258#[allow(dead_code)]
261pub(crate) fn word_wrap_lines<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'static>>
262where
263 I: IntoIterator<Item = &'a Line<'a>>,
264 O: Into<RtOptions<'a>>,
265{
266 let base_opts: RtOptions<'a> = width_or_options.into();
267 let mut out: Vec<Line<'static>> = Vec::new();
268
269 for (idx, line) in lines.into_iter().enumerate() {
270 let opts = if idx == 0 {
271 base_opts.clone()
272 } else {
273 let mut o = base_opts.clone();
274 let sub = o.subsequent_indent.clone();
275 o = o.initial_indent(sub);
276 o
277 };
278 let wrapped = word_wrap_line(line, opts);
279 push_owned_lines(&wrapped, &mut out);
280 }
281
282 out
283}
284
285#[allow(dead_code)]
286pub(crate) fn word_wrap_lines_borrowed<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'a>>
287where
288 I: IntoIterator<Item = &'a Line<'a>>,
289 O: Into<RtOptions<'a>>,
290{
291 let base_opts: RtOptions<'a> = width_or_options.into();
292 let mut out: Vec<Line<'a>> = Vec::new();
293 let mut first = true;
294 for line in lines.into_iter() {
295 let opts = if first {
296 base_opts.clone()
297 } else {
298 base_opts
299 .clone()
300 .initial_indent(base_opts.subsequent_indent.clone())
301 };
302 out.extend(word_wrap_line(line, opts));
303 first = false;
304 }
305 out
306}
307
308fn slice_line_spans<'a>(
309 original: &'a Line<'a>,
310 span_bounds: &[(Range<usize>, ratatui::style::Style)],
311 range: &Range<usize>,
312) -> Line<'a> {
313 let start_byte = range.start;
314 let end_byte = range.end;
315 let mut acc: Vec<Span<'a>> = Vec::new();
316 for (i, (range, style)) in span_bounds.iter().enumerate() {
317 let s = range.start;
318 let e = range.end;
319 if e <= start_byte {
320 continue;
321 }
322 if s >= end_byte {
323 break;
324 }
325 let seg_start = start_byte.max(s);
326 let seg_end = end_byte.min(e);
327 if seg_end > seg_start {
328 let local_start = seg_start - s;
329 let local_end = seg_end - s;
330 let content = original.spans[i].content.as_ref();
331 let slice = &content[local_start..local_end];
332 acc.push(Span {
333 style: *style,
334 content: std::borrow::Cow::Borrowed(slice),
335 });
336 }
337 if e >= end_byte {
338 break;
339 }
340 }
341 Line {
342 style: original.style,
343 alignment: original.alignment,
344 spans: acc,
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use itertools::Itertools as _;
352 use pretty_assertions::assert_eq;
353 use ratatui::style::Color;
354 use ratatui::style::Stylize;
355 use std::string::ToString;
356
357 fn concat_line(line: &Line) -> String {
358 line.spans
359 .iter()
360 .map(|s| s.content.as_ref())
361 .collect::<String>()
362 }
363
364 #[test]
365 fn trivial_unstyled_no_indents_wide_width() {
366 let line = Line::from("hello");
367 let out = word_wrap_line(&line, 10);
368 assert_eq!(out.len(), 1);
369 assert_eq!(concat_line(&out[0]), "hello");
370 }
371
372 #[test]
373 fn simple_unstyled_wrap_narrow_width() {
374 let line = Line::from("hello world");
375 let out = word_wrap_line(&line, 5);
376 assert_eq!(out.len(), 2);
377 assert_eq!(concat_line(&out[0]), "hello");
378 assert_eq!(concat_line(&out[1]), "world");
379 }
380
381 #[test]
382 fn simple_styled_wrap_preserves_styles() {
383 let line = Line::from(vec!["hello ".red(), "world".into()]);
384 let out = word_wrap_line(&line, 6);
385 assert_eq!(out.len(), 2);
386 assert_eq!(concat_line(&out[0]), "hello");
388 assert_eq!(out[0].spans.len(), 1);
389 assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
390 assert_eq!(concat_line(&out[1]), "world");
392 assert_eq!(out[1].spans.len(), 1);
393 assert_eq!(out[1].spans[0].style.fg, None);
394 }
395
396 #[test]
397 fn with_initial_and_subsequent_indents() {
398 let opts = RtOptions::new(8)
399 .initial_indent(Line::from("- "))
400 .subsequent_indent(Line::from(" "));
401 let line = Line::from("hello world foo");
402 let out = word_wrap_line(&line, opts);
403 assert!(concat_line(&out[0]).starts_with("- "));
405 assert!(concat_line(&out[1]).starts_with(" "));
406 assert!(concat_line(&out[2]).starts_with(" "));
407 assert_eq!(concat_line(&out[0]), "- hello");
409 assert_eq!(concat_line(&out[1]), " world");
410 assert_eq!(concat_line(&out[2]), " foo");
411 }
412
413 #[test]
414 fn empty_initial_indent_subsequent_spaces() {
415 let opts = RtOptions::new(8)
416 .initial_indent(Line::from(""))
417 .subsequent_indent(Line::from(" "));
418 let line = Line::from("hello world foobar");
419 let out = word_wrap_line(&line, opts);
420 assert!(concat_line(&out[0]).starts_with("hello"));
421 for l in &out[1..] {
422 assert!(concat_line(l).starts_with(" "));
423 }
424 }
425
426 #[test]
427 fn empty_input_yields_single_empty_line() {
428 let line = Line::from("");
429 let out = word_wrap_line(&line, 10);
430 assert_eq!(out.len(), 1);
431 assert_eq!(concat_line(&out[0]), "");
432 }
433
434 #[test]
435 fn leading_spaces_preserved_on_first_line() {
436 let line = Line::from(" hello");
437 let out = word_wrap_line(&line, 8);
438 assert_eq!(out.len(), 1);
439 assert_eq!(concat_line(&out[0]), " hello");
440 }
441
442 #[test]
443 fn multiple_spaces_between_words_dont_start_next_line_with_spaces() {
444 let line = Line::from("hello world");
445 let out = word_wrap_line(&line, 8);
446 assert_eq!(out.len(), 2);
447 assert_eq!(concat_line(&out[0]), "hello");
448 assert_eq!(concat_line(&out[1]), "world");
449 }
450
451 #[test]
452 fn break_words_false_allows_overflow_for_long_word() {
453 let opts = RtOptions::new(5).break_words(false);
454 let line = Line::from("supercalifragilistic");
455 let out = word_wrap_line(&line, opts);
456 assert_eq!(out.len(), 1);
457 assert_eq!(concat_line(&out[0]), "supercalifragilistic");
458 }
459
460 #[test]
461 fn hyphen_splitter_breaks_at_hyphen() {
462 let line = Line::from("hello-world");
463 let out = word_wrap_line(&line, 7);
464 assert_eq!(out.len(), 2);
465 assert_eq!(concat_line(&out[0]), "hello-");
466 assert_eq!(concat_line(&out[1]), "world");
467 }
468
469 #[test]
470 fn indent_consumes_width_leaving_one_char_space() {
471 let opts = RtOptions::new(4)
472 .initial_indent(Line::from(">>>>"))
473 .subsequent_indent(Line::from("--"));
474 let line = Line::from("hello");
475 let out = word_wrap_line(&line, opts);
476 assert_eq!(out.len(), 3);
477 assert_eq!(concat_line(&out[0]), ">>>>h");
478 assert_eq!(concat_line(&out[1]), "--el");
479 assert_eq!(concat_line(&out[2]), "--lo");
480 }
481
482 #[test]
483 fn wide_unicode_wraps_by_display_width() {
484 let line = Line::from("πππ");
485 let out = word_wrap_line(&line, 4);
486 assert_eq!(out.len(), 2);
487 assert_eq!(concat_line(&out[0]), "ππ");
488 assert_eq!(concat_line(&out[1]), "π");
489 }
490
491 #[test]
492 fn styled_split_within_span_preserves_style() {
493 use ratatui::style::Stylize;
494 let line = Line::from(vec!["abcd".red()]);
495 let out = word_wrap_line(&line, 2);
496 assert_eq!(out.len(), 2);
497 assert_eq!(out[0].spans.len(), 1);
498 assert_eq!(out[1].spans.len(), 1);
499 assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
500 assert_eq!(out[1].spans[0].style.fg, Some(Color::Red));
501 assert_eq!(concat_line(&out[0]), "ab");
502 assert_eq!(concat_line(&out[1]), "cd");
503 }
504
505 #[test]
506 fn wrap_lines_applies_initial_indent_only_once() {
507 let opts = RtOptions::new(8)
508 .initial_indent(Line::from("- "))
509 .subsequent_indent(Line::from(" "));
510
511 let lines = vec![Line::from("hello world"), Line::from("foo bar baz")];
512 let out = word_wrap_lines(&lines, opts);
513
514 let rendered: Vec<String> = out.iter().map(concat_line).collect();
517 assert!(rendered[0].starts_with("- "));
518 for r in rendered.iter().skip(1) {
519 assert!(r.starts_with(" "));
520 }
521 }
522
523 #[test]
524 fn wrap_lines_without_indents_is_concat_of_single_wraps() {
525 let lines = vec![Line::from("hello"), Line::from("world!")];
526 let out = word_wrap_lines(&lines, 10);
527 let rendered: Vec<String> = out.iter().map(concat_line).collect();
528 assert_eq!(rendered, vec!["hello", "world!"]);
529 }
530
531 #[test]
532 fn wrap_lines_borrowed_applies_initial_indent_only_once() {
533 let opts = RtOptions::new(8)
534 .initial_indent(Line::from("- "))
535 .subsequent_indent(Line::from(" "));
536
537 let lines = [Line::from("hello world"), Line::from("foo bar baz")];
538 let out = word_wrap_lines_borrowed(lines.iter(), opts);
539
540 let rendered: Vec<String> = out.iter().map(concat_line).collect();
541 assert!(rendered.first().unwrap().starts_with("- "));
542 for r in rendered.iter().skip(1) {
543 assert!(r.starts_with(" "));
544 }
545 }
546
547 #[test]
548 fn wrap_lines_borrowed_without_indents_is_concat_of_single_wraps() {
549 let lines = [Line::from("hello"), Line::from("world!")];
550 let out = word_wrap_lines_borrowed(lines.iter(), 10);
551 let rendered: Vec<String> = out.iter().map(concat_line).collect();
552 assert_eq!(rendered, vec!["hello", "world!"]);
553 }
554
555 #[test]
556 fn line_height_counts_double_width_emoji() {
557 let line = "πππ".into(); assert_eq!(word_wrap_line(&line, 4).len(), 2);
559 assert_eq!(word_wrap_line(&line, 2).len(), 3);
560 assert_eq!(word_wrap_line(&line, 6).len(), 1);
561 }
562
563 #[test]
564 fn wrap_ranges_many_newlines_width_one_does_not_panic() {
565 let text = "\n".repeat(30);
566 let ranges = wrap_ranges(
567 &text,
568 Options::new(1).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
569 );
570 for r in &ranges {
571 assert!(r.end <= text.len(), "range {r:?} out of bounds");
572 }
573 }
574
575 #[test]
576 #[allow(clippy::single_range_in_vec_init)]
577 fn wrap_ranges_trim_empty_text_does_not_panic() {
578 let ranges = wrap_ranges_trim("", 1);
579 assert!(ranges.is_empty() || ranges == vec![0..0]);
580 }
581
582 #[test]
583 fn word_wrap_line_width_one_with_newlines_does_not_panic() {
584 let line = Line::from("\n\n\n\n\n");
585 let out = word_wrap_line(&line, 1);
586 assert!(!out.is_empty());
587 }
588
589 #[test]
590 fn word_wrap_does_not_split_words_simple_english() {
591 let sample = "Years passed, and Willowmere thrived in peace and friendship. Miraβs herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
592 let line = Line::from(sample);
593 let lines = [line];
594 let wrapped = word_wrap_lines_borrowed(&lines, 40);
596 let joined: String = wrapped.iter().map(ToString::to_string).join("\n");
597 assert_eq!(
598 joined,
599 r#"Years passed, and Willowmere thrived
600in peace and friendship. Miraβs herb
601garden flourished with both ordinary and
602enchanted plants, and travelers spoke
603of the kindness of the woman who tended
604them."#
605 );
606 }
607}