GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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);
}
}