GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(scene): widget<->layout-engine bridge + Element layout hooks (Phase 2b)
Add opt-in Element::layout_style / intrinsic_size hooks and scene::bridge::layout_subtree, which mirrors a live widget subtree into a throwaway LayoutBox arena, runs measure/arrange, and writes the computed rects back via set_rect (pre-order). Migration is incremental: a widget opts in by returning Some from layout_style; widgets returning None keep their legacy set_rect path untouched. 3 headless tests drive real dyn Element widgets registered in a real UiContext. Not yet enabled on any live widget.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
src/scene/bridge.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++++
src/scene/mod.rs | 1 +
src/widget/mod.rs | 10 +++
3 files changed, 200 insertions(+)
diff --git a/src/scene/bridge.rs b/src/scene/bridge.rs
new file mode 100644
index 0000000..6a92c0e
--- /dev/null
+++ b/src/scene/bridge.rs
@@ -0,0 +1,189 @@
+//! Bridge between the live widget tree and the scene layout engine — Phase 2b of the core
+//! rebuild.
+//!
+//! [`scene::layout`](crate::scene::layout) is a pure solver over `Arena<LayoutBox>`; the running
+//! UI is a tree of `Box<dyn Element>` widgets linked through [`UiContext`]. This module connects
+//! them: given a widget subtree, it builds a throwaway `LayoutBox` arena mirroring that subtree,
+//! runs measure/arrange, and writes the resulting rects back into the widgets.
+//!
+//! Migration is incremental. A widget opts in by returning `Some` from
+//! [`Element::layout_style`]; leaf widgets report size via [`Element::intrinsic_size`]. Widgets
+//! that return `None` keep their legacy `set_rect` path untouched — so a single container can be
+//! moved onto the engine and verified in a running app without disturbing the rest.
+//!
+//! Structure comes from each widget's own [`Element::children`], so it respects widgets that
+//! store children locally as well as the default `UiContext`-backed tree.
+
+use crate::scene::arena::{Arena, NodeId};
+use crate::scene::layout::{self, LayoutBox, Rect};
+use crate::widget::{Element, UiContext};
+
+type ElemPtr = *mut (dyn Element + 'static);
+
+/// Lay out the widget subtree rooted at `root` into `area` using the scene layout engine, writing
+/// the computed rect into every widget via [`Element::set_rect`].
+///
+/// Widgets are written in pre-order (parent before children), so a container's own rect is set
+/// before the engine-computed rects of its children overwrite anything the container's `set_rect`
+/// might have done.
+///
+/// # Safety
+/// `root` must be a valid, live widget pointer, and the subtree reachable through
+/// `Element::children` must consist of live widgets — the same invariant the rest of the toolkit
+/// relies on for its `*mut dyn Element` tree.
+pub fn layout_subtree(ctx: &UiContext, root: ElemPtr, area: Rect) {
+ let mut arena: Arena<LayoutBox> = Arena::new();
+ let mut order: Vec<(NodeId, ElemPtr)> = Vec::new();
+ let root_node = build(&mut arena, ctx, root, &mut order);
+
+ layout::measure(&mut arena, root_node);
+ layout::arrange(&mut arena, root_node, area);
+
+ for &(node, ptr) in &order {
+ let r = arena.value(node).expect("layout node missing").rect;
+ unsafe {
+ (*ptr).set_rect(r.x, r.y, r.width, r.height);
+ }
+ }
+}
+
+/// Recursively mirror the widget subtree into `arena`, recording (node, widget) pairs in pre-order.
+fn build(arena: &mut Arena<LayoutBox>, ctx: &UiContext, ptr: ElemPtr, order: &mut Vec<(NodeId, ElemPtr)>) -> NodeId {
+ let (style, intrinsic, children) = unsafe {
+ let style = (*ptr).layout_style().unwrap_or_default();
+ let intrinsic = (*ptr).intrinsic_size();
+ let children = (*ptr).children(ctx);
+ (style, intrinsic, children)
+ };
+
+ // A node with children is a container; a childless node is a leaf sized by its intrinsic size.
+ let node = if children.is_empty() {
+ match intrinsic {
+ Some(size) => arena.insert(LayoutBox::leaf(style, size)),
+ None => arena.insert(LayoutBox::container(style)),
+ }
+ } else {
+ arena.insert(LayoutBox::container(style))
+ };
+ order.push((node, ptr));
+
+ for child in children {
+ let child_node = build(arena, ctx, child, order);
+ arena.append_child(node, child_node);
+ }
+ node
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::scene::layout::{CrossAlign, Size, Style};
+ use crate::widget::{Widget, WidgetId};
+
+ /// A minimal real widget: carries a `Widget` base (so it has an id and a settable rect) and an
+ /// opt-in layout style / intrinsic size.
+ struct W {
+ base: Widget,
+ style: Option<Style>,
+ intrinsic: Option<Size>,
+ }
+ impl W {
+ fn container(style: Style) -> Box<W> {
+ Box::new(W { base: Widget::new(), style: Some(style), intrinsic: None })
+ }
+ fn leaf(w: f32, h: f32) -> Box<W> {
+ Box::new(W { base: Widget::new(), style: None, intrinsic: Some(Size::new(w, h)) })
+ }
+ }
+ impl Element for W {
+ crate::impl_widget_base!(W);
+ fn color(&self) -> [f32; 4] {
+ [0.0, 0.0, 0.0, 0.0]
+ }
+ fn layout_style(&self) -> Option<Style> {
+ self.style
+ }
+ fn intrinsic_size(&self) -> Option<Size> {
+ self.intrinsic
+ }
+ }
+
+ /// Register a widget in `ctx` and return its (id, ptr).
+ fn reg(ctx: &mut UiContext, w: &mut W) -> (WidgetId, ElemPtr) {
+ let id = w.base.id();
+ let ptr: ElemPtr = w as *mut W;
+ ctx.register_widget(id, ptr);
+ (id, ptr)
+ }
+
+ fn rect_of(ptr: ElemPtr) -> Rect {
+ let (x, y, w, h) = unsafe { (*ptr).rect() };
+ Rect { x, y, width: w, height: h }
+ }
+
+ #[test]
+ fn drives_a_real_column_of_leaves() {
+ let mut ctx = UiContext::new();
+ // Keep the boxes alive for the duration; hand the tree raw pointers into them.
+ let mut root = W::container(Style::column().gap(4.0));
+ let mut a = W::leaf(10.0, 10.0);
+ let mut b = W::leaf(10.0, 20.0);
+
+ let (root_id, root_ptr) = reg(&mut ctx, &mut root);
+ let (a_id, a_ptr) = reg(&mut ctx, &mut a);
+ let (b_id, b_ptr) = reg(&mut ctx, &mut b);
+ ctx.link_ids(root_id, a_id);
+ ctx.link_ids(root_id, b_id);
+
+ layout_subtree(&ctx, root_ptr, Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 });
+
+ assert_eq!(rect_of(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 });
+ }
+
+ #[test]
+ fn lays_out_at_a_nonzero_origin_with_padding() {
+ let mut ctx = UiContext::new();
+ let mut root = W::container(Style::row().padding(5.0));
+ let mut a = W::leaf(10.0, 10.0);
+
+ let (root_id, root_ptr) = reg(&mut ctx, &mut root);
+ let (a_id, a_ptr) = reg(&mut ctx, &mut a);
+ ctx.link_ids(root_id, a_id);
+
+ // Subtree lives at (20, 30), not the origin.
+ layout_subtree(&ctx, root_ptr, Rect { x: 20.0, y: 30.0, width: 100.0, height: 50.0 });
+
+ assert_eq!(rect_of(root_ptr), Rect { x: 20.0, y: 30.0, width: 100.0, height: 50.0 });
+ // child inset by padding from the container's origin.
+ assert_eq!(rect_of(a_ptr), Rect { x: 25.0, y: 35.0, width: 10.0, height: 10.0 });
+ }
+
+ #[test]
+ fn drives_a_nested_subtree() {
+ let mut ctx = UiContext::new();
+ let mut root = W::container(Style::row());
+ let mut inner = W::container(Style::column().gap(2.0).cross_align(CrossAlign::Start));
+ let mut c1 = W::leaf(10.0, 10.0);
+ let mut c2 = W::leaf(10.0, 10.0);
+ let mut sibling = W::leaf(5.0, 5.0);
+
+ let (root_id, root_ptr) = reg(&mut ctx, &mut root);
+ let (inner_id, inner_ptr) = reg(&mut ctx, &mut inner);
+ let (c1_id, c1_ptr) = reg(&mut ctx, &mut c1);
+ let (c2_id, c2_ptr) = reg(&mut ctx, &mut c2);
+ let (sib_id, sib_ptr) = reg(&mut ctx, &mut sibling);
+ ctx.link_ids(root_id, inner_id);
+ ctx.link_ids(root_id, sib_id);
+ ctx.link_ids(inner_id, c1_id);
+ ctx.link_ids(inner_id, c2_id);
+
+ layout_subtree(&ctx, root_ptr, Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 });
+
+ assert_eq!(rect_of(inner_ptr), Rect { x: 0.0, y: 0.0, width: 10.0, height: 22.0 });
+ assert_eq!(rect_of(c1_ptr), Rect { x: 0.0, y: 0.0, width: 10.0, height: 10.0 });
+ assert_eq!(rect_of(c2_ptr), Rect { x: 0.0, y: 12.0, width: 10.0, height: 10.0 });
+ assert_eq!(rect_of(sib_ptr).x, 10.0, "sibling follows inner's measured width");
+ }
+}
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 8282842..95e20e8 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -6,6 +6,7 @@
//! top of the same node identity.
pub mod arena;
+pub mod bridge;
pub mod layout;
pub mod tree;
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index ec8da16..2f4b398 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -166,6 +166,16 @@ pub trait Element {
fn base_mut(&mut self) -> Option<&mut Widget> { None }
fn preferred_height(&self) -> Option<f32> { None }
+ /// Opt-in layout style for the scene layout engine (Phase 2b). `None` (the default) means this
+ /// widget does not participate in engine-driven layout yet and keeps its legacy `set_rect`
+ /// path; return `Some(..)` to have the engine size/position it and its children. See
+ /// `scene::bridge`.
+ fn layout_style(&self) -> Option<crate::scene::layout::Style> { None }
+
+ /// Intrinsic content size of a leaf widget (e.g. measured text/icon) for the engine's measure
+ /// pass. Ignored for widgets that have children.
+ fn intrinsic_size(&self) -> Option<crate::scene::layout::Size> { None }
+
fn check_out_of_bounds(&self, _event: &Event, _ctx: &UiContext) -> bool {
false
}