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    /// Background color for the code block.
135    pub background: Option<Color>,
136    /// Foreground (default text) color.
137    pub foreground: Option<Color>,
138    /// Styles for each highlight category, indexed by HIGHLIGHT_NAMES.
139    styles: [Style; crate::highlights::COUNT],
140}
141
142impl Default for Theme {
143    fn default() -> Self {
144        Self {
145            name: String::new(),
146            is_dark: true,
147            background: None,
148            foreground: None,
149            styles: std::array::from_fn(|_| Style::new()),
150        }
151    }
152}
153
154impl Theme {
155    /// Create an empty theme.
156    pub fn new(name: impl Into<String>) -> Self {
157        Self {
158            name: name.into(),
159            ..Default::default()
160        }
161    }
162
163    /// Get the style for a highlight index.
164    pub fn style(&self, index: usize) -> Option<&Style> {
165        self.styles.get(index)
166    }
167
168    /// Set the style for a highlight index.
169    pub fn set_style(&mut self, index: usize, style: Style) {
170        if index < self.styles.len() {
171            self.styles[index] = style;
172        }
173    }
174
175    /// Parse a theme from Helix-style TOML.
176    pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
177        let value: toml::Value = toml_str
178            .parse()
179            .map_err(|e| ThemeError::Parse(format!("{e}")))?;
180        let table = value
181            .as_table()
182            .ok_or(ThemeError::Parse("Expected table".into()))?;
183
184        let mut theme = Theme::default();
185
186        // Extract metadata
187        if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
188            theme.name = name.to_string();
189        }
190        if let Some(variant) = table.get("variant").and_then(|v| v.as_str()) {
191            theme.is_dark = variant != "light";
192        }
193
194        // Extract palette for color lookups
195        let palette: HashMap<&str, Color> = table
196            .get("palette")
197            .and_then(|v| v.as_table())
198            .map(|t| {
199                t.iter()
200                    .filter_map(|(k, v)| {
201                        v.as_str()
202                            .and_then(Color::from_hex)
203                            .map(|c| (k.as_str(), c))
204                    })
205                    .collect()
206            })
207            .unwrap_or_default();
208
209        // Helper to resolve a color (either hex or palette reference)
210        let resolve_color =
211            |s: &str| -> Option<Color> { Color::from_hex(s).or_else(|| palette.get(s).copied()) };
212
213        // Extract ui.background and ui.foreground
214        if let Some(bg) = table.get("ui.background")
215            && let Some(bg_table) = bg.as_table()
216            && let Some(bg_str) = bg_table.get("bg").and_then(|v| v.as_str())
217        {
218            theme.background = resolve_color(bg_str);
219        }
220        // Also check for simple "background" key
221        if let Some(bg_str) = table.get("background").and_then(|v| v.as_str()) {
222            theme.background = resolve_color(bg_str);
223        }
224
225        if let Some(fg) = table.get("ui.foreground") {
226            if let Some(fg_str) = fg.as_str() {
227                theme.foreground = resolve_color(fg_str);
228            } else if let Some(fg_table) = fg.as_table()
229                && let Some(fg_str) = fg_table.get("fg").and_then(|v| v.as_str())
230            {
231                theme.foreground = resolve_color(fg_str);
232            }
233        }
234        // Also check for simple "foreground" key
235        if let Some(fg_str) = table.get("foreground").and_then(|v| v.as_str()) {
236            theme.foreground = resolve_color(fg_str);
237        }
238
239        // Build mapping from Helix names to our indices using highlights module
240        use crate::highlights::HIGHLIGHTS;
241
242        // Parse each highlight rule - try main name and aliases
243        for (i, def) in HIGHLIGHTS.iter().enumerate() {
244            // Try main name
245            if let Some(rule) = table.get(def.name) {
246                let style = parse_style_value(rule, &resolve_color)?;
247                theme.styles[i] = style;
248                continue;
249            }
250
251            // Try aliases
252            for alias in def.aliases {
253                if let Some(rule) = table.get(*alias) {
254                    let style = parse_style_value(rule, &resolve_color)?;
255                    theme.styles[i] = style;
256                    break;
257                }
258            }
259        }
260
261        // Also handle some common Helix-specific mappings that aren't direct matches
262        let extra_mappings: &[(&str, &str)] = &[
263            ("keyword.control", "keyword"),
264            ("keyword.storage", "keyword"),
265            ("comment.line", "comment"),
266            ("comment.block", "comment"),
267            ("function.macro", "macro"),
268        ];
269
270        for (helix_name, our_name) in extra_mappings {
271            if let Some(rule) = table.get(*helix_name) {
272                // Find our index
273                if let Some(i) = HIGHLIGHTS.iter().position(|h| h.name == *our_name) {
274                    // Only apply if we don't already have a style
275                    if theme.styles[i].is_empty() {
276                        let style = parse_style_value(rule, &resolve_color)?;
277                        theme.styles[i] = style;
278                    }
279                }
280            }
281        }
282
283        Ok(theme)
284    }
285
286    /// Generate CSS for this theme.
287    ///
288    /// Uses CSS nesting for compact output. The selector_prefix is prepended
289    /// to scope the rules (e.g., `[data-theme="mocha"]`).
290    pub fn to_css(&self, selector_prefix: &str) -> String {
291        use crate::highlights::HIGHLIGHTS;
292        use std::collections::HashMap;
293
294        let mut css = String::new();
295
296        writeln!(css, "{selector_prefix} {{").unwrap();
297
298        // Background and foreground
299        if let Some(bg) = &self.background {
300            writeln!(css, "  background: {};", bg.to_hex()).unwrap();
301        }
302        if let Some(fg) = &self.foreground {
303            writeln!(css, "  color: {};", fg.to_hex()).unwrap();
304        }
305
306        // Build a map from tag -> style for parent lookups
307        let mut tag_to_style: HashMap<&str, &Style> = HashMap::new();
308        for (i, def) in HIGHLIGHTS.iter().enumerate() {
309            if !def.tag.is_empty() && !self.styles[i].is_empty() {
310                tag_to_style.insert(def.tag, &self.styles[i]);
311            }
312        }
313
314        // Generate rules for each highlight category
315        for (i, def) in HIGHLIGHTS.iter().enumerate() {
316            if def.tag.is_empty() {
317                continue; // Skip categories like "none" that have no tag
318            }
319
320            // Use own style, or fall back to parent style
321            let style = if !self.styles[i].is_empty() {
322                &self.styles[i]
323            } else if !def.parent_tag.is_empty() {
324                // Look up parent style
325                tag_to_style
326                    .get(def.parent_tag)
327                    .copied()
328                    .unwrap_or(&self.styles[i])
329            } else {
330                continue; // No style and no parent
331            };
332
333            if style.is_empty() {
334                continue;
335            }
336
337            write!(css, "  a-{} {{", def.tag).unwrap();
338
339            if let Some(fg) = &style.fg {
340                write!(css, " color: {};", fg.to_hex()).unwrap();
341            }
342            if let Some(bg) = &style.bg {
343                write!(css, " background: {};", bg.to_hex()).unwrap();
344            }
345
346            let mut decorations = Vec::new();
347            if style.modifiers.underline {
348                decorations.push("underline");
349            }
350            if style.modifiers.strikethrough {
351                decorations.push("line-through");
352            }
353            if !decorations.is_empty() {
354                write!(css, " text-decoration: {};", decorations.join(" ")).unwrap();
355            }
356
357            if style.modifiers.bold {
358                write!(css, " font-weight: bold;").unwrap();
359            }
360            if style.modifiers.italic {
361                write!(css, " font-style: italic;").unwrap();
362            }
363
364            writeln!(css, " }}").unwrap();
365        }
366
367        writeln!(css, "}}").unwrap();
368
369        css
370    }
371
372    /// Generate ANSI escape sequence for a style.
373    pub fn ansi_style(&self, index: usize) -> String {
374        let Some(style) = self.styles.get(index) else {
375            return String::new();
376        };
377
378        if style.is_empty() {
379            return String::new();
380        }
381
382        let mut codes = Vec::new();
383
384        if style.modifiers.bold {
385            codes.push("1".to_string());
386        }
387        if style.modifiers.italic {
388            codes.push("3".to_string());
389        }
390        if style.modifiers.underline {
391            codes.push("4".to_string());
392        }
393        if style.modifiers.strikethrough {
394            codes.push("9".to_string());
395        }
396
397        if let Some(fg) = &style.fg {
398            codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
399        }
400        if let Some(bg) = &style.bg {
401            codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
402        }
403
404        if codes.is_empty() {
405            String::new()
406        } else {
407            format!("\x1b[{}m", codes.join(";"))
408        }
409    }
410
411    /// ANSI reset sequence.
412    pub const ANSI_RESET: &'static str = "\x1b[0m";
413}
414
415/// Parse a style value from TOML (either string or table).
416fn parse_style_value(
417    value: &toml::Value,
418    resolve_color: &impl Fn(&str) -> Option<Color>,
419) -> Result<Style, ThemeError> {
420    let mut style = Style::new();
421
422    match value {
423        // Simple string: just foreground color
424        toml::Value::String(s) => {
425            style.fg = resolve_color(s);
426        }
427        // Table with fg, bg, modifiers
428        toml::Value::Table(t) => {
429            if let Some(fg) = t.get("fg").and_then(|v| v.as_str()) {
430                style.fg = resolve_color(fg);
431            }
432            if let Some(bg) = t.get("bg").and_then(|v| v.as_str()) {
433                style.bg = resolve_color(bg);
434            }
435            if let Some(mods) = t.get("modifiers").and_then(|v| v.as_array()) {
436                for m in mods {
437                    if let Some(s) = m.as_str() {
438                        match s {
439                            "bold" => style.modifiers.bold = true,
440                            "italic" => style.modifiers.italic = true,
441                            "underlined" | "underline" => style.modifiers.underline = true,
442                            "crossed_out" | "strikethrough" => style.modifiers.strikethrough = true,
443                            _ => {}
444                        }
445                    }
446                }
447            }
448        }
449        _ => {}
450    }
451
452    Ok(style)
453}
454
455/// Error type for theme parsing.
456#[derive(Debug)]
457pub enum ThemeError {
458    Parse(String),
459}
460
461impl std::fmt::Display for ThemeError {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        match self {
464            ThemeError::Parse(msg) => write!(f, "Theme parse error: {msg}"),
465        }
466    }
467}
468
469impl std::error::Error for ThemeError {}
470
471// ============================================================================
472// Built-in themes (include_str!'d from TOML files)
473// ============================================================================
474
475macro_rules! builtin_theme {
476    ($name:ident, $file:literal) => {
477        pub fn $name() -> &'static Theme {
478            use std::sync::OnceLock;
479            static THEME: OnceLock<Theme> = OnceLock::new();
480            THEME.get_or_init(|| {
481                Theme::from_toml(include_str!(concat!("../themes/", $file)))
482                    .expect(concat!("Failed to parse built-in theme: ", $file))
483            })
484        }
485    };
486}
487
488/// Built-in themes module.
489pub mod builtin {
490    use super::Theme;
491
492    builtin_theme!(catppuccin_mocha, "catppuccin-mocha.toml");
493    builtin_theme!(catppuccin_latte, "catppuccin-latte.toml");
494    builtin_theme!(catppuccin_frappe, "catppuccin-frappe.toml");
495    builtin_theme!(catppuccin_macchiato, "catppuccin-macchiato.toml");
496    builtin_theme!(dracula, "dracula.toml");
497    builtin_theme!(tokyo_night, "tokyo-night.toml");
498    builtin_theme!(nord, "nord.toml");
499    builtin_theme!(one_dark, "one-dark.toml");
500    builtin_theme!(github_dark, "github-dark.toml");
501    builtin_theme!(github_light, "github-light.toml");
502    builtin_theme!(gruvbox_dark, "gruvbox-dark.toml");
503    builtin_theme!(gruvbox_light, "gruvbox-light.toml");
504    builtin_theme!(monokai, "monokai.toml");
505    builtin_theme!(kanagawa_dragon, "kanagawa-dragon.toml");
506    builtin_theme!(rose_pine_moon, "rose-pine-moon.toml");
507    builtin_theme!(ayu_dark, "ayu-dark.toml");
508    builtin_theme!(ayu_light, "ayu-light.toml");
509    builtin_theme!(solarized_dark, "solarized-dark.toml");
510    builtin_theme!(solarized_light, "solarized-light.toml");
511    builtin_theme!(ef_melissa_dark, "ef-melissa-dark.toml");
512    builtin_theme!(melange_dark, "melange-dark.toml");
513    builtin_theme!(melange_light, "melange-light.toml");
514    builtin_theme!(light_owl, "light-owl.toml");
515    builtin_theme!(lucius_light, "lucius-light.toml");
516
517    /// Get all built-in themes.
518    pub fn all() -> Vec<&'static Theme> {
519        vec![
520            catppuccin_mocha(),
521            catppuccin_latte(),
522            catppuccin_frappe(),
523            catppuccin_macchiato(),
524            dracula(),
525            tokyo_night(),
526            nord(),
527            one_dark(),
528            github_dark(),
529            github_light(),
530            gruvbox_dark(),
531            gruvbox_light(),
532            monokai(),
533            kanagawa_dragon(),
534            rose_pine_moon(),
535            ayu_dark(),
536            ayu_light(),
537            solarized_dark(),
538            solarized_light(),
539            ef_melissa_dark(),
540            melange_dark(),
541            melange_light(),
542            light_owl(),
543            lucius_light(),
544        ]
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn test_color_from_hex() {
554        assert_eq!(Color::from_hex("#ff0000"), Some(Color::new(255, 0, 0)));
555        assert_eq!(Color::from_hex("00ff00"), Some(Color::new(0, 255, 0)));
556        assert_eq!(Color::from_hex("#invalid"), None);
557    }
558
559    #[test]
560    fn test_color_to_hex() {
561        assert_eq!(Color::new(255, 0, 0).to_hex(), "#ff0000");
562        assert_eq!(Color::new(0, 255, 0).to_hex(), "#00ff00");
563    }
564}