Skip to main content

diffs_rs/
theme.rs

1use std::{collections::HashMap, str::FromStr};
2
3use anyhow::Result;
4use palette::{
5    FromColor, Lab, Mix, Srgb, Xyz,
6    chromatic_adaptation::AdaptIntoUnclamped,
7    white_point::{D50, D65},
8};
9use ratatui::style::Color;
10use serde::Deserialize;
11use syntect::highlighting::{StyleModifier, Theme, ThemeItem};
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
14pub enum Appearance {
15    #[default]
16    Dark,
17    Light,
18}
19
20impl Appearance {
21    pub fn toggle(self) -> Self {
22        if self == Self::Dark {
23            Self::Light
24        } else {
25            Self::Dark
26        }
27    }
28    pub fn name(self) -> &'static str {
29        if self == Self::Dark { "dark" } else { "light" }
30    }
31}
32
33#[derive(Clone)]
34pub struct Colors {
35    pub bg: Color,
36    pub fg: Color,
37    pub dim: Color,
38    pub chrome: Color,
39    pub separator: Color,
40    pub empty: Color,
41    pub context: Color,
42    pub added: Color,
43    pub deleted: Color,
44    pub accent: Color,
45    pub add_bg: Color,
46    pub del_bg: Color,
47    pub add_word: Color,
48    pub del_word: Color,
49}
50
51#[derive(Deserialize)]
52struct ThemeData {
53    colors: HashMap<String, String>,
54    #[serde(rename = "tokenColors")]
55    tokens: Vec<Token>,
56}
57
58#[derive(Deserialize)]
59struct Token {
60    scope: serde_json::Value,
61    settings: HashMap<String, String>,
62}
63
64fn rgb(value: &str) -> Result<Color> {
65    let value = value.trim_start_matches('#');
66    let n = u32::from_str_radix(value.get(..6).unwrap_or(value), 16)?;
67    Ok(Color::Rgb((n >> 16) as u8, (n >> 8) as u8, n as u8))
68}
69
70fn syntect_color(color: Color) -> syntect::highlighting::Color {
71    let Color::Rgb(r, g, b) = color else {
72        unreachable!("theme colors are RGB")
73    };
74    syntect::highlighting::Color { r, g, b, a: 255 }
75}
76
77pub fn load(appearance: Appearance) -> Result<(Colors, Theme)> {
78    let input = match appearance {
79        Appearance::Dark => include_str!("../assets/pierre/pierre-dark.json"),
80        Appearance::Light => include_str!("../assets/pierre/pierre-light.json"),
81    };
82    let data: ThemeData = serde_json::from_str(input)?;
83    let get = |key: &str| -> Result<Color> { rgb(&data.colors[key]) };
84    let bg = get("editor.background")?;
85    let fg = get("editor.foreground")?;
86    let added = get("gitDecoration.addedResourceForeground")?;
87    let deleted = get("gitDecoration.deletedResourceForeground")?;
88    let dark = appearance == Appearance::Dark;
89    let add_bg = lab_mix(bg, added, if dark { 0.20 } else { 0.12 });
90    let del_bg = lab_mix(bg, deleted, if dark { 0.20 } else { 0.12 });
91    let colors = Colors {
92        bg,
93        fg,
94        added,
95        deleted,
96        dim: get("editorLineNumber.foreground")?,
97        chrome: get("sideBar.background")?,
98        accent: get("gitDecoration.modifiedResourceForeground")?,
99        separator: lab_mix(bg, fg, if dark { 0.15 } else { 0.04 }),
100        empty: lab_mix(bg, fg, if dark { 0.18 } else { 0.10 }),
101        context: lab_mix(bg, fg, if dark { 0.075 } else { 0.015 }),
102        add_bg,
103        del_bg,
104        add_word: alpha_mix(add_bg, added, if dark { 0.20 } else { 0.15 }),
105        del_word: alpha_mix(del_bg, deleted, if dark { 0.20 } else { 0.15 }),
106    };
107    let mut syntax = Theme::default();
108    syntax.settings.foreground = Some(syntect_color(fg));
109    syntax.settings.background = Some(syntect_color(bg));
110    for token in data.tokens {
111        let scopes = match token.scope {
112            serde_json::Value::String(s) => s,
113            serde_json::Value::Array(items) => items
114                .iter()
115                .filter_map(|s| s.as_str())
116                .collect::<Vec<_>>()
117                .join(","),
118            _ => continue,
119        };
120        if let Some(fg) = token.settings.get("foreground") {
121            syntax.scopes.push(ThemeItem {
122                scope: FromStr::from_str(&scopes)?,
123                style: StyleModifier {
124                    foreground: Some(syntect_color(rgb(fg)?)),
125                    ..StyleModifier::default()
126                },
127            });
128        }
129    }
130    Ok((colors, syntax))
131}
132
133fn components(color: Color) -> (u8, u8, u8) {
134    let Color::Rgb(r, g, b) = color else {
135        unreachable!("theme colors are RGB")
136    };
137    (r, g, b)
138}
139
140fn lab_mix(a: Color, b: Color, amount: f32) -> Color {
141    let (r, g, blue) = components(a);
142    let a: Xyz<D65, f32> = Xyz::from_color(Srgb::new(r, g, blue).into_format::<f32>());
143    let a: Xyz<D50, f32> = a.adapt_into_unclamped();
144    let a = Lab::from_color(a);
145    let (r, g, blue) = components(b);
146    let b: Xyz<D65, f32> = Xyz::from_color(Srgb::new(r, g, blue).into_format::<f32>());
147    let b: Xyz<D50, f32> = b.adapt_into_unclamped();
148    let b = Lab::from_color(b);
149    let mixed: Xyz<D50, f32> = Xyz::from_color(a.mix(b, amount));
150    let adapted: Xyz<D65, f32> = mixed.adapt_into_unclamped();
151    let color: Srgb<u8> = Srgb::from_color(adapted).into_format();
152    Color::Rgb(color.red, color.green, color.blue)
153}
154
155fn alpha_mix(a: Color, b: Color, amount: f32) -> Color {
156    let a = components(a);
157    let b = components(b);
158    let blend = |a: u8, b: u8| (a as f32 * (1.0 - amount) + b as f32 * amount).round() as u8;
159    Color::Rgb(blend(a.0, b.0), blend(a.1, b.1), blend(a.2, b.2))
160}