Skip to main content

tui_syntax/
theme.rs

1//! Theme definitions and TOML parsing.
2//!
3//! Themes define how syntax elements are styled. The format is compatible with
4//! [Helix editor themes](https://docs.helix-editor.com/themes.html).
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use ratatui::style::{Color, Modifier, Style as RatatuiStyle};
10use serde::Deserialize;
11
12/// Error loading or parsing a theme.
13#[derive(Debug)]
14pub enum ThemeError {
15    /// IO error reading theme file
16    Io(std::io::Error),
17    /// TOML parsing error
18    Parse(toml::de::Error),
19    /// Invalid color format
20    InvalidColor(String),
21}
22
23impl std::fmt::Display for ThemeError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            ThemeError::Io(e) => write!(f, "IO error: {}", e),
27            ThemeError::Parse(e) => write!(f, "Parse error: {}", e),
28            ThemeError::InvalidColor(c) => write!(f, "Invalid color: {}", c),
29        }
30    }
31}
32
33impl std::error::Error for ThemeError {}
34
35impl From<std::io::Error> for ThemeError {
36    fn from(e: std::io::Error) -> Self {
37        ThemeError::Io(e)
38    }
39}
40
41impl From<toml::de::Error> for ThemeError {
42    fn from(e: toml::de::Error) -> Self {
43        ThemeError::Parse(e)
44    }
45}
46
47/// Style modifiers (bold, italic, etc.)
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum StyleModifier {
51    Bold,
52    Dim,
53    Italic,
54    Underlined,
55    SlowBlink,
56    RapidBlink,
57    Reversed,
58    Hidden,
59    CrossedOut,
60}
61
62impl StyleModifier {
63    fn to_ratatui_modifier(self) -> Modifier {
64        match self {
65            StyleModifier::Bold => Modifier::BOLD,
66            StyleModifier::Dim => Modifier::DIM,
67            StyleModifier::Italic => Modifier::ITALIC,
68            StyleModifier::Underlined => Modifier::UNDERLINED,
69            StyleModifier::SlowBlink => Modifier::SLOW_BLINK,
70            StyleModifier::RapidBlink => Modifier::RAPID_BLINK,
71            StyleModifier::Reversed => Modifier::REVERSED,
72            StyleModifier::Hidden => Modifier::HIDDEN,
73            StyleModifier::CrossedOut => Modifier::CROSSED_OUT,
74        }
75    }
76}
77
78/// A style definition for a syntax element.
79#[derive(Debug, Clone, Default, Deserialize)]
80#[serde(default)]
81pub struct Style {
82    /// Foreground color (name from palette or hex)
83    pub fg: Option<String>,
84    /// Background color (name from palette or hex)
85    pub bg: Option<String>,
86    /// Style modifiers
87    #[serde(default)]
88    pub modifiers: Vec<StyleModifier>,
89}
90
91impl Style {
92    /// Convert to ratatui Style using the given color palette.
93    pub fn to_ratatui_style(&self, palette: &HashMap<String, String>) -> RatatuiStyle {
94        let mut style = RatatuiStyle::default();
95
96        if let Some(ref fg) = self.fg {
97            if let Some(color) = resolve_color(fg, palette) {
98                style = style.fg(color);
99            }
100        }
101
102        if let Some(ref bg) = self.bg {
103            if let Some(color) = resolve_color(bg, palette) {
104                style = style.bg(color);
105            }
106        }
107
108        for modifier in &self.modifiers {
109            style = style.add_modifier(modifier.to_ratatui_modifier());
110        }
111
112        style
113    }
114}
115
116/// Resolve a color string to a ratatui Color.
117///
118/// The color can be:
119/// - A palette name (looked up in the palette)
120/// - A hex color (#RRGGBB or #RGB)
121/// - A named color (red, green, blue, etc.)
122fn resolve_color(color: &str, palette: &HashMap<String, String>) -> Option<Color> {
123    // First check if it's a palette reference
124    if let Some(resolved) = palette.get(color) {
125        return parse_color(resolved);
126    }
127
128    // Otherwise try to parse directly
129    parse_color(color)
130}
131
132/// Parse a color string to a ratatui Color.
133fn parse_color(color: &str) -> Option<Color> {
134    let color = color.trim();
135
136    // Hex color
137    if let Some(hex) = color.strip_prefix('#') {
138        return match hex.len() {
139            6 => {
140                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
141                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
142                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
143                Some(Color::Rgb(r, g, b))
144            }
145            3 => {
146                let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
147                let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
148                let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
149                Some(Color::Rgb(r, g, b))
150            }
151            _ => None,
152        };
153    }
154
155    // Named colors
156    match color.to_lowercase().as_str() {
157        "black" => Some(Color::Black),
158        "red" => Some(Color::Red),
159        "green" => Some(Color::Green),
160        "yellow" => Some(Color::Yellow),
161        "blue" => Some(Color::Blue),
162        "magenta" => Some(Color::Magenta),
163        "cyan" => Some(Color::Cyan),
164        "gray" | "grey" => Some(Color::Gray),
165        "darkgray" | "darkgrey" => Some(Color::DarkGray),
166        "lightred" => Some(Color::LightRed),
167        "lightgreen" => Some(Color::LightGreen),
168        "lightyellow" => Some(Color::LightYellow),
169        "lightblue" => Some(Color::LightBlue),
170        "lightmagenta" => Some(Color::LightMagenta),
171        "lightcyan" => Some(Color::LightCyan),
172        "white" => Some(Color::White),
173        _ => None,
174    }
175}
176
177/// Raw theme data as parsed from TOML.
178#[derive(Debug, Deserialize)]
179struct RawTheme {
180    #[serde(default)]
181    palette: HashMap<String, String>,
182    #[serde(flatten)]
183    styles: HashMap<String, StyleValue>,
184}
185
186/// A style value can be either a full Style object or a simple string.
187#[derive(Debug, Deserialize)]
188#[serde(untagged)]
189enum StyleValue {
190    /// Full style definition
191    Full(Style),
192    /// Simple foreground color only
193    Simple(String),
194}
195
196impl StyleValue {
197    fn into_style(self) -> Style {
198        match self {
199            StyleValue::Full(s) => s,
200            StyleValue::Simple(fg) => Style {
201                fg: Some(fg),
202                bg: None,
203                modifiers: Vec::new(),
204            },
205        }
206    }
207}
208
209/// A syntax highlighting theme.
210///
211/// Themes map capture names (like "keyword", "string", "comment") to styles.
212#[derive(Debug, Clone)]
213pub struct Theme {
214    /// Theme name
215    pub name: String,
216    /// Color palette (named colors)
217    palette: HashMap<String, String>,
218    /// Styles for each capture name
219    styles: HashMap<String, Style>,
220    /// Cached ratatui styles
221    cached_styles: HashMap<String, RatatuiStyle>,
222}
223
224impl Theme {
225    /// Parse a theme from TOML string.
226    pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
227        Self::from_toml_with_name(toml_str, "custom")
228    }
229
230    /// Parse a theme from TOML string with a name.
231    pub fn from_toml_with_name(toml_str: &str, name: &str) -> Result<Self, ThemeError> {
232        let raw: RawTheme = toml::from_str(toml_str)?;
233
234        let mut styles = HashMap::new();
235        for (key, value) in raw.styles {
236            // Skip the palette key
237            if key == "palette" {
238                continue;
239            }
240            styles.insert(key, value.into_style());
241        }
242
243        let mut theme = Self {
244            name: name.to_string(),
245            palette: raw.palette,
246            styles,
247            cached_styles: HashMap::new(),
248        };
249
250        // Pre-cache all styles
251        theme.cache_styles();
252
253        Ok(theme)
254    }
255
256    /// Load a theme from a TOML file.
257    pub fn from_file(path: &Path) -> Result<Self, ThemeError> {
258        let content = std::fs::read_to_string(path)?;
259        let name = path
260            .file_stem()
261            .and_then(|s| s.to_str())
262            .unwrap_or("custom");
263        Self::from_toml_with_name(&content, name)
264    }
265
266    /// Get the ratatui style for a capture name.
267    ///
268    /// Uses hierarchical fallback: "keyword.control" falls back to "keyword".
269    pub fn style_for(&self, capture: &str) -> RatatuiStyle {
270        // Check exact match first
271        if let Some(style) = self.cached_styles.get(capture) {
272            return *style;
273        }
274
275        // Try hierarchical fallback
276        let mut parts: Vec<&str> = capture.split('.').collect();
277        while parts.len() > 1 {
278            parts.pop();
279            let parent = parts.join(".");
280            if let Some(style) = self.cached_styles.get(&parent) {
281                return *style;
282            }
283        }
284
285        // Default style
286        RatatuiStyle::default()
287    }
288
289    /// Cache all styles as ratatui styles.
290    fn cache_styles(&mut self) {
291        self.cached_styles.clear();
292        for (name, style) in &self.styles {
293            let ratatui_style = style.to_ratatui_style(&self.palette);
294            self.cached_styles.insert(name.clone(), ratatui_style);
295        }
296    }
297
298    /// Get the list of all capture names this theme defines styles for.
299    pub fn capture_names(&self) -> Vec<&str> {
300        self.styles.keys().map(|s| s.as_str()).collect()
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_parse_hex_color() {
310        assert_eq!(parse_color("#FF0000"), Some(Color::Rgb(255, 0, 0)));
311        assert_eq!(parse_color("#00FF00"), Some(Color::Rgb(0, 255, 0)));
312        assert_eq!(parse_color("#0000FF"), Some(Color::Rgb(0, 0, 255)));
313        assert_eq!(parse_color("#F00"), Some(Color::Rgb(255, 0, 0)));
314    }
315
316    #[test]
317    fn test_parse_named_color() {
318        assert_eq!(parse_color("red"), Some(Color::Red));
319        assert_eq!(parse_color("Blue"), Some(Color::Blue));
320        assert_eq!(parse_color("GREEN"), Some(Color::Green));
321    }
322
323    #[test]
324    fn test_theme_from_toml() {
325        let toml = r##"
326            [palette]
327            red = "#E06C75"
328            green = "#98C379"
329
330            [keyword]
331            fg = "red"
332            modifiers = ["bold"]
333
334            [string]
335            fg = "green"
336        "##;
337
338        let theme = Theme::from_toml(toml).unwrap();
339
340        let keyword_style = theme.style_for("keyword");
341        assert!(keyword_style.fg.is_some());
342
343        let string_style = theme.style_for("string");
344        assert!(string_style.fg.is_some());
345    }
346
347    #[test]
348    fn test_hierarchical_fallback() {
349        let toml = r##"
350            [keyword]
351            fg = "#FF0000"
352        "##;
353
354        let theme = Theme::from_toml(toml).unwrap();
355
356        // "keyword.control" should fall back to "keyword"
357        let style = theme.style_for("keyword.control");
358        assert_eq!(style.fg, Some(Color::Rgb(255, 0, 0)));
359    }
360
361    #[test]
362    fn test_simple_style_value() {
363        let toml = r##"
364            keyword = "#FF0000"
365            string = "green"
366        "##;
367
368        let theme = Theme::from_toml(toml).unwrap();
369
370        let keyword_style = theme.style_for("keyword");
371        assert_eq!(keyword_style.fg, Some(Color::Rgb(255, 0, 0)));
372
373        let string_style = theme.style_for("string");
374        assert_eq!(string_style.fg, Some(Color::Green));
375    }
376}