Files
endorphin-tessera-gui/src/render/layout.rs
T

1399 lines
65 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.
//! 原生布局求解器:自底向上测量期望尺寸,再自顶向下分配位置和实际尺寸。
//! Row/Column/Overlay、边距、边框、内边距、对齐和弹性空间都在有限约束内计算;过大的尺寸和坐标返回错误。
//! 缓存按组件形状、子项尺寸和约束判断可复用性。frame 先在克隆状态中求解,成功才替换运行状态,防止失败污染已发布几何。
//!
//! 调用路径:UiBridge/原生视觉入口调用 LayoutRuntime 或 frame_with_viewport 进行纯原生布局。
//! source_layout 复用约束、measure_native、flex_sizes、arrange_native 等单节点函数,在外层驱动源协议收敛。
//! 本模块只处理不可变 UiNode 和原生数值,不执行 VM 或持有托管根;每次出现身份由父 ID/slot/业务身份共同确定。
//!
//! Iterative measure/arrange/paint over derived visual data. Every occurrence
//! has its own geometry; measurement caches never become semantic object state.
use crate::{
document::{Frame, MountGeometry, RenderCommand},
text::{self, TextStyle},
ui_bridge::{MAX_DIMENSION, UiKind, UiNode},
};
use anyhow::{Context as _, Result, bail};
use std::collections::{HashMap, HashSet};
// 一次原生整树构建最多展开一万个出现,共享业务对象的不同位置也各计一次。
const MAX_LAYOUT_VISITS: usize = 10_000;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
/// 上右下左四边的空间占用,用于 margin、padding 和 border 计算。
pub struct Insets {
// 顶侧占用,来自上 margin/padding/border 的哪一层由调用方确定。
pub top: f32,
// 右侧占用,horizontal 与 left 相加,不含父容器自身位置。
pub right: f32,
// 底侧占用,vertical 与 top 相加。
pub bottom: f32,
// 左侧占用,安排内容原点时加到父边框盒 x。
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)]
/// 交叉轴对齐规则;Stretch 需要结合子项是否自动尺寸决定实际拉伸。
pub enum CrossAlign {
#[default]
// 对齐内容区起点;内容溢出时其他偏移模式也退回起点。
Start,
// 按交叉轴非负剩余空间的一半偏移,不改变自然尺寸。
Center,
// 按交叉轴全部非负剩余空间偏移。
End,
// 偏移为零,尺寸拉伸由 flex_sizes 按 Auto/上下限另行计算。
Stretch,
}
impl CrossAlign {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
// 模型字段编码用稳定小写名字,alignSelf 的 auto 不属于此枚举,由 Option 在模型层表示。
match self {
Self::Start => "start",
Self::Center => "center",
Self::End => "end",
Self::Stretch => "stretch",
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
// 拒绝未知对齐策略;auto 应在调用方转为无自身覆盖,不能解析成某个具体父策略。
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 {
// 溢出时剩余空间归零,Center/End 都退化为起点对齐,避免把内容前缘移出可达滚动范围。
let spare = (available - occupied).max(0.0);
// Stretch 的位置与 Start 相同,实际可拉伸尺寸在分配阶段已经决定。
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]
// 主轴从内容起点顺序排列。
Start,
// 把正剩余空间平分到内容前后,保持既定子项尺寸与固定 gap。
Center,
// 将正剩余空间放到首项之前。
End,
// 至少两个孩子时把剩余空间均匀加到项间,原固定 gap 仍保留。
SpaceBetween,
}
impl MainAlign {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
// 把已验证主轴枚举编码回源属性,字段名与布局分派共享这组名称。
match self {
Self::Start => "start",
Self::Center => "center",
Self::End => "end",
Self::SpaceBetween => "space-between",
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
// 主轴只支持四个明确策略,不接受交叉轴 stretch 或任意别名。
match value {
"start" => Ok(Self::Start),
"center" => Ok(Self::Center),
"end" => Ok(Self::End),
"space-between" => Ok(Self::SpaceBetween),
_ => Err(endor_core::VmError::InvalidArgument(
"mainAlign expects start, center, end or space-between",
)),
}
}
}
/// Main-axis constraints interpreted independently by each parent occurrence.
#[derive(Debug, Clone, Copy, PartialEq)]
/// 主轴伸缩策略与限值,不能用一个宽度常量替代完整分配规则。
pub struct FlexPolicy {
// Some 指定主轴首选基准,None 使用首次内容测量;grow/fill 的缺省基准在 flex_sizes 中解释为零。
pub basis: Option<f32>,
// 主轴弹性分配下限,还要与对应盒模型 min/max 求交。
pub min: f32,
// 主轴弹性分配上限,达到后退出剩余空间的再分配。
pub max: f32,
// 收缩权重还会乘 basis;默认零表示空间不足时不主动压缩该项。
pub shrink: f32,
}
impl Default for FlexPolicy {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
basis: None,
min: 0.0,
max: MAX_DIMENSION as f32,
shrink: 0.0,
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
/// 逻辑宽高值,不隐含窗口像素比例或相机缩放。
pub struct Size {
// 逻辑横向长度,测量/分配上下文决定是自然尺寸还是实际边框盒宽。
pub width: f32,
// 逻辑纵向长度,不携带 DPI 或相机倍率。
pub height: f32,
}
#[derive(Debug, Clone, PartialEq)]
/// 影响原生测量的缓存形状,避免把颜色等纯绘制变化视为尺寸变化。
enum Shape {
// 非默认盒策略把尺寸约束也纳入缓存键,防止同业务对象在不同父分配中误用结果。
Sized(Box<Shape>, crate::box_model::Sizing, [f32; 4]),
// 文本内容、字号等级与有效换行宽都影响测量,颜色不进入键。
Text(String, u32, f32),
// 固定自然宽高,亦用于视口、间隔和场景外包范围。
Rect(f32, f32),
// 方向、有效 inset、gap 和孩子外尺寸一起决定容器自然尺寸。
Container(bool, Insets, f32, Vec<Size>),
// 为内容形状套上实际 inset,文字自然测量需加回边框/内边距。
Padded(Box<Shape>, Insets),
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
/// 结构出现身份键,区分同一业务对象在不同父位置的展示。
struct Key {
// 父出现 Mount ID;同一业务节点在不同父下要有不同出现身份。
parent: Option<u64>,
// 父组成边的稳定 slot,不是当前数组下标或屏幕坐标。
slot: u64,
// 孩子业务身份文本,替换为不同对象时同一 slot 也不能误复用旧出现。
block: String,
}
#[derive(Debug, Clone, Default)]
/// 布局缓存、稳定出现身份与统计。构建在临时副本上完成,成功后才写回。
pub struct LayoutRuntime {
// 按 Block 身份只保留最后一份 Shape/Size;共享 Block 不同约束可交替覆盖,但每次复用前仍比较完整 Shape。
sizes: HashMap<String, (Shape, Size)>,
// 出现身份取父 Mount、稳定 slot 和 Block 身份三元组,区别于仅按业务组件分配一个全局 ID。
ids: HashMap<Key, u64>,
// 已分配 ID 高水位,清出现表或取消重试也不应降低。
next_id: u64,
// 每次成功 structure/frame 构建递增,供后续 Mount 与输入归属校验。
pub version: u64,
// 本次实际执行 measure_shape 的缓存未命中次数,不是扫描节点总数。
pub measured: usize,
// 完整原生构建安排出现次数;结构准备不记为已完成排列。
pub arranged: usize,
// 纯原生完整构建记一轮,结构准备记零;源布局驱动另统计其迭代。
pub settle_passes: usize,
// 此同步原生构建的工作片统计,源协议可恢复切片由外层驱动维护。
pub slices: usize,
// 本窗口逻辑可用大小,未知时使用有限数值的无界哨兵。
pub viewport: Option<Size>,
// 可选旧 Page 模式,用于生成根轴约束;独立场景视口另有自己的有限盒。
pub page_mode: Option<crate::page::Mode>,
}
/// 迭代遍历中的一个出现,保存父子索引、约束与稳定 ID。
struct Item<'a> {
// 借用当前原生不可变视觉节点,构建完成前根树需保持存活。
node: &'a UiNode,
// items 中父出现索引,发布时再转换为父 Mount ID。
parent: Option<usize>,
// items 中有序子出现索引,同一 UiNode Arc 可出现在多个索引。
children: Vec<usize>,
// 由结构 Key 复用或新分配的本出现 ID。
id: u64,
// 此出现独立的 [minW,minH,maxW,maxH],不能按共享业务对象合并。
constraints: [f32; 4],
}
/// 检查尺寸非负、有限且不超过布局上限。
fn bounded(value: f32) -> Result<f32> {
// 所有最终测量长度经此边界检查,NaN/负数/超预算都导致本次布局失败。
if !value.is_finite() || !(0.0..=MAX_DIMENSION as f32).contains(&value) {
bail!("UI layout exceeds the 1000000 coordinate/size limit");
}
Ok(value)
}
/// 检查允许有正负方向的坐标仍在安全布局范围内。
fn coordinate(value: f32) -> Result<f32> {
// 位置允许负方向,但有限性和绝对值预算仍约束最终原点及远端。
if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 {
bail!("UI coordinate exceeds the 1000000 limit");
}
Ok(value)
}
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
pub fn root_constraints(viewport: Option<Size>) -> [f32; 4] {
// 无视口时以 MAX_DIMENSION 作为无界哨兵;它是有限数值预算,但 Fill 必须另行要求真正有限分配。
let size = viewport.unwrap_or(Size {
width: MAX_DIMENSION as f32,
height: MAX_DIMENSION as f32,
});
[0.0, 0.0, size.width, size.height]
}
/// 从父级上限扣除内边距;无界哨兵保持无界,不因减法被误认为有限。
fn content_limit(available: f32, inset: f32) -> f32 {
// 不能直接从哨兵减 padding,否则略小于 MAX_DIMENSION 会被误认为有限空间,从而错误允许 Fill。
if available >= MAX_DIMENSION as f32 {
available
} else {
(available - inset).max(0.0)
}
}
/// 把父盒子的可用尺寸传递给子项,扣除容器内边距并考虑主轴是否可延伸。
pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
// 先让父自身 boxWidth/Height 与 max 约束进入可用空间,再选择孩子的轴规则。
let parent = node.sizing.available(parent);
// Overlay 双轴都受内容盒上限约束;不像 Row/Column 在主轴自然展开。
if let UiKind::Overlay { .. } = node.kind {
// Overlay 双轴内容区扣 border+有效 padding,不让孩子共享线性主轴无界规则。
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 { .. } | UiKind::Scene2D { .. } | UiKind::CanvasViewport { .. }
) {
// 世界 Canvas/Scene2D 与其观察视口不把屏幕尺寸传入世界布局,场景孩子用无界轴约束。
return root_constraints(None);
}
// 滚动孩子约束基于视口实际分配及允许轴,构造宽高先受父约束夹取。
if let UiKind::Scroll { width, height, .. } = node.kind {
return arranged_child_constraints(
node,
parent,
constrained(Size { width, height }, parent),
);
}
// Row 主轴 x 无界、交叉轴 y 有限;Column/Button/Scroll 的普通方向相反,具体滚动另有上方分支。
match container(node) {
Some((true, padding, _)) => [
0.0,
0.0,
MAX_DIMENSION as f32,
content_limit(parent[3], padding.vertical()),
],
Some((false, padding, _)) => [
0.0,
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] {
// 按横纵轴分别扣两侧 margin,不能用某一轴的总外边距同时改另一轴。
for (axis, margin) in [child.margin.horizontal(), child.margin.vertical()]
.into_iter()
.enumerate()
{
// 仅对真正有限轴扣 margin,保持无界哨兵;最小约束同时收窄以维持 min<=max。
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.
/// 根据具体子项尺寸策略和所在位置细化约束;世界 UI 根使用面板的有限像素尺寸。
pub fn occurrence_constraints(
parent: &UiNode,
child: &UiNode,
mut base: [f32; 4],
offer: Size,
child_index: usize,
) -> [f32; 4] {
// 世界面板 UI 根必须精确按纹理分辨率布局,最小与最大相同,不让外层视口世界尺寸泄入纹理空间。
if let UiKind::Viewport3D { panels, .. } = &parent.kind {
// 面板 child_index 与 panels/children 保持同序,该 UI 根按自己的纹理尺寸精确布局。
let size = panels[child_index];
return [size.width, size.height, size.width, size.height];
}
// Fill 初次测量需扣父内容 inset,后面再由 margin_constraints 扣孩子外边距。
let inset = insets(parent);
// 两轴独立判断 fill 与当前 offer 是否有限,不能因另一轴有限就允许无界 fill。
for (axis, dimension, available, padding) in [
(0, child.sizing.width, offer.width, inset.horizontal()),
(1, child.sizing.height, offer.height, inset.vertical()),
] {
// Fill 若从父容器主轴得到无界 base,但父本轮 offer 有限,则用内容可用长度测量,后面再参与权重分配。
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],
child_index: usize,
) -> [f32; 4] {
// 父自身盒策略先收窄初次可分配最大值,提供给 occurrence_constraints 补有限 fill offer。
let offer = parent.sizing.available(constraints);
occurrence_constraints(
parent,
child,
child_constraints(parent, constraints),
Size {
width: offer[2],
height: offer[3],
},
child_index,
)
}
/// 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 { axis, .. } = node.kind {
// 禁用滚动轴使用此出现已安排 bounds 扣 inset,不能继续按最初构造参数给文字宽度。
let padding = insets(node);
return [
0.0,
0.0,
// 允许横向滚动时内容宽使用无界哨兵;禁止横向时用实际内容视口有限宽以驱动换行。
if axis.horizontal() {
MAX_DIMENSION as f32
} else {
(bounds.width - padding.horizontal()).max(0.0)
},
// 纵轴独立判断是否可延伸,禁用轴必须受当前出现 bounds 限制。
if axis.vertical() {
MAX_DIMENSION as f32
} else {
(bounds.height - padding.vertical()).max(0.0)
},
];
}
child_constraints(node, available)
}
/// 将期望尺寸限制在调用方给出的最小/最大边界内。
pub fn constrained(size: Size, constraints: [f32; 4]) -> Size {
Size {
width: size.width.clamp(constraints[0], constraints[2]),
height: size.height.clamp(constraints[1], constraints[3]),
}
}
/// Effective content inset, including borders, in border-box coordinates.
/// 计算布局使用的有效内边距和边框占用,兼容旧的统一 padding。
pub fn insets(node: &UiNode) -> Insets {
// 单边 padding 优先,其次兼容容器的统一 padding,最后再加 border;其他叶节点无统一 padding 默认值。
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)> {
// 只识别线性 Row/Column/Button/Scroll,Overlay/世界场景在调用方有独立布局分支。
match &node.kind {
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,
}
}
/// 根据容器方向选择主轴,供 flex 和对齐共享同一判断。
pub fn main_axis(node: &UiNode) -> Option<usize> {
// row=true 映射横轴 0,纵向映射轴 1;非线性节点返回 None。
container(node).map(|(row, _, _)| usize::from(!row))
}
/// 根据形状键计算期望尺寸,包括文字换行、容器子项和盒模型占用。
fn measure_shape(shape: &Shape) -> Result<Size> {
// 只根据 Shape 计算原生自然/盒策略尺寸,不修改 UiNode、出现身份或任何业务对象。
let size = match shape {
// 先测内部内容形状,再由完整盒策略按当前约束求边框盒尺寸。
Shape::Sized(shape, sizing, constraints) => {
sizing.resolve(measure_shape(shape)?, *constraints)?
}
// 内容自然尺寸外加 inset,尺寸结果仍会在函数尾做全局预算检查。
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);
// 用同一字号在有效宽度下重新排段落高度,不能把自然单行高当作换行高。
let wrapped = text::measure_wrapped(value, style, *width);
Size {
width: intrinsic.width.min(*width),
height: wrapped.height,
}
}
Shape::Rect(width, height) => Size {
width: *width,
height: *height,
},
Shape::Container(row, padding, gap, children) => {
// 主轴相加、交叉轴取最大,再加对应 inset 与 n-1 个 gap;saturating_sub 保证零孩子不出现负间距。
let along = children
.iter()
.map(|s| if *row { s.width } else { s.height })
.sum::<f32>()
+ if *row {
padding.horizontal()
} else {
padding.vertical()
}
+ gap * children.len().saturating_sub(1) as f32;
// 交叉轴取最大孩子外尺寸,再加父交叉轴 inset,不把横向孩子高相加。
let cross = children
.iter()
.map(|s| if *row { s.height } else { s.width })
.fold(0.0_f32, f32::max)
+ if *row {
padding.vertical()
} else {
padding.horizontal()
};
// 将主/交叉轴长度还原为物理 width/height,方向不同不改变计算规则。
if *row {
Size {
width: along,
height: cross,
}
} else {
Size {
width: cross,
height: along,
}
}
}
};
bounded(size.width)?;
bounded(size.height)?;
Ok(size)
}
/// 建立影响测量的形状键,将布局属性、子项测量结果和约束组合起来。
fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Shape> {
// 先应用当前盒策略得到内容可测量上限,文字宽度键必须反映该次出现的有效约束。
let available = node.sizing.available(constraints);
// 按 UiKind 提取只影响测量的数据,纯绘制颜色/材质不会制造测量缓存失效。
let shape = match &node.kind {
UiKind::Overlay { .. } => {
// Overlay 自然尺寸使用孩子含 margin 的最大外框,再加自身有效 inset。
let padding = insets(node);
// Overlay 取含各自 margin 的子盒最大宽高,不把重叠项逐个相加。
let size = children.into_iter().zip(node.children()).fold(
Size::default(),
|size, (child, node)| {
// 先把此孩子 margin 加到自身边框盒,避免只看内容尺寸漏算外占用。
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::CanvasViewport { width, height, .. } | UiKind::Viewport3D { width, height, .. } => {
Shape::Rect(*width, *height)
}
UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } => {
// 世界内容外包范围从零开始,因此只有负坐标且右下端仍为负时不会扩张正向 extent。
let mut size = Size::default();
// 场景尺寸仅累计孩子右下端最大值;负方向内容不向左上平移整体,导航仍由相机负责。
for (position, child) in positions.iter().zip(children) {
size.width = size.width.max(position[0] + child.width);
size.height = size.height.max(position[1] + child.height);
}
Shape::Rect(size.width, size.height)
}
UiKind::Text { value, size, .. } => {
// 文字内容有效宽由本次 available 扣有效 inset,测量后 Padded 再加回。
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),
_ => {
// 剩余种类必须是已知线性容器;未知原生协议明确失败,不猜测为 Column。
let (row, padding, gap) = container(node).context("unknown native visual protocol")?;
// 缓存键保存含每个孩子 margin 的外尺寸,margin 改变即使孩子本身尺寸不变也需重测父。
let children = children
.into_iter()
.zip(node.children())
.map(|(size, child)| child.margin.outer(size))
.collect();
Shape::Container(row, padding, gap, children)
}
};
// 只有非默认盒尺寸策略才包 Sized 并把约束加入键;文字等自身 Shape 已包含其实际影响测量的参数。
Ok(if node.sizing == crate::box_model::Sizing::default() {
shape
} else {
Shape::Sized(Box::new(shape), node.sizing, constraints)
})
}
/// 按形状键求原生自然/盒策略尺寸;完整输入 min/max 的最终夹取由原生 Measure 或整树调用方负责。
pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Size> {
measure_shape(&shape(node, children, constraints)?)
}
/// Private derived scratch space for one occurrence during layout. Contains no
/// managed references; source code cannot mutate it through heap attributes.
#[derive(Debug, Default)]
/// 只读布局调用期间提供给 Mount 查询的派生上下文,不作为业务状态保留。
pub struct Context {
// 本轮当前出现的不可变根节点;准备未完成为 None,不可回退旧快照伪装。
pub node: Option<std::sync::Arc<UiNode>>,
// 孩子本轮受约束实测尺寸,供原生 measure/arrange 复用而不重复跑源子树。
pub children: Vec<Size>,
// 本轮首次测量的首选尺寸,不应被上轮 flex 实际分配覆盖,否则反复测量会改变伸缩基准。
pub preferred: Vec<Size>,
// 排列阶段可用的临时 [x,y,w,h];测量阶段为 None,禁止提前读几何。
pub bounds: Option<[f32; 4]>,
}
/// 为单个原生节点按已分配几何生成内容命令;容器背景/边框与整树遍历由外层组织。
pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderCommand> {
// 文字内容盒需要减 inset 并带 wrap_bounds;Rect/Button 背景则画完整 border-box。
// 保留分配 Size,防止 Text 模式中的字号字段 size 遮蔽后误用为像素盒宽高。
let size_for_text = size;
// 文字和三维视口绘制内容区,普通矩形/按钮仍使用完整边框盒。
let inset = insets(node);
// 每个节点至多生成一条内容/背景命令,边框和孩子由外层 paint_commands/遍历追加。
match &node.kind {
// 三维 View 占内容区,扣掉边框/内边距后负面积夹为零,世界矩阵保持不变。
UiKind::Viewport3D { view, .. } => Some(RenderCommand::Scene3D {
x: x + inset.left,
y: y + inset.top,
width: (size.width - inset.horizontal()).max(0.),
height: (size.height - inset.vertical()).max(0.),
view: view.clone(),
}),
// 文字原点移到内容起点,wrap_bounds 与本出现实际内容宽高保持一致。
UiKind::Text { value, size, color } => Some(RenderCommand::Text {
x: x + inset.left,
y: y + inset.top,
value: value.clone(),
size: *size,
color: color.clone(),
wrap_bounds: Some([
(size_for_text.width - inset.horizontal()).max(0.0),
(size_for_text.height - inset.vertical()).max(0.0),
]),
}),
// 矩形采用已分配的完整边框盒,自然 width/height 不覆盖父布局结果。
UiKind::Rect { radius, color, .. } => Some(RenderCommand::RoundedRect {
x,
y,
width: size.width,
height: size.height,
radius: *radius,
color: color.clone(),
}),
// 默认按钮背景覆盖边框盒,固定原生圆角/底色,子文字另外展开。
UiKind::Button { .. } => Some(RenderCommand::RoundedRect {
x,
y,
width: size.width,
height: size.height,
radius: 7.0,
color: "#E3EAF7".into(),
}),
// 纯容器/场景根没有默认内容命令,不代表其孩子可以跳过绘制。
_ => None,
}
}
/// 从一个节点及其分配几何生成原生绘制命令,包括背景、边框与内容。
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();
// 边框按四个零圆角条分别追加,宽厚各自钳到盒大小;不使用单一 stroke_width 混淆不等厚四边。
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.
/// 判断子项主轴是否采用 fill,从而参与剩余空间分配。
fn main_fill(child: &UiNode, row: bool) -> bool {
// 只解释线性父主轴对应的孩子盒策略,交叉轴 fill 不增加主轴增长权重。
(if row {
child.sizing.width
} else {
child.sizing.height
}) == crate::box_model::Dimension::Fill
}
/// 取出主轴 flex/尺寸策略共同约束下的最小和最大分配范围。
fn main_limits(child: &UiNode, row: bool) -> (f32, f32) {
// 盒模型提供物理轴的 min/max,下面与 flex_policy 范围求交。
let sizing = child.sizing;
// 根据父方向选择孩子的宽或高界限,不能将 flexMin 永远解释为宽度。
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),
)
}
/// 按权重分配主轴剩余空间,尊重每项 min/max;达到上限的项退出后重新分配剩余量。
pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[Size]) -> Vec<Size> {
// Overlay 各孩子独立填内容盒,不共享主轴剩余量;普通线性容器才做下方加权增长/收缩。
if matches!(node.kind, UiKind::Overlay { .. }) {
// Overlay 各项独享同一有限内容区,不把空间按兄弟总权重分割。
let inset = insets(node);
return children
.iter()
.zip(node.children())
.map(|(size, child)| {
// 复制孩子实测值,只修改符合 fill/stretch 条件的轴,不改共享 UiNode。
let mut size = *size;
// Overlay 横轴显式 fill 才占扣 margin 后可用宽,再受盒 min/max 夹取。
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);
}
// Overlay 纵轴 fill 或 auto+stretch 可扩高;显式 fixed 高度不被父对齐覆盖。
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();
}
// 非 Overlay 的非线性节点没有主轴弹性分配,原样返回孩子尺寸。
let Some((row, padding, gap)) = container(node) else {
return children.to_vec();
};
// 按视觉树孩子顺序读取策略,和输入 children/preferred 尺寸保持同序。
let nodes = node.children();
// 保留原实测尺寸副本,交叉轴拉伸与主轴重分配依次写到该返回值。
let mut output = children.to_vec();
// 先处理交叉轴 Stretch,仅 Auto 维度可拉伸;明确 Fixed 不因父对齐策略被改写。
// 先按每个孩子 alignSelf 或父 crossAlign 判断交叉轴资格,不依赖主轴是否有增长权重。
for (child, size) in nodes.iter().zip(&mut output) {
// 只有有效对齐为 stretch 才进入尺寸改写,center/end 仅在排列时产生偏移。
if child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch {
// 取得孩子盒轴策略和 min/max,拉伸仍不能超过这些明确界限。
let sizing = child.sizing;
// Row 的交叉轴为高度,只拉伸 auto 高,扣父 inset 与孩子上下 margin。
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);
// 纵向父的交叉轴为宽度,同样只拉伸 auto 宽,不修改 fixed。
} 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);
}
}
}
// 无主轴伸缩需求时也可能已完成交叉轴拉伸,直接返回这份 output,不丢弃上面调整。
if !nodes.iter().any(|child| child.has_flex()) {
return output;
}
// 可分配主轴空间先扣父 inset、每个孩子两侧 margin 和固定 gap;使用 f64 累计减小权重分配误差。
let space = ((if row { bounds.width } else { bounds.height }) as f64
- if row {
padding.horizontal()
} else {
padding.vertical()
} as f64
- nodes
.iter()
.map(|child| {
// 扣除每个孩子在主轴两侧的 margin,不能把横向父的上下 margin 算入可分配宽。
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);
// 显式 basis 优先;grow/fill 默认从零参与分配,其余用 preferred,不拿已经压缩后的 children 充当自然基准。
let bases: Vec<f64> = nodes
.iter()
.zip(preferred)
.map(|(child, size)| {
child.flex_policy.basis.unwrap_or_else(|| {
// 有增长或主轴 fill 且没有显式 basis 时从零参与分配,避免把原自然宽重复计入。
if child.flex > 0.0 || main_fill(child, row) {
0.0
} else if row {
size.width
} else {
size.height
}
}) as f64
})
.collect();
// 每个原 basis 先夹主轴合法范围,再根据总剩余量决定增长或收缩。
let mut sizes: Vec<f64> = nodes
.iter()
.zip(&bases)
.map(|(child, basis)| {
// 按孩子当前主轴取 flex/box 交集,这份界限同时用于初始夹取与终点饱和。
let (min, max) = main_limits(child, row);
basis.clamp(min as f64, max as f64)
})
.collect();
// 初始 basis 先钳 min/max,剩余正数为增长,负数为收缩;非参与项仍占据其合法基准尺寸。
let free = space - sizes.iter().sum::<f64>();
// free>0 进入增长,否则是收缩;即使无可分配余量也不会重新发明自然基准。
let growing = free > 0.0;
// 增长按 flex,主轴 Fill 且 flex=0 用权重1;收缩按 shrink×原 basis,保持大内容承担较多缩减。
let weights: Vec<f64> = nodes
.iter()
.zip(&bases)
.map(|(child, basis)| {
// 增长读取 flex(fill 且 flex=0 时给单位权重),收缩读取 shrink×原始 basis。
if growing {
// 纯 fill 的默认增长权重为一;显式 flex 则使用其用户权重。
if main_fill(child, row) && child.flex == 0.0 {
1.0
} else {
child.flex as f64
}
} else {
child.flex_policy.shrink as f64 * basis
}
})
.collect();
// 增长端点是各自 max,收缩端点是各自 min,方便以剩余容量统一分配。
let limits: Vec<f64> = nodes
.iter()
.map(|child| {
// 本次取分配终点,策略选择取决于整体 free 的符号,不按单个孩子单独猜测。
let (min, max) = main_limits(child, row);
// 选择本轮饱和终点,之后用绝对容量统一处理增长与收缩。
if growing { max as f64 } else { min as f64 }
})
.collect();
// 只让有正权重且未到 min/max 的项参与,每轮触顶/到底的项退出,剩余空间继续分给未饱和者。
let mut active: Vec<usize> = (0..nodes.len())
.filter(|&i| {
weights[i] > 0.0
&& if growing {
sizes[i] < limits[i]
} else {
sizes[i] > limits[i]
}
})
.collect();
// 剩余增长/收缩工作量取绝对值,方向只在更新 sizes 时施加。
let mut remaining = free.abs();
// 只对正权重未饱和项继续分配;饱和项退出后重算剩余权重,避免被夹掉的空间丢失。
while remaining > 0.0 && !active.is_empty() {
// 每轮以当前 active 权重总和为分母,不继续把已触顶/触底项计入比例。
let total = active.iter().map(|&i| weights[i]).sum::<f64>();
// 累计本轮真实分配量,受单项容量限制后可能小于 remaining。
let mut spent = 0.0;
// 记录是否有项达到限制;无饱和时按比例已分完,应停止而不追逐浮点残差。
let mut saturated = false;
// 全部项使用同一轮 remaining 比例,不能边更新 remaining 边偏向后项。
for &i in &active {
// 本项距离增长/收缩端点的绝对容量,本轮 amount 不得超过它。
let capacity = (limits[i] - sizes[i]).abs();
// 本轮按同一 remaining 比例计算,再限制到该项剩余容量;不能为单项超配后再粗暴钳制丢掉剩余量。
let amount = (remaining * weights[i] / total).min(capacity);
sizes[i] += if growing { amount } else { -amount };
spent += amount;
saturated |= amount >= capacity;
}
remaining = (remaining - spent).max(0.0);
// Without saturation this round distributed all available space. Do not
// spin on floating point residue; a saturated round freezes a child.
// 没有项饱和说明比例分配已完成,微小浮点残差不能导致无进展死循环。
if !saturated {
break;
}
// 饱和后筛出仍有容量的项,下一轮只在这些项中重新分配剩余量。
active.retain(|&i| {
// 增长保留 size<max,收缩保留 size>min,正权重条件在初始 active 已验证。
if growing {
sizes[i] < limits[i]
} else {
sizes[i] > limits[i]
}
});
}
// 只把最终主轴数值写回 output,交叉轴保留前面的 stretch 结果。
for (size, along) in output.iter_mut().zip(sizes) {
// 横向父写 width,纵向父写 height,不能用同一个 Size 字段表示所有主轴。
if row {
size.width = along as f32;
} else {
size.height = along as f32;
}
}
output
}
/// 根据最终分配尺寸计算子项原点,处理容器方向、对齐、间距与外边距。
pub fn arrange_native(
node: &UiNode,
x: f32,
y: f32,
bounds: Size,
children: &[Size],
) -> Result<Vec<(f32, f32)>> {
// 3D 面板根在各自纹理空间均从 (0,0) 开始,世界摆放交由 Panel world 矩阵,不把世界坐标写入普通 UI 布局。
if matches!(node.kind, UiKind::Viewport3D { .. }) {
return Ok(node.children().iter().map(|_| (0., 0.)).collect());
}
// Overlay 各孩子独立在同一内容盒内对齐,不能按线性游标累加位置。
if let UiKind::Overlay { .. } = node.kind {
// 取得含 border 的有效内容 inset,后面的对齐可用范围扣除它。
let padding = insets(node);
// Overlay 把 mainAlign 的 center/end 映射为横轴偏移;其他主轴模式在此按 start 处理。
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)| {
// 对齐比较占用含孩子 margin,最终原点再加左/上 margin。
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();
}
// 二维视口里的 Scene2D 根先放在内容起点,世界相机投影由后续 viewport 处理。
if matches!(node.kind, UiKind::CanvasViewport { .. }) {
// 边框/内边距影响视口内容原点,但不重写场景内部业务 x/y。
let inset = insets(node);
return Ok(node
.children()
.iter()
.map(|_| (x + inset.left, y + inset.top))
.collect());
}
// 世界布局直接用各孩子业务位置加父原点,不做线性 gap 或主轴对齐。
if let UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } = &node.kind {
return Ok(positions
.iter()
.map(|point| (x + point[0], y + point[1]))
.collect());
}
// 其余种类只有线性容器才安排孩子,叶节点返回空位置表。
let Some((row, padding, gap)) = container(node) else {
return Ok(vec![]);
};
// 按实际分配后的主轴外尺寸与固定 gap 求总占用,margin 不能漏算。
let occupied = children
.iter()
.zip(node.children())
.map(|(child, node)| {
// 根据父方向取宽+左右 margin 或高+上下 margin,交叉尺寸不进入主轴占用。
// 父交叉轴内容尺寸先扣该方向的 inset:Row 用高,纵向父用宽。
if row {
child.width + node.margin.horizontal()
} else {
child.height + node.margin.vertical()
}
})
.sum::<f32>()
+ gap * children.len().saturating_sub(1) as f32;
// 父实际分配的主轴边框盒长度,下一步扣父 inset 与孩子总占用。
let available = if row { bounds.width } else { bounds.height };
// 不足空间时剩余量夹为零,center/end 不产生负偏移让内容前缘不可达。
let spare = (available
- if row {
padding.horizontal()
} else {
padding.vertical()
}
- occupied)
.max(0.0);
// 主轴 center/end 只分配剩余空白,不再更改 flex 阶段已经求好的尺寸。
let offset = match node.main_align {
MainAlign::Center => spare / 2.0,
MainAlign::End => spare,
_ => 0.0,
};
// space-between 只在至少两个孩子间分配额外剩余量,原固定 gap 仍保留;单项保持 start。
let distributed_gap = if node.main_align == MainAlign::SpaceBetween && children.len() > 1 {
spare / (children.len() - 1) as f32
} else {
0.0
};
// 线性游标从父起侧 inset 加整体 offset 开始,后续逐项累积外尺寸和间距。
let mut cursor = if row { padding.left } else { padding.top } + offset;
// 输出只含孩子原点,顺序对应 children 和 node.children,不携带托管引用。
let mut points = Vec::with_capacity(children.len());
// 逐孩子按实际尺寸、各自 margin 与 alignSelf 计算,避免把共享业务对象的两处位置合并。
for (index, child) in children.iter().enumerate() {
// 用同一位置索引取得样式,不能按 block_id 查找唯一孩子而误处理中重复出现。
let child_node = &node.children()[index];
// 本项四边外占用,主轴推进和交叉对齐分别使用对应两边和。
let margin = child_node.margin;
// 显式 alignSelf 优先,None 才继承此父出现的 crossAlign。
let align = child_node.align_self.unwrap_or(node.cross_align);
// 在父交叉内容尺寸中按孩子外尺寸求非负偏移,不修改主轴游标。
let cross = align.offset(
// 孩子交叉外尺寸包含对应两边 margin,与父内容尺寸使用同一物理轴。
if row {
bounds.height - padding.vertical()
} else {
bounds.width - padding.horizontal()
},
if row {
child.height + margin.vertical()
} else {
child.width + margin.horizontal()
},
);
// 把主轴 cursor 与交叉偏移按方向还原成绝对 x/y,并叠加父与孩子起侧边距。
let point = if row {
(
x + cursor + margin.left,
y + padding.top + cross + margin.top,
)
} else {
(
x + padding.left + cross + margin.left,
y + cursor + margin.top,
)
};
coordinate(point.0)?;
coordinate(point.1)?;
coordinate(point.0 + child.width)?;
coordinate(point.1 + child.height)?;
points.push(point);
// 主轴游标推进当前盒尺寸、两侧 margin、固定及额外 gap;交叉轴对齐不改变主轴顺序。
cursor += if row { child.width } else { child.height };
cursor += if row {
margin.horizontal()
} else {
margin.vertical()
};
cursor += gap + distributed_gap;
}
Ok(points)
}
impl LayoutRuntime {
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
pub fn root_constraints(&self) -> [f32; 4] {
// 有旧页面模式时按其有限/延伸轴生成根约束,否则用普通视口最大约束。
self.page_mode.map_or_else(
|| root_constraints(self.viewport),
|mode| mode.constraints(self.viewport),
)
}
/// 撤销指定业务组件的测量缓存,下一次遇到该节点重新求值。
pub fn invalidate(&mut self, block: &str) {
// 只移除指定业务身份最后一份测量键,其他节点缓存和稳定出现 ID 不受此入口影响。
self.sizes.remove(block);
}
/// 提高新 Mount ID 的分配下限,避免取消/重试后复用已经暴露过的出现身份。
pub fn reserve_identity_floor(&mut self, floor: u64) {
// 取消恢复后把临时分配过的 ID 高水位带回,不能重新分配可能已暴露的身份。
self.next_id = self.next_id.max(floor);
}
/// 返回出现 ID 的当前分配高水位,供桥接层在取消/重试后继续保持唯一性。
pub fn identity_high_watermark(&self) -> u64 {
self.next_id
}
/// 清空结构出现身份表;业务对象和源状态不因此被复制。
pub fn clear_occurrences(&mut self) {
// 清出现映射不重置 next_id,高水位继续增加,避免旧事件与新出现数值身份意外重合。
self.ids.clear();
}
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
pub fn frame(&mut self, root: &UiNode) -> Result<Frame> {
// Stage all cache/identity changes; a rejected frame leaves the last
// valid occurrence registry intact and never publishes partial geometry.
// 缓存、ID 分配和统计都先在副本上变更,build 失败不污染当前运行时。
let mut next = self.clone();
// 完整原生构建成功才提交副本;返回 Frame 仍由桥接添加投影/发布上下文。
let frame = next.build(root, false)?;
*self = next;
Ok(frame)
}
/// Allocate stable occurrence identities without measuring or publishing geometry.
/// 先建立稳定的出现身份和父子关系,但不把此阶段当成最终测量结果。
pub fn structure(&mut self, root: &UiNode) -> Result<Frame> {
// 结构准备也使用事务式副本,失败不能留下部分新出现映射。
let mut next = self.clone();
// 只分配身份和链接,暂用零几何;后续源布局才提供有效测量与排列结果。
let frame = next.build(root, true)?;
*self = next;
Ok(frame)
}
/// 以形状、子项尺寸和约束查缓存;未命中才执行测量并更新本轮统计。
pub fn measure_node(
&mut self,
node: &UiNode,
children: Vec<Size>,
constraints: [f32; 4],
) -> Result<Size> {
// 缓存只有完整 Shape 相等才复用,纯颜色变化不在测量 Shape 中,因此无需重做尺寸求解。
let shape = shape(node, children, constraints)?;
// 同业务 ID 还必须完整 Shape 相等才命中;不同约束共享对象可能轮流覆盖最后键。
if let Some((_, size)) = self
.sizes
.get(&node.block_id)
.filter(|(old, _)| *old == shape)
{
return Ok(*size);
}
self.measured += 1;
// 实际未命中才测量,成功后更新 sizes;失败不安装不可用的测量条目。
let size = measure_shape(&shape)?;
self.sizes.insert(node.block_id.clone(), (shape, size));
Ok(size)
}
/// 迭代展开出现树、计算约束和几何、产生绘制命令;结构准备与完整布局共用此驱动。
fn build(&mut self, root: &UiNode, structural: bool) -> Result<Frame> {
// 按本次构建重置计数;structural 只准备身份,不把零几何当一次已完成测量/排列。
self.measured = 0;
self.arranged = 0;
self.settle_passes = usize::from(!structural);
self.slices = usize::from(!structural);
// 将树展开为父先子后出现数组,每个位置保存自己的约束与身份。
let mut items = Vec::<Item<'_>>::new();
// 用显式栈构建父先子后 items,根采用 slot0/无父;孩子逆序入栈保持原始布局顺序。
let mut work = vec![(root, None::<usize>, 0, self.root_constraints())];
// 本轮结构键集合用于检测重复 slot,并在成功后移除过时 ID 映射。
let mut used = HashSet::new();
// 显式 DFS 遍历原生视觉树,共享 Arc 在不同 slot/父下仍会形成独立出现。
while let Some((node, parent, slot, constraints)) = work.pop() {
// 按本次展开条目数检查一万上限,超限拒绝整帧而不是截断余下孩子。
if items.len() >= MAX_LAYOUT_VISITS {
bail!("expanded UI layout exceeds 10000 nodes");
}
// slot 与孩子必须一一对应,否则稳定出现身份不可信,不能用 zip 静默丢掉多余项。
if node.slots.len() != node.children().len() {
bail!("invalid child slot table");
}
// 稳定身份包含父 Mount ID、组成 slot 和业务 ID;不是仅凭对象身份分配一个 ID。
let key = Key {
parent: parent.map(|i| items[i].id),
slot,
block: node.block_id.clone(),
};
// 结构三元组未变则复用 ID,新出现 checked_add 分配;只在成功构建副本后才提交整表。
let id = if let Some(id) = self.ids.get(&key) {
*id
} else {
self.next_id = self
.next_id
.checked_add(1)
.context("Mount identity exhausted")?;
self.ids.insert(key.clone(), self.next_id);
self.next_id
};
// 同一展示出现键不得重复;共享组件在不同父或不同 slot 下使用不同 key,仍允许多次出现。
if !used.insert(key) {
bail!("duplicate child slot in one presentation");
}
// 保存本出现的数组索引,后面作为孩子 parent 与尺寸/位置数组下标。
let index = items.len();
// 将当前索引追加到父孩子列表,保持实际遍历次序供后序测量与兄弟链接。
if let Some(parent) = parent {
items[parent].children.push(index);
}
items.push(Item {
node,
parent,
children: vec![],
id,
constraints,
});
// 孩子逆序压栈以按原组成顺序出栈,初始约束同时按该出现的父 offer 细化。
for (child_index, (child, slot)) in
node.children().iter().zip(&node.slots).enumerate().rev()
{
work.push((
child.as_ref(),
Some(index),
*slot,
initial_child_constraints(node, child, constraints, child_index),
));
}
}
// 每个出现一个尺寸槽,不按共享业务 ID 合并几何;结构准备保留这些零值占位。
let mut sizes = vec![Size::default(); items.len()];
// 完整路径才反向测量,使每个父求自然尺寸时孩子尺寸已经可用。
if !structural {
// 反向 items 自底向上测量,孩子尺寸已经求出;structural=true 完全跳过此步骤,零几何仅用于挂载准备。
for (index, item) in items.iter().enumerate().rev() {
sizes[index] = constrained(
self.measure_node(
item.node,
item.children.iter().map(|i| sizes[*i]).collect(),
item.constraints,
)?,
item.constraints,
);
}
}
// 原点按出现数组独立保存,根为零,父排列后写入各子槽。
let mut points = vec![(0.0_f32, 0.0_f32); items.len()];
// 最终发布几何数组容量按出现数预留,但不会在构建失败时替换旧运行状态。
let mut mounts = Vec::with_capacity(items.len());
// 先序展开时按节点内容/边框顺序追加业务命令,后续投影阶段再处理视口和滚动。
let mut commands = Vec::new();
// 默认每个出现无后继,下面只对同一父的相邻孩子建立 next_sibling。
let mut siblings = vec![None; items.len()];
// 先按每个父的孩子顺序建立 next_sibling,最后一个默认 None;这与业务孩子列表身份不同。
// 父先序数组不保证相邻元素就是同级,须显式按每个父的 children 建链接。
for item in &items {
// 只把相邻孩子前项连向后项,最后一个保留 None,不串到下个父的子树。
for pair in item.children.windows(2) {
siblings[pair[0]] = Some(items[pair[1]].id);
}
}
// 按先序安排,父已先给孩子写入尺寸/原点,当前项可生成对应 Mount 与自身绘制。
for (index, item) in items.iter().enumerate() {
self.arranged += usize::from(!structural);
// 读取此出现原点,接着检查原点和加宽高后的两个远端都在有符号坐标预算中。
let (x, y) = points[index];
// 取本出现实际尺寸,可能已被父 flex 分配覆盖,不能重新读取业务自然尺寸。
let size = sizes[index];
coordinate(x)?;
coordinate(y)?;
coordinate(x + size.width)?;
coordinate(y + size.height)?;
mounts.push(MountGeometry {
panel: None,
canvas_viewport: matches!(item.node.kind, UiKind::CanvasViewport { .. }),
scene3d_viewport: matches!(item.node.kind, UiKind::Viewport3D { .. }),
camera_controls: matches!(
item.node.kind,
UiKind::CanvasViewport { .. } | UiKind::Viewport3D { controls: true, .. }
),
viewport_clip: None,
view: None,
id: item.id,
clip: None,
scroll: None,
block_id: item.node.block_id.clone(),
parent: item.parent.map(|i| items[i].id),
first_child: item.children.first().map(|i| items[*i].id),
next_sibling: siblings[index],
x,
y,
width: size.width,
height: size.height,
interactive: item.node.interactive,
pointer_events: item.node.pointer_events,
});
// 只有完整路径生成内容/边框命令,结构准备不得把零几何发布为实际画面。
if !structural {
commands.extend(paint_commands(item.node, x, y, size));
}
// 结构准备只需要身份/链接,直接跳过孩子尺寸分配和位置安排。
if structural {
continue;
}
// 收集此父孩子当前尺寸作为单次纯原生分配的输入,源协议多轮 remeasure 在外层完成。
let child_sizes: Vec<_> = item.children.iter().map(|child| sizes[*child]).collect();
// 自顶向下用父实际尺寸更新孩子分配后再求原点;更复杂的源协议反复求解由 source_layout 驱动。
let child_sizes = flex_sizes(item.node, size, &child_sizes, &child_sizes);
// 按出现索引写回 flex/拉伸实际分配,不改变共享 UiNode 的自然属性。
for (child, allocated) in item.children.iter().zip(&child_sizes) {
sizes[*child] = *allocated;
}
// 把 arrange_native 的同序位置表写入每个子出现槽,之后先序遍历即可消费。
for (child, point) in
item.children
.iter()
.zip(arrange_native(item.node, x, y, size, &child_sizes)?)
{
points[*child] = point;
}
}
// 成功求解后丢弃已消失出现和不在本树的测量缓存,避免历史页面不断积累派生条目。
self.ids.retain(|key, _| used.contains(key));
// 成功后只保留本树出现过的业务测量键,避免切页后旧派生尺寸持续积累。
let blocks: HashSet<_> = items
.iter()
.map(|item| item.node.block_id.as_str())
.collect();
self.sizes
.retain(|block, _| blocks.contains(block.as_str()));
// 每次结构/完整构建成功都推进版本,禁止溢出复用;该版本用于后续 Mount/输入归属校验。
self.version = self
.version
.checked_add(1)
.context("layout version exhausted")?;
Ok(Frame {
commands,
mounts,
layout_version: self.version,
..Frame::default()
})
}
}
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
pub fn frame(root: &UiNode) -> Result<Frame> {
// 一次性调用没有跨帧缓存;需要稳定出现身份的窗口应保留自己的 LayoutRuntime。
LayoutRuntime::default().frame(root)
}
/// 为指定逻辑视口建立临时布局运行时并求完整帧。
pub fn frame_with_viewport(root: &UiNode, viewport: Size) -> Result<Frame> {
// 为独立原生测量/测试建立有限视口运行时,不创建 VM 或 OS 窗口资源。
LayoutRuntime {
viewport: Some(viewport),
..Default::default()
}
.frame(root)
}