Skip to main content

fresh/services/
styled_html.rs

1//! Styled text rendering for clipboard copy feature
2//!
3//! This module renders styled text with syntax highlighting as HTML
4//! for pasting into rich text editors (Google Docs, Word, etc.)
5
6use crate::primitives::highlighter::HighlightSpan;
7use crate::view::theme::Theme;
8use ratatui::style::Color;
9
10/// Convert a ratatui Color to a CSS hex color string
11fn color_to_css(color: Color, default: &str) -> String {
12    match color {
13        Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
14        Color::Black => "#000000".to_string(),
15        Color::Red => "#cd3131".to_string(),
16        Color::Green => "#0dbc79".to_string(),
17        Color::Yellow => "#e5e510".to_string(),
18        Color::Blue => "#2472c8".to_string(),
19        Color::Magenta => "#bc3fbc".to_string(),
20        Color::Cyan => "#11a8cd".to_string(),
21        Color::Gray => "#808080".to_string(),
22        Color::DarkGray => "#505050".to_string(),
23        Color::LightRed => "#f14c4c".to_string(),
24        Color::LightGreen => "#23d18b".to_string(),
25        Color::LightYellow => "#f5f543".to_string(),
26        Color::LightBlue => "#3b8eea".to_string(),
27        Color::LightMagenta => "#d670d6".to_string(),
28        Color::LightCyan => "#29b8db".to_string(),
29        Color::White => "#e5e5e5".to_string(),
30        Color::Reset | Color::Indexed(_) => default.to_string(),
31    }
32}
33
34/// Render styled text with syntax highlighting to HTML with inline CSS
35///
36/// The generated HTML uses a `<pre>` block with inline styles for each
37/// syntax-highlighted span. This allows pasting into rich text editors
38/// like Google Docs, Word, etc.
39///
40/// # Arguments
41/// * `text` - The text to render
42/// * `highlight_spans` - Syntax highlighting spans with byte ranges and colors
43/// * `theme` - The theme to use for background and default foreground colors
44///
45/// # Returns
46/// HTML string with inline styles
47pub fn render_styled_html(text: &str, highlight_spans: &[HighlightSpan], theme: &Theme) -> String {
48    let bg_color = color_to_css(theme.editor_bg, "#1e1e1e");
49    let fg_color = color_to_css(theme.editor_fg, "#d4d4d4");
50
51    // Build a map of byte offset to color for quick lookup
52    let mut color_map: Vec<Option<Color>> = vec![None; text.len()];
53    for span in highlight_spans {
54        let start = span.range.start.min(text.len());
55        let end = span.range.end.min(text.len());
56        for slot in &mut color_map[start..end] {
57            *slot = Some(span.color);
58        }
59    }
60
61    // Build HTML with spans for colored regions
62    let mut html = String::new();
63    html.push_str(&format!(
64        "<pre style=\"background-color:{};color:{};font-family:'Fira Mono','Fira Code',Consolas,'Courier New',monospace;font-size:14px;padding:12px 16px;border-radius:6px;margin:0;white-space:pre;overflow-x:auto;\">",
65        bg_color, fg_color
66    ));
67
68    let mut current_color: Option<Color> = None;
69    let mut span_open = false;
70    let mut byte_offset = 0;
71
72    for ch in text.chars() {
73        let char_byte_len = ch.len_utf8();
74
75        // Get color for this character
76        let char_color = if byte_offset < color_map.len() {
77            color_map[byte_offset]
78        } else {
79            None
80        };
81
82        // Check if we need to change the color span
83        if char_color != current_color {
84            // Close previous span if open
85            if span_open {
86                html.push_str("</span>");
87                span_open = false;
88            }
89
90            // Open new span if we have a color
91            if let Some(color) = char_color {
92                let css_color = color_to_css(color, &fg_color);
93                html.push_str(&format!("<span style=\"color:{};\">", css_color));
94                span_open = true;
95            }
96
97            current_color = char_color;
98        }
99
100        // Escape HTML special characters and add the character
101        match ch {
102            '<' => html.push_str("&lt;"),
103            '>' => html.push_str("&gt;"),
104            '&' => html.push_str("&amp;"),
105            '"' => html.push_str("&quot;"),
106            '\'' => html.push_str("&#39;"),
107            _ => html.push(ch),
108        }
109
110        byte_offset += char_byte_len;
111    }
112
113    // Close any remaining span
114    if span_open {
115        html.push_str("</span>");
116    }
117
118    html.push_str("</pre>");
119    html
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::view::theme;
126
127    #[test]
128    fn test_render_html_simple() {
129        let text = "Hello, World!";
130        let spans = vec![];
131        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
132
133        let html = render_styled_html(text, &spans, &theme);
134
135        assert!(html.starts_with("<pre style=\""));
136        assert!(html.ends_with("</pre>"));
137        assert!(html.contains("Hello, World!"));
138    }
139
140    #[test]
141    fn test_render_html_escapes_special_chars() {
142        let text = "<script>&test</script>";
143        let spans = vec![];
144        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
145
146        let html = render_styled_html(text, &spans, &theme);
147
148        assert!(html.contains("&lt;script&gt;"));
149        assert!(html.contains("&amp;test"));
150        assert!(!html.contains("<script>"));
151    }
152
153    #[test]
154    fn test_render_html_with_highlights() {
155        use std::ops::Range;
156
157        let text = "fn main()";
158        let spans = vec![HighlightSpan {
159            range: Range { start: 0, end: 2 },
160            color: Color::Blue,
161            category: None,
162        }];
163        let theme = Theme::load_builtin(theme::THEME_DARK).unwrap();
164
165        let html = render_styled_html(text, &spans, &theme);
166
167        // Should contain a span with blue color for "fn"
168        assert!(html.contains("<span style=\"color:#2472c8;\">fn</span>"));
169        assert!(html.contains("main()"));
170    }
171
172    #[test]
173    fn test_color_to_css() {
174        assert_eq!(color_to_css(Color::Black, "#fff"), "#000000");
175        assert_eq!(color_to_css(Color::Rgb(255, 128, 0), "#fff"), "#ff8000");
176        assert_eq!(color_to_css(Color::Reset, "#default"), "#default");
177    }
178}