Skip to main content

retroglyph_widgets/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//! Requires the `egc` feature: wrapping is delegated entirely to
5//! [`retroglyph_core::layout::TextLayout`], which handles grapheme clusters
6//! and hard newlines correctly. This module adds no wrapping logic of its
7//! own -- see `crates/widgets/src/text.rs` for why that duplication was
8//! removed.
9use retroglyph_core::layout::TextLayout;
10use retroglyph_core::text::{Line, Span};
11use retroglyph_core::{Backend, Rect, Style, Terminal};
12
13use super::{Measure, Widget};
14
15/// Word-wrapped text in a single [`Style`].
16///
17/// `Paragraph::new(text)` wraps `text` to whatever width it is rendered at
18/// (via [`Widget::render`]), or reports the height it would need at a
19/// given width without rendering (via [`Measure::height_for`]) so a caller
20/// can size its pane to fit instead of guessing a fixed height. `style`
21/// defaults to [`Style::new()`]; set it with [`Paragraph::style`].
22///
23/// # Examples
24///
25/// ```
26/// use retroglyph_core::{Headless, Rect, Terminal};
27/// use retroglyph_widgets::{Measure, Paragraph, Widget};
28///
29/// let p = Paragraph::new("the quick brown fox jumps");
30/// let height = p.height_for(10); // rows needed to wrap at 10 columns
31///
32/// let mut term = Terminal::new(Headless::new(10, height));
33/// p.render(Rect::new(0, 0, 10, height), &mut term);
34/// ```
35#[derive(Clone, Copy, Debug)]
36pub struct Paragraph<'a> {
37    text: &'a str,
38    style: Style,
39}
40
41impl<'a> Paragraph<'a> {
42    /// Text to be word-wrapped, in the default style.
43    #[must_use]
44    pub fn new(text: &'a str) -> Self {
45        Self {
46            text,
47            style: Style::new(),
48        }
49    }
50
51    /// Set the text's style.
52    #[must_use]
53    pub const fn style(mut self, style: Style) -> Self {
54        self.style = style;
55        self
56    }
57
58    fn line(&self) -> Line {
59        Line::from(Span::styled(self.text, self.style))
60    }
61}
62
63impl Measure for Paragraph<'_> {
64    fn height_for(&self, width: u16) -> u16 {
65        let line = self.line();
66        TextLayout::new(&line)
67            .rect(Rect::new(0, 0, width, u16::MAX))
68            .measure()
69            .height
70    }
71}
72
73impl<B: Backend> Widget<B> for Paragraph<'_> {
74    fn render(self, area: Rect, term: &mut Terminal<B>) {
75        let line = self.line();
76        TextLayout::new(&line).rect(area).render(term);
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use retroglyph_core::Headless;
83
84    use super::*;
85
86    #[test]
87    fn height_for_matches_wrapped_line_count() {
88        let p = Paragraph::new("the quick brown fox jumps");
89        assert_eq!(p.height_for(10), 3); // "the quick" / "brown fox" / "jumps"
90        assert_eq!(p.height_for(100), 1);
91    }
92
93    #[test]
94    fn height_for_respects_hard_newlines() {
95        // A naive whitespace-based wrap would flatten this to one paragraph;
96        // TextLayout treats "\n" as a hard break regardless of width.
97        let p = Paragraph::new("first\nsecond\nthird");
98        assert_eq!(p.height_for(100), 3);
99    }
100
101    #[test]
102    fn render_draws_one_line_per_wrapped_row() {
103        let area = Rect::new(0, 0, 10, 5);
104        let mut term = Terminal::new(Headless::new(10, 5));
105        Paragraph::new("the quick brown fox jumps").render(area, &mut term);
106
107        let row0: String = (0..10).map(|x| term.grid().get(x, 0).glyph()).collect();
108        let row1: String = (0..10).map(|x| term.grid().get(x, 1).glyph()).collect();
109        let row2: String = (0..10).map(|x| term.grid().get(x, 2).glyph()).collect();
110        assert!(row0.starts_with("the quick"));
111        assert!(row1.starts_with("brown fox"));
112        assert!(row2.starts_with("jumps"));
113    }
114
115    #[test]
116    fn render_stops_at_the_area_bottom() {
117        // Only 1 row of height: only the first wrapped line should draw.
118        let area = Rect::new(0, 0, 10, 1);
119        let mut term = Terminal::new(Headless::new(10, 2));
120        Paragraph::new("the quick brown fox jumps").render(area, &mut term);
121
122        let row1: String = (0..10).map(|x| term.grid().get(x, 1).glyph()).collect();
123        assert_eq!(row1.trim(), "");
124    }
125}