1use super::{Color, Theme, darken_color};
2use std::path::Path;
3use std::sync::Arc;
4
5pub(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 pub fn syntect_theme(&self) -> &syntect::highlighting::Theme {
24 &self.syntect_theme
25 }
26
27 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
154fn 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
173fn 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#[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#[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 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 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 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 let theme = Theme::from(&syntect);
351
352 #[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}