use std::collections::{HashMap, HashSet}; /// Unique identifier for an element in the VDOM tree. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ElementId(pub u32); /// Tracks which variables affect which elements for incremental VDOM updates. #[derive(Debug, Clone)] pub struct ReactiveTracker { /// For each variable name, which elements depend on it subscribers: HashMap>, /// For each element, which variables it depends on dependencies: HashMap>, /// Elements that need to be re-evaluated dirty_set: HashSet, next_id: u32, } impl ReactiveTracker { pub fn new() -> Self { Self { subscribers: HashMap::new(), dependencies: HashMap::new(), dirty_set: HashSet::new(), next_id: 0, } } pub fn alloc_id(&mut self) -> ElementId { let id = ElementId(self.next_id); self.next_id += 1; id } /// Register that `element` depends on `var_name`. /// Call this during template parsing for each `$var` or `!rhee:{expr}` reference. pub fn add_dependency(&mut self, element: ElementId, var_name: &str) { self.subscribers.entry(var_name.to_string()) .or_default() .insert(element); self.dependencies.entry(element) .or_default() .insert(var_name.to_string()); } /// Register multiple dependencies for an element from a property value. pub fn scan_value(&mut self, element: ElementId, value: &str) { // Scan for `$var` patterns let mut chars = value.char_indices().peekable(); while let Some((_, c)) = chars.next() { if c == '$' { let mut var_name = String::new(); while let Some(&(_, next_c)) = chars.peek() { if next_c.is_ascii_alphanumeric() || next_c == '_' { var_name.push(chars.next().unwrap().1); } else { break; } } if !var_name.is_empty() { self.add_dependency(element, &var_name); } } } } /// Called when a variable changes. Marks affected elements as dirty. pub fn on_variable_changed(&mut self, name: &str) -> &HashSet { if let Some(affected) = self.subscribers.get(name) { self.dirty_set.extend(affected.iter()); } &self.dirty_set } /// Returns the current dirty set and clears it. pub fn take_dirty_set(&mut self) -> HashSet { std::mem::take(&mut self.dirty_set) } /// Returns true if the element needs re-evaluation. pub fn is_dirty(&self, id: ElementId) -> bool { self.dirty_set.contains(&id) } /// Clear all tracking data (e.g., on template reload). pub fn reset(&mut self) { self.subscribers.clear(); self.dependencies.clear(); self.dirty_set.clear(); self.next_id = 0; } } impl Default for ReactiveTracker { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_basic_dependency_tracking() { let mut tracker = ReactiveTracker::new(); let e1 = tracker.alloc_id(); let e2 = tracker.alloc_id(); tracker.add_dependency(e1, "volume"); tracker.add_dependency(e2, "volume"); tracker.add_dependency(e2, "brightness"); let affected = tracker.on_variable_changed("volume"); assert!(affected.contains(&e1)); assert!(affected.contains(&e2)); let affected = tracker.on_variable_changed("brightness"); assert!(!affected.contains(&e1)); assert!(affected.contains(&e2)); } #[test] fn test_scan_value() { let mut tracker = ReactiveTracker::new(); let e1 = tracker.alloc_id(); tracker.scan_value(e1, "Hello $name, you are $age years old"); let affected = tracker.on_variable_changed("name"); assert!(affected.contains(&e1)); let affected = tracker.on_variable_changed("age"); assert!(affected.contains(&e1)); } #[test] fn test_scan_no_vars() { let mut tracker = ReactiveTracker::new(); let e1 = tracker.alloc_id(); tracker.scan_value(e1, "Hello world"); let affected = tracker.on_variable_changed("name"); assert!(!affected.contains(&e1)); } #[test] fn test_take_dirty_set() { let mut tracker = ReactiveTracker::new(); let e1 = tracker.alloc_id(); tracker.add_dependency(e1, "x"); tracker.on_variable_changed("x"); let dirty = tracker.take_dirty_set(); assert_eq!(dirty.len(), 1); assert!(tracker.dirty_set.is_empty()); } #[test] fn test_reset() { let mut tracker = ReactiveTracker::new(); let e1 = tracker.alloc_id(); tracker.add_dependency(e1, "x"); tracker.reset(); assert!(tracker.subscribers.is_empty()); assert!(tracker.dependencies.is_empty()); assert!(tracker.dirty_set.is_empty()); } }