Skip to main content

bern_compiler/
style.rs

1//! Lower `style!` into the application's style sheet and check it: known
2//! controls, properties and states, semantic color roles, explicit colors
3//! for both modes, and motion tokens that exist.
4
5use crate::check::location;
6use bern_ir::style::{
7    AccentDef, BUILT_IN_MOTION, Band, COLOR_ROLES, ColorRef, ControlSize, CornerDef, Edge, Metric,
8    MotionToken, PatternDef, StateStyle, StyleEffect, StyleRule, StyleSheet, StyleState,
9    TransitionDef, TransitionKind, built_in_transitions,
10};
11use bern_ir::{SourceLocation, Srgb};
12use bern_syntax::{StyleArg, StyleItem, StyleValue};
13use bern_target_api::Diagnostic;
14use proc_macro2::{Ident, Span};
15
16/// Platforms a rule may name in `#[target(…)]` and `#[exempt(…)]`.
17pub const PLATFORMS: [&str; 7] = [
18    "macos", "ios", "tvos", "windows", "linux", "android", "browser",
19];
20
21/// Controls that take style overrides, with the states that apply to them.
22const STYLEABLE: &[(&str, &[StyleState])] = &[
23    ("Stack", &[]),
24    // Popover colors style the presented surface, not its trigger.
25    ("PopoverButton", &[]),
26    ("Sidebar", &[]),
27    ("Detail", &[]),
28    // A row's or column's lengths, per platform.
29    ("Row", &[]),
30    ("Column", &[]),
31    // A form's `background` is its rows'; the form itself shows the page.
32    ("Form", &[]),
33    (
34        "Button",
35        &[
36            StyleState::Hover,
37            StyleState::Pressed,
38            StyleState::Focus,
39            StyleState::Disabled,
40        ],
41    ),
42    (
43        "IconButton",
44        &[
45            StyleState::Hover,
46            StyleState::Pressed,
47            StyleState::Focus,
48            StyleState::Disabled,
49        ],
50    ),
51    (
52        "SidebarItem",
53        &[
54            StyleState::Hover,
55            StyleState::Pressed,
56            StyleState::Selected,
57            StyleState::Focus,
58        ],
59    ),
60];
61
62/// Effects allowed outside any state, and those allowed inside one.
63const BASE_EFFECTS: &[&str] = &[
64    "background",
65    "foreground",
66    "border",
67    "corner",
68    "padding",
69    "size",
70    "shadow",
71    "gap",
72    "padding_x",
73    "padding_y",
74    "width",
75    "height",
76    "font_size",
77    "row_height",
78];
79
80/// The elements that write each length a style may set in their place.
81fn writes(metric: Metric) -> &'static [&'static str] {
82    match metric {
83        Metric::Gap | Metric::PaddingX | Metric::PaddingY => &["Row", "Column", "Form"],
84        Metric::RowHeight => &["Form"],
85        Metric::Width => &["Row", "Column", "Stack"],
86        Metric::Height => &["Row", "Column", "Stack", "IconButton"],
87        Metric::FontSize => &["IconButton"],
88    }
89}
90const STATE_EFFECTS: &[&str] = &[
91    "background",
92    "foreground",
93    "border",
94    "scale",
95    "offset",
96    "opacity",
97    "shadow",
98    "system",
99    "none",
100];
101/// Only the selected state fills or marks an item.
102const SELECTED_EFFECTS: &[&str] = &["fill", "indicator"];
103
104struct Lowering<'a> {
105    file: &'a str,
106    diagnostics: &'a mut Vec<Diagnostic>,
107}
108
109impl Lowering<'_> {
110    fn error(&mut self, span: Span, message: impl Into<String>) {
111        self.diagnostics
112            .push(Diagnostic::error(message.into()).at(location(self.file, span)));
113    }
114
115    fn location(&self, span: Span) -> SourceLocation {
116        location(self.file, span)
117    }
118
119    fn number(&mut self, value: &StyleValue, what: &str) -> Option<f64> {
120        match value {
121            StyleValue::Number(number, _) => Some(*number),
122            other => {
123                self.error(other.span(), format!("{what} is a number"));
124                None
125            }
126        }
127    }
128
129    /// A length of 0 or more, such as a width or a radius.
130    fn length(&mut self, value: &StyleValue, what: &str) -> Option<f64> {
131        let length = self.number(value, what)?;
132        if length < 0.0 {
133            self.error(value.span(), format!("{what} is 0 or more"));
134            return None;
135        }
136        Some(length)
137    }
138
139    fn edge(&mut self, value: &StyleValue) -> Option<Edge> {
140        match value.dotted().as_deref() {
141            Some("leading") => Some(Edge::Leading),
142            Some("trailing") => Some(Edge::Trailing),
143            Some("top") => Some(Edge::Top),
144            Some("bottom") => Some(Edge::Bottom),
145            Some(other) => {
146                self.error(
147                    value.span(),
148                    format!("`{other}` is not an edge; use leading, trailing, top or bottom"),
149                );
150                None
151            }
152            None => {
153                self.error(value.span(), "an edge is leading, trailing, top or bottom");
154                None
155            }
156        }
157    }
158
159    fn color(&mut self, value: &StyleValue) -> Option<ColorRef> {
160        match value {
161            StyleValue::Name(_) => {
162                let role = value.dotted().unwrap_or_default();
163                if COLOR_ROLES.contains(&role.as_str()) {
164                    Some(ColorRef::Role { role })
165                } else {
166                    self.error(
167                        value.span(),
168                        format!(
169                            "`{role}` is not a color role; use one of {}",
170                            COLOR_ROLES.join(", ")
171                        ),
172                    );
173                    None
174                }
175            }
176            StyleValue::Call { name, args } if name == "color" => {
177                let mut light = None;
178                let mut dark = None;
179                for arg in args {
180                    let slot = match arg.name.as_ref().map(ToString::to_string).as_deref() {
181                        Some("light") => &mut light,
182                        Some("dark") => &mut dark,
183                        _ => {
184                            self.error(
185                                name.span(),
186                                "write `color(light: \"#RRGGBB\", dark: \"#RRGGBB\")`",
187                            );
188                            return None;
189                        }
190                    };
191                    match &arg.value {
192                        StyleValue::Text(hex, span) => match Srgb::parse(hex) {
193                            Some(color) => *slot = Some(color),
194                            None => self.error(*span, format!("`{hex}` is not a `#RRGGBB` color")),
195                        },
196                        other => self.error(other.span(), "a color is written `\"#RRGGBB\"`"),
197                    }
198                }
199                match (light, dark) {
200                    (Some(light), Some(dark)) => Some(ColorRef::Fixed { light, dark }),
201                    _ => {
202                        self.error(
203                            name.span(),
204                            "a fixed color gives both modes: `color(light: \"#…\", dark: \"#…\")`",
205                        );
206                        None
207                    }
208                }
209            }
210            other => {
211                self.error(
212                    other.span(),
213                    "a color is a role such as `accent` or `color(light: …, dark: …)`",
214                );
215                None
216            }
217        }
218    }
219
220    /// The trailing motion token of a call such as `scale(1.03, snappy)`.
221    fn motion(&mut self, args: &[StyleArg], sheet: &StyleSheet) -> String {
222        let named = args
223            .iter()
224            .rev()
225            .find(|arg| arg.name.is_none() && matches!(arg.value, StyleValue::Name(_)));
226        match named.and_then(|arg| arg.value.dotted()) {
227            Some(token) if sheet.token(&token).is_some() => token,
228            Some(token) => {
229                let span = named
230                    .map(|arg| arg.value.span())
231                    .unwrap_or_else(Span::call_site);
232                self.error(
233                    span,
234                    format!("`{token}` is not a motion token; declare it under `motion {{ … }}`"),
235                );
236                "smooth".into()
237            }
238            None => "smooth".into(),
239        }
240    }
241
242    fn numbers(&mut self, args: &[StyleArg]) -> (Vec<f64>, Vec<(String, f64)>) {
243        let mut positional = Vec::new();
244        let mut named = Vec::new();
245        for arg in args {
246            if let StyleValue::Number(number, _) = arg.value {
247                match &arg.name {
248                    Some(name) => named.push((name.to_string(), number)),
249                    None => positional.push(number),
250                }
251            }
252        }
253        (positional, named)
254    }
255
256    /// `slide` or `grow`, with the argument that names its motion token.
257    /// The edge and a slide's distance are given by name,
258    /// `slide(edge: top, distance: 24, snappy)`, or by position,
259    /// `slide(top, 24, snappy)`: the edge is the name before the token, and
260    /// a name alone is the edge when it is one.
261    fn edge_transition<'a>(
262        &mut self,
263        name: &Ident,
264        args: &'a [StyleArg],
265        sheet: &StyleSheet,
266    ) -> (TransitionKind, Option<&'a StyleArg>) {
267        let slide = name == "slide";
268        let moves = name == "move";
269        let usage = if slide {
270            "write `slide(bottom, 16, token)` or `slide(edge: bottom, distance: 16, token)`"
271        } else if moves {
272            "write `move(bottom, token)` or `move(edge: bottom, token)`"
273        } else {
274            "write `grow(bottom, token)` or `grow(edge: bottom, token)`"
275        };
276        let mut edge = None;
277        let mut distance = None;
278        let mut names = Vec::new();
279        for arg in args {
280            match (
281                arg.name.as_ref().map(ToString::to_string).as_deref(),
282                &arg.value,
283            ) {
284                (Some("edge"), StyleValue::Name(_)) if edge.is_none() => edge = Some(&arg.value),
285                (Some("distance") | None, StyleValue::Number(..))
286                    if slide && distance.is_none() =>
287                {
288                    distance = Some(&arg.value)
289                }
290                (None, StyleValue::Name(_)) => names.push(arg),
291                _ => self.error(arg.value.span(), usage),
292            }
293        }
294        let is_edge = |arg: &StyleArg| {
295            arg.value
296                .dotted()
297                .is_some_and(|word| ["leading", "trailing", "top", "bottom"].contains(&&*word))
298        };
299        let token = match names.as_slice() {
300            [] => None,
301            [only] if edge.is_none() && is_edge(only) => {
302                edge = Some(&only.value);
303                None
304            }
305            [token] if edge.is_none() => {
306                let word = token.value.dotted().unwrap_or_default();
307                if sheet.token(&word).is_some() {
308                    Some(*token)
309                } else {
310                    self.error(
311                        token.value.span(),
312                        format!(
313                            "`{word}` is neither an edge nor a motion token; an edge is leading, trailing, top or bottom"
314                        ),
315                    );
316                    None
317                }
318            }
319            [token] => Some(*token),
320            [first, token] if edge.is_none() => {
321                edge = Some(&first.value);
322                Some(*token)
323            }
324            _ => {
325                self.error(name.span(), usage);
326                None
327            }
328        };
329        let edge = edge
330            .and_then(|value| self.edge(value))
331            .unwrap_or(Edge::Bottom);
332        let kind = if slide {
333            TransitionKind::Slide {
334                edge,
335                distance: distance
336                    .and_then(|value| self.length(value, "a slide's distance"))
337                    .unwrap_or(16.0),
338            }
339        } else if moves {
340            TransitionKind::Move { edge }
341        } else {
342            TransitionKind::Grow { edge }
343        };
344        (kind, token)
345    }
346
347    fn effect(&mut self, value: &StyleValue, sheet: &StyleSheet) -> Option<StyleEffect> {
348        match value {
349            StyleValue::Name(path) if path.len() == 1 && path[0] == "system" => {
350                Some(StyleEffect::System)
351            }
352            StyleValue::Name(path) if path.len() == 1 && path[0] == "none" => {
353                Some(StyleEffect::Nothing)
354            }
355            StyleValue::Call { name, args } => {
356                let kind = name.to_string();
357                // The color is the first argument without a name.
358                let first = args
359                    .iter()
360                    .find(|arg| arg.name.is_none())
361                    .map(|arg| &arg.value);
362                if first.is_none()
363                    && ["background", "foreground", "fill", "border", "indicator"]
364                        .contains(&kind.as_str())
365                {
366                    self.error(
367                        name.span(),
368                        format!("`{kind}` takes a color, such as `{kind}(accent)`"),
369                    );
370                    return None;
371                }
372                let (positional, named) = self.numbers(args);
373                let get = |key: &str| {
374                    named
375                        .iter()
376                        .find(|(name, _)| name == key)
377                        .map(|(_, value)| *value)
378                };
379                let width = |lowering: &mut Self, default: f64, what: &str| {
380                    let width = get("width").unwrap_or(default);
381                    if width < 0.0 {
382                        lowering.error(name.span(), format!("{what} is 0 or more"));
383                        return None;
384                    }
385                    Some(width)
386                };
387                match kind.as_str() {
388                    "background" => Some(StyleEffect::Background {
389                        color: self.color(first?)?,
390                    }),
391                    "foreground" => Some(StyleEffect::Foreground {
392                        color: self.color(first?)?,
393                    }),
394                    "fill" => Some(StyleEffect::Fill {
395                        color: self.color(first?)?,
396                    }),
397                    "border" => Some(StyleEffect::Border {
398                        color: self.color(first?)?,
399                        width: width(self, 1.0, "a border's width")?,
400                    }),
401                    "indicator" => {
402                        let edge = match args
403                            .iter()
404                            .find(|arg| arg.name.as_ref().is_some_and(|name| name == "edge"))
405                        {
406                            Some(arg) => self.edge(&arg.value).unwrap_or(Edge::Leading),
407                            None => Edge::Leading,
408                        };
409                        Some(StyleEffect::Indicator {
410                            color: self.color(first?)?,
411                            edge,
412                            width: width(self, 3.0, "an indicator's width")?,
413                        })
414                    }
415                    "scale" => {
416                        let uniform = positional.first().copied();
417                        let x = get("x").or(uniform);
418                        let y = get("y").or(uniform);
419                        let (Some(x), Some(y)) = (x, y) else {
420                            self.error(
421                                name.span(),
422                                "write `scale(1.03, token)` or `scale(x: 0.97, y: 0.9, token)`",
423                            );
424                            return None;
425                        };
426                        Some(StyleEffect::Scale {
427                            x,
428                            y,
429                            motion: self.motion(args, sheet),
430                        })
431                    }
432                    "offset" => Some(StyleEffect::Offset {
433                        x: get("x").unwrap_or(0.0),
434                        y: get("y").unwrap_or(0.0),
435                        motion: self.motion(args, sheet),
436                    }),
437                    "opacity" => {
438                        let Some(value) = positional.first().copied() else {
439                            self.error(name.span(), "write `opacity(0.6, token)`");
440                            return None;
441                        };
442                        if !(0.0..=1.0).contains(&value) {
443                            self.error(name.span(), "an opacity is from 0 to 1");
444                            return None;
445                        }
446                        Some(StyleEffect::Opacity {
447                            value,
448                            motion: self.motion(args, sheet),
449                        })
450                    }
451                    "shadow" => {
452                        let Some(radius) = positional.first().copied() else {
453                            self.error(
454                                name.span(),
455                                "write `shadow(12)` or `shadow(12, y: 4, opacity: 0.2)`",
456                            );
457                            return None;
458                        };
459                        let opacity = get("opacity").unwrap_or(0.2);
460                        if radius < 0.0 || !(0.0..=1.0).contains(&opacity) {
461                            self.error(
462                                name.span(),
463                                "a shadow has a radius of 0 or more and an opacity from 0 to 1",
464                            );
465                            return None;
466                        }
467                        Some(StyleEffect::Shadow {
468                            radius,
469                            y: get("y").unwrap_or(radius / 3.0),
470                            opacity,
471                        })
472                    }
473                    other => {
474                        self.error(name.span(), format!("`{other}` is not a style effect"));
475                        None
476                    }
477                }
478            }
479            other => {
480                self.error(other.span(), "expected an effect such as `scale(1.03, snappy)`, `fill(accent)`, `system` or `none`");
481                None
482            }
483        }
484    }
485}
486
487/// Lower every `style!` of the application. `items` pairs each with the
488/// file declaring it.
489pub fn lower(items: &[(String, StyleItem)], diagnostics: &mut Vec<Diagnostic>) -> StyleSheet {
490    let mut sheet = StyleSheet {
491        motion: BUILT_IN_MOTION
492            .iter()
493            .map(|(name, period, damping)| MotionToken {
494                name: (*name).into(),
495                period: *period,
496                damping: *damping,
497            })
498            .collect(),
499        rules: Vec::new(),
500        transitions: built_in_transitions(),
501        patterns: Vec::new(),
502        accents: Vec::new(),
503        text_weights: Vec::new(),
504        corners: Vec::new(),
505    };
506    if items.len() > 1 {
507        for (file, item) in &items[1..] {
508            let mut lowering = Lowering { file, diagnostics };
509            lowering.error(item.span, "an application declares at most one `style!`");
510        }
511    }
512    let Some((file, item)) = items.first() else {
513        return sheet;
514    };
515    let mut lowering = Lowering { file, diagnostics };
516    lower_corners(&mut lowering, item, &mut sheet);
517    for decl in &item.motion {
518        let StyleValue::Call { name, args } = &decl.value else {
519            lowering.error(
520                decl.value.span(),
521                "a motion token is `spring(period: T, damping: ζ)`",
522            );
523            continue;
524        };
525        if name != "spring" {
526            lowering.error(
527                name.span(),
528                "a motion token is `spring(period: T, damping: ζ)`",
529            );
530            continue;
531        }
532        let (_, named) = lowering.numbers(args);
533        let get = |key: &str| {
534            named
535                .iter()
536                .find(|(name, _)| name == key)
537                .map(|(_, value)| *value)
538        };
539        let (Some(period), Some(damping)) = (get("period"), get("damping")) else {
540            lowering.error(name.span(), "a spring needs `period:` and `damping:`");
541            continue;
542        };
543        if !(period > 0.0 && damping > 0.0) {
544            lowering.error(name.span(), "a spring's period and damping are positive");
545            continue;
546        }
547        let token = MotionToken {
548            name: decl.name.to_string(),
549            period,
550            damping,
551        };
552        match sheet
553            .motion
554            .iter_mut()
555            .find(|existing| existing.name == token.name)
556        {
557            Some(existing) => *existing = token,
558            None => sheet.motion.push(token),
559        }
560    }
561    for decl in &item.transitions {
562        let StyleValue::Call { name, args } = &decl.value else {
563            lowering.error(decl.value.span(), "a transition is `fade(token)`, `pop(from: 0.8, token)`, `slide(edge: bottom, distance: 16, token)` or `move(edge: bottom, token)`");
564            continue;
565        };
566        let (positional, named) = lowering.numbers(args);
567        let get = |key: &str| {
568            named
569                .iter()
570                .find(|(name, _)| name == key)
571                .map(|(_, value)| *value)
572        };
573        let mut motion = args.as_slice();
574        let kind = match name.to_string().as_str() {
575            "fade" => TransitionKind::Fade,
576            "pop" => TransitionKind::Pop {
577                from: get("from").or(positional.first().copied()).unwrap_or(0.8),
578            },
579            "slide" | "grow" | "move" => {
580                let (kind, token) = lowering.edge_transition(name, args, &sheet);
581                motion = token.map(std::slice::from_ref).unwrap_or_default();
582                kind
583            }
584            other => {
585                lowering.error(
586                    name.span(),
587                    format!("`{other}` is not a transition; use fade, pop, grow, slide or move"),
588                );
589                continue;
590            }
591        };
592        let transition = TransitionDef {
593            name: decl.name.to_string(),
594            kind,
595            motion: lowering.motion(motion, &sheet),
596        };
597        match sheet
598            .transitions
599            .iter_mut()
600            .find(|existing| existing.name == transition.name)
601        {
602            Some(existing) => *existing = transition,
603            None => sheet.transitions.push(transition),
604        }
605    }
606    for decl in &item.patterns {
607        let StyleValue::Call { name, args } = &decl.value else {
608            lowering.error(
609                decl.value.span(),
610                "a pattern is `check(band(color, width, opacity), gap(width), …, twill: true)`",
611            );
612            continue;
613        };
614        if name != "check" {
615            lowering.error(name.span(), "a pattern is `check(band(…), gap(…), …)`");
616            continue;
617        }
618        let mut pattern = PatternDef {
619            name: decl.name.to_string(),
620            bands: Vec::new(),
621            twill: false,
622        };
623        for arg in args {
624            match (&arg.name, &arg.value) {
625                (Some(key), StyleValue::Name(path)) if key == "twill" => {
626                    pattern.twill = path.len() == 1 && path[0] == "true";
627                }
628                (
629                    None,
630                    StyleValue::Call {
631                        name: kind,
632                        args: band,
633                    },
634                ) if kind == "band" => {
635                    let (Some(color), Some(width)) = (band.first(), band.get(1)) else {
636                        lowering.error(kind.span(), "a band is `band(color, width, opacity)`");
637                        continue;
638                    };
639                    let Some(color) = lowering.color(&color.value) else {
640                        continue;
641                    };
642                    let Some(width) = lowering.length(&width.value, "a band's width") else {
643                        continue;
644                    };
645                    let opacity = match band.get(2) {
646                        Some(opacity) => {
647                            match lowering.number(&opacity.value, "a band's opacity") {
648                                Some(value) if !(0.0..=1.0).contains(&value) => {
649                                    lowering.error(
650                                        opacity.value.span(),
651                                        "a band's opacity is from 0 to 1",
652                                    );
653                                    continue;
654                                }
655                                value => value.unwrap_or(1.0),
656                            }
657                        }
658                        None => 1.0,
659                    };
660                    pattern.bands.push(Band {
661                        color: Some(color),
662                        width,
663                        opacity,
664                    });
665                }
666                (
667                    None,
668                    StyleValue::Call {
669                        name: kind,
670                        args: gap,
671                    },
672                ) if kind == "gap" => {
673                    let Some(width) = gap.first() else {
674                        lowering.error(kind.span(), "a gap is `gap(width)`");
675                        continue;
676                    };
677                    let Some(width) = lowering.length(&width.value, "a gap's width") else {
678                        continue;
679                    };
680                    pattern.bands.push(Band {
681                        color: None,
682                        width,
683                        opacity: 0.0,
684                    });
685                }
686                (_, other) => lowering.error(
687                    other.span(),
688                    "a pattern holds `band(…)`, `gap(…)` and `twill: true`",
689                ),
690            }
691        }
692        if !pattern
693            .bands
694            .iter()
695            .any(|band| band.color.is_some() && band.width > 0.0)
696        {
697            lowering.error(
698                name.span(),
699                "a pattern needs a colored band wider than zero",
700            );
701            continue;
702        }
703        match sheet
704            .patterns
705            .iter_mut()
706            .find(|existing| existing.name == pattern.name)
707        {
708            Some(existing) => *existing = pattern,
709            None => sheet.patterns.push(pattern),
710        }
711    }
712    for decl in &item.accents {
713        let color = match &decl.value {
714            // One color: each mode takes it as far as it reads there
715            // (`bern_theme::native_accent`), so a pale accent darkens on
716            // light surfaces by itself. `color(light:, dark:)` is kept as
717            // written.
718            StyleValue::Text(hex, span) => match Srgb::parse(hex) {
719                Some(color) => {
720                    let readable = |mode| {
721                        let theme = bern_theme::Srgb::new(color.red, color.green, color.blue);
722                        let adjusted = bern_theme::native_accent(theme, mode);
723                        Srgb {
724                            red: adjusted.r,
725                            green: adjusted.g,
726                            blue: adjusted.b,
727                        }
728                    };
729                    Some(ColorRef::Fixed {
730                        light: readable(bern_theme::Mode::Light),
731                        dark: readable(bern_theme::Mode::Dark),
732                    })
733                }
734                None => {
735                    lowering.error(*span, format!("`{hex}` is not a `#RRGGBB` color"));
736                    None
737                }
738            },
739            value @ StyleValue::Call { .. } => lowering.color(value),
740            other => {
741                lowering.error(
742                    other.span(),
743                    "an accent is `\"#RRGGBB\"` or `color(light: \"#…\", dark: \"#…\")`",
744                );
745                None
746            }
747        };
748        let Some(color) = color else { continue };
749        if matches!(color, ColorRef::Role { .. }) {
750            lowering.error(decl.value.span(), "an accent is a color, not a role");
751            continue;
752        }
753        let accent = AccentDef {
754            name: decl.name.to_string(),
755            color,
756        };
757        match sheet
758            .accents
759            .iter_mut()
760            .find(|existing| existing.name == accent.name)
761        {
762            Some(existing) => *existing = accent,
763            None => sheet.accents.push(accent),
764        }
765    }
766    for decl in &item.text {
767        let style = decl.name.to_string();
768        if style == "control" {
769            lowering.error(
770                decl.name.span(),
771                "`control` is the platform's control font, as its buttons have it; it takes no size or weight of the application's",
772            );
773            continue;
774        }
775        if !bern_ir::spec::TEXT_STYLES.contains(&style.as_str()) {
776            lowering.error(
777                decl.name.span(),
778                format!(
779                    "`{style}` is not a text style; text styles: {}",
780                    bern_ir::spec::TEXT_STYLES.join(", ")
781                ),
782            );
783            continue;
784        }
785        // `heavy`, or `font(size: 30, weight: heavy)`, with `tv: 34` for
786        // the size on a television.
787        let (weight, size, tv_size) = match &decl.value {
788            StyleValue::Name(path) if path.len() == 1 => (path[0].to_string(), None, None),
789            StyleValue::Call { name, args } if name == "font" => {
790                let mut weight = None;
791                let mut size = None;
792                let mut tv_size = None;
793                for arg in args {
794                    match (
795                        arg.name.as_ref().map(|name| name.to_string()).as_deref(),
796                        &arg.value,
797                    ) {
798                        (Some("weight"), StyleValue::Name(path)) if path.len() == 1 => {
799                            weight = Some(path[0].to_string())
800                        }
801                        (Some(name @ ("size" | "tv")), StyleValue::Number(value, span)) => {
802                            if *value <= 0.0 {
803                                lowering.error(*span, "a text size is more than 0 points");
804                            } else if name == "tv" {
805                                tv_size = Some(*value);
806                            } else {
807                                size = Some(*value);
808                            }
809                        }
810                        _ => lowering.error(
811                            arg.value.span(),
812                            "`font` takes `size: points`, `tv: points` (the size on a television) and `weight: name`",
813                        ),
814                    }
815                }
816                (weight.unwrap_or_else(|| "regular".into()), size, tv_size)
817            }
818            other => {
819                lowering.error(
820                    other.span(),
821                    "a text style takes a weight, such as `heavy`, or `font(size: 30, weight: heavy)`",
822                );
823                continue;
824            }
825        };
826        if !bern_ir::style::TEXT_WEIGHTS.contains(&weight.as_str()) {
827            lowering.error(
828                decl.value.span(),
829                format!(
830                    "`{weight}` is not a weight; weights: {}",
831                    bern_ir::style::TEXT_WEIGHTS.join(", ")
832                ),
833            );
834            continue;
835        }
836        let entry = bern_ir::style::TextWeight {
837            style,
838            weight,
839            size,
840            tv_size,
841        };
842        match sheet
843            .text_weights
844            .iter_mut()
845            .find(|existing| existing.style == entry.style)
846        {
847            Some(existing) => *existing = entry,
848            None => sheet.text_weights.push(entry),
849        }
850    }
851    for rule in &item.rules {
852        let component = rule.component.to_string();
853        let Some((_, states)) = STYLEABLE.iter().find(|(name, _)| *name == component) else {
854            lowering.error(
855                rule.component.span(),
856                format!(
857                    "`{component}` does not take style overrides yet; styleable: {}",
858                    STYLEABLE
859                        .iter()
860                        .map(|(name, _)| *name)
861                        .collect::<Vec<_>>()
862                        .join(", ")
863                ),
864            );
865            continue;
866        };
867        let mut targets = Vec::new();
868        for target in &rule.targets {
869            let name = target.to_string();
870            if PLATFORMS.contains(&name.as_str()) {
871                targets.push(name);
872            } else {
873                lowering.error(
874                    target.span(),
875                    format!("`{name}` is not a platform; use {}", PLATFORMS.join(", ")),
876                );
877            }
878        }
879        let mut exempt = Vec::new();
880        for (target, _) in &rule.exempt {
881            let name = target.to_string();
882            if PLATFORMS.contains(&name.as_str()) {
883                exempt.push(name);
884            } else {
885                lowering.error(
886                    target.span(),
887                    format!("`{name}` is not a platform; use {}", PLATFORMS.join(", ")),
888                );
889            }
890        }
891        let mut lowered = StyleRule {
892            component: component.clone(),
893            variant: rule.variant.as_ref().map(ToString::to_string),
894            targets,
895            exempt,
896            base: Vec::new(),
897            states: Vec::new(),
898            settle: None,
899            source: lowering.location(rule.span),
900        };
901        // Named corners the rule takes, by the platforms where they differ.
902        let mut corner_names: Vec<String> = Vec::new();
903        for prop in &rule.props {
904            let name = prop.name.to_string();
905            if name == "settle" {
906                match prop.value.dotted() {
907                    Some(token) if sheet.token(&token).is_some() => lowered.settle = Some(token),
908                    _ => lowering.error(prop.value.span(), "`settle` names a motion token"),
909                }
910                continue;
911            }
912            if let Some(state) = StyleState::ALL.iter().find(|state| state.name() == name) {
913                if !states.contains(state) {
914                    lowering.error(
915                        prop.name.span(),
916                        format!("`{component}` has no `{name}` state"),
917                    );
918                    continue;
919                }
920                let mut effects = Vec::new();
921                for term in prop.value.terms() {
922                    let Some(effect) = lowering.effect(term, &sheet) else {
923                        continue;
924                    };
925                    let allowed = STATE_EFFECTS.contains(&effect.name())
926                        || (*state == StyleState::Selected
927                            && SELECTED_EFFECTS.contains(&effect.name()));
928                    if allowed {
929                        effects.push(effect);
930                    } else {
931                        lowering.error(
932                            term.span(),
933                            format!("`{}` does not apply in the `{name}` state", effect.name()),
934                        );
935                    }
936                }
937                lowered.states.push(StateStyle {
938                    state: *state,
939                    effects,
940                });
941                continue;
942            }
943            let effect = match name.as_str() {
944                // A named corner takes its radius on every platform here;
945                // the platforms where it differs get it from the rules
946                // added after this one.
947                "corner" if prop.value.dotted().is_some() => {
948                    let name = prop.value.dotted().unwrap_or_default();
949                    match sheet.corner(&name, "") {
950                        Some(radius) => {
951                            corner_names.push(name);
952                            Some(StyleEffect::Corner { radius })
953                        }
954                        None => {
955                            let declared = sheet
956                                .corners
957                                .iter()
958                                .filter(|corner| corner.targets.is_empty())
959                                .map(|corner| corner.name.as_str());
960                            lowering.error(prop.value.span(), unknown_corner(&name, declared));
961                            None
962                        }
963                    }
964                }
965                "corner" => lowering
966                    .length(&prop.value, "`corner`")
967                    .map(|radius| StyleEffect::Corner { radius }),
968                "padding" => lowering
969                    .length(&prop.value, "`padding`")
970                    .map(|length| StyleEffect::Padding { length }),
971                "size" => match prop.value.dotted().as_deref() {
972                    Some("small") => Some(StyleEffect::Size {
973                        size: ControlSize::Small,
974                    }),
975                    Some("regular") => Some(StyleEffect::Size {
976                        size: ControlSize::Regular,
977                    }),
978                    Some("large") => Some(StyleEffect::Size {
979                        size: ControlSize::Large,
980                    }),
981                    Some("extra_large") => Some(StyleEffect::Size {
982                        size: ControlSize::ExtraLarge,
983                    }),
984                    _ => {
985                        lowering.error(
986                            prop.value.span(),
987                            "`size` is small, regular, large or extra_large",
988                        );
989                        None
990                    }
991                },
992                // `shadow: 12` at rest, or `shadow: shadow(12, y: 4)`.
993                "shadow" if matches!(&prop.value, StyleValue::Call { name, .. } if name == "shadow") => {
994                    lowering.effect(&prop.value, &sheet)
995                }
996                // `border: control.border`, or `border: border(fill.subtle, width: 0.5)`.
997                "border" if matches!(&prop.value, StyleValue::Call { name, .. } if name == "border") => {
998                    lowering.effect(&prop.value, &sheet)
999                }
1000                name if Metric::ALL.iter().any(|metric| metric.name() == name) => {
1001                    let metric = *Metric::ALL
1002                        .iter()
1003                        .find(|metric| metric.name() == name)
1004                        .unwrap();
1005                    let fits = matches!(metric, Metric::Width | Metric::Height);
1006                    if !writes(metric).contains(&component.as_str()) {
1007                        lowering.error(
1008                            prop.name.span(),
1009                            format!(
1010                                "`{component}` writes no `{name}`; a style sets it on {}",
1011                                writes(metric).join(", ")
1012                            ),
1013                        );
1014                        None
1015                    } else if fits && prop.value.dotted().as_deref() == Some("fit") {
1016                        Some(StyleEffect::Metric {
1017                            metric,
1018                            value: None,
1019                        })
1020                    } else if metric == Metric::FontSize {
1021                        match lowering.number(&prop.value, "`font_size`") {
1022                            Some(size) if size > 0.0 => Some(StyleEffect::Metric {
1023                                metric,
1024                                value: Some(size),
1025                            }),
1026                            Some(_) => {
1027                                lowering.error(prop.value.span(), "`font_size` is more than 0");
1028                                None
1029                            }
1030                            None => None,
1031                        }
1032                    } else if fits && !matches!(prop.value, StyleValue::Number(..)) {
1033                        lowering.error(prop.value.span(), format!("`{name}` is a length or `fit`"));
1034                        None
1035                    } else {
1036                        lowering
1037                            .length(&prop.value, &format!("`{name}`"))
1038                            .map(|length| StyleEffect::Metric {
1039                                metric,
1040                                value: Some(length),
1041                            })
1042                    }
1043                }
1044                "background" | "foreground" | "border" | "shadow" => {
1045                    let call = StyleValue::Call {
1046                        name: prop.name.clone(),
1047                        args: vec![StyleArg {
1048                            name: None,
1049                            value: prop.value.clone(),
1050                        }],
1051                    };
1052                    lowering.effect(&call, &sheet)
1053                }
1054                other => {
1055                    lowering.error(
1056                        prop.name.span(),
1057                        format!(
1058                            "`{other}` is not a style property; use {}, a state ({}) or `settle`",
1059                            BASE_EFFECTS.join(", "),
1060                            states
1061                                .iter()
1062                                .map(|state| state.name())
1063                                .collect::<Vec<_>>()
1064                                .join(", ")
1065                        ),
1066                    );
1067                    None
1068                }
1069            };
1070            lowered.base.extend(effect);
1071        }
1072        let platforms: Vec<String> = if lowered.targets.is_empty() {
1073            PLATFORMS
1074                .iter()
1075                .map(|platform| (*platform).to_string())
1076                .collect()
1077        } else {
1078            lowered.targets.clone()
1079        };
1080        let mut overrides = Vec::new();
1081        for name in &corner_names {
1082            let everywhere = sheet.corner(name, "");
1083            for platform in &platforms {
1084                let Some(radius) = sheet.corner(name, platform) else {
1085                    continue;
1086                };
1087                if Some(radius) == everywhere {
1088                    continue;
1089                }
1090                overrides.push(StyleRule {
1091                    component: lowered.component.clone(),
1092                    variant: lowered.variant.clone(),
1093                    targets: vec![platform.clone()],
1094                    exempt: Vec::new(),
1095                    base: vec![StyleEffect::Corner { radius }],
1096                    states: Vec::new(),
1097                    settle: None,
1098                    source: lowered.source.clone(),
1099                });
1100            }
1101        }
1102        sheet.rules.push(lowered);
1103        sheet.rules.extend(overrides);
1104    }
1105    sheet
1106}
1107
1108/// Lower the `corner` blocks: the names with their radii for every
1109/// platform, then the values `#[target(…)]` blocks give, which may only
1110/// name corners declared for every platform.
1111fn lower_corners(lowering: &mut Lowering<'_>, item: &StyleItem, sheet: &mut StyleSheet) {
1112    let blocks = item
1113        .corners
1114        .iter()
1115        .filter(|block| block.targets.is_empty())
1116        .chain(
1117            item.corners
1118                .iter()
1119                .filter(|block| !block.targets.is_empty()),
1120        );
1121    for block in blocks {
1122        let mut targets = Vec::new();
1123        for target in &block.targets {
1124            let name = target.to_string();
1125            if PLATFORMS.contains(&name.as_str()) {
1126                targets.push(name);
1127            } else {
1128                lowering.error(
1129                    target.span(),
1130                    format!("`{name}` is not a platform; use {}", PLATFORMS.join(", ")),
1131                );
1132            }
1133        }
1134        if !block.targets.is_empty() && targets.is_empty() {
1135            continue;
1136        }
1137        let mut seen = Vec::new();
1138        for decl in &block.corners {
1139            let name = decl.name.to_string();
1140            if seen.contains(&name) {
1141                lowering.error(
1142                    decl.name.span(),
1143                    format!("corner `{name}` is named twice in this block"),
1144                );
1145                continue;
1146            }
1147            seen.push(name.clone());
1148            let everywhere = sheet
1149                .corners
1150                .iter()
1151                .any(|corner| corner.name == name && corner.targets.is_empty());
1152            if targets.is_empty() && everywhere {
1153                lowering.error(
1154                    decl.name.span(),
1155                    format!("corner `{name}` already has a radius for every platform"),
1156                );
1157                continue;
1158            }
1159            if !targets.is_empty() && !everywhere {
1160                lowering.error(
1161                    decl.name.span(),
1162                    format!(
1163                        "corner `{name}` needs a radius for every platform first, in a `corner` block without `#[target(…)]`"
1164                    ),
1165                );
1166                continue;
1167            }
1168            let Some(radius) = lowering.length(&decl.value, "a corner radius") else {
1169                continue;
1170            };
1171            sheet.corners.push(CornerDef {
1172                name,
1173                radius,
1174                targets: targets.clone(),
1175            });
1176        }
1177    }
1178}
1179
1180/// The error for a corner name `style!` does not declare; `declared` are
1181/// the names it does.
1182pub fn unknown_corner<'a>(name: &str, declared: impl Iterator<Item = &'a str>) -> String {
1183    let names: Vec<&str> = declared.collect();
1184    if names.is_empty() {
1185        format!(
1186            "corner `{name}` is not declared; name it in a `corner` block of `style!`, such as `corner {{ {name}: 12 }}`"
1187        )
1188    } else {
1189        format!(
1190            "corner `{name}` is not declared; `style!` names {}",
1191            names.join(", ")
1192        )
1193    }
1194}
1195
1196/// The variants each control kind declares, for checking `variant:`.
1197pub fn variants(sheet: &StyleSheet) -> Vec<(String, String)> {
1198    sheet
1199        .rules
1200        .iter()
1201        .filter_map(|rule| Some((rule.component.clone(), rule.variant.clone()?)))
1202        .collect()
1203}