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

commitd1b8e50801166b77c846c353fd19661a9fb7f4c8
parent3a364e6413
authorLucas Galante <[email protected]>
date2026-07-09 10:37
feat(scene): paint walk carries per-widget fonts and clip rects (Phase 6d)

Adapted::paint_self no longer forwards Paint::paint's plain Text prims.
It re-emits the geometry verbatim (through the ctx, so the walk's
offset/clip apply exactly once) and serves text as text_with prims from
the same views the standard text bridges use: own_labels_with_font_and_
bounds (prim text + detached base label, widget_font, text_bounds or the
scroll-ancestor clip), or the 5s per-label hatch verbatim for hatched
widgets. A paint_tree display list's text is now renderable-correct for
migrated widgets — the precondition for the display_list() adopters
flipping display_list_text.

Recorded on the way: the legacy Element::paint_self default drains the
child-aggregating text_labels, so scene-path text double-emits for trees
still containing embedded-base containers — invisible while text prims
go unrendered, but it gates the flag flip on an embedded-base-free tree
(RFC 6d note).

Verified: 179 tests (new: paint-walk font/bounds attachment); cce-graph
live A/B zero structural diff (all residual under the 8% translucency
noise amplitude).

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

 docs/rfc-core-rebuild.md | 26 ++++++++++++++--
 src/widget/model.rs      | 80 +++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 97 insertions(+), 9 deletions(-)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index f30ab7a..21d4b44 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -776,9 +776,31 @@ Constraint respected: **each crate still builds standalone** — the new core is
     `FontSystem`, the `TextItem` cache, `rebuild_layout`, and the scale/rebuild bookkeeping.
     Non-interactive, so no event surface. This is the reference shape for a minimal Phase 6
     app.
-  - **Still to do (per-app, roughly smallest-first):** wallpaper/screenaver (likely trivial),
+  - **6c — `cce-wallpaper` + `cce-screenaver` across; `display_list` gains `(size, scale)`.
+    DONE (wallpaper live-A/B AE=0; screensaver background-fill verified live, sim quads are
+    the same mechanical loop).** The Phase 6 frame entry point now receives the frame's
+    logical size and HiDPI scale like `view` did (fullscreen apps size geometry from it);
+    mechanical sweep across the eight implementors. Both apps' dead `TextItem` caches
+    deleted.
+  - **6d — the paint walk carries per-widget fonts + clip rects. DONE (179 tests; cce-graph
+    live A/B shows zero structural diff — all residual below the 8% translucency-noise
+    amplitude).** `Adapted::paint_self` no longer forwards `Paint::paint`'s plain Text prims:
+    it re-emits the geometry verbatim (through the ctx so the walk's offset/clip apply
+    once) and serves text as `text_with` prims from the SAME views the standard text bridges
+    use — `own_labels_with_font_and_bounds` (prim text + detached base label, `widget_font`,
+    `text_bounds` or the scroll-ancestor clip) or the 5s per-label hatch verbatim (caveat
+    noted in-code: the hatch contract includes raw container children). This makes a
+    `paint_tree` display list's text renderable-correct for migrated widgets, which is the
+    precondition for the seven adopters flipping `display_list_text`. Found and recorded on
+    the way: the LEGACY `Element::paint_self` default drains the child-aggregating
+    `text_labels` for legacy containers, so scene-path text double-emits under the walk for
+    trees that still contain Layer/Page/etc. — invisible today (text prims unrendered
+    without the opt-in), but it means an app can only flip `display_list_text` once its
+    tree is embedded-base-free. Consistent with the dissolution plan; revisit per app.
+  - **Still to do (per-app, roughly smallest-first):**
     the seven display_list() adopters (flip `display_list_text` + drop their TextItem
-    assembly, one at a time, each A/B'd), then the widget-tree apps (routed events + scene
+    assembly, one at a time, each A/B'd — precondition: embedded-base-free tree, see 6d),
+    then the widget-tree apps (routed events + scene
     layout + dissolving the embedded-base containers), the demo (`cce-ui/src/main.rs`) as the
     reference `Application`, and popover-occlusion for display-list text before any app with
     popovers flips the flag. Delete the legacy `view*`/`text_items` paths, the per-widget
diff --git a/src/widget/model.rs b/src/widget/model.rs
index e12c966..c7ea4a4 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1198,8 +1198,26 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
     fn widget_font(&self) -> Option<String> {
         Paint::widget_font(&self.inner)
     }
+    /// Scene-path emission. Geometry comes from [`Paint::paint`]; its plain `Text` prims are
+    /// REPLACED by the same font+bounds view the standard text bridges serve
+    /// (`own_labels_with_font_and_bounds`, or the per-label hatch), so a display list built by
+    /// the paint walk carries per-widget fonts and clip rects (Phase 6 — text ordering
+    /// relative to geometry is immaterial: glyphs always render in the later text pass).
     fn paint_self(&self, ui: &UiContext, ctx: &mut PaintCtx) {
-        Paint::paint(&self.inner, self.content_rect(), ctx);
+        let mut tmp = PaintCtx::new();
+        Paint::paint(&self.inner, self.content_rect(), &mut tmp);
+        for item in tmp.finish().items {
+            match item.prim {
+                Prim::Text { .. } => {}
+                Prim::Quad { rect, color } => ctx.quad(rect, color),
+                Prim::RoundedRect { rect, radius, corners, color } => ctx.rounded_rect(rect, radius, corners, color),
+                Prim::Border { rect, radii, fill, border, thickness } => ctx.border(rect, radii, fill, border, thickness),
+                Prim::Bevel { rect, radii, color, depth } => ctx.bevel(rect, radii, color, depth),
+                Prim::Arc { cx, cy, radius, thickness, start, end, color } => ctx.arc(cx, cy, radius, thickness, start, end, color),
+                Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => ctx.vector(x1, y1, x2, y2, thickness, color, cap),
+                Prim::Circle { cx, cy, radius, color } => ctx.circle(cx, cy, radius, color),
+            }
+        }
         // The legacy default `paint_self` drained `all_quads`, which carries the focus
         // highlight — replicate for opt-in widgets, over the background (same draw order).
         // Forwarded highlights stay out: the paint walk reaches the owning child itself.
@@ -1210,12 +1228,17 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
                 }
             }
         }
