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#[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
65pub 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 ®istry(),
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, ®istry())
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 ®istry(),
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}