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::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}