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 ixy::HasSize;
154
155    use super::*;
156    use crate::grid::Pos;
157    use alloc::string::String;
158
159    #[test]
160    fn test_measure_single_line() {
161        let line = Line::raw("hello");
162        let m = TextLayout::new(&line)
163            .rect(Rect::new(0, 0, 20, 5))
164            .measure();
165        assert_eq!(m.width(), 5);
166        assert_eq!(m.height(), 1);
167    }
168
169    #[test]
170    fn test_measure_wraps() {
171        let line = Line::raw("hello world");
172        let m = TextLayout::new(&line)
173            .rect(Rect::new(0, 0, 7, 10))
174            .measure();
175        assert_eq!(m.height(), 2);
176        assert_eq!(m.width(), 5);
177    }
178
179    #[test]
180    fn test_render_left_top() {
181        use crate::backend::Headless;
182        use crate::terminal::Terminal;
183
184        let mut term = Terminal::new(Headless::new(20, 5));
185        let line = Line::raw("hi");
186        TextLayout::new(&line)
187            .rect(Rect::new(2, 1, 10, 3))
188            .render_to_surface(&mut term.surface());
189
190        assert_eq!(term.grid()[Pos::new(2, 1)].glyph(), 'h');
191        assert_eq!(term.grid()[Pos::new(3, 1)].glyph(), 'i');
192        assert_eq!(term.grid()[Pos::new(4, 1)].glyph(), ' '); // unchanged
193    }
194
195    #[test]
196    fn test_render_center_h() {
197        use crate::backend::Headless;
198        use crate::terminal::Terminal;
199
200        // "hi" (width 2) centred in a 10-wide box: x_offset = (10-2)/2 = 4
201        let mut term = Terminal::new(Headless::new(20, 5));
202        let line = Line::raw("hi");
203        TextLayout::new(&line)
204            .rect(Rect::new(0, 0, 10, 3))
205            .h_align(HAlign::Center)
206            .render_to_surface(&mut term.surface());
207
208        assert_eq!(term.grid()[Pos::new(4, 0)].glyph(), 'h');
209        assert_eq!(term.grid()[Pos::new(5, 0)].glyph(), 'i');
210    }
211
212    #[test]
213    fn test_render_right_h() {
214        use crate::backend::Headless;
215        use crate::terminal::Terminal;
216
217        // "hi" right-aligned in 10 columns: starts at col 8.
218        let mut term = Terminal::new(Headless::new(20, 5));
219        let line = Line::raw("hi");
220        TextLayout::new(&line)
221            .rect(Rect::new(0, 0, 10, 3))
222            .h_align(HAlign::Right)
223            .render_to_surface(&mut term.surface());
224
225        assert_eq!(term.grid()[Pos::new(8, 0)].glyph(), 'h');
226        assert_eq!(term.grid()[Pos::new(9, 0)].glyph(), 'i');
227    }
228
229    #[test]
230    fn test_render_middle_v() {
231        use crate::backend::Headless;
232        use crate::terminal::Terminal;
233
234        // 1 line of text, 5-row box: y_offset = (5-1)/2 = 2
235        let mut term = Terminal::new(Headless::new(20, 10));
236        let line = Line::raw("hi");
237        TextLayout::new(&line)
238            .rect(Rect::new(0, 0, 10, 5))
239            .v_align(VAlign::Middle)
240            .render_to_surface(&mut term.surface());
241
242        assert_eq!(term.grid()[Pos::new(0, 2)].glyph(), 'h');
243    }
244
245    #[test]
246    fn test_render_bottom_v() {
247        use crate::backend::Headless;
248        use crate::terminal::Terminal;
249
250        // 1 line in a 5-row box bottom-aligned: row 4.
251        let mut term = Terminal::new(Headless::new(20, 10));
252        let line = Line::raw("hi");
253        TextLayout::new(&line)
254            .rect(Rect::new(0, 0, 10, 5))
255            .v_align(VAlign::Bottom)
256            .render_to_surface(&mut term.surface());
257
258        assert_eq!(term.grid()[Pos::new(0, 4)].glyph(), 'h');
259    }
260
261    #[test]
262    fn test_render_clips_to_height() {
263        use crate::backend::Headless;
264        use crate::terminal::Terminal;
265
266        // "a b c" wraps to 3 lines in a 1-wide box; height=2 clips to 2.
267        let mut term = Terminal::new(Headless::new(10, 10));
268        let line = Line::raw("a b c");
269        TextLayout::new(&line)
270            .rect(Rect::new(0, 0, 1, 2))
271            .render_to_surface(&mut term.surface());
272
273        assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), 'a');
274        assert_eq!(term.grid()[Pos::new(0, 1)].glyph(), 'b');
275        assert_eq!(term.grid()[Pos::new(0, 2)].glyph(), ' '); // clipped
276    }
277
278    #[test]
279    fn text_layout_render_to_surface_escapes_the_surface_clip() {
280        use crate::backend::Headless;
281        use crate::terminal::Terminal;
282
283        // The rect (10x4) extends well past the surface's one-row clip: "hello world"
284        // wraps to "hello" / "world" at word boundaries, and the wrapped remainder
285        // ("world") must not be painted on row 1, outside the clip.
286        let mut term = Terminal::new(Headless::new(20, 5));
287        {
288            let mut surface = term.surface();
289            let mut bar = surface.clip(Rect::new(0, 0, 10, 1));
290            let line = Line::raw("hello world");
291            TextLayout::new(&line)
292                .rect(Rect::new(0, 0, 10, 4))
293                .render_to_surface(&mut bar);
294        }
295
296        let row0: String = (0..10)
297            .map(|x| term.grid()[Pos::new(x, 0)].glyph())
298            .collect();
299        assert_eq!(row0.trim_end(), "hello");
300        for x in 0..10 {
301            assert_eq!(term.grid()[Pos::new(x, 1)].glyph(), ' ');
302        }
303    }
304
305    #[test]
306    fn text_layout_wide_glyph_stays_inside_the_rect() {
307        use crate::backend::Headless;
308        use crate::terminal::Terminal;
309
310        // A single wide (2-column) glyph in a rect one column too narrow for it: neither
311        // the primary cell nor its spacer may be written, since the spacer would land at
312        // column 1, outside the 1-wide rect.
313        let mut term = Terminal::new(Headless::new(10, 5));
314        let line = Line::raw("\u{3042}"); // 'あ', a wide CJK glyph
315        TextLayout::new(&line)
316            .rect(Rect::new(0, 0, 1, 1))
317            .render_to_surface(&mut term.surface());
318
319        assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), ' ');
320        assert_eq!(term.grid()[Pos::new(1, 0)].glyph(), ' ');
321    }
322}