git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit680c87667fd7a252f0c504a09ca862b8051d430e
parent2a38f0f630
authorLucas Galante <[email protected]>
date2026-07-07 17:32
feat(scene): generational node arena + arena-backed widget tree (Phase 1)

Introduce src/scene: a generational node Arena (stable NodeId with a NonZeroU32 generation, so a handle to a removed node reads back as None instead of dereferencing freed memory) and WidgetTree, which folds UiContext's two hand-synced stores (widget_registry + layout_tree) into one arena keyed by WidgetId->NodeId. The public WidgetId API is unchanged, so all app crates build without edits.

UiContext, the widget-layer Element defaults, and the direct layout_tree pokes in keybinds_control/multi_control/plate/parameters_bg now route through WidgetTree. Parent/child links are kept symmetric (the legacy maps were left asymmetric in set_parent and a couple of detach paths), making children() self-consistent for paint and event propagation. Also removes the dead tick_hover/get_hover_quad duplicate hover animator. 24 new scene tests; 93 total pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

 src/context.rs                        | 161 +++------
 src/lib.rs                            |   1 +
 src/scene/arena.rs                    | 603 ++++++++++++++++++++++++++++++++++
 src/scene/mod.rs                      |  12 +
 src/scene/tree.rs                     | 410 +++++++++++++++++++++++
 src/widget/container/parameters_bg.rs |   2 +-
 src/widget/container/plate.rs         |   2 +-
 src/widget/input/keybinds_control.rs  |   6 +-
 src/widget/input/multi_control.rs     |   6 +-
 src/widget/mod.rs                     |  33 +-
 10 files changed, 1082 insertions(+), 154 deletions(-)

diff --git a/src/context.rs b/src/context.rs
index bcd344e..4364787 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1,5 +1,5 @@
 use std::collections::HashMap;
-use crate::widget::{Element, WidgetId, LayoutTree, Key, NamedKey, MouseButton, ElementState, Event};
+use crate::widget::{Element, WidgetId, Key, NamedKey, MouseButton, ElementState, Event};
 use crate::widget::core::hover_animation::HoverState;
 use crate::widget::core::context_menu::ContextMenuState;
 
