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

364 lines
15 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 的滚动状态、裁剪和输入目标选择。
//! 共享组件的不同出现保留独立偏移。嵌套滚动先选择可接收位移的内层,偏移在实际内容范围内钳制。
//!
//! 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
}