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}