GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(scene): opaque-leaf boundary + parent-supplied child styles; SplitBox hooks (Phase 2b)
Refine scene::bridge for incremental migration: recursion stops at non-participating widgets (opaque leaves get a rect but keep laying out their own internals), add Element::layout_children so a container whose child sizing lives on the parent (e.g. SplitBox proportions) can hand the engine per-child styles, and write a migrated container's rect to its base directly rather than re-running its legacy set_rect. Give SplitBox layout_style (row/column, gap, cross-stretch) + layout_children (grow = proportions), inert unless an app routes it through the bridge. 4 bridge tests incl. the opaque boundary; 109 total.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
src/scene/bridge.rs | 109 ++++++++++++++++++++++++++++++++-------
src/widget/container/splitbox.rs | 20 +++++++
src/widget/mod.rs | 5 ++
3 files changed, 116 insertions(+), 18 deletions(-)
diff --git a/src/scene/bridge.rs b/src/scene/bridge.rs
index 6a92c0e..e8f9096 100644
--- a/src/scene/bridge.rs
+++ b/src/scene/bridge.rs
@@ -15,7 +15,7 @@
//! 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::scene::layout::{self, LayoutBox, Rect, Size, Style};
use crate::widget::{Element, UiContext};
type ElemPtr = *mut (dyn Element + 'static);
@@ -34,7 +34,7 @@ type ElemPtr = *mut (dyn Element + 'static);
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);
+ let root_node = build(&mut arena, ctx, root, None, &mut order);
layout::measure(&mut arena, root_node);
layout::arrange(&mut arena, root_node, area);
@@ -42,33 +42,61 @@ pub fn layout_subtree(ctx: &UiContext, root: ElemPtr, area: Rect) {
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);
+ // A participating container's children are already placed by the engine, so we set
+ // only its own rect (writing the base directly) rather than calling its legacy
+ // `set_rect`, which would redundantly re-lay-out the children it no longer owns. An
+ // opaque leaf, by contrast, gets `set_rect` so it lays out its own internals.
+ let participating = (*ptr).layout_style().is_some();
+ match (participating, (*ptr).base_mut()) {
+ (true, Some(base)) => {
+ base.x = r.x;
+ base.y = r.y;
+ base.w = r.width;
+ base.h = r.height;
+ }
+ _ => (*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)
+///
+/// `assigned` is a style handed down by the parent (via [`Element::layout_children`]) that
+/// overrides this widget's own `layout_style`. Recursion only descends into widgets that
+/// themselves opt in (`layout_style` returns `Some`): a non-participating widget is an **opaque
+/// leaf** — the engine gives it a rect, but it keeps laying out its own internals via its own
+/// `set_rect`. That boundary is what lets one container be migrated without disturbing the widgets
+/// nested inside it.
+fn build(
+ arena: &mut Arena<LayoutBox>,
+ ctx: &UiContext,
+ ptr: ElemPtr,
+ assigned: Option<Style>,
+ order: &mut Vec<(NodeId, ElemPtr)>,
+) -> NodeId {
+ let own_style = unsafe { (*ptr).layout_style() };
+ let participates = own_style.is_some();
+ let style = assigned.or(own_style).unwrap_or_default();
+
+ // Only a participating container is descended into; opaque leaves stop the recursion.
+ let (children, child_styles) = if participates {
+ unsafe { ((*ptr).children(ctx), (*ptr).layout_children()) }
+ } else {
+ (Vec::new(), None)
};
- // 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)),
- }
+ let intrinsic = unsafe { (*ptr).intrinsic_size() };
+ arena.insert(LayoutBox::leaf(style, intrinsic.unwrap_or(Size::ZERO)))
} else {
arena.insert(LayoutBox::container(style))
};
order.push((node, ptr));
- for child in children {
- let child_node = build(arena, ctx, child, order);
+ for (i, child) in children.into_iter().enumerate() {
+ let assigned_child = child_styles.as_ref().and_then(|v| v.get(i).copied());
+ let child_node = build(arena, ctx, child, assigned_child, order);
arena.append_child(node, child_node);
}
node
@@ -86,13 +114,21 @@ mod tests {
base: Widget,
style: Option<Style>,
intrinsic: Option<Size>,
+ child_styles: Option<Vec<Style>>,
}
impl W {
fn container(style: Style) -> Box<W> {
- Box::new(W { base: Widget::new(), style: Some(style), intrinsic: None })
+ Box::new(W { base: Widget::new(), style: Some(style), intrinsic: None, child_styles: None })
+ }
+ fn container_with_child_styles(style: Style, child_styles: Vec<Style>) -> Box<W> {
+ Box::new(W { base: Widget::new(), style: Some(style), intrinsic: None, child_styles: Some(child_styles) })
}
fn leaf(w: f32, h: f32) -> Box<W> {
- Box::new(W { base: Widget::new(), style: None, intrinsic: Some(Size::new(w, h)) })
+ Box::new(W { base: Widget::new(), style: None, intrinsic: Some(Size::new(w, h)), child_styles: None })
+ }
+ /// A non-participating widget (opaque leaf): no layout_style, no intrinsic.
+ fn opaque() -> Box<W> {
+ Box::new(W { base: Widget::new(), style: None, intrinsic: None, child_styles: None })
}
}
impl Element for W {
@@ -106,6 +142,9 @@ mod tests {
fn intrinsic_size(&self) -> Option<Size> {
self.intrinsic
}
+ fn layout_children(&self) -> Option<Vec<Style>> {
+ self.child_styles.clone()
+ }
}
/// Register a widget in `ctx` and return its (id, ptr).
@@ -186,4 +225,38 @@ mod tests {
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");
}
+
+ #[test]
+ fn opaque_child_is_sized_but_not_recursed_into() {
+ // The incremental-migration boundary: a participating container lays out its direct
+ // children (here via parent-supplied grow weights, à la SplitBox), but a non-participating
+ // child is opaque — the engine must NOT descend into it and reposition its grandchildren.
+ let mut ctx = UiContext::new();
+ let mut root = W::container_with_child_styles(
+ Style::row().cross_align(CrossAlign::Stretch),
+ vec![Style::default().grow(1.0), Style::default().grow(1.0)],
+ );
+ let mut pane_a = W::opaque();
+ let mut pane_b = W::opaque();
+ let mut grandchild = W::leaf(7.0, 7.0);
+
+ let (root_id, root_ptr) = reg(&mut ctx, &mut root);
+ let (a_id, a_ptr) = reg(&mut ctx, &mut pane_a);
+ let (b_id, b_ptr) = reg(&mut ctx, &mut pane_b);
+ let (g_id, g_ptr) = reg(&mut ctx, &mut grandchild);
+ ctx.link_ids(root_id, a_id);
+ ctx.link_ids(root_id, b_id);
+ ctx.link_ids(a_id, g_id); // grandchild lives inside the opaque pane A
+
+ // Pre-position the grandchild; the opaque boundary must leave it untouched.
+ unsafe { (*g_ptr).set_rect(1.0, 2.0, 7.0, 7.0) };
+
+ layout_subtree(&ctx, root_ptr, Rect { x: 0.0, y: 0.0, width: 100.0, height: 40.0 });
+
+ // Panes: 50/50 by grow, stretched to full height.
+ assert_eq!(rect_of(a_ptr), Rect { x: 0.0, y: 0.0, width: 50.0, height: 40.0 });
+ assert_eq!(rect_of(b_ptr), Rect { x: 50.0, y: 0.0, width: 50.0, height: 40.0 });
+ // Grandchild untouched — recursion stopped at the opaque pane.
+ assert_eq!(rect_of(g_ptr), Rect { x: 1.0, y: 2.0, width: 7.0, height: 7.0 });
+ }
}
diff --git a/src/widget/container/splitbox.rs b/src/widget/container/splitbox.rs
index 79e128e..5a6b64d 100644
--- a/src/widget/container/splitbox.rs
+++ b/src/widget/container/splitbox.rs
@@ -95,6 +95,26 @@ impl Element for SplitBox {
self.children.clone()
}
+ // --- scene layout-engine opt-in (Phase 2b) ---
+ // A SplitBox is a flex row/column whose child sizing lives on the parent (`proportions`).
+ // These hooks let `scene::bridge::layout_subtree` drive the split via the engine; they are
+ // inert unless an app routes this widget through the bridge (legacy `set_rect` is unchanged).
+ fn layout_style(&self) -> Option<crate::scene::layout::Style> {
+ use crate::scene::layout::{CrossAlign, Style};
+ let mut style = match self.direction {
+ SplitDirection::Horizontal => Style::row(),
+ SplitDirection::Vertical => Style::column(),
+ };
+ style.gap = self.gap;
+ style.cross_align = CrossAlign::Stretch; // panes fill the cross axis, as `set_rect` does
+ Some(style)
+ }
+
+ fn layout_children(&self) -> Option<Vec<crate::scene::layout::Style>> {
+ // Each pane grows by its proportion — matching the proportional split in `set_rect`.
+ Some(self.proportions.iter().map(|&p| crate::scene::layout::Style::default().grow(p)).collect())
+ }
+
fn parent(&self, _ctx: &UiContext) -> Option<*mut (dyn Element + 'static)> {
self.parent
}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 2f4b398..dcdee4b 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -176,6 +176,11 @@ pub trait Element {
/// pass. Ignored for widgets that have children.
fn intrinsic_size(&self) -> Option<crate::scene::layout::Size> { None }
+ /// Per-child layout styles, for containers whose child sizing lives on the parent rather than
+ /// the children (e.g. `SplitBox` proportions). Returned in `children()` order; entry `i`
+ /// overrides child `i`'s own `layout_style`. `None` (default) means children use their own.
+ fn layout_children(&self) -> Option<Vec<crate::scene::layout::Style>> { None }
+
fn check_out_of_bounds(&self, _event: &Event, _ctx: &UiContext) -> bool {
false
}