scrin 0.1.73

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::Widget;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CodeTheme {
    pub bg: Color,
    pub gutter_bg: Color,
    pub gutter_fg: Color,
    pub text_fg: Color,
    pub keyword_fg: Color,
    pub string_fg: Color,
    pub number_fg: Color,
    pub comment_fg: Color,
    pub punctuation_fg: Color,
    pub current_line_bg: Color,
}

impl CodeTheme {
    pub const SCRIN: Self = Self {
        bg: Color::rgb(12, 17, 27),
        gutter_bg: Color::rgb(9, 13, 20),
        gutter_fg: Color::rgb(92, 99, 112),
        text_fg: Color::rgb(201, 209, 217),
        keyword_fg: Color::rgb(255, 123, 114),
        string_fg: Color::rgb(165, 214, 255),
        number_fg: Color::rgb(121, 192, 255),
        comment_fg: Color::rgb(110, 118, 129),
        punctuation_fg: Color::rgb(139, 148, 158),
        current_line_bg: Color::rgb(20, 28, 43),
    };
}

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

#[derive(Debug, Clone)]
pub struct CodeBlock {
    pub code: String,
    pub language: Option<String>,
    pub theme: CodeTheme,
    pub show_line_numbers: bool,
    pub start_line: usize,
    pub scroll: ScrollState,
    pub current_line: Option<usize>,
    pub pad_left: u16,
}

impl CodeBlock {
    pub fn new(code: &str) -> Self {
        Self {
            code: code.to_string(),
            language: None,
            theme: CodeTheme::default(),
            show_line_numbers: true,
            start_line: 1,
            scroll: ScrollState::new(),
            current_line: None,
            pad_left: 1,
        }
    }

    pub fn with_language(mut self, language: &str) -> Self {
        self.language = Some(language.to_string());
        self
    }

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

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

    pub fn with_start_line(mut self, start: usize) -> Self {
        self.start_line = start.max(1);
        self
    }

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

    pub fn with_current_line(mut self, line: usize) -> Self {
        self.current_line = Some(line);
        self
    }

    pub fn line_count(&self) -> usize {
        self.code.lines().count().max(1)
    }

    pub fn gutter_width(&self) -> usize {
        if self.show_line_numbers {
            (self.start_line + self.line_count()).to_string().len() + 2
        } else {
            0
        }
    }
}

impl Widget for CodeBlock {
    fn render(&self, buffer: &mut Buffer, area: Rect) {
        if area.width == 0 || area.height == 0 {
            return;
        }
        buffer.fill(area, ' ', self.theme.text_fg, Some(self.theme.bg));
        let lines: Vec<&str> = if self.code.is_empty() {
            vec![""]
        } else {
            self.code.lines().collect()
        };
        let mut scroll = self.scroll;
        scroll.set_bounds(lines.len(), area.height as usize);
        let gutter_width = self.gutter_width().min(area.width as usize);
        for (screen_row, line_idx) in scroll.visible_range().enumerate() {
            let y = area.y as usize + screen_row;
            if y >= area.bottom() as usize {
                break;
            }
            let absolute_line = self.start_line + line_idx;
            let line_bg = if self.current_line == Some(absolute_line) {
                self.theme.current_line_bg
            } else {
                self.theme.bg
            };
            for x in area.x as usize..area.right() as usize {
                buffer.set(x, y, Cell::new(' ', self.theme.text_fg, Some(line_bg)));
            }
            if self.show_line_numbers && gutter_width > 0 {
                let gutter = format!(
                    "{:>width$} │",
                    absolute_line,
                    width = gutter_width.saturating_sub(2)
                );
                let gutter = sanitize::truncate_str(&gutter, gutter_width);
                for x in
                    area.x as usize..(area.x as usize + gutter_width).min(area.right() as usize)
                {
                    buffer.set(
                        x,
                        y,
                        Cell::new(' ', self.theme.gutter_fg, Some(self.theme.gutter_bg)),
                    );
                }
                buffer.set_str(
                    area.x as usize,
                    y,
                    &gutter,
                    self.theme.gutter_fg,
                    Some(self.theme.gutter_bg),
                );
            }
            let code_x = area.x as usize + gutter_width + self.pad_left as usize;
            if code_x < area.right() as usize {
                let width = area.right() as usize - code_x;
                render_syntax_line(
                    buffer,
                    code_x,
                    y,
                    lines[line_idx],
                    width,
                    self.theme,
                    Some(line_bg),
                );
            }
        }
    }
}

