Files
endorphin-tessera-gui/src/model/block.rs
T

1038 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Block 的共享赋值、底层 Data 与视觉属性实现。Block 通过 Cell 间接指向 Data,使别名能观察到同一次替换。
//! Data 保存子组件 slot、布局配置、绑定和订阅。属性读写需要沿有效的类型/存储路径解析,不能把快照当成可变业务对象。
//! 提取视觉数据时将托管属性覆盖到原生描述;校验负责拒绝循环、非法尺寸和不符合类型的组成。
//!
//! 调用路径:UI 构造器经 allocate 建立托管图;Children/赋值入口经 validate 检查新组成。
//! RenderCache 经 resolve_tracked/visual_data 提取本轮描述,绑定刷新也用 apply_visual_property 预校验结果。
//! ObjectPayload 钩子由 Aster 属性查询、赋值及 GC 调用,不直接保存窗口或每次出现的几何。
//!
//! Live visual objects. All semantic references are owned by Aster's heap;
//! native Arcs are reserved for immutable rendering snapshots.
use std::sync::Arc;
use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, VmError};
use crate::ui_bridge::UiNode;
#[derive(Debug, Clone)]
/// 稳定组成 slot 与子 Block 的配对;slot 不依赖孩子当前的屏幕位置。
pub struct Child {
// slot 标识父组件中这条组成边,移动行序不改变它;同一 Block 可通过不同 slot 出现多次。
pub slot: u64,
// 组成边强持有的业务 Block;同一对象可通过不同 slot 多次出现。
pub block: ObjectRef,
}
#[derive(Debug, Clone)]
/// 原生组件分类,只描述业务组件种类;每种展示几何由布局和 Mount 另行生成。
pub enum Kind {
// 原生文字默认内容/离散样式;换行宽高来自各出现位置的约束。
Text {
value: String,
size: u32,
color: String,
},
// 原生矩形自然宽高、圆角和填充色,最终像素尺寸由布局分配。
Rect {
width: f32,
height: f32,
radius: f32,
color: String,
},
// 单一自然间距叶节点,不持有子组成。
Spacer(f32),
// 有限滚动视口的自然尺寸,滚动偏移和范围属于 Mount。
Scroll {
width: f32,
height: f32,
},
// 线性横向容器,孩子组成顺序由 Data.children 保存。
Row,
// 线性纵向容器,与 Row 共享通用盒模型及弹性策略。
Column,
// 叠放容器,子项共享内容区域,各自独立对齐而非线性累积。
Overlay,
// 二维世界组成根,子项业务 x/y 可带符号。
Scene2D,
// 承载 Scene3D 的有限视口,场景与相机引用保存于 Data。
Viewport3D {
width: f32,
height: f32,
},
// 承载唯一 Scene2D 根的有限二维视口,独立相机负责世界到视图投影。
CanvasViewport {
width: f32,
height: f32,
},
// 具有默认按钮视觉/事件协议的容器种类。
Button,
// 页面业务根,包含导航和兼容表面状态,生命周期有别于普通 Block。
Page,
}
/// The VM owns the Block -> Cell -> Data relationship. Shared assignment
/// changes the Cell to point at another Block, retaining the original identity.
#[derive(Debug, Clone)]
/// 对共享 Cell 的稳定包装。赋值可让所有别名一起改指向,而每次出现的 Mount 仍独立。
pub struct Block {
// 共享赋值单元 Cell;其值可为最终 Data 或另一个 Block,从而保留别名身份并转发属性/类型查询。
pub state: ObjectRef,
// 包装是否为 Page,限制共享赋值不能跨越页面与普通组件的生命周期边界。
pub page: bool,
// 该业务 owner 的任务作用域,独立于 Data 替换保留;trace 负责向 VM 暴露拥有边。
pub tasks: Option<ObjectRef>,
}
impl ObjectPayload for Block {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 沿 Block 的共享 Cell 定位属性存储,使别名替换后读取到同一份新 Data。
fn attribute_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
// VM 属性查询先读共享 Cell;不是对象意味着内部状态损坏,不能返回一个空属性集来掩盖。
let Value::Object(value) = store.cell_get(self.state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
// 若 Cell 指向替代 Block,将属性查询继续转发;否则读取 Data.properties 的普通托管字段。
if store.object(value)?.payload().as_any().is::<Block>() {
Ok(Some(value))
} else {
store
.payload::<Data>(value, "Block data")?
.properties
.map(Some)
.ok_or(VmError::InvalidArgument(
"Block has no managed property storage",
))
}
}
/// 从 Block 当前 Data 解析其类型转发目标,使共享替换后的类型查询仍一致。
fn type_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
// VM 类型查询同样沿 Cell 解析,防止别名的属性与名义类型指向不同替代对象。
let Value::Object(value) = store.cell_get(self.state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
// 若仍是包装 Block 则继续转发;到最终 Data 时保留当前包装本身的名义类型。
if store.object(value)?.payload().as_any().is::<Block>() {
Ok(Some(value))
} else {
store.payload::<Data>(value, "Block data")?;
// 到达最终 Data 时停止类型转发,使用当前 Block 自身名义类型;只有替换为其他 Block 才继续转发类型。
Ok(None)
}
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-block"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// Aster GC 追踪共享 Cell 与 owner 任务作用域;别名链不能隐藏在宿主 Arc 图中。
visit(self.state);
// 存在业务 owner 任务作用域时强持有它,替换视觉 Data 不会把该作用域漏出追踪。
if let Some(tasks) = self.tasks {
visit(tasks);
}
}
/// 向 VM 暴露共享赋值单元;替换的是指向关系,不是逐项复制组件业务字段。
fn assignment_cell(
&self,
source: ObjectRef,
store: &ObjectStore,
) -> Result<Option<ObjectRef>, VmError> {
// VM 询问赋值是否共享 Cell 时,只接收另一 Block 包装;其他值交回常规赋值规则。
if !store.object(source)?.payload().as_any().is::<Self>() {
return Ok(None);
}
// Page 与普通组件不能交叉共享赋值,防止把窗口/页面生命周期能力替换成普通叶组件。
if store.payload::<Self>(source, "Block")?.page != self.page {
return Err(VmError::InvalidArgument(
"Page assignment requires another Page",
));
}
// 建立转发前检查源展开图不能回到本 Cell,禁止共享赋值在别名或组成关系中形成环。
validate(store, source, Some(self.state))?;
Ok(Some(self.state))
}
}
#[derive(Debug, Clone)]
/// 组件当前业务描述与组成。与一次求出的屏幕几何分开保存,替换或属性变化会触发派生快照失效。
pub struct Data {
// 构造时原生种类及其默认视觉值;当前托管属性由 visual_data 覆盖到副本。
pub kind: Kind,
// 有顺序的组成边,slot 保留身份,Vec 顺序决定布局/绘制展开顺序。
pub children: Vec<Child>,
// 容器统一内边距,单边 padding_edges 为 Some 时优先覆盖。
pub padding: f32,
// 线性子项间距,参与自然尺寸、排列和剩余空间分配。
pub gap: f32,
// 父主轴有剩余空间时的增长权重,不是已分配像素宽高。
pub flex: f32,
// 主轴 basis、min/max 与 shrink 规则,供布局求解分配尺寸。
pub flex_policy: crate::layout::FlexPolicy,
// 容器主轴剩余空间的对齐策略,与子项 flex 尺寸分配区分。
pub main_align: crate::layout::MainAlign,
// 容器交叉轴默认对齐,子项 align_self 可单独覆盖。
pub cross_align: crate::layout::CrossAlign,
// None 编码 auto,表示继承当前父出现位置策略;共享孩子在不同父下可有不同几何。
pub align_self: Option<crate::layout::CrossAlign>,
// 子项外边距,参与父固有尺寸与分配,不包含在本项边框盒宽高中。
pub margin: crate::layout::Insets,
// 边框盒 auto/fill/显式尺寸及 min/max 限制,解码后由布局应用。
pub sizing: crate::box_model::Sizing,
// 四边覆盖顺序为上、右、下、左;None 表示回退统一 padding,不等于零。
pub padding_edges: [Option<f32>; 4],
// 四边边框厚度,占据边框盒内部空间,并生成独立绘制命令。
pub border: crate::layout::Insets,
// 已验证颜色文本,供边框绘制使用;不是业务内容自身的填充色。
pub border_color: String,
// overflow=clip 时限制后代的绘制与命中,不自动赋予滚动能力。
pub clip_children: bool,
// CanvasViewport/Viewport3D 使用的相机强引用;共享相机需源代码显式复用对象。
pub camera: Option<ObjectRef>,
// 三维视口的 Scene3D 业务根,面板 UI 由场景提取流程展开。
pub scene3d: Option<ObjectRef>,
// 三维视口自定义控制器;提取时检查名义 update,输入路径才调用它。
pub controller: Option<ObjectRef>,
// ScrollBlock 可滚动方向;实际偏移与有限范围按每个 Mount 保存。
pub scroll_axis: crate::scroll::Axis,
// Scene2D/canvas 等定位使用的带符号业务坐标,不能当作已投影屏幕位置。
pub position: [f32; 2],
// Page 兼容表面模式及 pan/zoom,具体页面身份在发布时补入。
pub surface: crate::page::Surface,
// 下次插入的单调 slot,初始孩子按 0..len 编号,后续重排不把旧 slot 重新编号。
pub next_slot: u64,
// Page 导航历史是真实托管拥有边;后退/前进可重新展示同一个对象,而非复制快照。
pub history: Vec<ObjectRef>,
// Page 当前导航历史位置,与 history 一起维护前进/后退关系。
pub history_index: usize,
// 目标拥有的属性连接,连接再持有 compute;取消令牌不反向保活目标。
pub bindings: Vec<ObjectRef>,
// 旧 textOf 源对象与字段名推导入口;通用 text 绑定建立后清除以避免竞争。
pub text_binding: Option<(ObjectRef, String)>,
// 兼容 onClick 直接回调,事件路径调用,GC 需保活该函数。
pub click: Option<ObjectRef>,
// 对象拥有的事件连接,派发按这些连接决定监听器资格。
pub subscriptions: Vec<ObjectRef>,
// 源可读写的视觉字段存储;Data 自身保存结构边与构造默认值,提取时再将当前属性覆盖到派生副本。
pub properties: Option<ObjectRef>,
}
impl Data {
/// 建立组件业务描述,为初始孩子分配稳定 slot,并初始化无绑定、无订阅的默认布局策略。
pub fn new(kind: Kind, children: Vec<ObjectRef>, padding: f32, gap: f32) -> Self {
// 先为输入孩子分配稳定 slot,next_slot 从当前长度继续,后续追加不会复用被删除条目的编号。
let next_slot = children.len() as u64;
// UI 构造器传入的种类/孩子/统一 padding 与 gap 形成默认描述,其余布局策略先取无约束默认值。
// 顺序编号孩子形成独立组成边,后续插入/删除只移动 Vec,不重编号旧 slot。
Self {
kind,
children: children
.into_iter()
.enumerate()
.map(|(slot, block)| Child {
slot: slot as u64,
block,
})
.collect(),
padding,
gap,
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,
camera: None,
scene3d: None,
controller: None,
scroll_axis: crate::scroll::Axis::Both,
position: [0.0, 0.0],
surface: crate::page::Surface::new(crate::page::Mode::Window),
next_slot,
history: vec![],
history_index: 0,
bindings: vec![],
text_binding: None,
click: None,
subscriptions: vec![],
properties: None,
}
}
/// 检查原生组件种类是否承载子组成,供树修改和布局选择使用。
pub fn is_container(&self) -> bool {
// 这些类别允许通过通用 Children 方法编辑;Viewport3D 的面板内容来自 Scene3D 提取,不是普通 children 列表。
matches!(
self.kind,
Kind::Row
| Kind::Column
| Kind::Overlay
| Kind::Scene2D
| Kind::CanvasViewport { .. }
| Kind::Button
| Kind::Page
| Kind::Scroll { .. }
)
}
}
impl ObjectPayload for Data {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-block-data"
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// 所有结构字段中出现的托管强引用必须枚举:场景/相机、属性存储、孩子、历史、绑定、订阅和回调。
// 三维场景根属于业务拥有边,不能只在渲染快照中借用而漏掉 GC。
if let Some(scene) = self.scene3d {
visit(scene);
}
// 自定义控制器可能持有源模型/闭包,需要进入循环回收图。
if let Some(controller) = self.controller {
visit(controller);
}
// 相机可被多个视口共享,Data 对它的每条强边都要被追踪。
if let Some(camera) = self.camera {
visit(camera);
}
// 普通源字段存储可能反向引用组件或页面,必须让 VM 看到这类环。
if let Some(properties) = self.properties {
visit(properties);
}
// 每条组成边都拥有孩子,不能因业务身份重复而漏计托管引用。
for child in &self.children {
visit(child.block);
}
// 页面历史需要保留可再次展示的业务对象,不是临时绘制快照。
for item in &self.history {
visit(*item);
}
// 旧 textOf 推导源仍是强引用,直到该入口被清除。
if let Some((source, _)) = &self.text_binding {
visit(*source);
}
// 连接由目标拥有,连接 payload 再追踪有效 compute。
for binding in &self.bindings {
visit(*binding);
}
// 事件连接持有回调所需图,取消和回收由事件连接状态决定。
for token in &self.subscriptions {
visit(*token);
}
// 兼容单回调同样要保活,不能只追踪通用 subscriptions。
if let Some(handler) = self.click {
visit(handler);
}
}
}
/// 分配 Data、共享 Cell 和 Block 包装,并让托管图拥有必要引用。
pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef, VmError> {
// UI 构造器调用 allocate;先固定包装的 Page 标记,后续共享赋值据此检查类别兼容。
let page = matches!(data.kind, Kind::Page);
// 分配拥有边前检查每个孩子都是有效有限 Block 图;非法组成直接失败,不截断部分 UI。
for child in &data.children {
validate(cx.store(), child.block, None)?;
}
// 把原生默认参数显式写成普通属性,使源赋值、绑定及精确依赖跟踪观察到同一存储。
let properties = cx.new_object()?;
// 将当前 kind 允许的默认字段写入新 properties,使源写入与精确依赖观察同一个对象。
for (name, value) in native_properties(&data) {
cx.set_attr(properties, name, value)?;
}
data.properties = Some(properties);
// 托管化完整描述后再创建 Cell/Block,拥有链由 VM 分配机制维护。
let data = cx.allocate(data)?;
// 最终建立 Block -> Cell -> Data -> properties/children 拥有链,均在 VM 中参与追踪和循环回收。
let state = cx.new_cell(Some(Value::Object(data)))?;
cx.allocate(Block {
state,
page,
tasks: None,
})
}
/// Borrowed final data; forwarding is part of the managed graph, never an Arc
/// ownership graph hidden from the VM's cycle collector.
/// 解析一个 Block 当前指向的 Data,返回底层身份和借用描述。
pub fn resolve(store: &ObjectStore, block: ObjectRef) -> Result<(ObjectRef, &Data), VmError> {
// 编辑、绑定和协议辅助调用只需最终 Data;有缓存需求的调用方应保留 tracked 返回的依赖路径。
let (reference, data, _) = resolve_tracked(store, block)?;
Ok((reference, data))
}
/// 解析 Block 的完整引用路径,并返回快照失效所需的依赖身份。
pub fn resolve_tracked(
store: &ObjectStore,
block: ObjectRef,
) -> Result<(ObjectRef, &Data, Vec<ObjectRef>), VmError> {
// 当前待解析包装,从调用方给定的业务别名开始,而非直接信任旧 Data 句柄。
let mut current = block;
// 解析转发链的全局去重集合;同一包装再次出现表示纯转发循环。
let mut seen = std::collections::HashSet::new();
// RenderCache 借用这份身份路径建立失效观察,不把依赖列表当作持久 GC 根。
let mut dependencies = Vec::new();
// 逐层走 Block→Cell→替代 Block,直到最终 Data 或明确错误,不按固定深度截断。
loop {
// 解析整条转发路径防环;依赖收集同时记录每层 Block 与 Cell,任一转发变更都会让视觉缓存失效。
if !seen.insert(current) {
return Err(VmError::InvalidArgument("cyclic Block reference"));
}
// 每一层都检查确为 Block payload,禁止任意对象被当成转发节点。
let state = store.payload::<Block>(current, "Block")?.state;
dependencies.extend([current, state]);
// Cell 内容须为托管对象,Nil/数值等状态不能产生合法视觉描述。
let Value::Object(value) = store.cell_get(state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
// 遇到 Data 返回最终存储身份与借用引用;否则下一轮要求该值必须是 Block,不接受任意对象。
if store.object(value)?.payload().as_any().is::<Data>() {
dependencies.push(value);
return Ok((
value,
store.payload::<Data>(value, "Block data")?,
dependencies,
));
}
current = value;
}
}
/// Validate the entire finite expansion before establishing a new composition
/// edge. The node count is an explicit rejected resource quota, not truncated UI.
/// 完整展开 Block/Cell/孩子图,拒绝环、禁止目标回边及超过 10000 次展开的组成。
/// allocate/Scene3D 面板构造传 None,只检查已有图;共享赋值传目标 Cell,
/// Children 的 append/insert/replace 和 Page 的 render/navigate 则传目标 Data。
/// 因此 forbidden_state 虽沿用 state 名称,实际可以是 Cell 或 Data 身份;二者都不能在新子图中出现。
pub fn validate(
store: &ObjectStore,
root: ObjectRef,
forbidden_state: Option<ObjectRef>,
) -> Result<(), VmError> {
// 显式进入/退出栈模拟深度优先遍历;退出时移除祖先链标记,允许共享子树。
enum Step {
Enter(ObjectRef),
Exit(ObjectRef),
}
// validate 由新组成/共享赋值入口调用;显式栈保存尚待展开的进入/退出步骤。
let mut work = vec![Step::Enter(root)];
// 只记录当前 DFS 祖先 Cell,兄弟分支复用同一孩子是合法共享而非循环。
let mut path = std::collections::HashSet::new();
// 计数实际展开工作量,重复共享出现也计入 10000 上限。
let mut visits = 0;
// 弹出后进先出工作项,避免用宿主递归栈承载深层业务图。
while let Some(step) = work.pop() {
// 退出清祖先标记;进入检查预算、禁用目标和所有孩子,不产出任何部分 UI。
match step {
// 子树已完成后移除祖先,允许后面的兄弟再次使用同一 Cell。
Step::Exit(state) => {
path.remove(&state);
}
// 每次组成或转发出现都检查,不能按不同对象数去重而绕过展开预算。
Step::Enter(block) => {
// 预算按实际展开次数而非不同对象数计算,共享子树重复出现也计入资源工作量。
visits += 1;
// 展开超限明确拒绝,不能返回部分孩子让 UI 悄悄缺项。
if visits > 10_000 {
return Err(VmError::ExecutionLimit("expanded Block graph"));
}
// 要求每个待展开节点为 Block,取共享 Cell 作为路径身份。
let state = store.payload::<Block>(block, "Block")?.state;
// path 只追踪当前祖先 Cell,允许其他分支共享同一组件;forbidden_state 额外阻止新边回到将被修改的目标。
if forbidden_state == Some(state) || !path.insert(state) {
return Err(VmError::InvalidArgument(
"Block composition would create a cycle",
));
}
work.push(Step::Exit(state));
// 与 resolve 一样拒绝非对象 Cell,避免非法状态混进组成拥有链。
let Value::Object(value) = store.cell_get(state)? else {
return Err(VmError::InvalidArgument("invalid Block state"));
};
// Children/Page 编辑传入的是 Data 身份,要在此比较 Cell 的值;共享赋值传入的 Cell 已在上方比较。
// 例如给容器追加它自己的别名时,包装身份可能不同,最终 Data 相同仍须拒绝。
if forbidden_state == Some(value) {
return Err(VmError::InvalidArgument(
"Block composition would create a cycle",
));
}
// Data 展开孩子,非 Data 则沿共享赋值转发链继续;显式 Exit 在子树完成后清除祖先标记。
if let Ok(data) = store.payload::<Data>(value, "Block data") {
// 逆序压栈让检查按原孩子顺序展开;每次出现都验证,重复子树不能跳过预算。
for child in data.children.iter().rev() {
work.push(Step::Enter(child.block));
}
} else {
work.push(Step::Enter(value));
}
}
}
}
Ok(())
}
/// 用一次性空 RenderCache 提取不可变视觉树;不跨调用保存快照缓存。
pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result<Arc<UiNode>, VmError> {
// 一次性提取使用空缓存,不保留跨调用结果;有增量复用需求的窗口应使用自己的 RenderCache。
crate::snapshot::RenderCache::default().snapshot(store, root)
}
/// Native visual parameters are ordinary managed attributes too. Composition,
/// histories and connections remain structural payload edges.
/// 列出本类型可投影到托管属性的原生字段及默认值。
/// allocate 用返回值初始化 properties;visual_data 用字段名重读当前值;RenderCache 用同一集合登记精确失效依赖。
/// 新增原生视觉字段时需同时补这里与 apply_visual_property,否则可能出现可解码但不进入快照/缓存观察的字段。
pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
/// 把已有 f32 默认字段编码为源 Number;这里不做范围校验,读取解码由 apply_visual_property 处理。
fn number(value: f32) -> Value {
Value::Number((value as f64).into())
}
// 先枚举每种 Block 都有的定位、弹性、盒尺寸、边框与对齐属性,再按 kind 增补专有字段。
let mut fields = vec![
("x", number(data.position[0])),
("y", number(data.position[1])),
("flex", number(data.flex)),
(
"flexBasis",
data.flex_policy
.basis
.map_or_else(|| Value::Text("auto".into()), number),
),
("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(
// 布尔内部状态编码成公开 overflow 策略文本,供源字段读取与后续解析一致往返。
if data.clip_children {
"clip"
} else {
"visible"
}
.into(),
),
),
(
"alignSelf",
Value::Text(
data.align_self
.map_or("auto", crate::layout::CrossAlign::name)
.into(),
),
),
];
// 按上右下左为四边输出覆盖值,None 与显式零必须保持可区分。
for (name, edge) in ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]
.into_iter()
.zip(data.padding_edges)
{
fields.push((
name,
// 单边 padding 的 auto 编码成文本,不用数值哨兵,读取时才能区分“继承统一值”和显式零。
edge.map_or_else(|| Value::Text("auto".into()), number),
));
}
// 只为实际原生 kind 公开专有默认字段;没有对应字段的种类不凭同名自定义属性改变行为。
match &data.kind {
// 三维视口带有限宽高、场景、控制器和相机,None 控制器编码为明确文本 none。
Kind::Viewport3D { width, height } => fields.extend([
("width", number(*width)),
("height", number(*height)),
("scene", data.scene3d.map_or(Value::Nil, Value::Object)),
(
"controller",
data.controller
.map_or_else(|| Value::Text("none".into()), Value::Object),
),
("camera", data.camera.map_or(Value::Nil, Value::Object)),
]),
// 二维有限视口公开宽高和 Camera2D,Scene2D 仍通过结构 children 持有。
Kind::CanvasViewport { width, height } => fields.extend([
("width", number(*width)),
("height", number(*height)),
("camera", data.camera.map_or(Value::Nil, Value::Object)),
]),
// 兼容 Page 表面公开 mode/pan/zoom,不把导航历史作为普通视觉字段。
Kind::Page => fields.extend([
("mode", Value::Text(data.surface.mode.name().into())),
("panX", number(data.surface.pan.x)),
("panY", number(data.surface.pan.y)),
("zoom", number(data.surface.zoom)),
]),
// 文字的内容、离散字号及颜色进入普通属性存储,允许绑定和精确读观察。
Kind::Text { value, size, color } => fields.extend([
("text", Value::Text(value.clone())),
("size", Value::Number((*size as i64).into())),
("color", Value::Text(color.clone())),
]),
// 矩形默认几何与填充色可由托管属性覆盖,出现位置的实际分配另由布局决定。
Kind::Rect {
width,
height,
radius,
color,
} => fields.extend([
("width", number(*width)),
("height", number(*height)),
("radius", number(*radius)),
("color", Value::Text(color.clone())),
]),
// 滚动容器公开初始尺寸和轴策略,实际偏移不放到共享业务字段。
Kind::Scroll { width, height } => fields.extend([
("width", number(*width)),
("height", number(*height)),
("axis", Value::Text(data.scroll_axis.name().into())),
]),
// Spacer 仅公开自身自然间距 size。
Kind::Spacer(size) => fields.push(("size", number(*size))),
_ => {}
}
// 容器才暴露统一 padding/gap 与主交叉轴策略;叶节点不因同名自定义字段被当成通用容器。
if data.is_container() {
fields.extend([
("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
}
/// 复制原生描述并用当前托管属性覆盖它;结果只用于展示,不反向修改业务对象。
pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
// 只改本轮派生副本,逐个读取实际属性并验证;读取字段名集合来自 native_properties,不遍历任意用户字段。
let mut data = data.clone();
// 提取必须有托管字段存储;没有时说明分配图不完整,不能退回构造默认值。
let properties = data
.properties
.ok_or(VmError::InvalidArgument("missing Block properties"))?;
// 字段集合由原生种类确定,每项从当前存储读取;RenderCache 调用方另用同一字段集合登记精确缓存依赖。
for (name, _) in native_properties(&data) {
// 借用读取的源值先解码到副本,错误不会反写已存在的业务描述。
let value = store.get_attr(properties, name)?;
apply_visual_property(&mut data, name, &value)?;
}
// 各单值合法仍可能存在跨字段矛盾,必须在全部覆盖完成后校验 min/max 关系。
if data.flex_policy.min > data.flex_policy.max {
return Err(VmError::InvalidArgument("flexMin exceeds flexMax"));
}
// 全部字段解码后才比较两轴 min/max,避免字段覆盖次序造成错误地提前拒绝。
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"));
}
// 有限二维视口另要求合法 Camera2D 与唯一 Scene2D 根,不能仅靠字段类型通过完整协议。
if matches!(data.kind, Kind::CanvasViewport { .. }) {
crate::viewport::camera(
store,
data.camera.ok_or(VmError::InvalidArgument(
"CanvasViewport requires a Camera2D",
))?,
)?;
// CanvasViewport 只容纳一个 Scene2D 根,允许其内部无限空间布局,但不接受多个普通孩子冒充场景。
if data.children.len() != 1
|| !matches!(
resolve(store, data.children[0].block)?.1.kind,
Kind::Scene2D
)
{
return Err(VmError::InvalidArgument(
"CanvasViewport requires exactly one Scene2D",
));
}
}
// 有自定义控制器时先验证名义方法;未指定 controller 则沿默认相机手势路径。
if matches!(data.kind, Kind::Viewport3D { .. })
&& let Some(controller) = data.controller
{
// 控制器必须有名义类型,不能依赖实例上的同名动态字段冒充 update 方法。
let definition = store.type_of(controller)?.ok_or(VmError::InvalidArgument(
"3D controller requires a nominal update method",
))?;
// 3D 控制器要求名义类型上的 update,接受含接收者在内三参;调用执行与预算在输入分发路径处理。
let method = store
.lookup_method(definition, "update")?
.ok_or(VmError::InvalidArgument(
"3D controller must define update(camera, gesture)",
))?;
// 包括 receiver 的三参形状需可接受;这里只校验,不在展示提取期间执行控制器副作用。
if store
.call_arity(method)?
.is_none_or(|arity| !arity.accepts(3))
{
return Err(VmError::InvalidArgument(
"3D controller update expects camera and CameraGesture",
));
}
}
Ok(data)
}
/// 按属性名检查和解码源值,再更新对应视觉字段;非法尺寸、枚举或颜色被拒绝。
/// binding::refresh 在写入托管属性前用副本试解码;visual_data 在全量覆盖后另查 min/max 和视口协议。
/// 本函数只验证本次值并更新传入 Data,不自行写 properties,也不保证多个相关字段组合起来一定合法。
pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Result<(), VmError> {
// 绑定刷新在写回前、visual_data 在读取时调用本解码器;通用类型/范围失败统一为视觉属性错误。
let bad = || VmError::InvalidArgument("invalid native visual property");
// 常规视觉长度/系数只接受非负有限值;坐标和 pan 在下面走允许负数的独立路径。
let number = || -> Result<f32, VmError> {
// 仅接受名义 Number,拒绝可格式化成数字的字符串/对象。
let Value::Number(value) = value else {
return Err(bad());
};
// 转为布局使用的 f32 后再检查有限性和上界,防止大数溢出为无穷。
let n = value.to_string().parse::<f32>().map_err(|_| bad())?;
// 拒绝 NaN、无穷、负数和超过全局视觉长度预算的数,保护后续布局/像素尺寸计算。
if !n.is_finite() || !(0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(&n) {
return Err(bad());
}
Ok(n)
};
// x/y 对所有组件生效;页面 pan/zoom 只作用 Page。这里不是任意字段存在性校验,不适用种类会跳过更新。
if matches!(name, "x" | "y" | "panX" | "panY" | "zoom") {
// 坐标路径允许带符号数,但仍使用 coordinate 的有限值/范围检查。
let n = crate::scroll::coordinate(value)?;
// x/y 更新业务位置;pan/zoom 只对 Page 解释,其他同名字段不影响原生描述。
match name {
"x" => data.position[0] = n,
"y" => data.position[1] = n,
"panX" if matches!(data.kind, Kind::Page) => data.surface.pan.x = n,
"panY" if matches!(data.kind, Kind::Page) => data.surface.pan.y = n,
"zoom" if matches!(data.kind, Kind::Page) => {
// Page 兼容相机缩放范围不同于独立 Camera2D 的 0.01..100,保持两套源协议各自限制。
if !(0.1..=4.0).contains(&n) {
return Err(invalid_zoom());
}
data.surface.zoom = n;
}
_ => {}
}
return Ok(());
}
// 旧 Page mode 仅接受可解析字符串,其他组件的自定义 mode 不在此解释。
if matches!(data.kind, Kind::Page) && name == "mode" {
// mode 的枚举解析只接受字符串;Number 等类型不做隐式转换。
let Value::Text(name) = value else {
return Err(bad());
};
data.surface.mode = crate::page::Mode::parse(name)?;
return Ok(());
}
// 先处理跨种类盒模型字段及视口引用,成功分支立即返回,避免落入后面的 kind 专有解码。
match name {
// controller 接受对象或明确的字符串 none,不能拿 Nil 隐式取消;后续 visual_data 检查其名义方法。
"controller" if matches!(data.kind, Kind::Viewport3D { .. }) => {
data.controller = match value {
Value::Object(controller) => Some(*controller),
Value::Text(text) if text == "none" => None,
_ => return Err(bad()),
};
return Ok(());
}
// camera/scene 这里仅检查 Object 并记录身份,实际 payload/结构验证由各视口快照入口继续执行。
"camera"
// 只有二维/三维视口把 camera 当作原生相机引用,其他种类不会因同名自定义字段改变协议。
if matches!(
data.kind,
Kind::CanvasViewport { .. } | Kind::Viewport3D { .. }
) =>
{
// 相机先要求对象身份,具体 2D/3D payload 与名义类型交由相应快照路径验证。
let Value::Object(camera) = value else {
return Err(bad());
};
data.camera = Some(*camera);
return Ok(());
}
"scene" if matches!(data.kind, Kind::Viewport3D { .. }) => {
// Scene3D 引用先保留业务身份,完整图验证发生在三维提取入口。
let Value::Object(scene) = value else {
return Err(bad());
};
data.scene3d = Some(*scene);
return Ok(());
}
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
// 四边覆盖数组固定按上右下左索引,保证与 native_properties 输出次序一致。
let index = match name {
"paddingTop" => 0,
"paddingRight" => 1,
"paddingBottom" => 2,
_ => 3,
};
// 单边 auto 清除覆盖值;数值零保留 Some(0),从而确实覆盖统一 padding。
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(());
}
// 下边框厚度影响内容可用高,不能当成外 margin。
"borderBottom" => {
data.border.bottom = number()?;
return Ok(());
}
// 左边框厚度进入内容起点和裁剪区的横向内缩。
"borderLeft" => {
data.border.left = number()?;
return Ok(());
}
"borderColor" => {
// 边框颜色必须为字符串,再由 parse_color 验证实际颜色格式。
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" => {
// overflow 是策略枚举,不接受 Bool 等近似表示,未知字符串明确报错。
let Value::Text(overflow) = value else {
return Err(bad());
};
// overflow 只切换可见/裁剪,不暗中启用滚动;需要偏移与输入消费必须使用 ScrollBlock。
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(());
}
// 边框盒宽可为 auto/fill/显式尺寸,由 Dimension 解析保留策略而非提前求像素。
"boxWidth" => {
data.sizing.width = crate::box_model::Dimension::parse(value)?;
return Ok(());
}
// 边框盒高同样保留 auto/fill 语义,有限父分配由布局阶段提供。
"boxHeight" => {
data.sizing.height = crate::box_model::Dimension::parse(value)?;
return Ok(());
}
// 记录宽度下限,min/max 的组合一致性等全部字段覆盖后检查。
"minWidth" => {
data.sizing.min_width = number()?;
return Ok(());
}
// 记录高度下限,参与布局夹取且不能为负。
"minHeight" => {
data.sizing.min_height = number()?;
return Ok(());
}
// 记录有限宽度上限,约束 fill/stretch 与显式尺寸的最终结果。
"maxWidth" => {
data.sizing.max_width = number()?;
return Ok(());
}
// 记录有限高度上限,避免 stretch 绕过盒尺寸边界。
"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(());
}
// basis 的 Nil 与 auto 都表示内容基准;其他值使用非负长度检查。flexMin/Max 的相互关系在完整提取后验证。
"flexBasis" => {
data.flex_policy.basis = if matches!(value, Value::Nil)
|| matches!(value, Value::Text(text) if text == "auto")
{
None
} else {
Some(number()?)
};
return Ok(());
}
// 弹性主轴分配下限,与 flexMax 的大小关系由 visual_data 整体检查。
"flexMin" => {
data.flex_policy.min = number()?;
return Ok(());
}
// 弹性主轴分配上限,达到边界后剩余空间可重新分给其他项。
"flexMax" => {
data.flex_policy.max = number()?;
return Ok(());
}
// 主轴不足时的收缩权重,零表示不参与该项加权收缩。
"flexShrink" => {
data.flex_policy.shrink = number()?;
return Ok(());
}
_ => {}
}
// 通用增长权重也按非负有限数解码;它不等价于 boxWidth=fill。
if name == "flex" {
data.flex = number()?;
return Ok(());
}
// 后续只有容器解释 mainAlign/crossAlign/padding/gap,叶子同名自定义字段保持业务含义。
let container = data.is_container();
// alignSelf 可由任何孩子独立声明,crossAlign 只解释容器配置;auto 恢复继承父交叉轴策略。
if name == "alignSelf" || (container && name == "crossAlign") {
// alignSelf/crossAlign 都是字符串策略,先检查类型再解析 auto 或具体对齐方式。
let Value::Text(value) = value else {
return Err(bad());
};
// 孩子可用 auto 取消自身覆盖;父容器 crossAlign 则必须是具体策略。
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" {
// 主轴策略不能用数值或对象代替文本,统一从 MainAlign 解析允许集合。
let Value::Text(value) = value else {
return Err(bad());
};
data.main_align = crate::layout::MainAlign::parse(value)?;
return Ok(());
}
// 最后按 kind 解释自然尺寸、文本、颜色与滚动轴;不存在的原生字段不改业务属性本身。
match (&mut data.kind, name) {
// 三维视口自然宽高采用相同长度限制,实际分配还受盒模型与父约束影响。
(Kind::Viewport3D { width, .. }, "width") => *width = number()?,
(Kind::Viewport3D { height, .. }, "height") => *height = number()?,
// 二维视口自然尺寸有限,不能因世界内容无界而让视口本身无限展开。
(Kind::CanvasViewport { width, .. }, "width") => *width = number()?,
(Kind::CanvasViewport { height, .. }, "height") => *height = number()?,
(Kind::Text { value: text, .. }, "text") => {
// 原生文字内容只接受源字符串,不能将对象 repr 自动变为显示内容。
let Value::Text(value) = value else {
return Err(bad());
};
// 文本属性按 UTF-8 字节设 16 KiB 上限,防止单个 Block 超过原生视觉文本预算。
if value.len() > 16_384 {
return Err(bad());
}
text.clone_from(value);
}
(Kind::Text { size, .. }, "size") => {
// 文字 size 先通过通用数值界限,再限制为离散倍率,不能截断小数。
let n = number()?;
// Text size 是 1..8 的离散倍率,不能把任意浮点字号通过 as u32 悄悄截断。
if !(1.0..=8.0).contains(&n) || n.fract() != 0.0 {
return Err(bad());
}
*size = n as u32;
}
(Kind::Text { color, .. } | Kind::Rect { color, .. }, "color") => {
// 文字/矩形填充色共用字符串检查;通过格式验证后才替换派生值。
let Value::Text(value) = value else {
return Err(bad());
};
// 颜色赋值先验证文本格式再替换派生颜色,避免错误字符串进入软件渲染器。
crate::document::parse_color(value).map_err(|_| bad())?;
color.clone_from(value);
}
(Kind::Scroll { .. }, "axis") => {
// ScrollBlock.axis 必须为字符串,再验证允许的 both/vertical/horizontal 集合。
let Value::Text(name) = value else {
return Err(bad());
};
data.scroll_axis = crate::scroll::Axis::parse(name)?;
}
// 滚动视口自然宽高与内容尺寸分开;真实分配可能被源 arrange 或盒模型覆盖。
(Kind::Scroll { width, .. }, "width") => *width = number()?,
(Kind::Scroll { height, .. }, "height") => *height = number()?,
// 矩形自然尺寸及圆角都按非负有限长度解码,不直接写任何 Mount 的排列结果。
(Kind::Rect { width, .. }, "width") => *width = number()?,
(Kind::Rect { height, .. }, "height") => *height = number()?,
(Kind::Rect { radius, .. }, "radius") => *radius = number()?,
// Spacer 自然间距使用同一数值界限,方向由所在布局协议决定。
(Kind::Spacer(size), "size") => *size = number()?,
// 剩余 kind 专有尺寸与容器字段按各自规则覆盖;未知字段忽略,不在此处删除或修改用户自定义属性。
(_, "padding") if container => data.padding = number()?,
(_, "gap") if container => data.gap = number()?,
_ => {}
}
Ok(())
}
/// 统一返回非法页面缩放错误,确保属性写入与输入路径使用相同限制。
fn invalid_zoom() -> VmError {
VmError::InvalidArgument("Page.zoom expects a number from 0.1 to 4")
}