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

244 lines
10 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] {
// 未收到原生视口尺寸时采用布局默认根约束;不能凭空建立固定宽高。
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 {
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 {
// 传入的数组是世界坐标。转换后使用左/上包含、右/下排除的视口边界;
// 未知视口时仍排除负坐标,但不限制右/下界。
// 传入世界坐标先投影到视图空间,再按视口半开边界检查,不能与 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) {
// 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);
}
}