Skip to main content

azul_core/
diff.rs

1//! DOM Reconciliation Module
2//!
3//! This module provides the reconciliation algorithm that compares two DOM trees
4//! and generates lifecycle events. It uses stable keys and content hashing to
5//! identify moves vs. mounts/unmounts.
6//!
7//! The reconciliation strategy is:
8//! 1. **Stable Key Match:** If `.with_key()` is used, it's an absolute match (O(1)).
9//! 2. **CSS ID Match:** If no key, use the CSS ID as key.
10//! 3. **Structural Key Match:** nth-of-type-within-parent + parent's key (recursive).
11//! 4. **Hash Match (Content Match):** Check for identical `DomNodeHash`.
12//! 5. **Structural Hash Match:** For text nodes, match by structural hash (ignoring content).
13//! 6. **Fallback:** Anything not matched is a `Mount` (new) or `Unmount` (old leftovers).
14
15use alloc::{collections::VecDeque, vec::Vec};
16use core::hash::Hash;
17
18use crate::{
19    dom::{DomId, DomNodeHash, DomNodeId, NodeData, IdOrClass},
20    events::{
21        ComponentEventFilter, EventData, EventFilter, EventPhase, EventSource, EventType,
22        LifecycleEventData, LifecycleReason, SyntheticEvent,
23    },
24    geom::LogicalRect,
25    id::NodeId,
26    styled_dom::{NodeHierarchyItemId, NodeHierarchyItem},
27    task::Instant,
28    FastHashMap,
29};
30
31/// Represents a mapping between a node in the old DOM and the new DOM.
32#[derive(Debug, Clone, Copy)]
33pub struct NodeMove {
34    /// The NodeId in the old DOM array
35    pub old_node_id: NodeId,
36    /// The NodeId in the new DOM array
37    pub new_node_id: NodeId,
38}
39
40/// The result of a DOM diff, containing lifecycle events and node mappings.
41#[derive(Debug, Clone)]
42pub struct DiffResult {
43    /// Lifecycle events generated by the diff (Mount, Unmount, Resize, Update)
44    pub events: Vec<SyntheticEvent>,
45    /// Maps Old NodeId -> New NodeId for state migration (focus, scroll, etc.)
46    pub node_moves: Vec<NodeMove>,
47}
48
49impl Default for DiffResult {
50    fn default() -> Self {
51        Self {
52            events: Vec::new(),
53            node_moves: Vec::new(),
54        }
55    }
56}
57
58/// Calculate the reconciliation key for a node using the priority hierarchy:
59/// 1. Explicit key (set via `.with_key()`)
60/// 2. CSS ID (set via `.with_id("my-id")`)
61/// 3. Structural key: nth-of-type-within-parent + parent's reconciliation key
62///
63/// The structural key prevents incorrect matching when nodes are inserted
64/// before existing nodes (e.g., prepending items to a list).
65///
66/// # Arguments
67/// * `node_data` - Slice of all node data
68/// * `hierarchy` - Slice of node hierarchy (parent/child relationships)
69/// * `node_id` - The node to calculate the key for
70///
71/// # Returns
72/// A 64-bit key that uniquely identifies this node's logical position in the tree.
73pub fn calculate_reconciliation_key(
74    node_data: &[NodeData],
75    hierarchy: &[NodeHierarchyItem],
76    node_id: NodeId,
77) -> u64 {
78    use highway::{HighwayHash, HighwayHasher, Key};
79    
80    let node = &node_data[node_id.index()];
81    
82    // Priority 1: Explicit key
83    if let Some(key) = node.get_key() {
84        return key;
85    }
86    
87    // Priority 2: CSS ID
88    for id_or_class in node.ids_and_classes.as_ref().iter() {
89        if let IdOrClass::Id(id) = id_or_class {
90            let mut hasher = HighwayHasher::new(Key([0; 4]));
91            id.as_str().hash(&mut hasher);
92            return hasher.finalize64();
93        }
94    }
95    
96    // Priority 3: Structural key = nth-of-type-within-parent + parent key
97    let mut hasher = HighwayHasher::new(Key([0; 4]));
98    
99    // Hash node type discriminant and classes (nth-of-type logic)
100    core::mem::discriminant(node.get_node_type()).hash(&mut hasher);
101    for id_or_class in node.ids_and_classes.as_ref().iter() {
102        if let IdOrClass::Class(class) = id_or_class {
103            class.as_str().hash(&mut hasher);
104        }
105    }
106    
107    // Calculate sibling index (nth-of-type within parent)
108    if let Some(hierarchy_item) = hierarchy.get(node_id.index()) {
109        if let Some(parent_id) = hierarchy_item.parent_id() {
110            // Count siblings of same type before this node
111            let mut sibling_index: usize = 0;
112            let parent_hierarchy = &hierarchy[parent_id.index()];
113            
114            // Walk siblings from first child to this node
115            let mut current = parent_hierarchy.first_child_id(parent_id);
116            while let Some(sibling_id) = current {
117                if sibling_id == node_id {
118                    break;
119                }
120                // Check if sibling has same type/classes
121                let sibling = &node_data[sibling_id.index()];
122                if core::mem::discriminant(sibling.get_node_type()) 
123                    == core::mem::discriminant(node.get_node_type()) 
124                {
125                    sibling_index += 1;
126                }
127                current = hierarchy[sibling_id.index()].next_sibling_id();
128            }
129            
130            sibling_index.hash(&mut hasher);
131            
132            // Recursively include parent's key
133            let parent_key = calculate_reconciliation_key(node_data, hierarchy, parent_id);
134            parent_key.hash(&mut hasher);
135        }
136    }
137    
138    hasher.finalize64()
139}
140
141/// Precompute reconciliation keys for all nodes in a DOM tree.
142///
143/// This should be called once before reconciliation to compute stable keys
144/// for all nodes. Keys are computed using the hierarchy:
145/// 1. Explicit key → 2. CSS ID → 3. Structural key (nth-of-type + parent key)
146///
147/// # Returns
148/// A map from NodeId to its reconciliation key.
149pub fn precompute_reconciliation_keys(
150    node_data: &[NodeData],
151    hierarchy: &[NodeHierarchyItem],
152) -> FastHashMap<NodeId, u64> {
153    let mut keys = FastHashMap::default();
154    for idx in 0..node_data.len() {
155        let node_id = NodeId::new(idx);
156        let key = calculate_reconciliation_key(node_data, hierarchy, node_id);
157        keys.insert(node_id, key);
158    }
159    keys
160}
161
162/// Calculates the difference between two DOM frames and generates lifecycle events.
163///
164/// This is the main entry point for DOM reconciliation. It compares the old and new
165/// DOM trees and produces:
166/// - Mount events for new nodes
167/// - Unmount events for removed nodes
168/// - Resize events for nodes whose bounds changed
169/// - Update events for nodes whose content changed (when matched by key)
170///
171/// # Arguments
172/// * `old_node_data` - Node data from the previous frame
173/// * `new_node_data` - Node data from the current frame
174/// * `old_layout` - Layout bounds from the previous frame
175/// * `new_layout` - Layout bounds from the current frame
176/// * `dom_id` - The DOM identifier
177/// * `timestamp` - Current timestamp for events
178pub fn reconcile_dom(
179    old_node_data: &[NodeData],
180    new_node_data: &[NodeData],
181    old_layout: &FastHashMap<NodeId, LogicalRect>,
182    new_layout: &FastHashMap<NodeId, LogicalRect>,
183    dom_id: DomId,
184    timestamp: Instant,
185) -> DiffResult {
186    let mut result = DiffResult::default();
187
188    // --- STEP 1: INDEX THE OLD DOM ---
189    // Create lookups to find old nodes by Key or by Hash.
190    // 
191    // IMPORTANT: We use TWO hash indexes:
192    // 1. Content Hash (calculate_node_data_hash) - for exact matching including text content
193    // 2. Structural Hash (calculate_structural_hash) - for text nodes where content may change
194    //
195    // This allows Text("Hello") to match Text("Hello World") as a structural match,
196    // preserving cursor/selection state during text editing.
197
198    let mut old_keyed: FastHashMap<u64, NodeId> = FastHashMap::default();
199    let mut old_hashed: FastHashMap<DomNodeHash, VecDeque<NodeId>> = FastHashMap::default();
200    let mut old_structural: FastHashMap<DomNodeHash, VecDeque<NodeId>> = FastHashMap::default();
201    let mut old_nodes_consumed = vec![false; old_node_data.len()];
202
203    for (idx, node) in old_node_data.iter().enumerate() {
204        let id = NodeId::new(idx);
205
206        if let Some(key) = node.get_key() {
207            // Priority 1: Explicit Key
208            old_keyed.insert(key, id);
209        } else {
210            // Priority 2: Content Hash (exact match)
211            let hash = node.calculate_node_data_hash();
212            old_hashed.entry(hash).or_default().push_back(id);
213            
214            // Priority 3: Structural Hash (for text node matching)
215            let structural_hash = node.calculate_structural_hash();
216            old_structural.entry(structural_hash).or_default().push_back(id);
217        }
218    }
219
220    // --- STEP 2: ITERATE NEW DOM AND CLAIM MATCHES ---
221
222    for (new_idx, new_node) in new_node_data.iter().enumerate() {
223        let new_id = NodeId::new(new_idx);
224        let mut matched_old_id = None;
225
226        // A. Try Match by Key
227        if let Some(key) = new_node.get_key() {
228            if let Some(&old_id) = old_keyed.get(&key) {
229                if !old_nodes_consumed[old_id.index()] {
230                    matched_old_id = Some(old_id);
231                }
232            }
233        }
234        // B. Try Match by Content Hash first (exact match - The "Automagic" Reordering)
235        else {
236            let hash = new_node.calculate_node_data_hash();
237
238            // Get the queue of old nodes with this identical content
239            if let Some(queue) = old_hashed.get_mut(&hash) {
240                // Find first non-consumed node in queue
241                while let Some(old_id) = queue.front() {
242                    if !old_nodes_consumed[old_id.index()] {
243                        matched_old_id = Some(*old_id);
244                        queue.pop_front();
245                        break;
246                    } else {
247                        queue.pop_front();
248                    }
249                }
250            }
251            
252            // C. If no exact match, try Structural Hash (for text nodes with changed content)
253            if matched_old_id.is_none() {
254                let structural_hash = new_node.calculate_structural_hash();
255                if let Some(queue) = old_structural.get_mut(&structural_hash) {
256                    while let Some(old_id) = queue.front() {
257                        if !old_nodes_consumed[old_id.index()] {
258                            matched_old_id = Some(*old_id);
259                            queue.pop_front();
260                            break;
261                        } else {
262                            queue.pop_front();
263                        }
264                    }
265                }
266            }
267        }
268
269        // --- STEP 3: PROCESS MATCH OR MOUNT ---
270
271        if let Some(old_id) = matched_old_id {
272            // FOUND A MATCH (It might be at a different index, but it's the "same" node)
273
274            old_nodes_consumed[old_id.index()] = true;
275            result.node_moves.push(NodeMove {
276                old_node_id: old_id,
277                new_node_id: new_id,
278            });
279
280            // Check for Resize
281            let old_rect = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
282            let new_rect = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
283
284            if old_rect.size != new_rect.size {
285                // Fire Resize Event
286                if has_resize_callback(new_node) {
287                    result.events.push(create_lifecycle_event(
288                        EventType::Resize,
289                        new_id,
290                        dom_id,
291                        &timestamp,
292                        LifecycleEventData {
293                            reason: LifecycleReason::Resize,
294                            previous_bounds: Some(old_rect),
295                            current_bounds: new_rect,
296                        },
297                    ));
298                }
299            }
300
301            // If matched by Key, the content might have changed, so we should check hash equality.
302            if new_node.get_key().is_some() {
303                let old_hash = old_node_data[old_id.index()].calculate_node_data_hash();
304                let new_hash = new_node.calculate_node_data_hash();
305
306                if old_hash != new_hash && has_update_callback(new_node) {
307                    result.events.push(create_lifecycle_event(
308                        EventType::Update,
309                        new_id,
310                        dom_id,
311                        &timestamp,
312                        LifecycleEventData {
313                            reason: LifecycleReason::Update,
314                            previous_bounds: Some(old_rect),
315                            current_bounds: new_rect,
316                        },
317                    ));
318                }
319            }
320        } else {
321            // NO MATCH FOUND -> MOUNT (New Node)
322            if has_mount_callback(new_node) {
323                let bounds = new_layout.get(&new_id).copied().unwrap_or(LogicalRect::zero());
324                result.events.push(create_lifecycle_event(
325                    EventType::Mount,
326                    new_id,
327                    dom_id,
328                    &timestamp,
329                    LifecycleEventData {
330                        reason: LifecycleReason::InitialMount,
331                        previous_bounds: None,
332                        current_bounds: bounds,
333                    },
334                ));
335            }
336        }
337    }
338
339    // --- STEP 4: CLEANUP (UNMOUNTS) ---
340    // Any old node that wasn't claimed is effectively destroyed.
341
342    for (old_idx, consumed) in old_nodes_consumed.iter().enumerate() {
343        if !consumed {
344            let old_id = NodeId::new(old_idx);
345            let old_node = &old_node_data[old_idx];
346
347            if has_unmount_callback(old_node) {
348                let bounds = old_layout.get(&old_id).copied().unwrap_or(LogicalRect::zero());
349                result.events.push(create_lifecycle_event(
350                    EventType::Unmount,
351                    old_id,
352                    dom_id,
353                    &timestamp,
354                    LifecycleEventData {
355                        reason: LifecycleReason::InitialMount, // Context implies unmount
356                        previous_bounds: Some(bounds),
357                        current_bounds: LogicalRect::zero(),
358                    },
359                ));
360            }
361        }
362    }
363
364    result
365}
366
367/// Creates a lifecycle event with all necessary fields.
368fn create_lifecycle_event(
369    event_type: EventType,
370    node_id: NodeId,
371    dom_id: DomId,
372    timestamp: &Instant,
373    data: LifecycleEventData,
374) -> SyntheticEvent {
375    let dom_node_id = DomNodeId {
376        dom: dom_id,
377        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
378    };
379    SyntheticEvent {
380        event_type,
381        source: EventSource::Lifecycle,
382        phase: EventPhase::Target,
383        target: dom_node_id,
384        current_target: dom_node_id,
385        timestamp: timestamp.clone(),
386        data: EventData::Lifecycle(data),
387        stopped: false,
388        stopped_immediate: false,
389        prevented_default: false,
390    }
391}
392
393/// Check if the node has an AfterMount callback registered.
394fn has_mount_callback(node: &NodeData) -> bool {
395    node.get_callbacks().iter().any(|cb| {
396        matches!(
397            cb.event,
398            EventFilter::Component(ComponentEventFilter::AfterMount)
399        )
400    })
401}
402
403/// Check if the node has a BeforeUnmount callback registered.
404fn has_unmount_callback(node: &NodeData) -> bool {
405    node.get_callbacks().iter().any(|cb| {
406        matches!(
407            cb.event,
408            EventFilter::Component(ComponentEventFilter::BeforeUnmount)
409        )
410    })
411}
412
413/// Check if the node has a NodeResized callback registered.
414fn has_resize_callback(node: &NodeData) -> bool {
415    node.get_callbacks().iter().any(|cb| {
416        matches!(
417            cb.event,
418            EventFilter::Component(ComponentEventFilter::NodeResized)
419        )
420    })
421}
422
423/// Check if the node has any lifecycle callback that would respond to updates.
424fn has_update_callback(node: &NodeData) -> bool {
425    // For now, we use Selected as a placeholder for "update" events
426    // This could be extended to a dedicated UpdateCallback in the future
427    node.get_callbacks().iter().any(|cb| {
428        matches!(
429            cb.event,
430            EventFilter::Component(ComponentEventFilter::Selected)
431        )
432    })
433}
434
435/// Migrate state (focus, scroll, etc.) from old node IDs to new node IDs.
436///
437/// This function should be called after reconciliation to update any state
438/// that references old NodeIds to use the new NodeIds.
439///
440/// # Example
441/// ```rust
442/// let diff = reconcile_dom(...);
443/// let migration_map = create_migration_map(&diff.node_moves);
444/// 
445/// // Migrate focus
446/// if let Some(current_focus) = focus_manager.focused_node {
447///     if let Some(&new_id) = migration_map.get(&current_focus) {
448///         focus_manager.focused_node = Some(new_id);
449///     } else {
450///         // Focused node was unmounted, clear focus
451///         focus_manager.focused_node = None;
452///     }
453/// }
454/// ```
455pub fn create_migration_map(node_moves: &[NodeMove]) -> FastHashMap<NodeId, NodeId> {
456    let mut map = FastHashMap::default();
457    for m in node_moves {
458        map.insert(m.old_node_id, m.new_node_id);
459    }
460    map
461}
462
463/// Executes state migration between the old DOM and the new DOM based on diff results.
464///
465/// This iterates through matched nodes. If a match has BOTH a merge callback AND a dataset,
466/// it executes the callback to transfer state from the old node to the new node.
467///
468/// This must be called **before** the old DOM is dropped, because we need to access its data.
469///
470/// # Arguments
471/// * `old_node_data` - Mutable reference to the old DOM's node data (source of heavy state)
472/// * `new_node_data` - Mutable reference to the new DOM's node data (target for heavy state)
473/// * `node_moves` - The matched nodes from the reconciliation diff
474///
475/// # Example
476/// ```rust,ignore
477/// let diff_result = reconcile_dom(&old_data, &new_data, ...);
478/// 
479/// // Execute state migration BEFORE old_dom is dropped
480/// transfer_states(&mut old_data, &mut new_data, &diff_result.node_moves);
481/// 
482/// // Now safe to drop old_dom - heavy resources have been transferred
483/// drop(old_dom);
484/// ```
485pub fn transfer_states(
486    old_node_data: &mut [NodeData],
487    new_node_data: &mut [NodeData],
488    node_moves: &[NodeMove],
489) {
490    use crate::refany::OptionRefAny;
491
492    for movement in node_moves {
493        let old_idx = movement.old_node_id.index();
494        let new_idx = movement.new_node_id.index();
495
496        // Bounds check
497        if old_idx >= old_node_data.len() || new_idx >= new_node_data.len() {
498            continue;
499        }
500
501        // 1. Check if the NEW node has requested a merge callback
502        let merge_callback = match new_node_data[new_idx].get_merge_callback() {
503            Some(cb) => cb,
504            None => continue, // No merge callback, skip
505        };
506
507        // 2. Check if BOTH nodes have datasets
508        // We need to temporarily take the datasets to satisfy borrow checker
509        let old_dataset = core::mem::replace(
510            &mut old_node_data[old_idx].dataset, 
511            OptionRefAny::None
512        );
513        let new_dataset = core::mem::replace(
514            &mut new_node_data[new_idx].dataset, 
515            OptionRefAny::None
516        );
517
518        match (new_dataset, old_dataset) {
519            (OptionRefAny::Some(new_data), OptionRefAny::Some(old_data)) => {
520                // 3. EXECUTE THE MERGE CALLBACK
521                // The callback receives both datasets and returns the merged result
522                let merged = (merge_callback.cb)(new_data, old_data);
523                
524                // 4. Store the merged result back in the new node
525                new_node_data[new_idx].dataset = OptionRefAny::Some(merged);
526            }
527            (new_ds, old_ds) => {
528                // One or both datasets missing - restore what we had
529                new_node_data[new_idx].dataset = new_ds;
530                old_node_data[old_idx].dataset = old_ds;
531            }
532        }
533    }
534}
535
536/// Calculate a stable key for a contenteditable node using the hierarchy:
537///
538/// 1. **Explicit Key** - If `.with_key()` was called, use that
539/// 2. **CSS ID** - If the node has a CSS ID (e.g., `#my-editor`), hash that
540/// 3. **Structural Key** - Hash of `(nth-of-type, parent_key)` recursively
541///
542/// The structural key prevents shifting when elements are inserted before siblings.
543/// For example, in `<div><p>A</p><p contenteditable>B</p></div>`, if we insert
544/// a new `<p>` at the start, the contenteditable `<p>` becomes nth-child(3) but
545/// its nth-of-type stays stable (it's still the 2nd `<p>`).
546///
547/// # Arguments
548/// * `node_data` - All nodes in the DOM
549/// * `hierarchy` - Parent-child relationships
550/// * `node_id` - The node to calculate the key for
551///
552/// # Returns
553/// A stable u64 key for the node
554pub fn calculate_contenteditable_key(
555    node_data: &[NodeData],
556    hierarchy: &[crate::styled_dom::NodeHierarchyItem],
557    node_id: NodeId,
558) -> u64 {
559    use highway::{HighwayHash, HighwayHasher, Key};
560    use crate::dom::IdOrClass;
561    
562    let node = &node_data[node_id.index()];
563    
564    // Priority 1: Explicit key (from .with_key())
565    if let Some(explicit_key) = node.get_key() {
566        return explicit_key;
567    }
568    
569    // Priority 2: CSS ID
570    for id_or_class in node.get_ids_and_classes().as_ref().iter() {
571        if let IdOrClass::Id(id) = id_or_class {
572            let mut hasher = HighwayHasher::new(Key([1; 4])); // Different seed for ID keys
573            hasher.append(id.as_str().as_bytes());
574            return hasher.finalize64();
575        }
576    }
577    
578    // Priority 3: Structural key = (nth-of-type, classes, parent_key)
579    let mut hasher = HighwayHasher::new(Key([2; 4])); // Different seed for structural keys
580    
581    // Get parent and calculate its key recursively
582    let parent_key = if let Some(parent_id) = hierarchy.get(node_id.index()).and_then(|h| h.parent_id()) {
583        calculate_contenteditable_key(node_data, hierarchy, parent_id)
584    } else {
585        0u64 // Root node
586    };
587    hasher.append(&parent_key.to_le_bytes());
588    
589    // Calculate nth-of-type (count siblings of same node type before this one)
590    // We compare discriminants directly without hashing
591    let node_discriminant = core::mem::discriminant(node.get_node_type());
592    let nth_of_type = if let Some(parent_id) = hierarchy.get(node_id.index()).and_then(|h| h.parent_id()) {
593        // Count siblings with same node type that come before this node
594        let mut count = 0u32;
595        let mut sibling_id = hierarchy.get(parent_id.index()).and_then(|h| h.first_child_id(parent_id));
596        while let Some(sib_id) = sibling_id {
597            if sib_id == node_id {
598                break;
599            }
600            let sibling_discriminant = core::mem::discriminant(node_data[sib_id.index()].get_node_type());
601            if sibling_discriminant == node_discriminant {
602                count += 1;
603            }
604            sibling_id = hierarchy.get(sib_id.index()).and_then(|h| h.next_sibling_id());
605        }
606        count
607    } else {
608        0
609    };
610    
611    hasher.append(&nth_of_type.to_le_bytes());
612    
613    // Hash the node type using its Debug representation as a stable identifier
614    // This works because NodeType implements Debug
615    #[cfg(feature = "std")]
616    {
617        let type_str = format!("{:?}", node_discriminant);
618        hasher.append(type_str.as_bytes());
619    }
620    #[cfg(not(feature = "std"))]
621    {
622        // For no_std, use the memory representation of the discriminant
623        // NodeType variants are numbered 0..N, and discriminant stores this
624        let discriminant_bytes: [u8; core::mem::size_of::<core::mem::Discriminant<crate::dom::NodeType>>()] = 
625            unsafe { core::mem::transmute(node_discriminant) };
626        hasher.append(&discriminant_bytes);
627    }
628    
629    // Also hash the classes for additional stability
630    for id_or_class in node.get_ids_and_classes().as_ref().iter() {
631        if let IdOrClass::Class(class) = id_or_class {
632            hasher.append(class.as_str().as_bytes());
633        }
634    }
635    
636    hasher.finalize64()
637}
638
639/// Reconcile cursor byte position when text content changes.
640///
641/// This function maps a cursor position from old text to new text, preserving
642/// the cursor's logical position as much as possible:
643///
644/// 1. If cursor is in unchanged prefix → stays at same byte offset
645/// 2. If cursor is in unchanged suffix → adjusts by length difference
646/// 3. If cursor is in changed region → places at end of new content
647///
648/// # Arguments
649/// * `old_text` - The previous text content
650/// * `new_text` - The new text content
651/// * `old_cursor_byte` - Cursor byte offset in old text
652///
653/// # Returns
654/// The reconciled cursor byte offset in new text
655///
656/// # Example
657/// ```rust,ignore
658/// let old_text = "Hello";
659/// let new_text = "Hello World";
660/// let old_cursor = 5; // cursor at end of "Hello"
661/// let new_cursor = reconcile_cursor_position(old_text, new_text, old_cursor);
662/// assert_eq!(new_cursor, 5); // cursor stays at same position (prefix unchanged)
663/// ```
664pub fn reconcile_cursor_position(
665    old_text: &str,
666    new_text: &str,
667    old_cursor_byte: usize,
668) -> usize {
669    // If texts are equal, cursor is unchanged
670    if old_text == new_text {
671        return old_cursor_byte;
672    }
673    
674    // Empty old text - place cursor at end of new text
675    if old_text.is_empty() {
676        return new_text.len();
677    }
678    
679    // Empty new text - place cursor at 0
680    if new_text.is_empty() {
681        return 0;
682    }
683    
684    // Find common prefix (how many bytes from the start are identical)
685    let common_prefix_bytes = old_text
686        .bytes()
687        .zip(new_text.bytes())
688        .take_while(|(a, b)| a == b)
689        .count();
690    
691    // If cursor was in the unchanged prefix, it stays at the same byte offset
692    if old_cursor_byte <= common_prefix_bytes {
693        return old_cursor_byte.min(new_text.len());
694    }
695    
696    // Find common suffix (how many bytes from the end are identical)
697    let common_suffix_bytes = old_text
698        .bytes()
699        .rev()
700        .zip(new_text.bytes().rev())
701        .take_while(|(a, b)| a == b)
702        .count();
703    
704    // Calculate where the suffix starts in old and new text
705    let old_suffix_start = old_text.len().saturating_sub(common_suffix_bytes);
706    let new_suffix_start = new_text.len().saturating_sub(common_suffix_bytes);
707    
708    // If cursor was in the unchanged suffix, adjust by length difference
709    if old_cursor_byte >= old_suffix_start {
710        let offset_from_end = old_text.len() - old_cursor_byte;
711        return new_text.len().saturating_sub(offset_from_end);
712    }
713    
714    // Cursor was in the changed region - place at end of inserted content
715    // This handles insertions (cursor moves with new text) and deletions (cursor at edit point)
716    new_suffix_start
717}
718
719/// Get the text content from a NodeData if it's a Text node.
720///
721/// Returns the text string if the node is `NodeType::Text`, otherwise `None`.
722pub fn get_node_text_content(node: &NodeData) -> Option<&str> {
723    if let crate::dom::NodeType::Text(ref text) = node.get_node_type() {
724        Some(text.as_str())
725    } else {
726        None
727    }
728}