-        // Inline-label widgets emit their own text in `paint`; detached labels come from the
-        // base, exactly as the legacy default `paint_self` emits them.
-        if !Layout::inline_label(&self.inner) {
-            for tl in self.base_label_fallback() {
-                ctx.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
-            }
+        // Own text with per-label font+bounds: the hatch view verbatim for hatched widgets
+        // (caveat: its contract includes raw container children — those few widgets keep the
+        // hatch until their hosts adopt the walk), else the standard own-labels bridge (prim
+        // text + the detached base label, one font, text_bounds or the scroll-ancestor clip).
+        let labels = if Paint::serves_legacy_labels(&self.inner) {
+            Paint::legacy_labels_with_font_and_bounds(&self.inner, self.content_rect(), ui)
+        } else {
+            self.own_labels_with_font_and_bounds(ui)
+        };
+        for (tl, font, bounds) in labels {
+            ctx.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, font, bounds);
         }
     }
 
@@ -1912,4 +1935,47 @@ mod tests {
         assert!(elem.as_menu_controller().is_none());
         assert!(elem.as_graph_controller().is_none());
     }
+    /// Phase 6: the paint walk's text prims carry the widget's font and clip rect (what the
+    /// display-list text path renders), not the bare `Paint::paint` text.
+    #[test]
+    fn paint_walk_text_carries_font_and_bounds() {
+        struct Tag;
+        impl Layout for Tag {}
+        impl Paint for Tag {
+            fn color(&self) -> [f32; 4] {
+                [0.0; 4]
+            }
+            fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+                ctx.text("hi", rect.x + 2.0, rect.y + 2.0, 12.0, [1, 2, 3]);
+            }
+            fn widget_font(&self) -> Option<String> {
+                Some("Mono:12".into())
+            }
+            fn text_bounds(&self, rect: Rect) -> Option<[f32; 4]> {
+                Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height])
+            }
+        }
+        impl Input for Tag {}
+
+        let mut ctx = UiContext::new();
+        let mut w = Box::new(Adapted::new(Tag));
+        let (id, ptr) = (w.id(), w.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        unsafe { (*ptr).set_rect(10.0, 20.0, 100.0, 30.0) };
+
+        let list = paint_tree(&ctx, ptr);
+        let texts: Vec<_> = list
+            .items
+            .iter()
+            .filter_map(|it| match &it.prim {
+                Prim::Text { text, font, bounds, .. } => Some((text.clone(), font.clone(), *bounds)),
+                _ => None,
+            })
+            .collect();
+        assert_eq!(texts.len(), 1, "one text prim, no plain duplicate");
+        assert_eq!(texts[0].0, "hi");
+        assert_eq!(texts[0].1.as_deref(), Some("Mono:12"), "widget_font attached");
+        assert_eq!(texts[0].2, Some([10.0, 20.0, 110.0, 50.0]), "text_bounds attached");
+    }
+
 }