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