scrin 0.1.79

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> {
        self.with_rows_for_width(width, |rows| rows.to_vec())
    }

    pub fn with_rows_for_width<R>(&self, width: u16, f: impl FnOnce(&[Line]) -> R) -> R {
        let needs_rebuild = match self.cache.borrow().as_ref() {
            Some(cached) => {
                cached.width != width
                    || cached.text != self.text
                    || cached.style != self.style
                    || cached.wrap != self.wrap
                    || cached.alignment != self.alignment
            }
            None => true,
        };
        if needs_rebuild {
            let rows = Paragraph::from_text(self.text.clone())
                .with_style(self.style)
                .wrap(self.wrap)
                .alignment(self.alignment)
                .wrapped_rows(width);
            *self.cache.borrow_mut() = Some(WrappedTextCache {
                width,
                text: self.text.clone(),
                style: self.style,
                wrap: self.wrap,
                alignment: self.alignment,
                rows,
            });
        }
        let cache = self.cache.borrow();
        let rows = &cache.as_ref().expect("scroll text cache must exist").rows;
        f(rows)
    }

    pub fn rendered_height(&self, width: u16) -> usize {
        self.with_rows_for_width(width, |rows| rows.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;
        }
        self.with_rows_for_width(area.width, |rows| {
            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);
    }
}