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.set_resolver(std::sync::Arc::new(resolver));
109 }
110
111 /// Set the module instance
112 pub fn set_module(&mut self, module: ModuleInstance) {
113 self.module = Some(module);
114 }
115
116 /// Set the render callback
117 pub fn set_render_callback<F>(&mut self, callback: F)
118 where
119 F: Fn(&[Patch]) + Send + Sync + 'static,
120 {
121 self.render_callback = Some(Box::new(callback));
122 }
123
124 /// Register an action handler
125 pub fn on_action<F>(&mut self, action_name: impl Into<String>, handler: F)
126 where
127 F: Fn(&Action) + Send + Sync + 'static,
128 {
129 self.actions.on(action_name, handler);
130 }
131
132 /// Render an element tree (initial render or full re-render)
133 pub fn render(&mut self, element: &Element) {
134 // Convert Element to IRNode for reconciliation
135 let ir_node = IRNode::Element(element.clone());
136
137 // Expand components (registry needs mutable access for lazy loading)
138 let expanded = self.component_registry.expand_ir_node(&ir_node);
139
140 // Get current state reference (no clone needed - reconcile only borrows)
141 let state: &serde_json::Value = self
142 .module
143 .as_ref()
144 .map(|m| m.get_state())
145 .unwrap_or(&NULL_STATE);
146
147 // Clear dependency graph before rebuilding
148 self.dependencies.clear();
149
150 // Reconcile and generate patches (dependencies are collected during tree construction)
151 let patches = reconcile_ir(&mut self.tree, &expanded, None, state, &mut self.dependencies);
152
153 // Send patches to renderer
154 self.emit_patches(patches);
155
156 self.revision += 1;
157 }
158
159 /// Handle a state change notification from the module host
160 /// The host keeps the actual state - we just need to know what changed
161 pub fn notify_state_change(&mut self, change: &StateChange) {
162 // Find affected nodes based on changed paths
163 let mut affected_nodes = indexmap::IndexSet::new();
164 for path in change.paths() {
165 affected_nodes.extend(self.dependencies.get_affected_nodes(path));
166 }
167
168 // Mark affected nodes as dirty
169 self.scheduler.mark_many_dirty(affected_nodes.iter().copied());
170
171 // Re-render dirty subtrees
172 // Note: The host will be asked to provide values during rendering via a callback
173 self.render_dirty();
174 }
175
176 /// Convenience method for backward compatibility / JSON patches
177 pub fn update_state(&mut self, state_patch: serde_json::Value) {
178 // Update module state FIRST before rendering
179 if let Some(module) = &mut self.module {
180 module.update_state(state_patch.clone());
181 }
182
183 // Then notify and render with the new state
184 let change = StateChange::from_json(&state_patch);
185 self.notify_state_change(&change);
186 }
187
188 /// Dispatch an action
189 pub fn dispatch_action(&mut self, action: Action) -> Result<(), EngineError> {
190 self.actions.dispatch(&action)
191 }
192
193 /// Render only dirty nodes
194 fn render_dirty(&mut self) {
195 let patches = crate::render::render_dirty_nodes(
196 &mut self.scheduler,
197 &mut self.tree,
198 self.module.as_ref(),
199 );
200
201 if !patches.is_empty() {
202 self.emit_patches(patches);
203 self.revision += 1;
204 }
205 }
206
207 /// Send patches to the renderer
208 fn emit_patches(&self, patches: Vec<Patch>) {
209 if let Some(ref callback) = self.render_callback {
210 callback(&patches);
211 }
212 }
213
214 /// Get the current revision number
215 pub fn revision(&self) -> u64 {
216 self.revision
217 }
218
219 /// Get access to the component registry
220 pub fn component_registry(&self) -> &ComponentRegistry {
221 &self.component_registry
222 }
223
224 /// Get mutable access to the component registry
225 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
226 &mut self.component_registry
227 }
228
229 /// Get access to the resource cache
230 pub fn resources(&self) -> &ResourceCache {
231 &self.resources
232 }
233
234 /// Get mutable access to the resource cache
235 pub fn resources_mut(&mut self) -> &mut ResourceCache {
236 &mut self.resources
237 }
238}
239
240impl Default for Engine {
241 fn default() -> Self {
242 Self::new()
243 }
244}
245