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

commitf7f72b77b7a9e394ca02c1e26ba4c2366d9e70d1
parent6dd9bae19c
authorLucas Galante <[email protected]>
date2026-07-07 19:49
feat(scene): paint walk — one traversal of the widget tree to a DisplayList (Phase 3)

scene::painter::paint_tree walks the widget tree once, emitting each widget's own primitives (via a new Element::paint_self, default composing rounded bg + quads + circles + text non-recursively) into a single ordered DisplayList through PaintCtx. Recursion and clipping live in the walk, not smeared across each container's all_* methods: a container pushes its rect as a clip (Element::clips_children) before descending, so clips compose on the stack automatically instead of hand-derived intersections. Invisible subtrees are skipped. 5 headless tests (order, clip, nested-clip intersection, visibility, default paint_self); 125 total. Not wired into the backend yet (runtime-gated follow-up: faithful paint_self for border/bevel/arc/vector/svg widgets, container clips_children overrides, and routing render() through the DisplayList with GPU set_scissor_rect).

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

 src/scene/mod.rs     |   1 +
 src/scene/painter.rs | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/widget/mod.rs    |  30 +++++++
 3 files changed, 275 insertions(+)

diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 0bf674f..6bb6ca2 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -9,6 +9,7 @@ pub mod arena;
 pub mod bridge;
 pub mod layout;
 pub mod paint;
+pub mod painter;
 pub mod tree;
 
 pub use arena::{Arena, Node, NodeId};
