diff --git a/Cargo.toml b/Cargo.toml index a7571f0..0c9ef48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/docs/code-comments.md b/docs/code-comments.md index ebb996d..5572e5a 100644 --- a/docs/code-comments.md +++ b/docs/code-comments.md @@ -1,6 +1,6 @@ # Tessera 中文源码注释与阅读索引 -本轮只为 Tessera 主项目补充注释,Aster 子模块固定版本与内容均沿用主线。注释按模块职责、类型/函数入口、关键字段、执行阶段和失败/取消路径组织;没有改变接口、算法、常量或资源预算。 +本轮进一步细化 Tessera 自有代码的中文注释:覆盖所有 Rust 模块、运行时/API 测试、内置起始页、45 个 Endor 示例、Cargo 依赖配置及两个工具脚本。说明聚焦具体输入/输出、状态归属、计算步骤、依赖观察、取消/失败路径和资源预算;不修改接口、算法、常量或非注释程序文本。Aster 是独立维护的 vendor 子模块,其固定版本与内容沿用主线。 ## 建议阅读顺序 @@ -24,7 +24,7 @@ ## Rust 逐文件说明 -每行说明该文件本次新增注释解释的职责和目的。生产函数/方法均增加中文入口说明;复杂状态机另外补充阶段和不变量说明。集成测试文件增加整体阅读与检查点说明,测试程序文本和源码范围断言保持原样。 +每行说明该文件本次深化注释的职责和目的。生产函数/方法的入口说明与内部关键逻辑互相补充;VM 可调用适配器说明实际参数约定,而非仅重复 trait 名称。集成测试中的每个顶层测试/辅助函数都有具体场景和断言说明,测试程序字符串与断言保持原样。 | 文件 | 注释解释的职责与目的 | | --- | --- | @@ -73,7 +73,7 @@ | [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) | 运行时集成回归与示例验收。测试经真实 Aster 前端、编译器、VM 和展示协议执行,不是手工伪造布局结果。 | +| [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 身份及有限坐标。 | @@ -106,11 +106,68 @@ | 文件 | 本次改动及目的 | | --- | --- | | [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) | 说明本地构建前提、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 | +| [scripts/build-macos-app.sh](../scripts/build-macos-app.sh) | 说明 macOS 构建环境前提、失败退出、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 | | [scripts/render-uml.mjs](../scripts/render-uml.mjs) | 说明独立文档依赖、DOM/Canvas 适配、错误图检查与 SVG/PNG 产物生成,避免混淆运行依赖与文档工具。 | -| [src/README.md](../src/README.md) | 增加本注释索引入口,便于从源码目录开始阅读。 | -| [docs/code-comments.md](code-comments.md) | 本逐文件说明与阅读顺序,覆盖本轮提交的文件及共同边界。 | +| [Cargo.toml](../Cargo.toml) | 解释每个依赖负责的能力、Aster 本地路径依赖、macOS 条件依赖与 release 优化配置。 | +| [docs/code-comments.md](code-comments.md) | 更新本逐文件说明、示例索引和实际核对方式,使本次注释范围与可验证结果对应。 | -## 核对方式 +## Endor 示例逐文件说明 -检查差异仅新增独立注释行和注释相关空白,并与主线去除注释后的文本对比;同时运行格式、完整 GUI 回归、Clippy 和包含私有项的 rustdoc。脚本使用语法检查,不执行文档渲染或应用打包。具体检查结果在 PR 中记录。 +每个示例的类、方法及关键逻辑旁增加中文说明。`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 中。 diff --git a/examples/async-file.endor b/examples/async-file.endor index 44a6add..4ba1cd0 100644 --- a/examples/async-file.endor +++ b/examples/async-file.endor @@ -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("") diff --git a/examples/async-network.endor b/examples/async-network.endor index e2f3e35..64dcda2 100644 --- a/examples/async-network.endor +++ b/examples/async-network.endor @@ -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("") diff --git a/examples/await-file.endor b/examples/await-file.endor index ce66007..50858af 100644 --- a/examples/await-file.endor +++ b/examples/await-file.endor @@ -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 文件" diff --git a/examples/box-layout.endor b/examples/box-layout.endor index ca42868..c49dd59 100644 --- a/examples/box-layout.endor +++ b/examples/box-layout.endor @@ -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) diff --git a/examples/campus-lms.endor b/examples/campus-lms.endor index 2675861..bc72c07 100644 --- a/examples/campus-lms.endor +++ b/examples/campus-lms.endor @@ -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() diff --git a/examples/classes.endor b/examples/classes.endor index 70f527d..6da88c5 100644 --- a/examples/classes.endor +++ b/examples/classes.endor @@ -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)) diff --git a/examples/constructor-contracts.endor b/examples/constructor-contracts.endor index 7447085..aab8dac 100644 --- a/examples/constructor-contracts.endor +++ b/examples/constructor-contracts.endor @@ -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, "列表成员之后的字段") diff --git a/examples/cooperative-tasks.endor b/examples/cooperative-tasks.endor index 48e18d6..665cab5 100644 --- a/examples/cooperative-tasks.endor +++ b/examples/cooperative-tasks.endor @@ -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) diff --git a/examples/events.endor b/examples/events.endor index 740d84c..28562e6 100644 --- a/examples/events.endor +++ b/examples/events.endor @@ -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) diff --git a/examples/exit-lifecycle.endor b/examples/exit-lifecycle.endor index fe2f6fe..a7f153e 100644 --- a/examples/exit-lifecycle.endor +++ b/examples/exit-lifecycle.endor @@ -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) diff --git a/examples/field-contracts.endor b/examples/field-contracts.endor index d841082..a6c683d 100644 --- a/examples/field-contracts.endor +++ b/examples/field-contracts.endor @@ -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") diff --git a/examples/flex-constraints.endor b/examples/flex-constraints.endor index 8d32743..669f7b2 100644 --- a/examples/flex-constraints.endor +++ b/examples/flex-constraints.endor @@ -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() diff --git a/examples/flex.endor b/examples/flex.endor index 3902161..cd475e9 100644 --- a/examples/flex.endor +++ b/examples/flex.endor @@ -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() diff --git a/examples/fonts.endor b/examples/fonts.endor index 3c994ca..808e6fb 100644 --- a/examples/fonts.endor +++ b/examples/fonts.endor @@ -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)) diff --git a/examples/function-types.endor b/examples/function-types.endor index b45f4c9..f7a2c3d 100644 --- a/examples/function-types.endor +++ b/examples/function-types.endor @@ -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("调用带类型检查的对象方法") diff --git a/examples/gc-lifecycle.endor b/examples/gc-lifecycle.endor index fd972bc..b4c9e1e 100644 --- a/examples/gc-lifecycle.endor +++ b/examples/gc-lifecycle.endor @@ -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)) diff --git a/examples/global-dependencies.endor b/examples/global-dependencies.endor index e09cccc..4e4091d 100644 --- a/examples/global-dependencies.endor +++ b/examples/global-dependencies.endor @@ -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 = "全局名字依赖" diff --git a/examples/hello.endor b/examples/hello.endor index 63aa6d3..11fcf45 100644 --- a/examples/hello.endor +++ b/examples/hello.endor @@ -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 间隔决定最终命令位置;返回后发布快照拥有所需数据。 } diff --git a/examples/layout-cache.endor b/examples/layout-cache.endor index 4c07404..36f17b7 100644 --- a/examples/layout-cache.endor +++ b/examples/layout-cache.endor @@ -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) diff --git a/examples/layout-negotiation.endor b/examples/layout-negotiation.endor index 82b806c..ac96927 100644 --- a/examples/layout-negotiation.endor +++ b/examples/layout-negotiation.endor @@ -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("测量与排列直到稳定") diff --git a/examples/main.endor b/examples/main.endor index 6c17a7d..3859ea5 100644 --- a/examples/main.endor +++ b/examples/main.endor @@ -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) } diff --git a/examples/managed-types.endor b/examples/managed-types.endor index 91bc438..9e2670b 100644 --- a/examples/managed-types.endor +++ b/examples/managed-types.endor @@ -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))) diff --git a/examples/method-contracts.endor b/examples/method-contracts.endor index 28432af..19b7dff 100644 --- a/examples/method-contracts.endor +++ b/examples/method-contracts.endor @@ -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) diff --git a/examples/mount-lifecycle.endor b/examples/mount-lifecycle.endor index 4acd2cc..40f19a7 100644 --- a/examples/mount-lifecycle.endor +++ b/examples/mount-lifecycle.endor @@ -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) diff --git a/examples/native-signatures.endor b/examples/native-signatures.endor index a86ebe1..71b30ce 100644 --- a/examples/native-signatures.endor +++ b/examples/native-signatures.endor @@ -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)) diff --git a/examples/native-subclasses.endor b/examples/native-subclasses.endor index cfd2c02..8b8735c 100644 --- a/examples/native-subclasses.endor +++ b/examples/native-subclasses.endor @@ -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) diff --git a/examples/owned-tasks.endor b/examples/owned-tasks.endor index 47d64b5..1ec05de 100644 --- a/examples/owned-tasks.endor +++ b/examples/owned-tasks.endor @@ -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") diff --git a/examples/page.endor b/examples/page.endor index 89607e7..82fb1c5 100644 --- a/examples/page.endor +++ b/examples/page.endor @@ -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 选择此页并生成出现位置及发布帧。 diff --git a/examples/presentable.endor b/examples/presentable.endor index 54ea7c1..ef25a07 100644 --- a/examples/presentable.endor +++ b/examples/presentable.endor @@ -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" diff --git a/examples/property-bindings.endor b/examples/property-bindings.endor index 356e6b9..384c093 100644 --- a/examples/property-bindings.endor +++ b/examples/property-bindings.endor @@ -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) diff --git a/examples/scene-viewports.endor b/examples/scene-viewports.endor index a7e9e92..0897bce 100644 --- a/examples/scene-viewports.endor +++ b/examples/scene-viewports.endor @@ -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" diff --git a/examples/scene3d.endor b/examples/scene3d.endor index 7fee8fb..f8fcea6 100644 --- a/examples/scene3d.endor +++ b/examples/scene3d.endor @@ -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) diff --git a/examples/scroll-axis.endor b/examples/scroll-axis.endor index 628bdaa..355c238 100644 --- a/examples/scroll-axis.endor +++ b/examples/scroll-axis.endor @@ -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) diff --git a/examples/scroll.endor b/examples/scroll.endor index 6db93f6..f6aad2c 100644 --- a/examples/scroll.endor +++ b/examples/scroll.endor @@ -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() diff --git a/examples/source-hit.endor b/examples/source-hit.endor index 5124764..8113a45 100644 --- a/examples/source-hit.endor +++ b/examples/source-hit.endor @@ -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) diff --git a/examples/source-layout.endor b/examples/source-layout.endor index 733831a..c0880cd 100644 --- a/examples/source-layout.endor +++ b/examples/source-layout.endor @@ -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("更新布局") diff --git a/examples/source-paint.endor b/examples/source-paint.endor index a8ba888..1a53c3a 100644 --- a/examples/source-paint.endor +++ b/examples/source-paint.endor @@ -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) diff --git a/examples/student-grades.endor b/examples/student-grades.endor index a792ce6..b1fd5b0 100644 --- a/examples/student-grades.endor +++ b/examples/student-grades.endor @@ -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, diff --git a/examples/tab-placement.endor b/examples/tab-placement.endor index 677a904..c3a42dc 100644 --- a/examples/tab-placement.endor +++ b/examples/tab-placement.endor @@ -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") diff --git a/examples/tabs.endor b/examples/tabs.endor index 917e608..afe823a 100644 --- a/examples/tabs.endor +++ b/examples/tabs.endor @@ -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") diff --git a/examples/visual-protocols.endor b/examples/visual-protocols.endor index 48824c4..afe3dd3 100644 --- a/examples/visual-protocols.endor +++ b/examples/visual-protocols.endor @@ -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) diff --git a/examples/window.endor b/examples/window.endor index f4626d3..f78dc63 100644 --- a/examples/window.endor +++ b/examples/window.endor @@ -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") diff --git a/examples/windows.endor b/examples/windows.endor index 620e2e4..b608051 100644 --- a/examples/windows.endor +++ b/examples/windows.endor @@ -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) diff --git a/examples/workspace.endor b/examples/workspace.endor index 44ad3c3..78314ec 100644 --- a/examples/workspace.endor +++ b/examples/workspace.endor @@ -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, diff --git a/examples/world-ui.endor b/examples/world-ui.endor index 763d50a..2fdbd2d 100644 --- a/examples/world-ui.endor +++ b/examples/world-ui.endor @@ -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" diff --git a/examples/wrapped-text.endor b/examples/wrapped-text.endor index ea416cb..3437f02 100644 --- a/examples/wrapped-text.endor +++ b/examples/wrapped-text.endor @@ -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("文字换行与相邻布局") diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 49040fd..2273d27 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -1,16 +1,21 @@ #!/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 的主程序。 @@ -31,5 +36,7 @@ cat > "$bundle/Contents/Info.plist.new" <NSHighResolutionCapable PLIST +# 元数据完整写入后再替换正式文件;这只是单文件替换,不表示整个 bundle 的构建具有事务回滚。 mv -f "$bundle/Contents/Info.plist.new" "$bundle/Contents/Info.plist" +# 标准输出返回最终 app 路径,便于构建调用方定位产物。 printf '%s\n' "$bundle" diff --git a/scripts/render-uml.mjs b/scripts/render-uml.mjs index a1257d8..1a43b4a 100644 --- a/scripts/render-uml.mjs +++ b/scripts/render-uml.mjs @@ -1,6 +1,6 @@ #!/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'; @@ -9,13 +9,14 @@ 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; @@ -23,15 +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, @@ -41,11 +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'}`; @@ -63,20 +68,23 @@ 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); @@ -84,7 +92,7 @@ for (const filename of (await readdir(source)).filter(name => name.endsWith('.pu // 同时保存可缩放 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(); diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index f642b14..426773d 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -1,4 +1,5 @@ //! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。 //! -// Module ownership follows the directory; crate-root aliases keep internal call sites stable. +// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。 +/// 注册托管类型/宿主方法,验证参数并连接业务对象、Mount、视觉协议与窗口/任务资源。 pub(crate) mod ui_bridge; diff --git a/src/bridge/ui_bridge.rs b/src/bridge/ui_bridge.rs index 3752eae..a27d987 100644 --- a/src/bridge/ui_bridge.rs +++ b/src/bridge/ui_bridge.rs @@ -30,6 +30,7 @@ pub const MAX_UI_NODES: usize = 4096; pub const MAX_PAGES: usize = 64; pub const MAX_DIMENSION: f64 = 1_000_000.0; +// 编译器已知宿主全局类型/能力名,同时用于 install_globals;仅列名不代表绕过实际 VM 名义类型查找。 pub const TYPE_NAMES: &[&str] = &[ "Object", "Type", @@ -102,7 +103,9 @@ struct Session { current: u64, master: ObjectRef, next_window: u64, + // 跨窗口共用 Mount 高水位,避免某窗口取消/重建后与其他窗口已暴露出现 ID 重合。 next_mount: u64, + // 正常关闭副窗口后暂存尚待发布的 Close 等命令;Presentation 已移除也不能丢掉最后的原生退役请求。 retired_commands: Vec, refresh_yields: usize, refresh_restarts: usize, @@ -127,6 +130,7 @@ impl std::ops::Deref for Session { type Target = crate::presentation::Presentation; /// 将包装类型的只读访问转交给内部当前上下文。 fn deref(&self) -> &Self::Target { + // Session 解引用始终指向 current Presentation;调用源回调前切换窗口,结束后恢复主窗口,不能把它当全局唯一展示。 self.presentations .get(&self.current) .expect("selected presentation") @@ -211,9 +215,10 @@ enum Operation { } impl Operation { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 返回该操作显式参数数量范围;这里不计接收者、Type 首参和源子类字段。 fn arity(self) -> endor_core::CallArity { use Operation::*; + // 此 arity 只含显式参数;TypeMethod 另加 receiver,NativeFactory 另加 Type 和子类字段外壳。 let (minimum, maximum) = match self { IsInstance | IsSubtype | Bind | Implement | Implements | Supports => (2, 2), Stats | Viewport | Window | PageWindow | Mounts => (0, 0), @@ -254,6 +259,7 @@ struct Function { operation: Operation, owner: Option, session: Shared, + // 原生构造的实际源子类身份;自由 ui 工厂为 None,类型构造器显式传入 Some。 requested_type: Option, } @@ -291,7 +297,7 @@ impl NativeCallable for TypeMethod { fn signature(&self) -> Option { Some(self.signature.clone()) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 在 Operation 的显式参数范围两端各加一,计入 VM 传入的隐式接收者。 fn arity(&self) -> Option { let arity = self.operation.arity(); Some(endor_core::CallArity::range( @@ -300,8 +306,9 @@ impl NativeCallable for TypeMethod { )) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 校验首参是预期名义类型实例,再剥离接收者,复用 Function 分派执行同一 Operation。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 名义方法统一存在类型上,VM 把 receiver 放首参;校验后剥离 receiver 再交 Function 的显式参数分派。 let owner = object(args.first())?; if !cx.store().is_instance(owner, self.expected)? { return Err(invalid("incorrect receiver type for visual method")); @@ -333,7 +340,7 @@ impl NativeCallable for NativeFactory { fn signature(&self) -> Option { self.signature.clone() } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 要求原生必填参数再加一个请求 Type;上界暂开放,子类字段与原生选项在 invoke 内分别校验。 fn arity(&self) -> Option { Some(endor_core::CallArity::range( self.operation.arity().required + 1, @@ -341,7 +348,7 @@ impl NativeCallable for NativeFactory { )) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 把请求类型的字段后缀与原生选项前缀分开验证,分配真实原生子类并初始化字段,避免非法字段先触发构造副作用。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let requested = object(args.first())?; let fields = cx @@ -350,12 +357,14 @@ impl NativeCallable for NativeFactory { .fields() .to_vec(); let values = &args[1..]; + // 构造实参末尾是类型声明的继承/自有字段,前缀才是原生构造选项;checked_sub 防止缺字段造成下溢。 let native_count = values .len() .checked_sub(fields.len()) .ok_or(invalid("missing subclass field arguments"))?; cx.store() .check_class_field_arguments(requested, &values[native_count..])?; + // 外壳允许宽变参,但原生前缀仍按具体 Operation 完整签名检查,不能因子类存在绕过类型或 arity。 self.options .as_ref() .ok_or(invalid("native factory is not initialized"))? @@ -375,6 +384,7 @@ impl NativeCallable for NativeFactory { let NativeResult::Return(Value::Object(instance)) = result else { return Err(invalid("native constructor did not return an instance")); }; + // 分配器必须返回精确 requested 类型,不能只返回基类实例;之后才初始化子类字段。 if cx.store().type_of(instance)? != Some(requested) { return Err(invalid( "native constructor returned an incorrect nominal type", @@ -429,6 +439,7 @@ fn dimension(value: Option<&Value>) -> Result { let Some(Value::Number(number)) = value else { return Err(invalid("expected a finite nonnegative dimension")); }; + // 分别支持大整数、十进制和二进制实数表示,不接受非实值;转 f64 后检查有限与范围,再降 f32。 let value = match number.as_real() { Some(EndorReal::Integer(v)) => v.to_string().parse::().ok(), Some(EndorReal::Decimal(v)) => v.literal().parse::().ok(), @@ -461,6 +472,7 @@ fn bind( session: session.clone(), requested_type: None, }, + // 绑定方法显式捕获 owner 与 desktop,让托管 GC 识别函数的真实强边,Rust Shared 会话本身不隐式保活 VM 对象。 vec![owner, desktop], ))?; attach_desktop_type(cx, function, "Function", desktop)?; @@ -498,6 +510,7 @@ fn new_camera2d( .unwrap_or(fallback); cx.set_attr(camera, name, crate::native_visual::value(value))?; } + // 初始化完所有相机属性后走完整相机校验,尤其 zoom 必须处于 0.01..100,不能只通过一般坐标范围。 crate::viewport::camera(cx.store(), camera)?; Ok(camera) } @@ -530,6 +543,7 @@ fn new_typed_block( .desktop; let expected = desktop_type(cx.store(), type_name, desktop)?; let type_object = requested.unwrap_or(expected); + // 叶/容器原生种类对应不同类型基类,禁止用 TextBlock 分配器创建并非其子类的名义对象。 if !cx.store().is_subtype(type_object, expected)? { return Err(invalid("native constructor cannot allocate this subclass")); } @@ -537,6 +551,7 @@ fn new_typed_block( cx.attach_type(block, type_object)?; { let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; + // 节点弱表受宿主预算限制;实际死亡弱项通过后续 reclaim 压缩,不能把弱索引当永不增长的免费结构。 if state.nodes.len() >= MAX_UI_NODES { return Err(VmError::ExecutionLimit("4096 node budget")); } @@ -572,6 +587,7 @@ fn create_window( if state.exiting { return Err(invalid("runtime is exiting; window creation is forbidden")); } + // 同一 VM 最多16窗口(含主壳);ID 单调分配并限制到 i64 正范围,关闭后不复用旧资源身份。 if state.presentations.len() >= 16 { return Err(VmError::ExecutionLimit("16 window budget")); } @@ -582,6 +598,7 @@ fn create_window( .ok_or(invalid("window identities exhausted"))?; let (window, desktop, tabs) = crate::window::create(cx, state.master, id, definition)?; let mut presentation = crate::presentation::Presentation::new(desktop, tabs); + // 新窗口即使暂无内容也需发布有限空帧,让 GUI 根据投影创建 OS 窗口,而不是在 VM 中直接创建。 presentation.force_frame = true; state.presentations.insert(id, presentation); state.next_window = id; @@ -610,6 +627,7 @@ fn remove_page( ) -> Result<(), VmError> { let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; crate::window::unregister_page(cx, window, page)?; + // 双层 pending 区分无请求与请求清空;移除当前/待选页时改选剩余第一页,或显式 pending=None页面。 if view.pending.unwrap_or(view.active) == Some(page) { let next = crate::window::pages(cx.store(), window)?.first().copied(); cx.set_attr( @@ -631,6 +649,7 @@ fn show_in( let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; let old = page_window(cx.store(), &state, page)?; let id = match target { + // 显式目标窗口既要未关闭,也要对应本会话 Presentation 的同一 desktop,拒绝其他 VM 的窗口身份。 Some(window) => { let data = cx .store() @@ -648,11 +667,13 @@ fn show_in( } id } + // 不指定窗口时优先保留 Page 现有归属;未打开过的新页才进入 current,而非每次 activate 都迁移到主窗口。 None => old.unwrap_or(state.current), }; if let Some(old) = old && old != id { + // 跨窗口移动先撤源窗口登记与必要活动页选择,再在目标登记同一 Page,业务内容对象不复制。 remove_page( cx, state @@ -669,6 +690,7 @@ fn show_in( let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; crate::window::register_page(cx, window, page)?; cx.set_attr(view.desktop, "pending", Value::Object(page))?; + // 仅记录展示请求并由 desktop.pending 强持有 Page;实际 active、Mount、布局和 published_version 留待刷新提交。 view.pending = Some(Some(page)); Ok(()) } @@ -684,17 +706,18 @@ struct PresentResult { session: Shared, } impl NativeCallable for PresentResult { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 接收 present 委托返回的唯一视觉对象,作为后续展示处理的输入。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 验证 present 结果为 Block;open 模式必要时包装 Page 并展示,否则只返回转换得到的视觉对象。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let [Value::Object(root)] = args else { return Err(invalid("Presentable.present must return a Block")); }; let is_page = cx.store().payload::(*root, "Block")?.page; + // present 委托返回值必须为 Block;open=true 必要时包 Page 并展示,open=false 只返回转换结果。 let result = if self.open { let page = if is_page { *root @@ -714,7 +737,7 @@ impl NativeCallable for PresentResult { } } -/// 根据 Presentable 能力把任意源值转换成可展示内容,避免只允许某个原生 Block 类型。 +/// 返回已有 Block,或把其他值的可读表示包装成 TextBlock;Presentable 委托由外层调用分派负责。 fn present_value( cx: &mut NativeContext<'_>, value: &Value, @@ -730,6 +753,7 @@ fn present_value( { return Ok(*block); } + // 非 Block 的标量/对象转可读文字,Presentable 方法委托在 calls.rs 的更外层处理,本函数不自己调用 present。 let value = match value { Value::Nil => "nil".into(), Value::Bool(v) => v.to_string(), @@ -771,7 +795,9 @@ struct LayoutContext { full_viewport: Option, desktop: ObjectRef, window: u64, + // 布局同步返回的生命周期序列拥有根,暂留到安全提交边界执行/释放,取消时也必须清理。 hooks: Option, + // 布局准备可能暂改 Mount payload 与 desktop.mounts,检查点保证失败或尺寸重定向能恢复未发布关系。 checkpoint: crate::mount::Checkpoint, } @@ -796,6 +822,7 @@ fn invoke_member( arguments: &[Value], instruction_budget: usize, ) -> Result { + // 使用 VM 正常绑定规则创建临时方法值;无论 call 成功/失败,先保存 result 再释放 bound 根。 let bound = vm.bind_attr(owner, method)?; let result = match &bound { Value::Object(function) => vm @@ -821,11 +848,13 @@ fn project_tabs( ) -> Result> { let window = object(Some(&vm.get_attr(state.desktop, "window")?))?; let pages = crate::window::pages(vm.object_store(), window)?; + // 标签选中反映待提交页面(若有请求),否则沿用 active;页面标题和顺序仍从真实窗口注册表提取。 let active = state.pending.unwrap_or(state.active); let mut live = Vec::new(); let mut tabs = Vec::new(); for (index, page) in pages.iter().copied().enumerate() { let weak = vm.object_store().downgrade(page)?; + // 同一 Page 弱身份复用该窗口标签 ID,新增页用高水位;移出后 live 更新会移除旧映射,不靠标题匹配。 let id = if let Some((_, id)) = state.tab_ids.iter().find(|(w, _)| *w == weak) { *id } else { @@ -836,6 +865,7 @@ fn project_tabs( state.next_tab }; live.push((weak, id)); + // 标题显示过滤控制字符并限制120个 Unicode 字符,缺失/空标题用默认序号;不修改原 Page.title。 let title = match vm.get_attr(page, "title") { Ok(Value::Text(title)) if !title.is_empty() => title .chars() @@ -863,6 +893,7 @@ impl UiBridge { /// already closed before source unmount hooks run, so they cannot remount. /// 按窗口和展示生命周期执行有界退出清理,允许调用方根据故障状态决定是否执行回调。 pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> { + // 退出先关闭新任务入口并取消旧任务,再标 session.exiting,避免卸载回调重新创建任务/窗口。 let tasks = self.tasks.quiesce(vm)?; let mut drivers = Vec::new(); let mut mounts = 0; @@ -877,6 +908,7 @@ impl UiBridge { let view = state.presentations.get_mut(id).expect("known window"); let desktop = view.desktop; mounts += view.mounts.active.len(); + // 正常域用空结构 synchronize 安排孩子先卸载的 hooks;故障域直接 retire,不能再次执行用户代码。 if callbacks { if let Some(hooks) = view.mounts.synchronize( vm, @@ -892,6 +924,7 @@ impl UiBridge { view.mounts.retire(vm, desktop)?; } let window = object(Some(&vm.get_attr(desktop, "window")?))?; + // 所有窗口先标 closed 并断 Page 拥有关系,然后才在下面执行用户卸载 hooks,阻止回调重新打开已关闭窗口。 crate::window::retire(vm, window)?; vm.set_attr(desktop, "pending", Value::Nil)?; vm.set_attr(desktop, "focus", Value::Nil)?; @@ -902,6 +935,7 @@ impl UiBridge { Ok(ids.len()) })(); let mut result = prepared.map(|windows| (windows, mounts, tasks)); + // 若之前清理/回调失败,余下 hooks 不再执行,但所有驱动根仍逐项 release,避免失败路径泄漏。 for (id, hooks) in drivers { if result.is_ok() { let call = (|| -> Result<()> { @@ -951,6 +985,7 @@ impl UiBridge { .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; let mut ids: Vec<_> = state.presentations.keys().copied().collect(); + // 优先处理切离旧 active 页的窗口,使跨窗口迁移先退旧出现再挂新出现;同优先级按窗口 ID 稳定排序。 ids.sort_by_key(|id| { let view = &state.presentations[id]; ( @@ -1009,6 +1044,7 @@ impl UiBridge { vm.set_attr(desktop, "focus", Value::Nil)?; vm.set_attr(desktop, "focusRequested", Value::Bool(false))?; // Draining is not publication: failed-domain effects are discarded. + // 故障宿主退役的命令只丢弃不发布,防止失败域遗留 Resize/Focus 等影响当前原生状态。 let _ = crate::window::take_commands(vm, window)?; let windows = object(Some(&vm.get_attr(state.master, "windows")?))?; vm.dict_delete(windows, &Value::Text(id.to_string()))?; @@ -1023,6 +1059,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 正常 finish_closed 只移除已标 closing 的副窗口,主壳保持到 OS 退出;其最后命令转存 retired_commands 待发布。 let ids: Vec<_> = state .presentations .iter() @@ -1048,6 +1085,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 先取已移除窗口的最后命令,再合并仍活动窗口 outbox;按窗口/窗口内序号排序,不丢失正常 Close 请求。 let mut commands = std::mem::take(&mut state.retired_commands); for view in state.presentations.values() { let window = object(Some(&vm.get_attr(view.desktop, "window")?))?; @@ -1071,6 +1109,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 尺寸比较在分配新输入对象前完成,避免相同 resize 通知改变 VM 版本而触发自我失效。 if state.layout.viewport == viewport { return Ok(false); } @@ -1081,6 +1120,7 @@ impl UiBridge { vm.set_attr(input, "height", Value::Number((size.height as f64).into()))?; vm.set_attr(state.desktop, "viewport", Value::Object(input)) })(); + // desktop 已建立 viewport 强边后释放局部根;字段/安装失败也先释放 input,再传播保存的错误。 vm.release(input)?; result?; } else { @@ -1089,6 +1129,7 @@ impl UiBridge { state.layout.viewport = viewport; // A fresh Console domain can own window inputs without replacing the // preserved displayed page until it actually opens/renders something. + // 空的新 Console 域可以记录逻辑尺寸,但未展示任何 Page 时不应覆盖用户仍看到的旧域画面。 state.force_frame |= state.active.is_some() || state.pending.flatten().is_some(); Ok(true) } @@ -1102,6 +1143,7 @@ impl UiBridge { Value::Text("View".into()), vm.get_attr(self.ui, "Block")?, )?; + // 全局名引用 ui 中同一批名义类型/函数对象,模块和 Console 不会各复制一份类型体系。 for name in TYPE_NAMES.iter().chain(QUERY_NAMES) { let value = vm.get_attr(self.ui, name)?; vm.dict_set(globals, Value::Text((*name).into()), value)?; @@ -1126,6 +1168,7 @@ impl UiBridge { let Ok(state) = self.session.lock() else { return false; }; + // 拖放源与目标都必须匹配各自发布版本,且真实 Page 注册顺序仍对应标签弱身份表,防止用陈旧列表下标重排。 for (id, version) in [ (input.window, input.layout_version), (target.window, target.layout_version), @@ -1173,6 +1216,7 @@ impl UiBridge { if state.published_version != input.layout_version { bail!("event targets an obsolete presentation"); } + // input.mount 在标签路径表示 Tab ID,先映回弱 Page,再确认仍在当前窗口 pages 集合,不是 Mount 几何下标。 let weak = state .tab_ids .iter() @@ -1215,12 +1259,14 @@ impl UiBridge { .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; page_window(vm.object_store(), &state, page)? }; + // 源 moveToWindow 可覆盖并拒绝/改投其他窗口;只有确认实际归属仍为预期目标才继续重排与激活。 if destination == Some(target.window) { let pages = crate::window::pages(vm.object_store(), window)?; let from = pages .iter() .position(|candidate| *candidate == page) .ok_or_else(|| anyhow::anyhow!("Page is no longer open"))?; + // 同窗口拖动的 index 是移除前插入缝位置,向后拖时先减一;跨窗口新加入则不用这一偏移修正。 let position = if input.window == target.window && from < target.index { target.index - 1 } else { @@ -1248,6 +1294,7 @@ impl UiBridge { _ => "activate", }; let value = invoke_member(vm, page, method, &[], 1_000_000)?; + // detach 返回值可能被源覆盖成其他值,仅确为 Window 且带屏幕位置才调用 moveTo;最后统一释放原返回值。 let placed = (|| -> Result<()> { if input.kind == "tab-detach" && let Some([x, y]) = input.screen_position @@ -1289,6 +1336,7 @@ impl UiBridge { { bail!("event belongs to another window"); } + // 窗口关闭通过名义 close 方法,保留源覆盖语义;直接调用宿主原生退役只用于故障清理路径。 if input.kind == "window-close" { let desktop = self .session @@ -1312,6 +1360,7 @@ impl UiBridge { vm.release_value(result?)?; return Ok(()); } + // 内置工具打开走同一 VM 的 developerTools 能力,返回值只作临时根使用,不跨到 GUI。 if let Some(name) = input.kind.strip_prefix("developer-open:") { let function = object(Some(&vm.get_attr(self.ui, "developerTools")?))?; let page = vm.call(function, &[Value::Text(name.into())])?; @@ -1336,6 +1385,7 @@ impl UiBridge { ) { return self.dispatch_tab(vm, input); } + // 普通指针输入取同一锁内的版本/目标/完整出现候选快照;随后不持 session 锁执行源 hitTest 或事件回调。 let (weak, desktop, candidates, viewport, surface) = { let state = self .session @@ -1365,6 +1415,7 @@ impl UiBridge { let point = input .point .ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?; + // scroll 不只校验 Mount 身份,还要指针仍在页面视口和出现裁剪中;相机/面板映射后的增量才可交给 scrollBy。 if !data.mounted || data.version != input.layout_version || !surface.contains(point, viewport) @@ -1402,6 +1453,7 @@ impl UiBridge { vm.release(mount)?; return result; } + // 坐标点击重新执行源协议选择按下和释放目标,二者同出现才分发;无坐标输入使用已捕获的弱目标。 let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) { if [press, release] .iter() @@ -1420,6 +1472,7 @@ impl UiBridge { }; let result = (|| { let data = vm.payload::(mount, "Mount")?.clone(); + // 3D 视口点击先生成 RayHit3D 并附到 Event.hit,再沿视口出现传播,不能只按二维外框假定击中某网格。 if data.geometry.scene3d_viewport && input.kind == "click" && let Some(point) = input.point @@ -1451,6 +1504,7 @@ impl UiBridge { if !data.geometry.interactive { bail!("Mount does not accept input"); } + // 普通点击/焦点先登记展示焦点请求,再按 kind 调 Block.click 或 Mount.requestFocus;生效仍需后续完整刷新。 vm.set_attr( desktop, "focus", @@ -1483,6 +1537,7 @@ impl UiBridge { } /// 整理不再有效的弱索引与展示根,并配合 VM 回收,避免派生缓存延长业务对象生命周期。 pub fn reclaim(&self, vm: &mut Vm, _roots: &mut Vec) -> Result<()> { + // 先回收托管环,再扫描弱节点表,只保留能升级的身份;render_cache.prune 同步清除死亡业务索引。 vm.collect_cycles(); let mut state = self .session @@ -1511,6 +1566,7 @@ impl UiBridge { let window = object(Some(&vm.get_attr(presentation.desktop, "window")?))?; // A failed first render must not become visible on the next input // merely because show() already registered its Page with the Window. + // 第一次 render 失败时撤销尚未发布的 Page 登记,避免下一次无关输入把失败页意外显示出来。 if presentation.active.is_none() && presentation.last_tabs.is_empty() { let unpublished = crate::window::pages(vm.object_store(), window)?.to_vec(); for page in unpublished { @@ -1518,6 +1574,7 @@ impl UiBridge { } } let pages = crate::window::pages(vm.object_store(), window)?; + // 失败输入已成功执行的关闭/迁移语句不回滚;重新选择仍在真实窗口注册表中的页,修复 pending 与拥有关系。 let next = if presentation.closing { None } else { @@ -1554,6 +1611,7 @@ impl UiBridge { } /// 重新评估已失效的属性绑定,并把结果计入会话统计。 pub fn refresh_bindings(&self, vm: &mut Vm) -> Result<()> { + // 只在锁内快照弱节点与宿主桌面集合,实际绑定回调在锁外执行,避免回调访问 UI 时死锁。 let (nodes, desktops) = { let state = self .session @@ -1571,11 +1629,13 @@ impl UiBridge { } /// 排空一次 VM 变化记录并通知各窗口,提取活动页面快照,判断本次是否需要布局与发布。 pub fn prepare_frame(&self, vm: &mut Vm) -> Result>>> { + // 一次排空变更必须广播所有 Presentation,不能让先刷新的窗口吃掉其他窗口共享业务数据的失效通知。 let changes = vm.take_changes(); let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 本轮目标优先 pending,包括显式清空;快照提取返回 None 代表目标无页面,不等于无需刷新。 let active = state.pending.unwrap_or(state.active); // One VM change drain feeds every presentation before selecting a root. // Otherwise a window refreshed first could consume another window's work. @@ -1585,6 +1645,7 @@ impl UiBridge { let root = active .map(|v| state.render_cache.snapshot(vm.object_store(), v)) .transpose()?; + // 视觉缓存用 Arc 身份快速判断根未变;即使根相同,焦点、滚动、标签或源依赖仍可能要求重绘。 let same = match (&root, &state.last_snapshot) { (Some(a), Some(b)) => Arc::ptr_eq(a, b), (None, None) => true, @@ -1612,6 +1673,7 @@ impl UiBridge { state.snapshot_visited = state.render_cache.visited; state.snapshot_reused = state.render_cache.reused; } + // 双层 Option 三态:外 None 无工作;Some(None) 需提交空帧;Some(Some(root)) 需布局/绘制根。 Ok(changed.then_some(root)) } /// 提交已经完成的出现几何与快照,并处理展示生命周期;不把挂起或失败的布局当成已发布帧。 @@ -1628,6 +1690,7 @@ impl UiBridge { let page = state.pending.unwrap_or(state.active); let desktop = state.desktop; let nodes = state.nodes.clone(); + // 提交几何同步清除临时 layout Context,并产生挂载/卸载序列;提交桥接状态不等于已写 GUI 共享文档。 let hooks = state .mounts .synchronize(vm, desktop, page, nodes, frame, false)?; @@ -1645,6 +1708,7 @@ impl UiBridge { state.last_snapshot = snapshot; state.last_tabs = frame.tabs.clone(); state.surface = frame.surface.clone(); + // 让随后源回调看到本轮几何与版本,但真正对外原子发布要等整个刷新循环稳定,回调可能继续触发下一轮。 state.published_version = frame.layout_version; // Hooks may call Page/container APIs; never hold the session mutex // across user code. Publish only after the refresh loop settles. @@ -1682,6 +1746,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 按最终帧绘制顺序查活动 Mount 弱身份,每条都必须存在;不按 HashMap 顺序调用源 paint。 let mounts = frame .mounts .iter() @@ -1721,6 +1786,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 完成后过滤所有窗口私有 desktop 读取,再拆对象版本与名称依赖;挂起切片不覆盖上一份完成的 paint 依赖。 let desktops = state.host_desktops(); let reads = draft .reads @@ -1743,6 +1809,7 @@ impl UiBridge { window: u64, work: crate::source_paint::Work, ) -> Result<()> { + // 先尝试取消 VM 续体,再尝试选原窗口并强制下一帧;前一操作失败也执行必要的宿主失效准备。 let cancelled = work.cancel(vm); let invalidated = self.select_window(window).and_then(|()| { self.session @@ -1765,6 +1832,7 @@ impl UiBridge { !vm.has_suspended_call() && !vm.object_store().is_read_only(), "cannot prepare another frame during suspended layout" ); + // 显式空页面无需源布局,清测量/布局读取并推进空帧版本,再同步退役旧 Mount;不是无工作返回。 let Some(root) = root else { self.session .lock() @@ -1821,6 +1889,7 @@ impl UiBridge { .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; project_tabs(vm, &mut state)? }; + // 记录真实原生页面视口供页面相机 clamp;兼容 Page.mode 只改变根约束,不永久篡改保存的窗口尺寸。 let full_viewport = layout.viewport; let surface = root.surface.clone().unwrap_or_default(); layout.page_mode = root.surface.as_ref().map(|surface| surface.mode); @@ -1829,6 +1898,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 按全会话高水位分配结构出现 ID,取消后也不回收已暴露过的数值身份。 layout.reserve_identity_floor(state.next_mount); } let mut frame = layout.structure(&root)?; @@ -1845,6 +1915,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 在准备态 synchronize 改写 Mount 前保留旧字典与 payload;准备失败立即恢复,不能留下半套父子关系。 let checkpoint = state.mounts.checkpoint(vm, desktop)?; let hooks = match state .mounts @@ -1858,6 +1929,7 @@ impl UiBridge { }; (hooks, state.mounts.ordered(), checkpoint) }; + // 检查点、暂存 hooks、所属窗口与根随源布局 Work 一起保留,跨量子不能只留下 VM 续体。 let context = LayoutContext { surface, root: root.clone(), @@ -1918,6 +1990,7 @@ impl UiBridge { checkpoint, .. } = context; + // 恢复必须在原窗口;若窗口或 session 锁已不可用,至少释放检查点拥有根,不能尝试恢复到另一个窗口。 let selected = self.select_window(window); let restored = if selected.is_ok() { match self.session.lock() { @@ -1934,6 +2007,7 @@ impl UiBridge { } else { checkpoint.release(vm).map_err(anyhow::Error::from) }; + // 无论选择/恢复是否失败都先尝试释放延迟 hooks;保存结果后再按优先级传播错误。 let released = hooks.map(|hooks| vm.release(hooks)).transpose(); selected?; restored?; @@ -1944,6 +2018,7 @@ impl UiBridge { /// 取消源布局续体并清理对应上下文,保持已发布帧可用。 pub fn cancel_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> { let PendingLayout { work, context } = pending; + // 取消只读执行与恢复 Mount 是两件事,均先尝试;没有继续执行原续体时也不能漏掉检查点恢复。 let cancelled = work.cancel(vm); let restored = self.discard_layout_context(vm, context); cancelled?; @@ -1962,6 +2037,7 @@ impl UiBridge { pending: PendingLayout, ) -> Result { let PendingLayout { work, context } = pending; + // 恢复时原窗口不存在则取消 owning VM 调用并清上下文,不重新建窗口或重放源布局。 if let Err(error) = self.select_window(context.window) { let cancelled = work.cancel(vm); let restored = self.discard_layout_context(vm, context); @@ -1981,6 +2057,7 @@ impl UiBridge { progress: Result, ) -> Result { match progress { + // Pending 原样捆绑 Work 与 Context;Complete 才投影、提交,Error 则撤销准备态上下文。 Ok(crate::source_layout::Progress::Pending(work)) => { Ok(LayoutProgress::Pending(Box::new(PendingLayout { work, @@ -2027,11 +2104,13 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 先在布局坐标中处理滚动,再叠二维视口/面板投影;绘制与输入共享最终 Frame 几何。 crate::scroll::project(vm, &state.mounts, &root, &mut frame)?; crate::viewport::project(&root, &mut frame)?; } // Native layout may replace the structural Frame; attach chrome only // after either layout path has produced the final page geometry. + // 小原生快速路径可能返回新的 Frame,标签/窗口与页面相机必须在最终布局之后重新附加。 frame.tabs = tabs; frame.window = window; if let Some(root) = frame.mounts.first() { @@ -2053,6 +2132,7 @@ impl UiBridge { // Shared intrinsic functions capture the primary desktop even when // invoked from another window. Desktop bookkeeping is private host // state; Window, viewport input and business dependencies remain. + // 源布局实际业务读取版本在生命周期 hooks 前保存,hooks 改写依赖后下一轮才能识别失效并重算。 reads.retain(|read| !desktops.contains(&read.object())); cache.exclude_host_desktops(&desktops); let (reads, names) = crate::presentation::split_reads(reads); @@ -2060,6 +2140,7 @@ impl UiBridge { Ok(Value::Number(id)) => id.as_i64().and_then(|n| u64::try_from(n).ok()), _ => None, }; + // 焦点 ID 仅保留仍存在且可交互的出现,切页/树编辑移除目标后自动清掉失效焦点。 frame.focus = frame .focus .filter(|id| frame.mounts.iter().any(|m| m.id == *id && m.interactive)); @@ -2074,6 +2155,7 @@ impl UiBridge { state.layout_reads = reads; state.layout_names = names; } + // 几何先提交到桥接状态,再执行准备阶段延后的挂载 hooks;其变化由全窗口刷新循环再收敛。 self.commit_frame(vm, Some(root), &frame)?; if let Some(hooks) = hooks { let value = vm @@ -2090,6 +2172,7 @@ impl UiBridge { } Ok(frame) })(); + // 收尾计算无论成功/失败,都先释放 hooks 与检查点的拥有根;只有所有必要清理通过才返回帧。 let released_hooks = hooks.map(|hooks| vm.release(hooks)).transpose(); let released_checkpoint = checkpoint.release(vm); let frame = result?; @@ -2104,6 +2187,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 此兼容提取接口只读取待展示 Page,不回退 active;持久增量路径使用 prepare_frame 的 active/pending 选择。 state .pending .flatten() @@ -2144,6 +2228,7 @@ pub(crate) fn desktop_type( name: &str, desktop: ObjectRef, ) -> Result { + // 查当前 desktop 的实际字典对象和精确类型条目;不是按名字创建新类型,缺条目沿 object 解码报错。 let types = object(Some(&store.get_attr(desktop, "types")?))?; object(store.dict_get(types, &Value::Text(name.into()))?.as_ref()) } diff --git a/src/bridge/ui_bridge/bootstrap.rs b/src/bridge/ui_bridge/bootstrap.rs index e174df4..4911385 100644 --- a/src/bridge/ui_bridge/bootstrap.rs +++ b/src/bridge/ui_bridge/bootstrap.rs @@ -6,6 +6,7 @@ use super::*; /// 把本模块的类型、方法或能力注册到所属运行时上下文。 pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { + // 先建立 desktop/ui/tabs 并加入显式 roots,后面初始化失败时由上层运行时统一释放,避免依赖 Rust 句柄保活。 let desktop = vm.new_object()?; roots.push(desktop); vm.set_attr(desktop, "viewport", Value::Nil)?; @@ -14,6 +15,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { 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([( @@ -33,6 +35,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { binding_evaluations: 0, exiting: false, })); + // 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。 let mut public_functions = Vec::new(); let mut factories = Vec::new(); for (name, operation) in [ @@ -75,6 +78,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { 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))?; @@ -83,6 +87,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("Object", root_types.object), ("Type", root_types.type_type), ]; + // 类型按基类先于子类的顺序创建。None 表示抽象能力/宿主结果类型,不开放普通源构造器。 for (name, base_name, constructor_name) in [ ("Trait", "Object", None), ("Function", "Object", None), @@ -124,6 +129,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ] { 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", @@ -150,6 +156,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { TypeConstructor::NativeTyped(factory) } Some(constructor_name) => { + // 把构造标签映射到宿主 Operation;3D 族委托其元数据,未知标签立即失败,不静默创建错误类型。 let operation = match constructor_name { "text" => Operation::Text, "rect" => Operation::Rect, @@ -186,6 +193,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { } None => TypeConstructor::Abstract, }; + // 记住实际工厂身份,类型构建完成后原地补签名,避免替换类型内指向的构造函数对象。 if let (Some(tag), TypeConstructor::NativeTyped(factory)) = (constructor_name, constructor) { factories.push((tag, factory)); @@ -195,6 +203,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { vm.set_attr(ui, name, Value::Object(definition))?; definitions.push((name, definition)); } + // 协议值采用普通托管类:Constraints/Bounds/Point 等字段有类型注解,PaintCommand/CameraGesture 后续再初始化属性。 for (name, fields) in [ ( "Constraints", @@ -212,6 +221,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { .into_iter() .map(|name| endor_core::ClassField { name: name.into(), + // children/commands 先声明 Object 接口,后续各协议解码器再要求受控列表;几何字段统一为 Number。 type_name: if matches!(name, "children" | "commands") { "Object" } else { @@ -232,6 +242,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { } // 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 { @@ -241,8 +252,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { 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, @@ -284,6 +297,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { }; 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()], @@ -292,6 +306,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { effect: options.effect, }; let mut captures = vec![desktop]; + // 参数和返回注解中的名义类型也是托管引用,必须纳入 captures,不能只把它们存进 Rust 签名结构。 captures.extend(options.references()); vm.replace_payload( function, @@ -307,6 +322,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ), )?; } else { + // 特殊构造器保留宽变参外壳,再由对应 Constructor 按类型字段数拆实参,返回准确 Event/ObjectList 名义类型。 let kind = if name == "event" { "Event" } else { @@ -377,6 +393,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { 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), @@ -429,12 +446,14 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { 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)?; @@ -442,6 +461,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { 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 }; diff --git a/src/bridge/ui_bridge/calls.rs b/src/bridge/ui_bridge/calls.rs index fd9f240..59ad18c 100644 --- a/src/bridge/ui_bridge/calls.rs +++ b/src/bridge/ui_bridge/calls.rs @@ -9,16 +9,17 @@ impl NativeCallable for Function { fn signature(&self) -> Option { self.signature.clone() } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 接收已经剥离 receiver 的显式参数,数量沿用 Operation 的范围。 fn arity(&self) -> Option { Some(self.operation.arity()) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 统一校验数量与只读限制,再按 Operation 分派构造、树修改、页面、绑定、状态与能力调用,返回托管结果或 VM 续调用。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { 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")); } @@ -26,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 @@ -58,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 { @@ -92,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( @@ -115,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 @@ -147,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, @@ -179,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); @@ -217,6 +224,7 @@ impl NativeCallable for Function { )?, ))); } + // 诊断会读对象总数与宿主变化统计,禁止在只读渲染中观察并形成反馈依赖。 if matches!(self.operation, Stats) { if cx.store().is_read_only() { return Err(invalid( @@ -287,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)?; @@ -303,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)) @@ -315,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) @@ -334,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, @@ -341,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 { @@ -396,6 +409,7 @@ impl NativeCallable for Function { } else { Kind::Column }, + // 线性容器固定前两参为 padding/gap,后续都是孩子对象;组成合法性在 Block 分配前统一检查。 args[2..] .iter() .map(|v| object(Some(v))) @@ -422,6 +436,7 @@ impl NativeCallable for Function { 0.0, 0.0, ); + // 每个新二维视口默认新建独立 Camera2D;显式属性共享相机才会联动其他视口。 data.camera = Some(new_camera2d(cx, &self.session, None, &[])?); Some(data) } @@ -451,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, @@ -460,6 +476,7 @@ impl NativeCallable for Function { )?); Some(data) } + // 按钮标签也是普通 TextBlock 孩子,布局和生命周期沿同一组成树,不是原生按钮硬编码文字。 Button => { let label = new_block( cx, @@ -479,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) @@ -502,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() @@ -528,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), @@ -560,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::(root, "Block")?.page { @@ -581,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())?; @@ -598,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"))?; @@ -615,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; @@ -626,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())?; } @@ -654,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) @@ -663,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 @@ -676,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 => { @@ -691,6 +720,7 @@ impl NativeCallable for Function { } } } + // children 查询返回 slot文本->Block 的托管字典,不把当前顺序假装成稳定 slot 连号数组。 Children => { let result = cx.new_dict()?; for child in data.children { @@ -709,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); @@ -719,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, @@ -730,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 { @@ -742,6 +775,7 @@ impl NativeCallable for Function { }]; show(cx, owner, &self.session)?; } + // 分离先创建新 Window,再把同一 Page 展示到它;跨窗口出现上下文由后续展示同步重建。 Detach => { let window = { let mut state = self @@ -780,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 @@ -828,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, @@ -835,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 @@ -862,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 @@ -869,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)?; ( @@ -883,6 +923,7 @@ impl NativeCallable for Function { } _ => return Err(invalid("unsupported Block operation")), } + // 非提前返回的结构编辑/订阅操作在末尾统一经过写屏障,触发拥有边更新和变更日志。 cx.replace_payload(data_ref, data)?; Ok(NativeResult::Return(result)) } diff --git a/src/bridge/ui_bridge/developer_pages.rs b/src/bridge/ui_bridge/developer_pages.rs index 86fb423..9f8be78 100644 --- a/src/bridge/ui_bridge/developer_pages.rs +++ b/src/bridge/ui_bridge/developer_pages.rs @@ -10,18 +10,20 @@ struct OpenTool { ui: ObjectRef, } impl NativeCallable for OpenTool { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 仅接受一个工具名称字符串,工具类型与白名单在 invoke 检查。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 把白名单工具名转换成对应托管 Page;New tab 转交预编译工厂,其余管理页复用已有实例或创建新页。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { 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 { @@ -29,6 +31,7 @@ impl NativeCallable for OpenTool { arguments: vec![], }); } + // 锁内只复制弱节点索引,之后逐个升级并查工具标记,不持 session 锁运行 show 的流程。 let nodes = self .session .lock() @@ -39,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 @@ -56,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), @@ -78,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(()) @@ -87,6 +93,7 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<() /// 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"), @@ -96,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) @@ -107,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, @@ -117,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 } diff --git a/src/bridge/ui_bridge/page_input.rs b/src/bridge/ui_bridge/page_input.rs index b5b354f..0eb6789 100644 --- a/src/bridge/ui_bridge/page_input.rs +++ b/src/bridge/ui_bridge/page_input.rs @@ -11,6 +11,7 @@ impl UiBridge { vm: &mut Vm, input: &crate::events::Input, ) -> Result<()> { + // 锁内只读取当前发布版本与弱 Mount 身份,离开锁后才进入 VM 操作,避免持 session 锁调用用户代码。 let (weak, desktop) = { let state = self .session @@ -33,6 +34,7 @@ impl UiBridge { .upgrade(weak)? .ok_or_else(|| anyhow::anyhow!("3D viewport Mount expired"))?; let result = (|| { + // 除 session 发布版本外,再检查目标自身仍挂接、版本匹配且确为 3D 视口,拒绝旧出现操作。 let occurrence = vm.payload::(mount, "Mount")?.clone(); anyhow::ensure!( occurrence.mounted @@ -45,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"))?; @@ -55,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 { @@ -63,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)) @@ -71,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( @@ -81,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.)), @@ -89,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( @@ -101,13 +110,16 @@ 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 } @@ -118,6 +130,7 @@ impl UiBridge { vm: &mut Vm, input: &crate::events::Input, ) -> Result<()> { + // 二维视口同样先校验发布版本,再从活动表捕获原 Mount,不能因目标消失自动换成另一个视口。 let weak = { let state = self .session @@ -151,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 @@ -170,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, @@ -181,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; @@ -188,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))?; @@ -208,6 +226,7 @@ impl UiBridge { vm: &mut Vm, input: &crate::events::Input, ) -> Result<()> { + // 兼容 Page 相机不通过 Mount 定位,而是读取当前活动页面及其 Surface 副本;发布版本仍必须一致。 let (page, mut surface, viewport) = { let state = self .session @@ -243,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( @@ -253,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))?; diff --git a/src/bridge/ui_bridge/signatures.rs b/src/bridge/ui_bridge/signatures.rs index aaa6744..adf6a42 100644 --- a/src/bridge/ui_bridge/signatures.rs +++ b/src/bridge/ui_bridge/signatures.rs @@ -14,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 => ( @@ -21,6 +23,7 @@ pub(super) fn method_signature( Unit, CallEffect::Mutating, ), + // 绑定创建可取消 Subscription,并改写目标连接关系,因此属于 Mutating。 Bind => ( vec![TextType, Function], nominal("Subscription")?, @@ -30,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")?], @@ -40,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 => ( @@ -71,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); @@ -81,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")?), @@ -99,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), @@ -106,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), @@ -119,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, @@ -144,6 +156,7 @@ pub(super) fn operation_signature( nominal("ScrollBlock")?, CallEffect::ReadOnly, ), + // 布局容器前两个固定参数是 Number,其后每个尾参都必须是 Block,返回类型按具体构造器选取。 Row | Column | Overlay => ( vec![Number, Number], Some(nominal("Block")?), @@ -169,6 +182,7 @@ pub(super) fn operation_signature( nominal("Viewport3D")?, CallEffect::ReadOnly, ), + // 3D 构造器参数来自 Kind 元数据:Number/String 映成基础注解,其余名称从本 desktop 注册表查名义类型。 SceneObject(kind) => { let parameters = kind .parameters() @@ -181,6 +195,7 @@ pub(super) fn operation_signature( .collect::, VmError>>()?; ( parameters, + // 3D 集合尾参使用 Object 名义约束;各具体构造操作仍需在实现中检查允许的场景对象类别。 if kind.variadic() { Some(nominal("Object")?) } else { @@ -210,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(), diff --git a/src/bridge/ui_bridge/start_page.endor b/src/bridge/ui_bridge/start_page.endor index 4e2da9b..73eb030 100644 --- a/src/bridge/ui_bridge/start_page.endor +++ b/src/bridge/ui_bridge/start_page.endor @@ -2,12 +2,15 @@ // 默认页由真实 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 @@ -16,15 +19,18 @@ 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 @@ -33,6 +39,7 @@ class StartActions { page = Page() page.title = "窗口页面" back = Button("返回画布") + // 绑定的是原 home 的 activate 方法;此回调恢复已有页身份,不重新构造主画布。 back.onClick(self.home.activate) page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back)) } @@ -45,10 +52,12 @@ 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 的身份或生命周期。 @@ -61,6 +70,7 @@ 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" @@ -68,10 +78,12 @@ class StartActions { } // 重新调用工厂得到独立页面与相机,已有画布继续保留自己的视角。 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) @@ -84,12 +96,14 @@ class StartActions { 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) @@ -99,10 +113,12 @@ function newTab(): Page { // 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 diff --git a/src/bridge/ui_bridge/tree.rs b/src/bridge/ui_bridge/tree.rs index 89edc4d..7f15049 100644 --- a/src/bridge/ui_bridge/tree.rs +++ b/src/bridge/ui_bridge/tree.rs @@ -16,6 +16,7 @@ pub struct UiNode { pub align_self: Option, pub margin: crate::layout::Insets, pub sizing: crate::box_model::Sizing, + // 单边可选覆盖值;None 表示沿用 kind 的统一 padding,不代表边距为零。 pub padding_edges: [Option; 4], pub border: crate::layout::Insets, pub border_color: String, @@ -25,6 +26,7 @@ pub struct UiNode { pub protocol_key: String, // slot 保持组成身份;数组次序确定当前绘制/布局顺序,二者不是同一概念。 pub slots: Vec, + // 从按钮、点击连接或自定义事件等能力推导的交互资格,供命中宽阶段使用,不代表最终 hitTest 必然成功。 pub interactive: bool, pub kind: UiKind, } @@ -48,6 +50,7 @@ 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 @@ -56,6 +59,7 @@ impl UiNode { /// 判断当前节点是否会为子项指定实际分配范围,供源布局选择安排路径。 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) @@ -65,6 +69,7 @@ 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 @@ -84,6 +89,7 @@ impl UiNode { { return false; } + // 直接孩子按 Arc 指针比较而不是递归深比较;依赖提取保证未变子树复用 Arc,父级只需验证其身份。 let same_children = || { self.children().len() == other.children().len() && self @@ -92,6 +98,7 @@ impl UiNode { .zip(other.children()) .all(|(a, b)| Arc::ptr_eq(a, b)) }; + // 同类变体再比较其专有数据和直接子项;变体不同必为 false,避免布局种类切换后误复用缓存。 match (&self.kind, &other.kind) { ( UiKind::Viewport3D { @@ -211,6 +218,7 @@ impl UiNode { #[derive(Debug, Clone, PartialEq)] /// 视觉内容的原生分类。叶节点保存绘制数据,容器保存子树,场景视口另外保存相机与世界投影。 pub enum UiKind { + // 有限 3D 视口包含不可变场景视图和面板 UI 孩子;panels 与 children/slots 按顺序对应。 Viewport3D { width: f32, height: f32, @@ -219,29 +227,35 @@ pub enum UiKind { panels: Vec, children: Vec>, }, + // 二维世界组成保留孩子的显式位置,不使用行列的累计偏移定位。 Scene2D { positions: Vec<[f32; 2]>, children: Vec>, }, + // 有限布局盒子观察独立 Scene2D,相机仅影响投影,不改变场景节点原始逻辑位置。 CanvasViewport { width: f32, height: f32, camera: crate::viewport::CameraView, children: Vec>, }, + // 孩子重叠布置,顺序同时决定叠放次序;普通有限布局与世界 Canvas 不同。 Overlay { padding: f32, children: Vec>, }, + // 旧 Page Canvas 模式的兼容投影,位置数组与孩子顺序一致,页面级 Surface 保存其相机。 Canvas { positions: Vec<[f32; 2]>, children: Vec>, }, + // 按钮是有子内容的容器,不把标签文本硬编码进 UiKind;交互资格另由 UiNode 标志表达。 Button { padding: f32, gap: f32, children: Vec>, }, + // 文字叶节点保留源字号等级与颜色文本;排版及最终设备比例由后续布局/显示列表转换处理。 Text { value: String, size: u32, @@ -263,7 +277,9 @@ pub enum UiKind { gap: f32, children: Vec>, }, + // 无视觉内容的间隔叶节点,提供布局占用尺寸。 Spacer(f32), + // 滚动盒保存有限分配尺寸、允许轴和普通内容;实际偏移按 Mount 出现分别维护,不放到共享节点里。 Scroll { axis: crate::scroll::Axis, width: f32, diff --git a/src/main.rs b/src/main.rs index f149124..f698d0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,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, @@ -41,7 +42,9 @@ fn main() -> Result<()> { 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)?; diff --git a/src/model/binding.rs b/src/model/binding.rs index c4689e9..395b572 100644 --- a/src/model/binding.rs +++ b/src/model/binding.rs @@ -18,15 +18,20 @@ use crate::{block, event_dispatch::Subscription, ui_bridge::attach_desktop_type} #[derive(Debug, Clone)] /// 上次绑定计算的读取观察,用于判断是否需要重新执行源计算。 struct Cached { + // 每项保存读取对象/属性的版本观察;刷新时向对象存储核对,而非复制业务值。 reads: Vec, + // 只借用上次结果的身份做相等比较;不能解引用其中可能已被回收的对象。 output: Value, } #[derive(Debug, Clone)] /// 托管属性连接,强持有计算/目标所需引用,并保留只读计算的版本缓存。 pub struct Binding { + // 目标属性名;目标 Block 通过 bindings 列表拥有本连接。 field: String, + // Some 表示连接有效且拥有计算函数;取消/替换后置 None,让 trace 停止保活函数。 pub compute: Option, + // payload 克隆共享同一缓存,刷新局部副本也能更新缓存;首次计算前为 None。 reads: Arc>>, } impl ObjectPayload for Binding { @@ -58,12 +63,13 @@ pub fn connect( compute: ObjectRef, desktop: ObjectRef, ) -> Result { + // 在触碰连接列表前拒绝非法名称,尤其禁止绑定运行时类型属性。 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"))?; @@ -73,6 +79,7 @@ pub fn connect( "binding compute must be a Function", )); } + // 刷新统一以零参数调用 compute,因此必须有参数元数据并且接受零实参。 if !cx .store() .call_arity(compute)? @@ -85,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 @@ -112,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)?, })?; @@ -130,14 +140,17 @@ pub fn refresh( nodes: &[WeakObjectRef], host_desktops: &HashSet, ) -> Result { + // 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) { @@ -154,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 @@ -165,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, @@ -181,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())?; @@ -201,6 +220,7 @@ pub fn refresh( }); Ok(()) })(); + // 缓存不拥有输出对象;释放调用返回值后再传播应用错误,避免失败路径漏引用。 vm.release_value(value)?; applied?; } @@ -209,6 +229,7 @@ pub fn refresh( vm.release(target)?; result?; } + // 整轮无需计算才达到固定点;即使某次计算输出未变,也再扫描一轮确认依赖有效。 if !evaluated { return Ok(work); } diff --git a/src/model/block.rs b/src/model/block.rs index 5e72d44..31b62e7 100644 --- a/src/model/block.rs +++ b/src/model/block.rs @@ -13,6 +13,7 @@ use crate::ui_bridge::UiNode; #[derive(Debug, Clone)] /// 稳定组成 slot 与子 Block 的配对;slot 不依赖孩子当前的屏幕位置。 pub struct Child { + // slot 标识父组件中这条组成边,移动行序不改变它;同一 Block 可通过不同 slot 出现多次。 pub slot: u64, pub block: ObjectRef, } @@ -57,6 +58,7 @@ pub enum Kind { #[derive(Debug, Clone)] /// 对共享 Cell 的稳定包装。赋值可让所有别名一起改指向,而每次出现的 Mount 仍独立。 pub struct Block { + // 共享赋值单元 Cell;其值可为最终 Data 或另一个 Block,从而保留别名身份并转发属性/类型查询。 pub state: ObjectRef, pub page: bool, pub tasks: Option, @@ -72,6 +74,7 @@ impl ObjectPayload for Block { 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::() { Ok(Some(value)) } else { @@ -94,6 +97,7 @@ impl ObjectPayload for Block { Ok(Some(value)) } else { store.payload::(value, "Block data")?; + // 到达最终 Data 时停止类型转发,使用当前 Block 自身名义类型;只有替换为其他 Block 才继续转发类型。 Ok(None) } } @@ -126,11 +130,13 @@ impl ObjectPayload for Block { if !store.object(source)?.payload().as_any().is::() { return Ok(None); } + // Page 与普通组件不能交叉共享赋值,防止把窗口/页面生命周期能力替换成普通叶组件。 if store.payload::(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)) } @@ -150,6 +156,7 @@ pub struct Data { pub align_self: Option, pub margin: crate::layout::Insets, pub sizing: crate::box_model::Sizing, + // 四边覆盖顺序为上、右、下、左;None 表示回退统一 padding,不等于零。 pub padding_edges: [Option; 4], pub border: crate::layout::Insets, pub border_color: String, @@ -160,19 +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, pub history_index: usize, pub bindings: Vec, pub text_binding: Option<(ObjectRef, String)>, pub click: Option, pub subscriptions: Vec, + // 源可读写的视觉字段存储;Data 自身保存结构边与构造默认值,提取时再将当前属性覆盖到派生副本。 pub properties: Option, } impl Data { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 建立组件业务描述,为初始孩子分配稳定 slot,并初始化无绑定、无订阅的默认布局策略。 pub fn new(kind: Kind, children: Vec, padding: f32, gap: f32) -> Self { + // 先为输入孩子分配稳定 slot,next_slot 从当前长度继续,后续追加不会复用被删除条目的编号。 let next_slot = children.len() as u64; Self { kind, @@ -215,6 +226,7 @@ impl Data { } /// 检查原生组件种类是否承载子组成,供树修改和布局选择使用。 pub fn is_container(&self) -> bool { + // 这些类别允许通过通用 Children 方法编辑;Viewport3D 的面板内容来自 Scene3D 提取,不是普通 children 列表。 matches!( self.kind, Kind::Row @@ -244,6 +256,7 @@ impl ObjectPayload for Data { } /// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。 fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { + // 所有结构字段中出现的托管强引用必须枚举:场景/相机、属性存储、孩子、历史、绑定、订阅和回调。 if let Some(scene) = self.scene3d { visit(scene); } @@ -280,15 +293,18 @@ impl ObjectPayload for Data { /// 分配 Data、共享 Cell 和 Block 包装,并让托管图拥有必要引用。 pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result { 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, @@ -314,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")); } @@ -322,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::() { dependencies.push(value); return Ok(( @@ -336,7 +354,7 @@ 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, @@ -356,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")?.state; + // path 只追踪当前祖先 Cell,允许其他分支共享同一组件;forbidden_state 额外阻止新边回到将被修改的目标。 if forbidden_state == Some(state) || !path.insert(state) { return Err(VmError::InvalidArgument( "Block composition would create a cycle", @@ -375,6 +395,7 @@ pub fn validate( "Block composition would create a cycle", )); } + // Data 展开孩子,非 Data 则沿共享赋值转发链继续;显式 Exit 在子树完成后清除祖先标记。 if let Ok(data) = store.payload::(value, "Block data") { for child in data.children.iter().rev() { work.push(Step::Enter(child.block)); @@ -388,8 +409,9 @@ pub fn validate( Ok(()) } -/// 将当前托管或可变状态提取为后续阶段使用的派生表示。 +/// 用一次性空 RenderCache 提取不可变视觉树;不跨调用保存快照缓存。 pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result, VmError> { + // 一次性提取使用空缓存,不保留跨调用结果;有增量复用需求的窗口应使用自己的 RenderCache。 crate::snapshot::RenderCache::default().snapshot(store, root) } @@ -401,6 +423,7 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, 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])), @@ -455,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), )); } @@ -505,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)), @@ -518,6 +543,7 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> { /// 复制原生描述并用当前托管属性覆盖它;结果只用于展示,不反向修改业务对象。 pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { + // 只改本轮派生副本,逐个读取实际属性并验证;读取字段名集合来自 native_properties,不遍历任意用户字段。 let mut data = data.clone(); let properties = data .properties @@ -526,6 +552,7 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { 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")); } @@ -541,6 +568,7 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { "CanvasViewport requires a Camera2D", ))?, )?; + // CanvasViewport 只容纳一个 Scene2D 根,允许其内部无限空间布局,但不接受多个普通孩子冒充场景。 if data.children.len() != 1 || !matches!( resolve(store, data.children[0].block)?.1.kind, @@ -558,6 +586,7 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { 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( @@ -578,6 +607,7 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { /// 按属性名检查和解码源值,再更新对应视觉字段;非法尺寸、枚举或颜色被拒绝。 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 { let Value::Number(value) = value else { return Err(bad()); @@ -588,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 { @@ -596,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()); } @@ -613,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), @@ -621,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, @@ -647,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 { @@ -682,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, @@ -733,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") @@ -762,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()); @@ -793,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()); } @@ -800,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()); } @@ -809,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); } @@ -824,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()?, _ => {} diff --git a/src/model/document.rs b/src/model/document.rs index 325cffa..b4c8ba2 100644 --- a/src/model/document.rs +++ b/src/model/document.rs @@ -49,8 +49,9 @@ impl Default for NamespaceState { } impl NamespaceState { - /// 仅接收当前 session 的纯数据窗口命令,并按种类合并同资源的重复请求。 + /// 在成功发布边界切换到指定 session,并按动作类别合并窗口命令;世代变化时丢弃旧命令。 pub fn publish_window_commands(&mut self, session: u64, commands: Vec) { + // 跨运行时世代先丢弃旧命令,并去除旧副窗口视口记录;主壳窗口可跨 VM 替换继续存在。 if self.runtime_session != session { self.window_commands.clear(); // The primary shell survives VM replacement. Secondary native @@ -59,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)); @@ -95,13 +98,14 @@ impl NamespaceState { Ok(Some(revision)) } - /// 清空本对象维护的当前内容或缓存,后续状态由调用方重新建立。 + /// 推进提交身份和发布版本,替换为空文档并清除运行时命令,使旧提交不能覆盖清空结果。 pub fn clear(&mut self) -> Result { 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; @@ -117,6 +121,7 @@ pub struct RenderDocument { #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub windows: std::collections::BTreeMap, #[serde(skip)] + // 仅本机增量渲染使用,不序列化;JSON 文档仍可凭 frame.commands 绘制,不需要持有 VM 句柄。 pub ui_root: Option>, #[serde(default)] pub source: String, @@ -129,6 +134,7 @@ 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) @@ -144,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 @@ -164,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() { @@ -188,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 @@ -199,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()) { @@ -214,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) @@ -223,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() @@ -234,6 +246,7 @@ impl RenderDocument { } } + // 携带本机视觉根时必须与第一个根出现的业务身份一致;反序列化无 ui_root 时跳过这项。 if let Some(root) = &self.ui_root && self .frame @@ -250,13 +263,15 @@ impl RenderDocument { Ok(()) } - /// 校验文档后把逻辑绘制命令转换成给定设备比例的显示列表,应用变换与裁剪。 + /// 将逻辑命令转换到设备比例并应用逐命令视图/裁剪;不自动调用 validate,发布入口须先校验。 pub fn display_list(&self, scale: f32) -> Result { 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, @@ -270,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)), ); @@ -277,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)) @@ -311,6 +328,7 @@ impl RenderDocument { list.push(DrawCommand::PopClip); } } + // 故障提示最后追加在页面上方,只显示错误第一行;状态是否允许输入由 Frame.hit_test 等另行限制。 if let Some(error) = &frame.fault { list.push( RenderCommand::RoundedRect { @@ -361,6 +379,7 @@ 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"); } @@ -370,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); } @@ -407,12 +427,14 @@ pub struct Frame { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub command_views: Vec>, #[serde(skip)] + // 仅本机装配源覆盖/面板时使用的命令出现归属表,不进入 JSON;display_list 绘制本身不读取它。 pub command_mounts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mounts: Vec, #[serde(default)] pub layout_version: u64, #[serde(default)] + // 运行时世代与 layout_version 共同确定输入归属;仅布局版本相同不能跨 VM 接受事件。 pub session: u64, #[serde(default)] pub focus: Option, @@ -449,6 +471,7 @@ impl Default for Frame { /// 某次出现的几何与投影元数据。逻辑矩形不被相机直接改写,view/panel 保存通往视图空间的映射。 pub struct MountGeometry { #[serde(default, skip_serializing_if = "Option::is_none")] + // 可嵌套的世界 UI 映射,把页面坐标还原到此出现所在面板纹理空间。 pub panel: Option>, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub canvas_viewport: bool, @@ -457,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, #[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, @@ -485,17 +510,20 @@ impl MountGeometry { } /// 把页面指针转换为该出现的布局坐标,供 hitTest 和局部事件处理使用。 pub fn logical_point(&self, point: Point) -> Option { + // 先处理面板反投影及遮挡,再撤销二维 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([ @@ -508,6 +536,7 @@ impl MountGeometry { 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| { @@ -524,6 +553,7 @@ impl Frame { .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| { @@ -535,9 +565,11 @@ 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() @@ -636,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"); } @@ -652,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] @@ -719,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), @@ -752,6 +787,7 @@ 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"); } @@ -768,6 +804,7 @@ pub fn parse_color(value: &str) -> Result { 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"); } @@ -776,6 +813,7 @@ pub fn parse_color(value: &str) -> Result { parse(0..2)?, parse(2..4)?, parse(4..6)?, + // 顺序为 RRGGBBAA 而非 AARRGGBB,六位色号默认完全不透明。 if hex.len() == 8 { parse(6..8)? } else { 255 }, )) } @@ -783,6 +821,7 @@ pub fn parse_color(value: &str) -> Result { #[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"}"##, @@ -808,6 +847,7 @@ mod tests { use super::*; #[test] + /// 默认空文档可通过验证且没有结构根/绘图命令,支持初始状态与清空发布。 fn empty_document_is_valid_and_blank() { let document = RenderDocument::default(); document.validate().unwrap(); @@ -816,6 +856,7 @@ mod tests { } #[test] + /// 检查六位色默认alpha255,八位色按RRGGBBAA解码,避免将末尾透明度当成首通道。 fn parses_rgb_and_rgba_colors() { assert_eq!( parse_color("#102030").unwrap(), @@ -832,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 { diff --git a/src/model/mod.rs b/src/model/mod.rs index 226731c..fe72df8 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,13 +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; diff --git a/src/model/mount.rs b/src/model/mount.rs index 8ed7fed..5e6a41d 100644 --- a/src/model/mount.rs +++ b/src/model/mount.rs @@ -26,7 +26,9 @@ pub struct Mount { // 已发布几何与每次出现的滚动位置分开保存,同一 Block 的多个位置互不覆盖。 pub geometry: MountGeometry, pub scroll_offset: [f32; 2], + // 最后同步的布局版本,Bounds 查询会一并返回,便于调用方识别几何属于哪轮发布。 pub version: u64, + // false 后禁止依赖当前几何的操作;外部即使还持有 Mount 对象也不能把它当活动出现。 pub mounted: bool, // Mount 可以拥有任务;退役后任务交付还要检查 owner 是否仍有效。 pub tasks: Option, @@ -53,6 +55,7 @@ impl ObjectPayload for Mount { 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 { @@ -86,6 +89,7 @@ enum Query { struct Method { signature: endor_core::CallSignature, owner: ObjectRef, + // 方法对象拥有 Mount,但不能经桌面反向保活整个窗口;需要桌面能力时再 upgrade。 desktop: WeakObjectRef, query: Query, } @@ -95,7 +99,7 @@ impl NativeCallable for Method { fn signature(&self) -> Option { Some(self.signature.clone()) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 适配器已绑定 Mount;坐标测试/转换与scrollTo/By接受两个数,其余层次/几何/焦点查询零参。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact( if matches!( @@ -114,9 +118,10 @@ impl NativeCallable for Method { )) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 读取具体出现位置,先检查仍挂载与几何可用性,再执行层次、坐标、焦点、滚动或拾取操作,避免使用已退役上下文。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let mount = cx.store().payload::(self.owner, "Mount")?.clone(); + // 层次查询允许观察已经脱离的对象(通常得到 Nil);几何、焦点、滚动、拾取等必须仍挂接。 if !mount.mounted && matches!( self.query, @@ -135,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 @@ -153,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", @@ -164,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, @@ -176,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, @@ -183,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( @@ -196,6 +206,7 @@ impl NativeCallable for Method { Value::Nil } } + // 同一滚动入口区分绝对位置和相对增量,钳制、挂接状态与写屏障交由 scroll::set 统一执行。 Query::ScrollTo | Query::ScrollBy => { crate::scroll::set( cx, @@ -231,6 +242,7 @@ impl NativeCallable for Method { ), )? } + // 返回帧中已钳制的有效位置与最大偏移,不直接暴露可能尚未投影的 scroll_offset 原值。 Query::ScrollPosition => { let p = mount .geometry @@ -243,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() @@ -260,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 { @@ -281,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 { @@ -297,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 { let Value::Number(number) = value else { return Err(VmError::InvalidArgument( @@ -331,6 +349,7 @@ impl NativeCallable for Method { Query::Page => mount.page, _ => unreachable!(), }; + // 弱链接不存在或目标已回收都返回 Nil;升级本身失败才传播 VM 错误。 weak.map(|weak| cx.upgrade(weak)) .transpose()? .flatten() @@ -350,6 +369,7 @@ fn mount_signature( ) -> Result { 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 => { @@ -362,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 @@ -391,6 +412,7 @@ fn mount_signature( #[derive(Debug, Default, Clone)] /// 活动出现的弱索引。真实托管拥有关系在 desktop 的挂载字典中,索引不能充当隐藏 GC 根。 pub struct MountTable { + // Mount ID 到托管弱身份,真实强拥有者是 desktop.mounts 字典;order 单独保存原布局顺序。 pub active: HashMap, order: Vec, } @@ -404,6 +426,7 @@ struct Sync { old: HashMap, old_order: Vec, frame: Frame, + // true 为布局准备阶段,安装临时 Context 并保留已有滚动偏移;false 为几何提交,清 Context。 preparing: bool, output: Arc>>, } @@ -417,13 +440,14 @@ struct LifecycleCall { unmount: bool, } impl NativeCallable for LifecycleCall { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// Block、Mount 与用户钩子都已捕获,生命周期序列无参调用此适配器。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 卸载时先取消 Mount 所有任务,再以 Block/Mount 为实参请求 VM 执行用户 onMount/onUnmount,不在 Rust 中直接运行源体。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + // 用户 onUnmount 运行前先取消该出现拥有的任务,防止退役后继续交付旧工作。 if self.unmount { crate::tasks::cancel_owner(cx, self.mount)?; } @@ -449,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 { @@ -462,13 +487,14 @@ fn lifecycle_call( } impl NativeCallable for Sync { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 展示同步步骤从捕获的帧与弱节点表读取输入,无需外部实参。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 把弱业务身份升级为本轮对象映射,再同步帧中出现位置到托管 Mount,建立结构/几何与待通知生命周期。 fn invoke(&self, cx: &mut NativeContext<'_>, _args: &[Value]) -> Result { + // 帧只含 Block 字符串身份,先将弱节点表升级为本次同步可用的实际对象映射。 let mut blocks = HashMap::new(); for weak in &self.nodes { if let Some(block) = cx.upgrade(*weak)? { @@ -493,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::(old, "Mount")?; (mount.mounted && mount.page == page && mount.block == block).then_some(old) @@ -516,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), @@ -552,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]; @@ -566,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::(object, "Mount")?.scroll_offset } else { @@ -573,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 { @@ -580,6 +611,7 @@ impl NativeCallable for Sync { }, }; let old = cx.store().payload::(object, "Mount")?; + // 只在上下文、几何、版本、链接或公开挂接属性确实需更新时替换 payload,减少无关变更日志。 if self.preparing || old.layout.is_some() || !old.mounted @@ -602,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()), @@ -611,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::(object, "Mount")?.clone(); + // 先撤销挂接和全部关系,再安排回调;onUnmount 不能继续使用失效几何或沿旧链操作。 mount.mounted = false; mount.layout = None; mount.parent = None; @@ -627,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 { @@ -644,6 +681,7 @@ impl NativeCallable for Sync { .into_iter() .map(|(id, object)| Ok((id, cx.store().downgrade(object)?))) .collect::, VmError>>()?; + // 先让桌面拥有新字典,再公布原生弱索引;返回的驱动保持待退役对象直到回调结束。 cx.set_attr(self.desktop, "mounts", Value::Object(root))?; *self .output @@ -661,6 +699,7 @@ impl NativeCallable for Sync { pub struct Checkpoint { table: MountTable, dictionary: Option, + // 保存 payload 与 mounted/layoutVersion 两项公开属性;None 表示原先没有属性,回滚要删除。 states: Vec<(ObjectRef, Mount, Option, Option)>, roots: Vec, } @@ -669,6 +708,7 @@ 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); @@ -685,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() { @@ -702,6 +743,7 @@ impl Checkpoint { } Ok::<_, VmError>(()) })(); + // 保存处理结果后先释放 upgrade 根,再传播处理错误,避免失败路径遗留临时强引用。 vm.release(object)?; result?; } @@ -709,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 { @@ -730,6 +773,7 @@ impl Checkpoint { *table = self.table.clone(); Ok(()) })(); + // 恢复过程失败也要释放检查点根;先尝试两项操作,再按恢复错误优先的顺序返回。 let released = self.release(vm); result?; released @@ -746,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); @@ -759,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::(object, "Mount")?.clone(); let mut attributes = Vec::new(); @@ -768,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); @@ -784,6 +831,7 @@ impl MountTable { } Ok::<_, VmError>(()) })(); + // 检查点创建到一半失败时释放已经取得的根,不返回一个不完整且可能泄漏的检查点。 if let Err(error) = result { checkpoint.release(vm)?; return Err(error); @@ -811,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)?; @@ -839,6 +888,7 @@ impl MountTable { preparing: bool, ) -> Result, VmError> { let output = Arc::new(Mutex::new(HashMap::new())); + // 同步入口显式保活桌面与可选页面;旧表和节点表本身仍是弱索引,不增加隐式根。 let mut captures = vec![desktop]; captures.extend(page); let function = vm.allocate(ContextNativeFunction::new( @@ -855,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() diff --git a/src/model/object_list.rs b/src/model/object_list.rs index 368a2d6..aa575ce 100644 --- a/src/model/object_list.rs +++ b/src/model/object_list.rs @@ -1,5 +1,5 @@ //! 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。 -//! 用于儿童组成、布局结果和几何输入等需要保留对象身份的场景;不是窗口线程共享的可变容器。 +//! 用于子节点组成、布局结果和几何输入等需要保留对象身份的场景;不是窗口线程共享的可变容器。 //! //! Managed indexed host results; values and the at method are traced edges. use endor_core::{ @@ -46,16 +46,17 @@ impl NativeCallable for At { endor_core::CallEffect::ReadOnly, )) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 绑定列表接收者已捕获,只接受一个显式索引。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 要求索引可精确转为非负平台整数,检查列表范围后返回对应托管成员,不复制成员对象。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { 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()) @@ -79,7 +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, @@ -91,6 +93,7 @@ fn build( "object list exceeds 10000 members", )); } + // 在移动 Vec 之前提取元信息;成员对象的拥有边由 ObjectList::trace 声明。 let first = mounts .first() .copied() @@ -105,6 +108,7 @@ 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))?; @@ -127,27 +131,30 @@ impl NativeCallable for Constructor { fn signature(&self) -> Option { self.signature.clone() } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 至少包含请求 Type,之后为可变成员对象与源子类字段,具体分段在 invoke 校验。 fn arity(&self) -> Option { Some(endor_core::CallArity::range(1, usize::MAX)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 按请求类型字段数切分成员/字段,禁止覆盖列表协议保留属性,验证后分配列表并初始化源字段。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let [Value::Object(definition), values @ ..] = args else { return Err(VmError::InvalidArgument("ObjectList type argument")); }; + // 首参是要构造的名义类型,余下实参分成“成员对象前缀”和“子类字段后缀”。 let fields = cx .store() .payload::(*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()) @@ -156,6 +163,7 @@ impl NativeCallable for Constructor { ))?; cx.store() .check_class_field_arguments(*definition, &values[count..])?; + // 字段值先按声明检查;成员前缀另行要求全部是对象,禁止把标量装入宿主对象列表。 let members = values[..count] .iter() .map(|value| { diff --git a/src/model/presentation.rs b/src/model/presentation.rs index 4336500..74e3eaf 100644 --- a/src/model/presentation.rs +++ b/src/model/presentation.rs @@ -11,12 +11,15 @@ use std::sync::Arc; #[derive(Debug)] /// 一个窗口独立的展示状态:活动页面、待提交请求、Mount、布局与源读取缓存;共享 VM 的业务状态不放在这里复制。 pub(crate) struct Presentation { + // 所属托管桌面身份;此结构借用身份,真正的展示根由托管桌面字典维持。 pub desktop: ObjectRef, pub closing: bool, pub tabs: ObjectRef, pub active: Option, + // 双层 Option 区分三态:None 无请求,Some(None) 请求清空,Some(Some(page)) 请求切页。 pub pending: Option>, pub last_snapshot: Option>, + // 弱身份到原生稳定标签 ID 的映射;不能因为标签历史而保活已经关闭的页面。 pub tab_ids: Vec<(WeakObjectRef, u64)>, pub next_tab: u64, pub last_tabs: Vec, @@ -25,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, pub layout_names: Vec, pub paint_reads: std::collections::BTreeMap, @@ -40,6 +45,8 @@ 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() @@ -62,6 +69,8 @@ impl Presentation { vm.change_version(*object).ok() ); } + // 再核对名称级依赖,避免仅凭对象版本漏掉名称解析路径上的变化。 + // 两个检查独立执行,因此同次观察可能分别增加计数,后者诊断可覆盖前者。 if let Some((phase, dependency)) = self .layout_names .iter() @@ -81,6 +90,7 @@ 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, @@ -94,16 +104,19 @@ impl Presentation { affected: &std::collections::HashSet, 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( @@ -118,7 +131,8 @@ impl Presentation { Ok(()) } - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 用桌面与标签注册表身份初始化窗口状态,布局和视觉缓存从空状态开始。 + // 这里只初始化窗口级缓存,不创建 VM、不复制业务对象;active/pending 留空等待窗口展示请求。 pub fn new(desktop: ObjectRef, tabs: ObjectRef) -> Self { Self { desktop, @@ -158,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 } => { @@ -174,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()); @@ -229,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(); diff --git a/src/model/protocols.rs b/src/model/protocols.rs index 4bf6f0b..9379c83 100644 --- a/src/model/protocols.rs +++ b/src/model/protocols.rs @@ -14,14 +14,18 @@ pub fn install( 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"), @@ -32,6 +36,7 @@ pub fn install( Some("Block"), ), ( + // 组成编辑能力属于 ContainerBlock,普通叶 Block 不因此获得追加或重排孩子的方法。 "Children", vec![ ("append", 1), @@ -45,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")); @@ -55,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() @@ -66,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( @@ -75,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(), @@ -82,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)?; } } diff --git a/src/model/scene3d.rs b/src/model/scene3d.rs index 4bbbb8d..d46af8e 100644 --- a/src/model/scene3d.rs +++ b/src/model/scene3d.rs @@ -84,8 +84,9 @@ impl Constructor { .into_iter() .find(|kind| kind.function() == name) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 返回三维原生构造的显式最小/最大参数数,不含请求 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), @@ -205,6 +206,7 @@ fn set_number( } /// 解析颜色值并检查其满足当前原生绘制约定。 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")); @@ -224,6 +226,7 @@ pub fn allocate( constructor: Constructor, args: &[Value], ) -> Result { + // 入口假设桥接工厂已按 arity/类型检查 args;Geometry 特殊地复制缓冲并提前返回,其余创建可编辑标记节点。 let kind = match constructor { Constructor::Vec3 => NodeKind::Vector, Constructor::Triangle => NodeKind::Triangle, @@ -236,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| { @@ -257,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. { @@ -272,6 +277,7 @@ pub fn allocate( triangles, uv: vec![], }; + // 缓存为原生 Arc 前统一验证顶点/三角形预算与下标范围,避免渲染线程再接触源缓冲对象。 if !geometry.valid() { return Err(invalid( "invalid Geometry3D buffers or geometry budget exceeded", @@ -300,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")); } @@ -315,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::, _>>()?; for child in &children { @@ -327,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 { @@ -345,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)?; @@ -379,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.), @@ -405,6 +416,7 @@ struct Reader<'a> { impl Reader<'_> { /// 读取字段并登记依赖,使后续字段变更能够使 3D 快照失效。 fn value(&mut self, object: ObjectRef, name: &str) -> Result { + // 读取前登记精确对象/属性依赖,HashSet 去重;返回给视觉缓存后,相关字段变化才能驱动重建。 self.reads.insert((object, name.into())); self.store.get_attr(object, name) } @@ -422,6 +434,7 @@ impl Reader<'_> { } /// 读取并校验透视或正交摄像机字段,生成不持有 VM 引用的投影描述。 fn camera(&mut self, object: ObjectRef) -> Result { + // 先验证宿主相机种类,再按投影策略读取 fov 或 height;不把有同名字段的对象当相机。 let projection = match self.store.payload::(object, "3D camera")?.0 { NodeKind::Perspective => Projection::Perspective { fov: self.number(object, "fov")?, @@ -456,6 +469,7 @@ pub struct PanelMount { pub content: ObjectRef, pub size: crate::layout::Size, } +// 三部分分别是可跨线程的不可变 View、VM 内缓存用的属性依赖、仍由 VM 驱动布局的面板 Block 根。 pub type Snapshot = ( Arc, Vec<(ObjectRef, String)>, @@ -496,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(); @@ -508,6 +523,7 @@ pub fn snapshot_reusing( continue; } visits += 1; + // 预算按展开出现次数,重复共享同一网格也消耗实例/顶点/三角形工作,不能只数不同对象身份。 if visits > 4096 { return Err(VmError::ExecutionLimit("3D scene occurrences")); } @@ -517,6 +533,7 @@ pub fn snapshot_reusing( work.push((object, parent, true, key)); let kind = store.payload::(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"])?, @@ -531,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); @@ -540,6 +559,7 @@ pub fn snapshot_reusing( NodeKind::Mesh => { let geometry = object_ref(&reader.value(object, "geometry")?)?; let geometry = store.payload::(geometry, "Geometry3D")?.0.clone(); + // 每次 mesh 出现都累计完整几何,Arc 共享只节约存储,不减少本帧实例绘制工作预算。 triangles += geometry.triangles.len(); vertices += geometry.vertices.len(); if vertices > 262_144 { @@ -560,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")); } @@ -584,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.), @@ -607,6 +630,7 @@ pub fn snapshot_reusing( surface: None, }), }); + // 同时把普通 UI 根交给宿主布局,scene Mesh 先只带分辨率/背景,surface 由后续 panel::compose 装配。 panels.push(PanelMount { key, content, @@ -622,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"))?; @@ -639,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 { @@ -646,6 +672,7 @@ 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")); } @@ -667,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); } @@ -675,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.), @@ -695,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)) @@ -718,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), @@ -752,7 +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, @@ -770,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; @@ -784,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, @@ -802,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 @@ -810,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; } @@ -818,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, @@ -833,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")); } @@ -849,7 +887,7 @@ pub fn control( Ok(()) } -/// 执行托管 3D 节点的原生平移/旋转操作,供尚不支持普通数值算术表达式的源程序调用。 +/// 对允许的 3D 节点累计平移或角度旋转;相机平移同时更新 target,旋转值归一到 [0,360)。 pub fn transform( cx: &mut NativeContext<'_>, owner: ObjectRef, @@ -869,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])?; @@ -884,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; @@ -904,11 +946,11 @@ pub fn transform( Ok(()) } impl endor_core::NativeCallable for PointerPick { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 输入 Mount 与点击点已捕获,拾取/分发准备步骤不再接受实参。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 在当前 Mount 中重新射线拾取,构造含真实 mesh 命中的 click Event,再交托管事件系统分发。 fn invoke( &self, cx: &mut NativeContext<'_>, @@ -921,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) } diff --git a/src/model/snapshot.rs b/src/model/snapshot.rs index 6b9d024..81b8b0a 100644 --- a/src/model/snapshot.rs +++ b/src/model/snapshot.rs @@ -31,10 +31,15 @@ struct Dependency { #[derive(Debug, Default)] /// 视觉快照缓存与反向依赖图。对象身份是借用索引,原生 Arc 数据不拥有 VM 根。 pub struct RenderCache { + // 场景对象身份到原生不可变几何的缓存;相机改变时仍有机会复用同一场景数据。 scenes3d: HashMap>, + // 每个业务 Block 的视觉结果;这里只保存原生快照,不通过 ObjectRef 增加 VM 强引用。 entries: HashMap, + // 读取源 -> 读取它的 Block。属性变动从这里找到首批需要重建的快照。 dependents: HashMap>, + // 子 Block -> 所有父 Block。共享子组件改变后,所有引用该子项的父快照都需要更新。 parents: HashMap>, + // 已失效但尚未重建的 Block 集合,同时用于传播去重。 dirty: HashSet, pub last_invalidation: String, pub visited: usize, @@ -45,11 +50,13 @@ 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 { @@ -70,6 +77,7 @@ impl RenderCache { pub fn invalidate_objects(&mut self, objects: impl IntoIterator) { 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) { @@ -81,6 +89,7 @@ impl RenderCache { /// 撤销旧缓存的反向读取边和父子边,为重新登记该节点做准备。 fn remove(&mut self, block: ObjectRef) -> Option { let old = self.entries.remove(&block)?; + // 旧边必须先撤销,否则节点重建后仍会被已经不再读取的对象触发失效。 for source in &old.dependencies { if let Some(owners) = self.dependents.get_mut(source) { owners.remove(&block); @@ -89,6 +98,7 @@ impl RenderCache { } } } + // 只撤销当前 Block 对孩子的父边;其他共享父节点仍保留。空集合及时删除以缩小索引。 for child in &old.children { if let Some(parents) = self.parents.get_mut(child) { parents.remove(&block); @@ -104,6 +114,7 @@ impl RenderCache { pub fn prune(&mut self, store: &ObjectStore) { self.scenes3d .retain(|object, _| store.object(*object).is_ok()); + // 先收集死亡身份,再修改 entries 与反向索引,避免一边借用遍历一边删除。 let dead: Vec<_> = self .entries .keys() @@ -112,6 +123,7 @@ impl RenderCache { .collect(); for block in dead { self.remove(block); + // 只有成功建立新条目后才清脏标记;前面任何校验失败都不会把节点错误标成干净。 self.dirty.remove(&block); } } @@ -124,6 +136,8 @@ impl RenderCache { ) -> Result, 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::>::new(); @@ -137,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)?; @@ -152,6 +168,7 @@ impl RenderCache { property: None, }) .collect(); + // 方法名称查找也构成依赖。即使属性数值未变,协议方法被替换也必须让快照失效。 let storage = *store .attribute_path(block)? .last() @@ -162,6 +179,7 @@ impl RenderCache { property: Some(name), }); } + // 原生视觉属性按精确字段登记,避免不参与视觉的其他属性写入触发整个节点重建。 if let Some(properties) = data.properties { for (name, _) in block::native_properties(&data) { dependencies.push(Dependency { @@ -180,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 @@ -223,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)? { @@ -237,6 +257,7 @@ impl RenderCache { }); } } + // 不仅记录最终方法,还沿基类链登记类型内容依赖,使继承关系或方法表变化能触发重建。 let event = store.lookup_method(definition, "onEvent")?; let mut current = Some(definition); while let Some(definition) = current { @@ -254,6 +275,7 @@ impl RenderCache { .transpose()? .unwrap_or(false); } + // 订阅连接的取消会改变 handler;记录连接内容依赖,使交互资格能随取消及时更新。 let mut click_connection = false; for connection in &data.subscriptions { dependencies.push(Dependency { @@ -264,9 +286,11 @@ impl RenderCache { .payload::(*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() { @@ -280,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, @@ -335,6 +360,7 @@ impl RenderCache { )?, children, }, + // 二维场景保留每个孩子的世界位置;普通行列容器则由布局决定孩子坐标。 Kind::Scene2D => UiKind::Scene2D { positions: data .children @@ -382,6 +408,7 @@ impl RenderCache { children, }, }; + // 统一外层样式与交互标志。UiKind 只表示具体内容,UiNode 保存所有类别共享的视觉元信息。 let next = UiNode { surface: matches!(data.kind, Kind::Page).then(|| { let mut surface = data.surface.clone(); @@ -408,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(); diff --git a/src/model/window.rs b/src/model/window.rs index 990a2f3..b68c2e7 100644 --- a/src/model/window.rs +++ b/src/model/window.rs @@ -32,6 +32,7 @@ pub struct Command { 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) } @@ -46,14 +47,17 @@ pub struct Envelope { /// 托管窗口资源状态与页面拥有关系;本对象存在不等于 OS 窗口已创建。 pub struct ManagedWindow { pub id: u64, + // 每窗口拥有独立 desktop、tabs 与页面顺序;同 VM 的类型注册表可共享,不复制业务模型。 pub desktop: ObjectRef, pub closed: bool, pub tasks: Option, tabs: ObjectRef, + // 页面身份的有序强拥有集合,tabs 字典同时提供托管注册入口;移动顺序只调整此 Vec。 pages: Vec, 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>>, } impl ObjectPayload for ManagedWindow { @@ -94,6 +98,7 @@ fn screen_coordinate(value: &Value) -> Result { 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)) @@ -137,7 +142,7 @@ impl NativeCallable for Method { fn signature(&self) -> Option { Some(self.signature.clone()) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 计入 Window 接收者:resize/moveTo 共三参,setTitle 两参,其他查询/关闭/聚焦只要接收者。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(match self.operation { Operation::Resize | Operation::MoveTo => 3, @@ -146,7 +151,7 @@ impl NativeCallable for Method { })) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 验证窗口资源与关闭状态;查询返回托管值,原生变更生成受世代保护的待执行命令,关闭走资源生命周期。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let Some(Value::Object(owner)) = args.first() else { return Err(invalid("Window method requires a Window")); @@ -155,6 +160,7 @@ impl NativeCallable for Method { .store() .payload::(*owner, "Window")? .clone(); + // 已关闭窗口仍可查询稳定 ID 与页面集合,其他读取/操作拒绝,避免用旧 viewport 或重开原生资源。 if data.closed && !matches!(self.operation, Operation::Id | Operation::Pages) { return Err(invalid("native window is closed")); } @@ -171,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")); @@ -185,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", @@ -203,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")); } @@ -228,6 +237,7 @@ pub fn enqueue( .store() .payload::(owner, "Window")? .clone(); + // 命令序列单调递增且检查溢出;同类合并会产生序号空洞,但仍可按序比较先后。 data.sequence = data .sequence .checked_add(1) @@ -242,7 +252,9 @@ 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(()) @@ -266,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( @@ -292,6 +305,7 @@ pub fn unregister_page( .store() .payload::(window, "Window")? .clone(); + // 不存在时撤销是空操作;存在时同时删有序列表与 tabs 字典边,不能只隐藏原生标签。 if !data.pages.contains(&page) { return Ok(()); } @@ -316,7 +330,7 @@ pub fn unregister_page_host( Ok(()) } -/// 在窗口之间移动页面的所有权和登记顺序,保留同一业务页面身份。 +/// 在同一窗口的已登记页面集合内重排标签位置,保留 Page 身份;不负责跨窗口迁移。 pub fn move_page( cx: &mut NativeContext<'_>, window: ObjectRef, @@ -343,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); @@ -361,6 +376,7 @@ pub fn take_commands(vm: &Vm, window: ObjectRef) -> Result, VmError .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) @@ -417,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, @@ -430,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( @@ -457,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)?; @@ -477,10 +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( @@ -494,17 +514,20 @@ 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::(window, "Window")? .clone(); data.closed = true; cx.replace_payload(window, data)?; + // 标记托管 closed 后仍可由内部 enqueue 安排原生 Close;成功输入发布前不会直接关闭系统窗口。 enqueue(cx, window, Action::Close) } @@ -512,6 +535,7 @@ pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmErro /// 撤销已失效窗口的托管资源关系,不重新执行源关闭请求。 pub fn retire(vm: &mut Vm, window: ObjectRef) -> Result<(), VmError> { let mut data = vm.payload::(window, "Window")?.clone(); + // 故障域的宿主退役仅断开页面拥有边并标 closed,不跑用户回调、不重新生成源关闭命令。 vm.dict_clear(data.tabs)?; data.pages.clear(); data.closed = true; diff --git a/src/render/box_model.rs b/src/render/box_model.rs index 2c68794..b9f1d42 100644 --- a/src/render/box_model.rs +++ b/src/render/box_model.rs @@ -75,8 +75,10 @@ impl Default for Sizing { impl Sizing { /// Constrain descendants using the actual finite box before wrapping text. - /// 根据固定/auto/fill 与 min/max 规则细化父级提供的可用约束。 + /// 将 [最小宽, 最小高, 最大宽, 最大高] 收窄,供子内容测量和文本换行使用。 + /// 固定尺寸限制最大可用空间;最后缩小最小约束以保持 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 { @@ -100,10 +102,12 @@ impl Sizing { max: f32, available: f32, ) -> Result { + // 先按策略选候选值: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", @@ -113,6 +117,7 @@ impl Sizing { available } }; + // 先满足自身 min/max,再服从父级上限;父级空间比 min 小时,父级上限优先。 Ok(value.clamp(min, max).min(available)) } Ok(Size { diff --git a/src/render/display_list.rs b/src/render/display_list.rs index b35925f..50fd84e 100644 --- a/src/render/display_list.rs +++ b/src/render/display_list.rs @@ -38,30 +38,38 @@ impl Color { #[derive(Clone, Debug)] /// 原生画布最终顺序执行的绘制原语,已经使用对应设备比例。 pub enum DrawCommand { + // 在 rect 内绘制不可变 3D 视图;平移/缩放改变视口矩形,不改写共享场景或相机。 Scene3D { rect: Rect, view: std::sync::Arc, }, + // 清除整个目标缓冲;不携带几何,因此列表平移和缩放不会改变它。 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, @@ -73,6 +81,7 @@ pub enum DrawCommand { impl DrawCommand { /// 返回平移后的绘制命令,并同步移动命令中的相关几何。 pub fn translated(mut self, delta: Point) -> Self { + // 平移只改变位置,保留尺寸、圆角、文字样式和颜色;消费 self 后返回修改后的命令。 match &mut self { Self::WrappedText { rect, .. } | Self::Scene3D { rect, .. } @@ -101,7 +110,9 @@ pub struct DisplayList { 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); @@ -131,6 +142,7 @@ impl DisplayList { } _ => None, }; + // 矩形的原点和宽高一起缩放,保证裁剪范围、文本框与实际绘制内容对齐。 if let Some(rect) = rect { rect.x *= scale; rect.y *= scale; @@ -145,6 +157,7 @@ impl DisplayList { } /// 平移当前显示列表中的命令,供整块页面或管理面板放入窗口坐标。 + // 与单条 translated 相同,但直接修改整个列表;Clear/PopClip 没有位置需要移动。 pub fn translate(&mut self, delta: Point) { for command in &mut self.commands { match command { @@ -171,6 +184,7 @@ impl DisplayList { } /// 按原顺序合并另一组绘制命令,不在这里重排层次。 + // 消费另一个列表并移动其命令,不复制字符串或场景数据,也不在连接处补裁剪指令。 pub fn extend(&mut self, other: DisplayList) { self.commands.extend(other.commands); } diff --git a/src/render/geometry.rs b/src/render/geometry.rs index 4c301ba..d8174e8 100644 --- a/src/render/geometry.rs +++ b/src/render/geometry.rs @@ -8,7 +8,7 @@ pub struct Point { } impl Point { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 直接保存二维坐标;不缩放、不舍入,也不检查有限性,调用方负责坐标空间和输入校验。 pub const fn new(x: f32, y: f32) -> Self { Self { x, y } } @@ -24,7 +24,7 @@ pub struct Rect { } impl Rect { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 直接保存左上角和宽高;不归一化负尺寸,布局调用方需先产生合法边界。 pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self { Self { x, @@ -34,7 +34,7 @@ impl Rect { } } - /// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。 + /// 用闭区间比较判断点是否命中,左右上下边缘都计入;调用前需将点转换到矩形所在空间。 pub fn contains(self, point: Point) -> bool { point.x >= self.x && point.x <= self.x + self.width @@ -48,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))); diff --git a/src/render/layout.rs b/src/render/layout.rs index 3a5f442..8534b5e 100644 --- a/src/render/layout.rs +++ b/src/render/layout.rs @@ -79,6 +79,7 @@ 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, @@ -125,9 +126,11 @@ impl MainAlign { #[derive(Debug, Clone, Copy, PartialEq)] /// 主轴伸缩策略与限值,不能用一个宽度常量替代完整分配规则。 pub struct FlexPolicy { + // Some 指定主轴首选基准,None 使用首次内容测量;grow/fill 的缺省基准在 flex_sizes 中解释为零。 pub basis: Option, pub min: f32, pub max: f32, + // 收缩权重还会乘 basis;默认零表示空间不足时不主动压缩该项。 pub shrink: f32, } impl Default for FlexPolicy { @@ -170,7 +173,9 @@ struct Key { #[derive(Debug, Clone, Default)] /// 布局缓存、稳定出现身份与统计。构建在临时副本上完成,成功后才写回。 pub struct LayoutRuntime { + // 按 Block 身份只保留最后一份 Shape/Size;共享 Block 不同约束可交替覆盖,但每次复用前仍比较完整 Shape。 sizes: HashMap, + // 出现身份取父 Mount、稳定 slot 和 Block 身份三元组,区别于仅按业务组件分配一个全局 ID。 ids: HashMap, next_id: u64, pub version: u64, @@ -209,6 +214,7 @@ fn coordinate(value: f32) -> Result { /// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。 pub fn root_constraints(viewport: Option) -> [f32; 4] { + // 无视口时以 MAX_DIMENSION 作为无界哨兵;它是有限数值预算,但 Fill 必须另行要求真正有限分配。 let size = viewport.unwrap_or(Size { width: MAX_DIMENSION as f32, height: MAX_DIMENSION as f32, @@ -218,6 +224,7 @@ pub fn root_constraints(viewport: Option) -> [f32; 4] { /// 从父级上限扣除内边距;无界哨兵保持无界,不因减法被误认为有限。 fn content_limit(available: f32, inset: f32) -> f32 { + // 不能直接从哨兵减 padding,否则略小于 MAX_DIMENSION 会被误认为有限空间,从而错误允许 Fill。 if available >= MAX_DIMENSION as f32 { available } else { @@ -227,6 +234,7 @@ fn content_limit(available: f32, inset: f32) -> f32 { /// 把父盒子的可用尺寸传递给子项,扣除容器内边距并考虑主轴是否可延伸。 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 [ @@ -240,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 { @@ -249,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, @@ -272,6 +282,7 @@ pub fn margin_constraints(child: &UiNode, mut constraints: [f32; 4]) -> [f32; 4] .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]); @@ -290,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]; @@ -299,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 @@ -333,6 +346,7 @@ pub fn initial_child_constraints( /// 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 [ @@ -364,6 +378,7 @@ 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, .. } @@ -406,6 +421,7 @@ fn measure_shape(shape: &Shape) -> Result { 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 { @@ -418,6 +434,7 @@ fn measure_shape(shape: &Shape) -> Result { 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 }) @@ -461,6 +478,7 @@ fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result { let padding = insets(node); + // Overlay 取含各自 margin 的子盒最大宽高,不把重叠项逐个相加。 let size = children.into_iter().zip(node.children()).fold( Size::default(), |size, (child, node)| { @@ -476,11 +494,13 @@ fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result { 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); @@ -511,6 +531,7 @@ fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result, constraints: [f32; 4]) pub struct Context { pub node: Option>, pub children: Vec, + // 本轮首次测量的首选尺寸,不应被上轮 flex 实际分配覆盖,否则反复测量会改变伸缩基准。 pub preferred: Vec, pub bounds: Option<[f32; 4]>, } /// 为单个原生节点按已分配几何生成内容命令;容器背景/边框与整树遍历由外层组织。 pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option { + // 文字内容盒需要减 inset 并带 wrap_bounds;Rect/Button 背景则画完整 border-box。 let size_for_text = size; let inset = insets(node); match &node.kind { @@ -580,6 +603,7 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option Vec { 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)], @@ -639,6 +663,7 @@ fn main_limits(child: &UiNode, row: bool) -> (f32, f32) { /// 按权重分配主轴剩余空间,尊重每项 min/max;达到上限的项退出后重新分配剩余量。 pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[Size]) -> Vec { + // Overlay 各孩子独立填内容盒,不共享主轴剩余量;普通线性容器才做下方加权增长/收缩。 if matches!(node.kind, UiKind::Overlay { .. }) { let inset = insets(node); return children @@ -668,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; @@ -682,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() @@ -703,6 +731,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S .sum::() as f64 - gap as f64 * children.len().saturating_sub(1) as f64) .max(0.0); + // 显式 basis 优先;grow/fill 默认从零参与分配,其余用 preferred,不拿已经压缩后的 children 充当自然基准。 let bases: Vec = nodes .iter() .zip(preferred) @@ -726,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::(); let growing = free > 0.0; + // 增长按 flex,主轴 Fill 且 flex=0 用权重1;收缩按 shrink×原 basis,保持大内容承担较多缩减。 let weights: Vec = nodes .iter() .zip(&bases) @@ -750,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 = (0..nodes.len()) .filter(|&i| { weights[i] > 0.0 @@ -767,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; @@ -775,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; } @@ -804,6 +838,7 @@ pub fn arrange_native( bounds: Size, children: &[Size], ) -> Result> { + // 3D 面板根在各自纹理空间均从 (0,0) 开始,世界摆放交由 Panel world 矩阵,不把世界坐标写入普通 UI 布局。 if matches!(node.kind, UiKind::Viewport3D { .. }) { return Ok(node.children().iter().map(|_| (0., 0.)).collect()); } @@ -881,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 { @@ -920,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() @@ -954,6 +991,7 @@ impl LayoutRuntime { /// 清空结构出现身份表;业务对象和源状态不因此被复制。 pub fn clear_occurrences(&mut self) { + // 清出现映射不重置 next_id,高水位继续增加,避免旧事件与新出现数值身份意外重合。 self.ids.clear(); } /// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。 @@ -982,6 +1020,7 @@ impl LayoutRuntime { children: Vec, constraints: [f32; 4], ) -> Result { + // 缓存只有完整 Shape 相等才复用,纯颜色变化不在测量 Shape 中,因此无需重做尺寸求解。 let shape = shape(node, children, constraints)?; if let Some((_, size)) = self .sizes @@ -1003,12 +1042,14 @@ impl LayoutRuntime { self.settle_passes = usize::from(!structural); self.slices = usize::from(!structural); let mut items = Vec::>::new(); + // 用显式栈构建父先子后 items,根采用 slot0/无父;孩子逆序入栈保持原始布局顺序。 let mut work = vec![(root, None::, 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"); } @@ -1017,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 { @@ -1027,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"); } @@ -1054,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( @@ -1069,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); @@ -1112,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; @@ -1125,6 +1171,7 @@ impl LayoutRuntime { points[*child] = point; } } + // 成功求解后丢弃已消失出现和不在本树的测量缓存,避免历史页面不断积累派生条目。 self.ids.retain(|key, _| used.contains(key)); let blocks: HashSet<_> = items .iter() @@ -1132,6 +1179,7 @@ impl LayoutRuntime { .collect(); self.sizes .retain(|block, _| blocks.contains(block.as_str())); + // 每次结构/完整构建成功都推进版本,禁止溢出复用;该版本用于后续 Mount/输入归属校验。 self.version = self .version .checked_add(1) diff --git a/src/render/mod.rs b/src/render/mod.rs index 14d1d63..1ff3924 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -1,18 +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; diff --git a/src/render/native_visual.rs b/src/render/native_visual.rs index a5043a9..9ef8ccc 100644 --- a/src/render/native_visual.rs +++ b/src/render/native_visual.rs @@ -41,6 +41,7 @@ pub(crate) fn number( .to_string() .parse::() .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")); } @@ -48,6 +49,7 @@ pub(crate) fn number( } /// 将原生布局计算结果封装为协议需要的托管 Value。 pub(crate) fn value(value: f32) -> Value { + // 原生 f32 提升到 f64 包装为 Endor Number;本函数不单独校验,调用路径必须已保证输入合法。 Value::Number((value as f64).into()) } /// 新建指定名义类型的托管记录,并逐项设置字段,作为视觉协议的结果返回。 @@ -57,6 +59,7 @@ pub(crate) fn record( kind: &str, fields: Vec<(&str, Value)>, ) -> Result { + // 先赋精确名义类型,再初始化字段;只读协议可创建结果对象,但不能借机写入旧业务图。 let object = cx.new_object()?; attach_desktop_type(cx, object, kind, desktop)?; for (key, value) in fields { @@ -64,8 +67,9 @@ pub(crate) fn record( } Ok(object) } -/// 在约束提供的视口内执行原生布局,从根出现提取宽高,再按协议约束限制结果。 +/// 以最大宽高作视口执行原生布局并提取根尺寸;最小/最大最终钳制由 Measure 分支负责。 fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result { + // 无 staged 子测量结果时,使用有限最大约束运行原生布局取得根尺寸;最终 min/max 钳制在调用方 Measure 分支。 let frame = layout::frame_with_viewport( node, Size { @@ -106,6 +110,7 @@ pub(crate) fn is_intrinsic( name: &str, desktop: ObjectRef, ) -> Result { + // 快速路径仅认可实际 ContextNativeFunction 的 Method 实现,函数名相同或签名相似的源函数不能冒充。 let Some(function) = store .object(function)? .payload() @@ -118,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")?), @@ -138,24 +144,27 @@ impl NativeCallable for Method { fn signature(&self) -> Option { Some(self.signature.clone()) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 接收三项底层参数:Block 接收者、具体 Mount、Constraints/Bounds/Point 协议输入。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(3)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 检查 Block/Mount 归属与有效性,再按 measure/arrange/paint/hitTest 执行原生视觉协议并返回受检查的托管值。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 三个实际参数含接收者 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::(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() @@ -171,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")); @@ -181,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 { @@ -197,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"))? @@ -211,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))), ], @@ -224,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 { @@ -248,16 +264,19 @@ impl NativeCallable for Method { }) .collect::, _>>()? }; + // 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, @@ -273,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, @@ -290,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 { @@ -299,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())), @@ -325,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))); @@ -347,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)?; @@ -378,7 +402,7 @@ impl NativeCallable for PointMethod { fn signature(&self) -> Option { Some(self.signature.clone()) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 圆形测试接收 Point 加圆心/半径共四项;矩形测试接收 Point 和 Bounds 共两项。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(if self.circle { 4 @@ -387,11 +411,12 @@ impl NativeCallable for PointMethod { })) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 先验证 Point 名义类型与有限坐标,再分别计算圆内距离或 Bounds 包含关系,返回 Bool。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { 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")); } @@ -407,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 { let value = value .to_string() @@ -420,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")); } @@ -432,6 +459,7 @@ 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))) @@ -457,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), @@ -485,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, diff --git a/src/render/page.rs b/src/render/page.rs index a3444f2..cc6b1d1 100644 --- a/src/render/page.rs +++ b/src/render/page.rs @@ -12,9 +12,13 @@ use serde::{Deserialize, Serialize}; /// 旧 Page 的有限或自由空间模式,新场景视口不依赖它表达布局方向。 pub enum Mode { #[default] + // 双轴被原生视口限制;兼容相机固定在原点且比例为 1。 Window, + // 宽度固定,高度允许展开到布局上限,只沿 y 方向导航。 Vertical, + // 高度固定,宽度允许展开到布局上限,只沿 x 方向导航。 Horizontal, + // 双轴自由布局并允许平移缩放;相机不按内容边缘钳制。 Canvas, } @@ -44,10 +48,12 @@ impl Mode { /// 为旧页面模式生成有限轴和可延伸轴的根约束。 pub fn constraints(self, viewport: Option) -> [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], @@ -61,10 +67,13 @@ impl Mode { /// 页面级兼容相机状态和有限内容范围,支持绘制与命中正逆变换。 pub struct Surface { pub mode: Mode, + // 发布给原生侧的页面身份文本,不是托管强引用。 pub page: String, + // 视图左上角对应的世界坐标;绘制时先减 pan 再乘 zoom。 pub pan: Point, #[serde(default = "unit")] pub zoom: f32, + // 布局产出的内容宽高,用于有限模式钳制;不决定 Canvas 的导航边界。 pub extent: Size, } @@ -87,7 +96,7 @@ impl Default for Surface { } impl Surface { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 指定页面模式并显式使用单位缩放,其他字段采用空页面默认值;无需分配 VM 或原生窗口资源。 pub fn new(mode: Mode) -> Self { Self { mode, @@ -97,6 +106,7 @@ impl Surface { } /// 把页面视图坐标反算为旧 Page 相机下的世界坐标。 + // 输入在视图空间,先除缩放再加世界偏移;调用方需保证 zoom 有限且大于零。 pub fn to_world(&self, point: Point) -> Point { Point::new( point.x / self.zoom + self.pan.x, @@ -105,6 +115,7 @@ impl Surface { } /// 把旧页面世界坐标变换到可见视图坐标。 + // 输入在世界空间,先减相机偏移再缩放,恰与 to_world 互逆。 pub fn to_view(&self, point: Point) -> Point { Point::new( (point.x - self.pan.x) * self.zoom, @@ -114,6 +125,8 @@ impl Surface { /// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。 pub fn contains(&self, point: [f32; 2], viewport: Option) -> bool { + // 传入的数组是世界坐标。转换后使用左/上包含、右/下排除的视口边界; + // 未知视口时仍排除负坐标,但不限制右/下界。 let point = self.to_view(Point::new(point[0], point[1])); point.x >= 0.0 && point.y >= 0.0 @@ -122,6 +135,8 @@ impl Surface { /// 只对有限页面按实际内容范围钳制偏移;自由画布不按内容边缘停止导航。 pub fn clamp(&mut self, viewport: Size) { + // Window 重置所有导航;单轴模式清除另一轴并恢复单位缩放。 + // 内容小于视口时 max(0) 令可滚动范围归零,避免 clamp 出现反向上下限。 match self.mode { Mode::Window => { self.pan = Point::default(); @@ -153,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; @@ -183,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 { diff --git a/src/render/scene.rs b/src/render/scene.rs index 2b8402d..28afd4a 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -25,6 +25,7 @@ pub fn build_scene( activity: &Activity, sidebar: &mut crate::sidebar::State, ) -> DisplayScene { + // 活动标签的 developer_tool 标记决定管理页类型,不靠当前窗口标题猜测;同步工具状态后再合成。 let tool = document .frame .tabs @@ -35,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), @@ -71,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, diff --git a/src/render/scene3d.rs b/src/render/scene3d.rs index e16130e..5636d3f 100644 --- a/src/render/scene3d.rs +++ b/src/render/scene3d.rs @@ -23,8 +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() @@ -39,6 +41,7 @@ impl Geometry { } /// 生成以原点为中心的盒体顶点和三角形索引;位置和旋转由网格世界矩阵另行描述。 pub fn cuboid(width: f32, height: f32, depth: f32) -> Self { + // 八个角点共享位置,每个面拆两个三角形;尺寸有效性由构造调用边界检查,cuboid 本身只生成数值。 let (x, y, z) = (width / 2., height / 2., depth / 2.); Self { uv: vec![], @@ -72,6 +75,7 @@ impl Geometry { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] /// 一次场景实例:几何、世界矩阵、颜色与可选 UI 面板,身份用于拾取回关联。 pub struct Mesh { + // 纯文本业务关联身份,用于拾取返回;不作为渲染线程可操作的托管引用。 pub identity: String, pub geometry: Arc, pub world: Matrix, @@ -110,6 +114,7 @@ pub struct Scene { pub ambient: f32, pub background: [f32; 3], #[serde(skip)] + // 序列化忽略的世界 BVH,反序列化后可按场景网格重建;相机改变不要求重建世界空间索引。 pub acceleration: Arc, } impl PartialEq for Scene { @@ -153,13 +158,13 @@ pub struct Hit { } impl View { - /// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。 + /// 递归验证相机、材质、灯光、网格/面板表面及全场景资源预算;世界 UI 嵌套最多八层。 pub fn valid(&self) -> bool { thread_local! { static DEPTH:std::cell::Cell=const { std::cell::Cell::new(0) }; } // 验证可能递归进入面板内的 3D;作用域结束时归还深度,错误返回也不泄漏计数。 struct Guard; impl Drop for Guard { - /// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。 + /// 退出当前嵌套校验层时递减线程局部深度;短路失败与正常返回都恢复同一计数。 fn drop(&mut self) { DEPTH.with(|depth| depth.set(depth.get() - 1)); } @@ -174,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) @@ -201,6 +208,7 @@ impl View { .sum::() <= MAX_TRIANGLES && self.scene.meshes.iter().all(|mesh| { + // 矩阵系数有限之外还验证每个变换后顶点范围,避免局部几何合法但累计世界变换产生无限坐标。 mesh.geometry.valid() && mesh.world.0.iter().all(|v| v.is_finite()) && color_valid(mesh.color) @@ -209,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 @@ -235,6 +244,7 @@ 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) @@ -242,6 +252,7 @@ impl View { } /// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。 pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option { + // 拾取只接受有效相机、视口内归一化点及正有限宽高比;不在此完整重验几何,调用方应先验证 View。 if !self.camera.valid() || !(0.0..=1.0).contains(&u) || !(0.0..=1.0).contains(&v) @@ -254,6 +265,7 @@ impl View { let basis = self.camera.basis()?; let mut hit: Option = None; let temporary; + // 无缓存或网格数失配时本次临时重建;数量相同的内容变更须由快照层生成新索引,不靠 matches 检测。 let index = if self.scene.acceleration.matches(self.scene.meshes.len()) { &*self.scene.acceleration } else { @@ -261,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; } @@ -272,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 @@ -282,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(), @@ -331,6 +347,7 @@ pub fn surface( height: u32, aspect: f32, ) -> Result, &'static str> { + // 全局缓存以 View Arc 身份+输出尺寸+aspect 为键;值相等但不同 Arc 的视图不会自动命中。 static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); let cache = CACHE.get_or_init(Default::default); { @@ -348,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; @@ -387,6 +405,7 @@ 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(); @@ -414,10 +433,12 @@ fn clip(mut polygon: Vec, camera: Camera, aspect: f32) -> Vec { 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))); } @@ -445,6 +466,7 @@ fn render_aspect( aspect: f32, ) -> Result, &'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 @@ -456,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(), @@ -484,12 +508,14 @@ fn render_aspect( // 宽阶段只筛选候选:离屏网格不进入后续纹理分配和逐三角形处理。 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; @@ -497,6 +523,7 @@ 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( @@ -506,6 +533,7 @@ fn render_aspect( basis, mesh.world.point(mesh.geometry.vertices[index as usize]), ), + // 没有 UV 的顶点用 [0,0];普通无纹理网格不依赖它,带纹理面板由构造器提供四角 UV。 uv: mesh .geometry .uv @@ -528,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"); @@ -535,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; @@ -543,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, @@ -576,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; @@ -599,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); @@ -614,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); @@ -624,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(), @@ -643,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![], @@ -658,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(); @@ -666,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.), @@ -689,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)); diff --git a/src/render/scene3d/math.rs b/src/render/scene3d/math.rs index 3fccd81..8cb3cd1 100644 --- a/src/render/scene3d/math.rs +++ b/src/render/scene3d/math.rs @@ -5,14 +5,14 @@ use serde::{Deserialize, Serialize}; use std::ops::{Add, Mul, Sub}; #[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] -/// 三个有限浮点分量的数学向量,不是托管 Vec3 对象。 +/// 三维数学向量;构造不校验分量,需在输入边界显式调用 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 } } @@ -34,11 +34,13 @@ impl Vec3 { } /// 仅在长度有效且不退化时返回单位向量,避免除零产生非有限结果。 pub fn normalized(self) -> Option { + // 先求模长,拒绝非有限值和长度不大于 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) @@ -80,12 +82,15 @@ impl Default for Matrix { impl Matrix { /// 求仿射世界矩阵的逆;退化或不可逆时返回空值。 pub fn inverse(self) -> Option { + // 这里按仿射矩阵处理:最后一行假设为 [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, @@ -105,6 +110,7 @@ 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; @@ -113,6 +119,7 @@ impl Matrix { } /// 组合父子仿射变换,调用顺序决定局部坐标最终落在哪个世界位置。 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 { @@ -133,6 +140,7 @@ impl Matrix { } /// 由平移、角度旋转和缩放构造局部仿射矩阵。 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(); @@ -161,7 +169,9 @@ impl Matrix { #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] /// 投影策略:透视视场角或正交可见高度。 pub enum Projection { + // fov 表示垂直视场角(度),水平范围再乘视口宽高比。 Perspective { fov: f32 }, + // height 是相机空间可见总高度,与深度无关;宽度为 height × aspect。 Orthographic { height: f32 }, } @@ -198,13 +208,16 @@ pub struct Basis { impl Camera { /// 从观察位置、目标和上方向建立正交相机基;方向退化时拒绝。 pub fn basis(self) -> Option { + // 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() @@ -223,15 +236,19 @@ 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 { 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, @@ -241,6 +258,7 @@ impl Camera { .normalized()?, }) } + // 正交射线方向都为 forward,只改变起点在相机右/上平面中的位置。 Projection::Orthographic { height } => Some(Ray { origin: self.position + basis.right * (x * height * aspect / 2.) @@ -259,15 +277,18 @@ pub struct Ray { 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; @@ -277,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])) } diff --git a/src/render/scene3d/panel.rs b/src/render/scene3d/panel.rs index 1f87182..9474fe4 100644 --- a/src/render/scene3d/panel.rs +++ b/src/render/scene3d/panel.rs @@ -17,17 +17,22 @@ 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, pub rect: [f32; 4], + // 外层二维相机变换;逆映射先处理它,再进入 3D 摄像机射线计算。 pub outer: Transform, pub clip: Option<[f32; 4]>, + // 嵌套面板的外层链;反投影从父到子,正投影从子到父。 pub parent: Option>, } impl Mapping { /// 沿父面板逆投影到当前平面的 UI 像素;要求可见时使用最近射线命中拒绝被遮挡的目标。 pub fn texture_point(&self, point: Point, clipped: bool) -> Option { + // 先把最外层窗口坐标逐层反投影为父面板像素,再检查当前面板所属空间的裁剪。 let point = if let Some(parent) = &self.parent { parent.texture_point(point, clipped)? } else { @@ -45,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 @@ -68,18 +74,22 @@ impl Mapping { 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( @@ -99,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, @@ -106,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., @@ -118,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 { @@ -142,6 +156,7 @@ 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>::new(); let mut roots = Vec::new(); // 每条命令都有出现归属;据此分流为窗口根命令或某个世界面板的内部命令。 @@ -163,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 { @@ -195,6 +211,7 @@ 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" @@ -208,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); @@ -221,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(); @@ -242,10 +262,12 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> { /// 为一个面板分配有限像素表面,并调用普通 UI 软件渲染;当前实现不会按面板内容独立缓存纹理。 pub fn texture(panel: &super::Panel) -> Result, &'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)), diff --git a/src/render/scene3d/spatial.rs b/src/render/scene3d/spatial.rs index 696c33e..b1a91b5 100644 --- a/src/render/scene3d/spatial.rs +++ b/src/render/scene3d/spatial.rs @@ -21,6 +21,7 @@ impl Bounds { .vertices .iter() .map(|point| mesh.world.point(*point)); + // 空网格没有包围盒,返回 None 后不会进入树;首顶点初始化上下界,避免把原点误算进边界。 let first = points.next()?; Some(points.fold( Self { @@ -64,6 +65,7 @@ impl Bounds { } /// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。 fn ray(self, ray: Ray) -> Option { + // slab 算法逐轴交区间;near 从零开始,只保留射线前方,起点在盒内时返回零。 let mut near = 0f32; let mut far = f32::INFINITY; for (origin, direction, min, max) in [ @@ -71,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; @@ -79,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 { @@ -87,8 +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, @@ -111,6 +116,7 @@ impl Bounds { ), ) }); + // 只有某个平面外侧包含全部八角时才剔除;盒与视锥可能无真实相交也会保守保留。 !planes.iter().any(|[a, b, c, d]| { corners .iter() @@ -148,8 +154,9 @@ pub struct Index { root: Option, } 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() @@ -173,11 +180,13 @@ impl Index { .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, @@ -198,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); @@ -211,11 +221,13 @@ 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() { @@ -236,11 +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 { + // 相机无法建立有效正交基时没有可靠视锥,返回空候选,不继续使用无效投影。 let Some(basis) = camera.basis() else { return Vec::new(); }; @@ -264,6 +278,7 @@ impl Index { } } } + // BVH 遍历顺序与场景顺序不同;恢复原下标顺序以保持后续绘制及同深度处理稳定。 output.sort_unstable(); output } diff --git a/src/render/scroll.rs b/src/render/scroll.rs index 7516a6e..3ecd7e3 100644 --- a/src/render/scroll.rs +++ b/src/render/scroll.rs @@ -54,6 +54,8 @@ impl Axis { /// 求两个裁剪矩形的交集,空区域仍保持合法非负尺寸。 fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + // 输入采用 [左, 上, 宽, 高]。交集左上取较大值,右下取较小值; + // 右/下边界再钳到左/上边界,保证完全错开时输出零面积而非负尺寸。 let x = a[0].max(b[0]); let y = a[1].max(b[1]); [ @@ -65,6 +67,8 @@ fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { } /// 沿出现树累计滚动偏移和裁剪;遇到独立场景视口时按其有限边界重新建立投影范围。 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() { @@ -82,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() @@ -95,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 { @@ -103,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 { @@ -114,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::)>::new(); frame.commands.clear(); frame.command_clips.clear(); @@ -130,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) @@ -142,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 = [ @@ -167,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::(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]]); @@ -187,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); } @@ -218,6 +236,7 @@ pub fn coordinate(value: &Value) -> Result { "scroll coordinates must be numbers", )); }; + // 通过 Number 文本表示转为 f32,再检查有限性及绝对值上限;允许负数以表达相对滚动。 let n = value .to_string() .parse::() @@ -235,6 +254,7 @@ pub fn set( point: [f32; 2], relative: bool, ) -> Result<(), VmError> { + // 只允许已提交且仍挂接的出现接受滚动;布局过程中的临时 Mount 不具备稳定滚动边界。 let mut mount = cx.store().payload::(owner, "Mount")?.clone(); if !mount.mounted || mount.layout.is_some() { return Err(VmError::InvalidArgument( @@ -257,7 +277,8 @@ 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)) } @@ -273,6 +294,7 @@ pub fn into_view( "scrollIntoView requires committed attached Mount", )); } + // rect 随祖先滚动调整,表示目标在当前投影中的可见需求;从最近父容器逐层向外处理。 let mut rect = [ mount.geometry.x, mount.geometry.y, @@ -286,6 +308,7 @@ pub fn into_view( }; let mount = cx.store().payload::(owner, "Mount")?.clone(); parent = mount.parent; + // 跨越独立场景边界时,外层只能滚动整个场景视口,不能使用内部世界坐标定位目标。 if mount.geometry.canvas_viewport || mount.geometry.scene3d_viewport { rect = [ mount.geometry.x, @@ -295,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 @@ -306,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] { @@ -318,12 +343,15 @@ pub fn into_view( /// 按逆出现顺序选择指针处可处理当前滚动方向的最内层容器。 pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option { 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] }) { diff --git a/src/render/software_renderer.rs b/src/render/software_renderer.rs index 0a7c566..23760f8 100644 --- a/src/render/software_renderer.rs +++ b/src/render/software_renderer.rs @@ -10,6 +10,7 @@ use crate::{ /// 显示列表的 CPU 执行入口,错误在原生 present 前向上传递。 pub struct SoftwareRenderer; +// 预算按渲染线程维护,递归 3D/面板渲染共用同一组计数;不同线程之间不共享额度。 thread_local! { static RENDER_DEPTH:std::cell::Cell=const { std::cell::Cell::new(0) }; static PIXEL_UNITS:std::cell::Cell=const { std::cell::Cell::new(0) }; @@ -21,9 +22,11 @@ impl RenderScope { pub fn enter() -> Result { 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)); } @@ -33,7 +36,7 @@ impl RenderScope { } } impl Drop for RenderScope { - /// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。 + /// 退出一次像素渲染作用域时归还嵌套深度;像素工作量仍按本帧累计,不因退出内层而返还。 fn drop(&mut self) { RENDER_DEPTH.with(|depth| depth.set(depth.get() - 1)); } @@ -41,6 +44,7 @@ impl Drop for RenderScope { /// 累加本帧中间表面工作量,超过预算立即返回错误。 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"); @@ -58,6 +62,7 @@ impl SoftwareRenderer { width: u32, height: u32, ) -> Result<(), &'static str> { + // RAII 保护让成功、提前返回和错误传播都恢复深度;target 长度须由调用方保证至少 width×height。 let _scope = RenderScope::enter()?; let mut canvas = Canvas { pixels: target, @@ -70,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) @@ -85,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); @@ -120,6 +130,7 @@ impl SoftwareRenderer { ); } DrawCommand::PopClip => { + // 多余 PopClip 在当前实现中被忽略;合法列表仍应由生成层保证配对。 if let Some(clip) = clips.pop() { canvas.clip = clip; } @@ -154,7 +165,7 @@ impl SoftwareRenderer { } } -/// 借用目标像素切片并维护当前裁剪,所有像素写入都受该上下文限制。 +/// 借用目标像素切片并维护当前裁剪;图形/文字受裁剪,Clear 明确填满整个目标。 struct Canvas<'a> { pixels: &'a mut [u32], width: u32, @@ -163,14 +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 { @@ -184,6 +197,7 @@ 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( @@ -209,6 +223,7 @@ 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( @@ -227,6 +242,7 @@ impl Canvas<'_> { 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); @@ -248,6 +264,7 @@ impl Canvas<'_> { value, style, color, + // 换行按完整段落宽度排版,而不是按被祖先裁剪后剩余的可见宽度重新排版。 Some(rect.width), clip, |dx, dy, pixel| self.blend_pixel(left + dx, top + dy, pixel), @@ -257,6 +274,7 @@ impl Canvas<'_> { /// 把连续矩形转换成目标缓冲内的整数像素范围,避免在不可见区域进行光栅循环。 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, @@ -273,6 +291,7 @@ impl Canvas<'_> { /// 在画布与当前裁剪范围内进行透明度混合,防止文字/图形写出目标缓冲。 fn blend_pixel(&mut self, x: i32, y: i32, source: Color) { + // 拒绝画布外、裁剪外与全透明像素;只有通过后才计算线性下标,避免负坐标转 usize。 if x < 0 || y < 0 || x >= self.width as i32 @@ -287,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; @@ -296,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; @@ -317,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); @@ -326,6 +349,7 @@ fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool { /// 将颜色通道打包成目标缓冲的整数像素表示。 fn pack(color: Color) -> u32 { + // 像素格式为 0x00RRGGBB,高字节不存透明度;透明度已在混合阶段折算进 RGB。 (color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32 } @@ -334,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))); @@ -358,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 { @@ -373,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))); @@ -388,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))); @@ -400,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)))); diff --git a/src/render/source_hit.rs b/src/render/source_hit.rs index f29d848..98dd961 100644 --- a/src/render/source_hit.rs +++ b/src/render/source_hit.rs @@ -13,13 +13,14 @@ use endor_core::{ /// 验证 hitTest 返回的是 Bool,避免把任意真值隐式当成命中。 struct Boolean; impl NativeCallable for Boolean { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 接收 hitTest 返回的唯一值,交给 invoke 严格检查 Bool。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 只接受真正的 Bool 并原样返回;数字或对象不能以宿主真值规则替代源协议契约。 fn invoke(&self, _: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 协议要求明确 Bool;拒绝数字、对象等“真值”,使用户实现错误立即暴露。 let [Value::Bool(value)] = args else { return Err(VmError::InvalidArgument("hitTest must return Bool")); }; @@ -30,13 +31,14 @@ impl NativeCallable for Boolean { /// 比较按下与释放的选择结果,只有同一目标才能继续点击分发。 struct SameTarget; impl NativeCallable for SameTarget { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 接收按下与释放两次命中选择的结果。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(2)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 仅当两结果为同一 Mount 对象才返回目标,否则返回 Nil;共享 Block 的不同出现位置也不能合成点击。 fn invoke(&self, _: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 比较的是实际出现 Mount 的身份;即使共享同一个 Block,不同出现之间拖动也不构成点击。 let value = match args { [Value::Object(first), Value::Object(second)] if first == second => { Value::Object(*first) @@ -53,6 +55,7 @@ fn code( constants: Vec, layout: CodeLayout, ) -> Result { + // 校验并编码指令,同时设置诊断位置;独立空 globals 避免驱动依赖业务全局命名空间。 let code = EndorCode::from_instructions(&instructions, constants, layout)?.with_location( "", "", @@ -76,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")?.clone(); + // 先排除已卸载、非交互和几何不命中的出现,避免为明显无效候选构造源调用。 if !target.mounted || !target.geometry.interactive || !target @@ -91,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)? @@ -111,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), @@ -118,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), @@ -139,6 +152,7 @@ fn selector( Instruction::simple(Opcode::ReturnValue), ]); } + // 所有候选均未命中时显式返回 Nil;不会把最后一个 false 当作选择结果。 let end = constants.len() as u32; constants.push(Value::Nil); instructions.extend([ @@ -156,13 +170,14 @@ struct Prepare { desktop: ObjectRef, } impl NativeCallable for Prepare { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 候选、按下点和释放点已保存于适配器,无需外部实参。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 为按下/释放各建立只读选择器,把两次结果放入同一 VM 调用序列,再交 SameTarget 比较实际出现身份。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + // 按下和释放分别构造选择器,但都在本次只读执行内调用,使用同一预算域。 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( @@ -170,6 +185,7 @@ impl NativeCallable for Prepare { SameTarget, vec![], ))?; + // 两次选择结果先存入局部槽,再作为两个实参交给 SameTarget;局部槽也保留中间结果生命周期。 let instructions = vec![ Instruction::new(Opcode::LoadConst, 0), Instruction::simple(Opcode::PushNull), @@ -199,6 +215,7 @@ impl NativeCallable for Prepare { ..CodeLayout::default() }, )?; + // 把执行权交回 VM 运行驱动,不在宿主里递归开启另一次 VM 调用。 Ok(NativeResult::Invoke { function: driver, arguments: vec![], @@ -206,7 +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, @@ -214,6 +231,7 @@ pub fn resolve( press: [f32; 2], release: [f32; 2], ) -> anyhow::Result> { + // 显式 captures 保活 desktop;候选列表仍是弱引用,不因等待选择而保活全部旧 Mount。 let prepare = vm.allocate(ContextNativeFunction::new( "prepare pointer hitTest", Prepare { @@ -232,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 => { diff --git a/src/render/source_layout.rs b/src/render/source_layout.rs index a28cbdf..0222eb4 100644 --- a/src/render/source_layout.rs +++ b/src/render/source_layout.rs @@ -40,6 +40,7 @@ fn desktop_type_registry(cx: &NativeContext<'_>, desktop: ObjectRef) -> Result, @@ -79,6 +80,7 @@ impl Cache { #[derive(Debug)] /// 一个等待源测量返回的收集项,记录本次方法、约束和子项输入。 struct Pending { + // 只读子调用的精确读取跟踪区间 token,由 Step 开始、Collect 在结果解码后结束。 token: usize, owner: ObjectRef, method: ObjectRef, @@ -102,6 +104,7 @@ struct Plan { pending: Vec>, constraints: Vec<[f32; 4]>, // Source-arranged dimensions remain exact for this solve, not business state. + // 每个出现两个轴的显式分配,Some 形成精确 min=max 约束;不是可写业务尺寸属性。 allocations: Vec<[Option; 2]>, custom_arrange: Vec, native_arrange: Vec, @@ -137,12 +140,12 @@ struct Step { desktop: ObjectRef, } impl NativeCallable for Step { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 单个布局步骤所需 Mount、阶段和计划已保存,实际调用无参。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 解析当前实际 measure/arrange 实现,决定原生快速路径或源调用,准备本节点输入及依赖收集,避免跳过源覆盖。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { let mount = cx.store().payload::(self.mount, "Mount")?.clone(); let definition = cx @@ -153,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, @@ -169,6 +173,7 @@ impl NativeCallable for Step { .iter() .map(|child| plan.sizes[*child]) .collect::>(); + // preferred 优先取本轮最初测量值,不能将上一轮 flex 分配当作新的自然基准,造成累计漂移。 let preferred = plan.children[self.index] .iter() .map(|child| plan.preferred[*child].unwrap_or(plan.sizes[*child])) @@ -181,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( @@ -213,6 +219,7 @@ impl NativeCallable for Step { }) }) .map(|entry| entry.size); + // 命中无需再打开读取跟踪;未命中保存 Pending 元信息,等待真正源调用结果返回才建立缓存。 if let Some(size) = cached { plan.cache.hits += 1; Some(size) @@ -236,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 @@ -250,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, @@ -294,6 +304,7 @@ impl NativeCallable for Step { } }; if matches!(self.phase, Phase::Measure) { + // 输入记录初始化完成后再开始子调用跟踪;Collect 解码返回值也在区间内,覆盖复用旧结果对象的字段读取。 let token = cx.store().begin_read_tracking()?; self.plan .lock() @@ -322,13 +333,14 @@ struct Collect { desktop: ObjectRef, } impl NativeCallable for Collect { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 接收一次 measure/arrange 返回的托管结果,按当前 phase 解码。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 结束当前测量读跟踪并解码结果,验证尺寸或完整子槽安排,再更新计划与可复用测量依赖。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 取走本节点待收集状态,确保每个跟踪区间只结束一次;arrange 不建立测量缓存项。 let pending = if matches!(self.phase, Phase::Measure) { self.plan .lock() @@ -345,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)?)? @@ -357,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)?, @@ -367,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() @@ -398,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 { @@ -429,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| { @@ -440,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 { @@ -452,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() @@ -496,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]) @@ -526,11 +547,11 @@ struct Settle { mounts: Arc<[WeakObjectRef]>, } impl NativeCallable for Settle { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 一轮测量/排列完成后无参进入收敛检查。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 由新 bounds 推导下一轮约束与自然尺寸,比较完整布局状态;稳定则结束,循环状态报错,否则继续同帧求解。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { let repeat = { let mut plan = self @@ -538,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(); @@ -570,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; @@ -580,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] { @@ -600,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)); @@ -624,6 +649,7 @@ impl NativeCallable for Settle { state.extend([0, 0]); } } + // 相邻两轮状态和约束均相同才停止;非相邻重复则是振荡环,不能把循环误当作稳定布局。 if plan.previous.as_ref() == Some(&state) && plan.constraints == next { false } else { @@ -632,6 +658,7 @@ impl NativeCallable for Settle { } plan.previous = Some(state); plan.constraints = next; + // 需要重算时清全部 bounds;下一轮测量不能访问上轮安排产生的陈旧几何。 plan.bounds.fill(None); true } @@ -660,11 +687,11 @@ struct Prepare { desktop: ObjectRef, } impl NativeCallable for Prepare { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 布局驱动的零参入口,Mount 列表/desktop/计划已由调用方捕获。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 建立按出现位置推进的 Advance 和固定字节码循环,保持同一 VM 预算,避免首次让出前创建整树临时调用对象。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { let advance = cx.allocate(ContextNativeFunction::new( "next layout occurrence", @@ -693,13 +720,14 @@ struct Advance { next: Mutex, } impl NativeCallable for Advance { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 由共享布局循环无参调用,游标表示本轮测量/排列的位置。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 按游标安排自底向上 measure 与自顶向下 arrange,短锁结束后才创建/执行当前节点调用,末尾进入收敛检查。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { let count = self.mounts.len(); + // 游标取值/递增只在短锁内完成,不能持锁执行源协议,否则源调用基类会再次访问同一 Plan。 let position = { let mut next = self .next @@ -711,6 +739,7 @@ impl NativeCallable for Advance { } position }; + // 每轮恰有 N 个逆序 measure 和 N 个正序 arrange,之后调用 Settle;需要重算时游标归零。 if position == count * 2 { let settled = Settle { plan: self.plan.clone(), @@ -728,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 { @@ -736,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 { @@ -757,6 +788,7 @@ impl NativeCallable for Advance { }, vec![self.desktop], ))?; + // 执行一步并立即收集,再给外层 repeat_call 返回 true;各轮迭代共享原 VM 帧和预算。 let function = crate::vm_sequence::collect_and_continue( cx, collector, @@ -810,6 +842,7 @@ impl Work { /// 显式结束布局续体和只读域,释放本次尚未提交的临时根。 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?; @@ -864,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 @@ -900,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" @@ -926,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() { @@ -938,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()], @@ -973,6 +1013,7 @@ pub fn start( desktop, slices: 1, }; + // 一次有界只读调用跨所有节点与收敛轮次;slice 只控制让出量,总指令预算仍共享一百万。 let progress = vm.start_read_only_call( prepare, &[], @@ -992,6 +1033,7 @@ fn finish( value: Value, reads: Vec, ) -> Result { + // 驱动返回值仅为控制流结果,先释放;真正结果保存在原生 Plan,而非返回值中的第二份业务图。 vm.release_value(value)?; let mut plan = work .plan @@ -999,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] = @@ -1016,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)); diff --git a/src/render/source_paint.rs b/src/render/source_paint.rs index b34af04..33147dc 100644 --- a/src/render/source_paint.rs +++ b/src/render/source_paint.rs @@ -33,7 +33,7 @@ fn object(value: Value) -> Result { Err(invalid("paint must return managed objects")) } } -/// 把当前源值或文本选择解码成此调用需要的文本表示。 +/// 读取指定命令字段并要求其值为 Text;不隐式字符串化其他类型。 fn text(store: &ObjectStore, object: ObjectRef, name: &str) -> Result { if let Value::Text(value) = store.get_attr(object, name)? { Ok(value) @@ -46,6 +46,7 @@ fn number(store: &ObjectStore, object: ObjectRef, name: &str) -> Result() @@ -73,12 +74,14 @@ fn decode( command: ObjectRef, expected: ObjectRef, ) -> Result { + // 使用当前 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 { @@ -86,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, @@ -102,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")); } @@ -128,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"), @@ -153,6 +160,7 @@ fn decode( } _ => return Err(invalid("unsupported paint command kind")), }; + // 变体字段解码后仍走统一命令校验,覆盖颜色格式、文本以及组合几何合法性。 result .validate() .map_err(|_| invalid("invalid paint command geometry, text or color"))?; @@ -162,7 +170,9 @@ fn decode( #[derive(Debug, Default)] /// 按出现 ID 收集已检查的源绘制覆盖,随后与原生默认命令合并。 struct PaintOutput { + // 按出现 ID 收集覆盖;同一个业务 Block 在多个 Mount 中可产生不同绘制结果。 commands: BTreeMap>, + // 全轮源命令累计数,不能靠拆分多个源 DisplayList 绕过 10000 条总预算。 count: usize, } type Output = Arc>; @@ -174,12 +184,12 @@ struct Collect { output: Output, } impl NativeCallable for Collect { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 接收当前出现位置 paint 的唯一 DisplayList 结果。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 验证返回名义类型并完整解码所有 PaintCommand,检查全轮命令预算后才把该出现位置结果写入输出表。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let [Value::Object(result)] = args else { return Err(invalid("paint must return a DisplayList")); @@ -190,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::, _>>()?; + // 仅收集结果时持锁,不在调用源 paint 期间持有结果锁;失败不会插入半个命令列表。 let mut output = self .output .lock() @@ -216,13 +228,14 @@ struct Prepare { output: Output, } impl NativeCallable for Prepare { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 零参数驱动入口,待绘制 Mount 和输出容器已保存在适配器。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 建立共享游标与固定外层字节码循环,让 VM 每轮只准备一个出现位置的 paint 调用。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + // 构造一个共享游标驱动;每轮只准备当前出现,避免一次保留所有出现的临时实参。 let advance = cx.allocate(ContextNativeFunction::new( "next paint occurrence", Advance { @@ -250,12 +263,13 @@ struct Advance { output: Output, } impl NativeCallable for Advance { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 由外层循环无参调用,下一出现位置由内部游标决定。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 短锁内取并推进游标,随后验证/准备当前 Mount 的方法与几何;用临时调用链收集结果,完成后才进入下一位置。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + // 锁只保护取游标与递增;离开此块后释放锁,再做 VM 分配和源调用。 let occurrence = { let mut next = self .next @@ -265,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))); }; @@ -283,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 [ @@ -296,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 { @@ -305,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, @@ -326,7 +345,9 @@ impl NativeCallable for Advance { /// 已经完成本阶段、但尚未由桥接层发布的派生结果,包含帧/缓存或真实读取依赖。 pub struct Draft { + // 已经合并命令的原生帧;Draft 仍需桥接层提交,完成计算不等于发布成功。 pub frame: Frame, + // 保留源绘制的业务读取依赖,供下次写入后判定是否必须重绘。 pub reads: Vec, pub slices: usize, } @@ -342,6 +363,7 @@ pub enum Progress { /// 拥有未完成源调用及临时根的工作句柄;丢弃 Rust 值不能替代 VM 内的取消。 pub struct Work { desktop: ObjectRef, + // 跨切片保留的入口根;完成或取消都必须在所属 VM 中显式释放。 prepare: ObjectRef, output: Output, root: Option>, @@ -352,6 +374,7 @@ pub struct Work { impl Work { /// 继续当前绘制续体,保留其先前收集的命令和所属窗口上下文。 pub fn resume(mut self, vm: &mut Vm, slice: usize) -> Result { + // 恢复保留原执行预算,slice 只决定本次让出的指令配额,不能重置整个绘制上限。 self.slices += 1; let progress = vm.resume_read_only_call(slice); self.accept(vm, progress) @@ -359,6 +382,7 @@ impl Work { /// 关闭未完成绘制续体并释放其临时根,不发布半完成命令列表。 pub fn cancel(self, vm: &mut Vm) -> Result<()> { + // 先执行取消和 release 两个操作,再依次传播错误;取消失败也仍尝试释放入口根。 let cancelled = vm.cancel_read_only_call(); let released = vm.release(self.prepare); cancelled?; @@ -373,6 +397,7 @@ impl Work { progress: Result, ) -> Result { match progress { + // 挂起时工作句柄继续持有 frame/output/prepare,不能发布部分输出。 Ok(endor_core::ExecutionProgress::Pending { .. }) => { Ok(Progress::Pending(Box::new(self))) } @@ -381,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) @@ -403,6 +430,7 @@ pub fn start( frame: Frame, slice: usize, ) -> Result { + // 禁止零切片和嵌套只读/挂起调用;同一 VM 同时只能由一个续体驱动。 anyhow::ensure!(slice > 0, "paint slice must be positive"); anyhow::ensure!( !vm.has_suspended_call() && !vm.object_store().is_read_only(), @@ -444,7 +472,9 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - .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, @@ -455,6 +485,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - 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() { @@ -466,7 +497,9 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - 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 { @@ -486,6 +519,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - 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"); } @@ -494,8 +528,11 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec) - 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| { diff --git a/src/render/text.rs b/src/render/text.rs index fb82f41..f1a09c0 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -67,9 +67,11 @@ pub struct TextSize { /// 文字整形缓存键,包含文本、样式和可选换行宽度。 struct TextKey { value: String, + // 用浮点原始位模式做键,精确区分整形参数;不是把字号取整为 u32。 font_size: u32, line_height: u32, family: TextFamily, + // None 表示不换行,Some 保存宽度的浮点位模式;同一文本不同宽度必须分开缓存。 wrap_width: Option, } @@ -85,15 +87,17 @@ struct TextEngine { code_family: Option, swash: SwashCache, buffers: HashMap, + // 累计缓存文本的 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 @@ -135,6 +139,7 @@ impl TextEngine { /// 为文本、字体样式和可选换行宽度建立整形缓存,超过缓存预算先清理旧结果。 fn prepare_width(&mut self, value: &str, style: TextStyle, wrap_width: Option) -> TextKey { + // 键包含全部排版输入;命中后测量与绘制都复用同一 Buffer,避免两套排版产生偏差。 let key = TextKey { value: value.to_owned(), font_size: style.font_size.to_bits(), @@ -146,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; @@ -163,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, @@ -175,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, @@ -193,6 +204,7 @@ impl TextEngine { /// 取得全局受锁保护的字体/整形引擎;共享缓存不等于可以并行修改字体状态。 fn engine() -> &'static Mutex { + // 惰性创建全进程引擎;Mutex 同时保护字体库、整形缓存与栅格缓存,测量和绘制不能并发修改它。 static ENGINE: OnceLock> = OnceLock::new(); ENGINE.get_or_init(|| Mutex::new(TextEngine::new())) } @@ -206,6 +218,7 @@ pub fn measure(value: &str, style: TextStyle) -> TextSize { /// 按可用宽度整形并测量多行高度,供布局与实际段落绘制共用。 pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize { + // 零或负可用宽度直接返回零宽与一行高,避免给整形器无效换行宽度。 if width <= 0.0 { return TextSize { width: 0.0, @@ -236,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, @@ -246,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() @@ -257,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; @@ -267,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; @@ -278,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, @@ -312,6 +338,7 @@ pub fn draw_clipped( } cosmic_text::SwashContent::SubpixelMask => continue, }; + // 只输出采样颜色,最终混合由调用方 pixel 完成;回调在引擎锁持有期间执行,不应重入文字引擎。 pixel(x, y, sample); } } @@ -326,6 +353,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String { // 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 @@ -338,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(); } @@ -345,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; @@ -361,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]) } @@ -371,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", @@ -397,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); @@ -416,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(); @@ -427,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] @@ -443,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(\"中文字体\")"; @@ -450,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!( diff --git a/src/render/viewport.rs b/src/render/viewport.rs index a914698..0f8afe5 100644 --- a/src/render/viewport.rs +++ b/src/render/viewport.rs @@ -39,10 +39,12 @@ pub struct CameraView { /// 读取并校验托管 Camera2D 的位置和缩放,生成原生投影描述。 pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result { + // 先验证实际宿主 payload,避免只有 x/y/zoom 同名字段的普通对象冒充相机。 store.payload::(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", @@ -63,6 +65,7 @@ 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", @@ -83,6 +86,7 @@ pub fn fit( .store() .payload::(root, "Mount")? .clone(); + // 只汇总场景直接子出现的有限边界,沿 sibling 链扫描;不把无限世界或整棵后代图作为布局盒。 let mut next = scene.first_child; let mut bounds: Option<[f32; 4]> = None; let mut count = 0; @@ -95,12 +99,14 @@ pub fn fit( .upgrade(weak)? .ok_or(VmError::InvalidArgument("expired scene child"))?; let child = cx.store().payload::(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]), @@ -113,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, @@ -127,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, @@ -160,6 +170,7 @@ impl Transform { 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, @@ -177,6 +188,7 @@ impl Transform { ) } /// 将当前变换再放入外层变换,保持绘制与逆向命中的组合顺序一致。 + // 组合为 outer(self(point)):内层平移也要乘外层缩放,不能只把两次平移相加。 pub fn then(self, outer: Self) -> Self { Self { zoom: self.zoom * outer.zoom, @@ -184,8 +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 @@ -198,6 +211,7 @@ 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]); [ @@ -222,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>, ); let mut contexts = HashMap::::new(); + // 保留 next_sibling 查找表,遍历可变 Mount 时仍可按稳定子出现顺序为面板根建立映射。 let next: HashMap<_, _> = frame .mounts .iter() @@ -237,6 +253,7 @@ pub fn project( HashMap::, 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(), @@ -253,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; @@ -263,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, @@ -282,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( @@ -296,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))); @@ -309,6 +330,7 @@ pub fn project( .. } = &node.kind { + // 沿面板映射链限制嵌套深度,防止递归纹理装配和输入逆投影无限展开。 let mut depth = 0; let mut ancestor = panel.clone(); while let Some(mapping) = ancestor { @@ -317,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 { @@ -333,6 +356,7 @@ pub fn project( clip: mount.viewport_clip, parent: panel.clone(), }); + // 记录待遍历面板根的新上下文;之后走到该孩子时切换纹理空间,不把外层 clip 误当纹理像素。 panels.insert(id, (mapping, *size)); child = next[&id]; } @@ -340,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!( @@ -353,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(()) } @@ -360,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., @@ -382,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); diff --git a/src/render/viewport/spatial.rs b/src/render/viewport/spatial.rs index 3bcf362..e77a995 100644 --- a/src/render/viewport/spatial.rs +++ b/src/render/viewport/spatial.rs @@ -9,12 +9,15 @@ use std::collections::HashMap; #[derive(Debug, Default)] /// 有限屏幕格子和后备集合。候选筛选允许保守多取,但不能因索引预算漏掉有效命中。 pub struct Index { + // 建立索引时的 Mount 总数,用来发现明显过期的索引;数量相同不代表几何未改变。 count: usize, + // 格子坐标映射到原 mounts 切片下标,保留原始绘制顺序信息,不持有托管对象。 cells: HashMap<(i32, i32), Vec>, + // 不能安全装入网格的候选,每次查询都参与;由后续精确 contains 排除误命中。 fallback: Vec, } impl Index { - /// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。 + /// 从帧几何构建 128 单位屏幕网格;大对象、投影面板和超预算项进入保守后备集合。 pub fn build(mounts: &[MountGeometry]) -> Self { let mut result = Self { count: mounts.len(), @@ -22,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 @@ -29,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); @@ -48,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) @@ -56,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; @@ -73,6 +83,7 @@ impl Index { } /// 返回当前网格格子与后备集合中的候选,去重并恢复逆绘制顺序。 pub fn candidates(&self, point: Point, count: usize) -> Vec { + // 索引总数与当前帧不一致时,下标集合不再可靠;直接返回整帧逆序候选。 if self.count != count { return (0..count).rev().collect(); } @@ -80,6 +91,7 @@ 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 @@ -87,6 +99,7 @@ impl Index { } /// 把屏幕坐标映射到有界整数网格坐标,处理负方向与极端数值。 fn cell(value: f32) -> i32 { + // 每格 128 个屏幕单位;floor 让 -1 属于负格而不是零格,再限制到整数表示范围。 (value / 128.) .floor() .clamp(i32::MIN as f32, i32::MAX as f32) as i32 @@ -96,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\"))", @@ -123,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); diff --git a/src/runtime/api.rs b/src/runtime/api.rs index 4bca2e5..6200dd5 100644 --- a/src/runtime/api.rs +++ b/src/runtime/api.rs @@ -34,6 +34,7 @@ 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; @@ -55,12 +56,14 @@ pub struct RuntimeControl { 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 } @@ -74,6 +77,7 @@ impl RuntimeControl { 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() @@ -105,11 +109,12 @@ struct BuildRequest { submission: u64, } -/// 建立本模块的执行/服务上下文,并把后续推进交给返回的句柄或驱动。 +/// 绑定回环 API,启动请求接收与单 VM worker,返回活动日志及一次性退出控制句柄。 pub fn start( namespace: Arc>, proxy: EventLoopProxy, ) -> Result<(Activity, RuntimeControl)> { + // 退出控制仅需一个消息槽,正常执行请求另走容量8队列,避免工作排满后无法通知关闭。 let (sender, receiver) = sync_channel(1); let control = RuntimeControl { sender, @@ -121,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}"))?; @@ -165,6 +171,7 @@ fn start_server_observed( observer_notify: Notify, shutdown: Option, ) -> Result> { + // HTTP 接收与 VM 执行分离,最多8个排队请求;源代码只在单个 endor-worker 中操作 VM。 let (sender, receiver) = sync_channel::(8); let worker_namespace = namespace.clone(); let worker_notify = notify.clone(); @@ -175,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 @@ -187,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; @@ -206,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 { @@ -213,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) => { @@ -228,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() @@ -261,6 +275,7 @@ fn start_server_observed( && current.runtime_session != runtime.id }) }) { + // 当前持久域同时失去 submission 和 runtime_session 归属时退役;不能仅因源码请求排队就误删仍权威的旧域。 worker.repl = None; } if wait.is_zero() { @@ -278,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 _ = @@ -306,8 +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() @@ -315,6 +332,7 @@ impl ObservedRequest { .map(ToString::to_string) .collect::>() .join("\n"); + // console 标记只是显示来源,不提供权限,也不改变 VM 执行语义。 let origin = if inner .headers() .iter() @@ -349,7 +367,9 @@ impl ObservedRequest { 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, @@ -390,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('?') @@ -400,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 { @@ -452,6 +474,7 @@ fn code_request( ); } + // GET 返回最近成功发布的 source 而非正在排队的输入,并通过头部暴露 revision/session/window/layout 及逻辑视口。 if method == Method::Get { let mut response = text( StatusCode(200), @@ -506,6 +529,7 @@ fn code_request( ); return response; } + // 释放读锁后才申请写锁,避免同一线程持读锁升级为写锁死锁;修改结束也先解锁再发送 GUI 通知。 drop(current); match method { @@ -548,6 +572,7 @@ fn submit( return; } }; + // 读取成功后才保存受限活动正文预览;超长或非法 UTF-8 请求不会进入执行队列。 request.record_body(&source); let Ok(mut current) = namespace.write() else { let _ = request.respond(error_response( @@ -560,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 { @@ -578,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. @@ -586,6 +613,7 @@ fn submit( current.submission = submission; } } + // 队列拒绝时不推进 submission,所以已经接受的旧工作不会因为本次失败请求而失去发布资格。 Err(TrySendError::Full(job)) => { let _ = job .request @@ -611,6 +639,7 @@ fn superseded(submission: u64) -> Response>> { /// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。 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('/')) @@ -628,6 +657,8 @@ fn read_code(request: &mut Request) -> Result { { anyhow::bail!("code exceeds the 1 MiB limit"); } + // 已知 Content-Length 先预拒绝,再在流上最多读上限+1;防止缺失/不实长度绕过容量限制。 + // read_to_string 要求 UTF-8,不用有损替换改变用户源码。 let mut code = String::new(); request .as_reader() @@ -644,6 +675,7 @@ fn read_code(request: &mut Request) -> Result { fn compile_error(error: anyhow::Error) -> Response>> { let mut diagnostic = serde_json::json!({ "severity": "error", "message": format!("{error:#}") }); + // 有 SourceDiagnostic 才附行/列和半开字节范围;其他运行时错误仍保留完整错误链消息,不编造源码位置。 if let Some(source) = error.downcast_ref::() { diagnostic["line"] = source.location.line.into(); diagnostic["column"] = source.location.column.into(); @@ -683,6 +715,7 @@ fn markdown(status: StatusCode, value: String) -> Response Response>> { + // 公共响应头只允许固定本机 Origin/方法/头名,非通配任意来源;这些头不替代回环绑定与路由校验。 let mut response = Response::from_string(value).with_status_code(status); add_header(&mut response, "Content-Type", content_type); add_header( @@ -705,6 +738,7 @@ fn text(status: StatusCode, value: &str, content_type: &str) -> Response>>, name: &str, value: &str) { + // 由 HTTP 库验证字段格式,非法头被忽略,不把未校验字符拼进原始响应报文。 if let Ok(header) = Header::from_bytes(name, value) { response.add_header(header); } diff --git a/src/runtime/api/guide.rs b/src/runtime/api/guide.rs index 6795d6a..c8e6f7f 100644 --- a/src/runtime/api/guide.rs +++ b/src/runtime/api/guide.rs @@ -1,9 +1,11 @@ //! 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。 //! -//! Live agent-facing guide generated from the current namespace. +//! 指南正文是返回给客户端的静态 Markdown 模板,只有命名空间、版本、地址和内置示例按参数填充。 use super::*; -/// 用当前命名空间、地址和版本填充在线指南,示例来自项目内置 Endor 源码。 +/// 为 /agent 生成可提交代码与查询接口的 Markdown 说明。 +/// format! 使用具名插值注入 namespace.name/revision 与监听地址;示例在编译时嵌入, +/// 因而请求不会访问示例文件、执行示例或改变会话。正文中的 JSON/Endor 是文档内容。 pub(super) fn agent_guide(namespace: &NamespaceState) -> String { format!( r##"# Tessera Agent API diff --git a/src/runtime/api/tests.rs b/src/runtime/api/tests.rs index ff68ab7..3a7c970 100644 --- a/src/runtime/api/tests.rs +++ b/src/runtime/api/tests.rs @@ -10,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"), @@ -18,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; @@ -80,6 +83,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { ); } +/// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。 struct TestServer { server: Arc, address: SocketAddr, @@ -87,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 @@ -105,6 +111,7 @@ impl TestServer { } impl Drop for TestServer { + /// 解除服务端 accept 等待并等待线程结束,避免测试之间遗留监听线程。 fn drop(&mut self) { self.server.unblock(); if let Some(api) = self.api.take() { @@ -114,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())); @@ -157,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); @@ -177,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())); @@ -215,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())); @@ -226,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(); @@ -262,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())); @@ -317,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\"))", @@ -361,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}, @@ -370,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 || { @@ -384,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(()); } @@ -406,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); @@ -415,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"), @@ -425,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")); @@ -435,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 { @@ -459,6 +488,7 @@ page.render(TextBlock("input priority")) next_session: 801, }; let notify: Notify = Arc::new(|| {}); + // 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。 worker.refresh_viewport_mode(&namespace, ¬ify, true); let value = worker .repl @@ -484,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 { @@ -586,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 { @@ -658,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", @@ -670,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 { @@ -761,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 { @@ -833,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( diff --git a/src/runtime/api/worker.rs b/src/runtime/api/worker.rs index a3d18bc..ae5ba0c 100644 --- a/src/runtime/api/worker.rs +++ b/src/runtime/api/worker.rs @@ -6,6 +6,7 @@ use super::*; type HttpResponse = Response>>; +// 外层 thread::Result 区分 Rust panic,内层 Result 区分可诊断编译/运行错误;正常结果另带待发布窗口命令。 type InputOutcome = std::thread::Result)>>; @@ -19,6 +20,7 @@ type CandidateOutcome = std::thread::Result< /// 已执行业务部分但仍在等待展示刷新的输入元数据,避免恢复时重放回调。 pub(super) struct PendingInput { + // 输入开始时所属 submission,与 id(VM 会话)分开;新 PUT 接受时可保留正在执行的原 VM 至输入收敛。 epoch: u64, id: u64, event: bool, @@ -27,6 +29,7 @@ pub(super) struct PendingInput { /// 挂起 HTTP 请求的阶段:等旧刷新、继续输入或继续隔离候选。 pub(super) enum PendingRequest { + // 仅等待旧只读刷新结束,业务输入尚未执行;恢复时可重新走入口,而 Input 阶段不能重放业务副作用。 BeforeInput, Input(Box), Candidate(Box), @@ -56,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); } @@ -77,6 +81,7 @@ impl Worker { error: None, }; }; + // 正常域允许有界关闭回调,故障域只做宿主清理;catch_unwind 防止清理 panic 跳过原生退出通知。 let callbacks = !runtime.is_faulted(); let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown())); match result { @@ -108,6 +113,7 @@ impl Worker { namespace: &Arc>, notify: &Notify, ) -> Response>> { + // 故障/失活资源的专用关闭只处理副窗口,主壳由 OS 关闭控制走全应用退出流程。 if input.window == crate::window::MAIN_WINDOW { return error_response( StatusCode(409), @@ -126,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; @@ -141,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), @@ -200,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(¤t); @@ -218,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. @@ -235,6 +246,7 @@ impl Worker { Ok(current) => current, Err(_) => return, }; + // 耗时 VM 工作期间 HTTP 线程可能接受 PUT/DELETE,发布写锁内再核对 submission 与 session 双身份。 if current.submission != epoch || current.runtime_session != id { return; } @@ -243,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); @@ -259,6 +272,7 @@ impl Worker { .chars() .take(1024) .collect(), + // 内部 panic 与普通背景错误不同,必须丢弃 VM 并撤销所有原生命令,不能继续运行损坏的域。 _ => { self.repl = None; current.runtime_session = 0; @@ -289,6 +303,7 @@ impl Worker { namespace: &Arc>, notify: &Notify, ) -> RequestProgress { + // 完整 PUT 独立候选,表达式隔离求值;只有 REPL/事件会进入当前持久域并等待已有刷新。 if job.kind == RequestKind::Code { return self.begin_program_request(job, namespace, notify); } @@ -311,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() { @@ -336,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, @@ -394,6 +411,7 @@ impl Worker { }; &document.frame }; + // 窗口关闭允许处理已失活/故障副窗口,不像点击一样要求可运行 VM;由专用宿主退役路径处理。 if input.kind == "window-close" && frame.window == input.window && frame.session == input.session @@ -414,6 +432,7 @@ impl Worker { ); } } + // 标签拖放同时验证目标窗口的会话、版本、无故障及插入位置,源窗口校验不能替代目标校验。 if let Some(target) = &input.tab_drop { let target_frame = if target.window == crate::window::MAIN_WINDOW { Some(¤t.document.frame) @@ -441,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) @@ -454,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| { @@ -461,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() @@ -480,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 { @@ -488,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 @@ -510,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 @@ -531,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 => { @@ -575,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 { @@ -592,6 +619,7 @@ impl Worker { result, }))); } + // 启动失败且残留挂起刷新时直接丢弃域,不能保留一个无法安全推进的 HTTP 请求。 // A failed start must never leave a request waiting on a broken VM. self.repl = None; } @@ -633,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 => { @@ -660,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); @@ -704,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); @@ -725,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) { @@ -750,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() @@ -771,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); @@ -828,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, @@ -838,6 +873,7 @@ impl Worker { ) })); let result = match started { + // 候选业务装载成功但布局未完成,整个候选移进 PendingRequest;不能把 None 当成空文档提交。 Ok(Ok((runtime, None))) => { return RequestProgress::Pending(PendingRequest::Candidate(Box::new(runtime))); } @@ -863,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)) @@ -886,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()?))) @@ -918,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)); @@ -934,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(¤t); drop(current); match failed { @@ -954,6 +994,7 @@ fn build( namespace: &Arc>, notify: &Notify, ) -> Response>> { + // 隔离表达式不读写持久 REPL 堆,也不发布文档,响应明确 persistent=false。 if job.kind == RequestKind::Expression { return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) { Ok(Ok(value)) => json( @@ -971,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) })); diff --git a/src/runtime/endor_host.rs b/src/runtime/endor_host.rs index 678fcb0..3f35090 100644 --- a/src/runtime/endor_host.rs +++ b/src/runtime/endor_host.rs @@ -33,6 +33,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result Result LIMITS, SourcePlan::Script(_) => ExecutionLimits { @@ -67,6 +69,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result 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 @@ -88,6 +91,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result Ok(root), None => ui_bridge::snapshot(&candidate.vm, &value), }); + // 提取快照结果先暂存,成功或失败都释放 main 返回值的拥有根,再传播快照错误。 candidate.vm.release_value(value)?; snapshot? } @@ -102,6 +106,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result Ok(document), @@ -117,6 +122,7 @@ pub fn evaluate(source: &str) -> Result { 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()) @@ -132,6 +138,7 @@ pub fn evaluate(source: &str) -> Result { .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(), @@ -171,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 { @@ -193,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 { @@ -218,6 +227,7 @@ struct PendingPaint { /// 跨 worker 轮次的全窗口刷新状态,保存已完成草稿和当前布局/绘制,直到稳定再发布。 struct RefreshWork { // 已完成窗口的草稿先留在本轮;所有窗口收敛后才合成一次完整发布。 + // RefreshWork 先克隆最近完整文档作为草稿基线,各窗口更新暂存于此;全轮稳定前不写共享 NamespaceState。 documents: std::collections::BTreeMap, windows: std::collections::VecDeque, current: Option, @@ -249,6 +259,7 @@ pub struct ReplSession { ast_nodes: usize, source: String, nodes: Vec, + // 回调/渲染失败后置位,拒绝继续普通业务输入;保留画面不表示堆还能安全执行。 faulted: bool, program_root: Option, documents: std::collections::BTreeMap, @@ -308,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 @@ -339,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 { @@ -372,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 { @@ -385,6 +399,7 @@ 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) } @@ -398,7 +413,7 @@ impl ReplSession { Ok(runtime) } - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 建立真实持久 VM、全局字典与 UI 桥接根,尚未执行用户输入或产生窗口帧。 pub fn new(id: u64) -> Result { let mut candidate = Candidate::new()?; let globals = candidate.vm.new_dict()?; @@ -452,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"); } @@ -468,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 @@ -487,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() { @@ -495,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)); @@ -527,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 @@ -544,6 +564,7 @@ impl ReplSession { } else { value.clone() }; + // 先算 repr 并把实际最后值写入全局 _,保留未命名 UI 对象;随后释放 execute 返回根,错误也逐项传播。 let repr = value_repr(&self.candidate.vm, &display); let retained = self.candidate @@ -566,6 +587,7 @@ impl ReplSession { input: self.inputs, }) })(); + // 失败丢弃未发布展示请求和原生命令,保留已执行业务写入,并在代码投影上明确标记非事务语义。 if result.is_err() { self.take_window_commands()?; self.bridge.discard_pending(&mut self.candidate.vm)?; @@ -574,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)?; @@ -605,6 +628,7 @@ impl ReplSession { &mut self, viewports: Option<&std::collections::BTreeMap>, ) -> Result> { + // 无增量视口参数的测试/同步入口排空状态机;worker 提供目标视口时只推进一次并记录重定向依据。 let Some(viewports) = viewports else { return self.refresh_document(); }; @@ -632,6 +656,7 @@ impl ReplSession { /// 推进一次刷新状态机,保存挂起的 Work;只有全窗口收敛时才生成可发布文档。 fn poll_refresh(&mut self, record_yield: bool) -> Result { + // 从 self 取出工作以便可变推进,挂起时放回;首次从已发布各窗口文档克隆初始草稿。 let mut work = self.refresh.take().unwrap_or_else(|| RefreshWork { documents: self.documents.clone(), windows: Default::default(), @@ -659,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)?; @@ -669,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, @@ -689,6 +717,7 @@ impl ReplSession { /// 执行一个刷新步骤:刷新绑定、选择窗口、准备快照、推进布局或绘制,并决定是否需要下一轮。 fn step_refresh(&mut self, work: &mut RefreshWork) -> Result { // 阶段一:新一轮先刷新绑定,再取得稳定窗口遍历顺序。 + // 每轮先使属性绑定达到固定点,再取窗口顺序;生命周期或绑定可能改变窗口/页面结构,因此需要多轮确认。 if work.pass_start { self.bridge.refresh_bindings(&mut self.candidate.vm)?; work.windows = self.bridge.window_ids()?.into(); @@ -703,6 +732,7 @@ impl ReplSession { work.current = Some(id); return Ok(false); } + // 任何窗口本轮生成新帧,就再扫描一轮检查依赖和挂载回调是否又引起变化,直到整轮无变化。 if work.pass_changed { work.pass_start = true; return Ok(false); @@ -758,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"); @@ -788,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; @@ -798,6 +830,7 @@ impl ReplSession { /// 收敛后组合所有窗口文档,保留本轮完整结果;没有变化时不额外制造发布。 fn finish_refresh(&mut self, mut work: RefreshWork) -> Result> { + // 收敛后完成已关闭窗口退役并删其旧文档;没有实际变化时不制造额外 revision。 self.bridge.finish_closed(&mut self.candidate.vm)?; let ids = self.bridge.window_ids()?; work.documents.retain(|id, _| ids.contains(id)); @@ -818,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() @@ -852,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() @@ -877,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; @@ -897,6 +934,7 @@ impl ReplSession { if self.faulted { bail!("runtime stopped after a callback or rendering failure"); } + // 仅目标视口集合确实不同才取消重定向;同一目标的下一 worker 轮继续原续体,不重复从头布局。 let retarget = self .refresh .as_ref() @@ -905,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(); @@ -917,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)?; @@ -926,6 +966,7 @@ impl ReplSession { } } self.bridge.select_window(crate::window::MAIN_WINDOW)?; + // 尺寸没变且无挂起工作则直接返回先前已完成文档,避免制造无意义新帧。 if !changed && !self.has_pending_refresh() { return Ok(previous); } @@ -989,10 +1030,12 @@ impl ReplSession { &mut self, viewports: Option<&std::collections::BTreeMap>, ) -> Result> { + // 异步回调不能插入只读布局/绘制续体中,即使后台邮箱已有结果也等刷新空闲边界。 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)?; @@ -1001,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); @@ -1014,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)?; @@ -1070,6 +1115,7 @@ impl ReplSession { /// 有界关闭任务、挂载和窗口,释放运行时根,并报告清理结果。 pub fn shutdown(&mut self) -> Result<(usize, usize, usize)> { + // 先取消视觉续体恢复挂载检查点,再判断原域是否允许正常关闭回调,随后标故障禁止后续普通执行。 self.cancel_pending_refresh()?; let callbacks = !self.faulted; self.faulted = true; @@ -1082,6 +1128,7 @@ impl ReplSession { // 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()?; @@ -1129,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)?; @@ -1154,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)?; } @@ -1166,8 +1216,9 @@ impl ReplSession { } impl Drop for ReplSession { - /// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。 + /// 析构兜底取消会话中未完成的布局/绘制续体;需要观察清理错误或运行退出钩子的路径仍须显式 shutdown。 fn drop(&mut self) { + // Drop 最后兜底取消未完成视觉工作,错误无法从析构返回;需要报告清理错误的路径必须显式 shutdown。 let _ = self.cancel_pending_refresh(); } } @@ -1210,9 +1261,11 @@ 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)?; } @@ -1225,16 +1278,16 @@ impl Candidate { } impl Drop for Candidate { - /// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。 + /// 析构调用 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() @@ -1264,6 +1317,7 @@ 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 { @@ -1284,6 +1338,7 @@ fn architecture( namespace: &str, submission: u64, ) -> CodeNode { + // CodeNode ID 由 namespace/submission 和本次递增序号构成,只是 Sources 投影身份,不是托管对象地址。 let mut tree = Architecture { line_starts: line_starts(source.text()), next_id: 0, @@ -1305,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), @@ -1320,6 +1376,7 @@ fn architecture( /// 建立源文本每行起点表,供字节 span 转换为行列位置。 fn line_starts(source: &str) -> Vec { 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')) { @@ -1337,10 +1394,11 @@ struct Architecture { } impl Architecture { - /// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。 + /// 分配本投影内节点 ID,并将半开字节 span 映射为1基起止行,组装 CodeNode。 fn node(&mut self, name: String, kind: &str, span: Span, children: Vec) -> 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), @@ -1352,11 +1410,12 @@ impl Architecture { } } - /// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。 + /// 区分函数与类声明;类下先投影字段,再投影方法。 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() @@ -1368,8 +1427,9 @@ impl Architecture { } } - /// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。 + /// 将函数声明及其体内语句投影为一个带源码范围的 function 节点。 fn function(&mut self, function: &frontend::ast::Function) -> CodeNode { + // 函数投影按函数体语句顺序递归展开,不单独生成参数节点;展示范围仍覆盖完整函数声明。 let children = function .body .statements @@ -1384,9 +1444,10 @@ impl Architecture { ) } - /// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。 + /// 按赋值、返回、表达式和声明种类构建 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); @@ -1428,13 +1489,15 @@ impl Architecture { } } - /// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。 + /// 递归投影表达式层次,用简短文本作显示名,并保留完整原始 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)]) } @@ -1447,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) diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index ecf4137..a9623be 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -8,12 +8,14 @@ use crate::document::{NamespaceState, RenderCommand}; const EXAMPLE: &str = include_str!("../../../examples/hello.endor"); const PAGE: &str = include_str!("../../../examples/page.endor"); -/// Opt-in release benchmark; run alone to avoid test-thread interference. +/// 可选性能基准:分别测世界 UI 和普通三维场景在 1x/2x DPI 下的相机交互更新、变更绘制和同列表缓存绘制。 +/// 默认 ignore,需在已确认的构建环境中以 release 单测试线程运行,避免其他测试干扰;下面命令仅为运行说明。 /// cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1 #[test] #[ignore] fn scene_interaction_performance() { use std::time::Instant; + /// 对非空毫秒样本排序后返回均值、索引中位数和向上取整秩的 p95;调用方每组固定保留 30 次测量。 fn summary(mut values: Vec) -> String { values.sort_by(f64::total_cmp); format!( @@ -23,6 +25,7 @@ fn scene_interaction_performance() { values[(values.len() * 95).div_ceil(100) - 1] ) } + // 每个场景/DPI 组合创建全新会话与像素缓冲,避免上一组合的相机状态和缓存直接污染下一组。 for (name, source) in [ ("world-ui", include_str!("../../../examples/world-ui.endor")), ("scene3d", include_str!("../../../examples/scene3d.endor")), @@ -44,6 +47,7 @@ fn scene_interaction_performance() { let mut updates = Vec::new(); let mut draws = Vec::new(); let mut cached = Vec::new(); + // 连续做 33 轮,前三轮预热;每轮使用最新发布版本构造事件,避免基准测到过期输入拒绝。 for sample in 0..33 { let target = document .frame @@ -51,6 +55,7 @@ fn scene_interaction_performance() { .iter() .find(|mount| mount.scene3d_viewport) .unwrap(); + // 选当前帧第一个三维视口,小幅 orbit;带中心锚点使世界面板等坐标映射路径也有完整输入。 let input = crate::events::Input { window: 1, session: document.frame.session, @@ -63,6 +68,7 @@ fn scene_interaction_performance() { tab_drop: None, screen_position: None, }; + // update 只计派发与完整文档刷新;display_list 构造在后面的绘制计时范围之外。 let start = Instant::now(); document = session.dispatch(&input).unwrap().document.unwrap(); let update = start.elapsed().as_secs_f64() * 1000.; @@ -75,6 +81,7 @@ fn scene_interaction_performance() { height, ) .unwrap(); + // 第一次 render 测相机已变化时的绘制;立即用同一列表再画一次,单独统计缓存重用路径。 let draw = start.elapsed().as_secs_f64() * 1000.; let start = Instant::now(); crate::software_renderer::SoftwareRenderer::render( @@ -84,6 +91,7 @@ fn scene_interaction_performance() { height, ) .unwrap(); + // 三类样本按同一轮配对保存,仅舍弃预热数据,不改变真实执行的相机操作。 if sample >= 3 { updates.push(update); draws.push(draw); @@ -96,6 +104,7 @@ fn scene_interaction_performance() { summary(draws), summary(cached) ); + // 额外打印派生工作量指标辅助解释耗时,不把这些查询本身算进上面的三类样本。 for stat in ["measured", "arranged", "snapshotVisited"] { println!( "PERFSTAT {name} {stat}={}", @@ -110,6 +119,8 @@ fn scene_interaction_performance() { } #[test] +/// 源 measure 直接读取 mount.layoutVersion 并将其作为宽度。 +/// 只使 paint 失效后仍必须重新测量;通过新版本宽度和零缓存命中,防止把真实 Mount 读取错误缓存。 fn actual_mount_version_reads_still_bypass_measurement_cache() { let (mut session, initial) = ReplSession::load( r#" @@ -139,6 +150,8 @@ page.render(rect) } #[test] +/// 加载布局协商示例,验证同一段落两个出现位置分别获得 280/360 的宽度。 +/// 加宽第一个出现位置后高度和后继 y 减小,第二个仍保持 360,且全部 Mount ID 不变。 fn source_allocations_and_measurement_limits_settle_before_publication() { let (mut session, initial) = ReplSession::load_with_viewport( include_str!("../../../examples/layout-negotiation.endor"), @@ -226,6 +239,8 @@ fn source_allocations_and_measurement_limits_settle_before_publication() { } #[test] +/// 源 arrange 把子项宽高不断交换,构造不收敛的布局状态。 +/// 加载必须报告 cycle,不能发布某个偶然中间态或依靠固定次数静默停止。 fn alternating_source_allocations_are_rejected_as_a_cycle() { let source = r#" class Alternating: Column { @@ -247,6 +262,8 @@ page.render(Alternating(0,0,RectBlock(120,60))) } #[test] +/// 源 paint 只提供 wrapWidth,省略成对需要的 wrapHeight。 +/// 检查命令解码明确拒绝不完整文字边界,而非生成测量/绘制不一致的帧。 fn source_text_paint_rejects_incomplete_wrap_bounds() { let source = r##" class Invalid: TextBlock { @@ -270,6 +287,8 @@ page.render(Invalid("invalid")) } #[test] +/// 分别对原生 TextBlock 和委托原生方法的源子类运行相同测试。 +/// 共享文字在不同内边距下独立换行;视口变宽只更新几何、保留 Mount,直接测量和 setText 也应同步反映尺寸。 fn wrapped_text_tracks_each_occurrence_width_resize_and_sibling_geometry() { let text = "中文段落随着宽度变化而换行,后面的对象跟随段落高度移动。".repeat(8); for custom in [false, true] { @@ -312,8 +331,8 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) }) .collect(); assert_eq!(commands.len(), 2); - // The viewport is already the available page area; there is no implicit - // rail/inset subtraction. The second occurrence has 50px padding per side. + // 输入 viewport 已是页面可用区域,不再隐式扣侧栏。 + // 第二个出现位置左右各有 50px 内边距,因此宽度比第一个少 100px。 assert_eq!(commands[0][0], 240.0); assert_eq!(commands[1][0], 140.0); assert!(commands[1][1] > commands[0][1]); @@ -402,6 +421,8 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) } #[test] +/// 加载回调签名示例,逐项尝试不匹配的事件/绑定/任务回调及参数个数。 +/// 注册期错误不能使会话 fault;合法零参数与 Event 参数回调均可执行,最后检查底层方法实际 arity。 fn native_signatures_validate_registration_and_deliver_the_event_argument() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/native-signatures.endor"), @@ -468,6 +489,8 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { } #[test] +/// 尝试错误字段赋值与构造,检查声明字段契约在原生分配副作用之前验证。 +/// 已有值和窗口页面数量必须保持不变,随后合法点击仍能递增并发布文字。 fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_values() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/field-contracts.endor"), @@ -532,6 +555,8 @@ fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_value } #[test] +/// 把 Numeric 视觉引用替换为 Textual,旧 alias 随共享转发指向新对象。 +/// 字段检查必须按新类型执行;合法字符串赋值通过,数字写入和返回错误类型的绑定均被拒绝。 fn checked_fields_follow_shared_block_forwarding_and_binding_writes() { let (mut session, _) = ReplSession::load( r#" @@ -570,6 +595,8 @@ page.render(block) } #[test] +/// 在容器中间插入子项,检查新稳定槽、后继 y 变化及旧 Mount 身份保留。 +/// 越界、负位置和自包含循环在修改前失败;移除新槽后旧出现位置仍有效。 fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_graphs() { let source = r#"head=TextBlock("head") tail=TextBlock("tail") @@ -634,6 +661,8 @@ fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_gra } #[test] +/// 先同窗重排再跨窗移动,确认调用 Page 源覆盖且保留 Page 业务身份。 +/// 跨窗目标帧版本不匹配必须拒绝;合法迁移后旧 Mount 失效,新窗口页面表指向原 Page。 fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targets() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/tab-placement.endor"), @@ -723,6 +752,8 @@ fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targe } #[test] +/// Page.moveToWindow 覆盖只返回 self,模拟源程序拒绝跨窗移动。 +/// 拖放不能强行改变窗口归属;随后 detach 使用独立入口,并排出带负桌面坐标的原生命令。 fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position() { let source = r#"class Declining: Page { calls: Object @@ -789,13 +820,18 @@ fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position ); } +/// 为异步文件测试写入唯一临时 UTF-8 文件,再把路径交给闭包。 +/// 进程号加原子序号防止并发冲突,Cleanup 析构保证断言 panic 时也尝试删除文件。 fn with_file_task_input(contents: &str, run: impl FnOnce(&std::path::Path)) { + // 只需唯一递增值,不依赖它同步其他内存,Relaxed 足以保证不同测试取到不同序号。 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let path = std::env::temp_dir().join(format!("tessera-text-task-{}-{id}.txt", std::process::id())); + // 将文件删除绑定到栈生命周期,避免测试提前失败时留下临时文件。 struct Cleanup(std::path::PathBuf); impl Drop for Cleanup { + // 清理失败不再次 panic,避免覆盖原测试故障或在异常展开时二次崩溃。 fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); } @@ -804,12 +840,16 @@ fn with_file_task_input(contents: &str, run: impl FnOnce(&std::path::Path)) { std::fs::write(&path, contents).unwrap(); run(&path); } +/// 生成 Loader 测试程序:readText 成功回调把 Task.result 写入未挂载的 target。 +/// 路径先按 JSON 字符串转义再插入 Endor,tail 用于附加替换/取消等测试操作。 fn file_task_source(path: &std::path::Path, tail: &str) -> String { let path = serde_json::to_string(&path.to_string_lossy()).unwrap(); format!( "class Loader {{\n target: TextBlock\n task: Object\n function apply(self: Loader): Unit {{ self.target.text = self.task.result() }}\n}}\ntarget = TextBlock(\"waiting\")\nloader = Loader(target,ui.state(0))\npage = Page()\npage.render(TextBlock(\"target stays hidden\"))\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)\n{tail}" ) } +/// 直接从当前 VM globals 找到 loader.task,按托管类型的方法表调用 status。 +/// 不通过 REPL execute 查询,避免状态观察自身引入一次输入、刷新或任务调度副作用。 fn file_task_state(session: &mut ReplSession) -> String { let globals = session.candidate.globals.unwrap(); let Value::Object(loader) = session @@ -848,6 +888,8 @@ fn file_task_state(session: &mut ReplSession) -> String { }; status } +/// 最多轮询 1000 次,每轮推进任务并检查其是否离开 pending。 +/// 短暂休眠让后台文件线程获得执行机会;取消/失败也算终态,超时则明确使测试失败。 fn complete_file_task(session: &mut ReplSession) { for _ in 0..1000 { session.poll_tasks().unwrap(); @@ -859,6 +901,8 @@ fn complete_file_task(session: &mut ReplSession) { panic!("file task did not complete"); } #[test] +/// 同一 owner/key 连续发起两个不同文件读取,第二个必须取消旧任务。 +/// 推进完成后仅新 UTF-8 内容写入 target;target 仍无 Mount,说明业务更新不依赖可见性。 fn file_task_updates_hidden_object_and_replacement_drops_old_delivery() { with_file_task_input("old", |old| { with_file_task_input("new UTF-8 文本", |new| { @@ -889,6 +933,8 @@ fn file_task_updates_hidden_object_and_replacement_drops_old_delivery() { }); } #[test] +/// 分别覆盖显式取消和 provider 读取不存在文件失败。 +/// 二者都不能运行成功回调;后者需保留错误文本但不使共享 VM fault,目标文字仍为 waiting。 fn cancelled_file_task_never_runs_callback_and_missing_file_keeps_vm_live() { with_file_task_input("ignored", |path| { let (mut cancelled, _) = ReplSession::load( @@ -924,6 +970,8 @@ fn cancelled_file_task_never_runs_callback_and_missing_file_keeps_vm_live() { }); } #[test] +/// 创建不带回调的 readText,完成后直接读取任务结果。 +/// 确认 result 的托管保留不依赖成功回调,且 error 为空字符串。 fn callback_free_file_task_retains_result_after_completion() { with_file_task_input("callback free", |path| { let path = serde_json::to_string(&path.to_string_lossy()).unwrap(); @@ -951,6 +999,8 @@ fn callback_free_file_task_retains_result_after_completion() { } #[test] +/// 以隐藏 Block 为 owner,用相同 key 替换定时任务。 +/// 旧任务立即取消,新回调仅在 poll 后写入 2;另一个被显式取消的任务不能再改变状态。 fn owned_tasks_replace_cancel_and_update_unmounted_objects() { let source = r#"class Action { state: Object @@ -1004,6 +1054,8 @@ fn owned_tasks_replace_cancel_and_update_unmounted_objects() { } #[test] +/// 并列创建 Page/Mount/Block/ui 作用域任务,再关闭并重开页面。 +/// 页面与出现位置任务取消,业务对象和应用任务继续;跨窗迁移保留 Page 作用域但取消旧 Mount,关窗取消窗口任务。 fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { let source = r#"class Action { state: Object @@ -1041,7 +1093,7 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { session.execute("state.value", "app.main").unwrap().value, "2" ); - // Moving a Page preserves its scope; the replaced Mount is cancelled. + // 跨窗移动保留 Page 自身作用域;原出现位置会被替换,因此旧 Mount 的任务必须取消。 session .execute( r#"moving=ui.after(page,"move",0,action.apply) @@ -1080,6 +1132,8 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { } #[test] +/// 只返回任务令牌而不保留临时 owner,检查弱作用域允许 owner 回收并取消任务。 +/// 随后第一个回调失败应停止共享执行域,后续递增不发生;通过 VM 直接读值验证状态仍为 0。 fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() { let source = r#"class Action { state: Object @@ -1128,6 +1182,8 @@ fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() } #[test] +/// 验证源函数、原生函数和绑定方法属于 Function,而类型构造器不属于 Function。 +/// 错误参数在函数体副作用前拒绝,错误返回在返回契约检查时拒绝;重定义同名类也不能改变旧实例/方法身份。 fn source_annotations_classify_functions_and_reject_before_body() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/function-types.endor"), @@ -1216,6 +1272,8 @@ fn source_annotations_classify_functions_and_reject_before_body() { } #[test] +/// 先由子窗口按钮回调制造运行时故障,再从宿主回收该窗口。 +/// Mount 和页面根必须脱离,onUnmount 的 marker 不可递增;已停止 VM 不能因清理重新执行源代码。 fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { let source = r#"class WatchedPage: Page { marker: Object @@ -1299,6 +1357,8 @@ fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { } #[test] +/// 加载多窗口示例,确认窗口共享业务计数但使用不重叠的 Mount。 +/// 迁移保留 Page 身份、替换旧 Mount;关闭并清除全局引用后 Window 可回收,保留旧叶 Mount 也不能留住窗口上下文。 fn native_windows_share_business_state_move_pages_and_release_closed_contexts() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/windows.endor"), @@ -1375,7 +1435,7 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() session.execute("0", "app.main").unwrap(); session.candidate.vm.collect_cycles(); assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); - // An externally retained old leaf Mount must not retain the window context. + // 外部仍持有旧叶 Mount 时,关闭窗口的上下文也必须可释放;下一次 detach 创建新的资源编号。 let detached = session .execute("detached = right.detachToWindow()", "app.main") .unwrap() @@ -1398,6 +1458,8 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() } #[test] +/// 一次输入先关闭子窗口,再用非法 resize 使后续语句失败。 +/// 先前关闭副作用仍存在,下一次成功输入必须清除残留展示;Page 可重新打开到主窗口。 fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/windows.endor"), @@ -1435,6 +1497,8 @@ fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() } #[test] +/// 依次通过事件激活页面、修改 title、关闭页面、清空及重开。 +/// 检查标签反映真实 Page 方法,稳定业务 ID 与递增展示 ID 各自正确,旧版本事件被桥接层拒绝。 fn tab_projection_uses_page_methods_and_rejects_old_presentations() { let source = r#"first = Page() first.title = "First" @@ -1461,7 +1525,7 @@ fn tab_projection_uses_page_methods_and_rejects_old_presentations() { let selected = session.dispatch(&input).unwrap().document.unwrap(); assert!(selected.frame.tabs[0].active); assert!(selected.frame.layout_version > initial.frame.layout_version); - // Query the bridge directly: stale transport must be rejected before faulting a runtime. + // 直接检查桥接层的版本拒绝:过期传输在进入源回调之前失败,不应被当成运行时故障。 assert!( session .bridge @@ -1517,6 +1581,8 @@ fn tab_projection_uses_page_methods_and_rejects_old_presentations() { } #[test] +/// 同次成功输入的两次 resize 合并为最后尺寸,并保留 title/focus 命令。 +/// 命令只消费一次;失败输入之前积累的 resize 也必须丢弃,不能送到原生窗口。 fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_requests() { let mut session = ReplSession::new(111).unwrap(); session @@ -1551,6 +1617,8 @@ fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_reque } #[test] +/// 检查 Window.pages 真实持有打开页面,close 解除归属,重新 open 追加到当前顺序末尾。 +/// 页面窗口关系保持双向一致,向非 Window 对象打开则明确失败。 fn window_page_roots_follow_open_close_and_creation_order() { let source = r#"first = Page() second = Page() @@ -1604,6 +1672,8 @@ fn window_page_roots_follow_open_close_and_creation_order() { } #[test] +/// 为 Page 建立弱句柄,close 后删除唯一全局绑定并运行循环回收。 +/// 弱升级应失败,证明窗口的页面顺序记录不会把已关闭页面意外作为强根保留。 fn closed_page_is_not_kept_alive_by_window_page_order() { let (mut session, _) = ReplSession::load( "page = Page()\npage.render(TextBlock(\"page\"))", @@ -1634,6 +1704,8 @@ fn closed_page_is_not_kept_alive_by_window_page_order() { } #[test] +/// 源 measure 内尝试 resize 原生窗口,加载应报告 read-only。 +/// 窗口命令也属于副作用,不能因其延后执行而绕过布局只读保护。 fn rendering_cannot_enqueue_native_window_side_effects() { let source = r##"class BadRect: RectBlock { function measure(self: BadRect, mount: Mount, constraints: Constraints): MeasureResult { @@ -1647,6 +1719,8 @@ fn rendering_cannot_enqueue_native_window_side_effects() { assert!(error.to_string().contains("read-only")); } +/// 直接调用 ui.stats 取得某个数字计数,读取后释放返回的 stats 托管对象。 +/// 辅助函数避免经 REPL 查询统计带来额外输入/刷新,使缓存命中与测量次数的断言可比较。 fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 { let Value::Object(function) = session .candidate @@ -1665,6 +1739,8 @@ fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 { session.candidate.vm.release(stats).unwrap(); value.as_i64().unwrap() } +/// 从 globals 中按名称找到业务 Block,再在给定完整文档中按 block_id 找对应 Mount。 +/// 构造携带当前会话与该文档版本的 click,返回派发后发布的新文档,供后续动作沿新版本继续。 fn click_named(session: &mut ReplSession, document: &RenderDocument, name: &str) -> RenderDocument { let globals = session.candidate.globals.unwrap(); let Value::Object(block) = session @@ -1701,6 +1777,8 @@ fn click_named(session: &mut ReplSession, document: &RenderDocument, name: &str) } #[test] +/// 同一个源 paint 对象出现在两个窗口,先执行与显示无关的表达式。 +/// 无关输入应不产生文档;修改 paint 实际读取的 tint 后,两个窗口都必须发布新颜色。 fn shared_intrinsic_desktop_does_not_repaint_other_windows_on_repl_input() { let (mut session, _) = ReplSession::load( r##" @@ -1752,6 +1830,8 @@ page2.moveToWindow(window2) } #[test] +/// 加载缓存示例并用真实 click 分别改变颜色与尺寸。 +/// 纯颜色更新复用全部测量,尺寸依赖变化只让受影响的结果失效;按布局 pass 数核对命中而非写死单次计数。 fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes() { let (mut session, initial) = ReplSession::load_with_viewport( include_str!("../../../examples/layout-cache.endor"), @@ -1792,6 +1872,8 @@ fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes } #[test] +/// 新建尚未 render 的 Console VM,执行普通表达式和 resize。 +/// 两者都不应产生发布文档,避免把 reset 后保留的画面替换成空白。 fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { let mut session = ReplSession::new_with_viewport( 104, @@ -1823,6 +1905,8 @@ fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { } #[test] +/// 源 measure 返回先前已创建的 MeasureResult,不在调用期间新建结果。 +/// 直接改其 width 后刷新必须失效并更新相同 Mount,证明返回值解码读取也进入依赖;再次刷新保持稳定。 fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { let source = r##"class HeldRect: RectBlock { result: MeasureResult @@ -1867,6 +1951,8 @@ fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { } #[test] +/// 在有限窗口约束下加载缓存示例,再缩窄真实可用视口。 +/// 原 Mount ID 不变,内容宽度按根 padding 改变,且输入计数仍为 0;重复尺寸不发布,NaN 尺寸失败但不停止 VM。 fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_source_input() { let (mut session, initial) = ReplSession::load_with_viewport( include_str!("../../../examples/layout-cache.endor"), @@ -1879,8 +1965,7 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour }), ) .unwrap(); - // The viewport is the actual page area; only the Endor root's 24px padding - // on each side is removed, both initially and after resizing. + // viewport 已是实际页面可用区域,初始与 resize 后都只扣 Endor 根左右各 24px 的 padding。 assert!( initial .frame @@ -1936,6 +2021,8 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour const SOURCE_LAYOUT: &str = include_str!("../../../examples/source-layout.endor"); #[test] +/// 检查首次 source measure/arrange 已可读取结构关系与子测量结果,产出预期位置。 +/// 直接改 Metrics.width 后通过依赖刷新同一 Mount,随后无变化刷新不再发布。 fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependencies() { let (mut session, initial) = ReplSession::load(SOURCE_LAYOUT, "app.main", 91, 1).unwrap(); let rect = initial @@ -1974,6 +2061,8 @@ fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependenc } #[test] +/// 把同一 rect 放入具有不同 Metrics 的两个父容器。 +/// 发布宽度必须分别为 200/140,不能把共享 Block 的一份测量结果误用于两个出现位置。 fn shared_block_source_measurements_belong_to_each_occurrence() { let source = SOURCE_LAYOUT.replace( "page.render(root)", @@ -2004,6 +2093,8 @@ fn shared_block_source_measurements_belong_to_each_occurrence() { } #[test] +/// measure 读取宽度 80,onMount 随后把该依赖写成 140。 +/// 首次发布必须已经重测为 140,不能把回调前的旧几何暴露出去,且后续刷新应稳定。 fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publication() { let source = r##"class Metrics { width: Number } class InitialRect: RectBlock { @@ -2029,6 +2120,8 @@ fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publicati } #[test] +/// 逐项修改示例,覆盖测量期提前读 bounds、只读范围业务写入、错误返回类型和伪造 child slot。 +/// 每个非法程序都要在发布前给出对应诊断,不让不完整或不属于当前容器的排列通过。 fn source_layout_rejects_premature_geometry_writes_wrong_types_and_foreign_slots() { for (before, after, message) in [ ( @@ -2059,6 +2152,8 @@ fn source_layout_rejects_premature_geometry_writes_wrong_types_and_foreign_slots } #[test] +/// 表驱动检查圆形内点击、圆外但底层可点、按下释放命中不同形状,以及页面外点击。 +/// 实际提交仍指向圆 Mount,worker 必须重新按源 hitTest 与 press_point 复核并选择合法目标。 fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() { for (press, release, expected_hits, expected_background) in [ ([64.0, 64.0], [64.0, 64.0], "1", "0"), @@ -2108,6 +2203,8 @@ fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() { } #[test] +/// 把 hitTest 改为返回 Number 或试图递增业务计数。 +/// 派发应报告 Bool 契约/只读错误并停止运行时,但业务写入与 click 回调均未发生,hits 保持 0。 fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { for (body, message) in [ ("return 1", "hitTest return expects Bool"), @@ -2160,6 +2257,8 @@ fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { } #[test] +/// 普通 RectBlock 通过 click 订阅变为可交互;断开令牌后新文档应移除所有交互标记。 +/// 检验命中资格依赖实际监听器状态,不只由构造类型决定。 fn ordinary_block_subscription_updates_interactivity_after_disconnect() { let source = r##"hits = ui.state(0) rect = RectBlock(80, 40, 0, "#DDEEFF") @@ -2180,6 +2279,8 @@ fn ordinary_block_subscription_updates_interactivity_after_disconnect() { ); } +/// 生成可调规模的源 paint 压力程序:copies 控制共享对象出现次数,locals 控制单次 paint 指令量。 +/// onMount 计数检测取消/恢复是否重复生命周期,tint 让各出现位置绘制结果可统一断言。 fn resumable_paint_source(copies: usize, locals: usize) -> String { let children = std::iter::repeat_n("item", copies) .collect::>() @@ -2191,6 +2292,8 @@ fn resumable_paint_source(copies: usize, locals: usize) -> String { } #[test] +/// 分别加载含源布局/源绘制的示例并主动 reclaim 临时辅助对象。 +/// 随后无业务变化刷新必须为 None,版本不变,证明缓存未把短命 helper 当作业务失效依赖。 fn collecting_lazy_helpers_does_not_invalidate_stable_layout_or_paint() { for source in [ include_str!("../../../examples/flex-constraints.endor"), @@ -2212,6 +2315,8 @@ fn collecting_lazy_helpers_does_not_invalidate_stable_layout_or_paint() { } #[test] +/// 用 160/640 个出现位置强制布局和绘制让出一次执行。 +/// 首次 Pending 时托管对象增长必须小于固定阈值,不能预建整树 Bounds/collector;显式取消后续体和只读范围都应关闭。 fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() { for copies in [160, 640] { let (mut session, initial) = @@ -2228,8 +2333,7 @@ fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() { let crate::source_paint::Progress::Pending(work) = progress else { panic!("paint must yield"); }; - // Each quantum may retain its current helper/results, not a Bounds - // and collector for every occurrence before reaching the first one. + // 每片只保留当前出现位置的辅助对象/结果;不能在首次让出前为所有位置预建 Bounds 和 collector。 assert!(session.candidate.vm.object_count() < before + 256); session .bridge @@ -2270,6 +2374,8 @@ fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() { } #[test] +/// 修改共享 tint 后逐片推进到 paint 挂起,检查旧完整文档仍可见。 +/// 换视口取消未发布绘制并重算,最终 80 条命令全为新色,onMount 仍恰好 80 次,取消与切片统计可观察。 fn pending_paint_retargets_without_replaying_completed_mount_hooks() { let (mut session, initial) = ReplSession::load(&resumable_paint_source(80, 0), "app.main", 278, 1).unwrap(); @@ -2326,6 +2432,8 @@ fn pending_paint_retargets_without_replaying_completed_mount_hooks() { } #[test] +/// 利用长 paint 先产生暂停,再在解码时遇到非法颜色。 +/// 必须同时验证曾挂起及最终失败;故障关闭 VM 续体/只读范围,保留旧文档版本和原始绘制颜色。 fn paint_error_after_pause_closes_read_only_scope_and_preserves_last_picture() { let (mut session, initial) = ReplSession::load(&resumable_paint_source(1, 300), "app.main", 279, 1).unwrap(); @@ -2363,6 +2471,8 @@ fn paint_error_after_pause_closes_read_only_scope_and_preserves_last_picture() { } #[test] +/// paint 局部变量先引用旧 ObjectList,再重新绑定到新 DisplayList。 +/// 允许局部引用重绑不等于允许修改旧业务对象;绘制成功后 previous 仍应为空。 fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it() { let source = r##"class ReboundRect: RectBlock { previous: ObjectList @@ -2385,6 +2495,8 @@ fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it } #[test] +/// 源 paint 修改原生基类返回的命令颜色,检查共享对象各出现位置均使用覆盖结果。 +/// 直接写堆上的 tint 而不执行 REPL,只靠 paint 的字段依赖就应触发重绘并在下一轮稳定。 fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/source-paint.endor"), @@ -2410,8 +2522,7 @@ fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { else { panic!("Block") }; - // No Console/global binding writes or focus changes: only the property - // read by paint changes, proving the paint dependency is sufficient. + // 不经 Console、不写全局绑定、不改焦点,只改变 paint 读过的属性,单独验证该读依赖足以使帧失效。 session .candidate .vm @@ -2423,6 +2534,8 @@ fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { } #[test] +/// paint 尝试写 self.tint,必须在写入前被只读保护拒绝并令会话 fault。 +/// 原业务颜色不变,错误展开后宿主可正常直接写属性;另测非法颜色命令的发布拒绝。 fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() { let source = r##"class BadPaint: RectBlock { tint: String @@ -2469,6 +2582,8 @@ fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() } #[test] +/// 生成 1100 个共享 rect 出现位置,使 Mount 变更通知超过单批容量。 +/// 首次仍需完整产出 1100 条命令,随后无变更刷新为 None,避免通知溢出造成永久重绘。 fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() { let fixture = include_str!("../../../examples/source-paint.endor"); let mut source = fixture[..fixture.find("root = Column").unwrap()].to_owned(); @@ -2483,6 +2598,8 @@ fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() { } #[test] +/// 检查可选原生构造参数、可变子项和源子类字段在同一构造中的独立分段。 +/// 错误类型在分配前拒绝,Block 数量不增;合法回调有效,并逐一验证已公开 UI 方法的 native signature。 fn constructor_options_and_subclass_fields_have_independent_contracts() { let (mut session, document) = ReplSession::load( include_str!("../../../examples/constructor-contracts.endor"), @@ -2584,6 +2701,8 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { } #[test] +/// 加载满足参数逆变/返回协变的视觉方法覆盖,验证测量和点击都按动态类型执行。 +/// 再分别缩窄参数、放宽返回类型,检查类定义被对应 override 诊断拒绝。 fn source_overrides_accept_wider_inputs_and_more_specific_results() { let source = include_str!("../../../examples/method-contracts.endor"); let (mut session, document) = ReplSession::load(source, "app.main", 164, 1).unwrap(); @@ -2625,6 +2744,8 @@ fn source_overrides_accept_wider_inputs_and_more_specific_results() { } #[test] +/// 把 Mount/Bounds 的名义类型、事件 Bool|String 选项及可空 Page.window/open 参数串联执行。 +/// 合法空值和 union 分支互通,Number 选项则必须以 Bool | String 契约诊断拒绝。 fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), @@ -2662,6 +2783,8 @@ fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() } #[test] +/// 向 measure/paint、几何方法、resize 和 setColor 传递错误名义类型。 +/// 检查每项错误点名所需类型,业务颜色保持不变,随后合法 Constraints 测量仍返回受限宽度。 fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), @@ -2714,6 +2837,8 @@ fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { } #[test] +/// 执行示例内直接调用的原生视觉协议,比较托管 MeasureResult/Arrangement/DisplayList 与真实发布命令。 +/// 局部命中允许点内/点外判断,但非法约束、错属 Mount 和已卸载上下文不得用于绘制。 fn native_visual_methods_use_managed_values_and_match_real_geometry() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), @@ -2775,6 +2900,8 @@ fn native_visual_methods_use_managed_values_and_match_real_geometry() { } #[test] +/// 先 invalidate paint 再 invalidate layout,对比 measured 为 0 和 1。 +/// 两者都可发布但不替换 Mount;无关查询不发布,未知失效类别明确报错。 fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when_requested() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), @@ -2836,6 +2963,8 @@ fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when } #[test] +/// 加载共享子项生命周期示例,通过日志计数检查每个出现位置各自挂载。 +/// 普通颜色更新不重复 onMount;关闭时子先父后卸载,保存的旧 Mount 永久失效,重开创建新出现位置。 fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/mount-lifecycle.endor"), @@ -2915,6 +3044,8 @@ fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { } #[test] +/// 让两个页面使用同一业务根,然后切换活动页面。 +/// 每次切换都卸载旧上下文并挂载新上下文;共享 Block 不能让旧 Mount 或 page() 指向继续复用。 fn page_switch_replaces_mount_context_even_for_shared_root() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/mount-lifecycle.endor"), @@ -2965,6 +3096,8 @@ fn page_switch_replaces_mount_context_even_for_shared_root() { } #[test] +/// onMount 向容器 append 新文字,最终文档应包含该结构变化,验证回调后继续收敛。 +/// 另一个 onMount 传入错误 setText 参数,错误应停止共享运行时而非忽略生命周期失败。 fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_domain() { let source = r#"class GrowingColumn: Column { function onMount(self: GrowingColumn, mount: Mount): Unit { self.append(TextBlock("from hook")) } @@ -2986,6 +3119,8 @@ fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_doma assert!(session.is_faulted()); } +/// 在标准事件示例后附加 extra 语句,创建独立会话和初始完整文档。 +/// 各路由测试只需描述额外监听器,即可共享相同父子树、计数器与默认事件配置。 fn event_session(extra: &str) -> (ReplSession, RenderDocument) { let source = format!( "{}\n{}", @@ -2995,6 +3130,8 @@ fn event_session(extra: &str) -> (ReplSession, RenderDocument) { ReplSession::load(&source, "app.main", 41, 1).unwrap() } +/// 按全局 button 的业务身份找给定文档中的可交互 Mount,模拟原生 click。 +/// 事件使用该文档的会话与布局版本,返回派发后的完整文档,避免用过期目标绕过真实输入检查。 fn click_document(session: &mut ReplSession, document: &RenderDocument) -> RenderDocument { let globals = session.candidate.globals.unwrap(); let Value::Object(button) = session @@ -3031,6 +3168,8 @@ fn click_document(session: &mut ReplSession, document: &RenderDocument) -> Rende } #[test] +/// 通过原生输入路径触发一次 click,检查捕获、目标方法、目标监听器、冒泡依次记为 1/2/3/4。 +/// 派发结束后的模型保留最后阶段信息,订阅令牌仍连接。 fn managed_event_route_orders_capture_method_listeners_and_bubble() { let (mut session, initial) = event_session(""); click_document(&mut session, &initial).validate().unwrap(); @@ -3062,6 +3201,8 @@ fn managed_event_route_orders_capture_method_listeners_and_bubble() { } #[test] +/// 第一场景在捕获中断开尚未调用的目标监听器,必须跳过它但继续冒泡。 +/// 第二场景在目标捕获中 stopPropagation,当前目标组仍执行,祖先冒泡被阻止。 fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_group() { let (mut session, initial) = event_session( "function cancel(event: Event): Unit { target.disconnect() }\nconnection = root.on(\"click\", cancel, \"capture\")", @@ -3109,6 +3250,8 @@ fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_g } #[test] +/// 目标捕获使用 stopImmediatePropagation,应连目标默认方法也跳过。 +/// 代码派发 custom 事件可返回同一带 preventDefault 标记的 Event,结束后 phase 为 none,并允许再次派发。 fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event() { let (mut session, initial) = event_session( "function stop(event: Event): Unit { event.stopImmediatePropagation() }\nconnection = button.on(\"click\", stop, \"capture\")", @@ -3135,13 +3278,15 @@ fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event( session.execute("result.phase", "app.main").unwrap().value, "\"none\"" ); - // Reusing a completed event is legal; its previous dispatch has no live guard. + // 已完成派发的 Event 可以重用;前次派发的活跃守卫必须已经解除。 session .execute("button.dispatch(result)", "app.main") .unwrap(); } #[test] +/// 回调失败后断开故障监听器,再派发同一 Event,验证活跃派发守卫已释放。 +/// 另外保留订阅 token 却删除隐藏目标,回收后目标弱升级失败且 token 断开,证明它不强持有目标。 fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() { let (mut session, _) = event_session(""); session.execute("function fail(event: Event): Unit { page.render(1) }\nfailing = button.on(\"failure\", fail)\nfailedEvent = Event(\"failure\")", "app.main").unwrap(); @@ -3193,6 +3338,8 @@ fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() { } #[test] +/// 加载原生视觉子类,把旧 label 引用保存到 alias 后替换为新的源实例。 +/// 共享转发应让 alias 读写新字段、更新多个绘制位置,同时旧属性存储的弱句柄可回收。 fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/native-subclasses.endor"), @@ -3275,6 +3422,8 @@ fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { } #[test] +/// 完整源码加载后执行 Console 类型查询,再用原生 click 调用原程序中的源回调。 +/// 两个共享文字都应变为 10 且 VM 健康,覆盖混合入口中源 ID/编译单元身份不冲突的行为。 fn loaded_program_console_and_native_events_share_unique_source_ids() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/managed-types.endor"), @@ -3326,6 +3475,8 @@ fn loaded_program_console_and_native_events_share_unique_source_ids() { } #[test] +/// 向被裁剪的子项中心发送 click,虽在其原始矩形内也不得触发计数。 +/// 随后滚动同一 ScrollBlock 的一个出现位置、scrollIntoView 并扩大容器,分别检查独立偏移、可见性及范围回落到零。 fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/scroll.endor"), @@ -3431,6 +3582,8 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { } #[test] +/// 为已有 Mount 建立长延时任务,再开始会挂起的 resize 布局并替换视口目标。 +/// 取消应恢复旧几何/挂载身份、清除临时 layout,最终重新收敛且原任务仍 pending,避免把放弃的准备当成卸载。 fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { let children = std::iter::repeat_n("item", 40) .collect::>() @@ -3496,6 +3649,8 @@ fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { } #[test] +/// 增量加载含 80 个出现位置的新候选,在初始布局尚未发布时替换视口目标。 +/// 候选文档在完成前始终为空;重新收敛后构建计数仍为 1、挂载计数恰好 80,证明取消只重做布局。 fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() { let children = std::iter::repeat_n("item", 80) .collect::>() @@ -3547,6 +3702,8 @@ fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() { } #[test] +/// 一次输入递增计数、改宽度并返回值,源执行结束后布局继续 Pending。 +/// 此时禁止新 mutation/命令投影,保留旧文档;换视口后最终几何更新,但返回值和递增都只发生一次。 fn incremental_input_retains_value_and_never_replays_source_while_retargeting() { let children = std::iter::repeat_n("item", 80) .collect::>() @@ -3600,6 +3757,8 @@ fn incremental_input_retains_value_and_never_replays_source_while_retargeting() } #[test] +/// 点击回调先改变业务计数,再通过多次增量刷新完成大树布局。 +/// 挂起期间旧文档版本不变,最终计数为 1 且 refreshYields 大于零,避免分片时重放 click。 fn incremental_event_runs_callback_once_before_publishing_its_layout() { let children = std::iter::repeat_n("item", 80) .collect::>() @@ -3657,6 +3816,8 @@ fn incremental_event_runs_callback_once_before_publishing_its_layout() { } #[test] +/// 只用原生布局的大树也应进入可恢复路径。 +/// Pending 期间保留旧帧,换视口取消后重算仍产出最后子项 x=158/width=2,并记录切片与取消次数。 fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() { let children = std::iter::repeat_n("item", 80) .collect::>() @@ -3704,6 +3865,8 @@ fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() { } #[test] +/// 两个立即就绪任务都递增同一状态,首次 poll 只交付第一个并启动刷新。 +/// 刷新未完成时重复 poll 不交付第二个;第一帧完成后再 poll 才发布第二次递增。 fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() { let (mut session, initial) = ReplSession::load( "state = ui.state(0)\npage = Page()\npage.render(ui.textOf(state,\"value\"))\na = ui.after(ui,\"a\",0,state.increment)\nb = ui.after(ui,\"b\",0,state.increment)", @@ -3758,6 +3921,8 @@ fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() { } #[test] +/// 启动增量 resize 后尝试普通 mutation,应拒绝但不使 VM fault。 +/// 逐次推进时每个未完成返回都保持旧文档版本,只有整体完成才一次发布新版本并清除 pending。 fn incremental_viewport_refresh_retains_published_documents_until_complete() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/flex-constraints.endor"), @@ -3808,6 +3973,8 @@ fn incremental_viewport_refresh_retains_published_documents_until_complete() { } #[test] +/// 绕过会话同步包装,直接调用桥接 begin_layout_frame 观察首个 Pending。 +/// 循环把工作所有权传回 resume,最终 Complete 才断言完整几何,且 VM 不再残留挂起调用。 fn bridge_layout_can_return_pending_before_committing_a_complete_frame() { let children = std::iter::repeat_n("item", 40) .collect::>() @@ -3858,6 +4025,8 @@ fn bridge_layout_can_return_pending_before_committing_a_complete_frame() { } #[test] +/// 同一 item 在 Row 中重复 40 次,布局需跨多个 VM 切片。 +/// 完整帧不能丢失出现位置,第 40 个的 x=78 且 Mount 数量覆盖容器与全部子项。 fn layout_slices_preserve_all_shared_occurrences() { let children = std::iter::repeat_n("item", 40) .collect::>() @@ -3879,6 +4048,8 @@ fn layout_slices_preserve_all_shared_occurrences() { } #[test] +/// 有限 Row 把带换行文字的 flex Column 缩窄,文字自然高度必须重新测量,不能沿用早期宽约束下的一行高度。 +/// 将文字改短后高度回到 20,确认新求解不把先前分配冻结为永久尺寸。 fn nested_flex_refreshes_natural_height_when_cross_width_changes() { let (mut session, initial) = ReplSession::load( r#" @@ -3918,6 +4089,8 @@ fn nested_flex_refreshes_natural_height_when_cross_width_changes() { } #[test] +/// 给两个 flex 子项显式 basis、不同增长权重和最大宽度,检查达到上限后剩余空间再分配。 +/// 随后切换为收缩并让首项触及最小值,再恢复 auto basis,验证每次新求解都重新计算自然尺寸。 fn bounded_flex_redistributes_growth_and_scaled_shrink() { let (mut session, initial) = ReplSession::load( r#" @@ -3966,6 +4139,8 @@ fn bounded_flex_redistributes_growth_and_scaled_shrink() { } #[test] +/// 同一段文字分别出现在两个 Row;首个 Row 的可伸缩兄弟权重改变会挤窄文字。 +/// 只第一个出现位置的换行高度增加,第二个保持原高度,证明约束与自然尺寸缓存按 Mount 隔离。 fn native_flex_reflows_shared_text_per_occurrence() { let (mut session, initial) = ReplSession::load( r#" @@ -4004,6 +4179,8 @@ fn native_flex_reflows_shared_text_per_occurrence() { } #[test] +/// 有限宽度 Row 先扣 padding、gap 与固定子项,再把 240 剩余宽度按 1:2 分配。 +/// 修改权重为 3:1 后同一出现位置实时改为 180/60,验证分配结果反过来约束子测量。 fn weighted_flex_remeasures_children_and_updates_live_weights() { let (mut session, initial) = ReplSession::load(r#" class Wide: Row { @@ -4053,6 +4230,8 @@ fn weighted_flex_remeasures_children_and_updates_live_weights() { } #[test] +/// 表驱动切换 start/center/end/space-between,逐项比较两个子项的 x。 +/// 子项宽度始终为自然宽度,mainAlign 只分配剩余间隙,不应被误当成 flex 拉伸。 fn main_alignment_updates_live_geometry_without_changing_sizes() { let (mut session, initial) = ReplSession::load(r#" class Wide: Row { @@ -4099,6 +4278,8 @@ fn main_alignment_updates_live_geometry_without_changing_sizes() { } #[test] +/// 同一 RectBlock 分别放在 Row 和 Column,以两个父容器的 crossAlign 得到独立坐标。 +/// alignSelf 显式值覆盖各父策略,恢复 auto 后再次继承,且修改一个父容器不影响另一个出现位置。 fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() { let (mut session, initial) = ReplSession::load(r#" class Tall: Row { @@ -4167,6 +4348,8 @@ fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() { } #[test] +/// 源 arrange 主动把第一个子项 y 改为 7,后续原生 crossAlign 不能覆盖源给定位置。 +/// 另一子项比父交叉轴更宽时按起点溢出,center 不应产生负起点。 fn cross_alignment_preserves_source_placements_and_starts_overflow() { let (mut session, initial) = ReplSession::load(r#" class Placed: Row { @@ -4206,6 +4389,8 @@ fn cross_alignment_preserves_source_placements_and_starts_overflow() { } #[test] +/// 带不对称 margin 的共享子项分别参与有限 flex Row 和自然尺寸 Row。 +/// 检查定位、交叉轴居中、剩余宽度与容器固有尺寸;增加右 margin 同时挤窄 flex 兄弟并扩大自然父宽度。 fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() { let (mut session, initial) = ReplSession::load(r#" class Wide: Row { @@ -4259,6 +4444,8 @@ fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() { } #[test] +/// 在固定尺寸 Overlay 内嵌带边框/不对称 padding 的 Column,子项还有 margin。 +/// 比较三层绝对坐标与边框绘制命令,再动态改变 min/max,验证对齐与内容偏移使用同一盒模型。 fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() { let (mut session, initial) = ReplSession::load( r##" @@ -4322,6 +4509,8 @@ fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() { } #[test] +/// 文字 boxWidth=fill 在有限父宽中获得整个边框盒,真正换行宽度再扣左右 padding。 +/// 另构造无界 Column 主轴上的 fill 高度,应明确拒绝无法定义的有限分配。 fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { let (mut session, initial) = ReplSession::load(r#" text = TextBlock("Text wrapping follows the finite border box rather than the original unbounded column width.") @@ -4362,6 +4551,8 @@ fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { } #[test] +/// 让子按钮超出带左边框且 overflow=clip 的固定 Row。 +/// 子 Mount 保留原几何,但 clip 仅覆盖父内容区域;命中在原按钮矩形内也应因裁剪而失败。 fn clipping_a_box_limits_descendant_paint_and_hit_geometry() { let (_, initial) = ReplSession::load(r#" class Overflow: RectBlock { @@ -4395,6 +4586,8 @@ fn clipping_a_box_limits_descendant_paint_and_hit_geometry() { } #[test] +/// 两个 fill 子项分享扣去 margin/gap/固定项后的主轴空间,其中一项触及 maxWidth 后重新分配。 +/// 交叉轴 stretch 仍受 maxHeight 和 alignSelf 限制;增大父宽后首项吸收新增空间。 fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() { let (mut session, initial) = ReplSession::load( r#" @@ -4444,6 +4637,8 @@ fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() { } #[test] +/// 通过真实 VM 加载盒布局示例并验证完整文档。 +/// 共享文字有两个 Mount,父盒宽度不同产生不同分配,防止示例只在说明文档中成立。 fn box_layout_example_runs_through_the_real_source_pipeline() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/box-layout.endor"), @@ -4476,6 +4671,8 @@ fn box_layout_example_runs_through_the_real_source_pipeline() { } #[test] +/// 固定 Overlay 按内容高度拉伸子项,再受 margin/maxHeight 限制得到 50。 +/// 改为显式 boxHeight 后使用 15,证明显式尺寸优先于 stretch,覆盖层不按线性子项重排。 fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { let (mut session, initial) = ReplSession::load( r#" @@ -4520,6 +4717,8 @@ fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { } #[test] +/// 两个有限视口共享同一 Scene2D,默认相机独立,出现位置各有变换和裁剪。 +/// 点击先经变换命中;只改首个相机不测量业务对象,显式共享 camera 后两边才同步缩放。 fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { let (mut session, initial) = ReplSession::load( r#" @@ -4624,6 +4823,8 @@ fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { } #[test] +/// 嵌套二维相机的缩放相乘、平移复合,检查最终变换和相交裁剪。 +/// 手势只驱动内层相机,外层不动;最后软件绘制采样确认固定 HUD 与变换后内容均位于预期像素。 fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { let (mut session, initial) = ReplSession::load( r##" @@ -4705,6 +4906,8 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { } #[test] +/// 在负世界 x 处放红矩形,经相机平移和二倍缩放映射到有限视口。 +/// 实际像素需显示固定蓝色覆盖层、可见红块和视口外背景,检验命令投影与裁剪最终进入光栅化。 fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { let (_, initial) = ReplSession::load( r##" @@ -4738,6 +4941,8 @@ fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { } #[test] +/// 加载场景视口示例并确认共享按钮出现两次。 +/// 再实际构造 ScrollView,检查它保持 ScrollBlock 名义类型兼容,而非只在文本说明中存在的别名。 fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/scene-viewports.endor"), @@ -4770,6 +4975,8 @@ fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() { } #[test] +/// 场景内容在负坐标,按当前 Mount 的有限尺寸 fitView。 +/// 比较相机 zoom/pan 与矩形投影后的 48px 留白,验证适配的是场景边界而非业务对象原始正坐标假设。 fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() { let (mut session, initial) = ReplSession::load( r#" @@ -4816,6 +5023,8 @@ fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() { } #[test] +/// 同一场景用透视和正交相机各显示一次,前景蓝盒应遮住后景红盒。 +/// 比较拾取距离、交点和实际中心像素;移动前景并只修改透视相机后,两视图露出后景且相机 z 保持独立。 fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_cameras() { let (mut session, initial) = ReplSession::load( r##" @@ -4898,6 +5107,8 @@ fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_came } #[test] +/// 先通过 Mount.pick3D 查询中心交点与真实 mesh 材质,再从原生输入派发同位置 click。 +/// 回调写 event.hit.mesh.color 后业务对象及最终像素都变绿,证明拾取结果保留正确托管业务身份。 fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() { let (mut session, initial) = ReplSession::load( r##" @@ -4976,6 +5187,8 @@ fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() } #[test] +/// 运行内置三维示例及其旋转动作,再替换为源构造的自定义三角网格。 +/// 另检查 Group3D 自循环和越界三角索引的明确错误,非法场景不能进入发布。 fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { let (mut session, document) = ReplSession::load( include_str!("../../../examples/scene3d.endor"), @@ -5030,6 +5243,8 @@ fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { } #[test] +/// 旋转 Panel3D 的 UI 以独立 100x100 分辨率布局,检查 toView/toLocal、射线命中和纹理像素。 +/// 点击及源 paint 字段修改保持响应;加入前景实体后同一点只命中外层三维视口,UI 不可穿透遮挡。 fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { let (mut session, initial) = ReplSession::load( r##" @@ -5172,6 +5387,8 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { } #[test] +/// 把可交互的内层 Viewport3D 作为旋转世界面板的 UI 内容。 +/// 中心点 camera_at/hit_test 必须找到内层视口,click 的 hit.mesh 更新内层立方体并通过两层表面呈现绿像素。 fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { let (mut session, initial) = ReplSession::load( r##" @@ -5244,6 +5461,8 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { } #[test] +/// 先让 Panel.content 指回包含自身的视口,检查跨 UI/场景组合循环。 +/// 再构造九层合法无环嵌套,检查超过八层表面预算的诊断;两种边界分别验证,避免把深度错误混同循环。 fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() { let error = ReplSession::load( r##" @@ -5283,6 +5502,8 @@ fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() { } #[test] +/// 同一按钮被两个世界面板共享,两个面板指定不同 UI 分辨率。 +/// 两个 Mount 的宽高分别服从各自纹理,投影中心位置及命中 ID 也独立,最终整帧仍可软件绘制。 fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() { let (mut session, initial) = ReplSession::load( r##" @@ -5373,6 +5594,8 @@ fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() } #[test] +/// 以实际有限窗口约束加载世界 UI 示例,重复相同视口/任务轮询应保持空闲。 +/// 检查面板内二维视口确实存在,执行 turn/fit/reset 并比较业务相机值,最后尝试完整光栅化。 fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { let (mut session, document) = ReplSession::load_with_viewport( include_str!("../../../examples/world-ui.endor"), @@ -5444,6 +5667,8 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { } #[test] +/// 同一段落进入纵向/横向滚动容器,禁止轴的最大范围与偏移必须为 0。 +/// 允许轴保留请求偏移,文字约束按滚动轴不同而独立换行;改策略后原横向偏移应立即清零。 fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/scroll-axis.endor"), @@ -5503,6 +5728,8 @@ fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { } #[test] +/// 未指定 axis 的 ScrollBlock 默认为 both。 +/// 把已挂载容器策略写为 diagonal 必须给出允许集合诊断,不能默认为某个方向。 fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { let mut session = ReplSession::new(262).unwrap(); session @@ -5537,6 +5764,8 @@ fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { } #[test] +/// 源 arrange 把构造宽度 300 的纵向 scroller 改分配为 120。 +/// 文字实际宽度需扣两侧 padding 得到至多 100,横向滚动范围为零,不能继续按构造参数测量。 fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { let source = r#" class Allocator: Column { @@ -5579,6 +5808,8 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { } #[test] +/// 嵌套两个 ScrollBlock,并让其中部分内容使用源 paint。 +/// 对末尾按钮 scrollIntoView 要联动内外偏移 128/148;绘制 clip 与新命中几何必须使用同一滚动投影。 fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { let mut session = ReplSession::new(241).unwrap(); let result = session @@ -5628,6 +5859,8 @@ fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { } #[test] +/// 直接调用 Button.click 得到名义 Event,dispatch 显式事件后返回值应与输入为同一托管对象。 +/// 通过底层 globals 值比较身份,避免仅比较 repr 造成不同对象也通过。 fn click_and_dispatch_return_the_actual_delivered_event() { let mut session = ReplSession::new(237).unwrap(); session @@ -5666,14 +5899,17 @@ returned = button.dispatch(dispatched)"#, } #[derive(Debug)] +/// 测试用原生转发器:保留公开签名,要求 VM 继续调用 target,用于模拟相同函数身份下实现变化。 struct VisualRedirect { target: ObjectRef, signature: endor_core::CallSignature, } impl endor_core::NativeCallable for VisualRedirect { + /// 返回原方法签名副本,使转发器仍参与同一参数/返回/副作用契约验证。 fn signature(&self) -> Option { Some(self.signature.clone()) } + /// 不直接递归执行 target;返回 Invoke 请求,由 VM 在其正常调用栈/预算中处理参数和结果。 fn invoke( &self, _: &mut endor_core::NativeContext<'_>, @@ -5687,6 +5923,8 @@ impl endor_core::NativeCallable for VisualRedirect { } #[test] +/// 从同一 desktop 的第二窗口查到 Block.measure,检查原生快速路径认的是方法与注册表组合。 +/// 同函数换成 paint 名称或外来 types 表都不能被认作 intrinsic,避免只凭对象形状或方法名跳过源执行。 fn native_visual_identity_accepts_shared_window_registries_only() { let mut session = ReplSession::new(238).unwrap(); session.execute("second = Window()", "app.main").unwrap(); @@ -5731,6 +5969,8 @@ fn native_visual_identity_accepts_shared_window_registries_only() { } #[test] +/// 替换 Block.measure 为源方法后既有实例应按新方法得到宽度 140。 +/// 再保持原 paint 对象身份、只替换 native payload 为转发器,刷新也必须识别实现改变并产生空命令。 fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { let (mut session, _) = ReplSession::load(r#" class Hooks { @@ -5808,6 +6048,8 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { } #[test] +/// 保持 Block.onEvent 的函数句柄不变,替换为有副作用的转发 payload。 +/// 通过 VM changes 使 RenderCache 失效后 rect 从不交互变为交互,protocol_key 也应变化,证明缓存追踪实现版本。 fn same_identity_event_payload_changes_invalidate_interactivity_cache() { let mut session = ReplSession::new(236).unwrap(); session @@ -5876,6 +6118,8 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { } #[test] +/// 查询简单、转发、递归、动态回调和有副作用函数的静态 call_effect。 +/// 绑定方法可借助接收者识别只读性,未绑定方法保守未知;重绑被调用全局函数后结果应失效,marker 始终为 0 证明查询未执行源码。 fn source_effect_queries_use_compiled_instructions_without_running_functions() { let mut session = ReplSession::new(234).unwrap(); session @@ -5973,6 +6217,8 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { } #[test] +/// 语言层拒绝用普通实例字段遮蔽有契约的 paint/measure/onEvent。 +/// 底层绕过语言写入存储模拟方法阴影,缓存需失效至祖先并报 TraitViolation;删除阴影后可再次生成合法快照。 fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { let mut session = ReplSession::new(233).unwrap(); session @@ -6029,6 +6275,8 @@ fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { } #[test] +/// 为参数/返回契约不兼容的 present 方法声明 Presentable,注册必须失败且方法体 marker 不执行。 +/// 参数更宽、返回更具体的合法方法可注册,并实际产出 TextBlock,运行时保持健康。 fn presentable_type_contracts_reject_before_registration_or_body_effects() { let mut session = ReplSession::new(232).unwrap(); session.execute("marker = ui.state(0)", "app.main").unwrap(); @@ -6083,6 +6331,8 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { } #[test] +/// 加载显式 Presentable 示例,检查视图的 source 字段仍指原 Product 对象。 +/// 改变 product.name 可响应式更新显示;另验证错误 Visual 覆盖在定义时拒绝,不破坏现有会话。 fn presentable_dispatch_preserves_source_identity_and_reactive_display() { let (mut session, initial) = ReplSession::load( include_str!("../../../examples/presentable.endor"), @@ -6137,6 +6387,8 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { } #[test] +/// 仅有同名且签名匹配的 present 方法不自动声明能力,ui.present 走回退且 marker 不增。 +/// 显式 implement 后 ui.open 才执行自定义方法;缺失实现的注册错误不改变已发布文档。 fn matching_present_method_does_not_implicitly_declare_presentable() { let mut session = ReplSession::new(231).unwrap(); session @@ -6195,6 +6447,8 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { } #[test] +/// 隐藏 rect 的 width 绑定先随模型更新,挂载到两个窗口后同一业务值更新两个窗口几何。 +/// 断开令牌后模型变化不再传递,直接写目标属性仍正常发布。 fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() { let mut session = ReplSession::new(220).unwrap(); session @@ -6247,6 +6501,8 @@ fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() } #[test] +/// 同一目标两个属性各绑定一个返回新 Record 的计算。 +/// 二者都应稳定为 value=7,不能因对象输出写入更新目标版本而相互触发无穷重算。 fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() { let mut session = ReplSession::new(222).unwrap(); session @@ -6281,6 +6537,8 @@ fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() { } #[test] +/// 可见和隐藏 rect 都绑定到读取全局 size 的函数,用 bindingEvaluations 比较精确失效。 +/// 无关 REPL 结果不重算,size 改变只各算一次;直接破坏一个输出仅重算该绑定并恢复正确值。 fn property_bindings_use_precise_names_and_repair_direct_output_changes() { let (mut session, _) = ReplSession::load( r#" @@ -6332,6 +6590,8 @@ page.render(rect) } #[test] +/// 加载 GC 示例后建立 Page/Block/等待协程之间的循环,先确认任务真的停在 await。 +/// 关闭页面后任务仍可独立存活;释放业务引用后对象数回基线、弱 Page/Block 失效,证明渲染缓存未额外持根。 fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { let (mut session, _) = ReplSession::load( include_str!("../../../examples/gc-lifecycle.endor"), @@ -6398,6 +6658,8 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { } #[test] +/// 仅保留绑定 token,删除隐藏目标并回收,目标弱句柄应失效且 token 断开。 +/// 另用会写模型的计算函数创建绑定,写入前只读保护失败并停止 VM,原 width 仍为 80。 fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { let mut session = ReplSession::new(221).unwrap(); session @@ -6473,6 +6735,8 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { } #[test] +/// 两个 textOf 绑定读取同一 counter,按钮调用源辅助函数再执行原生 increment。 +/// 点击后两条文字命令都必须更新为 10,覆盖跨源回调与原生写入的依赖传播。 fn native_method_callbacks_update_two_explicit_bindings() { let mut session = ReplSession::new(21).unwrap(); session.execute("counter = ui.state(9)\nfirst = ui.textOf(counter, \"value\")\nsecond = ui.textOf(counter, \"value\")\nbutton = ui.button(\"increment\")\nfunction increment(): Unit { counter.increment() }\nbutton.onClick(increment)\npage = ui.page()\npage.render(ui.row(0, 8, first, second, button))", "app.main").unwrap(); @@ -6494,6 +6758,8 @@ fn native_method_callbacks_update_two_explicit_bindings() { } #[test] +/// 保留 card 业务对象,关闭/重开页面确认隐藏内容修改仍可显示。 +/// 再次关闭并删除 Page 全局绑定后回收,Page 弱句柄应失效但 card 仍存在,子项不能反向强持有页面。 fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content() { let mut session = ReplSession::new(22).unwrap(); session @@ -6546,6 +6812,8 @@ fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content( } #[test] +/// 保存视觉对象 alias 并嵌入容器,给 card 赋新视觉值应沿共享引用更新嵌套出现位置且身份不变。 +/// 包含自身的替换被拒绝;unmount 后更新不发布,重新 render 使用最新值。 fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { let mut session = ReplSession::new(10).unwrap(); session.execute("card = ui.rect(10, 20)\nother = card\ncontainer = ui.column(0, 0, other)\npage = ui.page()\npage.render(container)", "app.main").unwrap(); @@ -6608,6 +6876,8 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { } #[test] +/// 创建文字但未挂载时只保留 VM 值,title 与最后结果 _ 都可跨输入读取。 +/// 挂载后引用替换自动更新画面,成功文档保留 Console transcript;无关查询不重复发布。 fn live_session_retains_objects_and_updates_only_after_mounting() { let mut session = ReplSession::new(7).unwrap(); let created = session @@ -6660,6 +6930,8 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { } #[test] +/// 编译期错误不能执行同输入前面的 value=2;运行期错误则保留已经发生的 value=3。 +/// 失败输入的待发布 render 被丢弃,后续显式 render 可恢复;另检查函数局部赋值正常工作。 fn live_session_compiles_before_execution_and_discards_failed_render() { let mut session = ReplSession::new(8).unwrap(); session @@ -6678,8 +6950,7 @@ fn live_session_compiles_before_execution_and_discards_failed_render() { let retained = session.execute("value", "app.main").unwrap(); assert_eq!(retained.value, "3"); assert!(retained.document.is_none()); - // Failed publication leaves the Page/bindings usable, but it can only - // become visible through a later successful explicit render. + // 发布失败后 Page/绑定仍可用,但失败输入的待显示内容不能自动发布,需后续成功的显式 render。 let recovered = session .execute("page.render(ui.text(\"recovered\"))", "app.main") .unwrap() @@ -6704,6 +6975,8 @@ fn live_session_compiles_before_execution_and_discards_failed_render() { } #[test] +/// 普通顶层 page 脚本直接经 Aster 执行,源码、结构树节点和原始行位置必须保留。 +/// 比较绘制几何及兼容 main 入口的命令等价性;两个独立 VM 的业务对象身份应不同。 fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { let document = compile(PAGE, "app.main", 9).unwrap(); assert_eq!(document.source, PAGE); @@ -6729,8 +7002,7 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { serde_json::to_value(&document.frame.commands).unwrap(), serde_json::to_value(&wrapped.frame.commands).unwrap() ); - // Separate VM instances render the same commands with independent - // managed object identities and presentation contexts. + // 不同 VM 可产生相同绘图命令,但其托管对象身份与展示上下文必须独立。 assert_ne!( document.frame.mounts[0].block_id, wrapped.frame.mounts[0].block_id @@ -6738,6 +7010,8 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { } #[test] +/// 提交含括号注释、辅助函数、多行赋值/调用及跨行成员链的完整源程序。 +/// 检查渲染成功与结构树起止行,避免用宿主按行切割或简单括号统计代替 Aster 语法解析。 fn script_helpers_multiline_bindings_calls_and_member_continuations_use_aster() { let source = r##"let color = "#242422" // comment with ( and ) function greeting(): Text { @@ -6770,6 +7044,8 @@ page } #[test] +/// 分别使用 LF/CRLF/CR,在前面含中文的脚本中制造 missing 引用。 +/// 诊断行列、字节 span 切片和提交来源都需对应原文;另检查语法错误的原始行号。 fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() { for ending in ["\n", "\r\n", "\r"] { let source = format!( @@ -6788,6 +7064,8 @@ fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() { } #[test] +/// 两个 Page 交替 render 后,把 first.render 保存成函数再调用。 +/// 绑定方法必须仍持有原接收者,最后一次 render 选择显示内容 last。 fn pages_bind_render_methods_and_last_render_selects_the_snapshot() { let source = r#"let first = ui.page() let second = ui.page() @@ -6803,6 +7081,8 @@ paint(ui.text("last"))"#; } #[test] +/// 先发布成功文档,再逐项提交无 render、错误 Page 参数、错误根和 render 后失败的源码。 +/// 每个候选都必须失败,已发布 source 与 revision 保持原值,不能出现部分帧。 fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { let mut state = NamespaceState { submission: 1, @@ -6830,6 +7110,8 @@ fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { } #[test] +/// 保留超过 MAX_PAGES 的页面引用后再请求显示,检查明确的 64 page budget 错误。 +/// 页面资源预算独立于最终画了多少节点,不能靠只显示一个页面绕过。 fn page_creation_has_a_separate_live_object_budget() { let mut source = String::new(); for index in 0..=ui_bridge::MAX_PAGES { @@ -6841,6 +7123,8 @@ fn page_creation_has_a_separate_live_object_budget() { } #[test] +/// 生成每层重复调用前一函数的指数展开计算,最后才创建文字。 +/// 即使尚未触及 UI 节点预算,指令总预算仍应终止计算并报告 instructions。 fn script_execution_is_limited_even_without_creating_ui_nodes() { let mut source = "function text0(): Text { return \"Hello Endor\" }\n".to_owned(); for index in 1..=16 { @@ -6861,6 +7145,8 @@ fn script_execution_is_limited_even_without_creating_ui_nodes() { } #[test] +/// 源 main 返回普通文字,再传给 ui.text 并挂载。 +/// 验证 VM 的字符串结果可正确被宿主解码成发布命令,而非要求它已经是视觉对象。 fn literal_string_return_from_aster_can_be_displayed_by_the_host() { let source = "function main(): Text { return \"Hello Endor\" }\nlet title = ui.text(main())\nui.page().render(title)"; let document = compile(source, "app.main", 1).unwrap(); @@ -6870,6 +7156,8 @@ fn literal_string_return_from_aster_can_be_displayed_by_the_host() { } #[test] +/// 为相同源码生成两个不同提交的结构树,开发工具先选中旧子节点。 +/// 接收新文档后旧 ID 不再有效,选择应回退到新树根,避免沿用旧提交位置。 fn developer_selection_resets_to_the_new_source_snapshot() { let first = compile(PAGE, "app.main", 1).unwrap(); let second = compile(PAGE, "app.main", 2).unwrap(); @@ -6884,6 +7172,8 @@ fn developer_selection_resets_to_the_new_source_snapshot() { } #[test] +/// 通过兼容模块/main 示例检查结构树、布局、文字实际测量和命令位置。 +/// compile 路径清理候选 VM 后,两种显示倍率仍能从拥有的数据生成显示列表,证明发布不依赖悬空 VM 句柄。 fn executes_aster_functions_and_renders_after_vm_cleanup() { let document = compile(EXAMPLE, "app.main", 1).unwrap(); assert_eq!(document.source, EXAMPLE); @@ -6912,13 +7202,14 @@ fn executes_aster_functions_and_renders_after_vm_cleanup() { &document.frame.commands[3], RenderCommand::Text { x, y: 168.0, .. } if (*x - (24.0 + first_width + 12.0 + 8.0 + 12.0)).abs() < 0.01 )); - // Candidate::close already proved zero remaining VM objects. Both - // redraws must still work from native data at different display scales. + // compile 已清理候选 VM;以下重绘只能使用发布的原生数据,两种显示倍率都不能再依赖 VM 对象。 assert_eq!(document.display_list(1.0).unwrap().commands().len(), 5); assert_eq!(document.display_list(2.0).unwrap().commands().len(), 5); } #[test] +/// 辅助函数接收借用 child 并把它放入 Row 两次。 +/// 编译完成后两个文字命令各自有正确几何,覆盖变量/返回值/共享子项之间的托管引用有效性。 fn borrowed_ui_handles_survive_variables_helpers_and_reused_children() { let source = r#"function wrap(child: View): View { return ui.row(4, 8, child, child) } function main(): View { let child = ui.text("shared") return wrap(child) }"#; @@ -6936,6 +7227,8 @@ function main(): View { let child = ui.text("shared") return wrap(child) }"#; } #[test] +/// 把同一模块示例换成 CRLF 或 CR,源快照必须逐字保留。 +/// 结构树的 main 起始行仍为 5,检查行定位对不同换行形式一致。 fn architecture_ranges_follow_crlf_and_cr_source_snapshots() { for ending in ["\r\n", "\r"] { let source = EXAMPLE.replace('\n', ending); @@ -6946,6 +7239,8 @@ fn architecture_ranges_follow_crlf_and_cr_source_snapshots() { } #[test] +/// 表驱动拒绝旧 namespace/scene 语法、缺失或有参 main、错误根类型和各类非法 UI 参数。 +/// 同时覆盖尺寸/字号/颜色预算与未知名称,确保兼容入口不会默默接受错误程序。 fn rejects_invalid_entry_objects_arguments_and_old_syntax() { for source in [ "namespace app.main export scene Main { render { Text(\"old\") } }", @@ -6971,6 +7266,8 @@ fn rejects_invalid_entry_objects_arguments_and_old_syntax() { } #[test] +/// 在中文字符串之后制造未知引用,检查 SourceDiagnostic 仍指向第三行和 submission 17。 +/// 避免宿主包装源代码后把错误定位成合成代码位置。 fn source_diagnostics_keep_unicode_locations_and_submission_origin() { let error = compile( "function main(): View {\n let title = ui.text(\"中文\")\n return missing\n}", @@ -6984,6 +7281,7 @@ fn source_diagnostics_keep_unicode_locations_and_submission_origin() { } #[test] +/// 无终止条件的 main 自递归必须被 VM 帧预算截断,报告 frames 而非让宿主栈溢出。 fn recursive_program_stops_at_the_vm_frame_limit() { let error = compile("function main(): View { return main() }", "app.main", 1).unwrap_err(); assert!( @@ -6993,6 +7291,8 @@ fn recursive_program_stops_at_the_vm_frame_limit() { ); } +/// 生成二叉视觉树程序。shared=true 每层只构造一个 child 再复用两次,false 则分别递归构造。 +/// 用于把业务对象数量增长和展示出现位置展开增长分开测试,最终由 main 返回最高层节点。 fn nested_functions(levels: usize, shared: bool) -> String { let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); for index in 1..=levels { @@ -7010,6 +7310,8 @@ fn nested_functions(levels: usize, shared: bool) -> String { } #[test] +/// 不共享子项的树先触发 4096 构造节点预算;共享树对象较少但展开出现位置更多。 +/// 后者需报告 expanded Block graph,证明两类预算分别约束对象分配和渲染规模。 fn construction_and_expanded_layout_have_separate_budgets() { let error = compile(&nested_functions(12, false), "app.main", 1).unwrap_err(); assert!(error.to_string().contains("4096 node budget"), "{error:#}"); @@ -7021,6 +7323,8 @@ fn construction_and_expanded_layout_have_separate_budgets() { } #[test] +/// 构造 64 层嵌套容器包住一个文字,完整编译后仍应有一条文字命令。 +/// 验证允许的深度不会因宿主遍历截断而悄悄丢掉叶节点。 fn managed_ui_depth_is_not_truncated_at_64_levels() { let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); for index in 1..=64 { @@ -7035,6 +7339,8 @@ fn managed_ui_depth_is_not_truncated_at_64_levels() { } #[test] +/// 直接测试 NamespaceState 的提交令牌:失败编译保留旧文档,新 submission 使旧候选失效。 +/// clear 后旧候选同样不能复活画面;随后新令牌的合法候选可继续递增 revision。 fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() { let mut state = NamespaceState::default(); state.submission = state.next_submission().unwrap(); @@ -7064,6 +7370,7 @@ fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() { ); } +// 退出 fixture 在两个窗口展示同一文字,以 log 统计每个出现位置的卸载,并保留一个待取消任务。 const EXIT_SOURCE: &str = r#" class ExitText: TextBlock { log: Object @@ -7082,6 +7389,7 @@ other.moveToWindow(second) pending = ui.sleep(ui,"pending",30000) "#; +/// 直接读取退出测试中的 log 托管状态,不通过 execute,因关闭后普通输入已经被禁止。 fn exit_log(session: &ReplSession) -> Value { let globals = session.candidate.globals.unwrap(); let Some(Value::Object(log)) = session @@ -7096,6 +7404,8 @@ fn exit_log(session: &ReplSession) -> Value { } #[test] +/// 两个窗口共享文字并保有 pending Task,shutdown 返回窗口/Mount/任务清理统计。 +/// 两个出现位置各调用一次 onUnmount,重复 shutdown 不再调用,关闭后的 execute 被拒绝。 fn shutdown_unmounts_all_windows_once_and_cancels_tasks() { let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 701, 1).unwrap(); assert_eq!(exit_log(&session), Value::Number(0_i64.into())); @@ -7110,6 +7420,8 @@ fn shutdown_unmounts_all_windows_once_and_cancels_tasks() { } #[test] +/// 手动将会话标为 faulted 后 shutdown,资源仍需清理但 log 保持 0。 +/// 验证停止域的退出清理不能借生命周期回调重新执行源代码。 fn shutdown_stopped_runtime_never_reenters_source_hooks() { let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 702, 1).unwrap(); session.faulted = true; @@ -7118,6 +7430,8 @@ fn shutdown_stopped_runtime_never_reenters_source_hooks() { } #[test] +/// 把退出 onUnmount 改成启动 sleep;关闭期间必须拒绝新任务并报告 exiting 诊断。 +/// 错误使运行时 fault,避免退出清理反而产生无法结束的后台工作。 fn shutdown_hooks_cannot_create_new_background_work() { let source = EXIT_SOURCE.replace("self.log.increment()", "ui.sleep(ui,\"late\",30000)"); let (mut session, _) = ReplSession::load(&source, "app.main", 703, 1).unwrap(); @@ -7127,6 +7441,8 @@ fn shutdown_hooks_cannot_create_new_background_work() { } #[test] +/// 以有限 400x300 视口比较 window/vertical/horizontal:普通页受窗口限制,延伸页仅沿声明轴增长。 +/// append 新子项后延伸尺寸连同 gap 实时更新,覆盖页面布局模式的旧兼容入口。 fn page_modes_grow_on_the_declared_axis_and_follow_live_children() { for (mode, expected) in [ ("window", (400.0, 300.0)), @@ -7173,6 +7489,8 @@ page.append(RectBlock(450,350))"# } #[test] +/// Canvas 中两次使用同一按钮,业务 ID 相同但 Mount ID 不同,均接受带符号世界坐标。 +/// 修改位置更新两处;切到另一普通页再回来,原 Page 的 pan/zoom 与页面身份仍保留。 fn canvas_positions_are_live_signed_and_shared_without_copying_objects() { let (mut session, initial) = ReplSession::load_with_viewport( r#" @@ -7240,6 +7558,8 @@ page.zoom=2 } #[test] +/// 通过 ui.newTab 加载内置起始页并确认二维视口存在。 +/// 逐个按画面文字位置点击示例入口,检查它们都使用 Window 表面、仅延伸示例带 ScrollBlock,再用真实 tab-activate 回到首页。 fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { let mut session = ReplSession::new_with_viewport( 302, diff --git a/src/runtime/endor_script.rs b/src/runtime/endor_script.rs index c0fbc46..11cc370 100644 --- a/src/runtime/endor_script.rs +++ b/src/runtime/endor_script.rs @@ -26,22 +26,26 @@ impl SourcePlan { } } -/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。 +/// 优先解析完整模块,否则按可完成的交互输入分段为 Script,并将 AST/诊断位置映回原文。 pub fn parse(source: &SourceUnit) -> Result { + // 完整模块优先;只有模块解析失败才退回连续交互输入模式,宿主不自行实现 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']) @@ -58,25 +62,30 @@ pub fn parse(source: &SourceUnit) -> Result { 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)?; @@ -84,8 +93,9 @@ pub fn parse(source: &SourceUnit) -> Result { Ok(SourcePlan::Script(roots)) } -/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。 +/// 为单个半开字节范围加回片段起始偏移,不处理其所属节点的子节点。 fn shift_span(span: &mut Span, offset: usize) { + // span 采用原文本字节偏移,start/end 同增 offset 保持长度;不是按字符数或行数偏移。 span.start += offset; span.end += offset; } @@ -93,6 +103,7 @@ fn shift_span(span: &mut Span, offset: usize) { /// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。 fn shift_input(input: &mut InteractiveInput, offset: usize) { shift_span(&mut input.span, offset); + // 根范围之外,逐条语句递归处理所有内层节点,避免错误只在顶层位置正确。 for statement in &mut input.statements { shift_statement(statement, offset); } @@ -112,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); @@ -134,6 +146,7 @@ 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); @@ -157,6 +170,7 @@ 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); @@ -172,6 +186,7 @@ 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) => { @@ -185,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 { diff --git a/src/runtime/event_dispatch.rs b/src/runtime/event_dispatch.rs index ad574e4..aa0ce51 100644 --- a/src/runtime/event_dispatch.rs +++ b/src/runtime/event_dispatch.rs @@ -1,5 +1,5 @@ //! 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。 -//! 分发沿出现关系执行 capture → target → bubble,并处理默认行为;只有同一 VM 中的回调可以接触托管对象。 +//! 分发沿出现关系执行 capture → target → bubble,记录默认行为阻止状态;只有同一 VM 中的回调可以接触托管对象。 //! //! Managed subscriptions and occurrence-specific propagation. A validated VM //! function sequences calls, so all handlers share one execution budget. @@ -9,6 +9,7 @@ use endor_core::{ Value, Vm, VmError, WeakObjectRef, }; +// 一次分发最多安排 10000 个处理器,另由共享 VM 驱动约束全部回调的总执行预算。 const MAX_DELIVERIES: usize = 10_000; /// 统一构造本模块的参数错误,让调用方停止处理无效输入。 fn invalid(message: &'static str) -> VmError { @@ -26,6 +27,7 @@ pub fn validate_handler( let arity = store .call_arity(function)? .ok_or(invalid("event handler requires positional metadata"))?; + // 允许无参便捷回调或单 Event 回调;实际递送时优先传 Event(若接受一参),否则零参调用。 if !arity.accepts(0) && !arity.accepts(1) { return Err(invalid( "event handler must accept zero arguments or one Event", @@ -37,10 +39,15 @@ pub fn validate_handler( #[derive(Debug, Clone, Default)] /// 本次传播的托管事件状态,包含目标、当前阶段和阻止默认/停止传播控制。 pub struct Event { + // 弱引用本轮 guard,检测同一 Event 是否正在分发;驱动释放后 guard 消失,事件可再次使用。 dispatch: Option, + // 当前传播组,一般对应路径中的一个出现/阶段;目标捕获与目标非捕获共用一组。 group: usize, + // stopPropagation 记下当前组,只阻止后续其他组;同组余下处理器仍执行。 stopped_at: Option, + // stopImmediatePropagation 全局跳过后续 Delivery,连当前组剩余处理器也停止。 immediate: bool, + // preventDefault 仅记录默认行为是否被阻止;本模块不在这里实现具体的系统默认动作。 prevented: bool, } impl ObjectPayload for Event { @@ -122,12 +129,12 @@ enum Method { Connected, } impl NativeCallable for Method { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 事件与订阅查询/控制方法无显式参数,只接受隐式 Event 或 Subscription 接收者。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 按操作读取或修改连接/传播状态;断开清除真实 handler/compute,停止标志写回同一 Event。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let [Value::Object(owner)] = args else { return Err(invalid("event method receiver")); @@ -139,6 +146,7 @@ impl NativeCallable for Method { .payload::(*owner, "Subscription")? .connection; let connection = cx.upgrade(weak)?; + // Subscription 同时包装事件 Connection 和属性 Binding;按 payload 类型清 handler 或 compute。 let connected = if let Some(connection) = connection { if cx .store() @@ -152,6 +160,7 @@ impl NativeCallable for Method { .payload::(connection, "Binding")? .clone(); let connected = data.compute.is_some(); + // 取消切断真实强拥有边,外部仍可保留 token,但 token 弱引用本身不保活回调或目标。 if matches!(self, Self::Disconnect) { data.compute = None; cx.replace_payload(connection, data)?; @@ -210,16 +219,17 @@ impl NativeCallable for Constructor { fn signature(&self) -> Option { self.signature.clone() } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 至少接收隐式请求 Type 和事件 kind,额外参数留给源子类声明字段。 fn arity(&self) -> Option { Some(endor_core::CallArity::range(2, usize::MAX)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 先验证事件子类字段数量与注解,再创建原生 Event 并填充声明字段,错误不得留下部分实例。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let [Value::Object(definition), Value::Text(kind), values @ ..] = args else { return Err(invalid("Event requires an event kind")); }; + // 事件子类构造先验证额外字段参数数量与注解,再创建默认事件并逐字段初始化。 let fields = cx .store() .payload::(*definition, "Type")? @@ -250,6 +260,7 @@ pub fn new_event( let event = cx.allocate(Event::default())?; cx.attach_type(event, definition)?; cx.set_attr(event, "kind", Value::Text(kind.into()))?; + // 新事件尚未递送,目标、出现与 hit 都为空;kind 保留输入种类,phase 初始 none。 for name in ["target", "mount", "currentTarget", "currentMount", "hit"] { cx.set_attr(event, name, Value::Nil)?; } @@ -268,7 +279,7 @@ impl NativeCallable for TypedMethod { fn signature(&self) -> Option { Some(self.signature.clone()) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 把已声明签名的调用交给内部 Method,复用事件/订阅控制实现,不创建第二套状态。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { self.method.invoke(cx, args) } @@ -294,6 +305,7 @@ pub fn install( (token_type, "disconnect", Method::Disconnect), (token_type, "isConnected", Method::Connected), ] { + // isDefaultPrevented/isConnected 返回 Bool 且只读;控制传播和取消方法返回 Unit 并声明 Mutating。 let query = matches!(method, Method::Prevented | Method::Connected); let function = vm.allocate(ContextNativeFunction::new( name, @@ -340,18 +352,20 @@ struct Delivery { handler: Handler, } impl NativeCallable for Delivery { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 单次递送步骤的 Event/handler/阶段已保存在适配器中,执行时不再接收参数。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 轮到该处理器时重查传播状态和连接是否仍有效,更新当前阶段/目标,再按真实回调 arity 发出 VM 调用。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { let mut event = cx.store().payload::(self.event, "Event")?.clone(); + // 真正轮到处理器时重新读取传播状态,之前回调的 stop 操作立即影响后续项,无需重建序列。 if event.immediate || event.stopped_at.is_some_and(|group| group != self.group) { return Ok(NativeResult::Return(Value::Nil)); } let (function, mut arguments) = match self.handler { + // 序列只捕获 Connection,不快照 handler;前面处理器取消它后,这里看到 None 并跳过。 Handler::Token(token) => { let Some(handler) = cx .store() @@ -363,6 +377,7 @@ impl NativeCallable for Delivery { (handler, vec![]) } Handler::Legacy(function) => (function, vec![]), + // 对象协议在执行到该项时解析实际类型的 onEvent;显式 receiver 先放入实参向量。 Handler::ObjectMethod => { let Some(definition) = cx.store().type_of(self.owner)? else { return Err(invalid("untyped event target")); @@ -374,6 +389,7 @@ impl NativeCallable for Delivery { (function, vec![Value::Object(self.owner)]) } }; + // 调用前更新 currentTarget/currentMount/phase,处理器观察到的是当前路径项,不会误用最终目标。 event.group = self.group; cx.replace_payload(self.event, event)?; cx.set_attr(self.event, "phase", Value::Text(self.phase.into()))?; @@ -383,6 +399,7 @@ impl NativeCallable for Delivery { "currentMount", self.mount.map(Value::Object).unwrap_or(Value::Nil), )?; + // 对象方法总是携带 Event;显式订阅/旧式回调按 arity 决定是否传 Event。 if matches!(self.handler, Handler::ObjectMethod) || cx .store() @@ -398,17 +415,18 @@ impl NativeCallable for Delivery { } } #[derive(Debug)] -/// 传播结束后的默认行为收尾步骤。 +/// 正常传播结束后清理当前目标、阶段和 guard 标记,返回仍保留最终控制状态的 Event。 struct Finish(ObjectRef); impl NativeCallable for Finish { - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 派发结束步骤已持有 Event 引用,无需显式输入。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 正常序列结束时清除活跃派发守卫与当前上下文,保留 target/prevented 并返回实际 Event,允许后续重用。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { let mut event = cx.store().payload::(self.0, "Event")?.clone(); + // 正常序列结束清递送标记与当前上下文,保留原始 target/mount 和 prevented 供调用方读取。 event.dispatch = None; cx.replace_payload(self.0, event)?; cx.set_attr(self.0, "phase", Value::Text("none".into()))?; @@ -421,7 +439,7 @@ impl NativeCallable for Finish { /// Snapshot the selected occurrence's route and listener order. Disconnection /// before a listener's turn skips it; newly registered listeners wait for the /// next dispatch. A code event without a Mount visits only its target object. -/// 按 Mount 祖先路径执行捕获、目标与冒泡阶段,再处理对应默认行为。 +/// 快照 Mount 路径和订阅顺序,构造共享预算的 capture/target/bubble 驱动并返回 VM 续调。 pub fn dispatch( cx: &mut NativeContext<'_>, owner: ObjectRef, @@ -430,6 +448,7 @@ pub fn dispatch( desktop: ObjectRef, ) -> Result { let mut event = cx.store().payload::(event_ref, "Event")?.clone(); + // 只有 guard 仍存活才禁止重入;此前分发失败但 guard 已释放时,不让旧弱身份永久锁住 Event。 if let Some(weak) = event.dispatch && cx.upgrade(weak)?.is_some() { @@ -441,13 +460,16 @@ pub fn dispatch( if kind.is_empty() || kind.len() > 128 { return Err(invalid("invalid event kind")); } + // 路线由目标向外保存。无 Mount 的代码事件只访问目标对象,不猜测共享 Block 属于哪个出现。 let mut route = vec![(owner, mount)]; if let Some(mount) = mount { let target = cx.store().payload::(mount, "Mount")?.clone(); + // 传入 Mount 必须仍挂接且确实展示 owner;同一 Block 的另一个出现不能替代该上下文。 if !target.mounted || target.block != owner { return Err(invalid("event context does not belong to this Block")); } let mut parent = target.parent; + // 沿弱 parent 链构建本轮路径快照,检查死亡祖先、脱离与环;不通过 Block 业务孩子关系反推祖先。 let mut seen = std::collections::HashSet::from([mount]); while let Some(weak) = parent { let ancestor = cx @@ -463,15 +485,18 @@ pub fn dispatch( route.push((data.block, Some(ancestor))); parent = data.parent; } + // 出现祖先之外再加入 Page 上下文,页面本身无 currentMount;弱页已失效则拒绝该旧路线。 if let Some(weak) = target.page { let page = cx.upgrade(weak)?.ok_or(invalid("event Page has expired"))?; route.push((page, None)); } } + // 所有递送和 Finish 显式捕获 guard,Event 只弱引用它;驱动完成/失败释放后 guard 自动结束本轮资格。 let guard = cx.new_object()?; let mut calls = Vec::new(); let mut group = 0; // Each occurrence is its own propagation group, even for shared Blocks. + // 先从最外层向目标递送捕获,再从目标向外递送非捕获;目标两次遍历都标记 phase=target。 let traversal = route .iter() .enumerate() @@ -486,6 +511,7 @@ pub fn dispatch( for (index, &(block, current_mount), capture) in traversal { let (_, data) = block::resolve(cx.store(), block)?; let mut handlers = Vec::new(); + // 本轮先快照符合事件 kind 与 capture 的订阅顺序;回调期间新注册的连接不进入当前分发。 for token in &data.subscriptions { let token_data = cx.store().payload::(*token, "Connection")?; if token_data.handler.is_some() @@ -496,6 +522,7 @@ pub fn dispatch( } } if !capture { + // 非捕获阶段 onEvent 先执行,再按订阅顺序,最后兼容旧 click 回调。 handlers.insert(0, Handler::ObjectMethod); if kind == "click" && let Some(handler) = data.click @@ -507,6 +534,7 @@ pub fn dispatch( if calls.len() >= MAX_DELIVERIES { return Err(invalid("event delivery budget exceeded")); } + // 每一项显式保活本轮 Event、guard、目标和可选 Mount/连接;不能仅靠 Rust ObjectRef 值当 GC 根。 let mut captures = vec![event_ref, guard, block]; if let Some(mount) = current_mount { captures.push(mount); @@ -538,6 +566,7 @@ pub fn dispatch( } // Capture and bubble at the target are one group: stopPropagation at // target capture still permits other target listeners. + // 目标捕获后不递增 group,使 stopPropagation 仍允许目标非捕获处理器;其他路径/阶段则隔离为新组。 if index != 0 || !capture { group += 1; } @@ -547,10 +576,12 @@ pub fn dispatch( Finish(event_ref), vec![event_ref, guard], ))?; + // 无论传播是否被停止,Finish 都作为序列最后一项执行,它不受 Delivery 的 stopped 判定影响。 calls.push(finish); let driver = crate::vm_sequence::build(cx, calls, "", desktop)?; event.dispatch = Some(cx.store().downgrade(guard)?); event.group = 0; + // 再次使用 Event 时清上一轮停止和默认阻止标志;目标与 Mount 属性也被本轮参数覆盖。 event.stopped_at = None; event.immediate = false; event.prevented = false; diff --git a/src/runtime/events.rs b/src/runtime/events.rs index d211035..d097887 100644 --- a/src/runtime/events.rs +++ b/src/runtime/events.rs @@ -21,6 +21,7 @@ use winit::event_loop::EventLoopProxy; /// 拖放目标窗口、目标布局版本和插入位置,源标签身份由外层 Input 保存。 pub struct TabDrop { pub window: u64, + // 事件所依据的已发布布局版本,由执行端校验过期帧;原生连续手势有单独刷新逻辑。 pub layout_version: u64, pub index: usize, } @@ -31,15 +32,19 @@ pub struct TabDrop { pub struct Input { #[serde(default = "default_window")] pub window: u64, + // 产生事件的运行时会话;重载后即使窗口或 Mount 数值相同也不能沿用旧事件。 pub session: u64, pub layout_version: u64, + // 捕获的具体出现身份;页面级相机事件使用零,独立视口相机事件要求非零。 pub mount: u64, pub kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] + // 事件位置或缩放锚点;click 与 press_point 配对,具体坐标语义由事件 kind 决定。 pub point: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] pub press_point: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] + // 平移/滚动增量,或缩放的 [倍率,0];不能无差别接受任意事件携带该字段。 pub delta: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tab_drop: Option, @@ -53,11 +58,12 @@ fn default_window() -> u64 { } impl Input { - /// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。 + /// 按事件种类核对可携带字段、坐标范围和 Mount 是否为零;不替代执行端的会话/能力校验。 pub fn validate(&self) -> Result<(), &'static str> { if self.window == 0 { return Err("window identity must be nonzero"); } + // 标签跨窗口放置仅接受目标元信息;禁止同时夹带指针/滚动/屏幕位置,避免混用两套操作。 if self.kind == "tab-drop" { let Some(target) = &self.tab_drop else { return Err("tab-drop requires target metadata"); @@ -73,9 +79,11 @@ impl Input { } return Ok(()); } + // 非 tab-drop 不得携带目标窗口信息,即使其他字段恰好合法也拒绝。 if self.tab_drop.is_some() { return Err("tab_drop is only accepted for tab-drop"); } + // 屏幕位置只服务 tab-detach 的新窗口定位,允许负数以支持左侧/上方显示器,但限制绝对范围。 if let Some(position) = self.screen_position && (self.kind != "tab-detach" || !position @@ -94,7 +102,9 @@ impl Input { | "scene-pan" | "scene-zoom" ) { + // 独立视口输入可包含负的局部位置(例如捕获拖动移出视口);页面级锚点要求非负。 let viewport = self.kind.starts_with("viewport-") || self.kind.starts_with("scene-"); + // 所有相机操作必须带有限、有界的增量;point 在非缩放操作可省略。 let valid_delta = self.delta.is_some_and(|delta| { delta .iter() @@ -117,6 +127,7 @@ impl Input { { return Err("invalid page camera input"); } + // 缩放必须给锚点并使用正倍率 0.1..=10;第二分量必须为零,不把缩放与二维平移混合编码。 if self.kind.ends_with("-zoom") && (self.point.is_none() || self @@ -127,6 +138,7 @@ impl Input { } return Ok(()); } + // 滚动需要当前点与二维有限增量,不接受按下点;Mount 能力与身份由执行端进一步核对。 if self.kind == "scroll" { return match (self.point, self.press_point, self.delta) { (Some(point), None, Some(delta)) @@ -145,6 +157,8 @@ impl Input { if self.delta.is_some() { return Err("delta is only accepted for scroll input"); } + // 无指针坐标的离散事件可通过此结构校验;若有指针信息,必须是 click 的按下/释放成对坐标。 + // 这里不做完整 kind 白名单授权,具体操作仍由后续分发层检查。 match (self.point, self.press_point) { (None, None) => Ok(()), (Some(point), Some(press)) @@ -165,15 +179,17 @@ impl Input { /// 原生输入的有界发送端,后台线程串行发往本地 API;所有窗口共享权威发布状态。 pub struct Client(SyncSender); impl Client { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 建立容量 64 的发送队列和单个后台 HTTP 线程;线程创建失败则返回错误。 pub fn new( proxy: EventLoopProxy, namespace: Arc>, ) -> anyhow::Result { + // 队列最多容纳 64 项;原生发送端使用 try_send,不阻塞 GUI 事件线程,也不保证满队列时无损。 let (sender, receiver) = sync_channel::(64); thread::Builder::new() .name("tessera-native-events".into()) .spawn(move || { + // 后台线程按队列顺序逐个发送 HTTP 请求;命名空间锁只覆盖检查,不跨越网络等待。 for mut input in receiver { if let Ok(current) = namespace.read() { if !refresh_continuous_input(&mut input, ¤t) { @@ -182,6 +198,7 @@ impl Client { } else { continue; } + // 传输/服务错误通过事件代理回 GUI;代理已关闭时忽略通知失败,接收端退出后线程自然结束。 if let Err(error) = send(&input) { let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed { message: error.to_string(), @@ -191,8 +208,9 @@ impl Client { })?; Ok(Self(sender)) } - /// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。 + /// 非阻塞尝试入队;成功仅代表队列接收,满队列或后台线程关闭返回 false。 pub fn send(&self, input: Input) -> bool { + // 只反馈是否成功入队;队列满或接收端关闭都返回 false,不代表 worker 已接受或执行事件。 self.0.try_send(input).is_ok() } } @@ -208,6 +226,7 @@ fn refresh_continuous_input(input: &mut Input, current: &crate::document::Namesp ) { return true; } + // 选取事件原窗口的权威帧;窗口已经消失时丢弃,不能把手势改投主窗口。 let frame = if input.window == crate::window::MAIN_WINDOW { Some(¤t.document.frame) } else { @@ -220,6 +239,7 @@ fn refresh_continuous_input(input: &mut Input, current: &crate::document::Namesp let Some(frame) = frame else { return false; }; + // 必须仍属同一运行时会话、帧无故障且原捕获 Mount 具备对应能力,才可更新布局版本。 if current.runtime_session != input.session || frame.session != input.session || frame.fault.is_some() @@ -236,12 +256,14 @@ fn refresh_continuous_input(input: &mut Input, current: &crate::document::Namesp { return false; } + // 只追随新版本,不改 session/window/mount,不重选目标;每条排队增量仍独立发送。 input.layout_version = frame.layout_version; true } /// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。 fn send(input: &Input) -> anyhow::Result<()> { + // 固定回环 API 的轻量客户端,使用 Content-Length 和 Connection: close 定界;不连接外部地址。 let body = serde_json::to_string(input)?; let mut stream = TcpStream::connect_timeout(&crate::api::API_ADDRESS.parse()?, Duration::from_secs(2))?; @@ -254,9 +276,11 @@ fn send(input: &Input) -> anyhow::Result<()> { body )?; let mut response = String::new(); + // 响应读取最多 32 KiB,避免错误正文无限增长;状态从 HTTP 状态行提取,不实现通用 HTTP 客户端。 stream.take(32768).read_to_string(&mut response)?; let status = response.split_whitespace().nth(1).unwrap_or(""); // An old frame's event is deliberately discarded, with no automatic retry. + // 409 代表过期帧冲突,按输入协议静默丢弃,不重试离散动作,避免重复点击或跨版本误操作。 if status == "409" { return Ok(()); } @@ -277,6 +301,7 @@ fn send(input: &Input) -> anyhow::Result<()> { mod tests { use super::*; #[test] + /// 重复消费旧帧捕获的连续 pan,允许刷新到当前版本且保留每条 delta;离散 click 不改版本,会话/目标替换拒绝手势。 fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() { let (mut session, document) = crate::endor_host::ReplSession::load( "view=CanvasViewport(200,100,Scene2D(RectBlock(20,20,0)))\npage=Page()\npage.render(view)", @@ -306,6 +331,7 @@ mod tests { document, ..Default::default() }; + // 反复从旧帧原始手势克隆输入,验证每条增量都被刷新版本并累计,12×3 应得到 36。 for _ in 0..12 { let mut queued = original.clone(); assert!(refresh_continuous_input(&mut queued, ¤t)); @@ -315,6 +341,7 @@ mod tests { session.execute("view.camera.x", "app.main").unwrap().value, "36" ); + // 离散点击不得自动刷新版本;随后验证会话替换和目标移除都会拒绝连续手势。 let mut click = original.clone(); click.kind = "click".into(); assert!(refresh_continuous_input(&mut click, ¤t)); @@ -326,6 +353,7 @@ mod tests { assert!(!refresh_continuous_input(&mut original.clone(), ¤t)); } #[test] + /// 对 tab-drop 元数据、位置上限与 tab-detach 桌面坐标逐项改坏,检查辅助字段只在对应事件类型和预算内合法。 fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() { let mut input:Input=serde_json::from_str(r#"{"window":1,"session":1,"layout_version":2,"mount":3,"kind":"tab-drop","tab_drop":{"window":2,"layout_version":4,"index":1}}"#).unwrap(); assert!(input.validate().is_ok()); @@ -343,6 +371,7 @@ mod tests { } #[test] + /// 旧 JSON 未带 window 时默认主窗口以保持兼容;显式 window=0 仍是非法资源,不能当成默认值。 fn legacy_event_window_defaults_but_zero_is_never_a_resource() { let mut input: Input = serde_json::from_str(r#"{"session":1,"layout_version":2,"mount":3,"kind":"focus"}"#) diff --git a/src/runtime/io_tasks.rs b/src/runtime/io_tasks.rs index 5f0a3d4..d1c0046 100644 --- a/src/runtime/io_tasks.rs +++ b/src/runtime/io_tasks.rs @@ -18,7 +18,9 @@ use std::{ time::Duration, }; +// 文件与 HTTP 返回文本最多 1 MiB,最终必须为有效 UTF-8;长度按字节计算。 const MAX_TEXT: usize = 1024 * 1024; +// 每个运行时的租约额度,独立于全进程 4 个执行线程和 64 项工作队列。 const MAX_ACTIVE: usize = 16; const WORKERS: usize = 4; const QUEUE: usize = 64; @@ -28,6 +30,7 @@ type Completion = Result; /// provider 共享的就绪结果与并发计数;内容只有纯数据和弱任务身份。 struct Shared { ready: Mutex>, + // 统计当前活租约而非正在执行的 OS 操作;完成结果被取走前,租约仍可能占用名额。 active: AtomicUsize, } #[derive(Clone, Debug, Default)] @@ -44,11 +47,13 @@ struct Control { impl Control { /// 撤销当前操作的后续交付资格,并执行对应取消清理。 fn cancel(&self) { + // 先撤销结果交付资格,再尝试终止当前子进程;文件读取无法用此标记中断,只能丢弃晚到结果。 self.cancelled.store(true, Ordering::Release); if let Ok(mut process) = self.process.lock() && let Some(child) = process.as_mut() { // Reaping belongs to the provider worker, never the VM thread. + // VM/租约线程这里只 kill,不 wait;子进程回收由持有 Reaper 的 I/O worker 完成,避免阻塞 VM。 let _ = child.kill(); } } @@ -68,13 +73,14 @@ pub struct Lease { control: Arc, } impl Drop for Lease { - /// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。 + /// 最后一个 Arc 释放时撤销交付资格、删除待交付结果并归还一次并发额度;后台 Job 不延长租约。 fn drop(&mut self) { // 租约释放立即取消交付资格;后台 OS 读取即使稍后结束,也不能再次唤醒旧 Task。 self.control.cancel(); if let Ok(mut ready) = self.shared.ready.lock() { ready.remove(&self.task); } + // Lease 包在 Arc 中,只有最后一个拥有者释放才析构并归还一次额度;Job 不持有 Lease。 self.shared.active.fetch_sub(1, Ordering::AcqRel); } } @@ -93,18 +99,22 @@ impl Provider { request: Request, replacing: bool, ) -> Result, VmError> { + // 原子更新同时完成检查和预占,防止并发提交都观察到剩余名额而超配。 + // replacing 允许替换期间临时多一个租约,旧任务取消后回到正常上限。 self.shared .active .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { (active < MAX_ACTIVE + usize::from(replacing)).then_some(active + 1) }) .map_err(|_| VmError::ExecutionLimit("16 I/O tasks per runtime"))?; + // 预占后立即建立 RAII 租约;线程池不可用或队列发送失败时,局部 lease 析构会归还名额。 let lease = Arc::new(Lease { shared: self.shared.clone(), task, control: Arc::new(Control::default()), }); let sender = pool().map_err(|_| VmError::InvalidArgument("I/O provider unavailable"))?; + // 非阻塞入队,不让调用 VM 等待后台吞吐;Job 只带参数、弱任务身份和原生取消/结果状态。 sender .try_send(Job { request, @@ -115,7 +125,8 @@ impl Provider { .map_err(|_| VmError::ExecutionLimit("I/O provider queue full or unavailable"))?; Ok(lease) } - /// 取走指定弱任务身份的 provider 完成结果;实际托管写入仍由 VM worker 执行。 + /// 一次取走当前邮箱内全部 (弱任务身份, 纯文本结果),实际托管交付仍由 VM worker 校验后执行。 + // 一次 drain 所有已就绪结果;移走纯数据不代表任务仍可交付,VM worker 还需检查弱身份和归属。 pub fn take_ready(&self) -> Result, VmError> { Ok(self .shared @@ -128,6 +139,7 @@ impl Provider { } /// 懒初始化共享工作线程和有界任务队列;初始化失败反馈给提交方。 fn pool() -> Result<&'static SyncSender, &'static str> { + // 全进程只初始化一次,初始化失败也会记住失败结果;后续 submit 返回 unavailable,不反复创建线程。 static POOL: OnceLock, String>> = OnceLock::new(); POOL.get_or_init(|| { let (sender, receiver) = sync_channel::(QUEUE); @@ -138,6 +150,7 @@ fn pool() -> Result<&'static SyncSender, &'static str> { .name(format!("tessera-io-{index}")) .spawn(move || { loop { + // std mpsc Receiver 通过 Mutex 共享;锁只覆盖取任务,实际 I/O 在离开该 match 后并行执行。 let job = match receiver.lock() { Ok(receiver) => receiver.recv(), Err(_) => break, @@ -145,6 +158,7 @@ fn pool() -> Result<&'static SyncSender, &'static str> { let Ok(job) = job else { break; }; + // 执行前先跳过已取消队列项,避免启动无用文件读取或 curl。 if job.control.cancelled.load(Ordering::Acquire) { continue; } @@ -152,6 +166,7 @@ fn pool() -> Result<&'static SyncSender, &'static str> { Request::ReadText(path) => read_text(path), Request::FetchText(url) => fetch_text(url, &job.control), }; + // 持有 ready 锁后再次检查取消,与 Lease::drop 的取消/移除配合,防止取消后重新留下晚到结果。 if let Ok(mut ready) = job.shared.ready.lock() && !job.control.cancelled.load(Ordering::Acquire) { @@ -168,6 +183,7 @@ fn pool() -> Result<&'static SyncSender, &'static str> { } /// 读取受字节上限限制的 UTF-8 文件内容,错误作为任务结果交付。 fn read_text(path: &str) -> Completion { + // 先按路径检查普通文件和已知长度,及早拒绝目录/超大源;实际打开后还会复核文件类型。 let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?; if !metadata.is_file() { return Err("text source must be a regular file".into()); @@ -184,6 +200,7 @@ fn read_text(path: &str) -> Completion { return Err("text source must be a regular file".into()); } let mut bytes = Vec::new(); + // 再用流读取硬上限检测增长中的文件:多读一个字节即可判断超限,不能只依赖之前 metadata 长度。 file.take(MAX_TEXT as u64 + 1) .read_to_end(&mut bytes) .map_err(|error| error.to_string())?; @@ -197,6 +214,7 @@ fn read_text(path: &str) -> Completion { /// Authentication/custom headers are deliberately outside this text GET API. /// 检查网络请求的 URL 协议、长度与凭据等约束,拒绝不受支持的输入。 pub fn valid_url(url: &str) -> bool { + // 控制字符必须在写 curl config 前拒绝,否则换行等可能破坏单个 url 配置项边界。 if url.len() > 8192 || url.chars().any(char::is_control) { return false; } @@ -206,6 +224,8 @@ pub fn valid_url(url: &str) -> bool { if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { return false; } + // 仅检查协议与 authority 基本约束;@ 禁止 userinfo 凭据,空白禁止模糊主机部分。 + // 这不是完整 URL 解析器,具体 HTTP 解析仍由 curl 负责。 let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); !authority.is_empty() && !authority.contains('@') && !authority.chars().any(char::is_whitespace) } @@ -214,8 +234,9 @@ pub fn valid_url(url: &str) -> bool { /// 子进程作用域收尾保护,避免取消/失败路径遗留未回收进程。 struct Reaper<'a>(&'a Control); impl Drop for Reaper<'_> { - /// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。 + /// 取走残留 curl 子进程后尝试 kill/wait,保证失败或取消路径也回收进程,且不持控制锁等待。 fn drop(&mut self) { + // 从控制槽取出子进程后释放锁,再 kill/wait;只有实际留下的进程才需要回收。 let child = self.0.process.lock().ok().and_then(|mut slot| slot.take()); if let Some(mut child) = child { let _ = child.kill(); @@ -233,6 +254,8 @@ fn fetch_text(url: &str, control: &Control) -> Completion { #[cfg(not(unix))] let executable = "curl.exe"; let mut child = Command::new(executable) + // --disable 放首项关闭 curlrc,--globoff 禁用 URL 展开,首次请求与重定向均限制 HTTP(S)。 + // 限制重定向数、连接/总超时和下载大小;--compressed 后仍会用下面字节读取上限限制解压文本。 .args([ "--disable", "--silent", @@ -263,6 +286,7 @@ fn fetch_text(url: &str, control: &Control) -> Completion { .stderr(Stdio::null()) .spawn() .map_err(|_| "HTTP transport unavailable: system curl is required".to_owned())?; + // 取走管道后把 Child 放入共享 Control,取消线程可随时 kill;Reaper 覆盖其后的所有提前返回。 let mut input = child.stdin.take().expect("piped stdin"); let output = child.stdout.take().expect("piped stdout"); match control.process.lock() { @@ -278,17 +302,21 @@ fn fetch_text(url: &str, control: &Control) -> Completion { return Err("request cancelled".into()); } // URL stays out of process arguments. Reject controls before config escaping. + // URL 经 curl 配置的引号/反斜杠转义后走 stdin,避免出现在进程参数;这里不经过 shell。 let escaped = url.replace('\\', "\\\\").replace('"', "\\\""); writeln!(input, "url = \"{escaped}\"").map_err(|_| "HTTP transport input failed".to_owned())?; + // 关闭 stdin 表示配置结束,否则 curl 可能继续等待输入,无法开始/完成请求。 drop(input); let mut bytes = Vec::new(); output + // 预留结尾换行和三位状态码共 4 字节,再额外读 1 字节探测正文超限。 .take(MAX_TEXT as u64 + 5) .read_to_end(&mut bytes) .map_err(|_| "HTTP response read failed".to_owned())?; if bytes.len() > MAX_TEXT + 4 { return Err("HTTP text exceeds 1 MiB".into()); } + // 读完输出后用 try_wait 检查退出;等待间隔先释放 process 锁,让其他线程仍可取消进程。 let status = loop { let mut slot = control .process @@ -299,6 +327,7 @@ fn fetch_text(url: &str, control: &Control) -> Completion { .try_wait() .map_err(|_| "HTTP transport wait failed".to_owned())? { + // try_wait 已回收退出状态,移除 Child 后 Reaper 析构不会再次 kill/wait 同一个进程。 slot.take(); break status; } @@ -308,12 +337,14 @@ fn fetch_text(url: &str, control: &Control) -> Completion { if control.cancelled.load(Ordering::Acquire) { return Err("request cancelled".into()); } + // --write-out 的末尾四字节应是换行加三位状态码;checked_sub 处理空/短输出,不直接倒数切片。 let code = bytes .len() .checked_sub(4) .and_then(|offset| (bytes[offset] == b'\n').then_some(&bytes[offset + 1..])) .and_then(|digits| std::str::from_utf8(digits).ok()) .and_then(|digits| digits.parse::().ok()); + // 已知 HTTP 3xx/4xx/5xx 优先以 HTTP 错误报告,其他 curl 非零状态再映射为超时、大小或传输失败。 if let Some(code) = code && code >= 300 { @@ -330,6 +361,7 @@ fn fetch_text(url: &str, control: &Control) -> Completion { if !code.is_some_and(|code| (200..300).contains(&code)) { return Err("HTTP response status unavailable".into()); } + // 确认 2xx 与成功退出后才剥去协议附加状态尾部,再严格解码 UTF-8 正文。 bytes.truncate(bytes.len() - 4); String::from_utf8(bytes).map_err(|_| "HTTP text must be UTF-8".into()) } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 986f0ba..d6a83bc 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1,11 +1,19 @@ //! 运行时模块入口。VM 生命周期、API、源程序组织、事件与异步任务在这里组合。 //! -// Module ownership follows the directory; crate-root aliases keep internal call sites stable. +// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。 +/// 提供 HTTP 路由、有界请求队列与串行 worker,协调候选 VM 和已发布 namespace。 pub(crate) mod api; +/// 拥有真实 Aster VM 会话,组合源执行、增量刷新、事件、任务和资源清理。 pub(crate) mod endor_host; +/// 识别并组织 Endor 源输入,保留原始诊断位置,支持脚本和兼容入口形式。 pub(crate) mod endor_script; +/// 在 VM 中执行捕获/目标/冒泡路由,处理监听器断开、传播停止与事件结果。 pub(crate) mod event_dispatch; +/// 定义原生输入信封与有界客户端,将窗口输入送往运行时而不跨线程调用源函数。 pub(crate) mod events; +/// 执行后台文件/HTTP 文本读取,使用租约拒绝取消后的交付;后台不操作 VM 对象。 pub(crate) mod io_tasks; +/// 维护 owner/key 任务身份、定时器、协作执行与 await 依赖,统一处理替换和取消。 pub(crate) mod tasks; +/// 封装可恢复 VM 工作的推进次序,使源布局/绘制在预算内保留同一续体而不重放。 pub(crate) mod vm_sequence; diff --git a/src/runtime/tasks.rs b/src/runtime/tasks.rs index 624a4c4..ae34f11 100644 --- a/src/runtime/tasks.rs +++ b/src/runtime/tasks.rs @@ -20,11 +20,13 @@ use endor_core::{ use crate::{block::Block, mount::Mount, ui_bridge::desktop_type, window::ManagedWindow}; +// 普通对象的私有任务字典属性名;Block/Mount/Window 则把同一拥有关系保存在宿主 payload。 const SCOPE: &str = "\0tessera.tasks"; const MAX_TASKS: usize = 1024; const MAX_OWNER_TASKS: usize = 64; const MAX_READY: usize = 64; +// 调度器只保存弱任务身份,不因排队把任务、回调或 owner 隐式保活。 type Index = Arc>>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,17 +54,22 @@ impl State { #[derive(Debug, Clone)] /// 托管异步对象,保存 owner/key、结果与真正拥有的回调/协程/等待引用。 struct Task { + // owner 通过 scope 字典强持有 Task,Task 反向弱引用 owner,避免任务把已关闭页面永久保活。 owner: WeakObjectRef, key: String, + // 单调时钟截止点;ready 与到期是两个独立条件,I/O 完成不受系统日期调整影响。 due: Instant, callback: Option, state: State, + // 定时/协作任务创建即 ready,但仍等 due;I/O 任务等 provider 结果写回后才 ready。 ready: bool, text: Option>, error: Option>, + // 仅任务 payload 持有 I/O 租约;取消/终态清除此边即可撤销后台交付并归还额度。 lease: Option>, cooperative: bool, coroutine: Option, + // 协程 await 的对象是真实托管强边;等待期间由它保活被等待值,不以弱调度索引代替。 waiting: Option, result: Option, } @@ -83,6 +90,7 @@ impl ObjectPayload for Task { fn poll_await(&self) -> Result { match self.state { State::Pending | State::Running => Ok(endor_core::AwaitPoll::Pending), + // 协作任务优先返回保存的任意 Value,I/O 返回 text,普通定时器默认为 Nil。 State::Completed => Ok(endor_core::AwaitPoll::Ready( self.result.clone().unwrap_or_else(|| { self.text @@ -126,6 +134,7 @@ fn object(value: &Value) -> Result { } /// 取得 owner 关联的托管任务作用域,供 key 替换和取消使用。 fn scope(store: &ObjectStore, owner: ObjectRef) -> Result, VmError> { + // scope 的位置依 owner 类别而异;普通对象即使使用私有属性,也要验证该值确为字典。 let payload = store.object(owner)?.payload(); if let Some(value) = payload.as_any().downcast_ref::() { return Ok(value.tasks); @@ -153,6 +162,7 @@ fn install_scope( owner: ObjectRef, scope: ObjectRef, ) -> Result<(), VmError> { + // 宿主 payload 通过 replace_payload 建立受 GC 追踪的强边,普通对象通过 set_attr;均不能绕过写屏障。 let payload = cx.store().object(owner)?.payload(); if let Some(value) = payload.as_any().downcast_ref::() { let mut value = value.clone(); @@ -178,6 +188,7 @@ fn eligible(store: &ObjectStore, owner: ObjectRef, desktop: ObjectRef) -> Result if !mount.mounted { return Ok(false); } + // Mount 仅 mounted 不足以交付,所属 Page 还必须在某个活动窗口页面注册表里,关闭再打开不能恢复旧任务。 if let Some(page) = mount.page { // No retained root is needed for this synchronous borrowed lookup. let windows = object(&store.get_attr(desktop, "windows")?)?; @@ -192,6 +203,7 @@ fn eligible(store: &ObjectStore, owner: ObjectRef, desktop: ObjectRef) -> Result } return Ok(false); } + // 窗口 owner 要求未 closed;Page owner 要求仍注册,普通非页面业务 owner 没有额外展示资格要求。 if let Some(window) = payload.as_any().downcast_ref::() { return Ok(!window.closed); } @@ -218,6 +230,7 @@ fn current( reference: ObjectRef, task: &Task, ) -> Result { + // key 指向的对象必须恰是当前 Task,替换后旧 Task 即使还被外部持有,也不能接收结果或执行回调。 Ok(match scope(store, owner)? { Some(scope) => { store.dict_get(scope, &Value::Text(task.key.clone()))? == Some(Value::Object(reference)) @@ -233,6 +246,7 @@ pub fn cancel_owner(cx: &mut NativeContext<'_>, owner: ObjectRef) -> Result<(), let Some(scope) = scope(cx.store(), owner)? else { return Ok(()); }; + // 克隆条目后逐个变更字典;取消 pending/running 的执行边,再删除所有 owner/key 拥有边。 for (key, value) in cx.store().dict_items(scope)?.to_vec() { let reference = object(&value)?; let mut task = cx.store().payload::(reference, "Task")?.clone(); @@ -276,13 +290,14 @@ impl NativeCallable for After { fn signature(&self) -> Option { Some(self.signature.clone()) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// 直接使用创建时准备的 after/sleep/readText/fetchText/spawn 签名数量,和具体 API 一致。 fn arity(&self) -> Option { Some(self.signature.arity) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 先拒绝退出或只读域的新工作,再解码 owner/key/提供方参数,取消同键旧任务并建立受作用域管理的新 Task。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 退出域先拒绝新任务;只读渲染也禁止异步提交,即使某构造操作只分配新的任务对象。 if self.quiescing.load(Ordering::Acquire) { return Err(invalid("runtime is exiting; new tasks are forbidden")); } @@ -291,6 +306,7 @@ impl NativeCallable for After { "asynchronous work is forbidden during read-only rendering", )); } + // 统一解码不同 API 参数:spawn 用零延时与回调,sleep 无回调,I/O 第三个位置是路径/URL。 let zero = Value::Number(0_i64.into()); let (owner, key, delay, callback) = match args { [owner, key, callback] if self.operation == Operation::Spawn => { @@ -311,6 +327,7 @@ impl NativeCallable for After { }; let owner = object(owner)?; let callback = callback.map(object).transpose()?; + // 所有回调都按零显式参数执行,必须是 Function 且参数元数据确认接受零参,不能等执行时再碰运气。 if let Some(callback) = callback { if !cx.store().object(callback)?.payload().is_function() { return Err(invalid("task callback must be a Function")); @@ -327,6 +344,7 @@ impl NativeCallable for After { let Value::Text(key) = key else { return Err(invalid("task key must be text")); }; + // key 是 owner 内替换/取消身份,限制 UTF-8 字节长度与控制字符,禁止空 key。 if key.is_empty() || key.len() > 128 || key.chars().any(char::is_control) { return Err(invalid("invalid task key")); } @@ -359,6 +377,7 @@ impl NativeCallable for After { "task delay must be an integer number of milliseconds", )); }; + // 定时延迟要求整数毫秒,范围为零到一天;spawn 复用零延迟路径,禁止小数/负数。 let milliseconds = delay .as_i64() .filter(|delay| (0..=86_400_000).contains(delay)) @@ -370,6 +389,7 @@ impl NativeCallable for After { if !eligible(cx.store(), owner, self.desktop)? { return Err(invalid("task owner is closed or detached")); } + // 在分配新任务前找同 key 旧身份,额度检查允许替换但不允许借其他 key 继续增加总量。 let existing_scope = scope(cx.store(), owner)?; let previous = existing_scope .map(|scope| cx.store().dict_get(scope, &Value::Text(key.clone()))) @@ -381,6 +401,7 @@ impl NativeCallable for After { .index .lock() .map_err(|_| invalid("task index poisoned"))?; + // 提交时压缩弱索引,只保留还存活且 pending/running 的任务;终态不占调度名额。 let mut live = Vec::new(); for weak in index.iter().copied() { if let Some(task) = cx.upgrade(weak)? @@ -401,6 +422,7 @@ impl NativeCallable for After { { return Err(VmError::ExecutionLimit("64 pending tasks per owner")); } + // 先分配并设置截止点/状态,再安装 I/O 租约和 owner 字典边;弱 owner 不承担保活作用。 let task = cx.allocate(Task { owner: cx.store().downgrade(owner)?, key: key.clone(), @@ -423,6 +445,7 @@ impl NativeCallable for After { })?; cx.attach_type(task, desktop_type(cx.store(), "Task", self.desktop)?)?; if let Some(request) = request { + // 只有旧任务仍待完成且真的持有 I/O 租约才使用 provider 临时替换额度,不给普通任务扩大 I/O 上限。 let replacing = previous .map(|previous| { cx.store().payload::(previous, "Task").map(|task| { @@ -440,6 +463,7 @@ impl NativeCallable for After { cx.replace_payload(task, value)?; } + // 首次提交才安装字典;以后同 owner 复用 scope,key 替换始终在同一拥有表中发生。 let task_scope = if let Some(scope) = existing_scope { scope } else { @@ -450,10 +474,12 @@ impl NativeCallable for After { // Publish the new identity before invalidating the old result. Nothing // external is dispatched until the worker accepts this VM generation. let previous_weak = previous.map(|old| cx.store().downgrade(old)).transpose()?; + // 替换字典边前临时升级旧任务,避免 dict_set 删除最后拥有边后,清理旧 payload 时对象已被回收。 if let Some(weak) = previous_weak { cx.upgrade(weak)?; } cx.dict_set(task_scope, Value::Text(key.clone()), Value::Object(task))?; + // 新 key 身份登记成功后再将旧任务取消,切断回调/协程/等待/结果与 I/O 租约,晚到结果不得复活。 if let Some(previous) = previous { // The replacement above can release the old Task's last owner edge. // Preserve it through the replacement even without a saved token. @@ -472,6 +498,7 @@ impl NativeCallable for After { } } let weak = cx.store().downgrade(task)?; + // 弱索引也移除被替换身份;spawn 额外设置 runnable 唤醒标志,使外层知道存在可推进协程。 live.retain(|old| Some(*old) != previous_weak); live.push(weak); *index = live; @@ -503,13 +530,14 @@ impl NativeCallable for Method { fn signature(&self) -> Option { Some(self.signature.clone()) } - /// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 + /// Task 方法无显式参数;底层调用包含唯一一个隐式 Task 接收者。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 + /// 验证 Task 接收者并按当前状态处理 status/active/result/error/cancel;仍活跃的任务需重新确认 owner/key 身份。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 方法无显式参数,实际 args 唯一一项是隐式 Task 接收者。 let [value] = args else { return Err(invalid("Task methods accept no arguments")); }; @@ -528,6 +556,7 @@ impl NativeCallable for Method { false }; let value = match self.query { + // pending/running 但已失去 owner/key 资格时,对源报告 cancelled;只读查询不因此改写 payload。 Query::Status => Value::Text( if pending && !active { State::Cancelled.name() @@ -537,6 +566,7 @@ impl NativeCallable for Method { .into(), ), Query::Active => Value::Bool(active), + // I/O 回调执行期间可读取已保存 text;协作任务必须真正 Completed 才能读最终 result,错误任务不返回成功值。 Query::Result => { if !matches!(task.state, State::Running | State::Completed) || task.error.is_some() @@ -570,6 +600,7 @@ impl NativeCallable for Method { if pending { task.state = State::Cancelled; task.callback = None; + // 回调开始前释放 I/O 名额,已收到的 text 仍留在任务内,可由回调查询。 task.lease = None; task.coroutine = None; task.waiting = None; @@ -583,6 +614,7 @@ impl NativeCallable for Method { cx.dict_delete(scope, &Value::Text(task.key))?; } } + // 返回本次是否取消了仍有效的待执行任务;重复 cancel 或已失去资格的任务返回 false。 Value::Bool(pending && active) } }; @@ -601,6 +633,7 @@ pub struct Scheduler { impl Scheduler { /// 停止接收该域的新任务工作,为关闭/替换运行时做准备。 pub fn quiesce(&self, vm: &mut Vm) -> Result { + // 先设置不可逆的退出门闩再取消全部任务,防止清理回调间隙继续接收新工作。 self.quiescing.store(true, Ordering::Release); self.cancel_all(vm) } @@ -615,6 +648,7 @@ impl Scheduler { anyhow::ensure!((1..=MAX_READY).contains(&limit), "invalid task poll limit"); // Provider threads only fill the mailbox. Only this VM worker updates // managed payloads, and delivery below rechecks owner/key generation. + // provider 只产纯数据,VM worker 此处重新核对任务 pending、租约仍在、owner 有效且 key 仍当前。 for (weak, completion) in self.provider.take_ready()? { if let Some(reference) = vm.upgrade(weak)? { let result = (|| -> Result<()> { @@ -637,6 +671,7 @@ impl Scheduler { if !active? { return Ok(()); } + // 收到后台结果只设置 ready/text/error,实际终态与回调交付由下面的有界调度扫描决定。 task.ready = true; match completion { Ok(text) => task.text = Some(text.into()), @@ -654,6 +689,7 @@ impl Scheduler { .lock() .map_err(|_| anyhow::anyhow!("task index poisoned"))? .clone(); + // 本轮使用同一 now 判断到期,避免长回调执行后在同轮无限吸收后来才到期的定时器。 let now = Instant::now(); let mut work = 0; let mut advanced = Vec::new(); @@ -677,10 +713,12 @@ impl Scheduler { } else { false }; + // owner 回收、页面关闭或 key 被替换都使任务取消;取消也计一次 work,避免清理无限占用一轮。 if !active { work += 1; return finish(vm, reference, State::Cancelled); } + // 被等待对象未就绪则不消费量子;被取消会取消等待者,失败等其他结果交回协程 await 语义处理。 if let Some(waiting) = task.waiting { match vm.object(waiting)?.payload().poll_await() { Ok(endor_core::AwaitPoll::Pending) => return Ok(()), @@ -695,7 +733,9 @@ impl Scheduler { return Ok(()); } work += 1; + // 记录实际推进的任务,轮末移到索引尾部,保证一回调空闲预算下仍能轮转多个协程。 advanced.push(weak); + // spawn 使用可恢复协程而非普通 call;其余 I/O/定时器回调作为一次完整有界调用执行。 if task.cooperative { return advance_coroutine(vm, reference); } @@ -725,6 +765,7 @@ impl Scheduler { .map_err(|error| crate::endor_host::runtime_error(vm, error)); match result { Ok(value) => { + // 普通异步回调契约要求 Unit;先释放实际返回值,再把不符合契约的回调标失败并向外报告。 let unit = matches!(value, Value::Nil); vm.release_value(value)?; if !unit { @@ -761,6 +802,7 @@ impl Scheduler { index.retain(|weak| !advanced.contains(weak)); index.extend(advanced); } + // 轮末移除终态/死亡任务并重算 runnable,等待外部 I/O 的任务不应导致忙轮询协程。 self.prune(vm)?; Ok(work) } @@ -782,6 +824,7 @@ impl Scheduler { .index .lock() .map_err(|_| anyhow::anyhow!("task index poisoned"))?; + // cancel_all 返回清空前索引项数,包含升级失败的弱项,不能等同于实际执行过的回调数。 let count = index.len(); index.clear(); self.runnable.store(false, Ordering::Release); @@ -799,6 +842,7 @@ impl Scheduler { if let Some(task) = vm.upgrade(weak)? { let pending = vm.payload::(task, "Task").map(|task| { let pending = matches!(task.state, State::Pending | State::Running); + // 可运行标记仅针对协作任务:无等待对象可立即推进,有等待对象则看其 poll_await 是否不再 Pending。 runnable |= pending && task.cooperative && task.waiting.is_none(); pending }); @@ -828,6 +872,7 @@ impl Scheduler { fn cyclic_wait(vm: &Vm, source: ObjectRef, mut target: ObjectRef) -> Result { let mut seen = std::collections::HashSet::new(); loop { + // 等待链到达 source 或重复已有目标都表示环;不是 Task 或没有 waiting 边时链终止,不判环。 if target == source || !seen.insert(target) { return Ok(true); } @@ -844,6 +889,7 @@ fn cyclic_wait(vm: &Vm, source: ObjectRef, mut target: ObjectRef) -> Result Result<()> { let mut task = vm.payload::(reference, "Task")?.clone(); + // 首次推进把 callback 转成托管 Coroutine,后续复用同一帧与预算,不重放已发生的源副作用。 let coroutine = if let Some(coroutine) = task.coroutine { coroutine } else { @@ -868,6 +914,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { } }; // Native preparation can itself cancel or replace this Task. + // 准备 coroutine 期间原生代码也可能取消/替换任务,必须重读当前状态,不能把旧 task 副本覆盖回去。 let current = vm.payload::(reference, "Task")?.state; if !matches!(current, State::Pending | State::Running) { vm.release(coroutine)?; @@ -876,16 +923,19 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { task.state = State::Running; task.callback = None; task.coroutine = Some(coroutine); + // 先由 payload 建立协程拥有边,再释放创建根;安装失败也尝试释放局部根。 let installed = vm.replace_payload(reference, task); vm.release(coroutine)?; installed?; coroutine }; let result = vm + // 每次最多执行 256 指令软量子,达到量子时仅挂起;总 100000 指令上限仍由协程本身累计。 .resume_coroutine(coroutine, 256) .map_err(|error| crate::endor_host::runtime_error(vm, error)); // Cancellation/replacement from the running function must win. A completed // value still owns a root and must be released even when delivery is stale. + // 源代码运行期间可取消自身,取消/替换优先于本轮完成;完成值即使不交付也必须释放其拥有根。 let active = matches!( vm.payload::(reference, "Task")?.state, State::Pending | State::Running @@ -895,6 +945,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { if !active { return Ok(()); } + // 登记等待强边前检查依赖环,拒绝永不满足的互相 await,并把当前任务转失败。 if cyclic_wait(vm, reference, awaitable)? { fail_callback(vm, reference, "cyclic task await")?; bail!("cyclic task await"); @@ -904,6 +955,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { vm.replace_payload(reference, task)?; Ok(()) } + // 软量子挂起不代表等待外部值,清 waiting 后下一轮仍视作可运行。 Ok(endor_core::CoroutineProgress::Pending { .. }) => { if active { let mut task = vm.payload::(reference, "Task")?.clone(); @@ -918,6 +970,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { return Ok(()); } let mut task = vm.payload::(reference, "Task")?.clone(); + // 结果先写入受 trace 管理的 payload,再释放 VM 返回值根,最后 finish 清执行边但保留成功结果。 task.result = Some(value.clone()); task.waiting = None; let stored = vm.replace_payload(reference, task); @@ -937,6 +990,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { /// 记录回调执行失败并沿运行时故障规则停止后续不安全的交付。 fn fail_callback(vm: &mut Vm, reference: ObjectRef, message: &str) -> Result<()> { let mut task = vm.payload::(reference, "Task")?.clone(); + // 错误预览按字符限长,保证 UTF-8 完整;记录后统一走终态清理而非保留失败协程。 task.error = Some(message.chars().take(1024).collect::().into()); vm.replace_payload(reference, task)?; finish(vm, reference, State::Failed) @@ -948,6 +1002,7 @@ fn finish(vm: &mut Vm, reference: ObjectRef, state: State) -> Result<()> { if !matches!(task.state, State::Pending | State::Running) { return Ok(()); } + // finish 只允许从 pending/running 转终态,重复调用不改变既有 Completed/Cancelled/Failed 的语义。 task.state = state; task.callback = None; task.lease = None; @@ -960,6 +1015,7 @@ fn finish(vm: &mut Vm, reference: ObjectRef, state: State) -> Result<()> { vm.replace_payload(reference, task.clone())?; if let Some(owner) = vm.upgrade(task.owner)? { let result = (|| -> Result<()> { + // 只有 key 仍指向本任务才删 owner 边,避免旧任务结束时误删同 key 新任务。 if current(vm.object_store(), owner, reference, &task)? && let Some(scope) = scope(vm.object_store(), owner)? { @@ -980,6 +1036,7 @@ pub fn install( ui: ObjectRef, desktop: ObjectRef, ) -> Result { + // 每个运行时一份弱索引、可运行/退出标记和 provider mailbox,UI API 与 Scheduler 共享这些原生状态。 let index = Arc::new(Mutex::new(Vec::new())); let runnable = Arc::new(AtomicBool::new(false)); let quiescing = Arc::new(AtomicBool::new(false)); @@ -1017,6 +1074,7 @@ pub fn install( endor_core::CallEffect::Mutating }, ); + // I/O 可带可选回调;spawn 去掉延时参数,sleep 去掉回调参数,保留各自正确 arity。 if external { signature.arity = endor_core::CallArity::range(3, 4); } @@ -1040,6 +1098,7 @@ pub fn install( roots.push(function); vm.set_attr(ui, name, Value::Object(function))?; } + // Task 类型安装 cancel/status/active/result/error:只有 cancel 声明 Mutating,其余只读查询。 for (name, query) in [ ("cancel", Query::Cancel), ("status", Query::Status), diff --git a/src/runtime/vm_sequence.rs b/src/runtime/vm_sequence.rs index ccbcf22..341fa1e 100644 --- a/src/runtime/vm_sequence.rs +++ b/src/runtime/vm_sequence.rs @@ -7,13 +7,17 @@ use endor_core::{ VmError, }; -/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。 +/// 将零参数调用列表编成一个托管驱动,按输入顺序执行,返回最后一次调用的值。 +/// 空列表返回 Nil。所有调用都在调用者的 VM 预算内运行,避免宿主逐个调用时重置预算。 +/// name 用于诊断位置;desktop 用来给生成函数挂接桌面 Function 类型。 pub fn build( cx: &mut NativeContext<'_>, calls: Vec, name: &'static str, desktop: ObjectRef, ) -> Result { + // 每个函数放入常量池。LoadConst/PushNull/Call 组成零参数调用, + // 中间结果由 PopTop 丢弃;最后结果留在栈顶交给 ReturnValue。 let mut instructions = Vec::with_capacity(calls.len() * 4 + 2); let mut constants: Vec = calls.into_iter().map(Value::Object).collect(); for index in 0..constants.len() { @@ -24,6 +28,7 @@ pub fn build( instructions.push(Instruction::simple(Opcode::PopTop)); } } + // 没有调用时也必须给 ReturnValue 提供合法栈顶,不能返回一个空栈。 if constants.is_empty() { constants.push(Value::Nil); instructions.push(Instruction::new(Opcode::LoadConst, 0)); @@ -47,6 +52,8 @@ pub fn repeat_call( name: &'static str, desktop: ObjectRef, ) -> Result { + // 回调返回 Bool;repeat_while 消费这个条件并决定是否跳回本帧开头。 + // 退出后显式返回 Nil,回调的继续条件不会泄露为驱动返回值。 let mut instructions = vec![ Instruction::new(Opcode::LoadConst, 0), Instruction::simple(Opcode::PushNull), @@ -77,6 +84,8 @@ pub fn collect_and_continue( name: &'static str, desktop: ObjectRef, ) -> Result { + // 常量布局固定为 collector、function、各实参、true。 + // 先把 collector 的调用前缀压栈,再执行 function,使其返回值直接成为 collector 的唯一实参。 let count = arguments.len(); let mut constants = vec![Value::Object(collector), Value::Object(function)]; constants.extend(arguments); @@ -90,6 +99,7 @@ pub fn collect_and_continue( for index in 0..count { instructions.push(Instruction::new(Opcode::LoadConst, index as u32 + 2)); } + // 收集器返回值不参与外层调度;丢弃后返回 true,通知重复驱动继续取下一项。 instructions.extend([ Instruction::new(Opcode::Call, count as u32), Instruction::new(Opcode::Call, 1), @@ -121,8 +131,12 @@ fn driver( /// false falls through. Operand widths include EXTENDED_ARG code units. /// 构造由继续条件决定是否再执行一轮的同帧循环。 pub fn repeat_while(instructions: &mut Vec) -> Result<(), VmError> { + // 跳转单位是编码后的双字节 code unit,不是 Instruction 数量。 + // 大操作数会生成 EXTENDED_ARG,因此必须编码后计算实际长度。 let body = Instruction::encode_all(instructions)?.len() / 2; let mut back = Instruction::new(Opcode::JumpBackward, 0); + // 回跳距离包含自身宽度;而距离变大又可能扩大自身编码宽度。 + // 迭代到宽度/距离一致后,false 跳过回跳指令,true 回到整个驱动的起点。 loop { let units = Instruction::encode_all(&[back])?.len() / 2; let distance = u32::try_from(body + 1 + units) @@ -141,7 +155,10 @@ mod tests { use super::*; use endor_core::{ExecutionLimits, Vm}; #[test] + /// 生成超过单字节跳距的循环,false 条件正常返回,true 条件只耗尽指令预算而非一帧预算;释放后对象数归零。 fn repeated_bytecode_handles_extended_operands_and_keeps_one_frame() { + // 300 个 Nop 强制跨越单字节跳转距离:false 应正常退出;true 应耗尽指令预算, + // 而非耗尽帧预算,从而验证循环确实复用同一个执行帧。 for condition in [false, true] { let mut instructions = vec![Instruction::simple(Opcode::Nop); 300]; instructions.push(Instruction::new(Opcode::LoadConst, 0)); @@ -177,6 +194,7 @@ mod tests { } vm.release(code).unwrap(); vm.release(globals).unwrap(); + // 释放代码与全局字典后执行循环回收,确认生成的常量/函数没有遗留托管对象。 vm.collect_cycles(); assert_eq!(vm.object_count(), 0); } diff --git a/src/shell/activity.rs b/src/shell/activity.rs index 4aa47a9..cb354f9 100644 --- a/src/shell/activity.rs +++ b/src/shell/activity.rs @@ -8,8 +8,11 @@ use std::{ time::{Instant, SystemTime, UNIX_EPOCH}, }; +// HTTP worker 与原生 Network 工具共享同一有界日志;持锁读取快照/更新记录,不在这里执行网络请求。 pub type Activity = Arc>; +// 预览正文按 UTF-8 字节限制;截断标记会额外占用少量字节。 pub const BODY_PREVIEW: usize = 32 * 1024; +// 只保留最近 80 次开始的请求,尚未完成的老请求也可能被淘汰。 const MAX_RECORDS: usize = 80; #[derive(Clone, Debug)] @@ -22,10 +25,12 @@ pub struct Exchange { pub timestamp: String, pub request: String, pub response: String, + // None 表示尚未记录响应;完成后保存 HTTP 状态码,不据此判断业务调用是否成功。 pub status: Option, pub elapsed_ms: u128, pub request_headers: String, pub response_headers: String, + // 使用单调时钟计算耗时,不受系统时间调整影响;timestamp 则用于显示 UTC 时刻。 started: Instant, } @@ -40,6 +45,7 @@ impl ActivityLog { pub fn begin(&mut self, method: String, path: String, origin: String, headers: String) -> u64 { self.next_id += 1; let id = self.next_id; + // 显示时间取 Unix 秒数的当日余数;系统时间早于纪元时回退到零。这里只显示时间,不保存日期。 let seconds = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -64,18 +70,21 @@ impl ActivityLog { response_headers: String::new(), started: Instant::now(), }); + // 从队首淘汰最早开始的记录。后续 body/finish 若找不到其 ID 就忽略,不重新插入旧请求。 while self.records.len() > MAX_RECORDS { self.records.pop_front(); } id } /// 更新请求正文预览,内容长度仍由活动记录策略限制。 + // 线性查找在最多 80 项范围内完成;本方法覆盖之前的请求预览,不拼接分块正文。 pub fn body(&mut self, id: u64, body: &str) { if let Some(record) = self.records.iter_mut().find(|r| r.id == id) { record.request = preview(body); } } - /// 完成本模块当前阶段的结果整理,把后续工作交回调用方。 + /// 为指定请求登记响应状态、正文/头部预览与单调时钟耗时;记录已淘汰时忽略。 + // 仅更新仍保留的请求,响应正文和头部都重新截断;耗时从 begin 起计算。 pub fn finish(&mut self, id: u64, status: u16, body: &str, headers: String) { if let Some(record) = self.records.iter_mut().find(|r| r.id == id) { record.status = Some(status); @@ -91,6 +100,7 @@ pub fn preview(value: &str) -> String { if value.len() <= BODY_PREVIEW { return value.to_owned(); } + // UTF-8 多字节字符不能从中间切开;从字节上限回退到合法边界,确保输出始终是有效字符串。 let mut end = BODY_PREVIEW; while !value.is_char_boundary(end) { end -= 1; diff --git a/src/shell/app.rs b/src/shell/app.rs index 4c6f811..2a48d12 100644 --- a/src/shell/app.rs +++ b/src/shell/app.rs @@ -38,38 +38,47 @@ pub enum AppEvent { /// 一个原生窗口的宿主状态;主窗口还协调子窗口资源,共用同一运行时与输入通道。 pub struct TesseraApp { + // 运行时窗口资源编号与 winit WindowId 分属不同命名空间,输入携带前者,OS 事件匹配后者。 resource: u64, control: crate::api::RuntimeControl, exit_requested: bool, + // 记录当前原生窗口所对应的运行时世代,避免重启后继续显示已失效的子窗口。 native_session: u64, startup_page_requested: bool, native_error: Option, + // 只有主窗口维护此表;子窗口有独立文档/绘制状态,但复用运行时控制与输入队列。 children: std::collections::BTreeMap, proxy: EventLoopProxy, + // 窗口、softbuffer 上下文与表面延迟创建;Arc 让表面持有有效的原生窗口句柄。 window: Option>, context: Option>>, surface: Option, Arc>>, namespace: Arc>, developer_tools: DeveloperToolsState, + // 最近一次绘制生成的宿主控件几何,用于下一次输入命中、光标和 IME 定位。 layout: SceneLayout, pointer: Point, sidebar: crate::sidebar::State, sidebar_keyboard: bool, panning: bool, + // 拖动开始时捕获视口,None 也可能表示拖动整张 Canvas,而非手势未开始。 pan_viewport: Option, orbiting: bool, space_down: bool, modifiers: ModifiersState, activity: Activity, event_client: crate::events::Client, + // 保存按下时的版本与位置;释放时需确认仍处在同一会话与布局,防止旧画面误点击。 guest_pressed: Option, guest_focus: Option, + // Enter/Space 的按下捕获;只在对应按键释放时发出一次 click,忽略系统连发。 guest_key: Option<(NamedKey, crate::events::Input)>, guest_keyboard: bool, } impl TesseraApp { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 先建立连接权威 namespace 的有界事件客户端,再初始化主窗口状态。 + /// 客户端创建失败向调用方返回错误;原生窗口和像素表面延迟到 resumed 创建。 pub fn new( namespace: Arc>, activity: Activity, @@ -137,11 +146,12 @@ impl TesseraApp { let session = namespace.runtime_session; let revision = namespace.revision; let submission = namespace.submission; + // 先复制已发布的子窗口投影并释放读锁,再创建/销毁 OS 资源;原生调用不会占着发布锁。 let documents = namespace.document.windows.clone(); drop(namespace); if session != self.native_session { - // Console reset preserves the old projection with input disabled. - // A real replacement changes frame sessions and drops old OS views. + // 控制台 reset 可以保留旧投影但禁用输入;真正替换运行时后, + // 新文档带新 session,此时销毁旧原生视图,防止跨世代复用。 let replaces = documents .values() .any(|document| document.frame.session == session && session != 0); @@ -198,6 +208,7 @@ impl TesseraApp { return; }; let session = namespace.runtime_session; + // 取走整批命令即表示消费;随后释放写锁,执行窗口 API 时不阻塞 worker 的文档发布。 let commands = std::mem::take(&mut namespace.window_commands); drop(namespace); for envelope in commands { @@ -270,6 +281,7 @@ impl TesseraApp { namespace.window_viewports.insert(self.resource, size); if self.resource == crate::window::MAIN_WINDOW { let session = namespace.runtime_session; + // 主窗口维护 worker 可见的视口总表:先移除旧世代/已关闭子窗口,再复制当前有效子窗口的逻辑尺寸。 namespace.window_viewports.retain(|id, _| { *id == crate::window::MAIN_WINDOW || self @@ -328,6 +340,7 @@ impl TesseraApp { /// 接收完整文档更新,协调子窗口和原生命令,再更新输入焦点并请求重绘。 fn document_updated(&mut self, event_loop: &ActiveEventLoop) { + // 先使原生资源对应新文档,再执行其窗口命令;随后重建焦点令牌,避免沿用旧布局身份。 self.reconcile_windows(event_loop); self.apply_window_commands(); if let Ok(namespace) = self.namespace.read() { @@ -359,6 +372,7 @@ impl TesseraApp { /// 先处理宿主管理输入、选择与快捷键,再按焦点决定是否交给页面。 fn handle_keyboard(&mut self, event: &winit::event::KeyEvent) { + // 宿主快捷键优先级最高;后续依次交给侧栏、页面交互、开发工具按钮及文本编辑。 let command = self.modifiers.super_key() || self.modifiers.control_key(); if command && event.state == ElementState::Pressed @@ -386,6 +400,7 @@ impl TesseraApp { } return; } + // 侧栏获得键盘路由后,Tab 在宿主目标间移动,Enter/Space 采用按下捕获、释放触发。 if self.sidebar_keyboard { if event.logical_key == Key::Named(NamedKey::Tab) && event.state == ElementState::Pressed @@ -429,6 +444,7 @@ impl TesseraApp { return; } } + // 页面交互成功消费按键才提前返回;未处理的按键还可交给开发工具,保持明确的路由顺序。 if self.guest_keyboard && self.handle_guest_key(event) { self.request_redraw(); return; @@ -458,6 +474,7 @@ impl TesseraApp { } return; } + // 只有展开且有编辑焦点的工具接受文本;剪切先成功写系统剪贴板,再删除选中内容。 if !self.developer_tools.open || self.developer_tools.focus.is_none() { return; } @@ -485,6 +502,7 @@ impl TesseraApp { } _ => {} } + // 输入法预编辑期间不再把原始按键转换为编辑动作;最终文本通过 Ime::Commit 插入。 } else if let Key::Named(key) = &event.logical_key { if !self.developer_tools.preedit.is_empty() { return; @@ -543,6 +561,7 @@ impl TesseraApp { if frame.session == 0 { return None; } + // 命中的是当前发布帧,事件同时携带 session/layout_version;worker 可拒绝排队期间已过期的输入。 let mount = frame.hit_test(point)?; Some(crate::events::Input { window: self.resource, @@ -631,6 +650,7 @@ impl TesseraApp { tab_drop: None, screen_position: None, }); + // 滚轮优先交给可滚动挂载;未命中则尝试 3D 缩放或整页平移,最后更新开发工具自身滚动。 if let Some(input) = input { self.send_guest(input); } @@ -673,6 +693,7 @@ impl TesseraApp { return None; } let world = frame.surface.to_world(point); + // 拖动沿用捕获 ID,滚轮/缩放则按当前点命中;被捕获的视口若已消失,取消此次输入而不切换目标。 let viewport = if self.panning { self.pan_viewport.and_then(|id| { frame.mounts.iter().find(|mount| { @@ -687,6 +708,7 @@ impl TesseraApp { if self.panning && self.pan_viewport.is_some() && viewport.is_none() { return None; } + // 局部 2D/3D 视口使用不同动作名。平移除去页面 zoom 以还原世界位移,缩放倍率本身不按 zoom 缩放。 if let Some(viewport) = viewport { return Some(crate::events::Input { window: self.resource, @@ -722,6 +744,7 @@ impl TesseraApp { screen_position: None, }); } + // 没有局部相机时才考虑整页变换:普通 Window 不平移,只有 Canvas 接受整页缩放。 if frame.surface.mode == crate::page::Mode::Window || kind == "page-zoom" && frame.surface.mode != crate::page::Mode::Canvas { @@ -766,6 +789,7 @@ impl TesseraApp { if frame.session == 0 || frame.fault.is_some() { return None; } + // 按发布帧中的挂载顺序遍历可交互目标;Shift+Tab 逆向环绕,尚无焦点时从首/末项进入。 let targets: Vec<_> = frame .mounts .iter() @@ -813,6 +837,7 @@ impl TesseraApp { if !event.repeat { self.guest_key = self.guest_focus.clone().map(|focus| (*key, focus)); } + // 释放会取走捕获,保证不会重复激活;仅与原来按下的 Enter/Space 相同才转换成 click。 } else if let Some((pressed, mut input)) = self.guest_key.take() && pressed == *key { @@ -854,6 +879,7 @@ impl TesseraApp { .with_titlebar_transparent(true) .with_fullsize_content_view(true) }; + // 先创建平台窗口,再建立软件绘图上下文/表面;任何一步失败都向外返回,由 resumed 记录并处理。 let window = Arc::new(event_loop.create_window(attributes)?); #[cfg(target_os = "macos")] crate::native_chrome::align_controls(&window); @@ -879,6 +905,7 @@ impl TesseraApp { } self.guest_pressed = None; self.guest_key = None; + // 正常关闭先请求 VM 清理,等待 ShutdownComplete 再退出 OS 循环;发送失败时自行投递结束通知并保留错误。 if self.control.request_shutdown() { self.exit_requested = true; } else { @@ -940,6 +967,7 @@ impl TesseraApp { let Ok(namespace) = self.namespace.read() else { return Ok(()); }; + // 读锁保护一次完整文档取样与场景生成;生成后释放锁,像素写入和 present 不再占用 namespace。 let scene = scene::build_scene( size.width, size.height, @@ -969,6 +997,7 @@ impl TesseraApp { LogicalSize::new(1.0, 19.0), ); + // 根据真实物理尺寸调整像素表面;逻辑坐标到物理像素的转换已由场景构造中的 scale_factor 负责。 surface .resize(width, height) .map_err(|error| anyhow::anyhow!("{error}"))?; @@ -987,8 +1016,8 @@ impl TesseraApp { impl ApplicationHandler for TesseraApp { /// 处理 OS 直接结束事件循环的收尾,尝试在明确时限内等待运行时清理。 fn exiting(&mut self, _: &ActiveEventLoop) { - // OS termination can bypass CloseRequested. The loop is already ending; - // use a bounded fallback rather than silently dropping every callback. + // OS 终止可能跳过 CloseRequested;此时循环已在退出,无法再等事件通知。 + // 最多阻塞 5 秒等待运行时完成清理,超时保留退出错误而不无限等待。 if !self.control.request_shutdown() { self.native_error = Some("VM worker unavailable during shutdown".into()); return; @@ -1075,6 +1104,7 @@ impl ApplicationHandler for TesseraApp { let Some(window) = self.window.as_ref().cloned() else { return; }; + // 主处理器先按 OS WindowId 转发子窗口事件;转发后汇总视口,供同一运行时布局所有窗口。 if window.id() != window_id { if let Some(child) = self.children.values_mut().find(|child| { child @@ -1089,6 +1119,7 @@ impl ApplicationHandler for TesseraApp { } match event { + // 主窗口关闭触发应用退出;子窗口关闭先发 window-close,由源程序生命周期逻辑决定资源清理。 WindowEvent::CloseRequested => { if self.resource == crate::window::MAIN_WINDOW { self.request_exit(); @@ -1123,6 +1154,7 @@ impl ApplicationHandler for TesseraApp { .. } => { let scale = window.scale_factor() as f32; + // OS 指针是物理像素,先除 DPI 得逻辑坐标;后续侧栏命中和页面世界坐标转换都从该坐标出发。 let next = Point::new(x as f32 / scale, y as f32 / scale); let delta = Point::new(next.x - self.pointer.x, next.y - self.pointer.y); self.pointer = next; @@ -1141,6 +1173,7 @@ impl ApplicationHandler for TesseraApp { && mount.camera_controls }) }); + // 3D 相机拖动超过世界坐标下的 3 单位阈值后转为 orbit,并清除 click 捕获,避免释放时误激活。 if is_3d && (point.x - start[0]).hypot(point.y - start[1]) > 3. { self.orbiting = true; self.panning = self.begin_pan(); @@ -1236,6 +1269,7 @@ impl ApplicationHandler for TesseraApp { ); } } else { + // 拖动结束仅结束手势,不把同一次释放再当成点击;普通点击需保持按下与释放的帧身份一致。 if self.panning { self.panning = false; self.guest_pressed = None; @@ -1249,6 +1283,7 @@ impl ApplicationHandler for TesseraApp { && released.session == pressed.session && released.layout_version == pressed.layout_version { + // 保留起点用于运行时判断点击/拖动语义;目标来自释放位置,具体可接受性仍由 worker 校验。 released.press_point = pressed.point; self.send_guest(released); } @@ -1265,6 +1300,7 @@ impl ApplicationHandler for TesseraApp { window.request_redraw(); } WindowEvent::MouseWheel { delta, .. } => { + // 行滚动按每行 38 逻辑像素换算,像素滚动除去 DPI;Shift 在几乎没有横向分量时将纵向量转横向。 let (mut dx, mut dy) = match delta { MouseScrollDelta::LineDelta(x, y) => (x * 38.0, y * 38.0), MouseScrollDelta::PixelDelta(p) => ( @@ -1353,6 +1389,7 @@ impl ApplicationHandler for TesseraApp { } window.request_redraw(); } + // 失焦可能导致收不到对应释放事件,因此清空按键/鼠标捕获、平移状态及修饰键,避免恢复焦点后卡住。 WindowEvent::Focused(false) => { self.guest_pressed = None; self.guest_key = None; diff --git a/src/shell/clipboard.rs b/src/shell/clipboard.rs index 261f070..642af6b 100644 --- a/src/shell/clipboard.rs +++ b/src/shell/clipboard.rs @@ -4,9 +4,10 @@ //! Clipboard text is always UTF-8, including Finder/LaunchServices launches without LANG. use anyhow::{Result, bail}; #[cfg(target_os = "macos")] -/// 运行选定平台的剪贴板命令并检查其退出状态。 +/// 构造固定 UTF-8 环境的系统命令;本函数不启动进程,退出状态由 read/write 检查。 fn command(program: &str) -> std::process::Command { let mut cmd = std::process::Command::new(program); + // 从 Finder 等无 LANG 的环境启动时也显式指定 UTF-8,防止系统工具按其他编码转换中文。 cmd.env("LC_ALL", "en_US.UTF-8") .env("LC_CTYPE", "en_US.UTF-8") .env("LANG", "en_US.UTF-8"); @@ -16,6 +17,7 @@ fn command(program: &str) -> std::process::Command { pub fn read() -> Result { #[cfg(target_os = "macos")] { + // 直接启动固定路径程序并传递独立参数,不经过 shell;-Prefer txt 要求文本表示。 let output = command("/usr/bin/pbpaste") .arg("-Prefer") .arg("txt") @@ -36,6 +38,7 @@ pub fn write(value: &str) -> Result<()> { #[cfg(target_os = "macos")] { use std::io::Write; + // 正文只经子进程标准输入传递,不拼成命令参数,因此引号、换行和源码中的命令字符保持原意。 let mut child = command("/usr/bin/pbcopy") .stdin(std::process::Stdio::piped()) .spawn()?; @@ -44,6 +47,7 @@ pub fn write(value: &str) -> Result<()> { .take() .ok_or_else(|| anyhow::anyhow!("clipboard stdin is unavailable"))?; stdin.write_all(value.as_bytes())?; + // 写完后先关闭管道发送 EOF,让 pbcopy 知道输入结束;之后等待退出状态,不能仅以写管道成功作为完成。 drop(stdin); if !child.wait()?.success() { bail!("system clipboard write failed"); @@ -52,6 +56,7 @@ pub fn write(value: &str) -> Result<()> { } #[cfg(not(target_os = "macos"))] { + // 非 macOS 当前没有实现适配器,显式报错而不伪装成写入成功。 let _ = value; bail!("system clipboard adapter is not available on this platform") } diff --git a/src/shell/developer_tools.rs b/src/shell/developer_tools.rs index 25ce46a..dd8cbb1 100644 --- a/src/shell/developer_tools.rs +++ b/src/shell/developer_tools.rs @@ -104,7 +104,7 @@ pub struct Workspace { active: usize, } impl Workspace { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 为 Console/Sources/Network/Settings 各创建一份独立状态,共享事件循环代理,初始选择 Console。 pub fn new(proxy: winit::event_loop::EventLoopProxy) -> Self { let states = [Tab::Console, Tab::Sources, Tab::Network, Tab::Settings].map(|tab| { DeveloperToolsState { @@ -123,6 +123,7 @@ impl Workspace { Some("Settings") => 3, _ => 0, }; + // 每个工具保留自己的文本/滚动/选择,强调色在切换时从旧活动页复制;切走先 blur 解除捕获与输入法状态。 let accent = self.states[self.active].accent; if next != self.active { self.states[self.active].blur(); @@ -160,7 +161,9 @@ pub struct DeveloperToolsState { pub selected_node_id: Option, pub focus: Option, pub input: String, + // Console 光标是 UTF-8 字节位置,移动和删除按 grapheme 边界维护,不用字符下标直接切字符串。 pub cursor: usize, + // 输入框表示全选状态,Sources 则结合 SourceSelection 表示全选;并非通用起止字节范围。 pub selection: bool, pub preedit: String, pub filter: String, @@ -170,6 +173,7 @@ pub struct DeveloperToolsState { tree_width: f32, network_fraction: f32, scroll: [Scroll; 5], + // 各 Region 独立保存行文本指纹与最大宽度,颜色/选择改变无需重测,文本改变才失效。 widths: [Option<(u64, f32)>; 5], reveal_input: bool, console_stamp: u64, @@ -177,10 +181,12 @@ pub struct DeveloperToolsState { cloud: bool, follow: bool, selected_request: Option, + // 清屏水位只隐藏旧记录,与共享 ActivityLog 的实际淘汰分开;Network 有独立水位。 console_after: u64, network_after: u64, history: Vec, history_index: Option, + // 首次进入历史导航前保存当前未提交输入,向下越过最新历史后恢复此草稿。 draft: String, } impl Default for DeveloperToolsState { @@ -237,6 +243,7 @@ pub struct DeveloperToolsLayout { impl DeveloperToolsLayout { /// 将工具局部绘制/交互布局平移到实际窗口位置。 pub fn translate(&mut self, dx: f32, dy: f32) { + // 绘制列表已由外层按设备像素平移,这里所有命中矩形和文字原点都按逻辑单位同步移动。 self.panel.x += dx; self.panel.y += dy; for target in &mut self.widgets { @@ -278,6 +285,7 @@ struct ConsoleInputLayout { impl DeveloperToolsState { /// 用最新完整源文档更新工具投影,维护 Sources 与 Console 历史的一致性。 pub fn document_updated(&mut self, doc: &RenderDocument) { + // 已选 AST ID 在新文档不存在时回退根;有效选择继续保留,不按行号盲目关联另一份代码。 if self .selected_node_id .as_deref() @@ -325,6 +333,7 @@ impl DeveloperToolsState { if !self.open || !layout.panel.contains(p) { return; } + // 鼠标重新定位输入目标时结束旧预编辑显示,避免把输入法组合文本残留到另一控件。 self.preedit.clear(); self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); @@ -332,6 +341,7 @@ impl DeveloperToolsState { self.focus = None; if let Some((area, origin, lines)) = &layout.source && area.contains(p) + // 只有未被滚动条/分隔条等控件命中时才开始源码拖选,管理控件优先于文字区域。 && target.is_none() { self.focus = Some(Focus::Source); @@ -354,6 +364,7 @@ impl DeveloperToolsState { self.drag = Some(Drag::Scroll(*region, *h, *ratio)); } } + // 文本框按下立即取得编辑焦点/定位光标;普通按钮动作仍等 release 确认同一启用目标。 Role::TextField => self.activate(target.action, layout, p, activity), _ => {} } @@ -375,6 +386,7 @@ impl DeveloperToolsState { if f == Focus::Input && let Some(input) = layout.input { + // 根据本帧输入原点和固定行高定位行,逐字素测量字节结束位置决定插入点,不能按等宽字符数量切中文。 let row = (((p.y - input.origin.y) / ROW).floor().max(0.0) as usize) .min(self.input.split('\n').count() - 1); let mut start = 0; @@ -405,6 +417,7 @@ impl DeveloperToolsState { self.reveal_cursor(); } } + // 清屏只更新水位/滚动,不删除 VM 全局对象;ResetSession 才向 API 请求清会话。 Some(Action::ClearConsole) => { self.console_after = activity.lock().unwrap().records.back().map_or(0, |r| r.id); self.scroll[Region::Transcript as usize] = Scroll::default(); @@ -431,6 +444,7 @@ impl DeveloperToolsState { self.scroll[Region::Source as usize].x = 0.0; self.scroll[Region::Source as usize].y = line.saturating_sub(1) as f32 * ROW; } + // 在 Console 选外部请求标题可切 Network 并清筛选,让目标记录可见;详情滚动回顶部。 Some(Action::Request(id)) => { self.selected_request = Some(id); if self.tab == Tab::Console { @@ -473,6 +487,7 @@ impl DeveloperToolsState { event::ElementState, keyboard::{Key, NamedKey}, }; + // 输入法正在组合时不抢 Enter/Space 等控件键盘激活,避免确认候选被误触发按钮。 if !self.open || !self.preedit.is_empty() { return false; } @@ -482,6 +497,7 @@ impl DeveloperToolsState { } return true; } + // Console 输入内普通 Tab 插四空格;Shift+Tab 或其他焦点的 Tab 走控件循环导航。 if event.logical_key == Key::Named(NamedKey::Tab) && (self.focus != Some(Focus::Input) || shift) { @@ -509,6 +525,7 @@ impl DeveloperToolsState { ) { if event.state == ElementState::Pressed { + // 控件只在非自动重复按下时捕获,抬起时返回动作,避免长按 Enter 重复提交控制操作。 if !event.repeat { self.interaction.key_press(&layout.widgets); } @@ -533,6 +550,7 @@ impl DeveloperToolsState { if self.source_selection.dragging && let Some((area, origin, lines)) = &layout.source { + // 源码拖选越出视口时每次指针移动增加/减少一行滚动;不是单独定时器持续自动滚动。 if p.y < area.y || p.y > area.y + area.height - 9.0 { let direction = if p.y < area.y { -ROW } else { ROW }; self.scroll[Region::Source as usize].y = @@ -551,6 +569,7 @@ impl DeveloperToolsState { / (layout.panel.height - 114.0).max(1.0)) .clamp(0.2, 0.8) } + // 手柄位移乘 Painter 保存的 content/travel 倍率转成内容偏移;手动滚动 transcript 会关闭自动 Follow。 Some(Drag::Scroll(r, h, ratio)) => { let s = &mut self.scroll[r as usize]; if h { @@ -567,6 +586,7 @@ impl DeveloperToolsState { } /// 将滚轮作用到当前工具的可滚动区域,钳制本工具偏移。 pub fn wheel(&mut self, layout: &DeveloperToolsLayout, p: Point, dx: f32, dy: f32) { + // 滚轮寻找最前景包含点的 Region,只钳下限;上限在下一轮绘制根据最新内容范围统一钳制。 if let Some((_, r, _, _)) = layout .regions .iter() @@ -585,6 +605,7 @@ impl DeveloperToolsState { pub fn insert(&mut self, value: &str) { match self.focus { Some(Focus::Input) => { + // 先检查当前输入+新文本字节总量,再处理全选替换;因此大输入全选后粘贴仍按此保守上限检查。 if self.input.len() + value.len() > 1_048_576 { self.input_error = "Input exceeds 1 MiB".into(); return; @@ -596,6 +617,7 @@ impl DeveloperToolsState { } self.input.insert_str(self.cursor, value); self.cursor += value.len(); + // 编辑历史条目后退出历史导航,当前输入成为新的可提交文本;光标使用插入文本实际 UTF-8 字节数推进。 self.history_index = None; self.reveal_cursor(); } @@ -605,6 +627,7 @@ impl DeveloperToolsState { self.selection = false; } self.filter + // Filter 每次插入最多200个无控制字符;这是单次输入限制,不是累计字符串长度上限。 .extend(value.chars().filter(|c| !c.is_control()).take(200)); self.scroll[Region::Requests as usize].y = 0.0; } @@ -616,6 +639,7 @@ impl DeveloperToolsState { self.custom.extend( value .chars() + // 颜色输入仅保留 hex/# 字符,每次最多7个;真正六位格式在 Enter 应用时验证。 .filter(|c| c.is_ascii_hexdigit() || *c == '#') .take(7), ); @@ -624,6 +648,7 @@ impl DeveloperToolsState { } } /// 调整输入区滚动位置,让当前光标保持在可见范围内。 + // 只设置下一帧 reveal 标记,具体滚动到可见范围由 Console build 在测量光标位置后完成。 pub(crate) fn reveal_cursor(&mut self) { self.reveal_input = true; } @@ -644,6 +669,7 @@ impl DeveloperToolsState { } /// 取得当前工具文本选择内容,供复制命令使用。 pub fn selected_text(&self) -> String { + // 源码支持任意拖选区间或全选;其他三个文本框当前只支持整框全选复制。 if self.focus == Some(Focus::Source) { return self.source_selection.text(self.selection); } @@ -674,6 +700,7 @@ impl DeveloperToolsState { } return; } + // 应用主题时解析六位 RGB 并调整过亮强调色,失败保留原主题并写输入错误。 if self.focus == Some(Focus::Custom) && *key == Enter { let hex = self.custom.trim_start_matches('#'); if hex.len() == 6 @@ -724,6 +751,7 @@ impl DeveloperToolsState { .grapheme_indices(true) .next_back() .map_or(0, |(i, _)| i); + // Backspace 删除前一完整字素;Delete 删除后一完整字素,组合音标和 emoji 不会拆成半个可见字符。 self.input.replace_range(n..self.cursor, ""); self.cursor = n; } else if *key == Delete && self.cursor < self.input.len() { @@ -776,10 +804,12 @@ impl DeveloperToolsState { }; self.selection = false; } + // command+上下或单行输入进入历史,普通多行上下则按字素列定位相邻行,两种导航职责分开。 ArrowUp | ArrowDown if command || !self.input.contains('\n') => { if self.history.is_empty() { return; } + // 仅首次进入历史保存草稿,连续翻旧记录不会把历史内容覆盖原草稿。 if self.history_index.is_none() { self.draft = self.input.clone(); } @@ -797,6 +827,7 @@ impl DeveloperToolsState { } ArrowUp | ArrowDown => { let start = self.input[..self.cursor].rfind('\n').map_or(0, |i| i + 1); + // 多行垂直导航保持字素列数,不保持像素 x;目标行较短时自然停到行尾。 let col = self.input[start..self.cursor].graphemes(true).count(); let target = if *key == ArrowUp { if start == 0 { @@ -828,11 +859,12 @@ impl DeveloperToolsState { } self.reveal_cursor(); } - /// 初始化内置工具对应的页面和展示状态,避免重复建立同一组工具。 + /// 在线程中经本地 REPL 请求打开指定内置工具,页面复用/创建由 VM 的 developerTools 实现负责。 pub fn initialize_tools(&mut self, name: &str) { let name = name.to_owned(); let proxy = self.proxy.clone(); thread::spawn(move || { + // 用调试字符串转义名称生成 Endor 文本,再经本地 REPL 执行;实际工具白名单由宿主能力验证。 let source = format!("ui.developerTools({name:?})"); let send = || -> std::io::Result<()> { let mut stream = TcpStream::connect_timeout( @@ -875,6 +907,8 @@ impl DeveloperToolsState { self.input.clone() }; let proxy = self.proxy.clone(); + // 后台仅发送一次当前输入或 DELETE 重置;源通过正文传递,传输失败带回原 source 让 GUI 恢复输入。 + // 线程成功启动不等于源执行成功,HTTP 结果由 ActivityLog 在 Console 展示。 let spawned=thread::Builder::new().name("tessera-console-client".into()).spawn(move||{ let send=||->std::io::Result<()> { let mut stream=TcpStream::connect_timeout(&"127.0.0.1:4737".parse().unwrap(),Duration::from_secs(2))?; @@ -886,6 +920,7 @@ impl DeveloperToolsState { if let Err(error)=send()&&let Some(proxy)=proxy{let _=proxy.send_event(AppEvent::ConsoleFailed{message:error.to_string(),source});} }); match spawned { + // 发送线程启动成功才清输入并加入最多80条历史;之后若网络失败由 ConsoleFailed 反馈,不能把清框当执行成功。 Ok(_) => { if !reset { self.history.push(self.input.clone()); @@ -906,7 +941,7 @@ impl DeveloperToolsState { } } -/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。 +/// 按当前工具生成设备比例显示列表与逻辑坐标命中布局,绘制后校正跨帧焦点和捕获。 pub fn build( width: u32, height: u32, @@ -918,6 +953,7 @@ pub fn build( if !state.open { return (Default::default(), Default::default()); } + // 布局和命中统一用逻辑像素,最后仅显示列表按设备 scale 转换,返回 layout 仍为逻辑坐标。 let width = width as f32 / scale; let height = height as f32 / scale; let w = width; @@ -946,6 +982,7 @@ pub fn build( MUTED, false, ); + // 锁内只克隆最多80条活动记录,接下来的文字排版/测量不持日志锁,HTTP worker 可继续更新。 let records = activity .lock() .unwrap() @@ -985,6 +1022,7 @@ pub fn build( ); // Splitters must be above toolbars, pane backgrounds and status bars for their // whole length. Register them last so their hit priority matches their paint order. + // 分隔条最后绘制/注册,命中优先级与可见叠放相同,细线扩展命中区不会被后画的背景遮住。 for (rect, kind) in p.layout.splits.clone().into_iter().rev() { p.ui.splitter( rect, @@ -996,6 +1034,7 @@ pub fn build( ); } p.ui.list.push(DrawCommand::PopClip); + // 把本管理 UI 当前使用的矩形、文字、裁剪、描边逐一转设备比例,几何与文字样式同倍率缩放。 let mut scaled = DisplayList::default(); for cmd in p.ui.list.commands() { let rect = @@ -1037,6 +1076,7 @@ pub fn build( }); } p.layout.widgets = p.ui.targets; + // 根据新目标集合清理失效焦点/捕获,若视觉状态变化要求再绘一帧,避免控件消失后保持旧按压外观。 state.repaint = state.interaction.reconcile(&p.layout.widgets); (scaled, p.layout) } diff --git a/src/shell/developer_tools/console.rs b/src/shell/developer_tools/console.rs index 7c8126c..a9df118 100644 --- a/src/shell/developer_tools/console.rs +++ b/src/shell/developer_tools/console.rs @@ -3,7 +3,7 @@ //! use super::*; -/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。 +/// 将有界 HTTP 活动、当前编辑输入和输入法预编辑投影为 Console 显示行与命中布局,并按需滚动到光标。 pub(super) fn build( p: &mut Painter, state: &mut DeveloperToolsState, @@ -12,6 +12,7 @@ pub(super) fn build( ) { let x = viewport.x; let w = viewport.width; + // 此工具页使用其既定标题栏/底栏布局坐标,输出区与输入区共享下方 transcript 滚动面板。 let bottom = viewport.height - 22.0; p.fill(Rect::new(x, 32.0, w, 30.0), BAR); @@ -44,7 +45,9 @@ pub(super) fn build( if w > 520.0 { p.label(x + 309.0, 39.0, "app.main · live objects", MUTED, false); } + // 先把活动记录投影成显示行,再把当前输入附在末尾;不会在绘制函数中提交或执行程序。 let mut lines = Vec::new(); + // console_after 是清屏水位,只展示其后会改变程序状态的请求;原生 /events 输入不刷入 Console。 for r in records.iter().filter(|r| { r.id > state.console_after && (r.method == "PUT" || r.method == "POST" || r.method == "DELETE") @@ -63,6 +66,7 @@ pub(super) fn build( } else { "CLEAR DOCUMENT" }; + // Console 自己发起的 REPL 请求省略冗余标题和 HTTP 状态行,外部请求保留来源与诊断入口。 let local = r.origin == "Console" && r.path.ends_with("/repl") && r.method == "POST"; if !local { let mut title = Line::new( @@ -73,6 +77,7 @@ pub(super) fn build( title.action = Some(Action::Request(r.id)); lines.push(title); } + // 请求正文逐行展示,首行 > 提示符、续行两个空格;保留源码换行而不执行文本。 for (i, line) in r.request.split('\n').enumerate() { lines.push(Line::new( format!("{}{line}", if i == 0 { "> " } else { " " }), @@ -84,6 +89,7 @@ pub(super) fn build( } else { OK }; + // 未完成请求显示 Executing,完成后才解析响应;HTTP 4xx/5xx 使用错误颜色。 if let Some(status) = r.status { if !local { lines.push(Line::new( @@ -107,6 +113,7 @@ pub(super) fn build( ); } use std::hash::{Hash, Hasher}; + // 只对输出行文本求 stamp,用来判断 Follow 是否需要跳到底部;当前编辑输入不参与输出变化检测。 let mut hash = std::collections::hash_map::DefaultHasher::new(); for line in &lines { line.value.hash(&mut hash); @@ -114,8 +121,10 @@ pub(super) fn build( let stamp = hash.finish(); let output_changed = stamp != state.console_stamp; state.console_stamp = stamp; + // 记录输入开始的行号,后续光标和点击区域都需加上 transcript 输出行偏移。 let first_input = lines.len(); let area = Rect::new(x + 4.0, 63.0, w - 5.0, (bottom - 88.0).max(1.0)); + // 输入法 preedit 只插入显示副本,不提交到实际 input;只有输入框持焦点时展示组合文本。 let mut input = state.input.clone(); if state.focus == Some(Focus::Input) { input.insert_str(state.cursor, &state.preedit); @@ -126,13 +135,16 @@ pub(super) fn build( lines.push(line); } let prefix = text::measure("> ", TextStyle::code()).width; + // cursor 是输入字符串的字节边界,由编辑逻辑维护;通过换行计数定位行,再用真实字体宽度计算列位置。 let before = &state.input[..state.cursor]; let row = before.matches('\n').count() as f32; let column = before.rsplit('\n').next().unwrap_or(""); let caret_x = prefix + text::measure(&format!("{column}{}", state.preedit), TextStyle::code()).width; let caret_y = (first_input as f32 + row) * ROW; + // 新输出触发 Follow,但显式 reveal_input 优先,避免执行后或编辑时光标被自动滚动隐藏。 let follow = state.follow && output_changed && !state.reveal_input; + // 把现有滚动量钳入“光标可见”的允许范围,而非每次将光标强制居中;横向预留 24 单位空间。 if state.reveal_input { let s = &mut state.scroll[Region::Transcript as usize]; s.y = s.y.clamp( @@ -148,6 +160,7 @@ pub(super) fn build( area.x + 8.0 + prefix - scroll.x, area.y + 5.0 + first_input as f32 * ROW - scroll.y, ); + // 保存最终滚动后的输入原点与视口,供后续鼠标定位复用,不能仅凭字符数估算点击位置。 p.layout.input = Some(ConsoleInputLayout { viewport: area, origin, @@ -155,6 +168,7 @@ pub(super) fn build( let input_top = origin.y.max(area.y); let input_bottom = (origin.y + state.input.split('\n').count() as f32 * ROW).min(area.y + area.height - 9.0); + // 仅为仍可见的输入片段注册文本输入命中区,扣除滚动条空间,避免历史输出区误抢输入焦点。 if input_bottom > input_top { p.ui.inline_input( Rect::new( @@ -166,6 +180,7 @@ pub(super) fn build( Action::Focus(Focus::Input), ); } + // 光标单独绘制并限制在文本视口内,不能穿过滚动条或工具页边缘;随后配对 PopClip。 if state.focus == Some(Focus::Input) { p.ui.list.push(DrawCommand::PushClip(Rect::new( area.x, @@ -184,6 +199,7 @@ pub(super) fn build( ); p.ui.list.push(DrawCommand::PopClip); } + // 底部无输入错误时展示快捷键,有错则复用同一位置显示错误文本及错误颜色。 p.label( x + 10.0, bottom - 23.0, diff --git a/src/shell/developer_tools/network.rs b/src/shell/developer_tools/network.rs index 12091d1..89177c8 100644 --- a/src/shell/developer_tools/network.rs +++ b/src/shell/developer_tools/network.rs @@ -3,7 +3,7 @@ //! use super::*; -/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。 +/// 将本进程 HTTP 活动投影为可筛选请求列表和独立滚动详情,并生成选择/分隔条命中区域。 pub(super) fn build( p: &mut Painter, state: &mut DeveloperToolsState, @@ -50,10 +50,13 @@ pub(super) fn build( MUTED, true, ); + // 上下区分隔位置取自工具状态比例,拖动只改变显示分配,不改变记录本身。 let divider = 92.0 + (bottom - 92.0) * state.network_fraction; + // 对方法、路径和状态的组合字符串做大小写不敏感子串匹配;未响应记录可用 pending 搜索。 let filter = state.filter.to_lowercase(); let visible = records .iter() + // 清屏按 ID 水位隐藏旧记录,不删除共享 ActivityLog,Console 仍可独立展示同一条请求。 .filter(|r| r.id > state.network_after) .filter(|r| { format!( @@ -82,10 +85,12 @@ pub(super) fn build( TEXT }, ); + // 请求 ID 是选择身份,不依赖筛选后行号;点击行仅生成 Request 动作,不重新发送 HTTP。 l.selected = state.selected_request == Some(r.id); l.action = Some(Action::Request(r.id)); lines.push(l); } + // 空列表可能来自无记录、清屏或过滤无匹配,统一展示本进程 API 的捕获范围提示。 if lines.is_empty() { lines.push(Line::new( "No matching requests. Listening to Tessera's local API.", @@ -100,6 +105,7 @@ pub(super) fn build( false, ); p.split(Rect::new(x + 1.0, divider, w - 2.0, 1.0), Split::Network); + // 详情从完整保留记录中查找,因此筛选隐藏某行后可继续查看已选详情;记录淘汰后才回提示。 let selected = records .iter() .find(|r| Some(r.id) == state.selected_request); @@ -113,6 +119,7 @@ pub(super) fn build( MUTED, ); } + // 请求列表与详情使用不同 Region,各自维护滚动量,避免切换详情影响列表定位。 p.lines( Rect::new(x + 1.0, divider + 5.0, w - 2.0, bottom - divider - 5.0), Region::Details, diff --git a/src/shell/developer_tools/painter.rs b/src/shell/developer_tools/painter.rs index 240d66f..f9b6eaa 100644 --- a/src/shell/developer_tools/painter.rs +++ b/src/shell/developer_tools/painter.rs @@ -9,16 +9,17 @@ pub(super) struct Painter { pub(super) layout: DeveloperToolsLayout, } impl Painter { - /// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。 + /// 向宿主显示列表追加填充矩形,不登记点击或滚动目标。 pub(super) fn fill(&mut self, r: Rect, c: Color) { self.ui.fill(r, c); } - /// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。 + /// 绘制工具文字,code 选择等宽风格;不在此处截断文本或登记目标。 pub(super) fn label(&mut self, x: f32, y: f32, value: &str, c: Color, code: bool) { self.ui.label(x, y, value, c, code); } - /// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。 + /// 创建普通或切换按钮,active 作为选中状态参与外观。 pub(super) fn button(&mut self, r: Rect, label: &str, action: Action, active: bool) { + // Follow 与数据源切换使用 Toggle 语义,其余动作使用普通按钮角色;身份仍由 action 决定。 let role = match action { Action::Follow | Action::Snapshot | Action::Cloud => Role::Toggle, _ => Role::Button, @@ -26,17 +27,18 @@ impl Painter { self.ui .button(r, action, Button::new(label).selected(active).role(role)); } - /// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。 + /// 创建带启用条件的按钮,禁用项仍可显示但不能激活动作。 pub(super) fn enabled_button(&mut self, r: Rect, label: &str, action: Action, enabled: bool) { self.ui .button(r, action, Button::new(label).enabled(enabled)); } - /// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。 + /// 记录待在最终覆盖层绘制的分隔条及其拖动类别,此处不立即画线。 pub(super) fn split(&mut self, r: Rect, kind: Split) { // Paint and register splitters after all pane content, in one overlay pass. + // 此处只记录分隔条布局,外层最后统一覆盖绘制/注册,保证线条与命中目标位于内容之上。 self.layout.splits.push((r, kind)); } - /// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。 + /// 测量并缓存完整行宽,钳制滚动,仅绘制可见行,同时登记区域、选择和两轴滚动条。 pub(super) fn lines( &mut self, area: Rect, @@ -45,17 +47,20 @@ impl Painter { state: &mut DeveloperToolsState, follow: bool, ) { + // 按固定行高估计完整内容高度并预留上下边距,不仅按当前可见行计算。 let content_h = lines.len() as f32 * ROW + 12.0; use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); for line in lines { line.value.hash(&mut hash); } + // 宽度缓存只依赖行文本指纹,颜色/选择变化无需重新测量;文字样式在此固定为 code。 let fingerprint = hash.finish(); let cache = &mut state.widths[region as usize]; let content_w = match *cache { Some((previous, width)) if previous == fingerprint => width, _ => { + // 失配时测量所有行的最大宽度并加边距;后面虽然只画可见行,横向范围仍反映完整内容。 let width = lines .iter() .map(|line| text::measure(&line.value, TextStyle::code()).width + 20.0) @@ -65,18 +70,21 @@ impl Painter { } }; let s = &mut state.scroll[region as usize]; + // Follow 跳到内容末端;否则只钳掉内容缩短后越界的旧偏移,保留用户当前阅读位置。 s.y = if follow { (content_h - area.height + 12.0).max(0.0) } else { s.y.clamp(0.0, (content_h - area.height + 12.0).max(0.0)) }; s.x = s.x.clamp(0.0, (content_w - area.width + 12.0).max(0.0)); + // 裁剪扣掉右/下 9 单位滚动条空间,避免行文字覆盖滚动手柄。 self.ui.list.push(DrawCommand::PushClip(Rect::new( area.x, area.y, (area.width - 9.0).max(0.0), (area.height - 9.0).max(0.0), ))); + // 源码行前七字符为显示行号与间隔,选择数据去掉这段;输入法/Console 编辑使用其他状态。 if region == Region::Source { let values = lines .iter() @@ -92,6 +100,7 @@ impl Painter { values, )); } + // 按滚动量只遍历可见行,并多取两行覆盖边缘部分行;完整文本仍保留供测量和复制。 let first = (s.y / ROW).floor() as usize; let count = (area.height / ROW).ceil() as usize + 2; for (i, line) in lines.iter().enumerate().skip(first).take(count) { @@ -109,6 +118,7 @@ impl Painter { if let Some(action) = &line.action { let top = y.max(area.y); let bottom = (y + ROW).min(area.y + area.height - 9.0); + // 部分可见行只注册可见矩形,避免裁剪虽隐藏图形、交互区域却仍延伸到面板之外。 if bottom > top { self.ui.row( paint, @@ -120,6 +130,7 @@ impl Painter { } else if line.selected { self.fill(paint, self.ui.theme.soft()); } + // 源码字素选区单独叠加高亮;行选择与文字选择共存,位置由去掉行号后的 origin 计算。 if region == Region::Source && let Some((_, origin, values)) = &self.layout.source && let Some((left, right)) = state.source_selection.span( @@ -136,13 +147,14 @@ impl Painter { self.label(area.x + 8.0 - s.x, y, &line.value, line.color, true); } self.ui.list.push(DrawCommand::PopClip); + // 记录完整内容范围给滚轮/拖动逻辑,再在裁剪弹出后绘制两轴滚动条,避免手柄被内容裁剪掉。 self.layout .regions .push((area, region, content_w, content_h)); self.scrollbar(area, region, false, s.y, content_h); self.scrollbar(area, region, true, s.x, content_w); } - /// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。 + /// 按内容/视口比例绘制单轴滚动手柄,并登记拖动到内容位移的换算倍率。 pub(super) fn scrollbar( &mut self, r: Rect, @@ -151,13 +163,16 @@ impl Painter { offset: f32, content: f32, ) { + // h 统一复用横纵轴公式;先扣除另一轴手柄占用,内容不溢出或视口无面积时不创建滚动条。 let viewport = if h { r.width } else { r.height } - 10.0; if content <= viewport || viewport <= 0.0 { return; } + // 手柄按 viewport/content 比例缩放,至少 24 且不大于轨道;travel 和 maximum 至少 1 防止除零。 let length = (viewport * viewport / content).max(24.0).min(viewport); let travel = (viewport - length).max(1.0); let maximum = (content - viewport).max(1.0); + // 内容偏移归一化后映射到手柄可移动距离;两者并非一比一像素关系。 let position = (offset / maximum).clamp(0.0, 1.0) * travel; let track = if h { Rect::new(r.x, r.y + r.height - 8.0, viewport, 8.0) @@ -177,6 +192,7 @@ impl Painter { ); self.layout .thumbs + // 保存每单位手柄拖动对应的内容位移倍率,事件处理可直接反算滚动量。 .push((thumb, region, h, maximum / travel)); } } @@ -196,7 +212,7 @@ pub(super) enum Divider { Neutral, } impl Line { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 创建默认无分隔线、未选中且无点击动作的显示行。 pub(super) fn new(value: impl Into, color: Color) -> Self { Self { value: value.into(), @@ -207,7 +223,7 @@ impl Line { } } } -/// 按行绘制工具文本,返回后续布局需要的占用范围。 +/// 将文本按换行分成显示行并展开 Tab,追加到 out;不直接绘制,也不返回布局范围。 pub(super) fn multiline(out: &mut Vec, value: &str, color: Color) { out.extend( value @@ -218,7 +234,9 @@ pub(super) fn multiline(out: &mut Vec, value: &str, color: Color) { } /// 把一条 Console 结果投影为可滚动、可选择的文字画面。 pub(super) fn console_result(out: &mut Vec, body: &str, color: Color) { + // 响应若为 JSON,按 reset、value、diagnostics、error、revision 优先提取可读结果;不满足时退回原文。 if let Ok(value) = serde_json::from_str::(body) { + // 先显示 REPL 是否发布新页面或仅保留内存状态,再显示实际结果/诊断,避免把成功执行等同于重绘。 if value["execution"] == "repl" { out.push(Line::new( if value["rendered"] == true { @@ -281,6 +299,7 @@ pub(super) fn console_result(out: &mut Vec, body: &str, color: Color) { } /// 将代码层次展开为工具可绘制的行数据,保留节点与源范围关联。 pub(super) fn flatten<'a>(node: &'a CodeNode, depth: usize, out: &mut Vec<(usize, &'a CodeNode)>) { + // 先序展开保持 AST 子节点顺序,depth 供显示缩进;借用原节点而不复制整棵树。 out.push((depth, node)); for child in &node.children { flatten(child, depth + 1, out); @@ -303,6 +322,7 @@ pub(super) fn request_details(out: &mut Vec, r: &Exchange) { ), TEXT, ); + // 固定次序展示请求头、请求正文、响应头、响应正文;这里只消费已经截断的观察记录。 for (title, body) in [ ("REQUEST HEADERS", &r.request_headers), ("REQUEST BODY", &r.request), diff --git a/src/shell/developer_tools/selection.rs b/src/shell/developer_tools/selection.rs index 81bd1c8..6007e83 100644 --- a/src/shell/developer_tools/selection.rs +++ b/src/shell/developer_tools/selection.rs @@ -12,13 +12,15 @@ use unicode_segmentation::UnicodeSegmentation; /// 行文本、锚点与选择端点,既供鼠标拖选也供键盘导航使用。 pub(super) struct SourceSelection { lines: Vec, + // 端点是 (行号, UTF-8 字节偏移),字节位置始终由完整字素边界产生,不用字符数直接切片。 anchor: (usize, usize), end: (usize, usize), pub dragging: bool, } impl SourceSelection { - /// 替换可选择文本行,并把旧选择限制在新文本仍有效的位置。 + /// 同步展示文本;内容变化时重置选择和拖动,内容完全相同时保留原选区。 pub fn set_lines(&mut self, lines: &[String]) { + // 文本变化时重置两端与拖选状态;当前实现不是把旧选区钳到新文本,避免对应内容已经改变。 if self.lines != lines { self.lines = lines.to_vec(); self.anchor = (0, 0); @@ -28,6 +30,7 @@ impl SourceSelection { } /// 把工具指针位置换算成合法行号与文本字符边界。 fn locate(p: Point, origin: Point, lines: &[String]) -> (usize, usize) { + // 按固定行高求行号,向上下界钳制;空文本通过 saturating_sub 与后续 get 安全返回原点。 let row = (((p.y - origin.y) / super::ROW).floor().max(0.0) as usize) .min(lines.len().saturating_sub(1)); let Some(line) = lines.get(row) else { @@ -35,6 +38,7 @@ impl SourceSelection { }; let x = (p.x - origin.x).max(0.0); let mut byte = line.len(); + // 逐个字素测量左右边界,以中点决定插入位置,点击超过行尾则保留 byte=line.len()。 for (i, g) in line.grapheme_indices(true) { let left = text::measure(&line[..i], TextStyle::code()).width; let right = text::measure(&line[..i + g.len()], TextStyle::code()).width; @@ -47,6 +51,7 @@ impl SourceSelection { } /// 在指针位置建立文本选择锚点,开始一次拖动选择。 pub fn begin(&mut self, p: Point, origin: Point, lines: &[String]) { + // 调用方提供的 lines 与当前展示一致;开始拖动先折叠旧选择,后续 extend 只移动活动端。 self.anchor = Self::locate(p, origin, lines); self.end = self.anchor; self.dragging = true; @@ -66,6 +71,7 @@ impl SourceSelection { let (mut row, mut byte) = self.end; let line = self.lines.get(row)?; match key { + // 水平移动按字素跨越,组合音标和 emoji 视作整体;到行边界后跨到相邻行的末尾/开头。 ArrowLeft => { if byte > 0 { byte = line[..byte] @@ -85,6 +91,7 @@ impl SourceSelection { byte = 0; } } + // 垂直移动保持的是字素列数,不是像素 x;目标行较短时取尽其字素,落到行尾。 ArrowUp | ArrowDown => { let col = line[..byte].graphemes(true).count(); row = if *key == ArrowUp { @@ -98,6 +105,7 @@ impl SourceSelection { .map(str::len) .sum(); } + // 普通 Home/End 选本行边界,command 修饰时转到整个文本首/末行。 Home => { if command { row = 0; @@ -113,6 +121,7 @@ impl SourceSelection { _ => return None, } self.end = (row, byte); + // 未按 Shift 时同步锚点以折叠选区;按 Shift 则仅移动 end,保持原锚点扩展选择。 if !shift { self.anchor = self.end; } @@ -129,6 +138,7 @@ impl SourceSelection { ), ); } + // 元组字典序先比行号再比字节位置,统一处理从后向前拖选与从前向后拖选。 (self.anchor.min(self.end), self.anchor.max(self.end)) } /// 拼接当前选择范围内的实际文本,保持多行换行与 UTF-8 边界。 @@ -139,6 +149,7 @@ impl SourceSelection { .enumerate() .filter(|(i, _)| *i >= a.0 && *i <= b.0) .map(|(i, line)| { + // 首尾行只取选中片段,中间行取完整文本;用安全 get 防止不一致范围导致切片 panic。 let start = if i == a.0 { a.1 } else { 0 }; let end = if i == b.0 { b.1 } else { line.len() }; line.get(start..end).unwrap_or("").to_owned() @@ -149,6 +160,7 @@ impl SourceSelection { /// 取得某行的可见选择片段,供高亮绘制复用。 pub fn span(&self, row: usize, all: bool, lines: &[String]) -> Option<(f32, f32)> { let (a, b) = self.range(all); + // 未选中的行或折叠选择不画高亮;有选择时返回两个真实字体测量的横向位置,不是宽度值。 if row < a.0 || row > b.0 || a == b { return None; } diff --git a/src/shell/developer_tools/settings.rs b/src/shell/developer_tools/settings.rs index 3ac5afa..74dd411 100644 --- a/src/shell/developer_tools/settings.rs +++ b/src/shell/developer_tools/settings.rs @@ -3,7 +3,7 @@ //! use super::*; -/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。 +/// 绘制强调色候选、自定义颜色编辑框及主题预览;实际应用和错误校验由输入动作处理。 pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport: Rect) { let x = viewport.x; let w = viewport.width; @@ -16,6 +16,7 @@ pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport: MUTED, false, ); + // 色块动作携带调色板索引,绘制仅比较当前 accent 标记选中;实际修改由统一动作处理器执行。 for (i, color) in PALETTE.iter().enumerate() { let r = Rect::new(x + 24.0 + i as f32 * 44.0, 122.0, 32.0, 32.0); p.ui.swatch(r, Action::Theme(i), *color, *color == state.accent); @@ -27,6 +28,7 @@ pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport: MUTED, false, ); + // 自定义颜色框显示尚未应用的编辑文本;聚焦只改变外观和输入接收位置,不在这里解析 hex。 let custom = Rect::new(x + 24.0, 204.0, 200.0, 32.0); p.ui.text_field( custom, @@ -49,11 +51,13 @@ pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport: true, ); + // 校验错误由输入处理路径写入,设置页只显示;错误不会在每次重绘时重复执行或清除。 if !state.input_error.is_empty() { p.label(x + 24.0, 245.0, &state.input_error, ERROR, false); } p.fill(Rect::new(x + 24.0, 285.0, w - 48.0, 1.0), BORDER); p.label(x + 24.0, 305.0, "Theme preview", TEXT, false); + // 预览复用当前强调色和标准成功色,让用户看到主题如何影响执行分隔线;示例文字不送入 VM。 p.fill(Rect::new(x + 24.0, 340.0, w - 48.0, 2.0), state.accent); p.label(x + 32.0, 353.0, "› \"Hello\"", TEXT, true); p.label(x + 32.0, 380.0, "‹ \"Hello\"", OK, true); diff --git a/src/shell/developer_tools/sources.rs b/src/shell/developer_tools/sources.rs index fd87b3b..f55f88d 100644 --- a/src/shell/developer_tools/sources.rs +++ b/src/shell/developer_tools/sources.rs @@ -3,7 +3,7 @@ //! use super::*; -/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。 +/// 根据云文件/当前快照模式构建左右两栏,展示文件或 AST 选择,并生成只读源码行和跳转动作。 pub(super) fn build( p: &mut Painter, state: &mut DeveloperToolsState, @@ -12,6 +12,7 @@ pub(super) fn build( ) { let x = viewport.x; let w = viewport.width; + // width 在本函数表示右边界坐标,w 才是视口宽度;用于计算右侧源码区域和按钮位置。 let width = viewport.x + viewport.width; let bottom = viewport.height - 22.0; @@ -28,6 +29,7 @@ pub(super) fn build( Action::Cloud, state.cloud, ); + // 两种数据源分开投影:云模式用 provider 的 files/opened,快照模式只读已发布文档 source/root。 if state.cloud { p.button( Rect::new(width - 92.0, 32.0, 88.0, 29.0), @@ -35,6 +37,7 @@ pub(super) fn build( Action::CloudRefresh, false, ); + // 树宽至少 120,通常为右侧内容保留 180;窄窗口仍保持树最小宽,交由绘制裁剪处理。 let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0)); let code_x = x + tree + 2.0; p.label(x + 10.0, 69.0, "CLOUD FILES", MUTED, false); @@ -44,6 +47,7 @@ pub(super) fn build( .iter() .map(|file| { let mut l = Line::new(&file.path, TEXT); + // 动作传递 provider 的不透明文件 ID,显示路径不参与本地磁盘读取或代码执行。 l.action = Some(Action::CloudOpen(file.id.clone())); l }) @@ -57,6 +61,7 @@ pub(super) fn build( ); p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree); let mut source_lines = Vec::new(); + // 成功打开的云文本逐行加显示行号;这里不替换 doc.source,也不触发程序热重载。 if let Some(source) = &state.cloud_sources.opened { p.label(code_x + 10.0, 69.0, &source.path, MUTED, false); for (i, line) in source.code.split('\n').enumerate() { @@ -69,6 +74,7 @@ pub(super) fn build( MUTED, ); } + // provider 错误附在当前源码下方;即使读取失败仍可展示上次成功打开的内容。 if !state.cloud_sources.error.is_empty() { source_lines.push(Line::new(&state.cloud_sources.error, ERROR)); } @@ -91,6 +97,7 @@ pub(super) fn build( if doc .root .as_ref() + // REPL 文档展示为交互记录,普通脚本展示为 app.main.endor;仅改变标题,不合成另一份源码。 .is_some_and(|root| root.kind == "repl-session") { "Console transcript" @@ -105,6 +112,7 @@ pub(super) fn build( let mut tree_lines = Vec::new(); if let Some(root) = &doc.root { let mut flat = Vec::new(); + // 将发布 AST 展平成带深度的行;行动作保存节点稳定 ID 与起始行,用于选择并跳到对应代码。 flatten(root, 0, &mut flat); for (depth, n) in flat { let mut l = Line::new(format!("{}{} {}", " ".repeat(depth), n.kind, n.name), TEXT); @@ -123,23 +131,27 @@ pub(super) fn build( false, ); p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree); + // 每次重绘按当前文档查节点,旧选择 ID 若已不存在则不高亮,避免沿用旧 AST 行范围。 let selected = state .selected_node_id .as_deref() .and_then(|id| doc.node(id)); let code_lines = doc .source + // 仅显示投影统一 CRLF 并把 Tab 展开为四空格;原始文档文本保持不变。 .replace("\r\n", "\n") .split('\n') .enumerate() .map(|(i, s)| { let mut l = Line::new(format!("{:>5} {}", i + 1, s.replace('\t', " ")), TEXT); + // i 是零基行号,AST 行号是一基;条件等价于 start_line <= i+1 <= end_line。 l.selected = selected.is_some_and(|n| i + 1 >= n.start_line && i < n.end_line); l }) .collect::>(); p.lines( Rect::new(code_x, 92.0, width - code_x, bottom - 92.0), + // 源码区与 AST 树各自保存滚动位置,选择节点并不把树文本误当作源码内容。 Region::Source, &code_lines, state, diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 0e8d0b8..de9c57b 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -1,13 +1,22 @@ //! 原生宿主模块入口。窗口、管理控件、系统适配和开发工具状态与用户业务界面分开维护。 //! -// Module ownership follows the directory; crate-root aliases keep internal call sites stable. +// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。 +/// 保存运行活动、请求和诊断记录,供宿主开发工具展示。 pub(crate) mod activity; +/// 实现 winit 应用生命周期、窗口协调、输入路由和 softbuffer 像素呈现。 pub(crate) mod app; +/// 调用平台剪贴板工具读写文本,向编辑器返回明确错误。 pub(crate) mod clipboard; +/// 组合控制台、源码、网络和设置工具的状态、交互与绘制。 pub(crate) mod developer_tools; #[cfg(target_os = "macos")] +/// 仅在 macOS 编译,调整原生交通灯按钮并处理标题栏平台行为。 pub(crate) mod native_chrome; +/// 维护页面/工具入口列表、折叠宽度和命中目标;可用页面宽度需扣除其占用。 pub(crate) mod sidebar; +/// 从源码与诊断生成可浏览的结构信息,供 Sources 工具定位对象和文本。 pub(crate) mod sources; +/// 提供宿主管理界面专用的主题、控件和交互状态,和 Endor 业务界面共享底层绘图能力。 pub(crate) mod system_ui; +/// 提供页面标签投影及相关数据处理,页面业务身份仍由运行时维护。 pub(crate) mod tabs; diff --git a/src/shell/native_chrome.rs b/src/shell/native_chrome.rs index dbb2ed7..c6c66c9 100644 --- a/src/shell/native_chrome.rs +++ b/src/shell/native_chrome.rs @@ -13,6 +13,7 @@ use winit::{ /// action automatically. Use the native click count and native window actions. /// 读取系统偏好的标题栏双击行为,委托原生窗口执行相应动作。 pub fn titlebar_double_click(window: &Window) -> bool { + // AppKit 必须在主线程调用;不在主线程、无有效句柄或句柄非 AppKit 时返回未处理。 let Some(main_thread) = MainThreadMarker::new() else { return false; }; @@ -28,6 +29,7 @@ pub fn titlebar_double_click(window: &Window) -> bool { let Some(event) = NSApplication::sharedApplication(main_thread).currentEvent() else { return false; }; + // 依赖系统原生连击计数,不自行推算两次输入时间,保持用户设置与系统行为一致。 if event.clickCount() != 2 { return false; } @@ -35,12 +37,14 @@ pub fn titlebar_double_click(window: &Window) -> bool { let Some(native) = view.window() else { return false; }; + // 全屏时把双击视作已处理,但不执行缩放/最小化,避免自绘标题行改变全屏状态。 if native.styleMask().contains(NSWindowStyleMask::FullScreen) { return true; } let action = NSUserDefaults::standardUserDefaults() .stringForKey(&NSString::from_str("AppleActionOnDoubleClick")) .map(|value| value.to_string()); + // 按系统偏好执行动作;缺省采用 Zoom,禁用或未知选项不动作,但仍返回已处理。 match action.as_deref() { Some("Minimize") => native.performMiniaturize(None), None | Some("Maximize" | "Fill") => native.performZoom(None), @@ -74,6 +78,7 @@ pub fn align_controls(window: &Window) { } let bounds = view.bounds(); let center = f64::from(crate::sidebar::HEADER) / 2.0; + // AppKit 视图可能以左下或左上为原点;先在内容视图内算标题行中心,再转到按钮父视图。 let content_y = if view.isFlipped() { bounds.origin.y + center } else { @@ -90,10 +95,12 @@ pub fn align_controls(window: &Window) { let Some(parent) = button.superview() else { continue; }; + // 按钮可能位于不同 AppKit 容器,必须使用 convertPoint 而不能直接赋内容视图坐标。 let target = parent.convertPoint_fromView(NSPoint::new(0.0, content_y), Some(view)); let frame = button.frame(); let y = target.y - frame.size.height / 2.0; // Preserve native button size, horizontal spacing and behavior. + // 只改变足够明显的纵向偏差,保留系统按钮横向间距、尺寸与原生动作,避免反复微调布局。 if (frame.origin.y - y).abs() > 0.01 { button.setFrameOrigin(NSPoint::new(frame.origin.x, y)); } diff --git a/src/shell/sidebar.rs b/src/shell/sidebar.rs index 977382b..7506c20 100644 --- a/src/shell/sidebar.rs +++ b/src/shell/sidebar.rs @@ -13,8 +13,10 @@ use crate::{ tabs::Tab, }; +// 展开侧栏固定逻辑宽度;实际设备像素比例由后续显示列表缩放处理。 pub const WIDTH: f32 = 256.0; pub const HEADER: f32 = 40.0; +// macOS 标题行预留原生三键安全区域,其他平台无需这段横向留白。 pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -32,6 +34,7 @@ pub struct State { pub open: bool, pub interaction: Interaction, pub targets: Vec>, + // 只滚动普通页面列表,顶部工具入口、折叠按钮和底部工作区标签保持固定。 pub scroll: f32, } @@ -55,6 +58,8 @@ impl State { /// 判断指针是否落入宿主侧栏/展开按钮区域,保护页面输入边界。 pub fn covers(&self, point: Point) -> bool { + // 展开时整个侧栏横向带均拦截页面输入,y 的窗口有效范围由外层事件入口保证。 + // 折叠时只覆盖顶部三键安全区与展开按钮,不继续挡住左侧整列页面。 if self.open { point.x >= 0.0 && point.x < WIDTH } else { @@ -69,10 +74,12 @@ impl State { height: f32, accent: crate::display_list::Color, ) -> DisplayList { + // 每帧重新生成控件目标与显示列表,保留 Interaction 的稳定身份状态,而不是复用上帧屏幕矩形。 let mut ui = Ui::new(Theme::new(accent), self.interaction.clone()); if self.open { ui.fill(Rect::new(0.0, 0.0, WIDTH, height), theme::BAR); } + // 展开时按钮靠侧栏右边,折叠时靠原生按钮安全区右侧,给页面释放左边宽度。 let toggle_x = if self.open { WIDTH - 40.0 } else { @@ -107,6 +114,7 @@ impl State { .into_iter() .enumerate() { + // 工具入口的选中状态来自已发布标签,而非仅凭最后点击动作推断,避免请求失败后显示错误选中。 let selected = tabs .iter() .any(|tab| tab.active && tab.developer_tool.as_deref() == Some(name)); @@ -118,10 +126,12 @@ impl State { } ui.label(20.0, 303.0, "页面", theme::MUTED, false); let area = Rect::new(8.0, 333.0, WIDTH - 16.0, (height - 383.0).max(0.0)); + // 开发工具已由固定入口展示,因此页面列表仅收集普通页,保持原注册表顺序。 let pages: Vec<_> = tabs .iter() .filter(|tab| tab.developer_tool.is_none()) .collect(); + // 页面关闭或窗口增高后重新钳制旧偏移,内容不足视口时回零,防止列表留在空白区域。 self.scroll = self .scroll .clamp(0.0, (pages.len() as f32 * 36.0 - area.height).max(0.0)); @@ -129,11 +139,13 @@ impl State { for (index, tab) in pages.into_iter().enumerate() { let y = area.y + index as f32 * 36.0 - self.scroll; // Register only fully visible rows: paint clipping must match input. + // 当前实现完全跳过部分可见行,绘制与注册都不发生;Clip 仍保护其他原语不溢出列表区域。 if y < area.y || y + 32.0 > area.y + area.height { continue; } ui.button( Rect::new(12.0, y, WIDTH - 56.0, 32.0), + // 切换和关闭动作携带稳定页面 ID,滚动或标题变化不会改变动作身份。 Action::Activate(tab.id), Button::new(&tab.title).selected(tab.active), ); @@ -146,6 +158,7 @@ impl State { ui.list.push(DrawCommand::PopClip); ui.label(20.0, height - 32.0, "本地工作区", theme::MUTED, false); } + // 重建目标后校正跨帧捕获/焦点;折叠会移除旧页面和工具目标,不能在释放时激活已隐藏控件。 self.targets = ui.targets; self.interaction.reconcile(&self.targets); ui.list @@ -156,12 +169,14 @@ impl State { mod tests { use super::*; #[test] + /// 先展开绘制再折叠重绘,检查交互目标只剩 Toggle、页面左侧让宽归零,其他区域不再拦截指针。 fn collapsing_removes_every_host_target_except_the_toggle() { let mut sidebar = State::default(); sidebar.draw(&[], 800.0, theme::PALETTE[0]); assert!(sidebar.targets.len() > 1); sidebar.open = false; sidebar.draw(&[], 800.0, theme::PALETTE[0]); + // 折叠后仅保留 Toggle,且页面不再让出左宽;顶部按钮区域之外的指针不被侧栏拦截。 assert_eq!(sidebar.targets.len(), 1); assert_eq!(sidebar.targets[0].action, Some(Action::Toggle)); assert_eq!(sidebar.left(), 0.0); diff --git a/src/shell/sources.rs b/src/shell/sources.rs index 0562102..07ada17 100644 --- a/src/shell/sources.rs +++ b/src/shell/sources.rs @@ -8,7 +8,9 @@ use anyhow::Result; #[derive(Clone, Debug)] /// provider 提供的一条文件元数据,供云文件列表显示。 pub struct SourceFile { + // provider 定义的不透明文件身份,后续 open_file 使用;显示路径不必等于该身份。 pub id: String, + // 面向用户显示的路径文本,不能据此推断为本机可直接读取的文件路径。 pub path: String, } #[derive(Clone, Debug)] @@ -19,8 +21,9 @@ pub struct OpenedSource { } /// 外部文件能力接口;只负责列出和读取,不与 VM 全局环境耦合。 +// 由宿主注入具体实现;Send 允许转移线程,但方法使用 &mut self,不声明可并行共享调用。 pub trait CloudSourceProvider: std::fmt::Debug + Send { - /// 通过当前 provider 请求文件列表;默认未连接实现返回明确不可用状态。 + /// 返回 provider 当前可列出的文件;未连接状态由 SourceWorkspace 的 Option 表达。 fn list_files(&mut self) -> Result>; /// 通过 provider 取得指定文件内容,不在工具查看动作中执行该内容。 fn open_file(&mut self, id: &str) -> Result; @@ -28,6 +31,7 @@ pub trait CloudSourceProvider: std::fmt::Debug + Send { #[derive(Debug, Default)] /// Sources 云文件页面保留的 provider、列表、选择、内容与错误状态。 pub struct SourceWorkspace { + // None 表示未接入外部文件服务;默认工作区不提供虚构文件或隐式认证。 pub provider: Option>, pub files: Vec, pub opened: Option, @@ -36,10 +40,12 @@ pub struct SourceWorkspace { impl SourceWorkspace { /// 更新云文件列表并保存结果或错误,供 Sources 页面显示。 pub fn refresh(&mut self) { + // 未连接时明确写错误并返回;已有文件列表/打开内容不会在失败路径中被清除。 let Some(provider) = &mut self.provider else { self.error = "No cloud file provider configured".into(); return; }; + // 调用是同步的,调用方负责安排执行位置;成功才整体替换列表,失败保留旧列表供查看。 match provider.list_files() { Ok(files) => { self.files = files; @@ -49,10 +55,12 @@ impl SourceWorkspace { } } /// 更新选中文件的内容与打开状态,失败保持可读错误信息。 + // 只读取内容而不执行代码;未连接时直接返回,不像 refresh 那样写入新的错误。 pub fn open(&mut self, id: &str) { let Some(provider) = &mut self.provider else { return; }; + // 成功替换 opened 并清错;失败只更新 error,用户仍可查看之前成功打开的内容。 match provider.open_file(id) { Ok(source) => { self.opened = Some(source); diff --git a/src/shell/system_ui/interaction.rs b/src/shell/system_ui/interaction.rs index 29d47f7..ccc3152 100644 --- a/src/shell/system_ui/interaction.rs +++ b/src/shell/system_ui/interaction.rs @@ -9,8 +9,9 @@ use std::hash::{Hash, Hasher}; /// 由语义 key 生成的稳定控件身份,跨帧移动和文字变化不应改变它。 pub struct WidgetId(u64); impl WidgetId { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 对调用方语义 key 求哈希,生成不随控件位置或标签变化的跨帧 WidgetId;相同 key 会得到同一身份。 pub fn new(key: impl Hash) -> Self { + // 语义 key 哈希为会话内身份;不依赖布局坐标,但调用方仍需避免重复 key,哈希并非全局唯一保证。 let mut hash = std::collections::hash_map::DefaultHasher::new(); key.hash(&mut hash); Self(hash.finish()) @@ -37,6 +38,7 @@ pub enum Role { impl Role { /// 判断控件角色是否可以接受键盘焦点。 fn focusable(self) -> bool { + // 行用于指针选择,分隔条/滚动条用于拖动,这三类不加入键盘 Tab 焦点循环。 !matches!(self, Self::Splitter(_) | Self::Scrollbar(_) | Self::Row) } } @@ -77,15 +79,19 @@ pub struct VisualState { pub struct Interaction { pointer: Option, hovered: Option, + // 按下时捕获的控件;即使指针移动到别处,也保留原身份直到释放或取消。 pointer_active: Option, + // 键盘按下时的焦点身份;释放时再次匹配,避免焦点已变却激活新控件。 key_active: Option, focused: Option, + // 区分键盘导航和鼠标获得焦点,visual 仅为键盘来源显示焦点外观。 keyboard_focus: bool, } impl Interaction { /// 按逆绘制顺序选择指针处的管理控件,避免重叠控件同时激活。 pub fn hit<'a, A>(&self, targets: &'a [Target]) -> Option<&'a Target> { let p = self.pointer?; + // 几何命中包含禁用控件,因此上层禁用项会挡住下层项;是否允许操作由 press/cursor 分别判断。 targets.iter().rev().find(|t| t.rect.contains(p)) } /// 更新指针与悬停目标,但不在移动时直接触发点击。 @@ -95,11 +101,13 @@ impl Interaction { } /// 清除离开窗口后的指针/悬停状态。 pub fn pointer_left(&mut self) { + // 离开窗口只清位置和悬停,保留捕获身份供外层拖动流程使用;完整失焦由 blur 处理。 self.pointer = None; self.hovered = None; } /// 捕获启用控件的稳定身份,并按角色更新键盘焦点。 pub fn press(&mut self, targets: &[Target]) -> Option> { + // 鼠标按下撤销旧键盘激活并关闭键盘焦点外观;点空白或不可聚焦角色会清除 focused。 self.key_active = None; self.keyboard_focus = false; let target = self.hit(targets).filter(|t| t.enabled).cloned(); @@ -109,6 +117,7 @@ impl Interaction { } /// 只有释放时仍处于原启用目标内才返回 action,拖出后释放不触发。 pub fn release(&mut self, targets: &[Target]) -> Option { + // take 先消费捕获,保证同一次按下最多激活一次;TextField 不走按钮 release action。 let active = self.pointer_active.take()?; self.hit(targets) .filter(|t| t.enabled && t.id == active && t.role != Role::TextField) @@ -134,6 +143,7 @@ impl Interaction { self.focused, self.key_active, ); + // 重建 UI 后必须仍存在且启用才保留焦点/捕获;hover 则依据当前指针重新命中,包含禁用项。 let exists = |id| targets.iter().any(|t| t.id == id && t.enabled); self.focused = self.focused.filter(|id| exists(*id)); self.pointer_active = self.pointer_active.filter(|id| exists(*id)); @@ -155,6 +165,7 @@ impl Interaction { } /// 沿启用且可聚焦的目标循环导航,支持正向与反向遍历。 pub fn focus_next(&mut self, targets: &[Target], back: bool) -> Option> { + // 键盘导航先取消未完成按压,避免切换焦点后补发旧激活;候选顺序沿本帧目标顺序。 self.cancel(); self.keyboard_focus = true; let candidates = targets @@ -166,6 +177,7 @@ impl Interaction { return None; } let current = candidates.iter().position(|t| Some(t.id) == self.focused); + // 有当前焦点时环绕前进/后退;无有效焦点时正向选首项、反向选末项。 let index = match current { Some(i) if back => (i + candidates.len() - 1) % candidates.len(), Some(i) => (i + 1) % candidates.len(), @@ -185,6 +197,7 @@ impl Interaction { } /// 结束键盘激活并按有效控件身份返回操作。 pub fn key_release(&mut self, targets: &[Target]) -> Option { + // 只有释放时仍聚焦相同启用目标才返回动作;take 防止重复 key-up 重复激活。 let active = self.key_active.take()?; self.focused(targets) .filter(|t| t.id == active) @@ -192,6 +205,7 @@ impl Interaction { } /// 组合悬停、按下、焦点、选中和启用状态,供控件选择正确样式。 pub fn visual(&self, id: WidgetId, selected: bool, enabled: bool) -> VisualState { + // 禁用控件不显示交互状态;指针按压需仍悬停才显示 pressed,拖出后保留捕获但取消按下外观。 let hovered = enabled && self.hovered == Some(id); VisualState { hovered, @@ -208,6 +222,7 @@ impl Interaction { } /// 按命中角色选择系统光标,例如文本、分隔条或可点击按钮。 pub fn cursor(&self, targets: &[Target]) -> Cursor { + // 拖动分隔条/滚动条时优先沿用捕获目标的光标,避免指针跨过其他控件导致拖动光标跳变。 let target = self .pointer_active .and_then(|id| { diff --git a/src/shell/system_ui/mod.rs b/src/shell/system_ui/mod.rs index 9fd86ea..31c65a1 100644 --- a/src/shell/system_ui/mod.rs +++ b/src/shell/system_ui/mod.rs @@ -1,9 +1,12 @@ //! 宿主管理控件的共享组件入口。主题、绘制原语和跨帧交互状态分离,用户 Endor 页面不依赖这里的控件树。 //! -//! Tessera's host management widgets. No Endor AST, VM, UiNode or ui_bridge dependency. -//! Shares only low-level drawing, font shaping and geometry with guest content. +//! 此模块不依赖 Endor AST、VM、UiNode 或 ui_bridge;只复用底层绘图、字体塑形和几何。 +// 跨帧保存 hover/press/focus,管理命中目标、键盘导航与控件视觉状态。 mod interaction; +// 提供管理界面统一的颜色、尺寸和排版常量,避免各工具各自定义主题。 pub mod theme; +// 把控件状态转换为显示列表和交互目标;本模块不承担用户源程序的执行。 mod widgets; +// 向上层隐藏内部文件布局,统一导出交互状态与控件类型。 pub use interaction::{Axis, Cursor, Interaction, Role, Target, VisualState, WidgetId}; pub use widgets::{Button, Ui}; diff --git a/src/shell/system_ui/theme.rs b/src/shell/system_ui/theme.rs index ec8788d..a2f83d1 100644 --- a/src/shell/system_ui/theme.rs +++ b/src/shell/system_ui/theme.rs @@ -1,6 +1,7 @@ //! 宿主控件的颜色与状态调色。根据背景、强调色和交互状态生成可读颜色,不改变业务状态。 //! use crate::display_list::Color; +// 宿主管理界面的白色基础背景;这些常量不会修改业务页面自己的颜色。 pub const BG: Color = Color::rgb(255, 255, 255); // A visibly separate chrome layer against the warm, near-white content canvas. pub const BAR: Color = Color::rgb(230, 233, 237); @@ -9,6 +10,7 @@ pub const TEXT: Color = Color::rgb(35, 42, 53); pub const MUTED: Color = Color::rgb(103, 114, 130); pub const ERROR: Color = Color::rgb(175, 42, 49); pub const OK: Color = Color::rgb(31, 121, 80); +// 供设置页选择的六个强调色候选;Theme::new 还会按可读性规则调整传入颜色。 pub const PALETTE: [Color; 6] = [ Color::rgb(22, 113, 213), Color::rgb(0, 132, 143), @@ -23,27 +25,29 @@ pub struct Theme { pub accent: Color, } impl Theme { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 保存经过亮度限制的强调色,所有控件状态色都从该色与管理界面背景混合得到。 pub fn new(accent: Color) -> Self { + // 构造时统一压低过亮强调色,之后 hover/pressed 等样式都从调整后的 accent 派生。 Self { accent: readable_accent(accent), } } - /// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。 + /// 以 10% 强调色混合背景,得到未悬停的柔和选中底色。 pub fn soft(self) -> Color { mix(BG, self.accent, 0.10) } - /// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。 + /// 以 17% 强调色混合背景,得到比 soft 更明显的悬停底色。 pub fn hover(self) -> Color { mix(BG, self.accent, 0.17) } - /// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。 + /// 以 29% 强调色混合背景,增强按下状态;混色输出始终不透明。 pub fn pressed(self) -> Color { mix(BG, self.accent, 0.29) } } /// 按比例混合颜色通道,作为状态颜色的基础运算。 pub fn mix(a: Color, b: Color, amount: f32) -> Color { + // 逐 RGB 通道线性插值并四舍五入,amount 钳到 [0,1];结果使用 rgb,输出 alpha 固定为 255。 let channel = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * amount.clamp(0.0, 1.0)).round() as u8; Color::rgb( @@ -54,6 +58,8 @@ pub fn mix(a: Color, b: Color, amount: f32) -> Color { } /// 根据背景选择可读的强调色,供选中与焦点状态使用。 pub fn readable_accent(mut c: Color) -> Color { + // 用 299/587/114 加权亮度做简单阈值判断,过亮时每轮各通道乘 0.94 变暗。 + // 这是启发式规则,不是对任意背景执行通用对比度求解。 while (c.red as u32 * 299 + c.green as u32 * 587 + c.blue as u32 * 114) / 1000 > 155 { c = Color::rgb( (c.red as f32 * 0.94) as u8, diff --git a/src/shell/system_ui/widgets.rs b/src/shell/system_ui/widgets.rs index 74dcacb..b812b62 100644 --- a/src/shell/system_ui/widgets.rs +++ b/src/shell/system_ui/widgets.rs @@ -20,7 +20,7 @@ pub struct Button<'a> { pub role: Role, } impl<'a> Button<'a> { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 创建默认启用、未选中的普通按钮描述,借用 label 而不分配文本。 pub fn new(label: &'a str) -> Self { Self { label, @@ -29,17 +29,17 @@ impl<'a> Button<'a> { role: Role::Button, } } - /// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。 + /// 按 builder 风格设置选中状态,返回描述供链式调用。 pub fn selected(mut self, value: bool) -> Self { self.selected = value; self } - /// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。 + /// 设置启用状态,影响外观和目标的可激活资格。 pub fn enabled(mut self, value: bool) -> Self { self.enabled = value; self } - /// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。 + /// 设置控件语义角色,影响焦点资格、光标及 Tab 底线等外观。 pub fn role(mut self, value: Role) -> Self { self.role = value; self @@ -51,10 +51,11 @@ pub struct Ui { pub list: DisplayList, pub targets: Vec>, pub theme: Theme, + // 绘制使用跨帧交互状态的本轮副本,只查询 visual/captured;事件处理器在外部更新权威状态。 interaction: Interaction, } impl Ui { - /// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。 + /// 建立空显示列表和目标集合,保存本轮主题与交互状态快照。 pub fn new(theme: Theme, interaction: Interaction) -> Self { Self { list: DisplayList::default(), @@ -63,7 +64,7 @@ impl Ui { interaction, } } - /// 使用当前主题绘制控件填充、轮廓或文字原语。 + /// 追加无圆角填充矩形命令;不注册交互目标。 pub fn fill(&mut self, r: Rect, color: Color) { self.list.push(DrawCommand::FillRoundedRect { rect: r, @@ -71,9 +72,10 @@ impl Ui { color, }); } - /// 使用当前主题绘制控件填充、轮廓或文字原语。 + /// 追加向内收缩的圆角描边,使传入矩形作为控件外边界。 fn outline(&mut self, r: Rect, color: Color, width: f32) { self.list.push(DrawCommand::StrokeRoundedRect { + // 描边以路径中心为基准,向内缩半个线宽,使描边整体尽量位于传入矩形内;尺寸钳非负。 rect: Rect::new( r.x + width / 2.0, r.y + width / 2.0, @@ -85,7 +87,7 @@ impl Ui { color, }); } - /// 使用当前主题绘制控件填充、轮廓或文字原语。 + /// 追加等宽或界面字体文字命令;调用方负责裁剪与可用宽度。 pub fn label(&mut self, x: f32, y: f32, value: &str, color: Color, code: bool) { self.list.push(DrawCommand::Text { origin: Point::new(x, y), @@ -112,6 +114,7 @@ impl Ui { selected: bool, enabled: bool, ) -> VisualState { + // 先取得上帧输入对应的视觉状态,再登记本帧目标;绘制本身不触发 action。 let response = self.interaction.visual(id, selected, enabled); self.targets.push(Target { id, @@ -122,8 +125,9 @@ impl Ui { }); response } - /// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。 + /// 按视觉状态绘制控件背景和焦点轮廓;本方法不注册目标。 fn surface(&mut self, rect: Rect, s: VisualState, base: Option) { + // 背景优先级为禁用、按下、悬停、选中、默认;焦点轮廓单独绘制,可与选中同时存在。 let color = if !s.enabled { base } else if s.pressed { @@ -142,8 +146,9 @@ impl Ui { self.outline(rect, self.theme.accent, 1.5); } } - /// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。 + /// 登记按钮目标,绘制状态背景、裁剪后的标签及可选 Tab 底线,返回组合视觉状态。 pub fn button(&mut self, rect: Rect, action: A, button: Button<'_>) -> VisualState { + // 默认以 action 哈希为稳定身份;同一帧多个独立控件若 action 完全相同会共享身份,调用方需合理区分。 let id = WidgetId::new(&action); let s = self.register( id, @@ -155,6 +160,7 @@ impl Ui { ); self.surface(rect, s, None); self.list.push(DrawCommand::PushClip(rect)); + // 扣掉左右各 8 的内边距后按真实字体截断,并用 PushClip 保证文字和标签底线不越出按钮。 let label = text::fit( button.label, (rect.width - 16.0).max(0.0), @@ -175,6 +181,7 @@ impl Ui { }, false, ); + // 仅 Tab 角色在选中时显示底部强调线,普通按钮选中只走 surface 背景。 if s.selected && button.role == Role::Tab { self.fill( Rect::new(rect.x, rect.y + rect.height - 2.0, rect.width, 2.0), @@ -184,7 +191,7 @@ impl Ui { self.list.push(DrawCommand::PopClip); s } - /// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。 + /// 登记文本输入角色并绘制背景和焦点/悬停描边,不负责实际文本与光标。 pub fn text_field(&mut self, rect: Rect, action: A, focused: bool) { let s = self.register( WidgetId::new(&action), @@ -194,6 +201,7 @@ impl Ui { false, true, ); + // 文本框固定白底,外部 focused 或键盘焦点都使用强调色描边,悬停次之。 self.surface(rect, s, Some(theme::BG)); self.outline( rect, @@ -208,7 +216,8 @@ impl Ui { ); } /// A terminal prompt shares its surface with output; its caret is the focus cue. - /// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。 + /// 只登记无边框终端输入的交互区域,外观由调用方绘制。 + // 终端输入与输出共享背景,只注册文本输入命中区,不绘制框线或文字,光标由 Console 负责。 pub fn inline_input(&mut self, rect: Rect, action: A) { self.register( WidgetId::new(&action), @@ -219,7 +228,8 @@ impl Ui { true, ); } - /// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。 + /// 分别接受行背景区域和可见命中区域,登记行选择并绘制其状态背景。 + // 行背景可绘制完整行,但 hit 可裁到可见部分;不要把裁剪区外的行注册成可点击区域。 pub fn row(&mut self, paint: Rect, hit: Rect, action: A, selected: bool) { let s = self.register( WidgetId::new(&action), @@ -231,7 +241,7 @@ impl Ui { ); self.surface(paint, s, None); } - /// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。 + /// 登记色块目标,绘制按压变暗、外圈及选中勾号。 pub fn swatch(&mut self, rect: Rect, action: A, color: Color, selected: bool) { let s = self.register( WidgetId::new(&action), @@ -241,6 +251,7 @@ impl Ui { selected, true, ); + // 滚动条手柄捕获时保持强调色,悬停使用混色,其余用中性底色;实际偏移换算由调用方处理。 self.fill( rect, if s.pressed { @@ -249,6 +260,7 @@ impl Ui { color }, ); + // 色块在交互或选中时增加外圈;按下变暗且加粗,选中再绘制勾号。 if s.hovered || s.focused || selected { self.outline( Rect::new( @@ -269,8 +281,9 @@ impl Ui { self.label(rect.x + 10.0, rect.y + 6.0, "✓", theme::BG, false); } } - /// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。 + /// 登记扩大的拖动命中区,绘制捕获/悬停强调线。 pub fn splitter(&mut self, rect: Rect, id: WidgetId, axis: Axis) { + // 分隔线命中区四边扩展 3,提高细线可拖动性;真实绘制线宽与命中区域独立。 let hit = Rect::new( rect.x - 3.0, rect.y - 3.0, @@ -278,6 +291,7 @@ impl Ui { rect.height + 6.0, ); let mut s = self.register(id, hit, Role::Splitter(axis), None, false, true); + // 分隔条捕获后即使拖出命中区仍显示按压态,不采用普通按钮“拖出取消外观”的规则。 s.pressed |= self.interaction.captured(id); let color = if s.pressed || s.hovered { self.theme.accent @@ -294,7 +308,7 @@ impl Ui { }; self.fill(line, color); } - /// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。 + /// 登记滚动手柄目标并绘制其状态颜色,偏移和拖动倍率由外部维护。 pub fn scrollbar(&mut self, rect: Rect, id: WidgetId, axis: Axis) { let mut s = self.register(id, rect, Role::Scrollbar(axis), None, false, true); s.pressed |= self.interaction.captured(id); diff --git a/src/shell/tabs.rs b/src/shell/tabs.rs index da5581c..6eaeecd 100644 --- a/src/shell/tabs.rs +++ b/src/shell/tabs.rs @@ -7,9 +7,13 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] /// 托管页面注册表的不可变投影,原生工具只使用稳定 ID 与显示元数据。 pub struct Tab { + // 会话内稳定 ID,供原生侧发起切页/关闭操作;不能直接当作 VM 对象地址。 pub id: u64, + // 本次发布的标题文本副本,原生界面显示时无需读取 VM。 pub title: String, + // 该标签是否是此窗口当前活动页;页面注册表顺序与选中状态分别表示。 pub active: bool, + // 开发工具页的类别标识;普通页省略此字段,反序列化缺失字段时默认为 None。 #[serde(default, skip_serializing_if = "Option::is_none")] pub developer_tool: Option, }