Skip to main content

ratatui_interact/components/
paragraph_ext.rs

1//! Extended Paragraph Widget
2//!
3//! A styled text widget with word-wrapping and scrolling support.
4//! Similar to ratatui's `Paragraph` but with cleaner rendering (no trailing spaces)
5//! and more control over wrapping behavior.
6//!
7//! # Example
8//!
9//! ```rust
10//! use ratatui_interact::components::ParagraphExt;
11//! use ratatui::text::Line;
12//! use ratatui::layout::Rect;
13//! use ratatui::buffer::Buffer;
14//! use ratatui::widgets::Widget;
15//!
16//! let lines = vec![
17//!     Line::from("Hello, world!"),
18//!     Line::from("This is a long line that will be word-wrapped automatically."),
19//! ];
20//!
21//! let widget = ParagraphExt::new(lines)
22//!     .scroll(0)
23//!     .width(40);
24//!
25//! let area = Rect::new(0, 0, 40, 10);
26//! let mut buf = Buffer::empty(area);
27//! widget.render(area, &mut buf);
28//! ```
29
30use ratatui::{buffer::Buffer, layout::Rect, style::Style, text::Line, widgets::Widget};
31
32/// Extended paragraph widget with word-wrapping and scrolling.
33///
34/// Unlike ratatui's `Paragraph`, this widget:
35/// - Does not pad lines with trailing spaces
36/// - Provides fine-grained control over word wrapping
37/// - Preserves per-character styling through wrapping
38/// - Supports vertical scrolling
39pub struct ParagraphExt<'a> {
40    lines: Vec<Line<'a>>,
41    scroll: u16,
42    width: Option<u16>,
43}
44
45impl<'a> ParagraphExt<'a> {
46    /// Create a new ParagraphExt with the given lines.
47    pub fn new(lines: Vec<Line<'a>>) -> Self {
48        Self {
49            lines,
50            scroll: 0,
51            width: None,
52        }
53    }
54
55    /// Set the vertical scroll offset (number of wrapped lines to skip).
56    pub fn scroll(mut self, scroll: u16) -> Self {
57        self.scroll = scroll;
58        self
59    }
60
61    /// Set the width for word wrapping.
62    ///
63    /// If not set, the render area width will be used.
64    pub fn width(mut self, width: u16) -> Self {
65        self.width = Some(width);
66        self
67    }
68
69    /// Word-wrap lines and return wrapped line content.
70    ///
71    /// Each wrapped line is a vector of (char, Style) tuples.
72    fn wrap_lines(&self, width: u16) -> Vec<Vec<(char, Style)>> {
73        let width = width as usize;
74        if width == 0 {
75            return vec![];
76        }
77
78        let mut wrapped = Vec::new();
79
80        for line in &self.lines {
81            // Flatten spans to chars with styles
82            let mut chars: Vec<(char, Style)> = Vec::new();
83            for span in &line.spans {
84                for ch in span.content.chars() {
85                    chars.push((ch, span.style));
86                }
87            }
88
89            if chars.is_empty() {
90                wrapped.push(vec![]);
91                continue;
92            }
93
94            // Word wrap
95            let mut start = 0;
96            while start < chars.len() {
97                let remaining = chars.len() - start;
98                if remaining <= width {
99                    wrapped.push(chars[start..].to_vec());
100                    break;
101                }
102
103                let end = start + width;
104                let mut break_at = end;
105
106                // Find last space for word break
107                for i in (start..end).rev() {
108                    if chars[i].0 == ' ' {
109                        break_at = i + 1;
110                        break;
111                    }
112                }
113
114                wrapped.push(chars[start..break_at].to_vec());
115                start = break_at;
116
117                // Skip leading spaces on continuation
118                while start < chars.len() && chars[start].0 == ' ' {
119                    start += 1;
120                }
121            }
122        }
123
124        wrapped
125    }
126
127    /// Calculate the total number of wrapped lines.
128    ///
129    /// This is useful for calculating scroll bounds.
130    pub fn line_count(&self, width: u16) -> usize {
131        self.wrap_lines(width).len()
132    }
133}
134
135impl Widget for ParagraphExt<'_> {
136    fn render(self, area: Rect, buf: &mut Buffer) {
137        let width = self.width.unwrap_or(area.width);
138        let wrapped = self.wrap_lines(width);
139        let scroll = self.scroll as usize;
140
141        // Clear area first
142        for y in area.y..area.y + area.height {
143            for x in area.x..area.x + area.width {
144                buf[(x, y)].reset();
145            }
146        }
147
148        let visible = wrapped.iter().skip(scroll).take(area.height as usize);
149
150        for (row, line_chars) in visible.enumerate() {
151            let y = area.y + row as u16;
152            if y >= area.y + area.height {
153                break;
154            }
155
156            // Only write actual content characters (no trailing spaces)
157            for (col, (ch, style)) in line_chars.iter().enumerate() {
158                let x = area.x + col as u16;
159                if x >= area.x + area.width {
160                    break;
161                }
162                buf[(x, y)].set_char(*ch).set_style(*style);
163            }
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use ratatui::style::Color;
172    use ratatui::text::Span;
173
174    #[test]
175    fn test_empty_lines() {
176        let widget = ParagraphExt::new(vec![]);
177        let area = Rect::new(0, 0, 20, 5);
178        let mut buf = Buffer::empty(area);
179        widget.render(area, &mut buf);
180        // Should not panic
181    }
182
183    #[test]
184    fn test_simple_render() {
185        let lines = vec![Line::from("Hello")];
186        let widget = ParagraphExt::new(lines);
187        let area = Rect::new(0, 0, 20, 5);
188        let mut buf = Buffer::empty(area);
189        widget.render(area, &mut buf);
190
191        // Check first 5 characters
192        assert_eq!(buf[(0, 0)].symbol(), "H");
193        assert_eq!(buf[(1, 0)].symbol(), "e");
194        assert_eq!(buf[(2, 0)].symbol(), "l");
195        assert_eq!(buf[(3, 0)].symbol(), "l");
196        assert_eq!(buf[(4, 0)].symbol(), "o");
197    }
198
199    #[test]
200    fn test_word_wrap() {
201        let lines = vec![Line::from("Hello world this is a test")];
202        let widget = ParagraphExt::new(lines).width(10);
203        let area = Rect::new(0, 0, 10, 5);
204        let mut buf = Buffer::empty(area);
205        widget.render(area, &mut buf);
206
207        // First line should be "Hello "
208        assert_eq!(buf[(0, 0)].symbol(), "H");
209        // Second line should start with "world "
210        assert_eq!(buf[(0, 1)].symbol(), "w");
211    }
212
213    #[test]
214    fn test_scroll() {
215        let lines = vec![
216            Line::from("Line 1"),
217            Line::from("Line 2"),
218            Line::from("Line 3"),
219        ];
220        let widget = ParagraphExt::new(lines).scroll(1);
221        let area = Rect::new(0, 0, 20, 2);
222        let mut buf = Buffer::empty(area);
223        widget.render(area, &mut buf);
224
225        // First visible line should be "Line 2"
226        assert_eq!(buf[(0, 0)].symbol(), "L");
227        assert_eq!(buf[(5, 0)].symbol(), "2");
228    }
229
230    #[test]
231    fn test_styled_text() {
232        let lines = vec![Line::from(vec![
233            Span::styled("Red", Style::default().fg(Color::Red)),
234            Span::raw(" "),
235            Span::styled("Blue", Style::default().fg(Color::Blue)),
236        ])];
237        let widget = ParagraphExt::new(lines);
238        let area = Rect::new(0, 0, 20, 1);
239        let mut buf = Buffer::empty(area);
240        widget.render(area, &mut buf);
241
242        // Check that styles are preserved
243        assert_eq!(buf[(0, 0)].fg, Color::Red);
244        assert_eq!(buf[(4, 0)].fg, Color::Blue);
245    }
246
247    #[test]
248    fn test_line_count() {
249        let lines = vec![Line::from("Hello world this is a long line")];
250        let widget = ParagraphExt::new(lines);
251
252        // With width 10, should wrap into multiple lines
253        let count = widget.line_count(10);
254        assert!(count > 1);
255    }
256
257    #[test]
258    fn test_empty_line_preserved() {
259        let lines = vec![Line::from("Line 1"), Line::from(""), Line::from("Line 3")];
260        let widget = ParagraphExt::new(lines);
261        let count = widget.line_count(20);
262        assert_eq!(count, 3);
263    }
264}