retroglyph_core/layout/word_wrap.rs
1//! Greedy grapheme-cluster-aware word-wrap engine, shared by [`super::TextLayout`] and [`wrap`].
2
3use crate::color::Style;
4use crate::text::{Line, Span};
5use alloc::borrow::Cow;
6use alloc::string::String;
7use alloc::vec::Vec;
8use unicode_segmentation::UnicodeSegmentation;
9use unicode_width::UnicodeWidthStr;
10
11/// One grapheme on a wrapped line, ready to be placed or measured.
12pub(super) struct WrappedGlyph {
13 /// The grapheme cluster string.
14 pub(super) grapheme: String,
15 /// Style inherited from the source span.
16 pub(super) style: Style,
17 /// Display width of this grapheme in terminal columns (1 or 2).
18 pub(super) width: u16,
19}
20
21/// A line produced by the word-wrap pass.
22pub(super) struct WrappedLine {
23 pub(super) glyphs: Vec<WrappedGlyph>,
24 /// Sum of all glyph widths on this line.
25 pub(super) width: u16,
26}
27
28/// Greedy word-wrap over a [`Line`](crate::text::Line)'s spans.
29///
30/// Breaks on ASCII space (`' '`): the space is consumed (not placed) at the
31/// break point, and overlong words are force-broken at the column boundary.
32/// Leading whitespace on soft-wrapped continuation lines is preserved.
33///
34/// Note: only `\n` and ASCII space are treated specially. Tabs, NBSP, and
35/// other whitespace are treated as printable 1-wide characters. Callers
36/// should expand tabs before calling if that matters.
37pub(super) fn wrap_line(line: &Line, max_width: u16) -> Vec<WrappedLine> {
38 let mut lines: Vec<WrappedLine> = alloc::vec![WrappedLine {
39 glyphs: Vec::new(),
40 width: 0,
41 }];
42
43 for span in &line.spans {
44 for grapheme in span.content.graphemes(true) {
45 // Hard newline.
46 if grapheme == "\n" {
47 lines.push(WrappedLine {
48 glyphs: Vec::new(),
49 width: 0,
50 });
51 continue;
52 }
53
54 #[allow(clippy::cast_possible_truncation)]
55 let gw = grapheme.width() as u16;
56 if gw == 0 {
57 continue; // zero-width (combining handled in write_grapheme)
58 }
59
60 // Soft wrap: this grapheme would overflow the line.
61 let col = lines.last().expect("always at least one line").width;
62 if u32::from(col) + u32::from(gw) > u32::from(max_width) && col > 0 {
63 let current = lines.last_mut().expect("always at least one line");
64
65 // Try to break at the last space on the current line.
66 if let Some(space_idx) = current.glyphs.iter().rposition(|g| g.grapheme == " ") {
67 // Drain everything after the space into a new line.
68 let remainder: Vec<WrappedGlyph> =
69 current.glyphs.drain(space_idx + 1..).collect();
70 // Drop the space itself: it's the width-1 glyph the rposition above matched.
71 current.glyphs.pop();
72
73 let new_width: u16 = remainder.iter().map(|g| g.width).sum();
74 current.width -= new_width + 1;
75 lines.push(WrappedLine {
76 glyphs: remainder,
77 width: new_width,
78 });
79 } else {
80 // No space on the line: force-break (overlong word).
81 lines.push(WrappedLine {
82 glyphs: Vec::new(),
83 width: 0,
84 });
85 // Drop the space that triggered this break: it would just be
86 // leading whitespace on the new line.
87 if grapheme == " " {
88 continue;
89 }
90 }
91 }
92
93 let current = lines.last_mut().expect("always at least one line");
94 current.width = current.width.saturating_add(gw);
95 current.glyphs.push(WrappedGlyph {
96 grapheme: String::from(grapheme),
97 style: span.style,
98 width: gw,
99 });
100 }
101 }
102
103 lines
104}
105
106/// Word-wraps `line` to `max_width` columns, returning the broken-apart [`Line`](crate::text::Line)s.
107///
108/// This is the same greedy, grapheme-cluster-aware wrap pass [`TextLayout`](super::TextLayout)
109/// runs internally on every render (breaking on ASCII space, honoring hard `\n`s, force-breaking
110/// an overlong word at the column boundary); it's exposed standalone for callers that need the
111/// wrapped pieces themselves rather than having them written straight to a surface, such as a
112/// scrollback log that wraps each message into rows while still addressing its window in whole
113/// messages.
114///
115/// Each returned `Line` is a single unstyled or uniformly-styled run per source span that
116/// survived onto that row; adjacent graphemes carrying the same [`Style`](crate::color::Style) are coalesced back
117/// into one [`Span`](crate::text::Span), so wrapping a plain [`Line::raw`](crate::text::Line::raw) round-trips to plain `Line::raw` rows.
118///
119/// # Examples
120///
121/// ```
122/// use retroglyph_core::layout::wrap;
123/// use retroglyph_core::text::Line;
124///
125/// let line = Line::raw("hello world");
126/// let rows = wrap(&line, 7);
127/// assert_eq!(rows.len(), 2);
128/// assert_eq!(rows[0].spans[0].content, "hello");
129/// assert_eq!(rows[1].spans[0].content, "world");
130/// ```
131#[must_use]
132pub fn wrap(line: &Line, max_width: u16) -> Vec<Line> {
133 wrap_line(line, max_width)
134 .into_iter()
135 .map(|wrapped| {
136 let mut spans: Vec<Span> = Vec::new();
137 for glyph in wrapped.glyphs {
138 if let Some(last) = spans.last_mut()
139 && last.style == glyph.style
140 {
141 last.content.push_str(&glyph.grapheme);
142 continue;
143 }
144 spans.push(Span::styled(glyph.grapheme, glyph.style));
145 }
146 Line { spans }
147 })
148 .collect()
149}
150
151/// Word-wraps plain `text` to `max_width` columns, returning the broken-apart rows.
152///
153/// This runs the same greedy, grapheme-cluster-aware wrap pass as [`wrap`] (breaking on ASCII
154/// space, honoring hard `\n`s, force-breaking an overlong word at the column boundary), for
155/// callers that just have a plain string and would otherwise hand-roll a wrap loop to get it.
156/// Reach for [`wrap`] instead when the text carries per-[`Span`](crate::text::Span)
157/// [`Style`](crate::color::Style): `text: &str` converts to a plain [`Line`] via `.into()`, so
158/// `wrap(&text.into(), w)` covers that case without constructing spans by hand.
159///
160/// Each row is grapheme-rejoined during wrapping, so this always returns
161/// [`Cow::Owned`](alloc::borrow::Cow::Owned); the `Cow` return type is kept so a row that turns
162/// out not to need wrapping can borrow `text` directly if a future revision adds that
163/// fast path.
164///
165/// # Examples
166///
167/// ```
168/// use retroglyph_core::layout::wrap_str;
169///
170/// let rows = wrap_str("hello world", 7);
171/// assert_eq!(rows, vec!["hello", "world"]);
172/// ```
173#[must_use]
174pub fn wrap_str(text: &str, max_width: u16) -> Vec<Cow<'_, str>> {
175 wrap_line(&Line::raw(text), max_width)
176 .into_iter()
177 .map(|wrapped| {
178 let mut row = String::new();
179 for glyph in wrapped.glyphs {
180 row.push_str(&glyph.grapheme);
181 }
182 Cow::Owned(row)
183 })
184 .collect()
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::color::Color;
191
192 fn red() -> Style {
193 Style::new().fg(Color::RED)
194 }
195
196 #[test]
197 fn test_wrap_no_wrap_needed() {
198 let line = Line::raw("hello");
199 let lines = wrap_line(&line, 10);
200 assert_eq!(lines.len(), 1);
201 assert_eq!(lines[0].width, 5);
202 }
203
204 #[test]
205 fn test_wrap_hard_newline() {
206 let line = Line::raw("hi\nthere");
207 let lines = wrap_line(&line, 20);
208 assert_eq!(lines.len(), 2);
209 assert_eq!(lines[0].width, 2);
210 assert_eq!(lines[1].width, 5);
211 }
212
213 #[test]
214 fn test_wrap_soft_break_on_space() {
215 // "hello world" in a 7-wide box: "hello" fits, space triggers break.
216 let line = Line::raw("hello world");
217 let lines = wrap_line(&line, 7);
218 assert_eq!(lines.len(), 2);
219 assert_eq!(lines[0].width, 5); // "hello", space consumed
220 assert_eq!(lines[1].width, 5); // "world"
221 }
222
223 #[test]
224 fn test_wrap_force_break_no_space() {
225 let line = Line::raw("abcdefgh");
226 let lines = wrap_line(&line, 4);
227 assert_eq!(lines.len(), 2);
228 assert_eq!(lines[0].width, 4);
229 assert_eq!(lines[1].width, 4);
230 }
231
232 #[test]
233 fn test_wrap_force_break_drops_the_triggering_space() {
234 // "abcd" fills the 4-wide box exactly; the following space has no room and no
235 // earlier space on the line to break at, so it force-breaks and is itself dropped
236 // rather than becoming leading whitespace on the new line.
237 let line = Line::raw("abcd e");
238 let lines = wrap_line(&line, 4);
239 assert_eq!(lines.len(), 2);
240 assert_eq!(lines[0].width, 4);
241 assert_eq!(lines[1].width, 1); // "e", not " e"
242 }
243
244 #[test]
245 fn test_wrap_wide_chars() {
246 // Each CJK char is width 2; "中文中" in a 4-wide box wraps after "中文".
247 let line = Line::raw("中文中");
248 let lines = wrap_line(&line, 4);
249 assert_eq!(lines.len(), 2);
250 assert_eq!(lines[0].width, 4);
251 assert_eq!(lines[1].width, 2);
252 }
253
254 #[test]
255 fn test_wrap_multi_span() {
256 let line = Line::from(vec![Span::raw("foo "), Span::styled("bar", red())]);
257 let lines = wrap_line(&line, 20);
258 assert_eq!(lines.len(), 1);
259 assert_eq!(lines[0].width, 7);
260 // The "bar" glyphs should carry the red style.
261 let bar_count = lines[0].glyphs.iter().filter(|g| g.style == red()).count();
262 assert_eq!(bar_count, 3);
263 }
264
265 #[test]
266 fn test_wrap_str_soft_break_on_space() {
267 let rows = wrap_str("hello world", 7);
268 assert_eq!(rows, alloc::vec!["hello", "world"]);
269 }
270
271 #[test]
272 fn test_wrap_str_hard_newline() {
273 let rows = wrap_str("hi\nthere", 20);
274 assert_eq!(rows, alloc::vec!["hi", "there"]);
275 }
276
277 #[test]
278 fn test_wrap_str_wide_chars() {
279 // Each CJK char is width 2; "中文中" in a 4-wide box wraps after "中文".
280 let rows = wrap_str("中文中", 4);
281 assert_eq!(rows, alloc::vec!["中文", "中"]);
282 }
283
284 #[test]
285 fn test_wrap_str_no_wrap_needed_returns_owned() {
286 let rows = wrap_str("hello", 10);
287 assert_eq!(rows.len(), 1);
288 assert!(matches!(rows[0], Cow::Owned(_)));
289 }
290
291 #[test]
292 fn test_wrap_str_max_width_does_not_overflow() {
293 // Regression: col/width additions must not panic in debug at max_width == u16::MAX.
294 let text = "a".repeat(70_000);
295 let rows = wrap_str(&text, u16::MAX);
296 assert_eq!(rows.len(), 2);
297 assert_eq!(rows[0].len(), u16::MAX as usize);
298 }
299}