Skip to main content

tpt_appfront_core/
reconcile.rs

1//! Backend-agnostic keyed list reconciliation (Phase 3).
2//!
3//! `tpt-appfront-dom` already performs keyed reconciliation against the live DOM
4//! in [`tpt_appfront_dom::update_list`]; this module factors the *pure* part of
5//! that algorithm out so it can be unit-tested on any target (the DOM backend
6//! is `wasm32`-only and can't run native tests) and reused by other backends
7//! that render keyed collections (`tpt-appfront-canvas`, `tpt-appfront-tui`).
8//!
9//! The input is two ordered sequences of keys (the previous render's keys and
10//! the next render's keys). The output is a [`KeyedDiff`] describing, per new
11//! item, whether the existing node can be kept in place, must be moved, or is
12//! new — plus the list of keys that disappeared and should be removed. A
13//! backend walks [`KeyedDiff::edits`] in order, reusing/creating/moving DOM
14//! (or canvas/`ratatui`) nodes accordingly, which adds/removes/reorders
15//! without rebuilding the whole list.
16//!
17//! Keys should be unique. Duplicate keys are not meaningful for reconciliation
18//! and will produce undefined moves.
19
20use std::collections::VecDeque;
21use std::hash::Hash;
22
23/// What to do with one item in the new sequence.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum ListEdit<K> {
26    /// Key already rendered and already sitting at this position — no-op.
27    Keep { key: K },
28    /// Key already rendered but at a different position — move it here.
29    Move { key: K },
30    /// Key is new — create a node here.
31    Insert { key: K },
32}
33
34/// The result of diffing two keyed sequences.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct KeyedDiff<K> {
37    /// One entry per item in the *new* sequence, in order.
38    pub edits: Vec<ListEdit<K>>,
39    /// Keys present in `old` but absent from `new` — remove their nodes.
40    pub removed: Vec<K>,
41}
42
43/// Diffs `old` against `new`, producing the edits a backend needs to reconcile
44/// a rendered keyed collection (add/remove/reorder) without a full rebuild.
45pub fn reconcile_keys<K>(old: &[K], new: &[K]) -> KeyedDiff<K>
46where
47    K: Clone + Eq + Hash,
48{
49    // Keys in `old` (in old order) that still appear in `new` — these are the
50    // candidates to keep/move. Keys in `old` but not `new` are removed.
51    let mut surviving: VecDeque<(usize, &K)> = old
52        .iter()
53        .enumerate()
54        .filter(|(_, k)| new.contains(k))
55        .collect();
56    let removed: Vec<K> = old.iter().filter(|&k| !new.contains(k)).cloned().collect();
57
58    let mut edits = Vec::with_capacity(new.len());
59    for k in new {
60        let in_old = old.contains(k);
61        if !in_old {
62            edits.push(ListEdit::Insert { key: k.clone() });
63            continue;
64        }
65        // Present in old: keep if it's the next survivor in old order, else move.
66        if let Some(front) = surviving.front() {
67            if front.1 == k {
68                surviving.pop_front();
69                edits.push(ListEdit::Keep { key: k.clone() });
70            } else {
71                if let Some(idx) = surviving.iter().position(|(_, sk)| *sk == k) {
72                    surviving.remove(idx);
73                }
74                edits.push(ListEdit::Move { key: k.clone() });
75            }
76        } else {
77            edits.push(ListEdit::Move { key: k.clone() });
78        }
79    }
80
81    KeyedDiff { edits, removed }
82}
83
84/// Applies a [`KeyedDiff`] to a keyed sequence, for tests and for backends
85/// that keep their own `Vec<key>` mirror. Returns the resulting order, which
86/// must equal `new`.
87pub fn apply_edits<K>(old: &[K], diff: &KeyedDiff<K>) -> Vec<K>
88where
89    K: Clone + Eq + Hash,
90{
91    let mut working: Vec<K> = old
92        .iter()
93        .filter(|&k| !diff.removed.contains(k))
94        .cloned()
95        .collect();
96    let mut out = Vec::with_capacity(diff.edits.len());
97    for edit in &diff.edits {
98        match edit {
99            ListEdit::Keep { key } | ListEdit::Move { key } => {
100                if let Some(idx) = working.iter().position(|w| w == key) {
101                    working.remove(idx);
102                }
103                out.push(key.clone());
104            }
105            ListEdit::Insert { key } => out.push(key.clone()),
106        }
107    }
108    out
109}
110
111/// A one-line human-readable description of a single [`ListEdit`], for
112/// change-explanation UIs (e.g. "moved a", "inserted c", "kept b"). This is the
113/// "what changed" half of the undo/redo + change-explanation utility (#75).
114pub fn edit_description<K: std::fmt::Display>(edit: &ListEdit<K>) -> String {
115    match edit {
116        ListEdit::Keep { key } => format!("kept {key}"),
117        ListEdit::Move { key } => format!("moved {key}"),
118        ListEdit::Insert { key } => format!("inserted {key}"),
119    }
120}
121
122/// A human-readable summary of a [`KeyedDiff`]: how many items were kept,
123/// moved, inserted, and removed. Suitable for surfacing "what changed between
124/// renders" in a devtools/undo panel.
125pub fn diff_summary<K: std::fmt::Display>(diff: &KeyedDiff<K>) -> String {
126    let mut keeps = 0;
127    let mut moves = 0;
128    let mut inserts = 0;
129    for e in &diff.edits {
130        match e {
131            ListEdit::Keep { .. } => keeps += 1,
132            ListEdit::Move { .. } => moves += 1,
133            ListEdit::Insert { .. } => inserts += 1,
134        }
135    }
136    format!(
137        "kept {keeps}, moved {moves}, inserted {inserts}, removed {}",
138        diff.removed.len()
139    )
140}
141
142/// A bounded undo/redo stack over snapshot values of type `T` (e.g. a `UITree`,
143/// a form struct, or any `Clone` app state). Pushing a *new* value records it
144/// as the present; [`History::undo`]/`[`History::redo`] walk the timeline.
145///
146/// This is intentionally generic and backend-agnostic: it stores plain `T`
147/// snapshots (the same `UITree`/`Signal` data the render core already uses),
148/// so any app gets "what changed / undo this" almost for free on top of the
149/// keyed-diffing in this module. Pair [`diff_summary`] with the snapshot delta
150/// to explain each step.
151pub struct History<T> {
152    past: Vec<T>,
153    present: T,
154    future: Vec<T>,
155    limit: Option<usize>,
156}
157
158impl<T: Clone> History<T> {
159    /// Creates a history seeded with `initial` as the present. `limit`, if
160    /// `Some`, caps how many past snapshots are retained (oldest dropped first).
161    pub fn new(initial: T, limit: Option<usize>) -> Self {
162        History {
163            past: Vec::new(),
164            present: initial,
165            future: Vec::new(),
166            limit,
167        }
168    }
169
170    /// Returns the current value.
171    pub fn present(&self) -> &T {
172        &self.present
173    }
174
175    /// Commits a new present, pushing the old one onto the undo stack and
176    /// clearing the redo stack. No-op (aside from `present` already being the
177    /// new value) if `new_value` is equal to the current present.
178    pub fn push(&mut self, new_value: T)
179    where
180        T: PartialEq,
181    {
182        if new_value == self.present {
183            return;
184        }
185        self.past.push(self.present.clone());
186        if let Some(limit) = self.limit {
187            while self.past.len() > limit {
188                self.past.remove(0);
189            }
190        }
191        self.present = new_value;
192        self.future.clear();
193    }
194
195    /// Moves the present onto the redo stack and restores the most recent past
196    /// snapshot. Returns `true` if an undo happened.
197    pub fn undo(&mut self) -> bool {
198        if let Some(prev) = self.past.pop() {
199            self.future.push(std::mem::replace(&mut self.present, prev));
200            true
201        } else {
202            false
203        }
204    }
205
206    /// Moves the present onto the undo stack and restores the most recent
207    /// future snapshot. Returns `true` if a redo happened.
208    pub fn redo(&mut self) -> bool {
209        if let Some(next) = self.future.pop() {
210            self.past.push(std::mem::replace(&mut self.present, next));
211            true
212        } else {
213            false
214        }
215    }
216
217    /// Whether an [`History::undo`] would succeed.
218    pub fn can_undo(&self) -> bool {
219        !self.past.is_empty()
220    }
221
222    /// Whether an [`History::redo`] would succeed.
223    pub fn can_redo(&self) -> bool {
224        !self.future.is_empty()
225    }
226
227    /// Number of undo-able steps currently retained.
228    pub fn depth(&self) -> usize {
229        self.past.len()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn diff_and_apply(old: &[&str], new: &[&str]) -> Vec<String> {
238        let owned_old: Vec<String> = old.iter().map(|s| s.to_string()).collect();
239        let owned_new: Vec<String> = new.iter().map(|s| s.to_string()).collect();
240        let diff = reconcile_keys(&owned_old, &owned_new);
241        apply_edits(&owned_old, &diff).into_iter().collect()
242    }
243
244    #[test]
245    fn identical_lists_are_all_keeps() {
246        let old = vec!["a".to_string(), "b".to_string(), "c".to_string()];
247        let diff = reconcile_keys(&old, &old);
248        assert!(diff.removed.is_empty());
249        assert_eq!(
250            diff.edits,
251            vec![
252                ListEdit::Keep {
253                    key: "a".to_string()
254                },
255                ListEdit::Keep {
256                    key: "b".to_string()
257                },
258                ListEdit::Keep {
259                    key: "c".to_string()
260                },
261            ]
262        );
263    }
264
265    #[test]
266    fn append_produces_insert_and_no_removes() {
267        let out = diff_and_apply(&["a", "b"], &["a", "b", "c"]);
268        assert_eq!(out, vec!["a", "b", "c"]);
269    }
270
271    #[test]
272    fn truncate_removes_tail() {
273        let old = vec!["a".to_string(), "b".to_string(), "c".to_string()];
274        let new = vec!["a".to_string()];
275        let diff = reconcile_keys(&old, &new);
276        assert_eq!(diff.removed, vec!["b".to_string(), "c".to_string()]);
277        let out = apply_edits(&old, &diff);
278        assert_eq!(out, vec!["a".to_string()]);
279    }
280
281    #[test]
282    fn reorder_is_moves_not_full_rebuild() {
283        let out = diff_and_apply(&["a", "b", "c", "d"], &["d", "c", "b", "a"]);
284        assert_eq!(out, vec!["d", "c", "b", "a"]);
285
286        let old = vec![
287            "a".to_string(),
288            "b".to_string(),
289            "c".to_string(),
290            "d".to_string(),
291        ];
292        let new = vec![
293            "d".to_string(),
294            "c".to_string(),
295            "b".to_string(),
296            "a".to_string(),
297        ];
298        let diff = reconcile_keys(&old, &new);
299        assert!(diff.removed.is_empty());
300        // No inserts: every key already existed.
301        assert!(diff
302            .edits
303            .iter()
304            .all(|e| !matches!(e, ListEdit::Insert { .. })));
305    }
306
307    #[test]
308    fn insert_in_middle_moves_following() {
309        // Inserting "x" between "a" and "b": a stays, x inserted, b and c are
310        // now already in their correct (shifted) positions so they stay Keep.
311        let old = vec!["a".to_string(), "b".to_string(), "c".to_string()];
312        let new = vec![
313            "a".to_string(),
314            "x".to_string(),
315            "b".to_string(),
316            "c".to_string(),
317        ];
318        let diff = reconcile_keys(&old, &new);
319        assert_eq!(
320            diff.edits[0],
321            ListEdit::Keep {
322                key: "a".to_string()
323            }
324        );
325        assert_eq!(
326            diff.edits[1],
327            ListEdit::Insert {
328                key: "x".to_string()
329            }
330        );
331        assert_eq!(
332            diff.edits[2],
333            ListEdit::Keep {
334                key: "b".to_string()
335            }
336        );
337        let out = apply_edits(&old, &diff);
338        assert_eq!(out, new);
339    }
340
341    #[test]
342    fn remove_from_middle_shifts_others_to_keep() {
343        let old = vec!["a".to_string(), "b".to_string(), "c".to_string()];
344        let new = vec!["a".to_string(), "c".to_string()];
345        let diff = reconcile_keys(&old, &new);
346        assert_eq!(diff.removed, vec!["b".to_string()]);
347        // a kept, c kept (still in order, just shifted left).
348        assert_eq!(
349            diff.edits[0],
350            ListEdit::Keep {
351                key: "a".to_string()
352            }
353        );
354        assert_eq!(
355            diff.edits[1],
356            ListEdit::Keep {
357                key: "c".to_string()
358            }
359        );
360    }
361
362    #[test]
363    fn mixed_add_remove_reorder_reproduces_new() {
364        let out = diff_and_apply(&["a", "b", "c", "d", "e"], &["e", "b", "f", "d"]);
365        assert_eq!(out, vec!["e", "b", "f", "d"]);
366    }
367
368    #[test]
369    fn empty_old_is_all_inserts() {
370        let old: Vec<String> = vec![];
371        let new = vec!["a".to_string(), "b".to_string()];
372        let diff = reconcile_keys(&old, &new);
373        assert!(diff.removed.is_empty());
374        assert_eq!(
375            diff.edits,
376            vec![
377                ListEdit::Insert {
378                    key: "a".to_string()
379                },
380                ListEdit::Insert {
381                    key: "b".to_string()
382                },
383            ]
384        );
385    }
386
387    #[test]
388    fn edit_description_is_readable() {
389        assert_eq!(edit_description(&ListEdit::Keep { key: "a" }), "kept a");
390        assert_eq!(edit_description(&ListEdit::Move { key: "b" }), "moved b");
391        assert_eq!(edit_description(&ListEdit::Insert { key: "c" }), "inserted c");
392    }
393
394    #[test]
395    fn diff_summary_counts_ops() {
396        let old = vec!["a".to_string(), "b".to_string(), "c".to_string()];
397        let new = vec!["a".to_string(), "x".to_string(), "c".to_string()];
398        let diff = reconcile_keys(&old, &new);
399        assert_eq!(diff_summary(&diff), "kept 2, moved 0, inserted 1, removed 1");
400    }
401}
402
403#[cfg(test)]
404mod history_tests {
405    use super::*;
406
407    #[test]
408    fn push_then_undo_redo_round_trips() {
409        let mut h = History::new(0u32, None);
410        h.push(1);
411        h.push(2);
412        assert_eq!(*h.present(), 2);
413        assert!(h.can_undo());
414        assert!(h.undo());
415        assert_eq!(*h.present(), 1);
416        assert!(h.undo());
417        assert_eq!(*h.present(), 0);
418        assert!(!h.can_undo());
419
420        assert!(h.redo());
421        assert_eq!(*h.present(), 1);
422        assert!(h.redo());
423        assert_eq!(*h.present(), 2);
424        assert!(!h.can_redo());
425    }
426
427    #[test]
428    fn new_push_clears_redo() {
429        let mut h = History::new(0u32, None);
430        h.push(1);
431        h.undo();
432        assert!(h.can_redo());
433        h.push(5);
434        assert!(!h.can_redo());
435        assert_eq!(*h.present(), 5);
436    }
437
438    #[test]
439    fn equal_push_is_noop() {
440        let mut h = History::new(1u32, None);
441        let depth_before = h.depth();
442        h.push(1);
443        assert_eq!(h.depth(), depth_before);
444    }
445
446    #[test]
447    fn limit_drops_oldest() {
448        let mut h = History::new(0u32, Some(2));
449        h.push(1);
450        h.push(2);
451        h.push(3);
452        // past holds at most 2 snapshots.
453        assert_eq!(h.depth(), 2);
454        assert!(h.undo());
455        assert_eq!(*h.present(), 2);
456    }
457}