Skip to main content

wisp/
theme.rs

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    /// The built-in theme is Sage, not syntect's bundled base16-ocean.dark with
156    /// Sage's three main colors bolted on: that stand-in drags Spacegray's red
157    /// and orange through every scope-derived role in the palette.
158    #[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}