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::fmt::Write as FmtWrite;
27
28/// RGB color.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Color {
31    pub r: u8,
32    pub g: u8,
33    pub b: u8,
34}
35
36impl Color {
37    pub const fn new(r: u8, g: u8, b: u8) -> Self {
38        Self { r, g, b }
39    }
40
41    /// Parse a hex color string like "#ff0000" or "ff0000".
42    pub fn from_hex(s: &str) -> Option<Self> {
43        let s = s.strip_prefix('#').unwrap_or(s);
44        if s.len() != 6 {
45            return None;
46        }
47        let r = u8::from_str_radix(&s[0..2], 16).ok()?;
48        let g = u8::from_str_radix(&s[2..4], 16).ok()?;
49        let b = u8::from_str_radix(&s[4..6], 16).ok()?;
50        Some(Self { r, g, b })
51    }
52
53    /// Convert to hex string with # prefix.
54    pub fn to_hex(&self) -> String {
55        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
56    }
57
58    /// Lighten the color by a factor (0.0 to 1.0).
59    pub fn lighten(&self, factor: f32) -> Self {
60        let factor = factor.clamp(0.0, 1.0);
61        Self {
62            r: (self.r as f32 + (255.0 - self.r as f32) * factor).round() as u8,
63            g: (self.g as f32 + (255.0 - self.g as f32) * factor).round() as u8,
64            b: (self.b as f32 + (255.0 - self.b as f32) * factor).round() as u8,
65        }
66    }
67
68    /// Darken the color by a factor (0.0 to 1.0).
69    pub fn darken(&self, factor: f32) -> Self {
70        let factor = factor.clamp(0.0, 1.0);
71        Self {
72            r: (self.r as f32 * (1.0 - factor)).round() as u8,
73            g: (self.g as f32 * (1.0 - factor)).round() as u8,
74            b: (self.b as f32 * (1.0 - factor)).round() as u8,
75        }
76    }
77}
78
79/// Text style modifiers.
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub struct Modifiers {
82    pub bold: bool,
83    pub italic: bool,
84    pub underline: bool,
85    pub strikethrough: bool,
86}
87
88/// A complete style for a highlight category.
89#[derive(Debug, Clone, Default)]
90pub struct Style {
91    pub fg: Option<Color>,
92    pub bg: Option<Color>,
93    pub modifiers: Modifiers,
94}
95
96impl Style {
97    pub const fn new() -> Self {
98        Self {
99            fg: None,
100            bg: None,
101            modifiers: Modifiers {
102                bold: false,
103                italic: false,
104                underline: false,
105                strikethrough: false,
106            },
107        }
108    }
109
110    pub const fn fg(mut self, color: Color) -> Self {
111        self.fg = Some(color);
112        self
113    }
114
115    pub const fn bold(mut self) -> Self {
116        self.modifiers.bold = true;
117        self
118    }
119
120    pub const fn italic(mut self) -> Self {
121        self.modifiers.italic = true;
122        self
123    }
124
125    pub const fn underline(mut self) -> Self {
126        self.modifiers.underline = true;
127        self
128    }
129
130    pub const fn strikethrough(mut self) -> Self {
131        self.modifiers.strikethrough = true;
132        self
133    }
134
135    /// Check if this style has any effect.
136    pub fn is_empty(&self) -> bool {
137        self.fg.is_none()
138            && self.bg.is_none()
139            && !self.modifiers.bold
140            && !self.modifiers.italic
141            && !self.modifiers.underline
142            && !self.modifiers.strikethrough
143    }
144}
145
146/// A complete syntax highlighting theme.
147#[derive(Debug, Clone)]
148pub struct Theme {
149    /// Theme name for display.
150    pub name: String,
151    /// Whether this is a dark or light theme.
152    pub is_dark: bool,
153    /// URL to the original theme source (for attribution).
154    pub source_url: Option<String>,
155    /// Background color for the code block.
156    pub background: Option<Color>,
157    /// Foreground (default text) color.
158    pub foreground: Option<Color>,
159    /// Styles for each highlight category, indexed by HIGHLIGHT_NAMES.
160    pub styles: [Style; crate::highlights::COUNT],
161}
162
163impl Default for Theme {
164    fn default() -> Self {
165        Self {
166            name: String::new(),
167            is_dark: true,
168            source_url: None,
169            background: None,
170            foreground: None,
171            styles: std::array::from_fn(|_| Style::new()),
172        }
173    }
174}
175
176impl Theme {
177    /// Create an empty theme.
178    pub fn new(name: impl Into<String>) -> Self {
179        Self {
180            name: name.into(),
181            ..Default::default()
182        }
183    }
184
185    /// Get the style for a highlight index.
186    pub fn style(&self, index: usize) -> Option<&Style> {
187        self.styles.get(index)
188    }
189
190    /// Set the style for a highlight index.
191    pub fn set_style(&mut self, index: usize, style: Style) {
192        if index < self.styles.len() {
193            self.styles[index] = style;
194        }
195    }
196
197    /// Parse a theme from Helix-style TOML.
198    ///
199    /// This method is only available when the `toml` feature is enabled.
200    #[cfg(feature = "toml")]
201    pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
202        let value: toml::Value = toml_str
203            .parse()
204            .map_err(|e| ThemeError::Parse(format!("{e}")))?;
205        let table = value
206            .as_table()
207            .ok_or(ThemeError::Parse("Expected table".into()))?;
208
209        let mut theme = Theme::default();
210
211        // Extract metadata
212        if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
213            theme.name = name.to_string();
214        }
215        if let Some(variant) = table.get("variant").and_then(|v| v.as_str()) {
216            theme.is_dark = variant != "light";
217        }
218        if let Some(source) = table.get("source").and_then(|v| v.as_str()) {
219            theme.source_url = Some(source.to_string());
220        }
221
222        // Extract palette for color lookups
223        let palette: std::collections::HashMap<&str, Color> = table
224            .get("palette")
225            .and_then(|v| v.as_table())
226            .map(|t| {
227                t.iter()
228                    .filter_map(|(k, v)| {
229                        v.as_str()
230                            .and_then(Color::from_hex)
231                            .map(|c| (k.as_str(), c))
232                    })
233                    .collect()
234            })
235            .unwrap_or_default();
236
237        // Helper to resolve a color (either hex or palette reference)
238        let resolve_color =
239            |s: &str| -> Option<Color> { Color::from_hex(s).or_else(|| palette.get(s).copied()) };
240
241        // Extract ui.background and ui.foreground
242        if let Some(bg) = table.get("ui.background")
243            && let Some(bg_table) = bg.as_table()
244            && let Some(bg_str) = bg_table.get("bg").and_then(|v| v.as_str())
245        {
246            theme.background = resolve_color(bg_str);
247        }
248        // Also check for simple "background" key
249        if let Some(bg_str) = table.get("background").and_then(|v| v.as_str()) {
250            theme.background = resolve_color(bg_str);
251        }
252
253        if let Some(fg) = table.get("ui.foreground") {
254            if let Some(fg_str) = fg.as_str() {
255                theme.foreground = resolve_color(fg_str);
256            } else if let Some(fg_table) = fg.as_table()
257                && let Some(fg_str) = fg_table.get("fg").and_then(|v| v.as_str())
258            {
259                theme.foreground = resolve_color(fg_str);
260            }
261        }
262        // Also check for simple "foreground" key
263        if let Some(fg_str) = table.get("foreground").and_then(|v| v.as_str()) {
264            theme.foreground = resolve_color(fg_str);
265        }
266
267        // Build mapping from Helix names to our indices using highlights module
268        use crate::highlights::HIGHLIGHTS;
269
270        // Parse each highlight rule - try main name and aliases
271        for (i, def) in HIGHLIGHTS.iter().enumerate() {
272            // Try main name
273            if let Some(rule) = table.get(def.name) {
274                let style = parse_style_value(rule, &resolve_color)?;
275                theme.styles[i] = style;
276                continue;
277            }
278
279            // Try aliases
280            for alias in def.aliases {
281                if let Some(rule) = table.get(*alias) {
282                    let style = parse_style_value(rule, &resolve_color)?;
283                    theme.styles[i] = style;
284                    break;
285                }
286            }
287        }
288
289        // Also handle some common Helix-specific mappings that aren't direct matches
290        let extra_mappings: &[(&str, &str)] = &[
291            ("keyword.control", "keyword"),
292            ("keyword.storage", "keyword"),
293            ("comment.line", "comment"),
294            ("comment.block", "comment"),
295            ("function.macro", "macro"),
296        ];
297
298        for (helix_name, our_name) in extra_mappings {
299            if let Some(rule) = table.get(*helix_name) {
300                // Find our index
301                if let Some(i) = HIGHLIGHTS.iter().position(|h| h.name == *our_name) {
302                    // Only apply if we don't already have a style
303                    if theme.styles[i].is_empty() {
304                        let style = parse_style_value(rule, &resolve_color)?;
305                        theme.styles[i] = style;
306                    }
307                }
308            }
309        }
310
311        Ok(theme)
312    }
313
314    /// Generate CSS for this theme.
315    ///
316    /// Uses CSS nesting for compact output. The selector_prefix is prepended
317    /// to scope the rules (e.g., `[data-theme="mocha"]`).
318    pub fn to_css(&self, selector_prefix: &str) -> String {
319        use crate::highlights::HIGHLIGHTS;
320        use std::collections::HashMap;
321
322        let mut css = String::new();
323
324        writeln!(css, "{selector_prefix} {{").unwrap();
325
326        // Background and foreground
327        if let Some(bg) = &self.background {
328            writeln!(css, "  background: {};", bg.to_hex()).unwrap();
329            writeln!(css, "  --bg: {};", bg.to_hex()).unwrap();
330            // Surface is background adjusted toward opposite (lighter for dark, darker for light)
331            let surface = if self.is_dark {
332                bg.lighten(0.08)
333            } else {
334                bg.darken(0.05)
335            };
336            writeln!(css, "  --surface: {};", surface.to_hex()).unwrap();
337        }
338        if let Some(fg) = &self.foreground {
339            writeln!(css, "  color: {};", fg.to_hex()).unwrap();
340            writeln!(css, "  --fg: {};", fg.to_hex()).unwrap();
341        }
342
343        // Find indices for accent and muted colors
344        let function_idx = HIGHLIGHTS.iter().position(|h| h.name == "function");
345        let keyword_idx = HIGHLIGHTS.iter().position(|h| h.name == "keyword");
346        let comment_idx = HIGHLIGHTS.iter().position(|h| h.name == "comment");
347
348        // --accent: use function color, fallback to keyword, fallback to foreground
349        let accent_color = function_idx
350            .and_then(|i| self.styles[i].fg.as_ref())
351            .or_else(|| keyword_idx.and_then(|i| self.styles[i].fg.as_ref()))
352            .or(self.foreground.as_ref());
353        if let Some(accent) = accent_color {
354            writeln!(css, "  --accent: {};", accent.to_hex()).unwrap();
355        }
356
357        // --muted: use comment color, fallback to faded foreground
358        let muted_color = comment_idx.and_then(|i| self.styles[i].fg.as_ref());
359        if let Some(muted) = muted_color {
360            writeln!(css, "  --muted: {};", muted.to_hex()).unwrap();
361        } else if let Some(fg) = &self.foreground {
362            let muted = if self.is_dark {
363                fg.darken(0.3)
364            } else {
365                fg.lighten(0.3)
366            };
367            writeln!(css, "  --muted: {};", muted.to_hex()).unwrap();
368        }
369
370        // Build a map from tag -> style for parent lookups
371        let mut tag_to_style: HashMap<&str, &Style> = HashMap::new();
372        for (i, def) in HIGHLIGHTS.iter().enumerate() {
373            if !def.tag.is_empty() && !self.styles[i].is_empty() {
374                tag_to_style.insert(def.tag, &self.styles[i]);
375            }
376        }
377
378        // Generate rules for each highlight category
379        for (i, def) in HIGHLIGHTS.iter().enumerate() {
380            if def.tag.is_empty() {
381                continue; // Skip categories like "none" that have no tag
382            }
383
384            // Use own style, or fall back to parent style
385            let style = if !self.styles[i].is_empty() {
386                &self.styles[i]
387            } else if !def.parent_tag.is_empty() {
388                // Look up parent style
389                tag_to_style
390                    .get(def.parent_tag)
391                    .copied()
392                    .unwrap_or(&self.styles[i])
393            } else {
394                continue; // No style and no parent
395            };
396
397            if style.is_empty() {
398                continue;
399            }
400
401            write!(css, "  a-{} {{", def.tag).unwrap();
402
403            if let Some(fg) = &style.fg {
404                write!(css, " color: {};", fg.to_hex()).unwrap();
405            }
406            if let Some(bg) = &style.bg {
407                write!(css, " background: {};", bg.to_hex()).unwrap();
408            }
409
410            let mut decorations = Vec::new();
411            if style.modifiers.underline {
412                decorations.push("underline");
413            }
414            if style.modifiers.strikethrough {
415                decorations.push("line-through");
416            }
417            if !decorations.is_empty() {
418                write!(css, " text-decoration: {};", decorations.join(" ")).unwrap();
419            }
420
421            if style.modifiers.bold {
422                write!(css, " font-weight: bold;").unwrap();
423            }
424            if style.modifiers.italic {
425                write!(css, " font-style: italic;").unwrap();
426            }
427
428            writeln!(css, " }}").unwrap();
429        }
430
431        writeln!(css, "}}").unwrap();
432
433        css
434    }
435
436    /// Generate ANSI escape sequence for a style.
437    pub fn ansi_style(&self, index: usize) -> String {
438        let Some(style) = self.styles.get(index) else {
439            return String::new();
440        };
441
442        if style.is_empty() {
443            return String::new();
444        }
445
446        let mut codes = Vec::new();
447
448        if style.modifiers.bold {
449            codes.push("1".to_string());
450        }
451        if style.modifiers.italic {
452            codes.push("3".to_string());
453        }
454        if style.modifiers.underline {
455            codes.push("4".to_string());
456        }
457        if style.modifiers.strikethrough {
458            codes.push("9".to_string());
459        }
460
461        if let Some(fg) = &style.fg {
462            codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
463        }
464        if let Some(bg) = &style.bg {
465            codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
466        }
467
468        if codes.is_empty() {
469            String::new()
470        } else {
471            format!("\x1b[{}m", codes.join(";"))
472        }
473    }
474
475    /// Generate ANSI escape sequence for a style, inheriting base foreground/background if not set.
476    ///
477    /// When rendering with a base background color, we want individual styles to
478    /// inherit that background unless they explicitly override it. Similarly, if a style
479    /// doesn't define a foreground, use the base foreground. This avoids the background
480    /// disappearing when switching between styled and unstyled text, and ensures colors
481    /// are complete.
482    pub fn ansi_style_with_base_bg(&self, index: usize) -> String {
483        let Some(style) = self.styles.get(index) else {
484            return String::new();
485        };
486
487        if style.is_empty() {
488            return String::new();
489        }
490
491        let mut codes = Vec::new();
492
493        if style.modifiers.bold {
494            codes.push("1".to_string());
495        }
496        if style.modifiers.italic {
497            codes.push("3".to_string());
498        }
499        if style.modifiers.underline {
500            codes.push("4".to_string());
501        }
502        if style.modifiers.strikethrough {
503            codes.push("9".to_string());
504        }
505
506        // Use style's foreground if defined, otherwise fall back to theme foreground
507        if let Some(fg) = &style.fg {
508            codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
509        } else if let Some(fg) = &self.foreground {
510            codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
511        }
512
513        // Use style's background if defined, otherwise fall back to theme background
514        if let Some(bg) = &style.bg {
515            codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
516        } else if let Some(bg) = &self.background {
517            codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
518        }
519
520        if codes.is_empty() {
521            String::new()
522        } else {
523            format!("\x1b[{}m", codes.join(";"))
524        }
525    }
526
527    /// Generate ANSI escape sequence for the theme's base foreground/background.
528    ///
529    /// This uses `background` and `foreground` and does not include any
530    /// per-highlight styling or text modifiers.
531    pub fn ansi_base_style(&self) -> String {
532        let mut codes = Vec::new();
533
534        if let Some(fg) = &self.foreground {
535            codes.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
536        }
537        if let Some(bg) = &self.background {
538            codes.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
539        }
540
541        if codes.is_empty() {
542            String::new()
543        } else {
544            format!("\x1b[{}m", codes.join(";"))
545        }
546    }
547
548    /// Generate ANSI escape sequence for border characters (half-blocks).
549    ///
550    /// Returns fg color only (no bg), slightly darker/lighter than theme background.
551    /// The transparent half of the half-block char shows the terminal background.
552    pub fn ansi_border_style(&self) -> String {
553        let Some(bg) = &self.background else {
554            return String::new();
555        };
556
557        // Border color: darker for dark themes, lighter for light themes
558        let border = if self.is_dark {
559            Color::new(
560                bg.r.saturating_add(30),
561                bg.g.saturating_add(30),
562                bg.b.saturating_add(30),
563            )
564        } else {
565            Color::new(
566                bg.r.saturating_sub(30),
567                bg.g.saturating_sub(30),
568                bg.b.saturating_sub(30),
569            )
570        };
571
572        format!("\x1b[38;2;{};{};{}m", border.r, border.g, border.b)
573    }
574
575    /// ANSI reset sequence.
576    pub const ANSI_RESET: &'static str = "\x1b[0m";
577}
578
579/// Parse a style value from TOML (either string or table).
580#[cfg(feature = "toml")]
581fn parse_style_value(
582    value: &toml::Value,
583    resolve_color: &impl Fn(&str) -> Option<Color>,
584) -> Result<Style, ThemeError> {
585    let mut style = Style::new();
586
587    match value {
588        // Simple string: just foreground color
589        toml::Value::String(s) => {
590            style.fg = resolve_color(s);
591        }
592        // Table with fg, bg, modifiers
593        toml::Value::Table(t) => {
594            if let Some(fg) = t.get("fg").and_then(|v| v.as_str()) {
595                style.fg = resolve_color(fg);
596            }
597            if let Some(bg) = t.get("bg").and_then(|v| v.as_str()) {
598                style.bg = resolve_color(bg);
599            }
600            if let Some(mods) = t.get("modifiers").and_then(|v| v.as_array()) {
601                for m in mods {
602                    if let Some(s) = m.as_str() {
603                        match s {
604                            "bold" => style.modifiers.bold = true,
605                            "italic" => style.modifiers.italic = true,
606                            "underlined" | "underline" => style.modifiers.underline = true,
607                            "crossed_out" | "strikethrough" => style.modifiers.strikethrough = true,
608                            _ => {}
609                        }
610                    }
611                }
612            }
613        }
614        _ => {}
615    }
616
617    Ok(style)
618}
619
620/// Error type for theme parsing.
621#[derive(Debug)]
622pub enum ThemeError {
623    Parse(String),
624}
625
626impl std::fmt::Display for ThemeError {
627    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
628        match self {
629            ThemeError::Parse(msg) => write!(f, "Theme parse error: {msg}"),
630        }
631    }
632}
633
634impl std::error::Error for ThemeError {}
635
636// ============================================================================
637// Built-in themes - generated from TOML files at build time
638// ============================================================================
639
640/// Built-in themes module.
641///
642/// These themes are generated from TOML files at build time by `cargo xtask gen`.
643/// No runtime TOML parsing is required.
644pub mod builtin {
645    include!("builtin_generated.rs");
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn test_color_from_hex() {
654        assert_eq!(Color::from_hex("#ff0000"), Some(Color::new(255, 0, 0)));
655        assert_eq!(Color::from_hex("00ff00"), Some(Color::new(0, 255, 0)));
656        assert_eq!(Color::from_hex("#invalid"), None);
657    }
658
659    #[test]
660    fn test_color_to_hex() {
661        assert_eq!(Color::new(255, 0, 0).to_hex(), "#ff0000");
662        assert_eq!(Color::new(0, 255, 0).to_hex(), "#00ff00");
663    }
664}