altui-core 0.2.0

A library to build rich terminal user interfaces or dashboards
Documentation
use crate::{
    buffer::Buffer,
    layout::{Alignment, Direction, Margin, Rect},
    style::Style,
    text::{StyledGrapheme, Text},
    widgets::{
        reflow::{LineComposer, LineTruncator, WordWrapper},
        Block, Borders, Scrollbar, ScrollbarOrientation, Widget,
    },
};
use std::iter;
use unicode_width::UnicodeWidthStr;

fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 {
    match alignment {
        Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2),
        Alignment::Right => text_area_width.saturating_sub(line_width),
        Alignment::Left => 0,
    }
}

/// A widget to display some text.
///
/// # Examples
///
/// ```
/// # use altui_core::text::{Text, Spans, Span};
/// # use altui_core::widgets::{Block, Borders, Paragraph, Wrap};
/// # use altui_core::style::{Style, Color, Modifier};
/// # use altui_core::layout::Alignment;
/// let text = vec![
///     Spans::from(vec![
///         Span::raw("First"),
///         Span::styled("line",Style::default().add_modifier(Modifier::ITALIC)),
///         Span::raw("."),
///     ]),
///     Spans::from(Span::styled("Second line", Style::default().fg(Color::Red))),
/// ];
/// let mut paragraph = Paragraph::new(text);
/// paragraph.block(Block::default().title("Paragraph").borders(Borders::ALL));
/// paragraph.style(Style::default().fg(Color::White).bg(Color::Black));
/// paragraph.alignment(Alignment::Center);
/// paragraph.wrap(Wrap { trim: true });
/// ```
#[derive(Debug, Clone)]
pub struct Paragraph<'a> {
    /// A block to wrap the widget in
    block: Option<Block<'a>>,
    /// A scrollbar to show scroll progress
    scrollbar: Option<Scrollbar<'a>>,
    scrollbar_direction: Option<Direction>,
    margin: Margin,
    /// Widget style
    style: Style,
    /// How to wrap the text
    wrap: Option<Wrap>,
    /// The text to display
    text: Text<'a>,
    /// Scroll
    scroll: (u16, u16),
    /// Alignment of the text
    alignment: Alignment,
    /// Content length for scroll
    content_height: u16,
    content_width: u16,
    text_was_updated: bool,
}

/// Describes how to wrap text across lines.
///
/// ## Examples
///
/// ```
/// # use altui_core::widgets::{Paragraph, Wrap};
/// # use altui_core::text::Text;
/// let bullet_points = Text::from(r#"Some indented points:
///     - First thing goes here and is long so that it wraps
///     - Here is another point that is long enough to wrap"#);
///
/// // With leading spaces trimmed (window width of 30 chars):
/// Paragraph::new(bullet_points.clone()).wrap(Wrap { trim: true });
/// // Some indented points:
/// // - First thing goes here and is
/// // long so that it wraps
/// // - Here is another point that
/// // is long enough to wrap
///
/// // But without trimming, indentation is preserved:
/// Paragraph::new(bullet_points).wrap(Wrap { trim: false });
/// // Some indented points:
/// //     - First thing goes here
/// // and is long so that it wraps
/// //     - Here is another point
/// // that is long enough to wrap
/// ```
#[derive(Debug, Clone, Copy)]
pub struct Wrap {
    /// Should leading whitespace be trimmed
    pub trim: bool,
}

impl<'a> Paragraph<'a> {
    pub fn new<T>(text: T) -> Paragraph<'a>
    where
        T: Into<Text<'a>>,
    {
        Paragraph {
            block: None,
            scrollbar: None,
            scrollbar_direction: None,
            margin: Margin::default(),
            style: Default::default(),
            wrap: None,
            text: text.into(),
            scroll: (0, 0),
            alignment: Alignment::Left,
            content_height: 0,
            content_width: 0,
            text_was_updated: true,
        }
    }

    pub fn text<T>(&mut self, text: T)
    where
        T: Into<Text<'a>>,
    {
        self.text = text.into();
        self.text_was_updated = true;
    }

    pub fn block(&mut self, block: Block<'a>) {
        self.block = Some(block);
    }

    /// Attach a scrollbar to the paragraph
    ///
    /// Adds a scrollbar that visually indicates the current scroll position
    /// and allows users to understand the relative position within the content.
    ///
    /// # See Also
    ///
    /// - [`Paragraph::content_height`] - Get total lines for vertical scrolling
    /// - [`Paragraph::content_width`] - Get max width for horizontal scrolling  
    /// - [`Paragraph::scroll`] - Set scroll position
    pub fn scrollbar(&mut self, scrollbar: Scrollbar<'a>) {
        self.scrollbar_direction = match scrollbar.show_orientation() {
            ScrollbarOrientation::VerticalRight | ScrollbarOrientation::VerticalLeft => {
                Some(Direction::Vertical)
            }
            _ => Some(Direction::Horizontal),
        };

        if self.block.is_none() {
            self.block = Some(Block::default().borders(Borders::NONE));
        }

        self.scrollbar = Some(scrollbar);
    }

