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#[derive(Clone, Copy, Debug)]
23pub struct Paragraph<'a> {
24    text: &'a str,
25    style: Style,
26}
27
28impl<'a> Paragraph<'a> {
29    /// Text to be word-wrapped, in the default style.
30    #[must_use]
31    pub fn new(text: &'a str) -> Self {
32        Self {
33            text,
34            style: Style::new(),
35        }
36    }
37
38    /// Set the text's style.
39    #[must_use]
40    pub const fn style(mut self, style: Style) -> Self {
41        self.style = style;
42        self
43    }
44
45    fn line(&self) -> Line {
46        Line::from(Span::styled(self.text, self.style))
47    }
48}
49
50impl Measure for Paragraph<'_> {
51    fn height_for(&self, width: u16) -> u16 {
52        let line = self.line();
53        TextLayout::new(&line)
54            .rect(Rect::new(0, 0, width, u16::MAX))
55            .measure()
56            .height
57    }
58}
59
60impl<B: Backend> Widget<B> for Paragraph<'_> {
61    fn render(self, area: Rect, term: &mut Terminal<B>) {
62        let line = self.line();
63        TextLayout::new(&line).rect(area).render(term);
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use retroglyph_core::Headless;
70
71    use super::*;
72
73    #[test]
74    fn height_for_matches_wrapped_line_count() {
75        let p = Paragraph::new("the quick brown fox jumps");
76        assert_eq!(p.height_for(10), 3); // "the quick" / "brown fox" / "jumps"
77        assert_eq!(p.height_for(100), 1);
78    }
79
80    #[test]
81    fn height_for_respects_hard_newlines() {
82        // A naive whitespace-based wrap would flatten this to one paragraph;
83        // TextLayout treats "\n" as a hard break regardless of width.
84        let p = Paragraph::new("first\nsecond\nthird");
85        assert_eq!(p.height_for(100), 3);
86    }
87
88    #[test]
89    fn render_draws_one_line_per_wrapped_row() {
90        let area = Rect::new(0, 0, 10, 5);
91        let mut term = Terminal::new(Headless::new(10, 5));
92        Paragraph::new("the quick brown fox jumps").render(area, &mut term);
93
94        let row0: String = (0..10).map(|x| term.grid().get(x, 0).glyph()).collect();
95        let row1: String = (0..10).map(|x| term.grid().get(x, 1).glyph()).collect();
96        let row2: String = (0..10).map(|x| term.grid().get(x, 2).glyph()).collect();
97        assert!(row0.starts_with("the quick"));
98        assert!(row1.starts_with("brown fox"));
99        assert!(row2.starts_with("jumps"));
100    }
101
102    #[test]
103    fn render_stops_at_the_area_bottom() {
104        // Only 1 row of height: only the first wrapped line should draw.
105        let area = Rect::new(0, 0, 10, 1);
106        let mut term = Terminal::new(Headless::new(10, 2));
107        Paragraph::new("the quick brown fox jumps").render(area, &mut term);
108
109        let row1: String = (0..10).map(|x| term.grid().get(x, 1).glyph()).collect();
110        assert_eq!(row1.trim(), "");
111    }
112}