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