10 Commits

125 changed files with 4110 additions and 77 deletions
+16
View File
@@ -1,3 +1,4 @@
# Cargo 包元数据与编译器最低版本;二进制入口默认取 src/main.rs。
[package]
name = "tessera"
version = "0.1.0"
@@ -6,24 +7,39 @@ rust-version = "1.91"
description = "Cross-platform desktop rendering client"
license = "MIT OR Apache-2.0"
# 跨平台运行依赖:语言执行、文字排版、序列化、窗口/像素表面与轻量 HTTP 服务。
# Aster 三个 crate 使用固定在本仓库 vendor 子模块中的本地源码,不从注册表选择同名实现。
[dependencies]
# 应用边界的错误聚合与上下文,保留底层失败原因。
anyhow = "1.0"
# Aster 字节码编译器,把前端结果交给同一套 VM 执行。
compiler = { path = "vendor/aster/crates/compiler" }
# 系统字体发现、塑形与换行,用于测量和软件文字绘制。
cosmic-text = "0.19"
# Aster 托管对象、类型系统、VM 和可恢复调用;依赖包名 core 在 Rust 中重命名为 endor_core。
endor_core = { package = "core", path = "vendor/aster/crates/core" }
# Endor 源语法解析与原始位置诊断。
frontend = { path = "vendor/aster/crates/frontend" }
# 派生序列化协议,rc 特性允许发布模型中的 Arc/Rc 数据参与序列化。
serde = { version = "1.0", features = ["derive", "rc"] }
# HTTP 请求/响应与事件信封的 JSON 编解码。
serde_json = "1.0"
# 把宿主软件光栅化得到的像素缓冲呈现到原生窗口。
softbuffer = "0.4.8"
# 本地 Agent API 的 HTTP 监听与请求响应,不引入异步 Web 运行时。
tiny_http = "0.12"
# 按 Unicode 字素边界处理编辑/选择,避免把组合字符拆开。
unicode-segmentation = "1.12"
# 跨平台窗口、事件循环、DPI 与输入法事件。
winit = "0.30.13"
# 仅 macOS 编译原生标题栏适配,显式开启所用 AppKit/Foundation API,其他平台不引入这些依赖。
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5.2"
objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSApplication", "NSButton", "NSEvent", "NSView", "NSWindow"] }
objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSGeometry", "NSString", "NSUserDefaults"] }
# 发布构建:thin LTO 与单代码生成单元偏重最终优化,strip 去除可执行文件符号以控制体积。
[profile.release]
lto = "thin"
codegen-units = 1
+173
View File
@@ -0,0 +1,173 @@
# Tessera 中文源码注释与阅读索引
本轮进一步细化 Tessera 自有代码的中文注释:覆盖所有 Rust 模块、运行时/API 测试、内置起始页、45 个 Endor 示例、Cargo 依赖配置及两个工具脚本。说明聚焦具体输入/输出、状态归属、计算步骤、依赖观察、取消/失败路径和资源预算;不修改接口、算法、常量或非注释程序文本。Aster 是独立维护的 vendor 子模块,其固定版本与内容沿用主线。
## 建议阅读顺序
1. [main](../src/main.rs):创建原生循环、共享文档与执行 worker。
2. [原生应用](../src/shell/app.rs):理解窗口生命周期、可用视口、输入边界和最终 present。
3. [API worker](../src/runtime/api/worker.rs) 与 [运行时](../src/runtime/endor_host.rs):理解候选/活跃 VM、输入串行化、分片刷新和发布。
4. [桥接层](../src/bridge/ui_bridge.rs)、[Block](../src/model/block.rs) 与 [Mount](../src/model/mount.rs):区分共享业务对象、每次出现和展示事务。
5. [快照](../src/model/snapshot.rs)、[源布局](../src/render/source_layout.rs) 和 [源绘制](../src/render/source_paint.rs):理解依赖缓存、只读执行及挂起工作。
6. [二维视口](../src/render/viewport.rs)、[3D](../src/render/scene3d.rs) 和 [面板映射](../src/render/scene3d/panel.rs):理解布局/世界/视图的边界。
7. [任务](../src/runtime/tasks.rs)、[provider](../src/runtime/io_tasks.rs) 和 [事件传播](../src/runtime/event_dispatch.rs):理解异步交付资格与生命周期。
## 阅读时需要保留的边界
- Block/Data 属于共享业务组成;Mount 属于一个展示位置,不能把一次出现的尺寸写回共享对象来代替排版。
- ObjectRef/WeakObjectRef 的含义由真实托管拥有关系决定;派生缓存中的身份索引不能成为隐藏根。
- 窗口线程消费完整发布值;VM、源回调和托管堆操作只在执行 worker 内推进。
- submission 防止旧请求覆盖新输入;session 防止跨 VM 资源误用;layout_version 防止旧几何上的离散操作误触。
- Pending Work 同时拥有续体与上下文;取消必须关闭原 VM 的只读执行并恢复未提交 Mount,不能只丢弃 Rust 值。
- 场景世界不是无限尺寸的排版盒子;有限视口参加布局,摄像机负责投影,命中沿相同链逆变换。
- 现有 CPU 绘制、缓存范围与输入队列限制仍按当前代码执行;说明性注释不代表性能优化已实现。
## Rust 逐文件说明
每行说明该文件本次深化注释的职责和目的。生产函数/方法的入口说明与内部关键逻辑互相补充;VM 可调用适配器说明实际参数约定,而非仅重复 trait 名称。集成测试中的每个顶层测试/辅助函数都有具体场景和断言说明,测试程序字符串与断言保持原样。
| 文件 | 注释解释的职责与目的 |
| --- | --- |
| [src/bridge/mod.rs](../src/bridge/mod.rs) | 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。 |
| [src/bridge/ui_bridge.rs](../src/bridge/ui_bridge.rs) | Endor 与 Tessera 的展示事务协调层:维护每个窗口的 Page、Mount、布局缓存和发布版本。 |
| [src/bridge/ui_bridge/bootstrap.rs](../src/bridge/ui_bridge/bootstrap.rs) | 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。 |
| [src/bridge/ui_bridge/calls.rs](../src/bridge/ui_bridge/calls.rs) | 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。 |
| [src/bridge/ui_bridge/developer_pages.rs](../src/bridge/ui_bridge/developer_pages.rs) | 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。 |
| [src/bridge/ui_bridge/page_input.rs](../src/bridge/ui_bridge/page_input.rs) | 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。 |
| [src/bridge/ui_bridge/signatures.rs](../src/bridge/ui_bridge/signatures.rs) | 声明宿主方法在 Endor 类型系统中的参数和返回契约。 |
| [src/bridge/ui_bridge/tree.rs](../src/bridge/ui_bridge/tree.rs) | 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。 |
| [src/main.rs](../src/main.rs) | 应用组合入口。先创建原生事件循环和共享发布状态,再启动 API/VM worker,最后把窗口事件交给 TesseraApp。 |
| [src/model/binding.rs](../src/model/binding.rs) | 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。 |
| [src/model/block.rs](../src/model/block.rs) | Block 的共享赋值、底层 Data 与视觉属性实现。Block 通过 Cell 间接指向 Data,使别名能观察到同一次替换。 |
| [src/model/document.rs](../src/model/document.rs) | 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。 |
| [src/model/mod.rs](../src/model/mod.rs) | 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。 |
| [src/model/mount.rs](../src/model/mount.rs) | 每次展示的托管上下文:同一个 Block 出现在两个位置时,有两个独立 Mount。 |
| [src/model/object_list.rs](../src/model/object_list.rs) | 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。 |
| [src/model/presentation.rs](../src/model/presentation.rs) | 一个原生窗口对应一份 Presentation:独立视口、Mount、测量缓存、焦点与依赖记录。 |
| [src/model/protocols.rs](../src/model/protocols.rs) | 安装 Visual、EventTarget、Children 和 Presentable 等能力契约。 |
| [src/model/scene3d.rs](../src/model/scene3d.rs) | 将 Endor 托管场景对象转换成可供渲染线程读取的 3D 数据。 |
| [src/model/snapshot.rs](../src/model/snapshot.rs) | 从可变托管对象图提取不可变 UiNode,并维护反向依赖与父子失效传播。 |
| [src/model/window.rs](../src/model/window.rs) | 托管 Window 的页面所有权、原生命令与关闭生命周期。 |
| [src/render/box_model.rs](../src/render/box_model.rs) | 有限布局盒子的尺寸策略。区分 auto、固定值和 fill,并处理 min/max 对约束与最终尺寸的限制。 |
| [src/render/display_list.rs](../src/render/display_list.rs) | 面向设备像素的绘制指令集合。它承载矩形、文字、裁剪与 3D 表面等原语,不直接操作 VM。 |
| [src/render/geometry.rs](../src/render/geometry.rs) | 共享的二维点和矩形值。命中判断在调用方完成坐标系转换后使用,矩形本身不隐式处理相机。 |
| [src/render/layout.rs](../src/render/layout.rs) | 原生布局求解器:自底向上测量期望尺寸,再自顶向下分配位置和实际尺寸。 |
| [src/render/mod.rs](../src/render/mod.rs) | 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。 |
| [src/render/native_visual.rs](../src/render/native_visual.rs) | 让原生组件也通过 Endor 的 measure/arrange/paint 协议被调用。 |
| [src/render/page.rs](../src/render/page.rs) | 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。 |
| [src/render/scene.rs](../src/render/scene.rs) | 合成整个原生窗口的显示列表:页面画面、宿主侧栏和开发工具绘制。 |
| [src/render/scene3d.rs](../src/render/scene3d.rs) | 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。 |
| [src/render/scene3d/math.rs](../src/render/scene3d/math.rs) | 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。 |
| [src/render/scene3d/panel.rs](../src/render/scene3d/panel.rs) | 连接 3D 平面与普通 UI 的坐标和绘制桥梁。 |
| [src/render/scene3d/spatial.rs](../src/render/scene3d/spatial.rs) | 不可变世界空间 BVH。用网格变换后的包围盒快速筛选视锥与射线候选。 |
| [src/render/scroll.rs](../src/render/scroll.rs) | 逐 Mount 的滚动状态、裁剪和输入目标选择。 |
| [src/render/software_renderer.rs](../src/render/software_renderer.rs) | 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。 |
| [src/render/source_hit.rs](../src/render/source_hit.rs) | 通过真正的源/原生 hitTest 协议确定指针目标。 |
| [src/render/source_layout.rs](../src/render/source_layout.rs) | 源 measure/arrange 协议的分片布局驱动器。自底向上的测量与自顶向下的安排共享一个只读 VM 调用域。 |
| [src/render/source_paint.rs](../src/render/source_paint.rs) | 执行源 paint 覆盖并解码受检查的 PaintCommand。 |
| [src/render/text.rs](../src/render/text.rs) | 统一字体发现、文字整形、测量与抗锯齿字形绘制。 |
| [src/render/viewport.rs](../src/render/viewport.rs) | 独立 2D 相机与可逆投影。视口尺寸属于布局;平移、缩放属于视图,不改变世界对象的逻辑位置。 |
| [src/render/viewport/spatial.rs](../src/render/viewport/spatial.rs) | 有界屏幕网格,用来减少普通二维命中和相机目标查询的候选。 |
| [src/runtime/api.rs](../src/runtime/api.rs) | 本机 HTTP API 入口:路由请求、限制输入大小、观察活动,并将执行工作送入串行 worker。 |
| [src/runtime/api/guide.rs](../src/runtime/api/guide.rs) | 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。 |
| [src/runtime/api/tests.rs](../src/runtime/api/tests.rs) | API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 |
| [src/runtime/api/worker.rs](../src/runtime/api/worker.rs) | 唯一串行执行 VM 的 API worker。保留活跃运行时,另建 PUT 候选,并在候选完整成功后切换发布。 |
| [src/runtime/endor_host.rs](../src/runtime/endor_host.rs) | 把 Aster 编译/执行与 Tessera 展示生命周期连接起来。Candidate 拥有 VM 与宿主根,ReplSession 保留实际运行中的全局环境。 |
| [src/runtime/endor_host/tests.rs](../src/runtime/endor_host/tests.rs) | 说明全部运行时测试和辅助函数的场景/断言,并解释新主线性能基准的 DPI 分组、预热、三类计时边界和统计方式。 |
| [src/runtime/endor_script.rs](../src/runtime/endor_script.rs) | 区分完整 Module 与顶层 Script,并保留输入源码位置。 |
| [src/runtime/event_dispatch.rs](../src/runtime/event_dispatch.rs) | 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。 |
| [src/runtime/events.rs](../src/runtime/events.rs) | 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。 |
| [src/runtime/io_tasks.rs](../src/runtime/io_tasks.rs) | 后台文件/网络 provider。线程池只携带纯数据、取消控制与弱 Task 身份,不持有 VM。 |
| [src/runtime/mod.rs](../src/runtime/mod.rs) | 运行时模块入口。VM 生命周期、API、源程序组织、事件与异步任务在这里组合。 |
| [src/runtime/tasks.rs](../src/runtime/tasks.rs) | owner/key 管理的异步任务与协作式源函数调度。弱索引用于查找,回调、等待对象和协程帧是托管图中的真实边。 |
| [src/runtime/vm_sequence.rs](../src/runtime/vm_sequence.rs) | 构造共享 VM 调用序列与同帧循环驱动的字节码。 |
| [src/shell/activity.rs](../src/shell/activity.rs) | 有界 HTTP 活动记录。保存请求/响应的可显示预览供 Network 工具读取,不负责执行请求。 |
| [src/shell/app.rs](../src/shell/app.rs) | winit 原生应用生命周期和窗口事件入口。这里创建窗口、发布逻辑视口、转发输入,并把不可变文档画到原生表面。 |
| [src/shell/clipboard.rs](../src/shell/clipboard.rs) | 把 UTF-8 文本读写适配到当前平台的系统剪贴板命令。 |
| [src/shell/developer_tools.rs](../src/shell/developer_tools.rs) | Console/Sources/Network/Settings 的宿主交互协调与绘制状态。 |
| [src/shell/developer_tools/console.rs](../src/shell/developer_tools/console.rs) | 绘制 Console 输入、结果历史和对应命中区域。 |
| [src/shell/developer_tools/network.rs](../src/shell/developer_tools/network.rs) | 绘制已观察到的 HTTP 活动、请求列表与详情。 |
| [src/shell/developer_tools/painter.rs](../src/shell/developer_tools/painter.rs) | 开发工具画面的绘制辅助:文字行、按钮、分隔线和滚动条。 |
| [src/shell/developer_tools/selection.rs](../src/shell/developer_tools/selection.rs) | 多行文本选择和光标导航。选择端点映射到合法字符边界,避免把 UTF-8 字节中间当成一个字符。 |
| [src/shell/developer_tools/settings.rs](../src/shell/developer_tools/settings.rs) | 绘制宿主设置页面和主题选择区域。 |
| [src/shell/developer_tools/sources.rs](../src/shell/developer_tools/sources.rs) | 绘制 AST、当前源码快照和云文件视图。 |
| [src/shell/mod.rs](../src/shell/mod.rs) | 原生宿主模块入口。窗口、管理控件、系统适配和开发工具状态与用户业务界面分开维护。 |
| [src/shell/native_chrome.rs](../src/shell/native_chrome.rs) | macOS 原生标题栏适配。定位系统窗口按钮并沿用用户的标题栏双击设置。 |
| [src/shell/sidebar.rs](../src/shell/sidebar.rs) | 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。 |
| [src/shell/sources.rs](../src/shell/sources.rs) | 云文件浏览的 provider 抽象与工作区状态。默认 provider 未连接,不假装已有外部文件。 |
| [src/shell/system_ui/interaction.rs](../src/shell/system_ui/interaction.rs) | 即时绘制控件的跨帧交互状态:稳定 WidgetId、悬停、按下捕获、键盘焦点和释放激活。 |
| [src/shell/system_ui/mod.rs](../src/shell/system_ui/mod.rs) | 宿主管理控件的共享组件入口。主题、绘制原语和跨帧交互状态分离,用户 Endor 页面不依赖这里的控件树。 |
| [src/shell/system_ui/theme.rs](../src/shell/system_ui/theme.rs) | 宿主控件的颜色与状态调色。根据背景、强调色和交互状态生成可读颜色,不改变业务状态。 |
| [src/shell/system_ui/widgets.rs](../src/shell/system_ui/widgets.rs) | 用统一主题绘制宿主管理控件,并同步注册交互目标。 |
| [src/shell/tabs.rs](../src/shell/tabs.rs) | 发布页面标签的数据结构。保留稳定页面身份、标题、选中状态和开发工具类别;页面的托管所有权由模型层管理。 |
## 其他文件
| 文件 | 本次改动及目的 |
| --- | --- |
| [src/bridge/ui_bridge/start_page.endor](../src/bridge/ui_bridge/start_page.endor) | 解释卡片源测量/背景、页面工厂、纵横布局与滚动组合、共享计数和场景位置,便于从真实 Endor 页面理解宿主边界。 |
| [scripts/build-macos-app.sh](../scripts/build-macos-app.sh) | 说明 macOS 构建环境前提、失败退出、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 |
| [scripts/render-uml.mjs](../scripts/render-uml.mjs) | 说明独立文档依赖、DOM/Canvas 适配、错误图检查与 SVG/PNG 产物生成,避免混淆运行依赖与文档工具。 |
| [Cargo.toml](../Cargo.toml) | 解释每个依赖负责的能力、Aster 本地路径依赖、macOS 条件依赖与 release 优化配置。 |
| [docs/code-comments.md](code-comments.md) | 更新本逐文件说明、示例索引和实际核对方式,使本次注释范围与可验证结果对应。 |
## Endor 示例逐文件说明
每个示例的类、方法及关键逻辑旁增加中文说明。`page.endor` 和 `hello.endor` 被测试固定断言源码行号,因此使用行尾注释,保留原始行数;其余示例使用独立注释行,程序符号与字面量不变。
| 文件 | 注释解释的场景与目的 |
| --- | --- |
| [examples/async-file.endor](../examples/async-file.endor) | 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。 |
| [examples/async-network.endor](../examples/async-network.endor) | 异步 HTTP(S) 文本 GET:Console 调用 fetcher.fetch(url),沿用 owner/key 替换、状态与成功回调协议。 |
| [examples/await-file.endor](../examples/await-file.endor) | 把 callback-free 文件 Task 与 ui.spawn 协作函数组合:await 保存执行位置,等待完成后继续,不阻塞 VM worker。 |
| [examples/box-layout.endor](../examples/box-layout.endor) | 盒模型组合:容器与 Page/相机独立,border-box 尺寸、padding、margin 和对齐分别参与布局。 |
| [examples/campus-lms.endor](../examples/campus-lms.endor) | 多页面校园演示:七个普通托管 Page 共享 CampusCourse/CampusWork 业务对象,所有课程与学生数据均虚构。 |
| [examples/classes.endor](../examples/classes.endor) | 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。 |
| [examples/constructor-contracts.endor](../examples/constructor-contracts.endor) | 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。 |
| [examples/cooperative-tasks.endor](../examples/cooperative-tasks.endor) | 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。 |
| [examples/events.endor](../examples/events.endor) | 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。 |
| [examples/exit-lifecycle.endor](../examples/exit-lifecycle.endor) | 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。 |
| [examples/field-contracts.endor](../examples/field-contracts.endor) | 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。 |
| [examples/flex-constraints.endor](../examples/flex-constraints.endor) | flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。 |
| [examples/flex.endor](../examples/flex.endor) | 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。 |
| [examples/fonts.endor](../examples/fonts.endor) | 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。 |
| [examples/function-types.endor](../examples/function-types.endor) | Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。 |
| [examples/gc-lifecycle.endor](../examples/gc-lifecycle.endor) | 托管对象循环与等待帧回收:先记 ui.stats().objects,再调用 mountCycle,观察 phase 与 pending.value.status()。 |
| [examples/global-dependencies.endor](../examples/global-dependencies.endor) | 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。 |
| [examples/hello.endor](../examples/hello.endor) | 解释辅助 heading、兼容 main 入口、局部业务引用和原生布局,保留源码位置夹具。 |
| [examples/layout-cache.endor](../examples/layout-cache.endor) | 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。 |
| [examples/layout-negotiation.endor](../examples/layout-negotiation.endor) | 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。 |
| [examples/main.endor](../examples/main.endor) | 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。 |
| [examples/managed-types.endor](../examples/managed-types.endor) | 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。 |
| [examples/method-contracts.endor](../examples/method-contracts.endor) | 方法覆盖遵守参数可更宽、返回可更具体的规则。 |
| [examples/mount-lifecycle.endor](../examples/mount-lifecycle.endor) | 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。 |
| [examples/native-signatures.endor](../examples/native-signatures.endor) | 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。 |
| [examples/native-subclasses.endor](../examples/native-subclasses.endor) | 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。 |
| [examples/owned-tasks.endor](../examples/owned-tasks.endor) | owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。 |
| [examples/page.endor](../examples/page.endor) | 解释顶层脚本创建文字/矩形、Page 与 render 的关系,保留四行结构树夹具。 |
| [examples/presentable.endor](../examples/presentable.endor) | 普通业务对象显式实现 Presentable 后,ui.present 可调用其 present(window) 生成视觉对象。 |
| [examples/property-bindings.endor](../examples/property-bindings.endor) | 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。 |
| [examples/scene-viewports.endor](../examples/scene-viewports.endor) | 一个二维世界可被多个有限视口观察,Scene2D 的对象位置共享,默认 Camera2D 各自独立。 |
| [examples/scene3d.endor](../examples/scene3d.endor) | 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。 |
| [examples/scroll-axis.endor](../examples/scroll-axis.endor) | 滚动轴策略影响可滚动方向与内容测量约束:vertical 限宽并允许长高,horizontal 允许横向展开。 |
| [examples/scroll.endor](../examples/scroll.endor) | 同一 ScrollBlock 在两个原生窗口中展示:内容业务对象共享,滚动偏移按 Mount 独立保存。 |
| [examples/source-hit.endor](../examples/source-hit.endor) | 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。 |
| [examples/source-layout.endor](../examples/source-layout.endor) | 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。 |
| [examples/source-paint.endor](../examples/source-paint.endor) | 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。 |
| [examples/student-grades.endor](../examples/student-grades.endor) | 学生成绩中心为纯内存演示,记录均虚构,不连接学校数据库。 |
| [examples/tab-placement.endor](../examples/tab-placement.endor) | 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。 |
| [examples/tabs.endor](../examples/tabs.endor) | 代码与原生侧栏都操作真实 Page 对象;activate/close/open 改变展示关系,业务对象可继续被全局引用保留。 |
| [examples/visual-protocols.endor](../examples/visual-protocols.endor) | 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。 |
| [examples/window.endor](../examples/window.endor) | 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。 |
| [examples/windows.endor](../examples/windows.endor) | 两个原生窗口共用一个 Aster VM 和 count 业务对象,各自拥有独立 Page 与 Mount。 |
| [examples/workspace.endor](../examples/workspace.endor) | 静态工作台组合示例:所有指标、进度与活动记录均为演示数据,不读取任务系统或网络。 |
| [examples/world-ui.endor](../examples/world-ui.endor) | 世界空间 UI:普通 Endor 树先在独立 UI 分辨率中布局/绘制,再作为 Panel3D 平面的纹理参与三维深度。 |
| [examples/wrapped-text.endor](../examples/wrapped-text.endor) | 多窗口共享长段落:同一文字对象在每个 Mount 按当前宽度独立换行,后继位置跟随实际高度。 |
## 核对方式与边界
- 与本次分支起点及最新主线比较,逐文件核对移除注释后的文本;Rust/Endor 另做词法扫描,完整保留字符串/原始字符串内容,确认新增说明没有进入程序字面量。
- 使用 rustfmt 的 `--emit stdout`、`skip_children=true` 逐文件做 Rust 语法解析,丢弃输出,不格式化文件、不运行 Cargo、不生成编译产物。
- 对工具脚本使用 `node --check` 和 `sh -n`,只检查语法,不执行渲染、构建或打包;Cargo 配置另以 TOML 解析核对。
- 检查固定行号的示例夹具、测试依赖的源码替换片段和 `git diff --check`。这些检查能证明注释外代码保持一致及语法可解析,不能替代运行时回归结果。
- 本次未执行编译、GUI 回归、Clippy 或 rustdoc;需要构建的验证应在用户已确认的远程 runner 上运行,不能把未执行步骤写成通过。实际交付结果记录在 PR 中。
+63
View File
@@ -0,0 +1,63 @@
# 场景交互性能研究
## 结论
性能问题有可验证的解决路径,但单改 Aster 字典不能让当前世界 UI 达到稳定 60 FPS。本 PR 交付可重复基准和研究记录;去除字符串键比较临时分配的改动仍在独立 Aster 分支,未随本 PR 集成;面板纹理缓存实验因当前示例收益不足而撤回。没有实现 GPU 后端或宣称完成性能目标。
## 方法
在 Apple M4 Pro、macOS 15.6 本机使用 release 构建,固定 1024×800 逻辑视口、1× / 2× 像素比例,分别加载 world-ui 和 scene3d 示例。每组执行 33 次真实 scene-orbit 运行时分发,前 3 次作为预热,统计后 30 次的均值、P50、P95。每次先测分发到文档更新,再测变化后的软件绘制,最后测相同显示列表的缓存绘制。没有运行 Console 编译来代替原生事件。
```sh
cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1
```
这是无窗口的运行时与软件渲染基准,包含回调、快照、布局和运行时收尾,但不包含本地 HTTP、OS 事件排队、窗口呈现或输入到显示延迟。阶段耗时不能简单当作实测 FPS。时间是本机样本,不是跨设备保证;基准默认 ignored,普通回归不以绝对耗时断言通过。
## 对照结果
世界 UI、2× 像素比例,单位 ms:
| 条件 | 更新均值 | 更新 P95 | 变化绘制均值 | 变化绘制 P95 |
| --- | ---: | ---: | ---: | ---: |
| 原始 Aster | 8.456 | 8.843 | 18.704 | 20.742 |
| 借用字符串键比较 | 6.842 | 7.226 | 18.598 | 20.665 |
| 再加面板纹理缓存实验 | 6.823 | 7.053 | 18.410 | 20.482 |
去掉字符串键比较的分配后,本组更新均值减少约 19%,绘制基本不变。面板缓存的绘制收益约 1%,不足以证明值得在本次增加缓存与失效复杂度,因此撤回。
作为对照,普通 scene3d 示例在 2× 下更新约 0.94 ms、变化绘制约 6.15 ms;它的视口尺寸、覆盖像素和 UI 复杂度不同,不能据此把两个示例的全部差值都归给某一层。
## Aster 发现与保留的修改
`EndorDictObject` 用插入有序的数组存储,`dict_position` 逐项调用 `key_equal`。原实现每次比较都会把内联文本或托管 String 转成新的 String;查找名称时会重复分配和复制候选键。
本次只改为借用字符串切片比较。保留线性查找、原 key_equal 分支、对象检查、只读依赖观察、键的插入身份与顺序、写入验证和引用计数流程。新增测试覆盖空文本、普通文本、Unicode、托管 / 内联字符串等价替换、删除和引用释放。
没有把通用字典直接改成 HashMap,也没有绕过 heap.replace_payload 的事务性边验证。前者还需处理托管键相等 / 哈希与顺序语义;后者涉及 GC、失败原子性和只读保护。它们不属于本次已经证明安全的改动。
## 尚未解决的主要成本
1. 世界 UI 的摄像机变化后,当前统计仍为 measured=8、arranged=88;普通 3D 为 measured=0、arranged=14。数据证明相机更新没有彻底绕开排版。
2. 3D 变化帧仍在窗口线程逐像素进行 CPU 绘制。当前世界 UI 的绘制本身已超过 60 Hz 的 16.7 ms 预算,其他 CPU 优化不能消除这个事实。
3. 原生连续输入仍逐项经本地 HTTP 发送,有界队列不等于按显示帧合并;真实排队和呈现延迟需要另外测量。
## 后续实施顺序与条件
1. **相机更新专用路径。** 复用已有 Mount 几何、UI 排版和绘制结果,仅更新视图 / 投影、可见性和命中映射。必须检查源 measure / arrange / paint 的实际读取依赖;源函数若读取相机,应继续正确失效。不能无条件跳过所有布局或绘制。验收:不依赖相机的 UI 在旋转时零测量、零重新排版,依赖相机的源协议回归仍通过。
2. **按帧收集连续输入。** 原生操作直接进入进程内通道,连续手势按捕获会话 / 目标与顺序合并;点击、页面切换和运行时替换保持边界。外部 HTTP API 保留严格版本校验。验收包括缩放锚点、非交换手势顺序、嵌套捕获与过期会话,不能只检查最终数值。
3. **GPU 合成与独立渲染。** 现有不可变网格、变换、相机、深度和 UV 数据可以成为渲染输入;VM 对象留在运行时线程。GPU 应直接向窗口表面呈现,同时合成 2D / 3D 内容,避免每帧 GPU→CPU 回读后再交给 softbuffer。跨线程队列只保留最新可呈现快照;纹理、缓冲与缓存均需资源预算和正确失效。
建议验收目标:固定示例 2× 下相机更新 P95 ≤2 ms、GPU 绘制 P95 ≤8 ms,另外记录真实窗口的帧间隔和输入到呈现延迟。目标尚未达到,也没有用无窗口基准宣称原生 60 FPS。
## 检查说明
Aster 全工作区测试发现 compiler 的 classes 测试中两项解析失败(第三行 expected EOF),以及 core 的 managed_coroutine_hard_budget_accumulates_and_error_releases_frames 指令预算失败。将对象实现恢复为未优化的基线后,这三项失败均能复现,说明不是本次借用字符串比较引入;不把全工作区标为全绿。对象层测试通过,Aster 全工作区 Clippy / 格式通过;GUI 为 186 项通过、1 项默认忽略的基准,GUI Clippy / 格式通过。该基准已经另行在 release 模式实际执行。
本次没有修改用户指向的 builtins.rs 第 176 行(该处是函数对象定义)。实际热路径修改位于 Aster 的 store_builtins.rs。
## PR #6 主线兼容修复
2026-11-12:合入最新 GUI main,保留 main 的 Aster f3e1670,而非旧性能分支 fd247ac。旧引用缺少 GUI 目前使用的精确读取依赖接口,不能为性能实验回退。上文数值是原有实验记录,不代表当前主线已经采用借用键优化。本 PR 的最终差异仅包括本研究记录和默认 ignored 的基准;Aster 优化需单独更新其分支并审阅后再集成。PR 标题中的 WIP 将移除,交付范围为可运行基准,不承诺上述后续性能目标。
本次兼容修复检查:GUI `cargo test --locked` 为 186 passed / 1 ignored;Clippy(全部 target,警告视为错误)、格式和差异空白检查通过。最初沙箱内 9 项 HTTP 测试因不能监听本地端口失败;允许回环网络后全套重跑通过。本次未重跑历史 release 性能采样,也未修改 Aster。
+7
View File
@@ -1,18 +1,25 @@
// 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。
// owner/key 相同的新读取替换旧任务;失败只记录 Task.error,不执行 apply。
// 持有三个显示对象与当前任务;task 初值占位,第一次 read 后替换为真实 Task。
class FileReader {
output: TextBlock
problem: TextBlock
status: TextBlock
task: Object
// 仅成功时调用,result 在成功回调期间已可读;写原 TextBlock 触发显示刷新。
function apply(self: FileReader): Unit {
self.output.text = self.task.result()
}
// 以 self 为 owner、file 为 key 启动读取,清空旧输出,并把状态/错误文字绑定到当前任务。
function read(self: FileReader, path: String): Unit {
self.task = ui.readText(self, "file", path, self.apply)
self.output.text = ""
// Task.status/error 是零参数只读方法,可直接作为绑定计算;换任务后绑定也随本次调用更新。
self.status.bind("text", self.task.status)
self.problem.bind("text", self.task.error)
}
}
// 先建立界面对象,保持 reader 为可从 Console 操作的全局业务入口。
output = TextBlock("尚未读取文件")
problem = TextBlock("")
status = TextBlock("")
+7
View File
@@ -1,21 +1,28 @@
// 异步 HTTP(S) 文本 GET:Console 调用 fetcher.fetch(url),沿用 owner/key 替换、状态与成功回调协议。
// 完整响应保存为 String,界面只显示完成提示,避免把可能很大的响应放进文字控件。
// response 是结果业务存储;output/problem/status 仅呈现任务进度与提示。
class TextFetcher {
output: TextBlock
problem: TextBlock
status: TextBlock
task: Object
response: String
// 成功后取出完整 UTF-8 结果,写 response 供 Console 查询;provider 失败不会进入此方法。
function apply(self: TextFetcher): Unit {
self.response = self.task.result()
self.output.text = "请求完成;在 REPL 查看 fetcher.response"
}
// 同一 self/request 身份的新请求取消旧交付,重置旧内容并把显示绑定到新任务。
function fetch(self: TextFetcher, url: String): Unit {
self.task = ui.fetchText(self, "request", url, self.apply)
self.response = ""
self.output.text = ""
// 绑定读取当前 Task 方法,完成和失败由 worker 的任务状态变更传播。
self.status.bind("text", self.task.status)
self.problem.bind("text", self.task.error)
}
}
// 界面不自动访问网络,用户以 Console 明确指定 URL 后才创建任务。
output = TextBlock("尚未请求网络")
problem = TextBlock("")
status = TextBlock("")
+7
View File
@@ -1,24 +1,31 @@
// 把 callback-free 文件 Task 与 ui.spawn 协作函数组合:await 保存执行位置,等待完成后继续,不阻塞 VM worker。
// readText 的 file 与 spawn 的 workflow 使用不同 key,分别管理读取和等待流程。
// 保存 provider Task、协程 Task 和结果;两种任务都是托管对象,可以从 Console 独立查看。
class AwaitReader {
output: TextBlock
status: TextBlock
task: Object
file: Object
response: String
// await 在文件未完成时挂起并保留 self/操作数;恢复后只执行后半段一次,再把字符串作为 workflow.result。
function apply(self: AwaitReader): String {
self.response = await self.file
self.output.text = "读取完成;在 REPL 查看 reader.response 或 reader.task.result()"
return self.response
}
// 先创建读任务,再调度普通函数 apply;spawn 而非新 async 语法提供可暂停执行上下文。
function read(self: AwaitReader, path: String): Unit {
self.file = ui.readText(self, "file", path)
self.task = ui.spawn(self, "workflow", self.apply)
self.response = ""
self.output.text = "等待文件"
// 显示的是 workflow 状态,等待 provider 时它仍报告 running;结束后 result 可从 Task 或 response 读取。
self.status.bind("text", self.task.status)
}
}
output = TextBlock("在 REPL 调用 reader.read(绝对路径字符串)")
status = TextBlock("")
// 占位值只用于满足初始字段构造;在 read 内被真实 Task 替换,页面建立不自动读文件。
reader = AwaitReader(output,status,ui.state(0),ui.state(0),"")
page = Page()
page.title = "await 文件"
+10 -1
View File
@@ -1,8 +1,12 @@
// Containers compose independently of Page and of any camera.
// 盒模型组合:容器与 Page/相机独立,border-box 尺寸、padding、margin 和对齐分别参与布局。
// 同一文字共享内容,在左右不同有限分配中各自换行;外层 Overlay 负责叠放固定角标并裁剪溢出。
// 内部布局基于容器自己的约束;Page 和相机只提供外部展示/投影上下文。
// 文字 boxWidth=fill 占父分配的可用宽度,marginBottom 在文字边框盒外参与排布。
shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2)
shared.boxWidth = "fill"
shared.marginBottom = 8
// 左 Column 使用 fill、min/max 宽度限制和四边边框;非对称 paddingLeft 覆盖统一 padding。
left = Column(16, 12, TextBlock("Row / Column", 2), shared, Button("继续探索"))
left.boxWidth = "fill"
left.minWidth = 180
@@ -15,24 +19,29 @@ left.borderColor = "#CBD5E1"
left.paddingLeft = 24
left.crossAlign = "center"
// 右 Column 也引用同一 shared,但最大宽只有 240,因此对应 Mount 的换行高度可以不同。
right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared)
right.boxWidth = "fill"
right.maxWidth = 240
right.crossAlign = "end"
// Row 分配有限 760 主轴空间,扣 padding/gap 后在两侧 fill 子项之间分配并服从上限。
content = Row(24, 24, left, right)
content.boxWidth = 760
content.crossAlign = "start"
// 角标是 Overlay 的另一子项,alignSelf 与 margin 控制它在容器交叉轴上的位置。
badge = TextBlock("Endorphin · layout")
badge.marginRight = 12
badge.marginBottom = 12
badge.alignSelf = "end"
// 固定 800x400 的 Overlay 不按线性顺序挤开兄弟,overflow=clip 同时约束绘制和命中。
surface = Overlay(0, 0, content, badge)
surface.boxWidth = 800
surface.boxHeight = 400
surface.mainAlign = "end"
surface.overflow = "clip"
// Page 只提供展示入口,根容器自身的盒模型规则不依赖页面导航方式。
page = Page()
page.title = "嵌套盒模型"
page.render(surface)
+40 -5
View File
@@ -1,7 +1,9 @@
// Endor Campus — a multi-page learning environment inspired by Canvas LMS.
// Fictional student/course data. Paste the complete source into Console and Enter.
// All seven tabs are ordinary managed Pages. There is no server or Canvas login.
// 多页面校园演示:七个普通托管 Page 共享 CampusCourse/CampusWork 业务对象,所有课程与学生数据均虚构。
// 无服务器、账号登录或真实上传;提交/评分按钮只应用预设字段值,其他页通过绑定读同一状态。
// 作为一次完整输入提交;七页共用同一VM,会话重置后数据消失。
// 白色圆角卡片容器,继承 Column 的排版,只覆盖自身背景绘制。
class CampusPanel: Column {
// 按当前出现位置 geometry 创建一条背景命令,子项由展示遍历继续绘制。
function paint(self: CampusPanel, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand()
fill.kind = "roundedRect"
@@ -14,18 +16,23 @@ class CampusPanel: Column {
return DisplayList(ObjectList(fill))
}
}
// 通过额外 width 字段固定表格列的期望宽,保持成绩表头和行对齐。
class CampusCell: Column {
width: Number
// 先算子内容自然高度,再改新测量结果宽度,保留真实字体引起的高度。
function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult {
result = Column.measure(self, mount, constraints)
result.width = self.width
return result
}
}
// 页面内容里的演示导航栏,与应用自身的原生页面侧栏是两棵不同控件树。
class CampusSidebar: Column {
// 返回固定168x620期望尺寸,外层 ScrollBlock 承担超出区域的滚动。
function measure(self: CampusSidebar, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(168, 620)
}
// 为导航区创建深红背景,位置/大小来自当前Mount,文字等子项保持独立命令。
function paint(self: CampusSidebar, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand()
fill.kind = "roundedRect"
@@ -38,11 +45,14 @@ class CampusSidebar: Column {
return DisplayList(ObjectList(fill))
}
}
// Button 子类增加背景字段,固定导航按钮尺寸,并保留原生事件目标能力。
class CampusNav: Button {
background: String
// 导航按钮期望144x40,确保四个入口宽高一致。
function measure(self: CampusNav, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(144, 40)
}
// 取原生按钮背景命令改色/圆角;文字由后面 replace 后的子项绘制,不在此重复生成。
function paint(self: CampusNav, mount: Mount, geometry: Bounds): DisplayList {
fill = Button.paint(self, mount, geometry).commands.at(0)
fill.color = self.background
@@ -50,6 +60,7 @@ class CampusNav: Button {
return DisplayList(ObjectList(fill))
}
}
// 课程业务记录,grade/progress 被多个页面绑定读取,其余静态字段构建时直接读取。
class CampusCourse {
title: String
code: String
@@ -57,6 +68,7 @@ class CampusCourse {
grade: String
progress: String
}
// 实验状态集中在一个对象,course 指向同一课程模型,评分因此能影响全站课程摘要。
class CampusWork {
status: String
score: String
@@ -65,12 +77,14 @@ class CampusWork {
todo: String
result: String
course: CampusCourse
// 模拟提交只改状态、文件名提示、待办和结果文字,不访问磁盘或网络。
function submit(self: CampusWork, event: Event): Unit {
self.status = "已提交 · 等待评分"
self.submission = "lab01.endor · 已提交示例内容"
self.todo = "已完成本次实验提交"
self.result = "示例提交成功,可以切到作业中心查看同一条记录。"
}
// 应用预设92分和反馈,并更新所属课程成绩/进度;绑定让仪表盘、课程、成绩页同步观察。
function assess(self: CampusWork, event: Event): Unit {
self.status = "已评分"
self.score = "92 / 100"
@@ -81,6 +95,7 @@ class CampusWork {
self.course.progress = "已完成 4 / 6 个学习单元"
self.result = "已应用预设评分;仪表盘、课程主页和成绩总览同步更新。"
}
// 恢复初始实验与课程状态,保留所有原对象和绑定,便于反复演示。
function reset(self: CampusWork, event: Event): Unit {
self.status = "未提交"
self.score = "— / 100"
@@ -92,20 +107,25 @@ class CampusWork {
self.result = "已恢复实验、课程成绩和学习进度的示例状态。"
}
}
// 用统一包装构造表格单元格;传入的content是已有Block引用。
function campusCell(content: Block, width: Number): Block {
return CampusCell(0, 0, content, width)
}
// 显式事件适配器把click的Event与Page.open可选Window参数分开,不能直接把Event误当窗口。
class CampusTarget {
page: Page
// Consume the click Event here; Page.open's optional argument is a Window.
// 事件参数在适配器这一层消化,Page.open 只接收其声明的可选 Window。
// 消费事件但不传给Page.open;无参open表示在合适窗口打开/激活目标页。
function open(self: CampusTarget, event: Event): Unit { self.page.open() }
}
// 为普通按钮创建持有目标Page的适配器,并把其绑定方法注册为click回调。
function campusLink(label: String, target: Page): Button {
button = Button(label)
action = CampusTarget(target)
button.on("click", action.open)
return button
}
// 构造带指定背景的导航按钮,替换原生文字子槽为白字,然后复用事件适配器连接目标页。
function campusNav(label: String, target: Page, background: String): Button {
button = CampusNav(label, background)
button.replace(0, TextBlock(label, 1, "#FFFFFF"))
@@ -113,6 +133,7 @@ function campusNav(label: String, target: Page, background: String): Button {
button.on("click", action.open)
return button
}
// 用目标Page和四个色调参数构建整块导航;不同页面复用目标资源但用不同高亮背景。
function campusNavigation(home: Page, course: Page, assignments: Page, grades: Page, homeTone: String, courseTone: String, workTone: String, gradeTone: String): Block {
return CampusSidebar(12, 10,
TextBlock("Endor", 2, "#FFFFFF"),
@@ -125,6 +146,7 @@ function campusNavigation(home: Page, course: Page, assignments: Page, grades: P
TextBlock("2026 秋季学期", 1, "#E7BAC0"),
TextBlock("本地演示", 1, "#E7BAC0"))
}
// 把演示导航与标题/正文横排,外层有限ScrollBlock启用双轴,容纳固定宽度演示内容。
function campusShell(navigation: Block, title: String, subtitle: String, body: Block): Block {
scroll = ScrollBlock(1120, 680,
Row(24, 28, navigation,
@@ -135,6 +157,7 @@ function campusShell(navigation: Block, title: String, subtitle: String, body: B
scroll.axis = "both"
return scroll
}
// 卡片静态显示课程代码/标题/教师,grade 用绑定随评分变化,入口按钮引用真实课程Page。
function campusCourseCard(course: CampusCourse, page: Page, color: String): Block {
return CampusPanel(0, 0,
RectBlock(248, 44, 8, color),
@@ -145,6 +168,7 @@ function campusCourseCard(course: CampusCourse, page: Page, color: String): Bloc
ui.textOf(course, "grade", 2, color),
campusLink("进入课程", page)))
}
// 成绩表一行复用固定列宽单元格,当前grade为响应式文字,详情链接指向指定课程页。
function campusGradeRow(course: CampusCourse, target: Page): Block {
return Column(0, 12,
Row(0, 0,
@@ -154,6 +178,7 @@ function campusGradeRow(course: CampusCourse, target: Page): Block {
campusLink("课程详情", target)),
RectBlock(752, 1, 0, "#EDF0F3"))
}
// 组合课程概况/进度和静态学习单元;上方两个链接复用作业/成绩Page,不创建新页面。
function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String, moduleTwo: String, moduleThree: String, assignments: Page, grades: Page): Block {
return Column(0, 20,
Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)),
@@ -171,11 +196,13 @@ function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String
RectBlock(752, 1, 0, "#EDF0F3"),
TextBlock(moduleThree, 1, "#526372")))
}
// The shared object graph is created once; each Page projects those same objects.
// 共享对象图只构造一次,各Page的文字绑定投影同一业务字段。
// 只创建三份课程模型和一份实验模型,后续各页读取这些对象,避免页面间状态副本不一致。
campusProgramming = CampusCourse("计算机程序设计", "CS 101", "陈老师 · 周二 / 周四", "86.0%", "已完成 3 / 6 个学习单元")
campusMathematics = CampusCourse("高等数学 I", "MATH 101", "周老师 · 周一 / 周三", "91.0%", "已完成 4 / 6 个学习单元")
campusEnglish = CampusCourse("学术英语", "ENG 102", "许老师 · 周五", "89.0%", "已完成 3 / 5 个学习单元")
campusWork = CampusWork("未提交", "— / 100", "提交后可在此查看教师反馈。", "尚无提交内容", "1 项实验待提交", "这是一个本地提交与评分演示。", campusProgramming)
// 先创建全部七个Page,允许导航按钮互相引用;随后逐一render建立内容。
campusDashboard = Page()
campusDashboard.title = "Campus · 仪表盘"
campusProgrammingPage = Page()
@@ -190,10 +217,12 @@ campusGrades = Page()
campusGrades.title = "Campus · 成绩总览"
campusWorkPage = Page()
campusWorkPage.title = "实验 01 · 提交详情"
// 预建四种高亮导航树,课程三页共享course导航,Page切换时各自拥有独立Mount。
campusHomeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#A64350", "#852B38", "#852B38", "#852B38")
campusCourseNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#A64350", "#852B38", "#852B38")
campusAssignmentNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#A64350", "#852B38")
campusGradeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#852B38", "#A64350")
// 仪表盘展示课程卡和当前实验摘要,status/todo均从同一campusWork绑定读取。
campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎回来。这里是你的课程与近期学习安排。",
Column(0, 20,
Row(0, 16,
@@ -206,6 +235,7 @@ campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎
ui.textOf(campusWork, "todo", 1, "#7C8793"),
campusLink("打开作业详情", campusWorkPage)),
TextBlock("虚构课程与学生数据 · Endor 本地多页面演示", 1, "#8C959F"))))
// 程序设计课程附加实验入口;数学和英语页复用相同概况结构但读取不同课程模型。
campusProgrammingPage.render(campusShell(campusCourseNav, "计算机程序设计", "课程 / CS 101 / 主页",
Column(0, 16,
campusCourseBody(campusProgramming, "通过实际编程理解对象、函数与交互。", "01 对象与字段 · 已完成", "02 函数与方法 · 已完成", "03 事件与可视界面 · 进行中", campusAssignments, campusGrades),
@@ -214,6 +244,7 @@ campusMathPage.render(campusShell(campusCourseNav, "高等数学 I", "课程 / M
campusCourseBody(campusMathematics, "学习极限、微分与积分的基本方法。", "01 函数与极限 · 已完成", "02 导数及其应用 · 已完成", "03 积分方法 · 进行中", campusAssignments, campusGrades)))
campusEnglishPage.render(campusShell(campusCourseNav, "学术英语", "课程 / ENG 102 / 主页",
campusCourseBody(campusEnglish, "阅读学术文本,练习清晰的论证与表达。", "01 Academic reading · 已完成", "02 Evidence and arguments · 已完成", "03 Research writing · 进行中", campusAssignments, campusGrades)))
// 作业中心只有程序设计实验为共享动态模型,其余两项是固定演示文字。
campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本学期 / 全部课程 / 作业与提交",
Column(0, 20,
CampusPanel(20, 12,
@@ -227,6 +258,7 @@ campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本
CampusPanel(20, 12,
TextBlock("Reading response", 1, "#263444"),
TextBlock("ENG 102 · 已评分 · 88 / 100", 1, "#7C8793")))))
// 成绩总览复用课程grade绑定和实验score/feedback,评分无需逐页手动写UI。
campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学期 / 当前课程成绩",
Column(0, 20,
CampusPanel(20, 16,
@@ -242,12 +274,14 @@ campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学
Row(0, 24, TextBlock("实验 01:对象与函数"), ui.textOf(campusWork, "score", 1, "#315E86")),
ui.textOf(campusWork, "feedback", 1, "#6D7987"),
campusLink("打开作业与反馈", campusWorkPage)))))
// 三个操作按钮连接同一个实验对象的submit/assess/reset,均为真实源方法回调。
campusSubmitButton = Button("模拟提交实验")
campusSubmitButton.on("click", campusWork.submit)
campusAssessButton = Button("应用示例评分")
campusAssessButton.on("click", campusWork.assess)
campusResetButton = Button("恢复演示数据")
campusResetButton.on("click", campusWork.reset)
// 详情页组合要求、提交状态、操作按钮和反馈;所有输入仍只是预设本地状态转换。
campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函数", "CS 101 / 作业 / 提交详情",
Column(0, 20,
Row(0, 16, campusLink("返回课程", campusProgrammingPage), campusLink("全部作业", campusAssignments), campusLink("成绩总览", campusGrades)),
@@ -265,4 +299,5 @@ campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函
Row(0, 24, TextBlock("成绩与反馈"), ui.textOf(campusWork, "score", 2, "#315E86")),
ui.textOf(campusWork, "feedback", 1, "#6D7987")),
ui.textOf(campusWork, "result", 1, "#7C8793"))))
// 逐页render会改变当前活动页,最后显式选择仪表盘作为打开后的首页。
campusDashboard.activate()
+9
View File
@@ -1,28 +1,37 @@
// 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。
// 两个 textOf 共享同一模型字段,修改一次业务值会更新两个展示位置。
// 定义业务状态和公共事件入口,Number/String 字段在构造与赋值时按声明契约检查。
class Counter {
value: Number
lastEvent: String
// 保存实际事件种类,再通过 self 调用可覆盖的方法;接收者保持具体子类身份。
function activate(self: Counter, event: Event): Unit {
self.lastEvent = event.kind
self.change()
}
// 基类默认行为把值置为 10,子类可复用入口并替换这部分策略。
function change(self: Counter): Unit {
self.value = 10
}
}
// 继承 Counter 的字段与 activate,仅替换 change;构造参数仍按继承字段顺序传入。
class DemoCounter: Counter {
// 覆盖把值设为 12,演示从继承方法内部发起的调用也会选中实际接收者的方法。
function change(self: DemoCounter): Unit {
self.value = 12
}
}
// 创建唯一模型,之后所有绑定和按钮回调都指向它,无需把 GUI 状态复制到另一份存储。
model = DemoCounter(9, "等待点击")
first = ui.textOf(model, "value", 2)
second = ui.textOf(model, "value", 2)
eventName = ui.textOf(model, "lastEvent")
button = Button("调用继承方法,再分派到覆盖方法")
// 注册绑定方法,运行时将 click 的 Event 传给 activate;最后 Page.render 才把对象树显示出来。
button.onClick(model.activate)
page = Page()
page.render(Column(24, 16, TextBlock("Endor class:继承、self 与动态方法分派", 2), Row(0, 16, first, second), button, eventName))
+9
View File
@@ -1,11 +1,20 @@
// 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。
// 省略 RectBlock 的 radius/color 不应使字符串 tag 被错当成原生颜色参数。
// 在原生矩形后增加 tag 字段;位置构造仍保留 width/height 及可选圆角/颜色规则。
class TaggedRect: RectBlock { tag: String }
// 在可变数量 Block 子项之后接收源 tag,子项列表与字段不混用。
class TaggedColumn: Column { tag: String }
// 保留原生事件 kind,再追加业务字符串字段。
class TaggedEvent: Event { tag: String }
// 原生列表成员之后追加 tag,列表元素仍可保存不同托管类型。
class TaggedList: ObjectList { tag: String }
// 将需要更新的矩形作为强业务引用保存,按钮回调只修改它的颜色。
class Actions {
rect: TaggedRect
// 写原对象的 color,挂载显示由依赖跟踪刷新,不需要重新构造整个页面。
function recolor(self: Actions): Unit { self.rect.color = "#237A45" }
}
// 这里刻意省略原生可选项,用末尾字符串初始化子类字段;后面的列表/Column 做对应可变参数演示。
rect = TaggedRect(180, 60, "省略 radius/color 后的字段")
event = TaggedEvent("click", "事件字段")
items = TaggedList(rect, event, "列表成员之后的字段")
+7
View File
@@ -1,5 +1,10 @@
// 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。
// 这里刻意重复增量语句,方便看到暂停点;取消不回滚已经发生的 steps 写入,单个原生调用仍是原子边界。
// 每个实例有独立 steps 状态,两个任务不会把局部帧或计数存储混在一起。
class CooperativeWork {
steps: Object
// 普通零参数方法被 spawn 调度后可跨量子继续;每条 increment 更新同一实例的计数。
// 连续语句无需手工 yield,VM 按累计预算暂停,恢复时不会从第一条重放。
function run(self: CooperativeWork): Unit {
self.steps.increment()
self.steps.increment()
@@ -67,10 +72,12 @@ class CooperativeWork {
self.steps.increment()
}
}
// 创建两个独立 owner,虽然 key 都叫 work,也不会相互替换;owner/key 组合才是任务身份。
left = CooperativeWork(ui.state(0))
right = CooperativeWork(ui.state(0))
leftTask = ui.spawn(left,"work",left.run)
rightTask = ui.spawn(right,"work",right.run)
// 状态文字绑定到 Task.status,业务进度可在 Console 查询 left/right 的 steps.value。
leftStatus = TextBlock("")
rightStatus = TextBlock("")
leftStatus.bind("text",leftTask.status)
+10
View File
@@ -1,3 +1,6 @@
// 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。
// EventLog 用一个递增序列记录实际次序;改变监听器/传播标记可对比跳过与停止的语义。
// 保存各阶段序号与最近 phase;count 是共享状态,其他字段直接写在本模型中。
class EventLog {
count: Object
captureStep: Number
@@ -5,24 +8,29 @@ class EventLog {
targetStep: Number
bubbleStep: Number
phase: String
// 祖先捕获阶段先递增序列,再保存 captureStep 和 Event.phase。
function capture(self: EventLog, event: Event): Unit {
self.count.increment()
self.captureStep = self.count.value
self.phase = event.phase
}
// 目标监听器的执行点;它排在目标类型 onEvent 方法之后。
function target(self: EventLog, event: Event): Unit {
self.count.increment()
self.targetStep = self.count.value
self.phase = event.phase
}
// 祖先冒泡阶段记录最后序号,未被 stopPropagation 时才到达。
function bubble(self: EventLog, event: Event): Unit {
self.count.increment()
self.bubbleStep = self.count.value
self.phase = event.phase
}
}
// 覆盖目标方法,比额外 on(click,...) 监听器更早处理目标阶段事件。
class EventButton: Button {
model: EventLog
// 把模型序列递增后写 methodStep,供断言与 capture/target/bubble 比较。
function onEvent(self: EventButton, event: Event): Unit {
self.model.count.increment()
self.model.methodStep = self.model.count.value
@@ -32,6 +40,8 @@ class EventButton: Button {
model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击")
button = EventButton("捕获 → 对象方法 → 监听器 → 冒泡", model)
root = Column(24, 16, TextBlock("同一对象系统中的事件分派", 2), button, ui.textOf(model.count, "value"), ui.textOf(model, "phase"))
// capture 选项显式选择捕获;不带选项的祖先监听器默认在冒泡阶段执行。
// 返回的令牌可 disconnect,同次派发中断开尚未执行的监听器也应生效。
capture = root.on("click", model.capture, "capture")
target = button.on("click", model.target)
bubble = root.on("click", model.bubble)
+7
View File
@@ -1,17 +1,24 @@
// 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。
// 退出先取消任务、关闭窗口,再进行允许的卸载回调;已停止 VM 的清理不能重新进入源代码。
// 在原生文字类型上增加共享日志,用来观察每个 Mount 的卸载通知。
class ExitText: TextBlock {
log: Object
// 每次真实卸载递增同一 log;普通重绘不调用,重复 shutdown 也不应重复调用。
function onUnmount(self: ExitText, mount: Mount): Unit {
self.log.increment()
}
}
log = ui.state(0)
// 文字业务对象只创建一次,Page 与另一窗都引用它,但两边有各自的 Mount 生命周期。
watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log)
page = Page()
page.title = "退出生命周期"
page.render(Column(24,16,watched,ui.textOf(log,"value")))
// 创建同 VM 内的另一原生窗口;迁移 other 后仍保留 watched 的业务身份。
second = Window()
second.setTitle("Exit lifecycle secondary")
other = Page()
other.render(watched)
other.moveToWindow(second)
// 应用级长定时任务用于观察退出会取消尚未完成的工作,不让退出过程等满 30 秒。
pending = ui.sleep(ui,"pending",30000)
+6
View File
@@ -1,12 +1,18 @@
// 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。
// 绑定显示读取该字段,GUI 回调与 Console 赋值受同一契约约束。
// 增加 value:Number;构造时先传原生 text,再传源字段值。
class CounterText: TextBlock {
value: Number
// 收到 click 后把已有数值改为 10,依赖文字随之更新,不修改原生 text 内容。
function change(self: CounterText, event: Event): Unit {
self.value = 10
}
}
// 声明 target 必须是 Block,可接收 CounterText,因为它是 TextBlock 的子类。
class Model {
target: Block
}
// 构造唯一受检对象,Model、事件回调和 textOf 均引用它。
counter = CounterText("共享的原生子类对象", 9)
model = Model(counter)
button = Button("把 Number 字段改成 10")
+9
View File
@@ -1,11 +1,17 @@
// flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。
// 增长达到上限或收缩达到下限后,剩余空间/缺额重新分给其他尚可调整的项,固定项保持 60。
// 给 Row 一个有限 340x60 的期望尺寸,便于精确观察分配数值。
class Strip: Row {
// 源测量返回固定尺寸;后续排列仍使用原生 Row 的 padding/gap 与 flex 规则。
function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(340, 60)
}
}
// 分别持有两个可调整矩形,按钮切换增长/收缩策略,而不是手动写最终 bounds。
class Actions {
first: RectBlock
second: RectBlock
// 两项 basis=100,按1:3分增长;首项 max=105 触顶后,多余增长转给第二项。
function grow(self: Actions): Unit {
self.first.flexMin = 0
self.first.flexMax = 105
@@ -14,6 +20,7 @@ class Actions {
self.first.flex = 1
self.second.flex = 3
}
// 两项 basis=200 造成缺额,以 flexShrink*basis 分摊;首项 min=150 后第二项继续收缩到90。
function shrink(self: Actions): Unit {
self.first.flexMax = 300
self.first.flexMin = 150
@@ -25,6 +32,7 @@ class Actions {
self.second.flexShrink = 1
}
}
// 自然宽20/40与basis可不同;fixed不设置权重,保持原始60宽。
first = RectBlock(20, 20, 4, "#A4C8F0")
second = RectBlock(40, 20, 4, "#A6D8B2")
fixed = RectBlock(60, 20, 4, "#E8C990")
@@ -33,6 +41,7 @@ grow = Button("增长:上限后重新分配")
grow.onClick(actions.grow)
shrink = Button("收缩:最小尺寸后重新分配")
shrink.onClick(actions.shrink)
// 先初始化增长策略再挂载,首次发布即采用105/135/60,不需等待按钮点击。
actions.grow()
strip = Strip(10, 10, first, second, fixed)
page = Page()
+7
View File
@@ -1,8 +1,14 @@
// 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。
// paragraph 只有一份业务内容,但在两个 Row 的 Mount 中各自按真实分配宽度测量/换行。
// 只持有首个 Row 的 filler,修改它的权重来间接改变同排文字分配。
class Actions {
filler: RectBlock
// 将 filler 权重恢复为 1,与 paragraph=1 近似等分可用空间。
function balanced(self: Actions): Unit { self.filler.flex = 1 }
// filler 权重改为 3,paragraph 在首排获得剩余空间的四分之一,换行高度随之增加。
function narrow(self: Actions): Unit { self.filler.flex = 3 }
}
// 共享文字的 flex=1 是业务布局策略,具体几何与自然尺寸缓存仍属于各 Mount。
paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。")
paragraph.flex = 1
filler = RectBlock(20, 20)
@@ -12,6 +18,7 @@ balanced = Button("等比例分配")
balanced.onClick(actions.balanced)
narrow = Button("文字只占四分之一")
narrow.onClick(actions.narrow)
// 首排两个可伸缩子项,次排只有文字 flex 与固定 300px 兄弟,因此首排按钮不会改次排宽度。
first = Row(10, 10, paragraph, filler)
second = Row(10, 10, paragraph, RectBlock(300, 20))
page = Page()
+6
View File
@@ -1,5 +1,11 @@
// 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。
// 布局使用实际字形宽度,iiii 与 WWWW 后的色块位置应不同,不能用字符数量估算宽度。
// 字号2与独立颜色用于标题,中文是否可见取决于运行环境可用CJK字体。
let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422")
// 同一文字控件包含中英文和重音字符,用于观察字体回退与基线。
let detail = ui.text("中文 English 123 — café", 1, "#606060")
// 两组相同尺寸色块跟在窄/宽字符后,12px gap 保持一致,差异来自真实文字测量。
let row = ui.row(0, 12, ui.text("iiii"), ui.rect(24, 20, 4, "#DCE8FF"), ui.text("WWWW"), ui.rect(24, 20, 4, "#DCE8FF"))
// 根Column统一padding和间距,render后这些对象才有具体Mount与绘图命令。
let page = ui.page()
page.render(ui.column(24, 12, title, detail, row))
+9
View File
@@ -1,13 +1,22 @@
// Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。
// 参数和返回注解在真实调用边界校验,事件注册同样使用这些签名。
// Number 输入原样返回,用于说明参数类型在进入函数体前检查。
function identity(value: Number): Number { return value }
// 接受 Function 值并零参数调用,结果仍需符合本函数的 Number 返回契约。
function callNumber(callback: Function): Number { return callback() }
// 接受声明 Visual 能力的对象,返回 Block;调用时两侧契约都要成立。
function visual(value: Visual): Block { return value }
// 保留共享 state,read 作为普通绑定函数使用,click 作为接收 Event 的事件回调。
class Actions {
state: Object
// 读取模型值而不修改业务状态,保存 actions.read 时接收者已绑定。
function read(self: Actions): Number { return self.state.value }
// 事件入口可忽略 Event 内容,但参数契约仍要求它是 Event,再修改同一 state。
function click(self: Actions, event: Event): Unit { self.state.increment() }
}
state = ui.state(0)
actions = Actions(state)
// 保存绑定方法对象,之后无参调用仍使用 actions 作为 self;不是重新按字符串查找对象。
savedFunction = actions.read
initial = identity(callNumber(savedFunction))
button = Button("调用带类型检查的对象方法")
+10 -4
View File
@@ -1,12 +1,13 @@
// Record ui.stats().objects, call mountCycle(), then inspect phase.value and
// pending.value.status(). Close the tab: the referenced Block still waits.
// releaseCycle() removes both external references; the next objects query
// should return to the same baseline before the five-minute wait finishes.
// 托管对象循环与等待帧回收:先记 ui.stats().objects,再调用 mountCycle,观察 phase 与 pending.value.status()。
// 关闭临时页仍可因业务引用保留等待任务;releaseCycle 清掉外部引用后,缓存不应让整个引用环常驻。
// 比较操作前后对象数时,关闭页面只解除展示根;holder/pending 外部引用也释放后才应回到基线。
holder = ui.state(0)
pending = ui.state(0)
phase = ui.state(0)
// 通过 peer 指回包含自己的 Page,配合等待协程形成包含视觉缓存读依赖的引用环。
class CycleRect: RectBlock {
peer: Object
// sleep 归属该 rect,await 保留协程帧;先写 phase=1,只有实际恢复后才写 2 并改 Page 标题。
function wait(self: CycleRect): Unit {
gate = ui.sleep(self,"sleep",300000)
phase.set(1)
@@ -14,15 +15,18 @@ class CycleRect: RectBlock {
phase.set(2)
self.peer.title = "等待已结束"
}
// 故意读取 peer.title,让布局依赖包含循环中的 Page;缓存应借用观察,不增加长期 GC 根。
function measure(self: CycleRect, mount: Mount, constraints: Constraints): MeasureResult {
observed = self.peer.title
return MeasureResult(180,40)
}
// 绘制也读取 peer.title,检查绘制缓存与等待帧不会共同意外保活循环。
function paint(self: CycleRect, mount: Mount, geometry: Bounds): DisplayList {
observed = self.peer.title
return RectBlock.paint(self,mount,geometry)
}
}
// 创建临时 Page 和 CycleRect,相互引用后挂载,再把 Page/Task 分别放入两个外部 holder。
function mountCycle(): Unit {
transient = Page()
transient.title = "临时引用环"
@@ -32,11 +36,13 @@ function mountCycle(): Unit {
phase.set(0)
pending.set(ui.spawn(rect,"workflow",rect.wait))
}
// 先关闭临时页,随后去掉两个外部引用;等待依赖无需等满五分钟,失去可达性后应整体回收。
function releaseCycle(): Unit {
holder.value.close()
holder.set(0)
pending.set(0)
}
// 基线页面始终存在,用它比较操作前后的托管对象数,不把临时页面当作基线。
base = Page()
base.title = "GC 基线"
base.render(TextBlock("关闭临时 Page 后应回收引用环",2))
+5
View File
@@ -1,11 +1,16 @@
// 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。
// Console 求值 42 仅改变最后结果 _,不应重绘;给 color 赋新值才使绘制失效。
color = "#ED6A5A"
// 保持原生几何,覆盖颜色策略,使示例只观察全局读取的依赖粒度。
class GlobalPaint: RectBlock {
// 调用原生基类产出新命令,读取当前 color 并写到新命令;不在只读阶段修改全局。
function paint(self: GlobalPaint, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self,mount,geometry)
drawing.commands.at(0).color = color
return drawing
}
}
// 显示固定大小矩形和操作提示,后续通过 Console 改同一个会话的 color。
rect = GlobalPaint(480,120,12,"#DDEEFF")
page = Page()
page.title = "全局名字依赖"
+7 -7
View File
@@ -1,10 +1,10 @@
function heading(value: Text): View {
return ui.text(value, 2, "#242422")
function heading(value: Text): View { // 辅助函数把字符串包装成大字号 TextBlock;返回值仍是托管视觉引用。
return ui.text(value, 2, "#242422") // 这里只创建文字对象,具体宽高由实际字体和出现位置约束测量。
}
function main(): View {
let title = heading("Hello Endor")
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI"))
return ui.column(24, 12, title, rectangle, detail)
function main(): View { // 兼容模块入口 main 返回视觉根,宿主负责把它显示到页面,不需要源代码显式 render。
let title = heading("Hello Endor") // 调用上述辅助函数,局部变量持有同一托管文字对象。
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 矩形使用逻辑尺寸,圆角与颜色由原生载荷保存。
let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI")) // Row 依次摆放文字、固定间隔和文字;共享几何仍由每次出现位置承担。
return ui.column(24, 12, title, rectangle, detail) // Column 的 24px 内边距和 12px 间隔决定最终命令位置;返回后发布快照拥有所需数据。
}
+12
View File
@@ -1,32 +1,44 @@
// 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。
// ViewportBand 还显式读取实际页面视口,使原生 resize 可通过依赖触发重测。
// 只负责测量需要的宽度,和绘制颜色分成两个业务对象以便观察独立失效。
class WidthModel {
width: Number
// 点击把模型宽度置为 260;源 measure 读取过该字段,缓存因此失效。
function expand(self: WidthModel, event: Event): Unit {
self.width = 260
}
}
// 保存 paint 使用的颜色,不参与 CachedRect 的测量。
class Tint {
color: String
// 只改颜色;正常情况下需重绘而不重新执行无关源测量。
function blue(self: Tint, event: Event): Unit {
self.color = "#526AC7"
}
}
// 原生矩形的尺寸/颜色策略分别由源协议覆盖,业务模型通过字段持有。
class CachedRect: RectBlock {
dimensions: WidthModel
tint: Tint
// 读取 dimensions.width 返回固定高 60;实际读观察由运行时缓存记录,不手工声明依赖表。
function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(self.dimensions.width, 60)
}
// 复用原生矩形命令,再改新命令的颜色;修改新结果被允许,但 paint 不能修改已有业务对象。
function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint.color
return drawing
}
}
// 宽度来自 ui.viewport 的当前逻辑页面区域,显示窗口变化也能成为测量依赖。
class ViewportBand: RectBlock {
// 返回可用视口宽度,最终仍服从父容器约束;外层 padding 会减少分配宽度。
function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(ui.viewport().width, 24)
}
}
// 创建相互独立的宽度/颜色模型并绑定两个按钮,用相同对象观察缓存与可见结果。
dimensions = WidthModel(200)
tint = Tint("#ED6A5A")
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
+12
View File
@@ -1,24 +1,34 @@
// 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。
// paragraph 被两个容器共享;每个 Mount 使用独立约束,不能把新宽度写回共享文字状态。
// 保存第一个段落出现位置的分配宽度,按钮只改策略值。
class Policy {
width: Number
// 选择 280 逻辑像素宽度,段落变高,后继文字下移。
function narrow(self: Policy, event: Event): Unit { self.width = 280 }
// 选择 560 逻辑像素宽度,段落变矮;另一个容器宽度不跟着改变。
function wide(self: Policy, event: Event): Unit { self.width = 560 }
}
// 在原生 Column 排列基础上覆盖第一个稳定子槽的宽度。
class ArrangedColumn: Column {
policy: Policy
// 先取得包含全部子槽的 Arrangement,仅改第一子项分配;保留其他槽使排列验证完整。
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
result = Column.arrange(self, mount, geometry)
result.children.at(0).width = self.policy.width
return result
}
}
// 通过源 measure 改整个容器的期望宽度,以对比 arrange 分配路径。
class MeasuredColumn: Column {
width: Number
// 调用原生测量后修改新结果 width;这个新结果不属于已有业务对象,允许在只读计算中填写。
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
result = Column.measure(self, mount, constraints)
result.width = self.width
return result
}
}
// 两个容器使用同一个 paragraph,分别由 Policy=280 与 measure.width=360 约束。
policy = Policy(280)
paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。")
tail = TextBlock("这一行跟随上方段落。")
@@ -26,10 +36,12 @@ arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
measured = MeasuredColumn(0, 12, paragraph, TextBlock("这一段的宽度来自源码 measure。"), 360)
narrow = Button("arrange 为 280")
wide = Button("arrange 为 560")
// 保留事件订阅引用,两个按钮调用同一 Policy 的不同方法。
narrowConnection = narrow.on("click", policy.narrow)
wideConnection = wide.on("click", policy.wide)
page = Page()
page.title = "约束协商"
page.render(Column(24, 16, Row(0, 12, narrow, wide), arranged, measured))
// 成功输入结束后宿主才消费原生窗口命令;窗口实际尺寸再回传为下一次布局约束。
ui.window().resize(850, 700)
ui.window().setTitle("测量与排列直到稳定")
+4
View File
@@ -1,5 +1,9 @@
// 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。
// 返回 Column 视觉对象,宿主选择/挂载页面;这里的 title 和 rectangle 只在 VM 内建立业务引用。
function main(): View {
// 文字内容与矩形参数和 page 示例一致,最终绘图命令应等价。
let heading = ui.text("Hello Endor", 2, "#242422")
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
// 布局边距与间隔保持一致;不同 VM 的对象/Mount 身份独立,命令相同不表示共享对象。
return ui.column(24, 12, heading, rectangle)
}
+5
View File
@@ -1,9 +1,14 @@
// 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。
// 两个文字绑定读同一 counter;原生点击和 Console 都能操作该对象并传播显示变化。
// state 提供可写 value 和 increment 方法,textOf 建立显式字段到文本的依赖。
counter = ui.state(9)
first = ui.textOf(counter, "value", 2)
second = ui.textOf(counter, "value", 2)
title = TextBlock("Endor:同一份对象,代码与 GUI 共同操作", 2)
button = Button("增加计数")
// 零参数源回调委托原生 state.increment;事件分发器根据签名调用,不强塞 Event 参数。
function increment(): Unit { counter.increment() }
// 把实际函数作为回调而非函数名文本;render 之前只创建业务对象,render 后才有出现位置。
button.onClick(increment)
page = Page()
page.render(Column(24, 16, title, Row(0, 16, first, second, button)))
+10
View File
@@ -1,23 +1,33 @@
// 方法覆盖遵守参数可更宽、返回可更具体的规则。
// WideRect 复用基类原生测量后返回 Sized 子类结果;事件改变基础 width 后按同一路径重测。
// MeasureResult 的名义子类,不增加字段,用于演示返回值协变。
class Sized: MeasureResult {}
// 用源方法显式委托原生测量,保留 Mount/Constraints 的基类契约。
class BaseRect: RectBlock {
// 原生方法按给定出现位置与约束测量,不把几何存进共享对象。
function measure(self: BaseRect, mount: Mount, constraints: Constraints): MeasureResult {
return RectBlock.measure(self, mount, constraints)
}
}
// 覆盖参数放宽为 Object,返回缩窄为 Sized;不能反过来缩窄参数或放宽返回。
class WideRect: BaseRect {
// 调用基类后复制测量数值到新的 Sized;新结果仍满足 MeasureResult 协议。
function measure(self: WideRect, mount: Object, constraints: Object): Sized {
measured = BaseRect.measure(self, mount, constraints)
return Sized(measured.width, measured.height)
}
}
// 保存具体 WideRect 和计数器,使宽度修改和点击次数同时作为业务变化传播。
class Actions {
rect: WideRect
count: Object
// 先改 width 再递增计数,新的布局刷新会使用上述动态测量覆盖。
function widen(self: Actions): Unit {
self.rect.width = 260
self.count.increment()
}
}
// 初始矩形宽 180;按钮事件完成后应变为 260,同时文字显示计数。
rect = WideRect(180, 60, 8, "#ED6A5A")
count = ui.state(0)
actions = Actions(rect, count)
+10
View File
@@ -1,3 +1,6 @@
// 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。
// 普通数据变化不会重新挂载;页面关闭时子项先卸载,父项随后卸载,旧 Mount 不因重开而复活。
// order 是统一时序,mounted/unmounted 是子项次数;latest 保存最近通知的实际 Mount。
class LifecycleLog {
order: Object
mounted: Object
@@ -8,8 +11,10 @@ class LifecycleLog {
childUnmounted: Number
latest: Object
}
// 共享文字用 log 记录两个出现位置,源钩子接收的是当前具体 Mount。
class WatchedText: TextBlock {
log: LifecycleLog
// 记录顺序和次数,保存 Mount 并修改原文字;运行时在回调后重新收敛才发布。
function onMount(self: WatchedText, mount: Mount): Unit {
self.log.order.increment()
self.log.mounted.increment()
@@ -17,6 +22,7 @@ class WatchedText: TextBlock {
self.log.latest.set(mount)
self.setText("同一个对象,两个独立挂载位置")
}
// 记录已经脱离的出现位置;保留 Mount 可查询 mounted,但不能再当有效 bounds 使用。
function onUnmount(self: WatchedText, mount: Mount): Unit {
self.log.order.increment()
self.log.unmounted.increment()
@@ -24,17 +30,21 @@ class WatchedText: TextBlock {
self.log.latest.set(mount)
}
}
// 父容器也记录钩子序号,便于对比挂载与子先父后的卸载顺序。
class WatchedColumn: Column {
log: LifecycleLog
// 记录父挂载顺序,不修改容器结构;子项随后各自执行通知。
function onMount(self: WatchedColumn, mount: Mount): Unit {
self.log.order.increment()
self.log.parentMounted = self.log.order.value
}
// 父卸载时子项卸载已完成,序列号应位于所有子项通知之后。
function onUnmount(self: WatchedColumn, mount: Mount): Unit {
self.log.order.increment()
self.log.parentUnmounted = self.log.order.value
}
}
// 多个 state 容器分别保存计数/最后 Mount,root 最后参数是源 log 字段,前面才是可变视觉子项。
log = LifecycleLog(ui.state(0), ui.state(0), ui.state(0), 0, 0, 0, 0, ui.state(0))
child = WatchedText("等待挂载", 2, log)
root = WatchedColumn(24, 16, child, child, TextBlock("挂载次数"), ui.textOf(log.mounted, "value"), TextBlock("卸载次数"), ui.textOf(log.unmounted, "value"), log)
+7
View File
@@ -1,12 +1,19 @@
// 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。
// 同一事件注册机制按实际签名调用;label 绑定再把事件 kind 转成可显示的字符串。
// 保留最后一次收到的 Event 对象,初值 kind=waiting;不是只存一份格式化事件文本。
received = ui.state(Event("waiting"))
count = ui.state(0)
// 只读计算提取当前事件种类,作为 label.text 绑定的依赖。
function eventKind(): String { return received.value.kind }
label = TextBlock("waiting")
subscription = label.bind("text", eventKind)
withEvent = Button("传递 Event 给原生 set")
// received.set 是一参数原生方法,分发器传入实际 click Event。
firstConnection = withEvent.on("click", received.set)
withoutEvent = Button("调用零参数原生 increment")
// count.increment 的签名为零参数,分发器调用时不传多余 Event。
secondConnection = withoutEvent.on("click", count.increment)
// 把已绑定状态与两种回调放到同一页面,便于对比输入效果。
page = Page()
page.title = "原生方法签名"
page.render(Column(24, 16, TextBlock("源码和原生回调共用参数契约"), label, ui.textOf(count, "value"), withEvent, withoutEvent))
+10
View File
@@ -1,33 +1,43 @@
// 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。
// label 在树中出现多次但仍是同一对象,原生方法和源字段写入共同更新各出现位置。
// 扩展 TextBlock 的业务字段;其文本/颜色仍由原生父类存储与绘制。
class StatusText: TextBlock {
activeColor: String
// 用继承的 setText/setColor 改原对象;activeColor 是构造时追加的源字段。
function activate(self: StatusText, event: Event): Unit {
self.setText("继承的原生文本已更新")
self.setColor(self.activeColor)
}
}
// 继续继承源子类,添加 message 并覆盖事件入口,保留 TextBlock 的原生能力。
class CustomStatus: StatusText {
message: String
// 显式调用基类 setText 以示范原生未绑定方法的接收者传递,再用继承方法改颜色。
function activate(self: CustomStatus, event: Event): Unit {
TextBlock.setText(self, self.message)
self.setColor(self.activeColor)
}
}
// 按钮保存目标 Block,事件到来时把同一 Event 转交给目标的动态 activate。
class ActionButton: Button {
target: Block
// 不复制目标或创建新展示对象,直接调用目标业务方法,选择实际类型的覆盖实现。
function activate(self: ActionButton, event: Event): Unit {
self.target.activate(event)
}
}
// Page 也可增加源字段;caption 用于构建页内标题,不替代原生窗口标题。
class CustomPage: Page {
caption: String
}
// 前面位置参数供原生 TextBlock 构造,后面的颜色/消息供继承字段;所有引用都保留托管类型。
label = CustomStatus("点击按钮更新同一个自定义文本对象", 2, "#242422", "#237A45", "源码子类与原生载荷共同工作")
button = ActionButton("调用源码子类方法", label)
button.onClick(button.activate)
+8
View File
@@ -1,15 +1,22 @@
// owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。
// 每个 owner 下同 key 的重复点击替换旧定时器;任务生命周期不由画面是否可见推断。
// 把 owner 与要修改的 state 分开持有,使同一个调度方法可用于三个作用域。
class Actions {
owner: Object
state: Object
// 定时任务成功交付后修改共享计数,零参数签名符合 timer 回调要求。
function complete(self: Actions): Unit { self.state.increment() }
// 每次启动 1500ms 的 load 定时器;相同 owner/key 只有最新任务继续等待。
function schedule(self: Actions, event: Event): Unit {
ui.after(self.owner, "load", 1500, self.complete)
}
}
state = ui.state(0)
// 该 Block 从未进入页面树,但全局引用仍持有它,因此它拥有的任务可执行。
hidden = TextBlock("这个 Block 没有挂载,也能拥有任务")
page = Page()
page.title = "任务生命周期"
// 三个 Action 共享一个 state,却分别归属 Page、Block、应用,便于比较关闭页面的取消边界。
pageActions = Actions(page, state)
blockActions = Actions(hidden, state)
appActions = Actions(ui, state)
@@ -20,5 +27,6 @@ pageConnection = pageButton.on("click", pageActions.schedule)
blockConnection = blockButton.on("click", blockActions.schedule)
appConnection = appButton.on("click", appActions.schedule)
page.render(Column(24, 16, TextBlock("1.5 秒后更新同一个状态对象;重复点击只保留最新任务"), ui.textOf(state, "value"), pageButton, blockButton, appButton))
// 额外应用级任务延迟 500ms,用于展示没有点击也可按 worker 空闲轮询交付。
initial = ui.after(ui, "initial", 500, appActions.complete)
ui.window().setTitle("Owned Tasks")
+4 -4
View File
@@ -1,4 +1,4 @@
let heading = ui.text("Hello Endor", 2, "#242422")
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
let myui = ui.page()
myui.render(ui.column(24, 12, heading, rectangle))
let heading = ui.text("Hello Endor", 2, "#242422") // 顶层脚本不需要 main:先创建文字对象;字号 2 和十六进制颜色传给原生文字构造器。
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 创建固定逻辑尺寸矩形;12 为圆角半径,业务对象尚未出现在任何页面中。
let myui = ui.page() // 创建托管 Page 资源;它负责持有展示根和页面生命周期。
myui.render(ui.column(24, 12, heading, rectangle)) // 把文字和矩形放入 Column,padding=24、gap=12;render 选择此页并生成出现位置及发布帧。
+11
View File
@@ -1,27 +1,38 @@
// 普通业务对象显式实现 Presentable 后,ui.present 可调用其 present(window) 生成视觉对象。
// 生成的视图保存 source 指向业务模型,绑定和按钮继续使用原对象,展示不是业务数据副本。
// name/color 是业务字段,零参读取方法提供只读绑定,update 提供事件修改。
class Product {
name: String
color: String
// 返回名称并记录实际属性读取,后续 name 修改会使文本绑定重算。
function label(self: Product): String { return self.name }
// 只读返回颜色,绑定到新建色块的 color。
function shade(self: Product): String { return self.color }
// 点击更新原业务对象,现有绑定会同步文字和矩形,不重新调用整个 present 构造树。
function update(self: Product, event: Event): Unit {
self.name = "原业务对象已更新"
self.color = "#237A45"
}
// 在指定 Window 上下文创建文字、色块和按钮,连接原 self 的方法后返回组合根。
function present(self: Product, window: Window): Block {
title = TextBlock(self.name)
swatch = RectBlock(180,48,8,self.color)
// 绑定令牌本身不承载另一份业务状态,绑定计算仍调用同一 Product。
titleBinding = title.bind("text",self.label)
colorBinding = swatch.bind("color",self.shade)
button = Button("调用业务对象方法")
connection = button.on("click",self.update)
result = Column(24,16,title,swatch,button)
// 保留原始业务身份,便于代码从展示根回到来源;contextWindow 仅记录创建时的窗口编号。
result.source = self
result.contextWindow = window.id()
return result
}
}
// 显式声明能力并验证方法契约;只有同名 present 不会自动成为 Presentable。
ui.implement(Product,Presentable)
product = Product("普通对象的显式展示能力", "#526AC7")
// 先获得视觉根,再由 ui.open 把它挂载到页面;最后设置 Page 标题。
view = ui.present(product)
page = ui.open(view)
page.title = "Presentable"
+9
View File
@@ -1,10 +1,17 @@
// 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。
// 绑定在目标隐藏时也会更新;两个页面共享同一组 Block,不需要两份模型。
// width/color/label 是唯一业务来源,绑定计算只读,expand 是显式事件写入入口。
class Model {
width: Number
color: String
label: String
// 返回 Number,目标 width 属性同时做类型/尺寸验证。
function getWidth(self: Model): Number { return self.width }
// 返回颜色文本;依赖只包含实际读取的模型属性。
function getColor(self: Model): String { return self.color }
// 返回当前说明文字,供 TextBlock.text 绑定使用。
function getLabel(self: Model): String { return self.label }
// 一个事件同时改三个属性,随后绑定传播,布局与绘制在完整刷新后发布。
function expand(self: Model, event: Event): Unit {
self.width = 260
self.color = "#237A45"
@@ -14,6 +21,7 @@ class Model {
model = Model(120, "#526AC7", "点击更新共享对象")
rect = RectBlock(120, 40, 8, "#526AC7")
label = TextBlock("初始化")
// 保存三个可断开的绑定令牌;令牌不强持有隐藏目标,目标仍需由业务对象图保留。
widthBinding = rect.bind("width", model.getWidth)
colorBinding = rect.bind("color", model.getColor)
labelBinding = label.bind("text", model.getLabel)
@@ -23,6 +31,7 @@ root = Column(24, 16, label, rect, button)
page = Page()
page.title = "通用属性绑定"
page.render(root)
// 第二页面使用同一根容器,切页只更换 Mount 展示上下文;最后 activate 回第一页面。
second = Page()
second.title = "同一组 Block"
second.render(root)
+9 -2
View File
@@ -1,7 +1,10 @@
// The scene has world positions; each finite viewport has its own camera.
// 一个二维世界可被多个有限视口观察,Scene2D 的对象位置共享,默认 Camera2D 各自独立。
// 内层视口也可作为世界子项;HUD 在外层 Overlay 中,所以不会随世界相机变换。
// 场景保存世界对象,视口负责有限窗口与相机变换。
count = ui.state(0)
increment = Button("共享计数 +1")
increment.onClick(count.increment)
// 业务卡片带世界 x/y 和边框,count 文字与按钮共同保留同一 state。
card = Column(20, 12, TextBlock("世界里的 UI", 2), ui.textOf(count, "value", 3), increment)
card.boxWidth = 220
card.x = -60
@@ -9,17 +12,21 @@ card.y = 40
card.borderLeft = 2
card.borderColor = "#8CA5B8"
// 有限内层视口看自己的小场景,再以世界坐标放进外层 Scene2D。
inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9")))
inner.x = 260
inner.y = 40
// 两个外层视口引用同一 world,卡片/内层视口因此各产生两个出现位置。
world = Scene2D(card, inner)
// 左右视口尺寸和相机平移/缩放不同;显式赋同一 camera 才会共享导航状态。
left = CanvasViewport(460, 320, world)
left.camera.x = -100
right = CanvasViewport(320, 320, world)
right.camera.x = -80
right.camera.zoom = 0.65
// Set right.camera = left.camera to share a view explicitly.
// 如需联动,在 Console 将 right.camera 赋为 left.camera;共享场景本身不会自动共享相机。
// 先横排两个视图,再把固定 HUD 叠在它们之上,输入命中也使用组合后的几何。
views = Row(0, 20, left, right)
hud = Column(16, 8, TextBlock("一个世界,多个视角", 2), TextBlock("滚轮平移 · 空格/中键拖动 · 捏合或 Cmd/Ctrl+滚轮缩放"))
hud.alignSelf = "end"
+11
View File
@@ -1,19 +1,27 @@
// 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。
// 三维内容与普通 Endor 二维控件一起排版,viewport 有限尺寸不限制场景世界坐标。
// 保存左侧视口和共享立方体;动作可改相机位置或真实网格变换。
class SceneActions {
view: Viewport3D
cube: Mesh3D
// 把左相机放到 z=6 正面,保持相机既有 target。
function front(self: SceneActions): Unit {
self.view.camera.x = 0
self.view.camera.y = 0
self.view.camera.z = 6
}
// 把同一相机移到侧上方,右侧正交相机保持原设置。
function side(self: SceneActions): Unit {
self.view.camera.x = 6
self.view.camera.y = 2
self.view.camera.z = 0
}
// 修改共享 cube 的欧拉旋转,因此两个视口都看到物体变化。
function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) }
// 示范 Event.hit.mesh 的真实托管网格材质写入;此文件尚未把 select 注册到视口。
function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" }
}
// 先构造并变换两个 Box3D,再由 Group3D 组成场景节点;几何与相机是不同对象。
cube = Box3D(1.6, 1.6, 1.6, "#6687AB")
cube.rotationY = 25
cube.rotationX = 15
@@ -21,11 +29,13 @@ small = Box3D(0.7, 0.7, 0.7, "#DBA36E")
small.x = 1.8
small.z = -0.8
group = Group3D(cube, small)
// 薄盒作地面,方向光与 ambient 一起影响材质颜色,不是二维背景矩形。
floor = Box3D(6, 0.08, 4, "#D8DEE6")
floor.y = -1.1
light = DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9)
scene = Scene3D(group, floor, light)
scene.ambient = 0.35
// 左视口使用默认透视相机,右视口显式换成 OrthographicCamera 并设置可见高度。
perspective = Viewport3D(480, 360, scene)
perspective.camera.z = 6
orthographic = Viewport3D(320, 360, scene)
@@ -34,6 +44,7 @@ orthographic.camera.height = 5
orthographic.camera.x = 4
orthographic.camera.y = 2
orthographic.camera.z = 5
// 按钮绑定零参数方法;页面文字说明原生默认控制器的交互,代码可继续替换 controller。
actions = SceneActions(perspective, cube)
front = Button("正面")
front.onClick(actions.front)
+10
View File
@@ -1,21 +1,31 @@
// 滚动轴策略影响可滚动方向与内容测量约束:vertical 限宽并允许长高,horizontal 允许横向展开。
// 同一 paragraph 出现两次,文字内容共享,换行/偏移/范围在各 Mount 中独立。
// 通过具体出现位置的 Mount 操作偏移;ScrollBlock.axis 则是对象级策略,可影响其所有出现位置。
class Actions {
vertical: ScrollBlock
horizontal: ScrollBlock
// 只对 vertical 第一个 Mount 累加纵偏移,运行时按当前范围夹取。
function down(self: Actions): Unit { self.vertical.mounts().at(0).scrollBy(0, 60) }
// 只对 horizontal 第一个 Mount 累加横偏移,禁止轴仍保持零。
function right(self: Actions): Unit { self.horizontal.mounts().at(0).scrollBy(60, 0) }
// 分别把两个出现位置移回原点,不修改共享段落文字。
function reset(self: Actions): Unit {
self.vertical.mounts().at(0).scrollTo(0, 0)
self.horizontal.mounts().at(0).scrollTo(0, 0)
}
// 把左对象策略改为双轴,下一次求解重新计算约束和范围。
function both(self: Actions): Unit { self.vertical.axis = "both" }
}
paragraph = TextBlock("这里展示的是同一个 TextBlock。左侧只允许纵向滚动,文字会按照这个 Mount 的实际视口宽度换行;右侧只允许横向滚动,文字保留横向展开。对象的文字和业务状态只有一份,每次展示的约束、几何和滚动位置分别保存。修改滚动轴也会重新测量受影响内容,并将已经不再允许的偏移夹回零。布局传播持续到受影响部分稳定,不会按固定跳数丢弃后续更新。这里展示的是同一个 TextBlock,两个视图可以拥有不同的换行结果。")
// 纵向区域padding后内容宽为244,长文字按此有限宽换行。
vertical = ScrollBlock(260, 140, paragraph)
vertical.padding = 8
vertical.axis = "vertical"
// 横向区域沿滚动轴允许内容自然展开,所以同一文字可以比左侧更宽、更矮。
horizontal = ScrollBlock(260, 140, paragraph)
horizontal.padding = 8
horizontal.axis = "horizontal"
// 绑定四个零参数按钮回调;最终把两视图与控制区放在普通 Column/Row 中。
actions = Actions(vertical, horizontal)
down = Button("向下滚动")
down.onClick(actions.down)
+8
View File
@@ -1,10 +1,16 @@
// 同一 ScrollBlock 在两个原生窗口中展示:内容业务对象共享,滚动偏移按 Mount 独立保存。
// 滚动只改变投影/裁剪,不复制业务计数;被裁剪内容不能通过原始矩形接受点击。
// 保留一个源 paint 委托,检验源命令与原生命令应用相同滚动裁剪。
class Painted: RectBlock {
// 使用当前 geometry 交给原生基类;外层滚动变换/clip 由展示流程统一组合。
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
return RectBlock.paint(self,mount,geometry)
}
}
// 三个按钮复用同一个 Action,任意可见按钮点击都递增同一 count。
class Actions {
count: Object
// 签名接受 Event,业务逻辑只需修改计数,不直接处理滚动位置。
function click(self: Actions, event: Event): Unit { self.count.increment() }
}
count = ui.state(0)
@@ -15,12 +21,14 @@ last = Button("底部按钮")
firstConnection = first.on("click",actions.click)
middleConnection = middle.on("click",actions.click)
lastConnection = last.on("click",actions.click)
// 高内容夹有三个按钮与矩形,使固定320x180 scroller产生可观察的横纵滚动范围。
body = Column(12,12,first,Painted(520,180,8,"#DCE8FF"),middle,RectBlock(520,180,8,"#E3EFE5"),last)
scroller = ScrollBlock(320,180,body)
root = Column(24,16,TextBlock("同一 Block,两处 Mount 独立滚动"),ui.textOf(count,"value"),scroller)
page = Page()
page.title = "滚动示例"
page.render(root)
// 另一窗口复用 root/scroller;它们的第二个 Mount 偏移与主窗口第一个 Mount 无关。
other = Window()
other.setTitle("Scroll Secondary")
second = Page()
+8
View File
@@ -1,23 +1,31 @@
// 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。
// 圆外部分可落到父容器监听器;按下与释放须经相同合法目标复核。
// 扩展矩形的绘制、命中和事件方法,用同一个 hits 状态观察交互结果。
class CircleTarget: RectBlock {
hits: Object
// point 已转换到该出现位置局部坐标;使用圆心(40,40)、半径40,而不是整个 160x80 外框。
function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool {
return point.inCircle(40, 40, 40)
}
// 把原生命令宽度缩为 80、圆角半径设为 40,得到与 hitTest 对应的圆形区域。
function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).width = 80
drawing.commands.at(0).radius = 40
return drawing
}
// 圆内点击递增自身计数并停止向祖先传播;父容器不会为同一次点击再递增。
function onEvent(self: CircleTarget, event: Event): Unit {
self.hits.increment()
event.stopPropagation()
}
}
hits = ui.state(0)
// 父监听器单独计数;它和圆形目标显示在同页,便于比较穿过透明区的输入。
background = ui.state(0)
circle = CircleTarget(160, 80, 0, "#237A45", hits)
root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value"))
// 订阅在父 Column 上,圆外有效区域可回退到父目标;源命中只读检查不执行这些业务回调。
connection = root.on("click", background.increment)
page = Page()
page.render(root)
+10
View File
@@ -1,25 +1,35 @@
// 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。
// 运行时在只读求解中跟踪这些读取,事件修改模型后重新布局;布局方法不能直接改业务字段。
// 保存宽度与左偏移;两字段只在事件回调中修改。
class Metrics {
width: Number
left: Number
// 同时改变测量和排列依赖,整帧需收敛后一起发布新尺寸/位置。
function expand(self: Metrics, event: Event): Unit {
self.width = 260
self.left = 68
}
}
// 复用原生矩形载荷,仅覆盖尺寸策略;同一 rect 放在不同父容器可得到不同测量。
class MeasuredRect: RectBlock {
// 首次测量前结构 Mount 链接已建立,因此可从 parent.block 读取当前出现位置的父策略。
// 此阶段还不能查询最终 bounds,返回值使用 MeasureResult 名义类型。
function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult {
parent = mount.parent()
return MeasureResult(parent.block.metrics.width, 60)
}
}
// 在原生排列结果上改第一个子槽 x,其他子项仍用 Column 的堆叠规则。
class OffsetColumn: Column {
metrics: Metrics
// Arrangement 里的 x 是当前排列使用的坐标;读取 metrics.left 成为排列依赖。
function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement {
positions = Column.arrange(self, mount, geometry)
positions.children.at(0).x = self.metrics.left
return positions
}
}
// 将原生参数与源字段传给具体子类,button 事件更新模型,Page.render 建立完整展示关系。
metrics = Metrics(200, 44)
rect = MeasuredRect(80, 20, 0, "#526AC7")
button = Button("更新布局")
+11
View File
@@ -1,15 +1,22 @@
// 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。
// 同一 rect 出现两次,两份命令随同一 tint 更新;业务身份共享,几何按 Mount 分开。
// 原生矩形测量不变,仅将绘制颜色切换为源字段 tint。
class TintRect: RectBlock {
tint: String
// 从原生基类取得当前出现位置的矩形命令,再修改本次返回列表中的颜色并交回。
function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint
return drawing
}
}
// 在原生文字前增加圆角底色,背景大小使用实际分配 geometry 而不是固定猜测值。
class Badge: TextBlock {
background: String
// 先完整填写 roundedRect 命令,再取原生文字命令改成白色;列表顺序决定先画背景后画文字。
function paint(self: Badge, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand()
// 命令 kind 决定宿主解码所需字段,坐标/宽高来自本次 Mount,radius/color 由业务策略提供。
fill.kind = "roundedRect"
fill.x = geometry.x
fill.y = geometry.y
@@ -17,17 +24,21 @@ class Badge: TextBlock {
fill.height = geometry.height
fill.radius = 6
fill.color = self.background
// 复用原生文字塑形和换行所需参数,避免手工文字命令漏掉边界。
text = TextBlock.paint(self, mount, geometry).commands.at(0)
text.color = "#FFFFFF"
return DisplayList(ObjectList(fill, text))
}
}
// 保存目标 TintRect,onEvent 是托管事件目标方法覆盖,不额外创建另一份状态。
class RecolorButton: Button {
target: TintRect
// 事件阶段可修改已有业务字段;依赖跟踪检测 tint 变化后重绘两个出现位置。
function onEvent(self: RecolorButton, event: Event): Unit {
self.target.tint = "#526AC7"
}
}
// 两次使用 rect 是同一对象,Badge 与按钮独立;根容器只组合它们,不复制业务数据。
rect = TintRect(200, 60, 12, "#DDEEFF", "#ED6A5A")
badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45")
button = RecolorButton("通过对象方法改变绘制颜色", rect)
+20 -2
View File
@@ -1,14 +1,19 @@
// Student grades demo. Fictional records; in-memory only.
// Paste the entire file into Tessera Console, then press Enter.
// 学生成绩中心为纯内存演示,记录均虚构,不连接学校数据库。
// 按钮操作 GradeCenter/Student 原对象;成绩行与摘要用 textOf 绑定,同一学生在全部/复核视图中复用业务身份。
// 整个文件作为一次完整源码输入提交;状态只存在本次 VM,会话重置后不保留。
// 为 Column 增加期望宽度字段,使手工表格各列对齐,而非让文字自然宽度决定列边界。
class GradeCell: Column {
width: Number
// 先保留 Column 的自然高度,再把新测量结果宽度设为字段值,最终仍由父约束协商。
function measure(self: GradeCell, mount: Mount, constraints: Constraints): MeasureResult {
size = Column.measure(self, mount, constraints)
size.width = self.width
return size
}
}
// 背景绘制由源覆盖,子项仍按普通 Column 排列与绘制。
class GradePanel: Column {
// 按本次 geometry 生成白色圆角底板;不重绘或复制子项命令,后代由统一遍历处理。
function paint(self: GradePanel, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand()
fill.kind = "roundedRect"
@@ -21,6 +26,7 @@ class GradePanel: Column {
return DisplayList(ObjectList(fill))
}
}
// 姓名/学号和各科成绩/状态是业务字段;数值修改受 Number 声明检查。
class Student {
name: String
number: String
@@ -29,9 +35,11 @@ class Student {
english: Number
status: String
}
// 用统一无 padding/gap 的单格容器包装任意 Block,末尾 width 是源字段构造参数。
function gradeCell(content: Block, width: Number): Block {
return GradeCell(0, 0, content, width)
}
// 姓名/学号直接取初值,成绩和状态用响应式 textOf;行末分隔线与固定列宽保持表格结构。
function gradeRow(student: Student): Block {
return Column(0, 12,
Row(0, 0,
@@ -43,6 +51,7 @@ function gradeRow(student: Student): Block {
gradeCell(ui.textOf(student, "status", 1, "#526C83"), 160)),
RectBlock(730, 1, 0, "#EDF0F3"))
}
// 保存全部/复核两棵现成子树与可替换 table,同时持有要复核的真实 Student。
class GradeCenter {
table: Column
all: Column
@@ -50,20 +59,24 @@ class GradeCenter {
student: Student
pending: Number
message: String
// 替换稳定槽0的内容为全部行,复用已有学生,不重新请求/重建数据。
function showAll(self: GradeCenter, event: Event): Unit {
self.table.replace(0, self.all)
self.message = "正在查看全部 6 位学生"
}
// 切换到预先构造的复核行;这是特定示例子树,不是动态过滤算法。
function showAttention(self: GradeCenter, event: Event): Unit {
self.table.replace(0, self.attention)
self.message = "林沐的数学成绩有一条复核记录"
}
// 写预设数学成绩68及状态,清待复核数;相关绑定在两个视图都读同一 studentLin。
function review(self: GradeCenter, event: Event): Unit {
self.student.math = 68
self.student.status = "已复核"
self.pending = 0
self.message = "已录入复核结果:林沐 · 数学 58 → 68"
}
// 恢复初始学生值/计数并显示全部列表,便于重复观察同一操作。
function reset(self: GradeCenter, event: Event): Unit {
self.student.math = 58
self.student.status = "待复核"
@@ -72,6 +85,7 @@ class GradeCenter {
self.message = "已恢复示例数据"
}
}
// 下面六条数据均本地构造;allGradeRows 与 attentionGradeRows 对 studentLin 建立不同视觉行但共享其业务值。
studentLin = Student("林沐", "2026001", 86, 58, 91, "待复核")
studentChen = Student("陈知夏", "2026002", 94, 96, 93, "已确认")
studentXu = Student("许星河", "2026003", 82, 88, 85, "已确认")
@@ -81,15 +95,18 @@ studentLu = Student("陆景行", "2026006", 79, 84, 87, "已确认")
allGradeRows = Column(0, 14, gradeRow(studentLin), gradeRow(studentChen), gradeRow(studentXu), gradeRow(studentZhou), gradeRow(studentSu), gradeRow(studentLu))
attentionGradeRows = Column(0, 14, gradeRow(studentLin))
gradeTable = Column(0, 0, allGradeRows)
// table 最初含一个稳定子槽,后续 replace(0,...) 正是切换这个槽。
gradeCenter = GradeCenter(gradeTable, allGradeRows, attentionGradeRows, studentLin, 1, "正在查看全部 6 位学生")
showAllGrades = Button("全部学生")
showAttentionGrades = Button("查看复核记录")
reviewGrade = Button("录入复核成绩")
resetGrades = Button("恢复示例数据")
// 四个回调均接受 Event,绑定到同一 GradeCenter 接收者。
showAllGrades.on("click", gradeCenter.showAll)
showAttentionGrades.on("click", gradeCenter.showAttention)
reviewGrade.on("click", gradeCenter.review)
resetGrades.on("click", gradeCenter.reset)
// 表头复用与数据行完全相同的列宽,摘要仅 pending 响应式,6/3是固定示例统计。
gradeHeader = Row(0, 0,
gradeCell(TextBlock("学生", 1, "#7A8390"), 150),
gradeCell(TextBlock("学号", 1, "#7A8390"), 120),
@@ -101,6 +118,7 @@ gradeSummary = Row(0, 40,
Column(0, 6, TextBlock("学生人数", 1, "#7A8390"), TextBlock("6", 2)),
Column(0, 6, TextBlock("考试科目", 1, "#7A8390"), TextBlock("3", 2)),
Column(0, 6, TextBlock("待复核", 1, "#7A8390"), ui.textOf(gradeCenter, "pending", 2)))
// 最终一次 render 组合摘要、控制区和表格面板;message 绑定显示最近业务操作结果。
gradesPage = Page()
gradesPage.title = "学生成绩管理中心"
gradesPage.render(Column(32, 20,
+10
View File
@@ -1,16 +1,23 @@
// 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。
// 当前侧栏提供选择/关闭,文案中的拖放场景也可经版本化事件 API 测试;不要把它理解成已实现的侧栏拖动手势。
// 继承 Page 后观察 moveToWindow/moveTab 的真实方法调用,保留原生迁移/重排行为。
class TrackedPage: Page {
calls: Object
// 先递增 calls,再显式调用基类执行跨窗迁移;Page 身份保留,旧 Mount 上下文会替换。
function moveToWindow(self: TrackedPage, window: Window): Page {
self.calls.increment()
return Page.moveToWindow(self, window)
}
// position 是所属窗口内最终零基位置,重排不等于创建新的业务 Page。
function moveTab(self: TrackedPage, position: Number): Page {
self.calls.increment()
return Page.moveTab(self, position)
}
}
// 保存容器业务对象,按钮插入只改变其子槽顺序,已有 child 的槽与 Mount 尽量保留。
class Actions {
list: Column
// 在索引 1 前插入新 TextBlock,返回的稳定槽可供 remove 等后续操作使用。
function insert(self: Actions, event: Event): Unit {
self.list.insert(1, TextBlock("插入的新对象"))
}
@@ -20,6 +27,7 @@ list = Column(0, 8, TextBlock("第一个子项"), TextBlock("最后一个子项"
actions = Actions(list)
button = Button("在位置 1 插入 Block")
connection = button.on("click", actions.insert)
// A/C/目标页共享同一根及 list;各页激活时使用独立展示上下文,业务插入结果共享。
shared = Column(24, 16, TextBlock("拖动 Tab 重排、移入另一个窗口,或拖出独立窗口"), TextBlock("Page 方法调用次数"), ui.textOf(calls,"value"), button, list)
first = TrackedPage(calls)
first.title = "A · 共享内容"
@@ -30,6 +38,7 @@ second.render(Column(24, 16, TextBlock("这是 B,Page 身份随 Tab 移动保
third = TrackedPage(calls)
third.title = "C · 同一对象"
third.render(shared)
// 建立目标窗口并指定桌面位置,MoveTo 使用物理桌面坐标且可能受窗口管理器调整。
other = Window()
other.resize(720,520)
other.setTitle("Tab Target")
@@ -38,6 +47,7 @@ target = TrackedPage(calls)
target.title = "目标窗口"
target.render(shared)
target.open(other)
// 恢复源窗口首个页面,最后排出 resize/title/move/focus,便于手动或 API 验证。
first.activate()
ui.window().resize(800,600)
ui.window().setTitle("Tab Source")
+7 -1
View File
@@ -1,4 +1,7 @@
// Code and tab chrome operate the same Page objects.
// 代码与原生侧栏都操作真实 Page 对象;activate/close/open 改变展示关系,业务对象可继续被全局引用保留。
// 三个页面共存于同一 VM,最后 first.activate 选择初始展示页。
// 当前宿主通过侧栏管理页面;Page 的代码接口保持同一业务身份。
// 创建持有计数器与按钮的页面;切到其他页不等于销毁 count。
first = Page()
first.title = "共享计数"
count = ui.state(0)
@@ -6,12 +9,15 @@ increment = Button("加一")
connection = increment.on("click", count.increment)
first.render(Column(24,16,TextBlock("切换页面后,计数对象继续存在"),ui.textOf(count,"value"),increment))
// 另一页演示 Page 生命周期与快捷键;界面提示是示例文案,实际导航位于当前宿主侧栏。
second = Page()
second.title = "另一个页面"
second.render(Column(24,16,TextBlock("点击上方 Tab 切换;× 关闭页面"),TextBlock("Ctrl+Tab 切换,Cmd/Ctrl+W 关闭;代码可调用 first.open() 重开")))
// 独立内容页面仍用同一个运行时的原生类型和资源注册表。
third = Page()
third.title = "窗口与布局"
third.render(Column(24,16,TextBlock("每个 Tab 都是同一个 VM 中的 Page 对象"),RectBlock(240,80,8,"#237A45")))
// 重新激活第一页面,原生侧栏和源查询可观察相同 active 状态。
first.activate()
ui.window().setTitle("Endor Pages")
+8
View File
@@ -1,3 +1,6 @@
// 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。
// onMount 作为业务阶段收集结果;实际布局/绘制计算仍受只读规则约束。
// 把协议探测结果保存到普通业务字段,textOf 将其中一部分显示出来。
class VisualResults {
width: Number
height: Number
@@ -5,8 +8,10 @@ class VisualResults {
childY: Number
color: String
}
// 继承原生矩形,在自身挂载后用该 Mount 显式调用 measure/paint。
class ProbeRect: RectBlock {
results: VisualResults
// 有限最大宽 120 约束自然宽 200;另用自定义 Bounds 查询绘制颜色,把返回结果写进共享 results。
function onMount(self: ProbeRect, mount: Mount): Unit {
measured = self.measure(mount, Constraints(0, 0, 120, 80))
self.results.width = measured.width
@@ -15,14 +20,17 @@ class ProbeRect: RectBlock {
self.results.color = drawing.commands.at(0).color
}
}
// 在挂载回调中探测原生排列,复用当前结构的稳定子槽。
class ProbeColumn: Column {
results: VisualResults
// 给定绝对起点(20,30)及容器范围,原生 padding=24 使首子项位于(44,54)。
function onMount(self: ProbeColumn, mount: Mount): Unit {
positions = self.arrange(mount, Bounds(20, 30, 500, 300))
self.results.childX = positions.children.at(0).x
self.results.childY = positions.children.at(0).y
}
}
// 先创建结果容器,再将它作为源子类字段共享;root 的最后一个参数是该字段,不是视觉子项。
results = VisualResults(0, 0, 0, 0, "等待挂载")
rect = ProbeRect(200, 60, 12, "#DDEEFF", results)
root = ProbeColumn(24, 16, TextBlock("对象的原生视觉协议", 2), rect, TextBlock("约束测量宽度"), ui.textOf(results, "width"), TextBlock("绘制颜色"), ui.textOf(results, "color"), results)
+8
View File
@@ -1,17 +1,25 @@
// 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。
// 实际可用视口再返回布局约束,原生窗口尺寸不是业务对象上凭空写入的一份几何副本。
// 保存目标 Window,两个按钮对同一资源请求不同尺寸。
class WindowActions {
window: Window
// 请求 900x600 逻辑尺寸,最终大小仍由窗口管理器决定。
function compact(self: WindowActions, event: Event): Unit {
self.window.resize(900, 600)
}
// 请求 1120x720;同次输入多次 resize 可由命令队列合并到最后值。
function wide(self: WindowActions, event: Event): Unit {
self.window.resize(1120, 720)
}
}
// 原生矩形的测量覆盖展示父约束如何随窗口变化。
class WindowBand: RectBlock {
// 取当前出现位置 maxWidth 作为期望宽度,固定高 32;不读取其他窗口的尺寸。
function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(constraints.maxWidth, 32)
}
}
// ui.window 返回当前主窗口托管资源;page.open 指定同一资源,后面的 title/resize 为原生命令。
window = ui.window()
actions = WindowActions(window)
band = WindowBand(80, 32, 0, "#237A45")
+8 -1
View File
@@ -1,18 +1,25 @@
// Both native windows use one Aster VM and one shared business object.
// 两个原生窗口共用一个 Aster VM 和 count 业务对象,各自拥有独立 Page 与 Mount。
// 任一窗口点击都修改同一计数,两个 textOf 显示随之更新。关闭窗口解除 Page 归属,持有的业务对象仍可重开。
// 跨窗共享的是业务对象,不是某个窗口中的具体 Mount。
// 唯一计数来源;两个按钮直接使用同一零参数原生 increment 方法。
count = ui.state(0)
leftButton = Button("共享计数 +1")
leftConnection = leftButton.on("click",count.increment)
// 先在主窗口建立页面,render 默认选择该页并挂载它的内容。
left = Page()
left.title = "主窗口页面"
left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton))
// Window() 分配同 VM 中的独立窗口资源,不创建第二个运行时。
other = Window()
rightButton = Button("另一个窗口 +1")
rightConnection = rightButton.on("click",count.increment)
right = Page()
right.title = "独立窗口页面"
right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton))
// 把页面放到 other,上下文更换将重建出现位置,业务 count 仍不变。
right.open(other)
// 窗口命令针对各自资源编号排队;成功输入后再交 GUI,另一窗口设置自己的尺寸。
ui.window().setTitle("Endor Main")
other.setTitle("Endor Shared")
other.resize(960,640)
+13 -2
View File
@@ -1,6 +1,8 @@
// Paste the complete file into Console in Script mode, then Run.
// All numbers, progress values and activity entries below are demo data.
// 静态工作台组合示例:所有指标、进度与活动记录均为演示数据,不读取任务系统或网络。
// 辅助函数返回视觉子树,最后统一 Page.render;它们是普通 Endor 函数,不是宿主特殊组件语法。
// 将整个文件作为完整输入提交;这里没有实时指标提供方,全部数据由下面的常量生成。
// 把标签、大数值、说明和色条纵向组合,参数控制文字与强调颜色。
function metric(label: Text, value: Text, note: Text, color: Text): View {
return ui.column(0, 6,
ui.text(label, 1, "#637082"),
@@ -10,11 +12,14 @@ function metric(label: Text, value: Text, note: Text, color: Text): View {
)
}
// 生成项目标题和状态,再用两个相邻矩形表示已完成/剩余长度;filled/remaining 是逻辑像素。
function project(name: Text, state: Text, detail: Text, filled: Number, remaining: Number, color: Text): View {
// 项目名和状态横排,状态颜色与进度前景相同。
let caption = ui.row(0, 12,
ui.text(name, 1, "#243047"),
ui.text(state, 1, color)
)
// 两块矩形间 gap=0 合成连续条,条宽由调用方传入的演示数值决定。
let progress = ui.row(0, 0,
ui.rect(filled, 6, 3, color),
ui.rect(remaining, 6, 3, "#E5EAF2")
@@ -26,6 +31,7 @@ function project(name: Text, state: Text, detail: Text, filled: Number, remainin
)
}
// 组合小圆点、时间戳、标题与说明;spacer 仅作视觉对齐,不代表真实事件时间线。
function activity(time: Text, title: Text, detail: Text, color: Text): View {
let dot = ui.column(0, 0, ui.spacer(7), ui.rect(6, 6, 3, color))
let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0"))
@@ -36,6 +42,7 @@ function activity(time: Text, title: Text, detail: Text, color: Text): View {
)
}
// 头部三个文本使用字号/颜色层级,后续 statistics 复用 metric 返回三组指标。
let heading = ui.column(0, 6,
ui.text("ENDOR STUDIO / WORKSPACE", 1, "#5263D6"),
ui.text("项目工作台", 2, "#243047"),
@@ -48,6 +55,7 @@ let statistics = ui.row(0, 20,
metric("页面草稿", "08", "等待下一次灵感", "#D9A34A")
)
// 纵向复用 project,示例名称与比例是常量,不做进度计算或持久化。
let projects = ui.column(0, 14,
ui.text("项目进度", 1, "#243047"),
project("Aster", "进行中", "语言解析 / 编译 / 执行", 240, 60, "#6574E8"),
@@ -55,6 +63,7 @@ let projects = ui.column(0, 14,
project("设计文档", "草稿", "Block / Page / 对象模型", 108, 192, "#D9A34A")
)
// 三条 activity 组成静态活动区,再与项目区横排成 body。
let timeline = ui.column(0, 14,
ui.text("最近活动", 1, "#243047"),
activity("10:24", "更新字体", "中英文混排预览", "#6574E8"),
@@ -64,6 +73,7 @@ let timeline = ui.column(0, 14,
let body = ui.row(0, 20, projects, timeline)
// 底部提示以细色块和文字组合,明确说明页面还未接业务交互。
let note = ui.row(0, 12,
ui.rect(4, 46, 2, "#6574E8"),
ui.column(0, 6,
@@ -72,6 +82,7 @@ let note = ui.row(0, 12,
)
)
// 最终根统一设置 24px padding 与20px间距;创建辅助子树本身不会提前发布页面。
let page = ui.page()
page.render(ui.column(24, 20,
heading,
+13 -1
View File
@@ -1,8 +1,12 @@
// A normal Endor UI tree rendered on a transformed plane in the 3D world.
// 世界空间 UI:普通 Endor 树先在独立 UI 分辨率中布局/绘制,再作为 Panel3D 平面的纹理参与三维深度。
// 面板内还有 CanvasViewport,输入需经外层射线、面板 UV 和内层二维相机逐层转换。
// 面板内容仍是完整 Endor UI,可共享对象、绑定、源视觉协议与嵌套视口。
// 分别持有外层三维视口、面板变换和面板内二维画布,避免一次动作同时驱动多层相机。
class WorldActions {
view: Viewport3D
panel: Panel3D
canvas: CanvasViewport
// 重置三维相机位置与观察目标,不改变面板或内层画布业务状态。
function reset(self: WorldActions): Unit {
self.view.camera.x = 0
self.view.camera.y = 0.5
@@ -11,19 +15,24 @@ class WorldActions {
self.view.camera.targetY = 0
self.view.camera.targetZ = 0
}
// 只旋转 Panel3D 平面,面板内 UI 树仍用自己的逻辑宽高。
function turn(self: WorldActions): Unit { self.panel.rotate(0, 15, 0) }
// 找到画布当前 Mount,按该出现位置的有限尺寸 fitView,而非用外层三维视口大小。
function fit(self: WorldActions): Unit { self.canvas.mounts().at(0).fitView() }
}
count = ui.state(0)
increment = Button("点击:世界里的真实按钮")
increment.onClick(count.increment)
// 二维场景卡片使用带符号世界坐标;canvas 再提供有限可见区域和独立 camera。
card = Column(10, 6, TextBlock("CanvasViewport", 1), RectBlock(180, 26, 6, "#9CB8A8"))
card.boxWidth = 240
card.x = 10
card.y = 4
canvas = CanvasViewport(280, 80, Scene2D(card))
// 普通 Column、文字、按钮和二维画布组成 UI 树,按钮仍修改同一个 count。
ui_root = Column(8, 8, TextBlock("世界空间 UI", 2), ui.textOf(count, "value", 2), increment, canvas)
ui_root.crossAlign = "stretch"
// 4x3 是三维世界尺寸,320x240 是 UI 布局/纹理分辨率;两者独立控制显示比例与清晰度。
panel = Panel3D(4, 3, ui_root)
panel.uiWidth = 320
panel.uiHeight = 240
@@ -34,8 +43,10 @@ cube.x = 2
cube.rotationY = 25
floor = Box3D(10, 0.08, 5, "#D8DEE6")
floor.y = -1.8
// 面板与普通网格/地面共处一个深度场景,实体可遮挡面板,也阻挡被遮住的 UI 命中。
scene = Scene3D(panel, cube, floor, DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9))
scene.ambient = 0.35
// 外层 Viewport3D 作为二维布局里的 flex 子项;相机控制外部观察,而非 UI 内部滚动。
view = Viewport3D(960, 520, scene)
view.flex = 1
view.camera.y = 0.5
@@ -47,6 +58,7 @@ turn = Button("转动 UI 面板")
turn.onClick(actions.turn)
fit = Button("适配面板内的画布")
fit.onClick(actions.fit)
// 控制按钮保持在二维页面空间,外层三维内容作为一个区域随布局伸展。
body = Column(24, 16, TextBlock("UI → 3D 世界 → UI → Canvas", 2), view, Row(0, 12, reset, turn, fit), TextBlock("空白处左键拖动旋转 3D · 中键/空格平移 · 滚轮缩放\n面板里的按钮、文本、布局和画布都由同一份 Endor 对象驱动。"))
body.flex = 1
body.crossAlign = "stretch"
+12
View File
@@ -1,18 +1,28 @@
// 多窗口共享长段落:同一文字对象在每个 Mount 按当前宽度独立换行,后继位置跟随实际高度。
// 源子类显式委托原生 measure/paint,保留系统字体塑形、组合字符和 emoji 的行为。
// 没有独立宽度副本;约束与换行结果属于出现位置,文本内容属于共享对象。
class Paragraph: TextBlock {
// 将当前 Mount 与 Constraints 交给原生文字测量,宽度变化触发重测高度。
function measure(self: Paragraph, mount: Mount, constraints: Constraints): MeasureResult {
return TextBlock.measure(self, mount, constraints)
}
// 使用本次 geometry 画文字,和原生测量采用相同换行区域。
function paint(self: Paragraph, mount: Mount, geometry: Bounds): DisplayList {
return TextBlock.paint(self, mount, geometry)
}
}
// 保存主窗口、共享文字和原全文,四个按钮分别改窗口尺寸或改文字内容。
class Actions {
window: Window
paragraph: TextBlock
full: String
// 请求主窗口变窄;真正的 viewport 由原生尺寸回传后影响换行。
function narrow(self: Actions, event: Event): Unit { self.window.resize(800, 650) }
// 请求主窗口变宽,文字高度随之减小,不改另一窗口尺寸。
function wide(self: Actions, event: Event): Unit { self.window.resize(1200, 650) }
// 用短文本替换同一对象内容,两个窗口的文字都重新测量。
function short(self: Actions, event: Event): Unit { self.paragraph.setText("短文本:同一个对象、同一个 Mount。") }
// 恢复保存的 full 字符串,不创建新的 Paragraph。
function restore(self: Actions, event: Event): Unit { self.paragraph.setText(self.full) }
}
full = "GUI 和代码操控同一套 Aster 内存对象。这个段落同时出现在两个窗口:对象内容相同,每个 Mount 根据自己的可用宽度换行。窗口变窄,段落高度增加,后面的对象向下移动;窗口变宽,段落高度减小,后面的对象向上移动。对象身份和已有 Mount 身份保持稳定。English words wrap at word boundaries; a_long_unbroken_identifier_also_wraps_when_it_cannot_fit_on_one_line. 中文、组合字符 é 与 emoji 👩‍💻 使用同一套字体整形。再次修改文字会重新计算高度,并更新两个窗口,不需要复制业务对象。"
@@ -30,6 +40,7 @@ restoreConnection = restore.on("click", actions.restore)
page = Page()
page.title = "约束下的文字换行"
page.render(Column(24, 16, Row(0, 12, narrow, wide, short, restore), paragraph, tail))
// 新窗口有独立可用宽度,但 second.render 仍用同一 paragraph,所以两个 Mount 可有不同高度。
other = Window()
other.resize(720, 500)
other.setTitle("同一个段落 · 另一组宽度")
@@ -38,6 +49,7 @@ second = Page()
second.title = "共享 Paragraph"
second.render(Column(24, 16, paragraph))
second.open(other)
// 恢复主页面活动状态并设置原生窗口尺寸/标题/桌面位置,focus 命令在成功输入后交宿主执行。
page.activate()
ui.window().resize(800, 650)
ui.window().setTitle("文字换行与相邻布局")
+12 -1
View File
@@ -1,18 +1,27 @@
#!/bin/sh
# Native macOS build and app bundle. No Docker, mirror configuration, or tests.
# macOS 应用打包入口:构建 release 可执行文件并组装最小 Tessera.app;脚本本身不运行测试或部署。
# 执行会产生构建产物,应在已确认的 macOS 构建环境中调用。
# 任一命令失败或使用未定义变量即退出,避免在失败编译后继续打包旧文件。
set -eu
# 此入口只能在 macOS 使用;构建授权由调用方确认,脚本不自动执行测试或部署。
if [ "$(uname -s)" != Darwin ]; then
echo "Build this app bundle on macOS." >&2
exit 1
fi
# 以脚本所在目录的父目录作为仓库根;清空这一次 cd 的 CDPATH,防止用户 shell 配置改变目录解析。
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
# 锁定依赖并生成 release 可执行文件;附加参数原样交给 Cargo。
cargo build --release --locked "$@"
# 从 Cargo 包元数据读取首个 version 值,空值由 test 拒绝,不生成缺版本的 bundle。
version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ { print $2; exit }' Cargo.toml)
test -n "$version"
# macOS bundle 约定:Contents/MacOS 保存入口二进制,Contents/Info.plist 保存系统识别信息。
bundle="$(pwd)/target/release/Tessera.app"
mkdir -p "$bundle/Contents/MacOS"
# 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。
cp target/release/tessera "$bundle/Contents/MacOS/tessera.new"
mv -f "$bundle/Contents/MacOS/tessera.new" "$bundle/Contents/MacOS/tessera"
# 生成最小 macOS bundle 元数据;应用名、入口与高分辨率标志供系统启动/显示使用。
cat > "$bundle/Contents/Info.plist.new" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -27,5 +36,7 @@ cat > "$bundle/Contents/Info.plist.new" <<PLIST
<key>NSHighResolutionCapable</key><true/>
</dict></plist>
PLIST
# 元数据完整写入后再替换正式文件;这只是单文件替换,不表示整个 bundle 的构建具有事务回滚。
mv -f "$bundle/Contents/Info.plist.new" "$bundle/Contents/Info.plist"
# 标准输出返回最终 app 路径,便于构建调用方定位产物。
printf '%s\n' "$bundle"
+21 -8
View File
@@ -1,20 +1,22 @@
#!/usr/bin/env node
// Documentation-only tool. Install pinned packages in a separate temporary directory;
// UML_RENDER_TOOLS points there. No app dependencies or remote rendering service.
// 文档 UML 批量渲染入口:读取仓库内 .puml,在 rendered 中写入 SVG/PNG。
// 固定版本的工具包安装到独立临时目录,由 UML_RENDER_TOOLS 指定;不会修改应用依赖。
import { createRequire } from 'node:module';
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const tools = process.env.UML_RENDER_TOOLS;
// 工具依赖放在独立目录,避免给应用运行依赖增加文档渲染包。
if (!tools) throw new Error('Set UML_RENDER_TOOLS to the documentation tool directory; see docs/architecture/uml/README.md');
// 从工具目录的 package.json 创建专用 require,所有渲染依赖均从该目录解析,不依赖运行时 cwd。
const require = createRequire(path.join(path.resolve(tools), 'package.json'));
const { createCanvas } = require('@napi-rs/canvas');
const { DOMImplementation, XMLSerializer, DOMParser } = require('@xmldom/xmldom');
const sharp = require('sharp');
// PlantUML's official JS engine uses DOM construction and Canvas text metrics.
// Supply a real XML DOM and native Canvas metrics, not approximate text widths.
// 官方 PlantUML JS 引擎依赖 DOM、Graphviz 和 Canvas 文本度量。
// 在导入引擎前补齐全局环境,让 Node 进程可执行原本面向浏览器的布局过程。
globalThis.PLANTUML_VIZ = require('@viz-js/viz');
globalThis.window = globalThis;
globalThis.XMLSerializer = XMLSerializer;
@@ -22,14 +24,17 @@ globalThis.DOMParser = DOMParser;
const document = new DOMImplementation().createDocument(null, 'root', null);
const element = document.createElement.bind(document);
const elementNS = document.createElementNS.bind(document);
// 创建只用于文本度量的 1x1 Canvas。此对象不承载最终图片像素,字体实际尺寸由原生引擎返回。
function measurementCanvas() {
// PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。
const canvas = createCanvas(1, 1);
const context = canvas.getContext('2d');
const measure = context.measureText.bind(context);
// 包装测量结果,修正 PlantUML 用于行距的 ascent/descent;保留其他真实 metrics 字段。
context.measureText = text => {
const metrics = measure(text);
// PlantUML uses this height for line spacing. Font-wide ascent/descent
// keeps adjacent class fields from colliding when glyphs lack descenders.
// 优先使用整套字体的上下界,而非当前字符串字形界;
// 否则不带下伸部的文字可能使相邻类字段的行距过小而重叠。
return {
...metrics,
width: metrics.width,
@@ -39,10 +44,13 @@ function measurementCanvas() {
};
return canvas;
}
// 普通标签交给 XML DOM,只有 canvas 替换为可实际 measureText 的原生对象。
document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag);
document.createElementNS = (namespace, tag) => {
// 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。
const node = elementNS(namespace, tag);
node.style = {};
// 无浏览器布局树时只模拟当前文字节点的包围盒:字体来自 SVG 属性,y 从基线向上偏移。
node.getBBox = () => {
const context = measurementCanvas().getContext('2d');
context.font = `${node.getAttribute('font-size') || 14}px ${node.getAttribute('font-family') || 'Arial'}`;
@@ -60,26 +68,31 @@ document.body = element('body');
document.documentElement.appendChild(document.body);
globalThis.document = document;
const { renderToString } = await import(pathToFileURL(require.resolve('@plantuml/core')));
// 仓库位置由脚本 URL 确定,与调用者工作目录无关;输出目录不存在时创建。
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = path.join(root, 'docs/architecture/uml');
const output = path.join(source, 'rendered');
await mkdir(output, { recursive: true });
// 按文件名排序使处理顺序/日志稳定,只处理本目录的 .puml,不递归扫描产物目录。
for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) {
// 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。
const text = await readFile(path.join(source, filename), 'utf8');
// 将引擎 resolve/reject 回调适配为 Promise,逐图等待;渲染失败直接终止,不覆盖该图产物。
const rendered = await new Promise((resolve, reject) => renderToString(text.split('\n'), resolve, reject));
const xml = new DOMParser().parseFromString(rendered, 'image/svg+xml');
const labels = Array.from(xml.getElementsByTagName('text'), node => node.textContent).join('\n');
if (xml.documentElement.tagName !== 'svg' || /syntax error|error line|assumed diagram type|an error has occurr/i.test(labels)) {
throw new Error(`${filename}: renderer returned an error diagram\n${labels}`);
}
// Explicit opaque paper keeps arrows/labels legible in dark Markdown viewers.
// 在最前方加入不透明白纸背景,保证深色 Markdown 查看器中的箭头与文字仍可辨认。
const paper = xml.createElementNS('http://www.w3.org/2000/svg', 'rect');
for (const [key, value] of Object.entries({ x: '0', y: '0', width: '100%', height: '100%', fill: '#FFFFFF' })) paper.setAttribute(key, value);
xml.documentElement.insertBefore(paper, xml.documentElement.firstChild);
const svg = new XMLSerializer().serializeToString(xml);
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
const stem = filename.slice(0, -5);
await writeFile(path.join(output, `${stem}.svg`), svg);
// High-resolution PNG is the universally visible Markdown preview; SVG keeps zoomable text.
// 以 192 DPI 光栅化为 PNG 供通用预览;SVG 同时保留矢量文字和缩放能力。
const png = sharp(Buffer.from(svg), { density: 192 });
await png.png().toFile(path.join(output, `${stem}.png`));
const metadata = await sharp(path.join(output, `${stem}.png`)).metadata();
+2
View File
@@ -1,5 +1,7 @@
# Tessera 源码导航
本体代码已补充中文模块、类型、函数与关键流程注释;建议按[注释阅读索引和逐文件说明](../docs/code-comments.md)从启动、运行时发布和视觉协议依次阅读。
整体设计和当前进展从 [架构导览](../docs/architecture/README.md) 开始;本轮每个文件的迁移对应见 [逐文件清单](../docs/architecture/file-map.md)。
`main.rs` 只负责组合启动,并保留 crate 私有模块路径别名。五组目录仍在同一个 binary crate 中,不是严格单向分层。
+4 -1
View File
@@ -1,2 +1,5 @@
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 注册托管类型/宿主方法,验证参数并连接业务对象、Mount、视觉协议与窗口/任务资源。
pub(crate) mod ui_bridge;
File diff suppressed because it is too large Load Diff
+24
View File
@@ -1,7 +1,12 @@
//! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。
//! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。
//!
//! Allocate and connect the managed desktop, type tables and host globals.
use super::*;
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
// 先建立 desktop/ui/tabs 并加入显式 roots,后面初始化失败时由上层运行时统一释放,避免依赖 Rust 句柄保活。
let desktop = vm.new_object()?;
roots.push(desktop);
vm.set_attr(desktop, "viewport", Value::Nil)?;
@@ -10,6 +15,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
let ui = vm.new_object()?;
roots.push(ui);
// 原生 Session 借用托管身份,主窗口先拥有独立 Presentation;业务对象仍全部位于传入的单一 VM。
let session = Arc::new(Mutex::new(Session {
nodes: Vec::new(),
presentations: std::collections::BTreeMap::from([(
@@ -29,6 +35,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
binding_evaluations: 0,
exiting: false,
}));
// 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。
let mut public_functions = Vec::new();
let mut factories = Vec::new();
for (name, operation) in [
@@ -71,6 +78,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(ui, name, Value::Object(function))?;
public_functions.push((name, function, operation));
}
// Object/Type 来自 VM 类型引导,不能用普通类代替;之后所有宿主名义类型从此根体系建立。
let root_types = vm.bootstrap_types()?;
roots.extend([root_types.object, root_types.type_type]);
vm.set_attr(ui, "Object", Value::Object(root_types.object))?;
@@ -79,6 +87,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Object", root_types.object),
("Type", root_types.type_type),
];
// 类型按基类先于子类的顺序创建。None 表示抽象能力/宿主结果类型,不开放普通源构造器。
for (name, base_name, constructor_name) in [
("Trait", "Object", None),
("Function", "Object", None),
@@ -120,6 +129,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
] {
let base = object(Some(&vm.get_attr(ui, base_name)?))?;
let constructor = match constructor_name {
// ObjectList 与 Event 有特殊的“类型首参+子类字段”构造规则,单独原生适配;其他类型共用 NativeFactory。
Some("object-list") => {
let factory = vm.allocate(ContextNativeFunction::new(
"object-list",
@@ -146,6 +156,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
TypeConstructor::NativeTyped(factory)
}
Some(constructor_name) => {
// 把构造标签映射到宿主 Operation;3D 族委托其元数据,未知标签立即失败,不静默创建错误类型。
let operation = match constructor_name {
"text" => Operation::Text,
"rect" => Operation::Rect,
@@ -182,6 +193,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
}
None => TypeConstructor::Abstract,
};
// 记住实际工厂身份,类型构建完成后原地补签名,避免替换类型内指向的构造函数对象。
if let (Some(tag), TypeConstructor::NativeTyped(factory)) = (constructor_name, constructor)
{
factories.push((tag, factory));
@@ -191,6 +203,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(ui, name, Value::Object(definition))?;
definitions.push((name, definition));
}
// 协议值采用普通托管类:Constraints/Bounds/Point 等字段有类型注解,PaintCommand/CameraGesture 后续再初始化属性。
for (name, fields) in [
(
"Constraints",
@@ -208,6 +221,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
.into_iter()
.map(|name| endor_core::ClassField {
name: name.into(),
// children/commands 先声明 Object 接口,后续各协议解码器再要求受控列表;几何字段统一为 Number。
type_name: if matches!(name, "children" | "commands") {
"Object"
} else {
@@ -228,6 +242,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
}
// The desktop owns the registry through ordinary traced attributes. The
// Rust session only borrows handles and adds no hidden lifetime roots.
// 桌面通过普通属性拥有类型字典;后续 nominal 查询必须使用同一 desktop 中注册的精确类型身份。
let types = vm.new_dict()?;
roots.push(types);
for (name, definition) in definitions {
@@ -237,8 +252,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.attach_type(desktop, root_types.object)?;
vm.attach_type(ui, root_types.object)?;
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
// 让后续 VM 创建的函数使用宿主 Function 名义类型,并打开源类型检查,再安装完整本机签名。
vm.set_function_type(function_type)?;
vm.set_source_type_checks(true)?;
// 以同一函数身份补完 operation_signature,不改变先前 ui 属性引用的函数对象。
for (name, function, operation) in public_functions {
vm.replace_payload(
function,
@@ -280,6 +297,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
};
if let Some(operation) = operation {
let options = operation_signature(vm.object_store(), desktop, operation)?;
// 外层 NativeTyped 工厂接收 Type 首参和任意候选尾参,允许子类字段;NativeFactory 用 options 再核对基构造参数。
let envelope = CallSignature {
arity: endor_core::CallArity::range(options.arity.required + 1, usize::MAX),
parameters: vec![AnnotationType::Type.into()],
@@ -288,6 +306,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
effect: options.effect,
};
let mut captures = vec![desktop];
// 参数和返回注解中的名义类型也是托管引用,必须纳入 captures,不能只把它们存进 Rust 签名结构。
captures.extend(options.references());
vm.replace_payload(
function,
@@ -303,6 +322,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
),
)?;
} else {
// 特殊构造器保留宽变参外壳,再由对应 Constructor 按类型字段数拆实参,返回准确 Event/ObjectList 名义类型。
let kind = if name == "event" {
"Event"
} else {
@@ -373,6 +393,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let function = object(Some(&vm.get_attr(ui, name)?))?;
vm.attach_type(function, function_type)?;
}
// 实例方法安装到对应名义类型;继承复用基类方法,TypeMethod 会验证实际接收者及参数契约。
for (type_name, name, operation) in [
("Block", "bind", Operation::Bind),
("OrbitController", "update", Operation::CameraControl),
@@ -425,12 +446,14 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
expected,
session: session.clone(),
},
// 方法 wrapper 捕获所属类型与桌面,保证分发和签名引用在方法仍存活时有效。
vec![expected, desktop],
))?;
roots.push(method);
vm.attach_type(method, function_type)?;
vm.define_method(expected, name, method)?;
}
// 类型与基础方法就绪后再装窗口、默认视觉、事件方法和能力契约,顺序满足各模块查类型/方法的前提。
crate::window::install(vm, roots, desktop, tabs)?;
let block_type = object(Some(&vm.get_attr(ui, "Block")?))?;
crate::native_visual::install(vm, roots, desktop, block_type, function_type)?;
@@ -438,6 +461,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?;
crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?;
crate::protocols::install(vm, roots, desktop, ui)?;
// 每个桥接域配套自己的任务 Scheduler,再接工具入口及托管起始页工厂,最后返回可使用的 UiBridge。
let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
developer_pages::install(vm, ui, &session)?;
let bridge = UiBridge { ui, session, tasks };
+47
View File
@@ -1,18 +1,25 @@
//! 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。
//! 先检查参数和只读阶段限制,再修改托管状态;对象引用的生命周期仍交给 NativeContext 和 Aster 管理。
//!
//! Native Block operations executed on the owning VM worker.
use super::*;
impl NativeCallable for Function {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<CallSignature> {
self.signature.clone()
}
/// 接收已经剥离 receiver 的显式参数,数量沿用 Operation 的范围。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(self.operation.arity())
}
/// 统一校验数量与只读限制,再按 Operation 分派构造、树修改、页面、绑定、状态与能力调用,返回托管结果或 VM 续调用。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
use Operation::*;
let arity = self.operation.arity();
let (minimum, maximum) = (arity.required, arity.maximum);
// Function 接收已剥离 receiver 的显式参数;先校验数量,后面索引 args 才安全。
if !(minimum..=maximum).contains(&args.len()) {
return Err(invalid("incorrect UI method argument count"));
}
@@ -20,6 +27,7 @@ impl NativeCallable for Function {
let camera = new_camera2d(cx, &self.session, self.requested_type, args)?;
return Ok(NativeResult::Return(Value::Object(camera)));
}
// 3D 构造与普通 Block 分开,requested_type 保留源子类名义身份,否则从本 desktop 取默认类型。
if let SceneObject(kind) = self.operation {
let desktop = self
.session
@@ -52,6 +60,7 @@ impl NativeCallable for Function {
)?;
return Ok(NativeResult::Return(Value::Nil));
}
// implement 修改显式能力关系;implements 查类型,supports 查对象实际能力,不靠同名字段鸭子类型推断。
if matches!(self.operation, Implement | Implements | Supports) {
let capability = object(args.get(1))?;
let value = match self.operation {
@@ -86,6 +95,7 @@ impl NativeCallable for Function {
};
return Ok(NativeResult::Return(Value::Bool(value)));
}
// 显式失效属于模型变更,只读阶段拒绝;paint/layout/content 精度不同,后续分别清理对应缓存。
if matches!(self.operation, Invalidate) {
if cx.store().is_read_only() {
return Err(invalid(
@@ -109,6 +119,7 @@ impl NativeCallable for Function {
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
let mut affected = std::collections::HashSet::new();
// 找出解析到同一底层 Data 的所有别名,失效共享数据时不能只刷新调用者包装身份。
for weak in &session.nodes {
if let Some(block) = cx.upgrade(*weak)?
&& block::resolve(cx.store(), block)?.0 == data
@@ -141,6 +152,7 @@ impl NativeCallable for Function {
}
}
}
// 跨窗口 Mount 查询按窗口/出现 ID 稳定排序,不依赖 HashMap 遍历顺序;返回每个具体出现而非几何副本。
positions.sort_unstable_by_key(|(window, id, _)| (*window, *id));
let set = crate::mount::collection(
cx,
@@ -173,6 +185,7 @@ impl NativeCallable for Function {
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 关闭托管窗口后为 Presentation 排空 pending 并标 closing,后续刷新才安排 Mount 退役与空帧。
if let Some(view) = state.presentations.get_mut(&id) {
cx.set_attr(view.desktop, "pending", Value::Nil)?;
view.pending = Some(None);
@@ -211,6 +224,7 @@ impl NativeCallable for Function {
)?,
)));
}
// 诊断会读对象总数与宿主变化统计,禁止在只读渲染中观察并形成反馈依赖。
if matches!(self.operation, Stats) {
if cx.store().is_read_only() {
return Err(invalid(
@@ -281,6 +295,7 @@ impl NativeCallable for Function {
)?;
return Ok(NativeResult::Return(Value::Object(stats)));
}
// 状态是普通名义 Object,value 属性经 VM 追踪,set/increment 方法捕获同一对象,不额外建立原生状态副本。
if matches!(self.operation, State) {
let state = cx.new_object()?;
attach_named_type(cx, state, "Object", &self.session)?;
@@ -297,6 +312,7 @@ impl NativeCallable for Function {
let Value::Number(value) = cx.store().get_attr(owner, "value")? else {
return Err(invalid("increment requires an integer state"));
};
// increment 要求可表示为 i64 的整数且加一不溢出,不能把任意浮点状态强转整数。
let next = value
.as_i64()
.and_then(|n| n.checked_add(1))
@@ -309,6 +325,7 @@ impl NativeCallable for Function {
if matches!(self.operation, TextOf) {
let source = object(args.first())?;
let field = text(args.get(1))?;
// textOf 建立前确认字段可读取,具体字符串化与依赖记录发生在快照提取时。
cx.store().get_attr(source, &field)?;
let size = args
.get(2)
@@ -328,6 +345,7 @@ impl NativeCallable for Function {
0.0,
0.0,
);
// 保留源对象强边与字段名,不在创建时复制一个静态字符串;后续属性变化会让文字快照重算。
data.text_binding = Some((source, field));
return Ok(NativeResult::Return(Value::Object(new_block(
cx,
@@ -335,6 +353,7 @@ impl NativeCallable for Function {
&self.session,
)?)));
}
// 普通组件先组装 Data,再统一由 new_typed_block 安装 Cell、属性和名义类型;不直接写原生窗口。
let constructor = match self.operation {
Scroll => Some(Data::new(
Kind::Scroll {
@@ -390,6 +409,7 @@ impl NativeCallable for Function {
} else {
Kind::Column
},
// 线性容器固定前两参为 padding/gap,后续都是孩子对象;组成合法性在 Block 分配前统一检查。
args[2..]
.iter()
.map(|v| object(Some(v)))
@@ -416,6 +436,7 @@ impl NativeCallable for Function {
0.0,
0.0,
);
// 每个新二维视口默认新建独立 Camera2D;显式属性共享相机才会联动其他视口。
data.camera = Some(new_camera2d(cx, &self.session, None, &[])?);
Some(data)
}
@@ -445,6 +466,7 @@ impl NativeCallable for Function {
data.camera = Some(camera);
data.scene3d = Some(object(args.get(2))?);
let definition = desktop_type(cx.store(), "OrbitController", desktop)?;
// 三维视口默认独立透视相机及 OrbitController,Scene3D 引用则来自调用者,可显式共享世界。
data.controller = Some(crate::scene3d_model::allocate(
cx,
desktop,
@@ -454,6 +476,7 @@ impl NativeCallable for Function {
)?);
Some(data)
}
// 按钮标签也是普通 TextBlock 孩子,布局和生命周期沿同一组成树,不是原生按钮硬编码文字。
Button => {
let label = new_block(
cx,
@@ -473,6 +496,7 @@ impl NativeCallable for Function {
}
_ => None,
};
// 统一检查文本等级和 UTF-8 字节量,再按 requested_type 生成实际对象,保留子类工厂身份。
if let Some(data) = constructor {
if let Kind::Text { size, value, .. } = &data.kind
&& (!(1..=8).contains(size) || value.len() > 16_384)
@@ -496,6 +520,7 @@ impl NativeCallable for Function {
.map_err(|_| invalid("UI session poisoned"))?;
(state.master, state.desktop)
};
// 非 Block 对象若显式支持 Presentable,调用其 present;已有 Block 直接进入下面的展示转换。
let capability = desktop_type(cx.store(), "Presentable", desktop)?;
if !cx
.store()
@@ -522,6 +547,7 @@ impl NativeCallable for Function {
},
vec![desktop],
))?;
// 小驱动先调用 present(value, window),再把结果交 PresentResult 验证/可选打开,所有委托共用当前 VM 预算。
let instructions = [
endor_core::Instruction::new(endor_core::Opcode::LoadConst, 0),
endor_core::Instruction::simple(endor_core::Opcode::PushNull),
@@ -554,6 +580,7 @@ impl NativeCallable for Function {
});
}
}
// ui.open 把普通结果转换为根组件,再在必要时包一层 Page 并展示;ui.present 仅转换,不自动打开页面。
if matches!(self.operation, OpenValue) {
let root = present_value(cx, &args[0], &self.session)?;
let page = if cx.store().payload::<block::Block>(root, "Block")?.page {
@@ -575,6 +602,7 @@ impl NativeCallable for Function {
&self.session,
)?)));
}
// 从这里起都是需要接收者的 Block/Page 方法,未绑定 owner 时不能继续修改任意全局对象。
let owner = self.owner.ok_or(invalid("method has no owner"))?;
if matches!(self.operation, Bind) {
let field = text(args.first())?;
@@ -592,6 +620,7 @@ impl NativeCallable for Function {
)?,
)));
}
// moveTab 重排同窗口页面索引并强制下一帧更新标签;移动的是原 Page,非新页面副本。
if matches!(self.operation, MoveTab) {
let position = usize::try_from(integer(args.first())?)
.map_err(|_| invalid("tab position out of range"))?;
@@ -609,6 +638,7 @@ impl NativeCallable for Function {
view.force_frame = true;
return Ok(NativeResult::Return(Value::Object(owner)));
}
// 接收者可能是共享转发别名,解析到最终 Data 后修改副本,最后用一次 replace_payload 写入结构边。
let (data_ref, data) = block::resolve(cx.store(), owner)?;
let mut data = data.clone();
let mut result = Value::Nil;
@@ -620,6 +650,7 @@ impl NativeCallable for Function {
return Err(invalid("text exceeds 16 KiB"));
}
*value = next;
// 直接 setText 取代旧 textOf 推导;同时更新普通 text 属性,使快照属性读取与 payload 描述保持一致。
data.text_binding = None;
cx.set_attr(owner, "text", args[0].clone())?;
}
@@ -648,8 +679,10 @@ impl NativeCallable for Function {
(data.children.len(), args.first())
};
let child = object(argument)?;
// 插入前禁止孩子图回到当前 Data,避免循环组成;position 是插入位置,返回值却是稳定 slot。
block::validate(cx.store(), child, Some(data_ref))?;
let slot = data.next_slot;
// slot 单调增加且限制到 i64 正范围,不复用已删除身份;前后插入/重排不会把其他孩子 slot 改号。
data.next_slot = slot
.checked_add(1)
.filter(|next| *next <= i64::MAX as u64)
@@ -657,6 +690,7 @@ impl NativeCallable for Function {
data.children.insert(position, Child { slot, block: child });
result = Value::Number((slot as i64).into());
} else {
// remove/replace/move 首参为稳定 slot 而非当前数组下标;只有 move 的第二参才是目标位置。
let slot = integer(args.first())?;
let index = data
.children
@@ -670,6 +704,7 @@ impl NativeCallable for Function {
Replace => {
let child = object(args.get(1))?;
block::validate(cx.store(), child, Some(data_ref))?;
// replace 保留该组成边 slot,但换实际 Block;出现键还含 Block 身份,因此可能产生新的 Mount 上下文。
data.children[index].block = child;
}
Move => {
@@ -685,6 +720,7 @@ impl NativeCallable for Function {
}
}
}
// children 查询返回 slot文本->Block 的托管字典,不把当前顺序假装成稳定 slot 连号数组。
Children => {
let result = cx.new_dict()?;
for child in data.children {
@@ -703,6 +739,7 @@ impl NativeCallable for Function {
let root = object(args.first())?;
block::validate(cx.store(), root, Some(data_ref))?;
if matches!(self.operation, Navigate) {
// 首次 navigate 把当前根纳入历史,之后新导航先截断前进分支,再追加新根,保持浏览历史语义。
if data.history.is_empty() {
if let Some(old) = data.children.first() {
data.history.push(old.block);
@@ -713,6 +750,7 @@ impl NativeCallable for Function {
data.history.push(root);
data.history_index = data.history.len() - 1;
}
// 页面内容统一保留根 slot0,render/navigate 改的是根对象;show 只记录待展示请求,后续刷新再布局。
data.children = vec![Child {
slot: 0,
block: root,
@@ -724,6 +762,7 @@ impl NativeCallable for Function {
if data.history.is_empty() {
return Ok(NativeResult::Return(Value::Nil));
}
// 后退/前进钳到历史首尾,复用已保留根对象而非重新执行旧源码。
let next = if matches!(self.operation, Back) {
data.history_index.saturating_sub(1)
} else {
@@ -736,6 +775,7 @@ impl NativeCallable for Function {
}];
show(cx, owner, &self.session)?;
}
// 分离先创建新 Window,再把同一 Page 展示到它;跨窗口出现上下文由后续展示同步重建。
Detach => {
let window = {
let mut state = self
@@ -774,10 +814,12 @@ impl NativeCallable for Function {
show_in(cx, owner, &self.session, target)?;
return Ok(NativeResult::Return(Value::Object(owner)));
}
// unmount 清内容但不关闭 Page 标签,也不直接等同于销毁整个业务对象。
Unmount => {
data.children.clear();
}
Close => {
// 关闭 Page 当场取消页面和所属 Mount 任务,即使同一输入稍后又打开它,也不能复活旧异步工作。
crate::tasks::cancel_owner(cx, owner)?;
let mut state = self
.session
@@ -822,6 +864,7 @@ impl NativeCallable for Function {
}
let handler = object(args.get(1))?;
crate::event_dispatch::validate_handler(cx.store(), handler)?;
// 订阅默认非捕获;可用 Bool 或 capture/bubble 文本明确阶段,其他值拒绝而不做真值转换。
let capture = match args.get(2) {
None | Some(Value::Bool(false)) => false,
Some(Value::Bool(true)) => true,
@@ -829,6 +872,7 @@ impl NativeCallable for Function {
Some(Value::Text(phase)) if phase == "bubble" => false,
_ => return Err(invalid("phase must be capture or bubble")),
};
// 清掉已断开连接后再计算容量,保留仍有效订阅顺序;新 Connection 拥有 handler,返回 token 仅弱引用连接。
let mut active = Vec::new();
for token in &data.subscriptions {
if cx
@@ -856,6 +900,7 @@ impl NativeCallable for Function {
data.subscriptions = active;
result = Value::Object(token);
}
// 基类事件/挂载回调默认空实现,子类可覆盖;不在这条默认路径生成额外状态。
OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)),
Click | Dispatch => {
let desktop = self
@@ -863,6 +908,7 @@ impl NativeCallable for Function {
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
// click 新建种类为 click 的 Event,dispatch 使用调用者提供事件;两者都将可选具体 Mount 交传播模块验证。
let (event, mount) = if matches!(self.operation, Click) {
let definition = desktop_type(cx.store(), "Event", desktop)?;
(
@@ -877,6 +923,7 @@ impl NativeCallable for Function {
}
_ => return Err(invalid("unsupported Block operation")),
}
// 非提前返回的结构编辑/订阅操作在末尾统一经过写屏障,触发拥有边更新和变更日志。
cx.replace_payload(data_ref, data)?;
Ok(NativeResult::Return(result))
}
+18
View File
@@ -1,22 +1,29 @@
//! 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。
//!
//! Developer workspaces are managed Pages, using the same roots and placement API.
use super::*;
#[derive(Debug)]
/// 工具打开能力,保存所属会话和 ui 对象;调用时校验工具名,再在该 VM 中展示页面。
struct OpenTool {
session: Shared,
ui: ObjectRef,
}
impl NativeCallable for OpenTool {
/// 仅接受一个工具名称字符串,工具类型与白名单在 invoke 检查。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 把白名单工具名转换成对应托管 Page;New tab 转交预编译工厂,其余管理页复用已有实例或创建新页。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Text(name)] = args else {
return Err(invalid("developerTools requires a tool name"));
};
// 工具名白名单包含四个管理页和特殊新标签动作,拒绝任意字符串进入工厂/工具标记路径。
if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) {
return Err(invalid("unknown developer tool"));
}
// 新标签交给预编译 ui.newTab 托管工厂,经 NativeResult::Invoke 回到同一 VM 执行。
if name == "New tab" {
let factory = object(Some(&cx.store().get_attr(self.ui, "newTab")?))?;
return Ok(NativeResult::Invoke {
@@ -24,6 +31,7 @@ impl NativeCallable for OpenTool {
arguments: vec![],
});
}
// 锁内只复制弱节点索引,之后逐个升级并查工具标记,不持 session 锁运行 show 的流程。
let nodes = self
.session
.lock()
@@ -34,6 +42,7 @@ impl NativeCallable for OpenTool {
if let Some(page) = cx.upgrade(weak)?
&& cx.store().get_attr(page, "_tesseraTool").ok() == Some(Value::Text(name.clone()))
{
// 已有同类工具页则激活并聚焦其所属原生窗口,不每次打开都创建重复 Page。
show(cx, page, &self.session)?;
let window = {
let state = self
@@ -51,6 +60,7 @@ impl NativeCallable for OpenTool {
return Ok(NativeResult::Return(Value::Object(page)));
}
}
// 没有现存工具页才创建空托管 Page,_tesseraTool 标记让宿主在该页面位置绘制对应管理工具。
let page = new_typed_block(
cx,
Data::new(Kind::Page, vec![], 0.0, 0.0),
@@ -63,6 +73,7 @@ impl NativeCallable for OpenTool {
Ok(NativeResult::Return(Value::Object(page)))
}
}
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()> {
let function = vm.allocate(ContextNativeFunction::new(
"developerTools",
@@ -72,6 +83,7 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
},
vec![ui],
))?;
// 函数 captures 保活 ui;安装属性建立拥有边后释放局部分配根,避免额外常驻根。
vm.set_attr(ui, "developerTools", Value::Object(function))?;
vm.release(function)?;
Ok(())
@@ -79,7 +91,9 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
/// Compile the bundled Endor page once per VM, in a private module namespace.
/// Its functions/classes and callbacks remain ordinary managed VM objects.
/// 编译并安装 VM 私有的起始页工厂,供新页面动作重复调用。
pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
// 源码随 binary 嵌入,每个 VM 安装时解析/编译一次;函数工厂之后重复执行不再重复编译模块。
let source = frontend::EndorReader::from_text(
"tessera:start-page.endor",
include_str!("start_page.endor"),
@@ -89,6 +103,7 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
&ast,
&source,
&compiler::CompileOptions {
// 显式声明模块可见的 ui 与宿主类型名;私有 globals 不污染用户主会话的命名空间。
globals: std::iter::once("ui")
.chain(TYPE_NAMES.iter().copied())
.map(str::to_owned)
@@ -100,6 +115,7 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
let result = (|| -> Result<()> {
bridge.install_globals(vm, globals)?;
let code = compiled.load(vm)?;
// 执行模块定义类和工厂,使用单次预算;这里不主动调用 newTab,不在安装时打开页面。
let value = vm.execute_with_limits(
code,
globals,
@@ -110,12 +126,14 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
);
vm.release(code)?;
vm.release_value(value?)?;
// 要求模块导出 newTab,将函数保存到 ui 上;该函数通过托管引用维持自己的私有全局环境。
let factory = vm
.dict_get(globals, &Value::Text("newTab".into()))?
.ok_or_else(|| anyhow::anyhow!("start page module requires newTab"))?;
vm.set_attr(bridge.ui, "newTab", factory)?;
Ok(())
})();
// 无论编译代码执行或导出查找成功与否,释放私有字典的局部根,再返回保存的处理结果。
vm.release(globals)?;
result
}
+28
View File
@@ -1,12 +1,17 @@
//! 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。
//! 输入携带具体出现的 Mount;嵌套世界 UI 先做逆投影,避免把屏幕位移直接当成场景坐标。
//!
//! Camera mutations run on the owning VM worker, just like other page input.
use super::*;
impl UiBridge {
/// 解码指定 3D 视口的旋转/平移/缩放输入,再调用其托管相机控制器。
pub(super) fn dispatch_scene_view(
&self,
vm: &mut Vm,
input: &crate::events::Input,
) -> Result<()> {
// 锁内只读取当前发布版本与弱 Mount 身份,离开锁后才进入 VM 操作,避免持 session 锁调用用户代码。
let (weak, desktop) = {
let state = self
.session
@@ -29,6 +34,7 @@ impl UiBridge {
.upgrade(weak)?
.ok_or_else(|| anyhow::anyhow!("3D viewport Mount expired"))?;
let result = (|| {
// 除 session 发布版本外,再检查目标自身仍挂接、版本匹配且确为 3D 视口,拒绝旧出现操作。
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone();
anyhow::ensure!(
occurrence.mounted
@@ -41,6 +47,7 @@ impl UiBridge {
let camera = data
.camera
.ok_or_else(|| anyhow::anyhow!("missing 3D camera"))?;
// 显式配置 controller 才能接收 3D 手势;没有控制器的场景仍可显示,但不能自动修改相机。
let controller = data
.controller
.ok_or_else(|| anyhow::anyhow!("3D controller disabled"))?;
@@ -51,6 +58,8 @@ impl UiBridge {
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("3D camera delta required"))?;
// 缩放使用倍率而非二维位移;旋转/平移通过 logical_delta 处理嵌套二维/面板投影。
// 不可逆映射时忽略本次连续增量,不在无效坐标上继续更新相机。
let local_delta = if input.kind == "scene-zoom" {
[0., 0.]
} else {
@@ -59,6 +68,7 @@ impl UiBridge {
};
delta
};
// 可用锚点先映回所在视口空间;无法取得时使用内容左上角,传给控制器的是扣除 inset 的局部点。
let point = input
.point
.and_then(|p| geometry.view_point(crate::geometry::Point::new(p[0], p[1]), false))
@@ -67,6 +77,7 @@ impl UiBridge {
geometry.x + inset.left,
geometry.y + inset.top,
));
// 将输入封装为名义 CameraGesture 临时对象;不把原生 Input 或窗口指针直接交给源控制器。
let gesture = vm.new_object()?;
let result = (|| {
vm.attach_type(
@@ -77,6 +88,7 @@ impl UiBridge {
for (name, value) in [
("dx", local_delta[0]),
("dy", local_delta[1]),
// factor 复用原 delta 第一项,仅缩放种类按倍率解释;dx/dy 已经转换为逻辑位移。
("factor", delta[0]),
("width", (geometry.width - inset.horizontal()).max(0.)),
("height", (geometry.height - inset.vertical()).max(0.)),
@@ -85,6 +97,7 @@ impl UiBridge {
] {
vm.set_attr(gesture, name, crate::native_visual::value(value))?;
}
// 绑定 controller.update 保留接收者语义,再带 camera/gesture 两个显式参数进行有界调用。
let function = vm.bind_attr(controller, "update")?;
let result = match function {
Value::Object(function) => vm.call_with_limits(
@@ -97,22 +110,27 @@ impl UiBridge {
),
_ => Err(invalid("controller.update is not callable")),
};
// 先保存调用结果再释放绑定方法,错误路径同样清理;成功返回值也不保留为业务状态。
vm.release_value(function)?;
vm.release_value(result?)?;
Ok(())
})();
// 临时手势无论控制器成功或失败均释放,然后返回先前处理结果。
vm.release(gesture)?;
result
})();
// upgrade 得到的拥有根在闭包执行后统一释放,内部任一步失败也不会遗留该临时根。
vm.release(mount)?;
result
}
/// 把具体 CanvasViewport 的位移或缩放锚点映射到其显式 Camera2D。
pub(super) fn dispatch_viewport_view(
&self,
vm: &mut Vm,
input: &crate::events::Input,
) -> Result<()> {
// 二维视口同样先校验发布版本,再从活动表捕获原 Mount,不能因目标消失自动换成另一个视口。
let weak = {
let state = self
.session
@@ -146,6 +164,7 @@ impl UiBridge {
let camera = data
.camera
.ok_or_else(|| anyhow::anyhow!("viewport has no camera"))?;
// 读取已校验的相机快照作为本次基准;共享相机用户会通过普通属性变更观察到相同更新。
let old = crate::viewport::camera(vm.object_store(), camera)?;
let delta = input
.delta
@@ -165,6 +184,7 @@ impl UiBridge {
.ok_or_else(|| anyhow::anyhow!("zoom anchor cannot be projected"))?;
let node = block::snapshot(vm.object_store(), block)?;
let inset = crate::layout::insets(&node);
// 已验证指针落在出现外框还不够;扣除 inset 后必须落入实际内容盒,禁止用边框作为缩放锚点。
let local = [
pointer.x - geometry.x - inset.left,
pointer.y - geometry.y - inset.top,
@@ -176,6 +196,7 @@ impl UiBridge {
&& local[1] < geometry.height - inset.vertical(),
"zoom anchor is outside content viewport"
);
// 以实际钳制后的新 zoom 反求 pan,保持锚点下世界位置:old_pan + local/old_zoom = new_pan + local/new_zoom。
zoom = (old.zoom * delta[0]).clamp(0.01, 100.0);
x += local[0] / old.zoom - local[0] / zoom;
y += local[1] / old.zoom - local[1] / zoom;
@@ -183,9 +204,11 @@ impl UiBridge {
let Some(delta) = geometry.logical_delta(input.point, delta) else {
return Ok(());
};
// 屏幕/父层逻辑位移先撤销外层投影,再除本视口 zoom 转为相机世界偏移,不能直接加原像素增量。
x += delta[0] / zoom;
y += delta[1] / zoom;
}
// 把世界偏移限制回模型坐标预算,再走 set_attr 变更追踪,不直接改原生渲染快照。
x = x.clamp(-MAX_DIMENSION as f32, MAX_DIMENSION as f32);
y = y.clamp(-MAX_DIMENSION as f32, MAX_DIMENSION as f32);
vm.set_attr(camera, "x", crate::native_visual::value(x))?;
@@ -197,11 +220,13 @@ impl UiBridge {
result
}
/// 处理旧 Page 模式的兼容相机事件;独立视口使用各自的输入路径。
pub(super) fn dispatch_page_view(
&self,
vm: &mut Vm,
input: &crate::events::Input,
) -> Result<()> {
// 兼容 Page 相机不通过 Mount 定位,而是读取当前活动页面及其 Surface 副本;发布版本仍必须一致。
let (page, mut surface, viewport) = {
let state = self
.session
@@ -237,6 +262,7 @@ impl UiBridge {
&& viewport.is_none_or(|v| pointer.x < v.width && pointer.y < v.height),
"zoom anchor is outside viewport"
);
// 先保留旧缩放下锚点对应的世界点,再调整 zoom 并解新 pan,避免缩放时内容从光标下漂移。
let world = surface.to_world(pointer);
surface.zoom = (surface.zoom * delta[0]).clamp(0.1, 4.0);
surface.pan = crate::geometry::Point::new(
@@ -247,12 +273,14 @@ impl UiBridge {
surface.pan.x += delta[0] / surface.zoom;
surface.pan.y += delta[1] / surface.zoom;
}
// 有限页面按当前内容/视口钳制;Window 会重置导航,Canvas 则保留自由 pan/zoom。
if let Some(viewport) = viewport {
surface.clamp(viewport);
}
let limit = MAX_DIMENSION as f32;
surface.pan.x = surface.pan.x.clamp(-limit, limit);
surface.pan.y = surface.pan.y.clamp(-limit, limit);
// 最终回写活动 Page 的普通托管属性,后续刷新重新提取 Surface;此函数不直接发布原生帧。
vm.set_attr(page, "panX", crate::native_visual::value(surface.pan.x))?;
vm.set_attr(page, "panY", crate::native_visual::value(surface.pan.y))?;
vm.set_attr(page, "zoom", crate::native_visual::value(surface.zoom))?;
+21
View File
@@ -1,6 +1,10 @@
//! 声明宿主方法在 Endor 类型系统中的参数和返回契约。
//! 签名用于构造/调用检查,应与 calls 和各 NativeCallable 实现保持一致;这里不执行实际业务操作。
//!
//! Canonical contracts for native Block methods and constructors.
use super::*;
/// 根据宿主方法类别提供 Endor 方法的签名,供类型与调用检查使用。
pub(super) fn method_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -10,6 +14,8 @@ pub(super) fn method_signature(
use AnnotationType::{Any, Function, Nominal, Number, String as TextType, Unit};
use Operation::*;
let nominal = |name| desktop_type(store, name, desktop).map(Nominal);
// 先描述显式实参,不含隐式 receiver;后面统一在 parameters 首部补接收者。
// ReadOnly/Mutating/External 是 VM 调用效果契约,不是线程调度策略。
let (arguments, returns, effect) = match operation {
Translate3D | Rotate3D => (vec![Number, Number, Number], Unit, CallEffect::Mutating),
CameraControl => (
@@ -17,6 +23,7 @@ pub(super) fn method_signature(
Unit,
CallEffect::Mutating,
),
// 绑定创建可取消 Subscription,并改写目标连接关系,因此属于 Mutating。
Bind => (
vec![TextType, Function],
nominal("Subscription")?,
@@ -26,6 +33,7 @@ pub(super) fn method_signature(
Children => (vec![], Any, CallEffect::ReadOnly),
Invalidate => (vec![TextType], Unit, CallEffect::Mutating),
SetText | SetColor => (vec![TextType], Unit, CallEffect::Mutating),
// append/insert 返回稳定 slot 数值;remove/replace/move 返回 Unit,调用方不能把它们当返回孩子对象的集合方法。
Append => (vec![nominal("Block")?], Number, CallEffect::Mutating),
Insert => (
vec![Number, nominal("Block")?],
@@ -36,7 +44,9 @@ pub(super) fn method_signature(
Replace => (vec![Number, nominal("Block")?], Unit, CallEffect::Mutating),
Move => (vec![Number, Number], Unit, CallEffect::Mutating),
Render | Navigate => (vec![nominal("Block")?], Unit, CallEffect::Mutating),
// 链式 API 返回接收者名义类型,保留子类返回契约,而不是统一降为 Block。
OnClick => (vec![Function], Nominal(receiver), CallEffect::Mutating),
// 事件和生命周期方法可能执行任意用户回调,使用 External,不能冒充只读视觉查询。
OnEvent => (vec![nominal("Event")?], Unit, CallEffect::External),
OnMount | OnUnmount => (vec![nominal("Mount")?], Unit, CallEffect::External),
Click => (
@@ -67,6 +77,7 @@ pub(super) fn method_signature(
),
_ => return Err(invalid("unsupported native method signature")),
};
// Operation 维护显式参数的必需/最大个数;方法签名整体加一,将 receiver 纳入调用约定。
let arity = operation.arity();
let mut parameters = vec![Nominal(receiver)];
parameters.extend(arguments);
@@ -77,6 +88,8 @@ pub(super) fn method_signature(
returns: returns.into(),
effect,
};
// 少数参数/返回需要联合或可空类型,先用占位 Any 建基础签名,再按下标细化。
// On 第三个显式选项位于 parameters[3],可接受 Bool 或字符串。
match operation {
Open => signature.parameters[1] = CallType::nullable(nominal("Window")?),
PageWindow => signature.returns = CallType::nullable(nominal("Window")?),
@@ -86,6 +99,7 @@ pub(super) fn method_signature(
Ok(signature)
}
/// 将具体宿主 Operation 映射到参数/返回类型契约。
pub(super) fn operation_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -94,6 +108,7 @@ pub(super) fn operation_signature(
use AnnotationType::{Any, Bool, Number, String as TextType, Trait, Type, Unit};
use Operation::*;
let nominal = |name| desktop_type(store, name, desktop).map(AnnotationType::Nominal);
// 自由 UI 操作没有隐式 receiver;同时声明固定参数、可变尾参、返回名义类型和调用效果。
let (parameters, variadic, returns, effect) = match operation {
IsInstance => (vec![Any, Type], None, Bool, CallEffect::ReadOnly),
IsSubtype => (vec![Type, Type], None, Bool, CallEffect::ReadOnly),
@@ -101,6 +116,7 @@ pub(super) fn operation_signature(
Implements => (vec![Type, Trait], None, Bool, CallEffect::ReadOnly),
Supports => (vec![Any, Trait], None, Bool, CallEffect::ReadOnly),
OpenValue => (vec![Any], None, nominal("Page")?, CallEffect::Mutating),
// present 可能转交用户展示协议,效果交由被委托调用确定,不能声明成固定 ReadOnly。
Present => (vec![Any], None, nominal("Block")?, CallEffect::Delegated),
State => (vec![Any], None, Any, CallEffect::ReadOnly),
StateSet => (vec![Any], None, Unit, CallEffect::Mutating),
@@ -114,6 +130,7 @@ pub(super) fn operation_signature(
),
Window => (vec![], None, nominal("Window")?, CallEffect::ReadOnly),
NewWindow => (vec![], None, nominal("Window")?, CallEffect::Mutating),
// 这些构造操作返回新对象,ReadOnly 允许分配并初始化新值,但不允许因此修改已有业务对象。
TextOf => (
vec![Any, TextType, Number, TextType],
None,
@@ -139,6 +156,7 @@ pub(super) fn operation_signature(
nominal("ScrollBlock")?,
CallEffect::ReadOnly,
),
// 布局容器前两个固定参数是 Number,其后每个尾参都必须是 Block,返回类型按具体构造器选取。
Row | Column | Overlay => (
vec![Number, Number],
Some(nominal("Block")?),
@@ -164,6 +182,7 @@ pub(super) fn operation_signature(
nominal("Viewport3D")?,
CallEffect::ReadOnly,
),
// 3D 构造器参数来自 Kind 元数据:Number/String 映成基础注解,其余名称从本 desktop 注册表查名义类型。
SceneObject(kind) => {
let parameters = kind
.parameters()
@@ -176,6 +195,7 @@ pub(super) fn operation_signature(
.collect::<Result<Vec<_>, VmError>>()?;
(
parameters,
// 3D 集合尾参使用 Object 名义约束;各具体构造操作仍需在实现中检查允许的场景对象类别。
if kind.variadic() {
Some(nominal("Object")?)
} else {
@@ -205,6 +225,7 @@ pub(super) fn operation_signature(
),
_ => return Err(invalid("unsupported UI function signature")),
};
// 最终 arity 直接沿用 Operation,参数注解与可变尾参分开保存;不在此处执行操作或分配业务对象。
Ok(CallSignature {
arity: operation.arity(),
parameters: parameters.into_iter().map(CallType::from).collect(),
+28
View File
@@ -1,11 +1,16 @@
// The page, cards and actions belong to Endor. The host only owns its sidebar.
// 默认页由真实 Endor 对象组成:宿主负责窗口和侧栏,卡片布局、绘制和按钮回调在源代码中定义。
// Note 复用 Column 的内容测量,但固定卡片宽度;paint 只提供背景,孩子仍沿正常树绘制。
class Note: Column {
// 卡片构造的最后一个子类字段保存背景色;前面的变参仍按 Column 的孩子参数处理。
tint: String
function measure(self: Note, mount: Mount, constraints: Constraints): MeasureResult {
// 沿用 Column 内容测量得到高度,但卡片宽固定为 224;不把背景圆角绘制误当布局尺寸。
measured = Column.measure(self, mount, constraints)
return MeasureResult(224, measured.height)
}
function paint(self: Note, mount: Mount, geometry: Bounds): DisplayList {
// 只生成当前卡片背景,命令使用宿主给定 Bounds 的绝对布局位置,孩子仍由各自 paint 绘制。
fill = PaintCommand()
fill.kind = "roundedRect"
fill.x = geometry.x
@@ -14,23 +19,31 @@ class Note: Column {
fill.height = geometry.height
fill.radius = 14
fill.color = self.tint
// DisplayList 中的 commands 必须是受检查 ObjectList,且成员为 PaintCommand,不能返回普通字符串。
return DisplayList(ObjectList(fill))
}
}
// 继承 Button 的事件能力与内容布局,仅覆盖自身背景 paint。
class QuietButton: Button {
// 返回空绘制列表保留 Button 的交互能力,同时让这个入口只显示孩子文字。
function paint(self: QuietButton, mount: Mount, geometry: Bounds): DisplayList {
return DisplayList(ObjectList())
}
}
// 动作对象集中保存回调和 home 引用;按钮绑定方法会按托管引用关系保留动作对象。
class StartActions {
// 保存起始页面身份,子页面中的返回按钮激活同一 Page,而不是复制一份页面。
home: Page
// 窗口页面是普通有限 Column;可用区域来自宿主发布的逻辑视口。
function blank(self: StartActions, event: Event): Unit {
page = Page()
page.title = "窗口页面"
back = Button("返回画布")
// 绑定的是原 home 的 activate 方法;此回调恢复已有页身份,不重新构造主画布。
back.onClick(self.home.activate)
page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back))
}
// 延伸方向属于容器与滚动组合:Column 安排内容,有限 ScrollBlock 控制可见范围。
function vertical(self: StartActions, event: Event): Unit {
page = Page()
page.title = "纵向页面"
@@ -39,12 +52,15 @@ class StartActions {
content.append(Note(24, 16, TextBlock("02 / 连接片段", 2), RectBlock(176, 130, 10, "#E6DBEF"), TextBlock("内容保留在同一条连续的页面里。"), "#F4EEF7"))
content.append(Note(24, 16, TextBlock("03 / 慢慢展开", 2), RectBlock(176, 130, 10, "#EDDCC5"), TextBlock("追加一个子组件,内容自然延伸。"), "#FAF2E7"))
content.append(Note(24, 16, TextBlock("04 / 留一点空白", 2), TextBlock("不必把所有想法都放在第一屏。"), "#EDF1F6"))
// 固定初始视口内装完整内容;下一行限制滚动轴,flex 和页面 stretch 再让视口服从窗口分配。
view = ScrollBlock(1024, 800, content)
view.axis = "vertical"
view.flex = 1
page.crossAlign = "stretch"
// 最后提交有限视口作为 Page 内容,触发展示请求;真正的测量、绘制与原生发布由宿主刷新流程完成。
page.render(view)
}
// Row 内可继续放 Column 卡片;横向滚动不要求改变 Page 的身份或生命周期。
function horizontal(self: StartActions, event: Event): Unit {
page = Page()
page.title = "横向页面"
@@ -54,15 +70,20 @@ class StartActions {
content.append(Note(24, 16, TextBlock("继续", 2), RectBlock(176, 230, 10, "#EFDFC7"), TextBlock("Shift + 滚轮浏览更多内容。"), "#FAF3E8"))
content.append(Note(24, 16, TextBlock("不止一屏", 2), TextBlock("右侧仍有空间。"), "#EEF1F5"))
view = ScrollBlock(1024, 800, content)
// Row 自然宽度可超出视口,只有横向滚动;卡片内部仍是 Column 的纵向排版。
view.axis = "horizontal"
view.flex = 1
page.crossAlign = "stretch"
page.render(view)
}
// 重新调用工厂得到独立页面与相机,已有画布继续保留自己的视角。
function canvas(self: StartActions, event: Event): Unit {
// 每次动作都调用工厂,得到新 Page、世界树和独立相机,不复用当前页的视角状态。
page = newTab()
}
// 两个文字绑定观察同一个状态对象,示范共享业务状态与多个展示位置的区别。
function counter(self: StartActions, event: Event): Unit {
// 共享的是同一个托管状态对象;textOf 两次读取该对象 value,increment 更新后两个展示一起刷新。
count = ui.state(0)
add = Button("增加计数")
add.onClick(count.increment)
@@ -71,27 +92,33 @@ class StartActions {
page.render(Column(48, 24, TextBlock("一个对象,两个视图", 2), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add))
}
}
// 页面工厂:先建立业务对象/动作,再建立世界组成,最后用一个有限视口显示它。
function newTab(): Page {
page = Page()
page.title = "自由画布"
// 先建立引用当前 Page 的动作对象,再绑定按钮;回调中的 home 指向这一轮工厂创建的页面。
actions = StartActions(page)
fixed = Button("窗口页面")
vertical = Button("纵向延伸")
horizontal = Button("横向延伸")
canvas = Button("新的画布")
shared = QuietButton("试试:一个对象,两个视图 ↗")
// 订阅绑定方法而非提前调用;事件触发后在原 VM 中创建/展示相应示例页。
fixed.on("click", actions.blank)
vertical.on("click", actions.vertical)
horizontal.on("click", actions.horizontal)
canvas.on("click", actions.canvas)
shared.on("click", actions.counter)
hero = Column(0, 16, TextBlock("给想法,一点空间。", 3, "#303941"), TextBlock("页面可以是一扇窗口,也可以一直延伸。", 1, "#7D858C"), Spacer(8), Row(0, 10, fixed, vertical), Row(0, 10, horizontal, canvas), shared)
// x/y 属于 Scene2D 子组件的世界位置;卡片内部依旧由普通 Row/Column 排版。
hero.x = 316
hero.y = 254
// Scene2D 只规定孩子的世界位置;加入后仍可追加普通卡片,位置不受窗口宽高限制。
world = Scene2D(hero)
left = Note(24, 14, TextBlock("随手记下", 2), RectBlock(176, 100, 10, "#D5E4DA"), TextBlock("想法不必排成一条直线。"), "#EDF3E9")
left.x = 36
left.y = 92
// 将卡片以稳定 slot 加入场景;其 x/y 在前面单独设置,不依赖追加次序推算位置。
world.append(left)
top = Note(24, 14, TextBlock("四种页面,同一个世界"), TextBlock("窗口 · 纵向 · 横向 · 自由画布", 1, "#7D858C"), "#EFF1F7")
top.x = 466
@@ -109,6 +136,7 @@ function newTab(): Page {
end.x = 716
end.y = 614
world.append(end)
// 视口参加有限布局并占用剩余空间;移动它的 Camera2D 不改写卡片世界位置。
view = CanvasViewport(1024, 800, world)
view.flex = 1
page.crossAlign = "stretch"
+28
View File
@@ -1,8 +1,13 @@
//! 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。
//! 节点保存排版、绘制和协议投影信息;浅比较用于决定是否复用已有快照。
//!
//! Owned immutable visual derivation; no managed VM handles.
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq)]
/// 不可变视觉节点。block_id 关联业务身份,slots 保留子项出现的稳定组成;kind 和盒模型字段用于派生排版与绘制。
pub struct UiNode {
// 只有页面根带兼容 Page 相机;其余字段是普通有限盒子的布局策略。
pub surface: Option<crate::page::Surface>,
pub flex: f32,
pub flex_policy: crate::layout::FlexPolicy,
@@ -11,18 +16,23 @@ pub struct UiNode {
pub align_self: Option<crate::layout::CrossAlign>,
pub margin: crate::layout::Insets,
pub sizing: crate::box_model::Sizing,
// 单边可选覆盖值;None 表示沿用 kind 的统一 padding,不代表边距为零。
pub padding_edges: [Option<f32>; 4],
pub border: crate::layout::Insets,
pub border_color: String,
pub clip_children: bool,
// 这些字符串只是派生身份/协议版本键,不是可以操作托管堆的 ObjectRef。
pub block_id: String,
pub protocol_key: String,
// slot 保持组成身份;数组次序确定当前绘制/布局顺序,二者不是同一概念。
pub slots: Vec<u64>,
// 从按钮、点击连接或自定义事件等能力推导的交互资格,供命中宽阶段使用,不代表最终 hitTest 必然成功。
pub interactive: bool,
pub kind: UiKind,
}
impl UiNode {
/// 按节点种类返回其视觉子项;叶节点使用空切片,避免为遍历另建组成。
pub fn children(&self) -> &[Arc<UiNode>] {
match &self.kind {
UiKind::Column { children, .. }
@@ -38,14 +48,18 @@ impl UiNode {
}
}
/// 判断是否需要对子项执行弹性主轴分配。
pub fn has_flex(&self) -> bool {
// 不仅旧 flex 数值,非默认 FlexPolicy 或任一轴 Fill 也要求父节点执行分配,不能仅按自然尺寸排布。
self.flex > 0.0
|| self.flex_policy != crate::layout::FlexPolicy::default()
|| self.sizing.width == crate::box_model::Dimension::Fill
|| self.sizing.height == crate::box_model::Dimension::Fill
}
/// 判断当前节点是否会为子项指定实际分配范围,供源布局选择安排路径。
pub fn allocates_children(&self) -> bool {
// 任何孩子需要弹性或拉伸即进入分配路径;align_self 优先于父 cross_align。
self.children().iter().any(|child| {
child.has_flex()
|| child.align_self.unwrap_or(self.cross_align)
@@ -53,7 +67,9 @@ impl UiNode {
})
}
/// 比较节点自身配置和直接子项身份,决定能否复用旧 Arc;不是深拷贝业务图。
pub fn shallow_eq(&self, other: &Self) -> bool {
// 先比较所有公共视觉/协议配置,任一变化都不能复用旧节点 Arc,即使子内容完全相同。
if self.surface != other.surface
|| self.flex_policy != other.flex_policy
|| self.flex != other.flex
@@ -73,6 +89,7 @@ impl UiNode {
{
return false;
}
// 直接孩子按 Arc 指针比较而不是递归深比较;依赖提取保证未变子树复用 Arc,父级只需验证其身份。
let same_children = || {
self.children().len() == other.children().len()
&& self
@@ -81,6 +98,7 @@ impl UiNode {
.zip(other.children())
.all(|(a, b)| Arc::ptr_eq(a, b))
};
// 同类变体再比较其专有数据和直接子项;变体不同必为 false,避免布局种类切换后误复用缓存。
match (&self.kind, &other.kind) {
(
UiKind::Viewport3D {
@@ -198,7 +216,9 @@ impl UiNode {
}
#[derive(Debug, Clone, PartialEq)]
/// 视觉内容的原生分类。叶节点保存绘制数据,容器保存子树,场景视口另外保存相机与世界投影。
pub enum UiKind {
// 有限 3D 视口包含不可变场景视图和面板 UI 孩子;panels 与 children/slots 按顺序对应。
Viewport3D {
width: f32,
height: f32,
@@ -207,29 +227,35 @@ pub enum UiKind {
panels: Vec<crate::layout::Size>,
children: Vec<Arc<UiNode>>,
},
// 二维世界组成保留孩子的显式位置,不使用行列的累计偏移定位。
Scene2D {
positions: Vec<[f32; 2]>,
children: Vec<Arc<UiNode>>,
},
// 有限布局盒子观察独立 Scene2D,相机仅影响投影,不改变场景节点原始逻辑位置。
CanvasViewport {
width: f32,
height: f32,
camera: crate::viewport::CameraView,
children: Vec<Arc<UiNode>>,
},
// 孩子重叠布置,顺序同时决定叠放次序;普通有限布局与世界 Canvas 不同。
Overlay {
padding: f32,
children: Vec<Arc<UiNode>>,
},
// 旧 Page Canvas 模式的兼容投影,位置数组与孩子顺序一致,页面级 Surface 保存其相机。
Canvas {
positions: Vec<[f32; 2]>,
children: Vec<Arc<UiNode>>,
},
// 按钮是有子内容的容器,不把标签文本硬编码进 UiKind;交互资格另由 UiNode 标志表达。
Button {
padding: f32,
gap: f32,
children: Vec<Arc<UiNode>>,
},
// 文字叶节点保留源字号等级与颜色文本;排版及最终设备比例由后续布局/显示列表转换处理。
Text {
value: String,
size: u32,
@@ -251,7 +277,9 @@ pub enum UiKind {
gap: f32,
children: Vec<Arc<UiNode>>,
},
// 无视觉内容的间隔叶节点,提供布局占用尺寸。
Spacer(f32),
// 滚动盒保存有限分配尺寸、允许轴和普通内容;实际偏移按 Mount 出现分别维护,不放到共享节点里。
Scroll {
axis: crate::scroll::Axis,
width: f32,
+10
View File
@@ -1,3 +1,6 @@
//! 应用组合入口。先创建原生事件循环和共享发布状态,再启动 API/VM worker,最后把窗口事件交给 TesseraApp。
//! VM 操作由运行时线程串行处理;窗口线程只消费发布文档与纯数据通知。crate 私有别名用于保持模块内部引用路径稳定。
//!
//! Application composition root. Directory modules own implementation; these
//! crate-private aliases keep existing internal paths stable during reorganization.
mod bridge;
@@ -7,6 +10,7 @@ mod runtime;
mod shell;
pub(crate) use bridge::ui_bridge;
// 模型层负责从 VM 场景对象提取快照,渲染层 scene3d 负责原生计算;别名区分两者。
pub(crate) use model::scene3d as scene3d_model;
pub(crate) use model::{
binding, block, document, mount, object_list, presentation, protocols, snapshot, window,
@@ -31,12 +35,18 @@ use app::{AppEvent, TesseraApp};
use document::NamespaceState;
use winit::event_loop::{ControlFlow, EventLoop};
/// 按顺序建立事件循环、共享文档、API/VM worker 和应用窗口;事件循环结束后返回原生退出清理结果。
fn main() -> Result<()> {
// 原生窗口由事件循环所属线程驱动;Wait 让空闲时休眠,而不是持续重绘。
let event_loop = EventLoop::<AppEvent>::with_user_event().build()?;
event_loop.set_control_flow(ControlFlow::Wait);
// 共享最近完整发布的文档。后台 worker 拥有 VM,GUI 不借用其托管堆。
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
// worker 发布完整 NamespaceState,activity 供 Network 工具观察,control 用于原生窗口发运行时控制请求。
let (activity, control) = api::start(namespace.clone(), event_loop.create_proxy())?;
// 两个 proxy 指向同一事件循环;后台线程通过纯数据通知唤醒 GUI,不直接操作原生窗口。
let mut app = TesseraApp::new(namespace, activity, event_loop.create_proxy(), control)?;
// run_app 接管线程直至退出;退出后仍需检查清理过程中记录的错误。
event_loop.run_app(&mut app)?;
app.exit_result()
}
+34 -2
View File
@@ -1,3 +1,6 @@
//! 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。
//! 缓存记录读取版本而不是复制业务模型;依赖变化才重新计算。托管连接对象追踪其拥有的引用。
//!
//! Target-owned managed bindings. Dependency versions are borrowed identities;
//! the host's weak Block registry never roots an otherwise unreachable graph.
use std::{
@@ -13,27 +16,38 @@ use crate::{block, event_dispatch::Subscription, ui_bridge::attach_desktop_type}
/// The output value is borrowed for equality only, including object handles.
/// It never owns a reference or dereferences a possibly reclaimed cached object.
#[derive(Debug, Clone)]
/// 上次绑定计算的读取观察,用于判断是否需要重新执行源计算。
struct Cached {
// 每项保存读取对象/属性的版本观察;刷新时向对象存储核对,而非复制业务值。
reads: Vec<endor_core::ReadDependency>,
// 只借用上次结果的身份做相等比较;不能解引用其中可能已被回收的对象。
output: Value,
}
#[derive(Debug, Clone)]
/// 托管属性连接,强持有计算/目标所需引用,并保留只读计算的版本缓存。
pub struct Binding {
// 目标属性名;目标 Block 通过 bindings 列表拥有本连接。
field: String,
// Some 表示连接有效且拥有计算函数;取消/替换后置 None,让 trace 停止保活函数。
pub compute: Option<ObjectRef>,
// payload 克隆共享同一缓存,刷新局部副本也能更新缓存;首次计算前为 None。
reads: Arc<Mutex<Option<Cached>>>,
}
impl ObjectPayload for Binding {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-binding"
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
if let Some(compute) = self.compute {
visit(compute);
@@ -41,6 +55,7 @@ impl ObjectPayload for Binding {
}
}
/// 检查绑定目标与可调用计算,建立托管连接及其引用关系。
pub fn connect(
cx: &mut NativeContext<'_>,
target: ObjectRef,
@@ -48,12 +63,13 @@ pub fn connect(
compute: ObjectRef,
desktop: ObjectRef,
) -> Result<ObjectRef, VmError> {
// 在触碰连接列表前拒绝非法名称,尤其禁止绑定运行时类型属性。
if field.is_empty() || field.len() > 128 || field == "type" {
return Err(VmError::InvalidArgument("invalid binding property"));
}
let (reference, data) = block::resolve(cx.store(), target)?;
let mut data = data.clone();
// Only actual fields can be bound; method names are not writable targets.
// 只允许属性存储里已存在的字段;方法名即使可读取,也不能作为绑定写入目标。
let properties = data
.properties
.ok_or(VmError::InvalidArgument("missing property storage"))?;
@@ -63,6 +79,7 @@ pub fn connect(
"binding compute must be a Function",
));
}
// 刷新统一以零参数调用 compute,因此必须有参数元数据并且接受零实参。
if !cx
.store()
.call_arity(compute)?
@@ -75,6 +92,8 @@ pub fn connect(
"binding compute requires zero unbound parameters",
));
}
// 同一字段只保留一个有效计算。其他字段连接沿用;旧同字段连接失效并清缓存,
// 使外部仍持有的 Subscription 不会继续驱动被替换的计算。
let mut retained = vec![];
for connection in &data.bindings {
let mut binding = cx
@@ -102,11 +121,12 @@ pub fn connect(
})?;
retained.push(connection);
data.bindings = retained;
// Generic text bindings replace the legacy textOf derivation.
// text 改由通用绑定负责后,清除旧 textOf 推导入口,避免两套机制竞争同一属性。
if field == "text" {
data.text_binding = None;
}
cx.replace_payload(reference, data)?;
// 返回的取消令牌仅弱引用连接;连接生命期由目标 Block 的 bindings 决定。
let token = cx.allocate(Subscription {
connection: cx.store().downgrade(connection)?,
})?;
@@ -114,19 +134,23 @@ pub fn connect(
Ok(token)
}
/// 在只读调用中重算失效绑定,采集新的读取版本,再将成功结果应用到目标属性。
pub fn refresh(
vm: &mut Vm,
nodes: &[WeakObjectRef],
host_desktops: &HashSet<ObjectRef>,
) -> Result<usize> {
// work 跨所有轮次累计。属性 A 的写入可能使已检查的 B 失效,所以不能只扫描一次。
let mut work = 0;
loop {
let mut evaluated = false;
// 每轮按解析后的真实 Block 身份去重,多个包装对象共享数据时只处理一次。
let mut seen = HashSet::new();
for weak in nodes {
let Some(target) = vm.upgrade(*weak)? else {
continue;
};
// 用闭包暂存失败,让下方 release 无论处理成功与否都能释放 upgrade 的强引用。
let result: Result<()> = (|| {
let (reference, data) = block::resolve(vm.object_store(), target)?;
if !seen.insert(reference) {
@@ -143,6 +167,8 @@ pub fn refresh(
.lock()
.map_err(|_| anyhow::anyhow!("binding cache poisoned"))?
.clone();
// 两项都成立才能跳过:读取依赖版本未变,且目标仍等于上次输出。
// 后一项让外部直接改写目标后,绑定仍能恢复其计算值。
if cached.as_ref().is_some_and(|cached| {
cached
.reads
@@ -154,11 +180,13 @@ pub fn refresh(
}) {
continue;
}
// 防止相互写入的绑定持续振荡;预算跨轮累计,超限向宿主报告不稳定。
work += 1;
if work > 10_000 {
bail!("property bindings did not stabilize within the execution budget");
}
evaluated = true;
// 计算阶段禁止写入,精确记录本次实际读取;单次调用另设帧数与指令上限。
let (value, reads) = vm
.call_read_only_precise_with_limits(
compute,
@@ -170,12 +198,14 @@ pub fn refresh(
)
.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
let applied: Result<()> = (|| {
// 宿主桌面对象不纳入此缓存依赖;保留业务读取,避免宿主发布状态驱动重算。
let reads = reads
.dependencies
.into_iter()
.filter(|read| !host_desktops.contains(&read.object()))
.collect();
let (_, data) = block::resolve(vm.object_store(), target)?;
// 先在数据副本上验证属性类型/范围,验证通过后才写回托管属性。
block::apply_visual_property(&mut data.clone(), &binding.field, &value)?;
if vm.get_attr(target, &binding.field)? != value {
vm.set_attr(target, &binding.field, value.clone())?;
@@ -190,6 +220,7 @@ pub fn refresh(
});
Ok(())
})();
// 缓存不拥有输出对象;释放调用返回值后再传播应用错误,避免失败路径漏引用。
vm.release_value(value)?;
applied?;
}
@@ -198,6 +229,7 @@ pub fn refresh(
vm.release(target)?;
result?;
}
// 整轮无需计算才达到固定点;即使某次计算输出未变,也再扫描一轮确认依赖有效。
if !evaluated {
return Ok(work);
}
+75
View File
@@ -1,3 +1,7 @@
//! Block 的共享赋值、底层 Data 与视觉属性实现。Block 通过 Cell 间接指向 Data,使别名能观察到同一次替换。
//! Data 保存子组件 slot、布局配置、绑定和订阅。属性读写需要沿有效的类型/存储路径解析,不能把快照当成可变业务对象。
//! 提取视觉数据时将托管属性覆盖到原生描述;校验负责拒绝循环、非法尺寸和不符合类型的组成。
//!
//! Live visual objects. All semantic references are owned by Aster's heap;
//! native Arcs are reserved for immutable rendering snapshots.
use std::sync::Arc;
@@ -7,12 +11,15 @@ use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, Vm
use crate::ui_bridge::UiNode;
#[derive(Debug, Clone)]
/// 稳定组成 slot 与子 Block 的配对;slot 不依赖孩子当前的屏幕位置。
pub struct Child {
// slot 标识父组件中这条组成边,移动行序不改变它;同一 Block 可通过不同 slot 出现多次。
pub slot: u64,
pub block: ObjectRef,
}
#[derive(Debug, Clone)]
/// 原生组件分类,只描述业务组件种类;每种展示几何由布局和 Mount 另行生成。
pub enum Kind {
Text {
value: String,
@@ -49,20 +56,25 @@ pub enum Kind {
/// The VM owns the Block -> Cell -> Data relationship. Shared assignment
/// changes the Cell to point at another Block, retaining the original identity.
#[derive(Debug, Clone)]
/// 对共享 Cell 的稳定包装。赋值可让所有别名一起改指向,而每次出现的 Mount 仍独立。
pub struct Block {
// 共享赋值单元 Cell;其值可为最终 Data 或另一个 Block,从而保留别名身份并转发属性/类型查询。
pub state: ObjectRef,
pub page: bool,
pub tasks: Option<ObjectRef>,
}
impl ObjectPayload for Block {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 沿 Block 的共享 Cell 定位属性存储,使别名替换后读取到同一份新 Data。
fn attribute_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
let Value::Object(value) = store.cell_get(self.state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
// 若 Cell 指向替代 Block,将属性查询继续转发;否则读取 Data.properties 的普通托管字段。
if store.object(value)?.payload().as_any().is::<Block>() {
Ok(Some(value))
} else {
@@ -76,6 +88,7 @@ impl ObjectPayload for Block {
}
}
/// 从 Block 当前 Data 解析其类型转发目标,使共享替换后的类型查询仍一致。
fn type_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
let Value::Object(value) = store.cell_get(self.state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
@@ -84,25 +97,31 @@ impl ObjectPayload for Block {
Ok(Some(value))
} else {
store.payload::<Data>(value, "Block data")?;
// 到达最终 Data 时停止类型转发,使用当前 Block 自身名义类型;只有替换为其他 Block 才继续转发类型。
Ok(None)
}
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-block"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
visit(self.state);
if let Some(tasks) = self.tasks {
visit(tasks);
}
}
/// 向 VM 暴露共享赋值单元;替换的是指向关系,不是逐项复制组件业务字段。
fn assignment_cell(
&self,
source: ObjectRef,
@@ -111,17 +130,20 @@ impl ObjectPayload for Block {
if !store.object(source)?.payload().as_any().is::<Self>() {
return Ok(None);
}
// Page 与普通组件不能交叉共享赋值,防止把窗口/页面生命周期能力替换成普通叶组件。
if store.payload::<Self>(source, "Block")?.page != self.page {
return Err(VmError::InvalidArgument(
"Page assignment requires another Page",
));
}
// 建立转发前检查源展开图不能回到本 Cell,禁止共享赋值在别名或组成关系中形成环。
validate(store, source, Some(self.state))?;
Ok(Some(self.state))
}
}
#[derive(Debug, Clone)]
/// 组件当前业务描述与组成。与一次求出的屏幕几何分开保存,替换或属性变化会触发派生快照失效。
pub struct Data {
pub kind: Kind,
pub children: Vec<Child>,
@@ -134,6 +156,7 @@ pub struct Data {
pub align_self: Option<crate::layout::CrossAlign>,
pub margin: crate::layout::Insets,
pub sizing: crate::box_model::Sizing,
// 四边覆盖顺序为上、右、下、左;None 表示回退统一 padding,不等于零。
pub padding_edges: [Option<f32>; 4],
pub border: crate::layout::Insets,
pub border_color: String,
@@ -144,18 +167,23 @@ pub struct Data {
pub scroll_axis: crate::scroll::Axis,
pub position: [f32; 2],
pub surface: crate::page::Surface,
// 下次插入的单调 slot,初始孩子按 0..len 编号,后续重排不把旧 slot 重新编号。
pub next_slot: u64,
// Page 导航历史是真实托管拥有边;后退/前进可重新展示同一个对象,而非复制快照。
pub history: Vec<ObjectRef>,
pub history_index: usize,
pub bindings: Vec<ObjectRef>,
pub text_binding: Option<(ObjectRef, String)>,
pub click: Option<ObjectRef>,
pub subscriptions: Vec<ObjectRef>,
// 源可读写的视觉字段存储;Data 自身保存结构边与构造默认值,提取时再将当前属性覆盖到派生副本。
pub properties: Option<ObjectRef>,
}
impl Data {
/// 建立组件业务描述,为初始孩子分配稳定 slot,并初始化无绑定、无订阅的默认布局策略。
pub fn new(kind: Kind, children: Vec<ObjectRef>, padding: f32, gap: f32) -> Self {
// 先为输入孩子分配稳定 slot,next_slot 从当前长度继续,后续追加不会复用被删除条目的编号。
let next_slot = children.len() as u64;
Self {
kind,
@@ -196,7 +224,9 @@ impl Data {
properties: None,
}
}
/// 检查原生组件种类是否承载子组成,供树修改和布局选择使用。
pub fn is_container(&self) -> bool {
// 这些类别允许通过通用 Children 方法编辑;Viewport3D 的面板内容来自 Scene3D 提取,不是普通 children 列表。
matches!(
self.kind,
Kind::Row
@@ -212,16 +242,21 @@ impl Data {
}
impl ObjectPayload for Data {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-block-data"
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// 所有结构字段中出现的托管强引用必须枚举:场景/相机、属性存储、孩子、历史、绑定、订阅和回调。
if let Some(scene) = self.scene3d {
visit(scene);
}
@@ -255,17 +290,21 @@ impl ObjectPayload for Data {
}
}
/// 分配 Data、共享 Cell 和 Block 包装,并让托管图拥有必要引用。
pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef, VmError> {
let page = matches!(data.kind, Kind::Page);
// 分配拥有边前检查每个孩子都是有效有限 Block 图;非法组成直接失败,不截断部分 UI。
for child in &data.children {
validate(cx.store(), child.block, None)?;
}
// 把原生默认参数显式写成普通属性,使源赋值、绑定及精确依赖跟踪观察到同一存储。
let properties = cx.new_object()?;
for (name, value) in native_properties(&data) {
cx.set_attr(properties, name, value)?;
}
data.properties = Some(properties);
let data = cx.allocate(data)?;
// 最终建立 Block -> Cell -> Data -> properties/children 拥有链,均在 VM 中参与追踪和循环回收。
let state = cx.new_cell(Some(Value::Object(data)))?;
cx.allocate(Block {
state,
@@ -276,11 +315,13 @@ pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef,
/// Borrowed final data; forwarding is part of the managed graph, never an Arc
/// ownership graph hidden from the VM's cycle collector.
/// 解析一个 Block 当前指向的 Data,返回底层身份和借用描述。
pub fn resolve(store: &ObjectStore, block: ObjectRef) -> Result<(ObjectRef, &Data), VmError> {
let (reference, data, _) = resolve_tracked(store, block)?;
Ok((reference, data))
}
/// 解析 Block 的完整引用路径,并返回快照失效所需的依赖身份。
pub fn resolve_tracked(
store: &ObjectStore,
block: ObjectRef,
@@ -289,6 +330,7 @@ pub fn resolve_tracked(
let mut seen = std::collections::HashSet::new();
let mut dependencies = Vec::new();
loop {
// 解析整条转发路径防环;依赖收集同时记录每层 Block 与 Cell,任一转发变更都会让视觉缓存失效。
if !seen.insert(current) {
return Err(VmError::InvalidArgument("cyclic Block reference"));
}
@@ -297,6 +339,7 @@ pub fn resolve_tracked(
let Value::Object(value) = store.cell_get(state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
// 遇到 Data 返回最终存储身份与借用引用;否则下一轮要求该值必须是 Block,不接受任意对象。
if store.object(value)?.payload().as_any().is::<Data>() {
dependencies.push(value);
return Ok((
@@ -311,11 +354,13 @@ pub fn resolve_tracked(
/// Validate the entire finite expansion before establishing a new composition
/// edge. The node count is an explicit rejected resource quota, not truncated UI.
/// 完整展开 Block/Cell/孩子图,拒绝环、禁止目标回边及超过 10000 次展开的组成。
pub fn validate(
store: &ObjectStore,
root: ObjectRef,
forbidden_state: Option<ObjectRef>,
) -> Result<(), VmError> {
// 显式进入/退出栈模拟深度优先遍历;退出时移除祖先链标记,允许共享子树。
enum Step {
Enter(ObjectRef),
Exit(ObjectRef),
@@ -329,11 +374,13 @@ pub fn validate(
path.remove(&state);
}
Step::Enter(block) => {
// 预算按实际展开次数而非不同对象数计算,共享子树重复出现也计入资源工作量。
visits += 1;
if visits > 10_000 {
return Err(VmError::ExecutionLimit("expanded Block graph"));
}
let state = store.payload::<Block>(block, "Block")?.state;
// path 只追踪当前祖先 Cell,允许其他分支共享同一组件;forbidden_state 额外阻止新边回到将被修改的目标。
if forbidden_state == Some(state) || !path.insert(state) {
return Err(VmError::InvalidArgument(
"Block composition would create a cycle",
@@ -348,6 +395,7 @@ pub fn validate(
"Block composition would create a cycle",
));
}
// Data 展开孩子,非 Data 则沿共享赋值转发链继续;显式 Exit 在子树完成后清除祖先标记。
if let Ok(data) = store.payload::<Data>(value, "Block data") {
for child in data.children.iter().rev() {
work.push(Step::Enter(child.block));
@@ -361,16 +409,21 @@ pub fn validate(
Ok(())
}
/// 用一次性空 RenderCache 提取不可变视觉树;不跨调用保存快照缓存。
pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result<Arc<UiNode>, VmError> {
// 一次性提取使用空缓存,不保留跨调用结果;有增量复用需求的窗口应使用自己的 RenderCache。
crate::snapshot::RenderCache::default().snapshot(store, root)
}
/// Native visual parameters are ordinary managed attributes too. Composition,
/// histories and connections remain structural payload edges.
/// 列出本类型可投影到托管属性的原生字段及默认值。
pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
fn number(value: f32) -> Value {
Value::Number((value as f64).into())
}
// 先枚举每种 Block 都有的定位、弹性、盒尺寸、边框与对齐属性,再按 kind 增补专有字段。
let mut fields = vec![
("x", number(data.position[0])),
("y", number(data.position[1])),
@@ -425,6 +478,7 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
{
fields.push((
name,
// 单边 padding 的 auto 编码成文本,不用数值哨兵,读取时才能区分“继承统一值”和显式零。
edge.map_or_else(|| Value::Text("auto".into()), number),
));
}
@@ -475,6 +529,7 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
Kind::Spacer(size) => fields.push(("size", number(*size))),
_ => {}
}
// 容器才暴露统一 padding/gap 与主交叉轴策略;叶节点不因同名自定义字段被当成通用容器。
if data.is_container() {
fields.extend([
("padding", number(data.padding)),
@@ -486,7 +541,9 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
fields
}
/// 复制原生描述并用当前托管属性覆盖它;结果只用于展示,不反向修改业务对象。
pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
// 只改本轮派生副本,逐个读取实际属性并验证;读取字段名集合来自 native_properties,不遍历任意用户字段。
let mut data = data.clone();
let properties = data
.properties
@@ -495,6 +552,7 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
let value = store.get_attr(properties, name)?;
apply_visual_property(&mut data, name, &value)?;
}
// 各单值合法仍可能存在跨字段矛盾,必须在全部覆盖完成后校验 min/max 关系。
if data.flex_policy.min > data.flex_policy.max {
return Err(VmError::InvalidArgument("flexMin exceeds flexMax"));
}
@@ -510,6 +568,7 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
"CanvasViewport requires a Camera2D",
))?,
)?;
// CanvasViewport 只容纳一个 Scene2D 根,允许其内部无限空间布局,但不接受多个普通孩子冒充场景。
if data.children.len() != 1
|| !matches!(
resolve(store, data.children[0].block)?.1.kind,
@@ -527,6 +586,7 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
let definition = store.type_of(controller)?.ok_or(VmError::InvalidArgument(
"3D controller requires a nominal update method",
))?;
// 3D 控制器要求名义类型上的 update,接受含接收者在内三参;调用执行与预算在输入分发路径处理。
let method = store
.lookup_method(definition, "update")?
.ok_or(VmError::InvalidArgument(
@@ -544,8 +604,10 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
Ok(data)
}
/// 按属性名检查和解码源值,再更新对应视觉字段;非法尺寸、枚举或颜色被拒绝。
pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Result<(), VmError> {
let bad = || VmError::InvalidArgument("invalid native visual property");
// 常规视觉长度/系数只接受非负有限值;坐标和 pan 在下面走允许负数的独立路径。
let number = || -> Result<f32, VmError> {
let Value::Number(value) = value else {
return Err(bad());
@@ -556,6 +618,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
}
Ok(n)
};
// x/y 对所有组件生效;页面 pan/zoom 只作用 Page。这里不是任意字段存在性校验,不适用种类会跳过更新。
if matches!(name, "x" | "y" | "panX" | "panY" | "zoom") {
let n = crate::scroll::coordinate(value)?;
match name {
@@ -564,6 +627,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
"panX" if matches!(data.kind, Kind::Page) => data.surface.pan.x = n,
"panY" if matches!(data.kind, Kind::Page) => data.surface.pan.y = n,
"zoom" if matches!(data.kind, Kind::Page) => {
// Page 兼容相机缩放范围不同于独立 Camera2D 的 0.01..100,保持两套源协议各自限制。
if !(0.1..=4.0).contains(&n) {
return Err(invalid_zoom());
}
@@ -581,6 +645,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
return Ok(());
}
match name {
// controller 接受对象或明确的字符串 none,不能拿 Nil 隐式取消;后续 visual_data 检查其名义方法。
"controller" if matches!(data.kind, Kind::Viewport3D { .. }) => {
data.controller = match value {
Value::Object(controller) => Some(*controller),
@@ -589,6 +654,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
};
return Ok(());
}
// camera/scene 这里仅检查 Object 并记录身份,实际 payload/结构验证由各视口快照入口继续执行。
"camera"
if matches!(
data.kind,
@@ -615,6 +681,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
"paddingBottom" => 2,
_ => 3,
};
// 单边 auto 清除覆盖值;数值零保留 Some(0),从而确实覆盖统一 padding。
data.padding_edges[index] = if matches!(value, Value::Text(text) if text == "auto") {
None
} else {
@@ -650,6 +717,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
let Value::Text(overflow) = value else {
return Err(bad());
};
// overflow 只切换可见/裁剪,不暗中启用滚动;需要偏移与输入消费必须使用 ScrollBlock。
data.clip_children = match overflow.as_str() {
"clip" => true,
"visible" => false,
@@ -701,6 +769,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
data.margin.left = number()?;
return Ok(());
}
// basis 的 Nil 与 auto 都表示内容基准;其他值使用非负长度检查。flexMin/Max 的相互关系在完整提取后验证。
"flexBasis" => {
data.flex_policy.basis = if matches!(value, Value::Nil)
|| matches!(value, Value::Text(text) if text == "auto")
@@ -730,6 +799,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
return Ok(());
}
let container = data.is_container();
// alignSelf 可由任何孩子独立声明,crossAlign 只解释容器配置;auto 恢复继承父交叉轴策略。
if name == "alignSelf" || (container && name == "crossAlign") {
let Value::Text(value) = value else {
return Err(bad());
@@ -761,6 +831,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
let Value::Text(value) = value else {
return Err(bad());
};
// 文本属性按 UTF-8 字节设 16 KiB 上限,防止单个 Block 超过原生视觉文本预算。
if value.len() > 16_384 {
return Err(bad());
}
@@ -768,6 +839,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
}
(Kind::Text { size, .. }, "size") => {
let n = number()?;
// Text size 是 1..8 的离散倍率,不能把任意浮点字号通过 as u32 悄悄截断。
if !(1.0..=8.0).contains(&n) || n.fract() != 0.0 {
return Err(bad());
}
@@ -777,6 +849,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
let Value::Text(value) = value else {
return Err(bad());
};
// 颜色赋值先验证文本格式再替换派生颜色,避免错误字符串进入软件渲染器。
crate::document::parse_color(value).map_err(|_| bad())?;
color.clone_from(value);
}
@@ -792,6 +865,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
(Kind::Rect { height, .. }, "height") => *height = number()?,
(Kind::Rect { radius, .. }, "radius") => *radius = number()?,
(Kind::Spacer(size), "size") => *size = number()?,
// 剩余 kind 专有尺寸与容器字段按各自规则覆盖;未知字段忽略,不在此处删除或修改用户自定义属性。
(_, "padding") if container => data.padding = number()?,
(_, "gap") if container => data.gap = number()?,
_ => {}
@@ -799,6 +873,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
Ok(())
}
/// 统一返回非法页面缩放错误,确保属性写入与输入路径使用相同限制。
fn invalid_zoom() -> VmError {
VmError::InvalidArgument("Page.zoom expects a number from 0.1 to 4")
}
+83
View File
@@ -1,3 +1,7 @@
//! 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。
//! RenderDocument/Frame 包含绘制命令、Mount 几何、页面标签和相机投影。发布前统一检查数量、坐标、颜色及索引一致性。
//! 屏幕命中、逻辑坐标和世界 UI 的射线映射必须与绘制采用同一变换;这里的 ID 是数据身份,不是窗口线程可操作的托管引用。
//!
use std::collections::HashSet;
use std::sync::Arc;
@@ -13,18 +17,23 @@ use crate::{
pub const DEFAULT_NAMESPACE: &str = "app.main";
#[derive(Clone, Debug)]
/// HTTP/GUI 共享的最近完整发布状态;submission 决定结果归属,revision 表示实际发布次数。
pub struct NamespaceState {
pub name: String,
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(),
@@ -40,7 +49,9 @@ impl Default for NamespaceState {
}
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
@@ -49,10 +60,12 @@ impl NamespaceState {
.retain(|id, _| *id == crate::window::MAIN_WINDOW);
}
self.runtime_session = session;
// 零表示无有效 VM 会话,清空传输请求并忽略传入命令,不能让退役结果重新控制窗口。
if session == 0 {
self.window_commands.clear();
return;
}
// 本方法接纳传入 session 作为当前世代,调用方负责只在成功发布边界调用;同资源同动作保留最新请求。
for command in commands {
self.window_commands
.retain(|old| !old.command.same_kind(&command));
@@ -63,13 +76,16 @@ impl NamespaceState {
.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);
}
@@ -82,12 +98,14 @@ impl NamespaceState {
Ok(Some(revision))
}
/// 推进提交身份和发布版本,替换为空文档并清除运行时命令,使旧提交不能覆盖清空结果。
pub fn clear(&mut self) -> Result<u64> {
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;
@@ -98,10 +116,12 @@ impl NamespaceState {
}
#[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)]
pub source: String,
@@ -112,7 +132,9 @@ pub struct RenderDocument {
}
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)
@@ -128,6 +150,7 @@ impl RenderDocument {
if self.windows.len() > 15 {
bail!("too many window projections");
}
// 副窗口只允许一层投影,键须等于其 frame.window,不能重复嵌入主窗口或再带嵌套 windows。
for (id, document) in &self.windows {
if *id == crate::window::MAIN_WINDOW
|| *id != document.frame.window
@@ -148,6 +171,7 @@ impl RenderDocument {
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()
{
@@ -172,6 +196,7 @@ impl RenderDocument {
if self.frame.mounts.len() > 10_000 {
bail!("frame exceeds the 10000 mount limit");
}
// 标签 ID 必须非零且唯一,最多一个活动页;标题按 UTF-8 字节限长,标签数受 MAX_PAGES 限制。
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
@@ -183,6 +208,7 @@ impl RenderDocument {
{
bail!("invalid tab projection");
}
// 先收集全部 Mount ID 并校验几何,再第二遍核对关系下标,允许链接指向帧中较后位置。
let mut mount_ids = HashSet::new();
for mount in &self.frame.mounts {
if mount.view.is_some_and(|view| !view.valid()) {
@@ -198,6 +224,7 @@ impl RenderDocument {
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)
@@ -207,6 +234,7 @@ impl RenderDocument {
bail!("invalid scroll projection");
}
}
// 此处只保证关系目标在帧内,不做完整树环/双向一致性验证;组成与生命周期层承担更强结构不变量。
for mount in &self.frame.mounts {
for linked in [mount.parent, mount.first_child, mount.next_sibling]
.into_iter()
@@ -218,6 +246,7 @@ impl RenderDocument {
}
}
// 携带本机视觉根时必须与第一个根出现的业务身份一致;反序列化无 ui_root 时跳过这项。
if let Some(root) = &self.ui_root
&& self
.frame
@@ -234,12 +263,15 @@ impl RenderDocument {
Ok(())
}
/// 将逻辑命令转换到设备比例并应用逐命令视图/裁剪;不自动调用 validate,发布入口须先校验。
pub fn display_list(&self, scale: f32) -> Result<DisplayList> {
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,
@@ -253,6 +285,7 @@ impl RenderDocument {
.unwrap_or_default();
list.push(
command
// 均匀缩放包含字体、半径和描边,随后平移只改原点;不能把位移再乘一次 view.zoom。
.to_draw_command(scale * view.zoom)?
.translated(Point::new(view.x * scale, view.y * scale)),
);
@@ -260,6 +293,7 @@ impl RenderDocument {
list.push(DrawCommand::PopClip);
}
}
// 根层只绘制非面板的焦点框;面板内焦点已在 panel::compose 放入纹理,避免重复或脱离面板。
if let Some(focus) = frame
.focus
.and_then(|id| frame.mounts.iter().find(|m| m.id == id))
@@ -294,6 +328,7 @@ impl RenderDocument {
list.push(DrawCommand::PopClip);
}
}
// 故障提示最后追加在页面上方,只显示错误第一行;状态是否允许输入由 Frame.hit_test 等另行限制。
if let Some(error) = &frame.fault {
list.push(
RenderCommand::RoundedRect {
@@ -321,12 +356,14 @@ impl RenderDocument {
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 {
pub id: String,
pub name: String,
@@ -340,7 +377,9 @@ pub struct 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");
}
@@ -350,6 +389,7 @@ impl CodeNode {
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);
}
@@ -359,6 +399,7 @@ impl CodeNode {
Ok(())
}
/// 按请求的代码节点身份或路径查找对应的源码投影。
fn find(&self, id: &str) -> Option<&CodeNode> {
if self.id == id {
return Some(self);
@@ -368,7 +409,9 @@ impl CodeNode {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
/// 一个窗口的一次完整视觉发布:有序命令及其裁剪/变换、出现几何、焦点和版本。平行索引表必须和命令数量一致。
pub struct Frame {
// 命中索引是可重建的本地优化,不作为序列化协议的一部分保存。
#[serde(skip)]
pub hit_index: Arc<crate::viewport::spatial::Index>,
#[serde(default)]
@@ -384,12 +427,14 @@ pub struct Frame {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
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")]
pub mounts: Vec<MountGeometry>,
#[serde(default)]
pub layout_version: u64,
#[serde(default)]
// 运行时世代与 layout_version 共同确定输入归属;仅布局版本相同不能跨 VM 接受事件。
pub session: u64,
#[serde(default)]
pub focus: Option<u64>,
@@ -400,6 +445,7 @@ pub struct Frame {
}
impl Default for Frame {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
hit_index: Arc::default(),
@@ -422,8 +468,10 @@ impl Default for Frame {
/// 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,
@@ -432,12 +480,14 @@ pub struct MountGeometry {
#[serde(default)]
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")]
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]>,
pub id: u64,
pub block_id: String,
@@ -452,22 +502,28 @@ pub struct MountGeometry {
}
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]);
}
let point = point?;
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([
@@ -475,10 +531,12 @@ impl MountGeometry {
((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| {
@@ -488,12 +546,14 @@ impl MountGeometry {
}
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| {
@@ -503,10 +563,13 @@ impl Frame {
})
})
}
/// 在无故障帧中按逆绘制顺序选中可交互的出现,空间索引只缩小候选。
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()
@@ -515,16 +578,19 @@ impl Frame {
}
}
/// 返回默认主窗口身份,供省略窗口字段的兼容输入使用。
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 {
Scene3D {
x: f32,
@@ -566,6 +632,7 @@ pub enum RenderCommand {
}
impl RenderCommand {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
pub(crate) fn validate(&self) -> Result<()> {
match self {
Self::Scene3D {
@@ -601,6 +668,7 @@ impl RenderCommand {
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");
}
@@ -617,6 +685,7 @@ impl RenderCommand {
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]
@@ -638,6 +707,7 @@ impl RenderCommand {
Ok(())
}
/// 把一条已验证的发布命令变成原生绘制原语,缩放几何和文字样式。
fn to_draw_command(&self, scale: f32) -> Result<DrawCommand> {
Ok(match self {
Self::Scene3D {
@@ -683,6 +753,7 @@ impl RenderCommand {
size,
color,
wrap_bounds,
// 根据是否提供换行边界选择 WrappedText 或 Text;两者都使用同一等级字号与设备缩放。
} => match wrap_bounds {
Some([width, height]) => DrawCommand::WrappedText {
rect: scaled_rect(*x, *y, *width, *height, scale),
@@ -701,32 +772,39 @@ impl RenderCommand {
}
}
/// 提供未指定描边宽度时的兼容默认值。
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<()> {
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");
}
@@ -735,6 +813,7 @@ pub fn parse_color(value: &str) -> Result<Color> {
parse(0..2)?,
parse(2..4)?,
parse(4..6)?,
// 顺序为 RRGGBBAA 而非 AARRGGBB,六位色号默认完全不透明。
if hex.len() == 8 { parse(6..8)? } else { 255 },
))
}
@@ -742,6 +821,7 @@ pub fn parse_color(value: &str) -> Result<Color> {
#[cfg(test)]
mod tests {
#[test]
/// 旧文字JSON缺wrap_bounds仍走不换行命令;合法边界随DPI缩放,负宽度在发布验证中拒绝。
fn text_wrap_bounds_validate_and_legacy_text_remains_unwrapped() {
let mut command: RenderCommand = serde_json::from_str(
r##"{"type":"text","x":0,"y":0,"value":"text","size":1,"color":"#000000"}"##,
@@ -767,6 +847,7 @@ mod tests {
use super::*;
#[test]
/// 默认空文档可通过验证且没有结构根/绘图命令,支持初始状态与清空发布。
fn empty_document_is_valid_and_blank() {
let document = RenderDocument::default();
document.validate().unwrap();
@@ -775,6 +856,7 @@ mod tests {
}
#[test]
/// 检查六位色默认alpha255,八位色按RRGGBBAA解码,避免将末尾透明度当成首通道。
fn parses_rgb_and_rgba_colors() {
assert_eq!(
parse_color("#102030").unwrap(),
@@ -791,6 +873,7 @@ mod tests {
mod window_projection_tests {
use super::*;
#[test]
/// 同会话窗口命令可累积,新会话发布先丢旧命令;clear重置runtime_session并清空所有原生命令。
fn replacement_and_reset_discard_old_window_projections() {
let mut state = NamespaceState::default();
let command = crate::window::Command {
+13 -1
View File
@@ -1,11 +1,23 @@
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
//! 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 维护托管属性绑定及其读依赖;计算结果写入目标,绑定令牌不应让隐藏目标意外常驻。
pub(crate) mod binding;
/// 定义托管视觉对象的业务状态、共享替换关系和容器子项;几何属于 Mount,不能写回共享 Block。
pub(crate) mod block;
/// 定义 GUI/HTTP 可见的完整发布文档和共享 namespace;跨线程传递拥有的数据,不传 VM 句柄。
pub(crate) mod document;
/// 维护对象在某个页面中的具体出现位置、稳定子槽与生命周期;同一对象可对应多个独立 Mount。
pub(crate) mod mount;
/// 将语言对象列表适配为宿主遍历与写入接口,保留托管身份并参与读取依赖跟踪。
pub(crate) mod object_list;
/// 按窗口管理页面展示上下文、Mount 准备与提交,分离业务对象寿命和一次展示的寿命。
pub(crate) mod presentation;
/// 提供托管视觉协议方法的解析/调用约定,让源覆盖与原生实现沿同一入口组合。
pub(crate) mod protocols;
/// 提取托管三维场景的节点、相机和材质;验证对象图并生成供纯数据渲染使用的场景快照。
pub(crate) mod scene3d;
/// 从托管对象提取展示快照与属性版本;快照是渲染输入,不承担源程序业务状态。
pub(crate) mod snapshot;
/// 管理 VM 内 Window/Page 资源关系、窗口命令与页面顺序,操作结果再由原生壳层执行。
pub(crate) mod window;
+86
View File
@@ -1,3 +1,7 @@
//! 每次展示的托管上下文:同一个 Block 出现在两个位置时,有两个独立 Mount。
//! Mount 保存父子关系、出现几何和布局期间的查询上下文;MountTable 用弱索引定位对象,不靠索引强行保活业务树。
//! 同步会安排 onMount/onUnmount。布局取消时使用 Checkpoint 恢复未发布状态,避免半完成的出现关系影响下一次刷新。
//!
//! Managed presentation contexts. The active desktop owns the mount dictionary;
//! reverse links and the host registry are weak, so shared content cannot retain a Page.
use crate::document::{Frame, MountGeometry};
@@ -11,16 +15,22 @@ use std::{
};
#[derive(Debug, Clone)]
/// 一个组件在一个展示位置上的上下文。强持有 Block,反向页面/父子链接使用弱身份,避免共享组件把已关闭页面保活。
pub struct Mount {
// 组件是强引用;页面、父节点和兄弟链接是弱引用,防止反向链接形成保活环。
pub block: ObjectRef,
pub page: Option<WeakObjectRef>,
pub parent: Option<WeakObjectRef>,
pub first_child: Option<WeakObjectRef>,
pub next_sibling: Option<WeakObjectRef>,
// 已发布几何与每次出现的滚动位置分开保存,同一 Block 的多个位置互不覆盖。
pub geometry: MountGeometry,
pub scroll_offset: [f32; 2],
// 最后同步的布局版本,Bounds 查询会一并返回,便于调用方识别几何属于哪轮发布。
pub version: u64,
// false 后禁止依赖当前几何的操作;外部即使还持有 Mount 对象也不能把它当活动出现。
pub mounted: bool,
// Mount 可以拥有任务;退役后任务交付还要检查 owner 是否仍有效。
pub tasks: Option<ObjectRef>,
// Private derived scratch, installed before the read-only layout call and
// cleared by geometry commit. It contains no managed references or roots.
@@ -28,18 +38,24 @@ pub struct Mount {
}
impl ObjectPayload for Mount {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-mount"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
// 仅 block 和 tasks 是 payload 的强拥有边;page/parent/child/sibling 是弱边,不参与 trace。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
visit(self.block);
if let Some(tasks) = self.tasks {
@@ -49,6 +65,7 @@ impl ObjectPayload for Mount {
}
#[derive(Debug, Clone, Copy)]
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
enum Query {
Bounds,
ScrollTo,
@@ -68,17 +85,21 @@ enum Query {
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
signature: endor_core::CallSignature,
owner: ObjectRef,
// 方法对象拥有 Mount,但不能经桌面反向保活整个窗口;需要桌面能力时再 upgrade。
desktop: WeakObjectRef,
query: Query,
}
impl NativeCallable for Method {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(self.signature.clone())
}
/// 适配器已绑定 Mount;坐标测试/转换与scrollTo/By接受两个数,其余层次/几何/焦点查询零参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(
if matches!(
@@ -97,8 +118,10 @@ impl NativeCallable for Method {
))
}
/// 读取具体出现位置,先检查仍挂载与几何可用性,再执行层次、坐标、焦点、滚动或拾取操作,避免使用已退役上下文。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let mount = cx.store().payload::<Mount>(self.owner, "Mount")?.clone();
// 层次查询允许观察已经脱离的对象(通常得到 Nil);几何、焦点、滚动、拾取等必须仍挂接。
if !mount.mounted
&& matches!(
self.query,
@@ -117,6 +140,7 @@ impl NativeCallable for Method {
{
return Err(VmError::InvalidArgument("Mount is no longer attached"));
}
// 除 VM 元数据检查外入口再核对实际参数数目;二维坐标/位移操作取两个 Number,其余零参。
let arity = if matches!(
self.query,
Query::HitTest
@@ -135,6 +159,7 @@ impl NativeCallable for Method {
}
let value = match self.query {
Query::ToView | Query::ToLocal => {
// 布局期间几何尚未提交,坐标转换不能把临时 bounds 与上帧投影混用。
if mount.layout.is_some() {
return Err(VmError::InvalidArgument(
"coordinate conversion requires committed geometry",
@@ -146,6 +171,7 @@ impl NativeCallable for Method {
);
let point =
if matches!(self.query, Query::ToView) {
// ToView 先将局部点加节点原点,再套二维 view;若在世界面板内,还继续向外投影到页面。
let point = mount.geometry.view.unwrap_or_default().point(
crate::geometry::Point::new(
point.x + mount.geometry.x,
@@ -158,6 +184,7 @@ impl NativeCallable for Method {
.as_ref()
.map_or(Some(point), |panel| panel.project_point(point))
} else {
// ToLocal 先撤销面板和视图投影,再减节点几何原点;不可逆时返回 Nil,而非虚构坐标。
mount.geometry.logical_point(point).map(|point| {
crate::geometry::Point::new(
point.x - mount.geometry.x,
@@ -165,6 +192,7 @@ impl NativeCallable for Method {
)
})
};
// 转换成功才分配名义 Point;失败返回 Nil,不把无效数值写进结果属性。
if let Some(point) = point {
let result = cx.new_object()?;
let desktop = cx.upgrade(self.desktop)?.ok_or(VmError::InvalidArgument(
@@ -178,6 +206,7 @@ impl NativeCallable for Method {
Value::Nil
}
}
// 同一滚动入口区分绝对位置和相对增量,钳制、挂接状态与写屏障交由 scroll::set 统一执行。
Query::ScrollTo | Query::ScrollBy => {
crate::scroll::set(
cx,
@@ -213,6 +242,7 @@ impl NativeCallable for Method {
),
)?
}
// 返回帧中已钳制的有效位置与最大偏移,不直接暴露可能尚未投影的 scroll_offset 原值。
Query::ScrollPosition => {
let p = mount
.geometry
@@ -225,6 +255,8 @@ impl NativeCallable for Method {
Value::Object(result)
}
Query::Bounds => {
// 布局只读调用中可查询当前安排阶段的临时 bounds;测量时尚无 bounds,明确报错。
// 非布局期则读取最后提交的 MountGeometry。
let geometry = if let Some(layout) = &mount.layout {
layout
.lock()
@@ -242,6 +274,7 @@ impl NativeCallable for Method {
]
};
let bounds = cx.new_object()?;
// 从本方法返回签名取得 Bounds 的名义类型,避免生成一个仅字段相似的普通对象。
let endor_core::CallType::Single(endor_core::AnnotationType::Nominal(definition)) =
&self.signature.returns
else {
@@ -263,6 +296,7 @@ impl NativeCallable for Method {
)?;
Value::Object(bounds)
}
// 请求焦点先让出现进入可见范围,再检查可聚焦资格并写入桌面请求;后续发布负责实际生效。
Query::Focus => {
crate::scroll::into_view(cx, self.owner, self.desktop)?;
if !mount.geometry.interactive {
@@ -279,12 +313,14 @@ impl NativeCallable for Method {
cx.set_attr(desktop, "focusRequested", Value::Bool(true))?;
Value::Nil
}
// 这里是 Mount 已提交几何的 contains 查询;源 Block 的 hitTest 覆盖由 source_hit 驱动另行执行。
Query::HitTest => {
if mount.layout.is_some() {
return Err(VmError::InvalidArgument(
"Mount hitTest is unavailable during layout",
));
}
// 命中坐标只要求 Number 可转有限 f32;与 scroll::coordinate 不同,此处不限制绝对值至一百万。
let number = |value: &Value| -> Result<f32, VmError> {
let Value::Number(number) = value else {
return Err(VmError::InvalidArgument(
@@ -313,6 +349,7 @@ impl NativeCallable for Method {
Query::Page => mount.page,
_ => unreachable!(),
};
// 弱链接不存在或目标已回收都返回 Nil;升级本身失败才传播 VM 错误。
weak.map(|weak| cx.upgrade(weak))
.transpose()?
.flatten()
@@ -324,6 +361,7 @@ impl NativeCallable for Method {
}
}
/// 声明 Mount 查询方法的源类型契约,区分几何、层次与坐标操作。
fn mount_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -331,6 +369,7 @@ fn mount_signature(
) -> Result<endor_core::CallSignature, VmError> {
use endor_core::{AnnotationType as T, CallEffect, CallSignature, CallType};
let nominal = |name| crate::ui_bridge::desktop_type(store, name, desktop).map(T::Nominal);
// 层次、拾取和坐标转换可能没有结果,使用 nullable 名义类型;变更操作返回 Unit。
let returns = match query {
Query::Bounds => nominal("Bounds")?.into(),
Query::Parent | Query::FirstChild | Query::NextSibling => {
@@ -343,6 +382,7 @@ fn mount_signature(
Query::ScrollPosition => T::Any.into(),
_ => T::Unit.into(),
};
// 滚动、相机拟合和焦点会写业务/桌面属性,声明 Mutating;其余查询允许在只读协议中调用。
let effect = if matches!(
query,
Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::FitView | Query::Focus
@@ -370,12 +410,15 @@ fn mount_signature(
}
#[derive(Debug, Default, Clone)]
/// 活动出现的弱索引。真实托管拥有关系在 desktop 的挂载字典中,索引不能充当隐藏 GC 根。
pub struct MountTable {
// Mount ID 到托管弱身份,真实强拥有者是 desktop.mounts 字典;order 单独保存原布局顺序。
pub active: HashMap<u64, WeakObjectRef>,
order: Vec<u64>,
}
#[derive(Debug)]
/// 同步出现关系时的共享临时工作状态,保存本轮重用、建立和退役安排。
struct Sync {
desktop: ObjectRef,
page: Option<ObjectRef>,
@@ -383,11 +426,13 @@ struct Sync {
old: HashMap<u64, WeakObjectRef>,
old_order: Vec<u64>,
frame: Frame,
// true 为布局准备阶段,安装临时 Context 并保留已有滚动偏移;false 为几何提交,清 Context。
preparing: bool,
output: Arc<Mutex<HashMap<u64, WeakObjectRef>>>,
}
#[derive(Debug)]
/// 将一个生命周期回调及参数接入同一 VM 调用序列。
struct LifecycleCall {
function: ObjectRef,
block: ObjectRef,
@@ -395,11 +440,14 @@ struct LifecycleCall {
unmount: bool,
}
impl NativeCallable for LifecycleCall {
/// Block、Mount 与用户钩子都已捕获,生命周期序列无参调用此适配器。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 卸载时先取消 Mount 所有任务,再以 Block/Mount 为实参请求 VM 执行用户 onMount/onUnmount,不在 Rust 中直接运行源体。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 用户 onUnmount 运行前先取消该出现拥有的任务,防止退役后继续交付旧工作。
if self.unmount {
crate::tasks::cancel_owner(cx, self.mount)?;
}
@@ -410,6 +458,7 @@ impl NativeCallable for LifecycleCall {
}
}
/// 构造生命周期调用所需的托管函数与参数,使回调遵循同一 VM 的引用和执行约束。
fn lifecycle_call(
cx: &mut NativeContext<'_>,
mount: ObjectRef,
@@ -424,6 +473,7 @@ fn lifecycle_call(
.store()
.lookup_method(definition, name)?
.ok_or(VmError::InvalidArgument("Block has no lifecycle method"))?;
// 生命周期包装显式捕获函数、Block、Mount,使序列执行到回调前这些对象不会被回收。
cx.allocate(ContextNativeFunction::new(
name,
LifecycleCall {
@@ -437,11 +487,14 @@ fn lifecycle_call(
}
impl NativeCallable for Sync {
/// 展示同步步骤从捕获的帧与弱节点表读取输入,无需外部实参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 把弱业务身份升级为本轮对象映射,再同步帧中出现位置到托管 Mount,建立结构/几何与待通知生命周期。
fn invoke(&self, cx: &mut NativeContext<'_>, _args: &[Value]) -> Result<NativeResult, VmError> {
// 帧只含 Block 字符串身份,先将弱节点表升级为本次同步可用的实际对象映射。
let mut blocks = HashMap::new();
for weak in &self.nodes {
if let Some(block) = cx.upgrade(*weak)? {
@@ -466,6 +519,7 @@ impl NativeCallable for Sync {
.map(|weak| cx.upgrade(*weak))
.transpose()?
.flatten();
// Mount ID 相同仍不足以复用:必须仍挂接、页面相同、真实 Block 相同,防止身份误归属。
let reusable = if let Some(old) = old {
let mount = cx.store().payload::<Mount>(old, "Mount")?;
(mount.mounted && mount.page == page && mount.block == block).then_some(old)
@@ -489,6 +543,7 @@ impl NativeCallable for Sync {
layout: None,
})?;
crate::ui_bridge::attach_desktop_type(cx, object, "Mount", self.desktop)?;
// 每个新 Mount 安装捕获自身的原生方法;captures 中只放 Mount,桌面仍保留弱身份。
for (name, query) in [
("bounds", Query::Bounds),
("scrollTo", Query::ScrollTo),
@@ -525,6 +580,7 @@ impl NativeCallable for Sync {
};
objects.insert(geometry.id, object);
}
// 先建立全部 Mount,再第二遍补链接,保证 parent/child/sibling 无论先后都能找到目标身份。
let root = cx.new_dict()?;
for geometry in &self.frame.mounts {
let object = objects[&geometry.id];
@@ -539,6 +595,7 @@ impl NativeCallable for Sync {
first_child: link(geometry.first_child)?,
next_sibling: link(geometry.next_sibling)?,
geometry: geometry.clone(),
// 准备阶段沿用用户滚动状态;提交阶段以帧中有效偏移为准,非滚动容器归零。
scroll_offset: if self.preparing {
cx.store().payload::<Mount>(object, "Mount")?.scroll_offset
} else {
@@ -546,6 +603,7 @@ impl NativeCallable for Sync {
},
version: self.frame.layout_version,
mounted: true,
// 临时 Context 只有原生派生数据,无托管根;提交时置 None,表示几何可供外部查询。
layout: if self.preparing {
Some(Arc::new(Mutex::new(crate::layout::Context::default())))
} else {
@@ -553,6 +611,7 @@ impl NativeCallable for Sync {
},
};
let old = cx.store().payload::<Mount>(object, "Mount")?;
// 只在上下文、几何、版本、链接或公开挂接属性确实需更新时替换 payload,减少无关变更日志。
if self.preparing
|| old.layout.is_some()
|| !old.mounted
@@ -575,6 +634,7 @@ impl NativeCallable for Sync {
Value::Number((self.frame.layout_version as i64).into()),
)?;
}
// 新字典逐项拥有活动 Mount;弱链接本身不负责让整棵出现树存活。
cx.dict_set(
root,
Value::Text(geometry.id.to_string()),
@@ -584,12 +644,14 @@ impl NativeCallable for Sync {
let mut hooks = Vec::new();
// Layout records are preorder; reversing guarantees children unmount
// before their parents. A Page change creates fresh Mount contexts.
// 旧帧按父先子后记录,逆序退役保证孩子 onUnmount 在父之前;复用对象不退役。
for id in self.old_order.iter().rev() {
let weak = self.old[id];
if let Some(object) = cx.upgrade(weak)?
&& objects.get(id) != Some(&object)
{
let mut mount = cx.store().payload::<Mount>(object, "Mount")?.clone();
// 先撤销挂接和全部关系,再安排回调;onUnmount 不能继续使用失效几何或沿旧链操作。
mount.mounted = false;
mount.layout = None;
mount.parent = None;
@@ -600,9 +662,11 @@ impl NativeCallable for Sync {
hooks.push(lifecycle_call(cx, object, "onUnmount")?);
}
}
// 新建顺序沿帧先序,onMount 默认父先子后;全部旧退役回调排在新挂载回调之前。
for object in created {
hooks.push(lifecycle_call(cx, object, "onMount")?);
}
// 同步只返回生命周期序列,不在此处立即执行用户回调;调用方决定在后续阶段何时运行。
let driver = if hooks.is_empty() {
None
} else {
@@ -617,6 +681,7 @@ impl NativeCallable for Sync {
.into_iter()
.map(|(id, object)| Ok((id, cx.store().downgrade(object)?)))
.collect::<Result<HashMap<_, _>, VmError>>()?;
// 先让桌面拥有新字典,再公布原生弱索引;返回的驱动保持待退役对象直到回调结束。
cx.set_attr(self.desktop, "mounts", Value::Object(root))?;
*self
.output
@@ -630,16 +695,20 @@ impl NativeCallable for Sync {
/// Explicitly owned roots protect the old presentation while preparation
/// temporarily replaces its desktop dictionary and Mount payloads.
/// 布局准备前的挂载快照与临时保留,用于取消恢复;成功提交后必须释放其保留引用。
pub struct Checkpoint {
table: MountTable,
dictionary: Option<ObjectRef>,
// 保存 payload 与 mounted/layoutVersion 两项公开属性;None 表示原先没有属性,回滚要删除。
states: Vec<(ObjectRef, Mount, Option<Value>, Option<Value>)>,
roots: Vec<ObjectRef>,
}
impl Checkpoint {
/// 释放检查点为回滚临时保留的托管根,成功提交后不再需要这些引用。
pub fn release(self, vm: &mut Vm) -> Result<(), VmError> {
let mut result = Ok(());
// 逆序释放所有临时根,单项失败仍继续尝试余下根,最终返回最后一次释放错误。
for root in self.roots.into_iter().rev() {
if let Err(error) = vm.release(root) {
result = Err(error);
@@ -648,6 +717,7 @@ impl Checkpoint {
result
}
/// 恢复检查点中尚未发布的出现关系与布局上下文,并释放临时保留。
pub fn restore(
self,
vm: &mut Vm,
@@ -655,6 +725,7 @@ impl Checkpoint {
table: &mut MountTable,
) -> Result<(), VmError> {
let result = (|| {
// 回滚先识别本轮新增的出现并将其脱离,再恢复原有 Mount payload、公开属性和桌面拥有字典。
let old: std::collections::HashSet<_> =
self.states.iter().map(|state| state.0).collect();
for weak in table.ordered() {
@@ -672,6 +743,7 @@ impl Checkpoint {
}
Ok::<_, VmError>(())
})();
// 保存处理结果后先释放 upgrade 根,再传播处理错误,避免失败路径遗留临时强引用。
vm.release(object)?;
result?;
}
@@ -679,6 +751,7 @@ impl Checkpoint {
for (object, mount, mounted, version) in &self.states {
vm.replace_payload(*object, mount.clone())?;
for (name, value) in [("mounted", mounted), ("layoutVersion", version)] {
// 原属性存在则恢复旧值,不存在则 del_attr;已缺失可忽略,其余删除错误仍要传播。
if let Some(value) = value {
vm.set_attr(*object, name, value.clone())?;
} else {
@@ -700,6 +773,7 @@ impl Checkpoint {
*table = self.table.clone();
Ok(())
})();
// 恢复过程失败也要释放检查点根;先尝试两项操作,再按恢复错误优先的顺序返回。
let released = self.release(vm);
result?;
released
@@ -707,6 +781,7 @@ impl Checkpoint {
}
impl MountTable {
/// 保留布局准备前的 Mount 状态,供失败或取消路径恢复。
pub fn checkpoint(&self, vm: &mut Vm, desktop: ObjectRef) -> Result<Checkpoint, VmError> {
let dictionary = match vm.get_attr(desktop, "mounts") {
Ok(Value::Object(dictionary)) => Some(dictionary),
@@ -715,6 +790,7 @@ impl MountTable {
Err(error) => return Err(error),
};
let mut roots = Vec::new();
// 保留旧字典,避免准备阶段 desktop.mounts 换成新字典后旧出现提前回收,导致无法恢复。
if let Some(dictionary) = dictionary {
vm.retain(dictionary)?;
roots.push(dictionary);
@@ -728,6 +804,7 @@ impl MountTable {
let result = (|| {
for weak in self.ordered() {
if let Some(object) = vm.upgrade(weak)? {
// upgrade 已提供一个拥有根,直接记录待释放列表,不重复 retain;随后读取失败也会统一清理。
checkpoint.roots.push(object);
let mount = vm.payload::<Mount>(object, "Mount")?.clone();
let mut attributes = Vec::new();
@@ -737,6 +814,7 @@ impl MountTable {
Err(VmError::AttributeNotFound(_)) => None,
Err(error) => return Err(error),
};
// 公开属性理论上可能持有对象,快照保存 Value 本身不会自动增加 VM 根,须额外 retain。
if let Some(Value::Object(object)) = &value {
vm.retain(*object)?;
checkpoint.roots.push(*object);
@@ -753,6 +831,7 @@ impl MountTable {
}
Ok::<_, VmError>(())
})();
// 检查点创建到一半失败时释放已经取得的根,不返回一个不完整且可能泄漏的检查点。
if let Err(error) = result {
checkpoint.release(vm)?;
return Err(error);
@@ -762,6 +841,7 @@ impl MountTable {
/// A faulted domain cannot run user onUnmount callbacks. Still detach every
/// context and release its owning dictionary through the normal heap barrier.
/// 退役当前出现表,撤销挂载关系并准备不再展示的生命周期状态。
pub fn retire(&mut self, vm: &mut Vm, desktop: ObjectRef) -> Result<(), VmError> {
for weak in self.ordered().into_iter().rev() {
if let Some(object) = vm.upgrade(weak)? {
@@ -779,6 +859,7 @@ impl MountTable {
result?;
}
}
// 故障退役不执行用户回调;将 desktop.mounts 换成空字典,再清除原生索引,撤销桌面拥有关系。
let dictionary = vm.new_dict()?;
let result = vm.set_attr(desktop, "mounts", Value::Object(dictionary));
vm.release(dictionary)?;
@@ -788,6 +869,7 @@ impl MountTable {
Ok(())
}
/// 按原始展示顺序返回弱 Mount 身份,供事件和协议驱动器保持稳定顺序。
pub fn ordered(&self) -> Vec<WeakObjectRef> {
self.order
.iter()
@@ -795,6 +877,7 @@ impl MountTable {
.collect()
}
/// 根据新结构复用或建立每次出现的 Mount,连接父子关系并准备生命周期调用。
pub fn synchronize(
&mut self,
vm: &mut Vm,
@@ -805,6 +888,7 @@ impl MountTable {
preparing: bool,
) -> Result<Option<ObjectRef>, VmError> {
let output = Arc::new(Mutex::new(HashMap::new()));
// 同步入口显式保活桌面与可选页面;旧表和节点表本身仍是弱索引,不增加隐式根。
let mut captures = vec![desktop];
captures.extend(page);
let function = vm.allocate(ContextNativeFunction::new(
@@ -821,9 +905,11 @@ impl MountTable {
},
captures,
))?;
// 先保存调用结果再释放同步入口,错误时同样清理;只有成功后才替换活动索引与顺序。
let result = vm.call(function, &[]);
vm.release(function)?;
let result = result?;
// 从共享输出移动弱表而不是克隆;生命周期驱动若存在则作为拥有结果交给调用方释放。
self.active = std::mem::take(
&mut *output
.lock()
+28
View File
@@ -1,23 +1,32 @@
//! 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。
//! 用于子节点组成、布局结果和几何输入等需要保留对象身份的场景;不是窗口线程共享的可变容器。
//!
//! Managed indexed host results; values and the at method are traced edges.
use endor_core::{
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
Value, VmError,
};
#[derive(Debug)]
/// 托管对象序列,trace 负责拥有其中的对象引用;源代码通过受检查方法读取。
struct ObjectList(Vec<ObjectRef>);
impl ObjectPayload for ObjectList {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-object-list"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
for mount in &self.0 {
visit(*mount);
@@ -26,8 +35,10 @@ impl ObjectPayload for ObjectList {
}
#[derive(Debug)]
/// 绑定到某个列表对象的下标访问适配器。
struct At(ObjectRef);
impl NativeCallable for At {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(endor_core::CallSignature::exact(
vec![endor_core::AnnotationType::Number],
@@ -35,14 +46,17 @@ impl NativeCallable for At {
endor_core::CallEffect::ReadOnly,
))
}
/// 绑定列表接收者已捕获,只接受一个显式索引。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 要求索引可精确转为非负平台整数,检查列表范围后返回对应托管成员,不复制成员对象。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Number(index)] = args else {
return Err(VmError::InvalidArgument("object list at expects an index"));
};
// 先要求可精确表示为 i64 的整数,再转为 usize,拒绝小数、负数及目标平台越界值。
let index = index
.as_i64()
.and_then(|v| usize::try_from(v).ok())
@@ -57,6 +71,7 @@ impl NativeCallable for At {
}
}
/// 将已有对象引用包装为新的 ObjectList,并安装默认名义类型及下标查询方法。
pub fn collection(
cx: &mut NativeContext<'_>,
mounts: Vec<ObjectRef>,
@@ -65,6 +80,8 @@ pub fn collection(
build(cx, mounts, desktop, None)
}
/// 建立列表 payload 和 first/count/at 属性;definition 指定子类时保留该名义类型。
/// 最多接受 10000 个成员;空列表的 first 为 Nil,at 越界仍报错而不返回 Nil。
fn build(
cx: &mut NativeContext<'_>,
mounts: Vec<ObjectRef>,
@@ -76,6 +93,7 @@ fn build(
"object list exceeds 10000 members",
));
}
// 在移动 Vec 之前提取元信息;成员对象的拥有边由 ObjectList::trace 声明。
let first = mounts
.first()
.copied()
@@ -90,44 +108,53 @@ fn build(
}
cx.set_attr(set, "first", first)?;
cx.set_attr(set, "count", Value::Number((count as i64).into()))?;
// at 捕获列表,列表属性又指向 at,形成托管环;显式 captures 让 GC 能追踪和回收此环。
let at = cx.allocate(ContextNativeFunction::new("at", At(set), vec![set]))?;
crate::ui_bridge::attach_desktop_type(cx, at, "Function", desktop)?;
cx.set_attr(set, "at", Value::Object(at))?;
Ok(set)
}
/// 借用受检查列表的条目;返回值的有效期仍依赖原托管对象存活。
pub fn items(store: &endor_core::ObjectStore, list: ObjectRef) -> Result<&[ObjectRef], VmError> {
Ok(&store.payload::<ObjectList>(list, "ObjectList")?.0)
}
#[derive(Debug)]
/// ObjectList 原生类型的可调用构造器,负责检查传入子对象。
pub struct Constructor {
pub signature: Option<endor_core::CallSignature>,
pub desktop: ObjectRef,
}
impl NativeCallable for Constructor {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<endor_core::CallSignature> {
self.signature.clone()
}
/// 至少包含请求 Type,之后为可变成员对象与源子类字段,具体分段在 invoke 校验。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::range(1, usize::MAX))
}
/// 按请求类型字段数切分成员/字段,禁止覆盖列表协议保留属性,验证后分配列表并初始化源字段。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Object(definition), values @ ..] = args else {
return Err(VmError::InvalidArgument("ObjectList type argument"));
};
// 首参是要构造的名义类型,余下实参分成“成员对象前缀”和“子类字段后缀”。
let fields = cx
.store()
.payload::<endor_core::EndorTypeObject>(*definition, "Type")?
.fields()
.to_vec();
// 这些名称由列表协议占用;允许子类覆盖会令 count/first/at 与底层成员不一致。
if fields
.iter()
.any(|field| matches!(field.name.as_str(), "count" | "first" | "at"))
{
return Err(VmError::InvalidArgument("reserved ObjectList field"));
}
// 字段按类型定义顺序占据最后 N 项;checked_sub 在实参不足时返回错误,避免下标下溢。
let count = values
.len()
.checked_sub(fields.len())
@@ -136,6 +163,7 @@ impl NativeCallable for Constructor {
))?;
cx.store()
.check_class_field_arguments(*definition, &values[count..])?;
// 字段值先按声明检查;成员前缀另行要求全部是对象,禁止把标量装入宿主对象列表。
let members = values[..count]
.iter()
.map(|value| {
+26
View File
@@ -1,3 +1,6 @@
//! 一个原生窗口对应一份 Presentation:独立视口、Mount、测量缓存、焦点与依赖记录。
//! 多个窗口仍使用同一个 VM 和共享业务对象。读取版本失效时只标记相关展示需要刷新,不能因此复制一份业务状态。
//!
//! Per-window rendering state. Business objects and code remain in one VM.
//! Registries and caches borrow checked identities; the managed desktop owns
//! presentation roots through its pending/mount dictionaries.
@@ -6,13 +9,17 @@ use endor_core::{ChangeBatch, NativeContext, ObjectRef, Vm, VmError, WeakObjectR
use std::sync::Arc;
#[derive(Debug)]
/// 一个窗口独立的展示状态:活动页面、待提交请求、Mount、布局与源读取缓存;共享 VM 的业务状态不放在这里复制。
pub(crate) struct Presentation {
// 所属托管桌面身份;此结构借用身份,真正的展示根由托管桌面字典维持。
pub desktop: ObjectRef,
pub closing: bool,
pub tabs: ObjectRef,
pub active: Option<ObjectRef>,
// 双层 Option 区分三态:None 无请求,Some(None) 请求清空,Some(Some(page)) 请求切页。
pub pending: Option<Option<ObjectRef>>,
pub last_snapshot: Option<Arc<UiNode>>,
// 弱身份到原生稳定标签 ID 的映射;不能因为标签历史而保活已经关闭的页面。
pub tab_ids: Vec<(WeakObjectRef, u64)>,
pub next_tab: u64,
pub last_tabs: Vec<crate::tabs::Tab>,
@@ -21,7 +28,9 @@ pub(crate) struct Presentation {
pub layout: crate::layout::LayoutRuntime,
pub mounts: crate::mount::MountTable,
pub published_version: u64,
// 即使根快照身份未变也要求重新发布一帧,例如源绘制读取的数据发生变化时。
pub force_frame: bool,
// 精确名称/字典种类依赖,与下面按整个对象版本记录的 reads 分开检查。
pub paint_names: Vec<endor_core::ReadDependency>,
pub layout_names: Vec<endor_core::ReadDependency>,
pub paint_reads: std::collections::BTreeMap<ObjectRef, u64>,
@@ -34,7 +43,10 @@ pub(crate) struct Presentation {
}
impl Presentation {
/// 比较布局/绘制读取版本和名称依赖,标记需要刷新并通知快照缓存。
pub fn observe_changes(&mut self, vm: &Vm, changes: &ChangeBatch) {
// 先检查粗粒度对象版本,layout 优先于 paint;每类只记录第一个失效项用于诊断。
// 对象已经释放时版本查询失败,也视作失效,而非继续使用旧缓存。
if let Some((phase, object, version)) = self
.layout_reads
.iter()
@@ -57,6 +69,8 @@ impl Presentation {
vm.change_version(*object).ok()
);
}
// 再核对名称级依赖,避免仅凭对象版本漏掉名称解析路径上的变化。
// 两个检查独立执行,因此同次观察可能分别增加计数,后者诊断可覆盖前者。
if let Some((phase, dependency)) = self
.layout_names
.iter()
@@ -76,28 +90,33 @@ impl Presentation {
};
self.last_invalid_dependency = format!("{phase} name {}:{name}", dependency.object());
}
// 把同一批 VM 写入交给视觉快照缓存,源布局/绘制依赖与原生快照依赖分别维护。
self.render_cache.invalidate(ChangeBatch {
changes: changes.changes.clone(),
rescan_required: changes.rescan_required,
});
}
/// 按尺寸、内容或绘制失效原因清理对应缓存,避免把所有变化都当成相同重建。
pub fn invalidate(
&mut self,
cx: &mut NativeContext<'_>,
affected: &std::collections::HashSet<ObjectRef>,
reason: &str,
) -> Result<(), VmError> {
// 仅绘制失效不影响测量;其余原因清除测量结果并按 Block ID 使布局缓存失效。
if reason != "paint" {
self.measure_cache.invalidate(affected);
for block in affected {
self.layout.invalidate(&block.to_string());
}
}
// 内容变化还可能改变组成结构,因此显式失效视觉快照及其父链;其他原因不额外做此传播。
if reason == "content" {
self.render_cache
.invalidate_objects(affected.iter().copied());
}
// 仅当受影响 Block 在该窗口有活动出现时强制刷新,避免无关窗口重复发帧。
for weak in self.mounts.active.values() {
if let Some(mount) = cx.upgrade(*weak)?
&& affected.contains(
@@ -112,6 +131,8 @@ impl Presentation {
Ok(())
}
/// 用桌面与标签注册表身份初始化窗口状态,布局和视觉缓存从空状态开始。
// 这里只初始化窗口级缓存,不创建 VM、不复制业务对象;active/pending 留空等待窗口展示请求。
pub fn new(desktop: ObjectRef, tabs: ObjectRef) -> Self {
Self {
desktop,
@@ -142,6 +163,7 @@ impl Presentation {
}
}
/// 把读取记录拆成对象版本和精确名称依赖,便于按实际观察内容判断失效。
pub(crate) fn split_reads(
reads: Vec<endor_core::ReadDependency>,
) -> (
@@ -150,6 +172,7 @@ pub(crate) fn split_reads(
) {
let mut objects = std::collections::BTreeMap::new();
let mut names = Vec::new();
// 整对象依赖按身份收集到有序表,重复项以最后一次版本为准;其余精确依赖保持原记录。
for dependency in reads {
match dependency {
endor_core::ReadDependency::Object { object, version } => {
@@ -166,7 +189,9 @@ mod tests {
use super::*;
use crate::{layout::Size, ui_bridge::UiKind};
#[test]
/// 同VM中两Presentation分别布局共享树,尺寸/出现身份独立;读观察可追踪共享对象但不能增添GC根。
fn independent_view_geometry_and_shared_vm_reads_do_not_add_roots() {
// 测试在同一 VM 中建立两个 Presentation,验证各自布局尺寸/出现 ID 独立,读取依赖共享业务对象。
let mut vm = Vm::new().unwrap();
let mut left = Presentation::new(vm.new_object().unwrap(), vm.new_dict().unwrap());
let mut right = Presentation::new(vm.new_object().unwrap(), vm.new_dict().unwrap());
@@ -221,6 +246,7 @@ mod tests {
left.observe_changes(&vm, &changes);
right.observe_changes(&vm, &changes);
assert!(left.force_frame && right.force_frame);
// 最后释放业务对象并回收:两份读取记录都不应成为托管根,弱引用应升级失败。
let weak = vm.downgrade(shared).unwrap();
vm.release(shared).unwrap();
vm.collect_cycles();
+15
View File
@@ -1,23 +1,31 @@
//! 安装 Visual、EventTarget、Children 和 Presentable 等能力契约。
//! 契约描述对象能响应什么源方法;具体测量、绘制、事件执行仍在各自协议驱动器中完成。
//!
//! Host capability definitions over Aster's explicit managed trait relations.
use endor_core::{
AnnotationType, CallArity, CallEffect, CallSignature, ObjectRef, TypedMethodRequirement, Value,
Vm, VmError,
};
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
desktop: ObjectRef,
ui: ObjectRef,
) -> Result<(), VmError> {
// 安装前要求桥接已经创建 Trait 类型与 desktop.types 注册表;缺失时立即失败,不能安装半定义能力。
let Value::Object(trait_type) = vm.get_attr(ui, "Trait")? else {
return Err(VmError::InvalidArgument("missing Trait type"));
};
let Value::Object(types) = vm.get_attr(desktop, "types")? else {
return Err(VmError::InvalidArgument("missing type registry"));
};
// 每组声明能力名、方法及显式参数数目、提供默认签名的原生类型。
// 签名还含接收者参数,下面会把总 arity 调整为 parameters+1。
for (name, methods, owner) in [
(
// 测量/布局/绘制/命中能力,签名取自 Block 的原生协议实现,保证效果约束与具体方法一致。
"Visual",
vec![("measure", 2), ("arrange", 2), ("paint", 2), ("hitTest", 2)],
Some("Block"),
@@ -28,6 +36,7 @@ pub fn install(
Some("Block"),
),
(
// 组成编辑能力属于 ContainerBlock,普通叶 Block 不因此获得追加或重排孩子的方法。
"Children",
vec![
("append", 1),
@@ -41,6 +50,7 @@ pub fn install(
),
("Presentable", vec![("present", 1)], None),
] {
// 从当前 ui 模块查名义类型身份,不靠字符串名称把另一个运行时的类型误接进来。
let owner_definition = if let Some(owner) = owner {
let Value::Object(definition) = vm.get_attr(ui, owner)? else {
return Err(VmError::InvalidArgument("missing protocol owner"));
@@ -51,6 +61,7 @@ pub fn install(
};
let mut requirements = Vec::new();
for (method, parameters) in methods {
// 优先复用原生方法参数/返回注解和调用效果,缺少原生签名元数据时拒绝安装。
let mut signature = if let Some(owner) = owner_definition {
let function = vm
.object_store()
@@ -62,6 +73,7 @@ pub fn install(
"protocol method lacks native types",
))?
} else {
// Presentable 没有默认 owner,手工声明 present(receiver, Window) -> Block,调用效果为 External。
let window = crate::ui_bridge::desktop_type(vm.object_store(), "Window", desktop)?;
let block = crate::ui_bridge::desktop_type(vm.object_store(), "Block", desktop)?;
CallSignature::exact(
@@ -71,6 +83,7 @@ pub fn install(
)
};
signature.arity = CallArity::exact(parameters + 1);
// 接收者类型改为 Any,使能力可由其他符合方法契约的名义类型实现,不把契约绑定死在 Block。
signature.parameters[0] = AnnotationType::Any.into();
requirements.push(TypedMethodRequirement {
name: method.into(),
@@ -78,11 +91,13 @@ pub fn install(
});
}
let capability = vm.new_typed_trait(name, requirements)?;
// 新能力放入宿主 roots 管理生命周期,再同时暴露于 ui 属性与 desktop 类型注册表。
roots.push(capability);
vm.attach_type(capability, trait_type)?;
vm.set_attr(ui, name, Value::Object(capability))?;
vm.dict_set(types, Value::Text(name.into()), Value::Object(capability))?;
if let Some(owner) = owner_definition {
// 有默认提供者时显式登记 implements 关系;Presentable 留待具体类型声明,不自动给所有 Block 安装。
vm.implement_trait(owner, capability)?;
}
}
+86
View File
@@ -1,3 +1,7 @@
//! 将 Endor 托管场景对象转换成可供渲染线程读取的 3D 数据。
//! 构造器和字段仍属于 VM;快照复制必要的数值、不可变网格和依赖,渲染器不读取托管堆。
//! 场景遍历组合局部变换、检查环和资源限制,并为 Panel3D 提取独立 UI 根。控制器更新相机;拾取结果再映射回托管节点。
//!
//! Traced 3D objects and checked immutable snapshots. No managed handle enters
//! the renderer; cameras and node transforms remain editable Endor attributes.
use crate::{
@@ -11,6 +15,7 @@ use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, Vm
use std::{collections::HashSet, sync::Arc};
#[derive(Debug, Clone, Copy)]
/// 源 3D 类型的构造元数据入口,统一管理名称、参数、数量和可变子项。
pub enum Constructor {
Vec3,
Triangle,
@@ -26,6 +31,7 @@ pub enum Constructor {
Panel,
}
impl Constructor {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
match self {
Self::Vec3 => "Vec3",
@@ -42,6 +48,7 @@ impl Constructor {
Self::Panel => "Panel3D",
}
}
/// 取得本构造器对应的源工厂函数名称。
pub fn function(self) -> &'static str {
match self {
Self::Vec3 => "vec3",
@@ -58,6 +65,7 @@ impl Constructor {
Self::Panel => "panel3D",
}
}
/// 反查源构造器名称对应的原生 3D 种类,不认识的名称返回空值。
pub fn from_function(name: &str) -> Option<Self> {
[
Self::Vec3,
@@ -76,7 +84,9 @@ impl Constructor {
.into_iter()
.find(|kind| kind.function() == name)
}
/// 返回三维原生构造的显式最小/最大参数数,不含请求 Type;Group/Scene 支持最多4096子节点。
pub fn arity(self) -> (usize, usize) {
// 构造器 arity 不含 NativeTyped 的类型首参,Group/Scene 允许零到 4096 个孩子,其他零参对象使用默认属性。
match self {
Self::Vec3 | Self::Triangle => (3, 3),
Self::Geometry | Self::Mesh => (2, 2),
@@ -87,6 +97,7 @@ impl Constructor {
_ => (0, 0),
}
}
/// 列出本构造器的参数类型名称,供签名安装与源调用检查使用。
pub fn parameters(self) -> &'static [&'static str] {
match self {
Self::Vec3 | Self::Triangle => &["Number", "Number", "Number"],
@@ -98,12 +109,14 @@ impl Constructor {
_ => &[],
}
}
/// 标记构造器是否接收数量可变的子对象。
pub fn variadic(self) -> bool {
matches!(self, Self::Group | Self::Scene)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// 托管 3D 节点的名义种类,防止仅有同名字段的任意对象冒充场景节点。
pub enum NodeKind {
Vector,
Triangle,
@@ -117,40 +130,52 @@ pub enum NodeKind {
Panel,
}
#[derive(Debug)]
/// 允许普通属性的 3D 标记 payload;数值字段仍由托管属性系统拥有。
pub struct Node(pub NodeKind);
impl ObjectPayload for Node {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"scene-3d-object"
}
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
}
#[derive(Debug)]
/// 托管不可变网格包装,顶点和三角形数值已经复制为原生 Arc 数据。
pub struct Geometry(pub Arc<scene3d::Geometry>);
impl ObjectPayload for Geometry {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"geometry-3d"
}
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
}
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 把源 Value 转换为受检查的托管对象引用,拒绝非对象输入。
fn object_ref(value: &Value) -> Result<ObjectRef, VmError> {
if let Value::Object(object) = value {
Ok(*object)
@@ -158,9 +183,11 @@ fn object_ref(value: &Value) -> Result<ObjectRef, VmError> {
Err(invalid("3D field requires an object"))
}
}
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
fn number(value: &Value) -> Result<f32, VmError> {
crate::scroll::coordinate(value)
}
/// 把当前源值或文本选择解码成此调用需要的文本表示。
fn text(value: &Value) -> Result<String, VmError> {
if let Value::Text(text) = value {
Ok(text.clone())
@@ -168,6 +195,7 @@ fn text(value: &Value) -> Result<String, VmError> {
Err(invalid("3D field requires text"))
}
}
/// 把经过转换的数值写回指定托管字段,仍通过 VM/NativeContext 的检查入口。
fn set_number(
cx: &mut NativeContext<'_>,
object: ObjectRef,
@@ -176,7 +204,9 @@ fn set_number(
) -> Result<(), VmError> {
cx.set_attr(object, name, crate::native_visual::value(value))
}
/// 解析颜色值并检查其满足当前原生绘制约定。
fn color(value: &str) -> Result<[f32; 3], VmError> {
// 3D 不透明材质拒绝半透明颜色,转换为 [0,1] RGB 数值;普通二维 UI 的 alpha 不受这里限制。
let color = crate::document::parse_color(value).map_err(|_| invalid("invalid 3D color"))?;
if color.alpha != 255 {
return Err(invalid("3D opaque material color requires full alpha"));
@@ -188,6 +218,7 @@ fn color(value: &str) -> Result<[f32; 3], VmError> {
])
}
/// 按构造器建立对应托管 3D 类型,检查几何输入并安装默认属性。
pub fn allocate(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
@@ -195,6 +226,7 @@ pub fn allocate(
constructor: Constructor,
args: &[Value],
) -> Result<ObjectRef, VmError> {
// 入口假设桥接工厂已按 arity/类型检查 args;Geometry 特殊地复制缓冲并提前返回,其余创建可编辑标记节点。
let kind = match constructor {
Constructor::Vec3 => NodeKind::Vector,
Constructor::Triangle => NodeKind::Triangle,
@@ -207,6 +239,7 @@ pub fn allocate(
Constructor::Orbit => NodeKind::Orbit,
Constructor::Panel => NodeKind::Panel,
Constructor::Geometry => {
// Geometry 构造只接受受控 ObjectList,逐成员验证 Vec3 payload 并复制数值;以后改源 Vec3 不会改已建几何。
let vertices = object_list::items(cx.store(), object_ref(&args[0])?)?
.iter()
.map(|&item| {
@@ -228,6 +261,7 @@ pub fn allocate(
return Err(invalid("geometry indices require Triangle3D"));
}
let mut triangle = [0; 3];
// 索引先从 Number 检查非负整数,再转 u32;是否越出顶点范围在整份 geometry.valid 校验。
for (index, name) in ["a", "b", "c"].into_iter().enumerate() {
let value = number(&cx.store().get_attr(item, name)?)?;
if value < 0. || value.fract() != 0. {
@@ -243,6 +277,7 @@ pub fn allocate(
triangles,
uv: vec![],
};
// 缓存为原生 Arc 前统一验证顶点/三角形预算与下标范围,避免渲染线程再接触源缓冲对象。
if !geometry.valid() {
return Err(invalid(
"invalid Geometry3D buffers or geometry budget exceeded",
@@ -271,6 +306,7 @@ pub fn allocate(
NodeKind::Mesh => {
let (geometry, material) = if matches!(constructor, Constructor::Box) {
let (w, h, d) = (number(&args[0])?, number(&args[1])?, number(&args[2])?);
// Box 允许零尺寸产生退化面,后续光栅/拾取跳过退化三角形;负尺寸在构造入口拒绝。
if w < 0. || h < 0. || d < 0. {
return Err(invalid("Box3D dimensions must be nonnegative"));
}
@@ -286,6 +322,7 @@ pub fn allocate(
cx.set_attr(instance, "geometry", Value::Object(geometry))?;
cx.set_attr(instance, "color", Value::Text(material))?;
}
// 孩子只接受 Mesh/Group/Light/Panel 标记,不允许把相机、控制器或任意 Object 放进场景组成。
NodeKind::Group | NodeKind::Scene => {
let children = args.iter().map(object_ref).collect::<Result<Vec<_>, _>>()?;
for child in &children {
@@ -298,6 +335,7 @@ pub fn allocate(
));
}
}
// 托管 children 列表拥有子节点;Node payload 自身只是标记,属性拥有边由 VM 正常追踪。
let children = object_list::collection(cx, children, desktop)?;
cx.set_attr(instance, "children", Value::Object(children))?;
if kind == NodeKind::Scene {
@@ -316,6 +354,7 @@ pub fn allocate(
cx.set_attr(instance, "color", args[3].clone())?;
set_number(cx, instance, "intensity", number(&args[4])?)?;
}
// 世界宽高与 UI 像素分辨率独立:初始纹理 512×320,content 是普通 Block 图,稍后参与独立有限布局。
NodeKind::Panel => {
let content = object_ref(&args[2])?;
crate::block::validate(cx.store(), content, None)?;
@@ -350,6 +389,7 @@ pub fn allocate(
set_number(cx, instance, "panSpeed", 1.)?;
}
}
// 只有可放置节点安装 TRS;相机用 position/target/up,方向光用方向向量,不复用网格欧拉角字段。
if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) {
for (name, value) in [
("x", 0.),
@@ -368,18 +408,23 @@ pub fn allocate(
Ok(instance)
}
/// 快照字段读取器,同时记录实际观察到的对象/属性依赖。
struct Reader<'a> {
store: &'a ObjectStore,
reads: HashSet<(ObjectRef, String)>,
}
impl Reader<'_> {
/// 读取字段并登记依赖,使后续字段变更能够使 3D 快照失效。
fn value(&mut self, object: ObjectRef, name: &str) -> Result<Value, VmError> {
// 读取前登记精确对象/属性依赖,HashSet 去重;返回给视觉缓存后,相关字段变化才能驱动重建。
self.reads.insert((object, name.into()));
self.store.get_attr(object, name)
}
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
fn number(&mut self, object: ObjectRef, name: &str) -> Result<f32, VmError> {
number(&self.value(object, name)?)
}
/// 连续读取三个数值字段,组成位置、旋转或缩放向量。
fn vector(&mut self, object: ObjectRef, names: [&str; 3]) -> Result<Vec3, VmError> {
Ok(Vec3::new(
self.number(object, names[0])?,
@@ -387,7 +432,9 @@ impl Reader<'_> {
self.number(object, names[2])?,
))
}
/// 读取并校验透视或正交摄像机字段,生成不持有 VM 引用的投影描述。
fn camera(&mut self, object: ObjectRef) -> Result<Camera, VmError> {
// 先验证宿主相机种类,再按投影策略读取 fov 或 height;不把有同名字段的对象当相机。
let projection = match self.store.payload::<Node>(object, "3D camera")?.0 {
NodeKind::Perspective => Projection::Perspective {
fov: self.number(object, "fov")?,
@@ -416,16 +463,19 @@ impl Reader<'_> {
}
}
/// 从场景中提取的有限 UI 根,携带稳定面板 key 和逻辑排版尺寸。
pub struct PanelMount {
pub key: u64,
pub content: ObjectRef,
pub size: crate::layout::Size,
}
// 三部分分别是可跨线程的不可变 View、VM 内缓存用的属性依赖、仍由 VM 驱动布局的面板 Block 根。
pub type Snapshot = (
Arc<scene3d::View>,
Vec<(ObjectRef, String)>,
Vec<PanelMount>,
);
/// 将当前托管或可变状态提取为后续阶段使用的派生表示。
pub fn snapshot(
store: &ObjectStore,
scene: ObjectRef,
@@ -433,6 +483,7 @@ pub fn snapshot(
) -> Result<Snapshot, VmError> {
snapshot_reusing(store, scene, camera, None)
}
/// 遍历场景并记录字段依赖;如果世界数据未变则复用不可变场景和 BVH,只替换摄像机视图。
pub fn snapshot_reusing(
store: &ObjectStore,
scene: ObjectRef,
@@ -459,6 +510,7 @@ pub fn snapshot_reusing(
background,
acceleration: Arc::default(),
};
// 显式进入/退出栈保存节点、父世界矩阵、退出标记与路径 key;path 仅追踪祖先,因此允许共享子树多次出现。
let mut work = vec![(scene, Matrix::default(), false, 0u64)];
let mut panels = Vec::new();
let mut path = HashSet::new();
@@ -471,6 +523,7 @@ pub fn snapshot_reusing(
continue;
}
visits += 1;
// 预算按展开出现次数,重复共享同一网格也消耗实例/顶点/三角形工作,不能只数不同对象身份。
if visits > 4096 {
return Err(VmError::ExecutionLimit("3D scene occurrences"));
}
@@ -480,6 +533,7 @@ pub fn snapshot_reusing(
work.push((object, parent, true, key));
let kind = store.payload::<Node>(object, "3D node")?.0;
let world = if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) {
// 局部先缩放、旋转、平移,再乘父 world;Scene/Light 不新增 TRS,方向光仍受祖先 Group 变换。
parent.compose(Matrix::trs(
reader.vector(object, ["x", "y", "z"])?,
reader.vector(object, ["rotationX", "rotationY", "rotationZ"])?,
@@ -494,6 +548,8 @@ pub fn snapshot_reusing(
for (index, child) in object_list::items(store, list)?.iter().enumerate().rev() {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
// 路径 key 包含父 key、当前孩子索引和托管身份,区分同一 Panel 节点在不同路径的出现。
// 此 key 与普通 Block slot 不同,场景列表重排会改变路径身份。
key.hash(&mut hasher);
index.hash(&mut hasher);
child.hash(&mut hasher);
@@ -503,6 +559,7 @@ pub fn snapshot_reusing(
NodeKind::Mesh => {
let geometry = object_ref(&reader.value(object, "geometry")?)?;
let geometry = store.payload::<Geometry>(geometry, "Geometry3D")?.0.clone();
// 每次 mesh 出现都累计完整几何,Arc 共享只节约存储,不减少本帧实例绘制工作预算。
triangles += geometry.triangles.len();
vertices += geometry.vertices.len();
if vertices > 262_144 {
@@ -523,10 +580,12 @@ pub fn snapshot_reusing(
NodeKind::Panel => {
// Arrangement slots cross Endor's signed integer API. A raw
// u64 hash can turn negative when native Bounds are created.
// Bounds.slot 经有符号整数 API 传递,清最高位避免变负;仍显式检测碰撞,不能假设哈希必然唯一。
let key = key & i64::MAX as u64;
if panels.iter().any(|panel: &PanelMount| panel.key == key) {
return Err(invalid("colliding 3D UI occurrence identities"));
}
// 有限面板根数量另限 64;每个面板还有独立像素分辨率限制和递归纹理总预算。
if panels.len() >= 64 {
return Err(VmError::ExecutionLimit("3D UI panels"));
}
@@ -547,6 +606,7 @@ pub fn snapshot_reusing(
let content = object_ref(&reader.value(object, "content")?)?;
crate::block::resolve(store, content)?;
let background = color(&text(&reader.value(object, "background")?)?)?;
// 面板平面以中心为原点,顶点 y 向上,UV 左上为 (0,0);两三角形共用四个角点。
let geometry = Arc::new(scene3d::Geometry {
vertices: vec![
Vec3::new(-width / 2., height / 2., 0.),
@@ -570,6 +630,7 @@ pub fn snapshot_reusing(
surface: None,
}),
});
// 同时把普通 UI 根交给宿主布局,scene Mesh 先只带分辨率/背景,surface 由后续 panel::compose 装配。
panels.push(PanelMount {
key,
content,
@@ -585,6 +646,7 @@ pub fn snapshot_reusing(
}
let direction =
reader.vector(object, ["directionX", "directionY", "directionZ"])?;
// 用两点之差撤销父矩阵平移,仅保留线性部分对方向的影响,再归一化;零方向拒绝。
let direction = (world.point(direction) - world.point(Vec3::default()))
.normalized()
.ok_or(invalid("light direction cannot be zero"))?;
@@ -602,6 +664,7 @@ pub fn snapshot_reusing(
_ => return Err(invalid("invalid node in 3D scene graph")),
}
}
// 完整世界值相等时复用旧 Scene/BVH,单独换 camera 生成新 View;不相等才重建世界空间索引。
let scene = if let Some(scene) = reuse.filter(|scene| **scene == output) {
scene
} else {
@@ -609,12 +672,14 @@ pub fn snapshot_reusing(
Arc::new(output)
};
let view = Arc::new(scene3d::View { scene, camera });
// 提取各项之后仍做完整视图校验,覆盖累计世界变换、光照、面板及整体资源不变量。
if !view.valid() {
return Err(invalid("invalid 3D scene snapshot"));
}
Ok((view, reader.reads.into_iter().collect(), panels))
}
/// 把视口中的指针射线结果转换成 RayHit3D,关联实际托管网格和交点数据。
pub fn pick(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
@@ -629,6 +694,7 @@ pub fn pick(
if !matches!(data.kind, crate::block::Kind::Viewport3D { .. }) {
return Err(invalid("pick3D requires a Viewport3D"));
}
// 先做出现/面板遮挡和裁剪命中,未命中直接 Nil;即使场景射线有交点也不能点穿外层遮挡。
if !mount.geometry.contains(point) {
return Ok(Value::Nil);
}
@@ -637,6 +703,7 @@ pub fn pick(
let Some(point) = mount.geometry.logical_point(point) else {
return Ok(Value::Nil);
};
// 从布局盒扣 inset 得到实际内容视口,再把逻辑点归一化;零面积视口没有有效投影。
let (width, height) = (
(mount.geometry.width - inset.horizontal()).max(0.),
(mount.geometry.height - inset.vertical()).max(0.),
@@ -657,6 +724,7 @@ pub fn pick(
let Some(hit) = hit else {
return Ok(Value::Nil);
};
// 原生 Hit.identity 只带文本,通过本次实际读取的托管身份重新关联节点;渲染器本身从不解引用 VM 对象。
let mesh = reads
.iter()
.find_map(|(object, _)| (object.to_string() == hit.identity).then_some(*object))
@@ -680,6 +748,7 @@ pub fn pick(
"triangle",
Value::Number((hit.triangle as i64).into()),
)?;
// 交点、法线、重心权重都包装为名义 Vec3,结果对象通过普通属性强持有它们。
for (name, vector) in [
("point", hit.point),
("normal", hit.normal),
@@ -706,6 +775,7 @@ pub fn pick(
}
#[derive(Debug)]
/// 指针拾取的中间结果,保留交点与所属实例信息供 Event/RayHit3D 使用。
pub struct PointerPick {
pub mount: ObjectRef,
pub desktop: ObjectRef,
@@ -713,6 +783,7 @@ pub struct PointerPick {
}
/// The default controller is replaceable; camera data has no dependency on it.
/// 默认 OrbitController 根据已解码的 CameraGesture 更新相机,处理轨道、平移和缩放;可由源控制器替换。
pub fn control(
cx: &mut NativeContext<'_>,
controller: ObjectRef,
@@ -730,6 +801,7 @@ pub fn control(
let dx = reader.number(gesture, "dx")?;
let dy = reader.number(gesture, "dy")?;
let kind = text(&reader.value(gesture, "kind")?)?;
// 默认轨道控制围绕 target 使用 position-target 偏移和半径;相机有效性保证观察方向不退化。
let offset = camera.position - camera.target;
let radius = offset.length();
let mut position = camera.position;
@@ -744,12 +816,14 @@ pub fn control(
.up
.normalized()
.ok_or(invalid("camera up cannot be zero"))?;
// 将偏移分解为 up 方向和水平面方向,用水平旋转更新 yaw、asin(vertical) 更新 pitch。
let vertical = (offset.dot(up) / radius).clamp(-1., 1.);
let horizontal = (offset - up * offset.dot(up))
.normalized()
.ok_or(invalid("orbit at pole requires a different up vector"))?;
let (sine, cosine) = (dx * speed).sin_cos();
let horizontal = horizontal * cosine + up.cross(horizontal) * sine;
// 俯仰限制在两极内侧 0.01 弧度,避免绕极点时水平基退化并导致轨道跳变。
let pitch = (vertical.asin() + dy * speed).clamp(
-std::f32::consts::FRAC_PI_2 + 0.01,
std::f32::consts::FRAC_PI_2 - 0.01,
@@ -762,6 +836,7 @@ pub fn control(
return Err(invalid("panSpeed expects 0 through 100"));
}
let height = reader.number(gesture, "height")?.max(1.);
// 把逻辑像素位移换成世界单位:透视按 target 距离处的可见高度,正交按固定投影高度。
let units = match camera.projection {
Projection::Perspective { fov } => {
2. * radius * (fov.to_radians() / 2.).tan() / height
@@ -770,6 +845,7 @@ pub fn control(
};
let basis = camera.basis().ok_or(invalid("invalid camera basis"))?;
let delta = basis.right * (dx * units * speed) + basis.up * (-dy * units * speed);
// 平移同时移动 position 和 target,保留观察方向与轨道半径;屏幕 y 向下所以 up 分量取负。
position = position + delta;
target = target + delta;
}
@@ -778,6 +854,7 @@ pub fn control(
if !(0.1..=10.0).contains(&factor) {
return Err(invalid("zoom factor expects 0.1 through 10"));
}
// 正交缩放只改投影高度,透视缩放改变到 target 的距离;倍率大于一均表现为拉近。
if let Projection::Orthographic { height } = camera.projection {
set_number(
cx,
@@ -793,6 +870,7 @@ pub fn control(
}
_ => return Err(invalid("unsupported 3D camera gesture")),
}
// 写回前校验整个计算结果,拒绝超世界范围;控制器通过普通属性更新让后续快照依赖观察变化。
if !position.valid() || !target.valid() {
return Err(invalid("camera movement exceeds world budget"));
}
@@ -809,6 +887,7 @@ pub fn control(
Ok(())
}
/// 对允许的 3D 节点累计平移或角度旋转;相机平移同时更新 target,旋转值归一到 [0,360)。
pub fn transform(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
@@ -828,11 +907,13 @@ pub fn transform(
{
return Err(invalid("unsupported 3D transform target"));
}
// 旋转增量作用欧拉角,平移增量作用位置;相机不接受本接口的欧拉角旋转。
let names = if rotate {
["rotationX", "rotationY", "rotationZ"]
} else {
["x", "y", "z"]
};
// 先收集并验证全部轴再写入,避免验证到第二/三轴才发现越界时已修改前面轴。
let mut updates = Vec::new();
for (index, name) in names.into_iter().enumerate() {
let delta = number(&args[index])?;
@@ -843,11 +924,13 @@ pub fn transform(
updates.push((
name,
if rotate {
// 角度归一到 [0,360),支持负增量而不使用产生负余数的普通取模。
value.rem_euclid(360.)
} else {
value
},
));
// 相机平移同步改 target,保留朝向;不把“移动摄像机”意外变成绕固定目标转动。
if camera {
let name = ["targetX", "targetY", "targetZ"][index];
let value = number(&cx.store().get_attr(owner, name)?)? + delta;
@@ -863,9 +946,11 @@ pub fn transform(
Ok(())
}
impl endor_core::NativeCallable for PointerPick {
/// 输入 Mount 与点击点已捕获,拾取/分发准备步骤不再接受实参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 在当前 Mount 中重新射线拾取,构造含真实 mesh 命中的 click Event,再交托管事件系统分发。
fn invoke(
&self,
cx: &mut NativeContext<'_>,
@@ -878,6 +963,7 @@ impl endor_core::NativeCallable for PointerPick {
let hit = pick(cx, self.desktop, &mount, self.point)?;
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
let event = crate::event_dispatch::new_event(cx, definition, "click")?;
// 点击事件把精确拾取值放到 hit(未击中则 Nil),仍以 Viewport 的 Block/Mount 作为事件传播目标。
cx.set_attr(event, "hit", hit)?;
crate::event_dispatch::dispatch(cx, mount.block, event, Some(self.mount), self.desktop)
}
+43
View File
@@ -1,3 +1,7 @@
//! 从可变托管对象图提取不可变 UiNode,并维护反向依赖与父子失效传播。
//! 缓存只借用可检查的对象身份,不增加托管根。对象释放后必须清理缓存索引;快照仍可持有独立的原生数值数据。
//! 干净子树直接复用 Arc;脏节点重新检查属性、方法、绑定和子项,循环与展开数量在发布前拒绝。
//!
//! Object-to-visual dependencies. Cached results never own VM references.
use crate::{
block::{self, Kind},
@@ -10,6 +14,7 @@ use std::{
};
#[derive(Debug)]
/// 某个业务组件的视觉快照,以及使它失效的读取依赖和子项身份。
struct Cached {
node: Arc<UiNode>,
dependencies: Vec<Dependency>,
@@ -17,17 +22,24 @@ struct Cached {
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
/// 反向依赖键:区分整个对象内容和精确属性名称,减少无关字段变化引起的失效。
struct Dependency {
object: ObjectRef,
property: Option<String>,
}
#[derive(Debug, Default)]
/// 视觉快照缓存与反向依赖图。对象身份是借用索引,原生 Arc 数据不拥有 VM 根。
pub struct RenderCache {
// 场景对象身份到原生不可变几何的缓存;相机改变时仍有机会复用同一场景数据。
scenes3d: HashMap<ObjectRef, Arc<crate::scene3d::Scene>>,
// 每个业务 Block 的视觉结果;这里只保存原生快照,不通过 ObjectRef 增加 VM 强引用。
entries: HashMap<ObjectRef, Cached>,
// 读取源 -> 读取它的 Block。属性变动从这里找到首批需要重建的快照。
dependents: HashMap<Dependency, HashSet<ObjectRef>>,
// 子 Block -> 所有父 Block。共享子组件改变后,所有引用该子项的父快照都需要更新。
parents: HashMap<ObjectRef, HashSet<ObjectRef>>,
// 已失效但尚未重建的 Block 集合,同时用于传播去重。
dirty: HashSet<ObjectRef>,
pub last_invalidation: String,
pub visited: usize,
@@ -35,13 +47,16 @@ pub struct RenderCache {
}
impl RenderCache {
/// 把 VM 变化映射到读取过这些属性/对象的视觉节点,再沿父链向上传播脏标记。
pub fn invalidate(&mut self, batch: ChangeBatch) {
let mut queue = VecDeque::new();
// 变更日志溢出后无法知道遗漏了哪些写入,保守地将已有快照全部失效。
if batch.rescan_required {
self.last_invalidation = "change journal overflow".into();
queue.extend(self.entries.keys().copied());
} else {
for change in batch.changes {
// 属性变更使用精确名称,Contents 使用整个对象键;与提取时登记的依赖粒度一致。
let key = Dependency {
object: change.object,
property: match change.key {
@@ -58,9 +73,11 @@ impl RenderCache {
self.invalidate_objects(queue);
}
/// 迭代传播显式失效,使用集合避免共享父链重复遍历或循环入队。
pub fn invalidate_objects(&mut self, objects: impl IntoIterator<Item = ObjectRef>) {
let mut queue: VecDeque<_> = objects.into_iter().collect();
while let Some(block) = queue.pop_front() {
// 只有第一次加入 dirty 才继续向父链传播,避免共享祖先被反复展开。
if self.dirty.insert(block)
&& let Some(parents) = self.parents.get(&block)
{
@@ -69,8 +86,10 @@ impl RenderCache {
}
}
/// 撤销旧缓存的反向读取边和父子边,为重新登记该节点做准备。
fn remove(&mut self, block: ObjectRef) -> Option<Cached> {
let old = self.entries.remove(&block)?;
// 旧边必须先撤销,否则节点重建后仍会被已经不再读取的对象触发失效。
for source in &old.dependencies {
if let Some(owners) = self.dependents.get_mut(source) {
owners.remove(&block);
@@ -79,6 +98,7 @@ impl RenderCache {
}
}
}
// 只撤销当前 Block 对孩子的父边;其他共享父节点仍保留。空集合及时删除以缩小索引。
for child in &old.children {
if let Some(parents) = self.parents.get_mut(child) {
parents.remove(&block);
@@ -90,9 +110,11 @@ impl RenderCache {
Some(old)
}
/// 删除托管堆中已不存在的身份,缓存本身不负责保持业务对象存活。
pub fn prune(&mut self, store: &ObjectStore) {
self.scenes3d
.retain(|object, _| store.object(*object).is_ok());
// 先收集死亡身份,再修改 entries 与反向索引,避免一边借用遍历一边删除。
let dead: Vec<_> = self
.entries
.keys()
@@ -101,10 +123,12 @@ impl RenderCache {
.collect();
for block in dead {
self.remove(block);
// 只有成功建立新条目后才清脏标记;前面任何校验失败都不会把节点错误标成干净。
self.dirty.remove(&block);
}
}
/// 按显式栈遍历托管组成;复用干净子树,重建脏节点,并在完成前检查循环和资源上限。
pub fn snapshot(
&mut self,
store: &ObjectStore,
@@ -112,10 +136,13 @@ impl RenderCache {
) -> Result<Arc<UiNode>, VmError> {
self.visited = 0;
self.reused = 0;
// 使用进入/退出两阶段的显式 DFS,避免深层组成消耗 Rust 调用栈。
// values 是后序结果栈:每个退出节点消费刚完成的孩子快照,再压入自己。
let mut work = vec![(root, false)];
let mut path = HashSet::new();
let mut values = Vec::<Arc<UiNode>>::new();
while let Some((block, finish)) = work.pop() {
// 入栈时先尝试复用;退出标记表示孩子已形成快照,可以组装父节点。
if !finish {
if !self.dirty.contains(&block)
&& let Some(cached) = self.entries.get(&block)
@@ -124,11 +151,13 @@ impl RenderCache {
values.push(cached.node.clone());
continue;
}
// 计数的是本次实际展开次数;共享节点可复用缓存,脏图展开仍有 10000 次上限。
self.visited += 1;
if self.visited > 10_000 {
return Err(VmError::ExecutionLimit("expanded Block graph"));
}
}
// 提取前验证实例存储与类型契约;resolve_tracked 同时提供数据以及经过的代理/存储依赖。
store.check_typed_instance_storage(block)?;
let (data_ref, raw_data, raw_dependencies) = block::resolve_tracked(store, block)?;
let mut data = block::visual_data(store, raw_data)?;
@@ -139,6 +168,7 @@ impl RenderCache {
property: None,
})
.collect();
// 方法名称查找也构成依赖。即使属性数值未变,协议方法被替换也必须让快照失效。
let storage = *store
.attribute_path(block)?
.last()
@@ -149,6 +179,7 @@ impl RenderCache {
property: Some(name),
});
}
// 原生视觉属性按精确字段登记,避免不参与视觉的其他属性写入触发整个节点重建。
if let Some(properties) = data.properties {
for (name, _) in block::native_properties(&data) {
dependencies.push(Dependency {
@@ -167,6 +198,7 @@ impl RenderCache {
});
}
}
// 3D 面板把托管内容映射为子 Block,同时保留尺寸与稳定 slot,供后续布局和出现身份使用。
let mut panel_sizes = Vec::new();
let view3d = if matches!(data.kind, Kind::Viewport3D { .. }) {
let scene = data
@@ -198,6 +230,7 @@ impl RenderCache {
} else {
None
};
// path 只追踪当前祖先链,因此允许同一业务组件在不同分支多次出现。
if !finish {
if !path.insert(data_ref) {
return Err(VmError::InvalidArgument("cyclic Block composition"));
@@ -209,6 +242,7 @@ impl RenderCache {
continue;
}
path.remove(&data_ref);
// 协议键包含类型身份、实际方法身份及其内容版本,供布局/绘制缓存识别实现变化。
let mut protocol_key = String::new();
let mut custom_event = false;
if let Some(definition) = store.type_of(block)? {
@@ -223,6 +257,7 @@ impl RenderCache {
});
}
}
// 不仅记录最终方法,还沿基类链登记类型内容依赖,使继承关系或方法表变化能触发重建。
let event = store.lookup_method(definition, "onEvent")?;
let mut current = Some(definition);
while let Some(definition) = current {
@@ -240,6 +275,7 @@ impl RenderCache {
.transpose()?
.unwrap_or(false);
}
// 订阅连接的取消会改变 handler;记录连接内容依赖,使交互资格能随取消及时更新。
let mut click_connection = false;
for connection in &data.subscriptions {
dependencies.push(Dependency {
@@ -250,9 +286,11 @@ impl RenderCache {
.payload::<crate::event_dispatch::Connection>(*connection, "Connection")?;
click_connection |= item.kind == "click" && item.handler.is_some();
}
// 逆序压入孩子确保后序结果与 data.children 原顺序一致;只取尾部的本节点孩子,保留兄弟结果。
let children = values.split_off(values.len() - data.children.len());
let kind = match &data.kind {
Kind::Text { value, size, color } => {
// 旧 textOf 路径按实际源字段提取字符串。属性路径中间对象用整对象依赖,末端用字段依赖。
let value = if let Some((source, field)) = &data.text_binding {
let storage = store.attribute_path(*source)?;
for (index, object) in storage.iter().enumerate() {
@@ -266,6 +304,7 @@ impl RenderCache {
Value::Bool(v) => v.to_string(),
Value::Number(v) => v.to_string(),
Value::Text(v) => v,
// 对象字符串化可能依赖其内容,所以除属性本身外还记录被引用对象的内容变化。
Value::Object(v) => {
dependencies.push(Dependency {
object: v,
@@ -321,6 +360,7 @@ impl RenderCache {
)?,
children,
},
// 二维场景保留每个孩子的世界位置;普通行列容器则由布局决定孩子坐标。
Kind::Scene2D => UiKind::Scene2D {
positions: data
.children
@@ -368,6 +408,7 @@ impl RenderCache {
children,
},
};
// 统一外层样式与交互标志。UiKind 只表示具体内容,UiNode 保存所有类别共享的视觉元信息。
let next = UiNode {
surface: matches!(data.kind, Kind::Page).then(|| {
let mut surface = data.surface.clone();
@@ -394,12 +435,14 @@ impl RenderCache {
|| custom_event,
kind,
};
// 即使本次重算结果相同,也要重新登记依赖。shallow_eq 相同时复用旧 Arc,减少下游无效重建。
let old = self.remove(block);
let node = if let Some(old) = old.as_ref().filter(|old| old.node.shallow_eq(&next)) {
old.node.clone()
} else {
Arc::new(next)
};
// 同一属性可能由不同提取路径重复读取,排序去重后再建立反向边。
dependencies.sort_unstable();
dependencies.dedup();
let child_refs: Vec<_> = data.children.iter().map(|child| child.block).collect();
+57
View File
@@ -1,3 +1,7 @@
//! 托管 Window 的页面所有权、原生命令与关闭生命周期。
//! 源码 resize/focus/setTitle 先产生纯数据命令;只有所属输入成功发布后,GUI 线程才应用它们。
//! 窗口 ID 和运行时 session 共同限定资源归属;切换 VM 或关闭窗口时不能让旧命令作用到新资源。
//!
//! Managed window resources and versioned, coalesced native projections.
//! The VM owns Page roots; only scalar commands cross to the GUI thread.
use crate::{native_visual, object_list, ui_bridge::desktop_type};
@@ -10,6 +14,7 @@ use std::sync::{Arc, Mutex};
pub const MAIN_WINDOW: u64 = 1;
pub const MAX_WINDOW_DIMENSION: f64 = 4096.0;
#[derive(Debug, Clone, PartialEq)]
/// 可投影到原生窗口的动作类别,如尺寸、标题、焦点与关闭。
pub enum Action {
Resize { width: f64, height: f64 },
SetTitle(String),
@@ -18,48 +23,61 @@ pub enum Action {
Close,
}
#[derive(Debug, Clone, PartialEq)]
/// 成功发布后由 GUI 执行的一条原生窗口请求,含资源身份和动作顺序。
pub struct Command {
pub window: u64,
pub sequence: u64,
pub action: Action,
}
impl Command {
/// 判断两条命令是否针对同一资源的同类动作,用于替换尚未执行的旧请求。
pub fn same_kind(&self, other: &Self) -> bool {
// 比较动作变体而不是参数值:同窗口多次 Resize/SetTitle 可合并为最后一次,不同动作仍按序保留。
self.window == other.window
&& std::mem::discriminant(&self.action) == std::mem::discriminant(&other.action)
}
}
#[derive(Debug, Clone)]
/// 附带运行时 session 的命令,阻止旧域请求在新 VM 上生效。
pub struct Envelope {
pub session: u64,
pub command: Command,
}
#[derive(Debug, Clone)]
/// 托管窗口资源状态与页面拥有关系;本对象存在不等于 OS 窗口已创建。
pub struct ManagedWindow {
pub id: u64,
// 每窗口拥有独立 desktop、tabs 与页面顺序;同 VM 的类型注册表可共享,不复制业务模型。
pub desktop: ObjectRef,
pub closed: bool,
pub tasks: Option<ObjectRef>,
tabs: ObjectRef,
// 页面身份的有序强拥有集合,tabs 字典同时提供托管注册入口;移动顺序只调整此 Vec。
pages: Vec<ObjectRef>,
sequence: u64,
// Transport state only. Draining it must not change a language object's
// version or create a render-dependency feedback loop.
// 仅保存纯数据传输队列,克隆 payload 仍共享同一 outbox;drain 不触发 VM 内容版本,避免反馈循环。
outbox: Arc<Mutex<Vec<Command>>>,
}
impl ObjectPayload for ManagedWindow {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-window"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
visit(self.desktop);
if let Some(tasks) = self.tasks {
@@ -71,19 +89,23 @@ impl ObjectPayload for ManagedWindow {
}
}
}
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 把源数值转换为有限屏幕坐标,拒绝越界或不可表示的值。
fn screen_coordinate(value: &Value) -> Result<i32, VmError> {
let Value::Number(value) = value else {
return Err(invalid("screen position requires integer coordinates"));
};
// 屏幕位置必须为整数,允许负坐标以覆盖多显示器;范围与 i32 转换均检查,不能把小数直接截断。
value
.as_i64()
.filter(|number| (-1_000_000..=1_000_000).contains(number))
.and_then(|number| i32::try_from(number).ok())
.ok_or(invalid("screen position exceeds limits"))
}
/// 从源 Value 取得窗口操作需要的数值,保留错误类型诊断。
fn scalar(value: &Value) -> Result<f64, VmError> {
let Value::Number(value) = value else {
return Err(invalid("window size requires numbers"));
@@ -98,6 +120,7 @@ fn scalar(value: &Value) -> Result<f64, VmError> {
Ok(number)
}
#[derive(Debug, Clone, Copy)]
/// 当前宿主 API 的操作分类,分派代码负责参数校验与状态更新。
enum Operation {
Id,
Viewport,
@@ -108,15 +131,18 @@ enum Operation {
Focus,
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
signature: endor_core::CallSignature,
operation: Operation,
desktop: ObjectRef,
}
impl NativeCallable for Method {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(self.signature.clone())
}
/// 计入 Window 接收者:resize/moveTo 共三参,setTitle 两参,其他查询/关闭/聚焦只要接收者。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(match self.operation {
Operation::Resize | Operation::MoveTo => 3,
@@ -125,6 +151,7 @@ impl NativeCallable for Method {
}))
}
/// 验证窗口资源与关闭状态;查询返回托管值,原生变更生成受世代保护的待执行命令,关闭走资源生命周期。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let Some(Value::Object(owner)) = args.first() else {
return Err(invalid("Window method requires a Window"));
@@ -133,6 +160,7 @@ impl NativeCallable for Method {
.store()
.payload::<ManagedWindow>(*owner, "Window")?
.clone();
// 已关闭窗口仍可查询稳定 ID 与页面集合,其他读取/操作拒绝,避免用旧 viewport 或重开原生资源。
if data.closed && !matches!(self.operation, Operation::Id | Operation::Pages) {
return Err(invalid("native window is closed"));
}
@@ -149,6 +177,7 @@ impl NativeCallable for Method {
Operation::Pages => {
Value::Object(object_list::collection(cx, data.pages, self.desktop)?)
}
// 读取该 Window 自己 desktop 的逻辑视口,封装成 MeasureResult;尚未由原生侧提供尺寸时明确失败。
Operation::Viewport => {
let Value::Object(input) = cx.store().get_attr(data.desktop, "viewport")? else {
return Err(invalid("window viewport is unavailable"));
@@ -163,6 +192,7 @@ impl NativeCallable for Method {
)?)
}
operation => {
// 原生命令虽暂存在内存,也属于变更,不能从只读测量/绘制回调发起窗口操作。
if cx.store().is_read_only() {
return Err(invalid(
"window operations are forbidden during read-only rendering",
@@ -181,6 +211,7 @@ impl NativeCallable for Method {
let Value::Text(title) = &args[1] else {
return Err(invalid("window title requires text"));
};
// 标题限制 UTF-8 字节长度并拒绝控制字符;不会在这里执行标题文本或展开 shell 字符。
if title.len() > 4096 || title.chars().any(char::is_control) {
return Err(invalid("invalid window title"));
}
@@ -196,6 +227,7 @@ impl NativeCallable for Method {
Ok(NativeResult::Return(result))
}
}
/// 把合法窗口操作追加到所属窗口的待发布命令,不能直接在 VM 线程操作 OS。
pub fn enqueue(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
@@ -205,6 +237,7 @@ pub fn enqueue(
.store()
.payload::<ManagedWindow>(owner, "Window")?
.clone();
// 命令序列单调递增且检查溢出;同类合并会产生序号空洞,但仍可按序比较先后。
data.sequence = data
.sequence
.checked_add(1)
@@ -219,12 +252,15 @@ pub fn enqueue(
.lock()
.map_err(|_| invalid("window outbox poisoned"))?;
// The heap barrier succeeds before any external transport state changes.
// 必须先成功经过 VM 写屏障,才修改不可回滚的原生 outbox;只读调用不能先留下待发命令再报错。
cx.replace_payload(owner, data)?;
// 同窗口同动作只保留最后请求,其他类别保持先后;此处只入队,GUI 是否执行取决于后续成功发布。
queue.retain(|old| !old.same_kind(&command));
queue.push(command);
Ok(())
}
/// 将页面登记到其窗口,维护托管所有权与页面顺序。
pub fn register_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
@@ -242,6 +278,7 @@ pub fn register_page(
if data.closed {
return Err(invalid("native window is closed"));
}
// 登记幂等,首次同时写 tabs 与 payload;字典键使用页面身份文本而非标题,重命名不改变登记。
if !data.pages.contains(&page) {
data.pages.push(page);
cx.dict_set(
@@ -253,6 +290,7 @@ pub fn register_page(
}
Ok(())
}
/// 从窗口的页面集合撤销指定页面,保持后续展示索引一致。
pub fn unregister_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
@@ -267,6 +305,7 @@ pub fn unregister_page(
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
// 不存在时撤销是空操作;存在时同时删有序列表与 tabs 字典边,不能只隐藏原生标签。
if !data.pages.contains(&page) {
return Ok(());
}
@@ -276,6 +315,7 @@ pub fn unregister_page(
Ok(())
}
/// Discard an unpublished Page registration without invoking guest callbacks.
/// 宿主收尾路径撤销页面登记,用于已经由外层决定的关闭/退役流程。
pub fn unregister_page_host(
vm: &mut Vm,
window: ObjectRef,
@@ -290,6 +330,7 @@ pub fn unregister_page_host(
Ok(())
}
/// 在同一窗口的已登记页面集合内重排标签位置,保留 Page 身份;不负责跨窗口迁移。
pub fn move_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
@@ -316,6 +357,7 @@ pub fn move_page(
"tab ordering is forbidden during read-only rendering",
));
}
// 只重排当前窗口中的页面,不迁移到另一窗口,也不销毁或重建 Page/Mount;跨窗口迁移由桥接层组合操作。
if from != position {
let page = data.pages.remove(from);
data.pages.insert(position, page);
@@ -323,19 +365,23 @@ pub fn move_page(
}
Ok(())
}
/// 借用一个有效托管窗口的已登记页面集合。
pub fn pages(store: &ObjectStore, window: ObjectRef) -> Result<&[ObjectRef], VmError> {
Ok(&store.payload::<ManagedWindow>(window, "Window")?.pages)
}
/// 取走当前窗口累积的命令,后续由成功发布结果携带到 GUI。
pub fn take_commands(vm: &Vm, window: ObjectRef) -> Result<Vec<Command>, VmError> {
let data = vm.payload::<ManagedWindow>(window, "Window")?;
let mut queue = data
.outbox
.lock()
.map_err(|_| invalid("window outbox poisoned"))?;
// 一次移走全部队列,再按序号排序;仅消费传输状态,不调用 replace_payload 或改变语言依赖版本。
let mut commands = std::mem::take(&mut *queue);
commands.sort_by_key(|command| command.sequence);
Ok(commands)
}
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
@@ -387,6 +433,7 @@ pub fn install(
vm.attach_type(function, function_type)?;
vm.define_method(definition, name, function)?;
}
// 安装主窗口托管资源,原生 OS 窗口生命周期仍由 GUI 管理;页面集合初始为空。
let window = vm.allocate(ManagedWindow {
id: MAIN_WINDOW,
desktop,
@@ -400,6 +447,7 @@ pub fn install(
roots.push(window);
vm.attach_type(window, definition)?;
vm.set_attr(desktop, "window", Value::Object(window))?;
// 主 desktop 拥有统一窗口注册表,以纯数字 ID 文本索引;注册表让附加窗口在 VM 图内可达。
let windows = vm.new_dict()?;
vm.set_attr(desktop, "windows", Value::Object(windows))?;
vm.dict_set(
@@ -413,6 +461,7 @@ pub fn install(
/// Create a managed native-window resource. The GUI creates the OS resource only
/// after the owning candidate/input publishes its immutable projection.
/// 建立托管窗口、desktop 与页面容器;原生窗口通过命令发布后出现。
pub fn create(
cx: &mut NativeContext<'_>,
master: ObjectRef,
@@ -426,6 +475,7 @@ pub fn create(
}
let desktop = cx.new_object()?;
let tabs = cx.new_dict()?;
// 附加桌面共享主域的同一 types 字典身份,保证跨窗口对象和协议使用同一名义类型体系。
let types = cx.store().get_attr(master, "types")?;
cx.set_attr(desktop, "types", types)?;
cx.set_attr(desktop, "viewport", Value::Nil)?;
@@ -446,9 +496,11 @@ pub fn create(
return Err(invalid("window registry missing"));
};
cx.dict_set(windows, Value::Text(id.to_string()), Value::Object(window))?;
// 返回创建的三个托管身份供桥接层建立 Presentation;此函数不直接申请 OS 资源或发起布局。
Ok((window, desktop, tabs))
}
/// 取消窗口/页面任务并撤销登记,标记 closed 后安排原生关闭;重复关闭直接返回。
pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmError> {
if cx.store().is_read_only() {
return Err(invalid(
@@ -462,23 +514,28 @@ pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmErro
if data.closed {
return Ok(());
}
// 真实关闭边界先取消窗口与所有页面 owner 的任务,再撤销页面登记,防止晚到结果继续更新已关闭资源。
crate::tasks::cancel_owner(cx, window)?;
for page in data.pages.clone() {
crate::tasks::cancel_owner(cx, page)?;
unregister_page(cx, window, page)?;
}
// 循环内 unregister_page 已替换过 Window payload,必须重读最新副本,不能用最初 data 把已移除页面写回。
data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
data.closed = true;
cx.replace_payload(window, data)?;
// 标记托管 closed 后仍可由内部 enqueue 安排原生 Close;成功输入发布前不会直接关闭系统窗口。
enqueue(cx, window, Action::Close)
}
/// Host teardown for a stopped VM: no binding, dispatch or source execution.
/// 撤销已失效窗口的托管资源关系,不重新执行源关闭请求。
pub fn retire(vm: &mut Vm, window: ObjectRef) -> Result<(), VmError> {
let mut data = vm.payload::<ManagedWindow>(window, "Window")?.clone();
// 故障域的宿主退役仅断开页面拥有边并标 closed,不跑用户回调、不重新生成源关闭命令。
vm.dict_clear(data.tabs)?;
data.pages.clear();
data.closed = true;
+16
View File
@@ -1,9 +1,13 @@
//! 有限布局盒子的尺寸策略。区分 auto、固定值和 fill,并处理 min/max 对约束与最终尺寸的限制。
//! margin、border、padding 的空间分配由布局层结合本模块完成;场景世界范围不被当成无限布局盒子。
//!
//! Native border-box sizing. An unbounded axis cannot resolve `fill`.
use crate::{layout::Size, ui_bridge::MAX_DIMENSION};
use anyhow::Result;
use endor_core::{Value, VmError};
#[derive(Debug, Clone, Copy, Default, PartialEq)]
/// 一个布局轴的尺寸策略:随内容、固定值或占用有限可用空间。
pub enum Dimension {
#[default]
Auto,
@@ -12,6 +16,7 @@ pub enum Dimension {
}
impl Dimension {
/// 把内部尺寸策略投影为相应的源属性表示。
pub fn value(self) -> Value {
match self {
Self::Auto => Value::Text("auto".into()),
@@ -20,6 +25,7 @@ impl Dimension {
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &Value) -> Result<Self, VmError> {
match value {
Value::Text(text) if text == "auto" => Ok(Self::Auto),
@@ -43,6 +49,7 @@ impl Dimension {
}
#[derive(Debug, Clone, Copy, PartialEq)]
/// border-box 尺寸策略与双轴上下限,实际求解仍要结合父级约束。
pub struct Sizing {
pub width: Dimension,
pub height: Dimension,
@@ -53,6 +60,7 @@ pub struct Sizing {
}
impl Default for Sizing {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
width: Dimension::Auto,
@@ -67,7 +75,10 @@ impl Default for Sizing {
impl Sizing {
/// Constrain descendants using the actual finite box before wrapping text.
/// 将 [最小宽, 最小高, 最大宽, 最大高] 收窄,供子内容测量和文本换行使用。
/// 固定尺寸限制最大可用空间;最后缩小最小约束以保持 min <= max。这里不直接计算最终盒子。
pub fn available(self, mut constraints: [f32; 4]) -> [f32; 4] {
// 先套自身 max,再应用 Fixed;Auto/Fill 在测量阶段保留父级允许的空间。
constraints[2] = constraints[2].min(self.max_width);
constraints[3] = constraints[3].min(self.max_height);
if let Dimension::Fixed(width) = self.width {
@@ -81,7 +92,9 @@ impl Sizing {
constraints
}
/// 结合内容期望尺寸、轴策略和父级约束求最终盒子尺寸;无界轴不能解析 fill。
pub fn resolve(self, intrinsic: Size, constraints: [f32; 4]) -> Result<Size> {
/// 针对单个尺寸轴应用约束与边界规则,供双轴尺寸求解复用。
fn axis(
policy: Dimension,
intrinsic: f32,
@@ -89,10 +102,12 @@ impl Sizing {
max: f32,
available: f32,
) -> Result<f32> {
// 先按策略选候选值:Auto 用内容测量值,Fixed 用指定值,Fill 用父级分配上限。
let value = match policy {
Dimension::Auto => intrinsic,
Dimension::Fixed(value) => value,
Dimension::Fill => {
// MAX_DIMENSION 充当无界哨兵;无界空间没有可填满的有限尺寸,必须拒绝。
if available >= MAX_DIMENSION as f32 {
return Err(VmError::InvalidArgument(
"fill requires a finite parent allocation on this axis",
@@ -102,6 +117,7 @@ impl Sizing {
available
}
};
// 先满足自身 min/max,再服从父级上限;父级空间比 min 小时,父级上限优先。
Ok(value.clamp(min, max).min(available))
}
Ok(Size {
+28
View File
@@ -1,7 +1,11 @@
//! 面向设备像素的绘制指令集合。它承载矩形、文字、裁剪与 3D 表面等原语,不直接操作 VM。
//! 平移和缩放统一作用于命令几何;文字样式和裁剪也要使用一致的设备比例。
//!
use crate::geometry::{Point, Rect};
use crate::text::TextStyle;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// 设备绘制使用的 RGBA 字节通道,区别于源代码的颜色文本。
pub struct Color {
pub red: u8,
pub green: u8,
@@ -10,6 +14,7 @@ pub struct Color {
}
impl Color {
/// 从 RGB 通道构造不透明颜色值。
pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
Self {
red,
@@ -19,6 +24,7 @@ impl Color {
}
}
/// 从 RGBA 通道构造带透明度的颜色值。
pub const fn rgba(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
Self {
red,
@@ -30,31 +36,40 @@ impl Color {
}
#[derive(Clone, Debug)]
/// 原生画布最终顺序执行的绘制原语,已经使用对应设备比例。
pub enum DrawCommand {
// 在 rect 内绘制不可变 3D 视图;平移/缩放改变视口矩形,不改写共享场景或相机。
Scene3D {
rect: Rect,
view: std::sync::Arc<crate::scene3d::View>,
},
// 清除整个目标缓冲;不携带几何,因此列表平移和缩放不会改变它。
Clear(Color),
// 将矩形压入裁剪栈;后续命令受裁剪约束,必须由对应 PopClip 恢复。
PushClip(Rect),
// 恢复上层裁剪。合并列表保持原顺序,调用方需保证裁剪栈配对。
PopClip,
// 填充圆角矩形;半径与矩形尺寸在设备缩放时使用同一比例。
FillRoundedRect {
rect: Rect,
radius: f32,
color: Color,
},
// 描边圆角矩形;描边宽度也是长度,必须随几何一起缩放。
StrokeRoundedRect {
rect: Rect,
radius: f32,
width: f32,
color: Color,
},
// 在有限矩形内进行文本布局;缩放同时作用于矩形和文字样式,保持换行比例。
WrappedText {
rect: Rect,
value: String,
style: TextStyle,
color: Color,
},
// 以原点定位文本,文本布局使用 TextStyle;与 WrappedText 不同,不额外携带有限文本框。
Text {
origin: Point,
value: String,
@@ -64,7 +79,9 @@ pub enum DrawCommand {
}
impl DrawCommand {
/// 返回平移后的绘制命令,并同步移动命令中的相关几何。
pub fn translated(mut self, delta: Point) -> Self {
// 平移只改变位置,保留尺寸、圆角、文字样式和颜色;消费 self 后返回修改后的命令。
match &mut self {
Self::WrappedText { rect, .. }
| Self::Scene3D { rect, .. }
@@ -85,13 +102,17 @@ impl DrawCommand {
}
#[derive(Clone, Debug, Default)]
/// 有序绘制原语容器;命令顺序同时约束叠放和裁剪栈行为。
pub struct DisplayList {
commands: Vec<DrawCommand>,
}
impl DisplayList {
/// 按设备比例缩放命令,文字、边框和矩形不能分别使用不一致比例。
pub fn scale(&mut self, scale: f32) {
// 原地累计缩放:重复调用会相乘,并非设置绝对缩放值。调用方负责提供有效设备比例。
for command in &mut self.commands {
// 先处理各变体独有的长度(字体、圆角、描边),再把公共矩形交给下方统一缩放。
let rect = match command {
DrawCommand::WrappedText { rect, style, .. } => {
*style = style.scaled(scale);
@@ -121,6 +142,7 @@ impl DisplayList {
}
_ => None,
};
// 矩形的原点和宽高一起缩放,保证裁剪范围、文本框与实际绘制内容对齐。
if let Some(rect) = rect {
rect.x *= scale;
rect.y *= scale;
@@ -129,10 +151,13 @@ impl DisplayList {
}
}
}
/// 按绘制顺序追加一条原语,后加入的内容在合成顺序中位于后面。
pub fn push(&mut self, command: DrawCommand) {
self.commands.push(command);
}
/// 平移当前显示列表中的命令,供整块页面或管理面板放入窗口坐标。
// 与单条 translated 相同,但直接修改整个列表;Clear/PopClip 没有位置需要移动。
pub fn translate(&mut self, delta: Point) {
for command in &mut self.commands {
match command {
@@ -153,10 +178,13 @@ impl DisplayList {
}
}
/// 借用按绘制顺序排列的原生原语,不复制命令集合。
pub fn commands(&self) -> &[DrawCommand] {
&self.commands
}
/// 按原顺序合并另一组绘制命令,不在这里重排层次。
// 消费另一个列表并移动其命令,不复制字符串或场景数据,也不在连接处补裁剪指令。
pub fn extend(&mut self, other: DisplayList) {
self.commands.extend(other.commands);
}
+9
View File
@@ -1,16 +1,21 @@
//! 共享的二维点和矩形值。命中判断在调用方完成坐标系转换后使用,矩形本身不隐式处理相机。
//!
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
/// 二维坐标值;具体处于逻辑、局部还是设备空间由调用上下文决定。
pub struct Point {
pub x: f32,
pub y: f32,
}
impl Point {
/// 直接保存二维坐标;不缩放、不舍入,也不检查有限性,调用方负责坐标空间和输入校验。
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
/// 轴对齐矩形,保存原点和宽高,用于布局、裁剪与命中。
pub struct Rect {
pub x: f32,
pub y: f32,
@@ -19,6 +24,7 @@ pub struct Rect {
}
impl Rect {
/// 直接保存左上角和宽高;不归一化负尺寸,布局调用方需先产生合法边界。
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
Self {
x,
@@ -28,6 +34,7 @@ impl Rect {
}
}
/// 用闭区间比较判断点是否命中,左右上下边缘都计入;调用前需将点转换到矩形所在空间。
pub fn contains(self, point: Point) -> bool {
point.x >= self.x
&& point.x <= self.x + self.width
@@ -41,7 +48,9 @@ mod tests {
use super::*;
#[test]
/// 矩形左上/右下边界均包含,刚越过左边界的点排除,固定基础命中边界策略。
fn rectangle_contains_edges_but_not_outside_points() {
// 同时验证左上和右下边界被包含,以及仅越过左边界的小偏差也会被排除。
let rect = Rect::new(10.0, 20.0, 30.0, 40.0);
assert!(rect.contains(Point::new(10.0, 20.0)));
assert!(rect.contains(Point::new(40.0, 60.0)));
+104
View File
@@ -1,3 +1,7 @@
//! 原生布局求解器:自底向上测量期望尺寸,再自顶向下分配位置和实际尺寸。
//! Row/Column/Overlay、边距、边框、内边距、对齐和弹性空间都在有限约束内计算;过大的尺寸和坐标返回错误。
//! 缓存按组件形状、子项尺寸和约束判断可复用性。frame 先在克隆状态中求解,成功才替换运行状态,防止失败污染已发布几何。
//!
//! Iterative measure/arrange/paint over derived visual data. Every occurrence
//! has its own geometry; measurement caches never become semantic object state.
use crate::{
@@ -11,6 +15,7 @@ use std::collections::{HashMap, HashSet};
const MAX_LAYOUT_VISITS: usize = 10_000;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
/// 上右下左四边的空间占用,用于 margin、padding 和 border 计算。
pub struct Insets {
pub top: f32,
pub right: f32,
@@ -19,12 +24,15 @@ pub struct Insets {
}
impl Insets {
/// 返回该边距集合在水平方向占据的总空间。
pub fn horizontal(self) -> f32 {
self.left + self.right
}
/// 返回该边距集合在垂直方向占据的总空间。
pub fn vertical(self) -> f32 {
self.top + self.bottom
}
/// 在内容尺寸外加上对应四边占用,得到外层测量尺寸。
fn outer(self, size: Size) -> Size {
Size {
width: size.width + self.horizontal(),
@@ -36,6 +44,7 @@ impl Insets {
/// Alignment inside a finite cross-axis content box. Overflow remains start
/// aligned so that content cannot become inaccessible beyond the leading edge.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
/// 交叉轴对齐规则;Stretch 需要结合子项是否自动尺寸决定实际拉伸。
pub enum CrossAlign {
#[default]
Start,
@@ -45,6 +54,7 @@ pub enum CrossAlign {
}
impl CrossAlign {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
match self {
Self::Start => "start",
@@ -54,6 +64,7 @@ impl CrossAlign {
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
match value {
"start" => Ok(Self::Start),
@@ -66,7 +77,9 @@ impl CrossAlign {
}
}
/// 根据交叉轴对齐方式计算有限剩余空间中的偏移。
fn offset(self, available: f32, occupied: f32) -> f32 {
// 溢出时剩余空间归零,Center/End 都退化为起点对齐,避免把内容前缘移出可达滚动范围。
let spare = (available - occupied).max(0.0);
match self {
Self::Start | Self::Stretch => 0.0,
@@ -77,6 +90,7 @@ impl CrossAlign {
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
/// 容器主轴的内容对齐或剩余间隔分配方式。
pub enum MainAlign {
#[default]
Start,
@@ -85,6 +99,7 @@ pub enum MainAlign {
SpaceBetween,
}
impl MainAlign {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
match self {
Self::Start => "start",
@@ -93,6 +108,7 @@ impl MainAlign {
Self::SpaceBetween => "space-between",
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
match value {
"start" => Ok(Self::Start),
@@ -108,13 +124,17 @@ impl MainAlign {
/// Main-axis constraints interpreted independently by each parent occurrence.
#[derive(Debug, Clone, Copy, PartialEq)]
/// 主轴伸缩策略与限值,不能用一个宽度常量替代完整分配规则。
pub struct FlexPolicy {
// Some 指定主轴首选基准,None 使用首次内容测量;grow/fill 的缺省基准在 flex_sizes 中解释为零。
pub basis: Option<f32>,
pub min: f32,
pub max: f32,
// 收缩权重还会乘 basis;默认零表示空间不足时不主动压缩该项。
pub shrink: f32,
}
impl Default for FlexPolicy {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
basis: None,
@@ -126,12 +146,14 @@ impl Default for FlexPolicy {
}
#[derive(Default, Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
/// 逻辑宽高值,不隐含窗口像素比例或相机缩放。
pub struct Size {
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, PartialEq)]
/// 影响原生测量的缓存形状,避免把颜色等纯绘制变化视为尺寸变化。
enum Shape {
Sized(Box<Shape>, crate::box_model::Sizing, [f32; 4]),
Text(String, u32, f32),
@@ -141,6 +163,7 @@ enum Shape {
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
/// 结构出现身份键,区分同一业务对象在不同父位置的展示。
struct Key {
parent: Option<u64>,
slot: u64,
@@ -148,8 +171,11 @@ struct Key {
}
#[derive(Debug, Clone, Default)]
/// 布局缓存、稳定出现身份与统计。构建在临时副本上完成,成功后才写回。
pub struct LayoutRuntime {
// 按 Block 身份只保留最后一份 Shape/Size;共享 Block 不同约束可交替覆盖,但每次复用前仍比较完整 Shape。
sizes: HashMap<String, (Shape, Size)>,
// 出现身份取父 Mount、稳定 slot 和 Block 身份三元组,区别于仅按业务组件分配一个全局 ID。
ids: HashMap<Key, u64>,
next_id: u64,
pub version: u64,
@@ -161,6 +187,7 @@ pub struct LayoutRuntime {
pub page_mode: Option<crate::page::Mode>,
}
/// 迭代遍历中的一个出现,保存父子索引、约束与稳定 ID。
struct Item<'a> {
node: &'a UiNode,
parent: Option<usize>,
@@ -169,6 +196,7 @@ struct Item<'a> {
constraints: [f32; 4],
}
/// 检查尺寸非负、有限且不超过布局上限。
fn bounded(value: f32) -> Result<f32> {
if !value.is_finite() || !(0.0..=MAX_DIMENSION as f32).contains(&value) {
bail!("UI layout exceeds the 1000000 coordinate/size limit");
@@ -176,6 +204,7 @@ fn bounded(value: f32) -> Result<f32> {
Ok(value)
}
/// 检查允许有正负方向的坐标仍在安全布局范围内。
fn coordinate(value: f32) -> Result<f32> {
if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 {
bail!("UI coordinate exceeds the 1000000 limit");
@@ -183,7 +212,9 @@ fn coordinate(value: f32) -> Result<f32> {
Ok(value)
}
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
pub fn root_constraints(viewport: Option<Size>) -> [f32; 4] {
// 无视口时以 MAX_DIMENSION 作为无界哨兵;它是有限数值预算,但 Fill 必须另行要求真正有限分配。
let size = viewport.unwrap_or(Size {
width: MAX_DIMENSION as f32,
height: MAX_DIMENSION as f32,
@@ -191,15 +222,19 @@ pub fn root_constraints(viewport: Option<Size>) -> [f32; 4] {
[0.0, 0.0, size.width, size.height]
}
/// 从父级上限扣除内边距;无界哨兵保持无界,不因减法被误认为有限。
fn content_limit(available: f32, inset: f32) -> f32 {
// 不能直接从哨兵减 padding,否则略小于 MAX_DIMENSION 会被误认为有限空间,从而错误允许 Fill。
if available >= MAX_DIMENSION as f32 {
available
} else {
(available - inset).max(0.0)
}
}
/// 把父盒子的可用尺寸传递给子项,扣除容器内边距并考虑主轴是否可延伸。
pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
let parent = node.sizing.available(parent);
// Overlay 双轴都受内容盒上限约束;不像 Row/Column 在主轴自然展开。
if let UiKind::Overlay { .. } = node.kind {
let padding = insets(node);
return [
@@ -213,6 +248,7 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
node.kind,
UiKind::Canvas { .. } | UiKind::Scene2D { .. } | UiKind::CanvasViewport { .. }
) {
// 世界 Canvas/Scene2D 与其观察视口不把屏幕尺寸传入世界布局,场景孩子用无界轴约束。
return root_constraints(None);
}
if let UiKind::Scroll { width, height, .. } = node.kind {
@@ -222,6 +258,7 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
constrained(Size { width, height }, parent),
);
}
// Row 主轴 x 无界、交叉轴 y 有限;Column/Button/Scroll 的普通方向相反,具体滚动另有上方分支。
match container(node) {
Some((true, padding, _)) => [
0.0,
@@ -239,11 +276,13 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
}
}
/// 从子项可用范围扣除外边距,并使最小值不超过调整后的最大值。
pub fn margin_constraints(child: &UiNode, mut constraints: [f32; 4]) -> [f32; 4] {
for (axis, margin) in [child.margin.horizontal(), child.margin.vertical()]
.into_iter()
.enumerate()
{
// 仅对真正有限轴扣 margin,保持无界哨兵;最小约束同时收窄以维持 min<=max。
if constraints[axis + 2] < MAX_DIMENSION as f32 {
constraints[axis + 2] = (constraints[axis + 2] - margin).max(0.0);
constraints[axis] = constraints[axis].min(constraints[axis + 2]);
@@ -254,6 +293,7 @@ pub fn margin_constraints(child: &UiNode, mut constraints: [f32; 4]) -> [f32; 4]
/// Main-axis fill first measures against a finite offer, then participates in
/// weighted allocation. The unlimited-axis sentinel is never made finite by padding.
/// 根据具体子项尺寸策略和所在位置细化约束;世界 UI 根使用面板的有限像素尺寸。
pub fn occurrence_constraints(
parent: &UiNode,
child: &UiNode,
@@ -261,6 +301,7 @@ pub fn occurrence_constraints(
offer: Size,
child_index: usize,
) -> [f32; 4] {
// 世界面板 UI 根必须精确按纹理分辨率布局,最小与最大相同,不让外层视口世界尺寸泄入纹理空间。
if let UiKind::Viewport3D { panels, .. } = &parent.kind {
let size = panels[child_index];
return [size.width, size.height, size.width, size.height];
@@ -270,6 +311,7 @@ pub fn occurrence_constraints(
(0, child.sizing.width, offer.width, inset.horizontal()),
(1, child.sizing.height, offer.height, inset.vertical()),
] {
// Fill 若从父容器主轴得到无界 base,但父本轮 offer 有限,则用内容可用长度测量,后面再参与权重分配。
if dimension == crate::box_model::Dimension::Fill
&& base[axis + 2] >= MAX_DIMENSION as f32
&& available < MAX_DIMENSION as f32
@@ -281,6 +323,7 @@ pub fn occurrence_constraints(
margin_constraints(child, base)
}
/// 生成求解开始时的子项约束,供后续测量和分配迭代使用。
pub fn initial_child_constraints(
parent: &UiNode,
child: &UiNode,
@@ -301,7 +344,9 @@ pub fn initial_child_constraints(
}
/// Scroll cross-axis constraints come from this occurrence's actual viewport,
/// including a source parent's allocation rather than only constructor size.
/// 根据已分配父尺寸重新细化子项约束,使实际安排能够反向影响换行等测量。
pub fn arranged_child_constraints(node: &UiNode, available: [f32; 4], bounds: Size) -> [f32; 4] {
// 滚动允许轴无界以容纳内容,禁用轴使用实际已安排的视口尺寸,文字才能按最终交叉轴宽度换行。
if let UiKind::Scroll { axis, .. } = node.kind {
let padding = insets(node);
return [
@@ -322,6 +367,7 @@ pub fn arranged_child_constraints(node: &UiNode, available: [f32; 4], bounds: Si
child_constraints(node, available)
}
/// 将期望尺寸限制在调用方给出的最小/最大边界内。
pub fn constrained(size: Size, constraints: [f32; 4]) -> Size {
Size {
width: size.width.clamp(constraints[0], constraints[2]),
@@ -330,7 +376,9 @@ pub fn constrained(size: Size, constraints: [f32; 4]) -> Size {
}
/// Effective content inset, including borders, in border-box coordinates.
/// 计算布局使用的有效内边距和边框占用,兼容旧的统一 padding。
pub fn insets(node: &UiNode) -> Insets {
// 单边 padding 优先,其次兼容容器的统一 padding,最后再加 border;其他叶节点无统一 padding 默认值。
let fallback = match node.kind {
UiKind::Row { padding, .. }
| UiKind::Column { padding, .. }
@@ -347,6 +395,7 @@ pub fn insets(node: &UiNode) -> Insets {
}
}
/// 提取容器方向与间距等原生布局配置,叶节点不参与容器规则。
fn container(node: &UiNode) -> Option<(bool, Insets, f32)> {
match &node.kind {
UiKind::Row { gap, .. } => Some((true, insets(node), *gap)),
@@ -358,10 +407,12 @@ fn container(node: &UiNode) -> Option<(bool, Insets, f32)> {
}
}
/// 根据容器方向选择主轴,供 flex 和对齐共享同一判断。
pub fn main_axis(node: &UiNode) -> Option<usize> {
container(node).map(|(row, _, _)| usize::from(!row))
}
/// 根据形状键计算期望尺寸,包括文字换行、容器子项和盒模型占用。
fn measure_shape(shape: &Shape) -> Result<Size> {
let size = match shape {
Shape::Sized(shape, sizing, constraints) => {
@@ -370,6 +421,7 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
Shape::Padded(shape, padding) => padding.outer(measure_shape(shape)?),
Shape::Text(value, size, width) => {
let style = TextStyle::ui(*size);
// 自然文字宽度与受限换行高度分别测量;布局宽度不超过可用宽度,高度反映完整段落行数。
let intrinsic = text::measure(value, style);
let wrapped = text::measure_wrapped(value, style, *width);
Size {
@@ -382,6 +434,7 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
height: *height,
},
Shape::Container(row, padding, gap, children) => {
// 主轴相加、交叉轴取最大,再加对应 inset 与 n-1 个 gap;saturating_sub 保证零孩子不出现负间距。
let along = children
.iter()
.map(|s| if *row { s.width } else { s.height })
@@ -419,11 +472,13 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
Ok(size)
}
/// 建立影响测量的形状键,将布局属性、子项测量结果和约束组合起来。
fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Shape> {
let available = node.sizing.available(constraints);
let shape = match &node.kind {
UiKind::Overlay { .. } => {
let padding = insets(node);
// Overlay 取含各自 margin 的子盒最大宽高,不把重叠项逐个相加。
let size = children.into_iter().zip(node.children()).fold(
Size::default(),
|size, (child, node)| {
@@ -439,11 +494,13 @@ fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Sh
size.height + padding.vertical(),
)
}
// 独立场景视口按自己的有限盒测量,不因远方世界几何或面板内容大小扩展其布局分配。
UiKind::CanvasViewport { width, height, .. } | UiKind::Viewport3D { width, height, .. } => {
Shape::Rect(*width, *height)
}
UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } => {
let mut size = Size::default();
// 场景尺寸仅累计孩子右下端最大值;负方向内容不向左上平移整体,导航仍由相机负责。
for (position, child) in positions.iter().zip(children) {
size.width = size.width.max(position[0] + child.width);
size.height = size.height.max(position[1] + child.height);
@@ -474,6 +531,7 @@ fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Sh
Shape::Container(row, padding, gap, children)
}
};
// 只有非默认盒尺寸策略才包 Sized 并把约束加入键;文字等自身 Shape 已包含其实际影响测量的参数。
Ok(if node.sizing == crate::box_model::Sizing::default() {
shape
} else {
@@ -481,6 +539,7 @@ fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Sh
})
}
/// 执行原生节点测量并应用布局约束,作为源协议的原生实现。
pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Size> {
measure_shape(&shape(node, children, constraints)?)
}
@@ -488,14 +547,18 @@ pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4])
/// Private derived scratch space for one occurrence during layout. Contains no
/// managed references; source code cannot mutate it through heap attributes.
#[derive(Debug, Default)]
/// 只读布局调用期间提供给 Mount 查询的派生上下文,不作为业务状态保留。
pub struct Context {
pub node: Option<std::sync::Arc<UiNode>>,
pub children: Vec<Size>,
// 本轮首次测量的首选尺寸,不应被上轮 flex 实际分配覆盖,否则反复测量会改变伸缩基准。
pub preferred: Vec<Size>,
pub bounds: Option<[f32; 4]>,
}
/// 为单个原生节点按已分配几何生成内容命令;容器背景/边框与整树遍历由外层组织。
pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderCommand> {
// 文字内容盒需要减 inset 并带 wrap_bounds;Rect/Button 背景则画完整 border-box。
let size_for_text = size;
let inset = insets(node);
match &node.kind {
@@ -537,8 +600,10 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderC
}
}
/// 从一个节点及其分配几何生成原生绘制命令,包括背景、边框与内容。
pub fn paint_commands(node: &UiNode, x: f32, y: f32, size: Size) -> Vec<RenderCommand> {
let mut commands: Vec<_> = paint_native(node, x, y, size).into_iter().collect();
// 边框按四个零圆角条分别追加,宽厚各自钳到盒大小;不使用单一 stroke_width 混淆不等厚四边。
let border = node.border;
for [x, y, width, height] in [
[x, y, size.width, border.top.min(size.height)],
@@ -573,6 +638,7 @@ pub fn paint_commands(node: &UiNode, x: f32, y: f32, size: Size) -> Vec<RenderCo
/// Allocate main-axis space with bounded growth and basis-weighted shrinking.
/// Preferred sizes come from this occurrence's first measurement in the solve;
/// remeasurement must not turn a prior allocation into a new intrinsic basis.
/// 判断子项主轴是否采用 fill,从而参与剩余空间分配。
fn main_fill(child: &UiNode, row: bool) -> bool {
(if row {
child.sizing.width
@@ -581,6 +647,7 @@ fn main_fill(child: &UiNode, row: bool) -> bool {
}) == crate::box_model::Dimension::Fill
}
/// 取出主轴 flex/尺寸策略共同约束下的最小和最大分配范围。
fn main_limits(child: &UiNode, row: bool) -> (f32, f32) {
let sizing = child.sizing;
let (min, max) = if row {
@@ -594,7 +661,9 @@ fn main_limits(child: &UiNode, row: bool) -> (f32, f32) {
)
}
/// 按权重分配主轴剩余空间,尊重每项 min/max;达到上限的项退出后重新分配剩余量。
pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[Size]) -> Vec<Size> {
// Overlay 各孩子独立填内容盒,不共享主轴剩余量;普通线性容器才做下方加权增长/收缩。
if matches!(node.kind, UiKind::Overlay { .. }) {
let inset = insets(node);
return children
@@ -624,6 +693,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
};
let nodes = node.children();
let mut output = children.to_vec();
// 先处理交叉轴 Stretch,仅 Auto 维度可拉伸;明确 Fixed 不因父对齐策略被改写。
for (child, size) in nodes.iter().zip(&mut output) {
if child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch {
let sizing = child.sizing;
@@ -638,9 +708,11 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
}
}
}
// 无主轴伸缩需求时也可能已完成交叉轴拉伸,直接返回这份 output,不丢弃上面调整。
if !nodes.iter().any(|child| child.has_flex()) {
return output;
}
// 可分配主轴空间先扣父 inset、每个孩子两侧 margin 和固定 gap;使用 f64 累计减小权重分配误差。
let space = ((if row { bounds.width } else { bounds.height }) as f64
- if row {
padding.horizontal()
@@ -659,6 +731,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
.sum::<f32>() as f64
- gap as f64 * children.len().saturating_sub(1) as f64)
.max(0.0);
// 显式 basis 优先;grow/fill 默认从零参与分配,其余用 preferred,不拿已经压缩后的 children 充当自然基准。
let bases: Vec<f64> = nodes
.iter()
.zip(preferred)
@@ -682,8 +755,10 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
basis.clamp(min as f64, max as f64)
})
.collect();
// 初始 basis 先钳 min/max,剩余正数为增长,负数为收缩;非参与项仍占据其合法基准尺寸。
let free = space - sizes.iter().sum::<f64>();
let growing = free > 0.0;
// 增长按 flex,主轴 Fill 且 flex=0 用权重1;收缩按 shrink×原 basis,保持大内容承担较多缩减。
let weights: Vec<f64> = nodes
.iter()
.zip(&bases)
@@ -706,6 +781,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
if growing { max as f64 } else { min as f64 }
})
.collect();
// 只让有正权重且未到 min/max 的项参与,每轮触顶/到底的项退出,剩余空间继续分给未饱和者。
let mut active: Vec<usize> = (0..nodes.len())
.filter(|&i| {
weights[i] > 0.0
@@ -723,6 +799,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
let mut saturated = false;
for &i in &active {
let capacity = (limits[i] - sizes[i]).abs();
// 本轮按同一 remaining 比例计算,再限制到该项剩余容量;不能为单项超配后再粗暴钳制丢掉剩余量。
let amount = (remaining * weights[i] / total).min(capacity);
sizes[i] += if growing { amount } else { -amount };
spent += amount;
@@ -731,6 +808,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
remaining = (remaining - spent).max(0.0);
// Without saturation this round distributed all available space. Do not
// spin on floating point residue; a saturated round freezes a child.
// 没有项饱和说明比例分配已完成,微小浮点残差不能导致无进展死循环。
if !saturated {
break;
}
@@ -752,6 +830,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
output
}
/// 根据最终分配尺寸计算子项原点,处理容器方向、对齐、间距与外边距。
pub fn arrange_native(
node: &UiNode,
x: f32,
@@ -759,6 +838,7 @@ pub fn arrange_native(
bounds: Size,
children: &[Size],
) -> Result<Vec<(f32, f32)>> {
// 3D 面板根在各自纹理空间均从 (0,0) 开始,世界摆放交由 Panel world 矩阵,不把世界坐标写入普通 UI 布局。
if matches!(node.kind, UiKind::Viewport3D { .. }) {
return Ok(node.children().iter().map(|_| (0., 0.)).collect());
}
@@ -836,6 +916,7 @@ pub fn arrange_native(
MainAlign::End => spare,
_ => 0.0,
};
// space-between 只在至少两个孩子间分配额外剩余量,原固定 gap 仍保留;单项保持 start。
let distributed_gap = if node.main_align == MainAlign::SpaceBetween && children.len() > 1 {
spare / (children.len() - 1) as f32
} else {
@@ -875,6 +956,7 @@ pub fn arrange_native(
coordinate(point.0 + child.width)?;
coordinate(point.1 + child.height)?;
points.push(point);
// 主轴游标推进当前盒尺寸、两侧 margin、固定及额外 gap;交叉轴对齐不改变主轴顺序。
cursor += if row { child.width } else { child.height };
cursor += if row {
margin.horizontal()
@@ -887,25 +969,32 @@ pub fn arrange_native(
}
impl LayoutRuntime {
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
pub fn root_constraints(&self) -> [f32; 4] {
self.page_mode.map_or_else(
|| root_constraints(self.viewport),
|mode| mode.constraints(self.viewport),
)
}
/// 撤销指定业务组件的测量缓存,下一次遇到该节点重新求值。
pub fn invalidate(&mut self, block: &str) {
self.sizes.remove(block);
}
/// 提高新 Mount ID 的分配下限,避免取消/重试后复用已经暴露过的出现身份。
pub fn reserve_identity_floor(&mut self, floor: u64) {
self.next_id = self.next_id.max(floor);
}
/// 返回出现 ID 的当前分配高水位,供桥接层在取消/重试后继续保持唯一性。
pub fn identity_high_watermark(&self) -> u64 {
self.next_id
}
/// 清空结构出现身份表;业务对象和源状态不因此被复制。
pub fn clear_occurrences(&mut self) {
// 清出现映射不重置 next_id,高水位继续增加,避免旧事件与新出现数值身份意外重合。
self.ids.clear();
}
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
pub fn frame(&mut self, root: &UiNode) -> Result<Frame> {
// Stage all cache/identity changes; a rejected frame leaves the last
// valid occurrence registry intact and never publishes partial geometry.
@@ -916,6 +1005,7 @@ impl LayoutRuntime {
}
/// Allocate stable occurrence identities without measuring or publishing geometry.
/// 先建立稳定的出现身份和父子关系,但不把此阶段当成最终测量结果。
pub fn structure(&mut self, root: &UiNode) -> Result<Frame> {
let mut next = self.clone();
let frame = next.build(root, true)?;
@@ -923,12 +1013,14 @@ impl LayoutRuntime {
Ok(frame)
}
/// 以形状、子项尺寸和约束查缓存;未命中才执行测量并更新本轮统计。
pub fn measure_node(
&mut self,
node: &UiNode,
children: Vec<Size>,
constraints: [f32; 4],
) -> Result<Size> {
// 缓存只有完整 Shape 相等才复用,纯颜色变化不在测量 Shape 中,因此无需重做尺寸求解。
let shape = shape(node, children, constraints)?;
if let Some((_, size)) = self
.sizes
@@ -943,18 +1035,21 @@ impl LayoutRuntime {
Ok(size)
}
/// 迭代展开出现树、计算约束和几何、产生绘制命令;结构准备与完整布局共用此驱动。
fn build(&mut self, root: &UiNode, structural: bool) -> Result<Frame> {
self.measured = 0;
self.arranged = 0;
self.settle_passes = usize::from(!structural);
self.slices = usize::from(!structural);
let mut items = Vec::<Item<'_>>::new();
// 用显式栈构建父先子后 items,根采用 slot0/无父;孩子逆序入栈保持原始布局顺序。
let mut work = vec![(root, None::<usize>, 0, self.root_constraints())];
let mut used = HashSet::new();
while let Some((node, parent, slot, constraints)) = work.pop() {
if items.len() >= MAX_LAYOUT_VISITS {
bail!("expanded UI layout exceeds 10000 nodes");
}
// slot 与孩子必须一一对应,否则稳定出现身份不可信,不能用 zip 静默丢掉多余项。
if node.slots.len() != node.children().len() {
bail!("invalid child slot table");
}
@@ -963,6 +1058,7 @@ impl LayoutRuntime {
slot,
block: node.block_id.clone(),
};
// 结构三元组未变则复用 ID,新出现 checked_add 分配;只在成功构建副本后才提交整表。
let id = if let Some(id) = self.ids.get(&key) {
*id
} else {
@@ -973,6 +1069,7 @@ impl LayoutRuntime {
self.ids.insert(key.clone(), self.next_id);
self.next_id
};
// 同一展示出现键不得重复;共享组件在不同父或不同 slot 下使用不同 key,仍允许多次出现。
if !used.insert(key) {
bail!("duplicate child slot in one presentation");
}
@@ -1000,6 +1097,7 @@ impl LayoutRuntime {
}
let mut sizes = vec![Size::default(); items.len()];
if !structural {
// 反向 items 自底向上测量,孩子尺寸已经求出;structural=true 完全跳过此步骤,零几何仅用于挂载准备。
for (index, item) in items.iter().enumerate().rev() {
sizes[index] = constrained(
self.measure_node(
@@ -1015,6 +1113,7 @@ impl LayoutRuntime {
let mut mounts = Vec::with_capacity(items.len());
let mut commands = Vec::new();
let mut siblings = vec![None; items.len()];
// 先按每个父的孩子顺序建立 next_sibling,最后一个默认 None;这与业务孩子列表身份不同。
for item in &items {
for pair in item.children.windows(2) {
siblings[pair[0]] = Some(items[pair[1]].id);
@@ -1058,6 +1157,7 @@ impl LayoutRuntime {
continue;
}
let child_sizes: Vec<_> = item.children.iter().map(|child| sizes[*child]).collect();
// 自顶向下用父实际尺寸更新孩子分配后再求原点;更复杂的源协议反复求解由 source_layout 驱动。
let child_sizes = flex_sizes(item.node, size, &child_sizes, &child_sizes);
for (child, allocated) in item.children.iter().zip(&child_sizes) {
sizes[*child] = *allocated;
@@ -1071,6 +1171,7 @@ impl LayoutRuntime {
points[*child] = point;
}
}
// 成功求解后丢弃已消失出现和不在本树的测量缓存,避免历史页面不断积累派生条目。
self.ids.retain(|key, _| used.contains(key));
let blocks: HashSet<_> = items
.iter()
@@ -1078,6 +1179,7 @@ impl LayoutRuntime {
.collect();
self.sizes
.retain(|block, _| blocks.contains(block.as_str()));
// 每次结构/完整构建成功都推进版本,禁止溢出复用;该版本用于后续 Mount/输入归属校验。
self.version = self
.version
.checked_add(1)
@@ -1091,10 +1193,12 @@ impl LayoutRuntime {
}
}
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
pub fn frame(root: &UiNode) -> Result<Frame> {
LayoutRuntime::default().frame(root)
}
/// 为指定逻辑视口建立临时布局运行时并求完整帧。
pub fn frame_with_viewport(root: &UiNode, viewport: Size) -> Result<Frame> {
LayoutRuntime {
viewport: Some(viewport),
+18 -1
View File
@@ -1,16 +1,33 @@
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
//! 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 计算边框、内外边距和内容区域,并应用容器主轴/交叉轴分配规则。
pub(crate) mod box_model;
/// 表达与转换绘图命令,集中处理显示列表裁剪和坐标变换。
pub(crate) mod display_list;
/// 提供点、矩形等基础几何运算,供布局、命中和绘制共享。
pub(crate) mod geometry;
/// 实现原生布局求解、约束分配及可重用的尺寸/几何数据结构。
pub(crate) mod layout;
/// 适配原生视觉方法的测量、排列与绘制行为,供语言桥接和纯布局流程使用。
pub(crate) mod native_visual;
/// 描述整页表面模式、平移缩放和视口到世界坐标转换。
pub(crate) mod page;
/// 组合页面、侧栏和开发工具的整窗显示列表,并返回宿主输入所需布局。
pub(crate) mod scene;
/// 完成三维投影、射线拾取、裁剪与软件深度光栅化;子模块拆分数学、空间索引和世界 UI 面板。
pub(crate) mod scene3d;
/// 计算每个出现位置的滚动范围、裁剪和滚轮目标,嵌套滚动按可消费方向选择。
pub(crate) mod scroll;
/// 将完整显示列表写入像素缓冲;处理二维命令以及嵌套场景表面的最终合成。
pub(crate) mod software_renderer;
/// 在受控只读上下文调用源 hitTest,结合出现位置与可见性判断输入目标。
pub(crate) mod source_hit;
/// 用可恢复 VM 调用执行源 measure/arrange,追踪读依赖并迭代到几何稳定后产出草稿。
pub(crate) mod source_layout;
/// 分片执行源 paint 并验证/解码返回命令;未完成的绘制不能当作完整帧发布。
pub(crate) mod source_paint;
/// 使用系统字体塑形、换行和字形缓存,统一测量与像素绘制的文字尺寸。
pub(crate) mod text;
/// 处理二维场景相机、局部视口投影和空间查询,供绘制与命中使用相同变换。
pub(crate) mod viewport;
+50
View File
@@ -1,3 +1,6 @@
//! 让原生组件也通过 Endor 的 measure/arrange/paint 协议被调用。
//! 将 Constraints、Size、Bounds 与 PaintCommand 在托管值和 Rust 值之间转换;原生方法与源覆盖方法共用调用契约。
//!
//! Native visual protocol entry points. Values are ordinary managed Endor
//! objects; geometry is scoped to a live occurrence, never stored on a Block.
use crate::{
@@ -12,9 +15,11 @@ use endor_core::{
NativeContext, NativeResult, ObjectRef, ObjectStore, Value, Vm, VmError,
};
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
fn object(value: &Value) -> Result<ObjectRef, VmError> {
if let Value::Object(object) = value {
Ok(*object)
@@ -22,6 +27,7 @@ fn object(value: &Value) -> Result<ObjectRef, VmError> {
Err(invalid("visual protocol requires objects"))
}
}
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
pub(crate) fn number(
cx: &NativeContext<'_>,
object: ObjectRef,
@@ -35,20 +41,25 @@ pub(crate) fn number(
.to_string()
.parse::<f32>()
.map_err(|_| invalid("invalid visual number"))?;
// 所有协议数值须有限且位于世界尺寸预算内;signed 区分坐标(可负)与长度/约束(非负)。
if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 || (!signed && value < 0.0) {
return Err(invalid("visual number exceeds bounds"));
}
Ok(value)
}
/// 将原生布局计算结果封装为协议需要的托管 Value。
pub(crate) fn value(value: f32) -> Value {
// 原生 f32 提升到 f64 包装为 Endor Number;本函数不单独校验,调用路径必须已保证输入合法。
Value::Number((value as f64).into())
}
/// 新建指定名义类型的托管记录,并逐项设置字段,作为视觉协议的结果返回。
pub(crate) fn record(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
kind: &str,
fields: Vec<(&str, Value)>,
) -> Result<ObjectRef, VmError> {
// 先赋精确名义类型,再初始化字段;只读协议可创建结果对象,但不能借机写入旧业务图。
let object = cx.new_object()?;
attach_desktop_type(cx, object, kind, desktop)?;
for (key, value) in fields {
@@ -56,7 +67,9 @@ pub(crate) fn record(
}
Ok(object)
}
/// 以最大宽高作视口执行原生布局并提取根尺寸;最小/最大最终钳制由 Measure 分支负责。
fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size, VmError> {
// 无 staged 子测量结果时,使用有限最大约束运行原生布局取得根尺寸;最终 min/max 钳制在调用方 Measure 分支。
let frame = layout::frame_with_viewport(
node,
Size {
@@ -75,6 +88,7 @@ fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size,
})
}
#[derive(Debug, Clone, Copy)]
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
enum Query {
Measure,
Arrange,
@@ -82,18 +96,21 @@ enum Query {
HitTest,
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
query: Query,
desktop: ObjectRef,
signature: CallSignature,
}
/// Only this actual implementation can use the matching intrinsic fast path.
/// 判断方法是否为匹配协议的宿主原生实现,决定能否安全使用快速路径。
pub(crate) fn is_intrinsic(
store: &ObjectStore,
function: ObjectRef,
name: &str,
desktop: ObjectRef,
) -> Result<bool, VmError> {
// 快速路径仅认可实际 ContextNativeFunction 的 Method 实现,函数名相同或签名相似的源函数不能冒充。
let Some(function) = store
.object(function)?
.payload()
@@ -106,6 +123,7 @@ pub(crate) fn is_intrinsic(
return Ok(false);
};
// Per-window desktops share the same nominal registry and intrinsic bodies.
// 同一 VM 不同窗口 desktop 可共享 types 字典,所以以注册表身份允许复用原生方法体。
let same_registry = method.desktop == desktop
|| matches!(
(store.get_attr(method.desktop, "types")?, store.get_attr(desktop, "types")?),
@@ -122,25 +140,31 @@ pub(crate) fn is_intrinsic(
}
impl NativeCallable for Method {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<CallSignature> {
Some(self.signature.clone())
}
/// 接收三项底层参数:Block 接收者、具体 Mount、Constraints/Bounds/Point 协议输入。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(3))
}
/// 检查 Block/Mount 归属与有效性,再按 measure/arrange/paint/hitTest 执行原生视觉协议并返回受检查的托管值。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 三个实际参数含接收者 Block、具体 Mount 和协议输入;几何属于 Mount,不能仅凭 Block 身份查询。
let [owner, context, input] = args else {
return Err(invalid("visual method expects a Mount and an input value"));
};
let owner = object(owner)?;
let mount = cx.store().payload::<Mount>(object(context)?, "Mount")?;
// 默认协议也校验上下文归属,防止调用者把另一个出现的几何套到当前组件。
if !mount.mounted || mount.block != owner {
return Err(invalid(
"visual context is detached or belongs to another Block",
));
}
let geometry = mount.geometry.clone();
// 布局阶段从临时 Context 取当前节点、孩子实测尺寸与首选尺寸;克隆派生数据后释放锁再进行计算。
let staged = mount
.layout
.as_ref()
@@ -156,6 +180,7 @@ impl NativeCallable for Method {
})
.transpose()?;
let input = object(input)?;
// 默认 hitTest 检查局部点在已提交盒内,采用左上包含、右下排除;布局阶段几何不稳定,禁止调用。
if matches!(self.query, Query::HitTest) {
if staged.is_some() {
return Err(invalid("hitTest is unavailable during layout"));
@@ -166,11 +191,13 @@ impl NativeCallable for Method {
x >= 0.0 && y >= 0.0 && x < geometry.width && y < geometry.height,
)));
}
// 存在布局 Context 但尚无当前节点,说明准备顺序不完整,不能回退读取旧快照伪装成当前数据。
if matches!(&staged, Some((None, _, _))) {
return Err(invalid(
"layout context is not prepared for this occurrence",
));
}
// 准备好则复用本轮不可变节点,非 staged 调用才从托管 Block 提取视觉快照。
let node = if let Some((Some(node), _, _)) = &staged {
node.clone()
} else {
@@ -182,9 +209,11 @@ impl NativeCallable for Method {
let min_h = number(cx, input, "minHeight", false)?;
let max_w = number(cx, input, "maxWidth", false)?;
let max_h = number(cx, input, "maxHeight", false)?;
// 最小值不可超过最大值,否则后面的 clamp 会无意义甚至失败;各数值已通过非负有限检查。
if min_w > max_w || min_h > max_h {
return Err(invalid("minimum exceeds maximum constraint"));
}
// 混合源/原生布局使用已经测好的子尺寸,避免重复执行子树;独立调用则走完整原生测量。
let size = if let Some((_, children, _)) = &staged {
layout::measure_native(&node, children.clone(), [min_w, min_h, max_w, max_h])
.map_err(|_| invalid("native measurement exceeds layout limits"))?
@@ -196,6 +225,7 @@ impl NativeCallable for Method {
self.desktop,
"MeasureResult",
vec![
// MeasureResult 最终明确落在传入 min/max 内,原生内容自然大小不能突破源协议约束。
("width", value(size.width.clamp(min_w, max_w))),
("height", value(size.height.clamp(min_h, max_h))),
],
@@ -209,6 +239,7 @@ impl NativeCallable for Method {
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
return Err(invalid("visual bounds exceed limits"));
}
// Arrange 优先使用本轮子测量;没有 staged 时按孩子的实际分配约束逐个测量后再安排。
let children = if let Some((_, children, _)) = &staged {
children.clone()
} else {
@@ -233,16 +264,19 @@ impl NativeCallable for Method {
})
.collect::<Result<Vec<_>, _>>()?
};
// preferred 保留弹性分配所需首选大小,与已受约束的实测 children 区分;无 staged 时两者相同。
let preferred = staged
.as_ref()
.map_or(children.as_slice(), |(_, _, preferred)| {
preferred.as_slice()
});
// 先依据父盒分配 flex 尺寸,再由 arrange_native 求位置,避免先定位再改变孩子大小导致重叠。
let children =
layout::flex_sizes(&node, Size { width, height }, &children, preferred);
let points = layout::arrange_native(&node, x, y, Size { width, height }, &children)
.map_err(|_| invalid("native arrangement exceeds limits"))?;
let mut result = Vec::new();
// 每个返回 Bounds 附稳定 slot,与 children 当前顺序分开;调用方可按 slot 校验组成身份。
for ((slot, size), (x, y)) in node.slots.iter().zip(children).zip(points) {
let bounds = record(
cx,
@@ -258,6 +292,7 @@ impl NativeCallable for Method {
cx.set_attr(bounds, "slot", Value::Number((*slot as i64).into()))?;
result.push(bounds);
}
// Arrangement 返回受控 ObjectList,不返回宿主 Vec 或任意形状的源对象集合。
let result = crate::object_list::collection(cx, result, self.desktop)?;
Value::Object(record(
cx,
@@ -275,6 +310,7 @@ impl NativeCallable for Method {
return Err(invalid("visual bounds exceed limits"));
}
let mut commands = Vec::new();
// 将 Rust 默认绘制命令反向编码成源 PaintCommand,允许源覆盖方法调用基类 paint 后继续组合。
for command in layout::paint_commands(&node, x, y, Size { width, height }) {
let fields = match command {
RenderCommand::Scene3D {
@@ -284,6 +320,7 @@ impl NativeCallable for Method {
height,
view,
} => {
// 3D 通过不可变 PaintView payload 暴露场景快照,不把运行中 VM 的场景对象交给后续绘制线程。
let snapshot = cx.allocate(crate::scene3d::PaintView(view))?;
vec![
("kind", Value::Text("scene3d".into())),
@@ -310,6 +347,7 @@ impl NativeCallable for Method {
("size", Value::Number((size as i64).into())),
("color", Value::Text(color)),
];
// 可选文本换行边界成对写入;缺省时两个字段都不创建,与源 paint 解码契约保持一致。
if let Some([width, height]) = wrap_bounds {
fields.push(("wrapWidth", value(width)));
fields.push(("wrapHeight", value(height)));
@@ -332,6 +370,7 @@ impl NativeCallable for Method {
("radius", value(radius)),
("color", Value::Text(color)),
],
// 当前反向编码支持明确列出的命令;新增 RenderCommand 变体时须同步此转换,不能静默丢弃。
_ => return Err(invalid("unsupported native paint command")),
};
let command = record(cx, self.desktop, "PaintCommand", fields)?;
@@ -352,15 +391,18 @@ impl NativeCallable for Method {
}
#[derive(Debug)]
/// Point 的矩形/圆形命中辅助方法,保存结果类型和具体几何判定种类。
struct PointMethod {
expected: ObjectRef,
circle: bool,
signature: CallSignature,
}
impl NativeCallable for PointMethod {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<CallSignature> {
Some(self.signature.clone())
}
/// 圆形测试接收 Point 加圆心/半径共四项;矩形测试接收 Point 和 Bounds 共两项。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(if self.circle {
4
@@ -369,10 +411,12 @@ impl NativeCallable for PointMethod {
}))
}
/// 先验证 Point 名义类型与有限坐标,再分别计算圆内距离或 Bounds 包含关系,返回 Bool。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let Some(Value::Object(point)) = args.first() else {
return Err(invalid("Point method requires a Point"));
};
// Point 辅助方法验证名义接收者,不能只要有 x/y 字段就接受普通对象。
if !cx.store().is_instance(*point, self.expected)? {
return Err(invalid("Point method requires a Point"));
}
@@ -388,6 +432,7 @@ impl NativeCallable for PointMethod {
else {
return Err(invalid("inCircle requires center x/y and radius"));
};
// 圆心和半径按 f64 解析及求平方距离,在有界数值内避免不必要的 f32 精度损失。
let parse = |value: &endor_core::EndorNumber| -> Result<f64, VmError> {
let value = value
.to_string()
@@ -401,6 +446,7 @@ impl NativeCallable for PointMethod {
let dx = x as f64 - parse(cx_value)?;
let dy = y as f64 - parse(cy_value)?;
let radius = parse(radius)?;
// 半径不能负;零半径只包含圆心,边界使用 <= 因此圆周也命中。
if radius < 0.0 {
return Err(invalid("circle radius must be nonnegative"));
}
@@ -413,12 +459,14 @@ impl NativeCallable for PointMethod {
let top = number(cx, *bounds, "y", true)?;
let width = number(cx, *bounds, "width", false)?;
let height = number(cx, *bounds, "height", false)?;
// 矩形辅助同样使用半开右/下边界;这与 geometry::Rect 的闭区间 contains 不同,调用方应留意语义。
x >= left && y >= top && x < left + width && y < top + height
};
Ok(NativeResult::Return(Value::Bool(inside)))
}
}
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
@@ -437,6 +485,7 @@ pub fn install(
let measure = nominal("MeasureResult")?;
let arrangement = nominal("Arrangement")?;
let display = nominal("DisplayList")?;
// 把四个默认方法安装到 Block,源子类可覆盖;所有视觉协议声明 ReadOnly,允许派生结果分配而不修改模型。
for (name, query, input, returns) in [
("measure", Query::Measure, constraints, measure),
("arrange", Query::Arrange, bounds, arrangement),
@@ -465,6 +514,7 @@ pub fn install(
&vm.dict_get(registry, &Value::Text("Point".into()))?
.ok_or(invalid("missing Point type"))?,
)?;
// Point.inside 接收 Bounds,inCircle 接收圆心 x/y 和半径;签名都包含隐式 Point receiver。
for (name, circle) in [("inside", false), ("inCircle", true)] {
let function = vm.allocate(ContextNativeFunction::new(
name,
+34
View File
@@ -1,3 +1,6 @@
//! 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。
//! 新组合应优先使用普通布局、ScrollView 和独立场景视口;保留这里是为了兼容已有 window/vertical/horizontal/canvas 页面。
//!
//! 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};
@@ -6,15 +9,21 @@ 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",
@@ -24,6 +33,7 @@ impl Mode {
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(name: &str) -> Result<Self, VmError> {
match name {
"window" => Ok(Self::Window),
@@ -36,11 +46,14 @@ impl Mode {
}
}
/// 为旧页面模式生成有限轴和可延伸轴的根约束。
pub fn constraints(self, viewport: Option<Size>) -> [f32; 4] {
// 未收到原生视口尺寸时采用布局默认根约束;不能凭空建立固定宽高。
let Some(size) = viewport else {
return crate::layout::root_constraints(None);
};
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],
@@ -51,20 +64,26 @@ impl Mode {
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
/// 页面级兼容相机状态和有限内容范围,支持绘制与命中正逆变换。
pub struct Surface {
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 {
Self {
mode: Mode::Window,
@@ -77,6 +96,7 @@ impl Default for Surface {
}
impl Surface {
/// 指定页面模式并显式使用单位缩放,其他字段采用空页面默认值;无需分配 VM 或原生窗口资源。
pub fn new(mode: Mode) -> Self {
Self {
mode,
@@ -85,6 +105,8 @@ impl Surface {
}
}
/// 把页面视图坐标反算为旧 Page 相机下的世界坐标。
// 输入在视图空间,先除缩放再加世界偏移;调用方需保证 zoom 有限且大于零。
pub fn to_world(&self, point: Point) -> Point {
Point::new(
point.x / self.zoom + self.pan.x,
@@ -92,6 +114,8 @@ impl Surface {
)
}
/// 把旧页面世界坐标变换到可见视图坐标。
// 输入在世界空间,先减相机偏移再缩放,恰与 to_world 互逆。
pub fn to_view(&self, point: Point) -> Point {
Point::new(
(point.x - self.pan.x) * self.zoom,
@@ -99,14 +123,20 @@ impl Surface {
)
}
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
pub fn contains(&self, point: [f32; 2], viewport: Option<Size>) -> bool {
// 传入的数组是世界坐标。转换后使用左/上包含、右/下排除的视口边界;
// 未知视口时仍排除负坐标,但不限制右/下界。
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 {
Mode::Window => {
self.pan = Point::default();
@@ -138,7 +168,9 @@ 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;
@@ -168,7 +200,9 @@ mod tests {
}
#[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 {
+14
View File
@@ -1,3 +1,6 @@
//! 合成整个原生窗口的显示列表:页面画面、宿主侧栏和开发工具绘制。
//! 窗口逻辑尺寸与设备像素比例在这里衔接。页面内容先应用其视角并裁剪,宿主控件的区域和命中布局另外返回。
//!
use crate::{
activity::Activity,
developer_tools::{self, DeveloperToolsLayout, Workspace as DeveloperToolsState},
@@ -7,10 +10,12 @@ use crate::{
};
#[derive(Clone, Debug, Default)]
/// 宿主管理区域的几何布局,供窗口事件分辨页面与工具命中。
pub struct SceneLayout {
pub developer_tools: DeveloperToolsLayout,
}
/// 组合页面、开发工具和侧栏的设备像素显示列表,同时返回供命中使用的宿主布局。
pub fn build_scene(
width: u32,
height: u32,
@@ -20,6 +25,7 @@ pub fn build_scene(
activity: &Activity,
sidebar: &mut crate::sidebar::State,
) -> DisplayScene {
// 活动标签的 developer_tool 标记决定管理页类型,不靠当前窗口标题猜测;同步工具状态后再合成。
let tool = document
.frame
.tabs
@@ -30,23 +36,28 @@ pub fn build_scene(
developer_tools_state.document_updated(document);
let left = sidebar.left();
let surface = &document.frame.surface;
// width/height 为设备像素,sidebar.left 为逻辑单位,先乘 scale 得到页面可用裁剪区域。
let content = Rect::new(
left * scale,
0.0,
(width as f32 - left * scale).max(0.0),
height as f32,
);
// 管理页使用单位页面缩放,避免业务 Page 相机改变宿主工具字号;普通页叠加旧 Surface zoom。
let zoom = if tool.is_some() { 1.0 } else { surface.zoom };
// 显示列表转换失败时降级为空默认文档;这只保护原生绘制,不把无效命令伪装成成功执行。
let mut page = document.display_list(scale * zoom).unwrap_or_else(|_| {
RenderDocument::default()
.display_list(scale)
.expect("default document is valid")
});
// 页面显示列表先已乘设备比例/zoom,再加入侧栏偏移和世界 pan 的设备像素平移,保持缩放顺序一致。
page.translate(Point::new(
(left - surface.pan.x * zoom) * scale,
-surface.pan.y * zoom * scale,
));
let mut display_list = DisplayList::default();
// 全窗只做一次 Clear,后续跳过页面列表自身 Clear,防止其覆盖侧栏或突破页面裁剪。
display_list.push(DrawCommand::Clear(
crate::document::parse_color(&document.frame.background)
.unwrap_or(crate::system_ui::theme::BG),
@@ -66,9 +77,11 @@ pub fn build_scene(
developer_tools_state,
activity,
);
// 工具命令在设备空间平移,交互布局在逻辑空间平移,两者比例不同但实际对齐同一页面起点。
developer_list.translate(Point::new(left * scale, 0.0));
developer_layout.translate(left, 0.0);
display_list.extend(developer_list);
// 侧栏最后覆盖合成,内部按逻辑尺寸绘制后统一设备缩放,不受页面相机影响。
let mut chrome = sidebar.draw(
&document.frame.tabs,
height as f32 / scale,
@@ -84,6 +97,7 @@ pub fn build_scene(
}
}
/// 整窗合成结果:显示列表与同帧管理交互几何一起返回。
pub struct DisplayScene {
pub display_list: DisplayList,
pub layout: SceneLayout,
+79
View File
@@ -1,3 +1,7 @@
//! 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。
//! 不可变 View 可以缓存光栅结果;相机变化会产生新视图。当前不是 GPU 后端,像素和光栅工作预算用于限制分配与循环工作量。
//! 面板 UI 先渲染为有限纹理,再以透视校正 UV 映射进世界。与相机相关的投影宽高比由具体视口提供。
//!
//! Software 3D surfaces share the application's existing CPU compositor. Mesh
//! visibility and picking use the same camera and transformed world triangles.
pub mod math;
@@ -11,6 +15,7 @@ pub const MAX_TRIANGLES: usize = 65_536;
pub const MAX_VERTICES: usize = 32_768;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 不可变顶点、三角形和可选 UV;多个网格可共享同一数值缓冲。
pub struct Geometry {
pub vertices: Vec<Vec3>,
pub triangles: Vec<[u32; 3]>,
@@ -18,7 +23,10 @@ pub struct Geometry {
pub uv: Vec<[f32; 2]>,
}
impl Geometry {
/// 检查顶点与三角数量、索引范围、坐标有效性及逐顶点 UV;允许缺省 UV,不在此限制 UV 到单位区间。
pub fn valid(&self) -> bool {
// 检查顶点/三角形资源量和所有索引范围;UV 可省略,否则必须逐顶点对应且每个分量有限。
// 不在这里强制 UV 位于 [0,1],纹理采样阶段再钳制。
self.vertices.len() <= MAX_VERTICES
&& (self.uv.is_empty()
|| self.uv.len() == self.vertices.len()
@@ -31,7 +39,9 @@ impl Geometry {
.flatten()
.all(|&index| (index as usize) < self.vertices.len())
}
/// 生成以原点为中心的盒体顶点和三角形索引;位置和旋转由网格世界矩阵另行描述。
pub fn cuboid(width: f32, height: f32, depth: f32) -> Self {
// 八个角点共享位置,每个面拆两个三角形;尺寸有效性由构造调用边界检查,cuboid 本身只生成数值。
let (x, y, z) = (width / 2., height / 2., depth / 2.);
Self {
uv: vec![],
@@ -63,7 +73,9 @@ impl Geometry {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 一次场景实例:几何、世界矩阵、颜色与可选 UI 面板,身份用于拾取回关联。
pub struct Mesh {
// 纯文本业务关联身份,用于拾取返回;不作为渲染线程可操作的托管引用。
pub identity: String,
pub geometry: Arc<Geometry>,
pub world: Matrix,
@@ -72,12 +84,14 @@ pub struct Mesh {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 面板内普通 UI 的绘制命令及其裁剪/二维变换,逻辑排版不随外部相机改变。
pub struct UiSurface {
pub commands: Vec<crate::document::RenderCommand>,
pub clips: Vec<Option<[f32; 4]>>,
pub views: Vec<Option<crate::viewport::Transform>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 世界平面的有限 UI 纹理描述,包含像素尺寸、背景与内部显示内容。
pub struct Panel {
pub key: u64,
pub width: u32,
@@ -86,21 +100,25 @@ pub struct Panel {
pub surface: Option<Arc<UiSurface>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 方向光的原生数值,方向和强度在场景快照阶段校验。
pub struct Light {
pub direction: Vec3,
pub color: [f32; 3],
pub intensity: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
/// 不含摄像机的世界实例和光照集合;BVH 是可丢弃的派生缓存。
pub struct Scene {
pub meshes: Vec<Mesh>,
pub lights: Vec<Light>,
pub ambient: f32,
pub background: [f32; 3],
#[serde(skip)]
// 序列化忽略的世界 BVH,反序列化后可按场景网格重建;相机改变不要求重建世界空间索引。
pub acceleration: Arc<spatial::Index>,
}
impl PartialEq for Scene {
/// 比较会影响场景结果的数据;加速索引属于派生缓存,不参与场景语义相等。
fn eq(&self, other: &Self) -> bool {
self.meshes == other.meshes
&& self.lights == other.lights
@@ -109,21 +127,26 @@ impl PartialEq for Scene {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 显式相机与共享世界的组合,一个 Scene 可由多个独立 View 观察。
pub struct View {
pub scene: Arc<Scene>,
pub camera: Camera,
}
#[derive(Debug)]
/// 只读绘制结果使用的名义包装,让源 PaintCommand 可以携带不可变 3D 快照。
pub struct PaintView(pub Arc<View>);
impl endor_core::ObjectPayload for PaintView {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"immutable-3d-paint"
}
}
#[derive(Debug, Clone, PartialEq)]
/// 射线与实际三角形的精确交点,含实例身份、距离、法线和重心坐标。
pub struct Hit {
pub instance: usize,
pub identity: String,
@@ -135,10 +158,13 @@ pub struct Hit {
}
impl View {
/// 递归验证相机、材质、灯光、网格/面板表面及全场景资源预算;世界 UI 嵌套最多八层。
pub fn valid(&self) -> bool {
thread_local! { static DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) }; }
// 验证可能递归进入面板内的 3D;作用域结束时归还深度,错误返回也不泄漏计数。
struct Guard;
impl Drop for Guard {
/// 退出当前嵌套校验层时递减线程局部深度;短路失败与正常返回都恢复同一计数。
fn drop(&mut self) {
DEPTH.with(|depth| depth.set(depth.get() - 1));
}
@@ -153,12 +179,14 @@ impl View {
}) {
return false;
}
// 深度检查通过才建立 Guard,之后任一短路校验失败都会恢复计数;上限覆盖嵌套面板中的 3D 命令。
let _guard = Guard;
let color_valid = |color: [f32; 3]| {
color
.iter()
.all(|value| value.is_finite() && (0.0..=1.0).contains(value))
};
// 同时限制实例数、灯光数、累计顶点数和三角形数,单个 Geometry 合法不代表整个 Scene 未超预算。
self.camera.valid()
&& self.scene.ambient.is_finite()
&& (0.0..=1.0).contains(&self.scene.ambient)
@@ -180,6 +208,7 @@ impl View {
.sum::<usize>()
<= MAX_TRIANGLES
&& self.scene.meshes.iter().all(|mesh| {
// 矩阵系数有限之外还验证每个变换后顶点范围,避免局部几何合法但累计世界变换产生无限坐标。
mesh.geometry.valid()
&& mesh.world.0.iter().all(|v| v.is_finite())
&& color_valid(mesh.color)
@@ -188,6 +217,7 @@ impl View {
.vertices
.iter()
.all(|point| mesh.world.point(*point).valid())
// 面板纹理限制在 1..2048 每轴,内部命令/视图/裁剪表等长并递归校验绘制;多一个命令额度留给装配焦点框。
&& mesh.panel.as_ref().is_none_or(|panel| {
panel.width > 0
&& panel.height > 0
@@ -214,12 +244,15 @@ impl View {
&& self.scene.lights.iter().all(|light| {
color_valid(light.color)
&& light.direction.valid()
// 方向光必须近似单位向量,确保点积仅反映夹角而非把方向模长重复乘入光照强度。
&& (light.direction.length() - 1.).abs() < 0.0001
&& light.intensity.is_finite()
&& (0.0..=32.0).contains(&light.intensity)
})
}
/// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。
pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option<Hit> {
// 拾取只接受有效相机、视口内归一化点及正有限宽高比;不在此完整重验几何,调用方应先验证 View。
if !self.camera.valid()
|| !(0.0..=1.0).contains(&u)
|| !(0.0..=1.0).contains(&v)
@@ -232,6 +265,7 @@ impl View {
let basis = self.camera.basis()?;
let mut hit: Option<Hit> = None;
let temporary;
// 无缓存或网格数失配时本次临时重建;数量相同的内容变更须由快照层生成新索引,不靠 matches 检测。
let index = if self.scene.acceleration.matches(self.scene.meshes.len()) {
&*self.scene.acceleration
} else {
@@ -239,6 +273,7 @@ impl View {
&temporary
};
for (entry, instance) in index.ray(ray) {
// BVH 候选按包围盒进入距离排序;后续盒子起点已比最近真实交点远时,可安全停止查询。
if hit.as_ref().is_some_and(|hit| entry > hit.distance) {
break;
}
@@ -250,6 +285,7 @@ impl View {
continue;
};
let point = ray.origin + ray.direction * distance;
// near/far 限制相机前向深度,而不是到相机的欧氏距离;离轴射线同样按视锥范围筛选。
let depth = self.camera.view(basis, point).z;
if depth < self.camera.near
|| depth > self.camera.far
@@ -260,9 +296,11 @@ impl View {
{
continue;
}
// 使用变换后的世界顶点计算面法线,支持非均匀缩放;退化三角形没有可靠法线,不作为命中。
let Some(normal) = (b - a).cross(c - a).normalized() else {
continue;
};
// 同距离时保留较早实例,与绘制严格小于深度比较一致;同实例后续同距三角形也不会覆盖前者。
hit = Some(Hit {
instance,
identity: mesh.identity.clone(),
@@ -278,13 +316,16 @@ impl View {
}
}
/// 把有限颜色通道转换成最终像素使用的字节。
fn channel(value: f32) -> u32 {
(value.clamp(0., 1.) * 255.).round() as u32
}
/// 将三个颜色通道打包为软件像素缓冲使用的 RGB 值。
pub fn rgb(color: [f32; 3]) -> u32 {
channel(color[0]) << 16 | channel(color[1]) << 8 | channel(color[2])
}
/// 某个不可变视图在特定分辨率和宽高比下的光栅结果。
struct CachedSurface {
view: Arc<View>,
width: u32,
@@ -293,17 +334,20 @@ struct CachedSurface {
pixels: Arc<[u32]>,
}
#[derive(Default)]
/// 限制条目数和总像素数的表面缓存;不承担场景业务所有权。
struct SurfaceCache {
entries: std::collections::VecDeque<CachedSurface>,
pixels: usize,
}
/// 按不可变视图身份、分辨率和投影比例查光栅缓存;未命中时在锁外绘制,支持嵌套世界 UI。
pub fn surface(
view: &Arc<View>,
width: u32,
height: u32,
aspect: f32,
) -> Result<Arc<[u32]>, &'static str> {
// 全局缓存以 View Arc 身份+输出尺寸+aspect 为键;值相等但不同 Arc 的视图不会自动命中。
static CACHE: std::sync::OnceLock<std::sync::Mutex<SurfaceCache>> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
{
@@ -321,6 +365,7 @@ pub fn surface(
// 3D viewport and recurse through the compositor.
let pixels: Arc<[u32]> = render_aspect(view, width, height, aspect)?.into();
let mut cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
// 按插入顺序从队首淘汰,不在命中时搬到队尾;最多 8 项、累计 8388608 像素,渲染单表面另有限额。
while cache.entries.len() >= 8 || cache.pixels + pixels.len() > 8_388_608 {
let Some(entry) = cache.entries.pop_front() else {
break;
@@ -341,11 +386,13 @@ pub fn surface(
/// Homogeneous frustum planes expressed in view space. Clipping before division
/// handles triangles crossing the near plane without huge screen coordinates.
#[derive(Clone, Copy)]
/// 裁剪过程中的相机空间顶点,位置与 UV 必须共同插值。
struct Vertex {
position: Vec3,
uv: [f32; 2],
}
impl Vertex {
/// 按裁剪交点比例同时插值位置和 UV,保持新顶点的几何与纹理坐标一致。
fn lerp(self, other: Self, t: f32) -> Self {
Self {
position: self.position + (other.position - self.position) * t,
@@ -356,7 +403,9 @@ impl Vertex {
}
}
}
/// 生成相机空间六个视锥平面,供三角形裁剪与 BVH 可见性查询共用。
fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
// 平面内侧满足 ax+by+cz+d>=0。透视侧平面随 z 展开,正交侧平面是固定 x/y 边界。
let (x, y, slope) = match camera.projection {
Projection::Perspective { fov } => {
let y = (fov.to_radians() / 2.).tan();
@@ -373,6 +422,7 @@ fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
[0., -1., y * slope, y * (1. - slope)],
]
}
/// 在透视除法前逐平面裁剪多边形,并同步插值 UV,避免跨近面顶点投影到极大坐标。
fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
for [a, b, c, d] in frustum_planes(camera, aspect) {
if polygon.is_empty() {
@@ -383,10 +433,12 @@ fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
a * p.x + b * p.y + c * p.z + d
};
let mut output = Vec::new();
// 从闭多边形最后一点连到第一点开始,逐边做 Sutherland–Hodgman 裁剪,不遗漏首尾边。
let mut previous = *polygon.last().unwrap();
let mut before = distance(previous);
for current in polygon {
let after = distance(current);
// 边跨平面时按带符号距离求交点比例,同步插值相机位置和 UV;内侧端点另行保留。
if (before >= 0.) != (after >= 0.) {
output.push(previous.lerp(current, before / (before - after)));
}
@@ -402,9 +454,11 @@ fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
}
#[cfg(test)]
/// 把当前绘制输入转换为输出表面;分配、裁剪和预算由本实现检查。
pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static str> {
render_aspect(view, width, height, width as f32 / height as f32)
}
/// 先校验并预留颜色/深度缓冲预算,再剔除、裁剪、投影和光栅化三角形;超预算返回错误。
fn render_aspect(
view: &View,
width: u32,
@@ -412,6 +466,7 @@ fn render_aspect(
aspect: f32,
) -> Result<Vec<u32>, &'static str> {
let _scope = crate::software_renderer::RenderScope::enter()?;
// 颜色与深度各占一份面积,既有每表面硬上限,也在下方计入递归累计像素预算。
let area = width as usize * height as usize;
// Offscreen targets have a hard per-surface resource bound.
if area > 4_194_304
@@ -423,12 +478,14 @@ fn render_aspect(
{
return Err("invalid 3D surface or resource budget exceeded");
}
// 先申请两份工作量再分配缓冲,深度初始化正无穷,首个有效面可写入。
crate::software_renderer::reserve_pixels(area.saturating_mul(2))?;
let mut pixels = vec![rgb(view.scene.background); area];
let mut depths = vec![f32::INFINITY; area];
let camera = view.camera;
let basis = camera.basis().unwrap();
let perspective = matches!(camera.projection, Projection::Perspective { .. });
// 点已经经过视锥裁剪,才做透视除法;屏幕 y 向下,保留相机 z 用于后续深度/UV 校正。
let project = |point: Vec3| {
let half = match camera.projection {
Projection::Perspective { fov } => point.z * (fov.to_radians() / 2.).tan(),
@@ -448,14 +505,17 @@ fn render_aspect(
temporary = spatial::Index::build(&view.scene.meshes);
&temporary
};
// 宽阶段只筛选候选:离屏网格不进入后续纹理分配和逐三角形处理。
for instance in index.visible(camera, aspect) {
let mesh = &view.scene.meshes[instance];
// 只为宽阶段可见的面板生成普通 UI 纹理;同一 mesh 的所有三角形复用本次纹理。
let texture = mesh.panel.as_ref().map(panel::texture).transpose()?;
for indices in &mesh.geometry.triangles {
let [a, b, c] = indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
let Some(normal) = (b - a).cross(c - a).normalized() else {
continue;
};
// 每三角形使用平面法线做 Lambert 漫反射,环境光为基础,方向光逐 RGB 累加;背光点积钳零。
let mut light = [view.scene.ambient; 3];
for source in &view.scene.lights {
let intensity = normal.dot(source.direction * -1.).max(0.) * source.intensity;
@@ -463,7 +523,9 @@ fn render_aspect(
*channel += intensity * color;
}
}
// 材质色乘累计光照后转 RGB,通道最终钳制;UI 面板纹理分支直接采纹理,不再乘这份网格光照。
let color = rgb(std::array::from_fn(|i| mesh.color[i] * light[i]));
// 在透视除法之前裁剪,并插入具有正确 UV 的交点;不能先投影再裁剪近面。
let polygon = clip(
indices
.map(|index| Vertex {
@@ -471,6 +533,7 @@ fn render_aspect(
basis,
mesh.world.point(mesh.geometry.vertices[index as usize]),
),
// 没有 UV 的顶点用 [0,0];普通无纹理网格不依赖它,带纹理面板由构造器提供四角 UV。
uv: mesh
.geometry
.uv
@@ -485,6 +548,7 @@ fn render_aspect(
if polygon.len() < 3 {
continue;
}
// 裁剪后可能多于三个顶点,用扇形重新三角化,保持同一平面的纹理参数。
for index in 1..polygon.len() - 1 {
let vertices = [polygon[0], polygon[index], polygon[index + 1]];
let [a, b, c] = vertices.map(|vertex| project(vertex.position));
@@ -492,6 +556,7 @@ fn render_aspect(
let max_x = a.x.max(b.x).max(c.x).ceil().min(width as f32) as u32;
let min_y = a.y.min(b.y).min(c.y).floor().max(0.) as u32;
let max_y = a.y.max(b.y).max(c.y).ceil().min(height as f32) as u32;
// 按屏幕包围矩形面积计工作,而非仅成功写像素数;大量重叠/细长三角形也不能绕过循环预算。
work += max_x.saturating_sub(min_x) as usize * max_y.saturating_sub(min_y) as usize;
if work > 32_000_000 {
return Err("3D raster work budget exceeded");
@@ -499,6 +564,7 @@ fn render_aspect(
let edge = |a: Vec3, b: Vec3, x: f32, y: f32| {
(x - a.x) * (b.y - a.y) - (y - a.y) * (b.x - a.x)
};
// 有向面积允许任意顶点绕序,除相同 area 得到一致重心权重;近零面积跳过,不做背面剔除。
let area = edge(a, b, c.x, c.y);
if area.abs() < 1e-6 {
continue;
@@ -507,6 +573,7 @@ fn render_aspect(
for x in min_x..max_x {
let px = x as f32 + 0.5;
let py = y as f32 + 0.5;
// 在像素中心求三个屏幕重心权重,负值容忍 1e-5 舍入误差,明显在外则不做深度和纹理计算。
let weights = [
edge(b, c, px, py) / area,
edge(c, a, px, py) / area,
@@ -515,16 +582,19 @@ fn render_aspect(
if weights.iter().any(|weight| *weight < -1e-5) {
continue;
}
// 透视投影下使用倒深度插值,正交投影下可直接线性插值。
let depth = if perspective {
1. / (weights[0] / a.z + weights[1] / b.z + weights[2] / c.z)
} else {
weights[0] * a.z + weights[1] * b.z + weights[2] * c.z
};
let index = y as usize * width as usize + x as usize;
// 只让最近表面写像素;等深度保留先绘制实例,与拾取的排序约定一致。
if depth < depths[index] {
depths[index] = depth;
pixels[index] =
if let (Some(texture), Some(panel)) = (&texture, &mesh.panel) {
// UV 也要除以深度再插值,否则倾斜面板会出现纹理拉伸失真。
let interpolated = |axis| {
if perspective {
depth
@@ -537,6 +607,7 @@ fn render_aspect(
+ weights[2] * vertices[2].uv[axis]
}
};
// 透视校正 UV 后做最近邻采样,钳到 [0,1] 并限制到最后一个像素,避免 UV=1 恰好越界。
let u = (interpolated(0).clamp(0., 1.) * panel.width as f32)
.min((panel.width - 1) as f32)
as usize;
@@ -560,6 +631,7 @@ fn render_aspect(
mod tests {
use super::*;
#[test]
/// 构造99个远离视锥的网格,宽阶段只保留正中实例;有/无预存BVH的精确拾取身份与距离必须一致。
fn spatial_index_culls_offscreen_meshes_and_preserves_ray_pick() {
let mut view = view(Geometry::cuboid(1., 1., 1.));
let scene = Arc::make_mut(&mut view.scene);
@@ -575,6 +647,7 @@ mod tests {
scene.meshes.push(far);
}
scene.acceleration = Arc::new(spatial::Index::build(&scene.meshes));
// 远处实例应被宽阶段剔除;随后比较带缓存与临时重建索引的精确拾取身份/距离,防止加速改变结果。
assert_eq!(scene.acceleration.visible(view.camera, 1.), vec![0]);
let ray = view.camera.ray(0.5, 0.5, 1.).unwrap();
assert_eq!(scene.acceleration.ray(ray).len(), 1);
@@ -585,6 +658,7 @@ mod tests {
assert_eq!(indexed.identity, fallback.identity);
assert_eq!(indexed.distance, fallback.distance);
}
/// 构造测试最小视图:默认相机、原点红网格、纯环境光与白背景,不依赖业务VM或外部光源。
fn view(geometry: Geometry) -> View {
View {
camera: Camera::default(),
@@ -604,6 +678,7 @@ mod tests {
}
}
#[test]
/// 跨越近裁剪面的三角形仍应产生像素,射线命中受同一 near/far 约束;缩短远面后命中消失。
fn triangles_crossing_near_plane_are_clipped_and_pick_uses_the_same_range() {
let mut view = view(Geometry {
uv: vec![],
@@ -619,6 +694,7 @@ mod tests {
view.camera.near = 1.;
view.camera.far = 10.;
view.camera.projection = Projection::Perspective { fov: 90. };
// 三角形跨近面仍应产生可见红像素,拾取也必须尊重相同 near/far;缩短 far 后命中消失。
let pixels = render(&view, 64, 64).unwrap();
assert!(pixels.contains(&0xFF0000));
let hit = view.pick(0.5, 0.5, 1.).unwrap();
@@ -627,6 +703,7 @@ mod tests {
assert!(view.pick(0.5, 0.5, 1.).is_none());
}
#[test]
/// 验证局部TRS先于父变换作用于点;正交投影不同水平采样的射线方向相同但原点按可见宽度平移。
fn group_transform_order_and_orthographic_rays_are_consistent() {
let local = Matrix::trs(
Vec3::new(1., 0., 0.),
@@ -650,10 +727,12 @@ mod tests {
assert_eq!(right.origin.x - left.origin.x, 2.);
}
#[test]
/// 同一不可变视图/尺寸/aspect复用像素Arc;改变aspect重绘,超像素预算或退化相机必须拒绝。
fn surface_cache_reuses_immutable_frames_and_rejects_invalid_resources() {
let view = Arc::new(view(Geometry::cuboid(1., 1., 1.)));
let first = surface(&view, 64, 64, 1.).unwrap();
let second = surface(&view, 64, 64, 1.).unwrap();
// 同一不可变视图/尺寸/aspect 返回同像素 Arc;仅改变 aspect 即使像素尺寸不变也必须重绘。
assert!(Arc::ptr_eq(&first, &second));
let wide = surface(&view, 64, 64, 2.).unwrap();
assert!(!Arc::ptr_eq(&first, &wide));
+51
View File
@@ -1,19 +1,26 @@
//! 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。
//! 矩阵组合顺序决定局部变换如何进入父坐标系;接近退化的逆矩阵、法线和射线使用 Option 拒绝无效计算。
//!
use serde::{Deserialize, Serialize};
use std::ops::{Add, Mul, Sub};
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
/// 三维数学向量;构造不校验分量,需在输入边界显式调用 valid,不是托管 Vec3 对象。
pub struct Vec3 {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl Vec3 {
/// 直接保存三个分量,不隐式归一化或拒绝非有限值。
pub const fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
/// 计算向量点积,用于方向投影、光照和相交判断。
pub fn dot(self, b: Self) -> f32 {
self.x * b.x + self.y * b.y + self.z * b.z
}
/// 计算右手叉积,用于摄像机基向量和三角形法线。
pub fn cross(self, b: Self) -> Self {
Self::new(
self.y * b.z - self.z * b.y,
@@ -21,14 +28,19 @@ impl Vec3 {
self.x * b.y - self.y * b.x,
)
}
/// 计算向量长度;归一化前还需检查是否接近零。
pub fn length(self) -> f32 {
self.dot(self).sqrt()
}
/// 仅在长度有效且不退化时返回单位向量,避免除零产生非有限结果。
pub fn normalized(self) -> Option<Self> {
// 先求模长,拒绝非有限值和长度不大于 1e-6 的向量;返回 None 让相机/法线调用方停止退化计算。
let length = self.length();
(length.is_finite() && length > 1e-6).then(|| self * (1.0 / length))
}
/// 要求三维向量各分量有限、绝对值不超过一百万;构造和算术运算不会自动调用此检查。
pub fn valid(self) -> bool {
// 构造函数与算术运算不会自动验证;此入口显式要求每个分量有限且绝对值至多一百万。
[self.x, self.y, self.z]
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
@@ -36,26 +48,31 @@ impl Vec3 {
}
impl Add for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
fn add(self, b: Self) -> Self {
Self::new(self.x + b.x, self.y + b.y, self.z + b.z)
}
}
impl Sub for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
fn sub(self, b: Self) -> Self {
Self::new(self.x - b.x, self.y - b.y, self.z - b.z)
}
}
impl Mul<f32> for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
fn mul(self, b: f32) -> Self {
Self::new(self.x * b, self.y * b, self.z * b)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 仿射 4×4 变换矩阵,支持局部到世界的组合与求逆。
pub struct Matrix(pub [f32; 16]);
impl Default for Matrix {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self([
1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
@@ -63,13 +80,17 @@ impl Default for Matrix {
}
}
impl Matrix {
/// 求仿射世界矩阵的逆;退化或不可逆时返回空值。
pub fn inverse(self) -> Option<Self> {
// 这里按仿射矩阵处理:最后一行假设为 [0,0,0,1],只逆左上 3×3,不实现一般透视矩阵求逆。
let m = self.0;
let determinant = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8])
+ m[2] * (m[4] * m[9] - m[5] * m[8]);
// 接近零行列式意味着某方向被压扁或数值不稳定;不能继续除以 determinant。
if !determinant.is_finite() || determinant.abs() < 1e-9 {
return None;
}
// 用伴随矩阵除行列式得到线性部分逆矩阵;先把逆平移留为零。
let d = 1. / determinant;
let mut out = Self([
(m[5] * m[10] - m[6] * m[9]) * d,
@@ -89,13 +110,16 @@ impl Matrix {
0.,
1.,
]);
// 仿射逆平移为 -A⁻¹t。此时 out 平移为零,所以 point 只应用刚求得的线性逆变换。
let translation = out.point(Vec3::new(m[3], m[7], m[11])) * -1.;
out.0[3] = translation.x;
out.0[7] = translation.y;
out.0[11] = translation.z;
Some(out)
}
/// 组合父子仿射变换,调用顺序决定局部坐标最终落在哪个世界位置。
pub fn compose(self, local: Self) -> Self {
// 行主序存储,点视作列向量;self.compose(local) = self × local,即先应用 local 再应用 self。
let mut out = [0.; 16];
for row in 0..4 {
for col in 0..4 {
@@ -106,6 +130,7 @@ impl Matrix {
}
Self(out)
}
/// 对一个位置应用矩阵,包括平移项;不要用它代替纯方向变换。
pub fn point(self, p: Vec3) -> Vec3 {
Vec3::new(
self.0[0] * p.x + self.0[1] * p.y + self.0[2] * p.z + self.0[3],
@@ -113,7 +138,9 @@ impl Matrix {
self.0[8] * p.x + self.0[9] * p.y + self.0[10] * p.z + self.0[11],
)
}
/// 由平移、角度旋转和缩放构造局部仿射矩阵。
pub fn trs(position: Vec3, rotation: Vec3, scale: Vec3) -> Self {
// 输入欧拉角单位为度,先转换为弧度。组合为 T × Rz × Ry × Rx × S,列分别乘对应缩放轴。
let (sx, cx) = rotation.x.to_radians().sin_cos();
let (sy, cy) = rotation.y.to_radians().sin_cos();
let (sz, cz) = rotation.z.to_radians().sin_cos();
@@ -140,12 +167,16 @@ impl Matrix {
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 投影策略:透视视场角或正交可见高度。
pub enum Projection {
// fov 表示垂直视场角(度),水平范围再乘视口宽高比。
Perspective { fov: f32 },
// height 是相机空间可见总高度,与深度无关;宽度为 height × aspect。
Orthographic { height: f32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 原生摄像机描述,包括观察基、近远面和投影参数;宽高比由视口提供。
pub struct Camera {
pub position: Vec3,
pub target: Vec3,
@@ -155,6 +186,7 @@ pub struct Camera {
pub projection: Projection,
}
impl Default for Camera {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
position: Vec3::new(0., 0., 5.),
@@ -167,19 +199,25 @@ impl Default for Camera {
}
}
#[derive(Debug, Clone, Copy)]
/// 摄像机的右、上、前单位基向量。
pub struct Basis {
pub right: Vec3,
pub up: Vec3,
pub forward: Vec3,
}
impl Camera {
/// 从观察位置、目标和上方向建立正交相机基;方向退化时拒绝。
pub fn basis(self) -> Option<Basis> {
// forward 指向观察目标;up 输入只提供大致上方向,叉积后重新正交化。
// 位置等于目标、up 为零或与前向平行时,normalized 返回 None。
let forward = (self.target - self.position).normalized()?;
let right = forward.cross(self.up).normalized()?;
let up = right.cross(forward).normalized()?;
Some(Basis { right, up, forward })
}
/// 验证相机向量/观察基、裁剪面次序和投影参数;退化方向、非法视场角或正交高度均返回 false。
pub fn valid(self) -> bool {
// 除了向量范围,还要求有效观察基、0 < near < far,以及合法视场角或正交高度。
self.position.valid()
&& self.target.valid()
&& self.up.valid()
@@ -196,15 +234,21 @@ impl Camera {
}
}
}
/// 把世界点投影到相机的右/上/前基向量坐标。
pub fn view(self, basis: Basis, p: Vec3) -> Vec3 {
// 先把世界点相对化,再点乘正交基;这里前方 z 为正,与某些图形 API 的负 z 约定不同。
let p = p - self.position;
Vec3::new(p.dot(basis.right), p.dot(basis.up), p.dot(basis.forward))
}
/// 从视口归一化坐标生成透视或正交射线;宽高比由具体视口提供。
pub fn ray(self, u: f32, v: f32, aspect: f32) -> Option<Ray> {
let basis = self.basis()?;
// u/v 通常是视口内 [0,1],映射到 [-1,1] 并翻转屏幕 y。
// 此函数不钳制输入,捕获拖动可以从视口外继续生成射线;aspect 由调用方保证有效。
let (x, y) = (u * 2. - 1., 1. - v * 2.);
match self.projection {
Projection::Perspective { fov } => {
// 透视射线共用相机原点,方向沿前向加上视平面偏移,最后归一化。
let half = (fov.to_radians() / 2.).tan();
Some(Ray {
origin: self.position,
@@ -214,6 +258,7 @@ impl Camera {
.normalized()?,
})
}
// 正交射线方向都为 forward,只改变起点在相机右/上平面中的位置。
Projection::Orthographic { height } => Some(Ray {
origin: self.position
+ basis.right * (x * height * aspect / 2.)
@@ -224,21 +269,26 @@ impl Camera {
}
}
#[derive(Debug, Clone, Copy)]
/// 世界坐标中的射线原点与方向,供网格和面板求交。
pub struct Ray {
pub origin: Vec3,
pub direction: Vec3,
}
impl Ray {
/// 求射线与三角形相交距离和重心坐标,拒绝退化三角形及射线起点后方的交点。
pub fn triangle(self, a: Vec3, b: Vec3, c: Vec3) -> Option<(f32, [f32; 3])> {
// Möller–Trumbore 求交:使用两条三角形边与叉积解重心系数,不先构造平面方程。
let ab = b - a;
let ac = c - a;
let p = self.direction.cross(ac);
let determinant = ab.dot(p);
// 近零表示射线与面平行或三角形退化;保留正负行列式,因此不做背面剔除。
if determinant.abs() < 1e-7 {
return None;
}
let inverse = 1.0 / determinant;
let t = self.origin - a;
// u 是顶点 b 的重心权重,必须位于 [0,1];v 是 c 的权重,a 的权重由 1-u-v 得到。
let u = t.dot(p) * inverse;
if !(0.0..=1.0).contains(&u) {
return None;
@@ -248,6 +298,7 @@ impl Ray {
if v < 0. || u + v > 1. {
return None;
}
// 返回的是射线参数 t,方向为单位向量时才等于世界距离;负值在射线起点后方,被拒绝。
let distance = ac.dot(q) * inverse;
(distance >= 0.).then_some((distance, [1. - u - v, u, v]))
}
+37
View File
@@ -1,3 +1,7 @@
//! 连接 3D 平面与普通 UI 的坐标和绘制桥梁。
//! Mapping 沿嵌套面板逐层把页面指针变成纹理坐标;可见命中还须检查最近射线交点,防止点穿遮挡物。
//! compose 把各 Mount 的绘制命令按所属面板分组并递归装配;texture 再调用普通软件渲染器绘制面板内容。
//!
//! Perspective UI mapping. The same world plane supplies texture coordinates
//! during rendering and inverse pointer coordinates during event dispatch.
use super::{
@@ -11,17 +15,24 @@ use crate::{
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
/// 一次面板出现的正逆投影信息,可以链接父面板形成嵌套映射。
pub struct Mapping {
// 包含本面板的 3D 视口 Mount ID,与 key 一起区分同一业务场景的多次出现。
pub owner: u64,
// 面板在场景中的稳定标识,不能仅凭 Block 身份选择某个具体面板出现。
pub key: u64,
pub view: Arc<View>,
pub rect: [f32; 4],
// 外层二维相机变换;逆映射先处理它,再进入 3D 摄像机射线计算。
pub outer: Transform,
pub clip: Option<[f32; 4]>,
// 嵌套面板的外层链;反投影从父到子,正投影从子到父。
pub parent: Option<Arc<Mapping>>,
}
impl Mapping {
/// 沿父面板逆投影到当前平面的 UI 像素;要求可见时使用最近射线命中拒绝被遮挡的目标。
pub fn texture_point(&self, point: Point, clipped: bool) -> Option<Point> {
// 先把最外层窗口坐标逐层反投影为父面板像素,再检查当前面板所属空间的裁剪。
let point = if let Some(parent) = &self.parent {
parent.texture_point(point, clipped)?
} else {
@@ -39,6 +50,7 @@ impl Mapping {
if width <= 0. || height <= 0. {
return None;
}
// rect 是 3D 视口在当前二维空间的位置,归一化坐标传给该视口相机;不是面板纹理坐标。
let (u, v) = ((point.x - x) / width, (point.y - y) / height);
let mesh = self.view.scene.meshes.iter().find(|mesh| {
mesh.panel
@@ -47,6 +59,7 @@ impl Mapping {
})?;
let panel = mesh.panel.as_ref()?;
if clipped {
// 指针落在平面投影内还不够:最近三角形必须属于本面板,不能点穿前方网格。
let hit = self.view.pick(u, v, width / height)?;
if self.view.scene.meshes[hit.instance]
.panel
@@ -56,22 +69,27 @@ impl Mapping {
return None;
}
}
// 捕获拖动可以不要求指针仍位于面板内,但仍使用原面板平面的连续逆映射。
let ray = self.view.camera.ray(u, v, width / height)?;
let center = mesh.world.point(Vec3::default());
let normal = (mesh.world.point(Vec3::new(1., 0., 0.)) - center)
.cross(mesh.world.point(Vec3::new(0., 1., 0.)) - center);
// 用世界变换后的局部 x/y 轴叉积求平面法线,支持旋转和非均匀缩放;平行射线无交点。
let denominator = normal.dot(ray.direction);
if denominator.abs() < 1e-7 {
return None;
}
// 解射线与无限平面交点,拒绝非有限及相机后方结果;clipped=false 时允许点落在面板边界外。
let distance = (center - ray.origin).dot(normal) / denominator;
if !distance.is_finite() || distance <= 0. {
return None;
}
// 把世界交点乘逆 world 回到面板局部坐标,逆矩阵退化时拒绝。
let local = mesh
.world
.inverse()?
.point(ray.origin + ray.direction * distance);
// 面板网格约定以局部原点为中心;x 向右、y 向上,转换为纹理左上原点、y 向下。
let world_width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x;
let world_height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y;
let point = Point::new(
@@ -81,6 +99,7 @@ impl Mapping {
(point.x.is_finite() && point.y.is_finite()).then_some(point)
}
/// 把当前面板的 UI 像素正向投影到外层页面空间,供坐标查询和交互测试使用。
pub fn project_point(&self, point: Point) -> Option<Point> {
let mesh = self.view.scene.meshes.iter().find(|mesh| {
mesh.panel
@@ -90,6 +109,7 @@ impl Mapping {
let panel = mesh.panel.as_ref()?;
let width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x;
let height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y;
// 正向路径与 texture_point 对称:纹理像素先转局部平面点,再进入世界空间。
let world = mesh.world.point(Vec3::new(
(point.x / panel.width as f32 - 0.5) * width,
(0.5 - point.y / panel.height as f32) * height,
@@ -97,10 +117,12 @@ impl Mapping {
));
let camera = self.view.camera;
let local = camera.view(camera.basis()?, world);
// 点在近远面之外时无可用投影;此方法仅算位置,不检查前方遮挡或二维 clip。
if local.z <= camera.near || local.z >= camera.far {
return None;
}
let [x, y, w, h] = self.rect;
// 透视半高随相机深度增长,正交半高恒定;横向除数还包含视口宽高比。
let half = match camera.projection {
Projection::Perspective { fov } => local.z * (fov.to_radians() / 2.).tan(),
Projection::Orthographic { height } => height / 2.,
@@ -109,6 +131,7 @@ impl Mapping {
x + (local.x / (half * w / h) + 1.) * w / 2.,
y + (1. - local.y / half) * h / 2.,
));
// 当前投影得到的是父面板纹理空间;继续向外层正投影,直到窗口页面坐标。
if let Some(parent) = &self.parent {
parent.project_point(point)
} else {
@@ -118,6 +141,7 @@ impl Mapping {
}
#[derive(Clone)]
/// 归属明确的一条内部 UI 绘制命令,装配时还保留裁剪、变换和 owner。
struct Entry {
command: crate::document::RenderCommand,
clip: Option<[f32; 4]>,
@@ -127,12 +151,15 @@ struct Entry {
/// Collect a panel's ordinary paint subtree into its texture. Nested 3D
/// commands are assembled recursively before their containing texture.
/// 按面板所属视口和稳定 key 分组绘制命令,递归将内部 UI 装配进 3D 面板表面。
pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
use crate::document::RenderCommand;
use std::collections::{HashMap, HashSet};
let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 分组键包含视口 owner 和面板 key,防止同一场景在两个视口出现时互相混入绘制命令。
let mut groups = HashMap::<(u64, u64), Vec<Entry>>::new();
let mut roots = Vec::new();
// 每条命令都有出现归属;据此分流为窗口根命令或某个世界面板的内部命令。
for (index, command) in frame.commands.iter().enumerate() {
let owner = frame.command_mounts[index];
let mount = mounts[&owner];
@@ -151,6 +178,7 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
roots.push(entry);
}
}
// 面板内焦点框也必须作为纹理内容绘制,不能留在窗口根层,否则会脱离世界面板位置。
if let Some(mount) = frame.focus.and_then(|id| mounts.get(&id))
&& let Some(panel) = &mount.panel
{
@@ -172,6 +200,7 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
owner: mount.id,
});
}
/// 递归装配一个绘制条目包含的世界 UI,检查嵌套环与深度,再生成独立不可变视图。
fn assemble(
entry: Entry,
groups: &HashMap<(u64, u64), Vec<Entry>>,
@@ -182,10 +211,12 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
if let RenderCommand::Scene3D { view, .. } = &mut entry.command
&& view.scene.meshes.iter().any(|mesh| mesh.panel.is_some())
{
// 只有含面板的 Scene3D 命令才递归装配;path 跟踪当前祖先出现,限制深度小于 8 并拒绝环。
anyhow::ensure!(
depth < 8 && path.insert(entry.owner),
"3D UI paint nesting cycle or depth budget exceeded"
);
// 只构造新的原生快照,不写回源 Scene3D;同一业务场景仍可被其他视口共享。
let mut scene = (*view.scene).clone();
for mesh in &mut scene.meshes {
if let Some(panel) = &mut mesh.panel {
@@ -194,6 +225,7 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
clips: Vec::new(),
views: Vec::new(),
};
// 按照原命令顺序递归装配面板内容;嵌套 3D 子命令先完成,父面板才取得完整表面。
for child in groups.get(&(entry.owner, panel.key)).into_iter().flatten() {
let child = assemble(child.clone(), groups, path, depth + 1)?;
surface.commands.push(child.command);
@@ -207,11 +239,13 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
scene: Arc::new(scene),
camera: view.camera,
});
// 退出当前分支时撤销祖先标记,允许同一身份在其他独立分支中出现而不误判成环。
path.remove(&entry.owner);
}
Ok(entry)
}
let mut path = HashSet::new();
// 面板内部命令已经移入 UiSurface,根帧只保留外层命令;四张平行元数据表同步重建。
frame.commands.clear();
frame.command_clips.clear();
frame.command_views.clear();
@@ -226,11 +260,14 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
Ok(())
}
/// 为一个面板分配有限像素表面,并调用普通 UI 软件渲染;当前实现不会按面板内容独立缓存纹理。
pub fn texture(panel: &super::Panel) -> Result<Vec<u32>, &'static str> {
// 先向统一像素预算申请有限纹理面积,再分配背景缓冲;嵌套面板同样受该预算约束。
crate::software_renderer::reserve_pixels(panel.width as usize * panel.height as usize)?;
let mut pixels =
vec![super::rgb(panel.background); panel.width as usize * panel.height as usize];
if let Some(surface) = &panel.surface {
// 将普通 UI 表面包装为临时文档复用现有显示列表和软件渲染路径;比例 1 对应纹理像素。
let document = crate::document::RenderDocument {
frame: crate::document::Frame {
background: format!("#{:06X}", super::rgb(panel.background)),
+32
View File
@@ -1,3 +1,6 @@
//! 不可变世界空间 BVH。用网格变换后的包围盒快速筛选视锥与射线候选。
//! 空间索引只做宽阶段筛选;最终拾取仍检查真实三角形。相机变化可复用索引,几何或变换变化需要新索引。
//!
//! Immutable world-space BVH. Camera changes reuse it; it contains no VM data.
use super::{
Mesh,
@@ -5,17 +8,20 @@ use super::{
};
#[derive(Debug, Clone, Copy)]
/// 变换后网格的世界空间 AABB,做保守筛选而不是精确表面描述。
pub struct Bounds {
pub min: Vec3,
pub max: Vec3,
}
impl Bounds {
/// 变换网格顶点并求世界 AABB,作为空间索引的保守外包围。
fn mesh(mesh: &Mesh) -> Option<Self> {
let mut points = mesh
.geometry
.vertices
.iter()
.map(|point| mesh.world.point(*point));
// 空网格没有包围盒,返回 None 后不会进入树;首顶点初始化上下界,避免把原点误算进边界。
let first = points.next()?;
Some(points.fold(
Self {
@@ -36,6 +42,7 @@ impl Bounds {
},
))
}
/// 求两个包围盒的最小共同外包围,供 BVH 父节点使用。
fn union(self, other: Self) -> Self {
Self {
min: Vec3::new(
@@ -50,12 +57,15 @@ impl Bounds {
),
}
}
/// 取指定轴中心位置,供最长轴排序和分割使用。
fn center(self, axis: usize) -> f32 {
let min = [self.min.x, self.min.y, self.min.z];
let max = [self.max.x, self.max.y, self.max.z];
(min[axis] + max[axis]) / 2.
}
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
fn ray(self, ray: Ray) -> Option<f32> {
// slab 算法逐轴交区间;near 从零开始,只保留射线前方,起点在盒内时返回零。
let mut near = 0f32;
let mut far = f32::INFINITY;
for (origin, direction, min, max) in [
@@ -63,6 +73,7 @@ impl Bounds {
(ray.origin.y, ray.direction.y, self.min.y, self.max.y),
(ray.origin.z, ray.direction.z, self.min.z, self.max.z),
] {
// 近乎平行该轴时不做除法;起点在 slab 外即可拒绝,在 slab 内则该轴不收窄距离。
if direction.abs() < 1e-12 {
if origin < min || origin > max {
return None;
@@ -71,6 +82,7 @@ impl Bounds {
}
let a = (min - origin) / direction;
let b = (max - origin) / direction;
// 方向可为负,先排序该轴进入/退出距离,再与之前各轴区间求交。
near = near.max(a.min(b));
far = far.min(a.max(b));
if far < near {
@@ -79,7 +91,9 @@ impl Bounds {
}
Some(near)
}
/// 检查八角是否全落在任一视锥平面外侧;返回保守可见性,不代表真实三角形可见。
fn visible(self, camera: Camera, basis: Basis, planes: &[[f32; 4]; 6]) -> bool {
// 用三位索引枚举 AABB 八角,再转到相机空间,使角点与视锥平面处于同一坐标系。
let corners: [Vec3; 8] = std::array::from_fn(|index| {
camera.view(
basis,
@@ -102,6 +116,7 @@ impl Bounds {
),
)
});
// 只有某个平面外侧包含全部八角时才剔除;盒与视锥可能无真实相交也会保守保留。
!planes.iter().any(|[a, b, c, d]| {
corners
.iter()
@@ -110,6 +125,7 @@ impl Bounds {
}
}
#[derive(Debug)]
/// BVH 叶或分支;叶保存实例索引,分支保存两个子节点索引。
enum Node {
Leaf {
bounds: Bounds,
@@ -122,6 +138,7 @@ enum Node {
},
}
impl Node {
/// 读取叶/分支统一的包围盒,隐藏节点种类差异。
fn bounds(&self) -> Bounds {
match self {
Self::Leaf { bounds, .. } | Self::Branch { bounds, .. } => *bounds,
@@ -130,13 +147,16 @@ impl Node {
}
#[derive(Debug, Default)]
/// 由不可变场景建立的包围盒树,供相机视锥和射线查询复用。
pub struct Index {
bounds: Vec<Option<Bounds>>,
nodes: Vec<Node>,
root: Option<usize>,
}
impl Index {
/// 用变换后的网格顶点建立世界 AABB 与 BVH;忽略空网格,树内仅保存原生几何下标。
pub fn build(meshes: &[Mesh]) -> Self {
// 保留与原网格下标等长的包围盒数组;items 仅收集非空网格,最终候选仍指向原场景下标。
let bounds: Vec<_> = meshes.iter().map(Bounds::mesh).collect();
let mut items: Vec<_> = bounds
.iter()
@@ -153,17 +173,20 @@ impl Index {
}
index
}
/// 按包围盒最长轴的中心排序并递归二分,叶节点保留少量网格候选。
fn split(&mut self, items: &mut [usize]) -> usize {
let bounds = items
.iter()
.map(|&index| self.bounds[index].unwrap())
.reduce(Bounds::union)
.unwrap();
// 先占位父节点,递归子树会扩展 nodes;返回后按稳定下标替换父占位,不保留可失效的引用。
let index = self.nodes.len();
self.nodes.push(Node::Leaf {
bounds,
items: Vec::new(),
});
// 至多四项成为叶;更大集合沿包围盒最长轴排序后二等分,控制树深而非按三角形拆分。
if items.len() <= 4 {
self.nodes[index] = Node::Leaf {
bounds,
@@ -184,6 +207,7 @@ impl Index {
.center(axis)
.total_cmp(&self.bounds[b].unwrap().center(axis))
});
// 两半均非空,即使所有中心重合也能递减集合规模,保证递归终止。
let mid = items.len() / 2;
let (left, right) = items.split_at_mut(mid);
let left = self.split(left);
@@ -196,10 +220,14 @@ impl Index {
}
index
}
/// 检查索引覆盖的网格数量是否与场景相符;缺失缓存时允许重建。
// 这里只校验数量,不验证顶点或 world 变换;调用方必须在几何改变后重建索引。
pub fn matches(&self, meshes: usize) -> bool {
self.bounds.len() == meshes
}
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
pub fn ray(&self, ray: Ray) -> Vec<(f32, usize)> {
// 空树的栈为空;显式栈避免查询递归,先测分支包围盒,未命中时整棵子树跳过。
let mut stack: Vec<_> = self.root.into_iter().collect();
let mut output = Vec::new();
while let Some(index) = stack.pop() {
@@ -220,10 +248,13 @@ impl Index {
}
}
}
// 距离是盒进入距离而非三角形命中距离;同距按场景下标排序,精确拾取仍由调用方完成。
output.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
output
}
/// 用相机六平面保守判断包围盒可见性,返回保持原场景顺序的候选。
pub fn visible(&self, camera: Camera, aspect: f32) -> Vec<usize> {
// 相机无法建立有效正交基时没有可靠视锥,返回空候选,不继续使用无效投影。
let Some(basis) = camera.basis() else {
return Vec::new();
};
@@ -247,6 +278,7 @@ impl Index {
}
}
}
// BVH 遍历顺序与场景顺序不同;恢复原下标顺序以保持后续绘制及同深度处理稳定。
output.sort_unstable();
output
}
+43 -1
View File
@@ -1,3 +1,6 @@
//! 逐 Mount 的滚动状态、裁剪和输入目标选择。
//! 共享组件的不同出现保留独立偏移。嵌套滚动先选择可接收位移的内层,偏移在实际内容范围内钳制。
//!
//! Per-occurrence scroll state and a single geometry/clip projection shared by
//! native paint, source paint and input. No business object is copied.
use crate::{
@@ -12,6 +15,7 @@ use endor_core::{NativeContext, ObjectRef, Value, Vm, VmError, WeakObjectRef};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
/// 滚动容器允许响应的轴,不决定其业务内容组成方向。
pub enum Axis {
#[default]
Both,
@@ -19,6 +23,7 @@ pub enum Axis {
Horizontal,
}
impl Axis {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
match self {
Self::Both => "both",
@@ -26,6 +31,7 @@ impl Axis {
Self::Horizontal => "horizontal",
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(name: &str) -> Result<Self, VmError> {
match name {
"both" => Ok(Self::Both),
@@ -36,15 +42,20 @@ impl Axis {
)),
}
}
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
pub fn horizontal(self) -> bool {
self != Self::Vertical
}
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
pub fn vertical(self) -> bool {
self != Self::Horizontal
}
}
/// 求两个裁剪矩形的交集,空区域仍保持合法非负尺寸。
fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
// 输入采用 [左, 上, 宽, 高]。交集左上取较大值,右下取较小值;
// 右/下边界再钳到左/上边界,保证完全错开时输出零面积而非负尺寸。
let x = a[0].max(b[0]);
let y = a[1].max(b[1]);
[
@@ -54,7 +65,10 @@ fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
(a[1] + a[3]).min(b[1] + b[3]).max(y) - y,
]
}
/// 沿出现树累计滚动偏移和裁剪;遇到独立场景视口时按其有限边界重新建立投影范围。
pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame) -> Result<()> {
// 第一阶段只建立查找表,不改坐标:Block ID 查语义节点,Mount ID 查帧中的出现。
// 同一个 Block 可以多次出现;滚动偏移后面按 Mount 读取,不能存入这个 Block 表。
let mut nodes = HashMap::new();
let mut work = vec![root];
while let Some(node) = work.pop() {
@@ -72,6 +86,8 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
.iter()
.map(|m| matches!(nodes[m.block_id.as_str()].kind, UiKind::Scroll { .. }))
.collect();
// extent 保存尚未滚动的内容右下边界。滚动容器先扣除自身右/下 inset,
// 后续传播子内容后再加回 padding,避免把空容器的内边距算成可滚动溢出。
let mut extent: Vec<_> = frame
.mounts
.iter()
@@ -85,6 +101,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
(m.x + m.width - inset.right, m.y + m.height - inset.bottom)
})
.collect();
// Mount 按父先子后排列,反向扫描才能先汇总子树,再将边界向父级传播。
for i in (0..frame.mounts.len()).rev() {
let m = &frame.mounts[i];
if let Some(parent) = m.parent {
@@ -93,8 +110,10 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
nodes[frame.mounts[parent].block_id.as_str()].kind,
UiKind::CanvasViewport { .. } | UiKind::Viewport3D { .. }
) {
// 场景世界内部的坐标范围不扩大外部有限布局盒子的滚动范围。
continue;
}
// 子滚动容器向外只贡献自己的视口;它内部的溢出由自己的偏移处理。
let e = if scrolling[i] {
(m.x + m.width, m.y + m.height)
} else {
@@ -104,6 +123,8 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
extent[parent].1 = extent[parent].1.max(e.1);
}
}
// 第二阶段从父到子投影。上下文保存祖先累计位移和裁剪,而非当前节点本身的偏移。
// 重建绘制命令及其平行元数据,保证显示、命中与裁剪使用同一套投影坐标。
let mut contexts = HashMap::<u64, (f32, f32, Option<[f32; 4]>)>::new();
frame.commands.clear();
frame.command_clips.clear();
@@ -120,6 +141,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
UiKind::Scroll { axis, .. } => axis,
_ => Axis::Both,
};
// limits 在未改写的布局坐标里计算;禁用轴直接设零,内容比视口小时也为零。
let limits = [
if axis.horizontal() {
(extent[i].0 + padding.right - m.x - m.width).max(0.0)
@@ -132,10 +154,12 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
0.0
},
];
// 当前容器跟随祖先滚动;它自己的 scroll_offset 只影响子节点,容器边框保持原位。
m.x -= dx;
m.y -= dy;
m.clip = clip;
let mut child = (dx, dy, clip);
// 普通裁剪使用边框内侧矩形,并与祖先裁剪求交,不能扩大祖先允许的区域。
if node.clip_children {
let border = node.border;
let viewport = [
@@ -157,11 +181,14 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
let owner = vm
.upgrade(*weak)?
.ok_or_else(|| anyhow::anyhow!("expired scroll Mount"))?;
// weak upgrade 临时保活 Mount。先保存 payload 读取结果再 release,
// 这样读取失败也会先释放强引用,然后由下方 offset? 传播错误。
let offset = vm
.payload::<Mount>(owner, "Mount")
.map(|mount| mount.scroll_offset);
vm.release(owner)?;
let offset = offset?;
// 内容缩短时旧偏移可能超界;此处只钳制帧投影,不改写托管 Mount 的原偏移。
let x = offset[0].clamp(0.0, limits[0]);
let y = offset[1].clamp(0.0, limits[1]);
m.scroll = Some([x, y, limits[0], limits[1]]);
@@ -177,6 +204,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
),
);
}
// 3D 子场景有独立投影空间,不能继续叠加外层二维滚动坐标和裁剪。
if matches!(node.kind, UiKind::Viewport3D { .. }) {
child = (0., 0., None);
}
@@ -201,12 +229,14 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
Ok(())
}
/// 把源滚动值解码为有限坐标,阻止非数或越界值进入投影。
pub fn coordinate(value: &Value) -> Result<f32, VmError> {
let Value::Number(value) = value else {
return Err(VmError::InvalidArgument(
"scroll coordinates must be numbers",
));
};
// 通过 Number 文本表示转为 f32,再检查有限性及绝对值上限;允许负数以表达相对滚动。
let n = value
.to_string()
.parse::<f32>()
@@ -216,6 +246,7 @@ pub fn coordinate(value: &Value) -> Result<f32, VmError> {
}
Ok(n)
}
/// 更新指定出现的滚动偏移,并钳制到本轴实际可滚动的内容范围。
pub fn set(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
@@ -223,6 +254,7 @@ pub fn set(
point: [f32; 2],
relative: bool,
) -> Result<(), VmError> {
// 只允许已提交且仍挂接的出现接受滚动;布局过程中的临时 Mount 不具备稳定滚动边界。
let mut mount = cx.store().payload::<Mount>(owner, "Mount")?.clone();
if !mount.mounted || mount.layout.is_some() {
return Err(VmError::InvalidArgument(
@@ -245,10 +277,12 @@ pub fn set(
let desktop = cx
.upgrade(desktop)?
.ok_or(VmError::InvalidArgument("Mount window expired"))?;
// Check the normal mutation barrier even when the clamped result is equal.
// 即使钳制后数值未变,也走正常写屏障,使只读调用不能借助无效滚动绕过变更限制。
// scrollRequested 通知后续刷新重做投影,而不是在这里直接重建布局或绘制。
cx.replace_payload(owner, mount)?;
cx.set_attr(desktop, "scrollRequested", Value::Bool(true))
}
/// 沿祖先滚动容器调整偏移,让目标出现进入可见范围。
pub fn into_view(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
@@ -260,6 +294,7 @@ pub fn into_view(
"scrollIntoView requires committed attached Mount",
));
}
// rect 随祖先滚动调整,表示目标在当前投影中的可见需求;从最近父容器逐层向外处理。
let mut rect = [
mount.geometry.x,
mount.geometry.y,
@@ -273,6 +308,7 @@ pub fn into_view(
};
let mount = cx.store().payload::<Mount>(owner, "Mount")?.clone();
parent = mount.parent;
// 跨越独立场景边界时,外层只能滚动整个场景视口,不能使用内部世界坐标定位目标。
if mount.geometry.canvas_viewport || mount.geometry.scene3d_viewport {
rect = [
mount.geometry.x,
@@ -282,6 +318,7 @@ pub fn into_view(
];
}
if let Some(offset) = mount.geometry.scroll {
// 目标起点在视口前则向前对齐;否则只补足末端溢出,已完全可见时返回零。
let delta = |start: f32, length: f32, viewport: f32, size: f32| {
if start < viewport {
start - viewport
@@ -293,6 +330,7 @@ pub fn into_view(
let dy = delta(rect[1], rect[3], mount.geometry.y, mount.geometry.height);
let x = (offset[0] + dx).clamp(0.0, offset[2]);
let y = (offset[1] + dy).clamp(0.0, offset[3]);
// 下一层祖先看到的目标位置必须扣掉本层实际移动量,不能扣未钳制的请求量。
rect[0] -= x - offset[0];
rect[1] -= y - offset[1];
if x != offset[0] || y != offset[1] {
@@ -302,14 +340,18 @@ pub fn into_view(
}
Ok(())
}
/// 按逆出现顺序选择指针处可处理当前滚动方向的最内层容器。
pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64> {
let indices: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 逆序先取最上层命中的出现,再沿其祖先寻找能消费位移的滚动容器。
// 不搜索被覆盖的兄弟容器,内层到边界后才交给外层滚动。
let mut current = frame
.mounts
.iter()
.rev()
.find(|mount| mount.contains(point));
while let Some(mount) = current {
// 只有至少一轴钳制后真正改变偏移才接收此次输入;没有任何容器可移动则返回 None。
if mount.scroll.is_some_and(|s| {
(s[0] + delta[0]).clamp(0.0, s[2]) != s[0] || (s[1] + delta[1]).clamp(0.0, s[3]) != s[1]
}) {
+48
View File
@@ -1,23 +1,32 @@
//! 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。
//! RenderScope 约束递归深度;累计像素预算限制嵌套 3D 和世界 UI 的中间表面分配。错误向上传递,不把半幅结果当成成功帧呈现。
//!
use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
text::{self, TextStyle},
};
/// 显示列表的 CPU 执行入口,错误在原生 present 前向上传递。
pub struct SoftwareRenderer;
// 预算按渲染线程维护,递归 3D/面板渲染共用同一组计数;不同线程之间不共享额度。
thread_local! {
static RENDER_DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) };
static PIXEL_UNITS:std::cell::Cell<usize>=const { std::cell::Cell::new(0) };
}
/// 递归渲染的 RAII 深度保护,最外层还重置累计像素工作预算。
pub(crate) struct RenderScope;
impl RenderScope {
/// 进入一次嵌套渲染并检查深度上限;最外层调用重新开始累计像素预算。
pub fn enter() -> Result<Self, &'static str> {
RENDER_DEPTH.with(|depth| {
let current = depth.get();
// 拒绝第 19 层进入,不先增加深度;成功返回的 RenderScope 析构时才对应减一。
if current >= 18 {
return Err("render nesting budget exceeded");
}
// 只有最外层渲染开始才清累计像素数;递归进入不能借此重置预算。
if current == 0 {
PIXEL_UNITS.with(|pixels| pixels.set(0));
}
@@ -27,12 +36,15 @@ impl RenderScope {
}
}
impl Drop for RenderScope {
/// 退出一次像素渲染作用域时归还嵌套深度;像素工作量仍按本帧累计,不因退出内层而返还。
fn drop(&mut self) {
RENDER_DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
/// 累加本帧中间表面工作量,超过预算立即返回错误。
pub(crate) fn reserve_pixels(units: usize) -> Result<(), &'static str> {
PIXEL_UNITS.with(|pixels| {
// 累计申请工作量而非当前存活像素;释放一个中间表面不会返还额度,饱和加法避免溢出绕过限制。
let next = pixels.get().saturating_add(units);
if next > 16_777_216 {
return Err("nested render pixel budget exceeded");
@@ -43,12 +55,14 @@ pub(crate) fn reserve_pixels(units: usize) -> Result<(), &'static str> {
}
impl SoftwareRenderer {
/// 顺序执行显示列表并维护裁剪栈;3D 和面板可递归进入同一渲染预算域。
pub fn render(
list: &DisplayList,
target: &mut [u32],
width: u32,
height: u32,
) -> Result<(), &'static str> {
// RAII 保护让成功、提前返回和错误传播都恢复深度;target 长度须由调用方保证至少 width×height。
let _scope = RenderScope::enter()?;
let mut canvas = Canvas {
pixels: target,
@@ -61,6 +75,7 @@ impl SoftwareRenderer {
for command in list.commands() {
match command {
DrawCommand::Scene3D { rect, view } => {
// 先将 3D 矩形与当前裁剪和目标尺寸相交;完全不可见时连中间表面都不生成。
let left = rect.x.max(canvas.clip.x).max(0.).floor() as u32;
let top = rect.y.max(canvas.clip.y).max(0.).floor() as u32;
let right = (rect.x + rect.width)
@@ -76,24 +91,28 @@ impl SoftwareRenderer {
if left >= right || top >= bottom || rect.width <= 0. || rect.height <= 0. {
continue;
}
// 3D 中间表面的最长边限制到约 2048 像素,再采样到请求尺寸;相机宽高比仍用原 rect,避免改变透视比例。
let lod = (2048. / rect.width.max(rect.height)).min(1.);
let w = (rect.width * lod).ceil().max(1.) as u32;
let h = (rect.height * lod).ceil().max(1.) as u32;
let surface = crate::scene3d::surface(view, w, h, rect.width / rect.height)?;
for y in top..bottom {
for x in left..right {
// 按目标像素中心反算纹理坐标并钳到有效表面边界;只复制可见区域,不循环整个放大矩形。
let u = (((x as f32 + 0.5 - rect.x) / rect.width) * w as f32)
.clamp(0., (w - 1) as f32)
as u32;
let v = (((y as f32 + 0.5 - rect.y) / rect.height) * h as f32)
.clamp(0., (h - 1) as f32)
as u32;
// 3D 表面是最终 RGB 像素,直接覆盖目标;不走二维 source alpha 混合。
canvas.pixels[y as usize * width as usize + x as usize] =
surface[v as usize * w as usize + u as usize];
}
}
}
DrawCommand::PushClip(rect) => {
// 先保存旧裁剪,再求交;嵌套 PushClip 只能收窄,矩形分离时宽高归零。
clips.push(canvas.clip);
let x = canvas.clip.x.max(rect.x);
let y = canvas.clip.y.max(rect.y);
@@ -111,6 +130,7 @@ impl SoftwareRenderer {
);
}
DrawCommand::PopClip => {
// 多余 PopClip 在当前实现中被忽略;合法列表仍应由生成层保证配对。
if let Some(clip) = clips.pop() {
canvas.clip = clip;
}
@@ -145,6 +165,7 @@ impl SoftwareRenderer {
}
}
/// 借用目标像素切片并维护当前裁剪;图形/文字受裁剪,Clear 明确填满整个目标。
struct Canvas<'a> {
pixels: &'a mut [u32],
width: u32,
@@ -153,12 +174,16 @@ struct Canvas<'a> {
}
impl Canvas<'_> {
/// 用不透明 RGB 填满目标切片;此命令忽略当前裁剪和输入颜色 alpha。
fn clear(&mut self, color: Color) {
// Clear 填整个提供的目标切片,不受当前 clip 约束;pack 忽略 alpha,清屏色作为不透明 RGB。
self.pixels.fill(pack(color));
}
/// 只遍历裁剪后的矩形像素,按圆角覆盖决定填充区域。
fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) {
let bounds = self.clamped_bounds(rect);
// 半径钳到非负且不超过半宽/半高;后续用像素中心判定圆角内外,不做边缘面积覆盖积分。
let radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0);
for y in bounds.1..bounds.3 {
for x in bounds.0..bounds.2 {
@@ -169,8 +194,10 @@ impl Canvas<'_> {
}
}
/// 用外轮廓与内轮廓的差绘制边框,避免把边框当成整个矩形填充。
fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, width: f32, color: Color) {
let bounds = self.clamped_bounds(rect);
// 描边请求先至少一单位再限制为半宽/半高;inner 从四边缩入,最终绘制外轮廓减内轮廓。
let width = width.max(1.0).min(rect.width / 2.0).min(rect.height / 2.0);
let outer_radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0);
let inner = Rect::new(
@@ -194,7 +221,9 @@ impl Canvas<'_> {
}
}
/// 把文字原点转换为局部裁剪区域,交给有界字形绘制,再混合到目标缓冲。
fn draw_text(&mut self, origin: Point, value: &str, style: TextStyle, color: Color) {
// 将文字原点对齐整数像素;转换 clip 到字形局部空间,回调再加原点写回目标。
let x = origin.x.round() as i32;
let y = origin.y.round() as i32;
let clip = Rect::new(
@@ -208,10 +237,12 @@ impl Canvas<'_> {
});
}
/// 把段落矩形与当前裁剪相交,按受限宽度绘制,再恢复外层裁剪。
fn draw_wrapped_text(&mut self, rect: Rect, value: &str, style: TextStyle, color: Color) {
if rect.width <= 0.0 || rect.height <= 0.0 {
return;
}
// 段落文本临时收窄 clip 到文本框;下方调用完成后恢复 old,后续命令不能继承段落私有裁剪。
let old = self.clip;
let x = old.x.max(rect.x);
let y = old.y.max(rect.y);
@@ -233,6 +264,7 @@ impl Canvas<'_> {
value,
style,
color,
// 换行按完整段落宽度排版,而不是按被祖先裁剪后剩余的可见宽度重新排版。
Some(rect.width),
clip,
|dx, dy, pixel| self.blend_pixel(left + dx, top + dy, pixel),
@@ -240,7 +272,9 @@ impl Canvas<'_> {
self.clip = old;
}
/// 把连续矩形转换成目标缓冲内的整数像素范围,避免在不可见区域进行光栅循环。
fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) {
// 左/上 floor,右/下 ceil 形成保守循环范围;blend_pixel 还会按当前 clip 检查实际整数像素。
(
rect.x.max(self.clip.x).max(0.0).floor() as i32,
rect.y.max(self.clip.y).max(0.0).floor() as i32,
@@ -255,7 +289,9 @@ impl Canvas<'_> {
)
}
/// 在画布与当前裁剪范围内进行透明度混合,防止文字/图形写出目标缓冲。
fn blend_pixel(&mut self, x: i32, y: i32, source: Color) {
// 拒绝画布外、裁剪外与全透明像素;只有通过后才计算线性下标,避免负坐标转 usize。
if x < 0
|| y < 0
|| x >= self.width as i32
@@ -270,6 +306,7 @@ impl Canvas<'_> {
}
let index = y as usize * self.width as usize + x as usize;
// 全不透明像素可直接打包覆盖,省去目的通道解码与乘除法。
if source.alpha == 255 {
self.pixels[index] = pack(source);
return;
@@ -279,6 +316,7 @@ impl Canvas<'_> {
let dst_red = (destination >> 16) & 0xff;
let dst_green = (destination >> 8) & 0xff;
let dst_blue = destination & 0xff;
// 非预乘源颜色按 source*alpha + destination*(1-alpha) 合成,目标不存 alpha,逐通道整除 255。
let alpha = source.alpha as u32;
let inverse = 255 - alpha;
let red = (source.red as u32 * alpha + dst_red * inverse) / 255;
@@ -288,6 +326,7 @@ impl Canvas<'_> {
}
}
/// 测试一个像素采样点是否位于圆角矩形内,角区域使用圆弧距离。
fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
if rect.width <= 0.0 || rect.height <= 0.0 {
return false;
@@ -299,6 +338,8 @@ fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
// Clamp in local coordinates: adding a large origin to a shrinking tab's
// half-width can otherwise round the lower bound above the upper bound.
let radius = radius.min(rect.width / 2.0).min(rect.height / 2.0);
// 在局部坐标钳到圆角中心可达矩形,距离向量对应最近角圆心;中心带距离为零。
// 先减大原点可降低浮点舍入导致 clamp 上下界反转的风险。
let local_x = x - rect.x;
let local_y = y - rect.y;
let dx = local_x - local_x.clamp(radius, rect.width - radius);
@@ -306,7 +347,9 @@ fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
dx * dx + dy * dy <= radius * radius
}
/// 将颜色通道打包成目标缓冲的整数像素表示。
fn pack(color: Color) -> u32 {
// 像素格式为 0x00RRGGBB,高字节不存透明度;透明度已在混合阶段折算进 RGB。
(color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32
}
@@ -315,6 +358,7 @@ mod tests {
use super::*;
#[test]
/// 画长文字进入嵌套裁剪,逐像素检查多行与裁剪交集;PopClip后的蓝矩形验证后续命令不沿用旧clip。
fn wrapped_text_uses_multiple_lines_respects_nested_clips_and_restores_the_clip() {
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(255, 255, 255)));
@@ -339,6 +383,7 @@ mod tests {
});
let mut pixels = vec![0; 80 * 60];
SoftwareRenderer::render(&list, &mut pixels, 80, 60).unwrap();
// 确认文字确实换成多行且每个非背景文字像素都落在嵌套裁剪交集内,蓝色矩形验证裁剪已恢复。
let mut second_line = false;
for y in 0..60 {
for x in 0..80 {
@@ -354,6 +399,7 @@ mod tests {
}
#[test]
/// 在黑底4x4缓冲画2x2红矩形,核对内部像素变红、外部仍黑,覆盖最小坐标/打包路径。
fn renderer_changes_pixels_inside_a_rectangle() {
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
@@ -369,6 +415,7 @@ mod tests {
}
#[test]
/// 半透明白字叠黑底必须含中间抗锯齿值且通道不超128;alpha=0的文字完全不改像素。
fn text_edges_are_antialiased_and_respect_opacity() {
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
@@ -381,6 +428,7 @@ mod tests {
let mut pixels = vec![0; 256 * 48];
SoftwareRenderer::render(&list, &mut pixels, 256, 48).unwrap();
assert!(pixels.iter().any(|pixel| *pixel > 0));
// 白字 alpha=128 叠黑底,任何通道都不应超过 128;中间覆盖值证明字形边缘仍有抗锯齿。
assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128));
assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255))));
+35
View File
@@ -1,3 +1,6 @@
//! 通过真正的源/原生 hitTest 协议确定指针目标。
//! 按逆绘制顺序筛选候选,并在同一只读 VM 调用与预算中比较按下、释放目标;透明或未命中的子项可以继续回退。
//!
//! Resolve pointer targets in paint order through source/native hitTest methods.
//! Both press and release selectors share one read-only VM call and budget.
use crate::{mount::Mount, ui_bridge::attach_desktop_type};
@@ -7,13 +10,17 @@ use endor_core::{
};
#[derive(Debug)]
/// 验证 hitTest 返回的是 Bool,避免把任意真值隐式当成命中。
struct Boolean;
impl NativeCallable for Boolean {
/// 接收 hitTest 返回的唯一值,交给 invoke 严格检查 Bool。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 只接受真正的 Bool 并原样返回;数字或对象不能以宿主真值规则替代源协议契约。
fn invoke(&self, _: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 协议要求明确 Bool;拒绝数字、对象等“真值”,使用户实现错误立即暴露。
let [Value::Bool(value)] = args else {
return Err(VmError::InvalidArgument("hitTest must return Bool"));
};
@@ -21,13 +28,17 @@ impl NativeCallable for Boolean {
}
}
#[derive(Debug)]
/// 比较按下与释放的选择结果,只有同一目标才能继续点击分发。
struct SameTarget;
impl NativeCallable for SameTarget {
/// 接收按下与释放两次命中选择的结果。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(2))
}
/// 仅当两结果为同一 Mount 对象才返回目标,否则返回 Nil;共享 Block 的不同出现位置也不能合成点击。
fn invoke(&self, _: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 比较的是实际出现 Mount 的身份;即使共享同一个 Block,不同出现之间拖动也不构成点击。
let value = match args {
[Value::Object(first), Value::Object(second)] if first == second => {
Value::Object(*first)
@@ -37,12 +48,14 @@ impl NativeCallable for SameTarget {
Ok(NativeResult::Return(value))
}
}
/// 构造只读命中选择驱动所需的托管代码与函数对象。
fn code(
cx: &mut NativeContext<'_>,
instructions: Vec<Instruction>,
constants: Vec<Value>,
layout: CodeLayout,
) -> Result<ObjectRef, VmError> {
// 校验并编码指令,同时设置诊断位置;独立空 globals 避免驱动依赖业务全局命名空间。
let code = EndorCode::from_instructions(&instructions, constants, layout)?.with_location(
"<pointer hitTest>",
"<tessera>",
@@ -52,6 +65,7 @@ fn code(
let globals = cx.new_dict()?;
cx.new_function("pointer hitTest", code, globals)
}
/// 按逆绘制顺序构造候选 hitTest 调用,先进行几何与局部坐标筛选。
fn selector(
cx: &mut NativeContext<'_>,
candidates: &[WeakObjectRef],
@@ -65,11 +79,13 @@ fn selector(
))?;
let mut instructions = Vec::new();
let mut constants = vec![Value::Object(validate)];
// 候选按绘制顺序提供,逆序使最上层先参与测试;弱引用失效视为过期输入错误。
for weak in candidates.iter().rev() {
let mount = cx
.upgrade(*weak)?
.ok_or(VmError::InvalidArgument("pointer Mount expired"))?;
let target = cx.store().payload::<Mount>(mount, "Mount")?.clone();
// 先排除已卸载、非交互和几何不命中的出现,避免为明显无效候选构造源调用。
if !target.mounted
|| !target.geometry.interactive
|| !target
@@ -80,14 +96,17 @@ fn selector(
}
let geometry = &target.geometry;
let point = crate::geometry::Point::new(point[0], point[1]);
// 把投影坐标反算到节点逻辑空间;不可逆/无效投影没有可用于 hitTest 的局部坐标。
let Some(point) = geometry.logical_point(point) else {
continue;
};
// 源 Point 以节点左上为原点;这里再次验证范围,避免变换后的点落到节点盒子之外。
let x = point.x - geometry.x;
let y = point.y - geometry.y;
if x < 0.0 || y < 0.0 || x > geometry.width || y > geometry.height {
continue;
}
// 依据真实 Block 的名义类型解析 hitTest,不用原生矩形命中替代源协议。
let definition = cx
.store()
.type_of(target.block)?
@@ -100,6 +119,7 @@ fn selector(
attach_desktop_type(cx, local, "Point", desktop)?;
cx.set_attr(local, "x", Value::Number((x as f64).into()))?;
cx.set_attr(local, "y", Value::Number((y as f64).into()))?;
// 每个候选占四个常量:协议函数、Block、Mount、局部 Point;索引供下面字节码复用。
let start = constants.len() as u32;
constants.extend([
Value::Object(function),
@@ -107,12 +127,16 @@ fn selector(
Value::Object(mount),
Value::Object(local),
]);
// false 分支只跳过“加载本 Mount 并返回”两条指令。按编码宽度计算,
// 常量索引大到需要扩展操作数时仍能跳到下一候选。
let skip = (Instruction::encode_all(&[
Instruction::new(Opcode::LoadConst, start + 2),
Instruction::simple(Opcode::ReturnValue),
])?
.len()
/ 2) as u32;
// 先调用 hitTest(block, mount, local),再把结果传给 validate。
// true 立即返回本 Mount;false 继续下一个候选,实现透明区域向下穿透。
instructions.extend([
Instruction::new(Opcode::LoadConst, 0),
Instruction::simple(Opcode::PushNull),
@@ -128,6 +152,7 @@ fn selector(
Instruction::simple(Opcode::ReturnValue),
]);
}
// 所有候选均未命中时显式返回 Nil;不会把最后一个 false 当作选择结果。
let end = constants.len() as u32;
constants.push(Value::Nil);
instructions.extend([
@@ -137,6 +162,7 @@ fn selector(
code(cx, instructions, constants, CodeLayout::default())
}
#[derive(Debug)]
/// 在一个只读调用中准备两次指针选择所需的候选与局部坐标。
struct Prepare {
candidates: Vec<WeakObjectRef>,
press: [f32; 2],
@@ -144,11 +170,14 @@ struct Prepare {
desktop: ObjectRef,
}
impl NativeCallable for Prepare {
/// 候选、按下点和释放点已保存于适配器,无需外部实参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 为按下/释放各建立只读选择器,把两次结果放入同一 VM 调用序列,再交 SameTarget 比较实际出现身份。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 按下和释放分别构造选择器,但都在本次只读执行内调用,使用同一预算域。
let down = selector(cx, &self.candidates, self.press, self.desktop)?;
let up = selector(cx, &self.candidates, self.release, self.desktop)?;
let compare = cx.allocate(ContextNativeFunction::new(
@@ -156,6 +185,7 @@ impl NativeCallable for Prepare {
SameTarget,
vec![],
))?;
// 两次选择结果先存入局部槽,再作为两个实参交给 SameTarget;局部槽也保留中间结果生命周期。
let instructions = vec![
Instruction::new(Opcode::LoadConst, 0),
Instruction::simple(Opcode::PushNull),
@@ -185,6 +215,7 @@ impl NativeCallable for Prepare {
..CodeLayout::default()
},
)?;
// 把执行权交回 VM 运行驱动,不在宿主里递归开启另一次 VM 调用。
Ok(NativeResult::Invoke {
function: driver,
arguments: vec![],
@@ -192,6 +223,7 @@ impl NativeCallable for Prepare {
}
}
/// Returned Mount owns one root. No-hit and mismatched targets return None.
/// 在一次有界只读调用中分别选择按下与释放目标;仅二者为同一 Mount 时返回其拥有根。
pub fn resolve(
vm: &mut Vm,
desktop: ObjectRef,
@@ -199,6 +231,7 @@ pub fn resolve(
press: [f32; 2],
release: [f32; 2],
) -> anyhow::Result<Option<ObjectRef>> {
// 显式 captures 保活 desktop;候选列表仍是弱引用,不因等待选择而保活全部旧 Mount。
let prepare = vm.allocate(ContextNativeFunction::new(
"prepare pointer hitTest",
Prepare {
@@ -217,9 +250,11 @@ pub fn resolve(
max_instructions: 1_000_000,
},
);
// 先保存调用结果再释放入口根,因此 VM 返回错误时也能完成入口清理。
vm.release(prepare)?;
let (value, _) = result.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
match value {
// 成功返回的 Mount 仍拥有一次根引用,交由调用方使用并释放;None 不转移任何对象根。
Value::Object(mount) => Ok(Some(mount)),
Value::Nil => Ok(None),
value => {
+85
View File
@@ -1,3 +1,7 @@
//! 源 measure/arrange 协议的分片布局驱动器。自底向上的测量与自顶向下的安排共享一个只读 VM 调用域。
//! Plan 只保存派生几何和求解过程;Measurement 缓存记录真实读取依赖。已有业务对象在只读阶段不能被修改。
//! 较大树可在节点/指令量子之间挂起。Work 拥有尚未发布的执行状态,必须 resume 或 cancel;完成后才把 Draft 交给桥接层提交。
//!
//! Bottom-up measurement and top-down arrangement share one read-only VM call.
//! The host scratch plan contains derived data only, never a second business heap.
use crate::{
@@ -22,9 +26,11 @@ use std::{
// same per-occurrence VM driver so even intrinsic work can yield between nodes.
const NATIVE_FAST_PATH_MAX_NODES: usize = 64;
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 读取当前 desktop 的类型字典,为布局协议结果提供名义类型检查。
fn desktop_type_registry(cx: &NativeContext<'_>, desktop: ObjectRef) -> Result<ObjectRef, VmError> {
match cx.store().get_attr(desktop, "types")? {
Value::Object(object) => Ok(object),
@@ -32,7 +38,9 @@ fn desktop_type_registry(cx: &NativeContext<'_>, desktop: ObjectRef) -> Result<O
}
}
#[derive(Debug, Clone)]
/// 一次源测量的缓存输入、输出和真实读取依赖;不同约束或依赖版本不能复用。
struct Measurement {
// 缓存记录借用的业务身份及方法身份,与子测量、约束和依赖版本共同决定复用资格,不增加托管根。
owner: ObjectRef,
method: ObjectRef,
children: Vec<Size>,
@@ -42,12 +50,14 @@ struct Measurement {
}
/// Borrowed identities and versions only: this cache adds no managed roots.
#[derive(Debug, Default, Clone)]
/// 按 Mount 弱身份保存源测量结果,不为业务对象增加托管根。
pub struct Cache {
entries: HashMap<WeakObjectRef, Measurement>,
pub hits: usize,
pub misses: usize,
}
impl Cache {
/// 从测量缓存依赖中移除宿主私有 desktop 记账对象,防止内部同步造成自我失效。
pub(crate) fn exclude_host_desktops(&mut self, desktops: &HashSet<ObjectRef>) {
for entry in self.entries.values_mut() {
entry
@@ -55,10 +65,12 @@ impl Cache {
.retain(|read| !desktops.contains(&read.object()));
}
}
/// 移除受影响 owner 的源测量缓存;其他出现继续按其读取版本检查。
pub fn invalidate(&mut self, owners: &HashSet<ObjectRef>) {
self.entries
.retain(|_, entry| !owners.contains(&entry.owner));
}
/// 清空本对象维护的当前内容或缓存,后续状态由调用方重新建立。
pub fn clear(&mut self) {
self.entries.clear();
self.hits = 0;
@@ -66,7 +78,9 @@ impl Cache {
}
}
#[derive(Debug)]
/// 一个等待源测量返回的收集项,记录本次方法、约束和子项输入。
struct Pending {
// 只读子调用的精确读取跟踪区间 token,由 Step 开始、Collect 在结果解码后结束。
token: usize,
owner: ObjectRef,
method: ObjectRef,
@@ -75,10 +89,13 @@ struct Pending {
key: WeakObjectRef,
}
#[derive(Debug)]
/// 未发布的布局求解工作区,包含出现树、期望尺寸、分配边界、收敛记录和缓存。
struct Plan {
// 使用出现索引保存树和几何,不为每个位置建立第二份业务对象。
nodes: Vec<Arc<UiNode>>,
children: Vec<Vec<usize>>,
sizes: Vec<Size>,
// preferred 是测量偏好,bounds 是父级实际分配;换行/flex 会使两者不同。
preferred: Vec<Option<Size>>,
bounds: Vec<Option<[f32; 4]>>,
runtime: LayoutRuntime,
@@ -87,21 +104,25 @@ struct Plan {
pending: Vec<Option<Pending>>,
constraints: Vec<[f32; 4]>,
// Source-arranged dimensions remain exact for this solve, not business state.
// 每个出现两个轴的显式分配,Some 形成精确 min=max 约束;不是可写业务尺寸属性。
allocations: Vec<[Option<f32>; 2]>,
custom_arrange: Vec<bool>,
native_arrange: Vec<bool>,
custom_measure: Vec<bool>,
measured_limits: Vec<[Option<f32>; 2]>,
// 保存几何状态用于检测收敛和重复状态,防止相互依赖布局无限循环。
previous: Option<Vec<u32>>,
seen: BTreeSet<Vec<u32>>,
}
type Shared = Arc<Mutex<Plan>>;
#[derive(Debug, Clone, Copy)]
/// 源视觉调用当前处于测量还是安排阶段,决定调用方法与结果解码。
enum Phase {
Measure,
Arrange,
}
impl Phase {
/// 把当前布局阶段映射为 measure 或 arrange 方法名称。
fn method(self) -> &'static str {
match self {
Self::Measure => "measure",
@@ -110,6 +131,7 @@ impl Phase {
}
}
#[derive(Debug)]
/// 为某次出现准备一个 measure/arrange 调用,选择原生快速结果或真正的源覆盖。
struct Step {
plan: Shared,
index: usize,
@@ -118,10 +140,12 @@ struct Step {
desktop: ObjectRef,
}
impl NativeCallable for Step {
/// 单个布局步骤所需 Mount、阶段和计划已保存,实际调用无参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 解析当前实际 measure/arrange 实现,决定原生快速路径或源调用,准备本节点输入及依赖收集,避免跳过源覆盖。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
let mount = cx.store().payload::<Mount>(self.mount, "Mount")?.clone();
let definition = cx
@@ -132,6 +156,7 @@ impl NativeCallable for Step {
.store()
.lookup_method(definition, self.phase.method())?
.ok_or(invalid("missing layout method"))?;
// 判断真实宿主实现而非方法名;源覆盖即使调用基类也不能整体绕过其用户逻辑。
let intrinsic = crate::native_visual::is_intrinsic(
cx.store(),
method,
@@ -148,6 +173,7 @@ impl NativeCallable for Step {
.iter()
.map(|child| plan.sizes[*child])
.collect::<Vec<_>>();
// preferred 优先取本轮最初测量值,不能将上一轮 flex 分配当作新的自然基准,造成累计漂移。
let preferred = plan.children[self.index]
.iter()
.map(|child| plan.preferred[*child].unwrap_or(plan.sizes[*child]))
@@ -160,6 +186,7 @@ impl NativeCallable for Step {
if matches!(self.phase, Phase::Measure) {
plan.custom_measure[self.index] = !intrinsic;
}
// 原生 measure 直接计算避免源 VM 调用;源 measure 尝试按 Mount、owner、方法、子尺寸、约束和读取版本复用。
let native_size = if matches!(self.phase, Phase::Measure) && intrinsic {
let constraints = plan.constraints[self.index];
Some(
@@ -192,6 +219,7 @@ impl NativeCallable for Step {
})
})
.map(|entry| entry.size);
// 命中无需再打开读取跟踪;未命中保存 Pending 元信息,等待真正源调用结果返回才建立缓存。
if let Some(size) = cached {
plan.cache.hits += 1;
Some(size)
@@ -215,10 +243,12 @@ impl NativeCallable for Step {
if matches!(self.phase, Phase::Arrange) {
plan.runtime.arranged += 1;
plan.native_arrange[self.index] = intrinsic;
// 原生 arrange 若需要分配 flex/stretch 也视作需显式记录子分配,参与后面的约束收敛。
plan.custom_arrange[self.index] = !intrinsic || node.allocates_children();
}
(node, children, preferred, bounds, native_size)
};
// 先安装本轮节点、子测量和临时 bounds,让源代码调用基类或 Mount.bounds 时观察当前阶段数据。
let context = mount.layout.ok_or(invalid("missing pre-layout context"))?;
{
let mut context = context
@@ -229,6 +259,7 @@ impl NativeCallable for Step {
context.preferred = preferred;
context.bounds = bounds;
}
// 原生结果或缓存命中都包装为同一名义 MeasureResult,后续 Collect 无需区分来源。
if let Some(size) = native_size {
return Ok(NativeResult::Return(Value::Object(record(
cx,
@@ -273,6 +304,7 @@ impl NativeCallable for Step {
}
};
if matches!(self.phase, Phase::Measure) {
// 输入记录初始化完成后再开始子调用跟踪;Collect 解码返回值也在区间内,覆盖复用旧结果对象的字段读取。
let token = cx.store().begin_read_tracking()?;
self.plan
.lock()
@@ -293,6 +325,7 @@ impl NativeCallable for Step {
}
}
#[derive(Debug)]
/// 接收并检查源协议返回结果,将其合入 Plan,而不是直接写成发布帧。
struct Collect {
plan: Shared,
index: usize,
@@ -300,11 +333,14 @@ struct Collect {
desktop: ObjectRef,
}
impl NativeCallable for Collect {
/// 接收一次 measure/arrange 返回的托管结果,按当前 phase 解码。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 结束当前测量读跟踪并解码结果,验证尺寸或完整子槽安排,再更新计划与可复用测量依赖。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 取走本节点待收集状态,确保每个跟踪区间只结束一次;arrange 不建立测量缓存项。
let pending = if matches!(self.phase, Phase::Measure) {
self.plan
.lock()
@@ -321,6 +357,7 @@ impl NativeCallable for Collect {
Phase::Measure => "MeasureResult",
Phase::Arrange => "Arrangement",
};
// 先验证 MeasureResult/Arrangement 名义类型,再解码字段,拒绝只有同名字段的普通对象。
if !cx
.store()
.is_instance(*result, desktop_type(cx.store(), expected, self.desktop)?)?
@@ -333,6 +370,7 @@ impl NativeCallable for Collect {
.map_err(|_| invalid("layout plan poisoned"))?;
match self.phase {
Phase::Measure => {
// 源返回宽高先检查非负有限,再钳制到本出现约束,不允许结果绕过父级分配。
let size = layout::constrained(
Size {
width: number(cx, *result, "width", false)?,
@@ -343,6 +381,7 @@ impl NativeCallable for Collect {
// Native shrink-to-content containers keep their existing input
// policy. Only a source measurement that differs from the native
// result supplies an additional content constraint.
// 源容器测量若不同于默认原生结果,将相应轴记成额外内容上限;后续碰巧相等也不撤销本轮已选限制。
if plan.custom_measure[self.index] && !plan.children[self.index].is_empty() {
let children = plan.children[self.index]
.iter()
@@ -374,6 +413,7 @@ impl NativeCallable for Collect {
})
.transpose()?;
if let Some((pending, reads)) = tracked {
// 若观察了 Mount 内容/几何等不稳定宿主状态,无法证明跨轮可复用,保守地删除该测量缓存。
let mut volatile = false;
let registry = desktop_type_registry(cx, self.desktop)?;
for read in &reads.accesses {
@@ -405,6 +445,7 @@ impl NativeCallable for Collect {
.into_iter()
.filter(|dependency| {
let object = dependency.object();
// 缓存只保留仍存在的业务依赖,去除 desktop、注册表与 Mount;避免宿主提交几何自己反过来使缓存失效。
object != self.desktop
&& object != registry
&& cx.store().object(object).is_ok_and(|value| {
@@ -416,6 +457,7 @@ impl NativeCallable for Collect {
);
}
}
// 仅首次填写 preferred;sizes 每轮更新,根测量直接给本轮根 bounds,安排才能从根向下展开。
plan.preferred[self.index].get_or_insert(size);
plan.sizes[self.index] = size;
if self.index == 0 {
@@ -428,11 +470,13 @@ impl NativeCallable for Collect {
};
let items = object_list::items(cx.store(), list)?;
let children = plan.children[self.index].clone();
// 安排结果必须覆盖全部孩子,不能省略零尺寸项;结合下面 slot 唯一检查保证逐一对应。
if items.len() != children.len() {
return Err(invalid(
"Arrangement must assign every child slot exactly once",
));
}
// 按稳定 slot 找实际孩子下标,不要求源返回列表与孩子原顺序一致;外来或重复 slot 都拒绝。
let slots: HashMap<_, _> = plan.nodes[self.index]
.slots
.iter()
@@ -472,6 +516,7 @@ impl NativeCallable for Collect {
return Err(invalid("arrangement bounds exceed limits"));
}
let child = children[position];
// 源安排或原生弹性分配的差异保留为显式轴分配,下一轮测量使用精确尺寸,确保文字按实际宽度重排。
if plan.custom_arrange[self.index] {
if plan.native_arrange[self.index]
&& let Some(main) = layout::main_axis(&plan.nodes[self.index])
@@ -496,14 +541,17 @@ impl NativeCallable for Collect {
}
}
#[derive(Debug)]
/// 检查本轮布局是否已收敛,必要时安排下一次测量/分配迭代。
struct Settle {
plan: Shared,
mounts: Arc<[WeakObjectRef]>,
}
impl NativeCallable for Settle {
/// 一轮测量/排列完成后无参进入收敛检查。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 由新 bounds 推导下一轮约束与自然尺寸,比较完整布局状态;稳定则结束,循环状态报错,否则继续同帧求解。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
let repeat = {
let mut plan = self
@@ -511,6 +559,7 @@ impl NativeCallable for Settle {
.lock()
.map_err(|_| invalid("layout plan poisoned"))?;
plan.runtime.settle_passes += 1;
// 安排完成后按最终 bounds 从父到子推导下一轮约束,测量限制和源分配可反向影响换行高度。
let mut next = plan.constraints.clone();
for index in 0..plan.nodes.len() {
let children = plan.children[index].clone();
@@ -543,6 +592,7 @@ impl NativeCallable for Settle {
child_index,
);
let mut constraints = natural;
// 显式轴分配同时设置最小与最大,区别于仅给孩子一个可自由缩小的最大可用宽度。
if let Some(width) = plan.allocations[child][0] {
constraints[0] = width;
constraints[2] = width;
@@ -553,6 +603,7 @@ impl NativeCallable for Settle {
}
if let Some(main) = layout::main_axis(&plan.nodes[index]) {
let cross = 1 - main;
// 交叉轴变化可能改变换行及主轴自然高度,清 preferred 让其重新测量;原生派生主轴分配也需暂时撤销。
if constraints[cross] != plan.constraints[child][cross]
|| constraints[cross + 2] != plan.constraints[child][cross + 2]
{
@@ -573,6 +624,7 @@ impl NativeCallable for Settle {
}
}
let mut state = Vec::with_capacity(plan.nodes.len() * 14);
// 把约束、实测、bounds、分配和首选值编码成几何状态,统一 +0/-0,避免仅符号位差异造成假不收敛。
let bits = |value: f32| if value == 0.0 { 0 } else { value.to_bits() };
for (index, constraints) in next.iter().enumerate() {
state.extend(constraints.iter().copied().map(bits));
@@ -597,6 +649,7 @@ impl NativeCallable for Settle {
state.extend([0, 0]);
}
}
// 相邻两轮状态和约束均相同才停止;非相邻重复则是振荡环,不能把循环误当作稳定布局。
if plan.previous.as_ref() == Some(&state) && plan.constraints == next {
false
} else {
@@ -605,6 +658,7 @@ impl NativeCallable for Settle {
}
plan.previous = Some(state);
plan.constraints = next;
// 需要重算时清全部 bounds;下一轮测量不能访问上轮安排产生的陈旧几何。
plan.bounds.fill(None);
true
}
@@ -626,15 +680,18 @@ impl NativeCallable for Settle {
}
#[derive(Debug)]
/// 准备当前求解步骤所需的约束和 Mount 临时上下文。
struct Prepare {
plan: Shared,
mounts: Vec<WeakObjectRef>,
desktop: ObjectRef,
}
impl NativeCallable for Prepare {
/// 布局驱动的零参入口,Mount 列表/desktop/计划已由调用方捕获。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 建立按出现位置推进的 Advance 和固定字节码循环,保持同一 VM 预算,避免首次让出前创建整树临时调用对象。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
let advance = cx.allocate(ContextNativeFunction::new(
"next layout occurrence",
@@ -655,6 +712,7 @@ impl NativeCallable for Prepare {
}
#[derive(Debug)]
/// 在同一驱动帧中安排下一项布局工作,使大树能够分片让出。
struct Advance {
plan: Shared,
mounts: Arc<[WeakObjectRef]>,
@@ -662,11 +720,14 @@ struct Advance {
next: Mutex<usize>,
}
impl NativeCallable for Advance {
/// 由共享布局循环无参调用,游标表示本轮测量/排列的位置。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 按游标安排自底向上 measure 与自顶向下 arrange,短锁结束后才创建/执行当前节点调用,末尾进入收敛检查。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
let count = self.mounts.len();
// 游标取值/递增只在短锁内完成,不能持锁执行源协议,否则源调用基类会再次访问同一 Plan。
let position = {
let mut next = self
.next
@@ -678,6 +739,7 @@ impl NativeCallable for Advance {
}
position
};
// 每轮恰有 N 个逆序 measure 和 N 个正序 arrange,之后调用 Settle;需要重算时游标归零。
if position == count * 2 {
let settled = Settle {
plan: self.plan.clone(),
@@ -695,6 +757,7 @@ impl NativeCallable for Advance {
}
return Ok(NativeResult::Return(Value::Bool(repeat)));
}
// nodes 是先序,逆序测量保证孩子先完成,正序安排保证父 bounds 已经分配到孩子。
let (phase, index) = if position < count {
(Phase::Measure, count - position - 1)
} else {
@@ -703,6 +766,7 @@ impl NativeCallable for Advance {
let mount = cx
.upgrade(self.mounts[index])?
.ok_or(invalid("layout Mount expired"))?;
// 只为当前出现建立 Step 与 collector,显式 captures 保活本次 Mount/desktop,避免一次分配整棵调用树。
let step = cx.allocate(ContextNativeFunction::new(
"layout step",
Step {
@@ -724,6 +788,7 @@ impl NativeCallable for Advance {
},
vec![self.desktop],
))?;
// 执行一步并立即收集,再给外层 repeat_call 返回 true;各轮迭代共享原 VM 帧和预算。
let function = crate::vm_sequence::collect_and_continue(
cx,
collector,
@@ -741,6 +806,7 @@ impl NativeCallable for Advance {
/// An unpublished layout frame and derived caches. These are merged only after
/// the driver completes; they are not another copy of application state.
/// 已经完成本阶段、但尚未由桥接层发布的派生结果,包含帧/缓存或真实读取依赖。
pub struct Draft {
pub frame: Frame,
pub runtime: LayoutRuntime,
@@ -749,6 +815,7 @@ pub struct Draft {
}
#[must_use = "pending layout work must be resumed or cancelled with its owning VM"]
/// 本次推进处于挂起还是完成;挂起工作必须继续恢复或显式取消。
pub enum Progress {
Pending(Work),
Complete(Draft),
@@ -756,6 +823,7 @@ pub enum Progress {
/// Host-owned continuation. No borrowed frame/cache references cross a pause.
/// The prepare handle owns one root until completion or explicit cancellation.
/// 拥有未完成源调用及临时根的工作句柄;丢弃 Rust 值不能替代 VM 内的取消。
pub struct Work {
state: Draft,
plan: Shared,
@@ -765,13 +833,16 @@ pub struct Work {
}
impl Work {
/// 恢复当前 Work 拥有的 VM 续体,继续同一轮求解而不是重放 measure/arrange。
pub fn resume(mut self, vm: &mut Vm, slice: usize) -> Result<Progress> {
self.slices += 1;
let progress = vm.resume_read_only_call(slice);
self.accept(vm, progress)
}
/// 显式结束布局续体和只读域,释放本次尚未提交的临时根。
pub fn cancel(self, vm: &mut Vm) -> Result<()> {
// 取消与释放入口根均先尝试,再依次传播错误;普通 Rust drop 无法替代 VM 只读域清理。
let cancelled = vm.cancel_read_only_call();
let released = vm.release(self.prepare);
cancelled?;
@@ -779,6 +850,7 @@ impl Work {
Ok(())
}
/// 接收一次续体进度,挂起时保留工作,完成时将派生结果交给收尾阶段。
fn accept(
self,
vm: &mut Vm,
@@ -805,6 +877,7 @@ impl Work {
}
}
/// 建立只读布局 Plan 与托管驱动函数;小型原生树可走直接路径,大树保留分片推进能力。
pub fn start(
vm: &mut Vm,
desktop: ObjectRef,
@@ -824,9 +897,11 @@ pub fn start(
cache.hits = 0;
cache.misses = 0;
let live: HashSet<_> = mounts.iter().copied().collect();
// 先删除不再出现在此结构中的测量项,防止已移除的弱 Mount 身份无限积累。
cache.entries.retain(|mount, _| live.contains(mount));
// Preserve the existing native measurement cache and avoid VM/result
// allocation for small trees without custom layout methods. Contexts already exist.
// 扫描每个出现的实际 measure/arrange,实现全部为原生还不够,下面 flex/拉伸也会要求收敛驱动。
let mut custom = false;
for weak in &mounts {
let mount = vm
@@ -860,16 +935,19 @@ pub fn start(
}
// Flex allocation can change text wrapping and ancestor measurements even
// when every protocol method is native. Use the same settling driver.
// 仅小于等于64节点且无自定义/弹性分配的树走直接路径;大原生树仍经同一驱动以便分片让出。
if !custom && nodes.len() <= NATIVE_FAST_PATH_MAX_NODES {
cache.clear();
let mut native = runtime.frame(&root)?;
// Only the structural pass consumes a published version.
// 结构准备已经分配本次发布版本,快速完整测量不能再多消耗一个可见布局版本。
native.layout_version = frame.layout_version;
runtime.version = frame.layout_version;
*frame = native;
state.reads.clear();
return Ok(Progress::Complete(state));
}
// 帧结构、弱 Mount 和视觉节点必须同序同数量,否则按出现下标执行协议会把上下文接错。
anyhow::ensure!(
nodes.len() == mounts.len() && nodes.len() == frame.mounts.len(),
"layout structure does not match Mount contexts"
@@ -886,6 +964,7 @@ pub fn start(
children[indices[&parent]].push(index);
}
}
// 从根向下初始化约束,之后每轮由 Settle 结合真实父分配细化;初始值不是最终精确几何。
let mut constraints = vec![runtime.root_constraints(); nodes.len()];
for index in 0..nodes.len() {
for (child_index, child) in children[index].iter().enumerate() {
@@ -898,6 +977,7 @@ pub fn start(
}
}
let count = nodes.len();
// 把运行时/缓存克隆进未发布 Plan,失败或取消时上层可以丢弃派生求解结果而不提交半帧。
let plan = Arc::new(Mutex::new(Plan {
sizes: vec![Size::default(); nodes.len()],
preferred: vec![None; nodes.len()],
@@ -933,6 +1013,7 @@ pub fn start(
desktop,
slices: 1,
};
// 一次有界只读调用跨所有节点与收敛轮次;slice 只控制让出量,总指令预算仍共享一百万。
let progress = vm.start_read_only_call(
prepare,
&[],
@@ -945,12 +1026,14 @@ pub fn start(
work.accept(vm, progress)
}
/// 验证求解已收敛,收集最终几何、读取依赖和缓存,返回尚待桥接层提交的 Draft。
fn finish(
vm: &mut Vm,
mut work: Work,
value: Value,
reads: Vec<endor_core::ReadDependency>,
) -> Result<Progress> {
// 驱动返回值仅为控制流结果,先释放;真正结果保存在原生 Plan,而非返回值中的第二份业务图。
vm.release_value(value)?;
let mut plan = work
.plan
@@ -958,6 +1041,7 @@ fn finish(
.map_err(|_| anyhow::anyhow!("layout plan poisoned"))?;
plan.runtime.slices = work.slices;
let frame = &mut work.state.frame;
// 用最终收敛 bounds 更新 frame 的各出现;这里只生成默认内容命令,后续投影/源 paint 还会重建完整绘制表。
frame.commands.clear();
for (index, mount) in frame.mounts.iter_mut().enumerate() {
let [x, y, width, height] =
@@ -975,6 +1059,7 @@ fn finish(
}
work.state.runtime = plan.runtime.clone();
work.state.cache = plan.cache.clone();
// 大原生树可能使用分片驱动但没有真正源读取,因此无需把宿主驱动读取当业务布局依赖发布。
if !plan.custom {
work.state.reads.clear();
return Ok(Progress::Complete(work.state));
+65
View File
@@ -1,3 +1,7 @@
//! 执行源 paint 覆盖并解码受检查的 PaintCommand。
//! 所有绘制回调位于只读域;临时返回对象可分配,但已有模型不可写。命令数量、类型、坐标和颜色在合入帧前检查。
//! 源命令与原生命令按 Mount 绘制顺序合并,随后装配世界 UI 面板。挂起绘制同样需要显式恢复或取消。
//!
//! Source paint dispatch produces checked immutable commands. One read-only VM
//! resumable call sequences all overrides, sharing one instruction/frame budget.
use crate::{
@@ -17,9 +21,11 @@ use std::{
sync::{Arc, Mutex},
};
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
fn object(value: Value) -> Result<ObjectRef, VmError> {
if let Value::Object(object) = value {
Ok(object)
@@ -27,6 +33,7 @@ fn object(value: Value) -> Result<ObjectRef, VmError> {
Err(invalid("paint must return managed objects"))
}
}
/// 读取指定命令字段并要求其值为 Text;不隐式字符串化其他类型。
fn text(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<String, VmError> {
if let Value::Text(value) = store.get_attr(object, name)? {
Ok(value)
@@ -34,10 +41,12 @@ fn text(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<String, Vm
Err(invalid("paint field must be text"))
}
}
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
fn number(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<f32, VmError> {
let Value::Number(value) = store.get_attr(object, name)? else {
return Err(invalid("paint field must be a Number"));
};
// x/y 允许负数以表达相对原点的绘制;其余长度必须非负。所有数值都必须有限且在尺寸预算内。
let value = value
.to_string()
.parse::<f32>()
@@ -50,6 +59,7 @@ fn number(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<f32, VmE
}
Ok(value)
}
/// 从当前 desktop 的类型注册表取得绘制结果必须符合的托管类型。
fn named_type(store: &ObjectStore, desktop: ObjectRef, name: &str) -> Result<ObjectRef, VmError> {
let registry = object(store.get_attr(desktop, "types")?)?;
object(
@@ -58,17 +68,20 @@ fn named_type(store: &ObjectStore, desktop: ObjectRef, name: &str) -> Result<Obj
.ok_or(invalid("missing paint type"))?,
)
}
/// 把源方法返回的托管绘制值解码成受限 Rust 命令,检查名义类型、字段和绘制预算。
fn decode(
store: &ObjectStore,
command: ObjectRef,
expected: ObjectRef,
) -> Result<RenderCommand, VmError> {
// 使用当前 desktop 注册的 PaintCommand 名义类型;字段形状相似的普通对象也不能冒充命令。
if !store.is_instance(command, expected)? {
return Err(invalid("paint commands must be PaintCommand instances"));
}
let x = number(store, command, "x")?;
let y = number(store, command, "y")?;
let kind = text(store, command, "kind")?;
// 3D 命令使用不可变场景视图,不读取颜色文本;其他命令的颜色由最终 validate 统一校验。
let color = if kind == "scene3d" {
String::new()
} else {
@@ -76,6 +89,7 @@ fn decode(
};
let result = match kind.as_str() {
"scene3d" => {
// 这里仅接受宿主生成的 PaintView payload,克隆原生 Arc,不把活动的 VM 场景图传到绘制线程。
let snapshot = object(store.get_attr(command, "scene")?)?;
RenderCommand::Scene3D {
x,
@@ -92,6 +106,7 @@ fn decode(
let width = number(store, command, "width")?;
let height = number(store, command, "height")?;
let radius = number(store, command, "radius")?;
// 单项数值合法还不足以保证右/下边界合法;额外检查原点与尺寸相加后的范围。
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
return Err(invalid("paint rectangle exceeds bounds"));
}
@@ -118,9 +133,11 @@ fn decode(
}
"text" => {
let size = number(store, command, "size")?;
// size 是离散文字等级而非任意像素字号,只接受 1 到 8 的整数。
if size.fract() != 0.0 || !(1.0..=8.0).contains(&size) {
return Err(invalid("paint text size must be an integer from 1 to 8"));
}
// 两个换行边界必须同时出现或同时缺失;只缺一个时传播属性错误,防止半定义文本框。
let wrap_bounds = match (
store.get_attr(command, "wrapWidth"),
store.get_attr(command, "wrapHeight"),
@@ -143,6 +160,7 @@ fn decode(
}
_ => return Err(invalid("unsupported paint command kind")),
};
// 变体字段解码后仍走统一命令校验,覆盖颜色格式、文本以及组合几何合法性。
result
.validate()
.map_err(|_| invalid("invalid paint command geometry, text or color"))?;
@@ -150,22 +168,28 @@ fn decode(
}
#[derive(Debug, Default)]
/// 按出现 ID 收集已检查的源绘制覆盖,随后与原生默认命令合并。
struct PaintOutput {
// 按出现 ID 收集覆盖;同一个业务 Block 在多个 Mount 中可产生不同绘制结果。
commands: BTreeMap<u64, Vec<RenderCommand>>,
// 全轮源命令累计数,不能靠拆分多个源 DisplayList 绕过 10000 条总预算。
count: usize,
}
type Output = Arc<Mutex<PaintOutput>>;
#[derive(Debug)]
/// 检查源 paint 返回列表并将命令保存到当前出现的绘制结果。
struct Collect {
desktop: ObjectRef,
mount: u64,
output: Output,
}
impl NativeCallable for Collect {
/// 接收当前出现位置 paint 的唯一 DisplayList 结果。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 验证返回名义类型并完整解码所有 PaintCommand,检查全轮命令预算后才把该出现位置结果写入输出表。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Object(result)] = args else {
return Err(invalid("paint must return a DisplayList"));
@@ -176,12 +200,14 @@ impl NativeCallable for Collect {
)? {
return Err(invalid("paint must return a DisplayList"));
}
// 先验证外层 DisplayList,再取其受控 ObjectList;所有成员解码成功后才写入 output。
let commands = object(cx.store().get_attr(*result, "commands")?)?;
let expected = named_type(cx.store(), self.desktop, "PaintCommand")?;
let commands = object_list::items(cx.store(), commands)?
.iter()
.map(|command| decode(cx.store(), *command, expected))
.collect::<Result<Vec<_>, _>>()?;
// 仅收集结果时持锁,不在调用源 paint 期间持有结果锁;失败不会插入半个命令列表。
let mut output = self
.output
.lock()
@@ -195,17 +221,21 @@ impl NativeCallable for Collect {
}
}
#[derive(Debug)]
/// 为当前出现准备 paint 方法、几何和类型检查上下文。
struct Prepare {
desktop: ObjectRef,
mounts: Vec<(u64, WeakObjectRef)>,
output: Output,
}
impl NativeCallable for Prepare {
/// 零参数驱动入口,待绘制 Mount 和输出容器已保存在适配器。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 建立共享游标与固定外层字节码循环,让 VM 每轮只准备一个出现位置的 paint 调用。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 构造一个共享游标驱动;每轮只准备当前出现,避免一次保留所有出现的临时实参。
let advance = cx.allocate(ContextNativeFunction::new(
"next paint occurrence",
Advance {
@@ -225,6 +255,7 @@ impl NativeCallable for Prepare {
}
#[derive(Debug)]
/// 安排下一个出现的绘制调用,保持原始绘制顺序和共享 VM 预算。
struct Advance {
desktop: ObjectRef,
mounts: Arc<[(u64, WeakObjectRef)]>,
@@ -232,10 +263,13 @@ struct Advance {
output: Output,
}
impl NativeCallable for Advance {
/// 由外层循环无参调用,下一出现位置由内部游标决定。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 短锁内取并推进游标,随后验证/准备当前 Mount 的方法与几何;用临时调用链收集结果,完成后才进入下一位置。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 锁只保护取游标与递增;离开此块后释放锁,再做 VM 分配和源调用。
let occurrence = {
let mut next = self
.next
@@ -245,6 +279,7 @@ impl NativeCallable for Advance {
*next += usize::from(occurrence.is_some());
occurrence
};
// false 是 repeat_call 的停止信号;有出现时即使跳过原生方法也返回 true 继续遍历。
let Some((id, weak)) = occurrence else {
return Ok(NativeResult::Return(Value::Bool(false)));
};
@@ -263,9 +298,11 @@ impl NativeCallable for Advance {
.store()
.lookup_method(definition, "paint")?
.ok_or(invalid("missing paint method"))?;
// 原生默认 paint 留给 finish 生成;仅执行实际覆盖的源方法,避免重复绘制同一出现。
if crate::native_visual::is_intrinsic(cx.store(), function, "paint", self.desktop)? {
return Ok(NativeResult::Return(Value::Bool(true)));
}
// 把已提交 Mount 几何封装为本轮临时 Bounds;新分配参数可初始化,业务模型仍处于只读域。
let bounds = cx.new_object()?;
attach_desktop_type(cx, bounds, "Bounds", self.desktop)?;
for (name, value) in [
@@ -276,6 +313,7 @@ impl NativeCallable for Advance {
] {
cx.set_attr(bounds, name, Value::Number((value as f64).into()))?;
}
// 收集器只捕获桌面根和当前出现 ID;输出是原生共享容器,不增加业务图的强引用。
let collector = cx.allocate(ContextNativeFunction::new(
"collect source paint",
Collect {
@@ -285,6 +323,7 @@ impl NativeCallable for Advance {
},
vec![self.desktop],
))?;
// 辅助帧先执行 paint(block, mount, bounds),收集其结果,再返回 true 推进外层游标。
let function = crate::vm_sequence::collect_and_continue(
cx,
collector,
@@ -304,21 +343,27 @@ impl NativeCallable for Advance {
}
}
/// 已经完成本阶段、但尚未由桥接层发布的派生结果,包含帧/缓存或真实读取依赖。
pub struct Draft {
// 已经合并命令的原生帧;Draft 仍需桥接层提交,完成计算不等于发布成功。
pub frame: Frame,
// 保留源绘制的业务读取依赖,供下次写入后判定是否必须重绘。
pub reads: Vec<endor_core::ReadDependency>,
pub slices: usize,
}
#[must_use = "pending paint must be resumed or cancelled on its owning VM"]
/// 本次推进处于挂起还是完成;挂起工作必须继续恢复或显式取消。
pub enum Progress {
Pending(Box<Work>),
Complete(Box<Draft>),
}
/// Only unpublished native commands and an owning prepare root cross a pause.
/// 拥有未完成源调用及临时根的工作句柄;丢弃 Rust 值不能替代 VM 内的取消。
pub struct Work {
desktop: ObjectRef,
// 跨切片保留的入口根;完成或取消都必须在所属 VM 中显式释放。
prepare: ObjectRef,
output: Output,
root: Option<Arc<UiNode>>,
@@ -327,13 +372,17 @@ pub struct Work {
}
impl Work {
/// 继续当前绘制续体,保留其先前收集的命令和所属窗口上下文。
pub fn resume(mut self, vm: &mut Vm, slice: usize) -> Result<Progress> {
// 恢复保留原执行预算,slice 只决定本次让出的指令配额,不能重置整个绘制上限。
self.slices += 1;
let progress = vm.resume_read_only_call(slice);
self.accept(vm, progress)
}
/// 关闭未完成绘制续体并释放其临时根,不发布半完成命令列表。
pub fn cancel(self, vm: &mut Vm) -> Result<()> {
// 先执行取消和 release 两个操作,再依次传播错误;取消失败也仍尝试释放入口根。
let cancelled = vm.cancel_read_only_call();
let released = vm.release(self.prepare);
cancelled?;
@@ -341,12 +390,14 @@ impl Work {
Ok(())
}
/// 区分源绘制续体是挂起还是完成,保留工作或结束只读域并生成 Draft。
fn accept(
self,
vm: &mut Vm,
progress: Result<endor_core::ExecutionProgress, VmError>,
) -> Result<Progress> {
match progress {
// 挂起时工作句柄继续持有 frame/output/prepare,不能发布部分输出。
Ok(endor_core::ExecutionProgress::Pending { .. }) => {
Ok(Progress::Pending(Box::new(self)))
}
@@ -355,11 +406,13 @@ impl Work {
dependencies,
..
}) => {
// VM 完成后先释放入口与驱动返回值,再读取纯原生收集结果;返回值本身不作为绘制命令。
vm.release(self.prepare)?;
vm.release_value(value)?;
finish(vm, self, dependencies)
}
Err(error) => {
// 趁 VM 诊断信息仍可用先生成错误,再取消只读执行并清理临时根。
let diagnostic = crate::endor_host::runtime_error(vm, error);
self.cancel(vm)?;
Err(diagnostic)
@@ -368,6 +421,7 @@ impl Work {
}
}
/// 建立本轮只读绘制驱动和结果收集器;源覆盖与原生默认绘制最终保持同一出现顺序。
pub fn start(
vm: &mut Vm,
desktop: ObjectRef,
@@ -376,6 +430,7 @@ pub fn start(
frame: Frame,
slice: usize,
) -> Result<Progress> {
// 禁止零切片和嵌套只读/挂起调用;同一 VM 同时只能由一个续体驱动。
anyhow::ensure!(slice > 0, "paint slice must be positive");
anyhow::ensure!(
!vm.has_suspended_call() && !vm.object_store().is_read_only(),
@@ -411,12 +466,15 @@ pub fn start(
work.accept(vm, result)
}
/// 按 Mount 合并源覆盖和原生命令,补齐投影/裁剪表,再递归装配世界 UI 面板。
fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -> Result<Progress> {
let output = work
.output
.lock()
.map_err(|_| anyhow::anyhow!("paint output poisoned"))?;
// 是否有源覆盖以 map 是否为空判断;源方法返回空命令列表也算覆盖,会抑制该出现的默认绘制。
let native_only = output.commands.is_empty();
// 没有根时没有节点投影可合并,直接交回已有帧并清空绘制读取,不展开默认命令。
if work.root.is_none() {
return Ok(Progress::Complete(Box::new(Draft {
frame: work.frame,
@@ -427,6 +485,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
let root = work.root.as_deref();
let desktop = work.desktop;
let frame = &mut work.frame;
// 建立 Block ID 到不可变节点的索引;最终合并顺序仍以 frame.mounts 为准,不能按 map 排序。
let mut nodes = HashMap::new();
let mut pending: Vec<_> = root.into_iter().collect();
while let Some(node) = pending.pop() {
@@ -438,7 +497,9 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
let mut views = Vec::new();
let mut owners = Vec::new();
for mount in &frame.mounts {
// 记录本出现追加前的长度,用差值为新增命令同步扩展 clip/view/owner 三张平行表。
let before = commands.len();
// 有源覆盖则使用其整个结果(包括空列表);否则用节点和 Mount 几何生成原生默认命令。
if let Some(custom) = output.commands.get(&mount.id) {
commands.extend(custom.iter().cloned());
} else {
@@ -458,6 +519,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
clips.extend(std::iter::repeat_n(mount.clip, commands.len() - before));
views.extend(std::iter::repeat_n(mount.view, commands.len() - before));
owners.extend(std::iter::repeat_n(mount.id, commands.len() - before));
// 混合原生与源命令后再检查总预算,前面的源输出预算不能覆盖这里新增的默认命令。
if commands.len() > 10_000 {
bail!("paint command budget exceeded");
}
@@ -466,8 +528,11 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
frame.command_clips = clips;
frame.command_views = views;
frame.command_mounts = owners;
// 统一命令和投影元数据就绪后,再把世界 UI 面板装配进对应 3D 场景。
crate::scene3d::panel::compose(frame)?;
let registry = object(vm.get_attr(desktop, "types")?)?;
// 排除宿主桌面、类型注册表、Mount 以及已经死亡的临时身份,只保留业务读取。
// 纯原生帧不需要保留这次驱动的读取记录。
let reads = reads
.into_iter()
.filter(|dependency| {
+64
View File
@@ -1,3 +1,7 @@
//! 统一字体发现、文字整形、测量与抗锯齿字形绘制。
//! 测量与绘制使用同一字体系统和样式,避免布局宽度与屏幕文字不一致;中文与其他字符通过字体回退处理。
//! 整形和字形缓存有数量/字节预算;极端放大采用有界字形 LOD,最终只遍历裁剪区域内的像素。
//!
//! Shared font discovery, shaping, measurement and antialiased rasterization.
use std::{
collections::HashMap,
@@ -9,12 +13,14 @@ use cosmic_text::{Align, Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Sw
use crate::display_list::Color;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
/// 界面比例字体与源码等宽字体的选择,不直接指定某个必然安装的系统字体。
pub enum TextFamily {
Interface,
Code,
}
#[derive(Clone, Copy, Debug)]
/// 字号、行高和字体类别,测量与绘制必须使用兼容样式。
pub struct TextStyle {
pub font_size: f32,
pub line_height: f32,
@@ -22,6 +28,7 @@ pub struct TextStyle {
}
impl TextStyle {
/// 生成界面文字的基准字号与行高,size 是协议使用的字号倍率。
pub fn ui(size: u32) -> Self {
Self {
font_size: 14.0 * size as f32,
@@ -30,6 +37,7 @@ impl TextStyle {
}
}
/// 生成源码/Console 使用的等宽风格基础尺寸。
pub fn code() -> Self {
Self {
font_size: 13.0,
@@ -38,6 +46,7 @@ impl TextStyle {
}
}
/// 同比缩放字号与行高,保持设备比例变化后的排版比例一致。
pub fn scaled(self, scale: f32) -> Self {
Self {
font_size: self.font_size * scale,
@@ -48,38 +57,47 @@ impl TextStyle {
}
#[derive(Clone, Copy, Debug)]
/// 整形后实际占用的逻辑文字尺寸。
pub struct TextSize {
pub width: f32,
pub height: f32,
}
#[derive(Clone, Hash, PartialEq, Eq)]
/// 文字整形缓存键,包含文本、样式和可选换行宽度。
struct TextKey {
value: String,
// 用浮点原始位模式做键,精确区分整形参数;不是把字号取整为 u32。
font_size: u32,
line_height: u32,
family: TextFamily,
// None 表示不换行,Some 保存宽度的浮点位模式;同一文本不同宽度必须分开缓存。
wrap_width: Option<u32>,
}
/// 已整形 Buffer 与测量结果,后续测量/绘制复用同一布局。
struct PreparedText {
buffer: Buffer,
size: TextSize,
}
/// 共享字体系统、字形缓存和整形缓存;调用通过全局锁串行访问。
struct TextEngine {
fonts: FontSystem,
code_family: Option<String>,
swash: SwashCache,
buffers: HashMap<TextKey, PreparedText>,
// 累计缓存文本的 UTF-8 字节数,只衡量源文本预算,并非 Buffer/字形的总内存。
source_bytes: usize,
}
impl TextEngine {
/// 扫描系统字体,选择可用的界面/等宽字体偏好,并建立空整形和栅格缓存。
fn new() -> Self {
let mut fonts = FontSystem::new();
// Select installed, readable families rather than assuming an OS font
// name exists. COSMIC Text handles per-glyph fallback (including CJK).
// 按偏好顺序查询实际安装字体,找到第一个匹配名;找不到则由字体系统默认族处理。
let choose = |choices: &[&str]| {
choices.iter().find_map(|choice| {
fonts
@@ -114,11 +132,14 @@ impl TextEngine {
}
}
/// 对不指定换行宽度的文本复用统一整形准备过程。
fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey {
self.prepare_width(value, style, None)
}
/// 为文本、字体样式和可选换行宽度建立整形缓存,超过缓存预算先清理旧结果。
fn prepare_width(&mut self, value: &str, style: TextStyle, wrap_width: Option<f32>) -> TextKey {
// 键包含全部排版输入;命中后测量与绘制都复用同一 Buffer,避免两套排版产生偏差。
let key = TextKey {
value: value.to_owned(),
font_size: style.font_size.to_bits(),
@@ -130,6 +151,8 @@ impl TextEngine {
return key;
}
// Cache recent shaped strings, without retaining every submitted source.
// 采用整批清空而非 LRU;新增前达到条数或文本字节阈值时释放旧整形结果。
// 单个超大文本仍会被整形,source_bytes 不是对单次输入长度的硬拒绝。
if self.buffers.len() >= 256 || self.source_bytes + value.len() > 524_288 {
self.buffers.clear();
self.source_bytes = 0;
@@ -147,11 +170,13 @@ impl TextEngine {
&mut self.fonts,
Metrics::new(style.font_size, style.line_height),
);
// 有限宽度使用按词优先、字形兜底的换行策略,兼容中文和无空格长标识符;无宽度则不换行。
buffer.set_wrap(if wrap_width.is_some() {
Wrap::WordOrGlyph
} else {
Wrap::None
});
// 高度不设上限,让测量能得到所有换行后的完整内容高度,而非当前裁剪区域高度。
buffer.set_size(wrap_width, None);
buffer.set_text(
value,
@@ -159,7 +184,9 @@ impl TextEngine {
Shaping::Advanced,
Some(Align::Left),
);
// 整形采用高级脚本处理和左对齐;实际字形及回退由同一 FontSystem 决定。
buffer.shape_until_scroll(&mut self.fonts, false);
// 即使空文本没有布局 run 也保留一行高度;宽度取各行最大值,高度取最下方行底。
let mut size = TextSize {
width: 0.0,
height: style.line_height,
@@ -175,18 +202,23 @@ impl TextEngine {
}
}
/// 取得全局受锁保护的字体/整形引擎;共享缓存不等于可以并行修改字体状态。
fn engine() -> &'static Mutex<TextEngine> {
// 惰性创建全进程引擎;Mutex 同时保护字体库、整形缓存与栅格缓存,测量和绘制不能并发修改它。
static ENGINE: OnceLock<Mutex<TextEngine>> = OnceLock::new();
ENGINE.get_or_init(|| Mutex::new(TextEngine::new()))
}
/// 返回不受换行宽度约束时的字体整形尺寸。
pub fn measure(value: &str, style: TextStyle) -> TextSize {
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare(value, style);
engine.buffers[&key].size
}
/// 按可用宽度整形并测量多行高度,供布局与实际段落绘制共用。
pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
// 零或负可用宽度直接返回零宽与一行高,避免给整形器无效换行宽度。
if width <= 0.0 {
return TextSize {
width: 0.0,
@@ -201,6 +233,7 @@ pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
/// Rasterize only visible glyph pixels. Large camera zooms use a bounded glyph
/// bitmap and sample it at the requested size instead of allocating an enormous
/// font image. Layout and wrapping still use proportionally identical metrics.
/// 限制字形分辨率并只采样可见像素;保留文字布局比例,同时避免极端相机缩放产生巨型位图。
pub fn draw_clipped(
value: &str,
style: TextStyle,
@@ -216,9 +249,12 @@ pub fn draw_clipped(
{
return;
}
// 超大字号按比例降到最高 512 再栅格化;普通字号保持 1。
// 字体、行高和换行宽度一起缩小,后续用 1/lod 恢复目标几何比例。
let lod = (512. / style.font_size).min(1.);
let mut engine = engine().lock().expect("text engine lock");
let key = engine.prepare_width(value, style.scaled(lod), width.map(|width| width * lod));
// 拆借可变字段以便在保留 Buffer 借用时访问字体和 Swash 缓存,不复制整形结果。
let TextEngine {
fonts,
swash,
@@ -226,6 +262,7 @@ pub fn draw_clipped(
..
} = &mut *engine;
let buffer = &buffers[&key].buffer;
// 从已有 image_cache 统计位图字节;不包含整形 Buffer,也不含字体数据库本身。
let mut cache_bytes: usize = swash
.image_cache
.values()
@@ -237,6 +274,7 @@ pub fn draw_clipped(
let physical = glyph.physical((0., run.line_y), 1.);
// Conservative font-sized guard includes bearings/overhangs. The
// exact bitmap bounds below determine the actual visible pixels.
// 先用保守包围范围排除完全不可见字形,尽量避免生成裁剪区外的位图;精确边界由实际位图决定。
let guard = glyph.font_size * 4. / lod;
let gx = physical.x as f32 / lod;
let gy = physical.y as f32 / lod;
@@ -247,10 +285,12 @@ pub fn draw_clipped(
{
continue;
}
// 达到条数或约 32 MiB 时替换整个 SwashCache,再生成当前可见字形;预算不因缩放无限增长。
if swash.image_cache.len() >= 4096 || cache_bytes >= 33_554_432 {
*swash = SwashCache::new();
cache_bytes = 0;
}
// 只对新生成的图像增加字节统计;缓存中的生成失败项由 None 跳过,不参与绘制。
let cached = swash.image_cache.contains_key(&physical.cache_key);
let Some(image) = swash.get_image(fonts, physical.cache_key) else {
continue;
@@ -258,22 +298,28 @@ pub fn draw_clipped(
if !cached {
cache_bytes += image.data.len();
}
// 位图 placement 包含字形 bearing。顶部使用基线减 top,再除 lod 转回目标绘制空间。
let left = (physical.x + image.placement.left) as f32 / lod;
let top = (physical.y - image.placement.top) as f32 / lod;
let right = left + image.placement.width as f32 / lod;
let bottom = top + image.placement.height as f32 / lod;
// 可选字形颜色覆盖 RGB,但透明度始终采用调用方的整体 alpha。彩色位图分支会使用位图自己的 RGB。
let base = glyph.color_opt.map_or(color, |value| {
Color::rgba(value.r(), value.g(), value.b(), color.alpha)
});
// 循环范围取位图与 clip 交集,只遍历最终可见像素;ceil 与半像素采样共同决定整数像素覆盖。
for y in top.max(clip.y).ceil() as i32..bottom.min(clip.y + clip.height).ceil() as i32 {
for x in
left.max(clip.x).ceil() as i32..right.min(clip.x + clip.width).ceil() as i32
{
// 把目标像素中心反映射到低分辨率位图,再钳制到最后一个有效像素,避免边缘采样越界。
let u = (((x as f32 + 0.5 - left) * lod) as u32)
.min(image.placement.width.saturating_sub(1));
let v = (((y as f32 + 0.5 - top) * lod) as u32)
.min(image.placement.height.saturating_sub(1));
let index = (v * image.placement.width + u) as usize;
// 灰度 Mask 把覆盖率乘整体 alpha;Color 保留位图 RGB 并乘其 alpha。
// SubpixelMask 不适用此软件合成路径,直接跳过。
let sample = match image.content {
cosmic_text::SwashContent::Mask => Color::rgba(
base.red,
@@ -292,6 +338,7 @@ pub fn draw_clipped(
}
cosmic_text::SwashContent::SubpixelMask => continue,
};
// 只输出采样颜色,最终混合由调用方 pixel 完成;回调在引擎锁持有期间执行,不应重入文字引擎。
pixel(x, y, sample);
}
}
@@ -300,11 +347,13 @@ pub fn draw_clipped(
}
/// Fit a single displayed source line without slicing a UTF-8/grapheme cluster.
/// 按真实字体测量截断到给定宽度,以字素簇边界拼接省略号,避免切开组合字符。
pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
use unicode_segmentation::UnicodeSegmentation;
// A source line may include a very large comment. Only shape a bounded
// preview for the sidebar; the stored source and its AST spans stay intact.
// 仅预览截断至约 8 KiB 字素边界,不改保存的源码。单个超长字素也不能被拆开。
let truncated = value.len() > 8192;
let value = if truncated {
let end = value
@@ -317,6 +366,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
} else {
value
};
// 原文本未受预览截断且已能放下时原样返回;受截断文本即使较短也要追加省略号提示。
if !truncated && measure(value, style).width <= width {
return value.to_owned();
}
@@ -324,15 +374,18 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
if width <= 0.0 || measure(ellipsis, style).width > width {
return String::new();
}
// 保存每个完整字素簇的结束字节位置,后续切片不会拆开组合音标或连字 emoji。
let ends: Vec<_> = value
.grapheme_indices(true)
.map(|(start, part)| start + part.len())
.collect();
let mut low = 0;
let mut high = ends.len();
// 二分搜索可容纳的最长字素前缀;取上中位数使成功分支能推进 low,避免相邻边界时死循环。
while low < high {
let count = (low + high).div_ceil(2);
let end = ends[count - 1];
// 省略号也参与真实整形测量,不能简单从总宽度减去字符数估算。
let candidate = format!("{}{ellipsis}", &value[..end]);
if measure(&candidate, style).width <= width {
low = count;
@@ -340,6 +393,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
high = count - 1;
}
}
// 没有任何字素能放下时只返回省略号;更早已经检查省略号本身可容纳。
let end = low.checked_sub(1).map_or(0, |index| ends[index]);
format!("{}{ellipsis}", &value[..end])
}
@@ -350,7 +404,9 @@ mod tests {
use unicode_segmentation::UnicodeSegmentation;
#[test]
/// 用一万倍缩放检验绘制回调被限制在32x32裁剪内,同时检查字形缓存字号不超过512以控制中间资源。
fn extreme_zoom_rasterizes_only_the_clip_with_bounded_glyph_sizes() {
// 极端缩放测试限制实际回调在 32×32 裁剪区,同时检查缓存字号不超过 512。
let mut count = 0;
draw_clipped(
"M",
@@ -376,8 +432,10 @@ mod tests {
}
#[test]
/// 混合中文、单词、长标识符、组合字符和emoji,检查窄宽约束分别换行/缓存,且不污染无约束自然测量。
fn width_sensitive_shaping_wraps_words_and_clusters_without_changing_intrinsic_metrics() {
let style = TextStyle::ui(1);
// 混合中文、长英文词、组合字符和 emoji,验证窄/宽换行缓存隔离且不改变无换行测量结果。
let text = "中文段落 with words and long_unbroken_identifier é 👩‍💻 中文段落";
let intrinsic = measure(text, style);
let narrow = measure_wrapped(text, style, 70.0);
@@ -395,7 +453,9 @@ mod tests {
}
#[test]
/// 比较比例字体宽窄字母、等宽字体、多行高度与缩放比例,证明尺寸来自字体整形而非按字符数估计。
fn proportional_metrics_monospace_and_multiline_height_come_from_the_fonts() {
// 用宽窄拉丁字母验证比例字体差异、等宽字体一致性、多行行高和同比例缩放。
let ui = TextStyle::ui(1);
assert!(measure("WWWW", ui).width > measure("iiii", ui).width * 2.0);
let mono = TextStyle::code();
@@ -406,8 +466,10 @@ mod tests {
}
#[test]
/// 普通和代码字体都需为四个中文字符产生非零glyph_id;依赖测试环境安装可用CJK回退字体。
fn system_fallback_shapes_chinese_without_missing_glyphs() {
let mut engine = engine().lock().unwrap();
// 两种字体风格都要求四个中文字符生成非零 glyph_id,确保系统回退实际找到了字形。
for style in [TextStyle::ui(1), TextStyle::code()] {
let key = engine.prepare("中文字体", style);
let glyphs: Vec<_> = engine.buffers[&key]
@@ -422,6 +484,7 @@ mod tests {
}
#[test]
/// 按实际测量宽度截断带组合字符的文本,结果需是原文字素边界前缀加省略号,且整体不超宽。
fn ellipsis_fits_actual_width_and_preserves_graphemes() {
let style = TextStyle::code();
let short_source = "let s = ui.text(\"中文字体\")";
@@ -429,6 +492,7 @@ mod tests {
let value = "e\u{301}e\u{301}中文字体e\u{301}e\u{301}";
let width = measure("e\u{301}e\u{301}中…", style).width + 0.01;
let fitted = fit(value, width, style);
// 检查结果前缀确实来自原文且结束于字素边界,而不仅是合法 UTF-8 字节边界。
let prefix = fitted.strip_suffix('…').unwrap();
assert!(value.starts_with(prefix));
assert!(
+49 -2
View File
@@ -1,3 +1,6 @@
//! 独立 2D 相机与可逆投影。视口尺寸属于布局;平移、缩放属于视图,不改变世界对象的逻辑位置。
//! project 为每次出现附加组合变换、裁剪和嵌套面板映射;绘制与命中共用这些结果。fit 根据有限内容边界调整指定相机。
//!
//! Reversible projections keep camera movement independent of layout.
pub mod spatial;
use crate::geometry::{Point, Rect};
@@ -6,21 +9,27 @@ use endor_core::{ObjectPayload, ObjectRef, ObjectStore, VmError};
/// Marker payload gives cameras a native nominal identity while their fields
/// remain ordinary traced, bindable VM attributes.
#[derive(Debug)]
/// 托管 2D 相机的名义标记,实际位置/缩放是被追踪的普通属性。
pub struct Camera2D;
impl ObjectPayload for Camera2D {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"camera-2d"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, _: &mut dyn FnMut(ObjectRef)) {}
}
#[derive(Debug, Clone, PartialEq)]
/// 从 VM 提取的独立相机数值描述,渲染与布局投影不直接读取托管堆。
pub struct CameraView {
pub identity: String,
pub x: f32,
@@ -28,11 +37,14 @@ pub struct CameraView {
pub zoom: f32,
}
/// 读取并校验托管 Camera2D 的位置和缩放,生成原生投影描述。
pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result<CameraView, VmError> {
// 先验证实际宿主 payload,避免只有 x/y/zoom 同名字段的普通对象冒充相机。
store.payload::<Camera2D>(object, "Camera2D")?;
let x = crate::scroll::coordinate(&store.get_attr(object, "x")?)?;
let y = crate::scroll::coordinate(&store.get_attr(object, "y")?)?;
let zoom = crate::scroll::coordinate(&store.get_attr(object, "zoom")?)?;
// coordinate 已检查有限性与绝对范围;zoom 还要严格为正并位于专门相机范围,保证可逆变换。
if !(0.01..=100.0).contains(&zoom) {
return Err(VmError::InvalidArgument(
"Camera2D.zoom expects 0.01 through 100",
@@ -48,10 +60,12 @@ pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result<CameraView, VmEr
/// Fit finite scene bounds using this occurrence's allocation. Sharing a camera
/// is explicit, so fitting one view then intentionally affects all its users.
/// 根据这个 CanvasViewport 出现的有限内容范围调整其相机;显式共享相机会同步影响其他视口。
pub fn fit(
cx: &mut endor_core::NativeContext<'_>,
mount: &crate::mount::Mount,
) -> Result<(), VmError> {
// 只接受已提交的 CanvasViewport,布局期间的临时几何不能作为 fitView 分配尺寸依据。
if !mount.geometry.canvas_viewport || mount.layout.is_some() {
return Err(VmError::InvalidArgument(
"fitView requires a committed CanvasViewport Mount",
@@ -72,6 +86,7 @@ pub fn fit(
.store()
.payload::<crate::mount::Mount>(root, "Mount")?
.clone();
// 只汇总场景直接子出现的有限边界,沿 sibling 链扫描;不把无限世界或整棵后代图作为布局盒。
let mut next = scene.first_child;
let mut bounds: Option<[f32; 4]> = None;
let mut count = 0;
@@ -84,12 +99,14 @@ pub fn fit(
.upgrade(weak)?
.ok_or(VmError::InvalidArgument("expired scene child"))?;
let child = cx.store().payload::<crate::mount::Mount>(object, "Mount")?;
// 先扣 scene 原点,把孩子绝对布局坐标转为场景相对坐标;此数组存左上/右下,而非宽高。
let rect = [
child.geometry.x - scene.geometry.x,
child.geometry.y - scene.geometry.y,
child.geometry.x - scene.geometry.x + child.geometry.width,
child.geometry.y - scene.geometry.y + child.geometry.height,
];
// 逐个扩展共同包围框,空场景保持 None 并在后面回到单位相机。
bounds = Some(bounds.map_or(rect, |b| {
[
b[0].min(rect[0]),
@@ -102,13 +119,16 @@ pub fn fit(
}
let node = crate::block::snapshot(cx.store(), mount.block)?;
let inset = crate::layout::insets(&node);
// 扣除布局 inset 后才是相机可用的内容区域;负值归零,避免用边框占用部分计算缩放。
let width = (mount.geometry.width - inset.horizontal()).max(0.0);
let height = (mount.geometry.height - inset.vertical()).max(0.0);
let (x, y, zoom) = if let Some(bounds) = bounds {
// 两个轴分别计算可容纳倍率再取较小者;内容跨度增加 48 世界单位形成留白,最终钳到相机范围。
let zoom = (width / (bounds[2] - bounds[0] + 48.0))
.min(height / (bounds[3] - bounds[1] + 48.0))
.clamp(0.01, 100.0);
(
// 让内容中心落在视口中心:相机位置 = 内容中心 - 半个视口的世界尺寸。
(bounds[0] + bounds[2]) / 2.0 - width / (2.0 * zoom),
(bounds[1] + bounds[3]) / 2.0 - height / (2.0 * zoom),
zoom,
@@ -116,6 +136,7 @@ pub fn fit(
} else {
(0.0, 0.0, 1.0)
};
// 最终通过普通属性写入走 VM 变更追踪/绑定机制;共享该相机的其他视口也会观察到更新。
for (name, value) in [("x", x), ("y", y), ("zoom", zoom)] {
cx.set_attr(
camera,
@@ -127,12 +148,14 @@ pub fn fit(
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
/// 可组合的均匀缩放与平移;正向绘制和逆向命中必须使用同一结果。
pub struct Transform {
pub zoom: f32,
pub x: f32,
pub y: f32,
}
impl Default for Transform {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
zoom: 1.0,
@@ -142,15 +165,19 @@ impl Default for Transform {
}
}
impl Transform {
/// 将逻辑点按当前缩放和平移变换到外层视图。
pub fn point(self, point: Point) -> Point {
Point::new(point.x * self.zoom + self.x, point.y * self.zoom + self.y)
}
/// 将视图点反算回逻辑坐标,供命中和手势锚点计算使用。
// 不在这里额外校验 zoom,调用方必须先保证 valid;零缩放无法反算。
pub fn inverse(self, point: Point) -> Point {
Point::new(
(point.x - self.x) / self.zoom,
(point.y - self.y) / self.zoom,
)
}
/// 使用同一缩放和平移转换矩形的位置与尺寸。
pub fn rect(self, rect: Rect) -> Rect {
let point = self.point(Point::new(rect.x, rect.y));
Rect::new(
@@ -160,6 +187,8 @@ impl Transform {
rect.height * self.zoom,
)
}
/// 将当前变换再放入外层变换,保持绘制与逆向命中的组合顺序一致。
// 组合为 outer(self(point)):内层平移也要乘外层缩放,不能只把两次平移相加。
pub fn then(self, outer: Self) -> Self {
Self {
zoom: self.zoom * outer.zoom,
@@ -167,7 +196,9 @@ impl Transform {
y: self.y * outer.zoom + outer.y,
}
}
/// 验证累计二维变换:zoom 有限且在 (0,1000000],平移有限且绝对值不超过一万亿。
pub fn valid(self) -> bool {
// 允许累计平移远大于单个世界坐标范围,因为相机偏移还会乘缩放;同时限制嵌套累计放大。
self.zoom.is_finite()
&& self.zoom > 0.0
&& self.zoom <= 1_000_000.0
@@ -178,7 +209,9 @@ impl Transform {
}
}
/// 计算视口与祖先裁剪的共同可见矩形。
fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
// clip 用 [x,y,width,height],分离的矩形返回零面积,不产生负宽高。
let x = a[0].max(b[0]);
let y = a[1].max(b[1]);
[
@@ -189,6 +222,7 @@ fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
]
}
/// 遍历出现树,组合相机与父视图变换,建立裁剪和世界 UI 映射,最后重建屏幕命中索引。
pub fn project(
root: &crate::ui_bridge::UiNode,
frame: &mut crate::document::Frame,
@@ -202,12 +236,14 @@ pub fn project(
nodes.insert(node.block_id.as_str(), node);
work.extend(node.children().iter().map(|node| node.as_ref()));
}
// 每个出现向孩子传递累计二维变换、当前空间的裁剪和嵌套面板映射;业务布局坐标不被改写。
type Scope = (
Transform,
Option<[f32; 4]>,
Option<Arc<crate::scene3d::panel::Mapping>>,
);
let mut contexts = HashMap::<u64, Scope>::new();
// 保留 next_sibling 查找表,遍历可变 Mount 时仍可按稳定子出现顺序为面板根建立映射。
let next: HashMap<_, _> = frame
.mounts
.iter()
@@ -217,6 +253,7 @@ pub fn project(
HashMap::<u64, (Arc<crate::scene3d::panel::Mapping>, crate::layout::Size)>::new();
for mount in &mut frame.mounts {
let node = nodes[mount.block_id.as_str()];
// 面板根进入独立纹理像素空间,二维变换重置为单位,裁剪重置为面板尺寸;普通节点沿用父上下文。
let (view, clip, panel) = if let Some((panel, size)) = panels.get(&mount.id) {
(
Transform::default(),
@@ -233,6 +270,7 @@ pub fn project(
view.valid(),
"nested viewport transform exceeds numeric budget"
);
// 单位变换省略存储,其余保持为显式 Transform;每轮先清理旧 viewport_clip,避免残留上帧状态。
mount.view = (view != Transform::default()).then_some(view);
mount.panel = panel.clone();
mount.clip = clip;
@@ -243,6 +281,7 @@ pub fn project(
let viewport = if let UiKind::CanvasViewport { camera, .. } = &node.kind {
let x = mount.x + inset.left;
let y = mount.y + inset.top;
// 围绕视口内容原点建立相机变换:原点保持锚定,世界内容先减相机位置再缩放,最后套父变换。
child_view = Transform {
zoom: camera.zoom,
x: x * (1.0 - camera.zoom) - camera.x * camera.zoom,
@@ -262,6 +301,7 @@ pub fn project(
(mount.width - inset.horizontal()).max(0.),
(mount.height - inset.vertical()).max(0.),
))
// 普通节点的 clip_children 裁到边框内侧,独立视口裁内容盒,Scroll 默认裁整个滚动盒。
} else if node.clip_children {
let border = node.border;
Some(Rect::new(
@@ -276,6 +316,7 @@ pub fn project(
None
};
if let Some(rect) = viewport {
// 本视口边界按父变换投影,不能套当前相机的 child_view,否则相机会连自己的视口框一起移动。
let rect = view.rect(rect);
let rect = [rect.x, rect.y, rect.width, rect.height];
child_clip = Some(clip.map_or(rect, |clip| intersect(clip, rect)));
@@ -289,6 +330,7 @@ pub fn project(
..
} = &node.kind
{
// 沿面板映射链限制嵌套深度,防止递归纹理装配和输入逆投影无限展开。
let mut depth = 0;
let mut ancestor = panel.clone();
while let Some(mapping) = ancestor {
@@ -297,6 +339,7 @@ pub fn project(
}
anyhow::ensure!(depth < 8, "3D UI viewport nesting exceeds 8 surfaces");
let mut child = mount.first_child;
// slots、面板尺寸和直接子出现按同序配对;缺少预期子出现时立即报错,不能把内容映射到错误面板。
for (key, size) in node.slots.iter().zip(sizes) {
let id = child.ok_or_else(|| anyhow::anyhow!("missing 3D UI root occurrence"))?;
let mapping = Arc::new(crate::scene3d::panel::Mapping {
@@ -313,6 +356,7 @@ pub fn project(
clip: mount.viewport_clip,
parent: panel.clone(),
});
// 记录待遍历面板根的新上下文;之后走到该孩子时切换纹理空间,不把外层 clip 误当纹理像素。
panels.insert(id, (mapping, *size));
child = next[&id];
}
@@ -320,6 +364,7 @@ pub fn project(
contexts.insert(mount.id, (child_view, child_clip, panel));
}
let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 按每条命令的 Mount 归属重建 view/clip,确保源绘制、默认绘制和输入都使用这一轮最终投影。
frame.command_views.clear();
frame.command_clips.clear();
anyhow::ensure!(
@@ -333,6 +378,7 @@ pub fn project(
frame.command_views.push(mount.view);
frame.command_clips.push(mount.clip);
}
// 几何、裁剪和视图信息全部就绪后才建立屏幕宽阶段索引,避免查询旧投影候选。
frame.hit_index = std::sync::Arc::new(spatial::Index::build(&frame.mounts));
Ok(())
}
@@ -340,9 +386,9 @@ pub fn project(
mod tests {
use super::*;
#[test]
/// 以实际两次点变换核对嵌套组合顺序、逆变换和矩形尺寸,并允许大相机投影平移而非套用较小世界坐标限额。
fn nested_views_compose_and_invert_without_changing_layout_coordinates() {
// Camera coordinates may be +/-1e6 at zoom 100. Their projected
// translation must not inherit the smaller world-coordinate limit.
// 相机坐标可达正负一百万,缩放100后投影平移可达一亿;不能继续使用较小的世界坐标限制。
assert!(
Transform {
zoom: 100.,
@@ -362,6 +408,7 @@ mod tests {
y: 20.0,
};
let point = Point::new(-5.0, 100.0);
// 以两次实际点变换验证组合顺序,再检查逆变换和矩形尺寸,避免只验证某一种公式写法。
let composed = inner.then(outer);
assert_eq!(composed.point(point), outer.point(inner.point(point)));
assert_eq!(composed.inverse(composed.point(point)), point);
+22
View File
@@ -1,15 +1,23 @@
//! 有界屏幕网格,用来减少普通二维命中和相机目标查询的候选。
//! 巨大对象和 3D 面板进入后备集合,再由精确命中判断;索引预算不足不能导致遗漏目标。反序列化或索引长度不匹配时可退回完整候选。
//!
//! Bounded screen-space broad phase. Projective world UI uses the precise
//! ray-based narrow phase rather than an unsafe screen rectangle approximation.
use crate::{document::MountGeometry, geometry::Point};
use std::collections::HashMap;
#[derive(Debug, Default)]
/// 有限屏幕格子和后备集合。候选筛选允许保守多取,但不能因索引预算漏掉有效命中。
pub struct Index {
// 建立索引时的 Mount 总数,用来发现明显过期的索引;数量相同不代表几何未改变。
count: usize,
// 格子坐标映射到原 mounts 切片下标,保留原始绘制顺序信息,不持有托管对象。
cells: HashMap<(i32, i32), Vec<usize>>,
// 不能安全装入网格的候选,每次查询都参与;由后续精确 contains 排除误命中。
fallback: Vec<usize>,
}
impl Index {
/// 从帧几何构建 128 单位屏幕网格;大对象、投影面板和超预算项进入保守后备集合。
pub fn build(mounts: &[MountGeometry]) -> Self {
let mut result = Self {
count: mounts.len(),
@@ -17,6 +25,7 @@ impl Index {
};
let mut entries = 0;
for (index, mount) in mounts.iter().enumerate() {
// 只有可交互项、场景视口或滚动容器需要参与输入目标宽阶段,其余外观节点跳过。
if !mount.interactive
&& !mount.canvas_viewport
&& !mount.scene3d_viewport
@@ -24,16 +33,19 @@ impl Index {
{
continue;
}
// 3D 面板使用射线投影,不能把局部矩形当成屏幕 AABB 安全近似,统一交给精确阶段。
if mount.panel.is_some() {
result.fallback.push(index);
continue;
}
// 普通二维节点取两对角点并应用视图变换,再排序上下界,兼容坐标方向变化。
let a = Point::new(mount.x, mount.y);
let b = Point::new(mount.x + mount.width, mount.y + mount.height);
let a = mount.view.map_or(a, |view| view.point(a));
let b = mount.view.map_or(b, |view| view.point(b));
let (mut x0, mut y0, mut x1, mut y1) =
(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
// 先与有效裁剪求交,缩小入网范围;完全不可见的矩形不需要成为候选。
if let Some([x, y, w, h]) = mount.clip {
x0 = x0.max(x);
y0 = y0.max(y);
@@ -43,6 +55,7 @@ impl Index {
if x1 < x0 || y1 < y0 {
continue;
}
// 极端或非有限坐标不做网格展开,转后备集合;不能因转换溢出而漏掉待精确检查的目标。
if ![x0, y0, x1, y1]
.iter()
.all(|value| value.is_finite() && value.abs() < 1e12)
@@ -51,8 +64,10 @@ impl Index {
continue;
}
let (left, top, right, bottom) = (cell(x0), cell(y0), cell(x1), cell(y1));
// 用 i64 计算格子跨度和饱和乘积,避免极大矩形在预算检查前发生算术溢出。
let cells = (i64::from(right) - i64::from(left) + 1)
.saturating_mul(i64::from(bottom) - i64::from(top) + 1);
// 单项最多展开 256 格,全索引最多 65536 项格子引用;超预算只降级,不丢弃目标。
if cells > 256 || entries + cells > 65_536 {
result.fallback.push(index);
continue;
@@ -66,7 +81,9 @@ impl Index {
}
result
}
/// 返回当前网格格子与后备集合中的候选,去重并恢复逆绘制顺序。
pub fn candidates(&self, point: Point, count: usize) -> Vec<usize> {
// 索引总数与当前帧不一致时,下标集合不再可靠;直接返回整帧逆序候选。
if self.count != count {
return (0..count).rev().collect();
}
@@ -74,12 +91,15 @@ impl Index {
if let Some(items) = self.cells.get(&(cell(point.x), cell(point.y))) {
output.extend(items);
}
// 按下标降序还原从前景到背景的绘制顺序;去重确保同一出现不会重复执行 hitTest。
output.sort_unstable_by(|a, b| b.cmp(a));
output.dedup();
output
}
}
/// 把屏幕坐标映射到有界整数网格坐标,处理负方向与极端数值。
fn cell(value: f32) -> i32 {
// 每格 128 个屏幕单位;floor 让 -1 属于负格而不是零格,再限制到整数表示范围。
(value / 128.)
.floor()
.clamp(i32::MIN as f32, i32::MAX as f32) as i32
@@ -89,6 +109,7 @@ fn cell(value: f32) -> i32 {
mod tests {
use super::*;
#[test]
/// 200个分散节点只返回局部网格候选;覆盖海量格的巨大前景节点进入后备集合后仍按前景顺序参与精确命中。
fn screen_grid_limits_candidates_and_keeps_huge_rectangles_hittable() {
let (_, document) = crate::endor_host::ReplSession::load(
"page=Page()\npage.render(Button(\"hit\"))",
@@ -116,6 +137,7 @@ mod tests {
}
let index = Index::build(&mounts);
assert_eq!(index.candidates(Point::new(5., 5.), mounts.len()), vec![0]);
// 把最前景节点扩大到覆盖海量格子,验证其进入后备集合后仍排在普通候选之前且可精确命中。
mounts[199].x = -1_000_000.;
mounts[199].width = 2_000_000.;
let index = Index::build(&mounts);
+68
View File
@@ -1,3 +1,7 @@
//! 本机 HTTP API 入口:路由请求、限制输入大小、观察活动,并将执行工作送入串行 worker。
//! HTTP 线程不操作 VM;共享 NamespaceState 只保存最近完整发布文档和资源命令。提交序号用来拒绝已被较新 PUT/DELETE 取代的结果。
//! 关闭流程先请求运行时有界清理,再通过完成通知让原生事件循环退出。
//!
mod guide;
mod worker;
@@ -30,46 +34,56 @@ use crate::{
endor_host,
};
// 只监听 IPv4 回环固定端口,接口用于本进程原生输入和本机 agent,不是对外网络服务器。
pub const API_ADDRESS: &str = "127.0.0.1:4737";
const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64;
type Notify = Arc<dyn Fn() + Send + Sync>;
#[derive(Clone, Debug)]
/// 运行时清理的摘要与可选错误,供最终原生退出结果报告。
pub struct ShutdownOutcome {
pub summary: String,
pub error: Option<String>,
}
type ShutdownCompletion = Arc<(Mutex<Option<ShutdownOutcome>>, Condvar)>;
#[derive(Clone)]
/// 跨线程的退出控制句柄,使用有界通知和完成条件变量,不携带 VM。
pub struct RuntimeControl {
sender: SyncSender<()>,
requested: Arc<AtomicBool>,
completion: ShutdownCompletion,
}
impl RuntimeControl {
/// 只发出一次退出请求,唤醒 worker 执行清理,并通过完成句柄通知原生循环。
pub fn request_shutdown(&self) -> bool {
// 原子标志合并重复退出请求;已请求直接返回 true,不在 GUI 线程重复排队清理。
if self.requested.swap(true, Ordering::AcqRel) {
return true;
}
if self.sender.try_send(()).is_ok() {
true
} else {
// 控制队列不可用时回滚请求标志,允许调用方识别失败并再次尝试,而非永远卡在假退出状态。
self.requested.store(false, Ordering::Release);
false
}
}
/// 读取退出标志,使窗口事件和新输入尽早停止进入运行时。
pub fn is_exiting(&self) -> bool {
self.requested.load(Ordering::Acquire)
}
/// 在明确超时内等待 worker 的清理结果,不能无限阻塞退出。
pub fn wait_shutdown(&self, timeout: std::time::Duration) -> Option<ShutdownOutcome> {
let (lock, ready) = &*self.completion;
let result = lock.lock().ok()?;
let (result, _) = ready
// 条件变量处理虚假唤醒,直到完成槽非空或超时;返回 None 也可能来自锁中毒,不能当作清理成功。
.wait_timeout_while(result, timeout, |result| result.is_none())
.ok()?;
result.clone()
}
}
/// worker 持有的关闭接收端、控制状态和 GUI 完成通知代理。
struct Shutdown {
receiver: Receiver<()>,
control: RuntimeControl,
@@ -77,6 +91,7 @@ struct Shutdown {
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// 送入执行队列的请求类别,区分完整源码、持久输入、事件与重置。
enum RequestKind {
Event,
Code,
@@ -85,6 +100,7 @@ enum RequestKind {
ResetRepl,
}
/// 保留原 HTTP 请求与提交身份的执行工作,完成或失败后由 worker 回应。
struct BuildRequest {
request: ObservedRequest,
kind: RequestKind,
@@ -93,10 +109,12 @@ struct BuildRequest {
submission: u64,
}
/// 绑定回环 API,启动请求接收与单 VM worker,返回活动日志及一次性退出控制句柄。
pub fn start(
namespace: Arc<RwLock<NamespaceState>>,
proxy: EventLoopProxy<AppEvent>,
) -> Result<(Activity, RuntimeControl)> {
// 退出控制仅需一个消息槽,正常执行请求另走容量8队列,避免工作排满后无法通知关闭。
let (sender, receiver) = sync_channel(1);
let control = RuntimeControl {
sender,
@@ -108,6 +126,7 @@ pub fn start(
control: control.clone(),
proxy: proxy.clone(),
};
// 绑定失败在启动阶段反馈,不能假装本机 API 可用;后台通知仅发送 GUI 事件,不直接画窗口。
let server = Server::http(API_ADDRESS)
.map_err(|error| anyhow::anyhow!(error))
.with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?;
@@ -127,6 +146,7 @@ pub fn start(
}
#[cfg(test)]
/// 为本地监听服务建立请求处理线程,并把执行请求转交有界队列。
fn start_server(
server: Arc<Server>,
namespace: Arc<RwLock<NamespaceState>>,
@@ -142,6 +162,7 @@ fn start_server(
)
}
/// 启动带活动观察的 HTTP 服务,将请求状态变化通知管理界面。
fn start_server_observed(
server: Arc<Server>,
namespace: Arc<RwLock<NamespaceState>>,
@@ -150,6 +171,7 @@ fn start_server_observed(
observer_notify: Notify,
shutdown: Option<Shutdown>,
) -> Result<thread::JoinHandle<()>> {
// HTTP 接收与 VM 执行分离,最多8个排队请求;源代码只在单个 endor-worker 中操作 VM。
let (sender, receiver) = sync_channel::<BuildRequest>(8);
let worker_namespace = namespace.clone();
let worker_notify = notify.clone();
@@ -160,11 +182,13 @@ fn start_server_observed(
.name("tessera-endor-worker".into())
.spawn(move || {
let mut worker = Worker::default();
// 分片请求跨循环保留原 HTTP 请求和继续句柄;同一时刻只推进一个有未完成 VM 执行的输入。
let mut pending: Option<(BuildRequest, PendingRequest)> = None;
loop {
if let Some(shutdown) = &shutdown
&& shutdown.receiver.try_recv().is_ok()
{
// 退出优先于下一量子:先撤去待响应请求,再由 shutdown_runtime 清理 VM 续体与生命周期资源。
if let Some((job, input)) = pending.take() {
drop(input);
let _ = job
@@ -172,12 +196,14 @@ fn start_server_observed(
.respond(error_response(StatusCode(503), "application is exiting"));
}
let outcome = worker.shutdown_runtime();
// 排队请求全部明确回应503,不能留着客户端无期限等待已经停止的执行线程。
while let Ok(job) = receiver.try_recv() {
let _ = job
.request
.respond(error_response(StatusCode(503), "application is exiting"));
}
if let Ok(mut current) = worker_namespace.write() {
// 撤销所有帧的会话/焦点与原生命令,让 GUI 不再把旧资源当成仍可执行的活域。
current.runtime_session = 0;
current.window_commands.clear();
current.document.frame.session = 0;
@@ -191,6 +217,7 @@ fn start_server_observed(
let (lock, ready) = &*shutdown.control.completion;
if let Ok(mut result) = lock.lock() {
*result = Some(outcome.clone());
// 先在锁内保存清理结果再唤醒等待者,同时用 AppEvent 通知原生事件循环退出,两条路径复用同一 outcome。
ready.notify_all();
}
let _ = shutdown.proxy.send_event(AppEvent::ShutdownComplete {
@@ -198,6 +225,7 @@ fn start_server_observed(
});
break;
}
// 分片只让出线程,不并行执行后续输入;当前请求完成才回应并处理下一队列项。
if let Some((job, input)) = pending.take() {
match worker.resume_request(&job, input, &worker_namespace, &worker_notify) {
RequestProgress::Pending(input) => {
@@ -213,6 +241,7 @@ fn start_server_observed(
}
continue;
}
// 空闲输入边界先推进视口刷新与任务,待执行刷新/协作任务时零等待,否则最多100ms检查一次。
worker.refresh_viewport(&worker_namespace, &worker_notify);
let wait = if worker.repl.as_ref().is_some_and(|(_, runtime)| {
runtime.has_pending_refresh() || runtime.has_cooperative_tasks()
@@ -246,6 +275,7 @@ fn start_server_observed(
&& current.runtime_session != runtime.id
})
}) {
// 当前持久域同时失去 submission 和 runtime_session 归属时退役;不能仅因源码请求排队就误删仍权威的旧域。
worker.repl = None;
}
if wait.is_zero() {
@@ -263,6 +293,7 @@ fn start_server_observed(
if exiting
.as_ref()
.is_some_and(|flag| flag.load(Ordering::Acquire))
// 退出后拒绝写入/执行路由,GET 与 OPTIONS 仍可读取最近发布值或探测接口。
&& !matches!(request.method(), Method::Get | Method::Options)
{
let _ =
@@ -283,6 +314,7 @@ fn start_server_observed(
}
// Capture only allowlisted headers: credentials and cookies never enter the inspector.
/// HTTP 请求的观察包装,在读取正文和响应时维护 Network 工具记录。
struct ObservedRequest {
inner: Request,
activity: Activity,
@@ -290,7 +322,9 @@ struct ObservedRequest {
notify: Notify,
}
impl ObservedRequest {
/// 从请求提取允许观察的头部与显示来源,创建活动记录并通知工具界面。
fn new(inner: Request, activity: Activity, notify: Notify) -> Self {
// 活动观察只保留 Content-Type/Length;X-Tessera-Origin 仅判来源标签,不把任意认证头复制进工具日志。
let headers = inner
.headers()
.iter()
@@ -298,6 +332,7 @@ impl ObservedRequest {
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
// console 标记只是显示来源,不提供权限,也不改变 VM 执行语义。
let origin = if inner
.headers()
.iter()
@@ -322,15 +357,19 @@ impl ObservedRequest {
notify,
}
}
/// 将受限请求正文记录到活动预览,不在观察器中执行源码。
fn record_body(&self, body: &str) {
self.activity.lock().unwrap().body(self.id, body);
(self.notify)();
}
/// 发送最终 HTTP 响应,并同步结束该条活动记录。
fn respond(self, response: Response<std::io::Cursor<Vec<u8>>>) -> std::io::Result<()> {
let status = response.status_code();
let headers = response.headers().to_vec();
let length = response.data_length();
// 响应正文本已在内存 Cursor,记录预览后用原 status/headers/reader/length 重建响应,不消费正文内容。
let reader = response.into_reader();
// 记录的是已构造响应,即使后面 socket 发送失败也已结束活动记录,不能据此声称客户端成功接收。
self.activity.lock().unwrap().finish(
self.id,
status.0,
@@ -348,16 +387,19 @@ impl ObservedRequest {
}
impl Deref for ObservedRequest {
type Target = Request;
/// 将包装类型的只读访问转交给内部当前上下文。
fn deref(&self) -> &Request {
&self.inner
}
}
impl DerefMut for ObservedRequest {
/// 将包装类型的可变访问转交给内部当前上下文。
fn deref_mut(&mut self) -> &mut Request {
&mut self.inner
}
}
/// 解析 HTTP 方法和路径;只读路由读取发布值,执行路由交给 worker。
fn handle(
request: Request,
namespace: &Arc<RwLock<NamespaceState>>,
@@ -368,6 +410,7 @@ fn handle(
) {
let request = ObservedRequest::new(request, activity.clone(), observer_notify.clone());
let method = request.method().clone();
// 路由匹配去掉查询串,namespace 从固定路径提取;不把 query 当作命名空间或源代码。
let path = request
.url()
.split('?')
@@ -378,6 +421,7 @@ fn handle(
let evaluation = namespace_from_route(&path, "/evaluate");
let repl = namespace_from_route(&path, "/repl");
let event = namespace_from_route(&path, "/events");
// PUT/code 完整替换,POST 在 events/repl/evaluate 中分类,DELETE/repl 是持久会话重置;其他交给资源读取/清空路径。
let route = if method == Method::Put {
namespace_from_path(&path).map(|name| (name, RequestKind::Code))
} else if method == Method::Post {
@@ -410,6 +454,7 @@ fn handle(
let _ = request.respond(response);
}
/// 处理源码资源的 GET/DELETE 等请求,返回对应版本头或撤销运行时所有权。
fn code_request(
method: Method,
addressed_namespace: &str,
@@ -429,6 +474,7 @@ fn code_request(
);
}
// GET 返回最近成功发布的 source 而非正在排队的输入,并通过头部暴露 revision/session/window/layout 及逻辑视口。
if method == Method::Get {
let mut response = text(
StatusCode(200),
@@ -483,6 +529,7 @@ fn code_request(
);
return response;
}
// 释放读锁后才申请写锁,避免同一线程持读锁升级为写锁死锁;修改结束也先解锁再发送 GUI 通知。
drop(current);
match method {
@@ -510,6 +557,7 @@ fn code_request(
}
}
/// 读取并检查请求体,分配提交身份,再把工作送进有界执行队列。
fn submit(
mut request: ObservedRequest,
addressed: &str,
@@ -524,6 +572,7 @@ fn submit(
return;
}
};
// 读取成功后才保存受限活动正文预览;超长或非法 UTF-8 请求不会进入执行队列。
request.record_body(&source);
let Ok(mut current) = namespace.write() else {
let _ = request.respond(error_response(
@@ -536,6 +585,7 @@ fn submit(
let _ = request.respond(error_response(StatusCode(404), "namespace is not active"));
return;
}
// 仅完整 Code 请求分配新 submission,事件/REPL/表达式继承当前身份,保留共享域输入顺序。
let submission = match if kind == RequestKind::Code {
current.next_submission()
} else {
@@ -554,6 +604,7 @@ fn submit(
namespace: addressed.into(),
submission,
};
// 持 namespace 写锁完成入队和接受身份更新;worker 提交使用同一锁,保证接受与发布之间没有竞态窗口。
match sender.try_send(job) {
// A rejected queue entry never invalidates accepted work. The worker
// uses this same lock when committing, so publication is atomic.
@@ -562,6 +613,7 @@ fn submit(
current.submission = submission;
}
}
// 队列拒绝时不推进 submission,所以已经接受的旧工作不会因为本次失败请求而失去发布资格。
Err(TrySendError::Full(job)) => {
let _ = job
.request
@@ -576,6 +628,7 @@ fn submit(
}
}
/// 生成较旧请求已被新提交取代的响应,拒绝晚到结果覆盖当前文档。
fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
json(
StatusCode(409),
@@ -584,15 +637,19 @@ fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
)
}
/// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。
fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> {
// 同时检查前后缀且命名空间非空、无额外斜杠;不做模糊路径前缀匹配。
path.strip_prefix("/v1/namespaces/")?
.strip_suffix(suffix)
.filter(|namespace| !namespace.is_empty() && !namespace.contains('/'))
}
/// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。
fn namespace_from_path(path: &str) -> Option<&str> {
namespace_from_route(path, "/code")
}
/// 按源码字节上限读取 UTF-8 请求体,读取失败或超限时返回诊断。
fn read_code(request: &mut Request) -> Result<String> {
if request
.body_length()
@@ -600,6 +657,8 @@ fn read_code(request: &mut Request) -> Result<String> {
{
anyhow::bail!("code exceeds the 1 MiB limit");
}
// 已知 Content-Length 先预拒绝,再在流上最多读上限+1;防止缺失/不实长度绕过容量限制。
// read_to_string 要求 UTF-8,不用有损替换改变用户源码。
let mut code = String::new();
request
.as_reader()
@@ -612,9 +671,11 @@ fn read_code(request: &mut Request) -> Result<String> {
Ok(code)
}
/// 将前端/编译/执行诊断编码为 API 错误响应,保留用户可理解的位置和消息。
fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
let mut diagnostic =
serde_json::json!({ "severity": "error", "message": format!("{error:#}") });
// 有 SourceDiagnostic 才附行/列和半开字节范围;其他运行时错误仍保留完整错误链消息,不编造源码位置。
if let Some(source) = error.downcast_ref::<frontend::SourceDiagnostic>() {
diagnostic["line"] = source.location.line.into();
diagnostic["column"] = source.location.column.into();
@@ -630,6 +691,7 @@ fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
)
}
/// 生成统一 JSON 错误响应,使调用方能区分协议错误与成功结果。
fn error_response(
status: StatusCode,
message: impl Into<String>,
@@ -637,6 +699,7 @@ fn error_response(
json(status, serde_json::json!({ "error": message.into() }))
}
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
text(
status,
@@ -645,11 +708,14 @@ fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Curso
)
}
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
fn markdown(status: StatusCode, value: String) -> Response<std::io::Cursor<Vec<u8>>> {
text(status, &value, "text/markdown; charset=utf-8")
}
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io::Cursor<Vec<u8>>> {
// 公共响应头只允许固定本机 Origin/方法/头名,非通配任意来源;这些头不替代回环绑定与路由校验。
let mut response = Response::from_string(value).with_status_code(status);
add_header(&mut response, "Content-Type", content_type);
add_header(
@@ -670,7 +736,9 @@ fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io
response
}
/// 向响应追加经过 HTTP 库检查的头字段,不直接拼接未验证的原始报文。
fn add_header(response: &mut Response<std::io::Cursor<Vec<u8>>>, name: &str, value: &str) {
// 由 HTTP 库验证字段格式,非法头被忽略,不把未校验字符拼进原始响应报文。
if let Ok(header) = Header::from_bytes(name, value) {
response.add_header(header);
}
+6 -1
View File
@@ -1,6 +1,11 @@
//! Live agent-facing guide generated from the current namespace.
//! 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。
//!
//! 指南正文是返回给客户端的静态 Markdown 模板,只有命名空间、版本、地址和内置示例按参数填充。
use super::*;
/// 为 /agent 生成可提交代码与查询接口的 Markdown 说明。
/// format! 使用具名插值注入 namespace.name/revision 与监听地址;示例在编译时嵌入,
/// 因而请求不会访问示例文件、执行示例或改变会话。正文中的 JSON/Endor 是文档内容。
pub(super) fn agent_guide(namespace: &NamespaceState) -> String {
format!(
r##"# Tessera Agent API
+44 -1
View File
@@ -1,3 +1,6 @@
//! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。
//! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。
//!
use super::*;
use std::{
io::Write,
@@ -7,6 +10,8 @@ use std::{
};
#[test]
/// 模拟 reset 后旧画面仍含编号 2 的窗口,而新 Console VM 恰好再次分配编号 2。
/// 关闭 session=0 的旧投影只能移除画面,不能按相同数字误关新 VM 的真实资源。
fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
let (_, mut picture) = endor_host::ReplSession::load(
include_str!("../../../examples/windows.endor"),
@@ -15,6 +20,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
150,
)
.unwrap();
// 把旧文档标成无活动 VM 的保留投影;编号相同也不能据此识别当前资源。
picture.frame.session = 0;
for view in picture.windows.values_mut() {
view.frame.session = 0;
@@ -77,6 +83,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
);
}
/// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。
struct TestServer {
server: Arc<Server>,
address: SocketAddr,
@@ -84,6 +91,8 @@ struct TestServer {
}
impl TestServer {
/// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。
/// 返回状态码、原始头和正文;10 秒超时使服务端死锁表现为测试失败而非无限等待。
fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) {
let mut stream = TcpStream::connect(self.address).unwrap();
stream
@@ -102,6 +111,7 @@ impl TestServer {
}
impl Drop for TestServer {
/// 解除服务端 accept 等待并等待线程结束,避免测试之间遗留监听线程。
fn drop(&mut self) {
self.server.unblock();
if let Some(api) = self.api.take() {
@@ -111,6 +121,8 @@ impl Drop for TestServer {
}
#[test]
/// 通过 HTTP 提交覆盖 moveTab 的 Page 子类,再发送带帧身份的 tab-drop。
/// 检查源覆盖恰好调用一次、页面顺序改变,以及重放旧事件返回 409 而不损坏运行时。
fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
@@ -154,6 +166,7 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
}),
}
};
// 保留同一事件正文用于重放测试,不能重新读取新 layout_version,否则就不是过期输入。
let body = serde_json::to_string(&input).unwrap();
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
assert_eq!(response.0, 200, "{}", response.2);
@@ -174,6 +187,8 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
}
#[test]
/// 零延时任务触发非法窗口尺寸,使后台回调先产生运行时故障。
/// 之后多次 REPL 请求都应被拒绝,同时保留原始 stop/window size 诊断,而非用后续错误覆盖。
fn background_failure_preserves_its_cause_after_rejected_console_input() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
@@ -212,6 +227,8 @@ fn background_failure_preserves_its_cause_after_rejected_console_input() {
}
#[test]
/// 依次创建未挂载对象、读取共享值、挂载、reset,再用完整 PUT 替换会话。
/// 验证未挂载输入不增 revision,reset 清绑定但保留画面,成功 PUT 才切换到新 VM。
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
@@ -223,6 +240,7 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
};
let repl = "/v1/namespaces/app.main/repl";
let code = "/v1/namespaces/app.main/code";
// 先创建对象但不 render;返回值可以跨 REPL 请求复用,发布 revision 此时仍为 0。
let created = client.request("POST", repl, "title = ui.text(\"中文\")");
assert_eq!(created.0, 200, "{}", created.2);
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
@@ -259,6 +277,8 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
}
#[test]
/// 经真实 HTTP 完成指南读取、PUT/GET、失败候选、再次 PUT 和 DELETE。
/// 同时检查诊断位置、源文本/版本头、空文档、错误 namespace 与通知次数,确保仅成功发布改变版本。
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
@@ -314,6 +334,8 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
}
#[test]
/// 直接推进 worker 的 submission,模拟新候选已接受但尚未成功替换的时刻。
/// 旧 VM 的 retained 仍可读;clear 撤销 namespace 后再刷新,才释放 live owner。
fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
let (runtime, document) = endor_host::ReplSession::load(
"retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))",
@@ -358,6 +380,8 @@ fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
}
#[test]
/// 用通知回调中的通道阻塞第一轮 worker,再使第二个 PUT 排队。
/// 此时 GET/DELETE 仍应可用;释放 worker 后第一请求成功、被 DELETE 超越的候选返回 409,文档保持清空。
fn http_delete_remains_available_and_invalidates_queued_put() {
use std::{
sync::{Mutex, mpsc},
@@ -367,6 +391,7 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
// 通知是共享闭包;用 Mutex 包装接收端使其满足线程共享约束,只阻塞第一次通知。
let release_rx = Mutex::new(release_rx);
let calls = AtomicUsize::new(0);
let notify: Notify = Arc::new(move || {
@@ -381,9 +406,11 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
server,
api: Some(api),
};
// Unblock even if a test assertion fails, before scoped threads join.
// 析构守卫在 scoped 线程 join 前释放阻塞通知;即使断言 panic 也不能让 worker 永久等通道。
// 持有释放信号的发送端;离开作用域即通知 worker 继续,包括异常展开路径。
struct Release(mpsc::Sender<()>);
impl Drop for Release {
// send 失败意味着接收端已结束,此时无需再阻塞测试清理。
fn drop(&mut self) {
let _ = self.0.send(());
}
@@ -403,6 +430,7 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
}
assert_eq!(client.request("GET", route, "").2, example);
assert_eq!(client.request("DELETE", route, "").0, 200);
// DELETE 已同步清空文档并提升提交身份,现在解除第一请求通知阻塞,让排队候选观察到失效。
drop(release);
assert_eq!(first.join().unwrap().0, 200);
assert_eq!(pending.join().unwrap().0, 409);
@@ -412,6 +440,7 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
}
#[test]
/// 检查 code 路径解析只接受完整的单段 namespace;缺少后缀和嵌套斜线均返回 None。
fn extracts_namespace_only_from_code_routes() {
assert_eq!(
namespace_from_path("/v1/namespaces/app.main/code"),
@@ -422,6 +451,7 @@ fn extracts_namespace_only_from_code_routes() {
}
#[test]
/// 对生成的指南做契约回归:保留代码 CRUD、可运行示例和当前地址,移除旧 export scene/display_list 暴露。
fn guide_contains_only_code_crud_routes() {
let guide = agent_guide(&NamespaceState::default());
assert!(guide.contains("Code CRUD"));
@@ -432,6 +462,8 @@ fn guide_contains_only_code_crud_routes() {
}
#[test]
/// 创建已经就绪的定时任务,先按前台模式刷新再读取计数,确保输入前不抢跑回调。
/// 随后允许空闲刷新并推进待完成工作,计数应从 0 变为 1。
fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() {
let source = r#"
class Action {
@@ -456,6 +488,7 @@ page.render(TextBlock("input priority"))
next_session: 801,
};
let notify: Notify = Arc::new(|| {});
// 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。
worker.refresh_viewport_mode(&namespace, &notify, true);
let value = worker
.repl
@@ -481,6 +514,8 @@ page.render(TextBlock("input priority"))
}
#[test]
/// 在负世界坐标放置按钮,通过 HTTP 平移/锚定缩放,再发送世界坐标 click。
/// 检查相机数值、旧帧重放 409、按钮计数,以及零倍率 400 不导致 VM fault。
fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
@@ -583,6 +618,8 @@ page.render(button)
}
#[test]
/// 给局部 CanvasViewport 添加父内边距和自身左边框,检验页面坐标到内容相机锚点的转换。
/// 成功平移/缩放后核对相机状态;边框、边界与视口外锚点均须返回 400,保留健康运行时。
fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
@@ -655,6 +692,7 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
expected
);
}
// 分别覆盖左边框、右侧边界及远离视口的锚点,避免只校验整个窗口范围。
for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] {
let response = client.request(
"POST",
@@ -667,6 +705,8 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
}
#[test]
/// 通过真实 HTTP 检查透视缩放/orbit、正交缩放,再替换为源实现 PanOnly。
/// 比较相机数值与自定义控制器结果,并验证视口外锚点和禁用控制器的拒绝状态不会停掉 VM。
fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
@@ -758,6 +798,8 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
}
#[test]
/// 在旋转的 Panel3D 上嵌入二维 CanvasViewport,经射线/UV 映射解释相机事件。
/// 缺失指针必须拒绝;带指针的平移/缩放只改变面板内相机,外层三维相机保持原值。
fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
@@ -830,6 +872,7 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
.unwrap()
};
let pan = value("canvas.camera.x");
// 面板倾斜后的投影位移不等于 UI 内部位移,不能把屏幕 delta 直接写入局部相机。
assert!(pan > 10.);
assert_eq!(value("view.camera.x"), 0.);
let response = client.request(
+66
View File
@@ -1,7 +1,12 @@
//! 唯一串行执行 VM 的 API worker。保留活跃运行时,另建 PUT 候选,并在候选完整成功后切换发布。
//! 输入、视口刷新与候选布局可以跨 worker 轮次挂起;中途只公布完整文档,不能让新输入进入未结束的只读调用。
//! 事件身份同时校验窗口、session、layout_version 和 Mount 能力。失败、替换与关闭分别处理,避免旧回调或旧命令泄漏到新 VM。
//!
//! Serial VM ownership, incremental requests, publication and shutdown.
use super::*;
type HttpResponse = Response<std::io::Cursor<Vec<u8>>>;
// 外层 thread::Result 区分 Rust panic,内层 Result 区分可诊断编译/运行错误;正常结果另带待发布窗口命令。
type InputOutcome =
std::thread::Result<Result<(endor_host::ReplResult, Vec<crate::window::Command>)>>;
@@ -13,24 +18,30 @@ type CandidateOutcome = std::thread::Result<
)>,
>;
/// 已执行业务部分但仍在等待展示刷新的输入元数据,避免恢复时重放回调。
pub(super) struct PendingInput {
// 输入开始时所属 submission,与 id(VM 会话)分开;新 PUT 接受时可保留正在执行的原 VM 至输入收敛。
epoch: u64,
id: u64,
event: bool,
result: endor_host::ReplResult,
}
/// 挂起 HTTP 请求的阶段:等旧刷新、继续输入或继续隔离候选。
pub(super) enum PendingRequest {
// 仅等待旧只读刷新结束,业务输入尚未执行;恢复时可重新走入口,而 Input 阶段不能重放业务副作用。
BeforeInput,
Input(Box<PendingInput>),
Candidate(Box<endor_host::ReplSession>),
}
/// 一次 worker 推进的结果:继续保留请求或交付最终响应。
pub(super) enum RequestProgress {
Pending(PendingRequest),
Complete(HttpResponse),
}
/// 只提取仍属于当前 session 的窗口尺寸,避免旧原生窗口给新候选提供错误约束。
fn current_viewports(
current: &NamespaceState,
) -> std::collections::BTreeMap<u64, crate::layout::Size> {
@@ -48,6 +59,7 @@ fn current_viewports(
})
.map(|(id, size)| (*id, *size))
.collect();
// 主窗口逻辑视口优先覆盖 map 同名值,主壳可以跨会话沿用;副窗口必须仍属于当前发布世代。
if let Some(viewport) = current.viewport {
viewports.insert(crate::window::MAIN_WINDOW, viewport);
}
@@ -55,11 +67,13 @@ fn current_viewports(
}
#[derive(Default)]
/// 唯一活跃 VM 的串行拥有者,另行管理候选和下一 session 身份。
pub(super) struct Worker {
pub(super) repl: Option<(u64, endor_host::ReplSession)>,
pub(super) next_session: u64,
}
impl Worker {
/// 撤销活跃运行时并有界清理窗口、Mount 和任务;故障域不再执行新的业务回调。
pub(super) fn shutdown_runtime(&mut self) -> ShutdownOutcome {
let Some((_, mut runtime)) = self.repl.take() else {
return ShutdownOutcome {
@@ -67,6 +81,7 @@ impl Worker {
error: None,
};
};
// 正常域允许有界关闭回调,故障域只做宿主清理;catch_unwind 防止清理 panic 跳过原生退出通知。
let callbacks = !runtime.is_faulted();
let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown()));
match result {
@@ -90,6 +105,7 @@ impl Worker {
}
}
/// 处理当前无法继续正常交互的窗口关闭,撤销原生资源与相应运行时记录。
pub(super) fn close_inactive_window(
&mut self,
input: &crate::events::Input,
@@ -97,6 +113,7 @@ impl Worker {
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
// 故障/失活资源的专用关闭只处理副窗口,主壳由 OS 关闭控制走全应用退出流程。
if input.window == crate::window::MAIN_WINDOW {
return error_response(
StatusCode(409),
@@ -115,6 +132,7 @@ impl Worker {
.1
.retire_window(input.window)
}));
// 退役失败不能继续借用半清理 VM,放弃整个活域并在下方清其他帧的会话与命令资格。
if !matches!(result, Ok(Ok(()))) {
// Never keep a half-cleaned VM live or borrow another generation.
self.repl = None;
@@ -130,6 +148,7 @@ impl Worker {
let Some(view) = current.document.windows.get(&input.window) else {
return error_response(StatusCode(409), "window is no longer active");
};
// 即便副窗口数值 ID 相同,也不能让旧会话的关闭请求删除新会话投影。
if view.frame.session != input.session {
return error_response(
StatusCode(409),
@@ -162,6 +181,7 @@ impl Worker {
)
}
/// 在没有前台输入的 worker 轮次推进尺寸和任务引起的展示刷新。
pub(super) fn refresh_viewport(
&mut self,
namespace: &Arc<RwLock<NamespaceState>>,
@@ -170,12 +190,14 @@ impl Worker {
self.refresh_viewport_mode(namespace, notify, false);
}
/// 在输入之间推进视口/任务刷新;提交序号或 session 已变时拒绝旧结果发布。
pub(super) fn refresh_viewport_mode(
&mut self,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
before_input: bool,
) {
// 在读锁内取得一个一致的提交身份和尺寸快照;耗时 VM 工作不占用共享状态写锁。
let (epoch, viewports) = match namespace.read() {
Ok(current) => {
let viewports = current_viewports(&current);
@@ -186,6 +208,7 @@ impl Worker {
let Some((owner, runtime)) = &mut self.repl else {
return;
};
// submission 变化先尝试保留仍权威的活域所有者,不立即拿旧 epoch 继续发布后台结果。
if *owner != epoch {
if let Ok(current) = namespace.read() {
self.preserve_live_owner(&current);
@@ -204,11 +227,13 @@ impl Worker {
}
// An accepted foreground input waits only for the existing
// refresh; do not keep placing new callbacks ahead of it.
// 已有前台请求只等当前刷新,不不断把新任务回调插到它前面,避免忙后台工作饿死用户输入。
let completed = if before_input {
None
} else {
runtime.poll_tasks_incremental(&viewports)?
};
// 任务后续刷新未收敛时可以发布较早已完整的 resize 文档,但暂扣任务产生的命令,防止半完成副作用外泄。
if runtime.has_pending_refresh() {
// A complete earlier resize may publish; task effects and their
// native commands wait until the task's refresh has settled.
@@ -216,10 +241,12 @@ impl Worker {
}
Ok((completed.or(resized), runtime.take_window_commands()?))
}));
// 计算结束后重新核对归属:期间若发生 PUT/DELETE,不能把旧域草稿发布出去。
let mut current = match namespace.write() {
Ok(current) => current,
Err(_) => return,
};
// 耗时 VM 工作期间 HTTP 线程可能接受 PUT/DELETE,发布写锁内再核对 submission 与 session 双身份。
if current.submission != epoch || current.runtime_session != id {
return;
}
@@ -228,6 +255,7 @@ impl Worker {
if let Some(document) = document {
(document, Some(commands))
} else {
// 无新文档也可能有标题/焦点等原生命令;只在实际有投影变化时唤醒 GUI,避免空闲无效重绘。
let projected = !commands.is_empty();
current.publish_window_commands(id, commands);
drop(current);
@@ -244,6 +272,7 @@ impl Worker {
.chars()
.take(1024)
.collect(),
// 内部 panic 与普通背景错误不同,必须丢弃 VM 并撤销所有原生命令,不能继续运行损坏的域。
_ => {
self.repl = None;
current.runtime_session = 0;
@@ -267,12 +296,14 @@ impl Worker {
}
}
/// 检查请求种类和归属,必要时先等已有刷新结束,再建立输入或候选任务。
pub(super) fn begin_request(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
// 完整 PUT 独立候选,表达式隔离求值;只有 REPL/事件会进入当前持久域并等待已有刷新。
if job.kind == RequestKind::Code {
return self.begin_program_request(job, namespace, notify);
}
@@ -295,6 +326,7 @@ impl Worker {
if epoch != job.submission {
return RequestProgress::Complete(superseded(job.submission));
}
// 重置丢弃内存会话但保留显示文档,清帧 session/focus 使旧画面不再接受旧对象事件。
if job.kind == RequestKind::ResetRepl {
self.repl = None;
if let Ok(mut current) = namespace.write() {
@@ -320,6 +352,7 @@ impl Worker {
{
return RequestProgress::Pending(PendingRequest::BeforeInput);
}
// 事件先做 JSON 结构、kind 白名单和 Input 字段校验,再用权威发布帧核对具体资源能力。
let event = if job.kind == RequestKind::Event {
let input: crate::events::Input = match serde_json::from_str(&job.source) {
Ok(input) => input,
@@ -378,6 +411,7 @@ impl Worker {
};
&document.frame
};
// 窗口关闭允许处理已失活/故障副窗口,不像点击一样要求可运行 VM;由专用宿主退役路径处理。
if input.kind == "window-close"
&& frame.window == input.window
&& frame.session == input.session
@@ -398,6 +432,7 @@ impl Worker {
);
}
}
// 标签拖放同时验证目标窗口的会话、版本、无故障及插入位置,源窗口校验不能替代目标校验。
if let Some(target) = &input.tab_drop {
let target_frame = if target.window == crate::window::MAIN_WINDOW {
Some(&current.document.frame)
@@ -425,6 +460,7 @@ impl Worker {
));
}
}
// 统一事件归属门:窗口/session/版本及操作对应资源必须都合法;window-close 特别允许旧布局版本。
if frame.window != input.window
|| frame.session != input.session
|| (input.kind != "window-close" && frame.layout_version != input.layout_version)
@@ -438,6 +474,7 @@ impl Worker {
input.kind.as_str(),
"tab-activate" | "tab-close" | "tab-detach" | "tab-drop"
) {
// 标签操作的 mount 字段承载 Tab ID,而普通输入承载 Mount ID;两套身份不能混着查询。
!frame.tabs.iter().any(|tab| tab.id == input.mount)
} else {
!frame.mounts.iter().any(|mount| {
@@ -445,6 +482,7 @@ impl Worker {
&& if input.kind.starts_with("viewport-") {
mount.canvas_viewport
} else if input.kind.starts_with("scene-") {
// 3D 手势还要求显式控制器能力,scroll 要有滚动投影,点击/焦点则要求 interactive。
mount.scene3d_viewport && mount.camera_controls
} else if input.kind == "scroll" {
mount.scroll.is_some()
@@ -464,6 +502,7 @@ impl Worker {
"event targets an obsolete or inactive frame",
));
}
// 兼容页面仅 Canvas 可缩放,Window 模式不接受 pan;结构合法不等于当前页面模式支持操作。
if input.kind == "page-zoom" && frame.surface.mode != crate::page::Mode::Canvas
|| input.kind == "page-pan" && frame.surface.mode == crate::page::Mode::Window
{
@@ -472,6 +511,7 @@ impl Worker {
"camera input is unavailable in this Page mode",
));
}
// 缩放锚点须经过面板反投影与内容 clip 检查,不能落在视口边框或被遮挡面板上仍缩放。
if matches!(input.kind.as_str(), "viewport-zoom" | "scene-zoom")
&& !frame.mounts.iter().any(|mount| {
mount.id == input.mount
@@ -494,6 +534,7 @@ impl Worker {
"zoom anchor is outside content viewport",
));
}
// 世界 UI 的非线性位移变换需要位置锚点;普通二维视口的线性位移可以省略 point。
if (input.kind.starts_with("viewport-") || input.kind.starts_with("scene-"))
&& input.point.is_none()
&& frame
@@ -515,6 +556,7 @@ impl Worker {
.as_ref()
.is_none_or(|(current, _)| *current != epoch)
{
// 没有当前 epoch 会话时惰性建立空 REPL,每次分配新会话 ID 并检查溢出,禁止复用旧输入身份。
self.next_session = match self.next_session.checked_add(1) {
Some(id) => id,
None => {
@@ -559,6 +601,7 @@ impl Worker {
} else {
session.execute_incremental(&job.source, &job.namespace, &viewports)?
};
// 业务执行可能已改 VM,但展示未完成时暂不取窗口命令;PendingInput 保存结果并只恢复展示阶段。
let commands = if session.has_pending_refresh() {
Vec::new()
} else {
@@ -576,6 +619,7 @@ impl Worker {
result,
})));
}
// 启动失败且残留挂起刷新时直接丢弃域,不能保留一个无法安全推进的 HTTP 请求。
// A failed start must never leave a request waiting on a broken VM.
self.repl = None;
}
@@ -591,6 +635,7 @@ impl Worker {
))
}
/// 恢复已挂起的请求,保持原 HTTP 响应和运行时身份直到最终完成。
pub(super) fn resume_request(
&mut self,
job: &BuildRequest,
@@ -607,6 +652,7 @@ impl Worker {
}
}
/// 继续同一 Console/事件输入的展示刷新,不再次执行其业务回调。
fn resume_input(
&mut self,
job: &BuildRequest,
@@ -615,6 +661,7 @@ impl Worker {
notify: &Notify,
) -> RequestProgress {
let viewports = match namespace.read() {
// 正在执行输入只要仍属当前提交或仍拥有权威 VM 就可收敛;两项皆失效才彻底废弃。
Ok(current)
if current.submission == input.epoch || current.runtime_session == input.id =>
{
@@ -642,6 +689,7 @@ impl Worker {
return RequestProgress::Complete(superseded(job.submission));
}
let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
// 这里只恢复 pending 展示/尺寸工作,不再调用 execute/dispatch,所以前一个量子的源副作用不会重放。
let document = session.resize_windows_incremental(&viewports, false)?;
if session.has_pending_refresh() {
return Ok(None);
@@ -671,6 +719,7 @@ impl Worker {
}
#[allow(clippy::too_many_arguments)]
/// 校验输入仍属于当前域,再一次性发布结果、元数据和原生窗口命令。
fn finish_input(
&mut self,
job: &BuildRequest,
@@ -685,6 +734,7 @@ impl Worker {
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
// 接受新 PUT 会使排队旧输入失效,但已执行的活域输入需先收敛,才能在候选失败时留下内部一致的旧程序。
let epoch = if current.submission != epoch {
if current.runtime_session != id {
drop(current);
@@ -706,6 +756,7 @@ impl Worker {
match result {
Ok(Ok((result, commands))) => {
let projected = !commands.is_empty();
// rendered 只表示此次交付带新文档,成功执行但页面未变仍可返回 value/session;不因此虚增 revision。
let rendered = result.document.is_some();
if let Some(document) = result.document {
match current.commit(epoch, document) {
@@ -731,6 +782,7 @@ impl Worker {
// Preserve the first failure of this VM; subsequent
// rejected Console input must not hide the callback cause.
// A preserved picture from another generation is unrelated.
// 同 VM 保留最初故障原因,后续“域已停止”错误不能覆盖真正回调故障;其他世代旧图不沿用故障。
let fault = (document.frame.session == id)
.then(|| document.frame.fault.clone())
.flatten()
@@ -752,6 +804,7 @@ impl Worker {
for view in document.windows.values_mut() {
view.frame.fault = document.frame.fault.clone();
}
// 输入 panic 保留最近画面但撤销运行时/命令并发布故障条,再将 self.repl 丢弃。
current.runtime_session = 0;
current.window_commands.clear();
let _ = current.commit(epoch, document);
@@ -768,6 +821,7 @@ impl Worker {
/// An accepted PUT changes the submission, but does not revoke the old
/// runtime. DELETE clears runtime_session and does revoke it.
/// 接受较新提交不等于立即销毁旧 VM;候选失败时仍需保留当前可用运行时。
pub(super) fn preserve_live_owner(&mut self, current: &NamespaceState) {
if let Some((epoch, runtime)) = &mut self.repl {
if current.runtime_session == runtime.id {
@@ -778,6 +832,7 @@ impl Worker {
}
}
/// 为完整 PUT 建立隔离候选,保留旧运行时直到新候选完整成功。
fn begin_program_request(
&mut self,
job: &BuildRequest,
@@ -807,6 +862,7 @@ impl Worker {
}
};
let id = self.next_session;
// 候选装载运行在独立 ReplSession,未成功前不修改 self.repl,旧程序仍是权威展示来源。
let started = catch_unwind(AssertUnwindSafe(|| {
endor_host::ReplSession::load_incremental(
&job.source,
@@ -817,6 +873,7 @@ impl Worker {
)
}));
let result = match started {
// 候选业务装载成功但布局未完成,整个候选移进 PendingRequest;不能把 None 当成空文档提交。
Ok(Ok((runtime, None))) => {
return RequestProgress::Pending(PendingRequest::Candidate(Box::new(runtime)));
}
@@ -830,6 +887,7 @@ impl Worker {
RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result))
}
/// 推进候选的布局与视口收敛,检查其是否已被新提交撤销。
fn resume_candidate(
&mut self,
job: &BuildRequest,
@@ -841,6 +899,7 @@ impl Worker {
Ok(current) if current.submission == job.submission => {
// Secondary native resources still belong to the old VM. Only
// the primary shell's current viewport applies to a candidate.
// 候选不沿用旧副窗口的视口;副窗口资源归旧 VM,只有主壳当前尺寸可作为新候选分配条件。
current
.viewport
.map(|size| (crate::window::MAIN_WINDOW, size))
@@ -864,6 +923,7 @@ impl Worker {
if runtime.has_pending_refresh() {
return Ok(None);
}
// 所有刷新结束仍没有 Page 文档时明确失败,不用旧显示值冒充候选自己的输出。
let document =
document.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?;
Ok(Some((document, runtime.take_window_commands()?)))
@@ -877,6 +937,7 @@ impl Worker {
RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result))
}
/// 候选成功才切换活跃 VM 和文档;失败保留先前已发布程序。
fn finish_candidate(
&mut self,
job: &BuildRequest,
@@ -895,6 +956,7 @@ impl Worker {
match result {
Ok(Ok((runtime, document, commands))) => match current.commit(job.submission, document)
{
// 写锁内先原子提交文档,再切换命令世代与活域;解锁后唤醒 GUI,新 VM 至此才取代旧 VM。
Ok(Some(revision)) => {
current.publish_window_commands(id, commands);
self.repl = Some((job.submission, runtime));
@@ -911,6 +973,7 @@ impl Worker {
failed => {
// The candidate never mutated the displayed program's heap.
// Restore that program's event epoch when a replacement fails.
// 候选失败只更新旧活域 epoch,保留其堆与页面;不存在旧域时也不会借用其他会话伪造成功。
self.preserve_live_owner(&current);
drop(current);
match failed {
@@ -925,11 +988,13 @@ impl Worker {
}
}
/// 处理隔离表达式或一次性文档编译;编译前后核对 submission,只发布仍有效的完整快照。
fn build(
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
// 隔离表达式不读写持久 REPL 堆,也不发布文档,响应明确 persistent=false。
if job.kind == RequestKind::Expression {
return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) {
Ok(Ok(value)) => json(
@@ -947,6 +1012,7 @@ fn build(
}
// Only an independent native snapshot crosses into the published state.
// A fresh VM is discarded on success, error and unwind alike.
// 一次性构建同样用独立 VM,计算锁外执行,返回后再次核对 submission 才能发布原生快照。
let result = catch_unwind(AssertUnwindSafe(|| {
endor_host::compile(&job.source, &job.namespace, job.submission)
}));
+144 -2
View File
@@ -1,3 +1,7 @@
//! 把 Aster 编译/执行与 Tessera 展示生命周期连接起来。Candidate 拥有 VM 与宿主根,ReplSession 保留实际运行中的全局环境。
//! 完整源码 PUT 使用隔离候选;Console 输入则在已有 VM 上执行,先前发生的写入不等于可以回放或事务回滚。
//! 刷新状态机遍历窗口并逐步推进布局/绘制。完成所有必要轮次才形成文档;取消会释放挂起调用并恢复未提交的 Mount。
//!
//! Compile complete source in a fresh VM or retain a worker-owned Console VM.
//! Console retains mounted native references and publishes immutable frames after successful inputs.
use anyhow::{Result, bail};
@@ -22,12 +26,14 @@ const LIMITS: ExecutionLimits = ExecutionLimits {
const MAX_AST_NODES: usize = 10_000;
const MAX_AST_DEPTH: usize = 64;
/// 把源输入编译并执行为一次独立结果,最终将可展示对象转换为检查后的文档。
pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderDocument> {
if source.len() > MAX_SOURCE_BYTES {
bail!("source exceeds the 1 MiB limit");
}
let source = EndorReader::from_text(format!("{namespace} [submission {submission}]"), source);
let plan = endor_script::parse(&source)?;
// 各 Script 根共享 AST 计数预算,不能通过拆成很多交互片段绕过整份源码节点上限。
let mut budget = AstBudget::default();
for ast in plan.roots() {
budget.visit_ast_root(ast)?;
@@ -41,6 +47,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderD
candidate.roots.push(globals);
let bridge = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?;
bridge.install_globals(&mut candidate.vm, globals)?;
// Script 多段分别执行,指令总额按片段数均分;Module 作为整体获得完整预算,避免每段都重获一百万。
let limits = match &plan {
SourcePlan::Module(_) => LIMITS,
SourcePlan::Script(_) => ExecutionLimits {
@@ -62,6 +69,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderD
SourcePlan::Script(_) => bridge
.rendered(&candidate.vm)?
.ok_or_else(|| anyhow::anyhow!("script must call ui.page().render(root)"))?,
// 模块先执行定义再调用 main;若 main 未显式 render,可将其返回值转换成展示根。
SourcePlan::Module(_) => {
let entry = candidate
.vm
@@ -83,6 +91,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderD
Some(root) => Ok(root),
None => ui_bridge::snapshot(&candidate.vm, &value),
});
// 提取快照结果先暂存,成功或失败都释放 main 返回值的拥有根,再传播快照错误。
candidate.vm.release_value(value)?;
snapshot?
}
@@ -97,6 +106,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderD
document.validate()?;
Ok(document)
})();
// 独立构建在成功与错误路径都显式释放宿主根/回收堆;返回成功文档也要求 cleanup 成功。
let cleanup = candidate.close();
match (result, cleanup) {
(Ok(document), Ok(())) => Ok(document),
@@ -106,11 +116,13 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderD
}
/// Evaluate exactly one expression in a disposable VM; never touch the published page.
/// 在隔离执行域求一个表达式结果;不会把结果作为持久 Console 环境发布。
pub fn evaluate(source: &str) -> Result<String> {
if source.len() > MAX_SOURCE_BYTES {
bail!("expression exceeds the 1 MiB limit");
}
let source = EndorReader::from_text("console expression", source);
// 隔离 evaluate 只接受一个表达式,默认编译选项不安装 ui 桥接,也不读已发布程序的 globals。
let ast = frontend::parse_source_ast(&source, frontend::ParseMode::Expression)?;
AstBudget::default().visit_ast_root(&ast)?;
let compiled = compiler::compile_ast(&ast, &source, &compiler::CompileOptions::default())
@@ -126,6 +138,7 @@ pub fn evaluate(source: &str) -> Result<String> {
.vm
.execute_with_limits(code, globals, LIMITS)
.map_err(|error| runtime_error(&candidate.vm, error))?;
// 只把标量或对象 repr 字符串交回 API,真实 Value 随一次性 VM 清理,不跨线程交付托管引用。
let rendered = match &value {
Value::Nil => "nil".to_owned(),
Value::Bool(v) => v.to_string(),
@@ -143,6 +156,7 @@ pub fn evaluate(source: &str) -> Result<String> {
}
}
/// 按 Script/Module 计划编译各根节点,为后续执行准备可加载模板。
fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result<Vec<compiler::CompiledCode>> {
compile_plan_with_globals(
source,
@@ -155,6 +169,7 @@ fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result<Vec<compiler::
)
}
/// 结合已有全局信息编译本轮输入,保留持久 Console 的名称解析上下文。
fn compile_plan_with_globals(
source: &SourceUnit,
plan: &SourcePlan,
@@ -163,6 +178,7 @@ fn compile_plan_with_globals(
// Match Aster module behavior for function forward references. Bindings are
// added in source order; every executable statement still uses Aster's own
// single-input Interactive compiler and the candidate's shared globals.
// 先收集全部函数/类声明名以支持前向引用;普通绑定仍按源码执行次序逐段加入已知全局名。
for root in plan.roots() {
if let AstRoot::Interactive(input) = root {
for statement in &input.statements {
@@ -185,6 +201,7 @@ fn compile_plan_with_globals(
compiler::compile_ast(root, source, &options)
.map_err(|error| source.annotate(error))?,
);
// 每段编译成功才把绑定/赋值名字加入下一段编译上下文,语法与代码生成都继续委托 Aster。
if let AstRoot::Interactive(input) = root {
for statement in &input.statements {
if let StmtKind::Binding(binding) = &statement.kind {
@@ -200,31 +217,40 @@ fn compile_plan_with_globals(
/// One real VM, owned only by the existing execution worker. No history replay.
/// The UI bridge shares the same managed object heap and publishes atomic frames.
/// 指定窗口尚未完成的绘制工作,文档与源续体必须保持配对。
struct PendingPaint {
window: u64,
document: RenderDocument,
work: Box<crate::source_paint::Work>,
}
/// 跨 worker 轮次的全窗口刷新状态,保存已完成草稿和当前布局/绘制,直到稳定再发布。
struct RefreshWork {
// 已完成窗口的草稿先留在本轮;所有窗口收敛后才合成一次完整发布。
// RefreshWork 先克隆最近完整文档作为草稿基线,各窗口更新暂存于此;全轮稳定前不写共享 NamespaceState。
documents: std::collections::BTreeMap<u64, RenderDocument>,
windows: std::collections::VecDeque<u64>,
current: Option<u64>,
root: Option<std::sync::Arc<crate::ui_bridge::UiNode>>,
// 续体必须保留其 owning VM;取消时显式关闭,不能靠丢弃 Rust 值。
pending: Option<Box<crate::ui_bridge::PendingLayout>>,
paint: Option<Box<PendingPaint>>,
// pass_changed 要求再跑一轮确认稳定,changed 记录整次刷新是否有可发布变化。
pass_start: bool,
pass_changed: bool,
changed: bool,
work: usize,
// 记录本轮尺寸目标,下一次 worker 轮次据此判断是否需要重定向。
viewports: Option<std::collections::BTreeMap<u64, crate::layout::Size>>,
}
/// 刷新仍在进行或已完成;完成也可能没有新文档需要发布。
enum RefreshProgress {
Pending,
Complete(Option<Box<RenderDocument>>),
}
/// 持久源运行时与其桥接会话,保存实际全局对象、代码投影、全窗口文档和挂起刷新。
pub struct ReplSession {
candidate: Candidate,
bridge: ui_bridge::UiBridge,
@@ -233,11 +259,13 @@ pub struct ReplSession {
ast_nodes: usize,
source: String,
nodes: Vec<CodeNode>,
// 回调/渲染失败后置位,拒绝继续普通业务输入;保留画面不表示堆还能安全执行。
faulted: bool,
program_root: Option<CodeNode>,
documents: std::collections::BTreeMap<u64, RenderDocument>,
refresh: Option<RefreshWork>,
}
/// 一次 Console/事件操作的标量显示结果与可选完整文档,避免直接跨线程暴露托管返回值。
pub struct ReplResult {
pub value: String,
pub document: Option<RenderDocument>,
@@ -248,6 +276,7 @@ impl ReplSession {
/// Compile once into an isolated candidate, then keep its actual heap and
/// callback environments alive after the first successful publication.
#[cfg(test)]
/// 测试用同步装载入口:建立完整候选并排空初始刷新,返回真实运行时与完成文档。
pub fn load(
source: &str,
namespace: &str,
@@ -258,6 +287,7 @@ impl ReplSession {
}
#[cfg(test)]
/// 测试用同步装载入口:建立完整候选并排空初始刷新,返回真实运行时与完成文档。
pub fn load_with_viewport(
source: &str,
namespace: &str,
@@ -275,6 +305,7 @@ impl ReplSession {
Ok((runtime, document))
}
/// 建立可由 worker 分轮推进的完整源码候选,初始布局不必在一次调用里排空。
pub fn load_incremental(
source: &str,
namespace: &str,
@@ -288,6 +319,7 @@ impl ReplSession {
.into_iter()
.collect();
let document = runtime.refresh_after_mutation(Some(&viewports))?;
// 初始刷新若已完成必须确实有 Page 文档才可接受候选;仍挂起则把整个运行时交 worker 下一量子继续。
if !runtime.has_pending_refresh() {
anyhow::ensure!(document.is_some(), "program did not publish a Page");
runtime
@@ -297,6 +329,7 @@ impl ReplSession {
Ok((runtime, document))
}
/// 建立隔离候选、安装宿主根并执行完整源码;候选尚未完成布局时不替换活跃程序。
fn prepare_program(
source: &str,
namespace: &str,
@@ -318,6 +351,7 @@ impl ReplSession {
runtime
.bridge
.set_viewport(&mut runtime.candidate.vm, viewport)?;
// 完整程序代码在候选自己的真实 globals 中执行,之后保留该堆用于事件与 Console,不通过重放历史恢复状态。
let globals = runtime.candidate.globals.expect("program globals");
let limits = if matches!(plan, SourcePlan::Script(_)) {
ExecutionLimits {
@@ -351,6 +385,7 @@ impl ReplSession {
.vm
.call_with_limits(main, &[], LIMITS)
.map_err(|error| runtime_error(&runtime.candidate.vm, error))?;
// main 已主动打开 Page 时尊重其选择;否则把返回值经展示协议打开,不能重复创建第二个默认页面。
let opened = if runtime.bridge.has_page()? {
Ok(())
} else {
@@ -364,10 +399,12 @@ impl ReplSession {
}
runtime.source = source.into();
runtime.ast_nodes = budget.nodes;
// 保存完整程序的 Sources 投影,后续 Console 追加时可移入交互记录树;不把 AST 投影当可执行堆对象。
runtime.program_root = Some(architecture(&unit, &plan, namespace, submission));
Ok(runtime)
}
/// 建立空的持久 Console 运行时,并安装当前逻辑视口输入。
pub fn new_with_viewport(id: u64, viewport: Option<crate::layout::Size>) -> Result<Self> {
let mut runtime = Self::new(id)?;
runtime
@@ -376,6 +413,7 @@ impl ReplSession {
Ok(runtime)
}
/// 建立真实持久 VM、全局字典与 UI 桥接根,尚未执行用户输入或产生窗口帧。
pub fn new(id: u64) -> Result<Self> {
let mut candidate = Candidate::new()?;
let globals = candidate.vm.new_dict()?;
@@ -398,10 +436,12 @@ impl ReplSession {
})
}
#[cfg(test)]
/// 同步执行一个 Console 输入并排空刷新,返回标量结果及可选新文档。
pub fn execute(&mut self, source: &str, namespace: &str) -> Result<ReplResult> {
self.execute_mode(source, namespace, None)
}
/// 执行一次输入的业务部分,再让展示刷新以可恢复工作跨 worker 轮次推进。
pub fn execute_incremental(
&mut self,
source: &str,
@@ -411,6 +451,7 @@ impl ReplSession {
self.execute_mode(source, namespace, Some(viewports))
}
/// 在当前真实 VM 中编译并执行新输入,再按同步或分片模式刷新展示;已发生的业务写入不会被源重放回滚。
fn execute_mode(
&mut self,
source: &str,
@@ -426,6 +467,7 @@ impl ReplSession {
if source.len() > MAX_SOURCE_BYTES {
bail!("input exceeds 1 MiB");
}
// 持久 Console 限制累计输入次数、源码文本和稍后的 AST 总节点数,用户需显式重置才能重新获得预算。
if self.inputs >= 256 || self.source.len() + source.len() + 80 > MAX_SOURCE_BYTES {
bail!("console session exceeds 256 inputs or 1 MiB; reset the session to continue");
}
@@ -442,6 +484,7 @@ impl ReplSession {
bail!("console session exceeds 10000 AST nodes; reset the session to continue");
}
let globals = self.candidate.globals.expect("live console globals");
// 从实际 globals 枚举已有名称供增量编译,支持前次输入创建的真实对象,而非靠解析历史猜名称。
let names = self
.candidate
.vm
@@ -461,6 +504,7 @@ impl ReplSession {
self.bridge.discard_pending(&mut self.candidate.vm)?;
self.bridge
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
// 解析/编译全部成功后才记本轮输入与源码;之后运行失败仍可能保留此前语句副作用,不做事务回滚。
self.inputs += 1;
self.ast_nodes += budget.nodes;
if let Some(root) = self.program_root.take() {
@@ -469,6 +513,7 @@ impl ReplSession {
if !self.source.is_empty() && !self.source.ends_with('\n') {
self.source.push('\n');
}
// 交互源码前加 Console input 注释分隔,投影节点行号再整体平移到累计 transcript 中。
let offset = line_starts(&self.source).len();
self.source
.push_str(&format!("// Console input {}\n", self.inputs));
@@ -501,6 +546,7 @@ impl ReplSession {
self.candidate.vm.release(code)?;
let value = executed?;
// Keep the actual last result accessible as `_`, including unnamed UI objects.
// 绑定/赋值语句展示其新变量实际值,普通表达式展示返回值;不是仅显示该语句执行的 Unit。
let display = if let AstRoot::Interactive(input) = ast {
match &input.statements[0].kind {
StmtKind::Binding(binding) => self
@@ -518,6 +564,7 @@ impl ReplSession {
} else {
value.clone()
};
// 先算 repr 并把实际最后值写入全局 _,保留未命名 UI 对象;随后释放 execute 返回根,错误也逐项传播。
let repr = value_repr(&self.candidate.vm, &display);
let retained =
self.candidate
@@ -540,6 +587,7 @@ impl ReplSession {
input: self.inputs,
})
})();
// 失败丢弃未发布展示请求和原生命令,保留已执行业务写入,并在代码投影上明确标记非事务语义。
if result.is_err() {
self.take_window_commands()?;
self.bridge.discard_pending(&mut self.candidate.vm)?;
@@ -548,6 +596,7 @@ impl ReplSession {
.push_str(" (error; earlier statements may remain live)");
}
}
// 未完成只读续体仍持有临时结果,不在中途执行完整 reclaim;等刷新结束后再回收不可达宿主根。
if !self.has_pending_refresh() {
self.bridge
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
@@ -555,14 +604,17 @@ impl ReplSession {
result
}
/// 查询调度器是否仍有需要主动推进的源协程工作。
pub fn has_cooperative_tasks(&self) -> bool {
self.bridge.has_cooperative_tasks()
}
/// 检查是否存在尚未结束的视觉刷新工作,决定能否接收新的业务变更。
pub fn has_pending_refresh(&self) -> bool {
self.refresh.is_some()
}
/// 拒绝在未完成的只读刷新内部再次进行业务变更,防止同一 VM 重入。
fn require_idle_refresh(&self) -> Result<()> {
anyhow::ensure!(
!self.has_pending_refresh(),
@@ -571,10 +623,12 @@ impl ReplSession {
Ok(())
}
/// 在业务变化后开始或继续展示刷新,保证后续输入不会进入未结束的只读视觉调用。
fn refresh_after_mutation(
&mut self,
viewports: Option<&std::collections::BTreeMap<u64, crate::layout::Size>>,
) -> Result<Option<RenderDocument>> {
// 无增量视口参数的测试/同步入口排空状态机;worker 提供目标视口时只推进一次并记录重定向依据。
let Some(viewports) = viewports else {
return self.refresh_document();
};
@@ -588,6 +642,7 @@ impl ReplSession {
})
}
/// 同步排空当前刷新状态机,直到全窗口收敛或出现错误。
fn refresh_document(&mut self) -> Result<Option<RenderDocument>> {
loop {
match self.poll_refresh(false)? {
@@ -599,7 +654,9 @@ impl ReplSession {
}
}
/// 推进一次刷新状态机,保存挂起的 Work;只有全窗口收敛时才生成可发布文档。
fn poll_refresh(&mut self, record_yield: bool) -> Result<RefreshProgress> {
// 从 self 取出工作以便可变推进,挂起时放回;首次从已发布各窗口文档克隆初始草稿。
let mut work = self.refresh.take().unwrap_or_else(|| RefreshWork {
documents: self.documents.clone(),
windows: Default::default(),
@@ -627,6 +684,7 @@ impl ReplSession {
self.refresh = Some(work);
Ok(RefreshProgress::Pending)
}
// 收敛后统一组合文档,再恢复主窗口选择;收尾失败也标故障,不把失败草稿当已发布帧。
Ok(true) => {
let result = self.finish_refresh(work);
self.bridge.select_window(crate::window::MAIN_WINDOW)?;
@@ -637,6 +695,8 @@ impl ReplSession {
}
Err(error) => {
self.faulted = true;
// 错误时必须显式取消 paint/layout 的 owning VM 续体并恢复检查点,不能仅丢弃 Rust 工作结构。
// 进入 paint 后只恢复其原续体和配对文档,不重复布局,也不重跑输入业务语句。
if let Some(paint) = work.paint.take() {
self.bridge.cancel_paint_frame(
&mut self.candidate.vm,
@@ -654,7 +714,10 @@ impl ReplSession {
}
}
/// 执行一个刷新步骤:刷新绑定、选择窗口、准备快照、推进布局或绘制,并决定是否需要下一轮。
fn step_refresh(&mut self, work: &mut RefreshWork) -> Result<bool> {
// 阶段一:新一轮先刷新绑定,再取得稳定窗口遍历顺序。
// 每轮先使属性绑定达到固定点,再取窗口顺序;生命周期或绑定可能改变窗口/页面结构,因此需要多轮确认。
if work.pass_start {
self.bridge.refresh_bindings(&mut self.candidate.vm)?;
work.windows = self.bridge.window_ids()?.into();
@@ -663,11 +726,13 @@ impl ReplSession {
work.pass_start = false;
return Ok(false);
}
// 阶段二:逐个选择窗口;一轮发生变化时再扫描一轮,直到依赖不再引起新帧。
let Some(id) = work.current else {
if let Some(id) = work.windows.pop_front() {
work.current = Some(id);
return Ok(false);
}
// 任何窗口本轮生成新帧,就再扫描一轮检查依赖和挂载回调是否又引起变化,直到整轮无变化。
if work.pass_changed {
work.pass_start = true;
return Ok(false);
@@ -675,6 +740,7 @@ impl ReplSession {
return Ok(true);
};
self.bridge.select_window(id)?;
// 已进入 paint 的窗口直接恢复原绘制续体,不重放业务回调或布局。
if let Some(paint) = work.paint.take() {
let PendingPaint {
window,
@@ -686,6 +752,7 @@ impl ReplSession {
.resume_paint_frame(&mut self.candidate.vm, *paint_work)?;
return Self::accept_window_paint(work, window, document, progress);
}
// 阶段三:恢复旧布局,或在快照/依赖确实变化时建立新布局。
let progress = if let Some(pending) = work.pending.take() {
self.bridge
.resume_layout_frame(&mut self.candidate.vm, *pending)?
@@ -704,6 +771,7 @@ impl ReplSession {
}
crate::ui_bridge::LayoutProgress::Complete(frame) => *frame,
};
// 阶段四:几何完成后建立候选文档,再运行 paint;这里尚未向 GUI 发布。
let mut document = RenderDocument {
windows: Default::default(),
source: self.source.clone(),
@@ -720,6 +788,7 @@ impl ReplSession {
};
document.frame.session = self.id;
document.validate()?;
// 跨所有窗口和稳定轮次累计布局工作,空帧也计一,防止持续无几何变化请求绕过收敛预算。
work.work += document.frame.mounts.len().max(1);
if work.work > 200_000 {
bail!("layout dependencies did not stabilize within the rendering work budget");
@@ -732,6 +801,7 @@ impl ReplSession {
Self::accept_window_paint(work, id, document, progress)
}
/// 接收指定窗口的绘制进度,将挂起 Work 保留或把完整文档存入本轮结果。
fn accept_window_paint(
work: &mut RefreshWork,
id: u64,
@@ -749,6 +819,7 @@ impl ReplSession {
crate::source_paint::Progress::Complete(draft) => {
document.frame = draft.frame;
document.validate()?;
// 只有整个绘制 Complete 且验证通过才替换当前窗口草稿;Pending 保留配对文档和 Work,不对 GUI 可见。
work.documents.insert(id, document);
work.pass_changed = true;
work.changed = true;
@@ -757,7 +828,9 @@ impl ReplSession {
Ok(false)
}
/// 收敛后组合所有窗口文档,保留本轮完整结果;没有变化时不额外制造发布。
fn finish_refresh(&mut self, mut work: RefreshWork) -> Result<Option<RenderDocument>> {
// 收敛后完成已关闭窗口退役并删其旧文档;没有实际变化时不制造额外 revision。
self.bridge.finish_closed(&mut self.candidate.vm)?;
let ids = self.bridge.window_ids()?;
work.documents.retain(|id, _| ids.contains(id));
@@ -778,12 +851,14 @@ impl ReplSession {
..Default::default()
});
main.source = self.source.clone();
// 优先采用源码与当前 transcript 相同的窗口代码投影,避免主窗口未重绘时 Sources 仍展示旧输入树。
main.root = work
.documents
.values()
.find(|document| document.source == self.source)
.and_then(|document| document.root.clone())
.or(main.root);
// 副窗口统一挂到主文档 windows 一层,子文档自身不再嵌套副窗口,最终 validate 检查完整跨线程值。
main.windows = work
.documents
.iter()
@@ -796,6 +871,7 @@ impl ReplSession {
}
#[cfg(test)]
/// 同步更新多个窗口尺寸,复用同一运行时并完成必要刷新。
pub fn resize_windows(
&mut self,
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
@@ -803,6 +879,7 @@ impl ReplSession {
self.resize_windows_incremental(viewports, true)
}
/// 用最新视口替换旧目标,显式取消未完成布局,同时保留已完成窗口结果和已发生的业务效果。
fn retarget_refresh(
&mut self,
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
@@ -810,6 +887,7 @@ impl ReplSession {
let Some(mut work) = self.refresh.take() else {
return Ok(());
};
// 记录实际取消了哪类续体用于诊断;已完成窗口草稿和已发生业务副作用不会因此回滚。
let cancelled = work.pending.is_some();
let cancelled_paint = work.paint.is_some();
if let Some(paint) = work.paint.take()
@@ -835,6 +913,7 @@ impl ReplSession {
// Keep completed window drafts and callback effects. Only the pending
// window's speculative Mount state was restored. A new external target
// begins a fresh convergence budget, not a continuation of a bad cycle.
// 重新从全窗口稳定轮开始,清当前选择/旧根并重置此次外部尺寸目标的收敛工作预算;草稿 documents 保留。
work.windows.clear();
work.current = None;
work.root = None;
@@ -846,6 +925,7 @@ impl ReplSession {
Ok(())
}
/// 把各窗口最新尺寸送入同一运行时;视口更新可跨轮次挂起,也可由调用方同步排空。
pub fn resize_windows_incremental(
&mut self,
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
@@ -854,6 +934,7 @@ impl ReplSession {
if self.faulted {
bail!("runtime stopped after a callback or rendering failure");
}
// 仅目标视口集合确实不同才取消重定向;同一目标的下一 worker 轮继续原续体,不重复从头布局。
let retarget = self
.refresh
.as_ref()
@@ -862,6 +943,7 @@ impl ReplSession {
if retarget {
self.retarget_refresh(viewports)?;
}
// 同目标 drain=true 同步排空旧刷新并保存完整结果;drain=false 只推进一量子,不能先执行新的尺寸变更。
let previous = if self.has_pending_refresh() && !retarget {
if drain {
let result = self.refresh_document();
@@ -874,6 +956,7 @@ impl ReplSession {
};
let ids = self.bridge.window_ids()?;
let mut changed = false;
// 仅更新桥接仍注册的窗口;旧 GUI 尺寸记录不应在新域里隐式创建窗口资源。
for (id, viewport) in viewports {
if ids.contains(id) {
self.bridge.select_window(*id)?;
@@ -883,6 +966,7 @@ impl ReplSession {
}
}
self.bridge.select_window(crate::window::MAIN_WINDOW)?;
// 尺寸没变且无挂起工作则直接返回先前已完成文档,避免制造无意义新帧。
if !changed && !self.has_pending_refresh() {
return Ok(previous);
}
@@ -900,6 +984,7 @@ impl ReplSession {
}
}
/// 在空闲轮次推进一个刷新步骤,完成时执行统一结果收尾。
fn advance_idle_refresh(&mut self) -> Result<Option<RenderDocument>> {
let result = self.poll_refresh(true).map(|progress| match progress {
RefreshProgress::Pending => None,
@@ -908,6 +993,7 @@ impl ReplSession {
self.finish_refresh_update(result)
}
/// 统一处理刷新失败、未发布状态清理和运行时引用回收。
fn finish_refresh_update(
&mut self,
result: Result<Option<RenderDocument>>,
@@ -925,11 +1011,13 @@ impl ReplSession {
/// Deliver ready work on the owning VM worker, never from the GUI or a
/// provider thread. A failed callback stops the same shared fault domain.
/// 同步推进可交付任务和必要刷新,供不使用分片 worker 的调用方使用。
pub fn poll_tasks(&mut self) -> Result<Option<RenderDocument>> {
self.poll_tasks_mode(None)
}
/// Idle delivery starts at most one callback, then parks its layout work.
/// 按有界任务交付量推进,再以可挂起刷新处理变化。
pub fn poll_tasks_incremental(
&mut self,
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
@@ -937,14 +1025,17 @@ impl ReplSession {
self.poll_tasks_mode(Some(viewports))
}
/// 按本轮预算交付已完成任务或推进协程,再对其业务变化执行相同的展示刷新流程。
fn poll_tasks_mode(
&mut self,
viewports: Option<&std::collections::BTreeMap<u64, crate::layout::Size>>,
) -> Result<Option<RenderDocument>> {
// 异步回调不能插入只读布局/绘制续体中,即使后台邮箱已有结果也等刷新空闲边界。
if self.has_pending_refresh() {
return Ok(None);
}
if self.faulted {
// 故障域不执行任何新用户任务,仅取消已有任务并在确实移除工作后回收引用。
if self.bridge.cancel_tasks(&mut self.candidate.vm)? > 0 {
self.bridge
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
@@ -953,6 +1044,7 @@ impl ReplSession {
}
let mut completed = false;
let result = (|| {
// worker 增量轮至多启动一个任务推进,给前台输入和窗口刷新公平机会;同步路径单轮允许64项。
let limit = if viewports.is_some() { 1 } else { 64 };
if self.bridge.poll_tasks(&mut self.candidate.vm, limit)? == 0 {
return Ok(None);
@@ -966,6 +1058,7 @@ impl ReplSession {
self.take_window_commands()?;
self.bridge.discard_pending(&mut self.candidate.vm)?;
}
// 任务推进或失败后才需回收,并且不能在仍有只读续体时清理其临时根。
if (completed || result.is_err()) && !self.has_pending_refresh() {
self.bridge
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?;
@@ -973,12 +1066,14 @@ impl ReplSession {
result
}
/// 取得本轮已稳定运行时的窗口命令,挂起刷新时禁止提前取走。
pub fn take_window_commands(&self) -> Result<Vec<crate::window::Command>> {
self.require_idle_refresh()?;
self.bridge.take_window_commands(&self.candidate.vm)
}
#[cfg(test)]
/// 测试用单窗口尺寸更新入口,遵循与原生尺寸发布相同的校验和刷新规则。
pub fn resize(
&mut self,
viewport: Option<crate::layout::Size>,
@@ -1000,6 +1095,7 @@ impl ReplSession {
result
}
/// 关闭尚未完成的刷新任务,释放续体并恢复未提交 Mount;不能只丢弃 Work 结构。
fn cancel_pending_refresh(&mut self) -> Result<()> {
if let Some(mut work) = self.refresh.take() {
if let Some(paint) = work.paint.take() {
@@ -1017,18 +1113,22 @@ impl ReplSession {
Ok(())
}
/// 有界关闭任务、挂载和窗口,释放运行时根,并报告清理结果。
pub fn shutdown(&mut self) -> Result<(usize, usize, usize)> {
// 先取消视觉续体恢复挂载检查点,再判断原域是否允许正常关闭回调,随后标故障禁止后续普通执行。
self.cancel_pending_refresh()?;
let callbacks = !self.faulted;
self.faulted = true;
self.bridge.shutdown(&mut self.candidate.vm, callbacks)
}
/// 撤销指定窗口的运行时展示资源,防止后续刷新重新发布它。
pub fn retire_window(&mut self, id: u64) -> Result<()> {
self.require_idle_refresh()?;
// This path is exclusively host cleanup after the domain has stopped.
// No language functions, layout, paint or lifecycle hooks may run here.
self.faulted = true;
// 专用退役只清宿主资源,不调源生命周期;删除已缓存文档和窗口命令,避免后续重发该副窗口。
self.bridge.retire_window(&mut self.candidate.vm, id)?;
self.documents.remove(&id);
let _ = self.take_window_commands()?;
@@ -1036,23 +1136,28 @@ impl ReplSession {
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)
}
/// 查询当前运行时是否仍接受对应的窗口或拖放目标。
pub fn accepts_tab_drop(&self, input: &crate::events::Input) -> bool {
self.bridge.accepts_tab_drop(&self.candidate.vm, input)
}
/// 查询当前运行时是否仍接受对应的窗口或拖放目标。
pub fn accepts_window(&self, id: u64) -> bool {
self.bridge.accepts_window(id)
}
/// 报告该运行时是否已经停止正常业务执行,供 worker 选择故障清理路径。
pub fn is_faulted(&self) -> bool {
self.faulted
}
#[cfg(test)]
/// 同步分发一次已校验输入并完成展示刷新。
pub fn dispatch(&mut self, input: &crate::events::Input) -> Result<ReplResult> {
self.dispatch_mode(input, None)
}
/// 分发一次输入回调,再把视觉刷新交给 worker 分片推进。
pub fn dispatch_incremental(
&mut self,
input: &crate::events::Input,
@@ -1061,6 +1166,7 @@ impl ReplSession {
self.dispatch_mode(input, Some(viewports))
}
/// 在正确窗口上下文执行输入回调,再刷新展示;故障时停止受影响域并清理未完成工作。
fn dispatch_mode(
&mut self,
input: &crate::events::Input,
@@ -1070,12 +1176,14 @@ impl ReplSession {
if self.faulted {
bail!("runtime stopped after a callback or rendering failure");
}
// 即使绕过 HTTP 入口直接调用,也再校验会话身份;共享窗口数值 ID 不提供跨 VM 权限。
if input.session != self.id {
bail!("event belongs to another runtime session");
}
if !self.accepts_tab_drop(input) {
bail!("drop targets an obsolete presentation");
}
// 将 ui.window/viewport 等隐式上下文切到事件所属窗口,分发后统一恢复主窗口。
self.bridge.select_window(input.window)?;
let result = (|| {
self.bridge.dispatch(&mut self.candidate.vm, input)?;
@@ -1095,6 +1203,7 @@ impl ReplSession {
self.bridge.select_window(crate::window::MAIN_WINDOW)?;
if result.is_err() {
self.take_window_commands()?;
// 回调失败属于共享 VM 故障,不只关闭单个按钮;丢弃未发布命令与 pending 请求,但不重放已执行写入。
self.faulted = true;
self.bridge.discard_pending(&mut self.candidate.vm)?;
}
@@ -1107,11 +1216,14 @@ impl ReplSession {
}
impl Drop for ReplSession {
/// 析构兜底取消会话中未完成的布局/绘制续体;需要观察清理错误或运行退出钩子的路径仍须显式 shutdown。
fn drop(&mut self) {
// Drop 最后兜底取消未完成视觉工作,错误无法从析构返回;需要报告清理错误的路径必须显式 shutdown。
let _ = self.cancel_pending_refresh();
}
}
/// 递归修正代码投影的源行号,使拼接输入仍可定位到原始文本。
fn shift_node_lines(node: &mut CodeNode, offset: usize) {
node.start_line += offset;
node.end_line += offset;
@@ -1119,6 +1231,7 @@ fn shift_node_lines(node: &mut CodeNode, offset: usize) {
shift_node_lines(child, offset);
}
}
/// 把 VM 返回值转换为 Console 的可显示表示,不让借用的托管引用跨线程。
fn value_repr(vm: &Vm, value: &Value) -> Result<String> {
Ok(match value {
Value::Nil => "nil".into(),
@@ -1129,6 +1242,7 @@ fn value_repr(vm: &Vm, value: &Value) -> Result<String> {
})
}
/// 拥有 VM、全局字典与宿主保留根;关闭时按顺序释放,失败也必须收尾。
struct Candidate {
vm: Vm,
roots: Vec<ObjectRef>,
@@ -1136,6 +1250,7 @@ struct Candidate {
}
impl Candidate {
/// 创建独立 VM,先不保留任何宿主根或全局字典;后续安装失败也由该候选负责清理。
fn new() -> Result<Self> {
Ok(Self {
vm: Vm::new()?,
@@ -1144,10 +1259,13 @@ impl Candidate {
})
}
/// 释放候选拥有的宿主根并清理 VM;通过显式关闭保证错误也能被报告。
fn close(&mut self) -> Result<()> {
// 清全局字典先断业务对象可达根,再逆序释放宿主分配根;最后执行循环回收并验证对象计数归零。
if let Some(globals) = self.globals.take() {
self.vm.dict_clear(globals)?;
}
// pop 逐项移出确保再次 close 不重复释放已成功处理的根;错误仍交显式关闭调用方报告。
while let Some(root) = self.roots.pop() {
self.vm.release(root)?;
}
@@ -1160,14 +1278,16 @@ impl Candidate {
}
impl Drop for Candidate {
/// 析构调用 Candidate.close 释放候选根和 VM,覆盖提前返回/异常展开;析构不能向调用者报告清理错误。
fn drop(&mut self) {
// Explicit close reports accounting errors; dropping the fresh VM is
// also the final cleanup boundary on every early return or unwind.
// 显式 close 可以报告引用计数/清理错误;Drop 则保证提前返回和异常展开仍经过最终清理边界。
let _ = self.close();
}
}
/// 把 VM 错误与当前源位置/调用栈组合成用户可读的诊断。
pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error {
// 错误发生后立即读取 VM traceback,保留函数、源文件与指令偏移;无栈信息时直接保留原 VmError。
let locations = vm
.last_traceback()
.iter()
@@ -1187,6 +1307,7 @@ pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error {
}
#[derive(Default)]
/// 编译前的语法节点数量/嵌套预算,限制输入结构复杂度。
struct AstBudget {
depth: usize,
nodes: usize,
@@ -1194,7 +1315,9 @@ struct AstBudget {
impl Visitor for AstBudget {
type Error = anyhow::Error;
/// 更新源结构遍历的深度/数量预算,拒绝超出允许范围的语法树。
fn enter_node(&mut self, _span: Span) -> Result<()> {
// 每次进入节点累计总数和当前深度;leave 只归还深度,整份输入总量不会因退出子树重置。
self.depth += 1;
self.nodes += 1;
if self.depth > MAX_AST_DEPTH || self.nodes > MAX_AST_NODES {
@@ -1202,17 +1325,20 @@ impl Visitor for AstBudget {
}
Ok(())
}
/// 离开当前语法节点时归还嵌套深度;累计节点数保留,不能通过退出子树绕过总量预算。
fn leave_node(&mut self) {
self.depth -= 1;
}
}
/// 把输入语法结构转换为 Sources 工具使用的 CodeNode 层次,保存原始源码位置。
fn architecture(
source: &SourceUnit,
plan: &SourcePlan,
namespace: &str,
submission: u64,
) -> CodeNode {
// CodeNode ID 由 namespace/submission 和本次递增序号构成,只是 Sources 投影身份,不是托管对象地址。
let mut tree = Architecture {
line_starts: line_starts(source.text()),
next_id: 0,
@@ -1234,6 +1360,7 @@ fn architecture(
}
}
let roots = plan.roots();
// 整体范围取首根起点至末根终点,Script 的多个交互片段在原始文本中的 span 已恢复偏移。
let span = Span::new(
roots.first().map_or(0, |root| root.span().start),
roots.last().map_or(0, |root| root.span().end),
@@ -1246,8 +1373,10 @@ fn architecture(
tree.node(namespace.to_owned(), kind, span, children)
}
/// 建立源文本每行起点表,供字节 span 转换为行列位置。
fn line_starts(source: &str) -> Vec<usize> {
let bytes = source.as_bytes();
// 按字节扫描行终止符,CRLF 仅在 LF 记一次,孤立 CR 也换行;空文本仍保留第1行起点0。
let mut starts = vec![0];
for (offset, byte) in bytes.iter().enumerate() {
if *byte == b'\n' || (*byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n')) {
@@ -1257,6 +1386,7 @@ fn line_starts(source: &str) -> Vec<usize> {
starts
}
/// 构造 Sources 语法投影所需的源码与定位辅助状态。
struct Architecture {
line_starts: Vec<usize>,
next_id: usize,
@@ -1264,9 +1394,11 @@ struct Architecture {
}
impl Architecture {
/// 分配本投影内节点 ID,并将半开字节 span 映射为1基起止行,组装 CodeNode。
fn node(&mut self, name: String, kind: &str, span: Span, children: Vec<CodeNode>) -> CodeNode {
let id = self.next_id;
self.next_id += 1;
// partition_point 返回不大于 offset 的行首数量,天然得到1基行号;span.end 是半开端,先减1找最后实际字符行。
let line = |offset| self.line_starts.partition_point(|start| *start <= offset);
CodeNode {
id: format!("{}:ast-{id}", self.identity),
@@ -1278,10 +1410,12 @@ impl Architecture {
}
}
/// 区分函数与类声明;类下先投影字段,再投影方法。
fn declaration(&mut self, declaration: &Decl) -> CodeNode {
match &declaration.kind {
DeclKind::Function(function) => self.function(function),
DeclKind::Class(class) => {
// 类投影先列字段再列方法;只复制显示名称和范围,不把声明 AST 当可执行对象保存。
let mut children: Vec<_> = class
.fields
.iter()
@@ -1293,7 +1427,9 @@ impl Architecture {
}
}
/// 将函数声明及其体内语句投影为一个带源码范围的 function 节点。
fn function(&mut self, function: &frontend::ast::Function) -> CodeNode {
// 函数投影按函数体语句顺序递归展开,不单独生成参数节点;展示范围仍覆盖完整函数声明。
let children = function
.body
.statements
@@ -1308,8 +1444,10 @@ impl Architecture {
)
}
/// 按赋值、返回、表达式和声明种类构建 Sources 层次,保留真实子表达式关系。
fn statement(&mut self, statement: &Stmt) -> CodeNode {
match &statement.kind {
// 成员赋值投影两个子表达式:目标对象和右值,成员名作为本节点显示名称。
StmtKind::MemberAssignment(assignment) => {
let object = self.expression(&assignment.object);
let value = self.expression(&assignment.value);
@@ -1351,12 +1489,15 @@ impl Architecture {
}
}
/// 递归投影表达式层次,用简短文本作显示名,并保留完整原始 span。
fn expression(&mut self, expr: &Expr) -> CodeNode {
let (name, kind, children) = match &expr.kind {
ExprKind::Name(name) => (name.clone(), "name", vec![]),
ExprKind::Number(value) => (value.clone(), "number", vec![]),
// 字符串展示名最多40个 Unicode 字符,但 span 保留整段字面量,截断标签不影响源码选择范围。
ExprKind::String(value) => (value.chars().take(40).collect(), "string", vec![]),
ExprKind::Symbol(name) => (name.text.clone(), "symbol", vec![]),
// 当前代码投影把 Unary 和 Await 共用 unary/negate 显示标签;这是展示简化,不表示 await 执行成取负。
ExprKind::Unary { operand, .. } | ExprKind::Await(operand) => {
("negate".into(), "unary", vec![self.expression(operand)])
}
@@ -1369,6 +1510,7 @@ impl Architecture {
(name.text.clone(), "member", vec![self.expression(object)])
}
ExprKind::Call { callee, arguments } => {
// 调用目标作为首个孩子,随后按原顺序列实参,Sources 可以区分调用表达式和参数层次。
let mut children = vec![self.expression(callee)];
children.extend(arguments.iter().map(|expr| self.expression(expr)));
("call".into(), "call", children)
File diff suppressed because it is too large Load Diff
+28
View File
@@ -1,3 +1,6 @@
//! 区分完整 Module 与顶层 Script,并保留输入源码位置。
//! 解析包装或片段拼接产生的 span 偏移要递归修正,保证诊断和 Sources 视图仍指向用户原始行列。
//!
//! Feed a source snapshot to Aster's existing module or interactive entry points.
//! Statement syntax, tokens and ASTs all remain Aster-owned. The host only batches
//! complete interactive inputs, just as a REPL feeds consecutive inputs to a VM.
@@ -7,12 +10,14 @@ use frontend::{
ast::{AstRoot, Decl, DeclKind, Expr, ExprKind, InteractiveInput, Stmt, StmtKind},
};
/// 待编译输入是完整模块还是顶层脚本;两者保留不同根结构但使用统一后续接口。
pub enum SourcePlan {
Module(AstRoot),
Script(Vec<AstRoot>),
}
impl SourcePlan {
/// 按输入计划返回需要编译的语法根,隐藏 Script 与 Module 组织差异。
pub fn roots(&self) -> &[AstRoot] {
match self {
Self::Module(root) => std::slice::from_ref(root),
@@ -21,21 +26,26 @@ impl SourcePlan {
}
}
/// 优先解析完整模块,否则按可完成的交互输入分段为 Script,并将 AST/诊断位置映回原文。
pub fn parse(source: &SourceUnit) -> Result<SourcePlan> {
// 完整模块优先;只有模块解析失败才退回连续交互输入模式,宿主不自行实现 Endor 语法。
if let Ok(module) = frontend::parse_source_ast(source, ParseMode::Module) {
return Ok(SourcePlan::Module(module));
}
// 一次词法分析获得原始字节 span,候选分段依赖 token 之间的实际换行而非直接按行切字符串。
let tokens = frontend::lex_source(source)?;
let mut roots = Vec::new();
let mut start = 0;
let mut depth = 0_i64;
for (index, token) in tokens.iter().enumerate() {
// 追踪圆/花括号嵌套,括号内换行不能直接结束输入;最终语法是否合法仍交给 Aster。
match token.kind {
TokenKind::LeftParen | TokenKind::LeftBrace => depth += 1,
TokenKind::RightParen | TokenKind::RightBrace => depth -= 1,
_ => {}
}
let end = token.span.end;
// 末尾 token 总是尝试解析;非末尾要求顶层换行,且下一 token 不像成员访问、调用或算术续接。
let boundary = tokens.get(index + 1).is_none_or(|next| {
depth == 0
&& source.text()[end..next.span.start].contains(['\r', '\n'])
@@ -52,25 +62,30 @@ pub fn parse(source: &SourceUnit) -> Result<SourcePlan> {
if !boundary {
continue;
}
// 当前片段从上一次成功边界 start 开始,保留期间的空白与注释;解析器看到的是片段局部字节位置。
match frontend::parse_ast(&source.text()[start..end], ParseMode::Interactive) {
Ok(mut root) => {
let AstRoot::Interactive(input) = &mut root else {
unreachable!("interactive mode");
};
// 成功后递归加回 start,把整个 AST 的局部 span 转成原文件位置,再推进下一段起点。
shift_input(input, start);
roots.push(root);
start = end;
}
// An incomplete interactive input can continue on the next line.
// Only Aster decides whether its combined syntax is valid.
// 错误恰在片段末尾且还有 token 时,视为可能未完成的交互输入,保持 start 不动继续拼接。
Err(diagnostic) if index + 1 < tokens.len() && diagnostic.span.start == end - start => {
}
Err(mut diagnostic) => {
// 其他语法错误立即恢复原始偏移,再用 SourceUnit 添加原文件来源与行列诊断。
shift_span(&mut diagnostic.span, start);
return Err(source.annotate(diagnostic).into());
}
}
}
// 空输入不能静默产生空计划;再次委托交互入口给出解析器自己的空输入诊断与来源。
if roots.is_empty() {
// Produce Aster's original empty-input diagnostic and provenance.
frontend::parse_source_ast(source, ParseMode::Interactive)?;
@@ -78,18 +93,23 @@ pub fn parse(source: &SourceUnit) -> Result<SourcePlan> {
Ok(SourcePlan::Script(roots))
}
/// 为单个半开字节范围加回片段起始偏移,不处理其所属节点的子节点。
fn shift_span(span: &mut Span, offset: usize) {
// span 采用原文本字节偏移,start/end 同增 offset 保持长度;不是按字符数或行数偏移。
span.start += offset;
span.end += offset;
}
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
fn shift_input(input: &mut InteractiveInput, offset: usize) {
shift_span(&mut input.span, offset);
// 根范围之外,逐条语句递归处理所有内层节点,避免错误只在顶层位置正确。
for statement in &mut input.statements {
shift_statement(statement, offset);
}
}
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
fn shift_statement(statement: &mut Stmt, offset: usize) {
shift_span(&mut statement.span, offset);
match &mut statement.kind {
@@ -103,6 +123,7 @@ fn shift_statement(statement: &mut Stmt, offset: usize) {
shift_span(&mut assignment.name.span, offset);
shift_expression(&mut assignment.value, offset);
}
// 成员赋值同时包含目标对象表达式、成员名和右值,三部分都要恢复原始位置。
StmtKind::MemberAssignment(assignment) => {
shift_span(&mut assignment.span, offset);
shift_span(&mut assignment.name.span, offset);
@@ -120,10 +141,12 @@ fn shift_statement(statement: &mut Stmt, offset: usize) {
}
}
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
fn shift_declaration(declaration: &mut Decl, offset: usize) {
shift_span(&mut declaration.span, offset);
match &mut declaration.kind {
DeclKind::Function(function) => shift_function(function, offset),
// 类的名称、基类、字段类型和方法均保存独立 span,不能仅移动声明外层范围。
DeclKind::Class(class) => {
shift_span(&mut class.span, offset);
shift_span(&mut class.name.span, offset);
@@ -142,10 +165,12 @@ fn shift_declaration(declaration: &mut Decl, offset: usize) {
}
}
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
fn shift_function(function: &mut frontend::ast::Function, offset: usize) {
shift_span(&mut function.span, offset);
shift_span(&mut function.name.span, offset);
shift_span(&mut function.return_type.span, offset);
// 参数自身、名称、类型各有范围;函数返回类型和函数体范围也独立移动。
for parameter in &mut function.parameters {
shift_span(&mut parameter.span, offset);
shift_span(&mut parameter.name.span, offset);
@@ -157,9 +182,11 @@ fn shift_function(function: &mut frontend::ast::Function, offset: usize) {
}
}
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
fn shift_expression(expression: &mut Expr, offset: usize) {
shift_span(&mut expression.span, offset);
match &mut expression.kind {
// 这些叶节点只用表达式外层 span;Symbol/Member 的名称还保存额外 span,需继续调整。
ExprKind::Name(_) | ExprKind::Number(_) | ExprKind::String(_) => {}
ExprKind::Symbol(name) => shift_span(&mut name.span, offset),
ExprKind::Unary { operand, .. } | ExprKind::Await(operand) => {
@@ -173,6 +200,7 @@ fn shift_expression(expression: &mut Expr, offset: usize) {
shift_expression(object, offset);
shift_span(&mut name.span, offset);
}
// 调用目标可继续是成员/调用等复合表达式;逐个递归实参而非只改整个 Call 的范围。
ExprKind::Call { callee, arguments } => {
shift_expression(callee, offset);
for argument in arguments {

Some files were not shown because too many files have changed in this diff Show More