1use ravenrs_core::Color;
4use serde_json::{Map, Value, json};
5
6use crate::{Palette, Radii, Semantic, TextStyle, Theme, Typography};
7
8const RADIUS_RANGE: std::ops::RangeInclusive<f64> = 0.0..=64.0;
10const SIZE_RANGE: std::ops::RangeInclusive<f64> = 8.0..=96.0;
12
13#[cfg_attr(feature = "live", ravenrs_live::expose)]
14impl Semantic {
15 pub const TOKENS: [Self; 15] = [
17 Self::Canvas,
18 Self::Surface,
19 Self::SurfaceElevated,
20 Self::Control,
21 Self::Border,
22 Self::Ink,
23 Self::InkMuted,
24 Self::Accent,
25 Self::AccentHover,
26 Self::AccentPressed,
27 Self::OnAccent,
28 Self::FocusRing,
29 Self::Success,
30 Self::Warning,
31 Self::Danger,
32 ];
33
34 pub const fn token(self) -> &'static str {
36 match self {
37 Self::Canvas => "bg-000",
38 Self::Surface => "bg-100",
39 Self::SurfaceElevated => "bg-200",
40 Self::Control => "bg-300",
41 Self::Border => "border",
42 Self::Ink => "ink",
43 Self::InkMuted => "ink-muted",
44 Self::Accent => "accent",
45 Self::AccentHover => "accent-hover",
46 Self::AccentPressed => "accent-pressed",
47 Self::OnAccent => "on-accent",
48 Self::FocusRing => "focus-ring",
49 Self::Success => "success",
50 Self::Warning => "warning",
51 Self::Danger => "danger",
52 Self::Scrim => "scrim",
53 Self::Chart1 => "chart-1",
54 Self::Chart2 => "chart-2",
55 Self::Chart3 => "chart-3",
56 Self::Chart4 => "chart-4",
57 Self::Chart5 => "chart-5",
58 Self::ChartGrid => "chart-grid",
59 }
60 }
61}
62
63#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
64impl Palette {
65 pub fn set(&mut self, role: Semantic, color: Color) {
67 match role {
68 Semantic::Canvas => self.bg_000 = color,
69 Semantic::Surface => self.bg_100 = color,
70 Semantic::SurfaceElevated => self.bg_200 = color,
71 Semantic::Control => self.bg_300 = color,
72 Semantic::Border => self.border = color,
73 Semantic::Ink => self.ink = color,
74 Semantic::InkMuted => self.ink_muted = color,
75 Semantic::Accent => self.accent = color,
76 Semantic::AccentHover => self.accent_hover = color,
77 Semantic::AccentPressed => self.accent_pressed = color,
78 Semantic::OnAccent => self.on_accent = color,
79 Semantic::FocusRing => self.focus_ring = color,
80 Semantic::Success => self.success = color,
81 Semantic::Warning => self.warning = color,
82 Semantic::Danger => self.danger = color,
83 _ => {}
84 }
85 }
86}
87
88#[cfg_attr(feature = "live", ravenrs_live::expose)]
89impl Radii {
90 pub const TOKENS: [&'static str; 4] = ["sm", "md", "lg", "xl"];
92
93 pub fn get(&self, token: &str) -> Option<f32> {
95 match token {
96 "sm" => Some(self.sm),
97 "md" => Some(self.md),
98 "lg" => Some(self.lg),
99 "xl" => Some(self.xl),
100 _ => None,
101 }
102 }
103
104 pub fn set(&mut self, token: &str, radius: f32) {
106 match token {
107 "sm" => self.sm = radius,
108 "md" => self.md = radius,
109 "lg" => self.lg = radius,
110 "xl" => self.xl = radius,
111 _ => {}
112 }
113 }
114}
115
116#[cfg_attr(feature = "live", ravenrs_live::expose)]
117impl TextStyle {
118 pub const TOKENS: [Self; 7] =
120 [Self::Metric, Self::Display, Self::Title, Self::Headline, Self::Body, Self::Callout, Self::Caption];
121
122 pub const fn token(self) -> &'static str {
124 match self {
125 Self::Metric => "metric",
126 Self::Display => "display",
127 Self::Title => "title",
128 Self::Headline => "headline",
129 Self::Body => "body",
130 Self::Callout => "callout",
131 Self::Caption => "caption",
132 }
133 }
134}
135
136#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
137impl Typography {
138 pub fn set_size(&mut self, style: TextStyle, size: f32) {
140 let step = match style {
141 TextStyle::Metric => &mut self.metric,
142 TextStyle::Display => &mut self.display,
143 TextStyle::Title => &mut self.title,
144 TextStyle::Headline => &mut self.headline,
145 TextStyle::Body => &mut self.body,
146 TextStyle::Callout => &mut self.callout,
147 TextStyle::Caption => &mut self.caption,
148 };
149 let proportion = step.line_height / step.size.max(1.0);
150 step.size = size;
151 step.line_height = (size * proportion).round();
152 }
153}
154
155fn to_hex(color: Color) -> String {
157 let byte = |channel: f32| (channel.clamp(0.0, 1.0) * 255.0).round() as u8;
158 let (r, g, b, a) = (byte(color.r), byte(color.g), byte(color.b), byte(color.a));
159 if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") }
160}
161
162fn from_hex(text: &str) -> Option<Color> {
163 let digits = text.strip_prefix('#')?;
164 let value = u32::from_str_radix(digits, 16).ok()?;
165 match digits.len() {
166 6 => Some(Color::hex(value)),
167 8 => Some(Color::hex_alpha(value)),
168 _ => None,
169 }
170}
171
172#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
173impl Theme {
174 pub const fn builtin() -> [Self; 3] {
176 [Self::raven_dark(), Self::raven_light(), Self::raven_glass()]
177 }
178
179 pub fn base(&self) -> Self {
181 Self::builtin().into_iter().find(|theme| theme.name == self.name).unwrap_or(*self)
182 }
183
184 fn changes(&self) -> (Map<String, Value>, Map<String, Value>, Map<String, Value>) {
186 let base = self.base();
187 let colors = Semantic::TOKENS
188 .into_iter()
189 .filter(|role| to_hex(self.palette.get(*role)) != to_hex(base.palette.get(*role)))
190 .map(|role| (role.token().to_owned(), json!(to_hex(self.palette.get(role)))))
191 .collect();
192 let radii = Radii::TOKENS
193 .into_iter()
194 .filter(|token| self.radii.get(token) != base.radii.get(token))
195 .map(|token| (token.to_owned(), json!(self.radii.get(token))))
196 .collect();
197 let sizes = TextStyle::TOKENS
198 .into_iter()
199 .filter(|style| self.typography.get(*style).size != base.typography.get(*style).size)
200 .map(|style| (style.token().to_owned(), json!(self.typography.get(style).size)))
201 .collect();
202 (colors, radii, sizes)
203 }
204
205 pub fn changed_tokens(&self) -> usize {
207 let (colors, radii, sizes) = self.changes();
208 colors.len() + radii.len() + sizes.len()
209 }
210
211 pub fn to_tokens_json(&self) -> String {
214 let (colors, radii, sizes) = self.changes();
215 let tokens = json!({ "base": self.name, "color": colors, "radius": radii, "type": sizes });
216 serde_json::to_string_pretty(&tokens).unwrap_or_default()
217 }
218
219 pub fn from_tokens_json(text: &str) -> Result<Self, String> {
222 let tokens: Value = serde_json::from_str(text).map_err(|error| error.to_string())?;
223 let name = tokens.get("base").and_then(Value::as_str).ok_or("no base theme")?;
224 let mut theme = Self::builtin()
225 .into_iter()
226 .find(|theme| theme.name == name)
227 .ok_or_else(|| format!("unknown base theme: {name}"))?;
228 let part = |key: &str| tokens.get(key).and_then(Value::as_object).cloned().unwrap_or_default();
229 for (token, value) in part("color") {
230 let role = Semantic::TOKENS
231 .into_iter()
232 .find(|role| role.token() == token)
233 .ok_or_else(|| format!("unknown color: {token}"))?;
234 let color = value.as_str().and_then(from_hex).ok_or_else(|| format!("bad color: {token}"))?;
235 theme.palette.set(role, color);
236 }
237 for (token, value) in part("radius") {
238 if !Radii::TOKENS.contains(&token.as_str()) {
239 return Err(format!("unknown radius: {token}"));
240 }
241 let radius = value
242 .as_f64()
243 .filter(|r| RADIUS_RANGE.contains(r))
244 .ok_or_else(|| format!("bad radius: {token}"))?;
245 theme.radii.set(&token, radius as f32);
246 }
247 for (token, value) in part("type") {
248 let style = TextStyle::TOKENS
249 .into_iter()
250 .find(|style| style.token() == token)
251 .ok_or_else(|| format!("unknown text size: {token}"))?;
252 let size = value
253 .as_f64()
254 .filter(|s| SIZE_RANGE.contains(s))
255 .ok_or_else(|| format!("bad text size: {token}"))?;
256 theme.typography.set_size(style, size as f32);
257 }
258 Ok(theme)
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn every_token_is_read_and_changed_by_its_name() {
268 for role in Semantic::TOKENS {
269 let mut palette = Theme::raven_dark().palette;
270 let color = Color::hex(0x123456);
271 palette.set(role, color);
272 assert_eq!(palette.get(role), color, "{}", role.token());
273 }
274 let mut radii = Theme::raven_dark().radii;
275 for token in Radii::TOKENS {
276 radii.set(token, 3.0);
277 assert_eq!(radii.get(token), Some(3.0));
278 }
279 }
280
281 #[test]
282 fn what_changed_goes_to_text_and_comes_back() {
283 let mut theme = Theme::raven_light();
284 assert_eq!(theme.changed_tokens(), 0);
285 theme.palette.set(Semantic::Accent, Color::hex(0xff0066));
286 theme.palette.set(Semantic::Surface, Color::hex_alpha(0x11223344));
287 theme.radii.set("md", 4.0);
288 theme.typography.set_size(TextStyle::Body, 16.0);
289 assert_eq!(theme.changed_tokens(), 4);
290 let again = Theme::from_tokens_json(&theme.to_tokens_json()).expect("reads");
291 assert_eq!(again.name, "Raven Light");
292 assert_eq!(to_hex(again.palette.accent), "#ff0066");
293 assert_eq!(to_hex(again.palette.bg_100), "#11223344");
294 assert_eq!(again.radii.md, 4.0);
295 assert_eq!(again.typography.body.size, 16.0);
296 assert_eq!(again.changed_tokens(), 4);
297 }
298
299 #[test]
300 fn a_line_keeps_its_proportion_to_its_size() {
301 let mut theme = Theme::raven_dark();
302 let before = theme.typography.body;
303 theme.typography.set_size(TextStyle::Body, before.size * 2.0);
304 assert_eq!(theme.typography.body.line_height, (before.line_height * 2.0).round());
305 }
306
307 #[test]
308 fn text_with_anything_strange_is_refused_whole() {
309 for bad in [
310 "not json",
311 "{}",
312 r##"{"base":"Other"}"##,
313 r##"{"base":"Raven Dark","color":{"nothing":"#000000"}}"##,
314 r##"{"base":"Raven Dark","color":{"ink":"black"}}"##,
315 r##"{"base":"Raven Dark","radius":{"md":9000}}"##,
316 r##"{"base":"Raven Dark","radius":{"full":1}}"##,
317 r##"{"base":"Raven Dark","type":{"body":1}}"##,
318 ] {
319 assert!(Theme::from_tokens_json(bad).is_err(), "{bad}");
320 }
321 }
322}