Skip to main content

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