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

commit507e894ad39a44e484c8916959b14d0a18b16261
parent6c3d74e4c8
authorLucas Galante <[email protected]>
date2026-07-13 11:26
refactor(painter)!: the paint walk takes shared borrows — slice 5, the retype program is complete

paint_tree/paint_root_into take &dyn WidgetHost (the walk only reads;
descent resolves children through the registry internally);
append_widget_text's lifetime-erasing transmute dies with the ptr param
it bridged to; render_widget's internal cast is gone; the demo paints
through five plain borrows instead of an unsafe self-alias loop. What
still carries *mut dyn WidgetHost is deliberate and documented: the
registry payload + registration args, walk-internal transients, and the
ctx-less Layout hook signatures.

Verified: 165 tests + 28-target workspace suite; TE/graph/DE/fonts live
captures render fully, canary-silent.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01N4ajhvVZtyEEEus9bodsj3

 docs/rfc-core-rebuild.md     | 27 ++++++++++++--
 src/backend/window_runner.rs |  2 +-
 src/layout.rs                |  3 +-
 src/main.rs                  | 17 ++++-----
 src/scene/painter.rs         | 86 ++++++++++++++++++++------------------------
 src/widget/model.rs          |  4 +--
 6 files changed, 74 insertions(+), 65 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index 81030b2..871beeb 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -1953,8 +1953,31 @@ Constraint respected: **each crate still builds standalone** — the new core is
        ways, Controls page unchanged, child mode alive; 28-target
        suite. Scrollbar thumb drag: user spot-check (held drags not
        headless-drivable).
