feat: implement nested border-box layout and alignment
This commit is contained in:
@@ -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 优先、裁剪命中和可运行示例。本阶段尚未重新打包或进行原生指针验收。
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
})?,
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user