Skip to main content

altui_core/widgets/
paragraph.rs

1use crate::{
2    buffer::Buffer,
3    layout::{Alignment, Direction, Margin, Rect},
4    style::Style,
5    text::{StyledGrapheme, Text},
6    widgets::{
7        reflow::{LineComposer, LineTruncator, WordWrapper},
8        Block, Borders, Scrollbar, ScrollbarOrientation, Widget,
9    },
10};
11use std::iter;
12use unicode_width::UnicodeWidthStr;
13
14fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 {
15    match alignment {
16        Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2),
17        Alignment::Right => text_area_width.saturating_sub(line_width),
18        Alignment::Left => 0,
19    }
20}
21
22/// A widget to display some text.
23///
24/// # Examples
25///
26/// ```
27/// # use altui_core::text::{Text, Spans, Span};
28/// # use altui_core::widgets::{Block, Borders, Paragraph, Wrap};
29/// # use altui_core::style::{Style, Color, Modifier};
30/// # use altui_core::layout::Alignment;
31/// let text = vec![
32///     Spans::from(vec![
33///         Span::raw("First"),
34///         Span::styled("line",Style::default().add_modifier(Modifier::ITALIC)),
35///         Span::raw("."),
36///     ]),
37///     Spans::from(Span::styled("Second line", Style::default().fg(Color::Red))),
38/// ];
39/// let mut paragraph = Paragraph::new(text);
40/// paragraph.block(Block::default().title("Paragraph").borders(Borders::ALL));
41/// paragraph.style(Style::default().fg(Color::White).bg(Color::Black));
42/// paragraph.alignment(Alignment::Center);
43/// paragraph.wrap(Wrap { trim: true });
44/// ```
45#[derive(Debug, Clone)]
46pub struct Paragraph<'a> {
47    /// A block to wrap the widget in
48    block: Option<Block<'a>>,
49    /// A scrollbar to show scroll progress
50    scrollbar: Option<Scrollbar<'a>>,
51    scrollbar_direction: Option<Direction>,
52    margin: Margin,
53    /// Widget style
54    style: Style,
55    /// How to wrap the text
56    wrap: Option<Wrap>,
57    /// The text to display
58    text: Text<'a>,
59    /// Scroll
60    scroll: (u16, u16),
61    /// Alignment of the text
62    alignment: Alignment,
63    /// Content length for scroll
64    content_height: u16,
65    content_width: u16,
66    text_was_updated: bool,
67}
68
69/// Describes how to wrap text across lines.
70///
71/// ## Examples
72///
73/// ```
74/// # use altui_core::widgets::{Paragraph, Wrap};
75/// # use altui_core::text::Text;
76/// let bullet_points = Text::from(r#"Some indented points:
77///     - First thing goes here and is long so that it wraps
78///     - Here is another point that is long enough to wrap"#);
79///
80/// // With leading spaces trimmed (window width of 30 chars):
81/// Paragraph::new(bullet_points.clone()).wrap(Wrap { trim: true });
82/// // Some indented points:
83/// // - First thing goes here and is
84/// // long so that it wraps
85/// // - Here is another point that
86/// // is long enough to wrap
87///
88/// // But without trimming, indentation is preserved:
89/// Paragraph::new(bullet_points).wrap(Wrap { trim: false });
90/// // Some indented points:
91/// //     - First thing goes here
92/// // and is long so that it wraps
93/// //     - Here is another point
94/// // that is long enough to wrap
95/// ```
96#[derive(Debug, Clone, Copy)]
97pub struct Wrap {
98    /// Should leading whitespace be trimmed
99    pub trim: bool,
100}
101
102impl<'a> Paragraph<'a> {
103    pub fn new<T>(text: T) -> Paragraph<'a>
104    where
105        T: Into<Text<'a>>,
106    {
107        Paragraph {
108            block: None,
109            scrollbar: None,
110            scrollbar_direction: None,
111            margin: Margin::default(),
112            style: Default::default(),
113            wrap: None,
114            text: text.into(),
115            scroll: (0, 0),
116            alignment: Alignment::Left,
117            content_height: 0,
118            content_width: 0,
119            text_was_updated: true,
120        }
121    }
122
123    pub fn text<T>(&mut self, text: T)
124    where
125        T: Into<Text<'a>>,
126    {
127        self.text = text.into();
128        self.text_was_updated = true;
129    }
130
131    pub fn block(&mut self, block: Block<'a>) {
132        self.block = Some(block);
133    }
134
135    /// Attach a scrollbar to the paragraph
136    ///
137    /// Adds a scrollbar that visually indicates the current scroll position
138    /// and allows users to understand the relative position within the content.
139    ///
140    /// # See Also
141    ///
142    /// - [`Paragraph::content_height`] - Get total lines for vertical scrolling
143    /// - [`Paragraph::content_width`] - Get max width for horizontal scrolling  
144    /// - [`Paragraph::scroll`] - Set scroll position
145    pub fn scrollbar(&mut self, scrollbar: Scrollbar<'a>) {
146        self.scrollbar_direction = match scrollbar.show_orientation() {
147            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => {
148                Some(Direction::Vertical)
149            }
150            _ => Some(Direction::Horizontal),
151        };
152
153        if self.block.is_none() {
154            self.block = Some(Block::default().borders(Borders::NONE));
155        }
156
157        self.scrollbar = Some(scrollbar);
158    }
159
160    pub fn text_style(&mut self, style: Style) {
161        self.text.patch_style(style);
162    }
163
164    pub fn style(&mut self, style: Style) {
165        self.style = style;
166    }
167
168    pub fn wrap(&mut self, wrap: Wrap) {
169        self.wrap = Some(wrap);
170    }
171
172    pub fn margin(&mut self, margin: u16) {
173        self.margin = Margin {
174            horizontal: margin,
175            vertical: margin,
176        };
177    }
178
179    pub fn horizontal_margin(&mut self, horizontal: u16) {
180        self.margin.horizontal = horizontal;
181    }
182
183    pub fn vertical_margin(&mut self, vertical: u16) {
184        self.margin.vertical = vertical;
185    }
186
187    /// Sets `Y` and `X` axis offsets accordingly
188    ///
189    /// # Note
190    ///
191    /// The scroll position will be automatically clamped during rendering to ensure
192    /// it doesn't exceed valid bounds:
193    /// - Vertical scroll will be limited to `max(0, content_height - viewport_height)`
194    /// - Horizontal scroll will be limited to `max(0, content_width - viewport_width)`
195    ///
196    /// Content dimensions (`content_height` and `content_width`) are calculated
197    /// during the first render after text changes. To get actual content dimensions:
198    /// 1. After setting new text, call `render()` at least once
199    /// 2. Then use [`Paragraph::content_height`] and [`Paragraph::content_width`] methods
200    pub fn scroll(&mut self, offset: (u16, u16)) {
201        self.scroll = offset;
202    }
203
204    pub fn alignment(&mut self, alignment: Alignment) {
205        self.alignment = alignment;
206    }
207
208    /// Returns `Some(content height)` if vertical scrollbar was set or `None`
209    pub fn content_height(&self) -> Option<u16> {
210        match self.scrollbar_direction.as_ref() {
211            Some(Direction::Vertical) => Some(self.content_height),
212            _ => None,
213        }
214    }
215
216    /// Returns `Some(content width)` if horizontal scrollbar was set or `None`
217    ///
218    /// This is the width of the longest line in the text.
219    pub fn content_width(&self) -> Option<u16> {
220        match self.scrollbar_direction.as_ref() {
221            Some(Direction::Horizontal) => Some(self.content_width),
222            _ => None,
223        }
224    }
225}
226
227impl<'a> Widget for Paragraph<'a> {
228    fn render(&mut self, area: Rect, buf: &mut Buffer) {
229        buf.set_style(area, self.style);
230        let text_area = match self.block.as_mut() {
231            Some(b) => {
232                let inner_area = b.inner(area);
233                b.render(area, buf);
234                inner_area
235            }
236            None => area,
237        };
238
239        let text_area = text_area.inner(&self.margin);
240
241        if !self.text_was_updated && self.scrollbar.is_some() {
242            self.scroll = (
243                self.scroll
244                    .0
245                    .min(self.content_height.saturating_sub(text_area.height)),
246                self.scroll
247                    .1
248                    .min(self.content_width.saturating_sub(text_area.width)),
249            )
250        }
251
252        if text_area.height < 1 {
253            return;
254        }
255
256        let style = self.style;
257        let mut styled = self.text.lines.iter().flat_map(|spans| {
258            spans
259                .0
260                .iter()
261                .flat_map(|span| span.styled_graphemes(style))
262                // Required given the way composers work but might be refactored out if we change
263                // composers to operate on lines instead of a stream of graphemes.
264                .chain(iter::once(StyledGrapheme {
265                    symbol: "\n",
266                    style: self.style,
267                }))
268        });
269
270        let mut line_composer: Box<dyn LineComposer> = if let Some(Wrap { trim }) = self.wrap {
271            Box::new(WordWrapper::new(&mut styled, text_area.width, trim))
272        } else {
273            let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width));
274            if let Alignment::Left = self.alignment {
275                line_composer.set_horizontal_offset(self.scroll.1);
276            }
277            line_composer
278        };
279        let mut y = 0;
280        let mut max_line_width = 0;
281        let height = text_area.height + self.scroll.0;
282
283        while let Some((current_line, current_line_width)) = line_composer.next_line() {
284            if y >= self.scroll.0 && y < height {
285                let mut x = get_line_offset(current_line_width, text_area.width, self.alignment);
286                for StyledGrapheme { symbol, style } in current_line {
287                    buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll.0)
288                        .set_symbol(if symbol.is_empty() {
289                            // If the symbol is empty, the last char which rendered last time will
290                            // leave on the line. It's a quick fix.
291                            " "
292                        } else {
293                            symbol
294                        })
295                        .set_style(*style);
296                    x += symbol.width() as u16;
297                }
298            }
299            y += 1;
300
301            if current_line_width > max_line_width && !self.text_was_updated {
302                max_line_width = current_line_width;
303            }
304
305            if y >= height && !self.text_was_updated {
306                break;
307            }
308        }
309
310        if self.text_was_updated {
311            self.content_height = y;
312            self.content_width = max_line_width;
313            self.text_was_updated = false;
314        }
315
316        if let (Some(scrollbar), Some(dir)) = (self.scrollbar.as_mut(), &self.scrollbar_direction) {
317            match dir {
318                Direction::Horizontal => {
319                    scrollbar.offset(self.scroll.1);
320                    scrollbar.content_length(self.content_width);
321                }
322                Direction::Vertical => {
323                    scrollbar.offset(self.scroll.0);
324                    scrollbar.content_length(self.content_height);
325                }
326            }
327            scrollbar.render(area, buf);
328        }
329    }
330}