248 lines
11 KiB
Rust
248 lines
11 KiB
Rust
//! 旧 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<Self, VmError> {
|
|
// 仅接受四个兼容模式,未知值明确拒绝,不能默认为 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<Size>) -> [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<Size>) -> 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);
|
|
}
|
|
}
|