1use crate::settings::{UiSettings, resolve_theme_file_path};
2use ratatui::style::Color;
3use std::path::Path;
4use std::sync::Arc;
5use syntect::highlighting::{Highlighter, Theme as SyntectTheme, ThemeSet};
6use syntect::parsing::Scope;
7use tracing::warn;
8
9#[derive(Clone, Debug)]
10pub struct Theme {
11 pub text_primary: Color,
12 pub text_secondary: Color,
13 pub background: Color,
14 pub sidebar_bg: Color,
15 pub accent: Color,
16 pub heading: Color,
17 pub link: Color,
18 pub blockquote: Color,
19 pub code_fg: Color,
20 pub code_bg: Color,
21 pub success: Color,
22 pub warning: Color,
23 pub error: Color,
24 pub info: Color,
25 pub muted: Color,
26 pub diff_added_fg: Color,
27 pub diff_added_bg: Color,
28 pub diff_removed_fg: Color,
29 pub diff_removed_bg: Color,
30 syntect: Arc<SyntectTheme>,
31}
32
33impl Theme {
34 pub fn load(settings: &UiSettings) -> Self {
35 resolve_theme_file_path(settings).map_or_else(Self::default, |path| Self::load_from_path(&path))
36 }
37
38 pub fn load_from_path(path: &Path) -> Self {
39 match ThemeSet::get_theme(path) {
40 Ok(theme) => Self::from_syntect(theme),
41 Err(error) => {
42 warn!("Failed to load theme from {}: {error}; using defaults", path.display());
43 Self::default()
44 }
45 }
46 }
47
48 pub fn syntect(&self) -> &SyntectTheme {
49 &self.syntect
50 }
51
52 fn from_syntect(theme: SyntectTheme) -> Self {
53 let text_primary = theme.settings.foreground.map_or(Color::Rgb(212, 221, 214), color_from_syntect);
54 let background = theme.settings.background.map_or(Color::Rgb(21, 29, 31), color_from_syntect);
55 let accent = theme.settings.caret.map_or(Color::Rgb(143, 188, 176), color_from_syntect);
56 let text_secondary = blend(text_primary, background, 60);
57 let sidebar_bg = blend(background, text_primary, 95);
58 let heading = scope_color(&theme, "markup.heading").unwrap_or(accent);
59 let link = scope_color(&theme, "markup.underline.link").unwrap_or(accent);
60 let blockquote = scope_color(&theme, "markup.quote").unwrap_or(text_secondary);
61 let muted = scope_color(&theme, "markup.list.bullet").unwrap_or(text_secondary);
62 let success =
63 scope_color(&theme, "markup.inserted").or_else(|| scope_color(&theme, "string")).unwrap_or(accent);
64 let warning = scope_color(&theme, "constant.numeric").unwrap_or(accent);
65 let error = scope_color(&theme, "markup.deleted").or_else(|| scope_color(&theme, "invalid")).unwrap_or(accent);
66 let info = scope_color(&theme, "entity.name.function").unwrap_or(accent);
67 let inline_code_foreground = scope_color(&theme, "markup.inline.raw.string.markdown").unwrap_or(text_primary);
68 let inline_code_background = blend(background, text_primary, 90);
69 let diff_added_fg = scope_color(&theme, "markup.inserted.diff").unwrap_or(success);
70 let diff_removed_fg = scope_color(&theme, "markup.deleted.diff").unwrap_or(error);
71
72 Self {
73 text_primary,
74 text_secondary,
75 background,
76 sidebar_bg,
77 accent,
78 heading,
79 link,
80 blockquote,
81 code_fg: inline_code_foreground,
82 code_bg: inline_code_background,
83 success,
84 warning,
85 error,
86 info,
87 muted,
88 diff_added_fg,
89 diff_added_bg: darken(diff_added_fg),
90 diff_removed_fg,
91 diff_removed_bg: darken(diff_removed_fg),
92 syntect: Arc::new(theme),
93 }
94 }
95}
96
97impl Default for Theme {
98 fn default() -> Self {
99 Self::from_syntect(native_default_syntect_theme())
100 }
101}
102
103fn native_default_syntect_theme() -> SyntectTheme {
104 let cursor = std::io::Cursor::new(include_bytes!("../assets/sage.tmTheme"));
105 ThemeSet::load_from_reader(&mut std::io::BufReader::new(cursor)).expect("embedded sage.tmTheme is valid")
106}
107
108fn scope_color(theme: &SyntectTheme, scope: &str) -> Option<Color> {
109 let scope = Scope::new(scope).ok()?;
110 let resolved = Highlighter::new(theme).style_for_stack(&[scope]).foreground;
111 let default = theme.settings.foreground?;
112 (resolved != default).then(|| color_from_syntect(resolved))
113}
114
115fn color_from_syntect(color: syntect::highlighting::Color) -> Color {
116 Color::Rgb(color.r, color.g, color.b)
117}
118
119fn darken(color: Color) -> Color {
120 match color {
121 Color::Rgb(r, g, b) => {
122 let darken_channel = |channel: u8| u8::try_from(u16::from(channel) * 3 / 10).unwrap_or(u8::MAX);
123 Color::Rgb(darken_channel(r), darken_channel(g), darken_channel(b))
124 }
125 other => other,
126 }
127}
128
129fn blend(first: Color, second: Color, first_percent: u16) -> Color {
130 match (first, second) {
131 (Color::Rgb(fr, fg, fb), Color::Rgb(sr, sg, sb)) => {
132 let mix = |a: u8, b: u8| {
133 let value = (u16::from(a) * first_percent + u16::from(b) * (100 - first_percent)) / 100;
134 u8::try_from(value).unwrap_or(u8::MAX)
135 };
136 Color::Rgb(mix(fr, sr), mix(fg, sg), mix(fb, sb))
137 }
138 (color, _) => color,
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn native_default_has_the_application_palette() {
148 let theme = Theme::default();
149 assert_eq!(theme.syntect().name.as_deref(), Some("Sage"));
150 assert_eq!(theme.background, Color::Rgb(0x15, 0x1d, 0x1f));
151 assert_eq!(theme.text_primary, Color::Rgb(0xd4, 0xdd, 0xd6));
152 assert_eq!(theme.accent, Color::Rgb(0x8f, 0xbc, 0xb0));
153 }
154
155 #[test]
159 fn native_default_derives_the_sage_scope_palette() {
160 let theme = Theme::default();
161 assert_eq!(theme.muted, Color::Rgb(0x5c, 0x70, 0x68));
162 assert_eq!(theme.blockquote, Color::Rgb(0xa0, 0xb4, 0xa8));
163 assert_eq!(theme.code_fg, Color::Rgb(0x8f, 0xbc, 0xb0));
164 assert_eq!(theme.heading, Color::Rgb(0xdf, 0xc2, 0x96));
165 assert_eq!(theme.link, Color::Rgb(0x82, 0xb1, 0xcc));
166 assert_eq!(theme.success, Color::Rgb(0xa7, 0xc0, 0x80));
167 assert_eq!(theme.warning, Color::Rgb(0xd8, 0xb5, 0x6a));
168 assert_eq!(theme.error, Color::Rgb(0xe6, 0x7e, 0x80));
169 assert_eq!(theme.info, Color::Rgb(0x82, 0xb1, 0xcc));
170 assert_eq!(theme.diff_added_fg, Color::Rgb(0xa7, 0xc0, 0x80));
171 assert_eq!(theme.diff_removed_fg, Color::Rgb(0xe6, 0x7e, 0x80));
172 }
173
174 #[test]
175 fn derives_distinct_code_background() {
176 let theme = Theme::default();
177 assert_ne!(theme.code_bg, theme.background);
178 }
179
180 #[test]
181 fn darkens_bright_rgb_channels_without_saturation() {
182 assert_eq!(darken(Color::Rgb(200, 180, 100)), Color::Rgb(60, 54, 30));
183 }
184}