use super::{Color, Theme, darken_color};
use std::path::Path;
use std::sync::Arc;
pub(super) fn parse_default_syntect_theme() -> syntect::highlighting::Theme {
let cursor = std::io::Cursor::new(include_bytes!("../../assets/sage.tmTheme"));
syntect::highlighting::ThemeSet::load_from_reader(&mut std::io::BufReader::new(cursor))
.expect("embedded sage.tmTheme is valid")
}
const DEFAULT_FG: Color = Color::Rgb { r: 0xBF, g: 0xBD, b: 0xB6 };
const DEFAULT_BG: Color = Color::Rgb { r: 0x1E, g: 0x1E, b: 0x2E };
const DEFAULT_CODE_BG: Color = Color::Rgb { r: 40, g: 40, b: 40 };
const DEFAULT_ACCENT: Color = Color::Rgb { r: 255, g: 215, b: 0 };
const DEFAULT_HIGHLIGHT_BG: Color = Color::Rgb { r: 0x1a, g: 0x4a, b: 0x50 };
impl Theme {
pub fn syntect_theme(&self) -> &syntect::highlighting::Theme {
&self.syntect_theme
}
pub fn load_from_path(path: &Path) -> Self {
use syntect::highlighting::ThemeSet;
use tracing::warn;
match ThemeSet::get_theme(path) {
Ok(syntect_theme) => Self::from(&syntect_theme),
Err(e) => {
warn!("Failed to load theme from {}: {e}. Falling back to defaults.", path.display());
Self::default()
}
}
}
}
impl From<&syntect::highlighting::Theme> for Theme {
#[allow(clippy::similar_names)]
fn from(syntect: &syntect::highlighting::Theme) -> Self {
let syntect_bg =
syntect.settings.background.unwrap_or(syntect::highlighting::Color { r: 0x1E, g: 0x1E, b: 0x2E, a: 0xFF });
let accent = syntect.settings.caret.map_or(DEFAULT_ACCENT, color_from_syntect);
let text_secondary = derive_text_secondary(syntect);
let heading = resolve_scope_fg(syntect, "markup.heading.markdown")
.or_else(|| resolve_scope_fg(syntect, "markup.heading"))
.unwrap_or(accent);
let link = resolve_scope_fg(syntect, "markup.underline.link")
.or_else(|| resolve_scope_fg(syntect, "markup.link"))
.unwrap_or(accent);
let blockquote = resolve_scope_fg(syntect, "markup.quote").unwrap_or(text_secondary);
let muted = resolve_scope_fg(syntect, "markup.list.bullet")
.or_else(|| syntect.settings.gutter_foreground.map(|c| composite_over(c, syntect_bg)))
.unwrap_or(text_secondary);
let fg = syntect.settings.foreground.map_or(DEFAULT_FG, color_from_syntect);
let inline_code_fg = resolve_scope_fg(syntect, "markup.inline.raw.string.markdown")
.or_else(|| resolve_scope_fg(syntect, "markup.raw"))
.unwrap_or(fg);
let error = resolve_scope_fg(syntect, "markup.deleted")
.or_else(|| resolve_scope_fg(syntect, "markup.deleted.diff"))
.or_else(|| resolve_scope_fg(syntect, "invalid"))
.unwrap_or(accent);
let warning = resolve_scope_fg(syntect, "constant.numeric").unwrap_or(accent);
let success = resolve_scope_fg(syntect, "markup.inserted")
.or_else(|| resolve_scope_fg(syntect, "markup.inserted.diff"))
.or_else(|| resolve_scope_fg(syntect, "string"))
.unwrap_or(accent);
let info = resolve_scope_fg(syntect, "entity.name.function")
.or_else(|| resolve_scope_fg(syntect, "support.function"))
.unwrap_or(accent);
let secondary = resolve_scope_fg(syntect, "keyword")
.or_else(|| resolve_scope_fg(syntect, "storage.type"))
.unwrap_or(accent);
let (bg, highlight_bg, highlight_fg, inline_code_bg) = resolve_bg_colors(syntect, syntect_bg, fg);
let sidebar_bg = nudge_toward_fg(bg, fg);
let diff_added_fg = resolve_scope_fg(syntect, "markup.inserted.diff")
.or_else(|| resolve_scope_fg(syntect, "markup.inserted"))
.or_else(|| resolve_scope_fg(syntect, "string"))
.unwrap_or(accent);
let diff_removed_fg = resolve_scope_fg(syntect, "markup.deleted.diff")
.or_else(|| resolve_scope_fg(syntect, "markup.deleted"))
.unwrap_or(accent);
Self {
fg,
bg,
accent,
highlight_bg,
highlight_fg,
text_secondary,
code_fg: inline_code_fg,
code_bg: inline_code_bg,
heading,
link,
blockquote,
muted,
success,
warning,
error,
info,
secondary,
sidebar_bg,
diff_added_fg,
diff_removed_fg,
diff_added_bg: darken_color(diff_added_fg),
diff_removed_bg: darken_color(diff_removed_fg),
syntect_theme: Arc::new(syntect.clone()),
}
}
}
#[allow(clippy::similar_names)]
fn resolve_bg_colors(
syntect: &syntect::highlighting::Theme,
syntect_bg: syntect::highlighting::Color,
fg: Color,
) -> (Color, Color, Color, Color) {
let bg = syntect.settings.background.map_or(DEFAULT_BG, color_from_syntect);
let highlight_bg = syntect
.settings
.line_highlight
.or(syntect.settings.selection)
.map_or(DEFAULT_HIGHLIGHT_BG, |c| composite_over(c, syntect_bg));
let highlight_fg = syntect.settings.selection_foreground.map_or(fg, color_from_syntect);
let inline_code_bg = syntect.settings.background.map_or(DEFAULT_CODE_BG, color_from_syntect);
(bg, highlight_bg, highlight_fg, inline_code_bg)
}
fn resolve_scope_fg(theme: &syntect::highlighting::Theme, scope_str: &str) -> Option<Color> {
use syntect::highlighting::Highlighter;
use syntect::parsing::Scope;
let scope = Scope::new(scope_str).ok()?;
let highlighter = Highlighter::new(theme);
let style = highlighter.style_for_stack(&[scope]);
let resolved = style.foreground;
let default_fg = theme.settings.foreground?;
if resolved.r == default_fg.r && resolved.g == default_fg.g && resolved.b == default_fg.b {
return None;
}
Some(color_from_syntect(resolved))
}
fn derive_text_secondary(theme: &syntect::highlighting::Theme) -> Color {
use syntect::highlighting::Color as SyntectColor;
let fg = theme.settings.foreground.unwrap_or(SyntectColor { r: 0xBF, g: 0xBD, b: 0xB6, a: 0xFF });
let bg = theme.settings.background.unwrap_or(SyntectColor { r: 0x28, g: 0x28, b: 0x28, a: 0xFF });
#[allow(clippy::cast_possible_truncation)]
let blend = |f: u8, b: u8| -> u8 { ((u16::from(f) * 60 + u16::from(b) * 40) / 100) as u8 };
Color::Rgb { r: blend(fg.r, bg.r), g: blend(fg.g, bg.g), b: blend(fg.b, bg.b) }
}
#[allow(clippy::cast_possible_truncation)]
fn nudge_toward_fg(bg: Color, fg: Color) -> Color {
match (bg, fg) {
(Color::Rgb { r: br, g: bg_g, b: bb }, Color::Rgb { r: fr, g: fg_g, b: fb }) => {
let blend = |b: u8, f: u8| -> u8 { ((u16::from(b) * 95 + u16::from(f) * 5) / 100) as u8 };
Color::Rgb { r: blend(br, fr), g: blend(bg_g, fg_g), b: blend(bb, fb) }
}
_ => bg,
}
}
fn color_from_syntect(color: syntect::highlighting::Color) -> Color {
Color::Rgb { r: color.r, g: color.g, b: color.b }
}
#[allow(clippy::cast_possible_truncation)]
fn composite_over(fg: syntect::highlighting::Color, bg: syntect::highlighting::Color) -> Color {
let a = u16::from(fg.a);
let blend = |f: u8, b: u8| -> u8 { ((u16::from(f) * a + u16::from(b) * (255 - a)) / 255) as u8 };
Color::Rgb { r: blend(fg.r, bg.r), g: blend(fg.g, bg.g), b: blend(fg.b, bg.b) }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use syntect::highlighting::ThemeSettings;
use tempfile::TempDir;
fn bare_syntect_theme() -> syntect::highlighting::Theme {
syntect::highlighting::Theme {
name: Some("Bare".into()),
author: None,
settings: ThemeSettings {
foreground: Some(syntect::highlighting::Color { r: 0xCC, g: 0xCC, b: 0xCC, a: 0xFF }),
background: Some(syntect::highlighting::Color { r: 0x11, g: 0x11, b: 0x11, a: 0xFF }),
caret: Some(syntect::highlighting::Color { r: 0xAA, g: 0xBB, b: 0xCC, a: 0xFF }),
..ThemeSettings::default()
},
scopes: Vec::new(),
}
}
const LOADABLE_TMTHEME: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>name</key>
<string>Loadable</string>
<key>settings</key>
<array>
<dict>
<key>settings</key>
<dict>
<key>foreground</key>
<string>#112233</string>
<key>background</key>
<string>#000000</string>
<key>selection</key>
<string>#334455</string>
</dict>
</dict>
</array>
</dict>
</plist>"#;
#[test]
fn bare_theme_falls_back_to_accent() {
let accent = Color::Rgb { r: 0xAA, g: 0xBB, b: 0xCC };
let syntect = bare_syntect_theme();
let theme = Theme::from(&syntect);
assert_eq!(theme.heading(), accent);
assert_eq!(theme.link(), accent);
assert_eq!(theme.error(), accent);
assert_eq!(theme.warning(), accent);
assert_eq!(theme.success(), accent);
assert_eq!(theme.info(), accent);
assert_eq!(theme.secondary(), accent);
assert_eq!(theme.diff_added_fg(), accent);
assert_eq!(theme.diff_removed_fg(), accent);
}
#[test]
fn valid_theme_file_loads_from_path() {
let temp_dir = TempDir::new().unwrap();
let theme_path = temp_dir.path().join("custom.tmTheme");
fs::write(&theme_path, LOADABLE_TMTHEME).unwrap();
let loaded = Theme::load_from_path(&theme_path);
assert_eq!(loaded.text_primary(), Color::Rgb { r: 0x11, g: 0x22, b: 0x33 });
}
#[test]
fn loaded_theme_preserves_syntect_theme_when_cloned() {
let temp_dir = TempDir::new().unwrap();
let theme_path = temp_dir.path().join("custom.tmTheme");
fs::write(&theme_path, LOADABLE_TMTHEME).unwrap();
let loaded = Theme::load_from_path(&theme_path);
let cloned = loaded.clone();
let syntect = cloned.syntect_theme();
assert_eq!(
syntect.settings.foreground,
Some(syntect::highlighting::Color { r: 0x11, g: 0x22, b: 0x33, a: 0xFF })
);
assert_eq!(
syntect.settings.selection,
Some(syntect::highlighting::Color { r: 0x33, g: 0x44, b: 0x55, a: 0xFF })
);
}
#[test]
fn highlight_bg_prefers_line_highlight_over_selection() {
let mut syntect = bare_syntect_theme();
syntect.settings.line_highlight = Some(syntect::highlighting::Color { r: 0x31, g: 0x32, b: 0x44, a: 0xFF });
syntect.settings.selection = Some(syntect::highlighting::Color { r: 0x99, g: 0x99, b: 0x99, a: 0x40 });
let theme = Theme::from(&syntect);
assert_eq!(theme.highlight_bg(), Color::Rgb { r: 0x31, g: 0x32, b: 0x44 });
}
#[test]
fn highlight_bg_falls_back_to_selection_without_line_highlight() {
let mut syntect = bare_syntect_theme();
syntect.settings.line_highlight = None;
syntect.settings.selection = Some(syntect::highlighting::Color { r: 0x33, g: 0x44, b: 0x55, a: 0xFF });
let theme = Theme::from(&syntect);
assert_eq!(theme.highlight_bg(), Color::Rgb { r: 0x33, g: 0x44, b: 0x55 });
}
#[test]
fn highlight_bg_composites_alpha_over_background() {
let mut syntect = bare_syntect_theme();
syntect.settings.background = Some(syntect::highlighting::Color { r: 0x21, g: 0x21, b: 0x21, a: 0xFF });
syntect.settings.line_highlight = Some(syntect::highlighting::Color { r: 0x00, g: 0x00, b: 0x00, a: 0x50 });
let theme = Theme::from(&syntect);
let expected = Color::Rgb { r: 0x16, g: 0x16, b: 0x16 };
assert_eq!(theme.highlight_bg(), expected);
}
#[test]
fn muted_composites_gutter_foreground_alpha() {
let mut syntect = bare_syntect_theme();
syntect.settings.background = Some(syntect::highlighting::Color { r: 0x1A, g: 0x1A, b: 0x2E, a: 0xFF });
syntect.settings.gutter_foreground = Some(syntect::highlighting::Color { r: 0x4F, g: 0x4F, b: 0x5E, a: 0x90 });
let theme = Theme::from(&syntect);
#[allow(clippy::cast_possible_truncation)]
let blend = |f: u16, b: u16| -> u8 { ((f * 0x90 + b * (255 - 0x90)) / 255) as u8 };
let expected = Color::Rgb { r: blend(0x4F, 0x1A), g: blend(0x4F, 0x1A), b: blend(0x5E, 0x2E) };
assert_eq!(theme.muted(), expected);
}
#[test]
fn malformed_theme_falls_back_to_default() {
let temp_dir = TempDir::new().unwrap();
let theme_path = temp_dir.path().join("broken.tmTheme");
fs::write(&theme_path, "not valid xml").unwrap();
let loaded = Theme::load_from_path(&theme_path);
let default = Theme::default();
assert_eq!(loaded.primary(), default.primary());
assert_eq!(loaded.code_bg(), default.code_bg());
}
}