//! 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。 //! 新组合应优先使用普通布局、ScrollView 和独立场景视口;保留这里是为了兼容已有 window/vertical/horizontal/canvas 页面。 //! //! 调用路径:Block.mode 属性解码用 Mode.parse;根布局按 Mode.constraints 选择有限/可延伸轴。 //! scene/输入导航使用 Surface 的正逆变换和可见性判断,内容变化后用 clamp 收敛兼容页面偏移。 //! 本模块不创建窗口、不修改 VM;Page 表面导航与独立 CanvasViewport/ScrollBlock 是不同状态。 //! //! Page space is independent of the native window. The camera is applied once //! for painting and inverted once for input; layout and Mounts stay in world units. use crate::{geometry::Point, layout::Size}; use endor_core::VmError; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] /// 旧 Page 的有限或自由空间模式,新场景视口不依赖它表达布局方向。 pub enum Mode { #[default] // 双轴被原生视口限制;兼容相机固定在原点且比例为 1。 Window, // 宽度固定,高度允许展开到布局上限,只沿 y 方向导航。 Vertical, // 高度固定,宽度允许展开到布局上限,只沿 x 方向导航。 Horizontal, // 双轴自由布局并允许平移缩放;相机不按内容边缘钳制。 Canvas, } impl Mode { /// 返回内部枚举或对象对应的稳定源名称。 pub fn name(self) -> &'static str { // 源字段编码与序列化采用同一组小写策略名,布局方向不由显示标题推断。 match self { Self::Window => "window", Self::Vertical => "vertical", Self::Horizontal => "horizontal", Self::Canvas => "canvas", } } /// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。 pub fn parse(name: &str) -> Result { // 仅接受四个兼容模式,未知值明确拒绝,不能默认为 window。 match name { "window" => Ok(Self::Window), "vertical" => Ok(Self::Vertical), "horizontal" => Ok(Self::Horizontal), "canvas" => Ok(Self::Canvas), _ => Err(VmError::InvalidArgument( "Page.mode expects window, vertical, horizontal or canvas", )), } } /// 为旧页面模式生成有限轴和可延伸轴的根约束。 pub fn constraints(self, viewport: Option) -> [f32; 4] { // LayoutRuntime::root_constraints 用于页面根测量;这里的可延伸轴仍有数值上限,不会向布局传 f32::INFINITY。 // 未收到原生视口尺寸时采用布局默认根约束;不能凭空建立固定宽高。 let Some(size) = viewport else { return crate::layout::root_constraints(None); }; // MAX_DIMENSION 在根约束中充当可延伸轴上限,不把它当作真实窗口尺寸。 let limit = crate::ui_bridge::MAX_DIMENSION as f32; // 数组采用 [最小宽, 最小高, 最大宽, 最大高];竖/横页面仍至少占满当前视口。 match self { Self::Window => [size.width, size.height, size.width, size.height], Self::Vertical => [size.width, size.height, size.width, limit], Self::Horizontal => [size.width, size.height, limit, size.height], Self::Canvas => [0.0, 0.0, limit, limit], } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] /// 页面级兼容相机状态和有限内容范围,支持绘制与命中正逆变换。 pub struct Surface { // 兼容 Page 空间策略,决定根约束及偏移夹取规则。 pub mode: Mode, // 发布给原生侧的页面身份文本,不是托管强引用。 pub page: String, // 视图左上角对应的世界坐标;绘制时先减 pan 再乘 zoom。 pub pan: Point, #[serde(default = "unit")] // 世界到视图的均匀比例,反变换要求非零有限;模型/文档入口负责合法范围。 pub zoom: f32, // 布局产出的内容宽高,用于有限模式钳制;不决定 Canvas 的导航边界。 pub extent: Size, } /// 生成页面相机计算所需的单位比例或边界默认值。 fn unit() -> f32 { 1.0 } impl Default for Surface { /// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。 fn default() -> Self { // 初始 Window 表面无页面身份、零偏移且单位缩放,可作为合法空发布值。 Self { mode: Mode::Window, page: String::new(), pan: Point::default(), zoom: 1.0, extent: Size::default(), } } } impl Surface { /// 指定页面模式并显式使用单位缩放,其他字段采用空页面默认值;无需分配 VM 或原生窗口资源。 pub fn new(mode: Mode) -> Self { Self { mode, zoom: 1.0, ..Self::default() } } /// 把页面视图坐标反算为旧 Page 相机下的世界坐标。 // 输入在视图空间,先除缩放再加世界偏移;调用方需保证 zoom 有限且大于零。 pub fn to_world(&self, point: Point) -> Point { // shell 先去除侧栏/设备比例等宿主偏移,再调用本逆变换;不在这里处理 Mount.view 或三维面板的第二层映射。 Point::new( point.x / self.zoom + self.pan.x, point.y / self.zoom + self.pan.y, ) } /// 把旧页面世界坐标变换到可见视图坐标。 // 输入在世界空间,先减相机偏移再缩放,恰与 to_world 互逆。 pub fn to_view(&self, point: Point) -> Point { Point::new( (point.x - self.pan.x) * self.zoom, (point.y - self.pan.y) * self.zoom, ) } /// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。 pub fn contains(&self, point: [f32; 2], viewport: Option) -> bool { // UiBridge 在点击/滚动前用它排除页面外输入;point 必须已经是页面世界点,不能把原始设备像素传进来。 // 传入的数组是世界坐标。转换后使用左/上包含、右/下排除的视口边界; // 未知视口时仍排除负坐标,但不限制右/下界。 // 传入世界坐标先投影到视图空间,再按视口半开边界检查,不能与 Rect.contains 的闭边界混淆。 let point = self.to_view(Point::new(point[0], point[1])); point.x >= 0.0 && point.y >= 0.0 && viewport.is_none_or(|size| point.x < size.width && point.y < size.height) } /// 只对有限页面按实际内容范围钳制偏移;自由画布不按内容边缘停止导航。 pub fn clamp(&mut self, viewport: Size) { // UiBridge 布局完成后和 page_input 导航后调用;只修改这个 Surface 数值副本,写回 Page 属性或发布 Frame 由调用方完成。 // Window 重置所有导航;单轴模式清除另一轴并恢复单位缩放。 // 内容小于视口时 max(0) 令可滚动范围归零,避免 clamp 出现反向上下限。 match self.mode { // 普通窗口页面关闭导航能力,每次夹取恢复零 pan 与 zoom=1。 Mode::Window => { self.pan = Point::default(); self.zoom = 1.0; } // 纵向模式仅允许 y 在内容高−视口高内移动,清横向偏移并禁用缩放。 Mode::Vertical => { self.zoom = 1.0; self.pan.x = 0.0; self.pan.y = self .pan .y .clamp(0.0, (self.extent.height - viewport.height).max(0.0)); } // 横向模式同理仅允许 x 移动,内容缩短后最大偏移随之减少。 Mode::Horizontal => { self.zoom = 1.0; self.pan.y = 0.0; self.pan.x = self .pan .x .clamp(0.0, (self.extent.width - viewport.width).max(0.0)); } // 自由画布不按内容边缘夹取,保留可查看负世界坐标与空白区域的导航。 Mode::Canvas => {} } } } #[cfg(test)] mod tests { use super::*; #[test] /// 检查页面相机的往返变换,缩放时反求 pan 使指针下世界点固定,并按视口验证世界点可见性。 fn camera_inverse_and_cursor_anchored_zoom_preserve_world_position() { // 先验证相机正逆变换,再改变 zoom 并反求 pan,保证指针下的世界点不发生漂移。 let mut view = Surface::new(Mode::Canvas); view.pan = Point::new(-240.0, 120.0); view.zoom = 0.75; // 固定视图指针位置作为缩放锚点,计算缩放前它对应的世界点。 let pointer = Point::new(300.0, 180.0); // 保存锚点世界位置,改变 zoom 后反求 pan,保证 to_view(world) 仍等于 pointer。 let world = view.to_world(pointer); assert_eq!(view.to_view(world), pointer); // 模拟指针锚定缩放而不是绕世界原点缩放,随后检查可见与不可见点。 view.zoom = 2.0; view.pan = Point::new( world.x - pointer.x / view.zoom, world.y - pointer.y / view.zoom, ); assert_eq!(view.to_view(world), pointer); assert!(view.contains( [world.x, world.y], Some(Size { width: 500.0, height: 400.0 }) )); assert!(!view.contains( [-1000.0, -1000.0], Some(Size { width: 500.0, height: 400.0 }) )); } #[test] /// 纵向页面将过大偏移夹到内容减视口范围;内容缩短到视口以内后偏移必须归零,横向偏移也禁用。 fn finite_pages_clamp_after_content_shrinks() { // 先将 900 的旧偏移钳到 800-600=200,再缩短内容至视口以内,确认偏移回零。 let mut view = Surface::new(Mode::Vertical); view.pan = Point::new(25.0, 900.0); view.extent = Size { width: 400.0, height: 800.0, }; view.clamp(Size { width: 400.0, height: 600.0, }); assert_eq!(view.pan, Point::new(0.0, 200.0)); // 内容缩到视口之内后重新夹取,旧 y=200 必须回零,不能继续显示内容外空白。 view.extent.height = 100.0; view.clamp(Size { width: 400.0, height: 600.0, }); assert_eq!(view.pan.y, 0.0); } }