arborium_theme/
theme.rs

1//! Theme support for syntax highlighting.
2//!
3//! This module provides a unified theme system that can generate both CSS and ANSI output.
4//! Themes use the Helix editor format for compatibility and ease of use.
5//!
6//! # Theme Format
7//!
8//! Themes are TOML files with highlight rules and an optional color palette:
9//!
10//! ```toml
11//! # Simple foreground color
12//! "keyword" = "purple"
13//!
14//! # With modifiers
15//! "comment" = { fg = "gray", modifiers = ["italic"] }
16//!
17//! # Using palette reference
18//! "function" = { fg = "blue1" }
19//!
20//! [palette]
21//! purple = "#c678dd"
22//! gray = "#5c6370"
23//! blue1 = "#61afef"
24//! ```
25
26use std::collections::HashMap;
27use std::fmt::Write as FmtWrite;
28
29/// RGB color.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct Color {
32    pub r: u8,
33    pub g: u8,
34    pub b: u8,
35}
36
37impl Color {
38    pub const fn new(r: u8, g: u8, b: u8) -> Self {
39        Self { r, g, b }
40    }
41
42    /// Parse a hex color string like "#ff0000" or "ff0000".
43    pub fn from_hex(s: &str) -> Option<Self> {
44        let s = s.strip_prefix('#').unwrap_or(s);
45        if s.len() != 6 {
46            return None;
47        }
48        let r = u8::from_str_radix(&s[0..2], 16).ok()?;
49        let g = u8::from_str_radix(&s[2..4], 16).ok()?;
50        let b = u8::from_str_radix(&s[4..6], 16).ok()?;
51        Some(Self { r, g, b })
52    }
53
54    /// Convert to hex string with # prefix.
55    pub fn to_hex(&self) -> String {
56        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
57    }
58}
59
60/// Text style modifiers.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
62pub struct Modifiers {
63    pub bold: bool,
64    pub italic: bool,
65    pub underline: bool,
66    pub strikethrough: bool,
67}
68
69/// A complete style for a highlight category.
70#[derive(Debug, Clone, Default)]
71pub struct Style {
72    pub fg: Option<Color>,
73    pub bg: Option<Color>,
74    pub modifiers: Modifiers,
75}
76
77impl Style {
78    pub const fn new() -> Self {
79        Self {
80            fg: None,
81            bg: None,
82            modifiers: Modifiers {
83                bold: false,
84                italic: false,
85                underline: false,
86                strikethrough: false,
87            },
88        }
89    }
90
91    pub const fn fg(mut self, color: Color) -> Self {
92        self.fg = Some(color);
93        self
94    }
95
96    pub const fn bold(mut self) -> Self {
97        self.modifiers.bold = true;
98        self
99    }
100
101    pub const fn italic(mut self) -> Self {
102        self.modifiers.italic = true;
103        self
104    }
105
106    pub const fn underline(mut self) -> Self {
107        self.modifiers.underline = true;
108        self
109    }
110
111    pub const fn strikethrough(mut self) -> Self {
112        self.modifiers.strikethrough = true;
113        self
114    }
115
116    /// Check if this style has any effect.
117    pub fn is_empty(&self) -> bool {
118        self.fg.is_none()
119            && self.bg.is_none()
120            && !self.modifiers.bold
121            && !self.modifiers.italic
122            && !self.modifiers.underline
123            && !self.modifiers.strikethrough
124    }
125}
126
127/// A complete syntax highlighting theme.
128#[derive(Debug, Clone)]
129pub struct Theme {
130    /// Theme name for display.
131    pub name: String,
132    /// Whether this is a dark or light theme.
133    pub is_dark: bool,
134    /// URL to the original theme source (for attribution).
135    pub source_url: Option<String>,
136    /// Background color for the code block.
137    pub background: Option<Color>,
138    /// Foreground (default text) color.
139    pub foreground: Option<Color>,
140    /// Styles for each highlight category, indexed by HIGHLIGHT_NAMES.
141    styles: [Style; crate::highlights::COUNT],
142}
143
144impl Default for Theme {
145    fn default() -> Self {
146        Self {
147            name: String::new(),
148            is_dark: true,
149            source_url: None,
150            background: None,
151            foreground: None,
152            styles: std::array::from_fn(|_| Style::new()),
153        }
154    }
155}
156
157impl Theme {
158    /// Create an empty theme.
159    pub fn new(name: impl Into<String>) -> Self {
160        Self {
161            name: name.into(),
162            ..Default::default()
163        }
164    }
165
166    /// Get the style for a highlight index.
167    pub fn style(&self, index: usize) -> Option<&Style> {
168        self.styles.get(index)
169    }
170
171    /// Set the style for a highlight index.
172    pub fn set_style(&mut self, index: usize, style: Style) {
173        if index < self.styles.len() {
174            self.styles[index] = style;
175        }
176    }
177
178    /// Parse a theme from Helix-style TOML.
179    pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
180        let value: toml::Value = toml_str
181            .parse()
182            .map_err(|e| ThemeError::Parse(format!("{e}")))?;
183        let table = value
184            .as_table()
185            .ok_or(ThemeError::Parse("Expected table".into()))?;
186
187        let mut theme = Theme::default();
188
189        // Extract metadata
190        if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
191            theme.name = name.to_string();
192        }
193        if let Some(variant) = table.get("variant").and_then(|v| v.as_str()) {
194            theme.is_dark = variant != "light";
195        }
196        if let Some(source) = table.get("source").and_then(|v| v.as_str()) {
197            theme.source_url = Some(source.to_string());
198        }
199
200        // Extract palette for color lookups
201        let palette: HashMap<&str, Color> = table
202            .get("palette")
203            .and_then(|v| v.as_table())
204            .map(|t| {
205                t.iter()
206                    .filter_map(|(k, v)| {
207                        v.as_str()
208                            .and_then(Color::from_hex)
209                            .map(|c| (k.as_str(), c))
210                    })
211                    .collect()
212            })
213            .unwrap_or_default();
214
215        // Helper to resolve a color (either hex or palette reference)
216        let resolve_color =
217            |s: &str| -> Option<Color> { Color::from_hex(s).or_else(|| palette.get(s).copied()) };
218
219        // Extract ui.background and ui.foreground
220        if let Some(bg) = table.get("ui.background")
221            && let Some(bg_table) = bg.as_table()
222            && let Some(bg_str) = bg_table.get("bg").and_then(|v| v.as_str())
223        {
224            theme.background = resolve_color(bg_str);
225        }
226        // Also check for simple "background" key
227        if let Some(bg_str) = table.get("background").and_then(|v| v.as_str()) {
228            theme.background = resolve_color(bg_str);
229        }
230
231        if let Some(fg) = table.get("ui.foreground") {
232            if let Some(fg_str) = fg.as_str() {
233                theme.foreground = resolve_color(fg_str);
234            } else if let Some(fg_table) = fg.as_table()
235                && let Some(fg_str) = fg_table.get("fg").and_then(|v| v.as_str())
236            {
237                theme.foreground = resolve_color(fg_str);
238            }
239        }
240        // Also check for simple "foreground" key
241        if let Some(fg_str) = table.get("foreground").and_then(|v| v.as_str()) {
242            theme.foreground = resolve_color(fg_str);
243        }
244
245        // Build mapping from Helix names to our indices using highlights module
246        use crate::highlights::HIGHLIGHTS;
247
248        // Parse each highlight rule - try main name and aliases
249        for (i, def) in HIGHLIGHTS.iter().enumerate() {
250            // Try main name
251            if let Some(rule) = table.get(def.name) {
252                let style = parse_style_value(rule, &resolve_color)?;
253                theme.styles[i] = style;
254                continue;
255            }
256
257            // Try aliases
258            for alias in def.aliases {
259                if let Some(rule) = table.get(*alias) {
260                    let style = parse_style_value(rule, &resolve_color)?;
261                    theme.styles[i] = style;
262                    break;
263                }
264            }
265        }
266
267        // Also handle some common Helix-specific mappings that aren't direct matches
268        let extra_mappings: &[(&str, &str)] = &[
269            ("keyword.control", "keyword"),
270            ("keyword.storage", "keyword"),
271            ("comment.line", "comment"),
272            ("comment.block", "comment"),
273            ("function.macro", "macro"),
274        ];
275
276        for (helix_name, our_name) in extra_mappings {
277            if let Some(rule) = table.get(*helix_name) {
278                // Find our index
279                if let Some(i) = HIGHLIGHTS.iter().position(|h| h.name == *our_name) {
280                    // Only apply if we don't already have a style
281                    if theme.styles[i].is_empty() {
282                        let style = parse_style_value(rule, &resolve_color)?;
283                        theme.styles[i] = style;
284                    }
285                }
286            }
287        }
288
289        Ok(theme)
290    }
291
292    /// Generate CSS for this theme.
293    ///
294    /// Uses CSS nesting for compact output. The selector_prefix is prepended
295    /// to scope the rules (e.g., `[data-theme="mocha"]`).
296    pub fn to_css(&self, selector_prefix: &str) -> String {
297        use crate::highlights::HIGHLIGHTS;
298        use std::collections::HashMap;
299
300        let mut css = String::new();
301
302        writeln!(css, "{selector_prefix} {{").unwrap();
303
304        // Background and foreground
305        if let Some(bg) = &self.background {
306            writeln!(css, "  background: {};", bg.to_hex()).unwrap();
307        }
308        if let Some(fg) = &self.foreground {
309            writeln!(css, "  color: {};", fg.to_hex()).unwrap();
310        }
311
312        // Build a map from tag -> style for parent lookups
313        let mut tag_to_style: HashMap<&str, &Style> = HashMap::new();
314        for (i, def) in HIGHLIGHTS.iter().enumerate() {
315            if !def.tag.is_empty() && !self.styles[i].is_empty() {
316                tag_to_style.insert(def.tag, &self.styles[i]);
317            }
318        }
319
320        // Generate rules for each highlight category
321        for (i, def) in HIGHLIGHTS.iter().enumerate() {
322            if def.tag.is_empty() {
323                continue; // Skip categories like "none" that have no tag
324            }
325
326            // Use own style, or fall back to parent style
327            let style = if !self.styles[i].is_empty() {
328                &self.styles[i]
329            } else if !def.parent_tag.is_empty() {
330                // Look up parent style
331                tag_to_style
332                    .get(def.parent_tag)
333                    .copied()
334                    .unwrap_or(&self.styles[i])
335            } else {
336                continue; // No style and no parent
337            };
338
339            if style.is_empty() {
340                continue;
341            }
342
343            write!(css, "  a-{} {{", def.tag).unwrap();
344
345            if let Some(fg) = &style.fg {
346                write!(css, " color: {};", fg.to_hex()).unwrap();
347            }
348            if let Some(bg) = &style.bg {
349                write!(css, " background: {};", bg.to_hex()).unwrap();
350            }
351
352            let mut decorations = Vec::new();
353            if style.modifiers.underline {
354                decorations.push("underline");
355            }
356            if style.modifiers.strikethrough {
357                decorations.push("line-through");
358            }
359            if !decorations.is_empty() {
360                write!(css, " text-decoration: {};", decorations.join(" ")).unwrap();
361            }
362
363            if style.modifiers.bold {
364                write!(css, " font-weight: bold;").unwrap();
365            }
366            if style.modifiers.italic {
367                write!(css, " font-style: italic;").unwrap();
368            }
369
370            writeln!(css, " }}").unwrap();
371        }
372
373        writeln!(css, "}}").unwrap();
374
375        css
376    }
377
378    /// Generate ANSI escape sequence for a style.
379    pub fn ansi_style(&self, index: usize) -> String {
380        let Some(style) = self.styles.get(index) else {
381            return String::new();
382        };
383
384        if style.is_empty() {
385            return String::new();
386        }
387
388        let mut codes = Vec::new();
389
390        if style.modifiers.bold {
391            codes.push("1".to_string());
392        }
393        if style.modifiers.italic {
394            codes.push("3".to_string());
395        }
396        if style.modifiers.underline {
397            codes.push("4".to_string());
398        }
399        if style.modifiers.strikethrough {
400            codes.push("9".to_string());
401        }
402
403        if let Some(fg) = &style.fg {
404            codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
405        }
406        if let Some(bg) = &style.bg {
407            codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
408        }
409
410        if codes.is_empty() {
411            String::new()
412        } else {
413            format!("\x1b[{}m", codes.join(";"))
414        }
415    }
416
417    /// ANSI reset sequence.
418    pub const ANSI_RESET: &'static str = "\x1b[0m";
419}
420
421/// Parse a style value from TOML (either string or table).
422fn parse_style_value(
423    value: &toml::Value,
424    resolve_color: &impl Fn(&str) -> Option<Color>,
425) -> Result<Style, ThemeError> {
426    let mut style = Style::new();
427
428    match value {
429        // Simple string: just foreground color
430        toml::Value::String(s) => {
431            style.fg = resolve_color(s);
432        }
433        // Table with fg, bg, modifiers
434        toml::Value::Table(t) => {
435            if let Some(fg) = t.get("fg").and_then(|v| v.as_str()) {
436                style.fg = resolve_color(fg);
437            }
438            if let Some(bg) = t.get("bg").and_then(|v| v.as_str()) {
439                style.bg = resolve_color(bg);
440            }
441            if let Some(mods) = t.get("modifiers").and_then(|v| v.as_array()) {
442                for m in mods {
443                    if let Some(s) = m.as_str() {
444                        match s {
445                            "bold" => style.modifiers.bold = true,
446                            "italic" => style.modifiers.italic = true,
447                            "underlined" | "underline" => style.modifiers.underline = true,
448                            "crossed_out" | "strikethrough" => style.modifiers.strikethrough = true,
449                            _ => {}
450                        }
451                    }
452                }
453            }
454        }
455        _ => {}
456    }
457
458    Ok(style)
459}
460
461/// Error type for theme parsing.
462#[derive(Debug)]
463pub enum ThemeError {
464    Parse(String),
465}
466
467impl std::fmt::Display for ThemeError {
468    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469        match self {
470            ThemeError::Parse(msg) => write!(f, "Theme parse error: {msg}"),
471        }
472    }
473}
474
475impl std::error::Error for ThemeError {}
476
477// ============================================================================
478// Built-in themes (include_str!'d from TOML files)
479// ============================================================================
480
481macro_rules! builtin_theme {
482    ($name:ident, $file:literal) => {
483        pub fn $name() -> &'static Theme {
484            use std::sync::OnceLock;
485            static THEME: OnceLock<Theme> = OnceLock::new();
486            THEME.get_or_init(|| {
487                Theme::from_toml(include_str!(concat!("../themes/", $file)))
488                    .expect(concat!("Failed to parse built-in theme: ", $file))
489            })
490        }
491    };
492}
493
494/// Built-in themes module.
495pub mod builtin {
496    use super::Theme;
497
498    builtin_theme!(catppuccin_mocha, "catppuccin-mocha.toml");
499    builtin_theme!(catppuccin_latte, "catppuccin-latte.toml");
500    builtin_theme!(catppuccin_frappe, "catppuccin-frappe.toml");
501    builtin_theme!(catppuccin_macchiato, "catppuccin-macchiato.toml");
502    builtin_theme!(dracula, "dracula.toml");
503    builtin_theme!(tokyo_night, "tokyo-night.toml");
504    builtin_theme!(nord, "nord.toml");
505    builtin_theme!(one_dark, "one-dark.toml");
506    builtin_theme!(github_dark, "github-dark.toml");
507    builtin_theme!(github_light, "github-light.toml");
508    builtin_theme!(gruvbox_dark, "gruvbox-dark.toml");
509    builtin_theme!(gruvbox_light, "gruvbox-light.toml");
510    builtin_theme!(monokai, "monokai.toml");
511    builtin_theme!(kanagawa_dragon, "kanagawa-dragon.toml");
512    builtin_theme!(rose_pine_moon, "rose-pine-moon.toml");
513    builtin_theme!(ayu_dark, "ayu-dark.toml");
514    builtin_theme!(ayu_light, "ayu-light.toml");
515    builtin_theme!(solarized_dark, "solarized-dark.toml");
516    builtin_theme!(solarized_light, "solarized-light.toml");
517    builtin_theme!(ef_melissa_dark, "ef-melissa-dark.toml");
518    builtin_theme!(melange_dark, "melange-dark.toml");
519    builtin_theme!(melange_light, "melange-light.toml");
520    builtin_theme!(light_owl, "light-owl.toml");
521    builtin_theme!(lucius_light, "lucius-light.toml");
522    builtin_theme!(rustdoc_light, "rustdoc-light.toml");
523    builtin_theme!(rustdoc_dark, "rustdoc-dark.toml");
524    builtin_theme!(rustdoc_ayu, "rustdoc-ayu.toml");
525    builtin_theme!(dayfox, "dayfox.toml");
526    builtin_theme!(alabaster, "alabaster.toml");
527    builtin_theme!(cobalt2, "cobalt2.toml");
528    builtin_theme!(zenburn, "zenburn.toml");
529    builtin_theme!(desert256, "desert256.toml");
530
531    /// Get all built-in themes.
532    pub fn all() -> Vec<&'static Theme> {
533        vec![
534            catppuccin_mocha(),
535            catppuccin_latte(),
536            catppuccin_frappe(),
537            catppuccin_macchiato(),
538            dracula(),
539            tokyo_night(),
540            nord(),
541            one_dark(),
542            github_dark(),
543            github_light(),
544            gruvbox_dark(),
545            gruvbox_light(),
546            monokai(),
547            kanagawa_dragon(),
548            rose_pine_moon(),
549            ayu_dark(),
550            ayu_light(),
551            solarized_dark(),
552            solarized_light(),
553            ef_melissa_dark(),
554            melange_dark(),
555            melange_light(),
556            light_owl(),
557            lucius_light(),
558            rustdoc_light(),
559            rustdoc_dark(),
560            rustdoc_ayu(),
561            dayfox(),
562            alabaster(),
563            cobalt2(),
564            zenburn(),
565            desert256(),
566        ]
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn test_color_from_hex() {
576        assert_eq!(Color::from_hex("#ff0000"), Some(Color::new(255, 0, 0)));
577        assert_eq!(Color::from_hex("00ff00"), Some(Color::new(0, 255, 0)));
578        assert_eq!(Color::from_hex("#invalid"), None);
579    }
580
581    #[test]
582    fn test_color_to_hex() {
583        assert_eq!(Color::new(255, 0, 0).to_hex(), "#ff0000");
584        assert_eq!(Color::new(0, 255, 0).to_hex(), "#00ff00");
585    }
586}