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

commit81b325b9665a59f635ae05c4d06d6bb77c8ea1f1
parentf06c1a931a
authorLucas Galante <[email protected]>
date2026-07-09 12:27
feat(scene): walk text for legacy containers and subtree painters (6d trap fix)

Two changes that make a paint_tree display list's text emit exactly once:

- The legacy Element::paint_self default emits text only for LEAVES. The
  legacy container text_labels overrides (Backplate/Layer/Page/SplitBox/
  Plate) AGGREGATE their children's labels, and the walk reaches those
  children itself, so emitting the aggregate double-drew every
  descendant's text — the recorded Phase 6d trap. A legacy container
  with OWN text overrides paint_self: Plate now does, serving its label
  (plain text, matching the legacy widget_font: None) over geometry
  identical to the default.

- The walk's renders_own_subtree branch (TreeList) now emits the
  subtree's text from the recursive bounded getter
  (text_labels_with_font_and_bounds) — the walk never descends into such
  subtrees, so the aggregate is that subtree's text, emitted once, with
  the same fonts/bounds the legacy tuple pipeline served.

Both are inert until a paint_tree app flips display_list_text.

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

 src/scene/painter.rs          |  7 ++++++-
 src/widget/container/plate.rs | 47 +++++++++++++++++++++++++++++++++++++++++++
 src/widget/mod.rs             | 11 ++++++++--
 3 files changed, 62 insertions(+), 3 deletions(-)

diff --git a/src/scene/painter.rs b/src/scene/painter.rs
index ba27589..dda9895 100644
--- a/src/scene/painter.rs
+++ b/src/scene/painter.rs
@@ -45,7 +45,9 @@ fn paint_node(ui: &UiContext, ptr: ElemPtr, pc: &mut PaintCtx) {
         }
 
         // Legacy subtree painters (e.g. TreeList) render their own geometry AND their children via
-        // a recursive all_rounded_quads/all_quads; emit those directly and stop descending.
+        // a recursive all_rounded_quads/all_quads; emit those directly and stop descending. Their
+        // text comes from the recursive bounded getter for the same reason — the walk never
+        // reaches the subtree's widgets, so the aggregate is the subtree's text, emitted once.
         if (*ptr).renders_own_subtree() {
             for (x, y, w, h, r, c, corners) in (*ptr).all_rounded_quads(ui) {
                 pc.rounded_rect(Rect { x, y, width: w, height: h }, r, corners, c);
@@ -59,6 +61,9 @@ fn paint_node(ui: &UiContext, ptr: ElemPtr, pc: &mut PaintCtx) {
             for (cx, cy, r, c) in (*ptr).extra_circles() {
                 pc.circle(cx, cy, r, c);
             }
+            for (tl, font, bounds) in (*ptr).text_labels_with_font_and_bounds(ui) {
+                pc.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, font, bounds);
+            }
             return;
         }
 
diff --git a/src/widget/container/plate.rs b/src/widget/container/plate.rs
index a505982..16c6cc1 100644
--- a/src/widget/container/plate.rs
+++ b/src/widget/container/plate.rs
@@ -143,6 +143,53 @@ impl Element for Plate {
         self.selected = selected;
     }
 
+    /// Walk emission: the legacy default now emits NO text for containers (Plate's
+    /// `text_labels` aggregates children, which the walk reaches itself), but a Plate's OWN
+    /// label lives here — geometry identical to the default, plus that one label (plain text,
+    /// matching the legacy `widget_font: None`).
+    fn paint_self(&self, ui: &UiContext, ctx: &mut crate::scene::paint::PaintCtx) {
+        use crate::scene::layout::Rect;
+        let (x, y, w, h) = self.rect();
+        let rect = Rect { x, y, width: w, height: h };
+        let color = Element::color(self);
+
+        if self.children(ui).is_empty() {
+            for (qx, qy, qw, qh, r, c, corners) in self.all_rounded_quads(ui) {
+                ctx.rounded_rect(Rect { x: qx, y: qy, width: qw, height: qh }, r, corners, c);
+            }
+        } else {
+            let cr = self.corner_radii();
+            let radii = (cr.top_left, cr.top_right, cr.bottom_right, cr.bottom_left);
+            if let Some(depth) = self.plate_bevel() {
+                ctx.bevel(rect, radii, color, depth);
+            } else if let Some((border_color, thickness)) = Element::solid_border(self) {
+                ctx.border(rect, radii, color, border_color, thickness);
+            } else if color[3].abs() > 0.001 {
+                let (r1, r2, r3, r4) = Element::rounded_corners(self);
+                if r1 || r2 || r3 || r4 {
+                    ctx.rounded_rect(rect, Element::corner_radius(self), (r1, r2, r3, r4), color);
+                }
+            }
+        }
+
+        for (qx, qy, qw, qh, c) in self.all_quads(ui) {
+            ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, c);
+        }
+        for (cx, cy, r, t, start, end, c) in self.extra_arcs() {
+            ctx.arc(cx, cy, r, t, start, end, c);
+        }
+        for (cx, cy, r, c) in self.extra_circles() {
+            ctx.circle(cx, cy, r, c);
+        }
+
+        if self.visible {
+            if let Some(ref label) = self.base.base.label {
+                let (_, font_size) = crate::layout::control_label_font_parsed();
+                ctx.text(label.clone(), self.base.base.x, self.base.base.y, font_size, colors::control_label_color_u8());
+            }
+        }
+    }
+
     fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) {
         for &child_ptr in &self.base.children {
             unsafe {
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index eddfa22..7876a23 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -519,8 +519,15 @@ pub trait Element {
         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);
+        // Text: leaves emit their own labels; containers emit NONE — the legacy container
+        // text_labels overrides (Backplate/Layer/Page/SplitBox/Plate) AGGREGATE their
+        // children's labels, and the walk reaches those children itself, so emitting the
+        // aggregate here would double-draw every descendant's text (the Phase 6d trap). A
+        // legacy container with OWN text overrides paint_self to add it (Plate's label).
+        if self.children(ui).is_empty() {
+            for tl in self.text_labels() {
+                ctx.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
+            }
         }
     }