diff --git a/src/render/scroll.rs b/src/render/scroll.rs index 3ecd7e3..2b32fd7 100644 --- a/src/render/scroll.rs +++ b/src/render/scroll.rs @@ -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 { 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::)>::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 { + // 只接受 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 { .to_string() .parse::() .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::(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 { + // 按 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 .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 }) { return Some(mount.id); } + // 只向命中出现的祖先回退,不改为搜索下层兄弟,也不把一次 delta 拆给多个容器。 current = mount.parent.and_then(|id| indices.get(&id).copied()); } None diff --git a/src/render/source_hit.rs b/src/render/source_hit.rs index 98dd961..29fdbe3 100644 --- a/src/render/source_hit.rs +++ b/src/render/source_hit.rs @@ -11,6 +11,7 @@ use endor_core::{ #[derive(Debug)] /// 验证 hitTest 返回的是 Bool,避免把任意真值隐式当成命中。 +// selector 把源 hitTest 返回值交给此适配器,之后字节码才根据 Bool 决定返回或继续查找。 struct Boolean; impl NativeCallable for Boolean { /// 接收 hitTest 返回的唯一值,交给 invoke 严格检查 Bool。 @@ -29,6 +30,7 @@ impl NativeCallable for Boolean { } #[derive(Debug)] /// 比较按下与释放的选择结果,只有同一目标才能继续点击分发。 +// Prepare 生成的驱动先执行两次 selector,最后调用此比较器;对象相等比较的是实际 Mount 身份。 struct SameTarget; impl NativeCallable for SameTarget { /// 接收按下与释放两次命中选择的结果。 @@ -49,6 +51,7 @@ impl NativeCallable for SameTarget { } } /// 构造只读命中选择驱动所需的托管代码与函数对象。 +// selector 和 Prepare 共用代码工厂;传入常量形成托管引用边,保持协议函数、实参和中间驱动存活。 fn code( cx: &mut NativeContext<'_>, instructions: Vec, @@ -61,22 +64,27 @@ fn code( "", 1, ); + // 先把校验后的字节码分配到当前 NativeContext,输出函数经托管引用拥有它及其常量。 let code = cx.allocate(EndorCodeObject::new(code))?; + // 驱动只读取显式常量和局部槽,空 globals 使它不依赖或修改业务脚本全局变量。 let globals = cx.new_dict()?; cx.new_function("pointer hitTest", code, globals) } /// 按逆绘制顺序构造候选 hitTest 调用,先进行几何与局部坐标筛选。 +// Prepare 分别为 press/release 构造选择器;准备阶段做原生粗筛,执行阶段才调用真实 hitTest 方法。 fn selector( cx: &mut NativeContext<'_>, candidates: &[WeakObjectRef], point: [f32; 2], desktop: ObjectRef, ) -> Result { + // 所有候选共享一个 Bool 验证器,放常量槽 0,避免每个候选重复创建适配器。 let validate = cx.allocate(ContextNativeFunction::new( "validate hitTest", Boolean, vec![], ))?; + // 可变指令和常量池按候选依次追加;前面候选返回 true 即提前 ReturnValue,剩余候选不会执行。 let mut instructions = Vec::new(); let mut constants = vec![Value::Object(validate)]; // 候选按绘制顺序提供,逆序使最上层先参与测试;弱引用失效视为过期输入错误。 @@ -84,6 +92,7 @@ fn selector( let mount = cx .upgrade(*weak)? .ok_or(VmError::InvalidArgument("pointer Mount expired"))?; + // 克隆 Mount 描述以便后面分配源 Point;不持有跨 NativeContext 分配的 store 借用。 let target = cx.store().payload::(mount, "Mount")?.clone(); // 先排除已卸载、非交互和几何不命中的出现,避免为明显无效候选构造源调用。 if !target.mounted @@ -94,6 +103,7 @@ fn selector( { continue; } + // 几何是本次出现的布局与投影快照,同一 Block 的其他 Mount 不能共享这里的局部坐标。 let geometry = &target.geometry; let point = crate::geometry::Point::new(point[0], point[1]); // 把投影坐标反算到节点逻辑空间;不可逆/无效投影没有可用于 hitTest 的局部坐标。 @@ -103,6 +113,7 @@ fn selector( // 源 Point 以节点左上为原点;这里再次验证范围,避免变换后的点落到节点盒子之外。 let x = point.x - geometry.x; let y = point.y - geometry.y; + // 经过逻辑逆变换后再按本地盒检查,源 hitTest 不接收明显超出该出现范围的点。 if x < 0.0 || y < 0.0 || x > geometry.width || y > geometry.height { continue; } @@ -111,15 +122,18 @@ fn selector( .store() .type_of(target.block)? .ok_or(VmError::InvalidArgument("untyped hit target"))?; + // 方法按实际名义类型查找,既可命中原生默认实现,也可执行源覆盖。 let function = cx .store() .lookup_method(definition, "hitTest")? .ok_or(VmError::InvalidArgument("missing hitTest method"))?; + // 为本次候选分配本桌面的 Point,先写 x/y 再加入常量池,辅助代码用它作为第三实参。 let local = cx.new_object()?; attach_desktop_type(cx, local, "Point", desktop)?; cx.set_attr(local, "x", Value::Number((x as f64).into()))?; cx.set_attr(local, "y", Value::Number((y as f64).into()))?; // 每个候选占四个常量:协议函数、Block、Mount、局部 Point;索引供下面字节码复用。 + // 记录当前候选四个常量的基址;start+2 是最终返回的 Mount,不能误返回共享 Block。 let start = constants.len() as u32; constants.extend([ Value::Object(function), @@ -129,6 +143,7 @@ fn selector( ]); // false 分支只跳过“加载本 Mount 并返回”两条指令。按编码宽度计算, // 常量索引大到需要扩展操作数时仍能跳到下一候选。 + // 跳转偏移以两字节指令单元计,因此编码字节长度除以 2;扩展操作数的长度也计入。 let skip = (Instruction::encode_all(&[ Instruction::new(Opcode::LoadConst, start + 2), Instruction::simple(Opcode::ReturnValue), @@ -153,6 +168,7 @@ fn selector( ]); } // 所有候选均未命中时显式返回 Nil;不会把最后一个 false 当作选择结果。 + // 末尾追加独立 Nil 常量,零候选或全部返回 false 都走同一个无命中返回路径。 let end = constants.len() as u32; constants.push(Value::Nil); instructions.extend([ @@ -164,7 +180,9 @@ fn selector( #[derive(Debug)] /// 在一个只读调用中准备两次指针选择所需的候选与局部坐标。 struct Prepare { + // 候选来自已提交帧的交互出现,按绘制顺序排列,构造选择器时才逐项升级。 candidates: Vec, + // 按下与释放点都位于输入投影空间,各自独立做逆变换和协议命中,而非仅比较粗命中矩形。 press: [f32; 2], release: [f32; 2], desktop: ObjectRef, @@ -179,7 +197,9 @@ impl NativeCallable for Prepare { fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { // 按下和释放分别构造选择器,但都在本次只读执行内调用,使用同一预算域。 let down = selector(cx, &self.candidates, self.press, self.desktop)?; + // 释放点重用同一候选序列;按下 selector 的最终结果只在执行期出现,准备时不提前猜测目标。 let up = selector(cx, &self.candidates, self.release, self.desktop)?; + // 分配比较器无须捕获业务根;真正待比较的 Mount 由驱动局部槽及实参保持生命周期。 let compare = cx.allocate(ContextNativeFunction::new( "same pointer target", SameTarget, @@ -202,6 +222,7 @@ impl NativeCallable for Prepare { Instruction::new(Opcode::Call, 2), Instruction::simple(Opcode::ReturnValue), ]; + // 为两个选择结果显式声明局部槽,常量顺序与上面的 LoadConst 0/1/2 对应。 let driver = code( cx, instructions, @@ -224,6 +245,7 @@ impl NativeCallable for Prepare { } /// Returned Mount owns one root. No-hit and mismatched targets return None. /// 在一次有界只读调用中分别选择按下与释放目标;仅二者为同一 Mount 时返回其拥有根。 +// ui_bridge 的点击分发路径调用,成功的 Some 根由调用方使用后释放;本函数不直接派发业务 click。 pub fn resolve( vm: &mut Vm, desktop: ObjectRef, @@ -242,6 +264,7 @@ pub fn resolve( }, vec![desktop], ))?; + // 同步执行整个准备、两次命中及比较过程,共享总帧深/指令预算;不把未完成续体交给输入调用方。 let result = vm.call_read_only_with_limits( prepare, &[], @@ -252,7 +275,9 @@ pub fn resolve( ); // 先保存调用结果再释放入口根,因此 VM 返回错误时也能完成入口清理。 vm.release(prepare)?; + // 只取选择值,丢弃只读依赖集合;一次性输入命中不建立绘制或布局的持久失效依赖。 let (value, _) = result.map_err(|error| crate::endor_host::runtime_error(vm, error))?; + // 接受 Mount 或 Nil 两类驱动结果,其他值先释放其可能拥有根,再报告内部协议错误。 match value { // 成功返回的 Mount 仍拥有一次根引用,交由调用方使用并释放;None 不转移任何对象根。 Value::Object(mount) => Ok(Some(mount)),