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

commit3af8eb518bdd09863cb25a61e7fc578a695da6b9
parent498c7403a3
authorLucas Galante <[email protected]>
date2026-07-08 08:04
feat(widget): narrow Layout/Paint traits + Adapted bridge (Phase 5a)

Begin the Element god-trait split via the RFC §5 adapter path: Layout
and Paint are independent single-concern traits, and Adapted<W> places
a narrow-trait widget into the legacy *mut dyn Element tree. Proven
end-to-end by a headless test that drives a pure narrow-trait tree
through the existing scene::bridge and scene::painter passes.

A non-breaking supertrait carve-out was ruled out by experiment (trait-
object coercion only exists for real supertraits, and shared structural
methods would go ambiguous at every call site) — recorded in the RFC.

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

 docs/rfc-core-rebuild.md |  28 ++++++
 src/widget/mod.rs        |   2 +
 src/widget/model.rs      | 243 +++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 273 insertions(+)

diff --git a/docs/rfc-core-rebuild.md b/docs/rfc-core-rebuild.md
index b383574..f03661b 100644
--- a/docs/rfc-core-rebuild.md
+++ b/docs/rfc-core-rebuild.md
@@ -334,6 +334,34 @@ Constraint respected: **each crate still builds standalone** — the new core is
   hover/press/`network_opacity`; consolidate `hover_animation`.
 - **Phase 5 — Trait split & cleanup.** Split `Element` into `Widget`/`Paint`/`Input`; remove
   `as_*_controller` downcasts; migrate remaining widgets; delete the compatibility shim.
+  - **Approach correction (resolved by experiment).** A *non-breaking supertrait carve-out* of
+    `Element` (`trait Element: Paint + …`) turns out to be impossible in Rust here. The structural
+    methods the passes need (`rect`, `children`, `set_rect`) are overridden in dozens of widgets
+    across cce-ui **and** the app crates (`rect` 31+8, `children` 23+2, `set_rect` 42+4): moving
+    them off `Element` breaks every override, and merely *declaring* them on a supertrait makes
+    every `elem.children()`/`elem.rect()` call site ambiguous (a supertrait method is always in
+    scope on the subtrait). Nor does a blanket "view" `impl<T: Element> Paint for T` let
+    `&dyn Element` coerce to `&dyn Paint` — that coercion exists only for real supertraits. So the
+    split follows the **adapter** path from §5, not a supertrait split: narrow traits independent
+    of `Element`, with `Adapted<W>` bridging a narrow-trait widget into the `*mut dyn Element`
+    tree. The compatibility-shim bullet is thus *this* adapter (there was never a discrete legacy
+    shim to delete — the earlier migration hung hooks directly on `Element`).
+  - **5a — Layout + Paint concerns + adapter: DONE (compile + tests; no runtime surface yet).**
+    `cce-ui/src/widget/model.rs` — the independent `Layout` (`layout_style` / `intrinsic_size` /
+    `layout_children`) and `Paint` (`color` / `paint` / `clips_children`, where `paint` takes the
+    laid-out rect rather than reading a stored one) traits, plus `Adapted<W>`: a wrapper that
+    carries the `Widget` base and forwards the `Element` layout/paint methods to `W`'s narrow
+    traits. A headless test builds a pure narrow-trait tree (a `Col` container + two `Dot` leaves,
+    none of which implement `Element`), wraps each in `Adapted`, and drives it through the
+    *existing* `scene::bridge` layout pass and `scene::painter` paint pass — asserting both the
+    computed rects and the painted quads. Purely additive: no existing widget or app changes, all
+    137 cce-ui tests pass. Runtime verification is N/A until a real widget is migrated onto the
+    adapter (nothing in a running app uses it yet).
+  - **Still to do:** the `Input` (event) concern trait + adapter forwarding; migrate real widgets
+    off `impl Element` onto the narrow traits (per-widget, Phase 6 flavour); replace the 8 live
+    `as_*_controller` downcast pairs (called by `cce-designer`, `cce-test-interface`, and ~10
+    cce-ui widgets) with a typed message/command channel; delete `Element` + `Adapted` once the
+    last widget is across.
 - **Phase 6 — Per-app migration.** Move each `cce-*` app onto the new core; delete legacy paths
   once the last app is across.
 
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 9227b2f..1d6e542 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -818,10 +818,12 @@ pub mod container;
 pub mod display;
 pub mod editor;
 pub mod layout_helper;
