Skip to main content

oxiui_core/
diff.rs

1//! Widget-tree diffing: turn an old tree into a new one with a minimal op set.
2//!
3//! Given two [`WidgetTree`]s whose nodes carry stable [`WidgetId`]s, [`diff`]
4//! produces an ordered [`Vec<DiffOp>`] of `Insert` / `Remove` / `Update` /
5//! `Move` operations that, applied in order, transform the old tree's structure
6//! into the new one. This is the reconciliation step a retained backend runs to
7//! avoid rebuilding unchanged subtrees.
8//!
9//! ## Algorithm
10//!
11//! Diffing is per-node: for each id present in both trees we compare the child
12//! id lists and compute a **longest common subsequence (LCS)**. Children in the
13//! LCS keep their relative order for free; children present in both lists but
14//! *not* in the LCS are emitted as `Move`s; children only in the new list are
15//! `Insert`s; children only in the old list are `Remove`s. A node whose own
16//! paint-relevant fields changed is emitted as an `Update`. Using the LCS keeps
17//! the move set minimal (you never move a node that didn't need moving), which
18//! is the property that makes keyed list diffing efficient and stable.
19
20use crate::tree::{WidgetId, WidgetNode, WidgetTree};
21use std::collections::HashSet;
22
23/// A single reconciliation operation produced by [`diff`].
24#[derive(Clone, Debug, PartialEq)]
25pub enum DiffOp {
26    /// A new node `id` was added as a child of `parent` at child-list index
27    /// `index`.
28    Insert {
29        /// The node being inserted.
30        id: WidgetId,
31        /// The parent it is inserted under.
32        parent: WidgetId,
33        /// The position within `parent`'s child list.
34        index: usize,
35    },
36    /// Node `id` (and its subtree) was removed.
37    Remove {
38        /// The node being removed.
39        id: WidgetId,
40    },
41    /// Node `id` persists but one or more of its paint-relevant fields changed.
42    Update {
43        /// The node whose fields changed.
44        id: WidgetId,
45    },
46    /// Node `id` kept its parent but moved to a new index within the child list.
47    Move {
48        /// The node being moved.
49        id: WidgetId,
50        /// Its parent (unchanged).
51        parent: WidgetId,
52        /// The new position within `parent`'s child list.
53        index: usize,
54    },
55}
56
57/// Compute the ordered diff that turns `old` into `new`.
58///
59/// Both trees are assumed to share an id space (ids are stable across frames).
60/// Removals are emitted before inserts/moves so a backend can free first.
61pub fn diff(old: &WidgetTree, new: &WidgetTree) -> Vec<DiffOp> {
62    let mut ops = Vec::new();
63
64    let old_ids: HashSet<WidgetId> = collect_ids(old);
65    let new_ids: HashSet<WidgetId> = collect_ids(new);
66
67    // ── Removals: ids in old but not in new (top-most only; a removed subtree
68    // is a single Remove of its root). ──────────────────────────────────────
69    for &id in old_ids.iter() {
70        if !new_ids.contains(&id) {
71            // Skip if an ancestor is also being removed (avoid redundant ops).
72            let ancestor_removed = ancestor_chain(old, id)
73                .into_iter()
74                .any(|anc| anc != id && !new_ids.contains(&anc));
75            if !ancestor_removed {
76                ops.push(DiffOp::Remove { id });
77            }
78        }
79    }
80
81    // ── Per-shared-node: child reconciliation + field updates. ───────────────
82    // Visit in DFS order of `new` so inserts happen parent-before-child.
83    let mut visit_order = Vec::new();
84    new.walk_dfs(|node, _| visit_order.push(node.id));
85
86    for id in visit_order {
87        let new_node = match new.get(id) {
88            Some(n) => n,
89            None => continue,
90        };
91
92        match old.get(id) {
93            // Node exists in both: reconcile children + maybe update fields.
94            Some(old_node) => {
95                if node_changed(old_node, new_node) {
96                    ops.push(DiffOp::Update { id });
97                }
98                reconcile_children(
99                    id,
100                    &old_node.children,
101                    &new_node.children,
102                    &old_ids,
103                    &mut ops,
104                );
105            }
106            // Node is new but its *parent* already existed (root-level new
107            // subtrees are handled by their parent's reconcile). If the parent
108            // is also new, the parent's reconcile emits this insert; skip here.
109            None => {
110                // Inserts are emitted by the parent's reconcile pass; nothing to
111                // do at the node itself.
112            }
113        }
114    }
115
116    ops
117}
118
119/// Reconcile one node's child list using an LCS, appending ops.
120fn reconcile_children(
121    parent: WidgetId,
122    old_children: &[WidgetId],
123    new_children: &[WidgetId],
124    old_ids: &HashSet<WidgetId>,
125    ops: &mut Vec<DiffOp>,
126) {
127    // The set of new children that already existed somewhere in the old tree.
128    // Children that are brand-new ids are pure inserts; the rest are candidates
129    // for "kept in place" (LCS) or "moved".
130    let lcs = longest_common_subsequence(old_children, new_children);
131    let kept: HashSet<WidgetId> = lcs.iter().copied().collect();
132
133    for (index, &child) in new_children.iter().enumerate() {
134        if kept.contains(&child) {
135            // In the LCS: stays put, no op.
136            continue;
137        }
138        if old_ids.contains(&child) {
139            // Existed before but not part of the stable subsequence → moved.
140            ops.push(DiffOp::Move {
141                id: child,
142                parent,
143                index,
144            });
145        } else {
146            // Never seen before → inserted.
147            ops.push(DiffOp::Insert {
148                id: child,
149                parent,
150                index,
151            });
152        }
153    }
154}
155
156/// Whether two versions of the same node differ in any paint-relevant field.
157/// Child-list changes are handled separately by [`reconcile_children`], so they
158/// are deliberately *not* compared here.
159fn node_changed(old: &WidgetNode, new: &WidgetNode) -> bool {
160    old.rect != new.rect
161        || old.z_index != new.z_index
162        || old.hit_testable != new.hit_testable
163        || old.focusable != new.focusable
164        || old.clip_rect != new.clip_rect
165        || old.label != new.label
166}
167
168/// Collect every id in a tree.
169fn collect_ids(tree: &WidgetTree) -> HashSet<WidgetId> {
170    let mut ids = HashSet::new();
171    tree.walk_dfs(|node, _| {
172        ids.insert(node.id);
173    });
174    ids
175}
176
177/// The chain of ids from `id` up to (and including) the root.
178fn ancestor_chain(tree: &WidgetTree, id: WidgetId) -> Vec<WidgetId> {
179    let mut chain = Vec::new();
180    let mut cur = tree.get(id);
181    while let Some(node) = cur {
182        chain.push(node.id);
183        cur = node.parent.and_then(|p| tree.get(p));
184    }
185    chain
186}
187
188/// Compute the longest common subsequence of two id sequences.
189///
190/// Standard O(n·m) dynamic-programming LCS over `WidgetId`. Returns the
191/// subsequence itself (in order). Used to identify children that can stay in
192/// place without a move.
193fn longest_common_subsequence(a: &[WidgetId], b: &[WidgetId]) -> Vec<WidgetId> {
194    let n = a.len();
195    let m = b.len();
196    if n == 0 || m == 0 {
197        return Vec::new();
198    }
199    // dp[i][j] = LCS length of a[i..] and b[j..]; (n+1)*(m+1) table.
200    let mut dp = vec![vec![0u32; m + 1]; n + 1];
201    for i in (0..n).rev() {
202        for j in (0..m).rev() {
203            dp[i][j] = if a[i] == b[j] {
204                dp[i + 1][j + 1] + 1
205            } else {
206                dp[i + 1][j].max(dp[i][j + 1])
207            };
208        }
209    }
210    // Reconstruct.
211    let mut result = Vec::new();
212    let (mut i, mut j) = (0usize, 0usize);
213    while i < n && j < m {
214        if a[i] == b[j] {
215            result.push(a[i]);
216            i += 1;
217            j += 1;
218        } else if dp[i + 1][j] >= dp[i][j + 1] {
219            i += 1;
220        } else {
221            j += 1;
222        }
223    }
224    result
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::geometry::Rect;
231
232    fn r(x: f32) -> Rect {
233        Rect::new(x, 0.0, 10.0, 10.0)
234    }
235
236    #[test]
237    fn lcs_basic() {
238        let a = [WidgetId(1), WidgetId(2), WidgetId(3), WidgetId(4)];
239        let b = [WidgetId(2), WidgetId(4), WidgetId(1), WidgetId(3)];
240        let lcs = longest_common_subsequence(&a, &b);
241        // A valid LCS of these is [1,3] or [2,4] — length 2 either way.
242        assert_eq!(lcs.len(), 2);
243    }
244
245    #[test]
246    fn identical_trees_yield_no_ops() {
247        let mut old = WidgetTree::new(r(0.0));
248        let a = old.insert(WidgetId::ROOT, r(1.0)).expect("root");
249        let _b = old.insert(WidgetId::ROOT, r(2.0)).expect("root");
250        let _c = old.insert(a, r(3.0)).expect("a");
251
252        // Build an identical tree (same ids because allocation order matches).
253        let mut new = WidgetTree::new(r(0.0));
254        let a2 = new.insert(WidgetId::ROOT, r(1.0)).expect("root");
255        let _b2 = new.insert(WidgetId::ROOT, r(2.0)).expect("root");
256        let _c2 = new.insert(a2, r(3.0)).expect("a");
257
258        let ops = diff(&old, &new);
259        assert!(
260            ops.is_empty(),
261            "identical trees should diff to nothing: {ops:?}"
262        );
263    }
264
265    #[test]
266    fn detects_field_update() {
267        let mut old = WidgetTree::new(r(0.0));
268        let a = old.insert(WidgetId::ROOT, r(1.0)).expect("root");
269
270        let mut new = WidgetTree::new(r(0.0));
271        let a2 = new.insert(WidgetId::ROOT, r(1.0)).expect("root");
272        if let Some(n) = new.get_mut(a2) {
273            n.rect = r(99.0); // moved rect → Update
274        }
275        assert_eq!(a, a2);
276
277        let ops = diff(&old, &new);
278        assert!(
279            ops.contains(&DiffOp::Update { id: a }),
280            "expected Update, got {ops:?}"
281        );
282    }
283
284    #[test]
285    fn detects_insert_and_remove() {
286        // old: root → [a]
287        let mut old = WidgetTree::new(r(0.0));
288        let a = old.insert(WidgetId::ROOT, r(1.0)).expect("root");
289
290        // new: root → [a, b]  (b is id 2, which never existed in old)
291        let mut new = WidgetTree::new(r(0.0));
292        let a2 = new.insert(WidgetId::ROOT, r(1.0)).expect("root");
293        let b = new.insert(WidgetId::ROOT, r(2.0)).expect("root");
294        assert_eq!(a, a2);
295
296        let ops = diff(&old, &new);
297        assert!(
298            ops.iter()
299                .any(|o| matches!(o, DiffOp::Insert { id, parent, index }
300                if *id == b && *parent == WidgetId::ROOT && *index == 1)),
301            "expected Insert of b at index 1, got {ops:?}"
302        );
303
304        // Reverse direction: removing b.
305        let ops_rev = diff(&new, &old);
306        assert!(
307            ops_rev.contains(&DiffOp::Remove { id: b }),
308            "expected Remove of b, got {ops_rev:?}"
309        );
310    }
311
312    #[test]
313    fn removed_subtree_emits_single_remove() {
314        // old: root → a → a1 ; remove a (and implicitly a1).
315        let mut old = WidgetTree::new(r(0.0));
316        let a = old.insert(WidgetId::ROOT, r(1.0)).expect("root");
317        let a1 = old.insert(a, r(2.0)).expect("a");
318
319        let new = WidgetTree::new(r(0.0)); // empty but root
320
321        let ops = diff(&old, &new);
322        // Only the subtree root `a` is removed; `a1` is not separately removed.
323        assert!(ops.contains(&DiffOp::Remove { id: a }), "got {ops:?}");
324        assert!(
325            !ops.contains(&DiffOp::Remove { id: a1 }),
326            "child should not get its own Remove: {ops:?}"
327        );
328    }
329
330    #[test]
331    fn reorder_emits_minimal_moves() {
332        // Build old with children [c1, c2, c3] under root.
333        let mut old = WidgetTree::new(r(0.0));
334        let c1 = old.insert(WidgetId::ROOT, r(1.0)).expect("root");
335        let c2 = old.insert(WidgetId::ROOT, r(2.0)).expect("root");
336        let c3 = old.insert(WidgetId::ROOT, r(3.0)).expect("root");
337
338        // New tree with the SAME ids but reordered to [c3, c1, c2]. Construct by
339        // inserting then reordering the root child vector directly.
340        let mut new = WidgetTree::new(r(0.0));
341        let _ = new.insert(WidgetId::ROOT, r(1.0)).expect("root"); // c1
342        let _ = new.insert(WidgetId::ROOT, r(2.0)).expect("root"); // c2
343        let _ = new.insert(WidgetId::ROOT, r(3.0)).expect("root"); // c3
344        if let Some(root) = new.get_mut(WidgetId::ROOT) {
345            root.children = vec![c3, c1, c2];
346        }
347
348        let ops = diff(&old, &new);
349        // LCS of [c1,c2,c3] and [c3,c1,c2] is [c1,c2] (len 2); only c3 moves.
350        let moves: Vec<_> = ops
351            .iter()
352            .filter(|o| matches!(o, DiffOp::Move { .. }))
353            .collect();
354        assert_eq!(moves.len(), 1, "exactly one move expected, got {ops:?}");
355        assert!(
356            matches!(moves[0], DiffOp::Move { id, index, .. } if *id == c3 && *index == 0),
357            "c3 should move to index 0, got {:?}",
358            moves[0]
359        );
360    }
361}