scrin 0.1.37

A terminal UI toolkit with panes, widgets, overlays, animations, and Aisling-powered effects/loaders.
Documentation
use crate::core::buffer::{Buffer, Cell};
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::sanitize;
use crate::scroll_state::ScrollState;
use crate::widgets::code::{render_syntax_line, CodeTheme};
use crate::widgets::Widget;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputTheme {
    pub bg: Option<Color>,
    pub text: Color,
    pub dim: Color,
    pub heading: Color,
    pub accent: Color,
    pub quote: Color,
    pub code: CodeTheme,
}

impl OutputTheme {
    pub const SCRIN: Self = Self {
        bg: None,
        text: Color::rgb(201, 209, 217),
        dim: Color::rgb(110, 118, 129),
        heading: Color::rgb(121, 192, 255),
        accent: Color::rgb(63, 185, 80),
        quote: Color::rgb(255, 178, 72),
        code: CodeTheme::SCRIN,
    };
}

impl Default for OutputTheme {
    fn default() -> Self {
        Self::SCRIN
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutputRow {
    Blank,
    Paragraph(String),
    Heading { level: usize, text: String },
    Bullet(String),
    Quote(String),
    CodeFenceStart(String),
    Code { text: String, line: usize },
    CodeFenceEnd,
}

#[derive(Debug, Clone)]
pub struct MarkdownOutput {
    pub content: String,
    pub theme: OutputTheme,
    pub scroll: ScrollState,
    pub wrap: bool,
    pub code_line_numbers: bool,
}

impl MarkdownOutput {
    pub fn new(content: &str) -> Self {
        Self {
            content: content.to_string(),
            theme: OutputTheme::default(),
            scroll: ScrollState::new(),
            wrap: true,
            code_line_numbers: false,
        }
    }

    pub fn with_theme(mut self, theme: OutputTheme) -> Self {
        self.theme = theme;
        self
    }

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

    pub fn with_wrap(mut self, wrap: bool) -> Self {
        self.wrap = wrap;
        self
    }

    pub fn with_code_line_numbers(mut self, show: bool) -> Self {
        self.code_line_numbers = show;
        self
    }

    pub fn rows_for_width(&self, width: usize) -> Vec<OutputRow> {
        parse_rows(&self.content, width.max(1), self.wrap)
    }
}

impl Widget for MarkdownOutput {
    fn render(&self, buffer: &mut Buffer, area: Rect) {
        if area.width == 0 || area.height == 0 {
            return;
        }
        if let Some(bg) = self.theme.bg {
            buffer.fill(area, ' ', self.theme.text, Some(bg));
        }
        let rows = self.rows_for_width(area.width as usize);
        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_row(
                buffer,
                area,
                y,
                &rows[row_idx],
                self.theme,
                self.code_line_numbers,
            );
        }
    }
}

fn parse_rows(content: &str, width: usize, wrap: bool) -> Vec<OutputRow> {
    let mut rows = Vec::new();
    let mut in_code = false;
    let mut code_line = 1usize;
    for raw in content.lines() {
        let trimmed = raw.trim_end();
        if let Some(lang) = trimmed.strip_prefix("```") {
            if in_code {
                rows.push(OutputRow::CodeFenceEnd);
                in_code = false;
            } else {
                rows.push(OutputRow::CodeFenceStart(lang.trim().to_string()));
                in_code = true;
                code_line = 1;
            }
            continue;
        }
        if in_code {
            rows.push(OutputRow::Code {
                text: trimmed.to_string(),
                line: code_line,
            });
            code_line += 1;
            continue;
        }
        if trimmed.is_empty() {
            rows.push(OutputRow::Blank);
        } else if let Some(text) = trimmed.strip_prefix("### ") {
            rows.push(OutputRow::Heading {
                level: 3,
                text: text.to_string(),
            });
        } else if let Some(text) = trimmed.strip_prefix("## ") {
            rows.push(OutputRow::Heading {
                level: 2,
                text: text.to_string(),
            });
        } else if let Some(text) = trimmed.strip_prefix("# ") {
            rows.push(OutputRow::Heading {
                level: 1,
                text: text.to_string(),
            });
        } else if let Some(text) = trimmed
            .strip_prefix("- ")
            .or_else(|| trimmed.strip_prefix("* "))
        {
            push_wrapped(&mut rows, text, width.saturating_sub(2), wrap, |s| {
                OutputRow::Bullet(s)
            });
        } else if let Some(text) = trimmed.strip_prefix("> ") {
            push_wrapped(&mut rows, text, width.saturating_sub(2), wrap, |s| {
                OutputRow::Quote(s)
            });
        } else {
            push_wrapped(&mut rows, trimmed, width, wrap, OutputRow::Paragraph);
        }
    }
    if rows.is_empty() {
        rows.push(OutputRow::Blank);
    }
    rows
}

fn push_wrapped<F>(rows: &mut Vec<OutputRow>, text: &str, width: usize, wrap: bool, make: F)
where
    F: Fn(String) -> OutputRow,
{
    if !wrap || width == 0 {
        rows.push(make(text.to_string()));
        return;
    }
    let mut line = String::new();
    for word in text.split_whitespace() {
        let add = if line.is_empty() {
            word.len()
        } else {
            word.len() + 1
        };
        if !line.is_empty() && line.len() + add > width {
            rows.push(make(line));
            line = String::new();
        }
        if !line.is_empty() {
            line.push(' ');
        }
        line.push_str(word);
    }
    if !line.is_empty() {
        rows.push(make(line));
    }
}

fn render_row(
    buffer: &mut Buffer,
    area: Rect,
    y: usize,
    row: &OutputRow,
    theme: OutputTheme,
    code_line_numbers: bool,
) {
    match row {
        OutputRow::Blank => {}
        OutputRow::Paragraph(text) => {
            write_line(
                buffer,
                area.x as usize,
                y,
                text,
                theme.text,
                theme.bg,
                area.width,
            );
        }
        OutputRow::Heading { level, text } => {
            let prefix = match level {
                1 => "# ",
                2 => "## ",
                _ => "### ",
            };
            let line = format!("{}{}", prefix, text);
            buffer.set_str_bold(
                area.x as usize,
                y,
                &sanitize::truncate_str(&line, area.width as usize),
                theme.heading,
                theme.bg,
            );
        }
        OutputRow::Bullet(text) => {
            buffer.set_str(area.x as usize, y, "", theme.accent, theme.bg);
            write_line(
                buffer,
                area.x as usize + 2,
                y,
                text,
                theme.text,
                theme.bg,
                area.width.saturating_sub(2),
            );
        }
        OutputRow::Quote(text) => {
            buffer.set_str(area.x as usize, y, "", theme.quote, theme.bg);
            write_line(
                buffer,
                area.x as usize + 2,
                y,
                text,
                theme.dim,
                theme.bg,
                area.width.saturating_sub(2),
            );
        }
        OutputRow::CodeFenceStart(lang) => {
            fill_code_row(buffer, area, y, theme.code.bg);
            let label = if lang.is_empty() {
                " code "
            } else {
                lang.as_str()
            };
            write_line(
                buffer,
                area.x as usize + 1,
                y,
                label,
                theme.code.gutter_fg,
                Some(theme.code.bg),
                area.width.saturating_sub(2),
            );
        }
        OutputRow::Code { text, line } => {
            fill_code_row(buffer, area, y, theme.code.bg);
            let mut x = area.x as usize + 1;
            if code_line_numbers && area.width > 5 {
                let gutter = format!("{:>3}", line);
                buffer.set_str(
                    x,
                    y,
                    &gutter,
                    theme.code.gutter_fg,
                    Some(theme.code.gutter_bg),
                );
                x += 6;
            }
            if x < area.right() as usize {
                render_syntax_line(
                    buffer,
                    x,
                    y,
                    text,
                    area.right() as usize - x,
                    theme.code,
                    Some(theme.code.bg),
                );
            }
        }
        OutputRow::CodeFenceEnd => {
            fill_code_row(buffer, area, y, theme.code.bg);
        }
    }
}

fn fill_code_row(buffer: &mut Buffer, area: Rect, y: usize, bg: Color) {
    for x in area.x as usize..area.right() as usize {
        buffer.set(x, y, Cell::new(' ', Color::WHITE, Some(bg)));
    }
}

fn write_line(
    buffer: &mut Buffer,
    x: usize,
    y: usize,
    text: &str,
    fg: Color,
    bg: Option<Color>,
    width: u16,
) {
    if width == 0 {
        return;
    }
    let text = sanitize::truncate_str(text, width as usize);
    buffer.set_str(x, y, &text, fg, bg);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn markdown_parses_code_fences() {
        let md = MarkdownOutput::new("# Title\n```rust\nfn main() {}\n```");
        let rows = md.rows_for_width(40);
        assert!(matches!(rows[0], OutputRow::Heading { .. }));
        assert!(matches!(rows[1], OutputRow::CodeFenceStart(_)));
        assert!(matches!(rows[2], OutputRow::Code { .. }));
    }

    #[test]
    fn markdown_renders_code_backdrop() {
        let md = MarkdownOutput::new("```rust\nfn main() {}\n```");
        let mut buffer = Buffer::new(40, 5);
        md.render(&mut buffer, Rect::new(0, 0, 40, 5));
        assert_eq!(buffer.get(0, 0).unwrap().bg, Some(CodeTheme::SCRIN.bg));
        assert_eq!(buffer.get(0, 1).unwrap().bg, Some(CodeTheme::SCRIN.bg));
    }

    #[test]
    fn markdown_tiny_area_no_panic() {
        let md = MarkdownOutput::new("- tiny\n```\nx\n```");
        let mut buffer = Buffer::new(3, 2);
        md.render(&mut buffer, Rect::new(0, 0, 3, 2));
    }
}