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