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 {
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 pub fn syntect_theme(&self) -> &syntect::highlighting::Theme {
44 &self.syntect_theme
45 }
46
47 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
205fn 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
224fn 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#[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#[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 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 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 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 let theme = Theme::from(&syntect);
530
531 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}