Skip to main content

retroglyph_core/
layout.rs

1//! Text layout: measurement, word wrapping, and bounded alignment.
2//!
3//! The entry point is [`TextLayout`], a builder that accepts a [`Line`] and
4//! layout parameters, then either measures the result or renders it into a
5//! [`Surface`].
6//!
7//! Only available when the `egc` feature is enabled (requires `alloc`).
8
9use crate::grid::{Grid, Rect};
10use crate::style::Style;
11use crate::surface::Surface;
12use crate::text::Line;
13use alloc::string::String;
14use alloc::vec::Vec;
15use unicode_segmentation::UnicodeSegmentation;
16use unicode_width::UnicodeWidthStr;
17
18/// Horizontal alignment within a bounded rectangle.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20pub enum HAlign {
21    /// Align text to the left edge (default).
22    #[default]
23    Left,
24    /// Centre text horizontally.
25    Center,
26    /// Align text to the right edge.
27    Right,
28}
29
30/// Vertical alignment within a bounded rectangle.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32pub enum VAlign {
33    /// Align text to the top edge (default).
34    #[default]
35    Top,
36    /// Centre text vertically.
37    Middle,
38    /// Align text to the bottom edge.
39    Bottom,
40}
41
42/// The display dimensions of a laid-out block of text.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
44pub struct TextMetrics {
45    /// Maximum line width in terminal columns.
46    pub width: u16,
47    /// Number of lines after word-wrapping.
48    pub height: u16,
49}
50
51// ---------------------------------------------------------------------------
52// Internal intermediate types
53// ---------------------------------------------------------------------------
54
55/// One grapheme on a wrapped line, ready to be placed or measured.
56struct WrappedGlyph {
57    /// The grapheme cluster string.
58    grapheme: String,
59    /// Style inherited from the source span.
60    style: Style,
61    /// Display width of this grapheme in terminal columns (1 or 2).
62    width: u16,
63}
64
65/// A line produced by the word-wrap pass.
66struct WrappedLine {
67    glyphs: Vec<WrappedGlyph>,
68    /// Sum of all glyph widths on this line.
69    width: u16,
70}
71
72// ---------------------------------------------------------------------------
73// Word-wrap engine (M3)
74// ---------------------------------------------------------------------------
75
76/// Greedy word-wrap over a [`Line`]'s spans.
77///
78/// Breaks on ASCII space (`' '`): the space is consumed (not placed) at the
79/// break point, and overlong words are force-broken at the column boundary.
80/// Leading whitespace on soft-wrapped continuation lines is preserved.
81///
82/// Note: only `\n` and ASCII space are treated specially. Tabs, NBSP, and
83/// other whitespace are treated as printable 1-wide characters. Callers
84/// should expand tabs before calling if that matters.
85fn wrap_line(line: &Line, max_width: u16) -> Vec<WrappedLine> {
86    let mut lines: Vec<WrappedLine> = alloc::vec![WrappedLine {
87        glyphs: Vec::new(),
88        width: 0,
89    }];
90    let mut col: u16 = 0;
91
92    for span in &line.spans {
93        for grapheme in span.content.graphemes(true) {
94            // Hard newline.
95            if grapheme == "\n" {
96                lines.push(WrappedLine {
97                    glyphs: Vec::new(),
98                    width: 0,
99                });
100                col = 0;
101                continue;
102            }
103
104            #[allow(clippy::cast_possible_truncation)]
105            let gw = grapheme.width() as u16;
106            if gw == 0 {
107                continue; // zero-width (combining handled in write_grapheme)
108            }
109
110            // Soft wrap: this grapheme would overflow the line.
111            if col + gw > max_width && col > 0 {
112                let current = lines.last_mut().expect("always at least one line");
113
114                // Try to break at the last space on the current line.
115                if let Some(space_idx) = current.glyphs.iter().rposition(|g| g.grapheme == " ") {
116                    // Drain everything after the space into a new line.
117                    let remainder: Vec<WrappedGlyph> =
118                        current.glyphs.drain(space_idx + 1..).collect();
119                    // Drop the space itself.
120                    current.glyphs.pop();
121                    current.width = current.glyphs.iter().map(|g| g.width).sum();
122
123                    let new_width: u16 = remainder.iter().map(|g| g.width).sum();
124                    // col will be incremented by gw in the fall-through below.
125                    col = new_width;
126                    lines.push(WrappedLine {
127                        glyphs: remainder,
128                        width: new_width,
129                    });
130                } else {
131                    // No space on the line: force-break (overlong word).
132                    lines.push(WrappedLine {
133                        glyphs: Vec::new(),
134                        width: 0,
135                    });
136                    col = 0;
137                    // Drop the space that triggered this break — it would just be
138                    // leading whitespace on the new line.
139                    if grapheme == " " {
140                        continue;
141                    }
142                }
143            }
144
145            let current = lines.last_mut().expect("always at least one line");
146            current.width += gw;
147            current.glyphs.push(WrappedGlyph {
148                grapheme: String::from(grapheme),
149                style: span.style,
150                width: gw,
151            });
152            col += gw;
153        }
154    }
155
156    lines
157}
158
159// ---------------------------------------------------------------------------
160// TextLayout builder (M4)
161// ---------------------------------------------------------------------------
162
163/// Builder for laying out a [`Line`] within a bounded [`Rect`].
164///
165/// Call [`measure`](TextLayout::measure) to get [`TextMetrics`] without
166/// touching any surface, or [`render_to_surface`](TextLayout::render_to_surface) to write
167/// directly into a [`Surface`].
168///
169/// # Examples
170///
171/// ```
172/// use retroglyph_core::layout::{TextLayout, HAlign, VAlign};
173/// use retroglyph_core::grid::Rect;
174/// use retroglyph_core::text::Line;
175///
176/// let rect = Rect::new(0, 0, 20, 5);
177/// let line = Line::raw("Hello, world!");
178///
179/// let metrics = TextLayout::new(&line)
180///     .rect(rect)
181///     .h_align(HAlign::Center)
182///     .measure();
183///
184/// assert_eq!(metrics.height, 1);
185/// ```
186pub struct TextLayout<'a> {
187    line: &'a Line,
188    rect: Rect,
189    h_align: HAlign,
190    v_align: VAlign,
191}
192
193impl<'a> TextLayout<'a> {
194    /// Creates a new layout builder for `line`.
195    ///
196    /// Defaults: zero-sized rect at origin, left/top alignment. Call
197    /// [`rect`](Self::rect) before [`measure`](Self::measure) or
198    /// [`render_to_surface`](Self::render_to_surface).
199    #[must_use]
200    pub const fn new(line: &'a Line) -> Self {
201        Self {
202            line,
203            rect: Rect::EMPTY,
204            h_align: HAlign::Left,
205            v_align: VAlign::Top,
206        }
207    }
208
209    /// Sets the bounding rectangle.
210    #[must_use]
211    pub const fn rect(mut self, rect: Rect) -> Self {
212        self.rect = rect;
213        self
214    }
215
216    /// Sets the horizontal alignment.
217    #[must_use]
218    pub const fn h_align(mut self, align: HAlign) -> Self {
219        self.h_align = align;
220        self
221    }
222
223    /// Sets the vertical alignment.
224    #[must_use]
225    pub const fn v_align(mut self, align: VAlign) -> Self {
226        self.v_align = align;
227        self
228    }
229
230    /// Measures the text without rendering, returning its [`TextMetrics`].
231    ///
232    /// Uses the rect's `width` for word-wrapping; ignores `height`.
233    #[must_use]
234    pub fn measure(&self) -> TextMetrics {
235        let lines = wrap_line(self.line, self.rect.width());
236        let width = lines.iter().map(|l| l.width).max().unwrap_or(0);
237        #[allow(clippy::cast_possible_truncation)]
238        let height = lines.len().min(u16::MAX as usize) as u16;
239        TextMetrics { width, height }
240    }
241
242    /// Renders the text into `surface`, clipping to both the rect's bounds and `surface`'s own
243    /// area (the rect is intersected with [`Surface::area`] first, so text can never escape the
244    /// surface it was given even if `rect` extends past it).
245    pub fn render_to_surface(&self, surface: &mut Surface<'_>) {
246        let clipped = Self {
247            line: self.line,
248            rect: self.rect.intersect(surface.area()),
249            h_align: self.h_align,
250            v_align: self.v_align,
251        };
252        let layer = surface.layer();
253        clipped.render_to_grid(surface.grid_mut(), layer);
254    }
255
256    /// Renders the text into `grid` on `layer`, clipping to the rect's bounds.
257    ///
258    /// The [`Grid`]-level twin of [`render_to_surface`](Self::render_to_surface), for callers
259    /// with no [`Surface`] of their own to hand over.
260    pub fn render_to_grid(&self, grid: &mut Grid, layer: u8) {
261        let lines = wrap_line(self.line, self.rect.width());
262        let rect = self.rect;
263
264        #[allow(clippy::cast_possible_truncation)]
265        let total_lines = lines.len().min(usize::from(rect.height())) as u16;
266
267        let y_offset = match self.v_align {
268            VAlign::Top => 0,
269            VAlign::Middle => rect.height().saturating_sub(total_lines) / 2,
270            VAlign::Bottom => rect.height().saturating_sub(total_lines),
271        };
272
273        for (line_idx, wrapped) in lines.into_iter().take(total_lines as usize).enumerate() {
274            let x_offset = match self.h_align {
275                HAlign::Left => 0,
276                HAlign::Center => rect.width().saturating_sub(wrapped.width) / 2,
277                HAlign::Right => rect.width().saturating_sub(wrapped.width),
278            };
279
280            #[allow(clippy::cast_possible_truncation)]
281            let row = rect.top() + y_offset + line_idx as u16;
282            let mut cx = rect.left() + x_offset;
283
284            for glyph in wrapped.glyphs {
285                if cx >= rect.right() {
286                    break;
287                }
288                grid.write_grapheme(layer, cx, row, &glyph.grapheme, glyph.style);
289                cx += glyph.width;
290            }
291        }
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Tests
297// ---------------------------------------------------------------------------
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::color::Color;
303    use crate::grid::Pos;
304    use crate::style::Style;
305    use crate::text::{Line, Span};
306
307    fn red() -> Style {
308        Style::new().fg(Color::RED)
309    }
310
311    // --- wrap_line ---
312
313    #[test]
314    fn test_wrap_no_wrap_needed() {
315        let line = Line::raw("hello");
316        let lines = wrap_line(&line, 10);
317        assert_eq!(lines.len(), 1);
318        assert_eq!(lines[0].width, 5);
319    }
320
321    #[test]
322    fn test_wrap_hard_newline() {
323        let line = Line::raw("hi\nthere");
324        let lines = wrap_line(&line, 20);
325        assert_eq!(lines.len(), 2);
326        assert_eq!(lines[0].width, 2);
327        assert_eq!(lines[1].width, 5);
328    }
329
330    #[test]
331    fn test_wrap_soft_break_on_space() {
332        // "hello world" in a 7-wide box: "hello" fits, space triggers break.
333        let line = Line::raw("hello world");
334        let lines = wrap_line(&line, 7);
335        assert_eq!(lines.len(), 2);
336        assert_eq!(lines[0].width, 5); // "hello" — space consumed
337        assert_eq!(lines[1].width, 5); // "world"
338    }
339
340    #[test]
341    fn test_wrap_force_break_no_space() {
342        let line = Line::raw("abcdefgh");
343        let lines = wrap_line(&line, 4);
344        assert_eq!(lines.len(), 2);
345        assert_eq!(lines[0].width, 4);
346        assert_eq!(lines[1].width, 4);
347    }
348
349    #[test]
350    fn test_wrap_wide_chars() {
351        // Each CJK char is width 2; "中文中" in a 4-wide box wraps after "中文".
352        let line = Line::raw("中文中");
353        let lines = wrap_line(&line, 4);
354        assert_eq!(lines.len(), 2);
355        assert_eq!(lines[0].width, 4);
356        assert_eq!(lines[1].width, 2);
357    }
358
359    #[test]
360    fn test_wrap_multi_span() {
361        let line = Line::from(vec![Span::raw("foo "), Span::styled("bar", red())]);
362        let lines = wrap_line(&line, 20);
363        assert_eq!(lines.len(), 1);
364        assert_eq!(lines[0].width, 7);
365        // The "bar" glyphs should carry the red style.
366        let bar_count = lines[0].glyphs.iter().filter(|g| g.style == red()).count();
367        assert_eq!(bar_count, 3);
368    }
369
370    // --- TextLayout::measure ---
371
372    #[test]
373    fn test_measure_single_line() {
374        let line = Line::raw("hello");
375        let m = TextLayout::new(&line)
376            .rect(Rect::new(0, 0, 20, 5))
377            .measure();
378        assert_eq!(m.width, 5);
379        assert_eq!(m.height, 1);
380    }
381
382    #[test]
383    fn test_measure_wraps() {
384        let line = Line::raw("hello world");
385        let m = TextLayout::new(&line)
386            .rect(Rect::new(0, 0, 7, 10))
387            .measure();
388        assert_eq!(m.height, 2);
389        assert_eq!(m.width, 5);
390    }
391
392    // --- TextLayout::render ---
393
394    #[test]
395    fn test_render_left_top() {
396        use crate::backend::Headless;
397        use crate::terminal::Terminal;
398
399        let mut term = Terminal::new(Headless::new(20, 5));
400        let line = Line::raw("hi");
401        TextLayout::new(&line)
402            .rect(Rect::new(2, 1, 10, 3))
403            .render_to_surface(&mut term.surface());
404
405        assert_eq!(term.grid()[Pos::new(2, 1)].glyph(), 'h');
406        assert_eq!(term.grid()[Pos::new(3, 1)].glyph(), 'i');
407        assert_eq!(term.grid()[Pos::new(4, 1)].glyph(), ' '); // unchanged
408    }
409
410    #[test]
411    fn test_render_center_h() {
412        use crate::backend::Headless;
413        use crate::terminal::Terminal;
414
415        // "hi" (width 2) centred in a 10-wide box: x_offset = (10-2)/2 = 4
416        let mut term = Terminal::new(Headless::new(20, 5));
417        let line = Line::raw("hi");
418        TextLayout::new(&line)
419            .rect(Rect::new(0, 0, 10, 3))
420            .h_align(HAlign::Center)
421            .render_to_surface(&mut term.surface());
422
423        assert_eq!(term.grid()[Pos::new(4, 0)].glyph(), 'h');
424        assert_eq!(term.grid()[Pos::new(5, 0)].glyph(), 'i');
425    }
426
427    #[test]
428    fn test_render_right_h() {
429        use crate::backend::Headless;
430        use crate::terminal::Terminal;
431
432        // "hi" right-aligned in 10 columns: starts at col 8.
433        let mut term = Terminal::new(Headless::new(20, 5));
434        let line = Line::raw("hi");
435        TextLayout::new(&line)
436            .rect(Rect::new(0, 0, 10, 3))
437            .h_align(HAlign::Right)
438            .render_to_surface(&mut term.surface());
439
440        assert_eq!(term.grid()[Pos::new(8, 0)].glyph(), 'h');
441        assert_eq!(term.grid()[Pos::new(9, 0)].glyph(), 'i');
442    }
443
444    #[test]
445    fn test_render_middle_v() {
446        use crate::backend::Headless;
447        use crate::terminal::Terminal;
448
449        // 1 line of text, 5-row box: y_offset = (5-1)/2 = 2
450        let mut term = Terminal::new(Headless::new(20, 10));
451        let line = Line::raw("hi");
452        TextLayout::new(&line)
453            .rect(Rect::new(0, 0, 10, 5))
454            .v_align(VAlign::Middle)
455            .render_to_surface(&mut term.surface());
456
457        assert_eq!(term.grid()[Pos::new(0, 2)].glyph(), 'h');
458    }
459
460    #[test]
461    fn test_render_bottom_v() {
462        use crate::backend::Headless;
463        use crate::terminal::Terminal;
464
465        // 1 line in a 5-row box bottom-aligned: row 4.
466        let mut term = Terminal::new(Headless::new(20, 10));
467        let line = Line::raw("hi");
468        TextLayout::new(&line)
469            .rect(Rect::new(0, 0, 10, 5))
470            .v_align(VAlign::Bottom)
471            .render_to_surface(&mut term.surface());
472
473        assert_eq!(term.grid()[Pos::new(0, 4)].glyph(), 'h');
474    }
475
476    #[test]
477    fn test_render_clips_to_height() {
478        use crate::backend::Headless;
479        use crate::terminal::Terminal;
480
481        // "a b c" wraps to 3 lines in a 1-wide box; height=2 clips to 2.
482        let mut term = Terminal::new(Headless::new(10, 10));
483        let line = Line::raw("a b c");
484        TextLayout::new(&line)
485            .rect(Rect::new(0, 0, 1, 2))
486            .render_to_surface(&mut term.surface());
487
488        assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), 'a');
489        assert_eq!(term.grid()[Pos::new(0, 1)].glyph(), 'b');
490        assert_eq!(term.grid()[Pos::new(0, 2)].glyph(), ' '); // clipped
491    }
492}