Skip to main content

foundry_runtime/
js.rs

1use boa_engine::property::Attribute;
2use boa_engine::NativeFunction;
3use boa_engine::{Context, JsArgs, JsValue, Source};
4use std::cell::RefCell;
5use std::rc::Rc;
6
7use crate::scene::*;
8
9#[derive(Debug, Clone)]
10pub enum DomMutation {
11    SetTextContent(String, String),
12    SetStyle(String, String, String),
13    AddClass(String, String),
14    RemoveClass(String, String),
15}
16
17pub struct JsEngine {
18    context: Context,
19    mutations: Rc<RefCell<Vec<DomMutation>>>,
20}
21
22impl Default for JsEngine {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl JsEngine {
29    pub fn new() -> Self {
30        let context = Context::default();
31        let mutations = Rc::new(RefCell::new(Vec::new()));
32
33        let mut engine = Self { context, mutations };
34        engine.register_dom_api();
35        engine
36    }
37
38    fn register_dom_api(&mut self) {
39        let mutations = self.mutations.clone();
40
41        // SAFETY: from_closure requires the closure is valid for 'static.
42        // The closure captures only Rc<RefCell<Vec<DomMutation>>> (no borrowed refs),
43        // so it satisfies the 'static bound.
44        let get_element = unsafe {
45            NativeFunction::from_closure(move |_, args, ctx| {
46                let id = args
47                    .get_or_undefined(0)
48                    .to_string(ctx)?
49                    .to_std_string_escaped();
50
51                let m = mutations.clone();
52                let element_id = id.clone();
53
54                let obj = boa_engine::JsObject::with_null_proto();
55
56                // setTextContent(text)
57                {
58                    let m = m.clone();
59                    let eid = element_id.clone();
60                    let func = NativeFunction::from_closure(move |_, args, ctx| {
61                        let text = args
62                            .get_or_undefined(0)
63                            .to_string(ctx)?
64                            .to_std_string_escaped();
65                        m.borrow_mut()
66                            .push(DomMutation::SetTextContent(eid.clone(), text));
67                        Ok(JsValue::undefined())
68                    });
69                    let js_func = func.to_js_function(ctx.realm());
70                    obj.set(
71                        boa_engine::js_string!("setTextContent"),
72                        js_func,
73                        false,
74                        ctx,
75                    )?;
76                }
77
78                // setStyle(property, value)
79                {
80                    let m = m.clone();
81                    let eid = element_id.clone();
82                    let func = NativeFunction::from_closure(move |_, args, ctx| {
83                        let prop = args
84                            .get_or_undefined(0)
85                            .to_string(ctx)?
86                            .to_std_string_escaped();
87                        let val = args
88                            .get_or_undefined(1)
89                            .to_string(ctx)?
90                            .to_std_string_escaped();
91                        m.borrow_mut()
92                            .push(DomMutation::SetStyle(eid.clone(), prop, val));
93                        Ok(JsValue::undefined())
94                    });
95                    let js_func = func.to_js_function(ctx.realm());
96                    obj.set(boa_engine::js_string!("setStyle"), js_func, false, ctx)?;
97                }
98
99                // addClass(className)
100                {
101                    let m = m.clone();
102                    let eid = element_id.clone();
103                    let func = NativeFunction::from_closure(move |_, args, ctx| {
104                        let class = args
105                            .get_or_undefined(0)
106                            .to_string(ctx)?
107                            .to_std_string_escaped();
108                        m.borrow_mut()
109                            .push(DomMutation::AddClass(eid.clone(), class));
110                        Ok(JsValue::undefined())
111                    });
112                    let js_func = func.to_js_function(ctx.realm());
113                    obj.set(boa_engine::js_string!("addClass"), js_func, false, ctx)?;
114                }
115
116                // removeClass(className)
117                {
118                    let m = m.clone();
119                    let eid = element_id.clone();
120                    let func = NativeFunction::from_closure(move |_, args, ctx| {
121                        let class = args
122                            .get_or_undefined(0)
123                            .to_string(ctx)?
124                            .to_std_string_escaped();
125                        m.borrow_mut()
126                            .push(DomMutation::RemoveClass(eid.clone(), class));
127                        Ok(JsValue::undefined())
128                    });
129                    let js_func = func.to_js_function(ctx.realm());
130                    obj.set(boa_engine::js_string!("removeClass"), js_func, false, ctx)?;
131                }
132
133                obj.set(
134                    boa_engine::js_string!("id"),
135                    JsValue::from(boa_engine::js_string!(element_id.as_str())),
136                    false,
137                    ctx,
138                )?;
139
140                Ok(JsValue::from(obj))
141            })
142        };
143
144        let document = boa_engine::JsObject::with_null_proto();
145        let get_elem_func = get_element.to_js_function(self.context.realm());
146        document
147            .set(
148                boa_engine::js_string!("getElementById"),
149                get_elem_func,
150                false,
151                &mut self.context,
152            )
153            .ok();
154
155        self.context
156            .register_global_property(
157                boa_engine::js_string!("document"),
158                JsValue::from(document),
159                Attribute::all(),
160            )
161            .ok();
162
163        // console.log
164        let console = boa_engine::JsObject::with_null_proto();
165        let log_fn = NativeFunction::from_fn_ptr(|_, args, ctx| {
166            let msg = args
167                .get_or_undefined(0)
168                .to_string(ctx)?
169                .to_std_string_escaped();
170            println!("[foundry:js] {}", msg);
171            Ok(JsValue::undefined())
172        });
173        let log_js_func = log_fn.to_js_function(self.context.realm());
174        console
175            .set(
176                boa_engine::js_string!("log"),
177                log_js_func,
178                false,
179                &mut self.context,
180            )
181            .ok();
182
183        self.context
184            .register_global_property(
185                boa_engine::js_string!("console"),
186                JsValue::from(console),
187                Attribute::all(),
188            )
189            .ok();
190    }
191
192    pub fn execute(&mut self, code: &str) -> Result<(), String> {
193        self.mutations.borrow_mut().clear();
194        // boa_engine::Context::eval is the official API for JS execution
195        self.context
196            .eval(Source::from_bytes(code))
197            .map_err(|e| format!("JS error: {}", e))?;
198        Ok(())
199    }
200
201    pub fn take_mutations(&self) -> Vec<DomMutation> {
202        self.mutations.borrow_mut().drain(..).collect()
203    }
204
205    pub fn apply_mutations(&self, scene: &mut SceneGraph, mutations: &[DomMutation]) {
206        for mutation in mutations {
207            match mutation {
208                DomMutation::SetTextContent(element_id, text) => {
209                    if let Some(node_id) = scene.find_by_element_id(element_id) {
210                        let children: Vec<NodeId> = scene.get(node_id).children.clone();
211                        let text_child = children
212                            .iter()
213                            .find(|&&c| scene.get(c).kind == ElementKind::Text);
214                        if let Some(&child_id) = text_child {
215                            scene.get_mut(child_id).text_content = Some(text.clone());
216                            scene.get_mut(child_id).dirty = true;
217                        } else {
218                            let child = scene.add_node(ElementKind::Text, "text".to_string());
219                            scene.get_mut(child).text_content = Some(text.clone());
220                            scene.add_child(node_id, child);
221                        }
222                        scene.mark_dirty_recursive(node_id);
223                    }
224                }
225                DomMutation::SetStyle(element_id, prop, val) => {
226                    if let Some(node_id) = scene.find_by_element_id(element_id) {
227                        crate::css::apply_property(&mut scene.get_mut(node_id).style, prop, val);
228                        scene.mark_dirty_recursive(node_id);
229                    }
230                }
231                DomMutation::AddClass(element_id, class) => {
232                    if let Some(node_id) = scene.find_by_element_id(element_id) {
233                        let node = scene.get_mut(node_id);
234                        if !node.classes.contains(class) {
235                            node.classes.push(class.clone());
236                            node.dirty = true;
237                        }
238                    }
239                }
240                DomMutation::RemoveClass(element_id, class) => {
241                    if let Some(node_id) = scene.find_by_element_id(element_id) {
242                        let node = scene.get_mut(node_id);
243                        node.classes.retain(|c| c != class);
244                        node.dirty = true;
245                    }
246                }
247            }
248        }
249    }
250}