scrin 0.1.83

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::interaction::{
    HitRegion, InteractionLayer, ScrollRowHit, SelectableSpan, SelectionGroup, TextRange,
    WidgetAction, WidgetId, WidgetRole,
};
use crate::sanitize;
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,
    pub selectable: bool,
    pub region_id: Option<WidgetId>,
    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,
            selectable: false,
            region_id: None,
            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 with_selectable(mut self, selectable: bool) -> Self {
        self.selectable = selectable;
        self
    }

    pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
        self.region_id = Some(id.into());
        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())
    }

    pub fn render_with_interaction(
        &self,
        buffer: &mut Buffer,
        area: Rect,
        layer: &mut InteractionLayer,
    ) {
        self.render(buffer, area);
        if area.is_empty() {
            return;
        }

        let region_id = self
            .region_id
            .clone()
            .unwrap_or_else(|| WidgetId::new("scrollable-text"));
        layer.push_region(
            HitRegion::new(region_id.clone(), area)
                .with_role(WidgetRole::Text)
                .with_label("scrollable text"),
        );

        self.with_rows_for_width(area.width, |rows| {
            let mut scroll = self.scroll;
            scroll.set_bounds(rows.len(), area.height as usize);
            let selection_group = SelectionGroup::new(format!("{}:text", region_id.as_ref()));
            let mut row_hits = Vec::new();
            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;
                }
                let row_id = WidgetId::new(format!("{}:row:{}", region_id.as_ref(), row_idx));
                let span_id = format!("{}:span:{}", region_id.as_ref(), row_idx);
                let row_area = Rect::new(area.x, y as u16, area.width, 1);
                let text = line_plain_text(&rows[row_idx]);
                layer.push_region(
                    HitRegion::new(row_id.clone(), row_area)
                        .with_role(WidgetRole::TextSpan)
                        .with_label(text.clone())
                        .with_action(WidgetAction::Select)
                        .with_row(row_idx)
                        .with_selection_group(selection_group.clone())
                        .with_z_index(1),
                );
                row_hits.push(
                    ScrollRowHit::new(row_id.clone(), row_idx)
                        .with_span_id(span_id.clone())
                        .with_item_id(row_id)
                        .with_wrapped_continuation(row_idx > 0),
                );
                if self.selectable {
                    let display = sanitize::sanitize_str(&text, area.width as usize);
                    let width = sanitize::str_display_width(&display).min(area.width as usize);
                    layer.push_selectable_span(
                        SelectableSpan::new(
                            span_id,
                            display.clone(),
                            0..display.len(),
                            Rect::new(area.x, y as u16, width as u16, 1),
                        )
                        .with_source_id(region_id.clone())
                        .with_group(selection_group.clone())
                        .with_logical_range(TextRange::new(
                            row_idx,
                            0,
                            sanitize::str_display_width(&display),
                        )),
                    );
                }
            }
            layer.push_scroll_region(region_id, area, scroll.offset, row_hits);
        });
    }
}

fn line_plain_text(line: &Line) -> String {
    line.spans
        .iter()
        .map(|span| span.content.as_str())
        .collect::<Vec<_>>()
        .join("")
}

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);
    }

    #[test]
    fn scrollable_text_registers_scroll_hits_and_spans() {
        let widget = ScrollableText::raw("alpha beta gamma")
            .wrap(WrapMode::Word { trim: true })
            .with_selectable(true)
            .with_region_id("transcript:body");
        let mut buffer = Buffer::new(8, 3);
        let mut layer = InteractionLayer::new();

        widget.render_with_interaction(&mut buffer, Rect::new(0, 0, 8, 3), &mut layer);

        let hit = layer.scroll_hit_test(1, 1).unwrap();
        assert_eq!(hit.region_id.as_ref(), "transcript:body");
        assert_eq!(hit.logical_row, 1);
        assert!(layer.selectable_at(1, 0).is_some());
    }
}