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::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::widget::{Measure, Paragraph, Widget};
57/// use retroglyph_ui::Surface;
58///
59/// let p = Paragraph::new("the quick brown fox jumps");
60/// let height = p.height_for(10); // rows needed to wrap at 10 columns
61///
62/// let area = Rect::new(0, 0, 10, height);
63/// let mut grid = Grid::new(10, height);
64/// p.render(&mut Surface::new(&mut grid, area, 0));
65/// ```
66#[derive(Clone, Copy, Debug)]
67pub struct Paragraph<'a> {
68    text: &'a str,
69    style: Style,
70}
71
72impl<'a> Paragraph<'a> {
73    /// Text to be word-wrapped, in the default style.
74    #[must_use]
75    pub fn new(text: &'a str) -> Self {
76        Self {
77            text,
78            style: Style::new(),
79        }
80    }
81
82    /// Set the text's style.
83    #[must_use]
84    pub const fn style(mut self, style: Style) -> Self {
85        self.style = style;
86        self
87    }
88
89    #[cfg(feature = "egc")]
90    fn line(&self) -> Line {
91        Line::from(Span::styled(self.text, self.style))
92    }
93}
94
95/// Greedy, `char`-boundary-safe word wrap used when the `egc` feature is off.
96///
97/// Breaks on ASCII space (`' '`, consumed at the break point, not placed) and on `'\n'` (a hard
98/// break, regardless of width); an overlong word with no space to break at is force-broken at a
99/// `char` boundary once it would exceed `max_width`. Zero-width `char`s (e.g. combining marks)
100/// are dropped rather than measured, since without `egc` there is no grapheme-cluster pass to
101/// attach them to the `char` before them. This mirrors
102/// [`retroglyph_core::layout::TextLayout`]'s own wrap algorithm one abstraction level down
103/// (`char` instead of grapheme cluster), so plain-ASCII input wraps identically either way.
104#[cfg(not(feature = "egc"))]
105fn wrap(text: &str, max_width: u16) -> Vec<String> {
106    use retroglyph_core::text::char_width;
107
108    let mut lines: Vec<String> = vec![String::new()];
109    let mut col: u16 = 0;
110
111    for ch in text.chars() {
112        if ch == '\n' {
113            lines.push(String::new());
114            col = 0;
115            continue;
116        }
117
118        let cw = char_width(ch);
119        if cw == 0 {
120            continue; // zero-width char with no grapheme pass to attach it to; drop it.
121        }
122
123        if col + cw > max_width && col > 0 {
124            let current = lines.last_mut().expect("always at least one line");
125            if let Some(space_idx) = current.rfind(' ') {
126                let remainder = current[space_idx + 1..].to_string();
127                current.truncate(space_idx); // also drops the space itself
128                col = remainder.chars().map(char_width).sum();
129                lines.push(remainder);
130            } else {
131                // No space on the line: force-break (overlong word).
132                lines.push(String::new());
133                col = 0;
134                if ch == ' ' {
135                    // Would just be leading whitespace on the new line.
136                    continue;
137                }
138            }
139        }
140
141        lines.last_mut().expect("always at least one line").push(ch);
142        col += cw;
143    }
144
145    lines
146}
147
148#[cfg(feature = "egc")]
149impl Measure for Paragraph<'_> {
150    fn height_for(&self, width: u16) -> u16 {
151        let line = self.line();
152        TextLayout::new(&line)
153            .rect(Rect::new(0, 0, width, u16::MAX))
154            .measure()
155            .height()
156    }
157}
158
159#[cfg(not(feature = "egc"))]
160impl Measure for Paragraph<'_> {
161    fn height_for(&self, width: u16) -> u16 {
162        let lines = wrap(self.text, width);
163        #[allow(clippy::cast_possible_truncation)]
164        let height = lines.len().min(usize::from(u16::MAX)) as u16;
165        height
166    }
167}
168
169#[cfg(feature = "egc")]
170impl Widget for Paragraph<'_> {
171    fn render(&self, surface: &mut Surface<'_>) {
172        let area = surface.area();
173        let line = self.line();
174        TextLayout::new(&line).rect(area).render_to_surface(surface);
175    }
176}
177
178#[cfg(not(feature = "egc"))]
179impl Widget for Paragraph<'_> {
180    fn render(&self, surface: &mut Surface<'_>) {
181        let width = surface.area().width();
182        let height = surface.area().height();
183        let lines = wrap(self.text, width);
184        for (row, line) in lines.into_iter().take(usize::from(height)).enumerate() {
185            #[allow(clippy::cast_possible_truncation)] // `row < height`, a `u16`
186            surface.print((0, row as u16), &line, self.style);
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use alloc::string::String;
194
195    use retroglyph_core::grid::{Grid, Pos, Rect};
196
197    use super::*;
198
199    #[test]
200    fn height_for_matches_wrapped_line_count() {
201        let p = Paragraph::new("the quick brown fox jumps");
202        assert_eq!(p.height_for(10), 3); // "the quick" / "brown fox" / "jumps"
203        assert_eq!(p.height_for(100), 1);
204    }
205
206    #[test]
207    fn height_for_respects_hard_newlines() {
208        // A naive whitespace-based wrap would flatten this to one paragraph;
209        // both wrap paths treat "\n" as a hard break regardless of width.
210        let p = Paragraph::new("first\nsecond\nthird");
211        assert_eq!(p.height_for(100), 3);
212    }
213
214    #[test]
215    fn render_draws_one_line_per_wrapped_row() {
216        let area = Rect::new(0, 0, 10, 5);
217        let mut grid = Grid::new(10, 5);
218        Paragraph::new("the quick brown fox jumps").render(&mut Surface::new(&mut grid, area, 0));
219
220        let row0: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
221        let row1: String = (0..10).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
222        let row2: String = (0..10).map(|x| grid[Pos::new(x, 2)].glyph()).collect();
223        assert!(row0.starts_with("the quick"));
224        assert!(row1.starts_with("brown fox"));
225        assert!(row2.starts_with("jumps"));
226    }
227
228    #[test]
229    fn render_stops_at_the_area_bottom() {
230        // Only 1 row of height: only the first wrapped line should draw.
231        let area = Rect::new(0, 0, 10, 1);
232        let mut grid = Grid::new(10, 2);
233        Paragraph::new("the quick brown fox jumps").render(&mut Surface::new(&mut grid, area, 0));
234
235        let row1: String = (0..10).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
236        assert_eq!(row1.trim(), "");
237    }
238
239    #[cfg(feature = "egc")]
240    #[test]
241    fn paragraph_honours_the_surface_clip() {
242        // Clip out rows 1-2: the wrapped remainder ("cccc") must not be drawn there,
243        // even though the `egc` render path used to write through `grid_mut()` and
244        // bypass the clip entirely.
245        let area = Rect::new(0, 0, 10, 3);
246        let mut grid = Grid::new(10, 3);
247        {
248            let mut surface = Surface::new(&mut grid, area, 0);
249            let mut clipped = surface.clip(Rect::new(0, 0, 10, 1));
250            Paragraph::new("aaaa bbbb cccc").render(&mut clipped);
251        }
252
253        let row0: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
254        assert_eq!(row0.trim_end(), "aaaa bbbb");
255        for row in 1..3 {
256            let r: String = (0..10).map(|x| grid[Pos::new(x, row)].glyph()).collect();
257            assert_eq!(r.trim(), "");
258        }
259    }
260}