hypen_engine/engine.rs
1use crate::{
2 dispatch::{Action, ActionDispatcher},
3 error::EngineError,
4 ir::{ComponentRegistry, Element, IRNode},
5 lifecycle::{ModuleInstance, ResourceCache},
6 reactive::{DependencyGraph, Scheduler},
7 reconcile::{reconcile_ir, InstanceTree, Patch},
8 state::StateChange,
9};
10
11/// Static null value to avoid cloning state when no module exists
12static NULL_STATE: serde_json::Value = serde_json::Value::Null;
13
14/// Callback type for rendering patches to the platform
15pub type RenderCallback = Box<dyn Fn(&[Patch]) + Send + Sync>;
16
17/// The main Hypen engine that orchestrates reactive UI rendering.
18///
19/// # Architecture: Single Active Module
20///
21/// Each `Engine` instance has one "active" module at a time for state bindings.
22/// When DSL templates reference `${state.xxx}`, they resolve against the active
23/// module's state.
24///
25/// ## Multi-module Applications
26///
27/// Multi-module apps (e.g., AppState + StatsScreen) work as follows:
28///
29/// 1. **TypeScript SDK layer** manages multiple `HypenModuleInstance` objects,
30/// each with its own Proxy-based state tracking.
31///
32/// 2. **Cross-module communication** uses `HypenGlobalContext`:
33/// ```typescript
34/// // In StatsScreen's action handler:
35/// const app = context.getModule<AppState>("app");
36/// app.setState({ showStats: true });
37/// ```
38///
39/// 3. **State binding scope**: Each module's template binds to its own state.
40/// You cannot reference another module's state directly in templates.
41/// Use props or callbacks instead:
42/// ```hypen
43/// // StatsScreen receives app data via props, not state binding
44/// StatsView(data: @props.appStats)
45/// ```
46///
47/// ## Limitation
48///
49/// The engine's state binding model (`${state.xxx}`) is scoped to a single module.
50/// Cross-module state access requires explicit prop passing or action dispatching.
51/// This keeps the reactive graph simple but requires careful data flow design.
52pub struct Engine {
53 /// Component registry for expanding custom components
54 component_registry: ComponentRegistry,
55
56 /// Current module instance (provides state for `${state.xxx}` bindings)
57 module: Option<ModuleInstance>,
58
59 /// Instance tree (virtual tree, no platform objects)
60 tree: InstanceTree,
61
62 /// Reactive dependency graph
63 dependencies: DependencyGraph,
64
65 /// Scheduler for dirty nodes
66 scheduler: Scheduler,
67
68 /// Action dispatcher
69 actions: ActionDispatcher,
70
71 /// Resource cache
72 resources: ResourceCache,
73
74 /// Callback to send patches to the platform renderer
75 render_callback: Option<RenderCallback>,
76
77 /// Revision counter for remote UI
78 revision: u64,
79}
80
81impl Engine {
82 pub fn new() -> Self {
83 Self {
84 component_registry: ComponentRegistry::new(),
85 module: None,
86 tree: InstanceTree::new(),
87 dependencies: DependencyGraph::new(),
88 scheduler: Scheduler::new(),
89 actions: ActionDispatcher::new(),
90 resources: ResourceCache::new(),
91 render_callback: None,
92 revision: 0,
93 }
94 }
95
96 /// Register a custom component
97 pub fn register_component(&mut self, component: crate::ir::Component) {
98 self.component_registry.register(component);
99 }
100
101 /// Set the component resolver for dynamic component loading
102 /// The resolver receives (component_name, context_path) and should return
103 /// ResolvedComponent { source, path } or None
104 pub fn set_component_resolver<F>(&mut self, resolver: F)
105 where
106 F: Fn(&str, Option<&str>) -> Option<crate::ir::ResolvedComponent> + Send + Sync + 'static,
107 {
108 self.component_registry
109 .set_resolver(std::sync::Arc::new(resolver));
110 }
111
112 /// Set the module instance
113 pub fn set_module(&mut self, module: ModuleInstance) {
114 self.module = Some(module);
115 }
116
117 /// Set the render callback
118 pub fn set_render_callback<F>(&mut self, callback: F)
119 where
120 F: Fn(&[Patch]) + Send + Sync + 'static,
121 {
122 self.render_callback = Some(Box::new(callback));
123 }
124
125 /// Register an action handler
126 pub fn on_action<F>(&mut self, action_name: impl Into<String>, handler: F)
127 where
128 F: Fn(&Action) + Send + Sync + 'static,
129 {
130 self.actions.on(action_name, handler);
131 }
132
133 /// Render an element tree (initial render or full re-render)
134 pub fn render(&mut self, element: &Element) {
135 // Convert Element to IRNode for reconciliation
136 let ir_node = IRNode::Element(element.clone());
137
138 // Expand components (registry needs mutable access for lazy loading)
139 let expanded = self.component_registry.expand_ir_node(&ir_node);
140
141 // Get current state reference (no clone needed - reconcile only borrows)
142 let state: &serde_json::Value = self
143 .module
144 .as_ref()
145 .map(|m| m.get_state())
146 .unwrap_or(&NULL_STATE);
147
148 // Clear dependency graph before rebuilding
149 self.dependencies.clear();
150
151 // Reconcile and generate patches (dependencies are collected during tree construction)
152 let patches = reconcile_ir(
153 &mut self.tree,
154 &expanded,
155 None,
156 state,
157 &mut self.dependencies,
158 );
159
160 // Send patches to renderer
161 self.emit_patches(patches);
162
163 self.revision += 1;
164 }
165
166 /// Handle a state change notification from the module host
167 /// The host keeps the actual state - we just need to know what changed
168 pub fn notify_state_change(&mut self, change: &StateChange) {
169 // Find affected nodes based on changed paths
170 let mut affected_nodes = indexmap::IndexSet::new();
171 for path in change.paths() {
172 affected_nodes.extend(self.dependencies.get_affected_nodes(path));
173 }
174
175 // Mark affected nodes as dirty
176 self.scheduler
177 .mark_many_dirty(affected_nodes.iter().copied());
178
179 // Re-render dirty subtrees
180 // Note: The host will be asked to provide values during rendering via a callback
181 self.render_dirty();
182 }
183
184 /// Convenience method for backward compatibility / JSON patches
185 pub fn update_state(&mut self, state_patch: serde_json::Value) {
186 // Update module state FIRST before rendering
187 if let Some(module) = &mut self.module {
188 module.update_state(state_patch.clone());
189 }
190
191 // Then notify and render with the new state
192 let change = StateChange::from_json(&state_patch);
193 self.notify_state_change(&change);
194 }
195
196 /// Dispatch an action
197 pub fn dispatch_action(&mut self, action: Action) -> Result<(), EngineError> {
198 self.actions.dispatch(&action)
199 }
200
201 /// Render only dirty nodes
202 fn render_dirty(&mut self) {
203 let patches = crate::render::render_dirty_nodes(
204 &mut self.scheduler,
205 &mut self.tree,
206 self.module.as_ref(),
207 );
208
209 if !patches.is_empty() {
210 self.emit_patches(patches);
211 self.revision += 1;
212 }
213 }
214
215 /// Send patches to the renderer
216 fn emit_patches(&self, patches: Vec<Patch>) {
217 if let Some(ref callback) = self.render_callback {
218 callback(&patches);
219 }
220 }
221
222 /// Get the current revision number
223 pub fn revision(&self) -> u64 {
224 self.revision
225 }
226
227 /// Get access to the component registry
228 pub fn component_registry(&self) -> &ComponentRegistry {
229 &self.component_registry
230 }
231
232 /// Get mutable access to the component registry
233 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
234 &mut self.component_registry
235 }
236
237 /// Get access to the resource cache
238 pub fn resources(&self) -> &ResourceCache {
239 &self.resources
240 }
241
242 /// Get mutable access to the resource cache
243 pub fn resources_mut(&mut self) -> &mut ResourceCache {
244 &mut self.resources
245 }
246}
247
248impl Default for Engine {
249 fn default() -> Self {
250 Self::new()
251 }
252}