1use std::fmt::{self, Write as _};
5
6use crate::condition::Media;
7use crate::sheet::{ORDER, TOKEN_LAYER};
8use crate::value::{Angle, Color, Length, Percentage, Time};
9
10pub trait TokenValue: fmt::Display + sealed::Sealed {
15 #[doc(hidden)]
16 fn from_var(css: String) -> Self;
17}
18
19mod sealed {
20 pub trait Sealed {}
21}
22
23macro_rules! token_values {
24 ($($ty:ty),*) => {$(
25 impl sealed::Sealed for $ty {}
26
27 impl TokenValue for $ty {
28 fn from_var(css: String) -> Self {
29 <$ty>::var(css)
30 }
31 }
32 )*};
33}
34
35token_values!(Color, Length, Percentage, Time, Angle);
36
37#[derive(Debug, Clone, PartialEq)]
57pub struct Token<T> {
58 name: &'static str,
59 default: T,
60}
61
62impl<T: TokenValue> Token<T> {
63 #[must_use]
71 pub fn new(name: &'static str, default: T) -> Self {
72 assert!(
73 !name.is_empty()
74 && name
75 .bytes()
76 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
77 "a token name is ASCII letters, digits, `-`, and `_`"
78 );
79 Self { name, default }
80 }
81
82 #[must_use]
84 pub fn name(&self) -> &'static str {
85 self.name
86 }
87
88 #[must_use]
90 pub fn var(&self) -> T {
91 T::from_var(format!("var(--{}, {})", self.name, self.default))
92 }
93}
94
95#[derive(Debug, Clone, Default, PartialEq)]
97pub struct Theme {
98 declarations: Vec<(Vec<Media>, &'static str, String)>,
99}
100
101impl Theme {
102 #[must_use]
104 pub fn new() -> Self {
105 Self::default()
106 }
107
108 #[must_use]
111 pub fn set<T: TokenValue>(mut self, token: &Token<T>, value: impl Into<T>) -> Self {
112 self.insert(Vec::new(), token.name, value.into().to_string());
113 self
114 }
115
116 #[must_use]
118 pub fn on(mut self, media: Media, theme: Self) -> Self {
119 for (mut conditions, name, value) in theme.declarations {
120 if let Err(at) = conditions.binary_search(&media) {
121 conditions.insert(at, media);
122 }
123 self.insert(conditions, name, value);
124 }
125 self
126 }
127
128 #[must_use]
130 pub fn merge(mut self, theme: Self) -> Self {
131 for (media, name, value) in theme.declarations {
132 self.insert(media, name, value);
133 }
134 self
135 }
136
137 fn insert(&mut self, media: Vec<Media>, name: &'static str, value: String) {
138 if let Some(old) = self
139 .declarations
140 .iter_mut()
141 .find(|(m, n, _)| *m == media && *n == name)
142 {
143 old.2 = value;
144 } else {
145 self.declarations.push((media, name, value));
146 }
147 }
148
149 #[must_use]
151 pub fn is_empty(&self) -> bool {
152 self.declarations.is_empty()
153 }
154
155 #[must_use]
159 pub fn to_css(&self) -> String {
160 let mut groups: Vec<&Vec<Media>> = self.declarations.iter().map(|(m, _, _)| m).collect();
161 groups.sort();
162 groups.dedup();
163 let mut out = format!("{ORDER}@layer {TOKEN_LAYER}{{");
164 for media in groups {
165 if !media.is_empty() {
166 out.push_str("@media ");
167 for (i, m) in media.iter().enumerate() {
168 if i > 0 {
169 out.push_str(" and ");
170 }
171 out.push_str(m.query());
172 }
173 out.push('{');
174 }
175 out.push_str(":root{");
176 let mut first = true;
177 for (_, name, value) in self.declarations.iter().filter(|(m, _, _)| m == media) {
178 if !first {
179 out.push(';');
180 }
181 first = false;
182 let _ = write!(out, "--{name}:{value}");
183 }
184 out.push('}');
185 if !media.is_empty() {
186 out.push('}');
187 }
188 }
189 out.push('}');
190 out
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::{Theme, Token};
197 use crate::value::{Color, px, rem};
198 use crate::{Media, Sheet, Style};
199
200 #[test]
201 fn reads_a_token_with_its_default_as_fallback() {
202 let space = Token::new("space-2", rem(0.5));
203 let style = Style::new().padding(space.var());
204 let mut sheet = Sheet::new();
205 sheet.add(&style);
206 assert!(sheet.to_css().contains("{padding:var(--space-2, 0.5rem)}"));
207 }
208
209 #[test]
210 fn sets_a_token_on_an_element() {
211 let brand = Token::new("brand", Color::RED);
212 let style = Style::new().token(&brand, Color::BLUE);
213 assert_eq!(style.class_names().collect::<Vec<_>>(), ["--brand_blue"]);
214 let mut sheet = Sheet::new();
215 sheet.add(&style);
216 assert!(sheet.to_css().ends_with("{.--brand_blue{--brand:blue}}"));
217 }
218
219 #[test]
220 fn writes_the_theme_in_the_token_layer() {
221 let brand = Token::new("brand", Color::RED);
222 let gap = Token::new("gap", px(4));
223 let theme = Theme::new()
224 .set(&brand, Color::BLUE)
225 .on(Media::Dark, Theme::new().set(&brand, Color::WHITE))
226 .set(&gap, px(8))
227 .set(&brand, Color::NAVY);
228 assert_eq!(
229 theme.to_css(),
230 "@layer hypersig-tokens,hypersig-base,hypersig-atoms,hypersig-toggled;\
231 @layer hypersig-tokens{:root{--brand:navy;--gap:8px}\
232 @media (prefers-color-scheme: dark){:root{--brand:white}}}"
233 );
234 }
235
236 #[test]
237 #[should_panic(expected = "a token name is ASCII letters")]
238 fn rejects_a_name_that_needs_escaping() {
239 let _ = Token::new("brand color", Color::RED);
240 }
241}