Files
endorphin-tessera-gui/src/model/document.rs
T

1088 lines
50 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.
//! 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。
//! RenderDocument/Frame 包含绘制命令、Mount 几何、页面标签和相机投影。发布前统一检查数量、坐标、颜色及索引一致性。
//! 屏幕命中、逻辑坐标和世界 UI 的射线映射必须与绘制采用同一变换;这里的 ID 是数据身份,不是窗口线程可操作的托管引用。
//! 调用路径:API worker 用 NamespaceState 提交/清空候选;桥接发布前调用 validate。
//! 窗口壳用 Frame 命中结果构造输入,worker 再复核;软件渲染入口用 display_list 转为设备坐标命令。
//! 所有发布值拥有绘制所需原生数据,不能要求窗口线程通过 ID 解引用 VM。
//!
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
ui_bridge::UiNode,
};
// HTTP/Console 未指定名字时的默认命名空间,初始化状态和源入口应使用同一值。
pub const DEFAULT_NAMESPACE: &str = "app.main";
#[derive(Clone, Debug)]
/// HTTP/GUI 共享的最近完整发布状态;submission 决定结果归属,revision 表示实际发布次数。
pub struct NamespaceState {
// 命名空间显示/API 身份,区别于每次输入的 submission 与 VM session。
pub name: String,
// 成功替换或 clear 才推进的发布版本,用于调用方识别可见文档更新。
pub revision: u64,
// 较新完整 PUT/DELETE 会改变提交身份;旧输入即使晚完成也不得覆盖新结果。
pub submission: u64,
// 最近成功的完整文档;编译/执行失败或过期候选不替换它。
pub document: RenderDocument,
// viewport 是主窗口逻辑页面区域;window_viewports 同时记录仍存活的副窗口。
pub viewport: Option<crate::layout::Size>,
pub window_viewports: std::collections::BTreeMap<u64, crate::layout::Size>,
// 原生命令与运行时世代一起发布,防止窗口 ID 在跨 VM 时被误用。
pub runtime_session: u64,
// 待窗口壳消费的世代化原生命令,按动作合并并保留全局请求顺序。
pub window_commands: Vec<crate::window::Envelope>,
}
impl Default for NamespaceState {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
name: DEFAULT_NAMESPACE.to_owned(),
revision: 0,
submission: 0,
document: RenderDocument::default(),
viewport: None,
window_viewports: std::collections::BTreeMap::new(),
runtime_session: 0,
window_commands: Vec::new(),
}
}
}
impl NamespaceState {
/// 在成功发布边界切换到指定 session,并按动作类别合并窗口命令;世代变化时丢弃旧命令。
pub fn publish_window_commands(&mut self, session: u64, commands: Vec<crate::window::Command>) {
// 跨运行时世代先丢弃旧命令,并去除旧副窗口视口记录;主壳窗口可跨 VM 替换继续存在。
if self.runtime_session != session {
self.window_commands.clear();
// The primary shell survives VM replacement. Secondary native
// resources belong to their originating runtime generation.
self.window_viewports
.retain(|id, _| *id == crate::window::MAIN_WINDOW);
}
self.runtime_session = session;
// 零表示无有效 VM 会话,清空传输请求并忽略传入命令,不能让退役结果重新控制窗口。
if session == 0 {
self.window_commands.clear();
return;
}
// 本方法接纳传入 session 作为当前世代,调用方负责只在成功发布边界调用;同资源同动作保留最新请求。
// 同一资源的同类动作保留最新请求,Focus/Resize 等不同动作仍分别排队。
for command in commands {
self.window_commands
.retain(|old| !old.command.same_kind(&command));
self.window_commands
.push(crate::window::Envelope { session, command });
}
// 合并后按 VM 原始 sequence 恢复执行顺序,避免 retain/push 使不同资源动作乱序。
self.window_commands
.sort_by_key(|command| command.command.sequence);
}
/// 计算下一个完整源码提交身份;计数溢出时返回错误而不是复用旧身份。
pub fn next_submission(&self) -> Result<u64> {
self.submission
.checked_add(1)
.context("submission counter exhausted")
}
/// 仅当提交身份仍是最新时替换完整文档并递增版本;已被取代的结果不发布。
pub fn commit(&mut self, submission: u64, document: RenderDocument) -> Result<Option<u64>> {
// 先核对归属,再计算新版本;失败或过时的结果不会替换已显示文档。
if submission != self.submission {
return Ok(None);
}
// 先计算可用新版本,溢出时整个提交失败且旧文档仍在。
let revision = self
.revision
.checked_add(1)
.context("revision counter exhausted")?;
self.document = document;
self.revision = revision;
Ok(Some(revision))
}
/// 推进提交身份和发布版本,替换为空文档并清除运行时命令,使旧提交不能覆盖清空结果。
pub fn clear(&mut self) -> Result<u64> {
// clear 也创建新提交身份,让先前在途的合法候选失去发布资格。
let submission = self.next_submission()?;
// 发布版本与提交令牌都先检查溢出,状态更新集中在全部检查成功之后。
let revision = self
.revision
.checked_add(1)
.context("revision counter exhausted")?;
// 先算好 submission 与 revision,任一溢出都不改状态;清空显示同时使旧异步发布失去提交资格。
self.submission = submission;
self.document = RenderDocument::default();
self.runtime_session = 0;
self.window_commands.clear();
self.revision = revision;
Ok(revision)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
/// 可以离开 VM 线程的完整展示值,包含源码投影、根帧和其他窗口的文档。
pub struct RenderDocument {
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
// 主文档携带的副窗口投影,最多一层,不是可任意递归的窗口树。
pub windows: std::collections::BTreeMap<u64, RenderDocument>,
#[serde(skip)]
// 仅本机增量渲染使用,不序列化;JSON 文档仍可凭 frame.commands 绘制,不需要持有 VM 句柄。
pub ui_root: Option<Arc<UiNode>>,
#[serde(default)]
// 完整输入源码或 Console 历史,Sources 使用原文与结构树范围定位。
pub source: String,
#[serde(default)]
// 可选源码投影根;空文档可无根,和视觉 ui_root 是不同的数据。
pub root: Option<CodeNode>,
#[serde(default)]
// 当前窗口完整视觉帧,副窗口文档也各有自己的 frame.window。
pub frame: Frame,
}
impl RenderDocument {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
pub fn validate(&self) -> Result<()> {
// 校验页面兼容相机的缩放、pan 和有限 extent;独立视口变换随后按各表与 Mount 另行检查。
let surface = &self.frame.surface;
// 所有页面兼容相机分量先检查范围,之后窗口绘制与输入可使用同一有限变换。
if !surface.zoom.is_finite()
|| !(0.1..=4.0).contains(&surface.zoom)
|| ![surface.pan.x, surface.pan.y]
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|| ![surface.extent.width, surface.extent.height]
.iter()
.all(|v| v.is_finite() && (0.0..=1_000_000.0).contains(v))
{
bail!("invalid page camera or extent");
}
// 主窗口之外最多十五个副窗口,与完整桌面窗口预算一致。
if self.windows.len() > 15 {
bail!("too many window projections");
}
// 副窗口只允许一层投影,键须等于其 frame.window,不能重复嵌入主窗口或再带嵌套 windows。
// 按每个副窗口独立校验帧,不允许借嵌套 map 绕开窗口计数。
for (id, document) in &self.windows {
// 副窗口不能冒用主窗口 ID,键/帧身份须相等,且不得再次嵌套 windows。
if *id == crate::window::MAIN_WINDOW
|| *id != document.frame.window
|| !document.windows.is_empty()
{
bail!("invalid secondary window projection");
}
document.validate()?;
}
// 源码预算按 UTF-8 字节计,不以字符数绕过 1 MiB 输入限制。
if self.source.len() > 1_048_576 {
bail!("source exceeds the 1 MiB limit");
}
// 绘制命令预算独立于 Mount 数量,防止单个源 paint 产生无界命令。
if self.frame.commands.len() > 10_000 {
bail!("frame exceeds the 10,000 command limit");
}
// 零窗口 ID 无效,不作为默认主窗口的别名接纳。
if self.frame.window == 0 {
bail!("invalid window identity");
}
parse_color(&self.frame.background)?;
// 空平行表是兼容的“全部无 clip/view”;一旦非空就必须与 commands 等长,禁止半张索引表。
if !self.frame.command_clips.is_empty()
&& self.frame.command_clips.len() != self.frame.commands.len()
{
bail!("invalid command clip table");
}
// 只校验存在的裁剪矩形;None 表示该命令不加这层裁剪。
for clip in self.frame.command_clips.iter().flatten() {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
// 视图表与裁剪表同样是平行索引,非空半表会使绘制参数错配。
if !self.frame.command_views.is_empty()
&& self.frame.command_views.len() != self.frame.commands.len()
{
bail!("invalid command view table");
}
// 各二维变换先检查缩放/平移合法性,后面的绘制转换可据此使用。
for view in self.frame.command_views.iter().flatten() {
// 拒绝不可逆或超界视图变换,避免绘制与命中给出互相矛盾的坐标。
if !view.valid() {
bail!("invalid command view transform");
}
}
// 逐条分派到命令自己的几何/文本/材质检查,不能只检查命令总数。
for command in &self.frame.commands {
command.validate()?;
}
// 出现位置也设独立上限,防止少量命令背后展开过多可命中节点。
if self.frame.mounts.len() > 10_000 {
bail!("frame exceeds the 10000 mount limit");
}
// 标签 ID 必须非零且唯一,最多一个活动页;标题按 UTF-8 字节限长,标签数受 MAX_PAGES 限制。
// 记录全部标签身份,集合同时用于检测重复;active 数量另外限制为至多一个。
let mut tab_ids = HashSet::new();
// 同时检查标签数量、活动项数和每项身份/标题预算,不能只验证某个当前活动页。
if self.frame.tabs.len() > crate::ui_bridge::MAX_PAGES
|| self.frame.tabs.iter().filter(|tab| tab.active).count() > 1
|| self
.frame
.tabs
.iter()
.any(|tab| tab.id == 0 || !tab_ids.insert(tab.id) || tab.title.len() > 1024)
{
bail!("invalid tab projection");
}
// 先收集全部 Mount ID 并校验几何,再第二遍核对关系下标,允许链接指向帧中较后位置。
// 先建整帧身份集合,第二轮才能允许关系链接指向后面才出现的节点。
let mut mount_ids = HashSet::new();
// 首轮逐项检查变换、身份与几何,收集关系检查所需的完整 ID 集合。
for mount in &self.frame.mounts {
// Mount 的二维变换与绘制表独立检查,不能因为命令合法就信任输入几何。
if mount.view.is_some_and(|view| !view.valid()) {
bail!("invalid Mount view transform");
}
// 业务 block_id 可因共享重复,但每个出现的 Mount ID 必须非零且帧内唯一。
if mount.id == 0 || mount.block_id.is_empty() || !mount_ids.insert(mount.id) {
bail!("invalid or duplicate mount identity");
}
validate_rect(mount.x, mount.y, mount.width, mount.height, 0.0)?;
// 祖先 clip 与节点原几何分别校验,裁剪不能包含 NaN 或负尺寸。
if let Some(clip) = mount.clip {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
// 相机手势额外内容区裁剪也须合法,避免手势使用未验证矩形。
if let Some(clip) = mount.viewport_clip {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
// 滚动投影为 [x,y,maxX,maxY],四项有限非负且实际偏移不得大于对应最大偏移。
if let Some(scroll) = mount.scroll
&& (!scroll.iter().all(|n| {
n.is_finite() && (0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(n)
}) || scroll[0] > scroll[2]
|| scroll[1] > scroll[3])
{
bail!("invalid scroll projection");
}
}
// 此处只保证关系目标在帧内,不做完整树环/双向一致性验证;组成与生命周期层承担更强结构不变量。
// 第二轮使用完整 ID 集合检查关系归属;这里不证明双向一致性或无环。
for mount in &self.frame.mounts {
// 只枚举实际存在的关系 ID,None 是合法边界,不补造关系。
for linked in [mount.parent, mount.first_child, mount.next_sibling]
.into_iter()
.flatten()
{
// 任何父/子/兄弟关系都必须落在本窗口帧内。
if !mount_ids.contains(&linked) {
bail!("mount links outside this frame");
}
}
}
// 携带本机视觉根时必须与第一个根出现的业务身份一致;反序列化无 ui_root 时跳过这项。
if let Some(root) = &self.ui_root
&& self
.frame
.mounts
.first()
.is_none_or(|mount| mount.block_id != root.block_id)
{
bail!("frame root does not match its visual projection");
}
// 源码树单独检查节点 ID 唯一,不能和视觉 Mount ID 集合混用。
let mut ids = HashSet::new();
// 无源码树是合法空发布;有根则递归检查深度、范围和身份。
if let Some(root) = &self.root {
root.validate(0, &mut ids)?;
}
Ok(())
}
/// 将逻辑命令转换到设备比例并应用逐命令视图/裁剪;不自动调用 validate,发布入口须先校验。
pub fn display_list(&self, scale: f32) -> Result<DisplayList> {
// 窗口渲染调用此转换,借用已发布完整帧,不访问候选 VM 或业务对象。
let frame = &self.frame;
// 新建拥有数据的设备显示列表,先清背景再按逻辑绘制顺序追加。
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(parse_color(&frame.background)?));
// 按命令顺序取对应 clip/view;缺项按无裁剪/单位变换处理,完整文档合法性应在发布入口预先验证。
for (index, command) in frame.commands.iter().enumerate() {
// 表可整体缺省;存在裁剪时为单条命令压入/弹出,防止影响后续无关命令。
let clip = frame.command_clips.get(index).copied().flatten();
// clip 已在父视图/面板对应空间,只乘设备 scale;命令本体则乘 view.zoom 再加 view 平移。
if let Some(clip) = clip {
list.push(DrawCommand::PushClip(scaled_rect(
clip[0], clip[1], clip[2], clip[3], scale,
)));
}
// 没有逐命令 view 时使用单位变换;实际缩放由设备 scale×view.zoom 组成。
let view = frame
.command_views
.get(index)
.copied()
.flatten()
.unwrap_or_default();
list.push(
command
// 均匀缩放包含字体、半径和描边,随后平移只改原点;不能把位移再乘一次 view.zoom。
.to_draw_command(scale * view.zoom)?
.translated(Point::new(view.x * scale, view.y * scale)),
);
// 本命令已经绘制,配对弹出先前压入的 clip,维持列表栈平衡。
if clip.is_some() {
list.push(DrawCommand::PopClip);
}
}
// 根层只绘制非面板的焦点框;面板内焦点已在 panel::compose 放入纹理,避免重复或脱离面板。
if let Some(focus) = frame
.focus
.and_then(|id| frame.mounts.iter().find(|m| m.id == id))
.filter(|mount| mount.panel.is_none())
{
// 焦点框使用与目标同一祖先裁剪,不能穿出滚动容器或有限面板。
if let Some(c) = focus.clip {
list.push(DrawCommand::PushClip(scaled_rect(
c[0], c[1], c[2], c[3], scale,
)));
}
list.push(
RenderCommand::StrokeRoundedRect {
x: focus
.view
.unwrap_or_default()
.rect(Rect::new(focus.x, focus.y, focus.width, focus.height))
.x,
y: focus
.view
.unwrap_or_default()
.rect(Rect::new(focus.x, focus.y, focus.width, focus.height))
.y,
width: focus.width * focus.view.unwrap_or_default().zoom,
height: focus.height * focus.view.unwrap_or_default().zoom,
radius: 7.0,
stroke_width: 2.0,
color: "#4F6FDB".into(),
}
.to_draw_command(scale)?,
);
// 焦点框单独配对弹出 clip,避免把之后的故障提示也裁掉。
if focus.clip.is_some() {
list.push(DrawCommand::PopClip);
}
}
// 故障提示最后追加在页面上方,只显示错误第一行;状态是否允许输入由 Frame.hit_test 等另行限制。
if let Some(error) = &frame.fault {
list.push(
RenderCommand::RoundedRect {
x: 0.0,
y: 0.0,
width: 10000.0,
height: 36.0,
radius: 0.0,
color: "#FFF1EF".into(),
}
.to_draw_command(scale)?,
);
list.push(
RenderCommand::Text {
x: 12.0,
y: 8.0,
value: format!("运行已停止:{}", error.lines().next().unwrap_or_default()),
size: 1,
color: "#A32D25".into(),
wrap_bounds: None,
}
.to_draw_command(scale)?,
);
}
Ok(list)
}
/// 从源码投影根按 ID 查找节点,供 Sources 查看与定位使用。
pub fn node(&self, id: &str) -> Option<&CodeNode> {
self.root.as_ref()?.find(id)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
/// Sources 使用的源码层次节点;ID、名称、种类和行列范围都属于代码投影而不是运行时对象句柄。
pub struct CodeNode {
// 源码节点稳定选择键,带命名空间/提交来源;与视觉 block_id 字符串用途不同。
pub id: String,
// 源声明或语句的显示名称,Sources 用于树列表标题,不作为全局唯一键。
pub name: String,
// 源码结构类别(如 script/module/function),不等于运行时视觉组件种类。
pub kind: String,
#[serde(default)]
// 原源码起始行,零表示缺少来源,不能默认当作第一行。
pub start_line: usize,
#[serde(default)]
// 原源码结束行;两端都已知时必须不早于 start_line。
pub end_line: usize,
#[serde(default)]
// 源码语法投影的有序孩子,验证 ID 唯一性时共享整树集合。
pub children: Vec<CodeNode>,
}
impl CodeNode {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
fn validate<'a>(&'a self, depth: usize, ids: &mut HashSet<&'a str>) -> Result<()> {
// 限制代码投影深度,避免递归查找/展示无界;同一 ids 集合跨所有分支检查全树 ID 唯一。
if depth > 64 {
bail!("architecture tree exceeds 64 levels");
}
// 空源码节点 ID 无法供 Sources 稳定选中或查找,发布时拒绝。
if self.id.is_empty() {
bail!("architecture node id cannot be empty");
}
// 集合覆盖整棵树,两个不同分支也不能重用同一节点 ID。
if !ids.insert(&self.id) {
bail!("duplicate architecture node id: {}", self.id);
}
// 零行号表示未提供来源;只有两端均非零时检查顺序,不把兼容缺失字段当非法。
if self.start_line > 0 && self.end_line > 0 && self.start_line > self.end_line {
bail!("invalid source range for node {}", self.id);
}
// 递归共享全局身份集合并增加深度,不能每个子树重新计数而绕过约束。
for child in &self.children {
child.validate(depth + 1, ids)?;
}
Ok(())
}
/// 按请求的代码节点身份或路径查找对应的源码投影。
fn find(&self, id: &str) -> Option<&CodeNode> {
// node(id) 先匹配当前节点,未命中再按孩子顺序递归返回首个匹配。
if self.id == id {
return Some(self);
}
self.children.iter().find_map(|child| child.find(id))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
/// 一个窗口的一次完整视觉发布:有序命令及其裁剪/变换、出现几何、焦点和版本。平行索引表必须和命令数量一致。
pub struct Frame {
// 命中索引是可重建的本地优化,不作为序列化协议的一部分保存。
#[serde(skip)]
pub hit_index: Arc<crate::viewport::spatial::Index>,
#[serde(default)]
// 页面级兼容 pan/zoom 与内容范围;独立相机另由 command_views/Mount.view 表达。
pub surface: crate::page::Surface,
#[serde(default = "default_window")]
// 非零原生窗口身份,副窗口 map 的键必须与它相同。
pub window: u64,
#[serde(default = "default_background")]
// 页面清屏颜色文本,每帧显示列表先生成一条 Clear。
pub background: String,
#[serde(default)]
// 按最终绘制顺序保存逻辑命令,后项覆盖前项;不包含显示列表追加的焦点/故障层。
pub commands: Vec<RenderCommand>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
// 逐命令有效裁剪 [x,y,width,height];可整体为空,否则必须与 commands 等长。
pub command_clips: Vec<Option<[f32; 4]>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
// 逐命令二维投影,缺省单位变换;clip 已在投影后的对应视图空间。
pub command_views: Vec<Option<crate::viewport::Transform>>,
#[serde(skip)]
// 仅本机装配源覆盖/面板时使用的命令出现归属表,不进入 JSON;display_list 绘制本身不读取它。
pub command_mounts: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
// 本窗口所有出现位置的平面索引,业务身份可重复,Mount ID 必须唯一。
pub mounts: Vec<MountGeometry>,
#[serde(default)]
// 本次布局发布版本,与 session/window 一起判断原生输入是否过期。
pub layout_version: u64,
#[serde(default)]
// 运行时世代与 layout_version 共同确定输入归属;仅布局版本相同不能跨 VM 接受事件。
pub session: u64,
#[serde(default)]
// 焦点 Mount ID,普通根焦点在 display_list 追加,面板焦点已组合进纹理。
pub focus: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
// 当前窗口标签投影,ID 唯一且最多一个 active。
pub tabs: Vec<crate::tabs::Tab>,
#[serde(default)]
// 停止域诊断,显示列表追加提示且 hit_test 拒绝继续选择交互目标。
pub fault: Option<String>,
}
impl Default for Frame {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
hit_index: Arc::default(),
surface: crate::page::Surface::default(),
window: crate::window::MAIN_WINDOW,
background: default_background(),
commands: Vec::new(),
command_clips: Vec::new(),
command_views: Vec::new(),
command_mounts: Vec::new(),
mounts: Vec::new(),
layout_version: 0,
session: 0,
focus: None,
tabs: Vec::new(),
fault: None,
}
}
}
/// A position in a published frame. Identifiers are opaque data, never VM handles.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// 某次出现的几何与投影元数据。逻辑矩形不被相机直接改写,view/panel 保存通往视图空间的映射。
pub struct MountGeometry {
#[serde(default, skip_serializing_if = "Option::is_none")]
// 可嵌套的世界 UI 映射,把页面坐标还原到此出现所在面板纹理空间。
pub panel: Option<Arc<crate::scene3d::panel::Mapping>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
// 是否二维相机视口,供窗口手势定位选择。
pub canvas_viewport: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
// 是否三维视口,原生拾取/相机事件据此进入对应协议。
pub scene3d_viewport: bool,
#[serde(default)]
// 三维视口是否具备相机控制器,camera_at 不只凭 scene3d_viewport 接受手势。
pub camera_controls: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 视口内容区的额外裁剪,供相机手势锚点检查;区别于一般 Mount 的祖先 clip。
pub viewport_clip: Option<[f32; 4]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 布局空间到当前视图/面板纹理空间的二维变换,None 为单位变换。
pub view: Option<crate::viewport::Transform>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 祖先合成后的可见裁剪,与投影后的点比较,不能当作布局空间矩形。
pub clip: Option<[f32; 4]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 当前有效滚动位置和上限,不是原始请求值;非滚动容器为 None。
pub scroll: Option<[f32; 4]>,
// 本帧内出现身份,重建/换页可变化,不是托管 ObjectRef。
pub id: u64,
// 业务对象身份文本,共享对象的多个 Mount 可拥有同一 block_id。
pub block_id: String,
// 父出现 ID;关系目标必须在本帧中,不允许跨窗口链接。
pub parent: Option<u64>,
// 首个子出现 ID,通过 next_sibling 遍历同级。
pub first_child: Option<u64>,
// 下一个同级出现 ID,保留组成展开次序。
pub next_sibling: Option<u64>,
// 出现的布局空间绝对横坐标,投影通过 view/panel 另算。
pub x: f32,
// 出现的布局空间绝对纵坐标,不随相机直接改写。
pub y: f32,
// 本次布局分配宽度,可不同于共享业务对象的自然 width。
pub width: f32,
// 本次布局分配高度,不和其他出现位置共用几何存储。
pub height: f32,
// 原生粗命中资格,真正源 hitTest/事件监听状态仍由 worker 复核。
pub interactive: bool,
#[serde(default)]
// 是否存在 pointer 系列处理需求,供窗口指针输入路由使用。
pub pointer_events: bool,
}
impl MountGeometry {
/// 先沿世界 UI 面板逆投影指针,按需要执行可见性和裁剪检查。
pub fn view_point(&self, point: Point, clipped: bool) -> Option<Point> {
self.panel
.as_ref()
.map_or(Some(point), |panel| panel.texture_point(point, clipped))
}
/// 把页面指针转换为该出现的布局坐标,供 hitTest 和局部事件处理使用。
pub fn logical_point(&self, point: Point) -> Option<Point> {
// 先处理面板反投影及遮挡,再撤销二维 view,结果仍是布局空间绝对坐标,尚未减节点原点。
let point = self.view_point(point, true)?;
Some(self.view.unwrap_or_default().inverse(point))
}
/// 把视图位移换算成目标出现的局部位移,处理缩放与世界 UI 投影差异。
pub fn logical_delta(&self, point: Option<[f32; 2]>, delta: [f32; 2]) -> Option<[f32; 2]> {
// 累计二维缩放用于把最终视图差值还原到布局位移;透视面板还需先投影两个端点。
let zoom = self.view.unwrap_or_default().zoom;
// 普通二维映射为线性,只除累计 zoom 即可;世界面板透视非线性,必须映射两个点求差。
if self.panel.is_none() {
return Some([delta[0] / zoom, delta[1] / zoom]);
}
// 面板投影非线性,缺少当前位置就无法正确换算 delta,明确返回 None。
let point = point?;
// 先反投影手势基点;clipped=false 允许捕获拖动移出面板,投影失败则停止处理。
let current = self.view_point(Point::new(point[0], point[1]), false)?;
// 对 point 与 point+delta 分别投影,使用 clipped=false 允许捕获拖动越出面板;投影不可逆则返回 None。
let previous =
self.view_point(Point::new(point[0] + delta[0], point[1] + delta[1]), false)?;
Some([
((previous.x - current.x) / zoom).clamp(-1_000_000., 1_000_000.),
((previous.y - current.y) / zoom).clamp(-1_000_000., 1_000_000.),
])
}
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
pub fn contains(&self, point: Point) -> bool {
// 先完成世界面板反投影和遮挡检查,失败直接视为未命中。
let Some(point) = self.view_point(point, true) else {
return false;
};
// 节点矩形在逻辑布局空间检查,clip 在视图/面板纹理空间检查,不能将二者坐标混用。
let local = self.view.map_or(point, |view| view.inverse(point));
Rect::new(self.x, self.y, self.width, self.height).contains(local)
&& self.clip.is_none_or(|c| {
c[2] > 0.0 && c[3] > 0.0 && Rect::new(c[0], c[1], c[2], c[3]).contains(point)
})
}
}
impl Frame {
/// 从顶层绘制顺序向后查找可控制的视口,命中与裁剪均成立才接收相机手势。
pub fn camera_at(&self, point: Point) -> Option<&MountGeometry> {
// 窗口手势目标选择使用空间索引候选次序,但仍须逐个通过视口种类、外框和内容裁剪。
self.hit_index
.candidates(point, self.mounts.len())
.into_iter()
.map(|index| &self.mounts[index])
.find(|mount| {
// 二维视口可控制,三维视口还要求显式 camera_controls;命中外框之外,还须位于内容 viewport_clip。
(mount.canvas_viewport || mount.scene3d_viewport && mount.camera_controls)
&& mount.contains(point)
&& mount.viewport_clip.is_some_and(|clip| {
mount.view_point(point, true).is_some_and(|point| {
Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point)
})
})
})
}
/// 在无故障帧中按逆绘制顺序选中可交互的出现,空间索引只缩小候选。
pub fn hit_test(&self, point: Point) -> Option<&MountGeometry> {
// 故障帧不返回可交互目标,防止用户继续向已经停止的域发送页面点击。
if self.fault.is_some() {
return None;
}
// 索引只筛几何候选,返回最前景 interactive 出现;真正源 hitTest 协议由 worker 的 source_hit 再执行。
self.hit_index
.candidates(point, self.mounts.len())
.into_iter()
.map(|index| &self.mounts[index])
.find(|mount| mount.interactive && mount.contains(point))
}
}
/// 返回默认主窗口身份,供省略窗口字段的兼容输入使用。
fn default_window() -> u64 {
crate::window::MAIN_WINDOW
}
/// 提供空帧或未指定背景时的默认页面底色。
fn default_background() -> String {
"#F7F7F5".to_owned()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
/// 可校验、可序列化的逻辑绘制命令;转换到设备像素在 display_list 阶段完成。
pub enum RenderCommand {
#[serde(skip_deserializing)]
// 仅本机可构造的不可变图像资源命令,禁止从外部 JSON 反序列化任意图像资源。
Image {
x: f32,
y: f32,
width: f32,
height: f32,
offset_x: f32,
offset_y: f32,
angle: f32,
opacity: f32,
tint: Option<Color>,
image: Arc<crate::render::image::Image>,
},
// 屏幕矩形中的三维 View 快照,世界几何和相机数据由原生 Arc 独立拥有。
Scene3D {
x: f32,
y: f32,
width: f32,
height: f32,
view: Arc<crate::scene3d::View>,
},
// 逻辑坐标填充矩形,颜色保存文本,设备缩放和通道解码在显示列表转换时完成。
RoundedRect {
x: f32,
y: f32,
width: f32,
height: f32,
#[serde(default)]
radius: f32,
color: String,
},
// 逻辑坐标描边矩形,stroke_width 与半径都参与设备同比缩放。
StrokeRoundedRect {
x: f32,
y: f32,
width: f32,
height: f32,
#[serde(default)]
radius: f32,
#[serde(default = "default_stroke_width")]
stroke_width: f32,
color: String,
},
// 逻辑文字和离散字号,可选 wrap_bounds 明确是否换行;旧数据缺省为不换行。
Text {
x: f32,
y: f32,
value: String,
#[serde(default = "default_text_size")]
size: u32,
color: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
wrap_bounds: Option<[f32; 2]>,
},
}
impl RenderCommand {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
pub(crate) fn validate(&self) -> Result<()> {
// RenderDocument.validate 逐条调用,按命令种类检查自身数据;不在这里尝试光栅化。
match self {
// 本机图片命令除了外框还包含偏移、角度和透明度,资源像素尺寸必须非零。
Self::Image {
x,
y,
width,
height,
offset_x,
offset_y,
angle,
opacity,
image,
..
} => {
validate_rect(*x, *y, *width, *height, 0.)?;
// 所有变换参数有限且 opacity 在 [0,1],防止无效混合或图像采样。
if ![offset_x, offset_y, angle, opacity]
.iter()
.all(|v| v.is_finite())
|| !(0. ..=1.).contains(opacity)
|| image.width == 0
|| image.height == 0
{
bail!("invalid image command");
}
}
// 三维视口外框与 view 快照各自验证,场景数据不能仅因外框合法就直接绘制。
Self::Scene3D {
x,
y,
width,
height,
view,
} => {
validate_rect(*x, *y, *width, *height, 0.0)?;
// View 的投影/场景不变量失败时拒绝发布,不让无效射线/深度计算进入渲染。
if !view.valid() {
bail!("invalid 3D view");
}
}
// 填充矩形检查有限非负尺寸/圆角,再解码颜色格式。
Self::RoundedRect {
x,
y,
width,
height,
radius,
color,
} => {
validate_rect(*x, *y, *width, *height, *radius)?;
parse_color(color)?;
}
// 描边矩形除公共几何外还必须具有正且有限的描边宽度。
Self::StrokeRoundedRect {
x,
y,
width,
height,
radius,
stroke_width,
color,
} => {
validate_rect(*x, *y, *width, *height, *radius)?;
// 描边必须正且有限;具体半宽限制在软件渲染器处理,这里不要求描边小于矩形尺寸。
if !stroke_width.is_finite() || *stroke_width <= 0.0 {
bail!("stroke_width must be a positive finite number");
}
parse_color(color)?;
}
// 文字无框时只要求坐标有限;有 wrap 框时还校验框和端点的协议范围。
Self::Text {
x,
y,
value,
size,
color,
wrap_bounds,
} => {
// 首先拒绝文字原点 NaN/无穷,后续测量与绘制不能处理这类坐标。
if !x.is_finite() || !y.is_finite() {
bail!("text coordinates must be finite");
}
// 有换行框时同时限制框尺寸、原点与右/下端点;无框文本在此只校验坐标有限性和文本/字号预算。
if let Some([width, height]) = wrap_bounds {
validate_rect(*x, *y, *width, *height, 0.0)?;
// 换行框本身及其远端坐标都不得超过视觉长度预算,避免原点与宽高相加后越界。
if [*x, *y, *width, *height, *x + *width, *y + *height]
.iter()
.any(|value| value.abs() > crate::ui_bridge::MAX_DIMENSION as f32)
{
bail!("text wrap bounds exceed limits");
}
}
// 单条文字按 UTF-8 字节限长 16 KiB,不按字符数估算资源用量。
if value.len() > 16_384 {
bail!("text command exceeds 16 KiB");
}
// size 是 1..8 的样式等级,不是任意像素字号。
if !(1..=8).contains(size) {
bail!("text size must be between 1 and 8");
}
parse_color(color)?;
}
}
Ok(())
}
/// 把一条已验证的发布命令变成原生绘制原语,缩放几何和文字样式。
fn to_draw_command(&self, scale: f32) -> Result<DrawCommand> {
// display_list 调用这里把逻辑命令同比缩放为设备原语;二维平移由调用方随后统一追加。
Ok(match self {
// 图像保留不可变资源 Arc,原点/局部矩形按比例缩放,角度、透明度和 tint 保持其含义。
Self::Image {
x,
y,
width,
height,
offset_x,
offset_y,
angle,
opacity,
tint,
image,
} => DrawCommand::Image {
image: image.clone(),
origin: Point::new(x * scale, y * scale),
rect: scaled_rect(*offset_x, *offset_y, *width, *height, scale),
angle: *angle,
opacity: *opacity,
tint: *tint,
},
// 只缩放三维视口屏幕矩形,内部相机与世界场景仍由 View 快照解释。
Self::Scene3D {
x,
y,
width,
height,
view,
} => DrawCommand::Scene3D {
rect: scaled_rect(*x, *y, *width, *height, scale),
view: view.clone(),
},
// 填充矩形位置/尺寸/圆角同比缩放,颜色转为渲染器通道值。
Self::RoundedRect {
x,
y,
width,
height,
radius,
color,
} => DrawCommand::FillRoundedRect {
rect: scaled_rect(*x, *y, *width, *height, scale),
radius: radius * scale,
color: parse_color(color)?,
},
// 描边宽度也乘设备比例,保持与矩形外观一致的视觉厚度。
Self::StrokeRoundedRect {
x,
y,
width,
height,
radius,
stroke_width,
color,
} => DrawCommand::StrokeRoundedRect {
rect: scaled_rect(*x, *y, *width, *height, scale),
radius: radius * scale,
width: stroke_width * scale,
color: parse_color(color)?,
},
Self::Text {
x,
y,
value,
size,
color,
wrap_bounds,
// 根据是否提供换行边界选择 WrappedText 或 Text;两者都使用同一等级字号与设备缩放。
} => match wrap_bounds {
// 有 wrap 框时产生 WrappedText,排字区域和样式同时缩放,保持换行语义。
Some([width, height]) => DrawCommand::WrappedText {
rect: scaled_rect(*x, *y, *width, *height, scale),
value: value.clone(),
style: crate::text::TextStyle::ui(*size).scaled(scale),
color: parse_color(color)?,
},
// 旧 JSON 未提供 wrap_bounds 时保留不换行 Text 行为,不凭测量结果补造裁剪。
None => DrawCommand::Text {
origin: Point::new(x * scale, y * scale),
value: value.clone(),
style: crate::text::TextStyle::ui(*size).scaled(scale),
color: parse_color(color)?,
},
},
})
}
}
/// 提供未指定描边宽度时的兼容默认值。
fn default_stroke_width() -> f32 {
1.0
}
/// 提供未指定字号时的协议默认值。
fn default_text_size() -> u32 {
1
}
/// 统一检查有限矩形坐标、尺寸与可选圆角,阻止异常值进入光栅循环。
fn validate_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Result<()> {
// 各命令/clip 共用基础几何检查;世界坐标可负,但四个分量必须有限。
if !x.is_finite() || !y.is_finite() || !width.is_finite() || !height.is_finite() {
bail!("rectangle values must be finite");
}
// 此基础检查只要求有限、非负几何与圆角,不加世界坐标绝对值上限;更严格范围由具体协议调用路径决定。
if width < 0.0 || height < 0.0 || !radius.is_finite() || radius < 0.0 {
bail!("rectangle dimensions and radius cannot be negative");
}
Ok(())
}
/// 将逻辑矩形转换为设备比例下的矩形,保持位置与尺寸同比缩放。
fn scaled_rect(x: f32, y: f32, width: f32, height: f32, scale: f32) -> Rect {
Rect::new(x * scale, y * scale, width * scale, height * scale)
}
/// 解析支持的十六进制 RGB/RGBA 文本,拒绝错误长度或非法字符。
pub fn parse_color(value: &str) -> Result<Color> {
// 属性解码、文档验证和显示列表转换共用颜色入口,必须带显式 # 前缀。
let hex = value
.strip_prefix('#')
.ok_or_else(|| anyhow::anyhow!("color must begin with #"))?;
// 先验证 ASCII 和精确长度,后面按字节区间切片才不会在多字节字符中间切开;不支持短色号或命名颜色。
if !hex.is_ascii() || (hex.len() != 6 && hex.len() != 8) {
bail!("color must use #RRGGBB or #RRGGBBAA");
}
// 已确认 ASCII 和长度后,可安全按字节成对解析 R/G/B/A,非法十六进制仍返回错误。
let parse = |range| u8::from_str_radix(&hex[range], 16);
Ok(Color::rgba(
parse(0..2)?,
parse(2..4)?,
parse(4..6)?,
// 顺序为 RRGGBBAA 而非 AARRGGBB,六位色号默认完全不透明。
if hex.len() == 8 { parse(6..8)? } else { 255 },
))
}
#[cfg(test)]
mod tests {
#[test]
/// 旧文字JSON缺wrap_bounds仍走不换行命令;合法边界随DPI缩放,负宽度在发布验证中拒绝。
fn text_wrap_bounds_validate_and_legacy_text_remains_unwrapped() {
// 从缺省 wrap_bounds 的旧 JSON 反序列化,验证兼容默认值而非手工构造近似命令。
let mut command: RenderCommand = serde_json::from_str(
r##"{"type":"text","x":0,"y":0,"value":"text","size":1,"color":"#000000"}"##,
)
.unwrap();
assert!(matches!(
command.to_draw_command(1.0).unwrap(),
DrawCommand::Text { .. }
));
// 添加合法 40×20 框,后面以 1.25 DPI 核对实际绘制原语尺寸。
if let RenderCommand::Text { wrap_bounds, .. } = &mut command {
*wrap_bounds = Some([40.0, 20.0]);
}
command.validate().unwrap();
assert!(
matches!(command.to_draw_command(1.25).unwrap(),DrawCommand::WrappedText{rect,..}if rect.width==50.0 && rect.height==25.0)
);
// 再破坏为负宽,要求发布验证拒绝,而不是把尺寸夹到零继续显示。
if let RenderCommand::Text { wrap_bounds, .. } = &mut command {
*wrap_bounds = Some([-1.0, 20.0]);
}
assert!(command.validate().is_err());
}
use super::*;
#[test]
/// 默认空文档可通过验证且没有结构根/绘图命令,支持初始状态与清空发布。
fn empty_document_is_valid_and_blank() {
// 默认值代表初始化/清空后的合法空状态,既不需要源码树也不需要业务命令。
let document = RenderDocument::default();
document.validate().unwrap();
assert!(document.root.is_none());
assert!(document.frame.commands.is_empty());
}
#[test]
/// 检查六位色默认alpha255,八位色按RRGGBBAA解码,避免将末尾透明度当成首通道。
fn parses_rgb_and_rgba_colors() {
// 六位色默认不透明,八位色最后两位为 alpha,分别核对通道顺序。
assert_eq!(
parse_color("#102030").unwrap(),
Color::rgba(16, 32, 48, 255)
);
assert_eq!(
parse_color("#10203080").unwrap(),
Color::rgba(16, 32, 48, 128)
);
}
}
#[cfg(test)]
mod window_projection_tests {
use super::*;
#[test]
/// 同会话窗口命令可累积,新会话发布先丢旧命令;clear重置runtime_session并清空所有原生命令。
fn replacement_and_reset_discard_old_window_projections() {
// 模拟 API 发布状态,先在同一 VM session 累积不同动作,再更换世代。
let mut state = NamespaceState::default();
// 第一动作 Focus 与随后 Resize 类型不同,同世代合并不应互相覆盖。
let command = crate::window::Command {
window: 1,
sequence: 1,
action: crate::window::Action::Focus,
};
state.publish_window_commands(1, vec![command.clone()]);
// 第二动作使用更大 sequence,检验同世代累积及换世代清旧命令。
let resize = crate::window::Command {
window: 1,
sequence: 2,
action: crate::window::Action::Resize {
width: 900.0,
height: 600.0,
},
};
state.publish_window_commands(1, vec![resize.clone()]);
assert_eq!(state.window_commands.len(), 2);
state.publish_window_commands(2, vec![resize]);
assert_eq!(state.window_commands.len(), 1);
assert_eq!(state.window_commands[0].session, 2);
// clear 应清除当前 runtime_session 和待传输命令,旧 VM 请求不能在重置后落到新窗口。
state.clear().unwrap();
assert_eq!(state.runtime_session, 0);
assert!(state.window_commands.is_empty());
}
}