sveltui-code 0.1.0-alpha.4

TextMate syntax-highlighted code preview and editor components for sveltui
Documentation
use crossterm::style::Color;
use std::cell::RefCell;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::LazyLock;
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use syntect::util::LinesWithEndings;

static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);

thread_local! {
    static HIGHLIGHT_CACHE: RefCell<Vec<(u64, Vec<Vec<HighlightedSpan>>)>> = const { RefCell::new(Vec::new()) };
}

fn cache_key(text: &str, language: Option<&str>, theme_name: Option<&str>) -> u64 {
    let mut hasher = DefaultHasher::new();
    text.hash(&mut hasher);
    language.hash(&mut hasher);
    theme_name.hash(&mut hasher);
    hasher.finish()
}

pub const DEFAULT_THEME: &str = "base16-ocean.dark";

pub fn syntax_set() -> &'static SyntaxSet {
    &SYNTAX_SET
}

pub fn theme_set() -> &'static ThemeSet {
    &THEME_SET
}

pub fn find_syntax(language: Option<&str>) -> &'static SyntaxReference {
    let ps = syntax_set();
    if let Some(lang) = language {
        let clean = lang.trim();
        if !clean.is_empty() {
            if let Some(syntax) = ps.find_syntax_by_token(clean) {
                return syntax;
            }
            if let Some(syntax) = ps.find_syntax_by_extension(clean) {
                return syntax;
            }
            if let Some(syntax) = ps.find_syntax_by_name(clean) {
                return syntax;
            }
        }
    }
    ps.find_syntax_plain_text()
}

pub fn find_theme(theme_name: Option<&str>) -> &'static Theme {
    let ts = theme_set();
    let name = theme_name.unwrap_or(DEFAULT_THEME);
    if let Some(theme) = ts.themes.get(name) {
        return theme;
    }
    if let Some(theme) = ts.themes.get(DEFAULT_THEME) {
        return theme;
    }
    ts.themes
        .values()
        .next()
        .expect("At least one default theme must exist in syntect")
}

pub fn syntect_color_to_crossterm(c: syntect::highlighting::Color) -> Color {
    Color::Rgb {
        r: c.r,
        g: c.g,
        b: c.b,
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HighlightedSpan {
    pub text: String,
    pub fg: Color,
    pub bg: Option<Color>,
    pub bold: bool,
    pub italic: bool,
    pub underline: bool,
}

impl HighlightedSpan {
    pub fn plain(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            fg: Color::White,
            bg: None,
            bold: false,
            italic: false,
            underline: false,
        }
    }
}

pub fn highlight_text(
    text: &str,
    language: Option<&str>,
    theme_name: Option<&str>,
) -> Vec<Vec<HighlightedSpan>> {
    if text.is_empty() {
        return vec![Vec::new()];
    }

    let key = cache_key(text, language, theme_name);
    let cached = HIGHLIGHT_CACHE.with(|c| {
        let cache = c.borrow();
        cache
            .iter()
            .find(|(k, _)| *k == key)
            .map(|(_, v)| v.clone())
    });
    if let Some(res) = cached {
        return res;
    }

    let ps = syntax_set();
    let syntax = find_syntax(language);
    let theme = find_theme(theme_name);
    let mut highlighter = HighlightLines::new(syntax, theme);

    let mut result = Vec::new();

    for line in LinesWithEndings::from(text) {
        let ranges = highlighter.highlight_line(line, ps).unwrap_or_default();
        let mut spans = Vec::new();
        for (style, segment) in ranges {
            let clean_text = segment.trim_end_matches(['\r', '\n']);
            if clean_text.is_empty() {
                continue;
            }
            spans.push(HighlightedSpan {
                text: clean_text.to_string(),
                fg: syntect_color_to_crossterm(style.foreground),
                bg: if style.background.a > 0 {
                    Some(syntect_color_to_crossterm(style.background))
                } else {
                    None
                },
                bold: style.font_style.contains(FontStyle::BOLD),
                italic: style.font_style.contains(FontStyle::ITALIC),
                underline: style.font_style.contains(FontStyle::UNDERLINE),
            });
        }
        result.push(spans);
    }

    if text.ends_with('\n') {
        result.push(Vec::new());
    }

    HIGHLIGHT_CACHE.with(|c| {
        let mut cache = c.borrow_mut();
        if cache.len() >= 32 {
            cache.remove(0);
        }
        cache.push((key, result.clone()));
    });

    result
}

pub fn clip_spans(
    spans: &[HighlightedSpan],
    scroll_col: usize,
    max_width: usize,
) -> Vec<HighlightedSpan> {
    if max_width == 0 {
        return Vec::new();
    }
    let end_col = scroll_col + max_width;
    let mut current_col = 0;
    let mut clipped = Vec::new();

    for span in spans {
        let span_chars = span.text.chars().count();
        let span_start = current_col;
        let span_end = current_col + span_chars;
        current_col = span_end;

        if span_end <= scroll_col || span_start >= end_col {
            continue;
        }

        let slice_start = scroll_col.saturating_sub(span_start);
        let slice_end = (end_col.saturating_sub(span_start)).min(span_chars);

        if slice_start < slice_end {
            let sliced_text: String = span
                .text
                .chars()
                .skip(slice_start)
                .take(slice_end - slice_start)
                .collect();
            if !sliced_text.is_empty() {
                let mut new_span = span.clone();
                new_span.text = sliced_text;
                clipped.push(new_span);
            }
        }
    }

    clipped
}