lv-tui-code-highlight 0.1.0

Syntax highlighting for the lv-tui framework
Documentation
use lv_tui::style::{Color, Style};
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;

/// Syntax-highlighted code rendered to styled lines.
pub struct CodeHighlight {
    pub lines: Vec<StyledLine>,
}

/// A styled text span (re-exported convenience type matching lv-tui-markdown).
#[derive(Debug, Clone)]
pub struct StyledSpan {
    pub text: String,
    pub style: Style,
}

/// A single line of highlighted output.
#[derive(Debug, Clone)]
pub struct StyledLine {
    pub spans: Vec<StyledSpan>,
}

impl CodeHighlight {
    /// Highlight `code` as the given `language` (e.g. "rust", "python").
    ///
    /// Unknown languages fall back to plain text with a code-block background.
    pub fn new(code: &str, language: &str) -> Self {
        let ss = load_syntax_set();
        let ts = ThemeSet::load_defaults();
        let theme = &ts.themes["base16-ocean.dark"];
        let syntax = ss.find_syntax_by_token(language)
            .unwrap_or_else(|| ss.find_syntax_plain_text());

        let mut highlighter = HighlightLines::new(syntax, theme);

        let mut lines = Vec::new();

        for line_str in code.lines() {
            let ops = highlighter.highlight_line(line_str, &ss).unwrap_or_default();
            let spans: Vec<StyledSpan> = ops.into_iter().map(|(style, text)| {
                let mut lv_style = syntect_style_to_lv(&style);
                if style.font_style.contains(syntect::highlighting::FontStyle::BOLD) {
                    lv_style = lv_style.bold();
                }
                StyledSpan { text: text.to_string(), style: lv_style }
            }).collect();
            lines.push(StyledLine { spans });
        }

        Self { lines }
    }

    pub fn line_count(&self) -> usize { self.lines.len() }

    /// Write a single line to the buffer.
    pub fn render_line(&self, index: usize, buffer: &mut lv_tui::buffer::Buffer, pos: lv_tui::geom::Pos, clip: lv_tui::geom::Rect) {
        if let Some(line) = self.lines.get(index) {
            let mut x = pos.x;
            for span in &line.spans {
                if span.text.is_empty() { continue; }
                buffer.write_text(lv_tui::geom::Pos { x, y: pos.y }, clip, &span.text, &span.style);
                let w: u16 = span.text.chars()
                    .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16)
                    .sum();
                x = x.saturating_add(w);
            }
        }
    }
}

/// Convert a syntect Style to a lv-tui Style.
fn syntect_style_to_lv(s: &syntect::highlighting::Style) -> Style {
    let mut style = Style::default();
    style = style.fg(syntect_color_to_lv(s.foreground));
    // Don't apply syntect's background colors — let the caller decide
    if s.font_style.contains(syntect::highlighting::FontStyle::ITALIC) {
        style = style.italic();
    }
    style
}

fn syntect_color_to_lv(c: syntect::highlighting::Color) -> Color {
    // Simplified mapping from syntect RGB to lv-tui named colors
    // Use the closest ANSI color based on intensity
    let r = c.r;
    let g = c.g;
    let b = c.b;
    let bright = r.max(g).max(b);
    let dull = r.min(g).min(b);
    let sat = bright - dull;

    if bright < 40 { return Color::Black; }
    if bright < 100 { return Color::Gray; }

    // Pick hue-dominant color
    if sat < 25 { return if bright > 180 { Color::White } else { Color::Gray }; }
    if r > g && r > b { return if r > 180 { Color::Red } else { Color::Red }; }
    if g > r && g > b { return if g > 180 { Color::Green } else { Color::Green }; }
    if b > r && b > g { return if b > 180 { Color::Blue } else { Color::Blue }; }
    if r > 180 && g > 180 { return Color::Yellow; }
    if r > 150 && b > 150 { return Color::Magenta; }
    if g > 150 && b > 150 { return Color::Cyan; }

    if bright > 180 { Color::White } else { Color::Gray }
}

fn load_syntax_set() -> SyntaxSet {
    SyntaxSet::load_defaults_newlines()
}

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

    #[test]
    fn test_rust_highlight() {
        let hl = CodeHighlight::new("fn main() {\n    println!(\"hello\");\n}\n", "rust");
        assert!(hl.line_count() >= 3);
        // First line should have non-empty spans
        assert!(!hl.lines[0].spans.is_empty());
    }

    #[test]
    fn test_unknown_language() {
        let hl = CodeHighlight::new("some code", "nonexistent-lang");
        assert!(hl.line_count() >= 1);
    }

    #[test]
    fn test_empty() {
        let hl = CodeHighlight::new("", "rust");
        assert_eq!(hl.line_count(), 0);
    }
}