Skip to main content

lumen_scene/
fragments.rs

1//! Fragments at run time: the app's declared fragment set, the marker a
2//! fragment instance carries, and the argument binding an instantiation
3//! resolves its placeholders against.
4//!
5//! A fragment is compiled once and instantiated many times. The compiler
6//! puts every declaration in the artifact's
7//! [`FragmentTable`](lumen_ir::fragment::FragmentTable); [`FragmentLibrary`]
8//! is that table as a world resource, so anything holding the world can
9//! instantiate by key. The instantiation itself lives with the rest of the
10//! DOM command applier, which is the only caller.
11//!
12//! Arguments are static: they substitute once, when the instance is built.
13//! A value that changes while the app runs is a `bind-*` attribute inside
14//! the fragment body, which the spawn path seeds and the per-tick binding
15//! systems drive. [`FragmentInstance`] records what an instance was built
16//! with, which is what a build reads back to write a component's body into
17//! the document it emits.
18
19use 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
28/// Put the app's declared fragments in `world`, replacing whatever set was
29/// there.
30///
31/// Every path that starts an app calls this with the table that app declares:
32/// the desktop with what it parsed or reloaded, and every path that boots from
33/// a compiled artifact with the table the artifact carries. One function
34/// rather than an insert per path, because a path that forgot the insert has
35/// an app whose fragments all report an unknown key, and nothing else about it
36/// looks wrong.
37pub fn install(world: &mut World, table: FragmentTable) {
38    world.insert_resource(FragmentLibrary::new(table));
39}
40
41/// The app's declared fragments, keyed by fragment key.
42///
43/// Installed from the artifact (or from the parsed sources) when the app is
44/// built. Cheap to clone: instantiation takes a handle on the table so the
45/// world is free for the spawn that follows.
46#[derive(Resource, Clone, Default)]
47pub struct FragmentLibrary(Arc<FragmentTable>);
48
49impl FragmentLibrary {
50    /// Wrap a compiled table.
51    pub fn new(table: FragmentTable) -> Self {
52        Self(Arc::new(table))
53    }
54
55    /// Look a fragment up by key.
56    pub fn get(&self, key: &str) -> Option<&Fragment> {
57        self.0.get(key)
58    }
59
60    /// Whether the app declares no fragments at all.
61    pub fn is_empty(&self) -> bool {
62        self.0.is_empty()
63    }
64}
65
66/// Marks the root of an instantiated fragment with the key it came from and
67/// the arguments it was built with.
68///
69/// Recorded so per-instance arguments can become live later, and read back
70/// by a build that writes what each instance rendered into its document.
71#[derive(Component, Debug, Clone)]
72pub struct FragmentInstance {
73    /// Key of the fragment this subtree came from.
74    pub key: String,
75    /// Arguments the instance resolved, defaults already folded in.
76    pub args: BTreeMap<String, String>,
77}
78
79/// Marks a spawned `<slot>`: the element a use site's child replaces.
80#[derive(Component, Debug, Clone)]
81pub struct SlotPlaceholder(pub String);
82
83/// The slot name an element occupies, or `None` when it is not a slot.
84pub 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/// Why a fragment could not be instantiated.
97#[derive(Debug, PartialEq, Eq)]
98pub enum FragmentFault {
99    /// No fragment is declared under the requested key.
100    UnknownKey,
101    /// The body has a number of roots other than one.
102    RootCount(usize),
103    /// The single root is itself a slot, so the instance would have no root
104    /// of its own once the slot is filled.
105    RootIsSlot,
106    /// The caller passed a child for a slot the body does not declare.
107    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
132/// The element an instance spawns from, or why it cannot.
133pub 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
143/// Bind the fragment's parameters: every declared parameter takes its
144/// default, then the use site's arguments override. An argument naming
145/// something the fragment does not declare is kept, so a body written
146/// against a wider parameter list than its declaration still resolves.
147pub 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
159/// Report a fragment fault once per `(key, fault)` pair. A command issued
160/// every tick reports the first time and stays quiet after, so a broken key
161/// is visible without flooding the log.
162pub 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}