pub(crate) fn render_syntax_line(
    buffer: &mut Buffer,
    x: usize,
    y: usize,
    line: &str,
    max_width: usize,
    theme: CodeTheme,
    bg: Option<Color>,
) {
    if max_width == 0 {
        return;
    }
    let line = sanitize::sanitize_str(line, max_width);
    let trimmed = line.trim_start();
    if trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.starts_with("--") {
        buffer.set_str(x, y, &line, theme.comment_fg, bg);
        return;
    }

    let mut col = 0usize;
    let mut chars = line.chars().peekable();
    while let Some(ch) = chars.next() {
        if col >= max_width {
            break;
        }
        if ch == '"' || ch == '\'' || ch == '`' {
            let quote = ch;
            let mut token = String::new();
            token.push(ch);
            for next in chars.by_ref() {
                token.push(next);
                if next == quote {
                    break;
                }
            }
            write_colored(
                buffer,
                x,
                y,
                &token,
                &mut col,
                max_width,
                theme.string_fg,
                bg,
            );
        } else if ch.is_ascii_digit() {
            let mut token = String::new();
            token.push(ch);
            while let Some(next) = chars.peek().copied() {
                if next.is_ascii_digit() || next == '_' || next == '.' {
                    token.push(next);
                    chars.next();
                } else {
                    break;
                }
            }
            write_colored(
                buffer,
                x,
                y,
                &token,
                &mut col,
                max_width,
                theme.number_fg,
                bg,
            );
        } else if is_ident_start(ch) {
            let mut token = String::new();
            token.push(ch);
            while let Some(next) = chars.peek().copied() {
                if is_ident_continue(next) {
                    token.push(next);
                    chars.next();
                } else {
                    break;
                }
            }
            let fg = if is_keyword(&token) {
                theme.keyword_fg
            } else {
                theme.text_fg
            };
            write_colored(buffer, x, y, &token, &mut col, max_width, fg, bg);
        } else {
            let fg = if ch.is_ascii_punctuation() {
                theme.punctuation_fg
            } else {
                theme.text_fg
            };
            write_colored(buffer, x, y, &ch.to_string(), &mut col, max_width, fg, bg);
        }
    }
}

fn write_colored(
    buffer: &mut Buffer,
    x: usize,
    y: usize,
    text: &str,
    col: &mut usize,
    max_width: usize,
    fg: Color,
    bg: Option<Color>,
) {
    for ch in text.chars() {
        if *col >= max_width {
            break;
        }
        buffer.set(x + *col, y, Cell::new(ch, fg, bg));
        *col += 1;
    }
}

fn is_ident_start(ch: char) -> bool {
    ch == '_' || ch.is_ascii_alphabetic()
}

fn is_ident_continue(ch: char) -> bool {
    ch == '_' || ch.is_ascii_alphanumeric()
}

fn is_keyword(token: &str) -> bool {
    matches!(
        token,
        "as" | "async"
            | "await"
            | "break"
            | "case"
            | "class"
            | "const"
            | "continue"
            | "crate"
            | "default"
            | "else"
            | "enum"
            | "export"
            | "false"
            | "fn"
            | "for"
            | "from"
            | "function"
            | "if"
            | "impl"
            | "import"
            | "in"
            | "let"
            | "loop"
            | "match"
            | "mod"
            | "mut"
            | "pub"
            | "return"
            | "self"
            | "static"
            | "struct"
            | "super"
            | "trait"
            | "true"
            | "type"
            | "use"
            | "where"
            | "while"
    )
}

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

    #[test]
    fn code_block_counts_lines() {
        let block = CodeBlock::new("fn main() {}\nlet x = 1;");
        assert_eq!(block.line_count(), 2);
        assert!(block.gutter_width() >= 3);
    }

    #[test]
    fn code_block_renders_backdrop() {
        let block = CodeBlock::new("fn main() {\n  let x = 1;\n}");
        let mut buffer = Buffer::new(40, 5);
        block.render(&mut buffer, Rect::new(0, 0, 40, 5));
        assert_eq!(
            buffer.get(0, 0).unwrap().bg,
            Some(CodeTheme::SCRIN.gutter_bg)
        );
        assert_eq!(buffer.get(8, 0).unwrap().bg, Some(CodeTheme::SCRIN.bg));
    }

    #[test]
    fn code_block_tiny_area_no_panic() {
        let block = CodeBlock::new("fn main() {}");
        let mut buffer = Buffer::new(2, 1);
        block.render(&mut buffer, Rect::new(0, 0, 2, 1));
    }
}