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::string::String;
6use alloc::vec::Vec;
7use unicode_segmentation::UnicodeSegmentation;
8use unicode_width::UnicodeWidthStr;
9
10/// One grapheme on a wrapped line, ready to be placed or measured.
11pub(super) struct WrappedGlyph {
12 /// The grapheme cluster string.
13 pub(super) grapheme: String,
14 /// Style inherited from the source span.
15 pub(super) style: Style,
16 /// Display width of this grapheme in terminal columns (1 or 2).
17 pub(super) width: u16,
18}
19
20/// A line produced by the word-wrap pass.
21pub(super) struct WrappedLine {
22 pub(super) glyphs: Vec<WrappedGlyph>,
23 /// Sum of all glyph widths on this line.
24 pub(super) width: u16,
25}
26
27/// Greedy word-wrap over a [`Line`](crate::text::Line)'s spans.
28///
29/// Breaks on ASCII space (`' '`): the space is consumed (not placed) at the
30/// break point, and overlong words are force-broken at the column boundary.
31/// Leading whitespace on soft-wrapped continuation lines is preserved.
32///
33/// Note: only `\n` and ASCII space are treated specially. Tabs, NBSP, and
34/// other whitespace are treated as printable 1-wide characters. Callers
35/// should expand tabs before calling if that matters.
36pub(super) fn wrap_line(line: &Line, max_width: u16) -> Vec<WrappedLine> {
37 let mut lines: Vec<WrappedLine> = alloc::vec![WrappedLine {
38 glyphs: Vec::new(),
39 width: 0,
40 }];
41 let mut col: u16 = 0;
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 col = 0;
52 continue;
53 }
54
55 #[allow(clippy::cast_possible_truncation)]
56 let gw = grapheme.width() as u16;
57 if gw == 0 {
58 continue; // zero-width (combining handled in write_grapheme)
59 }
60
61 // Soft wrap: this grapheme would overflow the line.
62 if col + gw > 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.
71 current.glyphs.pop();
72 current.width = current.glyphs.iter().map(|g| g.width).sum();
73
74 let new_width: u16 = remainder.iter().map(|g| g.width).sum();
75 // col will be incremented by gw in the fall-through below.
76 col = new_width;
77 lines.push(WrappedLine {
78 glyphs: remainder,
79 width: new_width,
80 });
81 } else {
82 // No space on the line: force-break (overlong word).
83 lines.push(WrappedLine {
84 glyphs: Vec::new(),
85 width: 0,
86 });
87 col = 0;
88 // Drop the space that triggered this break: it would just be
89 // leading whitespace on the new line.
90 if grapheme == " " {
91 continue;
92 }
93 }
94 }
95
96 let current = lines.last_mut().expect("always at least one line");
97 current.width += gw;
98 current.glyphs.push(WrappedGlyph {
99 grapheme: String::from(grapheme),
100 style: span.style,
101 width: gw,
102 });
103 col += gw;
104 }
105 }
106
107 lines
108}
109
110/// Word-wraps `line` to `max_width` columns, returning the broken-apart [`Line`](crate::text::Line)s.
111///
112/// This is the same greedy, grapheme-cluster-aware wrap pass [`TextLayout`](super::TextLayout)
113/// runs internally on every render (breaking on ASCII space, honoring hard `\n`s, force-breaking
114/// an overlong word at the column boundary); it's exposed standalone for callers that need the
115/// wrapped pieces themselves rather than having them written straight to a surface, such as a
116/// scrollback log that wraps each message into rows while still addressing its window in whole
117/// messages.
118///
119/// Each returned `Line` is a single unstyled or uniformly-styled run per source span that
120/// survived onto that row; adjacent graphemes carrying the same [`Style`](crate::color::Style) are coalesced back
121/// into one [`Span`](crate::text::Span), so wrapping a plain [`Line::raw`](crate::text::Line::raw) round-trips to plain `Line::raw` rows.
122///
123/// # Examples
124///
125/// ```
126/// use retroglyph_core::layout::wrap;
127/// use retroglyph_core::text::Line;
128///
129/// let line = Line::raw("hello world");
130/// let rows = wrap(&line, 7);
131/// assert_eq!(rows.len(), 2);
132/// assert_eq!(rows[0].spans[0].content, "hello");
133/// assert_eq!(rows[1].spans[0].content, "world");
134/// ```
135#[must_use]
136pub fn wrap(line: &Line, max_width: u16) -> Vec<Line> {
137 wrap_line(line, max_width)
138 .into_iter()
139 .map(|wrapped| {
140 let mut spans: Vec<Span> = Vec::new();
141 for glyph in wrapped.glyphs {
142 if let Some(last) = spans.last_mut()
143 && last.style == glyph.style
144 {
145 last.content.push_str(&glyph.grapheme);
146 continue;
147 }
148 spans.push(Span::styled(glyph.grapheme, glyph.style));
149 }
150 Line { spans }
151 })
152 .collect()
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::color::Color;
159
160 fn red() -> Style {
161 Style::new().fg(Color::RED)
162 }
163
164 #[test]
165 fn test_wrap_no_wrap_needed() {
166 let line = Line::raw("hello");
167 let lines = wrap_line(&line, 10);
168 assert_eq!(lines.len(), 1);
169 assert_eq!(lines[0].width, 5);
170 }
171
172 #[test]
173 fn test_wrap_hard_newline() {
174 let line = Line::raw("hi\nthere");
175 let lines = wrap_line(&line, 20);
176 assert_eq!(lines.len(), 2);
177 assert_eq!(lines[0].width, 2);
178 assert_eq!(lines[1].width, 5);
179 }
180
181 #[test]
182 fn test_wrap_soft_break_on_space() {
183 // "hello world" in a 7-wide box: "hello" fits, space triggers break.
184 let line = Line::raw("hello world");
185 let lines = wrap_line(&line, 7);
186 assert_eq!(lines.len(), 2);
187 assert_eq!(lines[0].width, 5); // "hello", space consumed
188 assert_eq!(lines[1].width, 5); // "world"
189 }
190
191 #[test]
192 fn test_wrap_force_break_no_space() {
193 let line = Line::raw("abcdefgh");
194 let lines = wrap_line(&line, 4);
195 assert_eq!(lines.len(), 2);
196 assert_eq!(lines[0].width, 4);
197 assert_eq!(lines[1].width, 4);
198 }
199
200 #[test]
201 fn test_wrap_force_break_drops_the_triggering_space() {
202 // "abcd" fills the 4-wide box exactly; the following space has no room and no
203 // earlier space on the line to break at, so it force-breaks and is itself dropped
204 // rather than becoming leading whitespace on the new line.
205 let line = Line::raw("abcd e");
206 let lines = wrap_line(&line, 4);
207 assert_eq!(lines.len(), 2);
208 assert_eq!(lines[0].width, 4);
209 assert_eq!(lines[1].width, 1); // "e", not " e"
210 }
211
212 #[test]
213 fn test_wrap_wide_chars() {
214 // Each CJK char is width 2; "中文中" in a 4-wide box wraps after "中文".
215 let line = Line::raw("中文中");
216 let lines = wrap_line(&line, 4);
217 assert_eq!(lines.len(), 2);
218 assert_eq!(lines[0].width, 4);
219 assert_eq!(lines[1].width, 2);
220 }
221
222 #[test]
223 fn test_wrap_multi_span() {
224 let line = Line::from(vec![Span::raw("foo "), Span::styled("bar", red())]);
225 let lines = wrap_line(&line, 20);
226 assert_eq!(lines.len(), 1);
227 assert_eq!(lines[0].width, 7);
228 // The "bar" glyphs should carry the red style.
229 let bar_count = lines[0].glyphs.iter().filter(|g| g.style == red()).count();
230 assert_eq!(bar_count, 3);
231 }
232}