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
19use super::text::{Token, WidthMode, tokenize};
20
21#[inline]
22fn bs(b: &[u8]) -> &str {
23    // SAFETY: token slices from `&str` input fall on valid UTF-8 boundaries.
24    unsafe { std::str::from_utf8_unchecked(b) }
25}
26
27/// Default break characters for [`wordwrap`] and [`wrap`]: hyphen, comma, period, semicolon, colon, and space (`"-,.;: "`).
28pub const DEFAULT_BREAKPOINTS: &str = "-,.;: ";
29
30/// Hard-wrap `s` so no visible line exceeds `limit` columns.
31///
32/// 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.
33pub fn hardwrap(s: &str, limit: usize, preserve_space: bool) -> String {
34    hardwrap_mode(s, limit, preserve_space, WidthMode::default(), false)
35}
36
37/// Width-mode variant of [`hardwrap`].
38///
39/// `mode` and `eaw_wide` control grapheme width calculation. `limit == 0` returns the input unchanged.
40pub fn hardwrap_mode(
41    s: &str,
42    limit: usize,
43    preserve_space: bool,
44    mode: WidthMode,
45    eaw_wide: bool,
46) -> String {
47    if limit == 0 {
48        return s.to_string();
49    }
50    let mut out = String::with_capacity(s.len());
51    let mut col = 0usize;
52    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
53        match tok {
54            Token::Escape(e) => out.push_str(bs(e)),
55            Token::Control(b'\n') => {
56                out.push('\n');
57                col = 0;
58            }
59            Token::Control(b) => out.push(b as char),
60            Token::Text { text, width } => {
61                let w = width as usize;
62                if w == 0 {
63                    out.push_str(bs(text));
64                    continue;
65                }
66                if col + w > limit {
67                    out.push('\n');
68                    col = 0;
69                    if !preserve_space && text == b" " {
70                        continue;
71                    }
72                }
73                out.push_str(bs(text));
74                col += w;
75            }
76        }
77    }
78    out
79}
80
81/// Word-wrap `s` at `breakpoints` so visible lines fit within `limit` where possible.
82///
83/// Long words are not split; use [`wrap`] when oversized words should be hard-wrapped. ANSI escapes are preserved and do not contribute width.
84pub fn wordwrap(s: &str, limit: usize, breakpoints: &str) -> String {
85    wordwrap_mode(s, limit, breakpoints, WidthMode::default(), false)
86}
87
88/// Width-mode variant of [`wordwrap`].
89///
90/// `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.
91pub fn wordwrap_mode(
92    s: &str,
93    limit: usize,
94    breakpoints: &str,
95    mode: WidthMode,
96    eaw_wide: bool,
97) -> String {
98    if limit == 0 {
99        return s.to_string();
100    }
101    let bp: Vec<char> = breakpoints.chars().collect();
102
103    // We build the output as: completed lines + current line state.
104    // `line` is the bytes already committed to the current line.
105    // `word` is the current pending word.
106    // `space` is whitespace separator buffered between `line` and `word`.
107    let mut out = String::with_capacity(s.len());
108    let mut line = String::new();
109    let mut line_w = 0usize;
110    let mut word = String::new();
111    let mut word_w = 0usize;
112    let mut space = String::new();
113    let mut space_w = 0usize;
114
115    let flush_word_to_line = |line: &mut String,
116                              line_w: &mut usize,
117                              word: &mut String,
118                              word_w: &mut usize,
119                              space: &mut String,
120                              space_w: &mut usize| {
121        if !word.is_empty() {
122            // If line+space+word exceeds limit and line is non-empty, wrap.
123            // Handled by caller before calling.
124            line.push_str(space);
125            line.push_str(word);
126            *line_w += *space_w + *word_w;
127            space.clear();
128            *space_w = 0;
129            word.clear();
130            *word_w = 0;
131        }
132    };
133
134    for tok in tokenize(s.as_bytes(), mode, eaw_wide) {
135        match tok {
136            Token::Escape(e) => {
137                // Attach escapes to whatever segment is currently being built.
138                if !word.is_empty() {
139                    word.push_str(bs(e));
140                } else if !space.is_empty() {
141                    space.push_str(bs(e));
142                } else {
143                    line.push_str(bs(e));
144                }
145            }
146            Token::Control(b'\n') => {
147                // Flush current word and emit the line.
148                flush_word_to_line(
149                    &mut line,
150                    &mut line_w,
151                    &mut word,
152                    &mut word_w,
153                    &mut space,
154                    &mut space_w,
155                );
156                out.push_str(&line);
157                out.push('\n');
158                line.clear();
159                line_w = 0;
160                space.clear();
161                space_w = 0;
162            }
163            Token::Control(b) => {
164                // Treat as part of the current word.
165                word.push(b as char);
166            }
167            Token::Text { text, width } => {
168                let w = width as usize;
169                let is_break = bs(text).chars().all(|c| bp.contains(&c));
170                let is_space = text == b" " || text == b"\t";
171
172                if is_space {
173                    // Spaces become separator.
174                    // First flush pending word to line.
175                    if !word.is_empty() {
176                        // If word doesn't fit on current line, wrap first.
177                        if line_w > 0 && line_w + space_w + word_w > limit {
178                            out.push_str(&line);
179                            out.push('\n');
180                            line.clear();
181                            line_w = 0;
182                            space.clear();
183                            space_w = 0;
184                        }
185                        flush_word_to_line(
186                            &mut line,
187                            &mut line_w,
188                            &mut word,
189                            &mut word_w,
190                            &mut space,
191                            &mut space_w,
192                        );
193                    }
194                    space.push_str(bs(text));
195                    space_w += w;
196                } else if is_break {
197                    // Non-space breakpoint (e.g. '-', ','). Stay attached to word but
198                    // mark that we can break after.
199                    word.push_str(bs(text));
200                    word_w += w;
201                    // Flush after the breakpoint character.
202                    if line_w > 0 && line_w + space_w + word_w > limit {
203                        out.push_str(&line);
204                        out.push('\n');
205                        line.clear();
206                        line_w = 0;
207                        space.clear();
208                        space_w = 0;
209                    }
210                    flush_word_to_line(
211                        &mut line,
212                        &mut line_w,
213                        &mut word,
214                        &mut word_w,
215                        &mut space,
216                        &mut space_w,
217                    );
218                } else {
219                    word.push_str(bs(text));
220                    word_w += w;
221                }
222            }
223        }
224    }
225
226    // Final flush.
227    if !word.is_empty() {
228        if line_w > 0 && line_w + space_w + word_w > limit {
229            out.push_str(&line);
230            out.push('\n');
231            line.clear();
232            line_w = 0;
233            space.clear();
234            space_w = 0;
235        }
236        flush_word_to_line(
237            &mut line,
238            &mut line_w,
239            &mut word,
240            &mut word_w,
241            &mut space,
242            &mut space_w,
243        );
244    } else if !space.is_empty() {
245        // Trailing space attaches to line.
246        line.push_str(&space);
247    }
248    out.push_str(&line);
249    out
250}
251
252/// Soft-wrap `s` at word breakpoints, then hard-wrap any remaining overlong line.
253///
254/// This combines [`wordwrap`] with [`hardwrap`] so every visible line fits within `limit` when `limit > 0`.
255pub fn wrap(s: &str, limit: usize, breakpoints: &str) -> String {
256    wrap_mode(s, limit, breakpoints, WidthMode::default(), false)
257}
258
259/// Width-mode variant of [`wrap`].
260///
261/// `mode` and `eaw_wide` control grapheme width calculation. `limit == 0` returns the input unchanged.
262pub fn wrap_mode(
263    s: &str,
264    limit: usize,
265    breakpoints: &str,
266    mode: WidthMode,
267    eaw_wide: bool,
268) -> String {
269    if limit == 0 {
270        return s.to_string();
271    }
272    // First wordwrap, then hardwrap each resulting line in case any word is
273    // longer than `limit`.
274    let wrapped = wordwrap_mode(s, limit, breakpoints, mode, eaw_wide);
275    let mut out = String::with_capacity(wrapped.len());
276    for (i, line) in wrapped.split('\n').enumerate() {
277        if i > 0 {
278            out.push('\n');
279        }
280        out.push_str(&hardwrap_mode(line, limit, false, mode, eaw_wide));
281    }
282    out
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn hardwrap_basic() {
291        assert_eq!(hardwrap("hello world", 5, true), "hello\n worl\nd");
292    }
293
294    #[test]
295    fn hardwrap_no_preserve_space() {
296        assert_eq!(hardwrap("hello world", 5, false), "hello\nworld");
297    }
298
299    #[test]
300    fn hardwrap_preserves_ansi() {
301        let s = "\x1b[31mhello world\x1b[m";
302        let got = hardwrap(s, 5, false);
303        assert_eq!(got, "\x1b[31mhello\nworld\x1b[m");
304    }
305
306    #[test]
307    fn hardwrap_explicit_newline() {
308        assert_eq!(hardwrap("ab\ncd", 5, true), "ab\ncd");
309    }
310
311    #[test]
312    fn wordwrap_basic() {
313        assert_eq!(wordwrap("hello world", 5, " "), "hello\nworld");
314    }
315
316    #[test]
317    fn wordwrap_long_word_not_broken() {
318        // "abcdefghij" is 10 wide, limit 5 — wordwrap leaves it intact.
319        assert_eq!(wordwrap("abcdefghij", 5, " "), "abcdefghij");
320    }
321
322    #[test]
323    fn wordwrap_explicit_newline() {
324        assert_eq!(wordwrap("a b\nc d", 10, " "), "a b\nc d");
325    }
326
327    #[test]
328    fn wordwrap_with_hyphen_break() {
329        let got = wordwrap("foo-bar-baz", 4, DEFAULT_BREAKPOINTS);
330        assert_eq!(got, "foo-\nbar-\nbaz");
331    }
332
333    #[test]
334    fn wrap_breaks_long_words() {
335        assert_eq!(wrap("abcdefghij", 5, " "), "abcde\nfghij");
336    }
337
338    #[test]
339    fn wrap_mixed() {
340        assert_eq!(
341            wrap("hello superlongword end", 5, " "),
342            "hello\nsuper\nlongw\nord\nend"
343        );
344    }
345
346    #[test]
347    fn wordwrap_zero_limit_returns_input() {
348        assert_eq!(wordwrap("anything", 0, " "), "anything");
349    }
350}