Skip to main content

tui/theme/
syntax.rs

1use super::{Color, Theme, darken_color};
2use std::path::Path;
3use std::sync::Arc;
4
5/// Parse the embedded Catppuccin Mocha `.tmTheme` into a syntect theme.
6///
7/// Called once at `Theme` construction time; the result is cached in
8/// `Theme::syntect_theme`.
9pub(super) fn parse_default_syntect_theme() -> syntect::highlighting::Theme {
10    let cursor = std::io::Cursor::new(include_bytes!("../../assets/catppuccin-mocha.tmTheme"));
11    syntect::highlighting::ThemeSet::load_from_reader(&mut std::io::BufReader::new(cursor))
12        .expect("embedded catppuccin-mocha.tmTheme is valid")
13}
14
15const DEFAULT_FG: Color = Color::Rgb { r: 0xBF, g: 0xBD, b: 0xB6 };
16const DEFAULT_BG: Color = Color::Rgb { r: 0x1E, g: 0x1E, b: 0x2E };
17const DEFAULT_CODE_BG: Color = Color::Rgb { r: 40, g: 40, b: 40 };
18const DEFAULT_ACCENT: Color = Color::Rgb { r: 255, g: 215, b: 0 };
19const DEFAULT_HIGHLIGHT_BG: Color = Color::Rgb { r: 0x1a, g: 0x4a, b: 0x50 };
20
21impl Theme {
22    /// Return the cached syntect theme for syntax highlighting.
23    pub fn syntect_theme(&self) -> &syntect::highlighting::Theme {
24        &self.syntect_theme
25    }
26
27    /// Load theme from a `.tmTheme` file.
28    pub fn load_from_path(path: &Path) -> Self {
29        use syntect::highlighting::ThemeSet;
30        use tracing::warn;
31
32        match ThemeSet::get_theme(path) {
33            Ok(syntect_theme) => Self::from(&syntect_theme),
34            Err(e) => {
35                warn!("Failed to load theme from {}: {e}. Falling back to defaults.", path.display());
36                Self::default()
37            }
38        }
39    }
40}
41
42impl From<&syntect::highlighting::Theme> for Theme {
43    #[allow(clippy::similar_names)]
44    fn from(syntect: &syntect::highlighting::Theme) -> Self {
45        let syntect_bg =
46            syntect.settings.background.unwrap_or(syntect::highlighting::Color { r: 0x1E, g: 0x1E, b: 0x2E, a: 0xFF });
47
48        let accent = syntect.settings.caret.map_or(DEFAULT_ACCENT, color_from_syntect);
49
50        let text_secondary = derive_text_secondary(syntect);
51
52        let heading = resolve_scope_fg(syntect, "markup.heading.markdown")
53            .or_else(|| resolve_scope_fg(syntect, "markup.heading"))
54            .unwrap_or(accent);
55
56        let link = resolve_scope_fg(syntect, "markup.underline.link")
57            .or_else(|| resolve_scope_fg(syntect, "markup.link"))
58            .unwrap_or(accent);
59
60        let blockquote = resolve_scope_fg(syntect, "markup.quote").unwrap_or(text_secondary);
61
62        let muted = resolve_scope_fg(syntect, "markup.list.bullet")
63            .or_else(|| syntect.settings.gutter_foreground.map(|c| composite_over(c, syntect_bg)))
64            .unwrap_or(text_secondary);
65
66        let fg = syntect.settings.foreground.map_or(DEFAULT_FG, color_from_syntect);
67
68        let inline_code_fg = resolve_scope_fg(syntect, "markup.inline.raw.string.markdown")
69            .or_else(|| resolve_scope_fg(syntect, "markup.raw"))
70            .unwrap_or(fg);
71
72        let error = resolve_scope_fg(syntect, "markup.deleted")
73            .or_else(|| resolve_scope_fg(syntect, "markup.deleted.diff"))
74            .or_else(|| resolve_scope_fg(syntect, "invalid"))
75            .unwrap_or(accent);
76
77        let warning = resolve_scope_fg(syntect, "constant.numeric").unwrap_or(accent);
78
79        let success = resolve_scope_fg(syntect, "markup.inserted")
80            .or_else(|| resolve_scope_fg(syntect, "markup.inserted.diff"))
81            .or_else(|| resolve_scope_fg(syntect, "string"))
82            .unwrap_or(accent);
83
84        let info = resolve_scope_fg(syntect, "entity.name.function")
85            .or_else(|| resolve_scope_fg(syntect, "support.function"))
86            .unwrap_or(accent);
87
88        let secondary = resolve_scope_fg(syntect, "keyword")
89            .or_else(|| resolve_scope_fg(syntect, "storage.type"))
90            .unwrap_or(accent);
91
92        let (bg, highlight_bg, highlight_fg, inline_code_bg) = resolve_bg_colors(syntect, syntect_bg, fg);
93
94        let sidebar_bg = nudge_toward_fg(bg, fg);
95
96        let diff_added_fg = resolve_scope_fg(syntect, "markup.inserted.diff")
97            .or_else(|| resolve_scope_fg(syntect, "markup.inserted"))
98            .or_else(|| resolve_scope_fg(syntect, "string"))
99            .unwrap_or(accent);
100
101        let diff_removed_fg = resolve_scope_fg(syntect, "markup.deleted.diff")
102            .or_else(|| resolve_scope_fg(syntect, "markup.deleted"))
103            .unwrap_or(accent);
104
105        Self {
106            fg,
107            bg,
108            accent,
109            highlight_bg,
110            highlight_fg,
111            text_secondary,
112            code_fg: inline_code_fg,
113            code_bg: inline_code_bg,
114            heading,
115            link,
116            blockquote,
117            muted,
118            success,
119            warning,
120            error,
121            info,
122            secondary,
123            sidebar_bg,
124            diff_added_fg,
125            diff_removed_fg,
126            diff_added_bg: darken_color(diff_added_fg),
127            diff_removed_bg: darken_color(diff_removed_fg),
128            syntect_theme: Arc::new(syntect.clone()),
129        }
130    }
131}
132
133#[allow(clippy::similar_names)]
134fn resolve_bg_colors(
135    syntect: &syntect::highlighting::Theme,
136    syntect_bg: syntect::highlighting::Color,
137    fg: Color,
138) -> (Color, Color, Color, Color) {
139    let bg = syntect.settings.background.map_or(DEFAULT_BG, color_from_syntect);
140
141    let highlight_bg = syntect
142        .settings
143        .line_highlight
144        .or(syntect.settings.selection)
145        .map_or(DEFAULT_HIGHLIGHT_BG, |c| composite_over(c, syntect_bg));
146
147    let highlight_fg = syntect.settings.selection_foreground.map_or(fg, color_from_syntect);
148
149    let inline_code_bg = syntect.settings.background.map_or(DEFAULT_CODE_BG, color_from_syntect);
150
151    (bg, highlight_bg, highlight_fg, inline_code_bg)
152}
153
154/// Resolve the foreground color for a scope string against the theme.
155fn resolve_scope_fg(theme: &syntect::highlighting::Theme, scope_str: &str) -> Option<Color> {
156    use syntect::highlighting::Highlighter;
157    use syntect::parsing::Scope;
158
159    let scope = Scope::new(scope_str).ok()?;
160    let highlighter = Highlighter::new(theme);
161    let style = highlighter.style_for_stack(&[scope]);
162
163    let resolved = style.foreground;
164    let default_fg = theme.settings.foreground?;
165
166    if resolved.r == default_fg.r && resolved.g == default_fg.g && resolved.b == default_fg.b {
167        return None;
168    }
169
170    Some(color_from_syntect(resolved))
171}
172
173/// Blend the theme's foreground toward its background at ~40%.
174fn derive_text_secondary(theme: &syntect::highlighting::Theme) -> Color {
175    use syntect::highlighting::Color as SyntectColor;
176
177    let fg = theme.settings.foreground.unwrap_or(SyntectColor { r: 0xBF, g: 0xBD, b: 0xB6, a: 0xFF });
178    let bg = theme.settings.background.unwrap_or(SyntectColor { r: 0x28, g: 0x28, b: 0x28, a: 0xFF });
179
180    #[allow(clippy::cast_possible_truncation)]
181    let blend = |f: u8, b: u8| -> u8 { ((u16::from(f) * 60 + u16::from(b) * 40) / 100) as u8 };
182
183    Color::Rgb { r: blend(fg.r, bg.r), g: blend(fg.g, bg.g), b: blend(fg.b, bg.b) }
184}
185
186/// Nudge a background color ~5% toward the foreground to produce a
187/// subtly distinct sidebar background.
188#[allow(clippy::cast_possible_truncation)]
189fn nudge_toward_fg(bg: Color, fg: Color) -> Color {
190    match (bg, fg) {
191        (Color::Rgb { r: br, g: bg_g, b: bb }, Color::Rgb { r: fr, g: fg_g, b: fb }) => {
192            let blend = |b: u8, f: u8| -> u8 { ((u16::from(b) * 95 + u16::from(f) * 5) / 100) as u8 };
193            Color::Rgb { r: blend(br, fr), g: blend(bg_g, fg_g), b: blend(bb, fb) }
194        }
195        _ => bg,
196    }
197}
198
199fn color_from_syntect(color: syntect::highlighting::Color) -> Color {
200    Color::Rgb { r: color.r, g: color.g, b: color.b }
201}
202
203/// Alpha-composite `fg` over `bg`, producing an opaque `Color`.
204///
205/// Many `.tmTheme` colors (e.g. `lineHighlight`, `selection`) use alpha to
206/// create subtle overlays. Since terminals can't render alpha, we pre-blend
207/// against the theme background.
208#[allow(clippy::cast_possible_truncation)]
209fn composite_over(fg: syntect::highlighting::Color, bg: syntect::highlighting::Color) -> Color {
210    let a = u16::from(fg.a);
211    let blend = |f: u8, b: u8| -> u8 { ((u16::from(f) * a + u16::from(b) * (255 - a)) / 255) as u8 };
212    Color::Rgb { r: blend(fg.r, bg.r), g: blend(fg.g, bg.g), b: blend(fg.b, bg.b) }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::fs;
219    use syntect::highlighting::ThemeSettings;
220    use tempfile::TempDir;
221
222    fn bare_syntect_theme() -> syntect::highlighting::Theme {
223        syntect::highlighting::Theme {
224            name: Some("Bare".into()),
225            author: None,
226            settings: ThemeSettings {
227                foreground: Some(syntect::highlighting::Color { r: 0xCC, g: 0xCC, b: 0xCC, a: 0xFF }),
228                background: Some(syntect::highlighting::Color { r: 0x11, g: 0x11, b: 0x11, a: 0xFF }),
229                caret: Some(syntect::highlighting::Color { r: 0xAA, g: 0xBB, b: 0xCC, a: 0xFF }),
230                ..ThemeSettings::default()
231            },
232            scopes: Vec::new(),
233        }
234    }
235
236    const LOADABLE_TMTHEME: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
237<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
238<plist version="1.0">
239<dict>
240    <key>name</key>
241    <string>Loadable</string>
242    <key>settings</key>
243    <array>
244        <dict>
245            <key>settings</key>
246            <dict>
247                <key>foreground</key>
248                <string>#112233</string>
249                <key>background</key>
250                <string>#000000</string>
251                <key>selection</key>
252                <string>#334455</string>
253            </dict>
254        </dict>
255    </array>
256</dict>
257</plist>"#;
258
259    #[test]
260    fn bare_theme_falls_back_to_accent() {
261        let accent = Color::Rgb { r: 0xAA, g: 0xBB, b: 0xCC };
262        let syntect = bare_syntect_theme();
263        let theme = Theme::from(&syntect);
264
265        assert_eq!(theme.heading(), accent);
266        assert_eq!(theme.link(), accent);
267        assert_eq!(theme.error(), accent);
268        assert_eq!(theme.warning(), accent);
269        assert_eq!(theme.success(), accent);
270        assert_eq!(theme.info(), accent);
271        assert_eq!(theme.secondary(), accent);
272        assert_eq!(theme.diff_added_fg(), accent);
273        assert_eq!(theme.diff_removed_fg(), accent);
274    }
275
276    #[test]
277    fn valid_theme_file_loads_from_path() {
278        let temp_dir = TempDir::new().unwrap();
279        let theme_path = temp_dir.path().join("custom.tmTheme");
280        fs::write(&theme_path, LOADABLE_TMTHEME).unwrap();
281
282        let loaded = Theme::load_from_path(&theme_path);
283
284        assert_eq!(loaded.text_primary(), Color::Rgb { r: 0x11, g: 0x22, b: 0x33 });
285    }
286
287    #[test]
288    fn loaded_theme_preserves_syntect_theme_when_cloned() {
289        let temp_dir = TempDir::new().unwrap();
290        let theme_path = temp_dir.path().join("custom.tmTheme");
291        fs::write(&theme_path, LOADABLE_TMTHEME).unwrap();
292
293        let loaded = Theme::load_from_path(&theme_path);
294        let cloned = loaded.clone();
295        let syntect = cloned.syntect_theme();
296
297        assert_eq!(
298            syntect.settings.foreground,
299            Some(syntect::highlighting::Color { r: 0x11, g: 0x22, b: 0x33, a: 0xFF })
300        );
301        assert_eq!(
302            syntect.settings.selection,
303            Some(syntect::highlighting::Color { r: 0x33, g: 0x44, b: 0x55, a: 0xFF })
304        );
305    }
306
307    #[test]
308    fn highlight_bg_prefers_line_highlight_over_selection() {
309        let mut syntect = bare_syntect_theme();
310        syntect.settings.line_highlight = Some(syntect::highlighting::Color { r: 0x31, g: 0x32, b: 0x44, a: 0xFF });
311        syntect.settings.selection = Some(syntect::highlighting::Color { r: 0x99, g: 0x99, b: 0x99, a: 0x40 });
312
313        let theme = Theme::from(&syntect);
314
315        assert_eq!(theme.highlight_bg(), Color::Rgb { r: 0x31, g: 0x32, b: 0x44 });
316    }
317
318    #[test]
319    fn highlight_bg_falls_back_to_selection_without_line_highlight() {
320        let mut syntect = bare_syntect_theme();
321        syntect.settings.line_highlight = None;
322        syntect.settings.selection = Some(syntect::highlighting::Color { r: 0x33, g: 0x44, b: 0x55, a: 0xFF });
323
324        let theme = Theme::from(&syntect);
325
326        assert_eq!(theme.highlight_bg(), Color::Rgb { r: 0x33, g: 0x44, b: 0x55 });
327    }
328
329    #[test]
330    fn highlight_bg_composites_alpha_over_background() {
331        // Kiwi-like: lineHighlight=#00000050 over background=#212121
332        let mut syntect = bare_syntect_theme();
333        syntect.settings.background = Some(syntect::highlighting::Color { r: 0x21, g: 0x21, b: 0x21, a: 0xFF });
334        syntect.settings.line_highlight = Some(syntect::highlighting::Color { r: 0x00, g: 0x00, b: 0x00, a: 0x50 });
335
336        let theme = Theme::from(&syntect);
337
338        // 0x50/0xFF ≈ 31.4% opacity: blend(0x00, 0x21) = (0*80 + 33*175)/255 ≈ 22 = 0x16
339        let expected = Color::Rgb { r: 0x16, g: 0x16, b: 0x16 };
340        assert_eq!(theme.highlight_bg(), expected);
341    }
342
343    #[test]
344    fn muted_composites_gutter_foreground_alpha() {
345        // Aster-like: gutterForeground=#4f4f5e90 over background=#1a1a2e
346        let mut syntect = bare_syntect_theme();
347        syntect.settings.background = Some(syntect::highlighting::Color { r: 0x1A, g: 0x1A, b: 0x2E, a: 0xFF });
348        syntect.settings.gutter_foreground = Some(syntect::highlighting::Color { r: 0x4F, g: 0x4F, b: 0x5E, a: 0x90 });
349        // No markup.list.bullet scope, so muted falls back to gutter_foreground
350        let theme = Theme::from(&syntect);
351
352        // blend(0x4F, 0x1A) = (0x4F*0x90 + 0x1A*(255-0x90)) / 255
353        #[allow(clippy::cast_possible_truncation)]
354        let blend = |f: u16, b: u16| -> u8 { ((f * 0x90 + b * (255 - 0x90)) / 255) as u8 };
355        let expected = Color::Rgb { r: blend(0x4F, 0x1A), g: blend(0x4F, 0x1A), b: blend(0x5E, 0x2E) };
356        assert_eq!(theme.muted(), expected);
357    }
358
359    #[test]
360    fn malformed_theme_falls_back_to_default() {
361        let temp_dir = TempDir::new().unwrap();
362        let theme_path = temp_dir.path().join("broken.tmTheme");
363        fs::write(&theme_path, "not valid xml").unwrap();
364
365        let loaded = Theme::load_from_path(&theme_path);
366
367        let default = Theme::default();
368        assert_eq!(loaded.primary(), default.primary());
369        assert_eq!(loaded.code_bg(), default.code_bg());
370    }
371}