    pub fn text_style(&mut self, style: Style) {
        self.text.patch_style(style);
    }

    pub fn style(&mut self, style: Style) {
        self.style = style;
    }

    pub fn wrap(&mut self, wrap: Wrap) {
        self.wrap = Some(wrap);
    }

    pub fn margin(&mut self, margin: u16) {
        self.margin = Margin {
            horizontal: margin,
            vertical: margin,
        };
    }

    pub fn horizontal_margin(&mut self, horizontal: u16) {
        self.margin.horizontal = horizontal;
    }

    pub fn vertical_margin(&mut self, vertical: u16) {
        self.margin.vertical = vertical;
    }

    /// Sets `Y` and `X` axis offsets accordingly
    ///
    /// # Note
    ///
    /// The scroll position will be automatically clamped during rendering to ensure
    /// it doesn't exceed valid bounds:
    /// - Vertical scroll will be limited to `max(0, content_height - viewport_height)`
    /// - Horizontal scroll will be limited to `max(0, content_width - viewport_width)`
    ///
    /// Content dimensions (`content_height` and `content_width`) are calculated
    /// during the first render after text changes. To get actual content dimensions:
    /// 1. After setting new text, call `render()` at least once
    /// 2. Then use [`Paragraph::content_height`] and [`Paragraph::content_width`] methods
    pub fn scroll(&mut self, offset: (u16, u16)) {
        self.scroll = offset;
    }

    pub fn alignment(&mut self, alignment: Alignment) {
        self.alignment = alignment;
    }

    /// Returns `Some(content height)` if vertical scrollbar was set or `None`
    pub fn content_height(&self) -> Option<u16> {
        match self.scrollbar_direction.as_ref() {
            Some(Direction::Vertical) => Some(self.content_height),
            _ => None,
        }
    }

    /// Returns `Some(content width)` if horizontal scrollbar was set or `None`
    ///
    /// This is the width of the longest line in the text.
    pub fn content_width(&self) -> Option<u16> {
        match self.scrollbar_direction.as_ref() {
            Some(Direction::Horizontal) => Some(self.content_width),
            _ => None,
        }
    }
}

impl<'a> Widget for Paragraph<'a> {
    fn render(&mut self, area: Rect, buf: &mut Buffer) {
        buf.set_style(area, self.style);
        let text_area = match self.block.as_mut() {
            Some(b) => {
                let inner_area = b.inner(area);
                b.render(area, buf);
                inner_area
            }
            None => area,
        };

        let text_area = text_area.inner(&self.margin);

        if !self.text_was_updated && self.scrollbar.is_some() {
            self.scroll = (
                self.scroll
                    .0
                    .min(self.content_height.saturating_sub(text_area.height)),
                self.scroll
                    .1
                    .min(self.content_width.saturating_sub(text_area.width)),
            )
        }

        if text_area.height < 1 {
            return;
        }

        let style = self.style;
        let mut styled = self.text.lines.iter().flat_map(|spans| {
            spans
                .0
                .iter()
                .flat_map(|span| span.styled_graphemes(style))
                // Required given the way composers work but might be refactored out if we change
                // composers to operate on lines instead of a stream of graphemes.
                .chain(iter::once(StyledGrapheme {
                    symbol: "\n",
                    style: self.style,
                }))
        });

        let mut line_composer: Box<dyn LineComposer> = if let Some(Wrap { trim }) = self.wrap {
            Box::new(WordWrapper::new(&mut styled, text_area.width, trim))
        } else {
            let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width));
            if let Alignment::Left = self.alignment {
                line_composer.set_horizontal_offset(self.scroll.1);
            }
            line_composer
        };
        let mut y = 0;
        let mut max_line_width = 0;
        let height = text_area.height + self.scroll.0;

        while let Some((current_line, current_line_width)) = line_composer.next_line() {
            if y >= self.scroll.0 && y < height {
                let mut x = get_line_offset(current_line_width, text_area.width, self.alignment);
                for StyledGrapheme { symbol, style } in current_line {
                    buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll.0)
                        .set_symbol(if symbol.is_empty() {
                            // If the symbol is empty, the last char which rendered last time will
                            // leave on the line. It's a quick fix.
                            " "
                        } else {
                            symbol
                        })
                        .set_style(*style);
                    x += symbol.width() as u16;
                }
            }
            y += 1;

            if current_line_width > max_line_width && !self.text_was_updated {
                max_line_width = current_line_width;
            }

            if y >= height && !self.text_was_updated {
                break;
            }
        }

        if self.text_was_updated {
            self.content_height = y;
            self.content_width = max_line_width;
            self.text_was_updated = false;
        }

        if let (Some(scrollbar), Some(dir)) = (self.scrollbar.as_mut(), &self.scrollbar_direction) {
            match dir {
                Direction::Horizontal => {
                    scrollbar.offset(self.scroll.1);
                    scrollbar.content_length(self.content_width);
                }
                Direction::Vertical => {
                    scrollbar.offset(self.scroll.0);
                    scrollbar.content_length(self.content_height);
                }
            }
            scrollbar.render(area, buf);
        }
    }
}