Skip to main content

retroglyph_core/layout/
text_layout.rs

1//! [`TextLayout`](crate::layout::TextLayout) builder: wraps a [`Line`](crate::text::Line) to a bounded [`Rect`](crate::grid::Rect) and positions it with
2//! [`HAlign`](crate::layout::HAlign)/[`VAlign`](crate::layout::VAlign).
3
4use super::align::{HAlign, VAlign};
5use super::word_wrap::wrap_line;
6use crate::grid::{Grid, Rect, Size};
7use crate::surface::Surface;
8use crate::text::Line;
9
10/// Builder for laying out a [`Line`](crate::text::Line) within a bounded [`Rect`](crate::grid::Rect).
11///
12/// Call [`measure`](crate::layout::TextLayout::measure) to get its [`Size`](crate::grid::Size) without
13/// touching any surface, or [`render_to_surface`](crate::layout::TextLayout::render_to_surface) to write
14/// directly into a [`Surface`](crate::surface::Surface).
15///
16/// # Examples
17///
18/// ```
19/// use retroglyph_core::layout::{TextLayout, HAlign, VAlign};
20/// use retroglyph_core::grid::Rect;
21/// use retroglyph_core::text::Line;
22/// use retroglyph_core::grid::HasSize;
23///
24/// let rect = Rect::new(0, 0, 20, 5);
25/// let line = Line::raw("Hello, world!");
26///
27/// let metrics = TextLayout::new(&line)
28///     .rect(rect)
29///     .h_align(HAlign::Center)
30///     .measure();
31///
32/// assert_eq!(metrics.height(), 1);
33/// ```
34pub struct TextLayout<'a> {
35    line: &'a Line,
36    rect: Rect,
37    h_align: HAlign,
38    v_align: VAlign,
39}
40
41impl<'a> TextLayout<'a> {
42    /// Creates a new layout builder for `line`.
43    ///
44    /// Defaults: zero-sized rect at origin, left/top alignment. Call
45    /// [`rect`](Self::rect) before [`measure`](Self::measure) or
46    /// [`render_to_surface`](Self::render_to_surface).
47    #[must_use]
48    pub const fn new(line: &'a Line) -> Self {
49        Self {
50            line,
51            rect: Rect::EMPTY,
52            h_align: HAlign::Left,
53            v_align: VAlign::Top,
54        }
55    }
56
57    /// Sets the bounding rectangle.
58    #[must_use]
59    pub const fn rect(mut self, rect: Rect) -> Self {
60        self.rect = rect;
61        self
62    }
63
64    /// Sets the horizontal alignment.
65    #[must_use]
66    pub const fn h_align(mut self, align: HAlign) -> Self {
67        self.h_align = align;
68        self
69    }
70
71    /// Sets the vertical alignment.
72    #[must_use]
73    pub const fn v_align(mut self, align: VAlign) -> Self {
74        self.v_align = align;
75        self
76    }
77
78    /// Measures the text without rendering, returning its [`Size`](crate::grid::Size): `width` is the widest
79    /// wrapped line in columns, `height` is the number of wrapped lines.
80    ///
81    /// Uses the rect's `width` for word-wrapping; ignores `height`.
82    #[must_use]
83    pub fn measure(&self) -> Size {
84        let lines = wrap_line(self.line, self.rect.width());
85        let width = lines.iter().map(|l| l.width).max().unwrap_or(0);
86        #[allow(clippy::cast_possible_truncation)]
87        let height = lines.len().min(u16::MAX as usize) as u16;
88        Size::new(width, height)
89    }
90
91    /// Renders the text into `surface`, clipping to both the rect's bounds and `surface`'s own
92    /// clip (the rect is intersected with [`Surface::clip_rect`](crate::surface::Surface::clip_rect) first, so text can never escape
93    /// whatever clip the caller applied even if `rect` extends past it).
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// use retroglyph_core::backend::Headless;
99    /// use retroglyph_core::grid::{Pos, Rect};
100    /// use retroglyph_core::layout::TextLayout;
101    /// use retroglyph_core::text::Line;
102    /// use retroglyph_core::terminal::Terminal;
103    ///
104    /// let mut term = Terminal::new(Headless::new(20, 5));
105    /// let line = Line::raw("hi");
106    /// TextLayout::new(&line)
107    ///     .rect(Rect::new(2, 1, 10, 3))
108    ///     .render_to_surface(&mut term.surface());
109    ///
110    /// assert_eq!(term.grid()[Pos::new(2, 1)].glyph(), 'h');
111    /// ```
112    pub fn render_to_surface(&self, surface: &mut Surface<'_>) {
113        let clipped = Self {
114            line: self.line,
115            rect: self.rect.intersect(surface.clip_rect()),
116            h_align: self.h_align,
117            v_align: self.v_align,
118        };
119        let layer = surface.layer();
120        clipped.render_to_grid(surface.grid_mut(), layer);
121    }
122
123    /// Renders the text into `grid` on `layer`, clipping to the rect's bounds.
124    pub fn render_to_grid(&self, grid: &mut Grid, layer: u8) {
125        let lines = wrap_line(self.line, self.rect.width());
126        let rect = self.rect;
127
128        #[allow(clippy::cast_possible_truncation)]
129        let total_lines = lines.len().min(usize::from(rect.height())) as u16;
130
131        let y_offset = self.v_align.offset(rect.height(), total_lines);
132
133        for (line_idx, wrapped) in lines.into_iter().take(total_lines as usize).enumerate() {
134            let x_offset = self.h_align.offset(rect.width(), wrapped.width);
135
136            #[allow(clippy::cast_possible_truncation)]
137            let row = rect.top() + y_offset + line_idx as u16;
138            let mut cx = rect.left() + x_offset;
139
140            for glyph in wrapped.glyphs {
141                if cx + glyph.width > rect.right() {
142                    break;
143                }
144                grid.write_grapheme(layer, cx, row, &glyph.grapheme, glyph.style);
145                cx += glyph.width;
146            }
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::grid::{HasSize, Pos};
155    use alloc::string::String;
156
157    #[test]
158    fn test_measure_single_line() {
159        let line = Line::raw("hello");
160        let m = TextLayout::new(&line)
161            .rect(Rect::new(0, 0, 20, 5))
162            .measure();
163        assert_eq!(m.width(), 5);
164        assert_eq!(m.height(), 1);
165    }
166
167    #[test]
168    fn test_measure_wraps() {
169        let line = Line::raw("hello world");
170        let m = TextLayout::new(&line)
171            .rect(Rect::new(0, 0, 7, 10))
172            .measure();
173        assert_eq!(m.height(), 2);
174        assert_eq!(m.width(), 5);
175    }
176
177    #[test]
178    fn test_render_left_top() {
179        use crate::backend::Headless;
180        use crate::terminal::Terminal;
181
182        let mut term = Terminal::new(Headless::new(20, 5));
183        let line = Line::raw("hi");
184        TextLayout::new(&line)
185            .rect(Rect::new(2, 1, 10, 3))
186            .render_to_surface(&mut term.surface());
187
188        assert_eq!(term.grid()[Pos::new(2, 1)].glyph(), 'h');
189        assert_eq!(term.grid()[Pos::new(3, 1)].glyph(), 'i');
190        assert_eq!(term.grid()[Pos::new(4, 1)].glyph(), ' '); // unchanged
191    }
192
193    #[test]
194    fn test_render_center_h() {
195        use crate::backend::Headless;
196        use crate::terminal::Terminal;
197
198        // "hi" (width 2) centred in a 10-wide box: x_offset = (10-2)/2 = 4
199        let mut term = Terminal::new(Headless::new(20, 5));
200        let line = Line::raw("hi");
201        TextLayout::new(&line)
202            .rect(Rect::new(0, 0, 10, 3))
203            .h_align(HAlign::Center)
204            .render_to_surface(&mut term.surface());
205
206        assert_eq!(term.grid()[Pos::new(4, 0)].glyph(), 'h');
207        assert_eq!(term.grid()[Pos::new(5, 0)].glyph(), 'i');
208    }
209
210    #[test]
211    fn test_render_right_h() {
212        use crate::backend::Headless;
213        use crate::terminal::Terminal;
214
215        // "hi" right-aligned in 10 columns: starts at col 8.
216        let mut term = Terminal::new(Headless::new(20, 5));
217        let line = Line::raw("hi");
218        TextLayout::new(&line)
219            .rect(Rect::new(0, 0, 10, 3))
220            .h_align(HAlign::Right)
221            .render_to_surface(&mut term.surface());
222
223        assert_eq!(term.grid()[Pos::new(8, 0)].glyph(), 'h');
224        assert_eq!(term.grid()[Pos::new(9, 0)].glyph(), 'i');
225    }
226
227    #[test]
228    fn test_render_middle_v() {
229        use crate::backend::Headless;
230        use crate::terminal::Terminal;
231
232        // 1 line of text, 5-row box: y_offset = (5-1)/2 = 2
233        let mut term = Terminal::new(Headless::new(20, 10));
234        let line = Line::raw("hi");
235        TextLayout::new(&line)
236            .rect(Rect::new(0, 0, 10, 5))
237            .v_align(VAlign::Middle)
238            .render_to_surface(&mut term.surface());
239
240        assert_eq!(term.grid()[Pos::new(0, 2)].glyph(), 'h');
241    }
242
243    #[test]
244    fn test_render_bottom_v() {
245        use crate::backend::Headless;
246        use crate::terminal::Terminal;
247
248        // 1 line in a 5-row box bottom-aligned: row 4.
249        let mut term = Terminal::new(Headless::new(20, 10));
250        let line = Line::raw("hi");
251        TextLayout::new(&line)
252            .rect(Rect::new(0, 0, 10, 5))
253            .v_align(VAlign::Bottom)
254            .render_to_surface(&mut term.surface());
255
256        assert_eq!(term.grid()[Pos::new(0, 4)].glyph(), 'h');
257    }
258
259    #[test]
260    fn test_render_clips_to_height() {
261        use crate::backend::Headless;
262        use crate::terminal::Terminal;
263
264        // "a b c" wraps to 3 lines in a 1-wide box; height=2 clips to 2.
265        let mut term = Terminal::new(Headless::new(10, 10));
266        let line = Line::raw("a b c");
267        TextLayout::new(&line)
268            .rect(Rect::new(0, 0, 1, 2))
269            .render_to_surface(&mut term.surface());
270
271        assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), 'a');
272        assert_eq!(term.grid()[Pos::new(0, 1)].glyph(), 'b');
273        assert_eq!(term.grid()[Pos::new(0, 2)].glyph(), ' '); // clipped
274    }
275
276    #[test]
277    fn text_layout_render_to_surface_escapes_the_surface_clip() {
278        use crate::backend::Headless;
279        use crate::terminal::Terminal;
280
281        // The rect (10x4) extends well past the surface's one-row clip: "hello world"
282        // wraps to "hello" / "world" at word boundaries, and the wrapped remainder
283        // ("world") must not be painted on row 1, outside the clip.
284        let mut term = Terminal::new(Headless::new(20, 5));
285        {
286            let mut surface = term.surface();
287            let mut bar = surface.clip(Rect::new(0, 0, 10, 1));
288            let line = Line::raw("hello world");
289            TextLayout::new(&line)
290                .rect(Rect::new(0, 0, 10, 4))
291                .render_to_surface(&mut bar);
292        }
293
294        let row0: String = (0..10)
295            .map(|x| term.grid()[Pos::new(x, 0)].glyph())
296            .collect();
297        assert_eq!(row0.trim_end(), "hello");
298        for x in 0..10 {
299            assert_eq!(term.grid()[Pos::new(x, 1)].glyph(), ' ');
300        }
301    }
302
303    #[test]
304    fn text_layout_wide_glyph_stays_inside_the_rect() {
305        use crate::backend::Headless;
306        use crate::terminal::Terminal;
307
308        // A single wide (2-column) glyph in a rect one column too narrow for it: neither
309        // the primary cell nor its spacer may be written, since the spacer would land at
310        // column 1, outside the 1-wide rect.
311        let mut term = Terminal::new(Headless::new(10, 5));
312        let line = Line::raw("\u{3042}"); // 'あ', a wide CJK glyph
313        TextLayout::new(&line)
314            .rect(Rect::new(0, 0, 1, 1))
315            .render_to_surface(&mut term.surface());
316
317        assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), ' ');
318        assert_eq!(term.grid()[Pos::new(1, 0)].glyph(), ' ');
319    }
320}