Skip to main content

oxicode_textarea/
wrapping.rs

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                // Skip slices whose pointers don't lie within `text`.
23                // This guards against empty Cow::Borrowed("") slices from
24                // textwrap that reference static memory instead of the
25                // input buffer (e.g. at zero or degenerate widths).
26                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
40/// Like `wrap_ranges` but returns ranges without trailing whitespace and
41/// without the sentinel extra byte. Suitable for general wrapping where
42/// trailing spaces should not be preserved.
43pub(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    /// The width in columns at which the text will be wrapped.
71    pub width: usize,
72    /// Line ending used for breaking lines.
73    pub line_ending: textwrap::LineEnding,
74    /// Indentation used for the first line of output. See the
75    /// [`Options::initial_indent`] method.
76    pub initial_indent: Line<'a>,
77    /// Indentation used for subsequent lines of output. See the
78    /// [`Options::subsequent_indent`] method.
79    pub subsequent_indent: Line<'a>,
80    /// Allow long words to be broken if they cannot fit on a line.
81    /// When set to `false`, some lines may be longer than
82    /// `self.width`. See the [`Options::break_words`] method.
83    pub break_words: bool,
84    /// Wrapping algorithm to use, see the implementations of the
85    /// [`textwrap::WrapAlgorithm`] trait for details.
86    pub wrap_algorithm: textwrap::WrapAlgorithm,
87    /// The line breaking algorithm to use, see the [`textwrap::WordSeparator`]
88    /// trait for an overview and possible implementations.
89    pub word_separator: textwrap::WordSeparator,
90    /// The method for splitting words. This can be used to prohibit
91    /// splitting words on hyphens, or it can be used to implement
92    /// language-aware machine hyphenation.
93    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                // ~infinite overflow penalty, we never want to overflow a line.
113                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    // Flatten the line and record span byte ranges.
180    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    // Compute first line range with reduced width due to initial indent.
202    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    // Build first wrapped line with initial indent.
212    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    // Wrap the remainder using subsequent indent width and map back to original indices.
228    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/// Wrap a sequence of lines, applying the initial indent only to the very first
259/// output line, and using the subsequent indent for all later wrapped pieces.
260#[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        // First line should carry the red style
387        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        // Second line is unstyled
391        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        // Expect three lines with proper prefixes
404        assert!(concat_line(&out[0]).starts_with("- "));
405        assert!(concat_line(&out[1]).starts_with("  "));
406        assert!(concat_line(&out[2]).starts_with("  "));
407        // And content roughly segmented
408        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        // Expect: first line prefixed with "- ", subsequent wrapped pieces with "  "
515        // and for the second input line, there should be no "- " prefix on its first piece
516        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(); // each emoji ~ width 2
558        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        // Force small width to exercise wrapping at spaces.
595        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}