use crate::compat::HashMap;
use crate::widget::capability::CapabilityValue;
use super::node::Node;
#[derive(Debug, Clone, PartialEq)]
pub enum Patch {
SetProperty {
id: crate::core::ObjectId,
name: String,
value: CapabilityValue,
},
Remove {
id: crate::core::ObjectId,
},
Insert {
parent: crate::core::ObjectId,
index: usize,
node: Node,
},
Move {
id: crate::core::ObjectId,
parent: crate::core::ObjectId,
index: usize,
},
Replace {
id: crate::core::ObjectId,
parent: crate::core::ObjectId,
index: usize,
node: Node,
},
}
impl Patch {
pub fn target_id(&self) -> crate::core::ObjectId {
match self {
Patch::SetProperty { id, .. }
| Patch::Remove { id }
| Patch::Move { id, .. }
| Patch::Replace { id, .. } => *id,
Patch::Insert { parent, .. } => *parent,
}
}
pub fn kind_name(&self) -> &'static str {
match self {
Patch::SetProperty { .. } => "SetProperty",
Patch::Remove { .. } => "Remove",
Patch::Insert { .. } => "Insert",
Patch::Move { .. } => "Move",
Patch::Replace { .. } => "Replace",
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DiffReport {
pub patches: Vec<Patch>,
pub positional_matches: usize,
pub replaced_subtrees: usize,
}
impl DiffReport {
pub fn is_unchanged(&self) -> bool {
self.patches.is_empty()
}
pub fn patch_count(&self) -> usize {
self.patches.len()
}
pub fn patches_of_kind(&self, kind: &str) -> Vec<&Patch> {
self.patches.iter().filter(|p| p.kind_name() == kind).collect()
}
pub fn written_properties(&self) -> Vec<&str> {
self.patches
.iter()
.filter_map(|p| match p {
Patch::SetProperty { name, .. } => Some(name.as_str()),
_ => None,
})
.collect()
}
}
pub fn diff(
old: &Node,
new: &Node,
id_of: &dyn Fn(&[usize], usize) -> Option<crate::core::ObjectId>,
) -> DiffReport {
let mut report = DiffReport::default();
let root_path = Vec::new();
diff_node(old, new, &root_path, None, 0, id_of, &mut report);
report
}
#[allow(clippy::too_many_arguments)]
fn diff_node(
old: &Node,
new: &Node,
path: &[usize],
parent_id: Option<crate::core::ObjectId>,
index: usize,
id_of: &dyn Fn(&[usize], usize) -> Option<crate::core::ObjectId>,
report: &mut DiffReport,
) {
let old_id = id_of(path, index);
let type_changed = old.widget != new.widget;
let (Some(old_id), false) = (old_id, type_changed) else {
match (old_id, parent_id) {
(None, Some(parent)) => {
report.patches.push(Patch::Insert { parent, index, node: new.clone() })
}
(_, Some(parent)) => {
report.patches.push(Patch::Replace {
id: old_id.unwrap_or(0),
parent,
index,
node: new.clone(),
});
report.replaced_subtrees += 1;
}
(None, None) => {}
(Some(id), None) => {
report.patches.push(Patch::Replace { id, parent: 0, index: 0, node: new.clone() });
report.replaced_subtrees += 1;
}
}
return;
};
for (name, value) in &new.props {
match old.props.get(name) {
Some(old_value) if old_value == value => {}
_ => report.patches.push(Patch::SetProperty {
id: old_id,
name: name.clone(),
value: value.clone(),
}),
}
}
for name in old.props.keys() {
if !new.props.contains_key(name) {
report.patches.push(Patch::SetProperty {
id: old_id,
name: name.clone(),
value: CapabilityValue::Null,
});
}
}
diff_children(old, new, path, old_id, id_of, report);
}
fn diff_children(
old: &Node,
new: &Node,
path: &[usize],
parent_id: crate::core::ObjectId,
id_of: &dyn Fn(&[usize], usize) -> Option<crate::core::ObjectId>,
report: &mut DiffReport,
) {
let mut old_key_index: HashMap<&str, usize> = HashMap::new();
for (i, child) in old.children.iter().enumerate() {
if let Some(k) = child.key_str() {
old_key_index.insert(k, i);
}
}
let mut consumed: Vec<bool> = vec![false; old.children.len()];
let mut matches: Vec<Option<usize>> = Vec::with_capacity(new.children.len());
for child in &new.children {
let found = match child.key_str() {
Some(k) => old_key_index.get(k).copied().filter(|&i| !consumed[i]),
None => {
old.children
.iter()
.enumerate()
.position(|(i, c)| !consumed[i] && c.widget == child.widget)
}
};
if let Some(i) = found {
consumed[i] = true;
} else if child.key.is_none() {
report.positional_matches += 1;
}
matches.push(found);
}
let mut last_matched_old: Option<usize> = None;
for (new_index, child) in new.children.iter().enumerate() {
let child_path: Vec<usize> = {
let mut p = path.to_vec();
p.push(new_index);
p
};
match matches[new_index] {
Some(old_index) => {
let moved = match last_matched_old {
Some(previous) => old_index < previous,
None => false,
};
if moved {
report.patches.push(Patch::Move {
id: id_of(
&{
let mut p = path.to_vec();
p.push(old_index);
p
},
old_index,
)
.unwrap_or(0),
parent: parent_id,
index: new_index,
});
}
let mut old_child_path = path.to_vec();
old_child_path.push(old_index);
diff_node(
&old.children[old_index],
child,
&old_child_path,
Some(parent_id),
old_index,
id_of,
report,
);
let _ = child_path;
last_matched_old = Some(old_index);
}
None => {
report.patches.push(Patch::Insert {
parent: parent_id,
index: new_index,
node: child.clone(),
});
}
}
}
for (i, child) in old.children.iter().enumerate() {
if consumed[i] {
continue;
}
let mut p = path.to_vec();
p.push(i);
if let Some(id) = id_of(&p, i) {
report.patches.push(Patch::Remove { id });
}
let _ = child;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ObjectId;
struct FixtureIds {
by_path: HashMap<Vec<usize>, ObjectId>,
next: ObjectId,
}
impl FixtureIds {
fn assign(&mut self, server: &Node) -> &mut Self {
self.by_path.clear();
self.next = 1;
let mut stack: Vec<(Vec<usize>, &Node, usize)> = vec![(Vec::new(), server, 0)];
while let Some((path, node, index)) = stack.pop() {
self.by_path.insert(path.clone(), self.next);
self.next += 1;
for (i, child) in node.children.iter().enumerate().rev() {
let mut p = path.clone();
p.push(i);
stack.push((p, child, i));
}
let _ = index;
}
self
}
fn lookup(&self) -> impl Fn(&[usize], usize) -> Option<ObjectId> + '_ {
move |path: &[usize], _index: usize| self.by_path.get(path).copied()
}
}
fn s(v: &str) -> CapabilityValue {
CapabilityValue::String(v.to_string())
}
fn run(old: &Node, new: &Node) -> DiffReport {
let mut ids = FixtureIds::default();
ids.assign(old);
let lookup = ids.lookup();
diff(old, new, &lookup)
}
#[test]
fn b4_1_identical_trees_produce_no_patches() {
let tree = Node::new("vbox")
.key("root")
.prop("spacing", CapabilityValue::Int(4))
.child(Node::new("label").key("a").prop("text", s("A")))
.child(Node::new("button").key("b").prop("text", s("B")));
let report = run(&tree, &tree);
assert!(report.is_unchanged(), "unchanged tree produced {:?}", report.patches);
assert_eq!(report.positional_matches, 0);
assert_eq!(report.replaced_subtrees, 0);
}
#[test]
fn b4_2_one_property_change_is_one_patch() {
let old =
Node::new("vbox").key("root").child(Node::new("label").key("a").prop("text", s("A")));
let new =
Node::new("vbox").key("root").child(Node::new("label").key("a").prop("text", s("Z")));
let report = run(&old, &new);
assert_eq!(report.patch_count(), 1, "got {:?}", report.patches);
assert_eq!(report.written_properties(), ["text"]);
match &report.patches[0] {
Patch::SetProperty { name, value, .. } => {
assert_eq!(name, "text");
assert_eq!(value, &s("Z"));
}
other => panic!("expected SetProperty, got {other:?}"),
}
}
#[test]
fn b4_2b_a_property_only_in_the_old_tree_is_reset() {
let old = Node::new("label").key("a").prop("text", s("A")).prop("tooltip", s("T"));
let new = Node::new("label").key("a").prop("text", s("A"));
let report = run(&old, &new);
assert_eq!(report.patch_count(), 1);
match &report.patches[0] {
Patch::SetProperty { name, value, .. } => {
assert_eq!(name, "tooltip");
assert_eq!(value, &CapabilityValue::Null, "a dropped property resets to Null");
}
other => panic!("expected SetProperty, got {other:?}"),
}
}
#[test]
fn b4_2c_a_changed_property_is_not_also_reset() {
let old = Node::new("label").key("a").prop("text", s("A"));
let new = Node::new("label").key("a").prop("text", s("B"));
let report = run(&old, &new);
assert_eq!(report.written_properties(), ["text"]);
}
#[test]
fn b4_3_appending_a_child_is_a_single_insert() {
let old =
Node::new("vbox").key("root").child(Node::new("label").key("a").prop("text", s("A")));
let new = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a").prop("text", s("A")))
.child(Node::new("button").key("b"));
let report = run(&old, &new);
assert_eq!(report.patch_count(), 1, "got {:?}", report.patches);
match &report.patches[0] {
Patch::Insert { index, node, .. } => {
assert_eq!(*index, 1, "appended at the end");
assert_eq!(node.widget, "button");
}
other => panic!("expected Insert, got {other:?}"),
}
}
#[test]
fn b4_4_head_insert_with_keys_does_not_renumber_its_siblings() {
let old = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a"))
.child(Node::new("label").key("b"));
let new = Node::new("vbox")
.key("root")
.child(Node::new("label").key("z"))
.child(Node::new("label").key("a"))
.child(Node::new("label").key("b"));
let mut ids = FixtureIds::default();
ids.assign(&old);
let a_id = ids.by_path[&vec![0]];
let b_id = ids.by_path[&vec![1]];
let report = diff(&old, &new, &ids.lookup());
assert_eq!(report.patch_count(), 1, "only the insert, got {:?}", report.patches);
assert_eq!(report.replaced_subtrees, 0);
match &report.patches[0] {
Patch::Insert { index, node, .. } => {
assert_eq!(*index, 0, "the insert lands at the head: {:?}", report.patches);
assert_eq!(node.key_str(), Some("z"));
}
other => panic!("expected Insert first, got {other:?}"),
}
assert_eq!(ids.by_path[&vec![0]], a_id);
assert_eq!(ids.by_path[&vec![1]], b_id);
}
#[test]
fn b4_5_removing_a_middle_child_is_a_single_remove() {
let old = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a"))
.child(Node::new("label").key("b"))
.child(Node::new("label").key("c"));
let new = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a"))
.child(Node::new("label").key("c"));
let report = run(&old, &new);
assert_eq!(report.patch_count(), 1, "got {:?}", report.patches);
assert_eq!(report.patches[0].kind_name(), "Remove");
}
#[test]
fn b4_5b_removing_a_keyed_child_does_not_remove_its_sibling() {
let old = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a"))
.child(Node::new("label").key("b"))
.child(Node::new("label").key("c"));
let new = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a"))
.child(Node::new("label").key("c"));
let mut ids = FixtureIds::default();
ids.assign(&old);
let c_id = ids.by_path[&vec![2]];
let report = diff(&old, &new, &ids.lookup());
for patch in &report.patches {
assert_ne!(patch.target_id(), c_id, "the surviving sibling was removed: {patch:?}");
}
}
#[test]
fn b4_6_reordering_keyed_children_moves_rather_than_recreating() {
let old = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a"))
.child(Node::new("label").key("b"))
.child(Node::new("label").key("c"));
let new = Node::new("vbox")
.key("root")
.child(Node::new("label").key("c"))
.child(Node::new("label").key("a"))
.child(Node::new("label").key("b"));
let report = run(&old, &new);
assert_eq!(report.replaced_subtrees, 0, "a reorder must not rebuild anything");
assert!(
!report.patches.is_empty() && report.patches.iter().all(|p| p.kind_name() == "Move"),
"expected only moves, got {:?}",
report.patches
);
}
#[test]
fn b4_7_a_type_change_is_a_replace_not_an_update() {
let old = Node::new("label").key("a").prop("text", s("A"));
let new = Node::new("button").key("a").prop("text", s("A"));
let report = run(&old, &new);
assert_eq!(report.replaced_subtrees, 1);
assert_eq!(report.patches_of_kind("Replace").len(), 1);
assert_eq!(report.patches_of_kind("SetProperty").len(), 0, "nothing to update");
}
#[test]
fn b4_7b_a_type_change_on_a_child_is_replaced_in_place() {
let old = Node::new("vbox").key("root").child(Node::new("label").key("a"));
let new = Node::new("vbox").key("root").child(Node::new("button").key("a"));
let report = run(&old, &new);
assert_eq!(report.replaced_subtrees, 1);
match &report.patches[0] {
Patch::Replace { index, node, .. } => {
assert_eq!(*index, 0, "the replacement takes the same position");
assert_eq!(node.widget, "button");
}
other => panic!("expected Replace, got {other:?}"),
}
}
#[test]
fn b4_8_a_keyless_head_insert_reports_the_degradation() {
let old = Node::new("vbox").key("root").child(Node::new("label")).child(Node::new("label"));
let new = Node::new("vbox")
.key("root")
.child(Node::new("label"))
.child(Node::new("label"))
.child(Node::new("label"));
let report = run(&old, &new);
assert!(
report.positional_matches > 0,
"a keyless insert must be reported as positional, got {report:?}"
);
assert_eq!(report.replaced_subtrees, 0, "same types still match, just by position");
}
#[test]
fn b4_8b_keyed_children_do_not_count_as_positional() {
let old = Node::new("vbox").key("root").child(Node::new("label").key("a"));
let new = Node::new("vbox").key("root").child(Node::new("label").key("a"));
let report = run(&old, &new);
assert_eq!(report.positional_matches, 0);
}
#[test]
fn b4_8c_positional_matching_still_finds_property_changes() {
let old = Node::new("vbox").key("root").child(Node::new("label").prop("text", s("before")));
let new = Node::new("vbox").key("root").child(Node::new("label").prop("text", s("after")));
let report = run(&old, &new);
assert_eq!(report.written_properties(), ["text"]);
}
#[test]
fn b5_float_change_is_detected_exactly() {
let old = Node::new("meter").key("m").prop("value", CapabilityValue::Float(1.0));
let new = Node::new("meter").key("m").prop("value", CapabilityValue::Float(1.000_000_1));
let report = run(&old, &new);
assert_eq!(report.patch_count(), 1, "no fuzzy float comparison is allowed here");
}
#[test]
fn b5_adding_a_property_is_a_write() {
let old = Node::new("label").key("a");
let new = Node::new("label").key("a").prop("text", s("new"));
let report = run(&old, &new);
assert_eq!(report.written_properties(), ["text"]);
}
#[test]
fn patch_targets_the_control_it_acts_on() {
let old = Node::new("label").key("a").prop("text", s("A"));
let new = Node::new("label").key("a").prop("text", s("B"));
let mut ids = FixtureIds::default();
ids.assign(&old);
let root_id = ids.by_path[&Vec::new()];
let report = diff(&old, &new, &ids.lookup());
assert_eq!(report.patches[0].target_id(), root_id);
}
#[test]
fn patches_of_kind_filters_without_reordering() {
let old = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a").prop("text", s("A")))
.child(Node::new("button").key("b"));
let new = Node::new("vbox")
.key("root")
.child(Node::new("label").key("a").prop("text", s("Z")))
.child(Node::new("button").key("c"));
let report = run(&old, &new);
assert_eq!(report.patches_of_kind("SetProperty").len(), 1, "got {:?}", report.patches);
assert_eq!(report.patches_of_kind("Insert").len(), 1, "the new key => a fresh control");
assert_eq!(report.patches_of_kind("Remove").len(), 1, "the dropped key => a removal");
assert_eq!(report.replaced_subtrees, 0, "a keyed add/remove is not a replace");
}
#[test]
fn a_change_deep_in_the_tree_is_patched_without_touching_the_rest() {
let build = |text: &str| {
Node::new("vbox")
.key("root")
.child(
Node::new("panel")
.key("card")
.child(Node::new("label").key("title").prop("text", s(text))),
)
.child(Node::new("button").key("ok"))
};
let report = run(&build("before"), &build("after"));
assert_eq!(report.patch_count(), 1, "only the label write, got {:?}", report.patches);
assert_eq!(report.written_properties(), ["text"]);
assert_eq!(report.replaced_subtrees, 0);
}
#[test]
fn an_inserted_subtree_is_carried_whole() {
let old = Node::new("vbox").key("root");
let new = Node::new("vbox")
.key("root")
.child(Node::new("panel").key("p").child(Node::new("label").key("inner")));
let report = run(&old, &new);
assert_eq!(report.patch_count(), 1);
match &report.patches[0] {
Patch::Insert { node, .. } => {
assert_eq!(node.node_count(), 2, "the subtree travels with the insert");
}
other => panic!("expected Insert, got {other:?}"),
}
}
#[test]
fn a_removed_subtree_is_removed_by_its_root() {
let old = Node::new("vbox").key("root").child(
Node::new("panel")
.key("p")
.child(Node::new("label").key("inner"))
.child(Node::new("label").key("inner2")),
);
let new = Node::new("vbox").key("root");
let report = run(&old, &new);
assert_eq!(report.patch_count(), 1, "got {:?}", report.patches);
assert_eq!(report.patches[0].kind_name(), "Remove");
}
#[test]
fn moving_a_child_to_another_parent_keeps_its_identity() {
let old = Node::new("vbox")
.key("root")
.child(Node::new("panel").key("left").child(Node::new("label").key("item")))
.child(Node::new("panel").key("right"));
let new = Node::new("vbox")
.key("root")
.child(Node::new("panel").key("left"))
.child(Node::new("panel").key("right").child(Node::new("label").key("item")));
let report = run(&old, &new);
let moves = report.patches_of_kind("Move");
let removes = report.patches_of_kind("Remove");
assert!(
!moves.is_empty() || !removes.is_empty(),
"cross-parent edit produced no patch: {:?}",
report.patches
);
}
#[test]
fn an_empty_diff_report_answers_its_own_questions() {
let report = DiffReport::default();
assert!(report.is_unchanged());
assert_eq!(report.patch_count(), 0);
assert!(report.patches_of_kind("Insert").is_empty());
assert!(report.written_properties().is_empty());
}
impl Default for FixtureIds {
fn default() -> Self {
Self { by_path: HashMap::new(), next: 1 }
}
}
}