Skip to main content

retroglyph_ui/widget/
paragraph.rs

1//! [`Paragraph`]: word-wrapped text, implementing both [`Widget`] and
2//! [`Measure`] so a caller can size a pane to fit before rendering.
3//!
4//! Always available: wrapping falls back to a `char`-boundary-safe,
5//! ASCII-whitespace word wrap with no grapheme-cluster correctness (a
6//! combining mark, ZWJ sequence, or wide CJK run may land on the wrong
7//! side of a wrap point). Enabling the `egc` feature upgrades this same
8//! type to route through [`retroglyph_core::layout::TextLayout`] instead,
9//! which handles grapheme clusters and hard newlines correctly; there is
10//! no second, egc-only `Paragraph` type to migrate to.
11#[cfg(not(feature = "egc"))]
12use alloc::string::{String, ToString as _};
13#[cfg(not(feature = "egc"))]
14use alloc::vec;
15#[cfg(not(feature = "egc"))]
16use alloc::vec::Vec;
17
18use retroglyph_core::color::Style;
19#[cfg(feature = "egc")]
20use retroglyph_core::grid::HasSize;
21#[cfg(feature = "egc")]
22use retroglyph_core::grid::Rect;
23#[cfg(feature = "egc")]
24use retroglyph_core::layout::TextLayout;
25#[cfg(feature = "egc")]
26use retroglyph_core::text::{Line, Span};
27
28use super::{Measure, Widget};
29use crate::Surface;
30
31/// Word-wrapped text in a single [`Style`].
32///
33/// `Paragraph::new(text)` wraps `text` to whatever width it is rendered at
34/// (via [`Widget::render`]), or reports the height it would need at a
35/// given width without rendering (via [`Measure::height_for`]) so a caller
36/// can size its pane to fit instead of guessing a fixed height. `style`
37/// defaults to [`Style::new()`]; set it with [`Paragraph::style`].
38///
39/// Without the `egc` feature, wrapping is `char`-boundary-safe and breaks
40/// on ASCII whitespace only: no grapheme-cluster segmentation, so a
41/// combining mark or wide CJK run can land on either side of a wrap point.
42/// Enabling `egc` upgrades wrapping to [`retroglyph_core::layout::TextLayout`],
43/// which is grapheme-cluster-aware; every other text-bearing widget in this
44/// crate (`List`, `Table`, `Log`) already has this same gap regardless of
45/// `egc`.
46///
47/// Unlike [`super::BoxBorder`], [`super::Gauge`], [`super::StatBar`],
48/// [`super::Table`], and [`super::Button`], `Paragraph` has no `theme()`/
49/// `theme_on()` pair: word-wrapped text has no single semantic
50/// [`Theme`](crate::Theme) role to map onto, so callers set `style` directly.
51///
52/// # Examples
53///
54/// ```
55/// use retroglyph_core::grid::{Grid, Rect};
56/// use retroglyph_ui::{Measure, Paragraph, Surface, Widget};
57///
58/// let p = Paragraph::new("the quick brown fox jumps");
59/// let height = p.height_for(10); // rows needed to wrap at 10 columns
60///
61/// let area = Rect::new(0, 0, 10, height);
62/// let mut grid = Grid::new(10, height);
63/// p.render(&mut Surface::new(&mut grid, area, 0));
64/// ```
65#[derive(Clone, Copy, Debug)]
66pub struct Paragraph<'a> {
67    text: &'a str,
68    style: Style,
69}
70
71impl<'a> Paragraph<'a> {
72    /// Text to be word-wrapped, in the default style.
73    #[must_use]
74    pub fn new(text: &'a str) -> Self {
75        Self {
76            text,
77            style: Style::new(),
78        }
79    }
80
81    /// Set the text's style.
82    #[must_use]
83    pub const fn style(mut self, style: Style) -> Self {
84        self.style = style;
85        self
86    }
87
88    #[cfg(feature = "egc")]
89    fn line(&self) -> Line {
90        Line::from(Span::styled(self.text, self.style))
91    }
92}
93
94/// Greedy, `char`-boundary-safe word wrap used when the `egc` feature is off.
95///
96/// Breaks on ASCII space (`' '`, consumed at the break point, not placed) and on `'\n'` (a hard
97/// break, regardless of width); an overlong word with no space to break at is force-broken at a
98/// `char` boundary once it would exceed `max_width`. Zero-width `char`s (e.g. combining marks)
99/// are dropped rather than measured, since without `egc` there is no grapheme-cluster pass to
100/// attach them to the `char` before them. This mirrors
101/// [`retroglyph_core::layout::TextLayout`]'s own wrap algorithm one abstraction level down
102/// (`char` instead of grapheme cluster), so plain-ASCII input wraps identically either way.
103#[cfg(not(feature = "egc"))]
104fn wrap(text: &str, max_width: u16) -> Vec<String> {
105    use retroglyph_core::text::char_width;
106
107    let mut lines: Vec<String> = vec![String::new()];
108    let mut col: u16 = 0;
109
110    for ch in text.chars() {
111        if ch == '\n' {
112            lines.push(String::new());
113            col = 0;
114            continue;
115        }
116
117        let cw = char_width(ch);
118        if cw == 0 {
119            continue; // zero-width char with no grapheme pass to attach it to; drop it.
120        }
121
122        if col + cw > max_width && col > 0 {
123            let current = lines.last_mut().expect("always at least one line");
124            if let Some(space_idx) = current.rfind(' ') {
125                let remainder = current[space_idx + 1..].to_string();
126                current.truncate(space_idx); // also drops the space itself
127                col = remainder.chars().map(char_width).sum();
128                lines.push(remainder);
129            } else {
130                // No space on the line: force-break (overlong word).
131                lines.push(String::new());
132                col = 0;
133                if ch == ' ' {
134                    // Would just be leading whitespace on the new line.
135                    continue;
136                }
137            }
138        }
139
140        lines.last_mut().expect("always at least one line").push(ch);
141        col += cw;
142    }
143
144    lines
145}
146
147#[cfg(feature = "egc")]
148impl Measure for Paragraph<'_> {
149    fn height_for(&self, width: u16) -> u16 {
150        let line = self.line();
151        TextLayout::new(&line)
152            .rect(Rect::new(0, 0, width, u16::MAX))
153            .measure()
154            .height()
155    }
156}
157
158#[cfg(not(feature = "egc"))]
159impl Measure for Paragraph<'_> {
160    fn height_for(&self, width: u16) -> u16 {
161        let lines = wrap(self.text, width);
162        #[allow(clippy::cast_possible_truncation)]
163        let height = lines.len().min(usize::from(u16::MAX)) as u16;
164        height
165    }
166}
167
168#[cfg(feature = "egc")]
169impl Widget for Paragraph<'_> {
170    fn render(&self, surface: &mut Surface<'_>) {
171        let area = surface.area();
172        let line = self.line();
173        TextLayout::new(&line).rect(area).render_to_surface(surface);
174    }
175}
176
177#[cfg(not(feature = "egc"))]
178impl Widget for Paragraph<'_> {
179    fn render(&self, surface: &mut Surface<'_>) {
180        let width = surface.area().width();
181        let height = surface.area().height();
182        let lines = wrap(self.text, width);
183        for (row, line) in lines.into_iter().take(usize::from(height)).enumerate() {
184            #[allow(clippy::cast_possible_truncation)] // `row < height`, a `u16`
185            surface.print((0, row as u16), &line, self.style);
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use alloc::string::String;
193
194    use retroglyph_core::grid::{Grid, Pos, Rect};
195
196    use super::*;
197
198    #[test]
199    fn height_for_matches_wrapped_line_count() {
200        let p = Paragraph::new("the quick brown fox jumps");
201        assert_eq!(p.height_for(10), 3); // "the quick" / "brown fox" / "jumps"
202        assert_eq!(p.height_for(100), 1);
203    }
204
205    #[test]
206    fn height_for_respects_hard_newlines() {
207        // A naive whitespace-based wrap would flatten this to one paragraph;
208        // both wrap paths treat "\n" as a hard break regardless of width.
209        let p = Paragraph::new("first\nsecond\nthird");
210        assert_eq!(p.height_for(100), 3);
211    }
212
213    #[test]
214    fn render_draws_one_line_per_wrapped_row() {
215        let area = Rect::new(0, 0, 10, 5);
216        let mut grid = Grid::new(10, 5);
217        Paragraph::new("the quick brown fox jumps").render(&mut Surface::new(&mut grid, area, 0));
218
219        let row0: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
220        let row1: String = (0..10).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
221        let row2: String = (0..10).map(|x| grid[Pos::new(x, 2)].glyph()).collect();
222        assert!(row0.starts_with("the quick"));
223        assert!(row1.starts_with("brown fox"));
224        assert!(row2.starts_with("jumps"));
225    }
226
227    #[test]
228    fn render_stops_at_the_area_bottom() {
229        // Only 1 row of height: only the first wrapped line should draw.
230        let area = Rect::new(0, 0, 10, 1);
231        let mut grid = Grid::new(10, 2);
232        Paragraph::new("the quick brown fox jumps").render(&mut Surface::new(&mut grid, area, 0));
233
234        let row1: String = (0..10).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
235        assert_eq!(row1.trim(), "");
236    }
237
238    #[cfg(feature = "egc")]
239    #[test]
240    fn paragraph_honours_the_surface_clip() {
241        // Clip out rows 1-2: the wrapped remainder ("cccc") must not be drawn there,
242        // even though the `egc` render path used to write through `grid_mut()` and
243        // bypass the clip entirely.
244        let area = Rect::new(0, 0, 10, 3);
245        let mut grid = Grid::new(10, 3);
246        {
247            let mut surface = Surface::new(&mut grid, area, 0);
248            let mut clipped = surface.clip(Rect::new(0, 0, 10, 1));
249            Paragraph::new("aaaa bbbb cccc").render(&mut clipped);
250        }
251
252        let row0: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
253        assert_eq!(row0.trim_end(), "aaaa bbbb");
254        for row in 1..3 {
255            let r: String = (0..10).map(|x| grid[Pos::new(x, row)].glyph()).collect();
256            assert_eq!(r.trim(), "");
257        }
258    }
259}