Skip to main content

bern_syntax/
style.rs

1//! `style!`: the application's style overrides and motion tokens.
2//!
3//! ```text
4//! style! {
5//!     motion {
6//!         jelly: spring(period: 0.42, damping: 0.42),
7//!     }
8//!     SidebarItem {
9//!         corner: 8,
10//!         hover: scale(1.03, snappy),
11//!         selected: fill(accent),
12//!     }
13//!     Button.hero { size: large }
14//!     #[target(macos)]
15//!     SidebarItem { selected: system }
16//! }
17//! ```
18//!
19//! This module only reads the declaration; what each property means and
20//! where it applies is checked by the build tool.
21
22use proc_macro2::{Span, TokenStream};
23use syn::ext::IdentExt;
24use syn::parse::{Parse, ParseStream};
25use syn::spanned::Spanned;
26use syn::{Ident, LitFloat, LitInt, LitStr, Token, braced, parenthesized};
27
28#[derive(Clone, Debug)]
29pub struct StyleItem {
30    pub motion: Vec<MotionDecl>,
31    /// `transition { name: pop(from: 0.4, jelly) }`.
32    pub transitions: Vec<MotionDecl>,
33    /// `pattern { name: check(band(accent, 1, 0.28), gap(6), …) }`.
34    pub patterns: Vec<MotionDecl>,
35    /// `accents { crimson: "#FF2E55" }`: accent colors the application
36    /// chooses among at run time.
37    pub accents: Vec<MotionDecl>,
38    /// `text { large_title: heavy }`: the weight of a text style.
39    pub text: Vec<MotionDecl>,
40    /// `corner { card: 22 }`: named corner radii; a block with
41    /// `#[target(…)]` gives other values on those platforms.
42    pub corners: Vec<CornerBlock>,
43    pub rules: Vec<StyleRuleItem>,
44    pub span: Span,
45}
46
47/// `name: spring(period: T, damping: ζ)`.
48#[derive(Clone, Debug)]
49pub struct MotionDecl {
50    pub name: Ident,
51    pub value: StyleValue,
52}
53
54/// A `corner { name: radius }` block, for every platform or, with
55/// `#[target(…)]`, for those named.
56#[derive(Clone, Debug)]
57pub struct CornerBlock {
58    pub targets: Vec<Ident>,
59    pub corners: Vec<MotionDecl>,
60    pub span: Span,
61}
62
63/// `Component` or `Component.variant`, with its properties.
64#[derive(Clone, Debug)]
65pub struct StyleRuleItem {
66    pub component: Ident,
67    pub variant: Option<Ident>,
68    /// `#[target(macos, ios)]`: the platforms the rule applies to; empty
69    /// for all.
70    pub targets: Vec<Ident>,
71    /// `#[exempt(gtk, "reason")]`: platforms that keep their default for
72    /// what they cannot show.
73    pub exempt: Vec<(Ident, String)>,
74    pub props: Vec<StyleProp>,
75    pub span: Span,
76}
77
78#[derive(Clone, Debug)]
79pub struct StyleProp {
80    pub name: Ident,
81    pub value: StyleValue,
82}
83
84/// A property value: a number, a name such as `accent` or `fill.subtle`,
85/// text, a call such as `scale(1.03, snappy)`, or effects joined by `+`.
86#[derive(Clone, Debug)]
87pub enum StyleValue {
88    Number(f64, Span),
89    Name(Vec<Ident>),
90    Text(String, Span),
91    Call { name: Ident, args: Vec<StyleArg> },
92    Sum(Vec<StyleValue>),
93}
94
95#[derive(Clone, Debug)]
96pub struct StyleArg {
97    pub name: Option<Ident>,
98    pub value: StyleValue,
99}
100
101impl StyleValue {
102    pub fn span(&self) -> Span {
103        match self {
104            StyleValue::Number(_, span) | StyleValue::Text(_, span) => *span,
105            StyleValue::Name(path) => path[0].span(),
106            StyleValue::Call { name, .. } => name.span(),
107            StyleValue::Sum(parts) => parts[0].span(),
108        }
109    }
110
111    /// `a.b` for a name.
112    pub fn dotted(&self) -> Option<String> {
113        match self {
114            StyleValue::Name(path) => Some(
115                path.iter()
116                    .map(ToString::to_string)
117                    .collect::<Vec<_>>()
118                    .join("."),
119            ),
120            _ => None,
121        }
122    }
123
124    /// The parts of a sum, or the value alone.
125    pub fn terms(&self) -> Vec<&StyleValue> {
126        match self {
127            StyleValue::Sum(parts) => parts.iter().collect(),
128            other => vec![other],
129        }
130    }
131}
132
133impl Parse for StyleItem {
134    fn parse(input: ParseStream) -> syn::Result<Self> {
135        let span = input.span();
136        let mut motion = Vec::new();
137        let mut transitions = Vec::new();
138        let mut patterns = Vec::new();
139        let mut accents = Vec::new();
140        let mut text = Vec::new();
141        let mut corners = Vec::new();
142        let mut rules = Vec::new();
143        while !input.is_empty() {
144            let attrs = input.call(syn::Attribute::parse_outer)?;
145            let component: Ident = input.parse()?;
146            if component == "corner" && input.peek(syn::token::Brace) {
147                let mut targets = Vec::new();
148                for attr in &attrs {
149                    if !attr.path().is_ident("target") {
150                        return Err(syn::Error::new(
151                            attr.span(),
152                            "a `corner` block takes only `#[target(…)]`",
153                        ));
154                    }
155                    let list = attr.parse_args_with(
156                        syn::punctuated::Punctuated::<Ident, Token![,]>::parse_terminated,
157                    )?;
158                    targets.extend(list);
159                }
160                let content;
161                braced!(content in input);
162                let mut list = Vec::new();
163                while !content.is_empty() {
164                    let name: Ident = content.parse()?;
165                    content.parse::<Token![:]>()?;
166                    let value: StyleValue = content.parse()?;
167                    list.push(MotionDecl { name, value });
168                    if !content.is_empty() {
169                        content.parse::<Token![,]>()?;
170                    }
171                }
172                corners.push(CornerBlock {
173                    targets,
174                    corners: list,
175                    span: component.span(),
176                });
177                continue;
178            }
179            if (component == "motion"
180                || component == "transition"
181                || component == "pattern"
182                || component == "accents"
183                || component == "text")
184                && attrs.is_empty()
185                && input.peek(syn::token::Brace)
186            {
187                let content;
188                braced!(content in input);
189                let list = if component == "motion" {
190                    &mut motion
191                } else if component == "transition" {
192                    &mut transitions
193                } else if component == "accents" {
194                    &mut accents
195                } else if component == "text" {
196                    &mut text
197                } else {
198                    &mut patterns
199                };
200                while !content.is_empty() {
201                    let name: Ident = content.parse()?;
202                    content.parse::<Token![:]>()?;
203                    let value: StyleValue = content.parse()?;
204                    list.push(MotionDecl { name, value });
205                    if !content.is_empty() {
206                        content.parse::<Token![,]>()?;
207                    }
208                }
209                continue;
210            }
211            let variant = if input.peek(Token![.]) {
212                input.parse::<Token![.]>()?;
213                Some(input.parse()?)
214            } else {
215                None
216            };
217            let mut targets = Vec::new();
218            let mut exempt = Vec::new();
219            for attr in &attrs {
220                if attr.path().is_ident("target") {
221                    let list = attr.parse_args_with(
222                        syn::punctuated::Punctuated::<Ident, Token![,]>::parse_terminated,
223                    )?;
224                    targets.extend(list);
225                } else if attr.path().is_ident("exempt") {
226                    attr.parse_args_with(|input: ParseStream| {
227                        let target: Ident = input.parse()?;
228                        input.parse::<Token![,]>()?;
229                        let reason: LitStr = input.parse()?;
230                        exempt.push((target, reason.value()));
231                        Ok(())
232                    })?;
233                } else {
234                    return Err(syn::Error::new(
235                        attr.span(),
236                        "style rules take `#[target(…)]` and `#[exempt(target, \"reason\")]`",
237                    ));
238                }
239            }
240            let content;
241            braced!(content in input);
242            let mut props = Vec::new();
243            while !content.is_empty() {
244                let name: Ident = content.parse()?;
245                content.parse::<Token![:]>()?;
246                let value: StyleValue = content.parse()?;
247                props.push(StyleProp { name, value });
248                if !content.is_empty() {
249                    content.parse::<Token![,]>()?;
250                }
251            }
252            rules.push(StyleRuleItem {
253                span: component.span(),
254                component,
255                variant,
256                targets,
257                exempt,
258                props,
259            });
260        }
261        Ok(StyleItem {
262            motion,
263            transitions,
264            patterns,
265            accents,
266            text,
267            corners,
268            rules,
269            span,
270        })
271    }
272}
273
274impl Parse for StyleValue {
275    fn parse(input: ParseStream) -> syn::Result<Self> {
276        let mut parts = vec![term(input)?];
277        while input.peek(Token![+]) {
278            input.parse::<Token![+]>()?;
279            parts.push(term(input)?);
280        }
281        Ok(if parts.len() == 1 {
282            parts.pop().unwrap()
283        } else {
284            StyleValue::Sum(parts)
285        })
286    }
287}
288
289fn number(input: ParseStream, negative: bool) -> syn::Result<StyleValue> {
290    let sign = if negative { -1.0 } else { 1.0 };
291    if input.peek(LitFloat) {
292        let lit: LitFloat = input.parse()?;
293        Ok(StyleValue::Number(
294            sign * crate::finite(lit.base10_parse()?, lit.span())?,
295            lit.span(),
296        ))
297    } else {
298        let lit: LitInt = input.parse()?;
299        Ok(StyleValue::Number(
300            sign * crate::finite(lit.base10_parse()?, lit.span())?,
301            lit.span(),
302        ))
303    }
304}
305
306fn term(input: ParseStream) -> syn::Result<StyleValue> {
307    if input.peek(Token![-]) {
308        input.parse::<Token![-]>()?;
309        return number(input, true);
310    }
311    if input.peek(LitFloat) || input.peek(LitInt) {
312        return number(input, false);
313    }
314    if input.peek(LitStr) {
315        let lit: LitStr = input.parse()?;
316        return Ok(StyleValue::Text(lit.value(), lit.span()));
317    }
318    // Any word, so `twill: true` reads as a name.
319    let name: Ident = input.call(Ident::parse_any)?;
320    if input.peek(syn::token::Paren) {
321        let content;
322        parenthesized!(content in input);
323        let mut args = Vec::new();
324        while !content.is_empty() {
325            let named = content.peek(Ident) && content.peek2(Token![:]);
326            let name = if named {
327                let name: Ident = content.parse()?;
328                content.parse::<Token![:]>()?;
329                Some(name)
330            } else {
331                None
332            };
333            let value: StyleValue = content.parse()?;
334            args.push(StyleArg { name, value });
335            if !content.is_empty() {
336                content.parse::<Token![,]>()?;
337            }
338        }
339        return Ok(StyleValue::Call { name, args });
340    }
341    let mut path = vec![name];
342    while input.peek(Token![.]) {
343        input.parse::<Token![.]>()?;
344        path.push(input.parse()?);
345    }
346    Ok(StyleValue::Name(path))
347}
348
349/// Parse the body of a `style!` invocation.
350pub fn parse_style(tokens: TokenStream) -> syn::Result<StyleItem> {
351    syn::parse2(tokens)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn a_style_reads_rules_motion_and_attributes() {
360        let style = parse_style(quote::quote! {
361            motion {
362                jelly: spring(period: 0.42, damping: 0.42),
363            }
364            SidebarItem {
365                corner: 8,
366                hover: scale(1.03, snappy),
367                selected: fill(accent),
368            }
369            IconButton {
370                hover: background(fill.subtle) + scale(1.08, snappy),
371            }
372            #[target(macos)]
373            #[exempt(gtk, "no transforms on rows")]
374            Button.hero { size: large, pressed: scale(x: 0.97, y: 0.9, press) }
375        })
376        .unwrap();
377        assert_eq!(style.motion.len(), 1);
378        assert_eq!(style.motion[0].name, "jelly");
379        assert_eq!(style.rules.len(), 3);
380        let icon = &style.rules[1];
381        let StyleValue::Sum(parts) = &icon.props[0].value else {
382            panic!("a sum")
383        };
384        assert_eq!(parts.len(), 2);
385        assert_eq!(parts[0].clone().terms().len(), 1);
386        let hero = &style.rules[2];
387        assert_eq!(hero.variant.as_ref().unwrap(), "hero");
388        assert_eq!(hero.targets[0], "macos");
389        assert_eq!(hero.exempt[0].1, "no transforms on rows");
390        let StyleValue::Call { args, .. } = &hero.props[1].value else {
391            panic!("a call")
392        };
393        assert_eq!(args[0].name.as_ref().unwrap(), "x");
394        assert!(matches!(args[2].value, StyleValue::Name(_)));
395        assert!(parse_style(quote::quote! { Button { corner } }).is_err());
396    }
397
398    #[test]
399    fn numbers_out_of_range_for_f64_are_rejected() {
400        for tokens in [
401            quote::quote! { Button { corner: 1e999 } },
402            quote::quote! { Button { hover: scale(-1e999, snappy) } },
403        ] {
404            let error = parse_style(tokens).unwrap_err().to_string();
405            assert!(error.contains("out of range for f64"), "{error}");
406        }
407    }
408}