Skip to main content

bern_target_api/
style.rs

1//! Style overrides of one platform: the rules of `style!` that apply to a
2//! control kind and variant, merged for a target to draw.
3
4use crate::names::pascal;
5use bern_ir::style::{ColorRef, ControlSize, StyleEffect, StyleRule, StyleState};
6use bern_ir::{Application, Element, Prop, Srgb};
7use std::borrow::Cow;
8
9/// The accent `[theme].brand_seed` gives the native targets that take
10/// their accent from the application rather than from the system's
11/// settings: the seed in each mode as far as it reads there, as a single
12/// color of `style!`'s `accents` is (`bern_theme::native_accent`). `None`
13/// without a seed, or when `WindowSet(accent:)` chooses one at run time.
14pub fn brand_accent(app: &Application) -> Option<ColorRef> {
15    if app.windows.accent.is_some() {
16        return None;
17    }
18    let seed = app.theme.brand_seed?;
19    let readable = |mode| {
20        let color = bern_theme::Srgb::new(seed.red, seed.green, seed.blue);
21        let adjusted = bern_theme::native_accent(color, mode);
22        Srgb {
23            red: adjusted.r,
24            green: adjusted.g,
25            blue: adjusted.b,
26        }
27    };
28    Some(ColorRef::Fixed {
29        light: readable(bern_theme::Mode::Light),
30        dark: readable(bern_theme::Mode::Dark),
31    })
32}
33
34/// A control kind and variant's style on one platform: the rules for the
35/// kind, then those for the variant, merged in declaration order.
36pub struct Resolved {
37    /// Name in the generated `BernStyles`, such as `buttonHero`.
38    pub name: String,
39    pub base: Vec<StyleEffect>,
40    pub states: Vec<(StyleState, Vec<StyleEffect>)>,
41    pub settle: Option<String>,
42}
43
44impl Resolved {
45    /// Whether the style draws the control instead of keeping its look.
46    pub fn draws_look(&self) -> bool {
47        let look = |effect: &StyleEffect| {
48            matches!(
49                effect,
50                StyleEffect::Background { .. }
51                    | StyleEffect::Foreground { .. }
52                    | StyleEffect::Border { .. }
53                    | StyleEffect::Corner { .. }
54                    | StyleEffect::Padding { .. }
55            )
56        };
57        self.base.iter().any(look)
58            || self
59                .states
60                .iter()
61                .filter(|(state, _)| *state != StyleState::Selected)
62                .any(|(_, effects)| effects.iter().any(look))
63    }
64
65    pub fn size(&self) -> Option<ControlSize> {
66        self.base.iter().find_map(|effect| match effect {
67            StyleEffect::Size { size } => Some(*size),
68            _ => None,
69        })
70    }
71}
72
73/// Opaque app-colored panels can follow the window; native material panels
74/// inherit their presenting area's contrast (for example, white on video).
75pub fn popover_window_theme(app: &Application, platform: &str, element: &Element) -> bool {
76    match element.keyword("panel_appearance") {
77        Some("window") => true,
78        Some("surface") => resolve(app, platform, "PopoverButton", element.keyword("variant"))
79            .is_some_and(|style| {
80                style
81                    .base
82                    .iter()
83                    .any(|effect| matches!(effect, StyleEffect::Background { .. }))
84            }),
85        _ => false,
86    }
87}
88
89fn applies(rule: &StyleRule, platform: &str) -> bool {
90    !rule.exempt.iter().any(|exempt| exempt == platform)
91}
92
93/// The style of `component` (and `variant`) on `platform`, if `style!`
94/// declares one.
95pub fn resolve(
96    app: &Application,
97    platform: &str,
98    component: &str,
99    variant: Option<&str>,
100) -> Option<Resolved> {
101    let rules: Vec<&StyleRule> = app
102        .style
103        .rules_for(platform)
104        .filter(|rule| rule.component == component && applies(rule, platform))
105        .filter(|rule| rule.variant.is_none() || rule.variant.as_deref() == variant)
106        .collect();
107    if rules.is_empty() {
108        return None;
109    }
110    let mut resolved = Resolved {
111        // `SidebarItem` becomes `sidebarItem`, `Button.hero` `buttonHero`.
112        name: {
113            let mut chars = component.chars();
114            let first: String = chars
115                .next()
116                .map(|c| c.to_lowercase().collect())
117                .unwrap_or_default();
118            let suffix = variant.map(pascal).unwrap_or_default();
119            format!("{first}{}{suffix}", chars.as_str())
120        },
121        base: Vec::new(),
122        states: Vec::new(),
123        settle: None,
124    };
125    // Rules for the kind first, then for the variant.
126    let ordered = rules
127        .iter()
128        .filter(|rule| rule.variant.is_none())
129        .chain(rules.iter().filter(|rule| rule.variant.is_some()));
130    for rule in ordered {
131        for effect in &rule.base {
132            resolved
133                .base
134                .retain(|existing| existing.name() != effect.name());
135            resolved.base.push(effect.clone());
136        }
137        for state in &rule.states {
138            match resolved
139                .states
140                .iter_mut()
141                .find(|(existing, _)| *existing == state.state)
142            {
143                Some((_, effects)) => *effects = state.effects.clone(),
144                None => resolved.states.push((state.state, state.effects.clone())),
145            }
146        }
147        if rule.settle.is_some() {
148            resolved.settle = rule.settle.clone();
149        }
150    }
151    Some(resolved)
152}
153
154/// Every style the application's controls use on `platform`: the kinds,
155/// and each variant instances select, in components and in the content
156/// written directly in a window.
157pub fn used(app: &Application, platform: &str) -> Vec<Resolved> {
158    let mut keys: Vec<(&str, Option<&str>)> = Vec::new();
159    app.walk(&mut |node, _| {
160        if let bern_ir::Node::Element(element) = node {
161            let key = (element.kind.as_str(), element.keyword("variant"));
162            if !keys.contains(&key) {
163                keys.push(key);
164            }
165        }
166    });
167    keys.into_iter()
168        .filter_map(|(kind, variant)| resolve(app, platform, kind, variant))
169        .collect()
170}
171
172/// The lengths the styles set on one of `platforms` in place of those the
173/// element writes: each length with a value replaces the written one, and
174/// a width or height styled `fit` drops it, so the element takes its
175/// content's.
176fn restyle(app: &Application, platforms: &[&str], element: &mut Element) {
177    let Some(style) = platforms
178        .iter()
179        .find_map(|platform| resolve(app, platform, &element.kind, element.keyword("variant")))
180    else {
181        return;
182    };
183    for effect in &style.base {
184        let (name, value) = match effect {
185            StyleEffect::Metric { metric, value } => (metric.name(), *value),
186            // A row's or column's padding is a length it writes too.
187            StyleEffect::Padding { length }
188                if matches!(element.kind.as_str(), "Row" | "Column") =>
189            {
190                ("padding", Some(*length))
191            }
192            _ => continue,
193        };
194        match value {
195            Some(value) => {
196                element.props.insert(name.into(), Prop::Number { value });
197            }
198            None => {
199                element.props.remove(name);
200            }
201        }
202    }
203}
204
205/// A chip outside the web: the platform's capsule at the chip's 30 points
206/// unless it says how tall.
207fn chip_as_capsule(element: &mut Element) {
208    if element.keyword("shape") == Some("chip") {
209        element.props.insert(
210            "shape".into(),
211            Prop::Keyword {
212                value: "capsule".into(),
213            },
214        );
215        element
216            .props
217            .entry("height".into())
218            .or_insert(Prop::Number { value: 30.0 });
219    }
220}
221
222/// `app` as `platforms` show it: the elements whose styles set lengths
223/// there take them, outside the web a chip is the platform's capsule, and
224/// lists in scroll views are lowered to lazy columns
225/// ([`bern_ir::lower_lists`]).
226pub fn for_platforms<'a>(app: &'a Application, platforms: &[&str]) -> Cow<'a, Application> {
227    let web = platforms.contains(&"browser");
228    let mut owned = app.clone();
229    let restyled = &mut |element: &mut Element| {
230        restyle(app, platforms, element);
231        if !web {
232            chip_as_capsule(element);
233        }
234    };
235    for component in &mut owned.components {
236        component.body.elements_mut(restyled);
237    }
238    for window in &mut owned.windows.windows {
239        window.content.elements_mut(restyled);
240    }
241    // Lists in scroll views build only the rows in sight, with the gaps
242    // the styles just gave their columns.
243    bern_ir::lower_lists(&mut owned);
244    if owned == *app {
245        Cow::Borrowed(app)
246    } else {
247        Cow::Owned(owned)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::brand_accent;
254    use bern_ir::style::ColorRef;
255    use bern_ir::{Expr, ExprKind, Srgb};
256    use bern_protocol::{Type, Value};
257
258    #[test]
259    fn the_brand_seed_is_the_accent_each_mode_reads() {
260        let mut app = crate::texts::tests::app(Vec::new());
261        assert_eq!(brand_accent(&app), None);
262        // A medium blue reads on both modes and stays as chosen; a pale
263        // yellow darkens on light surfaces.
264        app.theme.brand_seed = Some(Srgb {
265            red: 0x33,
266            green: 0x66,
267            blue: 0xCC,
268        });
269        let Some(ColorRef::Fixed { light, dark }) = brand_accent(&app) else {
270            panic!("no accent");
271        };
272        assert_eq!((light.red, light.green, light.blue), (0x33, 0x66, 0xCC));
273        assert_eq!((dark.red, dark.green, dark.blue), (0x33, 0x66, 0xCC));
274        app.theme.brand_seed = Some(Srgb {
275            red: 0xFF,
276            green: 0xE6,
277            blue: 0x6D,
278        });
279        let Some(ColorRef::Fixed { light, .. }) = brand_accent(&app) else {
280            panic!("no accent");
281        };
282        let sum = |c: Srgb| u32::from(c.red) + u32::from(c.green) + u32::from(c.blue);
283        assert!(sum(light) < 0xFF + 0xE6 + 0x6D);
284        // An accent chosen at run time wins.
285        app.windows.accent = Some(Expr {
286            kind: ExprKind::Literal {
287                value: Value::Text("blue".into()),
288            },
289            ty: Type::Text,
290        });
291        assert_eq!(brand_accent(&app), None);
292    }
293}