Skip to main content

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}