docs: explain scroll projection and source hit selection
This commit is contained in:
@@ -24,6 +24,7 @@ pub enum Axis {
|
||||
}
|
||||
impl Axis {
|
||||
/// 返回内部枚举或对象对应的稳定源名称。
|
||||
// model/block 的属性读取以此输出源文本,名称与 parse 接受的值保持对应。
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Both => "both",
|
||||
@@ -32,6 +33,7 @@ impl Axis {
|
||||
}
|
||||
}
|
||||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||||
// model/block 写入 ScrollBlock.axis 时调用;未知字符串返回参数错误,不降级成默认双轴。
|
||||
pub fn parse(name: &str) -> Result<Self, VmError> {
|
||||
match name {
|
||||
"both" => Ok(Self::Both),
|
||||
@@ -43,16 +45,19 @@ impl Axis {
|
||||
}
|
||||
}
|
||||
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
|
||||
// project 计算可滚范围时调用;禁用水平轴意味着该轴上限为零,旧偏移也会被投影钳到零。
|
||||
pub fn horizontal(self) -> bool {
|
||||
self != Self::Vertical
|
||||
}
|
||||
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
|
||||
// project 用于纵轴范围,Both 同时允许两个轴,Horizontal 只禁用此纵轴。
|
||||
pub fn vertical(self) -> bool {
|
||||
self != Self::Horizontal
|
||||
}
|
||||
}
|
||||
|
||||
/// 求两个裁剪矩形的交集,空区域仍保持合法非负尺寸。
|
||||
// project 的普通 clip_children 和滚动视口共同使用,裁剪数据随后供绘制与命中复用。
|
||||
fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
|
||||
// 输入采用 [左, 上, 宽, 高]。交集左上取较大值,右下取较小值;
|
||||
// 右/下边界再钳到左/上边界,保证完全错开时输出零面积而非负尺寸。
|
||||
@@ -66,21 +71,26 @@ fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
|
||||
]
|
||||
}
|
||||
/// 沿出现树累计滚动偏移和裁剪;遇到独立场景视口时按其有限边界重新建立投影范围。
|
||||
// ui_bridge 在完成 Mount 布局后调用,随后 viewport::project 填充相机变换;此处只完成滚动和原生绘制投影。
|
||||
pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame) -> Result<()> {
|
||||
// 第一阶段只建立查找表,不改坐标:Block ID 查语义节点,Mount ID 查帧中的出现。
|
||||
// 同一个 Block 可以多次出现;滚动偏移后面按 Mount 读取,不能存入这个 Block 表。
|
||||
let mut nodes = HashMap::new();
|
||||
// 显式栈遍历不可变视觉树,nodes 按业务 Block 身份索引,只存语义描述而非每次出现的偏移。
|
||||
let mut work = vec![root];
|
||||
// 记录节点并展开子节点,后续出现用 block_id 查回同一业务视觉;最终绘制顺序来自 frame.mounts。
|
||||
while let Some(node) = work.pop() {
|
||||
nodes.insert(node.block_id.as_str(), node);
|
||||
work.extend(node.children().iter().map(|node| node.as_ref()));
|
||||
}
|
||||
// Mount ID 映射到当前数组位置,后面的逆向范围汇总需要按 parent ID 直接访问父项。
|
||||
let indices: HashMap<_, _> = frame
|
||||
.mounts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// 与 frame.mounts 等长的布尔表,每次出现都单独标记是否滚动容器,便于两个阶段共享判断。
|
||||
let scrolling: Vec<_> = frame
|
||||
.mounts
|
||||
.iter()
|
||||
@@ -92,6 +102,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
.mounts
|
||||
.iter()
|
||||
.map(|m| {
|
||||
// 原始布局右下界作为范围起点;只有 Scroll 扣 inset,避免空内容凭 padding 产生溢出。
|
||||
let node = nodes[m.block_id.as_str()];
|
||||
let inset = if matches!(node.kind, UiKind::Scroll { .. }) {
|
||||
layout::insets(node)
|
||||
@@ -103,9 +114,12 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
.collect();
|
||||
// Mount 按父先子后排列,反向扫描才能先汇总子树,再将边界向父级传播。
|
||||
for i in (0..frame.mounts.len()).rev() {
|
||||
// 此时所有 Mount 仍是未减滚动量的布局位置,汇总不能混入第二阶段的投影后坐标。
|
||||
let m = &frame.mounts[i];
|
||||
// 根出现没有父级,不向外传播;其余出现要求 parent 存在于本帧的 indices 表。
|
||||
if let Some(parent) = m.parent {
|
||||
let parent = indices[&parent];
|
||||
// 二维/三维场景视口都以有限布局盒隔离世界内容范围,不把内部世界边界加到外层滚动上限。
|
||||
if matches!(
|
||||
nodes[frame.mounts[parent].block_id.as_str()].kind,
|
||||
UiKind::CanvasViewport { .. } | UiKind::Viewport3D { .. }
|
||||
@@ -126,17 +140,22 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
// 第二阶段从父到子投影。上下文保存祖先累计位移和裁剪,而非当前节点本身的偏移。
|
||||
// 重建绘制命令及其平行元数据,保证显示、命中与裁剪使用同一套投影坐标。
|
||||
let mut contexts = HashMap::<u64, (f32, f32, Option<[f32; 4]>)>::new();
|
||||
// 重建这一轮原生命令;command_views 先清空,后续 viewport::project 根据滚动后的几何建立变换元数据。
|
||||
frame.commands.clear();
|
||||
frame.command_clips.clear();
|
||||
frame.command_mounts.clear();
|
||||
frame.command_views.clear();
|
||||
// 父项先处理,子项才能从 contexts 取到累计位移和裁剪;i 同时索引原始 extent/scrolling 表。
|
||||
for (i, m) in frame.mounts.iter_mut().enumerate() {
|
||||
// 无父上下文时使用零位移、无裁剪;父上下文已包含祖先各层的有效滚动量。
|
||||
let (dx, dy, clip) = m
|
||||
.parent
|
||||
.and_then(|id| contexts.get(&id).copied())
|
||||
.unwrap_or_default();
|
||||
let node = nodes[m.block_id.as_str()];
|
||||
// insets 含有效盒模型边距,这里的右/下分量加回内容范围,以得到视口外的实际溢出。
|
||||
let padding = layout::insets(node);
|
||||
// 只有 Scroll 的配置决定限轴;非滚动节点暂用 Both 计算辅助值,但不会发布 m.scroll。
|
||||
let axis = match nodes[m.block_id.as_str()].kind {
|
||||
UiKind::Scroll { axis, .. } => axis,
|
||||
_ => Axis::Both,
|
||||
@@ -158,9 +177,11 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
m.x -= dx;
|
||||
m.y -= dy;
|
||||
m.clip = clip;
|
||||
// 先继承祖先上下文,当前层的子裁剪和自身滚动随后只写入这个 child,不能影响当前容器边框。
|
||||
let mut child = (dx, dy, clip);
|
||||
// 普通裁剪使用边框内侧矩形,并与祖先裁剪求交,不能扩大祖先允许的区域。
|
||||
if node.clip_children {
|
||||
// clip_children 的区域从边框内缘开始,不把边框本身裁掉;各轴尺寸钳到非负。
|
||||
let border = node.border;
|
||||
let viewport = [
|
||||
m.x + border.left.min(m.width),
|
||||
@@ -173,11 +194,14 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
.unwrap_or(viewport),
|
||||
);
|
||||
}
|
||||
// 仅滚动容器读取托管 Mount 的独立偏移,普通节点不访问该状态,也不产生滚动协议边界。
|
||||
if scrolling[i] {
|
||||
// 按出现 ID 查活动弱身份,缺失表示帧与挂载表不一致,不能拿同 Block 的其他出现代替。
|
||||
let weak = table
|
||||
.active
|
||||
.get(&m.id)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing scroll Mount"))?;
|
||||
// 升级后得到临时拥有根,payload 读取完成即显式 release,不把 VM 对象带入纯原生帧。
|
||||
let owner = vm
|
||||
.upgrade(*weak)?
|
||||
.ok_or_else(|| anyhow::anyhow!("expired scroll Mount"))?;
|
||||
@@ -192,7 +216,9 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
let x = offset[0].clamp(0.0, limits[0]);
|
||||
let y = offset[1].clamp(0.0, limits[1]);
|
||||
m.scroll = Some([x, y, limits[0], limits[1]]);
|
||||
// 滚动内容还受整个容器盒裁剪;如果前面 clip_children 已缩到边框内,则继续与它求交。
|
||||
let viewport = [m.x, m.y, m.width, m.height];
|
||||
// 子项累计位移加上本容器的有效偏移,同时收窄裁剪;容器自身 m.x/y 不减自己的 x/y。
|
||||
child = (
|
||||
dx + x,
|
||||
dy + y,
|
||||
@@ -208,7 +234,9 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
if matches!(node.kind, UiKind::Viewport3D { .. }) {
|
||||
child = (0., 0., None);
|
||||
}
|
||||
// 保存当前出现对子项的最终上下文;遇到 3D 视口时已重置为其独立二维内容空间。
|
||||
contexts.insert(m.id, child);
|
||||
// 按当前出现的滚动后位置生成默认命令,同步附带 clip 与 owner;后续源 paint 可整体替换这些默认命令。
|
||||
for command in layout::paint_commands(
|
||||
nodes[m.block_id.as_str()],
|
||||
m.x,
|
||||
@@ -223,6 +251,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
frame.command_mounts.push(m.id);
|
||||
}
|
||||
}
|
||||
// 整帧重建后检查一万条上限,失败由上层放弃草稿,不在这里发布超预算帧。
|
||||
if frame.commands.len() > 10_000 {
|
||||
bail!("scroll paint command budget exceeded");
|
||||
}
|
||||
@@ -230,7 +259,9 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
}
|
||||
|
||||
/// 把源滚动值解码为有限坐标,阻止非数或越界值进入投影。
|
||||
// 除滚动协议外,Mount 坐标查询、盒模型及场景参数也复用这一有限数值转换;具体正负规则由调用边界补充。
|
||||
pub fn coordinate(value: &Value) -> Result<f32, VmError> {
|
||||
// 只接受 Number,本函数不对 Text 做隐式数值解析;后面的字符串转换来自 Number 自身表示。
|
||||
let Value::Number(value) = value else {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"scroll coordinates must be numbers",
|
||||
@@ -241,12 +272,14 @@ pub fn coordinate(value: &Value) -> Result<f32, VmError> {
|
||||
.to_string()
|
||||
.parse::<f32>()
|
||||
.map_err(|_| VmError::InvalidArgument("invalid scroll coordinate"))?;
|
||||
// f32 转换可能溢成无穷,需在转换后同时检查有限性与绝对值预算。
|
||||
if !n.is_finite() || n.abs() > 1_000_000.0 {
|
||||
return Err(VmError::InvalidArgument("scroll coordinate exceeds limits"));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
/// 更新指定出现的滚动偏移,并钳制到本轴实际可滚动的内容范围。
|
||||
// model/mount 的 scrollTo/scrollBy 和 into_view 共用入口,owner 指当前出现,desktop 为所属窗口弱身份。
|
||||
pub fn set(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
@@ -261,10 +294,12 @@ pub fn set(
|
||||
"scroll requires committed attached Mount",
|
||||
));
|
||||
}
|
||||
// geometry.scroll 保存上次提交的 [有效x,有效y,最大x,最大y],缺失表示该出现不是可滚容器。
|
||||
let bounds = mount
|
||||
.geometry
|
||||
.scroll
|
||||
.ok_or(VmError::InvalidArgument("Mount is not scrollable"))?;
|
||||
// 相对操作基于 Mount 保存的原始偏移加增量,绝对操作直接使用请求值,随后统一钳到已提交范围。
|
||||
let next = if relative {
|
||||
[
|
||||
mount.scroll_offset[0] + point[0],
|
||||
@@ -274,6 +309,7 @@ pub fn set(
|
||||
point
|
||||
};
|
||||
mount.scroll_offset = [next[0].clamp(0.0, bounds[2]), next[1].clamp(0.0, bounds[3])];
|
||||
// 写入前确认窗口仍存活,升级根由 NativeContext 临时管理;失效时不替换 Mount 偏移。
|
||||
let desktop = cx
|
||||
.upgrade(desktop)?
|
||||
.ok_or(VmError::InvalidArgument("Mount window expired"))?;
|
||||
@@ -283,11 +319,13 @@ pub fn set(
|
||||
cx.set_attr(desktop, "scrollRequested", Value::Bool(true))
|
||||
}
|
||||
/// 沿祖先滚动容器调整偏移,让目标出现进入可见范围。
|
||||
// model/mount 的 scrollIntoView 以及焦点相关路径调用,从最近祖先向外调整可滚容器。
|
||||
pub fn into_view(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
desktop: WeakObjectRef,
|
||||
) -> Result<(), VmError> {
|
||||
// 先克隆目标描述以跨后续写入使用,读取目标几何后只遍历父链,不修改业务 Block。
|
||||
let mount = cx.store().payload::<Mount>(owner, "Mount")?.clone();
|
||||
if !mount.mounted || mount.layout.is_some() {
|
||||
return Err(VmError::InvalidArgument(
|
||||
@@ -301,8 +339,11 @@ pub fn into_view(
|
||||
mount.geometry.width,
|
||||
mount.geometry.height,
|
||||
];
|
||||
// 父关系保存弱身份,遍历时逐个升级;不会因一次滚动操作永久强持有整条祖先链。
|
||||
let mut parent = mount.parent;
|
||||
// 每层先记录下一父身份,再按当前祖先类型处理,保证本层 set 替换 payload 不影响遍历游标。
|
||||
while let Some(weak) = parent {
|
||||
// 父身份过期时停止外层调整,避免把已失效出现当成仍挂接的滚动容器。
|
||||
let Some(owner) = cx.upgrade(weak)? else {
|
||||
break;
|
||||
};
|
||||
@@ -317,22 +358,27 @@ pub fn into_view(
|
||||
mount.geometry.height,
|
||||
];
|
||||
}
|
||||
// 只有已提交几何带滚动范围的祖先需要调整,普通祖先只用于继续沿父链前进。
|
||||
if let Some(offset) = mount.geometry.scroll {
|
||||
// 目标起点在视口前则向前对齐;否则只补足末端溢出,已完全可见时返回零。
|
||||
let delta = |start: f32, length: f32, viewport: f32, size: f32| {
|
||||
// 目标起点在视口前方时优先对齐起点;目标比视口大时不会同时追求两端可见。
|
||||
if start < viewport {
|
||||
start - viewport
|
||||
} else {
|
||||
(start + length - viewport - size).max(0.0)
|
||||
}
|
||||
};
|
||||
// 分别计算两轴最小请求位移,再加到当前有效 offset 并按各轴最大范围钳制。
|
||||
let dx = delta(rect[0], rect[2], mount.geometry.x, mount.geometry.width);
|
||||
let dy = delta(rect[1], rect[3], mount.geometry.y, mount.geometry.height);
|
||||
// 用帧中的有效偏移作基准,目标位置的修正也必须使用钳制后的实际差值。
|
||||
let x = (offset[0] + dx).clamp(0.0, offset[2]);
|
||||
let y = (offset[1] + dy).clamp(0.0, offset[3]);
|
||||
// 下一层祖先看到的目标位置必须扣掉本层实际移动量,不能扣未钳制的请求量。
|
||||
rect[0] -= x - offset[0];
|
||||
rect[1] -= y - offset[1];
|
||||
// 已完全可见或被边界钳制后不能移动时,不触发 set 的写屏障和 scrollRequested。
|
||||
if x != offset[0] || y != offset[1] {
|
||||
set(cx, owner, desktop, [x, y], false)?;
|
||||
}
|
||||
@@ -341,7 +387,9 @@ pub fn into_view(
|
||||
Ok(())
|
||||
}
|
||||
/// 按逆出现顺序选择指针处可处理当前滚动方向的最内层容器。
|
||||
// shell/app 在滚轮路由时调用,输入点已经进入页面坐标;返回出现 ID 后仍由所属 VM 执行滚动更新。
|
||||
pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64> {
|
||||
// 按 ID 建立只读祖先查找,候选搜索选定最上层命中出现后只沿这一条父链移动。
|
||||
let indices: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
|
||||
// 逆序先取最上层命中的出现,再沿其祖先寻找能消费位移的滚动容器。
|
||||
// 不搜索被覆盖的兄弟容器,内层到边界后才交给外层滚动。
|
||||
@@ -350,6 +398,7 @@ pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64>
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|mount| mount.contains(point));
|
||||
// 内层先尝试消费;该层无滚动能力或当前方向已到边界时,才检查父容器。
|
||||
while let Some(mount) = current {
|
||||
// 只有至少一轴钳制后真正改变偏移才接收此次输入;没有任何容器可移动则返回 None。
|
||||
if mount.scroll.is_some_and(|s| {
|
||||
@@ -357,6 +406,7 @@ pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64>
|
||||
}) {
|
||||
return Some(mount.id);
|
||||
}
|
||||
// 只向命中出现的祖先回退,不改为搜索下层兄弟,也不把一次 delta 拆给多个容器。
|
||||
current = mount.parent.and_then(|id| indices.get(&id).copied());
|
||||
}
|
||||
None
|
||||
|
||||
Reference in New Issue
Block a user