@@ -50,8 +50,10 @@ impl SpatialGrid {
 }
 
 pub struct UiContext {
-    pub layout_tree: LayoutTree,
-    pub widget_registry: HashMap<WidgetId, *mut (dyn Element + 'static)>,
+    /// The widget tree + registry, consolidated into one generational store (Phase 1b of the
+    /// core rebuild). Replaces the former `layout_tree` + `widget_registry` maps; see
+    /// `scene/tree.rs`.
+    pub tree: crate::scene::WidgetTree,
     pub focused_widget: Option<*mut (dyn Element + 'static)>,
     pub active_popovers: Vec<*const (dyn Element + 'static)>,
     pub hover_state: HoverState,
@@ -76,11 +78,7 @@ pub struct UiContext {
 impl UiContext {
     pub fn new() -> Self {
         Self {
-            layout_tree: LayoutTree {
-                parents: HashMap::new(),
-                children: HashMap::new(),
-            },
-            widget_registry: HashMap::new(),
+            tree: crate::scene::WidgetTree::new(),
             focused_widget: None,
             active_popovers: Vec::new(),
             hover_state: HoverState::new(),
@@ -104,11 +102,11 @@ impl UiContext {
     }
 
     pub fn get_widget(&self, id: WidgetId) -> Option<&(dyn Element + 'static)> {
-        self.widget_registry.get(&id).map(|&ptr| unsafe { &*ptr })
+        self.tree.get_ptr(id).map(|ptr| unsafe { &*ptr })
     }
 
     pub fn get_widget_mut(&mut self, id: WidgetId) -> Option<&mut (dyn Element + 'static)> {
-        self.widget_registry.get(&id).map(|&ptr| unsafe { &mut *ptr })
+        self.tree.get_ptr(id).map(|ptr| unsafe { &mut *ptr })
     }
 
     pub fn propagate_event(&mut self, event: &Event, root: *mut (dyn Element + 'static)) -> bool {
@@ -184,7 +182,7 @@ impl UiContext {
                     } else if *state == ElementState::Released {
                         if self.is_dragging {
                             if let Some(target_id) = self.drag_target {
-                                if let Some(target_ptr) = self.widget_registry.get(&target_id).copied() {
+                                if let Some(target_ptr) = self.tree.get_ptr(target_id) {
                                     (*target_ptr).handle_event(&Event::DragEnd, self);
                                     (*target_ptr).mark_dirty(self);
                                 }
@@ -202,7 +200,7 @@ impl UiContext {
                             if self.is_dragging {
                                 let dx = *x - sx;
                                 let dy = *y - sy;
-                                if let Some(target_ptr) = self.widget_registry.get(&target_id).copied() {
+                                if let Some(target_ptr) = self.tree.get_ptr(target_id) {
                                     let (cx, cy, _, _) = (*target_ptr).rect();
                                     let drag_evt = Event::DragUpdate { dx, dy, x: *x, y: *y, local_x: *x - cx, local_y: *y - cy };
                                     let adjusted = (*root).transform_event_for_child(target_ptr, drag_evt, self);
@@ -215,7 +213,7 @@ impl UiContext {
                                 if (dx * dx + dy * dy).sqrt() > 3.0 {
                                     self.is_dragging = true;
                                     self.active_grab = Some(target_id);
-                                    if let Some(target_ptr) = self.widget_registry.get(&target_id).copied() {
+                                    if let Some(target_ptr) = self.tree.get_ptr(target_id) {
                                         (*target_ptr).handle_event(&Event::DragStart { start_x: sx, start_y: sy }, self);
                                         (*target_ptr).mark_dirty(self);
                                     }
@@ -236,7 +234,7 @@ impl UiContext {
                 | Event::DragUpdate { .. }
                 | Event::DragEnd = event
                 {
-                    if let Some(grabbed_ptr) = self.widget_registry.get(&grabbed_id).copied() {
+                    if let Some(grabbed_ptr) = self.tree.get_ptr(grabbed_id) {
                         let handled = (*grabbed_ptr).handle_event(event, self);
                         if handled {
                             (*grabbed_ptr).mark_dirty(self);
@@ -348,7 +346,9 @@ impl UiContext {
 
     pub fn clear_dirty(&mut self) {
         self.any_dirty = false;
-        for &ptr in self.widget_registry.values() {
+        let ptrs: Vec<*mut (dyn Element + 'static)> =
+            self.tree.iter_registered().map(|(_, ptr)| ptr).collect();
+        for ptr in ptrs {
             unsafe {
                 if let Some(b) = (*ptr).base_mut() {
                     b.dirty = false;
@@ -360,12 +360,12 @@ impl UiContext {
 
     pub fn rebuild_spatial_grid(&mut self) {
         self.spatial_grid.clear();
-        for (&id, &ptr) in &self.widget_registry {
+        let entries: Vec<(WidgetId, *mut (dyn Element + 'static))> =
+            self.tree.iter_registered().collect();
+        for (id, ptr) in entries {
             unsafe {
-                if !ptr.is_null() {
-                    let rect = (*ptr).rect();
-                    self.spatial_grid.insert(id, rect);
-                }
+                let rect = (*ptr).rect();
+                self.spatial_grid.insert(id, rect);
             }
         }
     }
@@ -383,19 +383,19 @@ impl UiContext {
     pub fn is_widget_visible(&self, id: WidgetId) -> bool {
         let mut curr = id;
         loop {
-            if let Some(w_ptr) = self.widget_registry.get(&curr) {
+            if let Some(w_ptr) = self.tree.get_ptr(curr) {
                 unsafe {
-                    if !(*(*w_ptr)).visible() {
+                    if !(*w_ptr).visible() {
                         return false;
                     }
                 }
             } else {
                 return false;
             }
-            if let Some(&parent_id) = self.layout_tree.parents.get(&curr) {
-                if let Some(parent_ptr) = self.widget_registry.get(&parent_id) {
+            if let Some(parent_id) = self.tree.parent_id(curr) {
+                if let Some(parent_ptr) = self.tree.get_ptr(parent_id) {
                     unsafe {
-                        if !(*(*parent_ptr)).is_child_visible(curr) {
+                        if !(*parent_ptr).is_child_visible(curr) {
                             return false;
                         }
                     }
@@ -413,7 +413,7 @@ impl UiContext {
         let ids = self.tick_receivers.clone();
         for id in ids {
             if self.is_widget_visible(id) {
-                if let Some(ptr) = self.widget_registry.get(&id).copied() {
+                if let Some(ptr) = self.tree.get_ptr(id) {
                     unsafe {
                         if (*ptr).tick(dt, self) {
                             (*ptr).mark_dirty(self);
@@ -558,9 +558,9 @@ impl UiContext {
         false
     }
 
-    // --- Registry ---
+    // --- Registry (backed by the generational WidgetTree; see scene/tree.rs) ---
     pub fn register_widget(&mut self, id: WidgetId, ptr: *mut (dyn Element + 'static)) {
-        self.widget_registry.insert(id, ptr);
+        self.tree.register(id, ptr);
         unsafe {
             if !ptr.is_null() && (*ptr).wants_tick() {
                 self.register_tick_receiver(id);
@@ -569,32 +569,19 @@ impl UiContext {
     }
 
     pub fn link_ids(&mut self, parent: WidgetId, child: WidgetId) {
-        self.layout_tree.parents.insert(child, parent);
-        let children = self.layout_tree.children.entry(parent).or_default();
-        if !children.contains(&child) {
-            children.push(child);
-        }
+        self.tree.link(parent, child);
     }
 
     pub fn unlink_child(&mut self, parent: WidgetId, child: WidgetId) {
-        self.layout_tree.parents.remove(&child);
-        if let Some(children) = self.layout_tree.children.get_mut(&parent) {
-            children.retain(|&x| x != child);
-        }
+        self.tree.unlink(parent, child);
     }
 
     pub fn clear_children_ids(&mut self, parent: WidgetId) {
-        if let Some(children) = self.layout_tree.children.remove(&parent) {
-            for child in children {
-                self.layout_tree.parents.remove(&child);
-            }
-        }
+        self.tree.clear_children(parent);
     }
 
     pub fn clear_hierarchy(&mut self) {
-        self.layout_tree.parents.clear();
-        self.layout_tree.children.clear();
-        self.widget_registry.clear();
+        self.tree.clear_all();
     }
 
     // --- Popovers ---
@@ -632,7 +619,7 @@ impl UiContext {
                 }
             }
         }
-        for &ptr in self.widget_registry.values() {
+        for (_, ptr) in self.tree.iter_registered() {
             let current_data = ptr as *const () as usize;
             if query_address == current_data {
                 continue;
@@ -689,79 +676,11 @@ impl UiContext {
         }
     }
 
-    pub fn tick_hover(&mut self, dt: f32) -> bool {
-        let s = &mut self.hover_state;
-        let decay = 15.0;
-        let mut changed = false;
-
-        if s.current_alpha <= 0.001 && s.target_alpha > 0.0 {
-            if let (Some(tx), Some(ty), Some(tw), Some(th)) = (s.target_x, s.target_y, s.target_w, s.target_h) {
-                s.current_x = tx;
-                s.current_y = ty;
-                s.current_w = tw;
-                s.current_h = th;
-            }
-        }
-
-        if (s.current_alpha - s.target_alpha).abs() > 0.001 {
-            s.current_alpha += (s.target_alpha - s.current_alpha) * (1.0 - (-decay * dt).exp());
-            changed = true;
-        } else if s.current_alpha != s.target_alpha {
-            s.current_alpha = s.target_alpha;
-            changed = true;
-        }
-
-        if let (Some(tx), Some(ty), Some(tw), Some(th)) = (s.target_x, s.target_y, s.target_w, s.target_h) {
-            if (s.current_x - tx).abs() > 0.1 {
-                s.current_x += (tx - s.current_x) * (1.0 - (-decay * dt).exp());
-                changed = true;
-            } else if s.current_x != tx {
-                s.current_x = tx;
-                changed = true;
-            }
-
-            if (s.current_y - ty).abs() > 0.1 {
-                s.current_y += (ty - s.current_y) * (1.0 - (-decay * dt).exp());
-                changed = true;
-            } else if s.current_y != ty {
-                s.current_y = ty;
-                changed = true;
-            }
-
-            if (s.current_w - tw).abs() > 0.1 {
-                s.current_w += (tw - s.current_w) * (1.0 - (-decay * dt).exp());
-                changed = true;
-            } else if s.current_w != tw {
-                s.current_w = tw;
-                changed = true;
-            }
-
-            if (s.current_h - th).abs() > 0.1 {
-                s.current_h += (th - s.current_h) * (1.0 - (-decay * dt).exp());
-                changed = true;
-            } else if s.current_h != th {
-                s.current_h = th;
-                changed = true;
-            }
-        }
-
-        changed
-    }
-
-    pub fn get_hover_quad(&self) -> Option<(f32, f32, f32, f32, [f32; 4])> {
-        let s = &self.hover_state;
-        if s.current_alpha > 0.001 {
-            Some((
-                s.current_x,
-                s.current_y,
-                s.current_w,
-                s.current_h,
-                [1.0, 1.0, 1.0, s.current_alpha],
-            ))
-        } else {
-            None
-        }
-    }
+    // NOTE: the animated hover-highlight for this context previously lived here as
+    // `tick_hover` / `get_hover_quad`, duplicating the live thread-local implementation in
+    // widget/core.rs (`hover_animation`). Both were dead (zero callers workspace-wide) and were
+    // removed; the single source of truth is `hover_animation`. This will be folded into the
+    // Animated<T> primitive in the core rebuild (see cce-ui/docs/rfc-core-rebuild.md, Phase 4).
 
     // --- Context Menu ---
     pub fn is_context_menu_visible(&self) -> bool {
@@ -869,7 +788,7 @@ impl UiContext {
             candidate_ids.dedup();
         }
         for &id in &candidate_ids {
-            if let Some(&ptr) = self.widget_registry.get(&id) {
+            if let Some(ptr) = self.tree.get_ptr(id) {
                 unsafe {
                     if !ptr.is_null() {
                         let w = &*ptr;
@@ -903,7 +822,7 @@ impl UiContext {
             candidate_ids.dedup();
         }
         for &id in &candidate_ids {
-            if let Some(&ptr) = self.widget_registry.get(&id) {
+            if let Some(ptr) = self.tree.get_ptr(id) {
                 unsafe {
                     if !ptr.is_null() {
                         let w = &*ptr;
diff --git a/src/lib.rs b/src/lib.rs
index 3a3de1e..d900814 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,6 +8,7 @@ pub mod engine;
 pub mod scale;
 pub mod backend;
 pub mod context;
+pub mod scene;
 pub mod process;
 pub mod file_dialog;
 pub mod ipc;
diff --git a/src/scene/arena.rs b/src/scene/arena.rs
new file mode 100644
index 0000000..917da71
--- /dev/null
+++ b/src/scene/arena.rs
@@ -0,0 +1,603 @@
+//! Generational node arena — the ownership spine of the rebuilt cce-ui core.
+//!
+//! This is Phase 1 of the core rebuild (see `docs/rfc-core-rebuild.md`). It replaces the old
+//! model where the widget tree was smeared across three parallel stores kept in sync by hand
+//! (`Backplate.children: Vec<*mut dyn Element>`, `UiContext.layout_tree`, and
+//! `UiContext.widget_registry`) and traversed through raw `*mut dyn Element` pointers that
+//! `Drop` did not fully clear.
+//!
+//! Here there is exactly **one** store. Every node lives in the [`Arena`], addressed by a
+//! [`NodeId`] that carries a generation. When a node is removed its slot's generation is bumped,
+//! so any [`NodeId`] still pointing at the old occupant reads back as [`None`] instead of
+//! dereferencing freed memory. Use-after-free becomes a missed lookup, not undefined behavior —
+//! the single property that dissolves the dangling-pointer bug class.
+//!
+//! The arena is a **forest**: a freshly [`insert`](Arena::insert)ed node is a detached root
+//! (`parent == None`); [`append_child`](Arena::append_child) links nodes into trees. Children are
+//! stored as `Vec<NodeId>` (indices, not pointers), so traversal never aliases a `&mut`, which
+//! keeps the whole thing safe and borrow-checker-friendly without `unsafe`.
+//!
+//! [`Node<T>`] is generic over its payload for now. In later phases the payload grows into the
+//! rich per-node record from the RFC (widget + style + computed layout + animation + dirty
+//! flags); nothing about the identity/ownership model below changes when it does.
+
+use std::num::NonZeroU32;
+
+/// A stable handle to a node in an [`Arena`].
+///
+/// Carries both a slot index and a generation. The generation makes the handle *safe across
+/// removal*: once the node it referred to is removed (and its slot possibly reused for a
+/// different node), every lookup with this id returns [`None`]. `NodeId` is `Copy` and cheap to
+/// pass around; `Option<NodeId>` is the same size as `NodeId` thanks to the `NonZero` generation.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct NodeId {
+    index: u32,
+    generation: NonZeroU32,
+}
+
+impl NodeId {
+    /// Opaque index into the arena's backing storage. Exposed only for debugging/telemetry —
+    /// do not use it to bypass generational checks.
+    #[inline]
+    pub fn slot_index(self) -> u32 {
+        self.index
+    }
+}
+
+/// A node in the arena: a payload plus its tree links. Links are only mutated through the
+/// [`Arena`] so the parent/child relationship stays symmetric.
+#[derive(Debug, Clone)]
+pub struct Node<T> {
+    parent: Option<NodeId>,
+    children: Vec<NodeId>,
+    value: T,
+}
+
+impl<T> Node<T> {
+    /// This node's parent, or `None` if it is a detached root.
+    #[inline]
+    pub fn parent(&self) -> Option<NodeId> {
+        self.parent
+    }
+
+    /// This node's direct children, in order.
+    #[inline]
+    pub fn children(&self) -> &[NodeId] {
+        &self.children
+    }
+
+    /// Shared access to the payload.
+    #[inline]
+    pub fn value(&self) -> &T {
+        &self.value
+    }
+
+    /// Mutable access to the payload. Tree links are intentionally not reachable here — use the
+    /// [`Arena`] methods so both ends of every edge stay consistent.
+    #[inline]
+    pub fn value_mut(&mut self) -> &mut T {
+        &mut self.value
+    }
+}
+
+enum Slot<T> {
+    /// A live node. `generation` matches the [`NodeId`] handed out for it.
+    Occupied { generation: NonZeroU32, node: Node<T> },
+    /// A free slot. `generation` is the generation the *next* occupant will receive, and
+    /// `next_free` chains the free list.
+    Vacant { generation: NonZeroU32, next_free: Option<u32> },
+}
+
+impl<T> Slot<T> {
+    #[inline]
+    fn generation(&self) -> NonZeroU32 {
+        match self {
+            Slot::Occupied { generation, .. } | Slot::Vacant { generation, .. } => *generation,
+        }
+    }
+}
+
+#[inline]
+fn bump(generation: NonZeroU32) -> NonZeroU32 {
+    // Wrap while skipping 0 (which `NonZeroU32` cannot hold). A wrap only aliases a generation
+    // after 2^32-1 reuses of the same slot, which no real UI session approaches.
+    let next = generation.get().wrapping_add(1);
+    NonZeroU32::new(if next == 0 { 1 } else { next }).unwrap()
+}
+
+const FIRST_GENERATION: NonZeroU32 = match NonZeroU32::new(1) {
+    Some(g) => g,
+    None => unreachable!(),
+};
+
+/// A generational forest of [`Node<T>`]. See the module docs for the design.
+pub struct Arena<T> {
+    slots: Vec<Slot<T>>,
+    free_head: Option<u32>,
+    len: usize,
+}
+
+impl<T> Default for Arena<T> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<T> Arena<T> {
+    /// An empty arena.
+    pub fn new() -> Self {
+        Arena { slots: Vec::new(), free_head: None, len: 0 }
+    }
+
+    /// An empty arena with room for `capacity` nodes before reallocating.
+    pub fn with_capacity(capacity: usize) -> Self {
+        Arena { slots: Vec::with_capacity(capacity), free_head: None, len: 0 }
+    }
+
+    /// Number of live nodes.
+    #[inline]
+    pub fn len(&self) -> usize {
+        self.len
+    }
+
+    /// Whether there are no live nodes.
+    #[inline]
+    pub fn is_empty(&self) -> bool {
+        self.len == 0
+    }
+
+    /// Whether `id` still refers to a live node (generation matches).
+    #[inline]
+    pub fn contains(&self, id: NodeId) -> bool {
+        matches!(self.slots.get(id.index as usize),
+            Some(Slot::Occupied { generation, .. }) if *generation == id.generation)
+    }
+
+    /// Insert a detached node (a new root) and return its id.
+    pub fn insert(&mut self, value: T) -> NodeId {
+        self.len += 1;
+        match self.free_head {
+            Some(index) => {
+                let slot = &mut self.slots[index as usize];
+                let generation = slot.generation();
+                let next_free = match slot {
+                    Slot::Vacant { next_free, .. } => *next_free,
+                    Slot::Occupied { .. } => unreachable!("free list pointed at an occupied slot"),
+                };
+                self.free_head = next_free;
+                *slot = Slot::Occupied {
+                    generation,
+                    node: Node { parent: None, children: Vec::new(), value },
+                };
+                NodeId { index, generation }
+            }
+            None => {
+                let index = self.slots.len() as u32;
+                let generation = FIRST_GENERATION;
+                self.slots.push(Slot::Occupied {
+                    generation,
+                    node: Node { parent: None, children: Vec::new(), value },
+                });
+                NodeId { index, generation }
+            }
+        }
+    }
+
+    /// Shared access to a node, or `None` if `id` is stale/out of range.
+    #[inline]
+    pub fn get(&self, id: NodeId) -> Option<&Node<T>> {
+        match self.slots.get(id.index as usize) {
+            Some(Slot::Occupied { generation, node }) if *generation == id.generation => Some(node),
+            _ => None,
+        }
+    }
+
+    /// Mutable access to a node, or `None` if `id` is stale/out of range.
+    #[inline]
+    pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Node<T>> {
+        match self.slots.get_mut(id.index as usize) {
+            Some(Slot::Occupied { generation, node }) if *generation == id.generation => Some(node),
+            _ => None,
+        }
+    }
+
+    /// Convenience: shared access to a node's payload.
+    #[inline]
+    pub fn value(&self, id: NodeId) -> Option<&T> {
+        self.get(id).map(Node::value)
+    }
+
+    /// Convenience: mutable access to a node's payload.
+    #[inline]
+    pub fn value_mut(&mut self, id: NodeId) -> Option<&mut T> {
+        self.get_mut(id).map(Node::value_mut)
+    }
+
+    /// Mutable access to two distinct nodes at once. Returns `None` if the ids are equal or
+    /// either is stale. Needed by passes that move data between two nodes (e.g. reparenting or
+    /// parent→child layout) without cloning.
+    pub fn get_pair_mut(&mut self, a: NodeId, b: NodeId) -> Option<(&mut Node<T>, &mut Node<T>)> {
+        if a.index == b.index {
+            return None;
+        }
+        let (lo, hi, swapped) = if a.index < b.index { (a, b, false) } else { (b, a, true) };
+        let (left, right) = self.slots.split_at_mut(hi.index as usize);
+        let lo_node = match left.get_mut(lo.index as usize) {
+            Some(Slot::Occupied { generation, node }) if *generation == lo.generation => node,
+            _ => return None,
+        };
+        let hi_node = match right.get_mut(0) {
+            Some(Slot::Occupied { generation, node }) if *generation == hi.generation => node,
+            _ => return None,
+        };
+        Some(if swapped { (hi_node, lo_node) } else { (lo_node, hi_node) })
+    }
+
+    /// This node's parent (or `None` for a root or a stale id).
+    #[inline]
+    pub fn parent(&self, id: NodeId) -> Option<NodeId> {
+        self.get(id).and_then(Node::parent)
+    }
+
+    /// This node's direct children (empty for a leaf or a stale id).
+    #[inline]
+    pub fn children(&self, id: NodeId) -> &[NodeId] {
+        match self.get(id) {
+            Some(node) => node.children(),
+            None => &[],
+        }
+    }
+
+    /// Make `child` the last child of `parent`, detaching it from any previous parent first.
+    ///
+    /// Panics if either id is stale, if `parent == child`, or if the link would create a cycle
+    /// (`parent` is `child` or one of its descendants). These are programmer errors — reads of a
+    /// stale id are still safe via [`get`](Arena::get); it is *mutating* through one that trips.
+    pub fn append_child(&mut self, parent: NodeId, child: NodeId) {
+        assert!(self.contains(parent), "append_child: parent is not a live node");
+        assert!(self.contains(child), "append_child: child is not a live node");
+        assert!(parent != child, "append_child: cannot make a node its own child");
+        assert!(
+            !self.is_ancestor(child, parent),
+            "append_child: would create a cycle (parent is a descendant of child)"
+        );
+
+        self.detach(child);
+        self.get_mut(child).unwrap().parent = Some(parent);
+        self.get_mut(parent).unwrap().children.push(child);
+    }
+
+    /// Unlink `id` from its parent, leaving it (and its subtree) in the arena as a detached root.
+    /// No-op if `id` is stale or already a root.
+    pub fn detach(&mut self, id: NodeId) {
+        let Some(parent) = self.parent(id) else { return };
+        if let Some(parent_node) = self.get_mut(parent) {
+            parent_node.children.retain(|&c| c != id);
+        }
+        if let Some(node) = self.get_mut(id) {
+            node.parent = None;
+        }
+    }
+
+    /// Remove `id` and its entire subtree from the arena, freeing every slot. Detaches `id` from
+    /// its parent first. Every [`NodeId`] into the removed subtree is invalidated (later lookups
+    /// return `None`). Returns the number of nodes removed; no-op returning 0 for a stale id.
+    pub fn remove_subtree(&mut self, id: NodeId) -> usize {
+        if !self.contains(id) {
+            return 0;
+        }
+        self.detach(id);
+
+        // Collect the subtree (pre-order) before mutating, so we don't invalidate mid-walk.
+        let mut to_free = Vec::new();
+        let mut stack = vec![id];
+        while let Some(current) = stack.pop() {
+            to_free.push(current);
+            // Children are pushed as-is; order within the free set does not matter.
+            stack.extend_from_slice(self.children(current));
+        }
+
+        for node_id in &to_free {
+            let index = node_id.index as usize;
+            let old_generation = self.slots[index].generation();
+            self.slots[index] = Slot::Vacant {
+                generation: bump(old_generation),
+                next_free: self.free_head,
+            };
+            self.free_head = Some(node_id.index);
+        }
+        self.len -= to_free.len();
+        to_free.len()
+    }
+
+    /// Whether `maybe_ancestor` is `id` itself or one of its ancestors.
+    pub fn is_ancestor(&self, maybe_ancestor: NodeId, id: NodeId) -> bool {
+        let mut current = Some(id);
+        while let Some(node) = current {
+            if node == maybe_ancestor {
+                return true;
+            }
+            current = self.parent(node);
+        }
+        false
+    }
+
+    /// Iterator over `id`'s ancestors, nearest first, excluding `id` itself. Empty for a stale id.
+    pub fn ancestors(&self, id: NodeId) -> Ancestors<'_, T> {
+        Ancestors { arena: self, next: self.parent(id) }
+    }
+
+    /// Iterator over the subtree rooted at `id` in pre-order (`id` first, then each child's
+    /// subtree). This is the walk order for layout and paint passes. Empty for a stale id.
+    pub fn subtree(&self, id: NodeId) -> Subtree<'_, T> {
+        let stack = if self.contains(id) { vec![id] } else { Vec::new() };
+        Subtree { arena: self, stack }
+    }
+
+    /// Remove every node.
+    pub fn clear(&mut self) {
+        self.slots.clear();
+        self.free_head = None;
+        self.len = 0;
+    }
+}
+
+/// Iterator returned by [`Arena::ancestors`].
+pub struct Ancestors<'a, T> {
+    arena: &'a Arena<T>,
+    next: Option<NodeId>,
+}
+
+impl<'a, T> Iterator for Ancestors<'a, T> {
+    type Item = NodeId;
+    fn next(&mut self) -> Option<NodeId> {
+        let current = self.next?;
+        self.next = self.arena.parent(current);
+        Some(current)
+    }
+}
+
+/// Iterator returned by [`Arena::subtree`] (pre-order DFS).
+pub struct Subtree<'a, T> {
+    arena: &'a Arena<T>,
+    stack: Vec<NodeId>,
+}
+
+impl<'a, T> Iterator for Subtree<'a, T> {
+    type Item = NodeId;
+    fn next(&mut self) -> Option<NodeId> {
+        let current = self.stack.pop()?;
+        // Push children in reverse so they are visited left-to-right.
+        let children = self.arena.children(current);
+        for &child in children.iter().rev() {
+            self.stack.push(child);
+        }
+        Some(current)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn insert_and_get() {
+        let mut arena: Arena<&str> = Arena::new();
+        let a = arena.insert("a");
+        assert_eq!(arena.len(), 1);
+        assert!(!arena.is_empty());
+        assert_eq!(arena.value(a), Some(&"a"));
+        assert_eq!(arena.parent(a), None); // detached root
+        assert!(arena.children(a).is_empty());
+    }
+
+    #[test]
+    fn stale_id_reads_as_none_after_removal() {
+        // The core safety property: a handle to a removed node never dereferences freed data.
+        let mut arena: Arena<i32> = Arena::new();
+        let a = arena.insert(10);
+        assert!(arena.contains(a));
+        arena.remove_subtree(a);
+        assert!(!arena.contains(a));
+        assert!(arena.get(a).is_none());
+        assert_eq!(arena.value(a), None);
+        assert_eq!(arena.len(), 0);
+    }
+
+    #[test]
+    fn slot_reuse_bumps_generation_and_invalidates_old_handle() {
+        let mut arena: Arena<i32> = Arena::new();
+        let first = arena.insert(1);
+        arena.remove_subtree(first);
+        let second = arena.insert(2); // reuses the freed slot
+
+        assert_eq!(second.slot_index(), first.slot_index(), "slot should be reused");
+        assert_ne!(second, first, "generation must differ so the old handle is distinct");
+        assert_eq!(arena.value(second), Some(&2));
+        assert!(arena.get(first).is_none(), "stale handle to the reused slot is still None");
+    }
+
+    #[test]
+    fn append_child_links_both_ends() {
+        let mut arena: Arena<&str> = Arena::new();
+        let parent = arena.insert("p");
+        let child = arena.insert("c");
+        arena.append_child(parent, child);
+
+        assert_eq!(arena.parent(child), Some(parent));
+        assert_eq!(arena.children(parent), &[child]);
+    }
+
+    #[test]
+    fn reparenting_removes_from_old_parent() {
+        let mut arena: Arena<&str> = Arena::new();
+        let a = arena.insert("a");
+        let b = arena.insert("b");
+        let child = arena.insert("c");
+
+        arena.append_child(a, child);
+        assert_eq!(arena.children(a), &[child]);
+
+        arena.append_child(b, child);
+        assert!(arena.children(a).is_empty(), "old parent must drop the child");
+        assert_eq!(arena.children(b), &[child]);
+        assert_eq!(arena.parent(child), Some(b));
+    }
+
+    #[test]
+    fn detach_keeps_node_but_unlinks_parent() {
+        let mut arena: Arena<&str> = Arena::new();
+        let parent = arena.insert("p");
+        let child = arena.insert("c");
+        arena.append_child(parent, child);
+
+        arena.detach(child);
+        assert_eq!(arena.parent(child), None);
+        assert!(arena.children(parent).is_empty());
+        assert!(arena.contains(child), "detach must not free the node");
+    }
+
+    #[test]
+    fn remove_subtree_frees_all_descendants() {
+        let mut arena: Arena<i32> = Arena::new();
+        let root = arena.insert(0);
+        let a = arena.insert(1);
+        let b = arena.insert(2);
+        let a1 = arena.insert(11);
+        arena.append_child(root, a);
+        arena.append_child(root, b);
+        arena.append_child(a, a1);
+
+        let freed = arena.remove_subtree(a);
+        assert_eq!(freed, 2, "a and a1");
+        assert!(!arena.contains(a));
+        assert!(!arena.contains(a1));
+        assert!(arena.contains(root));
+        assert!(arena.contains(b));
+        assert_eq!(arena.children(root), &[b], "a must be gone from root's children");
+    }
+
+    #[test]
+    fn subtree_iterates_preorder_left_to_right() {
+        let mut arena: Arena<&str> = Arena::new();
+        let root = arena.insert("root");
+        let a = arena.insert("a");
+        let b = arena.insert("b");
+        let a1 = arena.insert("a1");
+        let a2 = arena.insert("a2");
+        arena.append_child(root, a);
+        arena.append_child(root, b);
+        arena.append_child(a, a1);
+        arena.append_child(a, a2);
+
+        let order: Vec<&str> = arena.subtree(root).map(|id| *arena.value(id).unwrap()).collect();
+        assert_eq!(order, vec!["root", "a", "a1", "a2", "b"]);
+    }
+
+    #[test]
+    fn ancestors_walk_nearest_first() {
+        let mut arena: Arena<&str> = Arena::new();
+        let root = arena.insert("root");
+        let a = arena.insert("a");
+        let a1 = arena.insert("a1");
+        arena.append_child(root, a);
+        arena.append_child(a, a1);
+
+        let anc: Vec<NodeId> = arena.ancestors(a1).collect();
+        assert_eq!(anc, vec![a, root]);
+        assert!(arena.ancestors(root).next().is_none(), "root has no ancestors");
+    }
+
+    #[test]
+    fn is_ancestor_reports_self_and_chain() {
+        let mut arena: Arena<i32> = Arena::new();
+        let root = arena.insert(0);
+        let a = arena.insert(1);
+        arena.append_child(root, a);
+
+        assert!(arena.is_ancestor(root, a));
+        assert!(arena.is_ancestor(a, a), "a node is its own ancestor for cycle-check purposes");
+        assert!(!arena.is_ancestor(a, root));
+    }
+
+    #[test]
+    #[should_panic(expected = "cycle")]
+    fn append_child_rejects_cycles() {
+        let mut arena: Arena<i32> = Arena::new();
+        let root = arena.insert(0);
+        let a = arena.insert(1);
+        arena.append_child(root, a);
+        // Trying to make root a child of a (its descendant) would form a cycle.
+        arena.append_child(a, root);
+    }
+
+    #[test]
+    fn get_pair_mut_yields_distinct_nodes_in_argument_order() {
+        let mut arena: Arena<i32> = Arena::new();
+        let a = arena.insert(1);
+        let b = arena.insert(2);
+
+        let (na, nb) = arena.get_pair_mut(a, b).unwrap();
+        *na.value_mut() += 100;
+        *nb.value_mut() += 200;
+        assert_eq!(arena.value(a), Some(&101));
+        assert_eq!(arena.value(b), Some(&202));
+
+        // Order preserved when the higher-index id is passed first.
+        let (nb2, na2) = arena.get_pair_mut(b, a).unwrap();
+        assert_eq!(*nb2.value(), 202);
+        assert_eq!(*na2.value(), 101);
+
+        assert!(arena.get_pair_mut(a, a).is_none(), "same id must be rejected");
+    }
+
+    #[test]
+    fn clear_empties_the_arena() {
+        let mut arena: Arena<i32> = Arena::new();
+        let a = arena.insert(1);
+        arena.insert(2);
+        arena.clear();
+        assert!(arena.is_empty());
+        assert!(arena.get(a).is_none());
+    }
+
+    // Validation against a real trait object: the arena must be able to *own* and tree actual
+    // `dyn Element` widgets (the payload type Phase 3 will use), not just Copy scalars.
+    #[test]
+    fn holds_and_trees_real_dyn_element_payloads() {
+        use crate::widget::Element;
+
+        // A minimal real `Element` — `color` is the trait's only required method, everything
+        // else is defaulted, so this exercises the actual trait object without dragging in a
+        // heavyweight widget constructor.
+        struct Marker {
+            tint: [f32; 4],
+            painted: std::cell::Cell<bool>,
+        }
+        impl Element for Marker {
+            fn color(&self) -> [f32; 4] {
+                self.painted.set(true);
+                self.tint
+            }
+        }
+
+        let mut arena: Arena<Box<dyn Element>> = Arena::new();
+        let root = arena.insert(Box::new(Marker { tint: [1.0, 0.0, 0.0, 1.0], painted: false.into() }));
+        let child = arena.insert(Box::new(Marker { tint: [0.0, 1.0, 0.0, 1.0], painted: false.into() }));
+        arena.append_child(root, child);
+
+        // Walk the subtree the way a paint pass will, calling a real trait method on each node.
+        let tints: Vec<[f32; 4]> = arena
+            .subtree(root)
+            .map(|id| arena.value(id).unwrap().color())
+            .collect();
+        assert_eq!(tints, vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]]);
+
+        // The trait object is genuinely stored (its interior mutation is observable).
+        assert!(arena.value(root).unwrap().base().is_none()); // default impl still reachable
+
+        // Removing the root frees the child too, proving ownership lives in the arena.
+        arena.remove_subtree(root);
+        assert!(arena.is_empty());
+    }
+}
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
new file mode 100644
index 0000000..67b7ae2
--- /dev/null
+++ b/src/scene/mod.rs
@@ -0,0 +1,12 @@
+//! The rebuilt cce-ui core spine (see `docs/rfc-core-rebuild.md`).
+//!
+//! This module is being grown additively alongside the existing widget system; nothing here is
+//! wired into the live render path yet. Phase 1 lands the ownership foundation — a generational
+//! node [`Arena`]. Later phases add the layout pass, the paint/display-list, and animation on
+//! top of the same node identity.
+
+pub mod arena;
+pub mod tree;
+
+pub use arena::{Arena, Node, NodeId};
+pub use tree::WidgetTree;
diff --git a/src/scene/tree.rs b/src/scene/tree.rs
new file mode 100644
index 0000000..c0098ad
--- /dev/null
+++ b/src/scene/tree.rs
@@ -0,0 +1,410 @@
+//! `WidgetTree` — the arena-backed replacement for `UiContext`'s two tree stores.
+//!
+//! Today `UiContext` keeps the widget tree in two parallel `HashMap`s that must be maintained in
+//! lockstep by hand:
+//!   * `widget_registry: HashMap<WidgetId, *mut dyn Element>` — id → live pointer, and
+//!   * `layout_tree: { parents: HashMap<WidgetId, WidgetId>, children: HashMap<WidgetId, Vec<WidgetId>> }`.
+//!
+//! This type folds both into a single generational [`Arena`], keyed through a `WidgetId → NodeId`
+//! index so the *public* `WidgetId`-based API (`register_widget`, `link_ids`, `clear_hierarchy`,
+//! …) can be preserved unchanged for the app crates. Consolidating the stores removes the
+//! hand-sync burden, and the generational [`NodeId`] means a removed widget's handle reads back as
+//! `None` instead of dereferencing freed memory.
+//!
+//! ## One deliberate semantic change vs. the legacy maps
+//!
+//! The legacy maps are sometimes left **asymmetric**: `Element::set_parent(Some(p))` writes
+//! `parents[child] = p` but does *not* add `child` to `children[p]`; `plate`/`parameters_bg`
+//! detach by doing `parents.remove(child)` while leaving `child` in `children[p]`. The arena keeps
+//! parent and child links **symmetric** by construction, so here `set_parent`/`detach` update both
+//! ends. This is the single behavior difference to watch when swapping `WidgetTree` into
+//! `UiContext` — it makes the tree self-consistent, but it must be verified against the running
+//! apps (paint recursion and event propagation both read `children`). See
+//! `docs/rfc-core-rebuild.md` Phase 1b.
+//!
+//! Nothing here is wired into `UiContext` yet; this is the tested drop-in the swap will use.
+
+use std::collections::HashMap;
+
+use crate::scene::arena::{Arena, NodeId};
+use crate::widget::{Element, WidgetId};
+
+/// One arena node's payload: the widget's stable id plus its live pointer. The pointer is `None`
+/// for a node that has been *linked* into the tree (as a parent/child) but not yet *registered*
+/// with a real widget — mirroring the legacy maps, where a `layout_tree` link can precede the
+/// `widget_registry` entry. (`*mut dyn Element` is a fat pointer, so `Option` is the natural
+/// "absent" representation — there is no thin null to use as a sentinel.)
+#[derive(Clone, Copy)]
+struct Entry {
+    id: WidgetId,
+    ptr: Option<*mut (dyn Element + 'static)>,
+}
+
+/// Resolve an entry's pointer to a usable, non-null pointer (skipping link-only and null-data
+/// pointers exactly as the legacy `filter_map` over the registry did).
+#[inline]
+fn live_ptr(entry: &Entry) -> Option<*mut (dyn Element + 'static)> {
+    match entry.ptr {
+        Some(p) if !p.is_null() => Some(p),
+        _ => None,
+    }
+}
+
+/// The consolidated, generational widget tree. See the module docs.
+pub struct WidgetTree {
+    arena: Arena<Entry>,
+    by_id: HashMap<WidgetId, NodeId>,
+}
+
+impl Default for WidgetTree {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl WidgetTree {
+    pub fn new() -> Self {
+        WidgetTree { arena: Arena::new(), by_id: HashMap::new() }
+    }
+
+    /// Number of nodes known to the tree (registered or link-only).
+    pub fn len(&self) -> usize {
+        self.arena.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.arena.is_empty()
+    }
+
+    /// Get (or lazily create) the arena node for `id`. A freshly created node has a `null`
+    /// pointer until [`register`](WidgetTree::register) supplies one. Re-creates the node if a
+    /// stale `by_id` entry points at a removed slot.
+    fn ensure_node(&mut self, id: WidgetId) -> NodeId {
+        if let Some(&node) = self.by_id.get(&id) {
+            if self.arena.contains(node) {
+                return node;
+            }
+        }
+        let node = self.arena.insert(Entry { id, ptr: None });
+        self.by_id.insert(id, node);
+        node
+    }
+
+    /// Register (or overwrite) the live pointer for `id`. Mirrors `register_widget`'s
+    /// insert-overwrite semantics. Registering a `null` pointer is allowed (the node exists but
+    /// resolves to `None`), matching the legacy behavior where a link can precede registration.
+    pub fn register(&mut self, id: WidgetId, ptr: *mut (dyn Element + 'static)) {
+        let node = self.ensure_node(id);
+        // `ensure_node` guarantees the node exists.
+        self.arena.value_mut(node).unwrap().ptr = Some(ptr);
+    }
+
+    /// Make `child` a child of `parent` (deduped, reparenting from any previous parent). Mirrors
+    /// `link_ids`, but keeps both ends of the edge consistent. No-op (rather than panic) if the
+    /// link would form a cycle, which the legacy maps never guarded against but also never hit.
+    pub fn link(&mut self, parent: WidgetId, child: WidgetId) {
+        let parent_node = self.ensure_node(parent);
+        let child_node = self.ensure_node(child);
+        if parent_node == child_node || self.arena.is_ancestor(child_node, parent_node) {
+            return;
+        }
+        self.arena.append_child(parent_node, child_node);
+    }
+
+    /// Set or clear `child`'s parent. `Some(p)` links symmetrically (as [`link`](WidgetTree::link));
+    /// `None` detaches `child` from its current parent. Replaces the legacy asymmetric
+    /// `Element::set_parent`.
+    pub fn set_parent(&mut self, child: WidgetId, parent: Option<WidgetId>) {
+        match parent {
+            Some(p) => self.link(p, child),
+            None => {
+                if let Some(&node) = self.by_id.get(&child) {
+                    self.arena.detach(node);
+                }
+            }
+        }
+    }
+
+    /// Remove `child` from `parent` if it is currently a child of it. Mirrors `unlink_child`.
+    pub fn unlink(&mut self, parent: WidgetId, child: WidgetId) {
+        if let (Some(&child_node), Some(&parent_node)) =
+            (self.by_id.get(&child), self.by_id.get(&parent))
+        {
+            if self.arena.parent(child_node) == Some(parent_node) {
+                self.arena.detach(child_node);
+            }
+        }
+    }
+
+    /// Detach all of `parent`'s children, leaving them as (still-registered) roots. Mirrors
+    /// `clear_children_ids`: non-recursive, and it does *not* unregister the child pointers.
+    pub fn clear_children(&mut self, parent: WidgetId) {
+        if let Some(&parent_node) = self.by_id.get(&parent) {
+            let children: Vec<NodeId> = self.arena.children(parent_node).to_vec();
+            for child in children {
+                self.arena.detach(child);
+            }
+        }
+    }
+
+    /// Drop the entire tree. Mirrors `clear_hierarchy`'s reset of both maps.
+    pub fn clear_all(&mut self) {
+        self.arena.clear();
+        self.by_id.clear();
+    }
+
+    /// Remove `id` and its whole subtree, freeing arena slots and dropping their `by_id` entries.
+    /// Not used by the legacy-compatible swap (the old maps never removed individual nodes), but
+    /// available for the migrated code that will actually reclaim removed widgets.
+    pub fn remove(&mut self, id: WidgetId) {
+        let Some(&node) = self.by_id.get(&id) else { return };
+        let removed_ids: Vec<WidgetId> =
+            self.arena.subtree(node).filter_map(|n| self.arena.value(n).map(|e| e.id)).collect();
+        self.arena.remove_subtree(node);
+        for removed in removed_ids {
+            self.by_id.remove(&removed);
+        }
+    }
+
+    /// Whether `id` currently resolves to a live, non-null widget pointer.
+    pub fn is_registered(&self, id: WidgetId) -> bool {
+        self.get_ptr(id).is_some()
+    }
+
+    /// The live pointer for `id`, or `None` if unknown, link-only (null), or stale.
+    pub fn get_ptr(&self, id: WidgetId) -> Option<*mut (dyn Element + 'static)> {
+        let node = *self.by_id.get(&id)?;
+        live_ptr(self.arena.value(node)?)
+    }
+
+    /// `id`'s parent id, if any.
+    pub fn parent_id(&self, id: WidgetId) -> Option<WidgetId> {
+        let node = *self.by_id.get(&id)?;
+        let parent = self.arena.parent(node)?;
+        Some(self.arena.value(parent)?.id)
+    }
+
+    /// `id`'s parent pointer, if the parent is registered (non-null).
+    pub fn parent_ptr(&self, id: WidgetId) -> Option<*mut (dyn Element + 'static)> {
+        self.parent_id(id).and_then(|p| self.get_ptr(p))
+    }
+
+    /// `id`'s child ids in order (including link-only children not yet registered).
+    pub fn child_ids(&self, id: WidgetId) -> Vec<WidgetId> {
+        let Some(&node) = self.by_id.get(&id) else { return Vec::new() };
+        self.arena.children(node).iter().filter_map(|&c| self.arena.value(c).map(|e| e.id)).collect()
+    }
+
+    /// `id`'s child pointers in order, skipping any child that is link-only (null pointer) —
+    /// exactly matching the legacy `Element::children` `filter_map` over the registry.
+    pub fn children_ptrs(&self, id: WidgetId) -> Vec<*mut (dyn Element + 'static)> {
+        let Some(&node) = self.by_id.get(&id) else { return Vec::new() };
+        self.arena
+            .children(node)
+            .iter()
+            .filter_map(|&c| live_ptr(self.arena.value(c)?))
+            .collect()
+    }
+
+    /// Iterate every registered `(id, ptr)` with a non-null pointer, for the passes that sweep the
+    /// whole registry (`clear_dirty`, `rebuild_spatial_grid`, coverage tests).
+    pub fn iter_registered(&self) -> impl Iterator<Item = (WidgetId, *mut (dyn Element + 'static))> + '_ {
+        self.by_id.values().filter_map(move |&node| {
+            let entry = self.arena.value(node)?;
+            live_ptr(entry).map(|p| (entry.id, p))
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // A minimal real `Element` so tests exercise genuine `*mut dyn Element` payloads. The boxes
+    // are kept alive in a local `Vec` for the duration of each test; we hand the tree raw
+    // pointers into them, mirroring how widgets (owned by the app) are referenced by the tree.
+    struct Marker(#[allow(dead_code)] u32);
+    impl Element for Marker {
+        fn color(&self) -> [f32; 4] {
+            [0.0, 0.0, 0.0, 0.0]
+        }
+    }
+
+    /// Owns marker widgets and hands out stable raw pointers + ids for them.
+    struct Widgets {
+        boxes: Vec<Box<Marker>>,
+    }
+    impl Widgets {
+        fn new() -> Self {
+            Widgets { boxes: Vec::new() }
+        }
+        /// Create a widget, returning `(WidgetId, *mut dyn Element)`.
+        fn make(&mut self, tag: u32) -> (WidgetId, *mut (dyn Element + 'static)) {
+            let mut b = Box::new(Marker(tag));
+            let ptr: *mut (dyn Element + 'static) = &mut *b;
+            self.boxes.push(b);
+            (WidgetId(tag as usize), ptr)
+        }
+    }
+
+    #[test]
+    fn register_and_resolve() {
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (id, ptr) = w.make(1);
+        assert_eq!(tree.get_ptr(id), None, "unknown id resolves to None");
+        tree.register(id, ptr);
+        assert_eq!(tree.get_ptr(id), Some(ptr));
+        assert!(tree.is_registered(id));
+    }
+
+    #[test]
+    fn register_overwrites_pointer() {
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let id = WidgetId(1);
+        let (_, p1) = w.make(1);
+        let (_, p2) = w.make(2);
+        tree.register(id, p1);
+        tree.register(id, p2); // same id, new pointer
+        assert_eq!(tree.get_ptr(id), Some(p2));
+        assert_eq!(tree.len(), 1, "overwrite must not create a second node");
+    }
+
+    #[test]
+    fn link_is_symmetric_and_deduped() {
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (p, pp) = w.make(1);
+        let (c, cp) = w.make(2);
+        tree.register(p, pp);
+        tree.register(c, cp);
+
+        tree.link(p, c);
+        tree.link(p, c); // duplicate link is a no-op
+        assert_eq!(tree.parent_id(c), Some(p));
+        assert_eq!(tree.child_ids(p), vec![c]);
+        assert_eq!(tree.children_ptrs(p), vec![cp]);
+    }
+
+    #[test]
+    fn reparenting_removes_from_old_parent() {
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (a, ap) = w.make(1);
+        let (b, bp) = w.make(2);
+        let (c, cp) = w.make(3);
+        tree.register(a, ap);
+        tree.register(b, bp);
+        tree.register(c, cp);
+
+        tree.link(a, c);
+        assert_eq!(tree.child_ids(a), vec![c]);
+        tree.link(b, c);
+        assert!(tree.child_ids(a).is_empty(), "old parent drops the child");
+        assert_eq!(tree.child_ids(b), vec![c]);
+        assert_eq!(tree.parent_id(c), Some(b));
+    }
+
+    #[test]
+    fn link_before_register_uses_null_placeholder() {
+        // Mirrors the legacy case where a `layout_tree` link precedes the `widget_registry` entry:
+        // the child appears in `child_ids` but is skipped by `children_ptrs` until registered.
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (p, pp) = w.make(1);
+        tree.register(p, pp);
+        let child = WidgetId(2);
+
+        tree.link(p, child); // child not registered yet
+        assert_eq!(tree.child_ids(p), vec![child]);
+        assert!(tree.children_ptrs(p).is_empty(), "link-only child has no pointer yet");
+
+        let (_, cp) = w.make(2);
+        tree.register(child, cp);
+        assert_eq!(tree.children_ptrs(p), vec![cp], "now resolvable");
+    }
+
+    #[test]
+    fn set_parent_none_detaches_symmetrically() {
+        // The deliberate divergence from legacy: detaching clears BOTH ends, so the parent's
+        // children no longer list the child.
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (p, pp) = w.make(1);
+        let (c, cp) = w.make(2);
+        tree.register(p, pp);
+        tree.register(c, cp);
+        tree.link(p, c);
+
+        tree.set_parent(c, None);
+        assert_eq!(tree.parent_id(c), None);
+        assert!(tree.child_ids(p).is_empty(), "symmetric detach clears parent's child list too");
+        assert!(tree.is_registered(c), "detach keeps the widget registered");
+    }
+
+    #[test]
+    fn clear_children_detaches_but_keeps_registration() {
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (p, pp) = w.make(1);
+        let (c1, c1p) = w.make(2);
+        let (c2, c2p) = w.make(3);
+        tree.register(p, pp);
+        tree.register(c1, c1p);
+        tree.register(c2, c2p);
+        tree.link(p, c1);
+        tree.link(p, c2);
+
+        tree.clear_children(p);
+        assert!(tree.child_ids(p).is_empty());
+        assert_eq!(tree.parent_id(c1), None);
+        assert!(tree.is_registered(c1) && tree.is_registered(c2), "children stay registered");
+    }
+
+    #[test]
+    fn clear_all_empties_everything() {
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (p, pp) = w.make(1);
+        let (c, cp) = w.make(2);
+        tree.register(p, pp);
+        tree.register(c, cp);
+        tree.link(p, c);
+
+        tree.clear_all();
+        assert!(tree.is_empty());
+        assert_eq!(tree.get_ptr(p), None);
+        assert_eq!(tree.parent_id(c), None);
+    }
+
+    #[test]
+    fn remove_makes_stale_ids_resolve_to_none() {
+        // The safety win over the legacy registry, which never removed entries (leaving dangling
+        // pointers): after removal, the id resolves to None instead of a freed pointer.
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (p, pp) = w.make(1);
+        let (c, cp) = w.make(2);
+        tree.register(p, pp);
+        tree.register(c, cp);
+        tree.link(p, c);
+
+        tree.remove(p); // removes p and its subtree (c)
+        assert_eq!(tree.get_ptr(p), None);
+        assert_eq!(tree.get_ptr(c), None, "descendant removed too");
+        assert!(tree.is_empty());
+    }
+
+    #[test]
+    fn iter_registered_yields_only_non_null() {
+        let mut w = Widgets::new();
+        let mut tree = WidgetTree::new();
+        let (p, pp) = w.make(1);
+        tree.register(p, pp);
+        tree.link(p, WidgetId(99)); // link-only, null pointer
+
+        let seen: Vec<WidgetId> = tree.iter_registered().map(|(id, _)| id).collect();
+        assert_eq!(seen, vec![p], "link-only (null) node is not yielded");
+    }
+}
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 2a3f83c..6b72469 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -598,7 +598,7 @@ impl Element for ParametersBg {
                 ctx.link_ids(p_id, id);
             }
         } else {
-            ctx.layout_tree.parents.remove(&id);
+            ctx.tree.set_parent(id, None);
         }
     }
 
diff --git a/src/widget/container/plate.rs b/src/widget/container/plate.rs
index b6f8166..3ed02aa 100644
--- a/src/widget/container/plate.rs
+++ b/src/widget/container/plate.rs
@@ -338,7 +338,7 @@ impl Element for Plate {
                 ctx.link_ids(p_id, id);
             }
         } else {
-            ctx.layout_tree.parents.remove(&id);
+            ctx.tree.set_parent(id, None);
         }
     }
 
diff --git a/src/widget/input/keybinds_control.rs b/src/widget/input/keybinds_control.rs
index 266d121..7977406 100644
--- a/src/widget/input/keybinds_control.rs
+++ b/src/widget/input/keybinds_control.rs
@@ -161,11 +161,7 @@ fn link_child(parent_ptr: *mut (dyn Element + 'static), parent_id: WidgetId, chi
         let c_id = c_base.id();
         ctx.register_widget(parent_id, parent_ptr);
         ctx.register_widget(c_id, c_ptr);
-        ctx.layout_tree.parents.insert(c_id, parent_id);
-        let children = ctx.layout_tree.children.entry(parent_id).or_default();
-        if !children.contains(&c_id) {
-            children.push(c_id);
-        }
+        ctx.link_ids(parent_id, c_id);
     }
     child.set_parent(Some(parent_ptr), ctx);
 }
diff --git a/src/widget/input/multi_control.rs b/src/widget/input/multi_control.rs
index 0d0708c..8e29f2e 100644
--- a/src/widget/input/multi_control.rs
+++ b/src/widget/input/multi_control.rs
@@ -323,11 +323,7 @@ fn link_child(parent_ptr: *mut (dyn Element + 'static), parent_id: WidgetId, chi
         let c_id = c_base.id();
         ctx.register_widget(parent_id, parent_ptr);
         ctx.register_widget(c_id, c_ptr);
-        ctx.layout_tree.parents.insert(c_id, parent_id);
-        let children = ctx.layout_tree.children.entry(parent_id).or_default();
-        if !children.contains(&c_id) {
-            children.push(c_id);
-        }
+        ctx.link_ids(parent_id, c_id);
     }
     child.set_parent(Some(parent_ptr), ctx);
 }
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index dca6467..ec8da16 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -188,11 +188,9 @@ pub trait Element {
             parent_id = b.id.get();
         }
         if let Some(id) = parent_id {
-            if let Some(&p_id) = ctx.layout_tree.parents.get(&id) {
-                if let Some(&parent_ptr) = ctx.widget_registry.get(&p_id) {
-                    unsafe {
-                        (*parent_ptr).mark_dirty(ctx);
-                    }
+            if let Some(parent_ptr) = ctx.tree.parent_ptr(id) {
+                unsafe {
+                    (*parent_ptr).mark_dirty(ctx);
                 }
             }
         }
@@ -623,9 +621,7 @@ pub trait Element {
 
     fn parent(&self, ctx: &UiContext) -> Option<*mut (dyn Element + 'static)> {
         let base = self.base()?;
-        let id = base.id();
-        let parent_id = ctx.layout_tree.parents.get(&id).copied()?;
-        ctx.widget_registry.get(&parent_id).copied()
+        ctx.tree.parent_ptr(base.id())
     }
 
     fn set_parent(&mut self, parent: Option<*mut (dyn Element + 'static)>, ctx: &mut UiContext) {
@@ -637,21 +633,20 @@ pub trait Element {
                     ctx.register_widget(p_id, p_ptr);
                     let self_ptr = self.as_ptr();
                     ctx.register_widget(id, self_ptr);
-                    ctx.layout_tree.parents.insert(id, p_id);
+                    // Symmetric link (Phase 1b): unlike the legacy `parents.insert` this also
+                    // records the child under the parent, keeping `children()` consistent.
+                    ctx.tree.set_parent(id, Some(p_id));
                 }
             } else {
-                ctx.layout_tree.parents.remove(&id);
+                ctx.tree.set_parent(id, None);
             }
         }
     }
 
     fn children(&self, ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> {
-        if let Some(base) = self.base() {
-            let id = base.id();
-            let child_ids = ctx.layout_tree.children.get(&id).cloned().unwrap_or_default();
-            child_ids.iter().filter_map(|cid| ctx.widget_registry.get(cid).copied()).collect()
-        } else {
-            vec![]
+        match self.base() {
+            Some(base) => ctx.tree.children_ptrs(base.id()),
+            None => vec![],
         }
     }
 
@@ -662,11 +657,7 @@ pub trait Element {
             let self_ptr = self.as_ptr();
             ctx.register_widget(p_id, self_ptr);
             ctx.register_widget(c_id, child);
-            ctx.layout_tree.parents.insert(c_id, p_id);
-            let children = ctx.layout_tree.children.entry(p_id).or_default();
-            if !children.contains(&c_id) {
-                children.push(c_id);
-            }
+            ctx.tree.link(p_id, c_id);
         }
     }