1088 lines
50 KiB
Rust
1088 lines
50 KiB
Rust
//! 运行时与窗口线程之间的发布数据契约。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());
|
||
}
|
||
}
|