1use bevy_ecs::component::Component;
20use bevy_ecs::resource::Resource;
21use bevy_ecs::world::World;
22use lumen_core::warn_line;
23use lumen_ir::fragment::{DEFAULT_SLOT, Fragment, FragmentTable, SLOT_TAG};
24use lumen_ir::layout_ir::Element;
25use std::collections::BTreeMap;
26use std::sync::Arc;
27
28pub fn install(world: &mut World, table: FragmentTable) {
38 world.insert_resource(FragmentLibrary::new(table));
39}
40
41#[derive(Resource, Clone, Default)]
47pub struct FragmentLibrary(Arc<FragmentTable>);
48
49impl FragmentLibrary {
50 pub fn new(table: FragmentTable) -> Self {
52 Self(Arc::new(table))
53 }
54
55 pub fn get(&self, key: &str) -> Option<&Fragment> {
57 self.0.get(key)
58 }
59
60 pub fn is_empty(&self) -> bool {
62 self.0.is_empty()
63 }
64}
65
66#[derive(Component, Debug, Clone)]
72pub struct FragmentInstance {
73 pub key: String,
75 pub args: BTreeMap<String, String>,
77}
78
79#[derive(Component, Debug, Clone)]
81pub struct SlotPlaceholder(pub String);
82
83pub fn slot_name_of(el: &Element) -> Option<String> {
85 if el.tag != SLOT_TAG {
86 return None;
87 }
88 Some(
89 el.attrs
90 .slot_name
91 .clone()
92 .unwrap_or_else(|| DEFAULT_SLOT.to_string()),
93 )
94}
95
96#[derive(Debug, PartialEq, Eq)]
98pub enum FragmentFault {
99 UnknownKey,
101 RootCount(usize),
103 RootIsSlot,
106 UnknownSlot(String),
108}
109
110impl std::fmt::Display for FragmentFault {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 Self::UnknownKey => write!(f, "no fragment is declared under this key"),
114 Self::RootCount(n) => write!(
115 f,
116 "the body has {n} roots; instantiation returns one node, so a fragment \
117 reached this way needs exactly one root element"
118 ),
119 Self::RootIsSlot => {
120 write!(f, "the body root is a slot, which leaves no root to return")
121 }
122 Self::UnknownSlot(name) => {
123 write!(
124 f,
125 "no slot named `{name}` in the body; the child stays detached"
126 )
127 }
128 }
129 }
130}
131
132pub fn instance_body(fragment: &Fragment) -> Result<&Element, FragmentFault> {
134 let [root] = fragment.body.as_slice() else {
135 return Err(FragmentFault::RootCount(fragment.body.len()));
136 };
137 if slot_name_of(root).is_some() {
138 return Err(FragmentFault::RootIsSlot);
139 }
140 Ok(root)
141}
142
143pub fn bind_args(fragment: &Fragment, args: &[(String, String)]) -> BTreeMap<String, String> {
148 let mut bound: BTreeMap<String, String> = fragment
149 .params
150 .iter()
151 .map(|p| (p.name.clone(), p.default.clone().unwrap_or_default()))
152 .collect();
153 for (name, value) in args {
154 bound.insert(name.clone(), value.clone());
155 }
156 bound
157}
158
159pub fn report_once(key: &str, fault: &FragmentFault) {
163 use std::collections::HashSet;
164 use std::sync::{Mutex, OnceLock};
165 static SEEN: OnceLock<Mutex<HashSet<(String, String)>>> = OnceLock::new();
166 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
167 let mut guard = seen.lock().unwrap_or_else(|e| e.into_inner());
168 if guard.insert((key.to_string(), fault.to_string())) {
169 warn_line!("spawn_fragment `{key}`: {fault}");
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use lumen_ir::fragment::{FragmentKind, FragmentParam};
177 use lumen_ir::layout_ir::Attributes;
178
179 fn fragment(params: Vec<FragmentParam>, body: Vec<Element>) -> Fragment {
180 Fragment {
181 key: "card".to_string(),
182 params,
183 body,
184 origins: Vec::new(),
185 kind: FragmentKind::Template,
186 components: Vec::new(),
187 }
188 }
189
190 fn param(name: &str, default: Option<&str>) -> FragmentParam {
191 FragmentParam {
192 name: name.to_string(),
193 default: default.map(str::to_string),
194 }
195 }
196
197 fn el(tag: &str) -> Element {
198 Element {
199 tag: tag.to_string(),
200 ..Element::default()
201 }
202 }
203
204 #[test]
205 fn a_use_site_argument_beats_the_declared_default() {
206 let f = fragment(
207 vec![
208 param("title", Some("Untitled")),
209 param("tone", Some("calm")),
210 ],
211 vec![el("column")],
212 );
213 let bound = bind_args(&f, &[("title".to_string(), "Recent".to_string())]);
214 assert_eq!(bound.get("title").map(String::as_str), Some("Recent"));
215 assert_eq!(bound.get("tone").map(String::as_str), Some("calm"));
216 }
217
218 #[test]
219 fn a_parameter_with_no_default_and_no_argument_binds_empty() {
220 let f = fragment(vec![param("title", None)], vec![el("column")]);
221 let bound = bind_args(&f, &[]);
222 assert_eq!(bound.get("title").map(String::as_str), Some(""));
223 }
224
225 #[test]
226 fn one_root_is_the_instance_body() {
227 let f = fragment(Vec::new(), vec![el("column")]);
228 assert_eq!(instance_body(&f).expect("single root").tag, "column");
229 }
230
231 #[test]
232 fn several_roots_report_their_count() {
233 let f = fragment(Vec::new(), vec![el("label"), el("label")]);
234 assert_eq!(instance_body(&f).err(), Some(FragmentFault::RootCount(2)));
235 }
236
237 #[test]
238 fn an_empty_body_is_a_root_count_of_zero() {
239 let f = fragment(Vec::new(), Vec::new());
240 assert_eq!(instance_body(&f).err(), Some(FragmentFault::RootCount(0)));
241 }
242
243 #[test]
244 fn a_slot_root_is_rejected() {
245 let f = fragment(Vec::new(), vec![el(SLOT_TAG)]);
246 assert_eq!(instance_body(&f).err(), Some(FragmentFault::RootIsSlot));
247 }
248
249 #[test]
250 fn an_unnamed_slot_occupies_the_default_name() {
251 assert_eq!(slot_name_of(&el(SLOT_TAG)).as_deref(), Some(DEFAULT_SLOT));
252 assert_eq!(slot_name_of(&el("column")), None);
253 }
254
255 #[test]
256 fn a_named_slot_keeps_its_name() {
257 let mut slot = el(SLOT_TAG);
258 slot.attrs = Attributes {
259 slot_name: Some("footer".to_string()),
260 ..Attributes::default()
261 };
262 assert_eq!(slot_name_of(&slot).as_deref(), Some("footer"));
263 }
264}