-    5. window_runner render plumbing + remaining `as_ptr` sites; then
-       the `Element` + `Adapted` endgame (own design pass).
+    5. **window_runner render plumbing + remaining `as_ptr` sites —
+       DONE (2026-07-13, the last slice).** The slice-3 census was
+       right: window_runner held no pointer state (its one mention
+       was a doc comment). The real residue was the paint walk's
+       entry signatures — `paint_tree`/`paint_root_into` now take
+       `&dyn WidgetHost` (the walk only reads; descent resolves
+       children through the registry and derefs those transients
+       internally), and `append_widget_text`'s lifetime-erasing
+       transmute died with the ptr param it bridged to. Every caller
+       simplified: the unsafe self-alias blocks that existed ONLY to
+       mint `*mut` arguments (text-editor, graph, data-editor, the
+       demo, fonts' walk calls) are plain shared borrows now;
+       `render_widget`'s internal cast is gone. What still carries
+       `*mut dyn WidgetHost`, all deliberate and documented: the
+       WidgetTree registry payload + registration arguments (the
+       ownership bridge), machinery-internal walk transients, the
+       `Layout::arrange_children`/`container_children` hook
+       signatures (narrow-trait, ctx-less by design), and
+       EventCtx's transient host ptr. Verified: 28-target suite;
+       text-editor/graph/data-editor/fonts live captures render
+       fully, canary-silent. THE POINTER-RETYPE PROGRAM IS
+       COMPLETE — no further slices are recorded.
+       ~~then the `Element` + `Adapted` endgame (own design pass)~~
+       — landed long since as the 6bd flip (`Element` deleted,
+       `Adapted` survives as the one host wrapper).
     Stored-pointer state remaining after slices 1–3, all deliberate:
     the `WidgetTree` registry; TI's `ControlPanel.children`; and the
     tick-refreshed parent copies in MenuBar/StatusBar/Dropdown models
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 2d768b3..8a7fad3 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1562,7 +1562,7 @@ pub trait Application: Sized + 'static {
     /// and [`custom_vertices`](Application::custom_vertices) still go through their own paths;
     /// text renders from the list when [`display_list_text`](Application::display_list_text)
     /// opts in. Receives the frame's logical size and HiDPI scale. Typically implemented as
-    /// `Some(cce_ui::scene::painter::paint_tree(&self.ui_context, root_ptr))`.
+    /// `Some(cce_ui::scene::painter::paint_tree(&self.ui_context, &self.root))`.
     fn display_list(&mut self, _size: LogicalSize, _scale: f64) -> Option<crate::scene::paint::DisplayList> {
         None
     }
diff --git a/src/layout.rs b/src/layout.rs
index 3f368f8..6ab1acf 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -3203,9 +3203,8 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
     // drew the geometry via `all_quads`/`all_rounded_quads` above, so we take only the Text prims
     // from the walk. This drops the legacy `widget_font` + `text_labels_with_font_and_bounds`
     // getters from render_widget — the prim already carries the per-widget font+bounds.
-    let w_ptr = w as *mut T as *mut (dyn WidgetHost + 'static);
     let mut text_scratch = crate::scene::paint::PaintCtx::new();
-    crate::scene::painter::paint_root_into(&*ctx, w_ptr, &mut text_scratch);
+    crate::scene::painter::paint_root_into(&*ctx, &*w, &mut text_scratch);
     for item in text_scratch.finish().items {
         if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, font, bounds, .. } = item.prim {
             let color_f32 = [
diff --git a/src/main.rs b/src/main.rs
index fd695f9..f715a62 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -76,8 +76,8 @@ impl DemoApp {
         ]
     }
 
-    /// The widget roots as pointers, for the two genuinely pointer-consuming paths:
-    /// registration (the registry stores them) and the paint walk (it derefs them).
+    /// The widget roots as pointers, for the one genuinely pointer-consuming path left:
+    /// registration (the registry stores them). The paint walk takes shared borrows.
     fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 5] {
         [
             self.button.as_ptr_mut(),
@@ -312,14 +312,11 @@ impl Application for DemoApp {
         // Widgets: each root walked through the single paint pass. The walk recurses,
         // clips, and emits each widget's own geometry AND text (`Adapted::paint_self`
         // serves per-widget fonts and bounds).
-        {
-            let self_ptr = self as *mut Self;
-            unsafe {
-                for root in (*self_ptr).roots() {
-                    cce_ui::scene::painter::paint_root_into(&self.ui_context, root, &mut pc);
-                }
-            }
-        }
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.button, &mut pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.toggle, &mut pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.slider, &mut pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.name_box, &mut pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.theme_dropdown, &mut pc);
 
         // The dropdown popover — geometry and labels last, on top of everything, exactly
         // where it hit-tests. Labels carry bounds equal to the popover rect: that clips
diff --git a/src/scene/painter.rs b/src/scene/painter.rs
index d322694..cca9be9 100644
--- a/src/scene/painter.rs
+++ b/src/scene/painter.rs
@@ -16,14 +16,10 @@ use crate::scene::layout::Rect;
 use crate::scene::paint::{DisplayList, PaintCtx, Prim};
 use crate::widget::{WidgetHost, TextLabel, UiContext};
 
-type ElemPtr = *mut (dyn WidgetHost + 'static);
-
 /// Walk the widget subtree rooted at `root` and produce its ordered, clipped [`DisplayList`].
-///
-/// # Safety
-/// `root` and every widget reachable through `WidgetHost::children` must be live — the same
-/// invariant the rest of the toolkit relies on for its `*mut dyn WidgetHost` tree.
-pub fn paint_tree(ui: &UiContext, root: ElemPtr) -> DisplayList {
+/// The walk only reads through the widgets; descent resolves children through the registry
+/// (`ui.tree`), whose entries must be live — the toolkit-wide registration contract.
+pub fn paint_tree(ui: &UiContext, root: &dyn WidgetHost) -> DisplayList {
     let mut pc = PaintCtx::new();
     paint_root_into(ui, root, &mut pc);
     pc.finish()
@@ -31,10 +27,7 @@ pub fn paint_tree(ui: &UiContext, root: ElemPtr) -> DisplayList {
 
 /// Walk one root subtree into an existing [`PaintCtx`], for apps that compose several top-level
 /// widgets (and their own chrome) into a single display list rather than one `root_window` tree.
-///
-/// # Safety
-/// Same as [`paint_tree`]: `root` and its reachable subtree must be live widgets.
-pub fn paint_root_into(ui: &UiContext, root: ElemPtr, pc: &mut PaintCtx) {
+pub fn paint_root_into(ui: &UiContext, root: &dyn WidgetHost, pc: &mut PaintCtx) {
     paint_node(ui, root, pc);
 }
 
@@ -45,13 +38,8 @@ pub fn paint_root_into(ui: &UiContext, root: ElemPtr, pc: &mut PaintCtx) {
 /// `text_labels*` getters (the four hand-aggregate clients). The walk only reads through the
 /// widget, so a shared `&dyn WidgetHost` is enough.
 pub fn append_widget_text(ui: &UiContext, root: &dyn WidgetHost, pc: &mut PaintCtx) {
-    // SAFETY: the walk only reads through `root` (paint_self/children/visible are all `&self`),
-    // and widgets are concrete `'static` types — the invariant the toolkit's whole
-    // `*mut dyn WidgetHost` tree already relies on. Erase the borrowed trait-object lifetime bound
-    // to the `'static` `ElemPtr` the walk takes.
-    let ptr: ElemPtr = unsafe { std::mem::transmute::<*const dyn WidgetHost, ElemPtr>(root as *const dyn WidgetHost) };
     let mut scratch = PaintCtx::new();
-    paint_node(ui, ptr, &mut scratch);
+    paint_node(ui, root, &mut scratch);
     for item in scratch.finish().items {
         if let Prim::Text { text, x, y, font_size, color, font, bounds, .. } = item.prim {
             let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
@@ -137,37 +125,37 @@ pub fn fonted_leaf_labels(
     labels.into_iter().map(|l| (l, font.clone(), bounds)).collect()
 }
 
-fn paint_node(ui: &UiContext, ptr: ElemPtr, pc: &mut PaintCtx) {
-    unsafe {
-        if !(*ptr).visible() {
-            return;
-        }
+fn paint_node(ui: &UiContext, w: &dyn WidgetHost, pc: &mut PaintCtx) {
+    if !w.visible() {
+        return;
+    }
 
-        // Legacy subtree painters (e.g. TreeList) render their own geometry AND their children
-        // through their own recursive aggregates, exposed via a paint_self override (see
-        // TreeList::paint_self) — emit that and stop; the walk must not also descend.
-        if (*ptr).renders_own_subtree() {
-            (*ptr).paint_self(ui, pc);
-            return;
-        }
+    // Legacy subtree painters (e.g. TreeList) render their own geometry AND their children
+    // through their own recursive aggregates, exposed via a paint_self override (see
+    // TreeList::paint_self) — emit that and stop; the walk must not also descend.
+    if w.renders_own_subtree() {
+        w.paint_self(ui, pc);
+        return;
+    }
 
-        (*ptr).paint_self(ui, pc);
+    w.paint_self(ui, pc);
 
-        let children = ui.tree.children_ptrs((*ptr).base().id());
-        if children.is_empty() {
-            return;
-        }
-        if (*ptr).clips_children() {
-            let (x, y, w, h) = (*ptr).rect();
-            pc.clip(Rect { x, y, width: w, height: h }, |pc| {
-                for &child in &children {
-                    paint_node(ui, child, pc);
-                }
-            });
-        } else {
+    let children = ui.tree.children_ptrs(w.base().id());
+    if children.is_empty() {
+        return;
+    }
+    // SAFETY: registry-resolved transients — the entries are live by the toolkit-wide
+    // registration contract, and the walk only reads through them.
+    if w.clips_children() {
+        let (x, y, cw, ch) = w.rect();
+        pc.clip(Rect { x, y, width: cw, height: ch }, |pc| {
             for &child in &children {
-                paint_node(ui, child, pc);
+                paint_node(ui, unsafe { &*child }, pc);
             }
+        });
+    } else {
+        for &child in &children {
+            paint_node(ui, unsafe { &*child }, pc);
         }
     }
 }
@@ -208,6 +196,8 @@ mod tests {
         }
     }
 
+    type ElemPtr = *mut (dyn WidgetHost + 'static);
+
     fn reg(ctx: &mut UiContext, w: &mut P) -> (crate::widget::WidgetId, ElemPtr) {
         let ptr = &mut *w as *mut _ as *mut (dyn crate::widget::WidgetHost + 'static);
         let id = w.base.id();
@@ -241,7 +231,7 @@ mod tests {
         ctx.link_ids(root_id, a_id);
         ctx.link_ids(root_id, b_id);
 
-        let list = paint_tree(&ctx, root_ptr);
+        let list = paint_tree(&ctx, unsafe { &*root_ptr });
         assert_eq!(tags(&list), vec![1.0, 2.0, 3.0], "parent, then children left-to-right");
         assert!(list.items.iter().all(|it| it.clip.is_none()), "no clipping widget => no clips");
     }
@@ -265,7 +255,7 @@ mod tests {
         let (child_id, _) = reg(&mut ctx, &mut child);
         ctx.link_ids(root_id, child_id);
 
-        let list = paint_tree(&ctx, root_ptr);
+        let list = paint_tree(&ctx, unsafe { &*root_ptr });
         // Root paints itself unclipped; the child is clipped to the root's rect.
         assert_eq!(list.items[0].clip, None, "root's own quad is not self-clipped");
         assert_eq!(
@@ -292,7 +282,7 @@ mod tests {
         ctx.link_ids(root_id, sib_id);
         ctx.link_ids(mid_id, leaf_id);
 
-        let list = paint_tree(&ctx, root_ptr);
+        let list = paint_tree(&ctx, unsafe { &*root_ptr });
         assert_eq!(tags(&list), vec![1.0, 4.0], "mid (invisible) and its leaf are skipped");
     }
 
@@ -319,7 +309,7 @@ mod tests {
         ctx.link_ids(root_id, inner_id);
         ctx.link_ids(inner_id, leaf_id);
 
-        let list = paint_tree(&ctx, root_ptr);
+        let list = paint_tree(&ctx, unsafe { &*root_ptr });
         // inner's OWN quad is clipped by its parent (root) only — its own rect clips its children,
         // not itself. The leaf, a child of inner, is clipped to inner∩root = (50,50,50,50).
         assert_eq!(list.items[1].clip, Some(Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 }));
@@ -349,7 +339,7 @@ mod tests {
         let ptr = &mut w as *mut _ as *mut (dyn crate::widget::WidgetHost + 'static);
         ctx.register_widget(w.base.id(), ptr);
 
-        let list = paint_tree(&ctx, ptr);
+        let list = paint_tree(&ctx, unsafe { &*ptr });
         assert!(
             list.items.iter().any(|it| matches!(it.prim, Prim::RoundedRect { radius, .. } if radius == 4.0)),
             "default paint_self emits the rounded background",
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 4754451..f97c9b9 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1666,7 +1666,7 @@ mod tests {
         assert_eq!(rect_of(b_ptr), Rect { x: 0.0, y: 14.0, width: 10.0, height: 20.0 });
 
         // Paint: each Dot's `Paint::paint` default emits one quad at its laid-out rect, in colour.
-        let list = paint_tree(&ctx, root_ptr);
+        let list = paint_tree(&ctx, unsafe { &*root_ptr });
         let quads: Vec<_> = list
             .items
             .iter()
@@ -1817,7 +1817,7 @@ mod tests {
         ctx.register_widget(id, ptr);
         unsafe { (*ptr).set_rect(10.0, 20.0, 100.0, 30.0) };
 
-        let list = paint_tree(&ctx, ptr);
+        let list = paint_tree(&ctx, unsafe { &*ptr });
         let texts: Vec<_> = list
             .items
             .iter()