Skip to main content

ravenrs_style/
tokens.rs

1//! A theme's tokens by name, to change one at a time and to keep what changed as text.
2
3use ravenrs_core::Color;
4use serde_json::{Map, Value, json};
5
6use crate::{Palette, Radii, Semantic, TextStyle, Theme, Typography};
7
8/// The sizes a radius can take when it comes from text.
9const RADIUS_RANGE: std::ops::RangeInclusive<f64> = 0.0..=64.0;
10/// The sizes text can take when it comes from text.
11const SIZE_RANGE: std::ops::RangeInclusive<f64> = 8.0..=96.0;
12
13#[cfg_attr(feature = "live", ravenrs_live::expose)]
14impl Semantic {
15    /// The colors a theme chooses. The others follow from these.
16    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    /// Its name in `docs/design-system/tokens.json`.
35    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    /// Changes the color of a role. Roles that follow from others, as in charts, stay as they are.
66    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    /// The radii a theme chooses, by name. `full` is not one: it only means a pill.
91    pub const TOKENS: [&'static str; 4] = ["sm", "md", "lg", "xl"];
92
93    /// A radius by name.
94    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    /// Changes a radius by name, if there is one.
105    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    /// Every step of the scale, largest first.
119    pub const TOKENS: [Self; 7] =
120        [Self::Metric, Self::Display, Self::Title, Self::Headline, Self::Body, Self::Callout, Self::Caption];
121
122    /// Its name in `docs/design-system/tokens.json`.
123    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    /// Changes the size of a step, with its line height in the same proportion.
139    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
155/// A color as `#rrggbb`, or `#rrggbbaa` when it lets something through.
156fn 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    /// The themes that come with RavenRS.
175    pub const fn builtin() -> [Self; 3] {
176        [Self::raven_dark(), Self::raven_light(), Self::raven_glass()]
177    }
178
179    /// The built-in theme this one started from, by its name; itself when it did not.
180    pub fn base(&self) -> Self {
181        Self::builtin().into_iter().find(|theme| theme.name == self.name).unwrap_or(*self)
182    }
183
184    /// What differs from [`base`](Self::base), in the shape of [`to_tokens_json`](Self::to_tokens_json).
185    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    /// How many tokens differ from [`base`](Self::base).
206    pub fn changed_tokens(&self) -> usize {
207        let (colors, radii, sizes) = self.changes();
208        colors.len() + radii.len() + sizes.len()
209    }
210
211    /// What differs from [`base`](Self::base) as JSON, with the same names as
212    /// `docs/design-system/tokens.json`, for keeping or handing over.
213    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    /// A theme from what [`to_tokens_json`](Self::to_tokens_json) wrote. Anything it does not
220    /// recognize, or a size too far out to read, refuses the whole text, which may come from anywhere.
221    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}