Skip to main content

gpui_rhai/
component_styles.rs

1use std::collections::BTreeMap;
2
3use rhai::{Dynamic, Engine, Map, Scope};
4use thiserror::Error;
5
6use crate::{ComponentRegistry, ModuleId, Style};
7
8const MAX_STYLED_COMPONENTS: usize = 256;
9const MAX_STYLED_PARTS: usize = 4_096;
10
11/// Validated application-wide Style overrides keyed by formal component and part.
12///
13/// Rules contain the same typed [`Style`] values accepted by per-instance
14/// `style`/`part_styles`. They are merged after component source defaults and
15/// before explicit instance overrides.
16#[derive(Clone, Debug, Default, PartialEq)]
17pub struct ComponentStyleSheet {
18    rules: BTreeMap<ModuleId, BTreeMap<String, Style>>,
19}
20
21impl ComponentStyleSheet {
22    #[must_use]
23    pub fn is_empty(&self) -> bool {
24        self.rules.is_empty()
25    }
26
27    #[must_use]
28    pub fn len(&self) -> usize {
29        self.rules.values().map(BTreeMap::len).sum()
30    }
31
32    #[must_use]
33    pub fn component(&self, id: &ModuleId) -> Option<&BTreeMap<String, Style>> {
34        self.rules.get(id)
35    }
36
37    fn new(
38        rules: BTreeMap<ModuleId, BTreeMap<String, Style>>,
39        components: &ComponentRegistry,
40    ) -> Result<Self, ComponentStyleError> {
41        if rules.len() > MAX_STYLED_COMPONENTS {
42            return Err(ComponentStyleError::TooManyComponents(rules.len()));
43        }
44        let part_count = rules.values().map(BTreeMap::len).sum::<usize>();
45        if part_count > MAX_STYLED_PARTS {
46            return Err(ComponentStyleError::TooManyParts(part_count));
47        }
48        for (id, parts) in &rules {
49            let component = components
50                .get(id)
51                .ok_or_else(|| ComponentStyleError::UnknownComponent(id.clone()))?;
52            for part in parts.keys() {
53                if !component.schema.parts.contains(part) {
54                    return Err(ComponentStyleError::UnknownPart {
55                        component: id.clone(),
56                        part: part.clone(),
57                    });
58                }
59            }
60        }
61        Ok(Self { rules })
62    }
63}
64
65/// Compile and evaluate `component_styles() -> map`, then validate every
66/// component ID and named part against the active component registry.
67///
68/// # Errors
69///
70/// Returns compile/evaluation errors, shape/type errors, resource-limit errors,
71/// or an unknown component/part diagnostic.
72pub fn load_component_styles(
73    engine: &Engine,
74    source_name: &str,
75    source: &str,
76    components: &ComponentRegistry,
77) -> Result<ComponentStyleSheet, ComponentStyleError> {
78    let mut ast = engine
79        .compile(source)
80        .map_err(|error| ComponentStyleError::Script(error.to_string()))?;
81    crate::engine::validate_assignment_targets(&ast)
82        .map_err(|error| ComponentStyleError::Script(error.to_string()))?;
83    ast.set_source(source_name);
84    let raw: Dynamic = engine
85        .call_fn(&mut Scope::new(), &ast, "component_styles", ())
86        .map_err(|error| ComponentStyleError::Script(error.to_string()))?;
87    let root = raw
88        .try_cast::<Map>()
89        .ok_or(ComponentStyleError::RootNotMap)?;
90    let mut rules = BTreeMap::new();
91    for (raw_id, raw_parts) in root {
92        let id = ModuleId::parse(raw_id.as_str()).map_err(|source| {
93            ComponentStyleError::InvalidComponentId {
94                id: raw_id.to_string(),
95                source,
96            }
97        })?;
98        let parts = raw_parts
99            .try_cast::<Map>()
100            .ok_or_else(|| ComponentStyleError::ComponentNotMap(id.clone()))?;
101        let mut decoded = BTreeMap::new();
102        for (part, value) in parts {
103            if !value.is::<Style>() {
104                return Err(ComponentStyleError::PartNotStyle {
105                    component: id,
106                    part: part.to_string(),
107                });
108            }
109            decoded.insert(part.to_string(), value.cast::<Style>());
110        }
111        rules.insert(id, decoded);
112    }
113    ComponentStyleSheet::new(rules, components)
114}
115
116#[derive(Debug, Error)]
117pub enum ComponentStyleError {
118    #[error("component stylesheet script failed: {0}")]
119    Script(String),
120    #[error("component_styles() must return a map")]
121    RootNotMap,
122    #[error("component stylesheet ID `{id}` is invalid: {source}")]
123    InvalidComponentId {
124        id: String,
125        source: crate::ModuleIdError,
126    },
127    #[error("component stylesheet rule `{0}` must be a map of named Style values")]
128    ComponentNotMap(ModuleId),
129    #[error("component stylesheet rule `{component}.{part}` must be a Style")]
130    PartNotStyle { component: ModuleId, part: String },
131    #[error("component stylesheet references unavailable component `{0}`")]
132    UnknownComponent(ModuleId),
133    #[error("component stylesheet references unknown part `{component}.{part}`")]
134    UnknownPart { component: ModuleId, part: String },
135    #[error("component stylesheet has {0} components; the limit is {MAX_STYLED_COMPONENTS}")]
136    TooManyComponents(usize),
137    #[error("component stylesheet has {0} part rules; the limit is {MAX_STYLED_PARTS}")]
138    TooManyParts(usize),
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::{
145        ComponentDefinition, ComponentInstancePath, ComponentMetadata, ComponentSchema,
146        ExecutionPhase, RuntimeApiRange, UiContext, UiRuntimeState,
147    };
148    use semver::Version;
149    use std::cell::RefCell;
150    use std::rc::Rc;
151
152    fn registry() -> ComponentRegistry {
153        let mut registry = ComponentRegistry::new();
154        registry
155            .register(
156                ComponentDefinition::new(
157                    ComponentMetadata {
158                        id: ModuleId::parse("components/button").unwrap(),
159                        export: "Button".to_owned(),
160                        version: Version::new(0, 1, 0),
161                        runtime_api: RuntimeApiRange::new(3, 4),
162                        dependencies: std::collections::BTreeSet::default(),
163                        capabilities: BTreeMap::default(),
164                        assets: std::collections::BTreeSet::default(),
165                        tokens: std::collections::BTreeSet::new(),
166                        environment: std::collections::BTreeSet::new(),
167                    },
168                    ComponentSchema {
169                        parts: ["root".to_owned(), "label".to_owned()]
170                            .into_iter()
171                            .collect(),
172                        ..ComponentSchema::default()
173                    },
174                )
175                .unwrap(),
176                crate::RUNTIME_API_VERSION,
177            )
178            .unwrap();
179        registry
180    }
181
182    #[test]
183    fn source_decodes_typed_styles_and_validates_parts() {
184        let engine = crate::RuntimeEngine::new();
185        let sheet = load_component_styles(
186            engine.engine(),
187            "styles.rhai",
188            r#"
189                fn component_styles() {
190                    #{ "components/button": #{
191                        root: style().height(px(34)).radius(theme_radius("md")),
192                        label: style().typography("body").font_weight(600),
193                    } }
194                }
195            "#,
196            &registry(),
197        )
198        .unwrap();
199        assert_eq!(sheet.len(), 2);
200        assert_eq!(
201            sheet
202                .component(&ModuleId::parse("components/button").unwrap())
203                .unwrap()["root"]
204                .base
205                .height,
206            Some(crate::LayoutLength::Definite(crate::Length::Pixels(34.0)))
207        );
208    }
209
210    #[test]
211    fn source_rejects_unknown_components_parts_and_non_styles() {
212        let engine = crate::RuntimeEngine::new();
213        for (source, expected) in [
214            (
215                r#"fn component_styles() { #{ "components/missing": #{ root: style() } } }"#,
216                "unavailable component",
217            ),
218            (
219                r#"fn component_styles() { #{ "components/button": #{ icon: style() } } }"#,
220                "unknown part",
221            ),
222            (
223                r#"fn component_styles() { #{ "components/button": #{ root: 12 } } }"#,
224                "must be a Style",
225            ),
226        ] {
227            let error = load_component_styles(engine.engine(), "styles.rhai", source, &registry())
228                .unwrap_err();
229            assert!(error.to_string().contains(expected), "{error}");
230        }
231    }
232
233    #[test]
234    fn stylesheet_precedes_instance_overrides_during_component_render() {
235        let mut engine = crate::RuntimeEngine::new();
236        let compiled = engine
237            .compile(
238                r#"
239                    define_component(#{
240                        metadata: #{
241                            id: "components/button", "export": "Button", version: "0.1.0",
242                            runtime_api: #{ min_inclusive: 3, max_exclusive: 4 },
243                            dependencies: [], capabilities: #{}
244                        },
245                        schema: #{ props: #{}, state: #{ fields: #{} }, events: #{},
246                            slots: #{}, parts: ["root", "label"] },
247                        render: Fn("render_Button")
248                    });
249                    fn Button(props) { render_component("components/button", props) }
250                    fn render_Button(ctx, props) {
251                        text("button").with_style(ctx.component_style("root",
252                            style().height(px(24)).radius(px(0))))
253                    }
254                    fn view(ctx) {
255                        column([
256                            Button(#{ key: "global" }),
257                            Button(#{ key: "instance", style: style().height(px(40)) }),
258                        ])
259                    }
260                "#,
261            )
262            .unwrap();
263        let sheet = load_component_styles(
264            engine.engine(),
265            "styles.rhai",
266            r#"
267                fn component_styles() {
268                    #{ "components/button": #{
269                        root: style().height(px(34)).radius(px(6)),
270                    } }
271                }
272            "#,
273            &registry(),
274        )
275        .unwrap();
276        let mut state = UiRuntimeState::new();
277        state.replace_component_styles_from_host(sheet);
278        let state = Rc::new(RefCell::new(state));
279        let root_path = ComponentInstancePath::root("App", "styles");
280        let context = UiContext::new(
281            Rc::clone(&state),
282            root_path,
283            None,
284            ExecutionPhase::Render,
285            BTreeMap::new(),
286        )
287        .with_generation(compiled.generation());
288        let root = engine.render_with_context(&compiled, context).unwrap();
289        let crate::UiNodeKind::Box { children } = root.kind() else {
290            panic!("view must return a column");
291        };
292        assert_eq!(
293            children[0].style().base.height,
294            Some(crate::LayoutLength::Definite(crate::Length::Pixels(34.0)))
295        );
296        assert_eq!(
297            children[1].style().base.height,
298            Some(crate::LayoutLength::Definite(crate::Length::Pixels(40.0)))
299        );
300        assert_eq!(
301            children[0].style().base.radii.top_left,
302            Some(crate::Length::Pixels(6.0))
303        );
304    }
305}