Skip to main content

lv_tui_code_highlight/
highlight.rs

1use lv_tui::style::{Color, Style};
2use syntect::easy::HighlightLines;
3use syntect::highlighting::ThemeSet;
4use syntect::parsing::SyntaxSet;
5
6/// Syntax-highlighted code rendered to styled lines.
7pub struct CodeHighlight {
8    pub lines: Vec<StyledLine>,
9}
10
11/// A styled text span (re-exported convenience type matching lv-tui-markdown).
12#[derive(Debug, Clone)]
13pub struct StyledSpan {
14    pub text: String,
15    pub style: Style,
16}
17
18/// A single line of highlighted output.
19#[derive(Debug, Clone)]
20pub struct StyledLine {
21    pub spans: Vec<StyledSpan>,
22}
23
24impl CodeHighlight {
25    /// Highlight `code` as the given `language` (e.g. "rust", "python").
26    ///
27    /// Unknown languages fall back to plain text with a code-block background.
28    pub fn new(code: &str, language: &str) -> Self {
29        let ss = load_syntax_set();
30        let ts = ThemeSet::load_defaults();
31        let theme = &ts.themes["base16-ocean.dark"];
32        let syntax = ss.find_syntax_by_token(language)
33            .unwrap_or_else(|| ss.find_syntax_plain_text());
34
35        let mut highlighter = HighlightLines::new(syntax, theme);
36
37        let mut lines = Vec::new();
38
39        for line_str in code.lines() {
40            let ops = highlighter.highlight_line(line_str, &ss).unwrap_or_default();
41            let spans: Vec<StyledSpan> = ops.into_iter().map(|(style, text)| {
42                let mut lv_style = syntect_style_to_lv(&style);
43                if style.font_style.contains(syntect::highlighting::FontStyle::BOLD) {
44                    lv_style = lv_style.bold();
45                }
46                StyledSpan { text: text.to_string(), style: lv_style }
47            }).collect();
48            lines.push(StyledLine { spans });
49        }
50
51        Self { lines }
52    }
53
54    pub fn line_count(&self) -> usize { self.lines.len() }
55
56    /// Write a single line to the buffer.
57    pub fn render_line(&self, index: usize, buffer: &mut lv_tui::buffer::Buffer, pos: lv_tui::geom::Pos, clip: lv_tui::geom::Rect) {
58        if let Some(line) = self.lines.get(index) {
59            let mut x = pos.x;
60            for span in &line.spans {
61                if span.text.is_empty() { continue; }
62                buffer.write_text(lv_tui::geom::Pos { x, y: pos.y }, clip, &span.text, &span.style);
63                let w: u16 = span.text.chars()
64                    .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16)
65                    .sum();
66                x = x.saturating_add(w);
67            }
68        }
69    }
70}
71
72/// Convert a syntect Style to a lv-tui Style.
73fn syntect_style_to_lv(s: &syntect::highlighting::Style) -> Style {
74    let mut style = Style::default();
75    style = style.fg(syntect_color_to_lv(s.foreground));
76    // Don't apply syntect's background colors — let the caller decide
77    if s.font_style.contains(syntect::highlighting::FontStyle::ITALIC) {
78        style = style.italic();
79    }
80    style
81}
82
83fn syntect_color_to_lv(c: syntect::highlighting::Color) -> Color {
84    // Simplified mapping from syntect RGB to lv-tui named colors
85    // Use the closest ANSI color based on intensity
86    let r = c.r;
87    let g = c.g;
88    let b = c.b;
89    let bright = r.max(g).max(b);
90    let dull = r.min(g).min(b);
91    let sat = bright - dull;
92
93    if bright < 40 { return Color::Black; }
94    if bright < 100 { return Color::Gray; }
95
96    // Pick hue-dominant color
97    if sat < 25 { return if bright > 180 { Color::White } else { Color::Gray }; }
98    if r > g && r > b { return if r > 180 { Color::Red } else { Color::Red }; }
99    if g > r && g > b { return if g > 180 { Color::Green } else { Color::Green }; }
100    if b > r && b > g { return if b > 180 { Color::Blue } else { Color::Blue }; }
101    if r > 180 && g > 180 { return Color::Yellow; }
102    if r > 150 && b > 150 { return Color::Magenta; }
103    if g > 150 && b > 150 { return Color::Cyan; }
104
105    if bright > 180 { Color::White } else { Color::Gray }
106}
107
108fn load_syntax_set() -> SyntaxSet {
109    SyntaxSet::load_defaults_newlines()
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_rust_highlight() {
118        let hl = CodeHighlight::new("fn main() {\n    println!(\"hello\");\n}\n", "rust");
119        assert!(hl.line_count() >= 3);
120        // First line should have non-empty spans
121        assert!(!hl.lines[0].spans.is_empty());
122    }
123
124    #[test]
125    fn test_unknown_language() {
126        let hl = CodeHighlight::new("some code", "nonexistent-lang");
127        assert!(hl.line_count() >= 1);
128    }
129
130    #[test]
131    fn test_empty() {
132        let hl = CodeHighlight::new("", "rust");
133        assert_eq!(hl.line_count(), 0);
134    }
135}