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 ×tamp,
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 ×tamp,
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 ×tamp,
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 ×tamp,
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(¤t_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}