1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use thiserror::Error;
5
6use crate::{NodeKey, UiNode, UiNodeKindTag};
7
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub struct NodeId(u64);
14
15impl NodeId {
16 #[must_use]
17 pub const fn get(self) -> u64 {
18 self.0
19 }
20}
21
22impl fmt::Display for NodeId {
23 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
24 self.0.fmt(formatter)
25 }
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct RetainedChildLink {
30 group: String,
31 node: NodeId,
32}
33
34impl RetainedChildLink {
35 #[must_use]
36 pub fn group(&self) -> &str {
37 &self.group
38 }
39
40 #[must_use]
41 pub const fn node(&self) -> NodeId {
42 self.node
43 }
44}
45
46#[derive(Clone, Debug, PartialEq)]
47pub struct RetainedNode {
48 id: NodeId,
49 parent: Option<NodeId>,
50 key: Option<String>,
51 kind: UiNodeKindTag,
52 primitive: Option<crate::PrimitiveId>,
53 component_root: Option<crate::ComponentInstancePath>,
54 element_ref: Option<crate::ElementRef>,
55 attributes: BTreeMap<String, crate::UiValue>,
56 text: Option<String>,
57 handlers: BTreeMap<String, Vec<crate::UiEventBinding>>,
58 handler_payloads: BTreeMap<String, crate::UiValue>,
59 scrollable: bool,
60 focus_styled: bool,
61 canvas_commands: usize,
62 canvas_scene: Option<crate::CanvasScene>,
63 virtual_data_items: usize,
64 virtual_realized_items: usize,
65 children: Vec<RetainedChildLink>,
66}
67
68impl RetainedNode {
69 #[must_use]
70 pub const fn id(&self) -> NodeId {
71 self.id
72 }
73
74 #[must_use]
75 pub const fn parent(&self) -> Option<NodeId> {
76 self.parent
77 }
78
79 #[must_use]
80 pub fn key(&self) -> Option<&str> {
81 self.key.as_deref()
82 }
83
84 #[must_use]
85 pub const fn kind(&self) -> UiNodeKindTag {
86 self.kind
87 }
88
89 #[must_use]
90 pub const fn primitive(&self) -> Option<&crate::PrimitiveId> {
91 self.primitive.as_ref()
92 }
93
94 #[must_use]
95 pub fn component_root(&self) -> Option<&crate::ComponentInstancePath> {
96 self.component_root.as_ref()
97 }
98
99 #[must_use]
100 pub const fn element_ref(&self) -> Option<&crate::ElementRef> {
101 self.element_ref.as_ref()
102 }
103
104 #[must_use]
105 pub fn attributes(&self) -> &BTreeMap<String, crate::UiValue> {
106 &self.attributes
107 }
108
109 #[must_use]
110 pub fn text(&self) -> Option<&str> {
111 self.text.as_deref()
112 }
113
114 #[must_use]
115 pub fn event_handlers(&self, event: &str) -> &[crate::UiEventBinding] {
116 self.handlers.get(event).map_or(&[], Vec::as_slice)
117 }
118
119 #[must_use]
120 pub fn handler_payload(&self, event: &str) -> Option<&crate::UiValue> {
121 self.handler_payloads.get(event)
122 }
123
124 #[must_use]
125 pub fn handler_count(&self) -> usize {
126 self.handlers.values().map(Vec::len).sum()
127 }
128
129 #[must_use]
130 pub const fn scrollable(&self) -> bool {
131 self.scrollable
132 }
133
134 #[must_use]
135 pub const fn focus_styled(&self) -> bool {
136 self.focus_styled
137 }
138
139 #[must_use]
140 pub const fn canvas_command_count(&self) -> usize {
141 self.canvas_commands
142 }
143
144 #[must_use]
145 pub const fn canvas_scene(&self) -> Option<&crate::CanvasScene> {
146 self.canvas_scene.as_ref()
147 }
148
149 #[must_use]
150 pub const fn virtual_data_item_count(&self) -> usize {
151 self.virtual_data_items
152 }
153
154 #[must_use]
155 pub const fn virtual_realized_item_count(&self) -> usize {
156 self.virtual_realized_items
157 }
158
159 pub fn children(&self) -> impl ExactSizeIterator<Item = &RetainedChildLink> {
160 self.children.iter()
161 }
162}
163
164#[derive(Clone, Debug, Default, Eq, PartialEq)]
165pub struct ReconcileReport {
166 pub mounted: Vec<NodeId>,
167 pub preserved: Vec<NodeId>,
168 pub moved: Vec<NodeId>,
169 pub unmounted: Vec<NodeId>,
170}
171
172#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
173pub struct ReconcileMetrics {
174 pub mounted: usize,
175 pub preserved: usize,
176 pub moved: usize,
177 pub unmounted: usize,
178}
179
180impl ReconcileReport {
181 fn sort(&mut self) {
182 self.mounted.sort_unstable();
183 self.preserved.sort_unstable();
184 self.moved.sort_unstable();
185 self.unmounted.sort_unstable();
186 }
187
188 #[must_use]
189 pub fn metrics(&self) -> ReconcileMetrics {
190 ReconcileMetrics {
191 mounted: self.mounted.len(),
192 preserved: self.preserved.len(),
193 moved: self.moved.len(),
194 unmounted: self.unmounted.len(),
195 }
196 }
197}
198
199#[derive(Clone, Debug, Error, Eq, PartialEq)]
200pub enum ReconcileError {
201 #[error("node ID space is exhausted")]
202 NodeIdExhausted,
203 #[error("retained root state is inconsistent")]
204 RootStateMismatch,
205 #[error("retained node {0} is missing")]
206 MissingNode(NodeId),
207 #[error("retained child structure for node {0} is inconsistent with its snapshot")]
208 InconsistentSnapshot(NodeId),
209 #[error("duplicate key `{key}` in child group `{group}` below node {parent}")]
210 DuplicateKey {
211 parent: NodeId,
212 group: String,
213 key: String,
214 },
215 #[error("fragment nodes may contain only children, source, and an optional key")]
216 FragmentDecoration,
217}
218
219#[derive(Clone, Debug)]
225pub struct RetainedUiTree {
226 root: Option<UiNode>,
227 root_id: Option<NodeId>,
228 nodes: BTreeMap<NodeId, RetainedNode>,
229 next_id: u64,
230 last_report: ReconcileReport,
231}
232
233impl Default for RetainedUiTree {
234 fn default() -> Self {
235 Self {
236 root: None,
237 root_id: None,
238 nodes: BTreeMap::new(),
239 next_id: 1,
240 last_report: ReconcileReport::default(),
241 }
242 }
243}
244
245impl RetainedUiTree {
246 #[must_use]
247 pub fn new() -> Self {
248 Self::default()
249 }
250
251 #[must_use]
252 pub fn root(&self) -> Option<&UiNode> {
253 self.root.as_ref()
254 }
255
256 #[must_use]
257 pub const fn root_id(&self) -> Option<NodeId> {
258 self.root_id
259 }
260
261 #[must_use]
262 pub fn node(&self, id: NodeId) -> Option<&RetainedNode> {
263 self.nodes.get(&id)
264 }
265
266 #[must_use]
267 pub fn component_node(&self, component: &crate::ComponentInstancePath) -> Option<NodeId> {
268 self.nodes
269 .values()
270 .find_map(|node| (node.component_root.as_ref() == Some(component)).then_some(node.id))
271 }
272
273 pub fn nodes(&self) -> impl ExactSizeIterator<Item = &RetainedNode> {
274 self.nodes.values()
275 }
276
277 #[must_use]
278 pub fn len(&self) -> usize {
279 self.nodes.len()
280 }
281
282 #[must_use]
283 pub fn is_empty(&self) -> bool {
284 self.nodes.is_empty()
285 }
286
287 #[must_use]
288 pub const fn last_report(&self) -> &ReconcileReport {
289 &self.last_report
290 }
291
292 pub fn reconcile(&mut self, candidate: UiNode) -> Result<ReconcileReport, ReconcileError> {
299 let mut transaction = ReconcileTransaction {
300 old_nodes: &self.nodes,
301 new_nodes: BTreeMap::new(),
302 next_id: self.next_id,
303 report: ReconcileReport::default(),
304 reused: BTreeSet::new(),
305 };
306 let root_id = match (self.root.as_ref(), self.root_id) {
307 (Some(old), Some(old_id)) if reusable(old, &candidate) => {
308 transaction.reconcile_node(Some(old), Some(old_id), &candidate, None, false)?
309 }
310 (Some(_), Some(old_id)) => {
311 transaction.collect_unmounted(old_id)?;
312 transaction.reconcile_node(None, None, &candidate, None, false)?
313 }
314 (None, None) => transaction.reconcile_node(None, None, &candidate, None, false)?,
315 _ => return Err(ReconcileError::RootStateMismatch),
316 };
317 for old_id in self.nodes.keys().copied() {
318 if !transaction.reused.contains(&old_id)
319 && !transaction.report.unmounted.contains(&old_id)
320 {
321 transaction.report.unmounted.push(old_id);
322 }
323 }
324 transaction.report.sort();
325
326 let ReconcileTransaction {
327 new_nodes,
328 next_id,
329 report,
330 ..
331 } = transaction;
332
333 self.root = Some(candidate);
334 self.root_id = Some(root_id);
335 self.nodes = new_nodes;
336 self.next_id = next_id;
337 self.last_report = report.clone();
338 Ok(report)
339 }
340}
341
342struct ReconcileTransaction<'a> {
343 old_nodes: &'a BTreeMap<NodeId, RetainedNode>,
344 new_nodes: BTreeMap<NodeId, RetainedNode>,
345 next_id: u64,
346 report: ReconcileReport,
347 reused: BTreeSet<NodeId>,
348}
349
350impl ReconcileTransaction<'_> {
351 fn reconcile_node(
352 &mut self,
353 old_snapshot: Option<&UiNode>,
354 old_id: Option<NodeId>,
355 candidate: &UiNode,
356 parent: Option<NodeId>,
357 moved: bool,
358 ) -> Result<NodeId, ReconcileError> {
359 validate_fragment(candidate)?;
360 let id = if let Some(id) = old_id {
361 self.reused.insert(id);
362 self.report.preserved.push(id);
363 if moved {
364 self.report.moved.push(id);
365 }
366 id
367 } else {
368 self.allocate()?
369 };
370 let old_node = old_id
371 .map(|id| {
372 self.old_nodes
373 .get(&id)
374 .ok_or(ReconcileError::MissingNode(id))
375 })
376 .transpose()?;
377 let old_groups = match (old_snapshot, old_node) {
378 (Some(snapshot), Some(node)) => group_old_children(snapshot, node)?,
379 (None, None) => BTreeMap::new(),
380 _ => return Err(ReconcileError::InconsistentSnapshot(id)),
381 };
382 let mut child_links = Vec::new();
383 let mut seen_groups = BTreeSet::new();
384 for (group, candidates) in candidate.retained_child_groups() {
385 if !seen_groups.insert(group.clone()) {
386 return Err(ReconcileError::InconsistentSnapshot(id));
387 }
388 validate_unique_keys(id, &group, &candidates)?;
389 let old = old_groups.get(group.as_str()).cloned().unwrap_or_default();
390 let mut used_old = BTreeSet::new();
391 let keyed = old
392 .iter()
393 .filter_map(|child| {
394 child
395 .snapshot
396 .key()
397 .map(|key| (key.as_str().to_owned(), child))
398 })
399 .collect::<BTreeMap<_, _>>();
400 for (index, child) in candidates.into_iter().enumerate() {
401 let selected = child.key().and_then(|key| {
402 keyed
403 .get(key.as_str())
404 .filter(|old| old.snapshot.kind_tag() == child.kind_tag())
405 .copied()
406 });
407 let selected = selected.or_else(|| {
408 child
409 .key()
410 .is_none()
411 .then(|| old.get(index))
412 .flatten()
413 .filter(|old| old.snapshot.key().is_none() && reusable(old.snapshot, child))
414 });
415 let (old_snapshot, old_child_id, was_moved) =
416 selected.map_or((None, None, false), |selected| {
417 used_old.insert(selected.node);
418 (
419 Some(selected.snapshot),
420 Some(selected.node),
421 selected.index != index,
422 )
423 });
424 let child_id =
425 self.reconcile_node(old_snapshot, old_child_id, child, Some(id), was_moved)?;
426 child_links.push(RetainedChildLink {
427 group: group.clone(),
428 node: child_id,
429 });
430 }
431 for old_child in old {
432 if !used_old.contains(&old_child.node) {
433 self.collect_unmounted(old_child.node)?;
434 }
435 }
436 }
437 for (group, old) in old_groups {
438 if !seen_groups.contains(&group) {
439 for old_child in old {
440 self.collect_unmounted(old_child.node)?;
441 }
442 }
443 }
444 self.insert_candidate(id, parent, candidate, child_links);
445 Ok(id)
446 }
447
448 fn insert_candidate(
449 &mut self,
450 id: NodeId,
451 parent: Option<NodeId>,
452 candidate: &UiNode,
453 children: Vec<RetainedChildLink>,
454 ) {
455 self.new_nodes.insert(
456 id,
457 RetainedNode {
458 id,
459 parent,
460 key: candidate.key().map(|key| key.as_str().to_owned()),
461 kind: candidate.kind_tag(),
462 primitive: match candidate.kind() {
463 crate::UiNodeKind::Custom { primitive } => Some(primitive.primitive.clone()),
464 _ => None,
465 },
466 component_root: candidate.component_root().cloned(),
467 element_ref: candidate.element_ref().cloned(),
468 attributes: candidate.attributes().clone(),
469 text: match candidate.kind() {
470 crate::UiNodeKind::Text { text } | crate::UiNodeKind::RichText { text, .. } => {
471 Some(text.to_string())
472 }
473 _ => None,
474 },
475 handlers: candidate.handlers().clone(),
476 handler_payloads: candidate.handler_payloads().clone(),
477 scrollable: snapshot_scrollable(candidate),
478 focus_styled: candidate.style().focus.is_some(),
479 canvas_commands: match candidate.kind() {
480 crate::UiNodeKind::Canvas { scene } => scene.complexity(),
481 _ => 0,
482 },
483 canvas_scene: match candidate.kind() {
484 crate::UiNodeKind::Canvas { scene } => Some(scene.clone()),
485 _ => None,
486 },
487 virtual_data_items: match candidate.kind() {
488 crate::UiNodeKind::VirtualCollection { spec } => spec.data.len(),
489 _ => 0,
490 },
491 virtual_realized_items: match candidate.kind() {
492 crate::UiNodeKind::VirtualCollection { spec } => spec.realized.len(),
493 _ => 0,
494 },
495 children,
496 },
497 );
498 }
499
500 fn allocate(&mut self) -> Result<NodeId, ReconcileError> {
501 let id = NodeId(self.next_id);
502 self.next_id = self
503 .next_id
504 .checked_add(1)
505 .ok_or(ReconcileError::NodeIdExhausted)?;
506 self.report.mounted.push(id);
507 Ok(id)
508 }
509
510 fn collect_unmounted(&mut self, id: NodeId) -> Result<(), ReconcileError> {
511 if self.report.unmounted.contains(&id) {
512 return Ok(());
513 }
514 let node = self
515 .old_nodes
516 .get(&id)
517 .ok_or(ReconcileError::MissingNode(id))?;
518 for child in &node.children {
519 self.collect_unmounted(child.node)?;
520 }
521 self.report.unmounted.push(id);
522 Ok(())
523 }
524}
525
526fn snapshot_scrollable(node: &UiNode) -> bool {
527 matches!(
528 node.style().base.overflow_x,
529 Some(crate::OverflowMode::Scroll)
530 ) || matches!(
531 node.style().base.overflow_y,
532 Some(crate::OverflowMode::Scroll)
533 )
534}
535
536fn validate_fragment(node: &UiNode) -> Result<(), ReconcileError> {
537 if matches!(node.kind(), crate::UiNodeKind::Fragment { .. })
538 && (node.style() != &crate::Style::new()
539 || node.part_styles().next().is_some()
540 || !node.attributes().is_empty()
541 || !node.handlers().is_empty()
542 || !node.motions().is_empty()
543 || !node.exit_motions().is_empty()
544 || !node.progress_motions().is_empty()
545 || !node.timelines().is_empty()
546 || node.signal_bindings().next().is_some()
547 || node.element_ref().is_some())
548 {
549 Err(ReconcileError::FragmentDecoration)
550 } else {
551 Ok(())
552 }
553}
554
555#[derive(Clone, Copy)]
556struct OldChild<'a> {
557 index: usize,
558 node: NodeId,
559 snapshot: &'a UiNode,
560}
561
562fn group_old_children<'a>(
563 snapshot: &'a UiNode,
564 node: &RetainedNode,
565) -> Result<BTreeMap<String, Vec<OldChild<'a>>>, ReconcileError> {
566 let mut links = node.children.iter();
567 let mut result = BTreeMap::new();
568 for (group, snapshots) in snapshot.retained_child_groups() {
569 let mut children = Vec::with_capacity(snapshots.len());
570 for (index, snapshot) in snapshots.into_iter().enumerate() {
571 let link = links
572 .next()
573 .ok_or(ReconcileError::InconsistentSnapshot(node.id))?;
574 if link.group != group {
575 return Err(ReconcileError::InconsistentSnapshot(node.id));
576 }
577 children.push(OldChild {
578 index,
579 node: link.node,
580 snapshot,
581 });
582 }
583 result.insert(group, children);
584 }
585 if links.next().is_some() {
586 return Err(ReconcileError::InconsistentSnapshot(node.id));
587 }
588 Ok(result)
589}
590
591fn validate_unique_keys(
592 parent: NodeId,
593 group: &str,
594 children: &[&UiNode],
595) -> Result<(), ReconcileError> {
596 let mut keys = BTreeSet::new();
597 if let Some(key) = children
598 .iter()
599 .filter_map(|node| node.key().map(NodeKey::as_str))
600 .find(|key| !keys.insert((*key).to_owned()))
601 {
602 return Err(ReconcileError::DuplicateKey {
603 parent,
604 group: group.to_owned(),
605 key: key.to_owned(),
606 });
607 }
608 Ok(())
609}
610
611fn reusable(old: &UiNode, new: &UiNode) -> bool {
612 old.kind_tag() == new.kind_tag()
613 && old.key().map(NodeKey::as_str) == new.key().map(NodeKey::as_str)
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 fn keyed_text(key: &str, text: &str) -> UiNode {
621 UiNode::text(text).with_key(key)
622 }
623
624 #[test]
625 fn keyed_reorder_preserves_ids_and_reports_moves() {
626 let mut tree = RetainedUiTree::new();
627 tree.reconcile(UiNode::column(vec![
628 keyed_text("a", "A"),
629 keyed_text("b", "B"),
630 ]))
631 .unwrap();
632 let root = tree.root_id().unwrap();
633 let before = tree
634 .node(root)
635 .unwrap()
636 .children()
637 .map(RetainedChildLink::node)
638 .collect::<Vec<_>>();
639
640 let report = tree
641 .reconcile(UiNode::column(vec![
642 keyed_text("b", "B2"),
643 keyed_text("a", "A2"),
644 ]))
645 .unwrap();
646 let after = tree
647 .node(root)
648 .unwrap()
649 .children()
650 .map(RetainedChildLink::node)
651 .collect::<Vec<_>>();
652
653 assert_eq!(after, vec![before[1], before[0]]);
654 assert!(report.mounted.is_empty());
655 assert!(report.unmounted.is_empty());
656 assert_eq!(report.moved, before);
657 }
658
659 #[test]
660 fn kind_change_replaces_only_the_changed_subtree() {
661 let mut tree = RetainedUiTree::new();
662 tree.reconcile(UiNode::column(vec![keyed_text("item", "A")]))
663 .unwrap();
664 let root = tree.root_id().unwrap();
665 let old_child = tree.node(root).unwrap().children().next().unwrap().node();
666
667 let report = tree
668 .reconcile(UiNode::column(vec![
669 UiNode::row(Vec::new()).with_key("item"),
670 ]))
671 .unwrap();
672 let new_child = tree.node(root).unwrap().children().next().unwrap().node();
673
674 assert_ne!(old_child, new_child);
675 assert_eq!(report.mounted, vec![new_child]);
676 assert_eq!(report.unmounted, vec![old_child]);
677 assert!(report.preserved.contains(&root));
678 }
679
680 #[test]
681 fn duplicate_key_rejects_candidate_without_consuming_ids() {
682 let mut tree = RetainedUiTree::new();
683 tree.reconcile(UiNode::column(vec![keyed_text("good", "A")]))
684 .unwrap();
685 let old_root = tree.root().unwrap().clone();
686 let old_ids = tree.nodes().map(RetainedNode::id).collect::<Vec<_>>();
687 let old_report = tree.last_report().clone();
688
689 let error = tree
690 .reconcile(UiNode::column(vec![
691 keyed_text("same", "A"),
692 keyed_text("same", "B"),
693 ]))
694 .unwrap_err();
695 assert!(matches!(error, ReconcileError::DuplicateKey { .. }));
696 assert_eq!(tree.root(), Some(&old_root));
697 assert_eq!(tree.last_report(), &old_report);
698 assert_eq!(
699 tree.nodes().map(RetainedNode::id).collect::<Vec<_>>(),
700 old_ids
701 );
702
703 let report = tree
704 .reconcile(UiNode::column(vec![keyed_text("next", "B")]))
705 .unwrap();
706 assert_eq!(report.mounted.len(), 1);
707 assert_eq!(report.mounted[0].get(), 3);
708 assert_eq!(tree.last_report().metrics(), report.metrics());
709 }
710
711 #[test]
712 fn named_child_groups_isolate_duplicate_slot_keys() {
713 let mut tree = RetainedUiTree::new();
714 let overlay = UiNode::overlay(
715 keyed_text("shared", "trigger"),
716 keyed_text("shared", "content"),
717 crate::OverlayNodeSpec {
718 id: crate::OverlayId::new("overlay"),
719 parent: None,
720 kind: crate::OverlayKind::Popover,
721 initial_focus: crate::OverlayInitialFocus::Panel,
722 placement: crate::OverlayPlacement::Bottom,
723 anchor: None,
724 open: true,
725 gap: 0.0,
726 modal: false,
727 dismiss: crate::OverlayDismissPolicy {
728 escape: true,
729 outside: true,
730 },
731 tooltip_delays: None,
732 activate_on_trigger: true,
733 width_policy: crate::OverlayWidthPolicy::Content,
734 },
735 );
736 tree.reconcile(overlay).unwrap();
737 assert_eq!(tree.len(), 3);
738 }
739
740 #[test]
741 fn fragment_rejects_layout_or_interaction_decoration() {
742 let mut tree = RetainedUiTree::new();
743 let decorated = UiNode::fragment(vec![UiNode::text("child")])
744 .with_style(&crate::Style::new().flex_row());
745 assert_eq!(
746 tree.reconcile(decorated).unwrap_err(),
747 ReconcileError::FragmentDecoration
748 );
749 assert!(tree.is_empty());
750 }
751}