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

418 lines
21 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.
//! 逐 Mount 的滚动状态、裁剪和输入目标选择。
//! 共享组件的不同出现保留独立偏移。嵌套滚动先选择可接收位移的内层,偏移在实际内容范围内钳制。
//! UiBridge 完成布局后先调用 project,viewport::project 再附相机/面板变换;Mount 方法调用 set/into_view 只写请求,等待下一轮投影。
//!
//! Per-occurrence scroll state and a single geometry/clip projection shared by
//! native paint, source paint and input. No business object is copied.
use crate::{
document::Frame,
geometry::Point,
layout::{self, Size},
mount::{Mount, MountTable},
ui_bridge::{UiKind, UiNode},
};
use anyhow::{Result, bail};
use endor_core::{NativeContext, ObjectRef, Value, Vm, VmError, WeakObjectRef};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
/// 滚动容器允许响应的轴,不决定其业务内容组成方向。
pub enum Axis {
#[default]
Both,
Vertical,
Horizontal,
}
impl Axis {
/// 返回内部枚举或对象对应的稳定源名称。
// model/block 的属性读取以此输出源文本,名称与 parse 接受的值保持对应。
pub fn name(self) -> &'static str {
match self {
Self::Both => "both",
Self::Vertical => "vertical",
Self::Horizontal => "horizontal",
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
// model/block 写入 ScrollBlock.axis 时调用;未知字符串返回参数错误,不降级成默认双轴。
pub fn parse(name: &str) -> Result<Self, VmError> {
match name {
"both" => Ok(Self::Both),
"vertical" => Ok(Self::Vertical),
"horizontal" => Ok(Self::Horizontal),
_ => Err(VmError::InvalidArgument(
"ScrollBlock.axis expects both, vertical or horizontal",
)),
}
}
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
// 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] {
// 输入采用 [左, 上, 宽, 高]。交集左上取较大值,右下取较小值;
// 右/下边界再钳到左/上边界,保证完全错开时输出零面积而非负尺寸。
let x = a[0].max(b[0]);
let y = a[1].max(b[1]);
[
x,
y,
(a[0] + a[2]).min(b[0] + b[2]).max(x) - x,
(a[1] + a[3]).min(b[1] + b[3]).max(y) - y,
]
}
/// 沿出现树累计滚动偏移和裁剪;遇到独立场景视口时按其有限边界重新建立投影范围。
// 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()
.map(|m| matches!(nodes[m.block_id.as_str()].kind, UiKind::Scroll { .. }))
.collect();
// extent 保存尚未滚动的内容右下边界。滚动容器先扣除自身右/下 inset,
// 后续传播子内容后再加回 padding,避免把空容器的内边距算成可滚动溢出。
let mut extent: Vec<_> = 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)
} else {
layout::Insets::default()
};
(m.x + m.width - inset.right, m.y + m.height - inset.bottom)
})
.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 { .. }
) {
// 场景世界内部的坐标范围不扩大外部有限布局盒子的滚动范围。
continue;
}
// 子滚动容器向外只贡献自己的视口;它内部的溢出由自己的偏移处理。
let e = if scrolling[i] {
(m.x + m.width, m.y + m.height)
} else {
extent[i]
};
extent[parent].0 = extent[parent].0.max(e.0);
extent[parent].1 = extent[parent].1.max(e.1);
}
}
// 第二阶段从父到子投影。上下文保存祖先累计位移和裁剪,而非当前节点本身的偏移。
// 重建绘制命令及其平行元数据,保证显示、命中与裁剪使用同一套投影坐标。
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,
};
// limits 在未改写的布局坐标里计算;禁用轴直接设零,内容比视口小时也为零。
let limits = [
if axis.horizontal() {
(extent[i].0 + padding.right - m.x - m.width).max(0.0)
} else {
0.0
},
if axis.vertical() {
(extent[i].1 + padding.bottom - m.y - m.height).max(0.0)
} else {
0.0
},
];
// 当前容器跟随祖先滚动;它自己的 scroll_offset 只影响子节点,容器边框保持原位。
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),
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),
);
}
// 仅滚动容器读取托管 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"))?;
// weak upgrade 临时保活 Mount。先保存 payload 读取结果再 release,
// 这样读取失败也会先释放强引用,然后由下方 offset? 传播错误。
let offset = vm
.payload::<Mount>(owner, "Mount")
.map(|mount| mount.scroll_offset);
vm.release(owner)?;
let offset = offset?;
// 内容缩短时旧偏移可能超界;此处只钳制帧投影,不改写托管 Mount 的原偏移。
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,
Some(
child
.2
.map(|clip| intersection(clip, viewport))
.unwrap_or(viewport),
),
);
}
// 3D 子场景有独立投影空间,不能继续叠加外层二维滚动坐标和裁剪。
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,
m.y,
Size {
width: m.width,
height: m.height,
},
) {
frame.commands.push(command);
frame.command_clips.push(m.clip);
frame.command_mounts.push(m.id);
}
}
// 整帧重建后检查一万条上限,失败由上层放弃草稿,不在这里发布超预算帧。
if frame.commands.len() > 10_000 {
bail!("scroll paint command budget exceeded");
}
Ok(())
}
/// 把源滚动值解码为有限坐标,阻止非数或越界值进入投影。
// 除滚动协议外,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",
));
};
// 通过 Number 文本表示转为 f32,再检查有限性及绝对值上限;允许负数以表达相对滚动。
let n = value
.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,
desktop: WeakObjectRef,
point: [f32; 2],
relative: bool,
) -> Result<(), VmError> {
// 只允许已提交且仍挂接的出现接受滚动;布局过程中的临时 Mount 不具备稳定滚动边界。
let mut mount = cx.store().payload::<Mount>(owner, "Mount")?.clone();
if !mount.mounted || mount.layout.is_some() {
return Err(VmError::InvalidArgument(
"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],
mount.scroll_offset[1] + point[1],
]
} else {
point
};
mount.scroll_offset = [next[0].clamp(0.0, bounds[2]), next[1].clamp(0.0, bounds[3])];
// 这里使用上次提交的滚动上限;若业务内容随后缩短,新一轮 project 会再次钳制,几何提交再保存新的有效偏移。
// 写入前确认窗口仍存活,升级根由 NativeContext 临时管理;失效时不替换 Mount 偏移。
let desktop = cx
.upgrade(desktop)?
.ok_or(VmError::InvalidArgument("Mount window expired"))?;
// 即使钳制后数值未变,也走正常写屏障,使只读调用不能借助无效滚动绕过变更限制。
// scrollRequested 通知后续刷新重做投影,而不是在这里直接重建布局或绘制。
cx.replace_payload(owner, mount)?;
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(
"scrollIntoView requires committed attached Mount",
));
}
// rect 随祖先滚动调整,表示目标在当前投影中的可见需求;从最近父容器逐层向外处理。
let mut rect = [
mount.geometry.x,
mount.geometry.y,
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;
};
let mount = cx.store().payload::<Mount>(owner, "Mount")?.clone();
parent = mount.parent;
// 跨越独立场景边界时,外层只能滚动整个场景视口,不能使用内部世界坐标定位目标。
if mount.geometry.canvas_viewport || mount.geometry.scene3d_viewport {
rect = [
mount.geometry.x,
mount.geometry.y,
mount.geometry.width,
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)?;
}
}
}
Ok(())
}
/// 按逆出现顺序选择指针处可处理当前滚动方向的最内层容器。
// shell/app 在滚轮路由时调用,输入点已经进入页面坐标;返回出现 ID 后仍由所属 VM 执行滚动更新。
pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64> {
// shell 只借返回 ID 构造输入信封;本函数不运行源 hitTest、不校验 session/fault,也不直接修改任何滚动 owner。
// 按 ID 建立只读祖先查找,候选搜索选定最上层命中出现后只沿这一条父链移动。
let indices: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 逆序先取最上层命中的出现,再沿其祖先寻找能消费位移的滚动容器。
// 不搜索被覆盖的兄弟容器,内层到边界后才交给外层滚动。
let mut current = frame
.mounts
.iter()
.rev()
.find(|mount| mount.contains(point));
// 内层先尝试消费;该层无滚动能力或当前方向已到边界时,才检查父容器。
while let Some(mount) = current {
// 只有至少一轴钳制后真正改变偏移才接收此次输入;没有任何容器可移动则返回 None。
if mount.scroll.is_some_and(|s| {
(s[0] + delta[0]).clamp(0.0, s[2]) != s[0] || (s[1] + delta[1]).clamp(0.0, s[3]) != s[1]
}) {
return Some(mount.id);
}
// 只向命中出现的祖先回退,不改为搜索下层兄弟,也不把一次 delta 拆给多个容器。
// 一旦内层任一轴能移动就选它;未消费的另一轴或超出边界的剩余量不会在本次选择中继续分发给外层。
current = mount.parent.and_then(|id| indices.get(&id).copied());
}
None
}