diff --git a/src/scene/painter.rs b/src/scene/painter.rs
new file mode 100644
index 0000000..df7356a
--- /dev/null
+++ b/src/scene/painter.rs
@@ -0,0 +1,244 @@
+//! The paint walk — Phase 3 of the core rebuild.
+//!
+//! One traversal of the widget tree that emits every widget's own primitives into a single
+//! [`DisplayList`], in draw order, through a [`PaintCtx`]. Recursion and clipping live *here*
+//! (not smeared across each container's `all_*` methods): a widget contributes its own geometry
+//! via [`Element::paint_self`], then the walk descends into its children — pushing the widget's
+//! rect as a clip first when [`Element::clips_children`] is set, so the clip stack composes
+//! automatically instead of every container re-deriving intersections by hand.
+//!
+//! This replaces, once wired into the backend, the three uncoordinated render paths (top-level
+//! `view*`, recursive `all_rounded_quads`, immediate-mode `render_widget`). This module is the
+//! walk itself, unit-tested here; routing the backend's `render()` through its `DisplayList`
+//! (with GPU `set_scissor_rect` per clip) is the runtime-gated follow-up.
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::{DisplayList, PaintCtx};
+use crate::widget::{Element, UiContext};
+
+type ElemPtr = *mut (dyn Element + 'static);
+
+/// Walk the widget subtree rooted at `root` and produce its ordered, clipped [`DisplayList`].
+///
+/// # Safety
+/// `root` and every widget reachable through `Element::children` must be live — the same
+/// invariant the rest of the toolkit relies on for its `*mut dyn Element` tree.
+pub fn paint_tree(ui: &UiContext, root: ElemPtr) -> DisplayList {
+    let mut pc = PaintCtx::new();
+    paint_node(ui, root, &mut pc);
+    pc.finish()
+}
+
+fn paint_node(ui: &UiContext, ptr: ElemPtr, pc: &mut PaintCtx) {
+    unsafe {
+        if !(*ptr).visible() {
+            return;
+        }
+        (*ptr).paint_self(ui, pc);
+
+        let children = (*ptr).children(ui);
+        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 {
+            for &child in &children {
+                paint_node(ui, child, pc);
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::scene::paint::Prim;
+    use crate::widget::Widget;
+
+    /// A synthetic widget that paints a single quad tagged by `tag` (encoded in the red channel),
+    /// so tests can assert emission order and clipping precisely. Children come from the ctx tree.
+    struct P {
+        base: Widget,
+        tag: f32,
+        clips: bool,
+        vis: bool,
+    }
+    impl P {
+        fn new(tag: f32) -> Box<P> {
+            Box::new(P { base: Widget::new(), tag, clips: false, vis: true })
+        }
+    }
+    impl Element for P {
+        crate::impl_widget_base!(P);
+        fn color(&self) -> [f32; 4] {
+            [self.tag, 0.0, 0.0, 1.0]
+        }
+        fn visible(&self) -> bool {
+            self.vis
+        }
+        fn clips_children(&self) -> bool {
+            self.clips
+        }
+        fn paint_self(&self, _ui: &UiContext, ctx: &mut PaintCtx) {
+            let (x, y, w, h) = self.rect();
+            ctx.quad(Rect { x, y, width: w, height: h }, [self.tag, 0.0, 0.0, 1.0]);
+        }
+    }
+
+    fn reg(ctx: &mut UiContext, w: &mut P) -> (crate::widget::WidgetId, ElemPtr) {
+        let ptr = w.as_ptr_mut();
+        let id = w.base.id();
+        ctx.register_widget(id, ptr);
+        (id, ptr)
+    }
+
+    /// Tags of the emitted quads, in order.
+    fn tags(list: &DisplayList) -> Vec<f32> {
+        list.items
+            .iter()
+            .map(|it| match it.prim {
+                Prim::Quad { color, .. } => color[0],
+                _ => -1.0,
+            })
+            .collect()
+    }
+
+    #[test]
+    fn walks_parent_then_children_in_order() {
+        let mut ctx = UiContext::new();
+        let mut root = P::new(1.0);
+        let mut a = P::new(2.0);
+        let mut b = P::new(3.0);
+        root.base.w = 100.0;
+        root.base.h = 100.0;
+
+        let (root_id, root_ptr) = reg(&mut ctx, &mut root);
+        let (a_id, _) = reg(&mut ctx, &mut a);
+        let (b_id, _) = reg(&mut ctx, &mut b);
+        ctx.link_ids(root_id, a_id);
+        ctx.link_ids(root_id, b_id);
+
+        let list = paint_tree(&ctx, 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");
+    }
+
+    #[test]
+    fn clipping_container_clips_its_children() {
+        let mut ctx = UiContext::new();
+        let mut root = P::new(1.0);
+        root.clips = true;
+        root.base.x = 0.0;
+        root.base.y = 0.0;
+        root.base.w = 50.0;
+        root.base.h = 50.0;
+        let mut child = P::new(2.0);
+        child.base.x = 10.0;
+        child.base.y = 10.0;
+        child.base.w = 100.0;
+        child.base.h = 100.0;
+
+        let (root_id, root_ptr) = reg(&mut ctx, &mut root);
+        let (child_id, _) = reg(&mut ctx, &mut child);
+        ctx.link_ids(root_id, child_id);
+
+        let list = paint_tree(&ctx, 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!(
+            list.items[1].clip,
+            Some(Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 }),
+            "child clipped to the clipping container",
+        );
+    }
+
+    #[test]
+    fn invisible_subtree_is_skipped() {
+        let mut ctx = UiContext::new();
+        let mut root = P::new(1.0);
+        let mut mid = P::new(2.0);
+        mid.vis = false; // invisible: itself and its child must be skipped
+        let mut leaf = P::new(3.0);
+        let mut sibling = P::new(4.0);
+
+        let (root_id, root_ptr) = reg(&mut ctx, &mut root);
+        let (mid_id, _) = reg(&mut ctx, &mut mid);
+        let (leaf_id, _) = reg(&mut ctx, &mut leaf);
+        let (sib_id, _) = reg(&mut ctx, &mut sibling);
+        ctx.link_ids(root_id, mid_id);
+        ctx.link_ids(root_id, sib_id);
+        ctx.link_ids(mid_id, leaf_id);
+
+        let list = paint_tree(&ctx, root_ptr);
+        assert_eq!(tags(&list), vec![1.0, 4.0], "mid (invisible) and its leaf are skipped");
+    }
+
+    #[test]
+    fn nested_clipping_containers_intersect() {
+        let mut ctx = UiContext::new();
+        let mut root = P::new(1.0);
+        root.clips = true;
+        root.base.w = 100.0;
+        root.base.h = 100.0;
+        let mut inner = P::new(2.0);
+        inner.clips = true;
+        inner.base.x = 50.0;
+        inner.base.y = 50.0;
+        inner.base.w = 100.0;
+        inner.base.h = 100.0;
+        let mut leaf = P::new(3.0);
+        leaf.base.w = 200.0;
+        leaf.base.h = 200.0;
+
+        let (root_id, root_ptr) = reg(&mut ctx, &mut root);
+        let (inner_id, _) = reg(&mut ctx, &mut inner);
+        let (leaf_id, _) = reg(&mut ctx, &mut leaf);
+        ctx.link_ids(root_id, inner_id);
+        ctx.link_ids(inner_id, leaf_id);
+
+        let list = paint_tree(&ctx, 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 }));
+        assert_eq!(list.items[2].clip, Some(Rect { x: 50.0, y: 50.0, width: 50.0, height: 50.0 }));
+    }
+
+    #[test]
+    fn default_paint_self_emits_rounded_background() {
+        // A widget using the DEFAULT paint_self (rounded corners + opaque color) emits a rounded
+        // rect for its background.
+        struct Rounded {
+            base: Widget,
+        }
+        impl Element for Rounded {
+            crate::impl_widget_base!(Rounded);
+            fn color(&self) -> [f32; 4] {
+                [0.2, 0.4, 0.6, 1.0]
+            }
+            fn rounded_corners(&self) -> (bool, bool, bool, bool) {
+                (true, true, true, true)
+            }
+            fn corner_radius(&self) -> f32 {
+                4.0
+            }
+        }
+        let mut ctx = UiContext::new();
+        let mut w = Rounded { base: Widget::new() };
+        w.base.w = 20.0;
+        w.base.h = 10.0;
+        let ptr = w.as_ptr_mut();
+        ctx.register_widget(w.base.id(), ptr);
+
+        let list = paint_tree(&ctx, 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/mod.rs b/src/widget/mod.rs
index dcdee4b..812914f 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -473,6 +473,36 @@ pub trait Element {
         quads
     }
 
+    /// Emit this widget's OWN primitives (non-recursive) into the single paint pass (Phase 3).
+    /// The default composes the pieces the legacy recursive `all_*` emit for one node: rounded
+    /// background, plain/decoration quads, circles, and own text. Widgets with richer painting
+    /// (borders, bevels, arcs, vectors, SVGs) can override. Recursion into children and clipping
+    /// are handled by the paint walk (`scene::painter`), not here.
+    fn paint_self(&self, ui: &UiContext, ctx: &mut crate::scene::paint::PaintCtx) {
+        use crate::scene::layout::Rect;
+        let (r1, r2, r3, r4) = self.rounded_corners();
+        if r1 || r2 || r3 || r4 {
+            let (x, y, w, h) = self.rect();
+            let c = self.color();
+            if c[3].abs() > 0.001 {
+                ctx.rounded_rect(Rect { x, y, width: w, height: h }, self.corner_radius(), (r1, r2, r3, r4), c);
+            }
+        }
+        for (x, y, w, h, c) in self.all_quads(ui) {
+            ctx.quad(Rect { x, y, width: w, height: h }, c);
+        }
+        for (cx, cy, r, c) in self.extra_circles() {
+            ctx.circle(cx, cy, r, c);
+        }
+        for tl in self.text_labels() {
+            ctx.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
+        }
+    }
+
+    /// Whether the paint walk should clip this widget's children to its rect (scroll/backplate
+    /// containers). Default: no clipping.
+    fn clips_children(&self) -> bool { false }
+
     fn all_rounded_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
         if !self.visible() {
             return Vec::new();