Skip to main content

pite_script/
host.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::collections::HashMap;
3use std::sync::{Mutex, RwLock};
4
5use anyhow::Context as _;
6use pite_core::{NodeId, NodeTree, SignalRegistry};
7use pyo3::prelude::*;
8use pyo3::types::PyTuple;
9
10static CURRENT_HOST: RwLock<Option<std::sync::Arc<ScriptHost>>> = RwLock::new(None);
11
12#[cfg(test)]
13pub(crate) static HOST_SERIAL: Mutex<()> = Mutex::new(());
14
15pub fn set_current_host(host: std::sync::Arc<ScriptHost>) {
16    *CURRENT_HOST.write().unwrap() = Some(host);
17}
18
19pub(crate) fn current_host() -> Option<std::sync::Arc<ScriptHost>> {
20    CURRENT_HOST.read().unwrap().clone()
21}
22
23pub(crate) fn bound_target(callable: &Bound<'_, PyAny>) -> anyhow::Result<(String, String)> {
24    let owner = callable
25        .getattr("__self__")
26        .map_err(|_| anyhow::anyhow!("connect needs a bound pite method"))?;
27    let node_path: String = owner
28        .getattr("node_path")
29        .map_err(|_| anyhow::anyhow!("connect target is not a pite node method"))?
30        .extract()?;
31    let name: String = callable
32        .getattr("__name__")
33        .map_err(|_| anyhow::anyhow!("connect target has no method name"))?
34        .extract()?;
35    Ok((node_path, name))
36}
37
38pub struct ScriptHost {
39    tree: Mutex<NodeTree>,
40    instances: Mutex<HashMap<String, Py<PyAny>>>,
41    signals: Mutex<HashMap<String, SignalRegistry>>,
42    connections: Mutex<HashMap<(String, String), Vec<(String, String)>>>,
43    callable_connections: Mutex<HashMap<(String, String), Vec<Py<PyAny>>>>,
44    proxy_cache: Mutex<HashMap<(String, String), Py<NodeProxy>>>,
45}
46
47pub const SIGNAL_TYPES: &[&str] = &["int", "float", "str", "bool"];
48
49#[derive(Debug, Clone, PartialEq)]
50pub enum PayloadValue {
51    Int(i64),
52    Float(f64),
53    Str(String),
54    Bool(bool),
55}
56
57impl ScriptHost {
58    pub fn new(tree: NodeTree) -> Self {
59        Self {
60            tree: Mutex::new(tree),
61            instances: Mutex::new(HashMap::new()),
62            signals: Mutex::new(HashMap::new()),
63            connections: Mutex::new(HashMap::new()),
64            callable_connections: Mutex::new(HashMap::new()),
65            proxy_cache: Mutex::new(HashMap::new()),
66        }
67    }
68
69    pub fn get_node_position(&self, id: &str) -> Option<(f64, f64)> {
70        use pite_core::PropValue;
71        let tree = self.tree.lock().unwrap();
72        let node = tree.get(&NodeId::from(id.to_string()))?;
73        match node.props.get("position") {
74            Some(PropValue::Vec2(x, y)) => Some((*x, *y)),
75            _ => Some((0.0, 0.0)),
76        }
77    }
78
79    pub fn set_node_position(&self, id: &str, x: f64, y: f64) {
80        use pite_core::PropValue;
81        let mut tree = self.tree.lock().unwrap();
82        if let Some(node) = tree.get_mut(&NodeId::from(id.to_string())) {
83            node.props
84                .insert("position".to_string(), PropValue::Vec2(x, y));
85        }
86    }
87
88    pub fn set_node_text(&self, id: &str, text: &str) {
89        use pite_core::PropValue;
90        let mut tree = self.tree.lock().unwrap();
91        if let Some(node) = tree.get_mut(&NodeId::from(id.to_string())) {
92            node.props
93                .insert("text".to_string(), PropValue::Str(text.to_string()));
94        }
95    }
96
97    pub fn set_node_size(&self, id: &str, w: f64, h: f64) {
98        use pite_core::PropValue;
99        let mut tree = self.tree.lock().unwrap();
100        if let Some(node) = tree.get_mut(&NodeId::from(id.to_string())) {
101            node.props.insert("size".to_string(), PropValue::Vec2(w, h));
102        }
103    }
104
105    pub fn resolve_proxy(
106        self: &std::sync::Arc<Self>,
107        py: Python<'_>,
108        caller: &str,
109        path: &str,
110    ) -> anyhow::Result<Py<NodeProxy>> {
111        let key = (caller.to_string(), path.to_string());
112        {
113            let cache = self.proxy_cache.lock().unwrap();
114            if let Some(proxy) = cache.get(&key) {
115                return Ok(proxy.clone_ref(py));
116            }
117        }
118        let target = self
119            .resolve(caller, path)
120            .ok_or_else(|| anyhow::anyhow!("{caller}: get_node({path:?}) found nothing"))?;
121        let proxy = Py::new(py, proxy_for(self.clone(), target))?;
122        self.proxy_cache
123            .lock()
124            .unwrap()
125            .insert(key, proxy.clone_ref(py));
126        Ok(proxy)
127    }
128
129    pub fn register(&self, node: impl Into<String>, instance: Py<PyAny>) {
130        self.instances.lock().unwrap().insert(node.into(), instance);
131    }
132
133    pub fn unregister(&self, node: &str) {
134        self.instances.lock().unwrap().remove(node);
135    }
136
137    pub fn with_tree<R>(&self, f: impl FnOnce(&NodeTree) -> R) -> R {
138        f(&self.tree.lock().unwrap())
139    }
140
141    pub fn with_tree_mut<R>(&self, f: impl FnOnce(&mut NodeTree) -> R) -> R {
142        f(&mut self.tree.lock().unwrap())
143    }
144
145    pub fn resolve(&self, caller: &str, path: &str) -> Option<String> {
146        let tree = self.tree.lock().unwrap();
147        if let Some(abs) = path.strip_prefix('/') {
148            return tree
149                .get(&NodeId::from(abs.to_string()))
150                .map(|n| n.id.to_string());
151        }
152        let mut cursor = tree.get(&NodeId::from(caller.to_string()))?.clone();
153        for part in path.split('/') {
154            match part {
155                "" | "." => {}
156                ".." => {
157                    let parent = cursor.parent.clone()?;
158                    cursor = tree.get(&parent)?.clone();
159                }
160                name => {
161                    let child = cursor
162                        .children
163                        .iter()
164                        .map(|id| tree.get(id))
165                        .find(|n| n.map(|n| n.name == name).unwrap_or(false))?;
166                    cursor = child?.clone();
167                }
168            }
169        }
170        Some(cursor.id.to_string())
171    }
172
173    pub fn lookup_instance(&self, py: Python<'_>, node: &str) -> Option<Py<PyAny>> {
174        self.instances
175            .lock()
176            .unwrap()
177            .get(node)
178            .map(|inst| inst.clone_ref(py))
179    }
180
181    pub fn declare_signals(
182        &self,
183        node: &str,
184        defs: Vec<(String, Vec<String>)>,
185    ) -> anyhow::Result<()> {
186        for (name, payload) in &defs {
187            for ty in payload {
188                if !SIGNAL_TYPES.contains(&ty.as_str()) {
189                    anyhow::bail!(
190                        "{node}: signal {name:?} has unknown payload type {ty:?} (want one of {SIGNAL_TYPES:?})"
191                    );
192                }
193            }
194        }
195        let mut signals = self.signals.lock().unwrap();
196        let registry = signals.entry(node.to_string()).or_default();
197        for (name, payload) in defs {
198            registry.register(name, payload);
199        }
200        Ok(())
201    }
202
203    pub fn connect(
204        &self,
205        source: &str,
206        signal: &str,
207        target: &str,
208        handler: &str,
209    ) -> anyhow::Result<()> {
210        let signals = self.signals.lock().unwrap();
211        if signals
212            .get(source)
213            .and_then(|reg| reg.get(signal))
214            .is_none()
215        {
216            anyhow::bail!("{source}: connect to unknown signal {signal:?}");
217        }
218        drop(signals);
219        self.with_tree(|tree| {
220            if tree.get(&NodeId::from(target.to_string())).is_none() {
221                anyhow::bail!("connect target node {target:?} does not exist");
222            }
223            Ok(())
224        })?;
225        self.connections
226            .lock()
227            .unwrap()
228            .entry((source.to_string(), signal.to_string()))
229            .or_default()
230            .push((target.to_string(), handler.to_string()));
231        Ok(())
232    }
233
234    pub fn connect_callable(
235        &self,
236        _py: Python<'_>,
237        source: &str,
238        signal: &str,
239        callable: Py<PyAny>,
240    ) -> anyhow::Result<()> {
241        let signals = self.signals.lock().unwrap();
242        if signals
243            .get(source)
244            .and_then(|reg| reg.get(signal))
245            .is_none()
246        {
247            anyhow::bail!("{source}: connect to unknown signal {signal:?}");
248        }
249        drop(signals);
250        self.callable_connections
251            .lock()
252            .unwrap()
253            .entry((source.to_string(), signal.to_string()))
254            .or_default()
255            .push(callable);
256        Ok(())
257    }
258
259    pub fn emit(
260        &self,
261        py: Python<'_>,
262        source: &str,
263        signal: &str,
264        args: &Bound<'_, PyTuple>,
265    ) -> anyhow::Result<()> {
266        let expected = self
267            .signals
268            .lock()
269            .unwrap()
270            .get(source)
271            .and_then(|reg| reg.get(signal).cloned())
272            .ok_or_else(|| anyhow::anyhow!("{source}: emit of unknown signal {signal:?}"))?;
273        if args.len() != expected.payload.len() {
274            anyhow::bail!(
275                "{source}: signal {signal:?} wants {} values, got {}",
276                expected.payload.len(),
277                args.len()
278            );
279        }
280        let mut values = Vec::with_capacity(args.len());
281        for (i, (want, arg)) in expected.payload.iter().zip(args.iter()).enumerate() {
282            let value = coerce_payload(want, &arg)
283                .with_context(|| format!("{source}: signal {signal:?} arg {i} wants {want}"))?;
284            values.push(value);
285        }
286        let callables: Vec<Py<PyAny>> = self
287            .callable_connections
288            .lock()
289            .unwrap()
290            .get(&(source.to_string(), signal.to_string()))
291            .map(|list| list.iter().map(|c| c.clone_ref(py)).collect())
292            .unwrap_or_default();
293        if !callables.is_empty() {
294            let py_args: Vec<Py<PyAny>> = values.iter().map(|v| v.to_object(py)).collect();
295            let tuple = PyTuple::new(py, py_args).map_err(|e| anyhow::anyhow!("{e}"))?;
296            for c in callables {
297                c.bind(py)
298                    .call(tuple.clone(), None)
299                    .map_err(|e| anyhow::anyhow!("callable raised: {e}"))?;
300            }
301        }
302        let targets: Vec<(String, String)> = self
303            .connections
304            .lock()
305            .unwrap()
306            .get(&(source.to_string(), signal.to_string()))
307            .cloned()
308            .unwrap_or_default();
309        for (target, handler) in targets {
310            let Some(inst) = self.lookup_instance(py, &target) else {
311                continue;
312            };
313            let callable = inst.bind(py).getattr(handler.as_str()).map_err(|_| {
314                anyhow::anyhow!("{target}: handler {handler:?} missing for signal {signal:?}")
315            })?;
316            let py_args: Vec<Py<PyAny>> = values.iter().map(|v| v.to_object(py)).collect();
317            callable
318                .call(
319                    PyTuple::new(py, py_args).map_err(|e| anyhow::anyhow!("{e}"))?,
320                    None,
321                )
322                .map_err(|e| anyhow::anyhow!("{target}.{handler} raised: {e}"))?;
323        }
324        Ok(())
325    }
326
327    pub fn has_signal(&self, node: &str, signal: &str) -> bool {
328        self.signals
329            .lock()
330            .unwrap()
331            .get(node)
332            .and_then(|reg| reg.get(signal))
333            .is_some()
334    }
335
336    pub fn disconnect_node(&self, node: &str) {
337        self.signals.lock().unwrap().remove(node);
338        self.connections
339            .lock()
340            .unwrap()
341            .retain(|(src, _), targets| {
342                if src == node {
343                    return false;
344                }
345                targets.retain(|(t, _)| t != node);
346                !targets.is_empty()
347            });
348        self.callable_connections
349            .lock()
350            .unwrap()
351            .retain(|(src, _), _| src != node);
352        self.proxy_cache
353            .lock()
354            .unwrap()
355            .retain(|(src, _), _| src != node);
356    }
357
358    pub fn invalidate_proxy_cache(&self, node: &str) {
359        self.proxy_cache
360            .lock()
361            .unwrap()
362            .retain(|(src, _), _| src != node);
363    }
364}
365
366fn coerce_payload(want: &str, arg: &Bound<'_, PyAny>) -> anyhow::Result<PayloadValue> {
367    use pyo3::types::{PyBool, PyInt, PyString};
368    match want {
369        "bool" => {
370            if arg.is_instance_of::<PyBool>() {
371                Ok(PayloadValue::Bool(arg.extract()?))
372            } else {
373                anyhow::bail!("not a bool")
374            }
375        }
376        "int" => {
377            if arg.is_instance_of::<PyBool>() || !arg.is_instance_of::<PyInt>() {
378                anyhow::bail!("not an int");
379            }
380            Ok(PayloadValue::Int(arg.extract()?))
381        }
382        "float" => {
383            if arg.is_instance_of::<PyBool>() {
384                anyhow::bail!("not a float");
385            }
386            if let Ok(f) = arg.extract::<f64>() {
387                Ok(PayloadValue::Float(f))
388            } else {
389                anyhow::bail!("not a float");
390            }
391        }
392        "str" => {
393            if arg.is_instance_of::<PyString>() {
394                Ok(PayloadValue::Str(arg.extract()?))
395            } else {
396                anyhow::bail!("not a str");
397            }
398        }
399        other => anyhow::bail!("unknown payload type {other:?}"),
400    }
401}
402
403impl PayloadValue {
404    fn to_object(&self, py: Python<'_>) -> Py<PyAny> {
405        match self {
406            PayloadValue::Int(i) => i.into_pyobject(py).unwrap().into_any().unbind(),
407            PayloadValue::Float(f) => f.into_pyobject(py).unwrap().into_any().unbind(),
408            PayloadValue::Str(s) => s.into_pyobject(py).unwrap().into_any().unbind(),
409            PayloadValue::Bool(b) => pyo3::types::PyBool::new(py, *b)
410                .to_owned()
411                .unbind()
412                .into_any(),
413        }
414    }
415}
416
417#[pyclass(name = "NodeProxy")]
418pub struct NodeProxy {
419    host: std::sync::Arc<ScriptHost>,
420    target: String,
421}
422
423#[pymethods]
424impl NodeProxy {
425    #[getter]
426    fn position(&self) -> PyResult<crate::vec2::Vec2> {
427        use pite_core::PropValue;
428        self.host.with_tree(|tree| {
429            let node = tree
430                .get(&NodeId::from(self.target.clone()))
431                .ok_or_else(|| {
432                    pyo3::exceptions::PyKeyError::new_err(format!(
433                        "node {:?} no longer exists",
434                        self.target
435                    ))
436                })?;
437            match node.props.get("position") {
438                Some(PropValue::Vec2(x, y)) => Ok(crate::vec2::Vec2::new(*x, *y)),
439                _ => Ok(crate::vec2::Vec2::new(0.0, 0.0)),
440            }
441        })
442    }
443
444    #[setter]
445    fn set_position(&self, val: &Bound<'_, PyAny>) -> PyResult<()> {
446        let (x, y) = if let Ok(v) = val.extract::<crate::vec2::Vec2>() {
447            (v.x, v.y)
448        } else if let Ok(t) = val.extract::<(f64, f64)>() {
449            t
450        } else {
451            return Err(pyo3::exceptions::PyTypeError::new_err(
452                "position must be Vec2 or (x, y) tuple",
453            ));
454        };
455        use pite_core::PropValue;
456        self.host.with_tree_mut(|tree| {
457            if let Some(node) = tree.get_mut(&NodeId::from(self.target.clone())) {
458                node.props
459                    .insert("position".to_string(), PropValue::Vec2(x, y));
460            }
461        });
462        Ok(())
463    }
464
465    #[getter]
466    fn text(&self) -> PyResult<String> {
467        use pite_core::PropValue;
468        self.host.with_tree(|tree| {
469            let node = tree
470                .get(&NodeId::from(self.target.clone()))
471                .ok_or_else(|| {
472                    pyo3::exceptions::PyKeyError::new_err(format!(
473                        "node {:?} no longer exists",
474                        self.target
475                    ))
476                })?;
477            match node.props.get("text") {
478                Some(PropValue::Str(s)) => Ok(s.clone()),
479                _ => Ok(String::new()),
480            }
481        })
482    }
483
484    #[setter]
485    fn set_text(&self, text: String) {
486        use pite_core::PropValue;
487        self.host.with_tree_mut(|tree| {
488            if let Some(node) = tree.get_mut(&NodeId::from(self.target.clone())) {
489                node.props.insert("text".to_string(), PropValue::Str(text));
490            }
491        });
492    }
493
494    #[getter]
495    fn size(&self) -> PyResult<crate::vec2::Vec2> {
496        use pite_core::PropValue;
497        self.host.with_tree(|tree| {
498            let node = tree
499                .get(&NodeId::from(self.target.clone()))
500                .ok_or_else(|| {
501                    pyo3::exceptions::PyKeyError::new_err(format!(
502                        "node {:?} no longer exists",
503                        self.target
504                    ))
505                })?;
506            match node.props.get("size") {
507                Some(PropValue::Vec2(w, h)) => Ok(crate::vec2::Vec2::new(*w, *h)),
508                _ => Ok(crate::vec2::Vec2::new(0.0, 0.0)),
509            }
510        })
511    }
512
513    #[setter]
514    fn set_size(&self, val: &Bound<'_, PyAny>) -> PyResult<()> {
515        let (w, h) = if let Ok(v) = val.extract::<crate::vec2::Vec2>() {
516            (v.x, v.y)
517        } else if let Ok(t) = val.extract::<(f64, f64)>() {
518            t
519        } else {
520            return Err(pyo3::exceptions::PyTypeError::new_err(
521                "size must be Vec2 or (w, h) tuple",
522            ));
523        };
524        use pite_core::PropValue;
525        self.host.with_tree_mut(|tree| {
526            if let Some(node) = tree.get_mut(&NodeId::from(self.target.clone())) {
527                node.props.insert("size".to_string(), PropValue::Vec2(w, h));
528            }
529        });
530        Ok(())
531    }
532
533    fn __getattr__(&self, py: Python<'_>, name: String) -> PyResult<Py<PyAny>> {
534        if name == "position" || name == "text" || name == "size" {
535            return Err(pyo3::exceptions::PyAttributeError::new_err(name));
536        }
537        if self.host.has_signal(&self.target, &name) {
538            let bound = BoundSignal {
539                host: self.host.clone(),
540                node: self.target.clone(),
541                signal: name,
542            };
543            return Ok(Bound::new(py, bound)?.into_any().unbind());
544        }
545        let inst = self.host.lookup_instance(py, &self.target).ok_or_else(|| {
546            pyo3::exceptions::PyAttributeError::new_err(format!(
547                "node {:?} has no script with {name:?}",
548                self.target
549            ))
550        })?;
551        inst.bind(py).getattr(name.as_str()).map(|b| b.into())
552    }
553}
554
555#[pyclass(name = "BoundSignal")]
556pub struct BoundSignal {
557    host: std::sync::Arc<ScriptHost>,
558    node: String,
559    signal: String,
560}
561
562impl BoundSignal {
563    pub fn with_host(host: std::sync::Arc<ScriptHost>, node: String, signal: String) -> Self {
564        Self { host, node, signal }
565    }
566}
567
568#[pymethods]
569impl BoundSignal {
570    fn connect(&self, py: Python<'_>, callable: Bound<'_, PyAny>) -> PyResult<()> {
571        if let Ok((target, handler)) = bound_target(&callable) {
572            self.host
573                .connect(&self.node, &self.signal, &target, &handler)
574                .map_err(|e| pyo3::exceptions::PyKeyError::new_err(e.to_string()))
575        } else {
576            self.host
577                .connect_callable(py, &self.node, &self.signal, callable.into_any().unbind())
578                .map_err(|e| pyo3::exceptions::PyKeyError::new_err(e.to_string()))
579        }
580    }
581
582    #[pyo3(signature = (*args))]
583    fn emit(&self, py: Python<'_>, args: &Bound<'_, PyTuple>) -> PyResult<()> {
584        self.host
585            .emit(py, &self.node, &self.signal, args)
586            .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))
587    }
588}
589
590pub fn proxy_for(host: std::sync::Arc<ScriptHost>, target: String) -> NodeProxy {
591    NodeProxy { host, target }
592}
593
594pub fn resolve_caller(caller: &str, path: &str) -> anyhow::Result<NodeProxy> {
595    let host = current_host().ok_or_else(|| anyhow::anyhow!("get_node has no script host"))?;
596    let target = host
597        .resolve(caller, path)
598        .ok_or_else(|| anyhow::anyhow!("{caller}: get_node({path:?}) found nothing"))?;
599    Ok(proxy_for(host, target))
600}
601
602pub fn resolve_caller_py(
603    py: Python<'_>,
604    caller: &str,
605    path: &str,
606) -> anyhow::Result<Py<NodeProxy>> {
607    let host = current_host().ok_or_else(|| anyhow::anyhow!("get_node has no script host"))?;
608    host.resolve_proxy(py, caller, path)
609}
610
611pub fn fire_pressed(host: &ScriptHost, id: &str) -> anyhow::Result<()> {
612    Python::attach(|py| host.emit(py, id, "pressed", &PyTuple::empty(py)))
613}
614
615pub fn fire_timeout(host: &ScriptHost, id: &str) -> anyhow::Result<()> {
616    Python::attach(|py| host.emit(py, id, "timeout", &PyTuple::empty(py)))
617}
618
619pub fn fire_body_entered(host: &ScriptHost, area_id: &str, body_id: &str) -> anyhow::Result<()> {
620    Python::attach(|py| {
621        let arg = PyTuple::new(py, [body_id])?;
622        host.emit(py, area_id, "body_entered", &arg)
623    })
624}
625
626pub fn fire_body_exited(host: &ScriptHost, area_id: &str, body_id: &str) -> anyhow::Result<()> {
627    Python::attach(|py| {
628        let arg = PyTuple::new(py, [body_id])?;
629        host.emit(py, area_id, "body_exited", &arg)
630    })
631}
632
633pub fn fire_area_entered(
634    host: &ScriptHost,
635    area_id: &str,
636    other_area_id: &str,
637) -> anyhow::Result<()> {
638    Python::attach(|py| {
639        let arg = PyTuple::new(py, [other_area_id])?;
640        host.emit(py, area_id, "area_entered", &arg)
641    })
642}
643
644pub fn fire_area_exited(
645    host: &ScriptHost,
646    area_id: &str,
647    other_area_id: &str,
648) -> anyhow::Result<()> {
649    Python::attach(|py| {
650        let arg = PyTuple::new(py, [other_area_id])?;
651        host.emit(py, area_id, "area_exited", &arg)
652    })
653}