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}