364 lines
15 KiB
Rust
364 lines
15 KiB
Rust
//! 逐 Mount 的滚动状态、裁剪和输入目标选择。
|
||
//! 共享组件的不同出现保留独立偏移。嵌套滚动先选择可接收位移的内层,偏移在实际内容范围内钳制。
|
||
//!
|
||
//! 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 {
|
||
/// 返回内部枚举或对象对应的稳定源名称。
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
Self::Both => "both",
|
||
Self::Vertical => "vertical",
|
||
Self::Horizontal => "horizontal",
|
||
}
|
||
}
|
||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||
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",
|
||
)),
|
||
}
|
||
}
|
||
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
|
||
pub fn horizontal(self) -> bool {
|
||
self != Self::Vertical
|
||
}
|
||
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
|
||
pub fn vertical(self) -> bool {
|
||
self != Self::Horizontal
|
||
}
|
||
}
|
||
|
||
/// 求两个裁剪矩形的交集,空区域仍保持合法非负尺寸。
|
||
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,
|
||
]
|
||
}
|
||
/// 沿出现树累计滚动偏移和裁剪;遇到独立场景视口时按其有限边界重新建立投影范围。
|
||
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();
|
||
let mut work = vec![root];
|
||
while let Some(node) = work.pop() {
|
||
nodes.insert(node.block_id.as_str(), node);
|
||
work.extend(node.children().iter().map(|node| node.as_ref()));
|
||
}
|
||
let indices: HashMap<_, _> = frame
|
||
.mounts
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, m)| (m.id, i))
|
||
.collect();
|
||
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| {
|
||
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() {
|
||
let m = &frame.mounts[i];
|
||
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();
|
||
frame.commands.clear();
|
||
frame.command_clips.clear();
|
||
frame.command_mounts.clear();
|
||
frame.command_views.clear();
|
||
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()];
|
||
let padding = layout::insets(node);
|
||
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;
|
||
let mut child = (dx, dy, clip);
|
||
// 普通裁剪使用边框内侧矩形,并与祖先裁剪求交,不能扩大祖先允许的区域。
|
||
if node.clip_children {
|
||
let border = node.border;
|
||
let viewport = [
|
||
m.x + border.left.min(m.width),
|
||
m.y + border.top.min(m.height),
|
||
(m.width - border.horizontal()).max(0.0),
|
||
(m.height - border.vertical()).max(0.0),
|
||
];
|
||
child.2 = Some(
|
||
clip.map(|clip| intersection(clip, viewport))
|
||
.unwrap_or(viewport),
|
||
);
|
||
}
|
||
if scrolling[i] {
|
||
let weak = table
|
||
.active
|
||
.get(&m.id)
|
||
.ok_or_else(|| anyhow::anyhow!("missing scroll Mount"))?;
|
||
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]]);
|
||
let viewport = [m.x, m.y, m.width, m.height];
|
||
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);
|
||
}
|
||
contexts.insert(m.id, child);
|
||
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(())
|
||
}
|
||
|
||
/// 把源滚动值解码为有限坐标,阻止非数或越界值进入投影。
|
||
pub fn coordinate(value: &Value) -> Result<f32, VmError> {
|
||
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"))?;
|
||
if !n.is_finite() || n.abs() > 1_000_000.0 {
|
||
return Err(VmError::InvalidArgument("scroll coordinate exceeds limits"));
|
||
}
|
||
Ok(n)
|
||
}
|
||
/// 更新指定出现的滚动偏移,并钳制到本轴实际可滚动的内容范围。
|
||
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",
|
||
));
|
||
}
|
||
let bounds = mount
|
||
.geometry
|
||
.scroll
|
||
.ok_or(VmError::InvalidArgument("Mount is not scrollable"))?;
|
||
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])];
|
||
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))
|
||
}
|
||
/// 沿祖先滚动容器调整偏移,让目标出现进入可见范围。
|
||
pub fn into_view(
|
||
cx: &mut NativeContext<'_>,
|
||
owner: ObjectRef,
|
||
desktop: WeakObjectRef,
|
||
) -> Result<(), VmError> {
|
||
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;
|
||
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)
|
||
}
|
||
};
|
||
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];
|
||
if x != offset[0] || y != offset[1] {
|
||
set(cx, owner, desktop, [x, y], false)?;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
/// 按逆出现顺序选择指针处可处理当前滚动方向的最内层容器。
|
||
pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64> {
|
||
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);
|
||
}
|
||
current = mount.parent.and_then(|id| indices.get(&id).copied());
|
||
}
|
||
None
|
||
}
|