Skip to main content

comfit_core/
theme.rs

1//! Capture styles, terminal colors, and optional TOML theme loading.
2
3use std::{collections::BTreeMap, fmt::Write};
4
5use rgb::RGB8;
6
7/// Default styles, capture styles, bracket colors, and line emphasis.
8#[derive(Debug, Clone, Default)]
9pub struct Theme {
10    /// Display name from the theme source.
11    pub name: String,
12    /// Base style inherited by tokens.
13    pub default: Style,
14    /// Capture styles keyed by dotted query capture names.
15    pub styles: BTreeMap<String, Style>,
16    /// Bracket colors cycled by nesting depth. An empty palette disables bracket coloring.
17    pub rainbow: Vec<Color>,
18    /// Style applied to selected lines by a renderer.
19    pub line_highlight: Style,
20}
21
22/// Optional colors, font modifiers, and underline settings.
23#[derive(Debug, Clone, Default)]
24pub struct Style {
25    /// Foreground color, if explicitly set.
26    pub fg: Option<Color>,
27    /// Background color, if explicitly set.
28    pub bg: Option<Color>,
29    /// Font and visibility modifiers. An empty list resets inherited modifiers.
30    pub modifiers: Vec<Modifier>,
31    /// Underline settings, if explicitly set.
32    pub underline: Option<Underline>,
33}
34
35/// Truecolor, indexed terminal color, or the output default.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Color {
38    /// A 24-bit RGB color.
39    Rgb(RGB8),
40    /// An index in the 256-color terminal palette.
41    Indexed(u8),
42    /// The terminal default, represented as `inherit` in CSS.
43    Default,
44}
45
46/// Optional underline color and shape.
47#[derive(Debug, Clone, Default)]
48pub struct Underline {
49    /// Underline color. Uses the output default if omitted.
50    pub color: Option<Color>,
51    /// Underline shape. Uses a straight line if omitted.
52    pub style: Option<UnderlineStyle>,
53}
54
55/// Underline shapes supported by the renderers.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum UnderlineStyle {
58    /// A single straight line.
59    Line,
60    /// A wavy underline.
61    Curl,
62    /// A dashed underline.
63    Dashed,
64    /// A dotted underline.
65    Dotted,
66    /// Two straight lines.
67    DoubleLine,
68}
69
70/// Font, visibility, and terminal display modifiers.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Modifier {
73    /// Bold text.
74    Bold,
75    /// Dimmed text.
76    Dim,
77    /// Italic text.
78    Italic,
79    /// Slow terminal blinking. CSS uses blink decoration.
80    SlowBlink,
81    /// Rapid terminal blinking. CSS uses blink decoration.
82    RapidBlink,
83    /// Swap foreground and background colors.
84    Reversed,
85    /// Hide text without removing its space.
86    Hidden,
87    /// Strike through text.
88    CrossedOut,
89}
90
91impl Theme {
92    /// Combine the default style with styles matching the capture name and its prefixes.
93    ///
94    /// More specific styles override colors and underlines when those values are set.
95    /// Each matching style replaces the modifier list, including an empty list.
96    /// Leading dots are ignored when building prefixes. Empty prefixes still match
97    /// the empty style key. Only the final modifier list is cloned.
98    ///
99    /// # Examples
100    ///
101    /// ```
102    /// use comfit_core::{Color, Style, Theme};
103    ///
104    /// let mut theme = Theme::default();
105    /// theme.styles.insert("function".into(), Style {
106    ///     fg: Some(Color::Indexed(4)),
107    ///     ..Style::default()
108    /// });
109    ///
110    /// // A more specific capture inherits matching prefix styles.
111    /// assert_eq!(theme.style("function.call").fg, Some(Color::Indexed(4)));
112    /// ```
113    pub fn style(&self, capture: &str) -> Style {
114        let mut result = Style {
115            fg: self.default.fg,
116            bg: self.default.bg,
117            underline: self.default.underline.clone(),
118            modifiers: Vec::new(),
119        };
120        let mut modifiers = &self.default.modifiers;
121        let normalized = capture.trim_start_matches('.');
122        let mut end = 0;
123
124        for part in capture.split('.') {
125            if end > 0 {
126                end += 1;
127            }
128            end += part.len();
129
130            if let Some(style) = self.styles.get(&normalized[..end]) {
131                if style.fg.is_some() {
132                    result.fg = style.fg;
133                }
134
135                if style.bg.is_some() {
136                    result.bg = style.bg;
137                }
138
139                modifiers = &style.modifiers;
140
141                if style.underline.is_some() {
142                    result.underline.clone_from(&style.underline);
143                }
144            }
145        }
146
147        result.modifiers = modifiers.clone();
148        result
149    }
150
151    /// Load default, capture, rainbow, and line-highlight styles from TOML.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`crate::Error::Theme`] for invalid TOML or unsupported color, modifier,
156    /// underline, or table values.
157    #[cfg(feature = "theme-toml")]
158    pub fn from_toml(source: &str) -> Result<Self, crate::Error> {
159        let value: toml::Value =
160            toml::from_str(source).map_err(|error| crate::Error::Theme(error.to_string()))?;
161        let mut theme = Self {
162            name: value
163                .get("name")
164                .and_then(toml::Value::as_str)
165                .unwrap_or_default()
166                .to_owned(),
167            ..Self::default()
168        };
169
170        if let Some(default) = value.get("default") {
171            theme.default = parse_style(default)?;
172        }
173
174        if let Some(styles) = value.get("styles") {
175            let styles = styles
176                .as_table()
177                .ok_or_else(|| crate::Error::Theme("styles must be a table".into()))?;
178
179            for (name, style) in styles {
180                theme.styles.insert(name.clone(), parse_style(style)?);
181            }
182        }
183
184        if let Some(colors) = value.get("rainbow") {
185            let colors = colors
186                .as_array()
187                .ok_or_else(|| crate::Error::Theme("rainbow must be an array".into()))?;
188
189            for color in colors {
190                theme.rainbow.push(parse_color(color)?);
191            }
192        }
193
194        if let Some(style) = value.get("line_highlight") {
195            theme.line_highlight = parse_style(style)?;
196        }
197
198        Ok(theme)
199    }
200}
201
202impl Color {
203    /// Convert a color to CSS, using the standard terminal palette for indexed colors.
204    pub fn to_css(self) -> String {
205        let mut css = String::new();
206        self.write_css(&mut css);
207        css
208    }
209
210    fn write_css(self, css: &mut String) {
211        let color = match self {
212            Self::Default => {
213                css.push_str("inherit");
214                return;
215            }
216            Self::Rgb(color) => color,
217            Self::Indexed(index) if index < 16 => {
218                const COLORS: [(u8, u8, u8); 16] = [
219                    (0, 0, 0),
220                    (128, 0, 0),
221                    (0, 128, 0),
222                    (128, 128, 0),
223                    (0, 0, 128),
224                    (128, 0, 128),
225                    (0, 128, 128),
226                    (192, 192, 192),
227                    (128, 128, 128),
228                    (255, 0, 0),
229                    (0, 255, 0),
230                    (255, 255, 0),
231                    (0, 0, 255),
232                    (255, 0, 255),
233                    (0, 255, 255),
234                    (255, 255, 255),
235                ];
236                COLORS[index as usize].into()
237            }
238
239            Self::Indexed(index) if index >= 232 => {
240                let shade = 8 + (index - 232) * 10;
241                RGB8::new(shade, shade, shade)
242            }
243
244            Self::Indexed(index) => {
245                let index = index - 16;
246                let level = |value: u8| if value == 0 { 0 } else { 55 + value * 40 };
247                RGB8::new(level(index / 36), level(index / 6 % 6), level(index % 6))
248            }
249        };
250
251        write!(css, "#{:02x}{:02x}{:02x}", color.r, color.g, color.b).unwrap();
252    }
253}
254
255impl Style {
256    /// Render CSS declarations for explicitly set style properties.
257    ///
258    /// Reversed colors are swapped before rendering. Terminal blink speeds both map
259    /// to CSS blink decoration. Browser support determines the visible result.
260    pub fn to_css(&self) -> String {
261        let mut css = String::new();
262        self.write_css(&mut css);
263        css
264    }
265
266    /// Append declarations for explicitly set properties without clearing the buffer.
267    pub(crate) fn write_css(&self, css: &mut String) {
268        let (foreground, background) = if self.modifiers.contains(&Modifier::Reversed) {
269            (self.bg, self.fg)
270        } else {
271            (self.fg, self.bg)
272        };
273
274        if let Some(color) = foreground {
275            css.push_str("color:");
276            color.write_css(css);
277            css.push(';');
278        }
279
280        if let Some(color) = background {
281            css.push_str("background-color:");
282            color.write_css(css);
283            css.push(';');
284        }
285
286        for modifier in &self.modifiers {
287            css.push_str(match modifier {
288                Modifier::Bold => "font-weight:bold;",
289                Modifier::Dim => "opacity:0.5;",
290                Modifier::Italic => "font-style:italic;",
291                Modifier::Hidden => "visibility:hidden;",
292                Modifier::CrossedOut => "text-decoration-line:line-through;",
293                Modifier::SlowBlink | Modifier::RapidBlink => "text-decoration-line:blink;",
294                Modifier::Reversed => "",
295            });
296        }
297
298        if let Some(underline) = &self.underline {
299            let crossed = self.modifiers.contains(&Modifier::CrossedOut);
300            css.push_str(if crossed {
301                "text-decoration-line:underline line-through;"
302            } else {
303                "text-decoration-line:underline;"
304            });
305            let style = match underline.style.unwrap_or(UnderlineStyle::Line) {
306                UnderlineStyle::Line => "solid",
307                UnderlineStyle::Curl => "wavy",
308                UnderlineStyle::Dashed => "dashed",
309                UnderlineStyle::Dotted => "dotted",
310                UnderlineStyle::DoubleLine => "double",
311            };
312
313            css.push_str("text-decoration-style:");
314            css.push_str(style);
315            css.push(';');
316
317            if let Some(color) = underline.color {
318                css.push_str("text-decoration-color:");
319                color.write_css(css);
320                css.push(';');
321            }
322        }
323    }
324}
325
326impl std::str::FromStr for Color {
327    type Err = crate::Error;
328
329    /// Parse a color from `#RGB`, `#RRGGBB`, or `default`.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`crate::Error::Theme`] for unsupported formats or invalid hex digits.
334    fn from_str(color: &str) -> Result<Self, Self::Err> {
335        match color {
336            "default" => Ok(Color::Default),
337            color
338                if color.len() == 4
339                    && color.starts_with('#')
340                    && color[1..].bytes().all(|byte| byte.is_ascii_hexdigit()) =>
341            {
342                let value = u16::from_str_radix(&color[1..], 16).unwrap();
343
344                Ok(Color::Rgb(RGB8::new(
345                    ((value >> 8) as u8) * 17,
346                    ((value >> 4 & 15) as u8) * 17,
347                    ((value & 15) as u8) * 17,
348                )))
349            }
350            color
351                if color.len() == 7
352                    && color.starts_with('#')
353                    && color[1..].bytes().all(|byte| byte.is_ascii_hexdigit()) =>
354            {
355                let value = u32::from_str_radix(&color[1..], 16).unwrap();
356
357                Ok(Color::Rgb(RGB8::new(
358                    (value >> 16) as u8,
359                    (value >> 8) as u8,
360                    value as u8,
361                )))
362            }
363            _ => Err(crate::Error::Theme(format!("invalid color: {color:?}"))),
364        }
365    }
366}
367
368#[cfg(feature = "theme-toml")]
369fn parse_color(value: &toml::Value) -> Result<Color, crate::Error> {
370    if let Some(index) = value
371        .as_integer()
372        .and_then(|index| u8::try_from(index).ok())
373    {
374        return Ok(Color::Indexed(index));
375    }
376
377    value
378        .as_str()
379        .and_then(|color| color.parse().ok())
380        .ok_or_else(|| crate::Error::Theme(format!("invalid color: {value}")))
381}
382
383#[cfg(feature = "theme-toml")]
384fn parse_style(value: &toml::Value) -> Result<Style, crate::Error> {
385    let value = value
386        .as_table()
387        .ok_or_else(|| crate::Error::Theme("style must be a table".into()))?;
388
389    let mut style = Style::default();
390
391    if let Some(color) = value.get("fg") {
392        style.fg = Some(parse_color(color)?);
393    }
394
395    if let Some(color) = value.get("bg") {
396        style.bg = Some(parse_color(color)?);
397    }
398
399    if let Some(modifiers) = value.get("modifiers") {
400        let modifiers = modifiers
401            .as_array()
402            .ok_or_else(|| crate::Error::Theme("modifiers must be an array".into()))?;
403
404        for modifier in modifiers {
405            style.modifiers.push(match modifier.as_str() {
406                Some("bold") => Modifier::Bold,
407                Some("dim") => Modifier::Dim,
408                Some("italic") => Modifier::Italic,
409                Some("slow_blink") => Modifier::SlowBlink,
410                Some("rapid_blink") => Modifier::RapidBlink,
411                Some("reversed") => Modifier::Reversed,
412                Some("hidden") => Modifier::Hidden,
413                Some("crossed_out") => Modifier::CrossedOut,
414                _ => return Err(crate::Error::Theme(format!("invalid modifier: {modifier}"))),
415            });
416        }
417    }
418
419    if let Some(value) = value.get("underline") {
420        let value = value
421            .as_table()
422            .ok_or_else(|| crate::Error::Theme("underline must be a table".into()))?;
423        let color = value.get("color").map(parse_color).transpose()?;
424        let underline = match value
425            .get("style")
426            .and_then(toml::Value::as_str)
427            .unwrap_or("line")
428        {
429            "line" => UnderlineStyle::Line,
430            "curl" => UnderlineStyle::Curl,
431            "dashed" => UnderlineStyle::Dashed,
432            "dotted" => UnderlineStyle::Dotted,
433            "double_line" => UnderlineStyle::DoubleLine,
434            other => {
435                return Err(crate::Error::Theme(format!(
436                    "invalid underline style: {other}"
437                )));
438            }
439        };
440        style.underline = Some(Underline {
441            color,
442            style: Some(underline),
443        });
444    }
445
446    Ok(style)
447}