+pub mod model;
  
 // Re-exports
 pub use self::editor::TextEditorState;
 pub use self::layout_helper::{ColumnLayout, RowLayout};
+pub use self::model::{Adapted, Layout, Paint};
 pub use self::core::{Widget, focus, hover_animation, popovers, clipboard, context_menu, clear_widget_references};
 pub use self::core::focus::link_parent_child;
 pub use self::input::{
diff --git a/src/widget/model.rs b/src/widget/model.rs
new file mode 100644
index 0000000..0f01962
--- /dev/null
+++ b/src/widget/model.rs
@@ -0,0 +1,243 @@
+//! Narrow, single-concern widget traits + an adapter into the legacy `Element` tree — Phase 5 of
+//! the core rebuild (see `docs/rfc-core-rebuild.md` §3.5 and §5).
+//!
+//! Phase 5 replaces the ~123-method [`Element`] god-trait with small traits, one per concern. A
+//! *non-breaking supertrait carve-out* of `Element` is not possible in Rust, for two reasons found
+//! by experiment:
+//!
+//! 1. The structural methods the layout/paint passes need (`rect`, `children`, `set_rect`, …) are
+//!    overridden in dozens of widgets across cce-ui **and** the app crates. Moving them off
+//!    `Element` breaks every override; merely *declaring* them on a supertrait breaks every call
+//!    site too, because a supertrait method is always in scope on the subtrait — `elem.children()`
+//!    on a `&dyn Element` becomes ambiguous.
+//! 2. Trait-object coercion does not offer a way around it: a blanket "view" impl
+//!    `impl<T: Element> Paint for T` does **not** let `&dyn Element` coerce to `&dyn Paint`
+//!    (that coercion only exists for real supertraits).
+//!
+//! So we take the RFC's recommended **adapter** path. The traits here — [`Layout`] and [`Paint`] —
+//! are *independent* of `Element` (no super/sub relationship). A widget written against them is
+//! placed into the existing `*mut dyn Element` tree by wrapping it in [`Adapted`], whose `Element`
+//! impl forwards each legacy method to the matching narrow-trait method and supplies the
+//! [`Widget`] base that `Element`'s rect/id/dirty machinery reads. Existing `impl Element` widgets
+//! are untouched; new or migrated widgets implement only the concern traits they need; both kinds
+//! coexist in one tree. When the last widget is migrated, `Element` and this adapter are deleted.
+//!
+//! This commit lands the two concerns the scene passes already consume: [`Layout`] drives
+//! [`crate::scene::bridge`] and [`Paint`] drives [`crate::scene::painter`]. The input/event
+//! concern follows in its own commit.
+
+use crate::scene::layout::{Rect, Size, Style};
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Element, UiContext, Widget, WidgetId};
+
+/// Layout inputs for the scene layout engine — the RFC's `Widget` concern, named `Layout` here to
+/// avoid the existing [`Widget`] base struct. Mirrors the opt-in `Element::layout_style` /
+/// `intrinsic_size` / `layout_children` hooks consumed by [`crate::scene::bridge`].
+pub trait Layout {
+    /// Opt-in layout style for the engine. `None` (default) ⇒ this widget does not drive
+    /// engine-computed layout. See [`Element::layout_style`].
+    fn layout_style(&self) -> Option<Style> {
+        None
+    }
+
+    /// Intrinsic content size of a leaf (e.g. measured text) for the measure pass. See
+    /// [`Element::intrinsic_size`].
+    fn intrinsic_size(&self) -> Option<Size> {
+        None
+    }
+
+    /// Per-child styles for containers that size their children from the parent (e.g. `SplitBox`
+    /// proportions), in `children()` order. See [`Element::layout_children`].
+    fn layout_children(&self) -> Option<Vec<Style>> {
+        None
+    }
+}
+
+/// The paint concern — a widget's fill color, its own (non-recursive) geometry emission, and
+/// whether it clips its children. Mirrors `Element::color` / `paint_self` / `clips_children`, but
+/// [`paint`](Paint::paint) receives the laid-out `rect` as a parameter (the RFC shape) rather than
+/// reading a stored rect, so a narrow widget carries no base of its own.
+pub trait Paint {
+    /// This widget's fill color (RGBA).
+    fn color(&self) -> [f32; 4];
+
+    /// Emit this node's OWN primitives (non-recursive) into `ctx`, given its final `rect`. The
+    /// default paints a plain background from [`color`](Paint::color) — the common leaf case.
+    /// Recursion into children and clipping are the paint walk's job ([`crate::scene::painter`]),
+    /// not this method's.
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        let color = self.color();
+        if color[3].abs() > 0.001 {
+            ctx.quad(rect, color);
+        }
+    }
+
+    /// Whether the paint walk clips this widget's children to its `rect` (scroll/backplate
+    /// containers). Default: no.
+    fn clips_children(&self) -> bool {
+        false
+    }
+}
+
+/// Wraps a narrow-trait widget `W` so it lives in the legacy `*mut dyn Element` tree. Carries the
+/// [`Widget`] base that `Element`'s rect / id / dirty machinery needs, and forwards the concern
+/// methods to `W`. See the module docs for why this bridge exists rather than a supertrait split.
+pub struct Adapted<W> {
+    base: Widget,
+    inner: W,
+}
+
+impl<W> Adapted<W> {
+    /// Wrap `inner` with a fresh [`Widget`] base.
+    pub fn new(inner: W) -> Self {
+        Adapted { base: Widget::new(), inner }
+    }
+
+    /// The wrapped widget.
+    pub fn inner(&self) -> &W {
+        &self.inner
+    }
+
+    /// The wrapped widget, mutably.
+    pub fn inner_mut(&mut self) -> &mut W {
+        &mut self.inner
+    }
+
+    /// This widget's tree id (assigned lazily), for registering it in a [`UiContext`].
+    pub fn id(&self) -> WidgetId {
+        self.base.id()
+    }
+}
+
+impl<W: Layout + Paint + 'static> Element for Adapted<W> {
+    fn base(&self) -> Option<&Widget> {
+        Some(&self.base)
+    }
+    fn base_mut(&mut self) -> Option<&mut Widget> {
+        Some(&mut self.base)
+    }
+    fn as_any(&self) -> &dyn std::any::Any {
+        self
+    }
+    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
+        self
+    }
+    fn as_ptr(&self) -> *mut (dyn Element + 'static) {
+        self as *const Self as *mut Self as *mut (dyn Element + 'static)
+    }
+    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
+        self as *mut Self as *mut (dyn Element + 'static)
+    }
+
+    // --- Layout concern -> `Layout` ---
+    fn layout_style(&self) -> Option<Style> {
+        Layout::layout_style(&self.inner)
+    }
+    fn intrinsic_size(&self) -> Option<Size> {
+        Layout::intrinsic_size(&self.inner)
+    }
+    fn layout_children(&self) -> Option<Vec<Style>> {
+        Layout::layout_children(&self.inner)
+    }
+
+    // --- Paint concern -> `Paint` ---
+    fn color(&self) -> [f32; 4] {
+        Paint::color(&self.inner)
+    }
+    fn clips_children(&self) -> bool {
+        Paint::clips_children(&self.inner)
+    }
+    fn paint_self(&self, _ui: &UiContext, ctx: &mut PaintCtx) {
+        let (x, y, w, h) = self.rect();
+        Paint::paint(&self.inner, Rect { x, y, width: w, height: h }, ctx);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::scene::bridge::layout_subtree;
+    use crate::scene::layout::{CrossAlign, Size, Style};
+    use crate::scene::paint::Prim;
+    use crate::scene::painter::paint_tree;
+    use crate::widget::UiContext;
+
+    /// A leaf that only knows the two narrow concerns — no `Element` in sight: it reports an
+    /// intrinsic size ([`Layout`]) and a color ([`Paint`]).
+    struct Dot {
+        color: [f32; 4],
+        size: Size,
+    }
+    impl Layout for Dot {
+        fn intrinsic_size(&self) -> Option<Size> {
+            Some(self.size)
+        }
+    }
+    impl Paint for Dot {
+        fn color(&self) -> [f32; 4] {
+            self.color
+        }
+    }
+
+    /// A narrow container: it drives a column layout ([`Layout`]) and paints nothing.
+    struct Col;
+    impl Layout for Col {
+        fn layout_style(&self) -> Option<Style> {
+            Some(Style::column().gap(4.0).cross_align(CrossAlign::Start))
+        }
+    }
+    impl Paint for Col {
+        fn color(&self) -> [f32; 4] {
+            [0.0, 0.0, 0.0, 0.0]
+        }
+    }
+
+    fn rect_of(ptr: *mut (dyn Element + 'static)) -> Rect {
+        let (x, y, w, h) = unsafe { (*ptr).rect() };
+        Rect { x, y, width: w, height: h }
+    }
+
+    #[test]
+    fn narrow_widget_lays_out_and_paints_through_the_adapter() {
+        // A pure narrow-trait widget tree (Col + two Dots), wrapped in `Adapted`, is laid out by
+        // the existing bridge and painted by the existing painter — proving a widget that never
+        // touches `Element` participates in both live passes.
+        let mut ctx = UiContext::new();
+        let mut root = Box::new(Adapted::new(Col));
+        let mut a = Box::new(Adapted::new(Dot { color: [1.0, 0.0, 0.0, 1.0], size: Size::new(10.0, 10.0) }));
+        let mut b = Box::new(Adapted::new(Dot { color: [0.0, 1.0, 0.0, 1.0], size: Size::new(10.0, 20.0) }));
+
+        let (root_id, root_ptr) = (root.id(), root.as_ptr_mut());
+        let (a_id, a_ptr) = (a.id(), a.as_ptr_mut());
+        let (b_id, b_ptr) = (b.id(), b.as_ptr_mut());
+        ctx.register_widget(root_id, root_ptr);
+        ctx.register_widget(a_id, a_ptr);
+        ctx.register_widget(b_id, b_ptr);
+        ctx.link_ids(root_id, a_id);
+        ctx.link_ids(root_id, b_id);
+
+        // Layout: column of a 10x10 then a 10x20, gap 4, in a 100x100 area.
+        layout_subtree(&ctx, root_ptr, Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 });
+        assert_eq!(rect_of(a_ptr), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
+        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 quads: Vec<_> = list
+            .items
+            .iter()
+            .filter_map(|it| match it.prim {
+                Prim::Quad { rect, color } => Some((rect, color)),
+                _ => None,
+            })
+            .collect();
+        assert!(
+            quads.iter().any(|(r, c)| *r == Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 } && c[0] == 1.0),
+            "red Dot painted at its laid-out rect: {quads:?}",
+        );
+        assert!(
+            quads.iter().any(|(r, c)| *r == Rect { x: 0.0, y: 14.0, width: 10.0, height: 20.0 } && c[1] == 1.0),
+            "green Dot painted at its laid-out rect: {quads:?}",
+        );
+    }
+}