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 {
16    r: 0xBF,
17    g: 0xBD,
18    b: 0xB6,
19};
20const DEFAULT_BG: Color = Color::Rgb {
21    r: 0x1E,
22    g: 0x1E,
23    b: 0x2E,
24};
25const DEFAULT_CODE_BG: Color = Color::Rgb {
26    r: 40,
27    g: 40,
28    b: 40,
29};
30const DEFAULT_ACCENT: Color = Color::Rgb {
31    r: 255,
32    g: 215,
33    b: 0,
34};
35const DEFAULT_HIGHLIGHT_BG: Color = Color::Rgb {
36    r: 0x1a,
37    g: 0x4a,
38    b: 0x50,
39};
40
41impl Theme {
42    /// Return the cached syntect theme for syntax highlighting.
43    pub fn syntect_theme(&self) -> &syntect::highlighting::Theme {
44        &self.syntect_theme
45    }
46
47    /// Load theme from a `.tmTheme` file.
48    pub fn load_from_path(path: &Path) -> Self {
49        use syntect::highlighting::ThemeSet;
50        use tracing::warn;
51
52        match ThemeSet::get_theme(path) {
53            Ok(syntect_theme) => Self::from(&syntect_theme),
54            Err(e) => {
55                warn!(
56                    "Failed to load theme from {}: {e}. Falling back to defaults.",
57                    path.display()
58                );
59                Self::default()
60            }
61        }
62    }
63}
64
65impl From<&syntect::highlighting::Theme> for Theme {
66    #[allow(clippy::similar_names)]
67    fn from(syntect: &syntect::highlighting::Theme) -> Self {
68        let syntect_bg = syntect
69            .settings
70            .background
71            .unwrap_or(syntect::highlighting::Color {
72                r: 0x1E,
73                g: 0x1E,
74                b: 0x2E,
75                a: 0xFF,
76            });
77
78        let accent = syntect
79            .settings
80            .caret
81            .map_or(DEFAULT_ACCENT, color_from_syntect);
82
83        let text_secondary = derive_text_secondary(syntect);
84
85        let heading = resolve_scope_fg(syntect, "markup.heading.markdown")
86            .or_else(|| resolve_scope_fg(syntect, "markup.heading"))
87            .unwrap_or(accent);
88
89        let link = resolve_scope_fg(syntect, "markup.underline.link")
90            .or_else(|| resolve_scope_fg(syntect, "markup.link"))
91            .unwrap_or(accent);
92
93        let blockquote = resolve_scope_fg(syntect, "markup.quote").unwrap_or(text_secondary);
94
95        let muted = resolve_scope_fg(syntect, "markup.list.bullet")
96            .or_else(|| {
97                syntect
98                    .settings
99                    .gutter_foreground
100                    .map(|c| composite_over(c, syntect_bg))
101            })
102            .unwrap_or(text_secondary);
103
104        let fg = syntect
105            .settings
106            .foreground
107            .map_or(DEFAULT_FG, color_from_syntect);
108
109        let inline_code_fg = resolve_scope_fg(syntect, "markup.inline.raw.string.markdown")
110            .or_else(|| resolve_scope_fg(syntect, "markup.raw"))
111            .unwrap_or(fg);
112
113        let error = resolve_scope_fg(syntect, "markup.deleted")
114            .or_else(|| resolve_scope_fg(syntect, "markup.deleted.diff"))
115            .or_else(|| resolve_scope_fg(syntect, "invalid"))
116            .unwrap_or(accent);
117
118        let warning = resolve_scope_fg(syntect, "constant.numeric").unwrap_or(accent);
119
120        let success = resolve_scope_fg(syntect, "markup.inserted")
121            .or_else(|| resolve_scope_fg(syntect, "markup.inserted.diff"))
122            .or_else(|| resolve_scope_fg(syntect, "string"))
123            .unwrap_or(accent);
124
125        let info = resolve_scope_fg(syntect, "entity.name.function")
126            .or_else(|| resolve_scope_fg(syntect, "support.function"))
127            .unwrap_or(accent);
128
129        let secondary = resolve_scope_fg(syntect, "keyword")
130            .or_else(|| resolve_scope_fg(syntect, "storage.type"))
131            .unwrap_or(accent);
132
133        let (bg, highlight_bg, highlight_fg, inline_code_bg) =
134            resolve_bg_colors(syntect, syntect_bg, fg);
135
136        let sidebar_bg = nudge_toward_fg(bg, fg);
137
138        let diff_added_fg = resolve_scope_fg(syntect, "markup.inserted.diff")
139            .or_else(|| resolve_scope_fg(syntect, "markup.inserted"))
140            .or_else(|| resolve_scope_fg(syntect, "string"))
141            .unwrap_or(accent);
142
143        let diff_removed_fg = resolve_scope_fg(syntect, "markup.deleted.diff")
144            .or_else(|| resolve_scope_fg(syntect, "markup.deleted"))
145            .unwrap_or(accent);
146
147        Self {
148            fg,
149            bg,
150            accent,
151            highlight_bg,
152            highlight_fg,
153            text_secondary,
154            code_fg: inline_code_fg,
155            code_bg: inline_code_bg,
156            heading,
157            link,
158            blockquote,
159            muted,
160            success,
161            warning,
162            error,
163            info,
164            secondary,
165            sidebar_bg,
166            diff_added_fg,
167            diff_removed_fg,
168            diff_added_bg: darken_color(diff_added_fg),
169            diff_removed_bg: darken_color(diff_removed_fg),
170            syntect_theme: Arc::new(syntect.clone()),
171        }
172    }
173}
174
175#[allow(clippy::similar_names)]
176fn resolve_bg_colors(
177    syntect: &syntect::highlighting::Theme,
178    syntect_bg: syntect::highlighting::Color,
179    fg: Color,
180) -> (Color, Color, Color, Color) {
181    let bg = syntect
182        .settings
183        .background
184        .map_or(DEFAULT_BG, color_from_syntect);
185
186    let highlight_bg = syntect
187        .settings
188        .line_highlight
189        .or(syntect.settings.selection)
190        .map_or(DEFAULT_HIGHLIGHT_BG, |c| composite_over(c, syntect_bg));
191
192    let highlight_fg = syntect
193        .settings
194        .selection_foreground
195        .map_or(fg, color_from_syntect);
196
197    let inline_code_bg = syntect
198        .settings
199        .background
200        .map_or(DEFAULT_CODE_BG, color_from_syntect);
201
202    (bg, highlight_bg, highlight_fg, inline_code_bg)
203}
204
205/// Resolve the foreground color for a scope string against the theme.
206fn resolve_scope_fg(theme: &syntect::highlighting::Theme, scope_str: &str) -> Option<Color> {
207    use syntect::highlighting::Highlighter;
208    use syntect::parsing::Scope;
209
210    let scope = Scope::new(scope_str).ok()?;
211    let highlighter = Highlighter::new(theme);
212    let style = highlighter.style_for_stack(&[scope]);
213
214    let resolved = style.foreground;
215    let default_fg = theme.settings.foreground?;
216
217    if resolved.r == default_fg.r && resolved.g == default_fg.g && resolved.b == default_fg.b {
218        return None;
219    }
220
221    Some(color_from_syntect(resolved))
222}
223
224/// Blend the theme's foreground toward its background at ~40%.
225fn derive_text_secondary(theme: &syntect::highlighting::Theme) -> Color {
226    use syntect::highlighting::Color as SyntectColor;
227
228    let fg = theme.settings.foreground.unwrap_or(SyntectColor {
229        r: 0xBF,
230        g: 0xBD,
231        b: 0xB6,
232        a: 0xFF,
233    });
234    let bg = theme.settings.background.unwrap_or(SyntectColor {
235        r: 0x28,
236        g: 0x28,
237        b: 0x28,
238        a: 0xFF,
239    });
240
241    #[allow(clippy::cast_possible_truncation)]
242    let blend = |f: u8, b: u8| -> u8 { ((u16::from(f) * 60 + u16::from(b) * 40) / 100) as u8 };
243
244    Color::Rgb {
245        r: blend(fg.r, bg.r),
246        g: blend(fg.g, bg.g),
247        b: blend(fg.b, bg.b),
248    }
249}
250
251/// Nudge a background color ~5% toward the foreground to produce a
252/// subtly distinct sidebar background.
253#[allow(clippy::cast_possible_truncation)]
254fn nudge_toward_fg(bg: Color, fg: Color) -> Color {
255    match (bg, fg) {
256        (
257            Color::Rgb {
258                r: br,
259                g: bg_g,
260                b: bb,
261            },
262            Color::Rgb {
263                r: fr,
264                g: fg_g,
265                b: fb,
266            },
267        ) => {
268            let blend =
269                |b: u8, f: u8| -> u8 { ((u16::from(b) * 95 + u16::from(f) * 5) / 100) as u8 };
270            Color::Rgb {
271                r: blend(br, fr),
272                g: blend(bg_g, fg_g),
273                b: blend(bb, fb),
274            }
275        }
276        _ => bg,
277    }
278}
279
280fn color_from_syntect(color: syntect::highlighting::Color) -> Color {
281    Color::Rgb {
282        r: color.r,
283        g: color.g,
284        b: color.b,
285    }
286}
287
288/// Alpha-composite `fg` over `bg`, producing an opaque `Color`.
289///
290/// Many `.tmTheme` colors (e.g. `lineHighlight`, `selection`) use alpha to
291/// create subtle overlays. Since terminals can't render alpha, we pre-blend
292/// against the theme background.
293#[allow(clippy::cast_possible_truncation)]
294fn composite_over(fg: syntect::highlighting::Color, bg: syntect::highlighting::Color) -> Color {
295    let a = u16::from(fg.a);
296    let blend =
297        |f: u8, b: u8| -> u8 { ((u16::from(f) * a + u16::from(b) * (255 - a)) / 255) as u8 };
298    Color::Rgb {
299        r: blend(fg.r, bg.r),
300        g: blend(fg.g, bg.g),
301        b: blend(fg.b, bg.b),
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::fs;
309    use syntect::highlighting::ThemeSettings;
310    use tempfile::TempDir;
311
312    fn bare_syntect_theme() -> syntect::highlighting::Theme {
313        syntect::highlighting::Theme {
314            name: Some("Bare".into()),
315            author: None,
316            settings: ThemeSettings {
317                foreground: Some(syntect::highlighting::Color {
318                    r: 0xCC,
319                    g: 0xCC,
320                    b: 0xCC,
321                    a: 0xFF,
322                }),
323                background: Some(syntect::highlighting::Color {
324                    r: 0x11,
325                    g: 0x11,
326                    b: 0x11,
327                    a: 0xFF,
328                }),
329                caret: Some(syntect::highlighting::Color {
330                    r: 0xAA,
331                    g: 0xBB,
332                    b: 0xCC,
333                    a: 0xFF,
334                }),
335                ..ThemeSettings::default()
336            },
337            scopes: Vec::new(),
338        }
339    }
340
341    const LOADABLE_TMTHEME: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
342<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
343<plist version="1.0">
344<dict>
345    <key>name</key>
346    <string>Loadable</string>
347    <key>settings</key>
348    <array>
349        <dict>
350            <key>settings</key>
351            <dict>
352                <key>foreground</key>
353                <string>#112233</string>
354                <key>background</key>
355                <string>#000000</string>
356                <key>selection</key>
357                <string>#334455</string>
358            </dict>
359        </dict>
360    </array>
361</dict>
362</plist>"#;
363
364    #[test]
365    fn bare_theme_falls_back_to_accent() {
366        let accent = Color::Rgb {
367            r: 0xAA,
368            g: 0xBB,
369            b: 0xCC,
370        };
371        let syntect = bare_syntect_theme();
372        let theme = Theme::from(&syntect);
373
374        assert_eq!(theme.heading(), accent);
375        assert_eq!(theme.link(), accent);
376        assert_eq!(theme.error(), accent);
377        assert_eq!(theme.warning(), accent);
378        assert_eq!(theme.success(), accent);
379        assert_eq!(theme.info(), accent);
380        assert_eq!(theme.secondary(), accent);
381        assert_eq!(theme.diff_added_fg(), accent);
382        assert_eq!(theme.diff_removed_fg(), accent);
383    }
384
385    #[test]
386    fn valid_theme_file_loads_from_path() {
387        let temp_dir = TempDir::new().unwrap();
388        let theme_path = temp_dir.path().join("custom.tmTheme");
389        fs::write(&theme_path, LOADABLE_TMTHEME).unwrap();
390
391        let loaded = Theme::load_from_path(&theme_path);
392
393        assert_eq!(
394            loaded.text_primary(),
395            Color::Rgb {
396                r: 0x11,
397                g: 0x22,
398                b: 0x33
399            }
400        );
401    }
402
403    #[test]
404    fn loaded_theme_preserves_syntect_theme_when_cloned() {
405        let temp_dir = TempDir::new().unwrap();
406        let theme_path = temp_dir.path().join("custom.tmTheme");
407        fs::write(&theme_path, LOADABLE_TMTHEME).unwrap();
408
409        let loaded = Theme::load_from_path(&theme_path);
410        let cloned = loaded.clone();
411        let syntect = cloned.syntect_theme();
412
413        assert_eq!(
414            syntect.settings.foreground,
415            Some(syntect::highlighting::Color {
416                r: 0x11,
417                g: 0x22,
418                b: 0x33,
419                a: 0xFF,
420            })
421        );
422        assert_eq!(
423            syntect.settings.selection,
424            Some(syntect::highlighting::Color {
425                r: 0x33,
426                g: 0x44,
427                b: 0x55,
428                a: 0xFF,
429            })
430        );
431    }
432
433    #[test]
434    fn highlight_bg_prefers_line_highlight_over_selection() {
435        let mut syntect = bare_syntect_theme();
436        syntect.settings.line_highlight = Some(syntect::highlighting::Color {
437            r: 0x31,
438            g: 0x32,
439            b: 0x44,
440            a: 0xFF,
441        });
442        syntect.settings.selection = Some(syntect::highlighting::Color {
443            r: 0x99,
444            g: 0x99,
445            b: 0x99,
446            a: 0x40,
447        });
448
449        let theme = Theme::from(&syntect);
450
451        assert_eq!(
452            theme.highlight_bg(),
453            Color::Rgb {
454                r: 0x31,
455                g: 0x32,
456                b: 0x44,
457            }
458        );
459    }
460
461    #[test]
462    fn highlight_bg_falls_back_to_selection_without_line_highlight() {
463        let mut syntect = bare_syntect_theme();
464        syntect.settings.line_highlight = None;
465        syntect.settings.selection = Some(syntect::highlighting::Color {
466            r: 0x33,
467            g: 0x44,
468            b: 0x55,
469            a: 0xFF,
470        });
471
472        let theme = Theme::from(&syntect);
473
474        assert_eq!(
475            theme.highlight_bg(),
476            Color::Rgb {
477                r: 0x33,
478                g: 0x44,
479                b: 0x55,
480            }
481        );
482    }
483
484    #[test]
485    fn highlight_bg_composites_alpha_over_background() {
486        // Kiwi-like: lineHighlight=#00000050 over background=#212121
487        let mut syntect = bare_syntect_theme();
488        syntect.settings.background = Some(syntect::highlighting::Color {
489            r: 0x21,
490            g: 0x21,
491            b: 0x21,
492            a: 0xFF,
493        });
494        syntect.settings.line_highlight = Some(syntect::highlighting::Color {
495            r: 0x00,
496            g: 0x00,
497            b: 0x00,
498            a: 0x50,
499        });
500
501        let theme = Theme::from(&syntect);
502
503        // 0x50/0xFF ≈ 31.4% opacity: blend(0x00, 0x21) = (0*80 + 33*175)/255 ≈ 22 = 0x16
504        let expected = Color::Rgb {
505            r: 0x16,
506            g: 0x16,
507            b: 0x16,
508        };
509        assert_eq!(theme.highlight_bg(), expected);
510    }
511
512    #[test]
513    fn muted_composites_gutter_foreground_alpha() {
514        // Aster-like: gutterForeground=#4f4f5e90 over background=#1a1a2e
515        let mut syntect = bare_syntect_theme();
516        syntect.settings.background = Some(syntect::highlighting::Color {
517            r: 0x1A,
518            g: 0x1A,
519            b: 0x2E,
520            a: 0xFF,
521        });
522        syntect.settings.gutter_foreground = Some(syntect::highlighting::Color {
523            r: 0x4F,
524            g: 0x4F,
525            b: 0x5E,
526            a: 0x90,
527        });
528        // No markup.list.bullet scope, so muted falls back to gutter_foreground
529        let theme = Theme::from(&syntect);
530
531        // blend(0x4F, 0x1A) = (0x4F*0x90 + 0x1A*(255-0x90)) / 255
532        let blend = |f: u16, b: u16| -> u8 { ((f * 0x90 + b * (255 - 0x90)) / 255) as u8 };
533        let expected = Color::Rgb {
534            r: blend(0x4F, 0x1A),
535            g: blend(0x4F, 0x1A),
536            b: blend(0x5E, 0x2E),
537        };
538        assert_eq!(theme.muted(), expected);
539    }
540
541    #[test]
542    fn malformed_theme_falls_back_to_default() {
543        let temp_dir = TempDir::new().unwrap();
544        let theme_path = temp_dir.path().join("broken.tmTheme");
545        fs::write(&theme_path, "not valid xml").unwrap();
546
547        let loaded = Theme::load_from_path(&theme_path);
548
549        let default = Theme::default();
550        assert_eq!(loaded.primary(), default.primary());
551        assert_eq!(loaded.code_bg(), default.code_bg());
552    }
553}