Skip to main content

sequoia_style/
tokens.rs

1use sequoia_core::Color;
2
3use crate::Theme;
4
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct TokensError {
7    pub message: String,
8}
9
10impl TokensError {
11    fn new(message: impl Into<String>) -> Self {
12        Self {
13            message: message.into(),
14        }
15    }
16}
17
18impl std::fmt::Display for TokensError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(f, "tokens: {}", self.message)
21    }
22}
23
24impl std::error::Error for TokensError {}
25
26impl Theme {
27    pub fn from_tokens_str(src: &str) -> Result<Self, TokensError> {
28        let v: serde_json::Value =
29            serde_json::from_str(src).map_err(|e| TokensError::new(format!("bad JSON: {e}")))?;
30        let mut theme = Self::dark();
31        if let Some(color) = v.get("color") {
32            let obj = color
33                .as_object()
34                .ok_or_else(|| TokensError::new("'color' to obiekt { nazwa: hex }"))?;
35            for (key, val) in obj {
36                let hex = val.as_str().ok_or_else(|| {
37                    TokensError::new(format!("color.{key} is hex in quotes, not {val}"))
38                })?;
39                let c = Color::parse_hex(hex).ok_or_else(|| {
40                    TokensError::new(format!("color.{key} to hex #RRGGBB[AA], nie '{hex}'"))
41                })?;
42                match key.as_str() {
43                    "bg" | "background" => theme.background = c,
44                    "surface" => theme.surface = c,
45                    "border" => theme.border = c,
46                    "ink" | "text" => theme.text = c,
47                    "muted" | "text_secondary" => theme.text_secondary = c,
48                    "accent" => theme.accent = c,
49                    "accent_hover" => theme.accent_hover = c,
50                    "accent_pressed" => theme.accent_pressed = c,
51                    "disabled" => theme.disabled = c,
52                    _ => {}
53                }
54            }
55        }
56        if let Some(radius) = section_num(&v, "radius", &["md", "sm", "lg"])? {
57            theme.radius = radius;
58        }
59        if let Some(size) = section_num(&v, "font", &["md", "sm", "lg"])? {
60            theme.font_size = size;
61        }
62        Ok(theme)
63    }
64
65    pub fn from_tokens_file(path: &str) -> Result<Self, TokensError> {
66        let src = std::fs::read_to_string(path)
67            .map_err(|e| TokensError::new(format!("unable to read '{path}': {e}")))?;
68        Self::from_tokens_str(&src)
69    }
70}
71
72fn section_num(
73    v: &serde_json::Value,
74    section: &str,
75    keys: &[&str],
76) -> Result<Option<f32>, TokensError> {
77    let Some(obj) = v.get(section) else {
78        return Ok(None);
79    };
80    let obj = obj
81        .as_object()
82        .ok_or_else(|| TokensError::new(format!("'{section}' to obiekt {{ nazwa: liczba }}")))?;
83    for key in keys {
84        if let Some(n) = obj.get(*key) {
85            let n = n
86                .as_f64()
87                .ok_or_else(|| TokensError::new(format!("{section}.{key} to liczba, nie {n}")))?;
88            let n = n as f32;
89            if n.is_finite() {
90                return Ok(Some(n));
91            }
92            return Err(TokensError::new(format!(
93                "{section}.{key} must be finished"
94            )));
95        }
96    }
97    Ok(None)
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    const FULL: &str = r##"{
105        "color": {
106            "bg": "#141417", "surface": "#33363D", "surface2": "#232529",
107            "border": "#3C404A", "ink": "#E6E6E6", "muted": "#9A9AA0",
108            "accent": "#356FD1", "danger": "#B3261E", "ok": "#6AA84F", "warn": "#F0A63A"
109        },
110        "radius": { "sm": 6, "md": 8, "lg": 12 },
111        "gap": { "sm": 8 },
112        "font": { "sm": 12, "md": 14, "lg": 20 }
113    }"##;
114
115    #[test]
116    fn full_palette_maps_known_keys() {
117        let t = Theme::from_tokens_str(FULL).unwrap();
118        assert_eq!(t.background, Color::parse_hex("#141417").unwrap());
119        assert_eq!(t.surface, Color::parse_hex("#33363D").unwrap());
120        assert_eq!(t.text, Color::parse_hex("#E6E6E6").unwrap());
121        assert_eq!(t.text_secondary, Color::parse_hex("#9A9AA0").unwrap());
122        assert_eq!(t.accent, Color::parse_hex("#356FD1").unwrap());
123        assert_eq!(t.radius, 8.0);
124        assert_eq!(t.font_size, 14.0);
125
126        assert_eq!(t.accent_hover, Theme::dark().accent_hover);
127    }
128
129    #[test]
130    fn missing_keys_fall_back_to_dark() {
131        let t = Theme::from_tokens_str(r##"{"color": {"bg": "#000000"}}"##).unwrap();
132        let d = Theme::dark();
133        assert_eq!(t.background, Color::parse_hex("#000000").unwrap());
134        assert_eq!(t.surface, d.surface);
135        assert_eq!(t.radius, d.radius);
136        assert_eq!(t.font_size, d.font_size);
137    }
138
139    #[test]
140    fn empty_object_is_dark() {
141        assert_eq!(Theme::from_tokens_str("{}").unwrap(), Theme::dark());
142    }
143
144    #[test]
145    fn bad_hex_names_the_key() {
146        let err = Theme::from_tokens_str(r#"{"color": {"bg": "nie-kolor"}}"#).unwrap_err();
147        assert!(err.message.contains("color.bg"), "{err}");
148    }
149
150    #[test]
151    fn bad_json_is_rejected() {
152        assert!(Theme::from_tokens_str("{nie json").is_err());
153    }
154
155    #[test]
156    fn missing_file_names_the_path() {
157        let err = Theme::from_tokens_file("nie/ma/takiego.tokens.json").unwrap_err();
158        assert!(err.message.contains("nie/ma/takiego.tokens.json"), "{err}");
159    }
160}