scrin 0.1.78

A terminal UI toolkit with panes, widgets, overlays, animations, and Aisling-powered effects/loaders.
Documentation
use std::cell::RefCell;

use crate::core::buffer::Buffer;
use crate::core::rect::Rect;
use crate::scroll_state::ScrollState;
use crate::style::Style;
use crate::theme::ThemeTokens;
use crate::widgets::paragraph::{render_line, Alignment, Line, Paragraph, Text, WrapMode};
use crate::widgets::Widget;

#[derive(Debug, Clone, PartialEq)]
struct WrappedTextCache {
    width: u16,
    text: Text,
    style: Style,
    wrap: WrapMode,
    alignment: Alignment,
    rows: Vec<Line>,
}

#[derive(Debug, Clone)]
pub struct ScrollableText {
    pub text: Text,
    pub style: Style,
    pub wrap: WrapMode,
    pub scroll: ScrollState,
    pub alignment: Alignment,
    cache: RefCell<Option<WrappedTextCache>>,
}

impl ScrollableText {
    pub fn new(text: Text) -> Self {
        Self {
            text,
            style: Style::new(),
            wrap: WrapMode::Word { trim: true },
            scroll: ScrollState::new(),
            alignment: Alignment::Left,
            cache: RefCell::new(None),
        }
    }

    pub fn raw(content: &str) -> Self {
        Self::new(Text::raw(content))
    }

    pub fn with_style(mut self, style: Style) -> Self {
        self.style = style;
        self.invalidate();
        self
    }

    pub fn with_theme_tokens(self, tokens: ThemeTokens) -> Self {
        self.with_style(tokens.text_style())
    }

    pub fn wrap(mut self, mode: WrapMode) -> Self {
        self.wrap = mode;
        self.invalidate();
        self
    }

    pub fn with_scroll(mut self, scroll: ScrollState) -> Self {
        self.scroll = scroll;
        self
    }

    pub fn alignment(mut self, alignment: Alignment) -> Self {
        self.alignment = alignment;
        self.invalidate();
        self
    }

    pub fn set_text(&mut self, text: Text) {
        self.text = text;
        self.invalidate();
    }

    pub fn push_line(&mut self, line: Line) {
        self.text.push_line(line);
        self.invalidate();
    }

    pub fn invalidate(&self) {
        *self.cache.borrow_mut() = None;
    }

    pub fn rows_for_width(&self, width: u16) -> Vec<Line> {
        let mut cache = self.cache.borrow_mut();
        if let Some(cached) = cache.as_ref() {
            if cached.width == width
                && cached.text == self.text
                && cached.style == self.style
                && cached.wrap == self.wrap
                && cached.alignment == self.alignment
            {
                return cached.rows.clone();
            }
        }

        let rows = Paragraph::from_text(self.text.clone())
            .with_style(self.style)
            .wrap(self.wrap)
            .alignment(self.alignment)
            .wrapped_rows(width);
        *cache = Some(WrappedTextCache {
            width,
            text: self.text.clone(),
            style: self.style,
            wrap: self.wrap,
            alignment: self.alignment,
            rows: rows.clone(),
        });
        rows
    }

    pub fn rendered_height(&self, width: u16) -> usize {
        self.rows_for_width(width).len()
    }
}

impl From<Text> for ScrollableText {
    fn from(value: Text) -> Self {
        Self::new(value)
    }
}

impl From<&str> for ScrollableText {
    fn from(value: &str) -> Self {
        Self::raw(value)
    }
}

impl Widget for ScrollableText {
    fn render(&self, buffer: &mut Buffer, area: Rect) {
        if area.is_empty() {
            return;
        }
        let rows = self.rows_for_width(area.width);
        let mut scroll = self.scroll;
        scroll.set_bounds(rows.len(), area.height as usize);
        for (screen_row, row_idx) in scroll.visible_range().enumerate() {
            let y = area.y as usize + screen_row;
            if y >= area.bottom() as usize {
                break;
            }
            render_line(buffer, &rows[row_idx], area, y, 0);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::color::Color;

    #[test]
    fn scrollable_text_caches_wrapped_rows_by_width() {
        let text = ScrollableText::raw("alpha beta gamma").wrap(WrapMode::Word { trim: true });
        assert_eq!(text.rendered_height(8), 3);
        assert_eq!(text.rendered_height(80), 1);
    }

    #[test]
    fn scrollable_text_renders_styled_lines() {
        let text = Text::new(vec![Line::styled("hello", Style::new().fg(Color::CYAN))]);
        let widget = ScrollableText::new(text);
        let mut buffer = Buffer::new(8, 1);
        widget.render(&mut buffer, Rect::new(0, 0, 8, 1));
        assert_eq!(buffer.get(0, 0).unwrap().ch, 'h');
        assert_eq!(buffer.get(0, 0).unwrap().fg, Color::CYAN);
    }
}