Skip to main content

uncurses/ansi/
wrap.rs

1//! Width-aware wrapping for ANSI-decorated strings.
2//!
3//! ## Category
4//!
5//! Hard wrap, word wrap, and combined wrap utilities insert newlines based on
6//! terminal display columns while preserving ANSI escape sequences verbatim.
7//!
8//! ## Width conventions
9//!
10//! Visible text width comes from [`crate::ansi::text::tokenize`] and [`WidthMode`].
11//! Escape tokens are zero-width and stay attached to the current word, separator,
12//! or line segment so styling and hyperlinks survive wrapping.
13//!
14//! ## Mode interaction
15//!
16//! Wrapping does not interpret terminal modes. It treats mode-setting and
17//! mode-dependent sequences as bytes to preserve, not as state transitions.
18//!
19//! Sequence boundaries and widths come from [`crate::ansi::text`];
20//! which byte ends a control string, and when a byte in `0x80..=0x9F`
21//! is a C1 control rather than part of a character, are documented there.
22
23use super::text::{Token, WidthMode, string_width, tokenize};
24
25#[inline]
26fn bs(b: &[u8]) -> &str {
27    // The tokenizer never splits a character: text tokens are whole grapheme
28    // clusters, and every sequence scanner steps a whole UTF-8 character at a
29    // time. Nothing enforced that, and when a scanner did split one - 0x9C is
30    // 8-bit ST and also a continuation byte, so an OSC title containing a
31    // check mark ended mid-character - the ill-formed bytes arrived here and
32    // this was undefined behaviour. Checked where checking is free.
33    debug_assert!(
34        std::str::from_utf8(b).is_ok(),
35        "token split a UTF-8 character: {b:?}"
36    );
37    // SAFETY: `b` is a token slice of `&str` input, taken on character
38    // boundaries, as asserted above.
39    unsafe { std::str::from_utf8_unchecked(b) }
40}
41
42/// Default break characters for [`wordwrap`] and [`wrap`]: hyphen, comma, period, semicolon, colon, and space (`"-,.;: "`).
43pub const DEFAULT_BREAKPOINTS: &str = "-,.;: ";
44
45/// Hard-wrap `s` so no visible line exceeds `limit` columns.
46///
47/// Breaks occur at exact width boundaries, including inside words. ANSI escapes are copied verbatim and contribute zero width. If `preserve_space` is `false`, a space that lands at a break boundary is dropped.
48pub fn hardwrap(s: &str, limit: usize, preserve_space: bool) -> String {
49    hardwrap_mode(s, limit, preserve_space, WidthMode::default(), false)
50}
51
52/// Width-mode variant of [`hardwrap`].
53///
54/// `mode` and `eaw_wide` control grapheme width calculation. `limit == 0` returns the input unchanged.
55pub fn hardwrap_mode(
56    s: &str,
57    limit: usize,
58    preserve_space: bool,
59    mode: WidthMode,
60    eaw_wide: bool,
61) -> String {
62    if limit == 0 {
63        return s.to_string();
64    }
65    let mut out = String::with_capacity(s.len());
66    let mut col = 0usize;
67    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
68        match tok {
69            Token::Escape(e) => out.push_str(bs(e)),
70            Token::Control(b'\n') => {
71                out.push('\n');
72                col = 0;
73            }
74            Token::Control(b) => out.push(b as char),
75            Token::Text { text, width } => {
76                let w = width as usize;
77                if w == 0 {
78                    out.push_str(bs(text));
79                    continue;
80                }
81                if col + w > limit {
82                    out.push('\n');
83                    col = 0;
84                    if !preserve_space && text == b" " {
85                        continue;
86                    }
87                }
88                out.push_str(bs(text));
89                col += w;
90            }
91        }
92    }
93    out
94}
95
96/// Word-wrap `s` at `breakpoints` so visible lines fit within `limit` where possible.
97///
98/// Long words are not split; use [`wrap`] when oversized words should be hard-wrapped. ANSI escapes are preserved and do not contribute width.
99pub fn wordwrap(s: &str, limit: usize, breakpoints: &str) -> String {
100    wordwrap_mode(s, limit, breakpoints, WidthMode::default(), false)
101}
102
103/// Width-mode variant of [`wordwrap`].
104///
105/// `breakpoints` is a set of characters where a line may break. `mode` and `eaw_wide` control grapheme width calculation; `limit == 0` returns the input unchanged.
106pub fn wordwrap_mode(
107    s: &str,
108    limit: usize,
109    breakpoints: &str,
110    mode: WidthMode,
111    eaw_wide: bool,
112) -> String {
113    wordwrap_inner(s, limit, breakpoints, mode, eaw_wide).0
114}
115
116/// [`wordwrap_mode`], and whether any line it produced is still wider than
117/// `limit`.
118///
119/// Word wrapping measures every line it emits in order to decide where to
120/// break, so the answer costs nothing here and a second width pass over the
121/// whole output anywhere else. It is what lets [`wrap_mode`] stop after one
122/// pass on text whose words all fit, which is nearly all text.
123fn wordwrap_inner(
124    s: &str,
125    limit: usize,
126    breakpoints: &str,
127    mode: WidthMode,
128    eaw_wide: bool,
129) -> (String, bool) {
130    if limit == 0 {
131        // No wrapping happened, so nothing was made to fit and nothing needs
132        // to be: `hardwrap_mode` returns its input unchanged at this limit
133        // too.
134        return (s.to_string(), false);
135    }
136    // A breakpoint test that is an array index, not a scan. `is_break` runs
137    // once per grapheme, and a linear search of the breakpoint list per
138    // character is the kind of constant that only shows up on a megabyte.
139    let mut ascii_bp = [false; 128];
140    let mut wide_bp: Vec<char> = Vec::new();
141    for c in breakpoints.chars() {
142        match u32::from(c) {
143            n if n < 128 => ascii_bp[n as usize] = true,
144            _ => wide_bp.push(c),
145        }
146    }
147    // The ASCII half was an index and the non-ASCII half was still a scan,
148    // which put the same constant back for anyone whose breakpoints are not
149    // ASCII: wrapping n characters on n breakpoints measured an exponent of
150    // 1.895. Sorting once buys a binary search and needs no new type.
151    wide_bp.sort_unstable();
152    let is_bp = |c: char| match u32::from(c) {
153        n if n < 128 => ascii_bp[n as usize],
154        _ => wide_bp.binary_search(&c).is_ok(),
155    };
156
157    // We build the output as: completed lines + current line state.
158    // `line` is the bytes already committed to the current line.
159    // `word` is the current pending word.
160    // `space` is whitespace separator buffered between `line` and `word`.
161    let mut out = String::with_capacity(s.len());
162    let mut line = String::new();
163    let mut line_w = 0usize;
164    let mut word = String::new();
165    let mut word_w = 0usize;
166    let mut space = String::new();
167    let mut space_w = 0usize;
168    let mut over = false;
169
170    let mut flush_word_to_line = |line: &mut String,
171                                  line_w: &mut usize,
172                                  word: &mut String,
173                                  word_w: &mut usize,
174                                  space: &mut String,
175                                  space_w: &mut usize| {
176        if !word.is_empty() {
177            // If line+space+word exceeds limit and line is non-empty, wrap.
178            // Handled by caller before calling.
179            line.push_str(space);
180            line.push_str(word);
181            *line_w += *space_w + *word_w;
182            // The only place `line_w` grows, and it is cleared the moment the
183            // line is emitted, so its value here is the width that line will
184            // be emitted at - checking it once here covers all five emit
185            // sites. A line ends up over the limit when a single word is
186            // wider than the limit, which is what hard wrapping is for.
187            over |= *line_w > limit;
188            space.clear();
189            *space_w = 0;
190            word.clear();
191            *word_w = 0;
192        }
193    };
194
195    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
196        match tok {
197            Token::Escape(e) => {
198                // Attach escapes to whatever segment is currently being built.
199                if !word.is_empty() {
200                    word.push_str(bs(e));
201                } else if !space.is_empty() {
202                    space.push_str(bs(e));
203                } else {
204                    line.push_str(bs(e));
205                }
206            }
207            Token::Control(b'\n') => {
208                // Flush current word and emit the line.
209                flush_word_to_line(
210                    &mut line,
211                    &mut line_w,
212                    &mut word,
213                    &mut word_w,
214                    &mut space,
215                    &mut space_w,
216                );
217                out.push_str(&line);
218                out.push('\n');
219                line.clear();
220                line_w = 0;
221                space.clear();
222                space_w = 0;
223            }
224            Token::Control(b) => {
225                // Treat as part of the current word.
226                word.push(b as char);
227            }
228            Token::Text { text, width } => {
229                let w = width as usize;
230                let is_break = bs(text).chars().all(&is_bp);
231                let is_space = text == b" " || text == b"\t";
232
233                if is_space {
234                    // Spaces become separator.
235                    // First flush pending word to line.
236                    if !word.is_empty() {
237                        // If word doesn't fit on current line, wrap first.
238                        if line_w > 0 && line_w + space_w + word_w > limit {
239                            out.push_str(&line);
240                            out.push('\n');
241                            line.clear();
242                            line_w = 0;
243                            space.clear();
244                            space_w = 0;
245                        }
246                        flush_word_to_line(
247                            &mut line,
248                            &mut line_w,
249                            &mut word,
250                            &mut word_w,
251                            &mut space,
252                            &mut space_w,
253                        );
254                    }
255                    space.push_str(bs(text));
256                    space_w += w;
257                } else if is_break {
258                    // Non-space breakpoint (e.g. '-', ','). Stay attached to word but
259                    // mark that we can break after.
260                    word.push_str(bs(text));
261                    word_w += w;
262                    // Flush after the breakpoint character.
263                    if line_w > 0 && line_w + space_w + word_w > limit {
264                        out.push_str(&line);
265                        out.push('\n');
266                        line.clear();
267                        line_w = 0;
268                        space.clear();
269                        space_w = 0;
270                    }
271                    flush_word_to_line(
272                        &mut line,
273                        &mut line_w,
274                        &mut word,
275                        &mut word_w,
276                        &mut space,
277                        &mut space_w,
278                    );
279                } else {
280                    word.push_str(bs(text));
281                    word_w += w;
282                }
283            }
284        }
285    }
286
287    // Final flush.
288    if !word.is_empty() {
289        if line_w > 0 && line_w + space_w + word_w > limit {
290            out.push_str(&line);
291            out.push('\n');
292            line.clear();
293            line_w = 0;
294            space.clear();
295            space_w = 0;
296        }
297        flush_word_to_line(
298            &mut line,
299            &mut line_w,
300            &mut word,
301            &mut word_w,
302            &mut space,
303            &mut space_w,
304        );
305    } else if !space.is_empty() {
306        // Trailing space attaches to line, and can be what carries it over -
307        // the one line that ends up too wide without an oversized word in it.
308        line.push_str(&space);
309        over |= line_w + space_w > limit;
310    }
311    out.push_str(&line);
312    (out, over)
313}
314
315/// Soft-wrap `s` at word breakpoints, then hard-wrap any remaining overlong line.
316///
317/// This combines [`wordwrap`] with [`hardwrap`] so every visible line fits within `limit` when `limit > 0`.
318pub fn wrap(s: &str, limit: usize, breakpoints: &str) -> String {
319    wrap_mode(s, limit, breakpoints, WidthMode::default(), false)
320}
321
322/// Width-mode variant of [`wrap`].
323///
324/// `mode` and `eaw_wide` control grapheme width calculation. `limit == 0` returns the input unchanged.
325pub fn wrap_mode(
326    s: &str,
327    limit: usize,
328    breakpoints: &str,
329    mode: WidthMode,
330    eaw_wide: bool,
331) -> String {
332    if limit == 0 {
333        return s.to_string();
334    }
335    // First wordwrap, then hardwrap - but only the lines that need it, and
336    // only if any line does.
337    //
338    // Hard-wrapping exists for the one case word-wrapping cannot solve: a
339    // single word longer than the limit. Every other line is already inside
340    // it, and running the hard wrap over those lines is a second full pass
341    // that copies them to themselves. That pass is not cheap - measured, the
342    // wrap was exactly the sum of its two halves, so it cost as much as the
343    // wrapping did.
344    //
345    // Nor is asking whether a line is too wide, if it is asked by measuring
346    // the line again: the word wrap has already measured every line it
347    // emitted, so it is the only pass that needs to measure at all. It
348    // returns what it found. On text whose words all fit, which is nearly all
349    // text, the second pass now disappears entirely - along with the width
350    // pass that used to decide whether to run it.
351    let (wrapped, over) = wordwrap_inner(s, limit, breakpoints, mode, eaw_wide);
352    if !over {
353        // No cross-check of `over` against a fresh measurement of the output
354        // here. Measuring line by line restarts the parser at every newline,
355        // so a control string spanning one - an unterminated APC, say - reads
356        // as visible text on the lines after it, which is why the two
357        // disagree on output that is correct. The word wrap carries that
358        // state across newlines and is the one that can see it.
359        return wrapped;
360    }
361    // Some line is over. Measure to find which - the output is allocated
362    // lazily, at the first line that is.
363    let mut out: Option<String> = None;
364    let mut consumed = 0usize;
365    for line in wrapped.split('\n') {
366        let wide = string_width(line.as_bytes(), mode, eaw_wide) > limit;
367        match (&mut out, wide) {
368            // Still byte-for-byte `wrapped`; nothing to copy yet.
369            (None, false) => {}
370            (None, true) => {
371                let mut o = String::with_capacity(wrapped.len());
372                o.push_str(&wrapped[..consumed]);
373                o.push_str(&hardwrap_mode(line, limit, false, mode, eaw_wide));
374                out = Some(o);
375            }
376            (Some(o), _) => {
377                o.push('\n');
378                if wide {
379                    o.push_str(&hardwrap_mode(line, limit, false, mode, eaw_wide));
380                } else {
381                    // Copied rather than re-emitted. A line that fits comes
382                    // back from the hard wrap byte-identical, so this is
383                    // simply not doing the work - it is not guarding against
384                    // a difference. `hardwrap_mode` does re-encode a lone C1
385                    // control byte as the two-byte UTF-8 for that code point,
386                    // but that cannot arise from `&str` input: at a character
387                    // boundary in valid UTF-8 a byte in `0x80..=0x9F` is never
388                    // a lead byte, so the tokenizer never emits one as a
389                    // `Control`.
390                    o.push_str(line);
391                }
392            }
393        }
394        consumed += line.len() + 1;
395    }
396    out.unwrap_or(wrapped)
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn hardwrap_basic() {
405        assert_eq!(hardwrap("hello world", 5, true), "hello\n worl\nd");
406    }
407
408    #[test]
409    fn hardwrap_no_preserve_space() {
410        assert_eq!(hardwrap("hello world", 5, false), "hello\nworld");
411    }
412
413    #[test]
414    fn hardwrap_preserves_ansi() {
415        let s = "\x1b[31mhello world\x1b[m";
416        let got = hardwrap(s, 5, false);
417        assert_eq!(got, "\x1b[31mhello\nworld\x1b[m");
418    }
419
420    #[test]
421    fn hardwrap_explicit_newline() {
422        assert_eq!(hardwrap("ab\ncd", 5, true), "ab\ncd");
423    }
424
425    #[test]
426    fn wordwrap_basic() {
427        assert_eq!(wordwrap("hello world", 5, " "), "hello\nworld");
428    }
429
430    #[test]
431    fn wordwrap_long_word_not_broken() {
432        // "abcdefghij" is 10 wide, limit 5 — wordwrap leaves it intact.
433        assert_eq!(wordwrap("abcdefghij", 5, " "), "abcdefghij");
434    }
435
436    #[test]
437    fn wordwrap_explicit_newline() {
438        assert_eq!(wordwrap("a b\nc d", 10, " "), "a b\nc d");
439    }
440
441    #[test]
442    fn wordwrap_with_hyphen_break() {
443        let got = wordwrap("foo-bar-baz", 4, DEFAULT_BREAKPOINTS);
444        assert_eq!(got, "foo-\nbar-\nbaz");
445    }
446
447    /// Non-ASCII breakpoints are looked up in a sorted list, so they only
448    /// work if that list is actually sorted.
449    #[test]
450    fn wordwrap_breaks_on_non_ascii_breakpoints() {
451        // Deliberately unsorted, and with a decoy that is not in the input.
452        assert_eq!(
453            wordwrap("ab\u{3002}cd", 3, "\u{ff1b}\u{3002}\u{300c}"),
454            "ab\u{3002}\ncd"
455        );
456        assert_eq!(
457            wordwrap("ab\u{ff1b}cd", 3, "\u{ff1b}\u{3002}"),
458            "ab\u{ff1b}\ncd"
459        );
460        // A character absent from the set must not break.
461        assert_eq!(wordwrap("ab\u{3001}cd", 9, "\u{3002}"), "ab\u{3001}cd");
462    }
463
464    #[test]
465    fn wrap_breaks_long_words() {
466        assert_eq!(wrap("abcdefghij", 5, " "), "abcde\nfghij");
467    }
468
469    #[test]
470    fn wrap_mixed() {
471        assert_eq!(
472            wrap("hello superlongword end", 5, " "),
473            "hello\nsuper\nlongw\nord\nend"
474        );
475    }
476
477    /// `wrap` hard wraps only when word wrapping tells it a line is still too
478    /// wide, and word wrapping counts a line's width as it builds it. A
479    /// trailing space is the one thing that lands on a line after that count
480    /// is otherwise final: "hello" fits in five columns and "hello " does not,
481    /// with no oversized word anywhere to notice.
482    #[test]
483    fn wrap_hard_wraps_a_line_carried_over_by_a_trailing_space() {
484        assert_eq!(wrap("hello ", 5, " "), "hello\n");
485        assert_eq!(wrap("ab cd ", 5, " "), "ab cd\n");
486        // Still within the limit with the space on it, so left alone.
487        assert_eq!(wrap("hi  ", 5, " "), "hi  ");
488    }
489
490    #[test]
491    fn wordwrap_zero_limit_returns_input() {
492        assert_eq!(wordwrap("anything", 0, " "), "anything");
493    }
494
495    /// An unterminated control string swallows everything after it, newlines
496    /// included, so the text on the following lines is never visible and the
497    /// input needs no wrapping at all. Measuring the output one line at a time
498    /// cannot see that - the parser restarts at each newline and reads the
499    /// payload as ordinary text - which is why `wrap` trusts the width the
500    /// word wrap carried across the newline instead of measuring again.
501    #[test]
502    fn wrap_leaves_a_control_string_spanning_a_newline_alone() {
503        let s = "\x1b_\nworld\u{4e00}\u{1f1fa}\x07\u{1f1fa}";
504        assert_eq!(string_width(s.as_bytes(), WidthMode::default(), false), 0);
505        assert_eq!(wrap(s, 8, " \t-"), s);
506    }
507}