feat: implement nested border-box layout and alignment

This commit is contained in:
2026-11-10 22:17:19 +08:00
parent 525097a0b2
commit 32d7ada3ab
21 changed files with 1368 additions and 74 deletions
+45
View File
@@ -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 优先、裁剪命中和可运行示例。本阶段尚未重新打包或进行原生指针验收。
+1
View File
@@ -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. |
+38
View File
@@ -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)
+4 -1
View File
@@ -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",
};
+4
View File
@@ -43,6 +43,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("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<ObjectRef>) -> Result<UiBridge> {
("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<ObjectRef>) -> Result<UiBridge> {
"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<ObjectRef>) -> Result<UiBridge> {
"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),
+3 -1
View File
@@ -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
},
+3 -1
View File
@@ -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"
})?,
+36 -1
View File
@@ -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<crate::layout::CrossAlign>,
pub margin: crate::layout::Insets,
pub sizing: crate::box_model::Sizing,
pub padding_edges: [Option<f32>; 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<u64>,
@@ -18,6 +26,7 @@ impl UiNode {
pub fn children(&self) -> &[Arc<UiNode>] {
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<Arc<UiNode>>,
},
Canvas {
positions: Vec<[f32; 2]>,
children: Vec<Arc<UiNode>>,
+2 -2
View File
@@ -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,
+180 -1
View File
@@ -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<crate::layout::CrossAlign>,
pub margin: crate::layout::Insets,
pub sizing: crate::box_model::Sizing,
pub padding_edges: [Option<f32>; 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<Data, VmError> {
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());
+8
View File
@@ -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(),
+12
View File
@@ -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(),
+124
View File
@@ -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<Self, VmError> {
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<Size> {
fn axis(
policy: Dimension,
intrinsic: f32,
min: f32,
max: f32,
available: f32,
) -> Result<f32> {
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],
)?,
})
}
}
+430 -38
View File
@@ -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<Self, endor_core::VmError> {
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<Shape>, crate::box_model::Sizing, [f32; 4]),
Text(String, u32, f32),
Rect(f32, f32),
Container(bool, f32, f32, Vec<Size>),
Container(bool, Insets, f32, Vec<Size>),
Padded(Box<Shape>, Insets),
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
@@ -122,7 +190,25 @@ pub fn root_constraints(viewport: Option<Size>) -> [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<usize> {
fn measure_shape(shape: &Shape) -> Result<Size> {
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<Size> {
.iter()
.map(|s| if *row { s.width } else { s.height })
.sum::<f32>()
+ 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<Size> {
}
fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Shape> {
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<Size>, constraints: [f32; 4]) -> Result<Sh
}
Shape::Rect(size.width, size.height)
}
UiKind::Text { value, size, .. } => 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<RenderCommand> {
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<RenderC
}
}
pub fn paint_commands(node: &UiNode, x: f32, y: f32, size: Size) -> Vec<RenderCommand> {
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<Size> {
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::<f32>() as f64
- gap as f64 * children.len().saturating_sub(1) as f64)
.max(0.0);
let bases: Vec<f64> = 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::<f64>();
@@ -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<f64> = 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<usize> = (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<Vec<(f32, f32)>> {
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::<f32>()
+ 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;
+1
View File
@@ -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;
+8 -3
View File
@@ -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,
+23 -13
View File
@@ -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,
+21 -9
View File
@@ -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::<VmError>().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();
+2 -4
View File
@@ -425,7 +425,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
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<endor_core::ReadDependency>) -
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 {
+2
View File
@@ -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.
+421
View File
@@ -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::<f32>()
.unwrap();
let right = session
.execute("shared.mounts().at(1).bounds().width", "app.main")
.unwrap()
.value
.parse::<f32>()
.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(