Skip to main content

gpui_rhai/
retained.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use thiserror::Error;
5
6use crate::{NodeKey, UiNode, UiNodeKindTag};
7
8/// Stable runtime identity for one accepted declarative node.
9///
10/// IDs are monotonic within one [`RetainedUiTree`] and are never reused, so a
11/// stale reference cannot silently bind to a later node.
12#[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/// One accepted declarative snapshot plus its stable runtime identity graph.
220///
221/// Reconciliation builds a complete candidate map and only replaces live state
222/// after validation succeeds. The snapshot owns node payloads once; retained
223/// entries contain identity/edge metadata rather than duplicated subtrees.
224#[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    /// Reconcile and atomically accept one complete candidate snapshot.
293    ///
294    /// # Errors
295    ///
296    /// Returns a structural error without changing the current snapshot, IDs,
297    /// or allocation cursor.
298    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}