From 32d7ada3abcbf6d8cdf54de026dc1f6e38108722 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 10 Nov 2026 22:17:19 +0800 Subject: [PATCH] feat: implement nested border-box layout and alignment --- docs/scene-architecture.md | 45 +++ examples/README.md | 1 + examples/box-layout.endor | 38 +++ src/bridge/ui_bridge.rs | 5 +- src/bridge/ui_bridge/bootstrap.rs | 4 + src/bridge/ui_bridge/calls.rs | 4 +- src/bridge/ui_bridge/signatures.rs | 4 +- src/bridge/ui_bridge/tree.rs | 37 ++- src/main.rs | 4 +- src/model/block.rs | 181 ++++++++++- src/model/presentation.rs | 8 + src/model/snapshot.rs | 12 + src/render/box_model.rs | 124 ++++++++ src/render/layout.rs | 468 ++++++++++++++++++++++++++--- src/render/mod.rs | 1 + src/render/native_visual.rs | 11 +- src/render/scroll.rs | 36 ++- src/render/source_layout.rs | 30 +- src/render/source_paint.rs | 6 +- src/runtime/api/guide.rs | 2 + src/runtime/endor_host/tests.rs | 421 ++++++++++++++++++++++++++ 21 files changed, 1368 insertions(+), 74 deletions(-) create mode 100644 docs/scene-architecture.md create mode 100644 examples/box-layout.endor create mode 100644 src/render/box_model.rs diff --git a/docs/scene-architecture.md b/docs/scene-architecture.md new file mode 100644 index 0000000..da3877e --- /dev/null +++ b/docs/scene-architecture.md @@ -0,0 +1,45 @@ +# UI、场景、视口与摄像机 + +本次完整实现目标的持续记录。完成布局并不等于完成场景或原生交互验收。 + +## 边界 + +- Page 负责页面身份、生命周期和根内容。Row、Column、Overlay 负责有限盒子内的排版。 +- Scene2D、Scene3D 管理世界对象。世界不是一个无限宽高的布局盒子。 +- CanvasViewport、Viewport3D 是有限的 UI 盒子,可以加入 Row、Column、Overlay,也可以放进世界内的有限 UI 面板。 +- Camera2D、透视和正交摄像机是显式对象。视口保存自己的尺寸、裁剪和投影比例;共享场景不隐式共享摄像机。 +- 摄像机控制器独立于摄像机数据。移动相机只改变投影、可见性和命中坐标,不应重新排版无关内容。 +- 视口覆盖 UI 不随世界移动。世界内 UI 面板拥有有限大小,使用普通布局,再由对应的世界变换呈现。 +- 嵌套视口的输入逐级逆变换,尊重裁剪和捕获;同一手势不能同时驱动内外相机。 +- 无限导航受数值、可见对象、递归、内存和绘制预算约束;引用环必须在发布部分结果前拒绝。 + +## 当前实现状态 + +| 阶段 | 状态 | 证据 / 剩余工作 | +| --- | --- | --- | +| 基线审查 | 完成 | 保留共享 Block / 独立 Mount、源协议和已合并工作区示例 | +| 嵌套布局与盒模型 | 完成 | Row / Column / Overlay、双轴对齐、子项覆盖、外边距、非对称内边距和边框、尺寸策略、裁剪;见下面接口 | +| 独立 2D 场景与视口 | 待实现 | 现有 Page.mode / pan / zoom 仍是迁移前接口 | +| 3D 场景、视口和摄像机 | 待实现 | 尚无可运行的 3D 后端、控制器或拾取 | +| 原生综合验收 | 待执行 | Rust / VM / HTTP 回归不能代替鼠标、触控板和窗口外观验收 | +| 完成交付 | 待完成 | 完成实现、检查和文档后,更新可评审 PR,等待用户手动合并 | + +## 有限盒子的接口 + +现有 `Row(padding, gap, children...)`、`Column(padding, gap, children...)` 保留,新增 `Overlay(padding, gap, children...)`。Overlay 为构造协议兼容保留 gap 参数,但叠放不使用 gap;子项顺序决定绘制和命中顺序。 + +- `mainAlign`: `start`、`center`、`end`、`space-between`。Overlay 对应横向对齐,不使用 space-between 分散规则。 +- `crossAlign`: `start`、`center`、`end`、`stretch`。Row 对应纵向;Column 对应横向。Overlay 对应纵向。 +- `alignSelf`: `auto` 继承所在父容器的 crossAlign,或以上交叉轴选项。属性在对象上,每个 Mount 根据自己的父容器解释。 +- `marginTop/Right/Bottom/Left`: 外边距参与父容器的测量、主轴分配和对齐。不折叠外边距,不支持负外边距。 +- `paddingTop/Right/Bottom/Left`: 数值覆盖对应边;`auto` 回退到构造参数或容器的 padding 属性。非容器回退为 0。 +- `borderTop/Right/Bottom/Left` 和 `borderColor`: 边框占据 border box 内侧,压缩内容空间。原生绘制产生实心直角边框;自定义 paint 可调用基类绘制,也可自行提供完整外观。 +- `boxWidth/boxHeight`: `auto`、非负数值或 `fill`。保留 RectBlock / ScrollBlock 原有 width/height 和源子类字段语义,通用盒尺寸使用独立名称。数值表示 border box,不包含外边距。 +- `minWidth/minHeight/maxWidth/maxHeight`: 盒尺寸边界,最低值不得超过最高值。父分配小于最低值时,外部有限约束仍然生效。 +- 主轴 `fill` 使用零基准分享剩余空间;未显式设置 flex 时权重为 1。最大值饱和后,剩余空间继续分给其他项目。交叉轴 fill 使用父容器提供的有限内容尺寸。没有有限父分配的 fill 报错。 +- `stretch` 仅拉伸交叉轴 auto 尺寸,遵守盒尺寸边界;数值固定尺寸保留。显式源 arrange 返回的布局始终优先。 +- `overflow`: `visible` 或 `clip`;后者同时裁剪后代绘制和指针命中。需要滚动时使用 ScrollBlock,滚动轴与内层 Row/Column 排版独立。 + +尺寸、边距和边框必须有限、非负,受现有 1000000 上限限制。根约束、每个 Mount 的偏好尺寸、分配、收敛判定及源协议缓存均保持独立。示例见 [box-layout.endor](../examples/box-layout.endor)。 + +布局阶段验证:本地 `cargo test --locked` 的 162 项回归全部通过,`cargo clippy --all-targets --locked -- -D warnings`、格式与差异空白检查通过。新增用例执行实际 Endor 脚本,覆盖共享对象在不同父容器中的对齐、实时边距变更、flex 分配、叠放、尺寸边界、文本换行、有限 fill、无界 fill 拒绝、源 arrange 优先、裁剪命中和可运行示例。本阶段尚未重新打包或进行原生指针验收。 diff --git a/examples/README.md b/examples/README.md index 25a6122..1a43a9e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,7 @@ | File | Purpose | | --- | --- | +| [box-layout.endor](box-layout.endor) | Nested Row/Column/Overlay, shared text, finite border-box sizing, margins, borders, alignment and clipping. | | [gc-lifecycle.endor](gc-lifecycle.endor) | A Page/Block/Task/await-frame cycle: hidden retained Blocks keep running, then releasing the last references permits collection despite rendering caches. | | [scroll-axis.endor](scroll-axis.endor) | One shared TextBlock in vertical and horizontal viewports, live axis changes and independent offsets. | | [windows.endor](windows.endor) | Two real native windows in one Aster VM, live shared counter state and explicit Page ownership. | diff --git a/examples/box-layout.endor b/examples/box-layout.endor new file mode 100644 index 0000000..ca42868 --- /dev/null +++ b/examples/box-layout.endor @@ -0,0 +1,38 @@ +// Containers compose independently of Page and of any camera. +shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2) +shared.boxWidth = "fill" +shared.marginBottom = 8 + +left = Column(16, 12, TextBlock("Row / Column", 2), shared, Button("继续探索")) +left.boxWidth = "fill" +left.minWidth = 180 +left.maxWidth = 360 +left.borderLeft = 2 +left.borderTop = 2 +left.borderRight = 2 +left.borderBottom = 2 +left.borderColor = "#CBD5E1" +left.paddingLeft = 24 +left.crossAlign = "center" + +right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared) +right.boxWidth = "fill" +right.maxWidth = 240 +right.crossAlign = "end" + +content = Row(24, 24, left, right) +content.boxWidth = 760 +content.crossAlign = "start" +badge = TextBlock("Endorphin · layout") +badge.marginRight = 12 +badge.marginBottom = 12 +badge.alignSelf = "end" +surface = Overlay(0, 0, content, badge) +surface.boxWidth = 800 +surface.boxHeight = 400 +surface.mainAlign = "end" +surface.overflow = "clip" + +page = Page() +page.title = "嵌套盒模型" +page.render(surface) diff --git a/src/bridge/ui_bridge.rs b/src/bridge/ui_bridge.rs index 449271a..699ff0a 100644 --- a/src/bridge/ui_bridge.rs +++ b/src/bridge/ui_bridge.rs @@ -43,6 +43,7 @@ pub const TYPE_NAMES: &[&str] = &[ "Spacer", "Row", "Column", + "Overlay", "Button", "Page", "Window", @@ -143,6 +144,7 @@ enum Operation { Scroll, Row, Column, + Overlay, Button, Page, SetText, @@ -186,7 +188,7 @@ impl Operation { Rect => (2, 4), MoveTab | MoveWindow | OpenValue | Spacer | SetText | SetColor | Append | Remove | Render | Navigate | OnClick | Present => (1, 1), - Row | Column => (2, MAX_UI_NODES + 2), + Row | Column | Overlay => (2, MAX_UI_NODES + 2), Button => (1, 1), Insert | Replace | Move => (2, 2), Page | Unmount | Close | Activate | Back | Forward | Children | NewWindow @@ -425,6 +427,7 @@ fn new_typed_block( Kind::Scroll { .. } => "ScrollBlock", Kind::Row => "Row", Kind::Column => "Column", + Kind::Overlay => "Overlay", Kind::Button => "Button", Kind::Page => "Page", }; diff --git a/src/bridge/ui_bridge/bootstrap.rs b/src/bridge/ui_bridge/bootstrap.rs index 89cc254..4fd58a2 100644 --- a/src/bridge/ui_bridge/bootstrap.rs +++ b/src/bridge/ui_bridge/bootstrap.rs @@ -43,6 +43,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("spacer", Operation::Spacer), ("row", Operation::Row), ("column", Operation::Column), + ("overlay", Operation::Overlay), ("button", Operation::Button), ("page", Operation::Page), ("present", Operation::Present), @@ -86,6 +87,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("ScrollBlock", "ContainerBlock", Some("scroll")), ("Row", "ContainerBlock", Some("row")), ("Column", "ContainerBlock", Some("column")), + ("Overlay", "ContainerBlock", Some("overlay")), ("Button", "ContainerBlock", Some("button")), ("Page", "ContainerBlock", Some("page")), ("Window", "Object", Some("window-create")), @@ -130,6 +132,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "scroll" => Operation::Scroll, "row" => Operation::Row, "column" => Operation::Column, + "overlay" => Operation::Overlay, "button" => Operation::Button, "page" => Operation::Page, "window-create" => Operation::NewWindow, @@ -232,6 +235,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "scroll" => Some(Operation::Scroll), "row" => Some(Operation::Row), "column" => Some(Operation::Column), + "overlay" => Some(Operation::Overlay), "button" => Some(Operation::Button), "page" => Some(Operation::Page), "window-create" => Some(Operation::NewWindow), diff --git a/src/bridge/ui_bridge/calls.rs b/src/bridge/ui_bridge/calls.rs index 12c3fbe..3def9cd 100644 --- a/src/bridge/ui_bridge/calls.rs +++ b/src/bridge/ui_bridge/calls.rs @@ -346,9 +346,11 @@ impl NativeCallable for Function { 0.0, 0.0, )), - Row | Column => Some(Data::new( + Row | Column | Overlay => Some(Data::new( if matches!(self.operation, Row) { Kind::Row + } else if matches!(self.operation, Overlay) { + Kind::Overlay } else { Kind::Column }, diff --git a/src/bridge/ui_bridge/signatures.rs b/src/bridge/ui_bridge/signatures.rs index 0713506..254e4fd 100644 --- a/src/bridge/ui_bridge/signatures.rs +++ b/src/bridge/ui_bridge/signatures.rs @@ -133,11 +133,13 @@ pub(super) fn operation_signature( nominal("ScrollBlock")?, CallEffect::ReadOnly, ), - Row | Column => ( + Row | Column | Overlay => ( vec![Number, Number], Some(nominal("Block")?), nominal(if matches!(operation, Row) { "Row" + } else if matches!(operation, Overlay) { + "Overlay" } else { "Column" })?, diff --git a/src/bridge/ui_bridge/tree.rs b/src/bridge/ui_bridge/tree.rs index e346354..f8a1570 100644 --- a/src/bridge/ui_bridge/tree.rs +++ b/src/bridge/ui_bridge/tree.rs @@ -7,6 +7,14 @@ pub struct UiNode { pub flex: f32, pub flex_policy: crate::layout::FlexPolicy, pub main_align: crate::layout::MainAlign, + pub cross_align: crate::layout::CrossAlign, + pub align_self: Option, + pub margin: crate::layout::Insets, + pub sizing: crate::box_model::Sizing, + pub padding_edges: [Option; 4], + pub border: crate::layout::Insets, + pub border_color: String, + pub clip_children: bool, pub block_id: String, pub protocol_key: String, pub slots: Vec, @@ -18,6 +26,7 @@ impl UiNode { pub fn children(&self) -> &[Arc] { match &self.kind { UiKind::Column { children, .. } + | UiKind::Overlay { children, .. } | UiKind::Canvas { children, .. } | UiKind::Row { children, .. } | UiKind::Button { children, .. } @@ -27,7 +36,18 @@ impl UiNode { } pub fn has_flex(&self) -> bool { - self.flex > 0.0 || self.flex_policy != crate::layout::FlexPolicy::default() + self.flex > 0.0 + || self.flex_policy != crate::layout::FlexPolicy::default() + || self.sizing.width == crate::box_model::Dimension::Fill + || self.sizing.height == crate::box_model::Dimension::Fill + } + + pub fn allocates_children(&self) -> bool { + self.children().iter().any(|child| { + child.has_flex() + || child.align_self.unwrap_or(self.cross_align) + == crate::layout::CrossAlign::Stretch + }) } pub fn shallow_eq(&self, other: &Self) -> bool { @@ -35,6 +55,14 @@ impl UiNode { || self.flex_policy != other.flex_policy || self.flex != other.flex || self.main_align != other.main_align + || self.cross_align != other.cross_align + || self.align_self != other.align_self + || self.margin != other.margin + || self.sizing != other.sizing + || self.padding_edges != other.padding_edges + || self.border != other.border + || self.border_color != other.border_color + || self.clip_children != other.clip_children || self.block_id != other.block_id || self.protocol_key != other.protocol_key || self.slots != other.slots @@ -51,6 +79,9 @@ impl UiNode { .all(|(a, b)| Arc::ptr_eq(a, b)) }; match (&self.kind, &other.kind) { + (UiKind::Overlay { padding: a, .. }, UiKind::Overlay { padding: b, .. }) => { + a == b && same_children() + } (UiKind::Canvas { positions: a, .. }, UiKind::Canvas { positions: b, .. }) => { a == b && same_children() } @@ -130,6 +161,10 @@ impl UiNode { #[derive(Debug, Clone, PartialEq)] pub enum UiKind { + Overlay { + padding: f32, + children: Vec>, + }, Canvas { positions: Vec<[f32; 2]>, children: Vec>, diff --git a/src/main.rs b/src/main.rs index 85bf173..2e28118 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,8 @@ pub(crate) use model::{ binding, block, document, mount, object_list, presentation, protocols, snapshot, window, }; pub(crate) use render::{ - display_list, geometry, layout, native_visual, page, scene, scroll, software_renderer, - source_hit, source_layout, source_paint, text, + box_model, display_list, geometry, layout, native_visual, page, scene, scroll, + software_renderer, source_hit, source_layout, source_paint, text, }; pub(crate) use runtime::{ api, endor_host, endor_script, event_dispatch, events, io_tasks, tasks, vm_sequence, diff --git a/src/model/block.rs b/src/model/block.rs index 2185a8f..712b629 100644 --- a/src/model/block.rs +++ b/src/model/block.rs @@ -32,6 +32,7 @@ pub enum Kind { }, Row, Column, + Overlay, Button, Page, } @@ -120,6 +121,14 @@ pub struct Data { pub flex: f32, pub flex_policy: crate::layout::FlexPolicy, pub main_align: crate::layout::MainAlign, + pub cross_align: crate::layout::CrossAlign, + pub align_self: Option, + pub margin: crate::layout::Insets, + pub sizing: crate::box_model::Sizing, + pub padding_edges: [Option; 4], + pub border: crate::layout::Insets, + pub border_color: String, + pub clip_children: bool, pub scroll_axis: crate::scroll::Axis, pub position: [f32; 2], pub surface: crate::page::Surface, @@ -151,6 +160,14 @@ impl Data { flex: 0.0, flex_policy: crate::layout::FlexPolicy::default(), main_align: crate::layout::MainAlign::Start, + cross_align: crate::layout::CrossAlign::Start, + align_self: None, + margin: crate::layout::Insets::default(), + sizing: crate::box_model::Sizing::default(), + padding_edges: [None; 4], + border: crate::layout::Insets::default(), + border_color: "#D8DCE2".into(), + clip_children: false, scroll_axis: crate::scroll::Axis::Both, position: [0.0, 0.0], surface: crate::page::Surface::new(crate::page::Mode::Window), @@ -167,7 +184,12 @@ impl Data { pub fn is_container(&self) -> bool { matches!( self.kind, - Kind::Row | Kind::Column | Kind::Button | Kind::Page | Kind::Scroll { .. } + Kind::Row + | Kind::Column + | Kind::Overlay + | Kind::Button + | Kind::Page + | Kind::Scroll { .. } ) } } @@ -336,7 +358,50 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> { ("flexMin", number(data.flex_policy.min)), ("flexMax", number(data.flex_policy.max)), ("flexShrink", number(data.flex_policy.shrink)), + ("marginTop", number(data.margin.top)), + ("marginRight", number(data.margin.right)), + ("marginBottom", number(data.margin.bottom)), + ("marginLeft", number(data.margin.left)), + ("boxWidth", data.sizing.width.value()), + ("boxHeight", data.sizing.height.value()), + ("minWidth", number(data.sizing.min_width)), + ("minHeight", number(data.sizing.min_height)), + ("maxWidth", number(data.sizing.max_width)), + ("maxHeight", number(data.sizing.max_height)), + ("borderTop", number(data.border.top)), + ("borderRight", number(data.border.right)), + ("borderBottom", number(data.border.bottom)), + ("borderLeft", number(data.border.left)), + ("borderColor", Value::Text(data.border_color.clone())), + ( + "overflow", + Value::Text( + if data.clip_children { + "clip" + } else { + "visible" + } + .into(), + ), + ), + ( + "alignSelf", + Value::Text( + data.align_self + .map_or("auto", crate::layout::CrossAlign::name) + .into(), + ), + ), ]; + for (name, edge) in ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] + .into_iter() + .zip(data.padding_edges) + { + fields.push(( + name, + edge.map_or_else(|| Value::Text("auto".into()), number), + )); + } match &data.kind { Kind::Page => fields.extend([ ("mode", Value::Text(data.surface.mode.name().into())), @@ -373,6 +438,7 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> { ("padding", number(data.padding)), ("gap", number(data.gap)), ("mainAlign", Value::Text(data.main_align.name().into())), + ("crossAlign", Value::Text(data.cross_align.name().into())), ]); } fields @@ -390,6 +456,11 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { if data.flex_policy.min > data.flex_policy.max { return Err(VmError::InvalidArgument("flexMin exceeds flexMax")); } + if data.sizing.min_width > data.sizing.max_width + || data.sizing.min_height > data.sizing.max_height + { + return Err(VmError::InvalidArgument("box minimum exceeds maximum")); + } Ok(data) } @@ -430,6 +501,99 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu return Ok(()); } match name { + "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { + let index = match name { + "paddingTop" => 0, + "paddingRight" => 1, + "paddingBottom" => 2, + _ => 3, + }; + data.padding_edges[index] = if matches!(value, Value::Text(text) if text == "auto") { + None + } else { + Some(number()?) + }; + return Ok(()); + } + "borderTop" => { + data.border.top = number()?; + return Ok(()); + } + "borderRight" => { + data.border.right = number()?; + return Ok(()); + } + "borderBottom" => { + data.border.bottom = number()?; + return Ok(()); + } + "borderLeft" => { + data.border.left = number()?; + return Ok(()); + } + "borderColor" => { + let Value::Text(color) = value else { + return Err(bad()); + }; + crate::document::parse_color(color).map_err(|_| bad())?; + data.border_color.clone_from(color); + return Ok(()); + } + "overflow" => { + let Value::Text(overflow) = value else { + return Err(bad()); + }; + data.clip_children = match overflow.as_str() { + "clip" => true, + "visible" => false, + _ => { + return Err(VmError::InvalidArgument( + "overflow expects visible or clip; use ScrollBlock for scrolling", + )); + } + }; + return Ok(()); + } + "boxWidth" => { + data.sizing.width = crate::box_model::Dimension::parse(value)?; + return Ok(()); + } + "boxHeight" => { + data.sizing.height = crate::box_model::Dimension::parse(value)?; + return Ok(()); + } + "minWidth" => { + data.sizing.min_width = number()?; + return Ok(()); + } + "minHeight" => { + data.sizing.min_height = number()?; + return Ok(()); + } + "maxWidth" => { + data.sizing.max_width = number()?; + return Ok(()); + } + "maxHeight" => { + data.sizing.max_height = number()?; + return Ok(()); + } + "marginTop" => { + data.margin.top = number()?; + return Ok(()); + } + "marginRight" => { + data.margin.right = number()?; + return Ok(()); + } + "marginBottom" => { + data.margin.bottom = number()?; + return Ok(()); + } + "marginLeft" => { + data.margin.left = number()?; + return Ok(()); + } "flexBasis" => { data.flex_policy.basis = if matches!(value, Value::Nil) || matches!(value, Value::Text(text) if text == "auto") @@ -459,6 +623,21 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu return Ok(()); } let container = data.is_container(); + if name == "alignSelf" || (container && name == "crossAlign") { + let Value::Text(value) = value else { + return Err(bad()); + }; + if name == "alignSelf" { + data.align_self = if value == "auto" { + None + } else { + Some(crate::layout::CrossAlign::parse(value)?) + }; + } else { + data.cross_align = crate::layout::CrossAlign::parse(value)?; + } + return Ok(()); + } if container && name == "mainAlign" { let Value::Text(value) = value else { return Err(bad()); diff --git a/src/model/presentation.rs b/src/model/presentation.rs index 3460f7e..76e7277 100644 --- a/src/model/presentation.rs +++ b/src/model/presentation.rs @@ -183,6 +183,14 @@ mod tests { flex: 0.0, flex_policy: crate::layout::FlexPolicy::default(), main_align: crate::layout::MainAlign::Start, + cross_align: crate::layout::CrossAlign::Start, + align_self: None, + margin: crate::layout::Insets::default(), + sizing: crate::box_model::Sizing::default(), + padding_edges: [None; 4], + border: crate::layout::Insets::default(), + border_color: "#D8DCE2".into(), + clip_children: false, block_id: "shared".into(), protocol_key: "rect".into(), slots: Vec::new(), diff --git a/src/model/snapshot.rs b/src/model/snapshot.rs index f4b0542..a35df6b 100644 --- a/src/model/snapshot.rs +++ b/src/model/snapshot.rs @@ -259,6 +259,10 @@ impl RenderCache { children, }, Kind::Spacer(size) => UiKind::Spacer(*size), + Kind::Overlay => UiKind::Overlay { + padding: data.padding, + children, + }, Kind::Row => UiKind::Row { padding: data.padding, gap: data.gap, @@ -300,6 +304,14 @@ impl RenderCache { flex: data.flex, flex_policy: data.flex_policy, main_align: data.main_align, + cross_align: data.cross_align, + align_self: data.align_self, + margin: data.margin, + sizing: data.sizing, + padding_edges: data.padding_edges, + border: data.border, + border_color: data.border_color.clone(), + clip_children: data.clip_children, block_id: block.to_string(), protocol_key, slots: data.children.iter().map(|c| c.slot).collect(), diff --git a/src/render/box_model.rs b/src/render/box_model.rs new file mode 100644 index 0000000..c3a5924 --- /dev/null +++ b/src/render/box_model.rs @@ -0,0 +1,124 @@ +//! Native border-box sizing. An unbounded axis cannot resolve `fill`. +use crate::{layout::Size, ui_bridge::MAX_DIMENSION}; +use anyhow::Result; +use endor_core::{Value, VmError}; + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum Dimension { + #[default] + Auto, + Fixed(f32), + Fill, +} + +impl Dimension { + pub fn value(self) -> Value { + match self { + Self::Auto => Value::Text("auto".into()), + Self::Fill => Value::Text("fill".into()), + Self::Fixed(value) => Value::Number((value as f64).into()), + } + } + + pub fn parse(value: &Value) -> Result { + match value { + Value::Text(text) if text == "auto" => Ok(Self::Auto), + Value::Text(text) if text == "fill" => Ok(Self::Fill), + Value::Number(_) => Ok(Self::Fixed(crate::scroll::coordinate(value).and_then( + |n| { + if n >= 0.0 { + Ok(n) + } else { + Err(VmError::InvalidArgument( + "box dimensions must be nonnegative", + )) + } + }, + )?)), + _ => Err(VmError::InvalidArgument( + "box dimension expects auto, fill or a nonnegative number", + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Sizing { + pub width: Dimension, + pub height: Dimension, + pub min_width: f32, + pub min_height: f32, + pub max_width: f32, + pub max_height: f32, +} + +impl Default for Sizing { + fn default() -> Self { + Self { + width: Dimension::Auto, + height: Dimension::Auto, + min_width: 0.0, + min_height: 0.0, + max_width: MAX_DIMENSION as f32, + max_height: MAX_DIMENSION as f32, + } + } +} + +impl Sizing { + /// Constrain descendants using the actual finite box before wrapping text. + pub fn available(self, mut constraints: [f32; 4]) -> [f32; 4] { + constraints[2] = constraints[2].min(self.max_width); + constraints[3] = constraints[3].min(self.max_height); + if let Dimension::Fixed(width) = self.width { + constraints[2] = constraints[2].min(width.max(self.min_width)); + } + if let Dimension::Fixed(height) = self.height { + constraints[3] = constraints[3].min(height.max(self.min_height)); + } + constraints[0] = constraints[0].min(constraints[2]); + constraints[1] = constraints[1].min(constraints[3]); + constraints + } + + pub fn resolve(self, intrinsic: Size, constraints: [f32; 4]) -> Result { + fn axis( + policy: Dimension, + intrinsic: f32, + min: f32, + max: f32, + available: f32, + ) -> Result { + let value = match policy { + Dimension::Auto => intrinsic, + Dimension::Fixed(value) => value, + Dimension::Fill => { + if available >= MAX_DIMENSION as f32 { + return Err(VmError::InvalidArgument( + "fill requires a finite parent allocation on this axis", + ) + .into()); + } + available + } + }; + Ok(value.clamp(min, max).min(available)) + } + Ok(Size { + width: axis( + self.width, + intrinsic.width, + self.min_width, + self.max_width, + constraints[2], + )?, + height: axis( + self.height, + intrinsic.height, + self.min_height, + self.max_height, + constraints[3], + )?, + }) + } +} diff --git a/src/render/layout.rs b/src/render/layout.rs index d77d37c..5807a5d 100644 --- a/src/render/layout.rs +++ b/src/render/layout.rs @@ -10,6 +10,72 @@ use std::collections::{HashMap, HashSet}; const MAX_LAYOUT_VISITS: usize = 10_000; +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Insets { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +impl Insets { + pub fn horizontal(self) -> f32 { + self.left + self.right + } + pub fn vertical(self) -> f32 { + self.top + self.bottom + } + fn outer(self, size: Size) -> Size { + Size { + width: size.width + self.horizontal(), + height: size.height + self.vertical(), + } + } +} + +/// Alignment inside a finite cross-axis content box. Overflow remains start +/// aligned so that content cannot become inaccessible beyond the leading edge. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CrossAlign { + #[default] + Start, + Center, + End, + Stretch, +} + +impl CrossAlign { + pub fn name(self) -> &'static str { + match self { + Self::Start => "start", + Self::Center => "center", + Self::End => "end", + Self::Stretch => "stretch", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "start" => Ok(Self::Start), + "center" => Ok(Self::Center), + "end" => Ok(Self::End), + "stretch" => Ok(Self::Stretch), + _ => Err(endor_core::VmError::InvalidArgument( + "crossAlign/alignSelf expects start, center, end or stretch (alignSelf also accepts auto)", + )), + } + } + + fn offset(self, available: f32, occupied: f32) -> f32 { + let spare = (available - occupied).max(0.0); + match self { + Self::Start | Self::Stretch => 0.0, + Self::Center => spare / 2.0, + Self::End => spare, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum MainAlign { #[default] @@ -67,9 +133,11 @@ pub struct Size { #[derive(Debug, Clone, PartialEq)] enum Shape { + Sized(Box, crate::box_model::Sizing, [f32; 4]), Text(String, u32, f32), Rect(f32, f32), - Container(bool, f32, f32, Vec), + Container(bool, Insets, f32, Vec), + Padded(Box, Insets), } #[derive(Debug, Clone, Hash, PartialEq, Eq)] @@ -122,7 +190,25 @@ pub fn root_constraints(viewport: Option) -> [f32; 4] { }); [0.0, 0.0, size.width, size.height] } + +fn content_limit(available: f32, inset: f32) -> f32 { + if available >= MAX_DIMENSION as f32 { + available + } else { + (available - inset).max(0.0) + } +} pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] { + let parent = node.sizing.available(parent); + if let UiKind::Overlay { .. } = node.kind { + let padding = insets(node); + return [ + 0.0, + 0.0, + content_limit(parent[2], padding.horizontal()), + content_limit(parent[3], padding.vertical()), + ]; + } if matches!(node.kind, UiKind::Canvas { .. }) { return root_constraints(None); } @@ -138,33 +224,88 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] { 0.0, 0.0, MAX_DIMENSION as f32, - (parent[3] - padding * 2.0).max(0.0), + content_limit(parent[3], padding.vertical()), ], Some((false, padding, _)) => [ 0.0, 0.0, - (parent[2] - padding * 2.0).max(0.0), + content_limit(parent[2], padding.horizontal()), MAX_DIMENSION as f32, ], None => parent, } } + +pub fn margin_constraints(child: &UiNode, mut constraints: [f32; 4]) -> [f32; 4] { + for (axis, margin) in [child.margin.horizontal(), child.margin.vertical()] + .into_iter() + .enumerate() + { + if constraints[axis + 2] < MAX_DIMENSION as f32 { + constraints[axis + 2] = (constraints[axis + 2] - margin).max(0.0); + constraints[axis] = constraints[axis].min(constraints[axis + 2]); + } + } + constraints +} + +/// Main-axis fill first measures against a finite offer, then participates in +/// weighted allocation. The unlimited-axis sentinel is never made finite by padding. +pub fn occurrence_constraints( + parent: &UiNode, + child: &UiNode, + mut base: [f32; 4], + offer: Size, +) -> [f32; 4] { + let inset = insets(parent); + for (axis, dimension, available, padding) in [ + (0, child.sizing.width, offer.width, inset.horizontal()), + (1, child.sizing.height, offer.height, inset.vertical()), + ] { + if dimension == crate::box_model::Dimension::Fill + && base[axis + 2] >= MAX_DIMENSION as f32 + && available < MAX_DIMENSION as f32 + { + base[axis + 2] = (available - padding).max(0.0); + base[axis] = base[axis].min(base[axis + 2]); + } + } + margin_constraints(child, base) +} + +pub fn initial_child_constraints( + parent: &UiNode, + child: &UiNode, + constraints: [f32; 4], +) -> [f32; 4] { + let offer = parent.sizing.available(constraints); + occurrence_constraints( + parent, + child, + child_constraints(parent, constraints), + Size { + width: offer[2], + height: offer[3], + }, + ) +} /// Scroll cross-axis constraints come from this occurrence's actual viewport, /// including a source parent's allocation rather than only constructor size. pub fn arranged_child_constraints(node: &UiNode, available: [f32; 4], bounds: Size) -> [f32; 4] { - if let UiKind::Scroll { padding, axis, .. } = node.kind { + if let UiKind::Scroll { axis, .. } = node.kind { + let padding = insets(node); return [ 0.0, 0.0, if axis.horizontal() { MAX_DIMENSION as f32 } else { - (bounds.width - padding * 2.0).max(0.0) + (bounds.width - padding.horizontal()).max(0.0) }, if axis.vertical() { MAX_DIMENSION as f32 } else { - (bounds.height - padding * 2.0).max(0.0) + (bounds.height - padding.vertical()).max(0.0) }, ]; } @@ -178,12 +319,30 @@ pub fn constrained(size: Size, constraints: [f32; 4]) -> Size { } } -fn container(node: &UiNode) -> Option<(bool, f32, f32)> { +/// Effective content inset, including borders, in border-box coordinates. +pub fn insets(node: &UiNode) -> Insets { + let fallback = match node.kind { + UiKind::Row { padding, .. } + | UiKind::Column { padding, .. } + | UiKind::Button { padding, .. } + | UiKind::Scroll { padding, .. } + | UiKind::Overlay { padding, .. } => padding, + _ => 0.0, + }; + Insets { + top: node.padding_edges[0].unwrap_or(fallback) + node.border.top, + right: node.padding_edges[1].unwrap_or(fallback) + node.border.right, + bottom: node.padding_edges[2].unwrap_or(fallback) + node.border.bottom, + left: node.padding_edges[3].unwrap_or(fallback) + node.border.left, + } +} + +fn container(node: &UiNode) -> Option<(bool, Insets, f32)> { match &node.kind { - UiKind::Row { padding, gap, .. } => Some((true, *padding, *gap)), - UiKind::Scroll { padding, gap, .. } => Some((false, *padding, *gap)), - UiKind::Column { padding, gap, .. } | UiKind::Button { padding, gap, .. } => { - Some((false, *padding, *gap)) + UiKind::Row { gap, .. } => Some((true, insets(node), *gap)), + UiKind::Scroll { gap, .. } => Some((false, insets(node), *gap)), + UiKind::Column { gap, .. } | UiKind::Button { gap, .. } => { + Some((false, insets(node), *gap)) } _ => None, } @@ -195,6 +354,10 @@ pub fn main_axis(node: &UiNode) -> Option { fn measure_shape(shape: &Shape) -> Result { let size = match shape { + Shape::Sized(shape, sizing, constraints) => { + sizing.resolve(measure_shape(shape)?, *constraints)? + } + Shape::Padded(shape, padding) => padding.outer(measure_shape(shape)?), Shape::Text(value, size, width) => { let style = TextStyle::ui(*size); let intrinsic = text::measure(value, style); @@ -213,13 +376,21 @@ fn measure_shape(shape: &Shape) -> Result { .iter() .map(|s| if *row { s.width } else { s.height }) .sum::() - + padding * 2.0 + + if *row { + padding.horizontal() + } else { + padding.vertical() + } + gap * children.len().saturating_sub(1) as f32; let cross = children .iter() .map(|s| if *row { s.height } else { s.width }) .fold(0.0_f32, f32::max) - + padding * 2.0; + + if *row { + padding.vertical() + } else { + padding.horizontal() + }; if *row { Size { width: along, @@ -239,7 +410,25 @@ fn measure_shape(shape: &Shape) -> Result { } fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result { - Ok(match &node.kind { + let available = node.sizing.available(constraints); + let shape = match &node.kind { + UiKind::Overlay { .. } => { + let padding = insets(node); + let size = children.into_iter().zip(node.children()).fold( + Size::default(), + |size, (child, node)| { + let child = node.margin.outer(child); + Size { + width: size.width.max(child.width), + height: size.height.max(child.height), + } + }, + ); + Shape::Rect( + size.width + padding.horizontal(), + size.height + padding.vertical(), + ) + } UiKind::Canvas { positions, .. } => { let mut size = Size::default(); for (position, child) in positions.iter().zip(children) { @@ -248,14 +437,34 @@ fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result Shape::Text(value.clone(), *size, constraints[2]), + UiKind::Text { value, size, .. } => { + let padding = insets(node); + Shape::Padded( + Box::new(Shape::Text( + value.clone(), + *size, + (available[2] - padding.horizontal()).max(0.0), + )), + padding, + ) + } UiKind::Rect { width, height, .. } => Shape::Rect(*width, *height), UiKind::Spacer(size) => Shape::Rect(*size, *size), UiKind::Scroll { width, height, .. } => Shape::Rect(*width, *height), _ => { let (row, padding, gap) = container(node).context("unknown native visual protocol")?; + let children = children + .into_iter() + .zip(node.children()) + .map(|(size, child)| child.margin.outer(size)) + .collect(); Shape::Container(row, padding, gap, children) } + }; + Ok(if node.sizing == crate::box_model::Sizing::default() { + shape + } else { + Shape::Sized(Box::new(shape), node.sizing, constraints) }) } @@ -275,14 +484,18 @@ pub struct Context { pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option { let size_for_text = size; + let inset = insets(node); match &node.kind { UiKind::Text { value, size, color } => Some(RenderCommand::Text { - x, - y, + x: x + inset.left, + y: y + inset.top, value: value.clone(), size: *size, color: color.clone(), - wrap_bounds: Some([size_for_text.width, size_for_text.height]), + wrap_bounds: Some([ + (size_for_text.width - inset.horizontal()).max(0.0), + (size_for_text.height - inset.vertical()).max(0.0), + ]), }), UiKind::Rect { radius, color, .. } => Some(RenderCommand::RoundedRect { x, @@ -304,19 +517,126 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option Vec { + let mut commands: Vec<_> = paint_native(node, x, y, size).into_iter().collect(); + let border = node.border; + for [x, y, width, height] in [ + [x, y, size.width, border.top.min(size.height)], + [ + x, + y + (size.height - border.bottom).max(0.0), + size.width, + border.bottom.min(size.height), + ], + [x, y, border.left.min(size.width), size.height], + [ + x + (size.width - border.right).max(0.0), + y, + border.right.min(size.width), + size.height, + ], + ] { + if width > 0.0 && height > 0.0 { + commands.push(RenderCommand::RoundedRect { + x, + y, + width, + height, + radius: 0.0, + color: node.border_color.clone(), + }); + } + } + commands +} + /// Allocate main-axis space with bounded growth and basis-weighted shrinking. /// Preferred sizes come from this occurrence's first measurement in the solve; /// remeasurement must not turn a prior allocation into a new intrinsic basis. +fn main_fill(child: &UiNode, row: bool) -> bool { + (if row { + child.sizing.width + } else { + child.sizing.height + }) == crate::box_model::Dimension::Fill +} + +fn main_limits(child: &UiNode, row: bool) -> (f32, f32) { + let sizing = child.sizing; + let (min, max) = if row { + (sizing.min_width, sizing.max_width) + } else { + (sizing.min_height, sizing.max_height) + }; + ( + child.flex_policy.min.clamp(min, max), + child.flex_policy.max.clamp(min, max), + ) +} + pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[Size]) -> Vec { + if matches!(node.kind, UiKind::Overlay { .. }) { + let inset = insets(node); + return children + .iter() + .zip(node.children()) + .map(|(size, child)| { + let mut size = *size; + if child.sizing.width == crate::box_model::Dimension::Fill { + size.width = (bounds.width - inset.horizontal() - child.margin.horizontal()) + .max(0.0) + .clamp(child.sizing.min_width, child.sizing.max_width); + } + if child.sizing.height == crate::box_model::Dimension::Fill + || (child.sizing.height == crate::box_model::Dimension::Auto + && child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch) + { + size.height = (bounds.height - inset.vertical() - child.margin.vertical()) + .max(0.0) + .clamp(child.sizing.min_height, child.sizing.max_height); + } + size + }) + .collect(); + } let Some((row, padding, gap)) = container(node) else { return children.to_vec(); }; let nodes = node.children(); + let mut output = children.to_vec(); + for (child, size) in nodes.iter().zip(&mut output) { + if child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch { + let sizing = child.sizing; + if row && sizing.height == crate::box_model::Dimension::Auto { + size.height = (bounds.height - padding.vertical() - child.margin.vertical()) + .max(0.0) + .clamp(sizing.min_height, sizing.max_height); + } else if !row && sizing.width == crate::box_model::Dimension::Auto { + size.width = (bounds.width - padding.horizontal() - child.margin.horizontal()) + .max(0.0) + .clamp(sizing.min_width, sizing.max_width); + } + } + } if !nodes.iter().any(|child| child.has_flex()) { - return children.to_vec(); + return output; } let space = ((if row { bounds.width } else { bounds.height }) as f64 - - padding as f64 * 2.0 + - if row { + padding.horizontal() + } else { + padding.vertical() + } as f64 + - nodes + .iter() + .map(|child| { + if row { + child.margin.horizontal() + } else { + child.margin.vertical() + } + }) + .sum::() as f64 - gap as f64 * children.len().saturating_sub(1) as f64) .max(0.0); let bases: Vec = nodes @@ -324,7 +644,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S .zip(preferred) .map(|(child, size)| { child.flex_policy.basis.unwrap_or_else(|| { - if child.flex > 0.0 { + if child.flex > 0.0 || main_fill(child, row) { 0.0 } else if row { size.width @@ -338,7 +658,8 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S .iter() .zip(&bases) .map(|(child, basis)| { - basis.clamp(child.flex_policy.min as f64, child.flex_policy.max as f64) + let (min, max) = main_limits(child, row); + basis.clamp(min as f64, max as f64) }) .collect(); let free = space - sizes.iter().sum::(); @@ -348,7 +669,11 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S .zip(&bases) .map(|(child, basis)| { if growing { - child.flex as f64 + if main_fill(child, row) && child.flex == 0.0 { + 1.0 + } else { + child.flex as f64 + } } else { child.flex_policy.shrink as f64 * basis } @@ -357,11 +682,8 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S let limits: Vec = nodes .iter() .map(|child| { - if growing { - child.flex_policy.max as f64 - } else { - child.flex_policy.min as f64 - } + let (min, max) = main_limits(child, row); + if growing { max as f64 } else { min as f64 } }) .collect(); let mut active: Vec = (0..nodes.len()) @@ -400,7 +722,6 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S } }); } - let mut output = children.to_vec(); for (size, along) in output.iter_mut().zip(sizes) { if row { size.width = along as f32; @@ -418,6 +739,37 @@ pub fn arrange_native( bounds: Size, children: &[Size], ) -> Result> { + if let UiKind::Overlay { .. } = node.kind { + let padding = insets(node); + let horizontal = match node.main_align { + MainAlign::Center => CrossAlign::Center, + MainAlign::End => CrossAlign::End, + _ => CrossAlign::Start, + }; + return children + .iter() + .zip(node.children()) + .map(|(size, child)| { + let outer = child.margin.outer(*size); + let point = ( + x + padding.left + + child.margin.left + + horizontal.offset(bounds.width - padding.horizontal(), outer.width), + y + padding.top + + child.margin.top + + child + .align_self + .unwrap_or(node.cross_align) + .offset(bounds.height - padding.vertical(), outer.height), + ); + coordinate(point.0)?; + coordinate(point.1)?; + coordinate(point.0 + size.width)?; + coordinate(point.1 + size.height)?; + Ok(point) + }) + .collect(); + } if let UiKind::Canvas { positions, .. } = &node.kind { return Ok(positions .iter() @@ -429,11 +781,25 @@ pub fn arrange_native( }; let occupied = children .iter() - .map(|child| if row { child.width } else { child.height }) + .zip(node.children()) + .map(|(child, node)| { + if row { + child.width + node.margin.horizontal() + } else { + child.height + node.margin.vertical() + } + }) .sum::() + gap * children.len().saturating_sub(1) as f32; let available = if row { bounds.width } else { bounds.height }; - let spare = (available - padding * 2.0 - occupied).max(0.0); + let spare = (available + - if row { + padding.horizontal() + } else { + padding.vertical() + } + - occupied) + .max(0.0); let offset = match node.main_align { MainAlign::Center => spare / 2.0, MainAlign::End => spare, @@ -444,13 +810,34 @@ pub fn arrange_native( } else { 0.0 }; - let mut cursor = padding + offset; + let mut cursor = if row { padding.left } else { padding.top } + offset; let mut points = Vec::with_capacity(children.len()); - for child in children { + for (index, child) in children.iter().enumerate() { + let child_node = &node.children()[index]; + let margin = child_node.margin; + let align = child_node.align_self.unwrap_or(node.cross_align); + let cross = align.offset( + if row { + bounds.height - padding.vertical() + } else { + bounds.width - padding.horizontal() + }, + if row { + child.height + margin.vertical() + } else { + child.width + margin.horizontal() + }, + ); let point = if row { - (x + cursor, y + padding) + ( + x + cursor + margin.left, + y + padding.top + cross + margin.top, + ) } else { - (x + padding, y + cursor) + ( + x + padding.left + cross + margin.left, + y + cursor + margin.top, + ) }; coordinate(point.0)?; coordinate(point.1)?; @@ -458,6 +845,11 @@ pub fn arrange_native( coordinate(point.1 + child.height)?; points.push(point); cursor += if row { child.width } else { child.height }; + cursor += if row { + margin.horizontal() + } else { + margin.vertical() + }; cursor += gap + distributed_gap; } Ok(points) @@ -569,7 +961,7 @@ impl LayoutRuntime { child.as_ref(), Some(index), *slot, - child_constraints(node, constraints), + initial_child_constraints(node, child, constraints), )); } } @@ -617,8 +1009,8 @@ impl LayoutRuntime { height: size.height, interactive: item.node.interactive, }); - if !structural && let Some(command) = paint_native(item.node, x, y, size) { - commands.push(command); + if !structural { + commands.extend(paint_commands(item.node, x, y, size)); } if structural { continue; diff --git a/src/render/mod.rs b/src/render/mod.rs index 431072b..548b104 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -1,4 +1,5 @@ // Module ownership follows the directory; crate-root aliases keep internal call sites stable. +pub(crate) mod box_model; pub(crate) mod display_list; pub(crate) mod geometry; pub(crate) mod layout; diff --git a/src/render/native_visual.rs b/src/render/native_visual.rs index 0eb1c74..bce935c 100644 --- a/src/render/native_visual.rs +++ b/src/render/native_visual.rs @@ -217,9 +217,14 @@ impl NativeCallable for Method { .map(|child| { size( child, - layout::arranged_child_constraints( + layout::occurrence_constraints( &node, - [0.0, 0.0, width, height], + child, + layout::arranged_child_constraints( + &node, + [0.0, 0.0, width, height], + Size { width, height }, + ), Size { width, height }, ), ) @@ -268,7 +273,7 @@ impl NativeCallable for Method { return Err(invalid("visual bounds exceed limits")); } let mut commands = Vec::new(); - if let Some(command) = layout::paint_native(&node, x, y, Size { width, height }) { + for command in layout::paint_commands(&node, x, y, Size { width, height }) { let fields = match command { RenderCommand::Text { x, diff --git a/src/render/scroll.rs b/src/render/scroll.rs index 348a89c..c7a8717 100644 --- a/src/render/scroll.rs +++ b/src/render/scroll.rs @@ -76,11 +76,8 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame .mounts .iter() .map(|m| { - let padding = match nodes[m.block_id.as_str()].kind { - UiKind::Scroll { padding, .. } => padding, - _ => 0.0, - }; - (m.x + m.width - padding, m.y + m.height - padding) + let inset = layout::insets(nodes[m.block_id.as_str()]); + (m.x + m.width - inset.right, m.y + m.height - inset.bottom) }) .collect(); for i in (0..frame.mounts.len()).rev() { @@ -104,22 +101,20 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame .parent .and_then(|id| contexts.get(&id).copied()) .unwrap_or_default(); - let padding = match nodes[m.block_id.as_str()].kind { - UiKind::Scroll { padding, .. } => padding, - _ => 0.0, - }; + let node = nodes[m.block_id.as_str()]; + let padding = layout::insets(node); let axis = match nodes[m.block_id.as_str()].kind { UiKind::Scroll { axis, .. } => axis, _ => Axis::Both, }; let limits = [ if axis.horizontal() { - (extent[i].0 + padding - m.x - m.width).max(0.0) + (extent[i].0 + padding.right - m.x - m.width).max(0.0) } else { 0.0 }, if axis.vertical() { - (extent[i].1 + padding - m.y - m.height).max(0.0) + (extent[i].1 + padding.bottom - m.y - m.height).max(0.0) } else { 0.0 }, @@ -128,6 +123,19 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame m.y -= dy; m.clip = clip; let mut child = (dx, dy, clip); + if node.clip_children { + let border = node.border; + let viewport = [ + m.x + border.left.min(m.width), + m.y + border.top.min(m.height), + (m.width - border.horizontal()).max(0.0), + (m.height - border.vertical()).max(0.0), + ]; + child.2 = Some( + clip.map(|clip| intersection(clip, viewport)) + .unwrap_or(viewport), + ); + } if scrolling[i] { let weak = table .active @@ -149,13 +157,15 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame dx + x, dy + y, Some( - clip.map(|clip| intersection(clip, viewport)) + child + .2 + .map(|clip| intersection(clip, viewport)) .unwrap_or(viewport), ), ); } contexts.insert(m.id, child); - if let Some(command) = layout::paint_native( + for command in layout::paint_commands( nodes[m.block_id.as_str()], m.x, m.y, diff --git a/src/render/source_layout.rs b/src/render/source_layout.rs index fcc7ac9..0552742 100644 --- a/src/render/source_layout.rs +++ b/src/render/source_layout.rs @@ -165,7 +165,11 @@ impl NativeCallable for Step { Some( plan.runtime .measure_node(&node, children.clone(), constraints) - .map_err(|_| invalid("native measurement exceeds layout limits"))?, + .map_err(|error| { + error.downcast_ref::().cloned().unwrap_or_else(|| { + invalid("native measurement exceeds layout limits") + }) + })?, ) } else { if !intrinsic { @@ -211,8 +215,7 @@ impl NativeCallable for Step { if matches!(self.phase, Phase::Arrange) { plan.runtime.arranged += 1; plan.native_arrange[self.index] = intrinsic; - plan.custom_arrange[self.index] = - !intrinsic || node.children().iter().any(|child| child.has_flex()); + plan.custom_arrange[self.index] = !intrinsic || node.allocates_children(); } (node, children, preferred, bounds, native_size) }; @@ -532,7 +535,13 @@ impl NativeCallable for Settle { Size { width, height }, ); for child in children { - let mut constraints = base; + let natural = layout::occurrence_constraints( + &plan.nodes[index], + &plan.nodes[child], + base, + Size { width, height }, + ); + let mut constraints = natural; if let Some(width) = plan.allocations[child][0] { constraints[0] = width; constraints[2] = width; @@ -554,8 +563,8 @@ impl NativeCallable for Settle { // own replacement measurement. Source arrange // allocations remain authoritative. plan.allocations[child][main] = None; - constraints[main] = base[main]; - constraints[main + 2] = base[main + 2]; + constraints[main] = natural[main]; + constraints[main + 2] = natural[main + 2]; } } } @@ -844,7 +853,7 @@ pub fn start( let mut nodes = Vec::new(); let mut work = vec![root.clone()]; while let Some(node) = work.pop() { - custom |= node.has_flex(); + custom |= node.has_flex() || node.allocates_children(); work.extend(node.children().iter().rev().cloned()); nodes.push(node); } @@ -878,9 +887,12 @@ pub fn start( } let mut constraints = vec![runtime.root_constraints(); nodes.len()]; for index in 0..nodes.len() { - let next = layout::child_constraints(&nodes[index], constraints[index]); for child in &children[index] { - constraints[*child] = next; + constraints[*child] = layout::initial_child_constraints( + &nodes[index], + &nodes[*child], + constraints[index], + ); } } let count = nodes.len(); diff --git a/src/render/source_paint.rs b/src/render/source_paint.rs index b13ca3f..5a2e675 100644 --- a/src/render/source_paint.rs +++ b/src/render/source_paint.rs @@ -425,7 +425,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - let node = nodes .get(mount.block_id.as_str()) .ok_or_else(|| anyhow::anyhow!("paint projection has no node"))?; - if let Some(command) = layout::paint_native( + commands.extend(layout::paint_commands( node, mount.x, mount.y, @@ -433,9 +433,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - width: mount.width, height: mount.height, }, - ) { - commands.push(command); - } + )); } clips.extend(std::iter::repeat_n(mount.clip, commands.len() - before)); if commands.len() > 10_000 { diff --git a/src/runtime/api/guide.rs b/src/runtime/api/guide.rs index 9b26d62..bc3035e 100644 --- a/src/runtime/api/guide.rs +++ b/src/runtime/api/guide.rs @@ -142,6 +142,8 @@ ScrollBlock.axis is a managed visual attribute: "both" (default), "vertical" or ContainerBlock.mainAlign accepts "start" (default), "center", "end" or "space-between". Native arrangement uses allocated Bounds after padding/minimum gap, distributes positive remaining main-axis space and keeps child sizes unchanged. Overflow keeps start placement. Explicit source arrange placements remain authoritative; this is alignment, not flex sizing. +Finite box layout: Overlay(padding, gap, children...) stacks children (gap is unused); mainAlign aligns horizontally and crossAlign vertically. Row/Column crossAlign accepts "start", "center", "end" or "stretch". Block.alignSelf defaults to "auto" and can override crossAlign per occurrence. marginTop/Right/Bottom/Left participate in measurement, alignment and flex allocation, without collapsing. paddingTop/Right/Bottom/Left override a side of the container padding; "auto" restores its fallback. borderTop/Right/Bottom/Left reserve inner border space; borderColor controls native border paint. Source paint overrides remain authoritative. boxWidth/boxHeight accept "auto", a nonnegative border-box dimension, or "fill" (finite parent required). Main-axis fill shares remaining space with weight 1 unless flex is set; minWidth/minHeight/maxWidth/maxHeight bound each dimension. Stretch changes only auto cross-axis dimensions. overflow is "visible" or "clip"; clipping affects descendant paint and hits. ScrollBlock handles scrolling independently of its content layout. See examples/box-layout.endor and docs/scene-architecture.md. Scene2D/CanvasViewport/Camera2D and Scene3D/Viewport3D are still pending; Page.mode remains a transitional API. + Block.flex is a nonnegative weight (default 0). Native containers reserve fixed measured children, padding and gaps, then share remaining allocated main-axis space between positive weights with zero basis. Actual child allocation feeds remeasurement. It does not request parent expansion. Further controls are described below. diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index 47bf4c5..0c10651 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -3993,6 +3993,427 @@ fn main_alignment_updates_live_geometry_without_changing_sizes() { } } +#[test] +fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() { + let (mut session, initial) = ReplSession::load(r#" + class Tall: Row { + function measure(self: Tall, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) } + } + class Wide: Column { + function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) } + } + shared = RectBlock(20, 20) + row = Tall(10, 0, shared) + column = Wide(10, 0, shared) + row.crossAlign = "center" + column.crossAlign = "end" + page = Page() + page.render(Column(0, 0, row, column)) + "#, "app.main", 294, 1).unwrap(); + initial.validate().unwrap(); + for (expression, expected) in [ + ("shared.mounts().at(0).bounds().x", "10"), + ("shared.mounts().at(0).bounds().y", "40"), + ("shared.mounts().at(1).bounds().x", "170"), + ("shared.mounts().at(1).bounds().y", "110"), + ] { + assert_eq!( + session.execute(expression, "app.main").unwrap().value, + expected + ); + } + session + .execute("shared.alignSelf = \"start\"", "app.main") + .unwrap(); + assert_eq!( + session + .execute("shared.mounts().at(0).bounds().y", "app.main") + .unwrap() + .value, + "10" + ); + assert_eq!( + session + .execute("shared.mounts().at(1).bounds().x", "app.main") + .unwrap() + .value, + "10" + ); + session + .execute( + "shared.alignSelf = \"auto\"\nrow.crossAlign = \"end\"", + "app.main", + ) + .unwrap(); + assert_eq!( + session + .execute("shared.mounts().at(0).bounds().y", "app.main") + .unwrap() + .value, + "70" + ); + assert_eq!( + session + .execute("shared.mounts().at(1).bounds().x", "app.main") + .unwrap() + .value, + "170" + ); +} + +#[test] +fn cross_alignment_preserves_source_placements_and_starts_overflow() { + let (mut session, initial) = ReplSession::load(r#" + class Placed: Row { + function arrange(self: Placed, mount: Mount, geometry: Bounds): Arrangement { + result = Row.arrange(self, mount, geometry) + result.children.at(0).y = 7 + return result + } + } + class Small: Column { + function measure(self: Small, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(10, 30) } + } + first = RectBlock(20, 20) + second = RectBlock(40, 20) + placed = Placed(0, 0, first) + placed.crossAlign = "end" + small = Small(0, 0, second) + small.crossAlign = "center" + page = Page() + page.render(Column(0, 0, placed, small)) + "#, "app.main", 295, 1).unwrap(); + initial.validate().unwrap(); + assert_eq!( + session + .execute("first.mounts().at(0).bounds().y", "app.main") + .unwrap() + .value, + "7" + ); + assert_eq!( + session + .execute("second.mounts().at(0).bounds().x", "app.main") + .unwrap() + .value, + "0" + ); +} + +#[test] +fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() { + let (mut session, initial) = ReplSession::load(r#" + class Wide: Row { + function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) } + } + first = RectBlock(20, 20) + first.marginLeft = 3 + first.marginRight = 7 + first.marginTop = 5 + first.marginBottom = 15 + second = RectBlock(20, 20) + second.flex = 1 + row = Wide(10, 10, first, second) + row.crossAlign = "center" + intrinsic = Row(10, 10, first, RectBlock(30, 30)) + page = Page() + page.render(Column(0, 0, row, intrinsic)) + "#, "app.main", 296, 1).unwrap(); + initial.validate().unwrap(); + for (expression, expected) in [ + ("first.mounts().at(0).bounds().x", "13"), + ("first.mounts().at(0).bounds().y", "35"), + ("second.mounts().at(0).bounds().x", "50"), + ("second.mounts().at(0).bounds().width", "140"), + ("intrinsic.mounts().at(0).bounds().width", "90"), + ("intrinsic.mounts().at(0).bounds().height", "60"), + ] { + assert_eq!( + session.execute(expression, "app.main").unwrap().value, + expected, + "{expression}" + ); + } + session + .execute("first.marginRight = 17", "app.main") + .unwrap(); + assert_eq!( + session + .execute("second.mounts().at(0).bounds().width", "app.main") + .unwrap() + .value, + "130" + ); + assert_eq!( + session + .execute("intrinsic.mounts().at(0).bounds().width", "app.main") + .unwrap() + .value, + "100" + ); +} + +#[test] +fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() { + let (mut session, initial) = ReplSession::load( + r##" + child = RectBlock(20, 10) + child.marginLeft = 5 + child.marginTop = 4 + column = Column(0, 0, child) + column.boxWidth = 200 + column.boxHeight = 120 + column.paddingLeft = 11 + column.paddingTop = 7 + column.borderLeft = 3 + column.borderTop = 2 + column.borderColor = "#FF0000" + overlay = Overlay(10, 0, column) + overlay.boxWidth = 300 + overlay.boxHeight = 200 + overlay.mainAlign = "center" + overlay.crossAlign = "end" + page = Page() + page.render(overlay) + "##, + "app.main", + 297, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + for (expression, expected) in [ + ("overlay.mounts().at(0).bounds().width", "300"), + ("column.mounts().at(0).bounds().x", "50"), + ("column.mounts().at(0).bounds().y", "70"), + ("column.mounts().at(0).bounds().width", "200"), + ("child.mounts().at(0).bounds().x", "69"), + ("child.mounts().at(0).bounds().y", "83"), + ] { + assert_eq!( + session.execute(expression, "app.main").unwrap().value, + expected, + "{expression}" + ); + } + assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { x, y, width, height, color, .. } if *x == 50.0 && *y == 70.0 && *width == 3.0 && *height == 120.0 && color == "#FF0000"))); + session + .execute("column.maxWidth = 160\ncolumn.minHeight = 140", "app.main") + .unwrap(); + assert_eq!( + session + .execute("column.mounts().at(0).bounds().width", "app.main") + .unwrap() + .value, + "160" + ); + assert_eq!( + session + .execute("column.mounts().at(0).bounds().height", "app.main") + .unwrap() + .value, + "140" + ); +} + +#[test] +fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { + let (mut session, initial) = ReplSession::load(r#" + text = TextBlock("Text wrapping follows the finite border box rather than the original unbounded column width.") + text.boxWidth = "fill" + text.paddingLeft = 8 + text.paddingRight = 12 + column = Column(0, 0, text) + column.boxWidth = 160 + page = Page() + page.render(column) + "#, "app.main", 298, 1).unwrap(); + initial.validate().unwrap(); + assert_eq!( + session + .execute("text.mounts().at(0).bounds().width", "app.main") + .unwrap() + .value, + "160" + ); + assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::Text { x, wrap_bounds: Some([width, _]), .. } if *x == 8.0 && *width == 140.0))); + let error = ReplSession::load( + r#" + rect = RectBlock(10, 10) + rect.boxHeight = "fill" + page = Page() + page.render(Column(0, 0, rect)) + "#, + "app.main", + 299, + 1, + ) + .err() + .expect("unbounded fill must fail"); + assert!( + format!("{error:#}").contains("fill requires a finite parent allocation"), + "{error:#}" + ); +} + +#[test] +fn clipping_a_box_limits_descendant_paint_and_hit_geometry() { + let (_, initial) = ReplSession::load(r#" + class Overflow: RectBlock { + function measure(self: Overflow, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(100, 20) } + } + child = Button("outside") + child.boxWidth = 100 + row = Row(0, 0, Overflow(100, 20), child) + row.boxWidth = 60 + row.boxHeight = 40 + row.borderLeft = 2 + row.overflow = "clip" + page = Page() + page.render(row) + "#, "app.main", 300, 1).unwrap(); + initial.validate().unwrap(); + let child = initial + .frame + .mounts + .iter() + .find(|mount| mount.interactive) + .unwrap(); + assert_eq!(child.clip, Some([2.0, 0.0, 58.0, 40.0])); + assert!(child.x >= 100.0); + assert!( + initial + .frame + .hit_test(crate::geometry::Point::new(child.x + 1.0, child.y + 1.0)) + .is_none() + ); +} + +#[test] +fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() { + let (mut session, initial) = ReplSession::load( + r#" + first = RectBlock(10, 10) + first.boxWidth = "fill" + first.marginLeft = 5 + first.maxHeight = 30 + second = RectBlock(10, 10) + second.boxWidth = "fill" + second.maxWidth = 50 + fixed = RectBlock(40, 20) + fixed.alignSelf = "center" + row = Row(10, 5, first, second, fixed) + row.boxWidth = 240 + row.boxHeight = 80 + row.crossAlign = "stretch" + page = Page() + page.render(row) + "#, + "app.main", + 301, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + for (expression, expected) in [ + ("first.mounts().at(0).bounds().width", "115"), + ("second.mounts().at(0).bounds().width", "50"), + ("first.mounts().at(0).bounds().height", "30"), + ("second.mounts().at(0).bounds().height", "60"), + ("fixed.mounts().at(0).bounds().y", "30"), + ] { + assert_eq!( + session.execute(expression, "app.main").unwrap().value, + expected, + "{expression}" + ); + } + session.execute("row.boxWidth = 280", "app.main").unwrap(); + assert_eq!( + session + .execute("first.mounts().at(0).bounds().width", "app.main") + .unwrap() + .value, + "155" + ); +} + +#[test] +fn box_layout_example_runs_through_the_real_source_pipeline() { + let (mut session, initial) = ReplSession::load( + include_str!("../../../examples/box-layout.endor"), + "app.main", + 302, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + assert_eq!( + session + .execute("shared.mounts().count", "app.main") + .unwrap() + .value, + "2" + ); + let left = session + .execute("shared.mounts().at(0).bounds().width", "app.main") + .unwrap() + .value + .parse::() + .unwrap(); + let right = session + .execute("shared.mounts().at(1).bounds().width", "app.main") + .unwrap() + .value + .parse::() + .unwrap(); + assert!(left > right); +} + +#[test] +fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { + let (mut session, initial) = ReplSession::load( + r#" + child = RectBlock(20, 10) + child.marginTop = 3 + child.maxHeight = 50 + overlay = Overlay(5, 0, child, RectBlock(10, 10)) + overlay.boxWidth = 100 + overlay.boxHeight = 80 + overlay.crossAlign = "stretch" + page = Page() + page.render(overlay) + "#, + "app.main", + 303, + 1, + ) + .unwrap(); + initial.validate().unwrap(); + assert_eq!( + session + .execute("child.mounts().at(0).bounds().height", "app.main") + .unwrap() + .value, + "50" + ); + assert_eq!( + session + .execute("child.mounts().at(0).bounds().y", "app.main") + .unwrap() + .value, + "8" + ); + session.execute("child.boxHeight = 15", "app.main").unwrap(); + assert_eq!( + session + .execute("child.mounts().at(0).bounds().height", "app.main") + .unwrap() + .value, + "15" + ); +} + #[test] fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { let (mut session, initial) = ReplSession::load(