79 Commits

Author SHA1 Message Date
codex-bot 6d1ffdbcb4 docs: pin annotated Aster source for Nexus preparation 2026-11-15 20:08:30 +08:00
codex-bot 9caa94b562 docs: reconcile agent guide with resumable runtime behavior 2026-11-12 21:31:50 +08:00
codex-bot ec05424e22 docs: clarify agent guide template ownership and maintenance 2026-11-12 21:30:35 +08:00
codex-bot 2e905df467 docs: clarify application demo state and page factory lifecycles 2026-11-12 21:28:15 +08:00
codex-bot 0c12190e2d docs: clarify shared scene cameras and event lifecycle examples 2026-11-12 21:25:50 +08:00
codex-bot 8a6bdc3790 docs: clarify layout example dependency and occurrence contracts 2026-11-12 21:23:58 +08:00
codex-bot 94e0f96e4a docs: clarify task example ownership and failure boundaries 2026-11-12 21:22:00 +08:00
codex-bot 40b43ac3c3 docs: clarify typed example callbacks and delegation contracts 2026-11-12 21:20:10 +08:00
codex-bot c3e7d01c09 docs: clarify tool outputs and example window contexts 2026-11-12 21:18:12 +08:00
codex-bot ffc98fb657 docs: complete host regression annotation review 2026-11-12 21:16:40 +08:00
codex-bot 3bf5a5415a docs: clarify scene and protocol cache regression scope 2026-11-12 21:14:54 +08:00
codex-bot 831d013f75 docs: clarify incremental layout and pixel sampling regressions 2026-11-12 21:12:57 +08:00
codex-bot 435ec3b8be docs: clarify rendering contract and event regression evidence 2026-11-12 21:10:26 +08:00
codex-bot c15a5e6729 docs: distinguish window and rendering test coverage boundaries 2026-11-12 21:08:47 +08:00
codex-bot 3f47f76a8d docs: clarify host test observations and task wait limits 2026-11-12 21:07:05 +08:00
codex-bot 5e26994569 docs: align integration test comments with actual coverage 2026-11-12 21:04:10 +08:00
codex-bot fe8ffa1a78 docs: clarify developer tool filtering and module contracts 2026-11-12 21:02:02 +08:00
codex-bot 91801dc417 docs: clarify source viewing and host interaction boundaries 2026-11-12 21:00:31 +08:00
codex-bot b15d0872d7 docs: clarify shell observation and window-local tab contracts 2026-11-12 20:58:37 +08:00
codex-bot 2d5138c7ad docs: clarify event failure cleanup and VM sequence contracts 2026-11-12 20:53:49 +08:00
codex-bot f0bfbc9fc1 docs: explain 3D renderer panel and BVH ownership boundaries 2026-11-12 20:51:29 +08:00
codex-bot 3db8b13768 docs: clarify layout convergence and scene coordinate contracts 2026-11-12 20:49:23 +08:00
codex-bot cd0f292b78 docs: explain intrinsic visual paths and shared font cache lifetimes 2026-11-12 20:46:56 +08:00
codex-bot afff46c0a8 docs: explain hit selection and scroll viewport projection order 2026-11-12 20:43:30 +08:00
codex-bot 45b0b53d4f docs: clarify layout bounds and retained image text flow 2026-11-12 20:41:16 +08:00
codex-bot 0c4bd06231 docs: clarify protocol and document publication contracts 2026-11-12 20:38:06 +08:00
codex-bot ab3cd0c7cf docs: clarify mount checkpoints and snapshot ownership contracts 2026-11-12 20:34:58 +08:00
codex-bot 8042d2efd4 docs: clarify presentation state and window resource lifecycle 2026-11-12 20:32:45 +08:00
codex-bot 723e7f1fbd docs: clarify UI registration signatures and visual snapshot fields 2026-11-12 20:30:28 +08:00
codex-bot 373a95996c docs: clarify UI bridge continuation and publication boundaries 2026-11-12 20:27:32 +08:00
codex-bot b44d7833ea docs: clarify graphics resource and Block validation call paths 2026-11-12 20:24:05 +08:00
codex-bot 1f26349436 docs: clarify developer workspace editing and request presentation 2026-11-12 20:19:57 +08:00
codex-bot edf513bff7 docs: clarify shell input window and host widget lifecycles 2026-11-12 20:17:03 +08:00
codex-bot f743c427f7 docs: clarify source paint handoff and raster presentation limits 2026-11-12 20:11:57 +08:00
codex-bot b10a5b43d2 docs: clarify layout continuation and runtime refresh cleanup 2026-11-12 20:09:27 +08:00
codex-bot f1e413c8c5 docs: clarify API request phases and native input ordering 2026-11-12 20:05:19 +08:00
codex-bot a1c1781b00 docs: clarify task ownership and I/O completion cleanup 2026-11-12 20:01:32 +08:00
codex-bot 0cd708f8c7 docs: clarify binding refresh and worker publication boundaries 2026-11-12 19:57:03 +08:00
codex-bot 7f7464ae2b docs: explain scene graphics and composite example execution 2026-11-12 19:51:41 +08:00
codex-bot 99ce4e3fc6 docs: explain page window and scrolling example callers 2026-11-12 19:46:10 +08:00
codex-bot ce17cb6ed3 docs: explain event binding and object lifecycle examples 2026-11-12 19:43:48 +08:00
codex-bot 9cdbf00961 docs: clarify example inheritance and type contract calls 2026-11-12 19:40:58 +08:00
codex-bot 07812bc47a docs: explain layout example constraints and protocol calls 2026-11-12 19:39:17 +08:00
codex-bot a0545e7e40 docs: explain asynchronous example ownership and callbacks 2026-11-12 19:34:37 +08:00
codex-bot 8a61d27938 docs: explain application entry points and tooling workflows 2026-11-12 19:31:28 +08:00
codex-bot ca5bc863c1 docs: explain host widget interaction and visual states 2026-11-12 19:28:49 +08:00
codex-bot 0971d08e1b docs: explain native input capture and window event lifecycle 2026-11-12 19:25:56 +08:00
codex-bot b95912d840 docs: explain sidebar and native window coordination 2026-11-12 19:22:21 +08:00
codex-bot d8dc24da26 docs: explain developer workspace state and input lifecycle 2026-11-12 19:19:12 +08:00
codex-bot a0ff16e23f docs: explain developer tool painting and source selection 2026-11-12 19:15:28 +08:00
codex-bot bb6e78ad04 docs: explain console, network and settings view projections 2026-11-12 19:12:50 +08:00
codex-bot 50571e156e docs: explain shell activity and platform support boundaries 2026-11-12 19:10:08 +08:00
codex-bot c28c08a29a docs: explain text flow cursors and viewport hit indexing 2026-11-12 19:07:30 +08:00
codex-bot 9517053a75 docs: explain text shaping and viewport projection flows 2026-11-12 19:04:55 +08:00
codex-bot 03241d4ac1 docs: explain source layout caching and convergence lifecycle 2026-11-12 19:01:17 +08:00
codex-bot cd323de3ac docs: explain scroll projection and source hit selection 2026-11-12 18:57:09 +08:00
codex-bot ac6cc82b0e docs: explain source paint lifecycle and software composition 2026-11-12 18:54:25 +08:00
codex-bot b18d56c439 docs: explain 3D rasterization, picking and spatial indexing 2026-11-12 18:50:21 +08:00
codex-bot 0431510d0e docs: explain scene composition and 3D panel coordinate flows 2026-11-12 18:45:46 +08:00
codex-bot 3442e3b3c2 docs: explain layout constraints, flex allocation and occurrence identity 2026-11-12 18:41:07 +08:00
codex-bot df7e1846b2 docs: explain native visual protocols and legacy page coordinate rules 2026-11-12 18:36:10 +08:00
codex-bot 13a8b1b4b5 docs: explain box sizing, device drawing and image preparation 2026-11-12 18:32:01 +08:00
codex-bot 7a3eea3958 docs: explain managed windows, page ownership and native command delivery 2026-11-12 18:28:18 +08:00
codex-bot f4ab4db475 docs: explain managed 3d construction, scene extraction and camera updates 2026-11-12 18:25:42 +08:00
codex-bot c67a205867 docs: explain mount synchronization, queries and cancellation recovery 2026-11-12 18:21:44 +08:00
codex-bot f0a55e0f7a docs: explain object lists, presentation state and capability installation 2026-11-12 18:17:20 +08:00
codex-bot 38e8bbc1e6 docs: explain document publication and visual snapshot invalidation 2026-11-12 18:13:48 +08:00
codex-bot dbf0bb5800 docs: explain binding convergence and managed block model ownership 2026-11-12 18:08:16 +08:00
codex-bot 742fee4295 docs: explain effects, bindings, publication and shutdown test paths 2026-11-12 18:03:00 +08:00
codex-bot d9c191ec00 docs: explain world ui, scroll policies and native identity test paths 2026-11-12 17:56:13 +08:00
codex-bot 3ed148c880 docs: explain box layout, camera projection and 3d picking tests 2026-11-12 17:51:21 +08:00
codex-bot 00514825a0 docs: explain scrolling, incremental refresh and flex test invariants 2026-11-12 17:47:44 +08:00
codex-bot 6f84d6574a docs: explain visual contracts, mount lifecycle and event test assertions 2026-11-12 17:43:59 +08:00
codex-bot 91bd0d4c5a docs: explain host test setup, dependencies and incremental painting 2026-11-12 17:39:06 +08:00
codex-bot ed1a9d2956 docs: explain worker publication and HTTP integration regressions 2026-11-12 17:27:39 +08:00
codex-bot e7f387ed9f docs: explain task scheduling, VM sessions and API control flow 2026-11-12 17:13:43 +08:00
codex-bot 21aa7b837b docs: explain graphics resources and runtime dispatch lifecycles 2026-11-12 16:53:32 +08:00
codex-bot fbc5bed19e docs: explain UI bridge callers, control flow and VM ownership 2026-11-12 16:36:11 +08:00
Zayden-Jung f00fd05754 Merge pull request 'feat: 用 Endor 脚本实现龙与文字绕排并补齐通用图形输入能力' (#9) from codex/dragon-manuscript into main
Reviewed-on: #9
2026-11-12 03:12:08 +00:00
127 changed files with 7083 additions and 482 deletions
+11 -1
View File
@@ -1,18 +1,24 @@
// 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。
// owner/key 相同的新读取替换旧任务;失败只记录 Task.error,不执行 apply。
// owner/key 相同的新读取替换旧任务;provider 读取失败记录 Task.error、不执行 apply;apply 自身失败则遵循共享运行时停域规则。
// 持有三个显示对象与当前任务;task 初值占位,第一次 read 后替换为真实 Task。
class FileReader {
// 这三个字段保存已构造的文字对象引用,回调更新同一业务对象,不创建一份新的页面或复制 Mount。
output: TextBlock
problem: TextBlock
status: TextBlock
// 初始用通用 Object 占位;read 安装真实 Task 后可查询 status/error,result 需等待成功交付,apply 不会在占位阶段执行。
task: Object
// 仅成功时调用,result 在成功回调期间已可读;写原 TextBlock 触发显示刷新。
function apply(self: FileReader): Unit {
// 任务调度器在 I/O 成功且仍有交付资格时调用绑定的 apply;self 是原 reader,调用者无需另外传参。
// 本例适用于不超过 TextBlock 16 KiB UTF-8 字节预算的文件;readText 自身允许至多 1 MiB,读取成功不保证这次控件赋值合法。
self.output.text = self.task.result()
}
// 以 self 为 owner、file 为 key 启动读取,清空旧输出,并把状态/错误文字绑定到当前任务。
function read(self: FileReader, path: String): Unit {
// path 是 Console 本次传入的路径;任务创建同步返回句柄,文件读取在后台,方法这里不会等文件内容。
self.task = ui.readText(self, "file", path, self.apply)
// 清掉上次显示与当前读取状态对齐;已取消的旧任务不能稍后回写覆盖这次读取。
self.output.text = ""
// Task.status/error 是零参数只读方法,可直接作为绑定计算;换任务后绑定也随本次调用更新。
self.status.bind("text", self.task.status)
@@ -21,9 +27,13 @@ class FileReader {
}
// 先建立界面对象,保持 reader 为可从 Console 操作的全局业务入口。
output = TextBlock("尚未读取文件")
// 单独显示 Task.error;失败不会触发 apply,所以读不到新内容时也能通过绑定看到错误。
problem = TextBlock("")
// 独立进度控件绑定到这次任务的方法,普通重绘不会重新提交 readText。
status = TextBlock("")
// 全局 reader 保留 owner 和三个显示对象;ui.state(0) 仅满足 Object 字段初值,不是文件结果。
reader = FileReader(output, problem, status, ui.state(0))
// 页面只是展示输出,任务 owner 是 reader;关闭这个页面不等于直接取消 reader 下的任务。
page = Page()
page.title = "异步文件"
page.render(Column(24,16,TextBlock("在 REPL 调用 reader.read(绝对路径字符串)",2),status,output,problem))
+10
View File
@@ -5,16 +5,22 @@ class TextFetcher {
output: TextBlock
problem: TextBlock
status: TextBlock
// 保存最新请求句柄,owner/key 替换后 apply 只读取当前任务;初始 Object 值尚不是可查询的 Task。
task: Object
// 完整响应留在 VM 业务字段,TextBlock 只显示提示;外部读取和 GUI 排版的内容量分开。
response: String
// 成功后取出完整 UTF-8 结果,写 response 供 Console 查询;provider 失败不会进入此方法。
function apply(self: TextFetcher): Unit {
// 成功交付前调度器已写入 Task 结果;绑定 self 的零参回调在所属 VM 执行,不在网络线程写业务字段。
self.response = self.task.result()
self.output.text = "请求完成;在 REPL 查看 fetcher.response"
}
// 同一 self/request 身份的新请求取消旧交付,重置旧内容并把显示绑定到新任务。
function fetch(self: TextFetcher, url: String): Unit {
// url 来自显式 fetch 调用,HTTP(S) GET 由后台 provider 完成;新句柄返回不代表网络已完成。
self.task = ui.fetchText(self, "request", url, self.apply)
// 只有 fetchText 成功创建新句柄才清旧结果;若 URL 校验/任务额度拒绝,上面的调用报错,下面不会执行。
// 同步清旧结果,避免新请求等待期间让 Console 把旧响应误认成新请求结果。
self.response = ""
self.output.text = ""
// 绑定读取当前 Task 方法,完成和失败由 worker 的任务状态变更传播。
@@ -24,9 +30,13 @@ class TextFetcher {
}
// 界面不自动访问网络,用户以 Console 明确指定 URL 后才创建任务。
output = TextBlock("尚未请求网络")
// 错误单独绑定 Task.error,即使失败没有调用 apply 也能展示原因。
problem = TextBlock("")
// 进度显示绑定任务状态,响应内容不拼接进这个控件,避免大正文参与每次布局。
status = TextBlock("")
// 全局业务入口让后续 REPL 复用同一 owner,因此多次 fetch 才会按 request 键替换。
fetcher = TextFetcher(output, problem, status, ui.state(0), "")
// Page 展示与任务身份分开,任务归属 fetcher;保持该业务对象可达时,页面隐藏不自动取消它。
page = Page()
page.title = "异步网络"
page.render(Column(24,16,TextBlock("在 REPL 调用 fetcher.fetch(HTTP 或 HTTPS 地址字符串)",2),status,problem,output))
+11
View File
@@ -4,19 +4,28 @@
class AwaitReader {
output: TextBlock
status: TextBlock
// workflow 句柄由 ui.spawn 返回,用于观察协作函数状态和最终返回值。
task: Object
// provider 句柄由 ui.readText 返回,apply 的 await 等待它;两个字段不是同一个 Task 的别名。
file: Object
response: String
// await 在文件未完成时挂起并保留 self/操作数;恢复后只执行后半段一次,再把字符串作为 workflow.result。
function apply(self: AwaitReader): String {
// 调度器启动 apply 后遇到未完成 file 会保留续体,后续 provider 就绪再恢复;成功结果才赋给 response。
// 依赖取消会取消等待流程;依赖失败在 await 处变成源错误,本例没有捕获逻辑,也不会执行后面的完成提示。
self.response = await self.file
self.output.text = "读取完成;在 REPL 查看 reader.response 或 reader.task.result()"
// 正常返回值成为 workflow.result(),与 file 的读取结果来源相同但属于另一个任务结果。
return self.response
}
// 先创建读任务,再调度普通函数 apply;spawn 而非新 async 语法提供可暂停执行上下文。
function read(self: AwaitReader, path: String): Unit {
// 先安装本轮文件任务,省略成功回调;它以可 await 的结果状态通知 workflow。
self.file = ui.readText(self, "file", path)
// 再安装 workflow,捕获的是绑定 self 的源方法;它在后续调度量子内推进,不在此处同步跑完 apply。
// file/workflow 分属两个 key;连续启动不是一笔原子事务,若 spawn 创建失败,前面已创建的文件任务不会自动撤回。
self.task = ui.spawn(self, "workflow", self.apply)
// 启动后清上次业务结果及显示提示,新的 await 恢复后才写入本轮文本。
self.response = ""
self.output.text = "等待文件"
// 显示的是 workflow 状态,等待 provider 时它仍报告 running;结束后 result 可从 Task 或 response 读取。
@@ -24,9 +33,11 @@ class AwaitReader {
}
}
output = TextBlock("在 REPL 调用 reader.read(绝对路径字符串)")
// 只展示协作 workflow 的状态,provider 自身状态可从 reader.file 单独查询。
status = TextBlock("")
// 占位值只用于满足初始字段构造;在 read 内被真实 Task 替换,页面建立不自动读文件。
reader = AwaitReader(output,status,ui.state(0),ui.state(0),"")
// 页面只投影 status/output,两个任务 owner 都是 reader,页面可见与否不是该普通 owner 的交付资格条件。
page = Page()
page.title = "await 文件"
page.render(Column(24,16,status,output))
+10
View File
@@ -3,45 +3,55 @@
// 内部布局基于容器自己的约束;Page 和相机只提供外部展示/投影上下文。
// 文字 boxWidth=fill 占父分配的可用宽度,marginBottom 在文字边框盒外参与排布。
shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2)
// fill 是共享对象的布局策略,每次出现仍从各自父约束得到实际宽度;不把首次测量宽度保存回 shared。
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"
// 给父 Row 的分配加上下限/上限,min/max 是 border-box 约束,不是原生窗口宽度。
left.minWidth = 180
left.maxWidth = 360
// 四边分别设置厚度,测量/安排时边框与 padding 占内容外空间;颜色只影响绘制。
left.borderLeft = 2
left.borderTop = 2
left.borderRight = 2
left.borderBottom = 2
left.borderColor = "#CBD5E1"
left.paddingLeft = 24
// Column 的交叉轴为横轴,未自行覆盖对齐的孩子按 center 排;共享文字还有 fill 自身宽度策略。
left.crossAlign = "center"
// 右 Column 也引用同一 shared,但最大宽只有 240,因此对应 Mount 的换行高度可以不同。
right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared)
right.boxWidth = "fill"
right.maxWidth = 240
// 这里 end 仅选择右容器内的交叉轴位置,不改变 shared 业务对象在左容器的对齐。
right.crossAlign = "end"
// Row 分配有限 760 主轴空间,扣 padding/gap 后在两侧 fill 子项之间分配并服从上限。
content = Row(24, 24, left, right)
content.boxWidth = 760
// Row 的交叉轴为纵轴,start 让左右两列按可用上侧对齐,列高仍来自各自文字换行测量。
content.crossAlign = "start"
// 角标是 Overlay 的另一子项,alignSelf 与 margin 控制它在容器交叉轴上的位置。
badge = TextBlock("Endorphin · layout")
badge.marginRight = 12
badge.marginBottom = 12
// 子项显式对齐覆盖父级默认交叉轴对齐,margin 保留在边框盒外,不作为文字内容绘制。
badge.alignSelf = "end"
// 固定 800x400 的 Overlay 不按线性顺序挤开兄弟,overflow=clip 同时约束绘制和命中。
surface = Overlay(0, 0, content, badge)
surface.boxWidth = 800
surface.boxHeight = 400
// Overlay 的主轴对齐参与各子项在同一区域的分配,不像 Column 那样把 content 和 badge 顺序堆叠。
surface.mainAlign = "end"
// overflow 策略由宿主投影成子裁剪,超出有限表面的文字/按钮不能仅凭原布局盒穿透命中。
surface.overflow = "clip"
// Page 只提供展示入口,根容器自身的盒模型规则不依赖页面导航方式。
page = Page()
page.title = "嵌套盒模型"
// 顶层脚本执行到这里请求展示完整对象组合;布局驱动随后按出现约束求解,再发布不可变绘制帧。
page.render(surface)
+26
View File
@@ -5,6 +5,7 @@
class CampusPanel: Column {
// 按当前出现位置 geometry 创建一条背景命令,子项由展示遍历继续绘制。
function paint(self: CampusPanel, mount: Mount, geometry: Bounds): DisplayList {
// source_paint 为每个卡片出现调用;fill 是本轮新结果,允许写字段,不能在这里修改课程或作业模型。
fill = PaintCommand()
fill.kind = "roundedRect"
fill.x = geometry.x
@@ -18,9 +19,11 @@ class CampusPanel: Column {
}
// 通过额外 width 字段固定表格列的期望宽,保持成绩表头和行对齐。
class CampusCell: Column {
// 这是源子类的期望宽度策略,不是某个 Mount 的最终宽度;相同列宽仍要服从父 Constraints。
width: Number
// 先算子内容自然高度,再改新测量结果宽度,保留真实字体引起的高度。
function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult {
// source_layout 先调用覆盖,再显式委托 Column 得到新 MeasureResult;只改返回结果,不写 content 的业务状态。
result = Column.measure(self, mount, constraints)
result.width = self.width
return result
@@ -30,10 +33,12 @@ class CampusCell: Column {
class CampusSidebar: Column {
// 返回固定168x620期望尺寸,外层 ScrollBlock 承担超出区域的滚动。
function measure(self: CampusSidebar, mount: Mount, constraints: Constraints): MeasureResult {
// 每个侧栏出现的只读测量入口;固定返回值仍受父约束处理,不是强制原生窗口至少 620 高。
return MeasureResult(168, 620)
}
// 为导航区创建深红背景,位置/大小来自当前Mount,文字等子项保持独立命令。
function paint(self: CampusSidebar, mount: Mount, geometry: Bounds): DisplayList {
// 侧栏背景命令只覆盖当前容器,子按钮和文字通过独立出现继续绘制,避免在父命令里重复画子树。
fill = PaintCommand()
fill.kind = "roundedRect"
fill.x = geometry.x
@@ -50,10 +55,12 @@ class CampusNav: Button {
background: String
// 导航按钮期望144x40,确保四个入口宽高一致。
function measure(self: CampusNav, mount: Mount, constraints: Constraints): MeasureResult {
// source_layout 调用此固定尺寸策略,实际测量结果仍由宿主验证,再交给父导航容器安排。
return MeasureResult(144, 40)
}
// 取原生按钮背景命令改色/圆角;文字由后面 replace 后的子项绘制,不在此重复生成。
function paint(self: CampusNav, mount: Mount, geometry: Bounds): DisplayList {
// 原生按钮生成本轮临时命令,at(0) 只取背景;下面 campusNav 替换的白字孩子由统一遍历画出。
fill = Button.paint(self, mount, geometry).commands.at(0)
fill.color = self.background
fill.radius = 7
@@ -62,6 +69,7 @@ class CampusNav: Button {
}
// 课程业务记录,grade/progress 被多个页面绑定读取,其余静态字段构建时直接读取。
class CampusCourse {
// grade/progress 用 ui.textOf 注册实时依赖;其余字段传给 TextBlock 时只读取构造当时的值。
title: String
code: String
teacher: String
@@ -79,13 +87,16 @@ class CampusWork {
course: CampusCourse
// 模拟提交只改状态、文件名提示、待办和结果文字,不访问磁盘或网络。
function submit(self: CampusWork, event: Event): Unit {
// campusSubmitButton 的 click 连接调用;没有文件选择或成绩计算,重复点击会再次写相同预设状态。
self.status = "已提交 · 等待评分"
self.submission = "lab01.endor · 已提交示例内容"
self.todo = "已完成本次实验提交"
self.result = "示例提交成功,可以切到作业中心查看同一条记录。"
}
// 应用预设92分和反馈,并更新所属课程成绩/进度;绑定让仪表盘、课程、成绩页同步观察。
// 不要求先 submit,也不校验现有 status;再次 submit 只覆盖其写到的字段,不会自动清空旧 score/feedback。
function assess(self: CampusWork, event: Event): Unit {
// campusAssessButton 派发到这里,course 引用 campusProgramming;只改该课程,数学与英语保持原值。
self.status = "已评分"
self.score = "92 / 100"
self.feedback = "结构清晰,边界情况处理完整。建议补充函数说明。"
@@ -97,6 +108,7 @@ class CampusWork {
}
// 恢复初始实验与课程状态,保留所有原对象和绑定,便于反复演示。
function reset(self: CampusWork, event: Event): Unit {
// campusResetButton 的事件入口恢复共享模型字段,不重新创建七页,不需要逐页重新注册文字绑定。
self.status = "未提交"
self.score = "— / 100"
self.feedback = "提交后可在此查看教师反馈。"
@@ -109,6 +121,7 @@ class CampusWork {
}
// 用统一包装构造表格单元格;传入的content是已有Block引用。
function campusCell(content: Block, width: Number): Block {
// campusGradeRow 和成绩表头调用;width 放在原生 Column 的单个孩子之后,属于源字段而非额外孩子。
return CampusCell(0, 0, content, width)
}
// 显式事件适配器把click的Event与Page.open可选Window参数分开,不能直接把Event误当窗口。
@@ -116,17 +129,21 @@ class CampusTarget {
page: Page
// 事件参数在适配器这一层消化,Page.open 只接收其声明的可选 Window。
// 消费事件但不传给Page.open;无参open表示在合适窗口打开/激活目标页。
// campusLink/campusNav 保存此绑定方法;点击时以已保存的真实 Page 为目标,不按标题字符串重新找页。
function open(self: CampusTarget, event: Event): Unit { self.page.open() }
}
// 为普通按钮创建持有目标Page的适配器,并把其绑定方法注册为click回调。
function campusLink(label: String, target: Page): Button {
// 由卡片、课程正文和作业详情等组合函数调用,每次生成独立按钮/适配器,但复用传入 target 页身份。
button = Button(label)
action = CampusTarget(target)
// 连接持有绑定接收者所需引用,action 局部变量离开函数不代表回调丢失目标;调用方不需额外保存适配器。
button.on("click", action.open)
return button
}
// 构造带指定背景的导航按钮,替换原生文字子槽为白字,然后复用事件适配器连接目标页。
function campusNav(label: String, target: Page, background: String): Button {
// campusNavigation 调用,先用源字段构造导航按钮,再替换稳定槽 0 的文字,不把背景字符串误传成孩子。
button = CampusNav(label, background)
button.replace(0, TextBlock(label, 1, "#FFFFFF"))
action = CampusTarget(target)
@@ -135,6 +152,7 @@ function campusNav(label: String, target: Page, background: String): Button {
}
// 用目标Page和四个色调参数构建整块导航;不同页面复用目标资源但用不同高亮背景。
function campusNavigation(home: Page, course: Page, assignments: Page, grades: Page, homeTone: String, courseTone: String, workTone: String, gradeTone: String): Block {
// 顶层调用四次建立不同高亮版本;四个 Page 参数只提供导航目标,此函数不直接 open 或 render 它们。
return CampusSidebar(12, 10,
TextBlock("Endor", 2, "#FFFFFF"),
TextBlock("Campus", 1, "#E7BAC0"), Spacer(16),
@@ -148,6 +166,7 @@ function campusNavigation(home: Page, course: Page, assignments: Page, grades: P
}
// 把演示导航与标题/正文横排,外层有限ScrollBlock启用双轴,容纳固定宽度演示内容。
function campusShell(navigation: Block, title: String, subtitle: String, body: Block): Block {
// 七个 render 的参数求值调用本函数,各自新建外层滚动对象;传入 navigation 可共享,偏移则按出现分别保存。
scroll = ScrollBlock(1120, 680,
Row(24, 28, navigation,
Column(0, 16,
@@ -159,6 +178,7 @@ function campusShell(navigation: Block, title: String, subtitle: String, body: B
}
// 卡片静态显示课程代码/标题/教师,grade 用绑定随评分变化,入口按钮引用真实课程Page。
function campusCourseCard(course: CampusCourse, page: Page, color: String): Block {
// 仪表盘为三门课各调用一次;color 同时用于静态色条与成绩文字,grade 的实时值仍来自传入 course。
return CampusPanel(0, 0,
RectBlock(248, 44, 8, color),
Column(16, 8,
@@ -170,6 +190,7 @@ function campusCourseCard(course: CampusCourse, page: Page, color: String): Bloc
}
// 成绩表一行复用固定列宽单元格,当前grade为响应式文字,详情链接指向指定课程页。
function campusGradeRow(course: CampusCourse, target: Page): Block {
// 成绩总览逐门课程调用,创建新的表格视觉行,不复制 CampusCourse;详情按钮保留对应课程 Page。
return Column(0, 12,
Row(0, 0,
campusCell(TextBlock(course.title), 240),
@@ -180,6 +201,7 @@ function campusGradeRow(course: CampusCourse, target: Page): Block {
}
// 组合课程概况/进度和静态学习单元;上方两个链接复用作业/成绩Page,不创建新页面。
function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String, moduleTwo: String, moduleThree: String, assignments: Page, grades: Page): Block {
// 三个课程页调用同一构造函数,各自传不同课程模型/静态单元文案;只有明确 textOf 的字段会随模型改动重算。
return Column(0, 20,
Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)),
CampusPanel(20, 12,
@@ -202,6 +224,7 @@ campusProgramming = CampusCourse("计算机程序设计", "CS 101", "陈老师
campusMathematics = CampusCourse("高等数学 I", "MATH 101", "周老师 · 周一 / 周三", "91.0%", "已完成 4 / 6 个学习单元")
campusEnglish = CampusCourse("学术英语", "ENG 102", "许老师 · 周五", "89.0%", "已完成 3 / 5 个学习单元")
campusWork = CampusWork("未提交", "— / 100", "提交后可在此查看教师反馈。", "尚无提交内容", "1 项实验待提交", "这是一个本地提交与评分演示。", campusProgramming)
// course 只关联程序设计模型;课程标题/成绩等对象引用与页面资源随后通过各组合函数串成同一托管对象图。
// 先创建全部七个Page,允许导航按钮互相引用;随后逐一render建立内容。
campusDashboard = Page()
campusDashboard.title = "Campus · 仪表盘"
@@ -218,6 +241,7 @@ campusGrades.title = "Campus · 成绩总览"
campusWorkPage = Page()
campusWorkPage.title = "实验 01 · 提交详情"
// 预建四种高亮导航树,课程三页共享course导航,Page切换时各自拥有独立Mount。
// 所有“课程”导航按钮的目标均为 campusProgrammingPage;数学/英语详情由各自课程卡或成绩链接进入,不按当前页动态改目标。
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")
@@ -281,6 +305,7 @@ campusAssessButton = Button("应用示例评分")
campusAssessButton.on("click", campusWork.assess)
campusResetButton = Button("恢复演示数据")
campusResetButton.on("click", campusWork.reset)
// 注册三个方法只建立监听,不立即执行预设提交/评分/恢复;首次所有绑定读取上面的模型初值。
// 详情页组合要求、提交状态、操作按钮和反馈;所有输入仍只是预设本地状态转换。
campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函数", "CS 101 / 作业 / 提交详情",
Column(0, 20,
@@ -301,3 +326,4 @@ campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函
ui.textOf(campusWork, "result", 1, "#7C8793"))))
// 逐页render会改变当前活动页,最后显式选择仪表盘作为打开后的首页。
campusDashboard.activate()
// 激活只请求展示已有仪表盘,保留其余六页及共享课程/实验对象;所有页面都没有独立数据库或独立 VM。
+6
View File
@@ -2,17 +2,20 @@
// 两个 textOf 共享同一模型字段,修改一次业务值会更新两个展示位置。
// 定义业务状态和公共事件入口,Number/String 字段在构造与赋值时按声明契约检查。
class Counter {
// value 是两处 textOf 的读取目标,lastEvent 是第三处绑定的读取目标;它们都属于同一模型。
value: Number
lastEvent: String
// 保存实际事件种类,再通过 self 调用可覆盖的方法;接收者保持具体子类身份。
function activate(self: Counter, event: Event): Unit {
// Button.onClick 派发传入本次 Event;先记录种类,再调用下面的动态业务方法。
self.lastEvent = event.kind
self.change()
}
// 基类默认行为把值置为 10,子类可复用入口并替换这部分策略。
function change(self: Counter): Unit {
// 仅直接使用 Counter 或未覆盖 change 的子类才采用此默认值;本例 DemoCounter 会替换它。
self.value = 10
}
}
@@ -21,15 +24,18 @@ class Counter {
class DemoCounter: Counter {
// 覆盖把值设为 12,演示从继承方法内部发起的调用也会选中实际接收者的方法。
function change(self: DemoCounter): Unit {
// 本例从 Counter.activate 的 self.change() 进入这里,self 仍是 model,两个数值绑定一起失效。
self.value = 12
}
}
// 创建唯一模型,之后所有绑定和按钮回调都指向它,无需把 GUI 状态复制到另一份存储。
model = DemoCounter(9, "等待点击")
// 两次调用各创建一个展示对象,但都读取 model.value;不是把初值 9 各自复制成互不关联的计数器。
first = ui.textOf(model, "value", 2)
second = ui.textOf(model, "value", 2)
eventName = ui.textOf(model, "lastEvent")
// 构造按钮只建立对象,下一行 onClick 注册后才拥有修改模型的点击行为。
button = Button("调用继承方法,再分派到覆盖方法")
// 注册绑定方法,运行时将 click 的 Event 传给 activate;最后 Page.render 才把对象树显示出来。
button.onClick(model.activate)
+7
View File
@@ -10,17 +10,24 @@ class TaggedEvent: Event { tag: String }
class TaggedList: ObjectList { tag: String }
// 将需要更新的矩形作为强业务引用保存,按钮回调只修改它的颜色。
class Actions {
// 字段保存 TaggedRect 的对象引用,recolor 的写入会被当前页面中该对象的绘制读取观察到。
rect: TaggedRect
// 写原对象的 color,挂载显示由依赖跟踪刷新,不需要重新构造整个页面。
// 由下面 Button.onClick 注册为零参绑定方法;不接收 Event,也不修改 tag。
function recolor(self: Actions): Unit { self.rect.color = "#237A45" }
}
// 这里刻意省略原生可选项,用末尾字符串初始化子类字段;后面的列表/Column 做对应可变参数演示。
rect = TaggedRect(180, 60, "省略 radius/color 后的字段")
// kind 是原生 Event 参数,第二个字符串才是 TaggedEvent 的源字段 tag。
event = TaggedEvent("click", "事件字段")
// 前两个托管对象归入原生 ObjectList 成员,末尾字符串留给子类字段,不作为第三个列表成员。
// TaggedList.tag 是该列表实例的业务字段;它既不改变 items.count,也不会参与 rect 的容器布局。
items = TaggedList(rect, event, "列表成员之后的字段")
actions = Actions(rect)
button = Button("调用原对象方法")
// 这里只注册回调,不立即执行 recolor,因此首次显示仍使用矩形原生构造的默认颜色。
button.onClick(actions.recolor)
// Column 原生参数接收 padding/gap 和视觉孩子;末尾 tag 字符串不参与布局,textOf 则读取各对象的 tag。
root = TaggedColumn(24, 16, TextBlock("原生选项与子类字段分别检查", 2), rect, ui.textOf(rect, "tag"), ui.textOf(event, "tag"), ui.textOf(items, "tag"), button, "可变子项之后的字段")
page = Page()
page.title = "构造器契约"
+12 -1
View File
@@ -1,11 +1,14 @@
// 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。
// 这里刻意重复增量语句,方便看到暂停点;取消不回滚已经发生的 steps 写入,单个原生调用仍是原子边界。
// 这里刻意重复增量语句,方便观察推进;量子按 VM 指令计数,不保证每个 increment 或每组 16 条之后恰好暂停。
// 取消不回滚已经发生的 steps 写入,单个原生调用仍是原子边界。
// 每个实例有独立 steps 状态,两个任务不会把局部帧或计数存储混在一起。
class CooperativeWork {
// 保存 ui.state 返回的原生可变计数,每个 CooperativeWork 构造传入不同对象。
steps: Object
// 普通零参数方法被 spawn 调度后可跨量子继续;每条 increment 更新同一实例的计数。
// 连续语句无需手工 yield,VM 按累计预算暂停,恢复时不会从第一条重放。
function run(self: CooperativeWork): Unit {
// 第 1 至 16 次增量:由 spawn 的调用续体依次执行,每次原生 increment 完成后才进入下一条。
self.steps.increment()
self.steps.increment()
self.steps.increment()
@@ -22,6 +25,7 @@ class CooperativeWork {
self.steps.increment()
self.steps.increment()
self.steps.increment()
// 第 17 至 32 次增量:若此前在 VM 指令量子处让出,恢复后从保存的位置继续,不重跑前面的增量。
self.steps.increment()
self.steps.increment()
self.steps.increment()
@@ -38,6 +42,7 @@ class CooperativeWork {
self.steps.increment()
self.steps.increment()
self.steps.increment()
// 第 33 至 48 次增量:依然修改当前实例的 steps,另一个 owner 的 run 有独立调用帧与计数。
self.steps.increment()
self.steps.increment()
self.steps.increment()
@@ -54,6 +59,7 @@ class CooperativeWork {
self.steps.increment()
self.steps.increment()
self.steps.increment()
// 第 49 至 64 次增量:正常走完才结束任务;中途取消保留已发生修改,不把计数回滚为零。
self.steps.increment()
self.steps.increment()
self.steps.increment()
@@ -74,14 +80,19 @@ class CooperativeWork {
}
// 创建两个独立 owner,虽然 key 都叫 work,也不会相互替换;owner/key 组合才是任务身份。
left = CooperativeWork(ui.state(0))
// 左右实例共享类和方法定义,但这里分别分配 state,不能仅凭相同 work key 判断会相互取消。
right = CooperativeWork(ui.state(0))
// owner 是 left,方法是绑定 left 的 run;Task 句柄只用于状态/取消/结果查询,不是第二个业务计数对象。
leftTask = ui.spawn(left,"work",left.run)
// 同 key 配不同 owner 构成不同任务身份,worker 可以在两个续体之间按量子交替推进。
rightTask = ui.spawn(right,"work",right.run)
// 状态文字绑定到 Task.status,业务进度可在 Console 查询 left/right 的 steps.value。
leftStatus = TextBlock("")
// 两份文字控件分别绑定各自 Task.status,完成时间和状态不会通过同一字段覆盖彼此。
rightStatus = TextBlock("")
leftStatus.bind("text",leftTask.status)
rightStatus.bind("text",rightTask.status)
// 仅展示任务状态,任务 owner 不是 Page;关闭页面不会仅因内容消失就撤掉这两个普通业务 owner 的任务。
page = Page()
page.title = "独立暂停调用"
page.render(Column(24,16,TextBlock("两个任务分别保存执行帧;在 REPL 查看 left.steps.value / right.steps.value",2),leftStatus,rightStatus))
+10
View File
@@ -1,7 +1,9 @@
// 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。
// EventLog 用一个递增序列记录实际次序;改变监听器/传播标记可对比跳过与停止的语义。
// 首行仅列本例的非空阶段;额外的目标 capture、旧式 onClick 及其他祖先处理仍由通用分发器决定。
// 保存各阶段序号与最近 phase;count 是共享状态,其他字段直接写在本模型中。
class EventLog {
// count 保存跨阶段递增的总序列;四个 Step 保存各阶段最近一次序号,不是各自独立递增的计数器。
count: Object
captureStep: Number
methodStep: Number
@@ -10,18 +12,21 @@ class EventLog {
phase: String
// 祖先捕获阶段先递增序列,再保存 captureStep 和 Event.phase。
function capture(self: EventLog, event: Event): Unit {
// 由 root 的 capture 连接调用,当前 event.phase 来自本次派发路径,不由业务模型自行推断。
self.count.increment()
self.captureStep = self.count.value
self.phase = event.phase
}
// 目标监听器的执行点;它排在目标类型 onEvent 方法之后。
function target(self: EventLog, event: Event): Unit {
// button 的普通连接在目标 onEvent 后调用,读取已被前面阶段推进的同一个 count。
self.count.increment()
self.targetStep = self.count.value
self.phase = event.phase
}
// 祖先冒泡阶段记录最后序号,未被 stopPropagation 时才到达。
function bubble(self: EventLog, event: Event): Unit {
// 由 root 的非捕获连接调用;本例不设置停止标记,因此点击目标后可继续冒泡到这里。
self.count.increment()
self.bubbleStep = self.count.value
self.phase = event.phase
@@ -29,21 +34,26 @@ class EventLog {
}
// 覆盖目标方法,比额外 on(click,...) 监听器更早处理目标阶段事件。
class EventButton: Button {
// 原生 Button 参数之外追加模型引用,onEvent 和三个监听器共同写入该模型。
model: EventLog
// 把模型序列递增后写 methodStep,供断言与 capture/target/bubble 比较。
function onEvent(self: EventButton, event: Event): Unit {
// 分发器查找对象方法后进入这里,无需再给此方法单独注册 on 连接;方法返回后继续目标监听器。
// 本覆盖不检查 event.kind;显式 on 连接只监听 click,而该对象方法也可能处理源码派发的其他事件。
self.model.count.increment()
self.model.methodStep = self.model.count.value
self.model.phase = event.phase
}
}
model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击")
// 只在顶层创建一次日志,重复点击累计序列,四个 Step 会被各自最近一次阶段调用覆盖。
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)
// root 是目标的祖先;若把监听器放在另一无关容器,它不会仅因共享 model 而加入这条传播路径。
bubble = root.on("click", model.bubble)
page = Page()
page.render(root)
+8
View File
@@ -2,23 +2,31 @@
// 退出先取消任务、关闭窗口,再进行允许的卸载回调;已停止 VM 的清理不能重新进入源代码。
// 在原生文字类型上增加共享日志,用来观察每个 Mount 的卸载通知。
class ExitText: TextBlock {
// 业务对象共享同一日志引用,Mount 参数区分每次出现;不为每个窗口复制一份 ExitText.log。
log: Object
// 每次真实卸载递增同一 log;普通重绘不调用,重复 shutdown 也不应重复调用。
// 宿主挂载生命周期在某出现实际卸载时调用,不由普通 draw/repaint 调用;当前方法不再创建窗口或新任务。
function onUnmount(self: ExitText, mount: Mount): Unit {
// 每次出现通知增加一次共享值,可在保留的另一个视图观察;本例不把 Mount 当作另一份业务状态。
self.log.increment()
}
}
// 全局持有日志,之后 watched 及 textOf 也引用它,关闭一个窗口不意味着日志立即失去业务所有者。
log = ui.state(0)
// 文字业务对象只创建一次,Page 与另一窗都引用它,但两边有各自的 Mount 生命周期。
watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log)
// 主窗口 Page 持有组合根,watched 在组合中形成一次出现;另一页稍后再形成独立出现。
page = Page()
page.title = "退出生命周期"
page.render(Column(24,16,watched,ui.textOf(log,"value")))
// 创建同 VM 内的另一原生窗口;迁移 other 后仍保留 watched 的业务身份。
second = Window()
second.setTitle("Exit lifecycle secondary")
// 独立 Page 使用同一 watched 引用,挂到第二窗口时创建自己的 Mount 和卸载通知路径。
other = Page()
other.render(watched)
// 移动的是 Page 的窗口归属,watched 的共享对象身份保持;不创建第二个 VM 运行同一源码。
other.moveToWindow(second)
// 应用级长定时任务用于观察退出会取消尚未完成的工作,不让退出过程等满 30 秒。
// sleep 不带回调,返回可等待 Task;即使代码保存 pending,全局退出仍先取消任务而不等待计时结束。
pending = ui.sleep(ui,"pending",30000)
+6
View File
@@ -1,22 +1,28 @@
// 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。
// 绑定显示读取该字段,GUI 回调与 Console 赋值受同一契约约束。
// 不匹配的字段写入保留该字段旧值;这不表示整次回调或同一输入中较早的其他写入都会回滚。
// 增加 value:Number;构造时先传原生 text,再传源字段值。
class CounterText: TextBlock {
// value 是源字段,不是 TextBlock 的原生 text;页面把两者作为不同内容分别展示。
value: Number
// 收到 click 后把已有数值改为 10,依赖文字随之更新,不修改原生 text 内容。
function change(self: CounterText, event: Event): Unit {
// button.on("click", ...) 派发时提供 event;本例不读取事件载荷,只向受 Number 契约保护的字段写值。
self.value = 10
}
}
// 声明 target 必须是 Block,可接收 CounterText,因为它是 TextBlock 的子类。
class Model {
// 这里用父类型约束对象身份;不复制 counter,也不将它退化为只有 Block 字段的新对象。
target: Block
}
// 构造唯一受检对象,Model、事件回调和 textOf 均引用它。
counter = CounterText("共享的原生子类对象", 9)
model = Model(counter)
// model 仅演示构造时接受子类型;下面的回调和绑定直接引用 counter,不经过 model.target。
button = Button("把 Number 字段改成 10")
connection = button.on("click", counter.change)
// 保留 connection 供后续管理订阅;回调的接收者已经绑定为 counter,页面只负责把它与数值绑定挂载出来。
page = Page()
page.title = "字段类型契约"
page.render(Column(24, 16, counter, ui.textOf(counter, "value"), button, TextBlock("GUI 和源码写入同一个受检查的字段")))
+11
View File
@@ -3,6 +3,7 @@
// 给 Row 一个有限 340x60 的期望尺寸,便于精确观察分配数值。
class Strip: Row {
// 源测量返回固定尺寸;后续排列仍使用原生 Row 的 padding/gap 与 flex 规则。
// source_layout 在该 Strip 出现的测量阶段调用,返回 MeasureResult 仍会按父传入 Constraints 钳制。
function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(340, 60)
}
@@ -12,15 +13,19 @@ class Actions {
first: RectBlock
second: RectBlock
// 两项 basis=100,按1:3分增长;首项 max=105 触顶后,多余增长转给第二项。
// 增长按钮的绑定回调,先复位首项下界/上界,再改 basis 与权重,最终布局在本次输入后的刷新中求解。
function grow(self: Actions): Unit {
self.first.flexMin = 0
self.first.flexMax = 105
// basis 提供参与分配的起始主轴尺寸,与 RectBlock 构造时自然宽度不同。
self.first.flexBasis = 100
self.second.flexBasis = 100
self.first.flex = 1
// grow 不清除 shrink 曾设的 flexShrink=1;本例 basis 总量小于可用量,走增长分支,故不影响这里的预期。
self.second.flex = 3
}
// 两项 basis=200 造成缺额,以 flexShrink*basis 分摊;首项 min=150 后第二项继续收缩到90。
// 收缩按钮回调复位增长上界并设置下界,两个 flex=0 不参与增长,但 flexShrink=1 仍允许按 basis 收缩。
function shrink(self: Actions): Unit {
self.first.flexMax = 300
self.first.flexMin = 150
@@ -28,22 +33,28 @@ class Actions {
self.second.flexBasis = 200
self.first.flex = 0
self.second.flex = 0
// 权重是 flexShrink×basis,本例 basis 同为 200,首项触底后剩余缺额继续转到第二项。
self.first.flexShrink = 1
self.second.flexShrink = 1
}
}
// 自然宽20/40与basis可不同;fixed不设置权重,保持原始60宽。
first = RectBlock(20, 20, 4, "#A4C8F0")
// 两个可调对象分别用不同颜色展示,当前自然宽并不决定设置 flexBasis 后的起始分配。
second = RectBlock(40, 20, 4, "#A6D8B2")
// 未改 flex/basis/shrink 的固定项不承担本例缺额,保留 60 宽参与总宽计算。
fixed = RectBlock(60, 20, 4, "#E8C990")
actions = Actions(first, second)
grow = Button("增长:上限后重新分配")
// 注册零参源方法,由点击派发执行;下面显式 actions.grow() 才是在初次 render 前立即初始化。
grow.onClick(actions.grow)
shrink = Button("收缩:最小尺寸后重新分配")
shrink.onClick(actions.shrink)
// 先初始化增长策略再挂载,首次发布即采用105/135/60,不需等待按钮点击。
actions.grow()
// 340 总宽减左右 padding 共 20、两个 gap 共 20 和固定项 60,留给两项的主轴总量是 240。
strip = Strip(10, 10, first, second, fixed)
// 输出提示中的 105/135 或 150/90 基于 Strip 获得足够父约束的尺寸;实际几何仍由宿主约束校验。
page = Page()
page.title = "基础尺寸与边界"
page.render(Column(20, 12, Row(0, 10, grow, shrink), strip, TextBlock("增长:105 / 135 / 60;收缩:150 / 90 / 60。")))
+10
View File
@@ -2,25 +2,35 @@
// paragraph 只有一份业务内容,但在两个 Row 的 Mount 中各自按真实分配宽度测量/换行。
// 只持有首个 Row 的 filler,修改它的权重来间接改变同排文字分配。
class Actions {
// Actions 只持有首排填充块引用,不持有共享段落或第二排的固定矩形,按钮修改的对象范围因此明确。
filler: RectBlock
// 将 filler 权重恢复为 1,与 paragraph=1 近似等分可用空间。
// Button.onClick 接受绑定后的零参回调,事件系统负责调用;这里改权重,不直接给 Mount 写最终几何。
function balanced(self: Actions): Unit { self.filler.flex = 1 }
// filler 权重改为 3,paragraph 在首排获得剩余空间的四分之一,换行高度随之增加。
// 同一父 Row 内 paragraph=1、filler=3 按剩余空间分配,父容器与子上限仍可进一步限制实际宽度。
function narrow(self: Actions): Unit { self.filler.flex = 3 }
}
// 共享文字的 flex=1 是业务布局策略,具体几何与自然尺寸缓存仍属于各 Mount。
paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。")
// 原生 measure 取得段落自然/换行尺寸,原生 arrange 分配主轴空间,源布局驱动可为分配宽度再次测高。
paragraph.flex = 1
// 自然尺寸 20×20 不等于 flex 后最终宽度;flex=1 让这个出现参与首排剩余空间分配。
filler = RectBlock(20, 20)
filler.flex = 1
// 绑定下面两个按钮的方法时捕获同一 Actions 实例,后续点击仍更新已挂载的 filler。
actions = Actions(filler)
balanced = Button("等比例分配")
// 注册监听器而不立即执行 balanced,所以第一次布局沿用上面已经设好的初始权重。
balanced.onClick(actions.balanced)
narrow = Button("文字只占四分之一")
// 另一个按钮只选择同一实例的方法,切换策略不重新构造两排容器或共享文字。
narrow.onClick(actions.narrow)
// 首排两个可伸缩子项,次排只有文字 flex 与固定 300px 兄弟,因此首排按钮不会改次排宽度。
first = Row(10, 10, paragraph, filler)
// 第二排的段落仍是同一业务对象,兄弟是未设置 flex 的固定宽矩形,分配宽度属于第二个出现。
second = Row(10, 10, paragraph, RectBlock(300, 20))
// 同一个 Page 的根中同时包含两个段落 Mount,修改文本会影响两处,修改首排 filler 权重只改变首排约束。
page = Page()
page.title = "按权重分配与文字重排"
page.render(Column(10, 10, Row(0, 10, balanced, narrow), first, second))
+2
View File
@@ -4,8 +4,10 @@
let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422")
// 同一文字控件包含中英文和重音字符,用于观察字体回退与基线。
let detail = ui.text("中文 English 123 — café", 1, "#606060")
// title/detail 是两个独立 TextBlock,字体系统在各自测量和绘制阶段使用一致的整形规则,颜色不参与字宽估算。
// 两组相同尺寸色块跟在窄/宽字符后,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()
// 整个脚本顶层构造完成后请求展示,没有点击回调或定时刷新;宿主布局负责把 row 中的真实测量宽度累加到色块位置。
page.render(ui.column(24, 12, title, detail, row))
+5
View File
@@ -3,11 +3,13 @@
// Number 输入原样返回,用于说明参数类型在进入函数体前检查。
function identity(value: Number): Number { return value }
// 接受 Function 值并零参数调用,结果仍需符合本函数的 Number 返回契约。
// 顶层 initial 的求值会调用它,callback 是已绑定 actions 的 read,调用时无需再传 self。
function callNumber(callback: Function): Number { return callback() }
// 接受声明 Visual 能力的对象,返回 Block;调用时两侧契约都要成立。
function visual(value: Visual): Block { return value }
// 保留共享 state,read 作为普通绑定函数使用,click 作为接收 Event 的事件回调。
class Actions {
// Object 允许保存原生状态对象;实际 read/click 仍要求该对象提供 value/increment 成员。
state: Object
// 读取模型值而不修改业务状态,保存 actions.read 时接收者已绑定。
function read(self: Actions): Number { return self.state.value }
@@ -15,12 +17,15 @@ class Actions {
function click(self: Actions, event: Event): Unit { self.state.increment() }
}
state = ui.state(0)
// Actions 与 textOf 持有同一状态,点击 increment 后依赖显示读取新的 value,而非重新计算 initial。
actions = Actions(state)
// 保存绑定方法对象,之后无参调用仍使用 actions 作为 self;不是重新按字符串查找对象。
savedFunction = actions.read
initial = identity(callNumber(savedFunction))
// initial 只在顶层求值一次,未注册为响应式绑定,也未加入页面,因此后续点击不更新这个变量。
button = Button("调用带类型检查的对象方法")
connection = button.on("click",actions.click)
// Column 具备 Visual 能力且满足返回 Block 契约,visual 只验证并返回原对象,不额外包一层容器。
root = visual(Column(24,16,TextBlock("源码、原生与绑定方法统一为 Function"),ui.textOf(state,"value"),button))
page = Page()
page.title = "函数类型检查"
+11
View File
@@ -2,47 +2,58 @@
// 关闭临时页仍可因业务引用保留等待任务;releaseCycle 清掉外部引用后,缓存不应让整个引用环常驻。
// 比较操作前后对象数时,关闭页面只解除展示根;holder/pending 外部引用也释放后才应回到基线。
holder = ui.state(0)
// pending.value 后续保存 Task;它和 holder.value 保存的 Page 是两条需要显式清除的外部可达路径。
pending = ui.state(0)
phase = ui.state(0)
// 通过 peer 指回包含自己的 Page,配合等待协程形成包含视觉缓存读依赖的引用环。
class CycleRect: RectBlock {
// 指回 transient 页面;页面又通过 render 的根保留 rect,形成真实托管对象环。
peer: Object
// sleep 归属该 rect,await 保留协程帧;先写 phase=1,只有实际恢复后才写 2 并改 Page 标题。
function wait(self: CycleRect): Unit {
// ui.spawn 调用绑定 wait,sleep 的 owner 仍是同一 rect,键 sleep 与外层 workflow 任务键不同。
gate = ui.sleep(self,"sleep",300000)
phase.set(1)
// 在 gate 完成前挂起此调用,下面两条写入尚未执行;done 只是保存恢复结果,本例不读取它。
done = await gate
phase.set(2)
self.peer.title = "等待已结束"
}
// 故意读取 peer.title,让布局依赖包含循环中的 Page;缓存应借用观察,不增加长期 GC 根。
function measure(self: CycleRect, mount: Mount, constraints: Constraints): MeasureResult {
// source_layout 的只读测量调用;observed 虽不参与数值计算,成员访问仍是需要跟踪的实际读取。
observed = self.peer.title
return MeasureResult(180,40)
}
// 绘制也读取 peer.title,检查绘制缓存与等待帧不会共同意外保活循环。
function paint(self: CycleRect, mount: Mount, geometry: Bounds): DisplayList {
// source_paint 的只读计算也读取环中页面;委托基类生成绘制数据,不把 Page 放进输出命令。
observed = self.peer.title
return RectBlock.paint(self,mount,geometry)
}
}
// 创建临时 Page 和 CycleRect,相互引用后挂载,再把 Page/Task 分别放入两个外部 holder。
// 按 mountCycle → 观察 → releaseCycle 的顺序使用;未释放前重复 mountCycle 会另建页面并覆盖 holder,不是重置原环。
function mountCycle(): Unit {
// 供 Console 手工调用的零参入口,加载此文件只定义函数,不立即创建临时环或开始五分钟等待。
transient = Page()
transient.title = "临时引用环"
rect = CycleRect(180,40,8,"#3868D9",transient)
transient.render(rect)
holder.set(transient)
phase.set(0)
// spawn 返回工作流 Task 立即存入 pending,等待期间可经 pending.value.status() 观察,不能在此同步等完成。
pending.set(ui.spawn(rect,"workflow",rect.wait))
}
// 先关闭临时页,随后去掉两个外部引用;等待依赖无需等满五分钟,失去可达性后应整体回收。
function releaseCycle(): Unit {
// 应在 mountCycle 建立 holder 后由 Console 调用;close 先解除展示关系,再清除外部 Page 与 Task 引用。
holder.value.close()
holder.set(0)
pending.set(0)
}
// 基线页面始终存在,用它比较操作前后的托管对象数,不把临时页面当作基线。
base = Page()
// base/holder/pending/phase 这些顶层对象仍保留,比较 GC 数量时不能要求整个会话回到零对象。
base.title = "GC 基线"
base.render(TextBlock("关闭临时 Page 后应回收引用环",2))
+6 -1
View File
@@ -1,17 +1,22 @@
// 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。
// Console 求值 42 仅改变最后结果 _,不应重绘;给 color 赋新值才使绘制失效。
// Console 求值 42 仅改变最后结果 _,不应重绘;给 color 赋新值才使此处读取失效。
// 这不排除视口、原生几何或其他被读取的协议依赖触发刷新;本例对比的是 color 与未读取的全局名。
color = "#ED6A5A"
// 保持原生几何,覆盖颜色策略,使示例只观察全局读取的依赖粒度。
class GlobalPaint: RectBlock {
// 调用原生基类产出新命令,读取当前 color 并写到新命令;不在只读阶段修改全局。
function paint(self: GlobalPaint, mount: Mount, geometry: Bounds): DisplayList {
// source_paint 在当前出现绘制时调用;基类用本轮 geometry 构造新的 DisplayList 和矩形命令。
drawing = RectBlock.paint(self,mount,geometry)
// 右侧按全局名称读取 color,左侧只修改本次新结果;不是写 self.color,也不修改已有业务对象。
drawing.commands.at(0).color = color
// 宿主解码校验返回命令后缓存绘制结果;缓存失效观察上面真实发生的全局 color 读取。
return drawing
}
}
// 显示固定大小矩形和操作提示,后续通过 Console 改同一个会话的 color。
rect = GlobalPaint(480,120,12,"#DDEEFF")
// 构造颜色是原生载荷,但每轮源 paint 会用全局 color 覆盖返回命令,所以初次可见颜色来自 #ED6A5A。
page = Page()
page.title = "全局名字依赖"
page.render(Column(24,16,TextBlock("在 REPL 求值 42 不重绘;修改 color 会更新矩形颜色",2),rect))
+3 -3
View File
@@ -1,10 +1,10 @@
function heading(value: Text): View { // 辅助函数把字符串包装成大字号 TextBlock;返回值仍是托管视觉引用。
function heading(value: Text): View { // 辅助函数把字符串包装成大字号 TextBlock;返回值仍是托管视觉引用。调用方为下面的 main,参数 value 是该次调用的 Text,不是可写全局状态。
return ui.text(value, 2, "#242422") // 这里只创建文字对象,具体宽高由实际字体和出现位置约束测量。
}
function main(): View { // 兼容模块入口 main 返回视觉根,宿主负责把它显示到页面,不需要源代码显式 render。
function main(): View { // 兼容模块入口 main 返回视觉根,宿主负责把它显示到页面,不需要源代码显式 render。endor_host 的模块执行路径在定义加载完成后调用这个零参数入口。
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 间隔决定最终命令位置;返回后发布快照拥有所需数据。
return ui.column(24, 12, title, rectangle, detail) // Column 的 24px 内边距和 12px 间隔决定最终命令位置;返回后宿主提取原生发布快照;Column 在 VM 中继续按托管引用拥有这些子对象。
}
+71
View File
@@ -1,24 +1,33 @@
// All application state, motion, fire, hit reactions and scheduling live here.
// Tessera only supplies generic graphics/text/geometry, input and owned Tasks.
// 整个文件作为一次源码输入加载:应用状态、跟随运动、火焰与定时重绘均由源方法实现,宿主提供通用图形与任务能力。
// graphics 是通用图形入口,math 提供数学/时钟函数;assets 为相对于运行工作目录的素材路径,不是网络地址。
g = ui.graphics
math = g.math
assets = "examples/assets/manuscript/"
font = g.loadFont(assets + "IMFellEnglish-Regular.ttf", "IM FELL English")
// 同步 graphics.readText 先按 16 KiB UTF-8 资源预算读故事,再按 Unicode 标量索引截取;1000000 不会扩大读取预算。
// 两套字号/行距在顶层预整形,paint 按宽度选择;字体注册属于进程级文字引擎,不因这个 VM 关闭自动撤销。
story = g.textSlice(g.readText(assets + "story.txt"), 1, 1000000)
bodyText = g.prepareText(story, font, 21, 34, "#2A1A0A", 2)
smallText = g.prepareText(story, font, 16, 26, "#2A1A0A", 2)
hint = g.prepareText("Move the pointer. Hold to breathe fire.", font, 14, 22, "#705B44", 2)
// artwork 调用的精灵包装:image 是共享图片资源,返回新的绘制命令;位置/锚点/旋转不写回图片对象。
function sprite(image: Object, x: Number, y: Number, scale: Number, angle: Number, ax: Number, ay: Number): Object {
return g.sprite(image, x, y, image.width * scale, image.height * scale, angle, ax, ay, 1)
}
function wrappedAngle(angle: Number): Number {
// step 的姿态插值调用,把差值归一化到 [-π,π],跨越一圈边界时仍选择短方向;循环不修改传入业务姿态。
result = angle
// 大于正半圈就逐圈减去 2π,允许输入差值超过一圈,不只处理一次越界。
while result > math.pi { result = result - math.pi * 2 }
// 小于负半圈则反向逐圈加回,与上面共同覆盖任意有限角度差。
while result < -math.pi { result = result + math.pi * 2 }
return result
}
class Segment {
// 龙身体的单节状态,位置为手稿设计坐标,angle 为弧度;step 修改姿态,artwork 只读它生成本轮命令。
x: Number
y: Number
angle: Number
@@ -27,11 +36,14 @@ class Segment {
// Asset orientation is application data: angles are radians, clockwise in
// screen coordinates. Each PNG is calibrated against its own long axis.
class FireArt {
// image 为火焰图片资源,axis 为该图片长轴方向校准,artwork 用速度方向减它以正确旋转素材。
image: Object
axis: Number
}
// 顶层 fireDirections 的轻量记录,loadFire 逐一取 angle,与 Layer 2..11 的图片顺序对应。
class Direction { angle: Number }
class Flame {
// x/y、vx/vy 为设计坐标中的粒子位置与每步速度;age 按 step 次数递增,size/art 控制绘制,不使用独立后台线程。
x: Number
y: Number
vx: Number
@@ -41,6 +53,7 @@ class Flame {
art: FireArt
}
class Dragon {
// 三个列表分别保存龙节、活跃粒子与校准素材;图片字段共享资源,目标/按压/时间/seed 则为可变应用状态。
segments: Object
flames: Object
fireImages: Object
@@ -54,11 +67,13 @@ class Dragon {
lastMove: Number
seed: Number
function random(self: Dragon): Number {
// step 产生火焰时调用的确定性伪随机序列;先推进 seed,再取正弦混合值的小数部分,返回 [0,1)。
self.seed = self.seed + 1
value = math.sin(self.seed * 12.9898) * 43758.5453
return value - math.floor(value)
}
function pointer(self: Dragon, event: Event): Unit {
// pointermove 和 down 调用;event 为当前出现局部指针坐标,bounds 给出可用宽度,反变换与 paint 的缩放/留白保持一致。
bounds = event.mount.bounds()
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
self.targetX = (event.x - (bounds.width - 700 * scale) / 2) / scale
@@ -66,38 +81,49 @@ class Dragon {
self.lastMove = math.now()
}
function down(self: Dragon, event: Event): Unit {
// pointerdown 先更新目标和最近移动时刻,再开启喷火;这里只改状态,粒子生成留到定时 step。
self.pointer(event)
self.pressed = 1
}
// pointerup 关闭新粒子生成,已有火焰仍按后续 step 继续移动并到龄移除。
function up(self: Dragon, event: Event): Unit { self.pressed = 0 }
function leave(self: Dragon, event: Event): Unit {
// pointerleave/pointercancel 共用入口,清按压并把最近移动时间置为过期值,使龙回到待机姿态。
self.pressed = 0
self.lastMove = -10
}
function step(self: Dragon): Unit {
// Manuscript.tick 每次定时恢复调用一次;这是可写业务阶段,更新姿态/粒子供下一轮只读 paint 使用。
self.time = math.now()
// 超过两秒没有指针移动时,用平滑插值回到预设弧形,不继续追踪旧目标。
if self.time - self.lastMove > 2 {
// x/y 为本轮待机目标游标,i 从头节开始;float 明确提供可进行浮点运算的起始值。
x = math.float(150)
y = math.float(110)
i = 0
while i < self.segments.count {
// 顺序访问每一节,以索引定义弧度;每轮只修改这一节的姿态,再推进游标。
s = self.segments.at(i)
angle = -i / 19 * math.pi * 0.7
if i > 0 {
// 头部使用固定起点,后续节沿目标角度后退 30 设计单位,形成待机弧线。
x = x - math.cos(angle) * 30
y = y - math.sin(angle) * 30
}
s.x = s.x + (x - s.x) * 0.12
// 位置与角度都只靠近目标的 12%,wrappedAngle 防止跨 ±π 时绕远路。
s.y = s.y + (y - s.y) * 0.12
s.angle = s.angle + wrappedAngle(angle - s.angle) * 0.12
i = i + 1
}
} else {
// 最近有输入时,头部追逐指针目标,其余节依次根据已经更新的前一节重建位置。
head = self.segments.at(0)
dx = self.targetX - head.x
dy = self.targetY - head.y
distance = math.sqrt(dx * dx + dy * dy)
if distance > 4 {
// 距离足够才归一化方向,避免近零除法和微小抖动;速度下限 12,但不会超过剩余距离。
speed = math.min(distance, math.max(12, distance * 0.15))
head.x = head.x + dx / distance * speed
head.y = head.y + dy / distance * speed
@@ -105,6 +131,7 @@ class Dragon {
}
i = 1
while i < self.segments.count {
// 身体从第二节向尾部遍历,限制相对前节弯曲到 ±0.25 弧度,再保持 30 单位节距。
previous = self.segments.at(i - 1)
s = self.segments.at(i)
angle = math.atan2(previous.y - s.y, previous.x - s.x)
@@ -115,6 +142,7 @@ class Dragon {
}
}
i = self.flames.count
// 从列表尾部向前更新粒子,删除当前索引后不会跳过尚未处理的前项。
while i > 0 {
i = i - 1
p = self.flames.at(i)
@@ -122,18 +150,24 @@ class Dragon {
p.x = p.x + p.vx
p.y = p.y + p.vy
p.vx = p.vx * 0.95
// 速度衰减模拟阻力,vy 再减 0.6 令屏幕坐标中的火焰向上飘;年龄是步数,不是秒。
p.vy = p.vy * 0.95 - 0.6
// 存活 12 步后删除,artwork 的透明度使用相同寿命常量,避免留下不可见粒子。
if p.age >= 12 { self.flames.remove(i) }
}
// 按压状态才发射;先更新/清理旧粒子,再检查总数上限。
if self.pressed > 0 {
head = self.segments.at(0)
i = 0
while i < 1 {
// 当前循环上界为 1,即每个 step 至多尝试产生一个粒子;并非一次生成十二个。
if self.flames.count < 12 {
// 仍有容量时随机扰动喷射方向/速度/大小,按素材列表长度选一个合法图片索引。
angle = head.angle + (self.random() - 0.5) * 0.25
speed = 20 + self.random() * 12
size = 12 + self.random() * 8
art = self.fireImages.at(math.floor(self.random() * self.fireImages.count))
// 从头部前方 30 单位发射,位置和速度都在设计坐标里;缩放只在 artwork 转绘制命令时应用。
self.flames.append(Flame(head.x + math.cos(head.angle) * 30, head.y + math.sin(head.angle) * 30, math.cos(angle) * speed, math.sin(angle) * speed, size, 0, art))
}
i = i + 1
@@ -141,29 +175,36 @@ class Dragon {
}
}
function artwork(self: Dragon, ox: Number, oy: Number, scale: Number): Object {
// Manuscript.paint 的只读调用,commands 是本次新列表;允许填写新命令,不能在这里推进 seed 或粒子年龄。
commands = ObjectList()
wing = self.segments.at(5)
commands.append(sprite(self.backWing, ox + wing.x * scale, oy + wing.y * scale, scale * 0.24, wing.angle + math.sin(self.time * 3) * 0.4, 1, 1))
// 后翼先画,再从尾到头画龙节,让头部覆盖后面的身体;索引 5 对应的前翼在该节之后追加形成前景层。
i = self.segments.count
while i > 0 {
// 与 step 的头到尾更新顺序不同,这里反向遍历仅为控制绘制遮盖,不改 segments 顺序。
i = i - 1
s = self.segments.at(i)
ax = math.float(0.5)
if i == 0 {
// 头节使用校准锚点,先追加舌头再追加头图,舌根被头部覆盖。
ax = 0.45
commands.append(sprite(self.tongue, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, -s.image.width * 0.3 / self.tongue.width, 0.5))
}
commands.append(sprite(s.image, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, ax, 0.5))
// 前翼只在固定身体节追加,时间正弦控制摆动;图像资源自身仍不发生旋转字段写入。
if i == 5 { commands.append(sprite(self.frontWing, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle - math.sin(self.time * 3 + 0.5) * 0.4, 1, 1)) }
}
i = 0
while i < self.flames.count {
// 每个活跃粒子产生一条新精灵命令;按图片最大边等比缩放,以粒子速度角减素材轴角校准朝向。
p = self.flames.at(i)
image = p.art.image
imageScale = p.size * scale / math.max(image.width, image.height)
angle = math.atan2(p.vy, p.vx) - p.art.axis
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
drawing.opacity = 1 - p.age / 12
// 随年龄分别跨过两个色调阈值,第二个 if 可覆盖第一个 tint;二者不是互斥分支。
if p.age > 4 { drawing.tint = "#E08A30" }
if p.age > 8 { drawing.tint = "#F0C030" }
commands.append(drawing)
@@ -172,19 +213,24 @@ class Dragon {
return commands
}
function heat(self: Dragon, commands: ObjectList, ox: Number, oy: Number, scale: Number): Unit {
// paint 只把本轮 flowText 新命令传入;允许改这些临时字形命令,不改 prepared 资源或 Dragon 原状态。
i = 0
while i < commands.count {
// 外循环逐字形命令处理,内循环逐火焰累加热扰动,多个粒子可共同影响同一字形。
c = commands.at(i)
j = 0
while j < self.flames.count {
p = self.flames.at(j)
dx = c.x - ox - p.x * scale
if math.abs(dx) < 60 * scale {
// 先做横轴范围排除,命中后才计算纵轴差,减少远处粒子的距离计算。
dy = c.y - oy - p.y * scale
if math.abs(dy) < 60 * scale {
// 通过轴向粗筛后求欧氏距离,以下两个条件分别排除近零除法和圆形作用范围外区域。
distance = math.sqrt(dx * dx + dy * dy)
if distance > 0.1 {
if distance < 60 * scale {
// 距离越近、粒子越年轻权重越大;位置向外推开,轻微转动并降低透明度但至少保留 0.3。
weight = (1 - distance / (60 * scale)) * (1 - p.age / 12)
c.x = c.x + dx / distance * weight * 8 * scale
c.y = c.y + dy / distance * weight * 8 * scale
@@ -202,6 +248,7 @@ class Dragon {
}
}
class Manuscript: RectBlock {
// dragon 保存动态业务状态;page 是动画任务 owner;prepared/small/initial 是共享资源,timer 保存最近一次 after 返回的 Task。
dragon: Dragon
page: Object
prepared: Object
@@ -209,58 +256,76 @@ class Manuscript: RectBlock {
initial: Object
timer: Object
function measure(self: Manuscript, mount: Mount, limits: Constraints): MeasureResult {
// source_layout 调用的只读测量,给出不超过 1000×900 的期望大小,宿主仍按完整 Constraints 校验/钳制。
return MeasureResult(math.min(limits.maxWidth, 1000), math.min(limits.maxHeight, 900))
}
function onMount(self: Manuscript, mount: Mount): Unit {
// 宿主挂载通知启动首个 80ms 单次任务,任务归 page、键为 animation;不会在 paint 中调度副作用。
// 本例 view 只有一个有效出现;若复用它产生多个 Mount,仍共用同一 page/key 和 timer 字段,不会各自拥有独立动画时钟。
self.timer = ui.after(self.page, "animation", 80, self.tick)
}
// 宿主卸载通知取消最近安排的任务,防止隐藏/退役视图继续无意义地推进动画。
function onUnmount(self: Manuscript, mount: Mount): Unit { self.timer.cancel() }
function tick(self: Manuscript): Unit {
// after 到期且获得交付后调用 tick,先改龙状态,再安排下一次 80ms 单次任务;不是固定帧率或补齐错过帧。
// 位置插值与粒子年龄按实际 step 次数更新;worker 忙时回调延后,不按经过秒数补做相同数量的步。
self.dragon.step()
self.timer = ui.after(self.page, "animation", 80, self.tick)
}
function paint(self: Manuscript, mount: Mount, bounds: Bounds): DisplayList {
// source_paint 用本轮出现 Bounds 调用,设计画布宽 700,缩放限制为 0.2..1,ox/oy 把设计坐标映射到布局坐标。
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
ox = bounds.x + (bounds.width - 700 * scale) / 2
oy = bounds.y + 30
margin = 45 * scale
text = self.prepared
line = 34
// 窄于 540 时切到预整形的小字和 26 行距,否则用正文字体与 34 行距,不改任何资源对象。
if bounds.width < 540 {
text = self.small
line = 26
}
initialHeight = line * 7
// 首字母图高占七行,宽按素材比例计算;单独矩形排除区给正文留下首字母和右侧间距。
initialWidth = self.initial.width / self.initial.height * initialHeight
art = self.dragon.artwork(ox, oy, scale)
exclusions = ObjectList(g.rectExclusion(ox + margin, oy + margin, initialWidth + 12, initialHeight))
i = 0
while i < art.count {
// 每条龙/火焰精灵按图像形状生成绕排排除区,额外留白随画布缩放,不只用整体外接矩形挡文字。
exclusions.append(g.imageExclusion(art.at(i), 6 * scale))
i = i + 1
}
letters = 0
// 有火焰时请求可逐字母扰动的输出;无火焰时使用普通输出,避免总是拆成大量命令。
if self.dragon.flames.count > 0 { letters = 1 }
// 正文在给定矩形内绕开首字母与所有精灵;可用高度至少为零,避免小窗口产生负尺寸。
drawing = g.flowText(text, ox + margin, oy + margin, 700 * scale - margin * 2, math.max(0, bounds.height - 90 - margin), exclusions, letters)
// 只对新生成的正文命令应用热扰动,原文字符串和预整形资源保持原值。
if self.dragon.flames.count > 0 { self.dragon.heat(drawing.commands, ox, oy, scale) }
// 命令层次为矩形底色 → 首字母 → 正文 → 龙/火焰 → 页脚,列表追加顺序就是绘制顺序。
commands = RectBlock.paint(self, mount, bounds).commands
commands.append(g.sprite(self.initial, ox + margin, oy + margin, initialWidth, initialHeight, 0, 0, 0, 1))
commands.extend(drawing.commands)
commands.extend(art)
footer = g.flowText(hint, bounds.x + 20, bounds.y + bounds.height - 28, math.max(0, bounds.width - 40), 24, ObjectList(), 0)
// 页脚不使用正文排除区,也不应用 heat;把这批命令接到末尾再交宿主统一验证和绘制。
commands.extend(footer.commands)
return DisplayList(commands)
}
}
// 顶层先创建头节,再调用 makeBody 添加十九个身体节;图片只在加载示例时读取,不在动画步中加载。
segments = ObjectList()
headImage = g.loadImage(assets + "dragon-sprites/head.png")
segments.append(Segment(math.float(150), math.float(110), math.float(0), headImage))
function makeBody(): Unit {
// 顶层唯一一次调用,游标沿与待机姿态相同的弧线前进;i=1..19 对应 body-1.png 到 body-19.png。
i = 1
x = math.float(150)
y = math.float(110)
while i < 20 {
// 计算当前节相对于前节的后退位置,加载对应素材并保留在 Segment,随后递增索引。
angle = -i / 19 * math.pi * 0.7
x = x - math.cos(angle) * 30
y = y - math.sin(angle) * 30
@@ -274,24 +339,30 @@ fireImages = ObjectList()
// Layer 2..11: baseline directions differ; these values belong to this artwork.
fireDirections = ObjectList(Direction(-0.50), Direction(-0.30), Direction(-0.55), Direction(0.40), Direction(-1.35), Direction(-1.57), Direction(-1.57), Direction(0.05), Direction(-1.40), Direction(1.12))
function loadFire(): Unit {
// 顶层调用一次,按图片编号 2..11 加载十张火焰;校准表索引用 i-2 转成从零开始。
i = 2
while i <= 11 {
// FireArt 同时保留图片和其自身轴角,之后随机选择整个记录,避免图片和校准值错位。
image = g.loadImage(assets + "fire-sprites/Layer " + g.string(i) + ".png")
fireImages.append(FireArt(image, fireDirections.at(i - 2).angle))
i = i + 1
}
}
loadFire()
// 构造唯一 Dragon,空 flames 列表稍后由 step 填充;初始 lastMove=-10 进入待机路径,seed=42 固定伪随机起点。
dragon = Dragon(segments, ObjectList(), fireImages, g.loadImage(assets + "dragon-sprites/tongue.png"), g.loadImage(assets + "dragon-sprites/wing-front.png"), g.loadImage(assets + "dragon-sprites/wing-back.png"), math.float(150), math.float(110), 0, math.now(), -10, math.float(42))
page = Page()
page.title = "Illustrated Manuscript · Endor"
view = Manuscript(700, 800, 0, "#F4EEE0", dragon, page, bodyText, smallText, g.loadImage(assets + "dropcap.png"), ObjectList())
// 原生 RectBlock 构造参数之后追加六个源字段;timer 的临时初值在挂载通知中换成真正 Task,随后才能 cancel。
view.flex = 1
view.boxWidth = "fill"
page.crossAlign = "stretch"
// 五个连接共用 dragon 的绑定方法,取消与离开走同一清理入口;只注册输入,不在顶层启动 step 循环。
moveConnection = view.on("pointermove", dragon.pointer)
downConnection = view.on("pointerdown", dragon.down)
upConnection = view.on("pointerup", dragon.up)
leaveConnection = view.on("pointerleave", dragon.leave)
cancelConnection = view.on("pointercancel", dragon.leave)
// render 后建立出现,onMount 才启动动画任务;paint 由宿主刷新调用,tick 的状态变化使后续帧重新计算。
page.render(view)
+10
View File
@@ -4,6 +4,7 @@
class WidthModel {
width: Number
// 点击把模型宽度置为 260;源 measure 读取过该字段,缓存因此失效。
// 按钮 click 的 Event 回调,只写 WidthModel.width;后续是否重测由已记录的真实读取失效决定。
function expand(self: WidthModel, event: Event): Unit {
self.width = 260
}
@@ -12,21 +13,26 @@ class WidthModel {
class Tint {
color: String
// 只改颜色;正常情况下需重绘而不重新执行无关源测量。
// 另一按钮的 Event 回调只写 Tint.color,不改 dimensions,避免用无关模型写入混淆测量依赖。
function blue(self: Tint, event: Event): Unit {
self.color = "#526AC7"
}
}
// 原生矩形的尺寸/颜色策略分别由源协议覆盖,业务模型通过字段持有。
class CachedRect: RectBlock {
// CachedRect 引用两个不同策略对象;measure 读尺寸对象,paint 读颜色对象,各阶段依赖分开记录。
dimensions: WidthModel
tint: Tint
// 读取 dimensions.width 返回固定高 60;实际读观察由运行时缓存记录,不手工声明依赖表。
// 只读测量由 source_layout 调用,self 是共享矩形,mount/constraints 是当前出现输入,不写 geometry。
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 {
// 源 paint 由 source_paint 调用,显式委托基类得到本轮新 DisplayList,不在这里发输入事件或改变 Page。
drawing = RectBlock.paint(self, mount, geometry)
// 修改的是新分配命令的颜色字段,再由宿主统一解码校验;self.tint 仍只读。
drawing.commands.at(0).color = self.tint.color
return drawing
}
@@ -34,6 +40,7 @@ class CachedRect: RectBlock {
// 宽度来自 ui.viewport 的当前逻辑页面区域,显示窗口变化也能成为测量依赖。
class ViewportBand: RectBlock {
// 返回可用视口宽度,最终仍服从父容器约束;外层 padding 会减少分配宽度。
// ui.viewport 取当前窗口逻辑页面范围,实际返回尺寸还会受父级更窄的 Constraints 约束。
function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(ui.viewport().width, 24)
}
@@ -41,12 +48,15 @@ class ViewportBand: RectBlock {
// 创建相互独立的宽度/颜色模型并绑定两个按钮,用相同对象观察缓存与可见结果。
dimensions = WidthModel(200)
tint = Tint("#ED6A5A")
// 源字段 dimensions/tint 放在原生构造参数后,RectBlock 的默认尺寸不是这个覆盖 measure 的最终输出。
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
band = ViewportBand(80, 24, 0, "#237A45")
blue = Button("只改绘制颜色")
expand = Button("修改测量依赖")
// 保存两个监听连接,颜色按钮绑定 tint,宽度按钮绑定 dimensions,不把 UI 事件对象用作缓存 owner。
blueConnection = blue.on("click", tint.blue)
expandConnection = expand.on("click", dimensions.expand)
// 根组合同时展示 viewport 依赖和业务尺寸依赖;按钮回调完成后宿主统一刷新,不在点击方法里手工重排。
root = Column(24, 16, TextBlock("改变颜色保留测量缓存;改变宽度重新测量"), band, rect, blue, expand)
page = Page()
page.render(root)
+11
View File
@@ -4,26 +4,34 @@
class Policy {
width: Number
// 选择 280 逻辑像素宽度,段落变高,后继文字下移。
// narrow/wide 按钮事件回调更新同一 policy.width,只有持有该策略的 ArrangedColumn 读取它。
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 {
// 布局共享策略模型,不保存段落的全局最终宽度;某个出现的约束/分配由 Plan 独立记录。
policy: Policy
// 先取得包含全部子槽的 Arrangement,仅改第一子项分配;保留其他槽使排列验证完整。
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
// source_layout 的安排阶段调用覆盖方法,基类利用本轮孩子测量值生成包含全部稳定 slot 的临时结果。
result = Column.arrange(self, mount, geometry)
// 只改返回的第一子槽 Bounds.width,宿主会验证槽完整性并据显式分配反推下一轮精确测量约束。
// at(0) 只用于挑选返回列表中的首项;该 Bounds 原有的稳定 slot 保持不变,宿主按 slot 而非列表下标关联孩子。
result.children.at(0).width = self.policy.width
return result
}
}
// 通过源 measure 改整个容器的期望宽度,以对比 arrange 分配路径。
class MeasuredColumn: Column {
// MeasuredColumn 自有策略字段,不是共享 paragraph 的 boxWidth,也不复制段落内容。
width: Number
// 调用原生测量后修改新结果 width;这个新结果不属于已有业务对象,允许在只读计算中填写。
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
// 测量阶段先委托基类求自然尺寸,再修改临时 MeasureResult;已有业务对象仍受只读限制。
result = Column.measure(self, mount, constraints)
// 宿主读取这个新结果字段后还会按 Constraints 钳制;源返回 width 不等于绕过上层几何预算。
result.width = self.width
return result
}
@@ -32,13 +40,16 @@ class MeasuredColumn: Column {
policy = Policy(280)
paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。")
tail = TextBlock("这一行跟随上方段落。")
// 首个孩子是 paragraph,故 at(0) 修改的是它的分配;tail 的位置在重新测高与收敛后随段落高度更新。
arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
// 另一出现仍用同一 paragraph,但父测量策略为 360,与上面的 policy.width 独立。
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))
+2
View File
@@ -1,8 +1,10 @@
// 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。
// 返回 Column 视觉对象,宿主选择/挂载页面;这里的 title 和 rectangle 只在 VM 内建立业务引用。
// endor_script 将本文件作为完整模块解析,endor_host 调用零参数 main 后把返回的视觉根交给兼容展示路径。
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)
+3
View File
@@ -2,13 +2,16 @@
// 两个文字绑定读同一 counter;原生点击和 Console 都能操作该对象并传播显示变化。
// state 提供可写 value 和 increment 方法,textOf 建立显式字段到文本的依赖。
counter = ui.state(9)
// 两次 textOf 分别创建文字展示对象,但读取同一 counter.value;一次 increment 会更新两处绑定。
first = ui.textOf(counter, "value", 2)
second = ui.textOf(counter, "value", 2)
title = TextBlock("Endor:同一份对象,代码与 GUI 共同操作", 2)
button = Button("增加计数")
// 零参数源回调委托原生 state.increment;事件分发器根据签名调用,不强塞 Event 参数。
// 由 Button.onClick 在点击时调用,函数从当前会话全局取得 counter,不维护另一份局部数值。
function increment(): Unit { counter.increment() }
// 把实际函数作为回调而非函数名文本;render 之前只创建业务对象,render 后才有出现位置。
button.onClick(increment)
// Page 和 Column 将已有对象组成展示树;此时不调用 increment,首次绑定读到的值仍为 9。
page = Page()
page.render(Column(24, 16, title, Row(0, 16, first, second, button)))
+6
View File
@@ -6,6 +6,7 @@ class Sized: MeasureResult {}
class BaseRect: RectBlock {
// 原生方法按给定出现位置与约束测量,不把几何存进共享对象。
function measure(self: BaseRect, mount: Mount, constraints: Constraints): MeasureResult {
// 由 WideRect.measure 显式调用此实现,再委托 RectBlock 原生测量;传递的仍是当前出现与当前约束。
return RectBlock.measure(self, mount, constraints)
}
}
@@ -13,7 +14,10 @@ class BaseRect: RectBlock {
class WideRect: BaseRect {
// 调用基类后复制测量数值到新的 Sized;新结果仍满足 MeasureResult 协议。
function measure(self: WideRect, mount: Object, constraints: Object): Sized {
// 宿主布局驱动仍传入 Mount/Constraints;Object 注解只放宽本覆盖的参数检查,不改变实际输入对象。
// 若手工传入其他 Object,下一行委托 BaseRect.measure 仍检查其较窄的 Mount/Constraints 契约,不保证调用成功。
measured = BaseRect.measure(self, mount, constraints)
// 拷贝的是本轮测量结果的两个数值,不复制业务矩形或 Mount;返回新结果供宿主校验。
return Sized(measured.width, measured.height)
}
}
@@ -23,6 +27,7 @@ class Actions {
count: Object
// 先改 width 再递增计数,新的布局刷新会使用上述动态测量覆盖。
function widen(self: Actions): Unit {
// Button.onClick 调用零参绑定方法;width 写入令依赖该原生尺寸的测量失效,count 驱动另一文字绑定。
self.rect.width = 260
self.count.increment()
}
@@ -31,6 +36,7 @@ class Actions {
rect = WideRect(180, 60, 8, "#ED6A5A")
count = ui.state(0)
actions = Actions(rect, count)
// 所有点击都操作同一个 rect/count;重复设 width=260 不继续增加宽度,但 increment 每次都会增加计数。
button = Button("扩大矩形")
button.onClick(actions.widen)
root = Column(24, 16, TextBlock("参数可以更宽,返回类型可以更具体", 2), rect, button, ui.textOf(count, "value"))
+8
View File
@@ -2,6 +2,7 @@
// 普通数据变化不会重新挂载;页面关闭时子项先卸载,父项随后卸载,旧 Mount 不因重开而复活。
// order 是统一时序,mounted/unmounted 是子项次数;latest 保存最近通知的实际 Mount。
class LifecycleLog {
// order/mounted/unmounted/latest 为可写 state,四个顺序字段为 Number;构造参数按这里的字段次序传入。
order: Object
mounted: Object
unmounted: Object
@@ -16,14 +17,18 @@ class WatchedText: TextBlock {
log: LifecycleLog
// 记录顺序和次数,保存 Mount 并修改原文字;运行时在回调后重新收敛才发布。
function onMount(self: WatchedText, mount: Mount): Unit {
// 宿主为 child 的每个新出现分别调用;同一 self 会收到两次通知,但传入的是两个不同 Mount。
self.log.order.increment()
self.log.mounted.increment()
self.log.childMounted = self.log.order.value
// 保存最近一次参数供 Console 查询,后一次通知会覆盖前一次;mounted 累计值则保留两次调用。
// latest.value 强持有这个 Mount 供观察,不使已卸载 Mount 复活;Mount 到 Page/desktop 的反向关系仍为弱引用。
self.log.latest.set(mount)
self.setText("同一个对象,两个独立挂载位置")
}
// 记录已经脱离的出现位置;保留 Mount 可查询 mounted,但不能再当有效 bounds 使用。
function onUnmount(self: WatchedText, mount: Mount): Unit {
// 展示树移除此出现时由宿主调用,不能通过再次 setText 或保存 mount 让已经卸载的出现重新生效。
self.log.order.increment()
self.log.unmounted.increment()
self.log.childUnmounted = self.log.order.value
@@ -35,11 +40,13 @@ class WatchedColumn: Column {
log: LifecycleLog
// 记录父挂载顺序,不修改容器结构;子项随后各自执行通知。
function onMount(self: WatchedColumn, mount: Mount): Unit {
// 父 Mount 建立后的业务通知,只改日志;初始父通知先于下方两个 child 出现的通知。
self.log.order.increment()
self.log.parentMounted = self.log.order.value
}
// 父卸载时子项卸载已完成,序列号应位于所有子项通知之后。
function onUnmount(self: WatchedColumn, mount: Mount): Unit {
// 对整页的卸载,两个子出现先各计一次,父回调最后把当前总序号写入 parentUnmounted。
self.log.order.increment()
self.log.parentUnmounted = self.log.order.value
}
@@ -47,6 +54,7 @@ class WatchedColumn: Column {
// 多个 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)
// 两个 child 参数是同一对象引用;新增 log 字段位于所有原生视觉子项之后,不会被安排成额外孩子。
root = WatchedColumn(24, 16, child, child, TextBlock("挂载次数"), ui.textOf(log.mounted, "value"), TextBlock("卸载次数"), ui.textOf(log.unmounted, "value"), log)
page = Page()
page.render(root)
+6 -2
View File
@@ -1,15 +1,19 @@
// 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。
// 原生回调也发布 Function 签名:state.set 接收一个任意值,increment 接收零参数;此处事件分发将 Event 作为 set 的那个值。
// 同一事件注册机制按实际签名调用;label 绑定再把事件 kind 转成可显示的字符串。
// 保留最后一次收到的 Event 对象,初值 kind=waiting;不是只存一份格式化事件文本。
received = ui.state(Event("waiting"))
// 单独计数的状态与 received 无关,第二个按钮不会改变 eventKind 读取的事件对象。
count = ui.state(0)
// 只读计算提取当前事件种类,作为 label.text 绑定的依赖。
// 由 label.bind 的绑定求值调用,无参数;读取链经过状态 value 再到 Event.kind,返回可显示字符串。
function eventKind(): String { return received.value.kind }
label = TextBlock("waiting")
subscription = label.bind("text", eventKind)
// subscription 管理字段绑定,下面两个 Connection 管理输入监听,两种注册的用途不同。
withEvent = Button("传递 Event 给原生 set")
// received.set 是一参数原生方法,分发器传入实际 click Event。
// received.set 的参数并非专门限定为 Event;本次订阅按一参数签名调用,传入实际 click Event 并覆盖 received.value。
firstConnection = withEvent.on("click", received.set)
// 保存的是绑定 count 的原生方法,事件分发按它的零参签名调用,无需人为包装一个丢弃 Event 的函数。
withoutEvent = Button("调用零参数原生 increment")
// count.increment 的签名为零参数,分发器调用时不传多余 Event。
secondConnection = withoutEvent.on("click", count.increment)
+6
View File
@@ -2,10 +2,12 @@
// label 在树中出现多次但仍是同一对象,原生方法和源字段写入共同更新各出现位置。
// 扩展 TextBlock 的业务字段;其文本/颜色仍由原生父类存储与绘制。
class StatusText: TextBlock {
// 构造后保存颜色策略;CustomStatus 继续继承此字段,源覆盖中通过同一 self 读取。
activeColor: String
// 用继承的 setText/setColor 改原对象;activeColor 是构造时追加的源字段。
function activate(self: StatusText, event: Event): Unit {
// 这是基类事件策略,本例实际 label 是 CustomStatus,因此 ActionButton 转发会选中下方覆盖。
self.setText("继承的原生文本已更新")
self.setColor(self.activeColor)
}
@@ -17,6 +19,7 @@ class CustomStatus: StatusText {
// 显式调用基类 setText 以示范原生未绑定方法的接收者传递,再用继承方法改颜色。
function activate(self: CustomStatus, event: Event): Unit {
// 接收的是按钮转发的同一 Event;显式基类方法调用只传目标 self 和新文本,不把 Event 当作文本参数。
TextBlock.setText(self, self.message)
self.setColor(self.activeColor)
}
@@ -28,6 +31,7 @@ class ActionButton: Button {
// 不复制目标或创建新展示对象,直接调用目标业务方法,选择实际类型的覆盖实现。
function activate(self: ActionButton, event: Event): Unit {
// button.onClick(button.activate) 派发进入这里,target 实际为 CustomStatus,动态查找其 activate 覆盖。
self.target.activate(event)
}
}
@@ -41,5 +45,7 @@ class CustomPage: Page {
label = CustomStatus("点击按钮更新同一个自定义文本对象", 2, "#242422", "#237A45", "源码子类与原生载荷共同工作")
button = ActionButton("调用源码子类方法", label)
button.onClick(button.activate)
// caption 是 CustomPage 的源构造字段;下面 render 只把它的当前字符串用于标题 TextBlock,没有注册 caption 绑定。
page = CustomPage("Endor 原生类型的源码继承")
// label 在根孩子中引用两次而不复制,setText/setColor 一次写入会刷新两处出现;末尾 textOf 单独展示 message 字段。
page.render(Column(24, 16, TextBlock(page.caption, 2), label, label, button, ui.textOf(label, "message")))
+14
View File
@@ -2,31 +2,45 @@
// 每个 owner 下同 key 的重复点击替换旧定时器;任务生命周期不由画面是否可见推断。
// 把 owner 与要修改的 state 分开持有,使同一个调度方法可用于三个作用域。
class Actions {
// 保存任务的生命周期归属,不一定等于回调接收者 self;schedule 把它作为 ui.after 第一参。
owner: Object
// 回调实际修改的共享计数,与 owner 分开,三个 Actions 可以修改同一状态但有不同取消边界。
state: Object
// 定时任务成功交付后修改共享计数,零参数签名符合 timer 回调要求。
// 任务调度器用已绑定 self 的 complete 零参调用,timer 不额外传 Event;自有 state 的增量是交付后的业务修改。
function complete(self: Actions): Unit { self.state.increment() }
// 每次启动 1500ms 的 load 定时器;相同 owner/key 只有最新任务继续等待。
// 三个按钮的 click 事件路由调用 schedule,event 为事件参数;方法内部不读取它,只创建延时任务。
function schedule(self: Actions, event: Event): Unit {
// 返回句柄未存入字段也不立即取消:owner 的任务字典强持有 Task,调度器本身仅保存弱索引。
// 此处 owner 仍由全局/业务字段保留;同一 owner/load 新任务替换旧等待,不靠局部 Task 变量维持资格。
ui.after(self.owner, "load", 1500, self.complete)
}
}
// 唯一业务计数,三种 owner 的成功回调都递增此对象,文字绑定显示同一个 value。
state = ui.state(0)
// 该 Block 从未进入页面树,但全局引用仍持有它,因此它拥有的任务可执行。
hidden = TextBlock("这个 Block 没有挂载,也能拥有任务")
page = Page()
page.title = "任务生命周期"
// 三个 Action 共享一个 state,却分别归属 Page、Block、应用,便于比较关闭页面的取消边界。
// Page 仍在窗口注册表中才具备此 owner 的交付资格,单纯切换标签不等于关闭 Page。
pageActions = Actions(page, state)
// hidden 是普通 Block,未挂载不阻止其拥有任务;全局引用和 Actions 字段保留业务对象。
blockActions = Actions(hidden, state)
// 应用 owner 使用 ui,页面关闭后仍可交付,但整个运行时退出时会统一取消未完成任务。
appActions = Actions(ui, state)
pageButton = Button("Page 任务:关闭 Tab 会取消")
blockButton = Button("隐藏 Block 任务:关闭 Tab 仍继续")
appButton = Button("应用任务:关闭 Tab 仍继续")
// 保存连接对象供显式监听器生命周期管理,按钮的事件路由先调用对应 Actions.schedule 再安装定时器。
pageConnection = pageButton.on("click", pageActions.schedule)
// 监听器绑定 blockActions,实际任务 owner 来自该实例字段 hidden,不误用触发点击的按钮 Mount。
blockConnection = blockButton.on("click", blockActions.schedule)
// 监听器绑定 appActions,所以同一个按钮回调实现可复用而不改变三个不同 owner 身份。
appConnection = appButton.on("click", appActions.schedule)
page.render(Column(24, 16, TextBlock("1.5 秒后更新同一个状态对象;重复点击只保留最新任务"), ui.textOf(state, "value"), pageButton, blockButton, appButton))
// 额外应用级任务延迟 500ms,用于展示没有点击也可按 worker 空闲轮询交付。
// key=initial 与应用按钮的 key=load 不同,不会因启动演示定时器而取消后续 load 任务。
initial = ui.after(ui, "initial", 500, appActions.complete)
ui.window().setTitle("Owned Tasks")
+2 -2
View File
@@ -1,4 +1,4 @@
let heading = ui.text("Hello Endor", 2, "#242422") // 顶层脚本不需要 main:先创建文字对象;字号 2 和十六进制颜色传给原生文字构造器。
let heading = ui.text("Hello Endor", 2, "#242422") // 顶层脚本不需要 main:先创建文字对象;字号 2 和十六进制颜色传给原生文字构造器。endor_host 的脚本执行路径按顶层语句顺序运行本文件。
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 选择此页并生成出现位置及发布帧。
myui.render(ui.column(24, 12, heading, rectangle)) // 把文字和矩形放入 Column,padding=24、gap=12;render 请求展示此页,宿主刷新时再完成出现位置、测量、安排与绘制;源方法调用结束不等于 OS 已呈现像素。
+6
View File
@@ -10,18 +10,22 @@ class Product {
function shade(self: Product): String { return self.color }
// 点击更新原业务对象,现有绑定会同步文字和矩形,不重新调用整个 present 构造树。
function update(self: Product, event: Event): Unit {
// 由 present 中 button.on 注册的绑定方法调用,self 仍是顶层 product,不是 result 容器或 title。
self.name = "原业务对象已更新"
self.color = "#237A45"
}
// 在指定 Window 上下文创建文字、色块和按钮,连接原 self 的方法后返回组合根。
function present(self: Product, window: Window): Block {
// ui.present 验证显式能力后调用本方法,window 是本次展示上下文;每次调用都会新建下面这些视觉对象。
title = TextBlock(self.name)
swatch = RectBlock(180,48,8,self.color)
// 绑定令牌本身不承载另一份业务状态,绑定计算仍调用同一 Product。
titleBinding = title.bind("text",self.label)
colorBinding = swatch.bind("color",self.shade)
// title/swatch 的初值只是构造输入,后续 getter 绑定读取原 Product,使事件写入能继续传播。
button = Button("调用业务对象方法")
connection = button.on("click",self.update)
// result 保留三个视觉孩子,返回该根给调用方;这里尚未通过 Page.render 建立出现树。
result = Column(24,16,title,swatch,button)
// 保留原始业务身份,便于代码从展示根回到来源;contextWindow 仅记录创建时的窗口编号。
result.source = self
@@ -31,8 +35,10 @@ class Product {
}
// 显式声明能力并验证方法契约;只有同名 present 不会自动成为 Presentable。
ui.implement(Product,Presentable)
// 能力注册针对 Product 类型,实际业务字段在这个唯一实例上初始化;label/shade 将读取此实例。
product = Product("普通对象的显式展示能力", "#526AC7")
// 先获得视觉根,再由 ui.open 把它挂载到页面;最后设置 Page 标题。
view = ui.present(product)
// ui.open 接收上面已经生成的 Block,不会再次把 product 交给 present 重新生成另一棵视觉树。
page = ui.open(view)
page.title = "Presentable"
+8 -1
View File
@@ -2,10 +2,11 @@
// 绑定在目标隐藏时也会更新;两个页面共享同一组 Block,不需要两份模型。
// width/color/label 是唯一业务来源,绑定计算只读,expand 是显式事件写入入口。
class Model {
// 三个业务值由下面三个 getter 分别读取;绑定的目标属性保存在视觉对象上,业务修改入口在 expand。
width: Number
color: String
label: String
// 返回 Number,目标 width 属性同时做类型/尺寸验证。
// 绑定求值调用此零参 getter 并取得 Number;结果写入 rect.width,布局读取时仍须符合有限尺寸范围。
function getWidth(self: Model): Number { return self.width }
// 返回颜色文本;依赖只包含实际读取的模型属性。
function getColor(self: Model): String { return self.color }
@@ -13,21 +14,26 @@ class Model {
function getLabel(self: Model): String { return self.label }
// 一个事件同时改三个属性,随后绑定传播,布局与绘制在完整刷新后发布。
function expand(self: Model, event: Event): Unit {
// 由 clickConnection 派发调用,可写业务状态;三个 getter 则由绑定求值调用,不能反向修改模型。
self.width = 260
self.color = "#237A45"
self.label = "绑定已更新"
}
}
model = Model(120, "#526AC7", "点击更新共享对象")
// 原生构造给初始载荷,后面的 bind 再把这些属性接到模型;label 的初始化字符串也会被绑定结果替换。
rect = RectBlock(120, 40, 8, "#526AC7")
label = TextBlock("初始化")
// 保存三个可断开的绑定令牌;令牌不强持有隐藏目标,目标仍需由业务对象图保留。
widthBinding = rect.bind("width", model.getWidth)
// 每次传入的是已绑定 model 的零参函数,注册不会调用 expand,也不把方法名当作待执行源码字符串。
colorBinding = rect.bind("color", model.getColor)
labelBinding = label.bind("text", model.getLabel)
button = Button("更新宽度、颜色和文字")
clickConnection = button.on("click", model.expand)
root = Column(24, 16, label, rect, button)
// 两个页面共享 root 及其子对象,因此只有一组属性绑定,不需要为每个 Mount 再注册一组。
// 两页同属一个窗口,本例最终仅激活第一页;同时跨窗口显示的传播由宿主测试另外覆盖。
page = Page()
page.title = "通用属性绑定"
page.render(root)
@@ -35,4 +41,5 @@ page.render(root)
second = Page()
second.title = "同一组 Block"
second.render(root)
// 切回第一页不销毁第二页或重建模型;隐藏页仍引用同一组更新后的业务/视觉对象。
page.activate()
+6
View File
@@ -2,26 +2,31 @@
// 内层视口也可作为世界子项;HUD 在外层 Overlay 中,所以不会随世界相机变换。
// 场景保存世界对象,视口负责有限窗口与相机变换。
count = ui.state(0)
// 同一按钮随 card 在两个视口各出现一次,但只注册这一份回调,点击任意出现都修改同一个 count。
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
// 这些 x/y 是 Scene2D 世界坐标,不是页面 Row 中的像素位置;每个外层相机分别投影它们。
card.y = 40
card.borderLeft = 2
card.borderColor = "#8CA5B8"
// 有限内层视口看自己的小场景,再以世界坐标放进外层 Scene2D。
inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9")))
// inner 自身占外层世界里的有限盒,盒内的小场景另走 inner 的相机和裁剪,输入也需逐层换算。
inner.x = 260
inner.y = 40
// 两个外层视口引用同一 world,卡片/内层视口因此各产生两个出现位置。
// inner 自身仍是同一个 CanvasViewport 对象,其 camera 属性也只有一份;两个 inner Mount 的导航会联动,约束/裁剪仍各自独立。
world = Scene2D(card, inner)
// 左右视口尺寸和相机平移/缩放不同;显式赋同一 camera 才会共享导航状态。
left = CanvasViewport(460, 320, world)
left.camera.x = -100
right = CanvasViewport(320, 320, world)
// 两次构造视口默认产生不同 Camera2D,改 right.camera 的字段不会修改 left.camera。
right.camera.x = -80
right.camera.zoom = 0.65
// 如需联动,在 Console 将 right.camera 赋为 left.camera;共享场景本身不会自动共享相机。
@@ -30,6 +35,7 @@ right.camera.zoom = 0.65
views = Row(0, 20, left, right)
hud = Column(16, 8, TextBlock("一个世界,多个视角", 2), TextBlock("滚轮平移 · 空格/中键拖动 · 捏合或 Cmd/Ctrl+滚轮缩放"))
hud.alignSelf = "end"
// HUD 是 views 的 Overlay 兄弟,不是 world 的孩子;其位置受普通布局控制,不参与 world 相机平移缩放。
page = Page()
page.title = "场景与视口"
page.render(Overlay(12, 0, views, hud))
+8 -1
View File
@@ -1,24 +1,29 @@
// 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。
// 三维内容与普通 Endor 二维控件一起排版,viewport 有限尺寸不限制场景世界坐标。
// 三维内容与普通 Endor 二维控件一起排版;viewport 的像素宽高不是场景世界坐标范围,世界数值与几何规模仍受宿主预算检查。
// 保存左侧视口和共享立方体;动作可改相机位置或真实网格变换。
class SceneActions {
// view 只保存左透视视口,cube 保存两个视口共同引用的网格,两个字段的修改传播范围不同。
view: Viewport3D
cube: Mesh3D
// 把左相机放到 z=6 正面,保持相机既有 target。
function front(self: SceneActions): Unit {
// front.onClick 以零参绑定方法调用,按绝对坐标设置左相机,不重新构造 Scene3D 或清除网格旋转。
self.view.camera.x = 0
self.view.camera.y = 0
self.view.camera.z = 6
}
// 把同一相机移到侧上方,右侧正交相机保持原设置。
function side(self: SceneActions): Unit {
// side.onClick 调用;只设置位置三个分量,保留既有 target 和投影参数。
self.view.camera.x = 6
self.view.camera.y = 2
self.view.camera.z = 0
}
// 修改共享 cube 的欧拉旋转,因此两个视口都看到物体变化。
// rotate.onClick 调用,15 是本次绕 Y 轴增加的角度,重复点击累加,不把旋转每次复位到 15。
function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) }
// 示范 Event.hit.mesh 的真实托管网格材质写入;此文件尚未把 select 注册到视口。
// 这是供后续命中事件注册的入口,须由提供 hit.mesh 的网格命中事件调用;本文件的三个按钮均不调用它。
function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" }
}
// 先构造并变换两个 Box3D,再由 Group3D 组成场景节点;几何与相机是不同对象。
@@ -29,6 +34,7 @@ small = Box3D(0.7, 0.7, 0.7, "#DBA36E")
small.x = 1.8
small.z = -0.8
group = Group3D(cube, small)
// group 保留两个网格的变换层级,两视口复用下面同一个 scene,不分别创建立方体副本。
// 薄盒作地面,方向光与 ambient 一起影响材质颜色,不是二维背景矩形。
floor = Box3D(6, 0.08, 4, "#D8DEE6")
floor.y = -1.1
@@ -40,6 +46,7 @@ perspective = Viewport3D(480, 360, scene)
perspective.camera.z = 6
orthographic = Viewport3D(320, 360, scene)
orthographic.camera = OrthographicCamera()
// height 表示正交相机可见世界高度,不是右视口 360 的逻辑像素高度。
orthographic.camera.height = 5
orthographic.camera.x = 4
orthographic.camera.y = 2
+8 -1
View File
@@ -2,24 +2,30 @@
// 同一 paragraph 出现两次,文字内容共享,换行/偏移/范围在各 Mount 中独立。
// 通过具体出现位置的 Mount 操作偏移;ScrollBlock.axis 则是对象级策略,可影响其所有出现位置。
class Actions {
// 两个不同 ScrollBlock 对象引用同一 paragraph;对象级 axis 策略各自保存,Mount 偏移也各自保存。
vertical: ScrollBlock
horizontal: ScrollBlock
// 只对 vertical 第一个 Mount 累加纵偏移,运行时按当前范围夹取。
// down.onClick 调用零参方法;点击发生在挂载之后,本例 vertical 仅有一个有效 Mount,故选择 at(0)。
function down(self: Actions): Unit { self.vertical.mounts().at(0).scrollBy(0, 60) }
// 只对 horizontal 第一个 Mount 累加横偏移,禁止轴仍保持零。
// right.onClick 选择另一个滚动对象,60 是相对增量,不是强制把偏移设为 60。
function right(self: Actions): Unit { self.horizontal.mounts().at(0).scrollBy(60, 0) }
// 分别把两个出现位置移回原点,不修改共享段落文字。
function reset(self: Actions): Unit {
// reset.onClick 调用;这里用 scrollTo 指定绝对原点,保留容器尺寸和当前 axis 策略。
self.vertical.mounts().at(0).scrollTo(0, 0)
self.horizontal.mounts().at(0).scrollTo(0, 0)
}
// 把左对象策略改为双轴,下一次求解重新计算约束和范围。
// both.onClick 改左侧对象策略而不是某次出现的偏移,也不把右侧 horizontal 改成双轴。
function both(self: Actions): Unit { self.vertical.axis = "both" }
}
paragraph = TextBlock("这里展示的是同一个 TextBlock。左侧只允许纵向滚动,文字会按照这个 Mount 的实际视口宽度换行;右侧只允许横向滚动,文字保留横向展开。对象的文字和业务状态只有一份,每次展示的约束、几何和滚动位置分别保存。修改滚动轴也会重新测量受影响内容,并将已经不再允许的偏移夹回零。布局传播持续到受影响部分稳定,不会按固定跳数丢弃后续更新。这里展示的是同一个 TextBlock,两个视图可以拥有不同的换行结果。")
// 纵向区域padding后内容宽为244,长文字按此有限宽换行。
// 构造视口宽 260,左右 padding 各 8 时内容宽为 244;若父分配另行缩窄,则应按该 Mount 实际视口重算。
vertical = ScrollBlock(260, 140, paragraph)
vertical.padding = 8
// 先给左对象设置 vertical,右对象随后独立设 horizontal;同一 paragraph 不保存任何一个父容器的轴策略。
vertical.axis = "vertical"
// 横向区域沿滚动轴允许内容自然展开,所以同一文字可以比左侧更宽、更矮。
horizontal = ScrollBlock(260, 140, paragraph)
@@ -35,6 +41,7 @@ reset = Button("回到起点")
reset.onClick(actions.reset)
both = Button("左侧恢复双轴")
both.onClick(actions.both)
// 注册只保存函数,不提前查询 mounts;render 建立两个滚动区域的出现后,点击才会执行上述偏移操作。
page = Page()
page.title = "滚动轴"
page.render(Column(24, 16, TextBlock("同一个对象,不同的滚动轴", 2), Row(0, 24, Column(0, 8, TextBlock("vertical"), vertical), Column(0, 8, TextBlock("horizontal"), horizontal)), Row(0, 12, down, right, reset, both)))
+7 -1
View File
@@ -4,13 +4,16 @@
class Painted: RectBlock {
// 使用当前 geometry 交给原生基类;外层滚动变换/clip 由展示流程统一组合。
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
// source_paint 为当前出现调用此覆盖;只返回基类新命令,不把滚动偏移写回共享矩形坐标。
return RectBlock.paint(self,mount,geometry)
}
}
// 三个按钮复用同一个 Action,任意可见按钮点击都递增同一 count。
class Actions {
// 三个按钮连接同一 Actions,count 是共享业务状态;每个 ScrollBlock 出现的偏移不保存在这里。
count: Object
// 签名接受 Event,业务逻辑只需修改计数,不直接处理滚动位置。
// 原生指针点击先经过滚动裁剪命中,再由事件分发调用此方法;event 不承担滚动状态存储。
function click(self: Actions, event: Event): Unit { self.count.increment() }
}
count = ui.state(0)
@@ -19,11 +22,13 @@ first = Button("顶部按钮")
middle = Button("中间按钮")
last = Button("底部按钮")
firstConnection = first.on("click",actions.click)
// 三个连接都在创建树时注册一次,第二窗口复用对象不会额外注册或导致一次点击重复计数。
middleConnection = middle.on("click",actions.click)
lastConnection = last.on("click",actions.click)
// 高内容夹有三个按钮与矩形,使固定320x180 scroller产生可观察的横纵滚动范围。
// 高内容夹有三个按钮与矩形,在获得构造所需的 320×180 分配时产生横纵滚动范围;最终仍受各窗父约束限制。
body = Column(12,12,first,Painted(520,180,8,"#DCE8FF"),middle,RectBlock(520,180,8,"#E3EFE5"),last)
scroller = ScrollBlock(320,180,body)
// body 自然内容大于固定视口,宿主为各 Mount 计算范围;同一 scroller 不需要克隆 body 来独立滚动。
root = Column(24,16,TextBlock("同一 Block,两处 Mount 独立滚动"),ui.textOf(count,"value"),scroller)
page = Page()
page.title = "滚动示例"
@@ -34,5 +39,6 @@ other.setTitle("Scroll Secondary")
second = Page()
second.title = "独立滚动位置"
second.open(other)
// 先指定副窗口归属再 render,使这份 root 的新出现进入 other,而不是新建另一份业务根。
second.render(root)
ui.window().setTitle("Scroll Primary")
+6
View File
@@ -2,13 +2,16 @@
// 圆外部分可落到父容器监听器;按下与释放须经相同合法目标复核。
// 扩展矩形的绘制、命中和事件方法,用同一个 hits 状态观察交互结果。
class CircleTarget: RectBlock {
// hits 由下面顶层 state 提供,源命中只读它所需的几何,计数写入留给 onEvent。
hits: Object
// point 已转换到该出现位置局部坐标;使用圆心(40,40)、半径40,而不是整个 160x80 外框。
function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool {
// source_hit 在候选出现的命中阶段调用,point 已转换为局部坐标,不应再减 geometry.x/y。
return point.inCircle(40, 40, 40)
}
// 把原生命令宽度缩为 80、圆角半径设为 40,得到与 hitTest 对应的圆形区域。
function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList {
// source_paint 调用此覆盖;修改新生成的第一条矩形命令,不把共享对象的原生 width 从 160 改成 80。
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).width = 80
drawing.commands.at(0).radius = 40
@@ -16,6 +19,8 @@ class CircleTarget: RectBlock {
}
// 圆内点击递增自身计数并停止向祖先传播;父容器不会为同一次点击再递增。
function onEvent(self: CircleTarget, event: Event): Unit {
// 此示例的点击分发选中圆后调用对象方法;stopPropagation 只阻止后续祖先组,不撤销已经执行的捕获处理。
// 此覆盖没有按 event.kind 过滤;若从源码派发其他事件,同样会递增 hits 并停止其后续祖先传播。
self.hits.increment()
event.stopPropagation()
}
@@ -24,6 +29,7 @@ hits = ui.state(0)
// 父监听器单独计数;它和圆形目标显示在同页,便于比较穿过透明区的输入。
background = ui.state(0)
circle = CircleTarget(160, 80, 0, "#237A45", hits)
// 布局仍给 circle 原生的 160×80 盒,只有绘制和形状命中缩成左侧圆;透明右半部并非不存在的布局区域。
root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value"))
// 订阅在父 Column 上,圆外有效区域可回退到父目标;源命中只读检查不执行这些业务回调。
connection = root.on("click", background.increment)
+11
View File
@@ -2,9 +2,11 @@
// 运行时在只读求解中跟踪这些读取,事件修改模型后重新布局;布局方法不能直接改业务字段。
// 保存宽度与左偏移;两字段只在事件回调中修改。
class Metrics {
// width 提供 measure 输出,left 提供 arrange 的首子槽 x;模型状态不是 Mount 的派生几何副本。
width: Number
left: Number
// 同时改变测量和排列依赖,整帧需收敛后一起发布新尺寸/位置。
// button.on 的绑定事件回调写入两个字段,方法返回后的刷新把受影响布局合并到同一帧。
function expand(self: Metrics, event: Event): Unit {
self.width = 260
self.left = 68
@@ -15,7 +17,10 @@ class MeasuredRect: RectBlock {
// 首次测量前结构 Mount 链接已建立,因此可从 parent.block 读取当前出现位置的父策略。
// 此阶段还不能查询最终 bounds,返回值使用 MeasureResult 名义类型。
function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult {
// 从当前出现而非共享 rect 查父 Mount,同一 MeasuredRect 换父容器时可读取不同的 metrics。
// 读取真实 Mount 上下文时,当前测量缓存会保守判定易变;不能仅因最终宽度只取模型字段就认为结果可跨帧复用。
parent = mount.parent()
// 父 Block 需要符合示例的 metrics 约定;MeasureResult 宽高随后仍由宿主按当前 Constraints 检查和钳制。
return MeasureResult(parent.block.metrics.width, 60)
}
}
@@ -24,16 +29,22 @@ class OffsetColumn: Column {
metrics: Metrics
// Arrangement 里的 x 是当前排列使用的坐标;读取 metrics.left 成为排列依赖。
function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement {
// 保留原生 Column 对全部孩子的槽分配,临时结果可修改,不在 arrange 中写 self.metrics。
positions = Column.arrange(self, mount, geometry)
// 这里直接覆盖排列结果坐标为 metrics.left,不是给 geometry.x 再加相对位移;其他槽保持原生结果。
positions.children.at(0).x = self.metrics.left
return positions
}
}
// 将原生参数与源字段传给具体子类,button 事件更新模型,Page.render 建立完整展示关系。
metrics = Metrics(200, 44)
// 原生矩形构造参数仍提供绘制载荷,实际测量被源覆盖为模型宽度和固定高 60。
rect = MeasuredRect(80, 20, 0, "#526AC7")
button = Button("更新布局")
// 首个孩子是 rect,metrics 是 Column 子类新增字段,不是额外的视觉子项。
root = OffsetColumn(24, 16, rect, TextBlock("尺寸来自源码 measure,位置来自源码 arrange"), button, metrics)
// 保存监听连接使按钮操作同一个模型;布局方法由宿主协议调用,不由事件回调直接手工调用。
connection = button.on("click", metrics.expand)
page = Page()
// 先建立出现树和临时 Mount 上下文,再运行 measure/arrange,源方法才能在求解阶段查看父关系。
page.render(root)
+7
View File
@@ -2,9 +2,11 @@
// 同一 rect 出现两次,两份命令随同一 tint 更新;业务身份共享,几何按 Mount 分开。
// 原生矩形测量不变,仅将绘制颜色切换为源字段 tint。
class TintRect: RectBlock {
// 源颜色策略与原生构造颜色分别保存;两个出现都读取同一 tint,但用各自 geometry 生成命令。
tint: String
// 从原生基类取得当前出现位置的矩形命令,再修改本次返回列表中的颜色并交回。
function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList {
// source_paint 在只读计算阶段调用,基类返回本轮新列表,修改其中命令不等于修改已有业务状态。
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint
return drawing
@@ -15,6 +17,7 @@ class Badge: TextBlock {
background: String
// 先完整填写 roundedRect 命令,再取原生文字命令改成白色;列表顺序决定先画背景后画文字。
function paint(self: Badge, mount: Mount, geometry: Bounds): DisplayList {
// Badge 的源覆盖构造一条新背景命令,再组合原生文字命令;并未创建额外的背景 Block 或 Mount。
fill = PaintCommand()
// 命令 kind 决定宿主解码所需字段,坐标/宽高来自本次 Mount,radius/color 由业务策略提供。
fill.kind = "roundedRect"
@@ -27,6 +30,7 @@ class Badge: TextBlock {
// 复用原生文字塑形和换行所需参数,避免手工文字命令漏掉边界。
text = TextBlock.paint(self, mount, geometry).commands.at(0)
text.color = "#FFFFFF"
// ObjectList 的先后决定遮盖次序,返回时宿主统一验证命令字段和范围。
return DisplayList(ObjectList(fill, text))
}
}
@@ -35,11 +39,14 @@ class RecolorButton: Button {
target: TintRect
// 事件阶段可修改已有业务字段;依赖跟踪检测 tint 变化后重绘两个出现位置。
function onEvent(self: RecolorButton, event: Event): Unit {
// 分发器调用 Button 子类的对象方法,无需另写 button.on;这里可写 target,paint 中只能读它。
// onEvent 本身不限定 click:本覆盖没有检查 event.kind,其他经对象方法递送的事件也会执行改色。
self.target.tint = "#526AC7"
}
}
// 两次使用 rect 是同一对象,Badge 与按钮独立;根容器只组合它们,不复制业务数据。
rect = TintRect(200, 60, 12, "#DDEEFF", "#ED6A5A")
// Badge 的末尾字符串是新增 background 字段;TextBlock 的原生可选颜色省略,随后 paint 将文字改为白色。
badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45")
button = RecolorButton("通过对象方法改变绘制颜色", rect)
root = Column(24, 16, TextBlock("源码 paint 进入真实窗口", 2), rect, rect, badge, button)
+16 -1
View File
@@ -3,9 +3,11 @@
// 整个文件作为一次完整源码输入提交;状态只存在本次 VM,会话重置后不保留。
// 为 Column 增加期望宽度字段,使手工表格各列对齐,而非让文字自然宽度决定列边界。
class GradeCell: Column {
// 自有源字段控制期望列宽;每个调用包装不同内容对象,父约束可进一步限制实际分配。
width: Number
// 先保留 Column 的自然高度,再把新测量结果宽度设为字段值,最终仍由父约束协商。
function measure(self: GradeCell, mount: Mount, constraints: Constraints): MeasureResult {
// source_layout 调用覆盖,Column.measure 返回新临时结果;改 size.width 不修改 student 或其绑定文字。
size = Column.measure(self, mount, constraints)
size.width = self.width
return size
@@ -15,6 +17,7 @@ class GradeCell: Column {
class GradePanel: Column {
// 按本次 geometry 生成白色圆角底板;不重绘或复制子项命令,后代由统一遍历处理。
function paint(self: GradePanel, mount: Mount, geometry: Bounds): DisplayList {
// source_paint 的只读入口创建新命令,不在绘制阶段修改成绩/表格;绘制顺序由父背景后接孩子命令保证。
fill = PaintCommand()
fill.kind = "roundedRect"
fill.x = geometry.x
@@ -28,6 +31,7 @@ class GradePanel: Column {
}
// 姓名/学号和各科成绩/状态是业务字段;数值修改受 Number 声明检查。
class Student {
// 同一 Student 可由多个 gradeRow 引用,响应式成绩/状态绑定始终读取原对象,没有每行一份成绩副本。
name: String
number: String
chinese: Number
@@ -37,10 +41,13 @@ class Student {
}
// 用统一无 padding/gap 的单格容器包装任意 Block,末尾 width 是源字段构造参数。
function gradeCell(content: Block, width: Number): Block {
// gradeRow 和 gradeHeader 调用,传入孩子之后的 width 是 GradeCell 源字段,不能混进 Column 视觉子项列表。
return GradeCell(0, 0, content, width)
}
// 姓名/学号直接取初值,成绩和状态用响应式 textOf;行末分隔线与固定列宽保持表格结构。
// 姓名/学号直接取初值,成绩和状态用响应式 textOf;行末分隔线与期望列宽组织表格。
// 修改 student.name/number 不会自动更新已构造的 TextBlock;需要像成绩一样显式绑定或重新构造行。
function gradeRow(student: Student): Block {
// 顶层 allGradeRows 调用六次,attentionGradeRows 另为 studentLin 调用一次;视觉树不同,学生业务身份可相同。
return Column(0, 12,
Row(0, 0,
gradeCell(TextBlock(student.name), 150),
@@ -53,6 +60,7 @@ function gradeRow(student: Student): Block {
}
// 保存全部/复核两棵现成子树与可替换 table,同时持有要复核的真实 Student。
class GradeCenter {
// all/attention 两棵子树都由模型强引用保留,切掉某个展示根不会丢失它;student 是要复核的 studentLin。
table: Column
all: Column
attention: Column
@@ -61,16 +69,20 @@ class GradeCenter {
message: String
// 替换稳定槽0的内容为全部行,复用已有学生,不重新请求/重建数据。
function showAll(self: GradeCenter, event: Event): Unit {
// showAllGrades 的绑定事件入口,替换容器当前槽 0 内容,不把 all 的孩子逐个拷贝到 table。
self.table.replace(0, self.all)
self.message = "正在查看全部 6 位学生"
}
// 切换到预先构造的复核行;这是特定示例子树,不是动态过滤算法。
function showAttention(self: GradeCenter, event: Event): Unit {
// showAttentionGrades 调用,选中既有 attention 子树;pending 变成零也不会自动删除这棵预建视图。
self.table.replace(0, self.attention)
self.message = "林沐的数学成绩有一条复核记录"
}
// 写预设数学成绩68及状态,清待复核数;相关绑定在两个视图都读同一 studentLin。
// 不检查 pending 或当前表格模式,重复 review 仍写相同预设值;这不是逐条审核队列。
function review(self: GradeCenter, event: Event): Unit {
// reviewGrade 事件入口写入同一学生,隐藏的另一行也通过绑定观察新值;没有遍历所有行手工改文字。
self.student.math = 68
self.student.status = "已复核"
self.pending = 0
@@ -78,6 +90,7 @@ class GradeCenter {
}
// 恢复初始学生值/计数并显示全部列表,便于重复观察同一操作。
function reset(self: GradeCenter, event: Event): Unit {
// resetGrades 派发时恢复字段并切回 all;保留 Student、GradeCenter 和按钮身份,便于继续点击演示。
self.student.math = 58
self.student.status = "待复核"
self.pending = 1
@@ -94,6 +107,7 @@ studentSu = Student("苏映雪", "2026005", 88, 92, 90, "已确认")
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 始终只包含一个内容槽,后续 replace 在这个边界换整棵视图;表头独立放在外层 GradePanel。
gradeTable = Column(0, 0, allGradeRows)
// table 最初含一个稳定子槽,后续 replace(0,...) 正是切换这个槽。
gradeCenter = GradeCenter(gradeTable, allGradeRows, attentionGradeRows, studentLin, 1, "正在查看全部 6 位学生")
@@ -106,6 +120,7 @@ showAllGrades.on("click", gradeCenter.showAll)
showAttentionGrades.on("click", gradeCenter.showAttention)
reviewGrade.on("click", gradeCenter.review)
resetGrades.on("click", gradeCenter.reset)
// 四次注册不立即调用方法,所以初始化仍显示全部学生、待复核数 1 和林沐数学 58。
// 表头复用与数据行完全相同的列宽,摘要仅 pending 响应式,6/3是固定示例统计。
gradeHeader = Row(0, 0,
gradeCell(TextBlock("学生", 1, "#7A8390"), 150),
+7
View File
@@ -2,14 +2,18 @@
// 当前侧栏提供选择/关闭,文案中的拖放场景也可经版本化事件 API 测试;不要把它理解成已实现的侧栏拖动手势。
// 继承 Page 后观察 moveToWindow/moveTab 的真实方法调用,保留原生迁移/重排行为。
class TrackedPage: Page {
// 每个 TrackedPage 引用同一 calls 状态,统计的是这两个覆盖方法的调用,不是渲染或所有页面操作次数。
calls: Object
// 先递增 calls,再显式调用基类执行跨窗迁移;Page 身份保留,旧 Mount 上下文会替换。
function moveToWindow(self: TrackedPage, window: Window): Page {
// 外部代码/API 选择此源覆盖时进入;显式 Page.moveToWindow 绕过再次动态分派,避免递归调用自身。
// 计数先于原生迁移执行;如果随后基类报错,已发生的 increment 不自动回滚,不能把它当作成功迁移次数。
self.calls.increment()
return Page.moveToWindow(self, window)
}
// position 是所属窗口内最终零基位置,重排不等于创建新的业务 Page。
function moveTab(self: TrackedPage, position: Number): Page {
// 显式调用原生基类并返回它的 Page 结果,计数副作用不会把位置参数改成相对偏移。
self.calls.increment()
return Page.moveTab(self, position)
}
@@ -19,11 +23,13 @@ class Actions {
list: Column
// 在索引 1 前插入新 TextBlock,返回的稳定槽可供 remove 等后续操作使用。
function insert(self: Actions, event: Event): Unit {
// 由 button 的 click 连接派发;索引 1 是本次调用时的位置,每次点击新建文字并插入到当前第二项之前。
self.list.insert(1, TextBlock("插入的新对象"))
}
}
calls = ui.state(0)
list = Column(0, 8, TextBlock("第一个子项"), TextBlock("最后一个子项"))
// list 的可变孩子槽由同一 Column 持有,所有共享页面观察插入后的同一序列,不为各页单独执行回调。
actions = Actions(list)
button = Button("在位置 1 插入 Block")
connection = button.on("click", actions.insert)
@@ -46,6 +52,7 @@ other.moveTo(1700,80)
target = TrackedPage(calls)
target.title = "目标窗口"
target.render(shared)
// 这里调用继承的 open,不直接调用上面的 moveToWindow 覆盖;不能把初始建页过程当作这两个方法的演示调用。
target.open(other)
// 恢复源窗口首个页面,最后排出 resize/title/move/focus,便于手动或 API 验证。
first.activate()
+4
View File
@@ -5,19 +5,23 @@
first = Page()
first.title = "共享计数"
count = ui.state(0)
// connection 保存输入订阅,textOf 另外建立值到文字的绑定;二者共用 count,但承担不同调用职责。
increment = Button("加一")
connection = increment.on("click", count.increment)
first.render(Column(24,16,TextBlock("切换页面后,计数对象继续存在"),ui.textOf(count,"value"),increment))
// 另一页演示 Page 生命周期与快捷键;界面提示是示例文案,实际导航位于当前宿主侧栏。
second = Page()
// 第二页有自己的内容根,没有引用 count;切页不等于把第一页面里的对象移进第二页面。
second.title = "另一个页面"
second.render(Column(24,16,TextBlock("点击上方 Tab 切换;× 关闭页面"),TextBlock("Ctrl+Tab 切换,Cmd/Ctrl+W 关闭;代码可调用 first.open() 重开")))
// 独立内容页面仍用同一个运行时的原生类型和资源注册表。
third = Page()
// 三个全局 Page 引用在 close 后仍可保留页面业务身份,重新 open 会建立新的有效展示上下文。
third.title = "窗口与布局"
third.render(Column(24,16,TextBlock("每个 Tab 都是同一个 VM 中的 Page 对象"),RectBlock(240,80,8,"#237A45")))
// 重新激活第一页面,原生侧栏和源查询可观察相同 active 状态。
first.activate()
// 当前活动页切回 first 后请求该窗口标题,命令由成功输入后的 GUI 处理,不在这里同步操作原生窗口。
ui.window().setTitle("Endor Pages")
+7
View File
@@ -2,6 +2,8 @@
// onMount 作为业务阶段收集结果;实际布局/绘制计算仍受只读规则约束。
// 把协议探测结果保存到普通业务字段,textOf 将其中一部分显示出来。
class VisualResults {
// width/height 来自测量返回值,childX/childY 来自安排返回值,color 来自新绘制命令;都不是最终 Mount 的几何快照。
// 只在 onMount 探测时覆盖这些值,未建立对所有后续协议调用的自动镜像或响应式绑定。
width: Number
height: Number
childX: Number
@@ -13,10 +15,13 @@ class ProbeRect: RectBlock {
results: VisualResults
// 有限最大宽 120 约束自然宽 200;另用自定义 Bounds 查询绘制颜色,把返回结果写进共享 results。
function onMount(self: ProbeRect, mount: Mount): Unit {
// 宿主挂载通知进入此业务回调,允许写 results;这里主动调用继承的原生 measure 来查询指定约束下的结果。
measured = self.measure(mount, Constraints(0, 0, 120, 80))
self.results.width = measured.width
self.results.height = measured.height
// 自定义 Bounds 仅作为这次 paint 的输入,不会把当前页面里 rect 的实际布局强制设为该位置和大小。
drawing = self.paint(mount, Bounds(12, 18, 200, 60))
// 第一个命令来自 RectBlock 的原生矩形绘制,本例读取颜色后保存到业务模型供下方 textOf 展示。
self.results.color = drawing.commands.at(0).color
}
}
@@ -25,6 +30,7 @@ class ProbeColumn: Column {
results: VisualResults
// 给定绝对起点(20,30)及容器范围,原生 padding=24 使首子项位于(44,54)。
function onMount(self: ProbeColumn, mount: Mount): Unit {
// 当前父出现的子槽已存在,at(0) 对应下面 root 的标题 TextBlock;查询结果不替换宿主随后发布的最终安排。
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
@@ -32,6 +38,7 @@ class ProbeColumn: Column {
}
// 先创建结果容器,再将它作为源子类字段共享;root 的最后一个参数是该字段,不是视觉子项。
results = VisualResults(0, 0, 0, 0, "等待挂载")
// 父 ProbeColumn 与子 ProbeRect 共享同一结果容器,分别填写自己的探测字段,避免把结果误写到原生宽高载荷。
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)
page = Page()
+8 -1
View File
@@ -2,13 +2,16 @@
// 实际可用视口再返回布局约束,原生窗口尺寸不是业务对象上凭空写入的一份几何副本。
// 保存目标 Window,两个按钮对同一资源请求不同尺寸。
class WindowActions {
// 保存一个 Window 资源引用;事件发生在哪个按钮并不重新选择目标窗口,两个方法始终使用这个字段。
window: Window
// 请求 900x600 逻辑尺寸,最终大小仍由窗口管理器决定。
function compact(self: WindowActions, event: Event): Unit {
// compactConnection 派发此绑定方法,只发 resize 请求,不给 band 或 Mount 直接写宽高。
self.window.resize(900, 600)
}
// 请求 1120x720;同次输入多次 resize 可由命令队列合并到最后值。
function wide(self: WindowActions, event: Event): Unit {
// wideConnection 调用此事件入口;实际窗口变化回报后,新父约束才进入下面 WindowBand.measure。
self.window.resize(1120, 720)
}
}
@@ -16,19 +19,23 @@ class WindowActions {
class WindowBand: RectBlock {
// 取当前出现位置 maxWidth 作为期望宽度,固定高 32;不读取其他窗口的尺寸。
function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult {
// source_layout 在每个出现的测量阶段调用,maxWidth 是上层已扣除布局预算的约束,不是整窗原始宽度。
return MeasureResult(constraints.maxWidth, 32)
}
}
// ui.window 返回当前主窗口托管资源;page.open 指定同一资源,后面的 title/resize 为原生命令。
// 此文件顶层在默认主窗口上下文执行,ui.window() 因而取主窗口;事件/渲染回调中则跟随其当前窗口上下文。
// page.open 指定保存的同一资源,后面的 title/resize 为异步原生命令,不等于已改变原生实际尺寸。
window = ui.window()
actions = WindowActions(window)
band = WindowBand(80, 32, 0, "#237A45")
compact = Button("紧凑窗口 900 × 600")
wide = Button("宽窗口 1120 × 720")
compactConnection = compact.on("click", actions.compact)
// 两个回调均接收 Event,保存连接不等于立即请求尺寸;初次尺寸由文件最后的 resize 设置。
wideConnection = wide.on("click", actions.wide)
page = Page()
page.render(Column(24, 16, TextBlock("代码与 GUI 操作同一个受管窗口对象"), band, compact, wide))
page.open(window)
// 明确归属后向同一 window 排队标题和尺寸命令;Page 的业务内容与原生窗口资源分别保留身份。
window.setTitle("Endor Window")
window.resize(1120, 720)
+5
View File
@@ -3,15 +3,18 @@
// 跨窗共享的是业务对象,不是某个窗口中的具体 Mount。
// 唯一计数来源;两个按钮直接使用同一零参数原生 increment 方法。
count = ui.state(0)
// 左右按钮是两个视觉对象,连接的却是绑定同一 count 的原生零参 increment 方法。
leftButton = Button("共享计数 +1")
leftConnection = leftButton.on("click",count.increment)
// 先在主窗口建立页面,render 默认选择该页并挂载它的内容。
left = Page()
left.title = "主窗口页面"
// 此次 textOf 建立左侧文字绑定,右页还会建立另一个;计数业务状态仍只有上面一份。
left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton))
// Window() 分配同 VM 中的独立窗口资源,不创建第二个运行时。
other = Window()
// 新窗口资源不改变 rightButton 回调的业务接收者;从哪个窗口点击都只增加同一个 count.value。
rightButton = Button("另一个窗口 +1")
rightConnection = rightButton.on("click",count.increment)
right = Page()
@@ -19,6 +22,8 @@ right.title = "独立窗口页面"
right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton))
// 把页面放到 other,上下文更换将重建出现位置,业务 count 仍不变。
right.open(other)
// open 把 right 归入副窗口;它不把后续顶层执行上下文永久改为该窗口,下面 ui.window() 仍取默认主窗口。
// 若在副窗口事件回调里调用 ui.window(),则取该事件窗口;other 变量始终显式指向此处构造的副窗口资源。
// 窗口命令针对各自资源编号排队;成功输入后再交 GUI,另一窗口设置自己的尺寸。
ui.window().setTitle("Endor Main")
other.setTitle("Endor Shared")
+6
View File
@@ -4,6 +4,7 @@
// 把标签、大数值、说明和色条纵向组合,参数控制文字与强调颜色。
function metric(label: Text, value: Text, note: Text, color: Text): View {
// 由顶层 statistics 连续调用三次,每次返回新的容器和文字;value 是展示字符串,不在这里解析或累计任务数。
return ui.column(0, 6,
ui.text(label, 1, "#637082"),
ui.text(value, 2, "#243047"),
@@ -14,6 +15,7 @@ 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 {
// 顶层 projects 为三组演示数据调用,state 只是状态文案,不是 ui.state 托管状态对象。
// 项目名和状态横排,状态颜色与进度前景相同。
let caption = ui.row(0, 12,
ui.text(name, 1, "#243047"),
@@ -24,6 +26,7 @@ function project(name: Text, state: Text, detail: Text, filled: Number, remainin
ui.rect(filled, 6, 3, color),
ui.rect(remaining, 6, 3, "#E5EAF2")
)
// caption/progress 是本次函数调用的局部视觉对象,返回根通过子项关系保留它们,不需要放到全局逐个挂载。
return ui.column(0, 6,
caption,
ui.text(detail, 1, "#637082"),
@@ -33,7 +36,9 @@ function project(name: Text, state: Text, detail: Text, filled: Number, remainin
// 组合小圆点、时间戳、标题与说明;spacer 仅作视觉对齐,不代表真实事件时间线。
function activity(time: Text, title: Text, detail: Text, color: Text): View {
// 由 timeline 的三次普通调用构造记录,time 是固定字符串;没有时钟读取、排序或后续自动推进。
let dot = ui.column(0, 0, ui.spacer(7), ui.rect(6, 6, 3, color))
// stamp 把小圆点列与时间文字横排;上方 spacer 给圆点增加局部纵向留白,不是一个全局坐标。
let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0"))
return ui.column(0, 4,
stamp,
@@ -72,6 +77,7 @@ let timeline = ui.column(0, 14,
)
let body = ui.row(0, 20, projects, timeline)
// body 只建立两个已构造子树的组合关系,页面尚未 render,不会在构造这一行单独发布一帧。
// 底部提示以细色块和文字组合,明确说明页面还未接业务交互。
let note = ui.row(0, 12,
+8
View File
@@ -3,11 +3,14 @@
// 面板内容仍是完整 Endor UI,可共享对象、绑定、源视觉协议与嵌套视口。
// 分别持有外层三维视口、面板变换和面板内二维画布,避免一次动作同时驱动多层相机。
class WorldActions {
// 三个引用分别指向观察相机所在视口、三维面板实体和内层二维视口,不把它们的坐标系混用。
view: Viewport3D
panel: Panel3D
canvas: CanvasViewport
// 重置三维相机位置与观察目标,不改变面板或内层画布业务状态。
// 不重建 Camera,也不复位其 fov/near/far/up 或 controller;reset 仅写下面显式列出的六个属性。
function reset(self: WorldActions): Unit {
// reset.onClick 派发零参绑定方法,按绝对值恢复外层相机位置/target;不会调用 canvas.fitView。
self.view.camera.x = 0
self.view.camera.y = 0.5
self.view.camera.z = 7
@@ -16,13 +19,16 @@ class WorldActions {
self.view.camera.targetZ = 0
}
// 只旋转 Panel3D 平面,面板内 UI 树仍用自己的逻辑宽高。
// turn.onClick 调用,每次给面板世界变换增加 15 度 Y 旋转,不修改 UI 分辨率或子树字段。
function turn(self: WorldActions): Unit { self.panel.rotate(0, 15, 0) }
// 找到画布当前 Mount,按该出现位置的有限尺寸 fitView,而非用外层三维视口大小。
// fit.onClick 在挂载后调用;本例 canvas 只在一个面板出现,at(0) 选择其有效 Mount,再按该出现计算相机适配。
function fit(self: WorldActions): Unit { self.canvas.mounts().at(0).fitView() }
}
count = ui.state(0)
increment = Button("点击:世界里的真实按钮")
increment.onClick(count.increment)
// 这个按钮在 Panel3D 内,后面的 reset/turn/fit 在外层页面;它们共享 VM,但输入需要不同坐标转换路径。
// 二维场景卡片使用带符号世界坐标;canvas 再提供有限可见区域和独立 camera。
card = Column(10, 6, TextBlock("CanvasViewport", 1), RectBlock(180, 26, 6, "#9CB8A8"))
card.boxWidth = 240
@@ -36,6 +42,7 @@ ui_root.crossAlign = "stretch"
panel = Panel3D(4, 3, ui_root)
panel.uiWidth = 320
panel.uiHeight = 240
// panel 的 x/rotationY 改三维平面变换;UI 仍在 320×240 的逻辑区域里布局,不把世界单位当作字形像素。
panel.x = -1.2
panel.rotationY = 15
cube = Box3D(1.4, 1.4, 1.4, "#7B9CB7")
@@ -51,6 +58,7 @@ view = Viewport3D(960, 520, scene)
view.flex = 1
view.camera.y = 0.5
view.camera.z = 7
// 控制器保存已创建的三层对象,注册回调不会提前执行重置、转动或 Mount 查询。
actions = WorldActions(view, panel, canvas)
reset = Button("重置摄像机")
reset.onClick(actions.reset)
+11
View File
@@ -3,16 +3,19 @@
// 没有独立宽度副本;约束与换行结果属于出现位置,文本内容属于共享对象。
class Paragraph: TextBlock {
// 将当前 Mount 与 Constraints 交给原生文字测量,宽度变化触发重测高度。
// 宿主测量驱动调用源覆盖,源方法再显式调用原生基类,使用当前出现的可用宽度与字体系统计算高度。
function measure(self: Paragraph, mount: Mount, constraints: Constraints): MeasureResult {
return TextBlock.measure(self, mount, constraints)
}
// 使用本次 geometry 画文字,和原生测量采用相同换行区域。
// 源绘制驱动在布局完成后传本轮 Bounds,原生文字命令携带对应换行边界,不重新猜另一套宽度。
function paint(self: Paragraph, mount: Mount, geometry: Bounds): DisplayList {
return TextBlock.paint(self, mount, geometry)
}
}
// 保存主窗口、共享文字和原全文,四个按钮分别改窗口尺寸或改文字内容。
class Actions {
// 只保存主窗口资源;narrow/wide 不改下面 other 的尺寸,两个窗口共享的是 paragraph 而非 viewport。
window: Window
paragraph: TextBlock
full: String
@@ -21,33 +24,41 @@ class Actions {
// 请求主窗口变宽,文字高度随之减小,不改另一窗口尺寸。
function wide(self: Actions, event: Event): Unit { self.window.resize(1200, 650) }
// 用短文本替换同一对象内容,两个窗口的文字都重新测量。
// 短文本提示只是示例字符串;真实 paragraph 仍同时出现在两个窗口,修改内容不会把两处 Mount 合成一个。
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 👩‍💻 使用同一套字体整形。再次修改文字会重新计算高度,并更新两个窗口,不需要复制业务对象。"
// 只创建一次共享业务对象,full 字符串另由 Actions 保存供 restore 恢复,不重新 new 一个段落实例。
paragraph = Paragraph(full)
// 后继文字没有硬编码 y,由 Column 安排读取前一个段落本轮实际高度来定位。
tail = TextBlock("这一行的位置由前面段落的实际高度决定。")
actions = Actions(ui.window(), paragraph, full)
narrow = Button("变窄")
wide = Button("变宽")
short = Button("短文本")
restore = Button("恢复段落")
// 四个连接各绑定同一 Actions 实例的不同事件方法;方法声明的 event 由点击派发提供。
narrowConnection = narrow.on("click", actions.narrow)
wideConnection = wide.on("click", actions.wide)
shortConnection = short.on("click", actions.short)
restoreConnection = restore.on("click", actions.restore)
// 主页面先展示四按钮、共享段落和 tail,页面与窗口身份和 paragraph 业务身份分别保存。
page = Page()
page.title = "约束下的文字换行"
page.render(Column(24, 16, Row(0, 12, narrow, wide, short, restore), paragraph, tail))
// 新窗口有独立可用宽度,但 second.render 仍用同一 paragraph,所以两个 Mount 可有不同高度。
// 创建同 VM 的另一个窗口资源,后面的 resize/title/move 是提交后由 GUI 执行的窗口命令。
other = Window()
other.resize(720, 500)
other.setTitle("同一个段落 · 另一组宽度")
other.moveTo(1700, 80)
// 第二页面使用不同的 Column 宽度上下文,再引用同一个 paragraph,产生独立换行测量和 Mount。
second = Page()
second.title = "共享 Paragraph"
second.render(Column(24, 16, paragraph))
// 将此页归到副窗口,不把主窗口 Page 或它的 Mount 直接复制过去。
second.open(other)
// 恢复主页面活动状态并设置原生窗口尺寸/标题/桌面位置,focus 命令在成功输入后交宿主执行。
page.activate()
+7 -1
View File
@@ -2,6 +2,7 @@
# macOS 应用打包入口:构建 release 可执行文件并组装最小 Tessera.app;脚本本身不运行测试或部署。
# 执行会产生构建产物,应在已确认的 macOS 构建环境中调用。
# 任一命令失败或使用未定义变量即退出,避免在失败编译后继续打包旧文件。
# 构建调用方显式执行此脚本才产生文件;阅读或 sh -n 仅检查语法,不会运行下方 Cargo。
set -eu
# 此入口只能在 macOS 使用;构建授权由调用方确认,脚本不自动执行测试或部署。
if [ "$(uname -s)" != Darwin ]; then
@@ -11,17 +12,22 @@ fi
# 以脚本所在目录的父目录作为仓库根;清空这一次 cd 的 CDPATH,防止用户 shell 配置改变目录解析。
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
# 锁定依赖并生成 release 可执行文件;附加参数原样交给 Cargo。
# 后续复制路径固定为 target/release/tessera,不会随 --target、--target-dir 或 CARGO_TARGET_DIR 自动调整。
# 这里确实触发编译,不是只验证清单;依赖锁不匹配时 --locked 要求失败而非自动更新 Cargo.lock。
cargo build --release --locked "$@"
# 从 Cargo 包元数据读取首个 version 值,空值由 test 拒绝,不生成缺版本的 bundle。
# 从 Cargo.toml 文本读取首个行首 version 值,空值由 test 拒绝;这不是 cargo metadata 的结构化查询。
# version 从清单文本读取,后面用于两个 plist 版本键;脚本不调用构建后的 Tessera 来查询版本。
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"
# 创建/复用固定 release 产物目录,不清空旧 bundle;后面逐文件替换主二进制和元数据。
mkdir -p "$bundle/Contents/MacOS"
# 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。
cp target/release/tessera "$bundle/Contents/MacOS/tessera.new"
mv -f "$bundle/Contents/MacOS/tessera.new" "$bundle/Contents/MacOS/tessera"
# 生成最小 macOS bundle 元数据;应用名、入口与高分辨率标志供系统启动/显示使用。
# heredoc 展开本次 version,XML 文本与入口文件名相互对应;这里没有签名、公证或安装动作。
cat > "$bundle/Contents/Info.plist.new" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+9 -1
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env node
// 文档 UML 批量渲染入口:读取仓库内 .puml,在 rendered 中写入 SVG/PNG。
// 固定版本的工具包安装到独立临时目录,由 UML_RENDER_TOOLS 指定;不会修改应用依赖。
// UML_RENDER_TOOLS 指向已安装依赖的工具目录;README 给出固定版本安装示例,脚本自身不安装或校验版本。
import { createRequire } from 'node:module';
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
// 文档维护者从命令行执行本脚本;运行时入口不导入它,依赖应事先安装,本脚本不执行包管理器。
const tools = process.env.UML_RENDER_TOOLS;
// 工具依赖放在独立目录,避免给应用运行依赖增加文档渲染包。
if (!tools) throw new Error('Set UML_RENDER_TOOLS to the documentation tool directory; see docs/architecture/uml/README.md');
@@ -17,6 +18,7 @@ const sharp = require('sharp');
// 官方 PlantUML JS 引擎依赖 DOM、Graphviz 和 Canvas 文本度量。
// 在导入引擎前补齐全局环境,让 Node 进程可执行原本面向浏览器的布局过程。
// 这些赋值修改当前 Node 进程 globalThis,脚本结束前不恢复;按独立命令行工具使用,不是无全局副作用的库。
globalThis.PLANTUML_VIZ = require('@viz-js/viz');
globalThis.window = globalThis;
globalThis.XMLSerializer = XMLSerializer;
@@ -67,6 +69,7 @@ document.createElementNS = (namespace, tag) => {
document.body = element('body');
document.documentElement.appendChild(document.body);
globalThis.document = document;
// 全局 DOM/Canvas 适配已设置完毕才加载引擎,避免模块初始化读取到缺失的浏览器接口。
const { renderToString } = await import(pathToFileURL(require.resolve('@plantuml/core')));
// 仓库位置由脚本 URL 确定,与调用者工作目录无关;输出目录不存在时创建。
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -80,6 +83,8 @@ for (const filename of (await readdir(source)).filter(name => name.endsWith('.pu
// 将引擎 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');
// 引擎有时以正常 SVG 输出错误页,所以不能只判断 Promise resolve;还要检查文字层的错误标签。
// 检查根标签和几类错误文本,不是完整 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}`);
@@ -88,9 +93,12 @@ for (const filename of (await readdir(source)).filter(name => name.endsWith('.pu
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);
// 序列化修改后的 DOM,而不是直接写引擎原字符串,确保白底矩形进入两个导出格式。
const svg = new XMLSerializer().serializeToString(xml);
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
const stem = filename.slice(0, -5);
// 两种产物顺序写入且不组成事务;SVG 成功后若 PNG 失败,前面已写出的文件不会自动回滚。
// 同名文件直接覆盖;本轮没有对应 .puml 的旧产物不会被自动删除,较早已完成的图也不会因后图失败撤回。
await writeFile(path.join(output, `${stem}.svg`), svg);
// 以 192 DPI 光栅化为 PNG 供通用预览;SVG 同时保留矢量文字和缩放能力。
const png = sharp(Buffer.from(svg), { density: 192 });
+190 -2
View File
@@ -1,5 +1,10 @@
//! General retained graphics resources for source paint: images, fonts, prepared
//! text, geometric exclusions and numerical functions. No application behaviour.
//! 通用保留式图形资源桥接:图片、字体、预排文字、绕排避让形状和数值工具。
//! ui_bridge::bootstrap 调用 install,把原生 callable 挂到 ui.graphics 与 ui.graphics.math。
//! Endor 源程序调用这些函数创建模型资源/绘图命令;source_paint 解码图片命令时调用 decode_image。
//! PointerDispatch 由 UiBridge::dispatch 包装成临时 callable,用同一 VM 分发原始指针事件。
//! 这里不定义应用剧情或按钮业务。像素资源经 Arc 共享,托管资源身份与引用边仍由 Aster 管理。
//! graphics.readText 是初始化用的同步 UTF-8 小文件读取(16 KiB);ui.readText 的后台任务接口另由 io_tasks 实现。
//! 图片命令经托管 image 属性拥有资源;source_paint 解码后只把 Arc 像素带入渲染命令,绘制线程无需访问 VM。
use crate::{
document::{RenderCommand, parse_color},
geometry::{Point, Rect},
@@ -14,26 +19,34 @@ use endor_core::{
ObjectStore, Value, Vm, VmError,
};
use std::{any::Any, sync::Arc};
/// 本文件的解码和分派分支统一构造 VM 参数错误,让调用栈继续提供源代码诊断位置。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 图片/字体/避让对象参数的解码入口,只复制对象句柄;payload 种类由各调用点进一步检查。
fn object(v: &Value) -> Result<ObjectRef, VmError> {
// 不从 nil、字符串或数字猜测资源 ID,避免把源值误指到 VM 堆中任意对象。
if let Value::Object(o) = v {
Ok(*o)
} else {
Err(invalid("expected Object"))
}
}
/// 图形几何参数调用,先用 Endor 数字转换再限制绝对值,最后降为渲染器使用的 f32。
fn number(v: &Value) -> Result<f32, VmError> {
// 原生签名没有逐参数类型声明,因此 invoke 内仍必须检查值的实际表示。
let Value::Number(v) = v else {
return Err(invalid("expected Number"));
};
// to_f64 传播无法有限转换等错误;通用坐标允许负数,宽高/半径的非负约束由具体操作检查。
let n = v.to_f64()?;
if n.abs() > 1_000_000. {
// 在转换 f32 和进入排版/光栅化前限制坐标预算,拒绝极端图形数值。
return Err(invalid("graphics number exceeds bounds"));
}
Ok(n as f32)
}
/// 文件路径、字体名和源文本参数调用,借用真实字符串而不对任意对象隐式执行 repr。
fn text(v: &Value) -> Result<&str, VmError> {
if let Value::Text(s) = v {
Ok(s)
@@ -41,37 +54,52 @@ fn text(v: &Value) -> Result<&str, VmError> {
Err(invalid("expected String"))
}
}
/// command/flow/指针事件把原生几何返回语言时调用,统一把 f32 提升为 Endor Number。
fn n(v: f32) -> Value {
Value::Number((v as f64).into())
}
#[derive(Debug)]
/// LoadImage/flow 产生的托管资源 payload;Arc 像素可供发布命令持有,不要求 GUI 线程读取 VM。
pub(crate) struct ImageResource(pub Arc<Image>);
#[derive(Debug)]
/// LoadFont 创建、Prepare 读取的字体族句柄,字体注册/塑形细节由 render::text 负责。
struct FontResource(TextFamily);
#[derive(Debug)]
/// Prepare 生成、Flow 消费的字词/字母预排结果,昂贵的塑形可在多次绕排中复用。
struct TextResource {
// 图形流转中仅复制 Arc,不为每一行重复塑形和生成字形图片。
prepared: Arc<Prepared>,
// 每次 Flow 的行带高度,ranges 用它判断避让形状与这一行的相交范围。
line: f32,
}
#[derive(Debug)]
/// Circle/Rectangle/ImageExclusion 产生,Flow/ranges 读取的纯原生避让形状,不保存 VM 子对象引用。
struct Exclusion {
// 圆用圆心/半径描述;多边形资源保持 None,避免两种形状同时隐式叠加。
circle: Option<(Point, f32)>,
// 图片轮廓或矩形顶点,已经变换到绕排使用的世界/页面逻辑坐标。
polygon: Vec<Point>,
// 在多边形行带与横向阻塞范围外再扩展的避让距离。
padding: f32,
}
// 四类资源复用 ObjectPayload 适配;没有托管引用字段,因此不需要额外 trace 子对象。
macro_rules! resource {
($t:ty,$name:literal) => {
impl ObjectPayload for $t {
// VM 的 payload::<T> 通过 Any 向下转型,防止把普通 Object 当图形资源读取。
fn as_any(&self) -> &dyn Any {
self
}
fn kind_name(&self) -> &'static str {
// 稳定种类名供类型错误/诊断显示,不代替 Endor 名义 Type。
$name
}
fn allows_attributes(&self) -> bool {
// 资源宽高等元信息作为普通托管属性暴露,仍经 store 的属性访问规则管理。
true
}
fn reference_assignment_only(&self) -> bool {
// 赋值共享同一资源对象,不能按值复制资源 payload 后失去既有身份。
true
}
}
@@ -81,21 +109,27 @@ resource!(ImageResource, "image-resource");
resource!(FontResource, "font-resource");
resource!(TextResource, "prepared-text");
resource!(Exclusion, "layout-exclusion");
/// Function/flow 分配原生资源后调用,附当前宿主域的 Object 名义类型,不创建新的资源专属类。
fn attach(cx: &mut NativeContext<'_>, o: ObjectRef, desktop: ObjectRef) -> Result<(), VmError> {
crate::ui_bridge::attach_desktop_type(cx, o, "Object", desktop)
}
/// LoadImage/LoadFont/ReadText 的同步文件读取助手;调用者先拒绝布局/绘制只读阶段的 I/O。
fn file(path: &str, limit: u64) -> Result<Vec<u8>, VmError> {
use std::io::Read;
if path.len() > 8192 {
// 路径本身也有限长,不能只限制最终文件大小而接受无限字符串操作。
return Err(invalid("resource path too long"));
}
// 文件句柄限定在本调用作用域,任何 ? 返回都会由 Rust 关闭,不放进托管图形资源。
let f = std::fs::File::open(path).map_err(|_| invalid("resource file cannot be opened"))?;
// 只接收有界普通文件,拒绝目录/特殊设备等无法靠 metadata 给出可靠预算的输入。
let meta = f
.metadata()
.map_err(|_| invalid("resource metadata unavailable"))?;
if !meta.is_file() || meta.len() > limit {
return Err(invalid("resource must be a bounded regular file"));
}
// metadata 检查后文件仍可能增长,所以实际读取最多 limit+1 字节并再次检查。
let mut data = Vec::new();
f.take(limit + 1)
.read_to_end(&mut data)
@@ -105,20 +139,26 @@ fn file(path: &str, limit: u64) -> Result<Vec<u8>, VmError> {
}
Ok(data)
}
/// decode_image 读取 PaintCommand 的数值属性时调用,缺字段/错误类型直接传播,不自行补缺省。
fn field(s: &ObjectStore, o: ObjectRef, key: &str) -> Result<f32, VmError> {
number(&s.get_attr(o, key)?)
}
/// source_paint 的 image 命令分支及 Sprite/ImageExclusion 调用,验证托管命令并提取纯数据图片绘制命令。
pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result<RenderCommand, VmError> {
// 命令引用真实 ImageResource,不能只靠同名 width/height 属性冒充有效图片。
let resource = object(&s.get_attr(c, "image")?)?;
// 克隆像素 Arc,使发布结果拥有资源,不再依赖命令/资源 ObjectRef 的 VM 生命周期。
let image = s
.payload::<ImageResource>(resource, "ImageResource")?
.0
.clone();
// tint 可缺失/nil;显式错误颜色和其他属性读取失败仍报错,不静默退回无着色。
let tint = match s.get_attr(c, "tint") {
Ok(Value::Text(v)) => Some(parse_color(&v).map_err(|_| invalid("invalid image tint"))?),
Ok(Value::Nil) | Err(VmError::AttributeNotFound(_)) => None,
_ => return Err(invalid("invalid image tint")),
};
// 同名局部 c 从对象身份变为原生命令;所有几何字段均经受控 number 解码。
let c = RenderCommand::Image {
x: field(s, c, "x")?,
y: field(s, c, "y")?,
@@ -131,10 +171,12 @@ pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result<RenderComman
tint,
image,
};
// 命令级约束继续检查宽高/透明度等组合语义,单个有限坐标检查不足以证明命令合法。
c.validate()
.map_err(|_| invalid("invalid image geometry"))?;
Ok(c)
}
/// Sprite 和 Flow 生成 image 类 PaintCommand 的共同助手,保留图片托管强边及几何字段。
fn command(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
@@ -144,6 +186,7 @@ fn command(
angle: f32,
opacity: f32,
) -> Result<ObjectRef, VmError> {
// origin 是旋转定位点,rect.x/y 是相对该原点的局部图像偏移,不与绝对 x/y 混用。
let c = crate::native_visual::record(
cx,
desktop,
@@ -164,6 +207,7 @@ fn command(
Ok(c)
}
#[derive(Debug, Clone, Copy)]
/// install 的名称表选择实际操作;arity/invoke 同用此枚举,不依赖函数显示名称猜业务分支。
enum Op {
LoadImage,
LoadFont,
@@ -189,34 +233,47 @@ enum Op {
Clamp,
}
#[derive(Debug)]
/// install 分配到 VM 的 callable payload,供源代码经 ui.graphics/math 调用。
struct Function {
// 具体操作决定数量与实现,图形和数值名字可以挂在不同命名空间中共享分派器。
op: Op,
// 资源/命令的名义类型来自该注册域;captures 同时持有对应托管强边。
desktop: ObjectRef,
}
impl NativeCallable for Function {
/// VM 在进入 invoke 前查询精确参数数目,下面按位置索引 args 依赖此调用边界检查。
fn arity(&self) -> Option<endor_core::CallArity> {
use Op::*;
Some(endor_core::CallArity::exact(match self.op {
// 时钟无参数;资源路径和一元数学操作各一参,类型/范围在具体分支校验。
Now => 0,
LoadImage | ReadText | Float | Sin | Cos | Sqrt | Abs | Stringify | Floor => 1,
// 二参分别是路径+字体族、两个操作数,或图片命令+避让边距。
LoadFont | Atan2 | Min | Max | ImageExclusion => 2,
// 圆/矩形几何、数值上下界和文本切片都有固定数量,不接任意变参。
Circle | Clamp | Slice => 3,
Rectangle => 4,
// Prepare 接文本/字体/字号/行高/颜色/比例;Flow/Sprite 再按各自几何接口解释位置参数。
Prepare => 6,
Flow => 7,
Sprite => 9,
}))
}
/// VM 执行注册 callable 时调用;同步资源加载拒绝只读阶段,纯资源派生/数学可供源 paint 使用。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
use Op::*;
// 复制所属类型域身份,后面分配资源和协议值都复用这一个注册表。
let desktop = self.desktop;
// 各分支产出语言值,除提前返回数学分支外统一包成 NativeResult::Return。
let result = match self.op {
Now => {
if cx.store().is_read_only() {
// 时钟变化不能成为只读绘制中的隐式输入,源程序应在事件/任务里采样并存进模型。
return Err(invalid(
"sample the clock in an event/task and store it in the model",
));
}
// 进程级首次调用确定单调起点,返回的是相对秒数,不是 UTC 时间戳或每 VM 独立时钟。
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
Value::Number(
START
@@ -228,11 +285,15 @@ impl NativeCallable for Function {
}
LoadImage => {
if cx.store().is_read_only() {
// 源布局/绘制可复用已加载图片,但不允许在只读阶段重新执行文件 I/O。
return Err(invalid("load resources outside paint/layout"));
}
// 先限制输入文件 4 MiB,再由 PNG 解码器校验格式和解码图像预算。
let image = Image::png(&file(text(&args[0])?, 4_194_304)?)
.map_err(|_| invalid("invalid or oversized PNG"))?;
// 宽高取自解码结果,而非源程序可任意伪造的资源属性。
let (w, h) = (image.width, image.height);
// 托管外壳提供稳定身份,像素仍由 Arc 共享给后续绘图命令。
let resource = cx.allocate(ImageResource(image))?;
attach(cx, resource, desktop)?;
cx.set_attr(resource, "width", n(w as f32))?;
@@ -240,20 +301,25 @@ impl NativeCallable for Function {
Value::Object(resource)
}
LoadFont => {
// 不仅文件读取有副作用,register_font 还会修改字体注册表,因此布局/绘制只读回调也不能加载字体。
if cx.store().is_read_only() {
return Err(invalid("load resources outside paint/layout"));
}
// 显式字体族名用于注册与后续塑形查找,不把文件名直接当字体族名。
let family = text(&args[1])?;
if family.len() > 128 {
return Err(invalid("font family name too long"));
}
// 文件读取预算与字体注册表预算分别检查;错误不返回一个看似有效但会回退的字体资源。
let font = crate::text::register_font(file(text(&args[0])?, 4_194_304)?, family)
.map_err(|_| invalid("invalid font family or registry budget"))?;
// FontResource 只保存验证后的族句柄,源代码不能直接改写全局字体数据库。
let resource = cx.allocate(FontResource(font))?;
attach(cx, resource, desktop)?;
Value::Object(resource)
}
ReadText => {
// 同步读取小型资源文本供模型初始化使用;后台异步读取仍走 runtime/io_tasks 的接口。
if cx.store().is_read_only() {
return Err(invalid("load resources outside paint/layout"));
}
@@ -263,22 +329,28 @@ impl NativeCallable for Function {
)
}
Slice => {
// 源文本切片按 Unicode 标量索引而非 UTF-8 字节下标;先保留原文借用再转换边界。
let value = text(&args[0])?;
// number 的通用预算之外,这两个索引还必须为非负、递增的整数值。
let start = number(&args[1])?;
// 结束位置为半开区间上界,不包含该索引上的字符。
let end = number(&args[2])?;
if start < 0. || end < start || start.fract() != 0. || end.fract() != 0. {
return Err(invalid("invalid text slice"));
}
// char_indices 把字符序号换回合法字节边界;超出文本时夹到末尾,不切开多字节字符。
let lo = value
.char_indices()
.nth(start as usize)
.map_or(value.len(), |(i, _)| i);
// 与 lo 同一转换规则保证切片边界总在原字符串范围内。
let hi = value
.char_indices()
.nth(end as usize)
.map_or(value.len(), |(i, _)| i);
Value::Text(value[lo..hi].into())
}
// 仅格式化标量,不对任意对象调用 repr,避免资源/图形计算触发未知对象逻辑。
Stringify => Value::Text(match &args[0] {
Value::Text(s) => s.clone(),
Value::Number(v) => v.to_string(),
@@ -287,16 +359,21 @@ impl NativeCallable for Function {
_ => return Err(invalid("string expects scalar value")),
}),
Prepare => {
// 读取源文本并按 UTF-8 字节预算限制一次塑形的内容量。
let value = text(&args[0])?;
if value.len() > 16_384 {
return Err(invalid("prepared text exceeds 16 KiB"));
}
// 必须是 loadFont 返回的原生 FontResource,普通对象的同名字体属性不能冒充资源。
let family = cx
.store()
.payload::<FontResource>(object(&args[1])?, "FontResource")?
.0;
// size 是逻辑字体尺寸,line 是逻辑行带高度,scale 只控制字形预绘制清晰度/资源量。
let size = number(&args[2])?;
// 行高不得小于字号,避免绕排占位与字形高度约定完全失配。
let line = number(&args[3])?;
// 设备比例有独立预算,不能通过极大 scale 把短文本变成巨量像素资源。
let scale = number(&args[5])?;
if !(8. ..=96.).contains(&size)
|| !(size..=192.).contains(&line)
@@ -304,8 +381,10 @@ impl NativeCallable for Function {
{
return Err(invalid("invalid text metrics"));
}
// 颜色预先烘入字形图片,后续 flow 不为每个词重复解析/绘制文字。
let color =
parse_color(text(&args[4])?).map_err(|_| invalid("invalid text color"))?;
// 交给 text_flow 完成词/字母拆分、塑形及像素墨迹提取,这里只描述样式与预算。
let prepared = Prepared::new(
value,
TextStyle {
@@ -316,7 +395,9 @@ impl NativeCallable for Function {
scale,
color,
);
// text_flow 的静态错误文本转成 VM 参数错误,源调用得到当前调用栈上下文。
let prepared = prepared.map_err(invalid)?;
// Arc 让多次 flow 复用同一预排结果,line 单独保存供每行障碍相交测试使用。
let resource = cx.allocate(TextResource {
prepared: Arc::new(prepared),
line,
@@ -325,17 +406,27 @@ impl NativeCallable for Function {
Value::Object(resource)
}
Sprite => {
// Sprite 返回一条变换图片命令,不立即画到屏幕;原图对象必须带真实 ImageResource payload。
let image = object(&args[0])?;
cx.store()
.payload::<ImageResource>(image, "ImageResource")?;
// x/y 是旋转定位原点,图像左上角还受 anchor 比例和尺寸影响。
let x = number(&args[1])?;
// 垂直定位与水平定位使用相同逻辑坐标空间。
let y = number(&args[2])?;
// 目标宽高允许缩放原图,但命令 validate 会拒绝非法几何范围。
let w = number(&args[3])?;
// 图片采样继续使用原像素尺寸,不把 h 当作纹理行数。
let h = number(&args[4])?;
// a 按渲染器约定为弧度,绕 origin 旋转局部矩形。
let a = number(&args[5])?;
// ax/ay 把图像内部相对锚点换成局部偏移,0 是边缘,0.5 是中心。
let ax = number(&args[6])?;
// 不在这里默认 clamp 锚点,源代码可以选择矩形之外的旋转中心。
let ay = number(&args[7])?;
// 透明度除通用有限值外还受 RenderCommand::validate 的 0..1 语义限制。
let opacity = number(&args[8])?;
// 本地矩形以负锚点偏移定位,确保绘制与 imageExclusion 共用同一几何变换。
let c = command(
cx,
desktop,
@@ -345,10 +436,12 @@ impl NativeCallable for Function {
a,
opacity,
)?;
// 立即复用实际 paint 解码器验证命令,不等到下一帧才暴露非法 sprite 几何。
decode_image(cx.store(), c)?;
Value::Object(c)
}
ImageExclusion => {
// 从已验证图片命令拿同一套尺寸/偏移/角度,不用近似外接矩形替代透明轮廓。
let RenderCommand::Image {
x,
y,
@@ -361,22 +454,29 @@ impl NativeCallable for Function {
..
} = decode_image(cx.store(), object(&args[0])?)?
else {
// decode_image 的唯一成功变体是 Image,其他变体到此说明内部接口约定被破坏。
unreachable!()
};
// 避让边距只扩展文字禁用区域,不改图片自身的绘制尺寸。
let padding = number(&args[1])?;
if !(0. ..=1000.).contains(&padding) {
return Err(invalid("invalid exclusion padding"));
}
// 对全部轮廓顶点共用同一旋转三角值,保持与 sprite 绘制的角度一致。
let (sin, cos) = angle.sin_cos();
// 原图像素轮廓按目标宽高缩放,再加 anchor 偏移、旋转、平移到绕排坐标。
let polygon = image
.outline
.iter()
.map(|p| {
// 归一化原图 x 到目标图像宽度,并还原相对旋转中心的局部偏移。
let px = offset_x + p.x / image.width as f32 * width;
// y 方向使用原图高度归一化,非正方形缩放不能复用宽度比例。
let py = offset_y + p.y / image.height as f32 * height;
Point::new(x + cos * px - sin * py, y + sin * px + cos * py)
})
.collect();
// 轮廓已经是普通 Point 数组,避让资源不再需要引用原图片对象。
let e = cx.allocate(Exclusion {
circle: None,
polygon,
@@ -386,12 +486,16 @@ impl NativeCallable for Function {
Value::Object(e)
}
Circle | Rectangle => {
// 圆的前两参是圆心,矩形的前两参是左上角;第三参分别为半径或宽度。
let x = number(&args[0])?;
// 坐标可负,几何 extent 的非负约束单独检查。
let y = number(&args[1])?;
// 共用 extent 检查,但不把圆半径误当直径或矩形面积。
let size = number(&args[2])?;
if size < 0. {
return Err(invalid("negative exclusion extent"));
}
// 圆保留解析几何,矩形用闭合四点多边形,ranges 分别采用相应相交算法。
let e = if matches!(self.op, Circle) {
Exclusion {
circle: Some((Point::new(x, y), size)),
@@ -399,6 +503,7 @@ impl NativeCallable for Function {
padding: 0.,
}
} else {
// 矩形独有第四参数为高度,零面积形状允许存在但不制造负方向面积。
let h = number(&args[3])?;
if h < 0. {
return Err(invalid("negative exclusion height"));
@@ -414,28 +519,36 @@ impl NativeCallable for Function {
padding: 0.,
}
};
// 把纯几何避让值包装成有稳定身份的 VM 对象,供 ObjectList 在 flow 时引用。
let e = cx.allocate(e)?;
attach(cx, e, desktop)?;
Value::Object(e)
}
// 绕排工作集中在助手中,复用已预排字形与已验证避让资源,返回 DisplayList。
Flow => flow(cx, desktop, args)?,
_ => {
// 数学分支用 f64,和受 1e6 坐标预算的 number 解码区分,允许数学中间量范围更大。
let real = |v: &Value| -> Result<f64, VmError> {
if let Value::Number(n) = v {
// Endor 的整数/十进制/二进制数统一按显式浮点近似参与这些数学函数。
n.to_f64()
} else {
// 不进行文本到数字或布尔到整数的隐式强制转换。
Err(invalid("math expects Number"))
}
};
// 所有剩余操作至少有一个数字参数,数量已经由 arity 在 VM 调用边界验证。
let x = real(&args[0])?;
if matches!(self.op, Floor) {
if x.abs() > 1_000_000_000. {
// floor 返回整数,先限定转换预算,不能依靠 Rust 饱和转换掩盖超大数值。
return Err(invalid("floor result exceeds integer conversion budget"));
}
return Ok(NativeResult::Return(Value::Number(
(x.floor() as i64).into(),
)));
}
// 按操作计算一个结果,二/三元分支继续解析额外数字,不预读不需要的参数。
let v = match self.op {
Float => x,
Sin => x.sin(),
@@ -446,16 +559,20 @@ impl NativeCallable for Function {
Min => x.min(real(&args[1])?),
Max => x.max(real(&args[1])?),
Clamp => {
// 下界/上界顺序必须合法,否则 f64::clamp 会 panic,不能让源参数引发宿主崩溃。
let lo = real(&args[1])?;
// 上界是第三参数,不能交换两端后悄悄修正调用者的错误范围。
let hi = real(&args[2])?;
if lo > hi {
return Err(invalid("clamp minimum exceeds maximum"));
}
x.clamp(lo, hi)
}
// 非数学操作已经在外层 match 命中,Floor 也已提前返回,因此不存在其他合法分支。
_ => unreachable!(),
};
if !v.is_finite() {
// sqrt 负数等情况会生成 NaN,必须转成语言错误而非让非有限数流入下一轮图形几何。
return Err(invalid("non-finite math result"));
}
Value::Number(v.into())
@@ -465,97 +582,144 @@ impl NativeCallable for Function {
}
}
/// flow 为每个行带调用:合并避让形状的水平阻塞范围,返回按 x 递增且至少 8 单位宽的可排文字区间。
/// 这是按整行带求保守横向范围,不是每个字形轮廓与多边形的精确碰撞检测。
fn ranges(left: f32, right: f32, y: f32, line: f32, exclusions: &[&Exclusion]) -> Vec<(f32, f32)> {
// 每个形状先贡献一个阻塞区间;最后统一排序/扫线,支持多个重叠障碍。
let mut blocked = Vec::new();
// 按资源分别求本行投影;不修改 Exclusion,使同一障碍可被多个 Flow 调用和不同文本列共享。
for e in exclusions {
// 圆直接求解析弦宽;矩形/图片轮廓的 circle 为 None,继续交给下面的多边形边遍历。
if let Some((p, r)) = e.circle {
// 行带包含圆心时 gap=0,否则为圆心到最近水平边的垂直距离。
let gap = (y - p.y).max(p.y - y - line).max(0.);
if gap < r {
// 勾股关系求该行带可触及的最大半弦长,整段横向范围都留给圆形障碍。
let dx = (r * r - gap * gap).sqrt();
blocked.push((p.x - dx, p.x + dx));
}
}
// 多边形先把行带上下扩 padding,随后还会把水平投影左右扩 padding。
let (top, bottom) = (y - e.padding, y + line + e.padding);
// 收集行带内的顶点以及边与上下边界交点,取最小/最大 x 形成保守阻塞范围。
let mut xs = Vec::new();
for i in 0..e.polygon.len() {
// 当前顶点和下一顶点构成一条边;取模闭合最后一条边,不要求调用者重复首顶点。
let a = e.polygon[i];
// 空多边形不会进入循环,因此这里的取模不会对零求余。
let b = e.polygon[(i + 1) % e.polygon.len()];
if a.y >= top && a.y <= bottom {
// 行带覆盖的顶点直接参与水平包围,避免水平边/角点没有穿越交点时被漏掉。
xs.push(a.x);
}
// 同一条斜边可能跨越行带的上下两边,两个交点都要计入横向包围,不能找到一个就停止。
for edge in [top, bottom] {
if (a.y < edge && b.y > edge) || (b.y < edge && a.y > edge) {
// 只有真正跨过水平边界才插值,分母 b.y-a.y 在此条件下非零。
xs.push(a.x + (b.x - a.x) * (edge - a.y) / (b.y - a.y));
}
}
}
if !xs.is_empty() {
// 凹多边形也取整体横向包围,宁可留更宽避让,不在行带内钻进不可靠的局部空洞。
blocked.push((
xs.iter().copied().fold(f32::INFINITY, f32::min) - e.padding,
xs.iter().copied().fold(f32::NEG_INFINITY, f32::max) + e.padding,
));
}
}
// 先按左端排序,扫描游标便可同时处理重叠和包含关系,避免输出交叉的可用区间。
blocked.sort_by(|a, b| a.0.total_cmp(&b.0));
// x 是尚未被处理阻塞覆盖的最左位置,始终夹在文字行的 left..right 范围内。
let mut x = left;
// 与函数同名的局部值是结果区间数组,后面 Prepared::row 按这个顺序放词。
let mut ranges = Vec::new();
for (lo, hi) in blocked {
if hi <= left || lo >= right {
// 完全位于文本列外的形状不消耗可用宽度,也不移动扫描游标。
continue;
}
if lo > x {
// 当前阻塞前的空隙才是可用区间;右端不能超过列右边界。
ranges.push((x, lo.min(right)));
}
// 重叠阻塞只推进到更右处,不因下一个区间的左端较小而倒退或重复输出空间。
x = x.max(hi).min(right);
}
if x < right {
// 所有阻塞处理后剩余的尾段仍可放文字,整行无障碍时这里返回完整列宽。
ranges.push((x, right));
}
// 极窄缝不进入字词放置,既降低工作量,也避免在几何边缘生成难以使用的片段。
ranges.into_iter().filter(|(l, r)| r - l >= 8.).collect()
}
/// Function 的 Flow 分支调用,把预排文字放入有限矩形并避开障碍,生成包含图片字形的 DisplayList。
fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Result<Value, VmError> {
// 第一参必须是 Prepare 返回的资源;只靠对象上的同名 prepared 属性不能替代原生 payload。
let resource = object(&args[0])?;
// 借 payload 取资源元数据,随后复制 Arc/行高以结束不可变借用,允许下面继续分配 VM 对象。
let text = cx
.store()
.payload::<TextResource>(resource, "PreparedText")?;
// 只共享塑形与字形图片,不把每次 Flow 的游标写回资源,多个视图可独立排同一份文字。
let prepared = text.prepared.clone();
// 行高在 Prepare 时已验证为正,下面 height/line 的行数预算因此有明确含义。
let line = text.line;
// x/y 是本次文字矩形的逻辑原点,与 Exclusion 中的点必须处于同一坐标空间。
let x = number(&args[1])?;
// y 也作为首行 top,不按字体 baseline 参数解释。
let y = number(&args[2])?;
// 列宽约束 ranges 的左右边界,不改变预排字形本身的宽度。
let width = number(&args[3])?;
// 矩形高度限定完整行带数量,行带放不下时停止而非溢出绘制区域。
let height = number(&args[4])?;
// 数字开关决定完整单词是否拆成独立字母图像命令;不是修改词/字母换行规则的开关。
let letters = number(&args[6])? != 0.;
if width < 0. || height < 0. || height / line > 1024. {
// 限制布局循环最多 1024 行;可用区间全被遮挡时也不能无界向下寻找空间。
return Err(invalid("text flow bounds exceed budget"));
}
// 障碍必须经宿主 ObjectList 的只读元素接口取得,避免在计算中执行任意源迭代协议。
let items = crate::object_list::items(cx.store(), object(&args[5])?)?;
if items.len() > 512 {
return Err(invalid("text flow exceeds 512 exclusions"));
}
// 每个元素验证真实 Exclusion payload;普通对象不因拥有 circle/polygon 属性就成为障碍资源。
let exclusions = items
.iter()
.map(|&o| cx.store().payload::<Exclusion>(o, "Exclusion"))
.collect::<Result<Vec<_>, _>>()?;
// 第一阶段只计算词/字母摆放,仍未生成任何 VM PaintCommand。
let mut placements = Vec::new();
// 每次调用从词首开始,资源可复用但消费位置不跨调用保留。
let mut cursor = Cursor::default();
// 行扫描从原点向下推进,即使一行全被避让也会消耗 line 高度,保证循环有界。
let mut top = y;
while top + line <= y + height && cursor.word < prepared.words.len() {
// 当前行的可用区间随障碍形状变化,不能沿用上一行的投影宽度。
let intervals = ranges(x, x + width, top, line, &exclusions);
prepared.row(&mut cursor, &intervals, top, &mut placements);
top += line;
}
// 第二阶段将摆放变成图片命令,字符墨迹已在 Prepare 时形成,无需再次字体塑形。
let mut commands = Vec::new();
for p in placements {
// p.word/p.letter 指回预排资源,同一个词的多个出现共享像素图片。
let word = &prepared.words[p.word];
// 已拆行的单字母必须只画该字母;完整词可按 letters 开关选择逐字图或整词图。
let inks = if let Some(i) = p.letter {
std::slice::from_ref(&word.letters[i])
} else if letters {
// 逐字命令便于源程序后续分别变换,每张图仍来自同一份字形预绘制结果。
&word.letters[..]
} else {
// 整词图命令数量更少,布局宽度仍按同一 word 度量,视觉原点与字母模式一致。
std::slice::from_ref(&word.ink)
};
// 字母模式沿词内 advance 累加 x;整词模式只迭代一张图,不重复应用词总宽。
let mut px = p.x;
for ink in inks {
// 为图片共享像素创建托管资源外壳,PaintCommand 的 image 属性形成明确 VM 强边。
let image = cx.allocate(ImageResource(ink.image.clone()))?;
attach(cx, image, desktop)?;
commands.push(command(
@@ -569,10 +733,12 @@ fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Resul
)?);
px += ink.width;
if commands.len() > 10_000 {
// 行数预算之外再限制命令数,防止单行极多字母模式产生巨量发布数据。
return Err(invalid("text flow command budget exceeded"));
}
}
}
// DisplayList 采用受检查 ObjectList,不把 Rust Vec 或任意用户对象当绘制命令集合。
let commands = crate::object_list::collection(cx, commands, desktop)?;
Ok(Value::Object(crate::native_visual::record(
cx,
@@ -581,15 +747,18 @@ fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Resul
vec![("commands", Value::Object(commands))],
)?))
}
/// ui_bridge::bootstrap 在类型/协议安装后调用,注册 ui.graphics 和 math 命名空间,不加载资源或执行绘制。
pub(crate) fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
ui: ObjectRef,
desktop: ObjectRef,
) -> Result<(), VmError> {
// 命名空间自身是普通 Object,显式根由候选运行时统一管理,安装失败时也会清理。
let graphics = vm.new_object()?;
roots.push(graphics);
attach_vm(vm, graphics, "Object", desktop)?;
// 数值辅助函数独立挂在 graphics.math,避免把 sin/now 等名字注入用户全局命名空间。
let math = vm.new_object()?;
roots.push(math);
attach_vm(vm, math, "Object", desktop)?;
@@ -617,6 +786,7 @@ pub(crate) fn install(
(math, "max", Op::Max),
(math, "clamp", Op::Clamp),
] {
// 每个入口有独立 Op 标签及同一个 desktop 捕获,VM 根据 arity 检查数量再进入 Function::invoke。
let f = vm.allocate(ContextNativeFunction::new(
name,
Function { op, desktop },
@@ -626,48 +796,66 @@ pub(crate) fn install(
attach_vm(vm, f, "Function", desktop)?;
vm.set_attr(owner, name, Value::Object(f))?;
}
// 圆周率是数值属性而非零参函数,读取无须采样时钟或进入原生调度。
vm.set_attr(math, "pi", Value::Number(std::f64::consts::PI.into()))?;
vm.set_attr(graphics, "math", Value::Object(math))?;
vm.set_attr(ui, "graphics", Value::Object(graphics))?;
Ok(())
}
/// install 使用直接 VM 分配而非 NativeContext,借此把命名空间/函数附上已注册名义类型。
fn attach_vm(vm: &mut Vm, o: ObjectRef, name: &str, desktop: ObjectRef) -> Result<(), VmError> {
// 查精确 Type 身份,不为每个函数重新创建名为 Function 的类型对象。
let t = crate::ui_bridge::desktop_type(vm.object_store(), name, desktop)?;
vm.attach_type(o, t)
}
#[derive(Debug)]
/// UiBridge::dispatch 为一次原始 pointer 输入创建的临时 payload,经 VM 调用进入事件传播。
pub(crate) struct PointerDispatch {
// 接收事件的业务 Block;同一个 Block 可以有多个 Mount,因此不能只存 owner。
pub owner: ObjectRef,
// 本次具体出现位置,提供挂载状态、局部变换与后续传播路径上下文。
pub mount: ObjectRef,
// Event 类型和传播资源域来自当前窗口 desktop,不从 GUI 持有的原生指针取得。
pub desktop: ObjectRef,
// 保留 pointer-down/move/up 等原始事件种类,不自动合成为 click。
pub kind: String,
// 输入坐标先保留外层空间表示,invoke 在所属 Mount 几何上逆投影。
pub point: Point,
}
impl NativeCallable for PointerDispatch {
/// VM 调用无参数临时驱动,全部输入已捕获在 payload 中,避免调用方再次传不匹配目标。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// VM 分派临时 callable 时验证 Mount,生成带局部 x/y 的 Event,再交事件模块执行捕获/目标/冒泡。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 再检查实时 payload,GUI 命中时的可交互标记不能保证当前对象仍挂载或仍订阅指针事件。
let data = cx
.store()
.payload::<crate::mount::Mount>(self.mount, "Mount")?;
// 几何属于具体出现位置,源对象属性上的 x/y 不能替代投影后的 Mount 几何。
let g = &data.geometry;
if !data.mounted || !g.pointer_events {
return Err(invalid(
"pointer target is detached or has no pointer subscriptions",
));
}
// 先撤销嵌套视图/面板映射,再撤销该出现的二维视图,失败时不沿用原屏幕坐标。
let local = g
.view_point(self.point, false)
.map(|p| g.view.unwrap_or_default().inverse(p))
.ok_or(invalid("pointer cannot be projected"))?;
// 目标事件坐标以该出现外框左上角为原点,不是页面/世界绝对位置。
let (x, y) = (local.x - g.x, local.y - g.y);
// Event 必须与当前宿主域的名义类型一致,不能借用另一个会话的类型身份。
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
// NativeContext 负责临时根,返回/委托时提升输出,异常路径也不会遗留新建 Event 根。
let event = crate::event_dispatch::new_event(cx, definition, &self.kind)?;
cx.set_attr(event, "x", n(x))?;
cx.set_attr(event, "y", n(y))?;
// 事件模块可以返回 Invoke 延续源处理器,仍使用当前 VM 的预算/调用栈而非递归启动解释器。
crate::event_dispatch::dispatch(cx, self.owner, event, Some(self.mount), self.desktop)
}
}
+3
View File
@@ -1,7 +1,10 @@
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
//! endor_host 建立会话时安装 ui_bridge 能力,源程序随后通过名义方法调用宿主;渲染线程不直接运行这些回调。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 注册托管类型/宿主方法,验证参数并连接业务对象、Mount、视觉协议与窗口/任务资源。
pub(crate) mod ui_bridge;
/// 注册图片、字体、文字流与避让形状能力,并把托管绘制参数解码为可跨线程使用的不可变资源。
/// ui_bridge 安装时调用注册入口,source_paint 解码 image 命令时复用图像检查。
pub(crate) mod graphics;
+446 -74
View File
File diff suppressed because it is too large Load Diff
+70 -3
View File
@@ -1,42 +1,65 @@
//! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。
//! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。
//!
//! Allocate and connect the managed desktop, type tables and host globals.
//! 调用链:`endor_host::compile` / `ReplSession::new` 创建候选 VM,
//! 随后调用本模块的 `install`;运行时再通过 install_globals 注入源程序使用的 UI 值。
//! 本文件只建立宿主环境。源程序稍后调用构造器或实例方法时,才会进入 `calls::Function::invoke`。
//! 安装返回的 UiBridge 由运行时保存;传入的 roots 由候选运行时持有,并在失败或退役时统一释放。
//! 安装顺序还承载 GC 关系:desktop.types 拥有类型,类型拥有工厂/方法,callable 再追踪签名和显式 captures。
//! 两阶段补签名保留原对象身份,因此 ui 属性和 TypeConstructor 不需要重新指向另一批函数。
use super::*;
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
/// 为一次候选程序/REPL 会话安装 Tessera 宿主环境,供运行时后续注入全局值和执行源程序。
///
/// 类型要先创建,签名才能引用它们;因此先分配没有签名的函数,再原地补齐 payload。
/// 所有 `?` 都把安装失败交还候选运行时,不会在这里把半成品替换成活跃程序。
/// `roots` 保存明确的 VM 拥有根;Rust 的 ObjectRef 数值和 Arc<Mutex<Session>> 均不能替代它们。
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
// 先建立 desktop/ui/tabs 并加入显式 roots,后面初始化失败时由上层运行时统一释放,避免依赖 Rust 句柄保活。
// desktop 是此桥接域的类型注册表和窗口资源入口;它不是操作系统桌面或原生窗口。
let desktop = vm.new_object()?;
roots.push(desktop);
// 安装时尚无窗口尺寸;运行时收到 viewport 后再填充,避免在 VM 引导期间依赖 GUI 状态。
vm.set_attr(desktop, "viewport", Value::Nil)?;
// tabs 是主窗口的托管页面注册表,Presentation 和 window::install 会使用同一字典身份。
let tabs = vm.new_dict()?;
roots.push(tabs);
vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
// ui 是对源代码公开的命名空间;desktop 保存内部记账,二者用途不能混同。
let ui = vm.new_object()?;
roots.push(ui);
// 原生 Session 借用托管身份,主窗口先拥有独立 Presentation;业务对象仍全部位于传入的单一 VM。
let session = Arc::new(Mutex::new(Session {
// 节点弱索引用来检查数量与回收失效记录,不应让已关闭的业务对象继续存活。
nodes: Vec::new(),
// 先登记主窗口上下文;后续窗口通过 create_window 建立各自的 Presentation。
presentations: std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::presentation::Presentation::new(desktop, tabs),
)]),
// Session 的 Deref 按 current 选择布局/挂载状态;初始所有操作都落在主窗口。
current: crate::window::MAIN_WINDOW,
// master 始终指向主 desktop,副窗口仍借用同一 VM 的类型和资源域。
master: desktop,
// 分配器从现有最高窗口 ID 继续增长;Mount 则尚无任何已分配出现位置。
next_window: crate::window::MAIN_WINDOW,
next_mount: 0,
// 关闭后尚未交给 GUI 的原生命令暂存在这里,不能随 Presentation 删除而丢失。
retired_commands: Vec::new(),
// 累计诊断计数由刷新流程更新,供 ui.stats/开发工具观察本会话的实际工作量。
refresh_yields: 0,
refresh_restarts: 0,
layout_cancels: 0,
paint_cancels: 0,
paint_slices: 0,
binding_evaluations: 0,
// 清理阶段会置位,create_window 等操作据此拒绝在退出期间重新创建资源。
exiting: false,
}));
// 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。
// 保存 ui.name 的函数身份及 Operation,等全部类型注册后再为这些函数补齐精确签名。
let mut public_functions = Vec::new();
// 另外记录 TypeConstructor 所用的工厂;它们多接收一个 Type 首参,不能套用自由函数签名。
let mut factories = Vec::new();
for (name, operation) in [
("implement", Operation::Implement),
@@ -63,6 +86,8 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("viewport", Operation::Viewport),
("window", Operation::Window),
] {
// 每个名字得到自己的 VM callable,实际操作由 Function 根据 operation 分派。
// desktop 放在 captures 中,确保函数仍可达时它所依赖的类型注册表也能被 GC 追踪。
let function = vm.allocate(ContextNativeFunction::new(
name,
Function {
@@ -75,6 +100,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vec![desktop],
))?;
roots.push(function);
// 属性供源程序的 ui.text/ui.present 等查询;列表供本函数第二阶段补签名,职责不同。
vm.set_attr(ui, name, Value::Object(function))?;
public_functions.push((name, function, operation));
}
@@ -83,6 +109,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
roots.extend([root_types.object, root_types.type_type]);
vm.set_attr(ui, "Object", Value::Object(root_types.object))?;
vm.set_attr(ui, "Type", Value::Object(root_types.type_type))?;
// definitions 汇集全部类型,稍后写入 desktop.types,供 desktop_type 按名称查精确身份。
let mut definitions = vec![
("Object", root_types.object),
("Type", root_types.type_type),
@@ -127,10 +154,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Task", "Object", None),
("ObjectList", "Object", Some("object-list")),
] {
// 依照表中基类名取得当前 VM 的真实类型,不能用另一个 VM 中的同名 Type 代替。
let base = object(Some(&vm.get_attr(ui, base_name)?))?;
// 根据构造规则选择专用工厂、通用工厂或抽象类型;这里只注册,不分配业务实例。
let constructor = match constructor_name {
// ObjectList 与 Event 有特殊的“类型首参+子类字段”构造规则,单独原生适配;其他类型共用 NativeFactory。
Some("object-list") => {
// ObjectList 构造要保持托管元素身份与字段规则,由 object_list::Constructor 处理。
let factory = vm.allocate(ContextNativeFunction::new(
"object-list",
crate::object_list::Constructor {
@@ -140,17 +170,20 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vec![desktop],
))?;
roots.push(factory);
// 工厂本身也是语言中的 Function 实例,挂类型后才能通过名义函数检查。
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
vm.attach_type(factory, function_type)?;
TypeConstructor::NativeTyped(factory)
}
Some("event") => {
// Event 另有必填事件名;其专用 Constructor 在真正调用时解析事件参数和子类字段。
let factory = vm.allocate(ContextNativeFunction::new(
"event",
crate::event_dispatch::Constructor { signature: None },
vec![],
))?;
roots.push(factory);
// 与列表工厂保持相同函数类型体系;无 desktop 捕获的分配器不额外引入桌面强边。
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
vm.attach_type(factory, function_type)?;
TypeConstructor::NativeTyped(factory)
@@ -176,6 +209,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
.map(Operation::SceneObject)
.ok_or(invalid("unknown native type allocator"))?,
};
// 通用 NativeFactory 在调用时区分原生参数与源子类字段,并保留 requested Type。
let factory = vm.allocate(ContextNativeFunction::new(
constructor_name,
NativeFactory {
@@ -187,10 +221,12 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vec![desktop],
))?;
roots.push(factory);
// 这个函数属于构造器元数据,不直接放在 ui.name;但仍需标为 Function 实例。
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
vm.attach_type(factory, function_type)?;
TypeConstructor::NativeTyped(factory)
}
// 没有公开分配规则的类型仍可用于类型检查/协议;直接把它当构造器调用会由 VM 拒绝。
None => TypeConstructor::Abstract,
};
// 记住实际工厂身份,类型构建完成后原地补签名,避免替换类型内指向的构造函数对象。
@@ -198,6 +234,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
{
factories.push((tag, factory));
}
// 生成新 Type 后同时公开到 ui,并加入待提交注册表,两个入口必须指向同一对象。
let definition = vm.new_type(name, base, constructor)?;
roots.push(definition);
vm.set_attr(ui, name, Value::Object(definition))?;
@@ -217,6 +254,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("PaintCommand", vec![]),
("CameraGesture", vec![]),
] {
// 表里的字段名转成 VM 可检查的字段声明;map 的每次调用只描述一个字段,不执行布局。
let fields = fields
.into_iter()
.map(|name| endor_core::ClassField {
@@ -225,11 +263,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
type_name: if matches!(name, "children" | "commands") {
"Object"
} else {
// 余下字段都是测量/几何数值,让 VM 在实例化时先拒绝非数字实参。
"Number"
}
.into(),
})
.collect();
// 这些协议值只携带源字段,使用普通 class 分配;不需要 Block 的原生 payload。
let definition = vm.new_class(
name,
root_types.object,
@@ -243,14 +283,17 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
// The desktop owns the registry through ordinary traced attributes. The
// Rust session only borrows handles and adds no hidden lifetime roots.
// 桌面通过普通属性拥有类型字典;后续 nominal 查询必须使用同一 desktop 中注册的精确类型身份。
// 类型字典由 desktop 属性托管持有;后续桥接调用可从其查找 Block、Event 等精确类型。
let types = vm.new_dict()?;
roots.push(types);
for (name, definition) in definitions {
// 字符串键是宿主查询约定,值才是参与 is_instance/is_subtype 的真实名义身份。
vm.dict_set(types, Value::Text(name.into()), Value::Object(definition))?;
}
vm.set_attr(desktop, "types", Value::Object(types))?;
vm.attach_type(desktop, root_types.object)?;
vm.attach_type(ui, root_types.object)?;
// 安装后续编译函数的默认类型,也用于下方所有方法 wrapper 的 attach_type。
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
// 让后续 VM 创建的函数使用宿主 Function 名义类型,并打开源类型检查,再安装完整本机签名。
vm.set_function_type(function_type)?;
@@ -273,6 +316,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
)?;
}
for (name, function) in factories {
// 恢复第一阶段记录的分配器类别;Event/ObjectList 不走 NativeFactory,需要单独补外壳。
let operation = match name {
"text" => Some(Operation::Text),
"rect" => Some(Operation::Rect),
@@ -288,6 +332,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
"button" => Some(Operation::Button),
"page" => Some(Operation::Page),
"window-create" => Some(Operation::NewWindow),
// None 在本轮只表示专用构造器,不表示前一阶段的 Abstract 类型。
"event" | "object-list" => None,
name => Some(
crate::scene3d_model::Constructor::from_function(name)
@@ -296,6 +341,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
),
};
if let Some(operation) = operation {
// options 只描述原生构造选项,调用时由 NativeFactory 在切掉 Type 和子类字段后校验。
let options = operation_signature(vm.object_store(), desktop, operation)?;
// 外层 NativeTyped 工厂接收 Type 首参和任意候选尾参,允许子类字段;NativeFactory 用 options 再核对基构造参数。
let envelope = CallSignature {
@@ -305,8 +351,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
returns: options.returns.clone(),
effect: options.effect,
};
// 初始强边是注册表;随后追加签名引用的类型,供 ContextNativeFunction::trace 使用。
let mut captures = vec![desktop];
// 参数和返回注解中的名义类型也是托管引用,必须纳入 captures,不能只把它们存进 Rust 签名结构。
// ContextNativeFunction 自动追踪外壳 signature,但内部 options 不对它暴露;窄参数中的名义类型要额外加入 captures。
// 即使 desktop.types 当前也能保活它们,仍显式枚举 options 真正使用的引用,不把内部签名藏在不可见的 Rust 拥有图中。
captures.extend(options.references());
vm.replace_payload(
function,
@@ -323,19 +371,26 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
)?;
} else {
// 特殊构造器保留宽变参外壳,再由对应 Constructor 按类型字段数拆实参,返回准确 Event/ObjectList 名义类型。
// 为返回值选取专用名义类型,避免宽变参外壳同时放宽了返回值检查。
let kind = if name == "event" {
// 前面已穷举通用工厂,此分支只剩 Event 和 ObjectList;这里的名字决定返回契约而不是业务实例类型。
"Event"
} else {
"ObjectList"
};
// Event 的 Type 之后还有必填字符串事件名;ObjectList 只有 Type 为必填,其余由实现解释。
let signature = CallSignature {
arity: endor_core::CallArity::range(
// 这里包含 Type 首参;源子类字段由构造器验证,所以最大个数保留开放。
if name == "event" { 2 } else { 1 },
usize::MAX,
),
// 固定前缀与 required 数量保持一致,额外实参由 variadic 和字段验证共同约束。
parameters: if name == "event" {
// Event 的事件名在外壳阶段就要求 String;随后专用构造器再拆出其余子类字段。
vec![AnnotationType::Type.into(), AnnotationType::String.into()]
} else {
// ObjectList 元素和子类字段都在开放尾参中,由构造器按 requested Type 的字段声明分界。
vec![AnnotationType::Type.into()]
},
variadic: Some(AnnotationType::Any.into()),
@@ -344,6 +399,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
effect: CallEffect::ReadOnly,
};
if name == "event" {
// 原地更新 Event 工厂,TypeConstructor 中已保存的对象引用继续有效。
vm.replace_payload(
function,
ContextNativeFunction::new(
@@ -355,6 +411,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
),
)?;
} else {
// 这里只有 ObjectList;保留 desktop 捕获,以便专用分配器给结果附加列表类型。
vm.replace_payload(
function,
ContextNativeFunction::new(
@@ -369,6 +426,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
}
}
}
// 对这批公开入口补 Function 类型身份;不重新分配 callable,ui 属性仍指向原对象。
for name in [
"open",
"text",
@@ -390,6 +448,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
"implements",
"supports",
] {
// 从 ui 取回前面已补完签名的函数;名称表与安装表不一致会在这里报错而非静默漏装。
let function = object(Some(&vm.get_attr(ui, name)?))?;
vm.attach_type(function, function_type)?;
}
@@ -437,7 +496,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Page", "back", Operation::Back),
("Page", "forward", Operation::Forward),
] {
// expected 同时用于方法所属类型、receiver 校验和签名中的名义参数身份。
let expected = object(Some(&vm.get_attr(ui, type_name)?))?;
// VM 的方法分派负责绑定 receiver;TypeMethod 再验证它并转交 Function 执行业务操作。
let method = vm.allocate(ContextNativeFunction::new(
name,
TypeMethod {
@@ -451,20 +512,26 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
))?;
roots.push(method);
vm.attach_type(method, function_type)?;
// 写到类型方法表而非每个实例的属性,实例与源子类可共享同一个原生实现。
vm.define_method(expected, name, method)?;
}
// 类型与基础方法就绪后再装窗口、默认视觉、事件方法和能力契约,顺序满足各模块查类型/方法的前提。
crate::window::install(vm, roots, desktop, tabs)?;
// 默认 measure/arrange/paint 安装到 Block 体系;源代码之后可按协议覆盖它们。
let block_type = object(Some(&vm.get_attr(ui, "Block")?))?;
crate::native_visual::install(vm, roots, desktop, block_type, function_type)?;
// 事件方法需要识别 Event 实例;订阅返回值则使用 Subscription 身份管理取消连接。
let event_type = object(Some(&vm.get_attr(ui, "Event")?))?;
// token_type 传给事件安装器,不能拿 Event 类型代替,否则取消令牌会违反返回契约。
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。
// Scheduler 随 UiBridge 交还运行时;它还未开始执行用户任务,只完成 ui 异步 API 注册。
let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
crate::bridge::graphics::install(vm, roots, ui, desktop)?;
developer_pages::install(vm, ui, &session)?;
// 将语言命名空间、展示会话与任务调度打包,运行时随后通过同一 bridge 推进刷新与回调。
let bridge = UiBridge { ui, session, tasks };
developer_pages::install_start_page(vm, &bridge)?;
Ok(bridge)
+179 -7
View File
@@ -1,46 +1,59 @@
//! 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。
//! 先检查参数和只读阶段限制,再修改托管状态;对象引用的生命周期仍交给 NativeContext 和 Aster 管理。
//!
//! Native Block operations executed on the owning VM worker.
//! 调用来源:VM 直接执行 bootstrap 注册的 ui 自由函数,或由 TypeMethod/NativeFactory 剥离接收者/Type 后转交。
//! Function.owner 保存绑定对象,requested_type 保存原生子类构造请求;本文件 args 只包含显式原生参数。
//! 简单分支同步返回 NativeResult::Return;需要执行源协议/事件处理器的分支返回 Invoke,让 VM 接着推进调用栈。
//! NativeContext 追踪本次分配和 upgrade 的临时根,返回/委托时提升输出引用,其余在域退出时释放。
//! 下面的提前返回按能力分层:相机/场景、查询/状态、构造、展示协议先处理,最后才要求 Block owner 并克隆 Data。
//! 这不是统一回滚事务:有些分支会先写托管属性或窗口注册表,末尾 replace_payload 只提交仍需写回的结构副本。
use super::*;
impl NativeCallable for Function {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
/// VM 在调用边界查询 bootstrap/bind 写入的签名;由外层已校验的临时转交 Function 可返回 None。
fn signature(&self) -> Option<CallSignature> {
self.signature.clone()
}
/// 接收已经剥离 receiver 的显式参数,数量沿用 Operation 的范围。
/// VM 查询自由函数的实参数量,或供转交适配器使用;这里不再给 receiver/Type 首参加一。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(self.operation.arity())
}
/// 统一校验数量与只读限制,再按 Operation 分派构造、树修改、页面、绑定、状态与能力调用,返回托管结果或 VM 续调用。
/// VM 或外层原生调用适配器进入的实际业务分派,返回借用结果或委托;不负责 GUI 帧的原子发布。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
use Operation::*;
// 外层 TypeMethod/NativeFactory 会直接调用本方法,因此这里仍需防御性检查参数数量。
let arity = self.operation.arity();
// 显式区间不包含已剥离的 receiver/Type,后面 args[0] 等索引依赖这次验证。
let (minimum, maximum) = (arity.required, arity.maximum);
// Function 接收已剥离 receiver 的显式参数;先校验数量,后面索引 args 才安全。
if !(minimum..=maximum).contains(&args.len()) {
// 在任何分配或模型修改之前拒绝数量不符,避免越界或带半次副作用的失败。
return Err(invalid("incorrect UI method argument count"));
}
if matches!(self.operation, Camera2D) {
// 独立相机不是 Block,不进入下方的视觉 Data.kind 构造;requested_type 保留源子类。
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 {
// 只复制当前类型域身份,放锁后进入三维分配器;该分配器校验 kind 的实参与子类约束。
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
// 类型构造保留请求身份,自由工厂选择当前 desktop 中 kind.name 对应的默认类型。
let definition =
self.requested_type
.unwrap_or(desktop_type(cx.store(), kind.name(), desktop)?);
// model::scene3d 建立托管几何/场景对象,此刻不投影、不光栅化也不建立 OS 资源。
let object = crate::scene3d_model::allocate(cx, desktop, definition, kind, args)?;
return Ok(NativeResult::Return(Value::Object(object)));
}
if matches!(self.operation, CameraControl) {
// receiver 是控制器,两个显式参数是相机与手势;实际状态更新由 scene3d_model::control 解释。
crate::scene3d_model::control(
cx,
self.owner
@@ -51,6 +64,7 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, Translate3D | Rotate3D) {
// 三分量实参共用转换入口,末尾布尔值区分旋转/位移;对象缺少 owner 时直接报错。
crate::scene3d_model::transform(
cx,
self.owner
@@ -62,9 +76,12 @@ impl NativeCallable for Function {
}
// implement 修改显式能力关系;implements 查类型,supports 查对象实际能力,不靠同名字段鸭子类型推断。
if matches!(self.operation, Implement | Implements | Supports) {
// 第二实参必须是托管 Trait,注册和查询共用身份,但标量 supports 仍需先验证 Trait 本身。
let capability = object(args.get(1))?;
// 此分支计算语言返回值,随后统一包 Return;只有 Implement 真正修改类型关系。
let value = match self.operation {
Implement => {
// 第一参数是 Type,VM 的 implement_trait 检查能力契约并建立托管引用关系。
cx.implement_trait(object(args.first())?, capability)?;
Value::Nil
}
@@ -73,24 +90,30 @@ impl NativeCallable for Function {
.implements_trait(object(args.first())?, capability)?,
),
Supports => {
// Validate the contract even for immediate scalar values.
// 即使第一参数是标量也先验证第二参数真为 Trait;不能用 false 掩盖错误能力对象。
cx.store()
.payload::<endor_core::EndorTraitObject>(capability, "Trait")?;
Value::Bool(match args[0] {
// 对象按实际能力契约查询,标量没有这里的宿主对象能力而返回 false。
Value::Object(value) => cx.store().supports(value, capability)?,
_ => false,
})
}
// 外层 matches 已限定三种操作,其他枚举值到这里表示分派实现内部不一致。
_ => unreachable!(),
};
return Ok(NativeResult::Return(value));
}
if matches!(self.operation, IsInstance | IsSubtype) {
// 当前实现要求两者都解码成对象:签名中的 Any 不等于可在这里传 nil 后获得 false。
let candidate = object(args.first())?;
// expected 必须属于此 VM 的真实类型体系;关系查询不按类型名字字符串比较。
let expected = object(args.get(1))?;
// 实例关系检查对象的类型,子类型关系检查两个 Type 的继承关系,不能混为同一查询。
let value = if matches!(self.operation, IsInstance) {
cx.store().is_instance(candidate, expected)?
} else {
// isSubtype 用于类型间关系,不把 candidate 当实例读取其 type_of。
cx.store().is_subtype(candidate, expected)?
};
return Ok(NativeResult::Return(Value::Bool(value)));
@@ -98,48 +121,61 @@ impl NativeCallable for Function {
// 显式失效属于模型变更,只读阶段拒绝;paint/layout/content 精度不同,后续分别清理对应缓存。
if matches!(self.operation, Invalidate) {
if cx.store().is_read_only() {
// measure/paint 等只读域不能靠显式失效反复制造下一轮更新反馈。
return Err(invalid(
"invalidation is forbidden during read-only rendering",
));
}
// 仅省略参数才默认 layout,错误类型不回退;transpose 保留显式解析失败。
let reason = args
.first()
.map(|value| text(Some(value)))
.transpose()?
.unwrap_or_else(|| "layout".into());
if !matches!(reason.as_str(), "paint" | "layout" | "content") {
// 精度标签必须已知,否则不能可靠决定清哪些缓存和依赖集合。
return Err(invalid(
"invalidation reason must be paint, layout or content",
));
}
// TypeMethod 已把 receiver 移入 owner,没有绑定对象的自由调用不能执行此方法。
let owner = self.owner.ok_or(invalid("invalidation requires a Block"))?;
// 以解析后的共享 Data 身份归并别名,不能只让调用者这一个包装对象失效。
let (data, _) = block::resolve(cx.store(), owner)?;
// 锁内只遍历弱节点并更新派生失效状态,不在这里调用用户布局/绘制方法。
let mut session = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 去重后的 Block 身份集合供每个窗口判断是否包含同一份受影响的共享业务状态。
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
{
// NativeContext 持有升级临时根直到本次调用结束,不为弱索引永久添加拥有关系。
affected.insert(block);
}
}
for presentation in session.presentations.values_mut() {
// 同一 Data 可以出现在多个窗口,失效通知必须广播,不能仅清 current 的缓存。
presentation.invalidate(cx, &affected, &reason)?;
}
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, Mounts) {
// 返回的是这个 Block 的具体展示出现,不能把底层 Data 相同的其他别名也算到 owner 上。
let owner = self.owner.ok_or(invalid("mount query requires a Block"))?;
// 查全部窗口,锁内只读取原生 Mount 表与托管 payload,不执行源回调。
let session = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 暂留窗口/出现 ID 作为排序键,最后列表只暴露 Mount 对象,不暴露宿主元组。
let mut positions = Vec::new();
for (window, presentation) in &session.presentations {
// 每窗活动表只有当前仍展示的出现,死亡弱项和其他 Block 的出现都不加入结果。
for (id, weak) in &presentation.mounts.active {
if let Some(object) = cx.upgrade(*weak)?
&& cx
@@ -154,6 +190,7 @@ impl NativeCallable for Function {
}
// 跨窗口 Mount 查询按窗口/出现 ID 稳定排序,不依赖 HashMap 遍历顺序;返回每个具体出现而非几何副本。
positions.sort_unstable_by_key(|(window, id, _)| (*window, *id));
// 用专用托管集合保留结果引用和只读访问契约,不把 Rust Vec 直接当源值返回。
let set = crate::mount::collection(
cx,
positions.into_iter().map(|(_, _, object)| object).collect(),
@@ -162,25 +199,31 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Object(set)));
}
if matches!(self.operation, NewWindow) {
// 持锁分配新的窗口 ID 并建立 Presentation,create_window 还会检查只读/退出/数量限制。
let mut state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// Window 只能通过 Type 构造入口创建,缺少 requested_type 说明不是合法的类型调用。
let definition = self
.requested_type
.ok_or(invalid("Window constructor requires a type"))?;
// 返回托管资源对象,原生窗口由随后发布的帧/命令驱动 GUI 创建。
let window = create_window(cx, &mut state, definition)?;
return Ok(NativeResult::Return(Value::Object(window)));
}
if matches!(self.operation, CloseWindow) {
// 资源对象来自 bound receiver,不用 current 猜要关闭哪个窗口。
let window = self
.owner
.ok_or(invalid("window close requires a receiver"))?;
// 用 payload 的资源 ID 定位 Presentation;关闭状态由 window::close 在模型中维护。
let id = cx
.store()
.payload::<crate::window::ManagedWindow>(window, "Window")?
.id;
crate::window::close(cx, window)?;
// model 关闭成功后才改宿主展示状态,关闭失败不能提前标记为已退场。
let mut state = self
.session
.lock()
@@ -195,6 +238,7 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, Window) {
// ui.window() 查询当前选择窗口的资源对象;和 Window() 分配新窗口的路径不同。
let desktop = self
.session
.lock()
@@ -205,15 +249,19 @@ impl NativeCallable for Function {
));
}
if matches!(self.operation, Viewport) {
// ui.viewport() 从当前窗口的输入对象生成结果,不直接读取 winit/OS 尺寸。
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
// 运行时还没提供尺寸时属性为 nil,明确报不可用,不把它伪装成零宽高。
let Value::Object(input) = cx.store().get_attr(desktop, "viewport")? else {
return Err(invalid("window viewport is unavailable"));
};
// 两个普通属性读取参与 VM 依赖跟踪,尺寸变化可让调用方的测量失效。
let width = cx.store().get_attr(input, "width")?;
// 返回 MeasureResult 协议值而非把内部输入对象本身暴露给源程序修改。
let height = cx.store().get_attr(input, "height")?;
return Ok(NativeResult::Return(Value::Object(
crate::native_visual::record(
@@ -227,26 +275,35 @@ impl NativeCallable for Function {
// 诊断会读对象总数与宿主变化统计,禁止在只读渲染中观察并形成反馈依赖。
if matches!(self.operation, Stats) {
if cx.store().is_read_only() {
// 即使签名标 External,这里仍防御直接适配器调用,防止布局自观察计数形成反馈。
return Err(invalid(
"runtime statistics are unavailable during read-only rendering",
));
}
// 各窗口共享的计数与当前展示派生计数一起投影,锁内不推进刷新。
let session = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// blocks/pages 数的是成功升级的存活业务对象,而不是弱表历史长度或 Mount 出现数。
let mut blocks = 0_i64;
// 页面是 Block 的子集,同一页跨窗口迁移不会增加业务页面计数。
let mut pages = 0_i64;
for weak in &session.nodes {
// 弱索引可能已死亡;只统计成功升级的对象,不能用 nodes.len() 代表当前存活 Block 数。
if let Some(object) = cx.upgrade(*weak)? {
blocks += 1;
// Page 是这些存活 Block 的子集,按 payload 标志计一次,不按标签数或 Mount 数重复统计。
if cx.store().payload::<block::Block>(object, "Block")?.page {
pages += 1;
}
}
}
// 统计对象总数在分配 stats 结果之前采样,避免把本次诊断结果计入自己。
let objects = cx.store().object_count() as i64;
// session 的 Deref 指向 current,tabs/mounts 与布局计数属于当前窗口,而非跨窗求和。
let tabs = cx.store().dict_items(session.tabs)?.len() as i64;
// 新普通对象只装原生数值/文本,不把 VM store 或 Session 句柄暴露给程序。
let stats = cx.new_object()?;
for (name, value) in [
("objects", objects),
@@ -273,8 +330,10 @@ impl NativeCallable for Function {
session.dependency_invalidations as i64,
),
] {
// 统一转成语言整数,字段名字是 ui.stats 的观察接口,不影响调度逻辑。
cx.set_attr(stats, name, Value::Number(value.into()))?;
}
// 单独保留文本失效原因,数值计数不能解释究竟哪条源属性触发了重算。
cx.set_attr(
stats,
"lastInvalidDependency",
@@ -297,6 +356,7 @@ impl NativeCallable for Function {
}
// 状态是普通名义 Object,value 属性经 VM 追踪,set/increment 方法捕获同一对象,不额外建立原生状态副本。
if matches!(self.operation, State) {
// value 及绑定方法都是普通托管属性,GC 能追踪回调和状态之间的真实边。
let state = cx.new_object()?;
attach_named_type(cx, state, "Object", &self.session)?;
cx.set_attr(state, "value", args[0].clone())?;
@@ -305,10 +365,13 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Object(state)));
}
if matches!(self.operation, StateSet | Increment) {
// 状态对象由 bind 捕获,不能把 args[0] 当 receiver 再切一次参数。
let owner = self.owner.ok_or(invalid("state method has no owner"))?;
// set 接受任意新值;increment 则对当前 value 做精确整数读取与溢出检查。
let value = if matches!(self.operation, StateSet) {
args[0].clone()
} else {
// 先限制 Number 表示,再从中取可表示 i64 的整数;不截断十进制或二进制小数。
let Value::Number(value) = cx.store().get_attr(owner, "value")? else {
return Err(invalid("increment requires an integer state"));
};
@@ -319,22 +382,28 @@ impl NativeCallable for Function {
.ok_or(invalid("integer state increment exceeds its range"))?;
Value::Number(next.into())
};
// 通过统一属性写入口更新版本,textOf/绑定的依赖失效因此能被下一轮刷新观察。
cx.set_attr(owner, "value", value)?;
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, TextOf) {
// 先保存被观察对象和属性名;这里不是把当前值立即格式化成一个永不更新的字符串。
let source = object(args.first())?;
// 属性名必须是真字符串,不能从任意对象 repr 推导不稳定的名字。
let field = text(args.get(1))?;
// textOf 建立前确认字段可读取,具体字符串化与依赖记录发生在快照提取时。
cx.store().get_attr(source, &field)?;
// 字号可选但必须是等级整数,缺省为 1;颜色在后面按独立可选参数解析。
let size = args
.get(2)
.map(|v| integer(Some(v)))
.transpose()?
.unwrap_or(1);
if !(1..=8).contains(&size) {
// 数字能转为整数不代表是受支持等级,在分配 TextBlock 之前拒绝越界。
return Err(invalid("text size must be 1 through 8"));
}
// 初始文字为空,真正内容留给快照读取 source.field;构造不冻结当前属性值。
let mut data = Data::new(
Kind::Text {
value: String::new(),
@@ -355,6 +424,7 @@ impl NativeCallable for Function {
}
// 普通组件先组装 Data,再统一由 new_typed_block 安装 Cell、属性和名义类型;不直接写原生窗口。
let constructor = match self.operation {
// 滚动视口有限宽高和唯一内容 Block;滚动轴/偏移由属性和 Mount 后续处理。
Scroll => Some(Data::new(
Kind::Scroll {
width: dimension(args.first())?,
@@ -364,6 +434,7 @@ impl NativeCallable for Function {
0.0,
0.0,
)),
// 文本是叶节点,size 的 u64→u32 转换和下面统一 1..=8 检查分别限制表示与语义。
Text => Some(Data::new(
Kind::Text {
value: text(args.first())?,
@@ -380,6 +451,7 @@ impl NativeCallable for Function {
0.0,
0.0,
)),
// 宽高必填,圆角缺省零,颜色缺省浅蓝;每个尺寸都先经有限非负检查。
Rect => Some(Data::new(
Kind::Rect {
width: dimension(args.first())?,
@@ -395,18 +467,23 @@ impl NativeCallable for Function {
0.0,
0.0,
)),
// 空白只提供主轴占位,不带孩子或绘图颜色。
Spacer => Some(Data::new(
Kind::Spacer(dimension(args.first())?),
vec![],
0.0,
0.0,
)),
// 三种容器共用参数解码,Kind 决定后续横排/纵排/叠放,不在这里计算子项坐标。
Row | Column | Overlay => Some(Data::new(
if matches!(self.operation, Row) {
// 行布局随后沿水平主轴累计孩子位置。
Kind::Row
} else if matches!(self.operation, Overlay) {
// 叠放布局共享内容原点,不采用线性 gap 累加位置。
Kind::Overlay
} else {
// 此入口仅剩 Column,后续沿纵向排列。
Kind::Column
},
// 线性容器固定前两参为 padding/gap,后续都是孩子对象;组成合法性在 Block 分配前统一检查。
@@ -417,7 +494,9 @@ impl NativeCallable for Function {
dimension(args.first())?,
dimension(args.get(1))?,
)),
// 空 Page 尚未登记到任何窗口,直到 render/open 才安排展示。
Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)),
// 二维场景只有孩子对象序列;各对象的 x/y 等世界属性在快照提取时读取。
Scene2D => Some(Data::new(
Kind::Scene2D,
args.iter()
@@ -427,6 +506,7 @@ impl NativeCallable for Function {
0.0,
)),
CanvasViewport => {
// 视口自身参加有限布局,第三实参保存 Scene2D 引用,不把场景孩子展开成行列内容。
let mut data = Data::new(
Kind::CanvasViewport {
width: dimension(args.first())?,
@@ -441,12 +521,15 @@ impl NativeCallable for Function {
Some(data)
}
Viewport3D => {
// 当前 desktop 提供相机/控制器的名义类型,所有默认资源在同一 VM 内创建。
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
// 默认相机使用透视投影;这里分配模型对象,不立即生成场景渲染快照。
let definition = desktop_type(cx.store(), "PerspectiveCamera", desktop)?;
// 空参数选择场景模块的默认姿态,之后源属性仍可显式换相机或改参数。
let camera = crate::scene3d_model::allocate(
cx,
desktop,
@@ -454,6 +537,7 @@ impl NativeCallable for Function {
crate::scene3d_model::Constructor::Perspective,
&[],
)?;
// 普通 children 先为空,世界场景独立存于 scene3d 字段;面板 UI 会在提取时派生。
let mut data = Data::new(
Kind::Viewport3D {
width: dimension(args.first())?,
@@ -465,6 +549,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(
@@ -478,6 +563,7 @@ impl NativeCallable for Function {
}
// 按钮标签也是普通 TextBlock 孩子,布局和生命周期沿同一组成树,不是原生按钮硬编码文字。
Button => {
// 先创建真实文字孩子,让按钮尺寸和文本测量、字体缓存、子树生命周期保持一致。
let label = new_block(
cx,
Data::new(
@@ -494,6 +580,7 @@ impl NativeCallable for Function {
)?;
Some(Data::new(Kind::Button, vec![label], 10.0, 0.0))
}
// 非构造操作继续进入页面/实例方法分派;None 不是失败,也不创建占位对象。
_ => None,
};
// 统一检查文本等级和 UTF-8 字节量,再按 requested_type 生成实际对象,保留子类工厂身份。
@@ -501,6 +588,7 @@ impl NativeCallable for Function {
if let Kind::Text { size, value, .. } = &data.kind
&& (!(1..=8).contains(size) || value.len() > 16_384)
{
// 两条文本工厂共用语义限制;防止类型工厂参数能转 u32 却超出实际支持字号等级。
return Err(invalid("text exceeds size limits"));
}
return Ok(NativeResult::Return(Value::Object(new_typed_block(
@@ -513,7 +601,9 @@ impl NativeCallable for Function {
if matches!(self.operation, OpenValue | Present)
&& let Value::Object(value) = args[0]
{
// 对象值可能实现 Presentable;标量不可能走这一协议路径,稍后按普通文字转换处理。
let (desktop, current) = {
// master 用于共享能力类型,current 用于传给源 present 的窗口资源,二者不能混用。
let state = self
.session
.lock()
@@ -530,15 +620,20 @@ impl NativeCallable for Function {
.is::<block::Block>()
&& cx.store().supports(value, capability)?
{
// 只有非 Block 且显式实现能力的对象才执行源 present,不能凭同名属性鸭子类型推断。
// 能力实现依附于类型,需从该类型的方法表取 present,而非随意的实例属性。
let definition = cx
.store()
.type_of(value)?
.ok_or(invalid("untyped Presentable"))?;
// lookup_method 处理继承规则,缺少实现即能力契约错误。
let function = cx
.store()
.lookup_method(definition, "present")?
.ok_or(invalid("missing present method"))?;
// 源 present 接收实际目标窗口,不能总把主窗口传给副窗口中的调用。
let window = object(Some(&cx.store().get_attr(current, "window")?))?;
// 源方法返回后由 PresentResult 检查真实 Block,并按 open 标志决定是否包装 Page。
let complete = cx.allocate(ContextNativeFunction::new(
"validate presentation",
PresentResult {
@@ -548,6 +643,8 @@ impl NativeCallable for Function {
vec![desktop],
))?;
// 小驱动先调用 present(value, window),再把结果交 PresentResult 验证/可选打开,所有委托共用当前 VM 预算。
// 两个 PushNull 填 Call 的空接收者槽,避免 VM 额外插入 receiver;value/window 已作为 present 的两个显式参数加载。
// 栈先保留 complete 的调用,再嵌套两参 present,最后把其结果作 complete 的一个参数,形成验证续接。
let instructions = [
endor_core::Instruction::new(endor_core::Opcode::LoadConst, 0),
endor_core::Instruction::simple(endor_core::Opcode::PushNull),
@@ -559,6 +656,7 @@ impl NativeCallable for Function {
endor_core::Instruction::new(endor_core::Opcode::Call, 1),
endor_core::Instruction::simple(endor_core::Opcode::ReturnValue),
];
// 常量表按字节码的 0..3 索引依次提供续接、源方法、receiver 和 Window。
let code = endor_core::EndorCode::from_instructions(
&instructions,
vec![
@@ -570,8 +668,11 @@ impl NativeCallable for Function {
endor_core::CodeLayout::default(),
)?
.with_location("Presentable.present", "<tessera>", 1);
// 模板包装成托管代码后,驱动函数的强边负责维持常量/代码存活。
let code = cx.allocate(endor_core::EndorCodeObject::new(code))?;
// 驱动只访问常量,不需要用户命名空间,私有空字典避免污染全局。
let globals = cx.new_dict()?;
// 返回 Invoke 把驱动接入同一 VM 帧循环,不在 NativeContext 内递归进入 VM。
let driver = cx.new_function("Presentable.present", code, globals)?;
attach_desktop_type(cx, driver, "Function", desktop)?;
return Ok(NativeResult::Invoke {
@@ -582,10 +683,13 @@ impl NativeCallable for Function {
}
// ui.open 把普通结果转换为根组件,再在必要时包一层 Page 并展示;ui.present 仅转换,不自动打开页面。
if matches!(self.operation, OpenValue) {
// 协议对象已在上面返回 Invoke;这里仅处理已有 Block 或普通值的文字回退。
let root = present_value(cx, &args[0], &self.session)?;
// Page 可直接保留,普通 Block 外包一层新 Page,以便加入窗口的页面注册表。
let page = if cx.store().payload::<block::Block>(root, "Block")?.page {
root
} else {
// 包装持有原 root,不复制业务对象,因此源对象/状态仍可继续被其他位置展示。
new_block(
cx,
Data::new(Kind::Page, vec![root], 0.0, 0.0),
@@ -596,6 +700,7 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Object(page)));
}
if matches!(self.operation, Present) {
// 只做值到视觉的转换,不调用 show,不改变活动页或窗口归属。
return Ok(NativeResult::Return(Value::Object(present_value(
cx,
&args[0],
@@ -605,7 +710,9 @@ impl NativeCallable for Function {
// 从这里起都是需要接收者的 Block/Page 方法,未绑定 owner 时不能继续修改任意全局对象。
let owner = self.owner.ok_or(invalid("method has no owner"))?;
if matches!(self.operation, Bind) {
// 属性名与计算函数分开解码;connect 建立依赖和可取消 Subscription,不立即变成静态值。
let field = text(args.first())?;
// 函数对象的签名/效果由绑定模块检查,不能只因它是 Object 就当作合法计算闭包。
let compute = object(args.get(1))?;
return Ok(NativeResult::Return(Value::Object(
crate::binding::connect(
@@ -622,17 +729,22 @@ impl NativeCallable for Function {
}
// moveTab 重排同窗口页面索引并强制下一帧更新标签;移动的是原 Page,非新页面副本。
if matches!(self.operation, MoveTab) {
// 页面位置是当前顺序下标,不是 Tab ID 或子组成 slot,先做平台 usize 表示检查。
let position = usize::try_from(integer(args.first())?)
.map_err(|_| invalid("tab position out of range"))?;
// 同锁内找所属窗口并重排,不能只用 current:源代码可持有其他窗口的 Page。
let mut state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 未登记的 Page 没有可重排标签,不能自动打开它再进行移动。
let id = page_window(cx.store(), &state, owner)?.ok_or(invalid("Page is not open"))?;
// 对真实所属窗口设置强制帧,确保即使内容根没变,标签顺序仍会发布。
let view = state
.presentations
.get_mut(&id)
.ok_or(invalid("missing window"))?;
// model/window 维护实际顺序,Presentation 只负责其派生投影和刷新标记。
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
crate::window::move_page(cx, window, owner, position)?;
view.force_frame = true;
@@ -640,13 +752,18 @@ impl NativeCallable for Function {
}
// 接收者可能是共享转发别名,解析到最终 Data 后修改副本,最后用一次 replace_payload 写入结构边。
let (data_ref, data) = block::resolve(cx.store(), owner)?;
// data_ref 是最终 Data,不是 owner 包装或它的 Cell;新子组成用此身份防回边,末尾也替换这一对象的 payload。
// 在副本上编辑结构,再经末尾 replace_payload 更新强边与版本,避免直接篡改借用 payload。
let mut data = data.clone();
// 默认返回 Unit/Nil,append/insert/订阅等分支会改成自己的业务结果;查询分支直接返回。
let mut result = Value::Nil;
match self.operation {
SetText => match &mut data.kind {
Kind::Text { value, .. } => {
// 新文字必须为真实字符串且受 UTF-8 字节预算约束,再替换 payload/普通属性。
let next = text(args.first())?;
if next.len() > 16_384 {
// 检查在写属性前,错误输入不会先清掉旧 text_binding 或部分改文字。
return Err(invalid("text exceeds 16 KiB"));
}
*value = next;
@@ -654,33 +771,43 @@ impl NativeCallable for Function {
data.text_binding = None;
cx.set_attr(owner, "text", args[0].clone())?;
}
// receiver 的名义类型校验之外再验证 payload,避免对不兼容原生种类写文本。
_ => return Err(invalid("setText requires a text Block")),
},
SetColor => match &mut data.kind {
Kind::Text { color: value, .. } | Kind::Rect { color: value, .. } => {
// 颜色格式先校验,随后把普通 color 属性与本地 payload 副本保持一致。
*value = color(args.first(), "#242422")?;
cx.set_attr(owner, "color", Value::Text(value.clone()))?;
}
// 容器/空白不支持这一颜色修改接口,不能往无关 payload 中补出虚构颜色字段。
_ => return Err(invalid("this Block has no color")),
},
Append | Insert | Remove | Replace | Move => {
if !data.is_container() {
// 只允许有组成语义的容器;叶 Block 不因调用 append 而隐式变成 Column。
return Err(invalid("expected a container Block"));
}
if matches!(self.operation, Append | Insert) {
// 新增边需要新 slot;append 取末尾,insert 的首参是可插入缝位置。
let (position, argument) = if matches!(self.operation, Insert) {
// 平台索引转换和容器长度检查分开,len 本身允许表示“插到末尾”。
let position = usize::try_from(integer(args.first())?)
.map_err(|_| invalid("child position out of range"))?;
// 插入位置是孩子之间的缝,允许等于 len 表示末尾;大于 len 则在分配 slot 和修改子序列之前拒绝。
if position > data.children.len() {
return Err(invalid("child position out of range"));
}
(position, args.get(1))
} else {
// append 不接位置,唯一实参就是新孩子;不会误把它解析成索引。
(data.children.len(), args.first())
};
// 统一孩子对象解码;validate 再检查真实 Block 和无环组成。
let child = object(argument)?;
// 插入前禁止孩子图回到当前 Data,避免循环组成;position 是插入位置,返回值却是稳定 slot。
block::validate(cx.store(), child, Some(data_ref))?;
// 保存当前高水位作为返回身份,再推进 next_slot,删除旧边也不回退该计数。
let slot = data.next_slot;
// slot 单调增加且限制到 i64 正范围,不复用已删除身份;前后插入/重排不会把其他孩子 slot 改号。
data.next_slot = slot
@@ -692,6 +819,7 @@ impl NativeCallable for Function {
} else {
// remove/replace/move 首参为稳定 slot 而非当前数组下标;只有 move 的第二参才是目标位置。
let slot = integer(args.first())?;
// 在当前顺序中查该稳定身份的位置,不能直接把 slot 转 usize 当数组下标。
let index = data
.children
.iter()
@@ -699,31 +827,39 @@ impl NativeCallable for Function {
.ok_or(invalid("child slot does not exist"))?;
match self.operation {
Remove => {
// 删除这一稳定边,不重新编号其余孩子;是否销毁 Block 由托管引用关系决定。
data.children.remove(index);
}
Replace => {
// 新孩子先过无环/类型校验,再替换旧边对象;边 slot 保持不变。
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 => {
// 第二参是目标顺序下标,首参仍是稳定 slot;目标必须是已有序列内位置。
let to = usize::try_from(integer(args.get(1))?)
.map_err(|_| invalid("child position out of range"))?;
if to >= data.children.len() {
// move 不新增孩子,因此 len 是越界,不同于 insert 允许插到末尾的 len。
return Err(invalid("child position out of range"));
}
// 移动完整 Child 值,slot 与对象身份都保持,不分配新的出现组成键。
let child = data.children.remove(index);
data.children.insert(to, child);
}
// 外层已排除 append/insert,此处只可能是 remove/replace/move。
_ => unreachable!(),
}
}
}
// children 查询返回 slot文本->Block 的托管字典,不把当前顺序假装成稳定 slot 连号数组。
Children => {
// 查询创建新字典但不改原容器;key 是 slot 文本,迭代顺序不作为下标身份保证。
let result = cx.new_dict()?;
for child in data.children {
// VM 字典赋值记录真实 Block 强边,调用结果可安全独立持有孩子而非引用 Rust 临时切片。
cx.dict_set(
result,
Value::Text(child.slot.to_string()),
@@ -734,17 +870,21 @@ impl NativeCallable for Function {
}
Render | Navigate => {
if !matches!(data.kind, Kind::Page) {
// 只有 Page 有根替换与导航历史,不能对普通 ContainerBlock 偷用页面语义。
return Err(invalid("expected a Page"));
}
// 新内容须真实 Block 且不能包含当前 Page 的共享 Data,防止出现循环组成。
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() {
// 首次导航才把现有根作为历史起点;空 Page 没有可保留的起始根。
data.history.push(old.block);
}
} else {
// 从后退位置发起新导航会丢弃原前进分支,不能保留两条互相矛盾的线性历史。
data.history.truncate(data.history_index + 1);
}
data.history.push(root);
@@ -760,12 +900,16 @@ impl NativeCallable for Function {
}
Back | Forward => {
if data.history.is_empty() {
// 无历史是正常无动作,不把索引减法做在空列表上,也不制造一次空展示请求。
return Ok(NativeResult::Return(Value::Nil));
}
// 后退/前进钳到历史首尾,复用已保留根对象而非重新执行旧源码。
// 这里不增删历史;导航根通过 Data.trace 保活,show 仅安排后续刷新,不立即切换 active 或产生 Mount。
let next = if matches!(self.operation, Back) {
// saturating_sub 在首项停住,不绕回列表尾部。
data.history_index.saturating_sub(1)
} else {
// 前进上界为最后已有项,不为不存在的未来页分配对象。
(data.history_index + 1).min(data.history.len() - 1)
};
data.history_index = next;
@@ -777,11 +921,14 @@ impl NativeCallable for Function {
}
// 分离先创建新 Window,再把同一 Page 展示到它;跨窗口出现上下文由后续展示同步重建。
Detach => {
// 短锁中只创建资源,锁释放后再 show_in,避免内部二次锁定同一个 Session。
let window = {
// 新窗口编号和 Presentation 同步建立,使用主注册域的 Window 默认类型。
let mut state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// detach 创建默认 Window,不继承 Page 的源类型作为窗口类型。
let definition = desktop_type(cx.store(), "Window", state.master)?;
create_window(cx, &mut state, definition)?
};
@@ -789,24 +936,29 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Object(window)));
}
PageWindow => {
// 这是查询:按真实注册表找 owner 的归属,不能直接返回当前窗口冒充其宿主。
let state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 未打开/已关闭 Page 正常返回 nil,签名为 nullable(Window)。
let value = if let Some(id) = page_window(cx.store(), &state, owner)? {
cx.store()
.get_attr(state.presentations[&id].desktop, "window")?
} else {
// 仍存活的 Page 可以暂时没有窗口归属,不因此新建窗口或报错。
Value::Nil
};
return Ok(NativeResult::Return(value));
}
Open | Activate | MoveWindow => {
// moveToWindow 必须明确目标;open/activate 的缺省则保留原归属或使用当前窗口。
if matches!(self.operation, MoveWindow)
&& matches!(args.first(), None | Some(Value::Nil))
{
return Err(invalid("Page.moveToWindow requires a Window"));
}
// None 与显式 nil 在 open 路径等价;非空实参先解码对象,show_in 再检查 Window payload/域。
let target = match args.first() {
None | Some(Value::Nil) => None,
Some(value) => Some(object(Some(value))?),
@@ -816,20 +968,23 @@ impl NativeCallable for Function {
}
// unmount 清内容但不关闭 Page 标签,也不直接等同于销毁整个业务对象。
Unmount => {
// 内容强边在统一 replace_payload 时撤销,Page 自己仍在标签注册表中可再次 render。
data.children.clear();
}
Close => {
// 关闭 Page 当场取消页面和所属 Mount 任务,即使同一输入稍后又打开它,也不能复活旧异步工作。
crate::tasks::cancel_owner(cx, owner)?;
// 找出现位置并撤登记的过程只操作宿主表/任务,不在锁内执行用户生命周期钩子。
let mut state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
if let Some(id) = page_window(cx.store(), &state, owner)? {
// Closing the tab ends its presentation even if source
// reopens it before the next layout can detach the Mounts.
// 关闭即终止旧出现的任务归属,不等待下一次布局实际卸载才取消。
// 弱 Page 身份用于精确筛选这个页面的 Mount,不能取消整个窗口其他页面任务。
let page = cx.store().downgrade(owner)?;
for weak in state.presentations[&id].mounts.ordered() {
// 升级成功且 Mount.page 匹配才取消;不会把共享 Block 的其他出现一并取消。
if let Some(mount) = cx.upgrade(weak)?
&& cx
.store()
@@ -837,9 +992,11 @@ impl NativeCallable for Function {
.page
== Some(page)
{
// 取消 owner/key 调度资格,旧 I/O 即使稍后完成也不能再向此出现交付。
crate::tasks::cancel_owner(cx, mount)?;
}
}
// 撤注册后为原窗口选择替代页或安排清空,生命周期卸载仍交后续刷新处理。
remove_page(
cx,
state
@@ -852,16 +1009,19 @@ impl NativeCallable for Function {
return Ok(NativeResult::Return(Value::Nil));
}
OnClick => {
// 简便 onClick 保存单个处理器,validate_handler 检查真实 callable 契约而非仅有对象身份。
let handler = object(args.first())?;
crate::event_dispatch::validate_handler(cx.store(), handler)?;
data.click = Some(handler);
result = Value::Object(owner);
}
On => {
// 字符串事件名用于传播匹配,按 UTF-8 字节限制长度,不允许空事件名。
let kind = text(args.first())?;
if kind.is_empty() || kind.len() > 128 {
return Err(invalid("invalid event kind"));
}
// 先校验回调再建立 Connection,避免非法处理器留下不可取消的半条订阅。
let handler = object(args.get(1))?;
crate::event_dispatch::validate_handler(cx.store(), handler)?;
// 订阅默认非捕获;可用 Bool 或 capture/bubble 文本明确阶段,其他值拒绝而不做真值转换。
@@ -875,23 +1035,28 @@ impl NativeCallable for Function {
// 清掉已断开连接后再计算容量,保留仍有效订阅顺序;新 Connection 拥有 handler,返回 token 仅弱引用连接。
let mut active = Vec::new();
for token in &data.subscriptions {
// data.subscriptions 实际保存 Connection 身份,已断开者 handler=None 不再占容量。
if cx
.store()
.payload::<crate::event_dispatch::Connection>(*token, "Connection")?
.handler
.is_some()
{
// 按原序保留仍有效连接,事件路由中的监听顺序因此不因清理而改变。
active.push(*token);
}
}
if active.len() >= MAX_UI_NODES {
// 清理后仍满才拒绝,避免死亡连接永久占满一个 Block 的订阅预算。
return Err(invalid("too many event subscriptions"));
}
// Connection 强持 handler,由 Block 的 subscriptions 拥有;任务和事件生命周期可据此断开。
let connection = cx.allocate(crate::event_dispatch::Connection {
kind,
handler: Some(handler),
capture,
})?;
// 返回的 Subscription 仅弱指连接,不应因用户保留令牌而让整个已卸载组件和回调常驻。
let token = cx.allocate(crate::event_dispatch::Subscription {
connection: cx.store().downgrade(connection)?,
})?;
@@ -903,6 +1068,7 @@ impl NativeCallable for Function {
// 基类事件/挂载回调默认空实现,子类可覆盖;不在这条默认路径生成额外状态。
OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)),
Click | Dispatch => {
// 事件/订阅类型来自当前展示域,传播上下文仍由明确 owner/可选 Mount 指定。
let desktop = self
.session
.lock()
@@ -910,20 +1076,26 @@ impl NativeCallable for Function {
.desktop;
// click 新建种类为 click 的 Event,dispatch 使用调用者提供事件;两者都将可选具体 Mount 交传播模块验证。
let (event, mount) = if matches!(self.operation, Click) {
// click 总创建一份新 Event,调用方只可补具体 Mount;不会把旧事件对象重复分发。
let definition = desktop_type(cx.store(), "Event", desktop)?;
(
crate::event_dispatch::new_event(cx, definition, "click")?,
args.first(),
)
} else {
// dispatch 接现成事件,第二实参才是可选出现;传播模块继续验证两者语义。
(object(args.first())?, args.get(1))
};
// None 保留“未指定出现”含义,显式非法值传播错误;不从任意当前 Mount 猜目标。
let mount = mount.map(|value| object(Some(value))).transpose()?;
// 返回 Invoke/Return 由事件模块决定,源捕获/目标/冒泡回调仍走同一 VM 帧循环。
return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop);
}
// 其余操作应在前面的专用分支处理;漏分派必须显式报错,不能悄悄返回成功 Unit。
_ => return Err(invalid("unsupported Block operation")),
}
// 非提前返回的结构编辑/订阅操作在末尾统一经过写屏障,触发拥有边更新和变更日志。
// 只覆盖解析到的 Data;原 Block/Cell 身份保持,所有转发到同一 Data 的别名因此观察到本次结构修改。
cx.replace_payload(data_ref, data)?;
Ok(NativeResult::Return(result))
}
+28 -7
View File
@@ -1,30 +1,38 @@
//! 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。
//!
//! Developer workspaces are managed Pages, using the same roots and placement API.
//! bootstrap::install 调用 install 和 install_start_page;前者注册 ui.developerTools,后者注册 ui.newTab。
//! GUI 发出的 developer-open:* 输入由 UiBridge::dispatch 转成一次 VM 调用,再进入 OpenTool::invoke。
//! 这里管理 Page 的创建与归属;Console/Network 等工具的原生内容由 shell/developer_tools 按页面标记绘制。
use super::*;
#[derive(Debug)]
/// 工具打开能力,保存所属会话和 ui 对象;调用时校验工具名,再在该 VM 中展示页面。
struct OpenTool {
// 查找现有页面和安排 show 时使用同一展示域;不另建开发工具专属 VM。
session: Shared,
// 保存 ui.newTab 工厂入口;ContextNativeFunction 的 captures 同时声明这个托管强边。
ui: ObjectRef,
}
impl NativeCallable for OpenTool {
/// 仅接受一个工具名称字符串,工具类型与白名单在 invoke 检查。
/// VM 在进入 invoke 前检查一个显式参数;这里只有数量检查,字符串类型与白名单由 invoke 检查。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 把白名单工具名转换成对应托管 Page;New tab 转交预编译工厂,其余管理页复用已有实例或创建新页。
/// VM 执行 ui.developerTools 时调用:New tab 委托源工厂,其余工具优先激活已有页面,否则创建标记页。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 同时验证数量和字符串表示;不能把任意对象的可读表示当作工具名字接受。
let [Value::Text(name)] = args else {
// 错误发生在任何页面分配/登记之前,因此无须撤销一次半完成的工具打开。
return Err(invalid("developerTools requires a tool name"));
};
// 工具名白名单包含四个管理页和特殊新标签动作,拒绝任意字符串进入工厂/工具标记路径。
if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) {
// 白名单错误交回 VM,不把名字继续写入 _tesseraTool 让 GUI 猜测工具类型。
return Err(invalid("unknown developer tool"));
}
// 新标签交给预编译 ui.newTab 托管工厂,经 NativeResult::Invoke 回到同一 VM 执行。
if name == "New tab" {
// 只解析函数身份,Invoke 让 VM 自己推进源工厂,保留调用栈/预算与托管返回值语义。
let factory = object(Some(&cx.store().get_attr(self.ui, "newTab")?))?;
return Ok(NativeResult::Invoke {
function: factory,
@@ -32,6 +40,7 @@ impl NativeCallable for OpenTool {
});
}
// 锁内只复制弱节点索引,之后逐个升级并查工具标记,不持 session 锁运行 show 的流程。
// 搜索范围是会话全部节点而非 current 的标签;已关闭但仍被源引用的工具页也可复用,死亡弱项则跳过。
let nodes = self
.session
.lock()
@@ -39,16 +48,19 @@ impl NativeCallable for OpenTool {
.nodes
.clone();
for weak in nodes {
// NativeContext::upgrade 的临时引用由原生调用域管理;这里只保留实际选中的工具页作为返回值。
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 = {
// show 可能保留旧归属;重新查所属窗口,Focus 应发给真实窗口而不是当前主窗口。
let state = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// show 成功后仍无登记表示桥接关系异常,不能随意选择其他窗口聚焦。
let id = page_window(cx.store(), &state, page)?
.ok_or(invalid("tool is not open"))?;
object(Some(
@@ -56,25 +68,29 @@ impl NativeCallable for OpenTool {
.get_attr(state.presentations[&id].desktop, "window")?,
))?
};
// 只登记原生命令,GUI 在发布后再执行系统聚焦;VM worker 不直接操作 OS 窗口。
crate::window::enqueue(cx, window, crate::window::Action::Focus)?;
return Ok(NativeResult::Return(Value::Object(page)));
}
}
// 没有现存工具页才创建空托管 Page,_tesseraTool 标记让宿主在该页面位置绘制对应管理工具。
// 工具原生内容不需要伪造 Block 子树;空 Page 仍沿正常窗口注册、标签投影及生命周期刷新路径展示。
let page = new_typed_block(
cx,
Data::new(Kind::Page, vec![], 0.0, 0.0),
&self.session,
None,
)?;
// title 是用户可见标签,_tesseraTool 是宿主识别内容的私有标记,不能只设置其中一个。
cx.set_attr(page, "title", Value::Text(name.clone()))?;
cx.set_attr(page, "_tesseraTool", Value::Text(name.clone()))?;
show(cx, page, &self.session)?;
Ok(NativeResult::Return(Value::Object(page)))
}
}
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
/// bootstrap::install 在任务/类型能力就绪后调用,注册开发工具 callable;此时不打开任何页面。
pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()> {
// payload 共享 Session,captures 显式持有 ui;仅存 Rust ObjectRef 不会形成 VM 可追踪强边。
let function = vm.allocate(ContextNativeFunction::new(
"developerTools",
OpenTool {
@@ -89,16 +105,18 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
Ok(())
}
/// Compile the bundled Endor page once per VM, in a private module namespace.
/// Its functions/classes and callbacks remain ordinary managed VM objects.
/// 编译并安装 VM 私有的起始页工厂,供新页面动作重复调用。
/// bootstrap 在 UiBridge 组装完成后调用,编译内置起始页模块并把 newTab 工厂挂到 ui。
/// 每个 VM 仅安装一次,后续新标签操作重复调用工厂;源类/闭包仍由正常 VM 对象系统管理。
pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
// 源码随 binary 嵌入,每个 VM 安装时解析/编译一次;函数工厂之后重复执行不再重复编译模块。
// 模板之后必须 load 到传入的所属 VM;不同会话的类、闭包和私有 globals 不能复用另一 VM 的托管对象身份。
let source = frontend::EndorReader::from_text(
"tessera:start-page.endor",
include_str!("start_page.endor"),
);
// 按完整 Module 解析,保证类型与工厂定义属于一次安装环境,而不是用户 REPL 的多个输入片段。
let ast = frontend::parse_source_ast(&source, frontend::ParseMode::Module)?;
// 这里只生成代码模板,后面的 load 才把模板变成当前 VM 的托管代码对象。
let compiled = compiler::compile_ast(
&ast,
&source,
@@ -111,9 +129,12 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
},
)
.map_err(|error| source.annotate(error))?;
// 私有全局字典防止内置页面辅助类污染用户命名;导出闭包可通过托管边继续持有其环境。
let globals = vm.new_dict()?;
// 暂存安装/执行错误,闭包返回之后仍统一释放 globals 的局部拥有根。
let result = (|| -> Result<()> {
bridge.install_globals(vm, globals)?;
// 加载所得代码有独立根,execute 成功/失败后都需 vm.release(code)。
let code = compiled.load(vm)?;
// 执行模块定义类和工厂,使用单次预算;这里不主动调用 newTab,不在安装时打开页面。
let value = vm.execute_with_limits(
+50 -4
View File
@@ -1,11 +1,14 @@
//! 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。
//! 输入携带具体出现的 Mount;嵌套世界 UI 先做逆投影,避免把屏幕位移直接当成场景坐标。
//!
//! Camera mutations run on the owning VM worker, just like other page input.
//! UiBridge::dispatch 按输入 kind 分流到下面三个方法:整页、局部二维视口和局部三维场景分别处理。
//! 它们只在所属 VM worker 上修改托管相机属性或调用 controller.update,不直接改 GUI 已发布帧。
//! 姿态变化随后由正常依赖/快照刷新观察;任何新帧仍需完成布局、桥接挂载提交、绘制和稳定发布。
//! 这三个入口由 dispatch 在信封校验与选窗检查之后调用;内部仍分别验证发布版本和该手势所需的目标/坐标字段。
use super::*;
impl UiBridge {
/// 解码指定 3D 视口的旋转/平移/缩放输入,再调用其托管相机控制器。
/// dispatch 的 scene-pan/orbit/zoom 分支调用,把命中出现位置的输入包装成 CameraGesture 交给控制器。
pub(super) fn dispatch_scene_view(
&self,
vm: &mut Vm,
@@ -13,6 +16,7 @@ impl UiBridge {
) -> Result<()> {
// 锁内只读取当前发布版本与弱 Mount 身份,离开锁后才进入 VM 操作,避免持 session 锁调用用户代码。
let (weak, desktop) = {
// 当前窗口已由外层选定;本锁内同时读版本和 Mount,避免把旧几何与新弱身份混用。
let state = self
.session
.lock()
@@ -30,9 +34,11 @@ impl UiBridge {
state.desktop,
)
};
// upgrade 返回临时拥有根,下方即使任一步失败也通过闭包外的 release 清理。
let mount = vm
.upgrade(weak)?
.ok_or_else(|| anyhow::anyhow!("3D viewport Mount expired"))?;
// 不能直接 ? 返回整个方法,否则错误时跳过 Mount 根释放;用闭包暂存处理结果。
let result = (|| {
// 除 session 发布版本外,再检查目标自身仍挂接、版本匹配且确为 3D 视口,拒绝旧出现操作。
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone();
@@ -42,8 +48,11 @@ impl UiBridge {
&& occurrence.geometry.scene3d_viewport,
"obsolete 3D viewport"
);
// resolve 跟随 Block 的共享替换关系,再读取实际视觉状态,不能只信任旧 payload。
let (_, data) = block::resolve(vm.object_store(), occurrence.block)?;
// visual_data 合并当前可见属性,后面的相机/控制器来自模型而非原生画面的旧快照。
let data = block::visual_data(vm.object_store(), data)?;
// 没有显式相机就无法解释手势,拒绝更新而不是猜一个默认相机改变场景语义。
let camera = data
.camera
.ok_or_else(|| anyhow::anyhow!("missing 3D camera"))?;
@@ -51,18 +60,26 @@ impl UiBridge {
let controller = data
.controller
.ok_or_else(|| anyhow::anyhow!("3D controller disabled"))?;
// 同一视觉对象的边框/内边距由快照解析,手势锚点与可用尺寸都应扣除这部分。
let node = block::snapshot(vm.object_store(), occurrence.block)?;
// 此处的一次性快照只用于读取盒模型,不替换 Presentation 的视觉缓存,也不提交新的布局或画面。
// inset 将外框换算成视口内容盒,避免控制器把边框尺寸算成场景宽高。
let inset = crate::layout::insets(&node);
// 几何来自具体 Mount,两个窗口中共享同一场景仍可能有不同视图投影。
let geometry = &occurrence.geometry;
// 外层二维视图逆变换用于锚点定位;没有视图变换时使用恒等变换。
let outer = geometry.view.unwrap_or_default();
// delta 的第一分量还会用作缩放倍率;缺失说明输入信封不满足相机事件要求。
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("3D camera delta required"))?;
// 缩放使用倍率而非二维位移;旋转/平移通过 logical_delta 处理嵌套二维/面板投影。
// 不可逆映射时忽略本次连续增量,不在无效坐标上继续更新相机。
let local_delta = if input.kind == "scene-zoom" {
// 缩放不产生平移 dx/dy,只把原第一分量写到 gesture.factor。
[0., 0.]
} else {
// 平移/旋转先撤销外层投影;映射失败表示本帧增量无法可靠应用。
let Some(delta) = geometry.logical_delta(input.point, delta) else {
return Ok(());
};
@@ -79,6 +96,7 @@ impl UiBridge {
));
// 将输入封装为名义 CameraGesture 临时对象;不把原生 Input 或窗口指针直接交给源控制器。
let gesture = vm.new_object()?;
// 临时手势也有独立拥有根;把附类型/写字段/控制器错误暂存,外层始终 release(gesture)。
let result = (|| {
vm.attach_type(
gesture,
@@ -95,10 +113,12 @@ impl UiBridge {
("pointX", point.x - geometry.x - inset.left),
("pointY", point.y - geometry.y - inset.top),
] {
// 全部数值通过统一语言数值编码写属性,控制器读取的是普通托管字段。
vm.set_attr(gesture, name, crate::native_visual::value(value))?;
}
// 绑定 controller.update 保留接收者语义,再带 camera/gesture 两个显式参数进行有界调用。
let function = vm.bind_attr(controller, "update")?;
// callable 校验在 VM 执行边界继续完成,非对象方法值直接作为契约错误返回。
let result = match function {
Value::Object(function) => vm.call_with_limits(
function,
@@ -124,7 +144,7 @@ impl UiBridge {
result
}
/// 把具体 CanvasViewport 的位移或缩放锚点映射到其显式 Camera2D。
/// dispatch 的 viewport-pan/zoom 分支调用,逆投影具体 CanvasViewport 输入并改写其 Camera2D 属性。
pub(super) fn dispatch_viewport_view(
&self,
vm: &mut Vm,
@@ -132,6 +152,7 @@ impl UiBridge {
) -> Result<()> {
// 二维视口同样先校验发布版本,再从活动表捕获原 Mount,不能因目标消失自动换成另一个视口。
let weak = {
// 锁内只检查发布版本并复制弱身份;下面读取相机和写 VM 属性时不持有会话锁。
let state = self
.session
.lock()
@@ -146,10 +167,13 @@ impl UiBridge {
.get(&input.mount)
.ok_or_else(|| anyhow::anyhow!("viewport is no longer active"))?
};
// 临时根保证输入处理期间 Mount 仍可读取,方法尾部无论成功失败都释放。
let mount = vm
.upgrade(weak)?
.ok_or_else(|| anyhow::anyhow!("viewport Mount expired"))?;
// 闭包集中实际处理,使所有 ? 都先回到统一释放路径。
let result = (|| {
// 弱身份能升级不代表仍挂载/版本有效,先验证具体出现上下文。
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?;
anyhow::ensure!(
occurrence.mounted
@@ -157,32 +181,45 @@ impl UiBridge {
&& occurrence.geometry.canvas_viewport,
"obsolete viewport occurrence"
);
// 复制几何和 Block 身份,结束 occurrence 对 VM 的借用后才能改写相机。
let geometry = occurrence.geometry.clone();
// Block 只用于找相机/边距,输入定位仍来自 Mount 的几何。
let block = occurrence.block;
// 共享替换后的实际视觉状态才包含当前相机引用。
let (_, data) = block::resolve(vm.object_store(), block)?;
// 托管属性覆盖 payload 缺省值,保持与布局/绘制同一视觉读取约定。
let data = block::visual_data(vm.object_store(), data)?;
// 局部视口必须有 Camera2D,不把整页 Surface 当作相机替代。
let camera = data
.camera
.ok_or_else(|| anyhow::anyhow!("viewport has no camera"))?;
// 读取已校验的相机快照作为本次基准;共享相机用户会通过普通属性变更观察到相同更新。
let old = crate::viewport::camera(vm.object_store(), camera)?;
// 该分量在 zoom 时表示倍率,在 pan 时表示外部逻辑位移,随后分支分别解释。
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("viewport input has no delta"))?;
// 基于旧快照计算三个新值,完成验证后才依次回写托管属性。
// x/y 是世界偏移而非屏幕位移;几何先撤外层投影,除以本相机 zoom 后才属于可累加到 x/y 的单位。
let (mut x, mut y, mut zoom) = (old.x, old.y, old.zoom);
if input.kind == "viewport-zoom" {
// 缩放必须有锚点,不能让缺失指针导致隐式绕视口原点缩放。
let pointer = input
.point
.ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?;
// 先用屏幕/视图坐标做外框检查,再逆投影到布局逻辑坐标。
let pointer = crate::geometry::Point::new(pointer[0], pointer[1]);
anyhow::ensure!(
geometry.contains(pointer),
"zoom anchor is outside viewport"
);
// 外框命中不保证投影可逆;None 作为错误返回,不能继续使用原屏幕数值。
let pointer = geometry
.logical_point(pointer)
.ok_or_else(|| anyhow::anyhow!("zoom anchor cannot be projected"))?;
// 当前 Block 快照给出布局边距,用它取得正确内容盒的缩放锚点。
let node = block::snapshot(vm.object_store(), block)?;
// inset 与布局模块共用同一规则,避免相机手势与绘制对内容原点理解不同。
let inset = crate::layout::insets(&node);
// 已验证指针落在出现外框还不够;扣除 inset 后必须落入实际内容盒,禁止用边框作为缩放锚点。
let local = [
@@ -201,6 +238,7 @@ impl UiBridge {
x += local[0] / old.zoom - local[0] / zoom;
y += local[1] / old.zoom - local[1] / zoom;
} else {
// 平移没有锚点保持公式,只需逆投影增量再除相机自身缩放。
let Some(delta) = geometry.logical_delta(input.point, delta) else {
return Ok(());
};
@@ -216,11 +254,12 @@ impl UiBridge {
vm.set_attr(camera, "zoom", crate::native_visual::value(zoom))?;
Ok(())
})();
// release 发生在 result? 之前,保证投影/属性写入错误不会泄漏升级得到的 Mount。
vm.release(mount)?;
result
}
/// 处理旧 Page 模式的兼容相机事件;独立视口使用各自的输入路径。
/// dispatch 的 page-pan/zoom 分支调用,兼容旧 Page 表面相机;独立 CanvasViewport 不走此方法。
pub(super) fn dispatch_page_view(
&self,
vm: &mut Vm,
@@ -228,6 +267,7 @@ impl UiBridge {
) -> Result<()> {
// 兼容 Page 相机不通过 Mount 定位,而是读取当前活动页面及其 Surface 副本;发布版本仍必须一致。
let (page, mut surface, viewport) = {
// 只复制当前已发布页的相机与真实视口,不持锁执行下面的坐标计算或 VM 属性写入。
let state = self
.session
.lock()
@@ -244,17 +284,21 @@ impl UiBridge {
state.layout.viewport,
)
};
// 外层信封校验之后仍要求本路径必需的增量字段,防止不完整调用静默改变相机。
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("page view requires delta"))?;
if input.kind == "page-zoom" {
// 旧 Page 模式只允许 Canvas 缩放,有限文档或 Window 页面保持各自尺寸语义。
anyhow::ensure!(
surface.mode == crate::page::Mode::Canvas,
"zoom requires canvas mode"
);
// 以光标为缩放锚点,先要求它存在,再检查可用视口范围。
let pointer = input
.point
.ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?;
// 转为页面几何共用的 Point,便于调用 Surface::to_world 做缩放前逆变换。
let pointer = crate::geometry::Point::new(pointer[0], pointer[1]);
anyhow::ensure!(
pointer.x >= 0.0
@@ -270,6 +314,7 @@ impl UiBridge {
world.y - pointer.y / surface.zoom,
);
} else {
// 平移按当前 zoom 换成世界距离,不直接把屏幕像素累加进世界 pan。
surface.pan.x += delta[0] / surface.zoom;
surface.pan.y += delta[1] / surface.zoom;
}
@@ -277,6 +322,7 @@ impl UiBridge {
if let Some(viewport) = viewport {
surface.clamp(viewport);
}
// 内容模式 clamp 之外再加通用坐标预算;Canvas 可自由漫游,但也不能无限增长偏移。
let limit = MAX_DIMENSION as f32;
surface.pan.x = surface.pan.x.clamp(-limit, limit);
surface.pan.y = surface.pan.y.clamp(-limit, limit);
+62 -4
View File
@@ -1,10 +1,16 @@
//! 声明宿主方法在 Endor 类型系统中的参数和返回契约。
//! 签名用于构造/调用检查,应与 calls 和各 NativeCallable 实现保持一致;这里不执行实际业务操作。
//!
//! Canonical contracts for native Block methods and constructors.
//! bootstrap 在类型安装的第二阶段调用这两个函数,把完整签名写入 TypeMethod、Function 和 NativeFactory。
//! ui_bridge::bind 也会为状态对象的绑定函数调用 operation_signature。
//! VM 随后在调用边界读取这些签名,验证数量、类型及调用效果;业务实现位于 calls/scene3d 等模块。
//! 参数注解并不决定是否必填:Operation::arity 给出实参数量,注解表则检查实际出现的每一个参数。
//! 例如 Text 的注解列出 text/size/color 三项,但 arity 为 1..3,所以省略后两项合法,显式 nil 仍不满足 Number/String。
//! ReadOnly 构造可初始化本次新分配对象;Delegated 只允许进入委托入口,后续被调用函数仍要接受各自的效果检查。
use super::*;
/// 根据宿主方法类别提供 Endor 方法的签名,供类型与调用检查使用。
/// bootstrap 安装实例方法时调用,构造包含 receiver 的 VM 方法契约。
/// receiver 是方法声明所在类型,不是某次调用的实例;实际接收者可以是该类型的合法子类。
pub(super) fn method_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -13,11 +19,14 @@ pub(super) fn method_signature(
) -> Result<CallSignature, VmError> {
use AnnotationType::{Any, Function, Nominal, Number, String as TextType, Unit};
use Operation::*;
// 闭包把宿主约定的名字解析成当前 desktop 的真实 Type;查找失败中止安装,不能降级为 Any。
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 => (
vec![nominal("Object")?, nominal("CameraGesture")?],
Unit,
@@ -29,9 +38,13 @@ pub(super) fn method_signature(
nominal("Subscription")?,
CallEffect::Mutating,
),
// 查询已有出现位置,返回托管列表;不修改对象或展示关系。
Mounts => (vec![], nominal("ObjectList")?, CallEffect::ReadOnly),
// Children 的实际值仍由容器实现解释;这里的 Any 不改变只读效果约束。
Children => (vec![], Any, CallEffect::ReadOnly),
// 可选文本用于标记失效类别;是否可省略由 arity 决定,非此处的 vec 长度决定。
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),
@@ -40,15 +53,19 @@ pub(super) fn method_signature(
Number,
CallEffect::Mutating,
),
// 数字注解只验证 Number;非负整数、稳定槽位是否存在等语义由 calls 检查。
Remove => (vec![Number], Unit, CallEffect::Mutating),
Replace => (vec![Number, nominal("Block")?], Unit, CallEffect::Mutating),
Move => (vec![Number, Number], Unit, CallEffect::Mutating),
// 页面内容替换/导航接受可视 Block,并改写待展示状态;此处不提前执行布局。
Render | Navigate => (vec![nominal("Block")?], Unit, CallEffect::Mutating),
// 链式 API 返回接收者名义类型,保留子类返回契约,而不是统一降为 Block。
// 链式 API 返回声明 receiver 的名义类型;源子类实例也满足此基类契约,不承诺精确动态子类。
OnClick => (vec![Function], Nominal(receiver), CallEffect::Mutating),
// 事件和生命周期方法可能执行任意用户回调,使用 External,不能冒充只读视觉查询。
OnEvent => (vec![nominal("Event")?], Unit, CallEffect::External),
// Mount 标识一次具体出现位置;同一 Block 的多次展示不能共用一个隐式上下文。
OnMount | OnUnmount => (vec![nominal("Mount")?], Unit, CallEffect::External),
// click/dispatch 返回实际事件,便于调用者观察传播状态;Mount 可选性由 arity 给出。
Click => (
vec![nominal("Mount")?],
nominal("Event")?,
@@ -59,28 +76,38 @@ pub(super) fn method_signature(
nominal("Event")?,
CallEffect::External,
),
// 迁移要求真实 Window 实例并返回页面,窗口是否关闭、是否属于当前运行时由实现继续检查。
MoveWindow => (
vec![nominal("Window")?],
Nominal(receiver),
CallEffect::Mutating,
),
// 标签位置是数值,Activate 无显式实参;二者都改变展示选择并允许链式使用。
MoveTab => (vec![Number], Nominal(receiver), CallEffect::Mutating),
Activate => (vec![], Nominal(receiver), CallEffect::Mutating),
// detach 新建目标窗口,返回 Window 而非当前页面;资源创建属于修改操作。
Detach => (vec![], nominal("Window")?, CallEffect::Mutating),
// 这些操作只修改生命周期或历史状态,均没有业务返回值。
Unmount | Close | CloseWindow | Back | Forward => (vec![], Unit, CallEffect::Mutating),
// 先以 Any 占位,后面用 nullable(Window) 收窄,允许省略/显式 nil 选择默认归属。
Open => (vec![Any], Nominal(receiver), CallEffect::Mutating),
// 页面尚未打开时无所属窗口,稍后把返回 Any 改为 Window | nil。
PageWindow => (vec![], Any, CallEffect::ReadOnly),
// 事件名、回调和可选捕获选项;返回 Subscription 令牌供取消,不直接返回 receiver。
On => (
vec![TextType, Function, Any],
nominal("Subscription")?,
CallEffect::Mutating,
),
// 自由工厂 Operation 不应误装成实例方法;缺少明确契约就拒绝安装,防止默认放宽检查。
_ => return Err(invalid("unsupported native method signature")),
};
// Operation 维护显式参数的必需/最大个数;方法签名整体加一,将 receiver 纳入调用约定。
let arity = operation.arity();
// parameters[0] 固定为 receiver,因此后面细化显式参数时都必须把索引加一。
let mut parameters = vec![Nominal(receiver)];
parameters.extend(arguments);
// 先把简单 AnnotationType 升格为 CallType;下面再改写少数联合/可空位置。
let mut signature = CallSignature {
arity: endor_core::CallArity::range(arity.required + 1, arity.maximum + 1),
parameters: parameters.into_iter().map(CallType::from).collect(),
@@ -91,15 +118,20 @@ pub(super) fn method_signature(
// 少数参数/返回需要联合或可空类型,先用占位 Any 建基础签名,再按下标细化。
// On 第三个显式选项位于 parameters[3],可接受 Bool 或字符串。
match operation {
// 参数允许 nil,但非空值必须是本 desktop 注册的 Window 实例。
Open => signature.parameters[1] = CallType::nullable(nominal("Window")?),
// 没有归属是正常查询结果,与错误窗口对象的类型失败不同。
PageWindow => signature.returns = CallType::nullable(nominal("Window")?),
// 兼容布尔捕获标记和命名阶段字符串;具体字符串语义在事件连接操作中检查。
On => signature.parameters[3] = CallType::Union(vec![AnnotationType::Bool, TextType]),
// 其余操作已经完整表达,不应额外增加可空或联合类型。
_ => {}
}
Ok(signature)
}
/// 将具体宿主 Operation 映射到参数/返回类型契约。
/// bootstrap 和 ui_bridge::bind 调用,描述不含 receiver 的自由函数/状态绑定函数原生参数。
/// NativeFactory 复用本结果作为 options 窄签名,再在外部包上 Type 首参和源子类字段。
pub(super) fn operation_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -107,27 +139,36 @@ pub(super) fn operation_signature(
) -> Result<CallSignature, VmError> {
use AnnotationType::{Any, Bool, Number, String as TextType, Trait, Type, Unit};
use Operation::*;
// 注册表来自所属 VM 的 desktop;同名但不同身份的外部 Type 不能满足当前名义契约。
let nominal = |name| desktop_type(store, name, desktop).map(AnnotationType::Nominal);
// 自由 UI 操作没有隐式 receiver;同时声明固定参数、可变尾参、返回名义类型和调用效果。
let (parameters, variadic, returns, effect) = match operation {
// 类型关系查询不改变类型表;IsInstance 签名首参为 Any,但 calls 当前仍要求实际值为对象。
IsInstance => (vec![Any, Type], None, Bool, CallEffect::ReadOnly),
IsSubtype => (vec![Type, Type], None, Bool, CallEffect::ReadOnly),
// 注册能力实现会修改类型关系;implements/supports 只查询已有关系。
Implement => (vec![Type, Trait], None, Unit, CallEffect::Mutating),
Implements => (vec![Type, Trait], None, Bool, CallEffect::ReadOnly),
Supports => (vec![Any, Trait], None, Bool, CallEffect::ReadOnly),
// open 可把普通值包装成视觉/Page 并安排展示,返回供继续操作的 Page。
OpenValue => (vec![Any], None, nominal("Page")?, CallEffect::Mutating),
// present 可能转交用户展示协议,效果交由被委托调用确定,不能声明成固定 ReadOnly。
Present => (vec![Any], None, nominal("Block")?, CallEffect::Delegated),
// state 可分配新的状态对象;ReadOnly 允许新对象初始化,不授权修改原有对象。
State => (vec![Any], None, Any, CallEffect::ReadOnly),
// set/increment 的 receiver 已存在 Function.owner 中,不计入本处参数表。
StateSet => (vec![Any], None, Unit, CallEffect::Mutating),
Increment => (vec![], None, Unit, CallEffect::Mutating),
// stats 读取宿主计数而非纯托管属性依赖,按 External 描述,不作为只读布局计算接口。
Stats => (vec![], None, Any, CallEffect::External),
// viewport 返回尺寸协议值;窗口尺寸尚缺失等情形仍由实际实现处理。
Viewport => (
vec![],
None,
nominal("MeasureResult")?,
CallEffect::ReadOnly,
),
// ui.window 查询现有窗口,Window 类型构造则创建新资源,二者不能共享调用效果。
Window => (vec![], None, nominal("Window")?, CallEffect::ReadOnly),
NewWindow => (vec![], None, nominal("Window")?, CallEffect::Mutating),
// 这些构造操作返回新对象,ReadOnly 允许分配并初始化新值,但不允许因此修改已有业务对象。
@@ -137,18 +178,21 @@ pub(super) fn operation_signature(
nominal("TextBlock")?,
CallEffect::ReadOnly,
),
// TextOf 前两个参数表示对象/属性名;Text 直接接收字符串,其后字号和颜色可选。
Text => (
vec![TextType, Number, TextType],
None,
nominal("TextBlock")?,
CallEffect::ReadOnly,
),
// 矩形宽高为必填,后续数值/颜色的缺省规则由 Operation::arity 与构造实现共同约束。
Rect => (
vec![Number, Number, Number, TextType],
None,
nominal("RectBlock")?,
CallEffect::ReadOnly,
),
// 空白块只需要长度;滚动块额外要求宽高和一个 Block 内容。
Spacer => (vec![Number], None, nominal("Spacer")?, CallEffect::ReadOnly),
Scroll => (
vec![Number, Number, nominal("Block")?],
@@ -160,15 +204,20 @@ pub(super) fn operation_signature(
Row | Column | Overlay => (
vec![Number, Number],
Some(nominal("Block")?),
// 共享参数规则,但返回值必须保留具体容器名义类型;matches 只选择类型名,不执行排版。
nominal(if matches!(operation, Row) {
// 主轴水平排列的容器。
"Row"
} else if matches!(operation, Overlay) {
// 多个孩子叠放在同一内容区域的容器。
"Overlay"
} else {
// 此分支入口只有 Row/Column/Overlay,前两者排除后剩 Column。
"Column"
})?,
CallEffect::ReadOnly,
),
// 按钮接收初始文本,Page 无显式参数;两者都只创建值,不自动打开窗口。
Button => (
vec![TextType],
None,
@@ -176,6 +225,7 @@ pub(super) fn operation_signature(
CallEffect::ReadOnly,
),
Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly),
// 三维视口的宽高和 Scene3D 在调用边界检查;摄像机、场景元素等由场景模块继续验证。
Viewport3D => (
vec![Number, Number, nominal("Scene3D")?],
None,
@@ -184,12 +234,15 @@ pub(super) fn operation_signature(
),
// 3D 构造器参数来自 Kind 元数据:Number/String 映成基础注解,其余名称从本 desktop 注册表查名义类型。
SceneObject(kind) => {
// 元数据与真实构造器共用一份参数定义,避免桥接手工重复参数表后与 3D 实现漂移。
let parameters = kind
.parameters()
.iter()
.map(|name| match *name {
// 基础数值/字符串是语言原生注解,不需要到宿主类型字典查询。
"Number" => Ok(Number),
"String" => Ok(TextType),
// Vec3/Geometry3D 等名称必须解析为本会话真实 Type;未知名让整个签名安装失败。
name => nominal(name),
})
.collect::<Result<Vec<_>, VmError>>()?;
@@ -197,14 +250,17 @@ pub(super) fn operation_signature(
parameters,
// 3D 集合尾参使用 Object 名义约束;各具体构造操作仍需在实现中检查允许的场景对象类别。
if kind.variadic() {
// 允许额外对象参数的场景组等构造器用 Object 作尾参上界。
Some(nominal("Object")?)
} else {
// 固定参数构造器禁止多余实参,不能统一使用 Any 尾参放开上界。
None
},
nominal(kind.name())?,
CallEffect::ReadOnly,
)
}
// 二维场景可接受零到多个 Block;场景视口固定接宽、高、Scene2D,不把孩子直接当场景。
Scene2D => (
vec![],
Some(nominal("Block")?),
@@ -217,12 +273,14 @@ pub(super) fn operation_signature(
nominal("CanvasViewport")?,
CallEffect::ReadOnly,
),
// x/y/zoom 都声明为 Number,但可缺省;缩放范围等数值约束由 new_camera2d 校验。
Camera2D => (
vec![Number, Number, Number],
None,
nominal("Camera2D")?,
CallEffect::ReadOnly,
),
// 实例方法专用 Operation 不在自由函数表内,误用在这里应立即暴露安装错误。
_ => return Err(invalid("unsupported UI function signature")),
};
// 最终 arity 直接沿用 Operation,参数注解与可变尾参分开保存;不在此处执行操作或分配业务对象。
+40 -1
View File
@@ -1,14 +1,19 @@
// The page, cards and actions belong to Endor. The host only owns its sidebar.
// 默认页由真实 Endor 对象组成:宿主负责窗口和侧栏,卡片布局、绘制和按钮回调在源代码中定义。
// developer_pages::install_start_page 在每个 VM 的私有 globals 中安装类/函数,再将工厂挂到 ui.newTab;不写用户同名全局。
// 安装模块不主动打开页面;OpenTool 的 New tab、源码 ui.newTab() 和下方 canvas 回调才实际调用工厂。
// 渲染器通过视觉协议调用 measure/paint,按钮事件通过 VM 分派到 StartActions 的绑定方法。
// Note 复用 Column 的内容测量,但固定卡片宽度;paint 只提供背景,孩子仍沿正常树绘制。
class Note: Column {
// 卡片构造的最后一个子类字段保存背景色;前面的变参仍按 Column 的孩子参数处理。
tint: String
// source_layout 调用这个覆盖方法;显式借用 Column 的测量结果,只替换卡片宽度,保留内容自然高度。
function measure(self: Note, mount: Mount, constraints: Constraints): MeasureResult {
// 沿用 Column 内容测量得到高度,但卡片宽固定为 224;不把背景圆角绘制误当布局尺寸。
// 沿用 Column 内容测量得到高度,并请求宽 224;结果仍按当前 Constraints 钳制,不保证窄父容器中最终宽度也是 224。
measured = Column.measure(self, mount, constraints)
return MeasureResult(224, measured.height)
}
// source_paint 在已完成几何上调用;这里返回绘制命令,未直接向 OS 或像素缓冲写入。
function paint(self: Note, mount: Mount, geometry: Bounds): DisplayList {
// 只生成当前卡片背景,命令使用宿主给定 Bounds 的绝对布局位置,孩子仍由各自 paint 绘制。
fill = PaintCommand()
@@ -36,25 +41,35 @@ class StartActions {
home: Page
// 窗口页面是普通有限 Column;可用区域来自宿主发布的逻辑视口。
function blank(self: StartActions, event: Event): Unit {
// “窗口页面”按钮的 click 回调创建独立 Page,标题和内容属于新页面,home 仍指向原画布。
page = Page()
// 标题供 project_tabs 投影到原生侧栏,不影响页面身份。
page.title = "窗口页面"
// 返回按钮保留原 home 的绑定方法,点击时激活已有页而非新建一个画布。
back = Button("返回画布")
// 绑定的是原 home 的 activate 方法;此回调恢复已有页身份,不重新构造主画布。
back.onClick(self.home.activate)
// render 设置内容并安排展示,Column 的 padding/gap 只影响这张页的有限布局。
page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back))
}
// 延伸方向属于容器与滚动组合:Column 安排内容,有限 ScrollBlock 控制可见范围。
function vertical(self: StartActions, event: Event): Unit {
// “纵向延伸”按钮调用;保持 Page 默认有限布局,滚动行为由后面的 ScrollBlock 承担。
page = Page()
// 标签标题和其他示例区分,Page 本身不切换到旧式无限页面模式。
page.title = "纵向页面"
// 外 Column 累加卡片高度,内 Column 组织说明文字;自然内容高度可超过可见视口。
content = Column(48, 28, Column(0, 12, TextBlock("向下,继续生长", 3), TextBlock("纵向滚动和 Column 排版可以独立组合。")))
// 每张 Note 的最后一参对应 tint 子类字段,前面的视觉值仍是 Column 的子项。
content.append(Note(24, 16, TextBlock("01 / 收集想法", 2), RectBlock(176, 130, 10, "#E2EADB"), TextBlock("从一个小小的念头开始。"), "#F0F3E9"))
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"
// flex 请求父布局分配剩余主轴空间,crossAlign 让有限视口横向填满页面。
view.flex = 1
page.crossAlign = "stretch"
// 最后提交有限视口作为 Page 内容,触发展示请求;真正的测量、绘制与原生发布由宿主刷新流程完成。
@@ -62,18 +77,25 @@ class StartActions {
}
// Row 内可继续放 Column 卡片;横向滚动不要求改变 Page 的身份或生命周期。
function horizontal(self: StartActions, event: Event): Unit {
// “横向延伸”按钮调用;横向排列和可见区域分别交给 Row 与 ScrollBlock。
page = Page()
// 仅设置标签内容,实际页面资源仍在当前 VM 的 Window 注册表中登记。
page.title = "横向页面"
// Row 沿横轴连接 Note;各 Note 保留自己的纵向 Column 测量/背景绘制覆盖。
content = Row(48, 32, Note(24, 16, TextBlock("从左,走向右", 2), RectBlock(176, 180, 10, "#DDE8F1"), TextBlock("Row 内仍然可以包含 Column。"), "#EDF3F8"))
// append 按顺序创建稳定子 slot,横向坐标由布局累加,不需要手工填写 x/y。
content.append(Note(24, 16, TextBlock("收集", 2), RectBlock(176, 210, 10, "#D7E5DA"), TextBlock("一张卡片,一个想法。"), "#EFF4EC"))
content.append(Note(24, 16, TextBlock("整理", 2), RectBlock(176, 170, 10, "#E5D9EB"), TextBlock("横向滚动,保留思考的顺序。"), "#F4EFF7"))
content.append(Note(24, 16, TextBlock("继续", 2), RectBlock(176, 230, 10, "#EFDFC7"), TextBlock("Shift + 滚轮浏览更多内容。"), "#FAF3E8"))
content.append(Note(24, 16, TextBlock("不止一屏", 2), TextBlock("右侧仍有空间。"), "#EEF1F5"))
// 这些尺寸是构造缺省值,flex/stretch 会让视口接受当前窗口的实际可用空间。
view = ScrollBlock(1024, 800, content)
// Row 自然宽度可超出视口,只有横向滚动;卡片内部仍是 Column 的纵向排版。
view.axis = "horizontal"
// 保留横向滚动内容的自然宽度,视口本身仍参加有限页面的弹性布局。
view.flex = 1
page.crossAlign = "stretch"
// 只安排展示,不在 click 回调里递归运行 measure/paint;worker 在回调结束后刷新。
page.render(view)
}
// 重新调用工厂得到独立页面与相机,已有画布继续保留自己的视角。
@@ -85,8 +107,10 @@ class StartActions {
function counter(self: StartActions, event: Event): Unit {
// 共享的是同一个托管状态对象;textOf 两次读取该对象 value,increment 更新后两个展示一起刷新。
count = ui.state(0)
// 按钮保留 increment 的绑定 owner,同一状态可同时驱动两个 textOf 派生视觉对象。
add = Button("增加计数")
add.onClick(count.increment)
// 创建承载两个观察视图的新页面,状态对象不会因函数返回立即消失:绑定/回调仍持有它。
page = Page()
page.title = "共享对象"
page.render(Column(48, 24, TextBlock("一个对象,两个视图", 2), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add))
@@ -94,14 +118,21 @@ class StartActions {
}
// 页面工厂:先建立业务对象/动作,再建立世界组成,最后用一个有限视口显示它。
function newTab(): Page {
// OpenTool 的 New tab 与 StartActions.canvas 都调用这个工厂;每次都创建不同页面身份。
page = Page()
// 后续标签投影读此字段,不使用标题作为查找或去重依据。
page.title = "自由画布"
// 先建立引用当前 Page 的动作对象,再绑定按钮;回调中的 home 指向这一轮工厂创建的页面。
actions = StartActions(page)
// 每个按钮只保存动作入口,点击前不提前创建示例 Page。
fixed = Button("窗口页面")
// 这里只构造入口按钮;下面注册 actions.vertical 后仍要等点击才创建纵向滚动页,不在注册时执行回调。
vertical = Button("纵向延伸")
// 横向示例和纵向示例各有独立 Page/ScrollBlock,互不共享滚动偏移。
horizontal = Button("横向延伸")
// 新画布动作再调用工厂,使相机导航状态与此画布隔离。
canvas = Button("新的画布")
// QuietButton 只隐藏原生按钮背景,其 click 事件和孩子文字仍沿普通 Button 协议工作。
shared = QuietButton("试试:一个对象,两个视图 ↗")
// 订阅绑定方法而非提前调用;事件触发后在原 VM 中创建/展示相应示例页。
fixed.on("click", actions.blank)
@@ -109,37 +140,45 @@ function newTab(): Page {
horizontal.on("click", actions.horizontal)
canvas.on("click", actions.canvas)
shared.on("click", actions.counter)
// hero 内部用有限行列排版,只有整个 hero 在世界场景中的摆放采用显式 x/y。
hero = Column(0, 16, TextBlock("给想法,一点空间。", 3, "#303941"), TextBlock("页面可以是一扇窗口,也可以一直延伸。", 1, "#7D858C"), Spacer(8), Row(0, 10, fixed, vertical), Row(0, 10, horizontal, canvas), shared)
// x/y 属于 Scene2D 子组件的世界位置;卡片内部依旧由普通 Row/Column 排版。
hero.x = 316
hero.y = 254
// Scene2D 只规定孩子的世界位置;加入后仍可追加普通卡片,位置不受窗口宽高限制。
world = Scene2D(hero)
// 左侧卡片由 Note 测高并绘制 tint 背景,文字和矩形作为正常孩子分别绘制。
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)
// 顶部卡片只有文本,Note 仍复用内容测量,不需要为没有矩形的情况增加特殊宿主代码。
top = Note(24, 14, TextBlock("四种页面,同一个世界"), TextBlock("窗口 · 纵向 · 横向 · 自由画布", 1, "#7D858C"), "#EFF1F7")
top.x = 466
top.y = 58
world.append(top)
// 右侧卡片展示“组件布局”和“相机视角”分开:移动相机不改写这个对象的世界坐标。
right = Note(24, 14, RectBlock(176, 138, 12, "#DDDAEA"), TextBlock("把片段放在一起", 2), TextBlock("布局属于组件,视角属于摄像机。"), "#F1EDF5")
right.x = 826
right.y = 190
world.append(right)
// 底部说明中的换行属于源文本内容,不能把它误当实际控制输入或页面模式配置。
bottom = Note(24, 14, TextBlock("不止眼前这一屏", 2), TextBlock("触控板或滚轮平移\n空格拖动 / 鼠标中键拖动\n双指捏合 / ⌘ Ctrl + 滚轮缩放"), "#F7F0E5")
bottom.x = 104
bottom.y = 524
world.append(bottom)
// 场景边缘再放一张普通 Note,展示超出初始屏幕范围的内容仍可通过相机漫游到达。
end = Note(24, 14, TextBlock("继续向外探索", 2), RectBlock(176, 100, 10, "#CEDFE7"), TextBlock("画布上的对象仍是普通 Endor 组件。"), "#EAF1F5")
end.x = 716
end.y = 614
world.append(end)
// 视口参加有限布局并占用剩余空间;移动它的 Camera2D 不改写卡片世界位置。
view = CanvasViewport(1024, 800, world)
// 有限视口申请剩余空间,页面交叉轴拉伸,不把 Scene2D 改造成窗口尺寸的有限容器。
view.flex = 1
page.crossAlign = "stretch"
page.render(view)
// render 已登记/安排展示,返回 Page 给 OpenTool/调用者;返回值不是 GUI 线程的原生窗口对象。
return page
}
+70 -5
View File
@@ -1,7 +1,9 @@
//! 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。
//! 节点保存排版、绘制和协议投影信息;浅比较用于决定是否复用已有快照。
//!
//! Owned immutable visual derivation; no managed VM handles.
//! model::snapshot/Block 快照提取构造 UiNode;render::layout/source_layout、绘制和命中流程读取它。
//! 这里不持有 ObjectRef,因此 Arc 树可作为纯数据派生结果跨线程传递,不授权另一线程访问 VM。
//! shallow_eq 由快照缓存判断能否复用旧 Arc;children/has_flex/allocates_children 供结构遍历和布局选择使用。
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq)]
@@ -9,33 +11,47 @@ use std::sync::Arc;
pub struct UiNode {
// 只有页面根带兼容 Page 相机;其余字段是普通有限盒子的布局策略。
pub surface: Option<crate::page::Surface>,
// 兼容 flex 权重,父布局据此分配剩余主轴空间;不表示节点自身已经测量出的尺寸。
pub flex: f32,
// 显式 grow/shrink 等分配策略;与 flex 数值一起参与缓存相等性和父级分配判断。
pub flex_policy: crate::layout::FlexPolicy,
// 父容器主轴空白的对齐策略,与孩子排列次序和具体尺寸计算分开。
pub main_align: crate::layout::MainAlign,
// 容器为未指定 align_self 的孩子提供的交叉轴缺省策略。
pub cross_align: crate::layout::CrossAlign,
// 孩子对父 cross_align 的单独覆盖;None 表示继承,不能解释为禁止 stretch。
pub align_self: Option<crate::layout::CrossAlign>,
// 父布局分配时需要预留的外边距,不作为孩子内容绘制的本地原点偏移。
pub margin: crate::layout::Insets,
// 两轴的自然/固定/填充尺寸规则,由盒模型参与测量和最终安排。
pub sizing: crate::box_model::Sizing,
// 单边可选覆盖值;None 表示沿用 kind 的统一 padding,不代表边距为零。
pub padding_edges: [Option<f32>; 4],
// 四边边框厚度;layout::insets 将其与有效 padding 相加,内容盒、相机锚点及绘制共用该内缩量。
pub border: crate::layout::Insets,
// 留原始颜色字符串供显示列表解码;颜色变更也必须使 shallow_eq 为 false。
pub border_color: String,
// 是否把后代限制在内容可视区域内,绘制/命中需要采用同一剪裁关系。
pub clip_children: bool,
// 这些字符串只是派生身份/协议版本键,不是可以操作托管堆的 ObjectRef。
pub block_id: String,
// 源视觉协议相关的派生版本键;即使盒模型未变,协议变化仍可能要求重新测量或绘制。
pub protocol_key: String,
// slot 保持组成身份;数组次序确定当前绘制/布局顺序,二者不是同一概念。
pub slots: Vec<u64>,
// 从按钮、点击连接或自定义事件等能力推导的交互资格,供命中宽阶段使用,不代表最终 hitTest 必然成功。
pub interactive: bool,
// 标识原始 pointer 事件能力,与高层 click 的可交互标记分别参与输入分派。
pub pointer_events: bool,
// 具体叶/容器/场景内容,原生测量与软件绘制按此枚举选择规则。
pub kind: UiKind,
}
impl UiNode {
/// 按节点种类返回其视觉子项;叶节点使用空切片,避免为遍历另建组成。
/// 布局、快照和命中结构遍历调用,返回已有孩子切片,不分配新列表或递归复制子树。
pub fn children(&self) -> &[Arc<UiNode>] {
match &self.kind {
// 这些种类都显式包含 children;三维面板 UI 也复用普通 UiNode 子树遍历。
UiKind::Column { children, .. }
| UiKind::Overlay { children, .. }
| UiKind::Canvas { children, .. }
@@ -45,11 +61,12 @@ impl UiNode {
| UiKind::Row { children, .. }
| UiKind::Button { children, .. }
| UiKind::Scroll { children, .. } => children,
// Text/Rect/Spacer 没有视觉子树;返回静态空切片,调用方无需按种类再分支。
_ => &[],
}
}
/// 判断是否需要对子项执行弹性主轴分配。
/// 父布局/allocates_children 查询此孩子是否需要分配空间;判断的是节点需求,不会在此实际改尺寸。
pub fn has_flex(&self) -> bool {
// 不仅旧 flex 数值,非默认 FlexPolicy 或任一轴 Fill 也要求父节点执行分配,不能仅按自然尺寸排布。
self.flex > 0.0
@@ -58,17 +75,19 @@ impl UiNode {
|| self.sizing.height == crate::box_model::Dimension::Fill
}
/// 判断当前节点是否会为子项指定实际分配范围,供源布局选择安排路径。
/// source_layout 选择安排路径时调用,检查任一孩子的弹性需求或交叉轴拉伸是否要求明确分配。
pub fn allocates_children(&self) -> bool {
// 任何孩子需要弹性或拉伸即进入分配路径;align_self 优先于父 cross_align。
self.children().iter().any(|child| {
// any 短路即可确定是否需要分配;align_self 优先级高于容器 cross_align。
child.has_flex()
|| child.align_self.unwrap_or(self.cross_align)
== crate::layout::CrossAlign::Stretch
})
}
/// 比较节点自身配置和直接子项身份,决定能否复用旧 Arc;不是深拷贝业务图。
/// model::snapshot 缓存重建节点后调用,判断可否保留旧 Arc;返回 true 不会修改任何节点或 VM 对象。
/// 缓存调用方仍会用本轮读取重建依赖/父边;这里只证明视觉值可复用,不能证明依赖来源没有变化。
pub fn shallow_eq(&self, other: &Self) -> bool {
// 先比较所有公共视觉/协议配置,任一变化都不能复用旧节点 Arc,即使子内容完全相同。
if self.surface != other.surface
@@ -89,6 +108,7 @@ impl UiNode {
|| self.interactive != other.interactive
|| self.pointer_events != other.pointer_events
{
// 共有字段任一不同都足以否决复用,不再比较 kind 或递归访问后代。
return false;
}
// 直接孩子按 Arc 指针比较而不是递归深比较;依赖提取保证未变子树复用 Arc,父级只需验证其身份。
@@ -102,6 +122,7 @@ impl UiNode {
};
// 同类变体再比较其专有数据和直接子项;变体不同必为 false,避免布局种类切换后误复用缓存。
match (&self.kind, &other.kind) {
// a/b 与 d/e 是尺寸,c/f 是场景视图,g/h 是控制开关,i/j 是面板尺寸;孩子还需同身份。
(
UiKind::Viewport3D {
width: a,
@@ -120,6 +141,7 @@ impl UiNode {
..
},
) => a == d && b == e && c == f && g == h && i == j && same_children(),
// 二维视口除了宽高还比较相机快照;相机变了即使场景孩子没变也不能复用旧节点。
(
UiKind::CanvasViewport {
width: a,
@@ -135,14 +157,18 @@ impl UiNode {
},
) => a == d && b == e && c == f && same_children(),
(UiKind::Scene2D { positions: a, .. }, UiKind::Scene2D { positions: b, .. }) => {
// 显式世界位置和孩子身份一起决定组成,不能因同一批对象只移动位置而漏掉刷新。
a == b && same_children()
}
(UiKind::Overlay { padding: a, .. }, UiKind::Overlay { padding: b, .. }) => {
// Overlay 没有 gap/累计主轴位置,专有属性只比 padding,再检查孩子序列。
a == b && same_children()
}
(UiKind::Canvas { positions: a, .. }, UiKind::Canvas { positions: b, .. }) => {
// 兼容 Page Canvas 与 Scene2D 相同地保留显式位置,但仍要求两边变体相同。
a == b && same_children()
}
// 文本叶没有孩子,内容、字号和颜色相等即可;共有字段已在上面验证。
(
UiKind::Text {
value: a,
@@ -155,6 +181,7 @@ impl UiNode {
color: cb,
},
) => a == b && sa == sb && ca == cb,
// 矩形依次比较宽、高、圆角和颜色,不以外框尺寸相同掩盖绘制变化。
(
UiKind::Rect {
width: a,
@@ -169,7 +196,9 @@ impl UiNode {
color: h,
},
) => (a, b, c, d) == (e, f, g, h),
// Spacer 唯一专有属性是占位长度。
(UiKind::Spacer(a), UiKind::Spacer(b)) => a == b,
// 滚动偏移属于 Mount,此处只比轴/有限尺寸/内容规则及孩子,避免共享节点携带单次出现偏移。
(
UiKind::Scroll {
axis: aa,
@@ -188,6 +217,7 @@ impl UiNode {
children: _,
},
) => aa == ab && a == c && b == d && pa == pb && ga == gb && same_children(),
// 这三种容器共用 padding/gap 比较,但模式配对仍只允许 Column 对 Column 等同种比较。
(
UiKind::Column {
padding: a, gap: b, ..
@@ -212,6 +242,7 @@ impl UiNode {
padding: c, gap: d, ..
},
) => a == c && b == d && same_children(),
// 任何不同变体都不能复用,哪怕专有数据恰巧具有相同数值。
_ => false,
}
}
@@ -222,72 +253,106 @@ impl UiNode {
pub enum UiKind {
// 有限 3D 视口包含不可变场景视图和面板 UI 孩子;panels 与 children/slots 按顺序对应。
Viewport3D {
// 有限布局盒子尺寸,不是三维世界的几何范围。
width: f32,
height: f32,
// 已提取的场景、相机和投影数据,render 不需要为每个像素回读 VM。
view: Arc<crate::scene3d::View>,
// 是否存在可用控制器,输入宽阶段据此判断相机手势能力。
controls: bool,
// 各世界 UI 面板的逻辑尺寸,与 children 次序对应,供投影与布局共用。
panels: Vec<crate::layout::Size>,
// 面板 UI 的普通二维树;业务对象身份仍由 UiNode.block_id/slots 描述。
children: Vec<Arc<UiNode>>,
},
// 二维世界组成保留孩子的显式位置,不使用行列的累计偏移定位。
Scene2D {
// 每个孩子的世界 x/y,数组下标与 children 对齐,不按叠放顺序推算位置。
positions: Vec<[f32; 2]>,
children: Vec<Arc<UiNode>>,
},
// 有限布局盒子观察独立 Scene2D,相机仅影响投影,不改变场景节点原始逻辑位置。
CanvasViewport {
// 参加父布局的有限盒子尺寸;不是 Scene2D 孩子的总包围盒。
width: f32,
height: f32,
// 纯数据相机快照,只影响本视口的世界到屏幕映射,不改写原世界树。
camera: crate::viewport::CameraView,
children: Vec<Arc<UiNode>>,
},
// 孩子重叠布置,顺序同时决定叠放次序;普通有限布局与世界 Canvas 不同。
Overlay {
// 统一内边距,单边覆盖仍由 UiNode.padding_edges 提供。
padding: f32,
children: Vec<Arc<UiNode>>,
},
// 旧 Page Canvas 模式的兼容投影,位置数组与孩子顺序一致,页面级 Surface 保存其相机。
Canvas {
// 页面根中每个孩子的业务位置,保持与 children 同长度同顺序;Surface 再统一变换到页面视图。
positions: Vec<[f32; 2]>,
// 业务组成提取的不可变子树;数组顺序决定遍历/绘制顺序,不按 x/y 自动排序。
children: Vec<Arc<UiNode>>,
},
// 按钮是有子内容的容器,不把标签文本硬编码进 UiKind;交互资格另由 UiNode 标志表达。
Button {
// 内容与边框之间的统一内边距;单边覆盖仍优先使用 UiNode.padding_edges。
padding: f32,
// 多个内容孩子的主轴间距,不含父节点外 margin 或按钮边框厚度。
gap: f32,
// 标签只是其中一个普通 Text 子树,按钮没有独立的硬编码文字字段。
children: Vec<Arc<UiNode>>,
},
// 文字叶节点保留源字号等级与颜色文本;排版及最终设备比例由后续布局/显示列表转换处理。
Text {
// 已读取的文本内容,字体测量与软件绘制使用同一字符串。
value: String,
// 宿主字号等级,实际字体像素大小由 text/布局映射规则确定。
size: u32,
// 源颜色格式已在提取路径验证,绘制阶段再转成原生颜色值。
color: String,
},
// 纯绘制叶节点;几何尺寸与圆角供布局/显示列表共用,不含托管对象句柄。
Rect {
// 自然宽高用于测量;父安排与盒尺寸策略仍可给实际出现分配其他尺寸。
width: f32,
height: f32,
// 圆角半径影响显示列表的矩形命令,不给孩子增加布局空间。
radius: f32,
// 已校验格式的填充色字符串,与 UiNode.border_color 分别控制填充和边框。
color: String,
},
// Column 沿纵轴累加孩子位置,Row 沿横轴;二者都保存统一内边距和孩子间距。
Column {
// 容器默认内边距,最终内容内缩还包括单边覆盖与边框厚度。
padding: f32,
// 相邻孩子间沿纵向增加的空隙,首尾不额外增加一份 gap。
gap: f32,
// 当前组成顺序;节点可共享 Arc,但每次出现的 Mount 和几何另行建立。
children: Vec<Arc<UiNode>>,
},
// 横排容器与 Column 使用相同盒模型,但 gap 和弹性分配沿水平主轴计算。
Row {
// 统一内边距,由 layout::insets 与单边覆盖/边框合成有效值。
padding: f32,
// 横向相邻孩子间距,不是孩子自身宽度或两边 margin 的替代值。
gap: f32,
// 源组成的逻辑顺序,不因视觉位置变化而重排 slot。
children: Vec<Arc<UiNode>>,
},
// 无视觉内容的间隔叶节点,提供布局占用尺寸。
Spacer(f32),
// 滚动盒保存有限分配尺寸、允许轴和普通内容;实际偏移按 Mount 出现分别维护,不放到共享节点里。
Scroll {
// axis 指定允许消费的滚动方向;实际可消费距离还取决于具体 Mount 的内容/视口范围。
axis: crate::scroll::Axis,
// 有限视口的自然尺寸;内容可能大于视口,溢出范围和偏移由布局与 Mount 派生。
width: f32,
height: f32,
// 内容布局的统一内边距,与视口外 margin 分开。
padding: f32,
// 内容子项的纵向间隔,保持与原生滚动容器布局规则一致。
gap: f32,
// 当前内容树;滚动仅改变出现投影,不把移动后的坐标写入共享 UiNode。
children: Vec<Arc<UiNode>>,
},
}
+6
View File
@@ -3,12 +3,14 @@
//!
//! Application composition root. Directory modules own implementation; these
//! crate-private aliases keep existing internal paths stable during reorganization.
// 以下目录模块只声明编译归属,真正启动服务和创建窗口发生在 main 的顺序调用中。
mod bridge;
mod model;
mod render;
mod runtime;
mod shell;
// crate 内旧路径通过私有重导出访问目录实现,不为 GUI 与 runtime 额外建立另一套对象或注册表。
pub(crate) use bridge::ui_bridge;
// 模型层负责从 VM 场景对象提取快照,渲染层 scene3d 负责原生计算;别名区分两者。
pub(crate) use model::scene3d as scene3d_model;
@@ -36,17 +38,21 @@ use document::NamespaceState;
use winit::event_loop::{ControlFlow, EventLoop};
/// 按顺序建立事件循环、共享文档、API/VM worker 和应用窗口;事件循环结束后返回原生退出清理结果。
// 操作系统启动可执行文件时进入;各阶段的 ? 将启动/循环错误返回进程入口,不把失败当作成功运行。
fn main() -> Result<()> {
// 原生窗口由事件循环所属线程驱动;Wait 让空闲时休眠,而不是持续重绘。
let event_loop = EventLoop::<AppEvent>::with_user_event().build()?;
// 后台发布通过 EventLoopProxy 唤醒等待,原生窗口请求 RedrawRequested 驱动实际绘制。
event_loop.set_control_flow(ControlFlow::Wait);
// 共享最近完整发布的文档。后台 worker 拥有 VM,GUI 不借用其托管堆。
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
// worker 发布完整 NamespaceState,activity 供 Network 工具观察,control 用于原生窗口发运行时控制请求。
// start 先绑定本机 API 并启动后台设施;端口或线程启动错误会阻止后续应用状态构造。
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)?;
// 仅 run_app 正常返回后汇总窗口/VM 清理错误;循环本身返回 Err 时上面的 ? 已直接传播。
app.exit_result()
}
+35 -1
View File
@@ -1,6 +1,9 @@
//! 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。
//! 缓存记录读取版本而不是复制业务模型;依赖变化才重新计算。托管连接对象追踪其拥有的引用。
//!
//! 调用路径:UI 的 Block.bind 原生方法调用 connect;UiBridge 刷新入口在 VM 序列中调用 refresh。
//! connect 建立目标拥有的连接,refresh 在发布布局前反复求值到稳定;隐藏 Block 也参与刷新。
//!
//! Target-owned managed bindings. Dependency versions are borrowed identities;
//! the host's weak Block registry never roots an otherwise unreachable graph.
use std::{
@@ -25,7 +28,7 @@ struct Cached {
}
#[derive(Debug, Clone)]
/// 托管属性连接,强持有计算/目标所需引用,并保留只读计算的版本缓存。
/// 托管属性连接:由目标强持有,连接只强持有 compute,不反向保活目标;缓存共享只读计算的观察结果。
pub struct Binding {
// 目标属性名;目标 Block 通过 bindings 列表拥有本连接。
field: String,
@@ -49,6 +52,7 @@ impl ObjectPayload for Binding {
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// Aster GC 调用 trace 时只追踪有效计算;reads/output 都是借用观察,不能增加长期拥有边。
if let Some(compute) = self.compute {
visit(compute);
}
@@ -67,13 +71,16 @@ pub fn connect(
if field.is_empty() || field.len() > 128 || field == "type" {
return Err(VmError::InvalidArgument("invalid binding property"));
}
// 沿共享赋值解析到底层 Data 身份,避免给旧别名副本建立不再生效的连接。
let (reference, data) = block::resolve(cx.store(), target)?;
// 复制宿主描述以编辑 bindings;最终 replace_payload 才更新托管拥有边,不能原地绕过 VM 引用追踪。
let mut data = data.clone();
// 只允许属性存储里已存在的字段;方法名即使可读取,也不能作为绑定写入目标。
let properties = data
.properties
.ok_or(VmError::InvalidArgument("missing property storage"))?;
cx.store().get_attr(properties, field)?;
// 计算必须是 Function(含绑定函数),可构造的类型对象等 callable 不能替代绑定函数。
if !cx.store().object(compute)?.payload().is_function() {
return Err(VmError::InvalidArgument(
"binding compute must be a Function",
@@ -94,15 +101,20 @@ pub fn connect(
}
// 同一字段只保留一个有效计算。其他字段连接沿用;旧同字段连接失效并清缓存,
// 使外部仍持有的 Subscription 不会继续驱动被替换的计算。
// 收集仍有效且属于其他字段的连接,随后以这份列表替换原 bindings。
let mut retained = vec![];
// 遍历目标拥有的旧连接,已失效项不再保留;同字段有效项必须取消以保证单字段单计算。
for connection in &data.bindings {
// 克隆 payload 以修改 compute,缓存 Arc 仍共享原连接的观察状态。
let mut binding = cx
.store()
.payload::<Binding>(*connection, "Binding")?
.clone();
// 其他字段仍有效的连接原样保留,其计算函数继续由目标间接保活。
if binding.compute.is_some() && binding.field != field {
retained.push(*connection);
} else if binding.compute.is_some() {
// 只取消仍有效的同字段旧连接;已失效项无需再次清理或加入 retained。
binding.compute = None;
*binding
.reads
@@ -111,9 +123,11 @@ pub fn connect(
cx.replace_payload(*connection, binding)?;
}
}
// 预算按替换后仍有效的其他字段计数,给新连接预留一个槽,避免单 Block 连接无限增长。
if retained.len() >= 128 {
return Err(VmError::ExecutionLimit("128 bindings per Block"));
}
// 新连接初始无缓存,第一次 refresh 必须执行 compute;分配后目标的 Data 负责持有它。
let connection = cx.allocate(Binding {
field: field.into(),
compute: Some(compute),
@@ -135,33 +149,47 @@ pub fn connect(
}
/// 在只读调用中重算失效绑定,采集新的读取版本,再将成功结果应用到目标属性。
/// UiBridge::refresh_bindings 在每轮展示刷新开始时调用;compute 只负责返回值,不能直接写已有模型。
/// 本函数退出只读调用后才验证并写目标属性,这些宿主写入可以使其他绑定失效,因而按完整轮次求固定点。
pub fn refresh(
vm: &mut Vm,
nodes: &[WeakObjectRef],
host_desktops: &HashSet<ObjectRef>,
) -> Result<usize> {
// work 跨所有轮次累计。属性 A 的写入可能使已检查的 B 失效,所以不能只扫描一次。
// 返回实际执行的计算次数,UiBridge 用它统计 bindingEvaluations,而非统计扫描节点数量。
let mut work = 0;
// 反复扫描弱注册表,直到一整轮没有计算;后面绑定写入可能使本轮已处理项重新失效。
loop {
// 记录本轮是否至少执行过一次计算,即使输出没变也再扫一轮核对依赖。
let mut evaluated = false;
// 每轮按解析后的真实 Block 身份去重,多个包装对象共享数据时只处理一次。
let mut seen = HashSet::new();
// nodes 是宿主保存的弱 Block 索引,不作为 GC 根;隐藏但仍由业务持有的对象同样会被检查。
for weak in nodes {
// 升级只为本轮临时保活;已经回收的目标直接跳过,不重建它或延续其绑定。
let Some(target) = vm.upgrade(*weak)? else {
continue;
};
// 用闭包暂存失败,让下方 release 无论处理成功与否都能释放 upgrade 的强引用。
let result: Result<()> = (|| {
// 按最终 Data 身份去重,多个包装别名不能在同轮重复评估同一组 bindings。
let (reference, data) = block::resolve(vm.object_store(), target)?;
// 本轮已处理这个 Data 时跳过,目标强引用仍由外层统一 release。
if !seen.insert(reference) {
return Ok(());
}
// 先复制连接句柄列表以结束 Data 借用,后续 VM 调用可能修改堆或更新属性。
let connections = data.bindings.clone();
// 目标拥有这些连接;逐个检查其取消状态和缓存,避免只刷新可见快照中的属性。
for connection in connections {
// 取当前 payload 快照,compute 与缓存必须来自同一连接状态。
let binding = vm.payload::<Binding>(connection, "Binding")?.clone();
// 断开或被替换的连接不再执行,即使外部仍持有旧 Subscription。
let Some(compute) = binding.compute else {
continue;
};
// 锁只用于复制缓存,源 compute 执行期间不持锁,避免执行源代码时占着宿主缓存互斥量。
let cached = binding
.reads
.lock()
@@ -182,6 +210,7 @@ pub fn refresh(
}
// 防止相互写入的绑定持续振荡;预算跨轮累计,超限向宿主报告不稳定。
work += 1;
// 已评估次数跨轮累计,超过预算就报告不收敛,而非丢弃剩余绑定继续发布。
if work > 10_000 {
bail!("property bindings did not stabilize within the execution budget");
}
@@ -197,6 +226,7 @@ pub fn refresh(
},
)
.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
// 暂存应用阶段错误,让下方 release_value 无论校验/写入是否成功都释放计算返回值。
let applied: Result<()> = (|| {
// 宿主桌面对象不纳入此缓存依赖;保留业务读取,避免宿主发布状态驱动重算。
let reads = reads
@@ -204,12 +234,15 @@ pub fn refresh(
.into_iter()
.filter(|read| !host_desktops.contains(&read.object()))
.collect();
// 计算结束后重新解析目标,不能沿用调用前借用的 Data;随后只在副本上试解码视觉值。
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())?;
}
// 成功应用后才保存新依赖和输出身份;缓存不 retain 输出对象,也不解引用旧对象句柄。
*binding
.reads
.lock()
@@ -226,6 +259,7 @@ pub fn refresh(
}
Ok(())
})();
// 无论 result 成功还是失败,先释放弱升级带来的临时强引用,再传播错误。
vm.release(target)?;
result?;
}
+159 -1
View File
@@ -2,6 +2,10 @@
//! Data 保存子组件 slot、布局配置、绑定和订阅。属性读写需要沿有效的类型/存储路径解析,不能把快照当成可变业务对象。
//! 提取视觉数据时将托管属性覆盖到原生描述;校验负责拒绝循环、非法尺寸和不符合类型的组成。
//!
//! 调用路径:UI 构造器经 allocate 建立托管图;Children/赋值入口经 validate 检查新组成。
//! RenderCache 经 resolve_tracked/visual_data 提取本轮描述,绑定刷新也用 apply_visual_property 预校验结果。
//! ObjectPayload 钩子由 Aster 属性查询、赋值及 GC 调用,不直接保存窗口或每次出现的几何。
//!
//! Live visual objects. All semantic references are owned by Aster's heap;
//! native Arcs are reserved for immutable rendering snapshots.
use std::sync::Arc;
@@ -15,41 +19,54 @@ use crate::ui_bridge::UiNode;
pub struct Child {
// slot 标识父组件中这条组成边,移动行序不改变它;同一 Block 可通过不同 slot 出现多次。
pub slot: u64,
// 组成边强持有的业务 Block;同一对象可通过不同 slot 多次出现。
pub block: ObjectRef,
}
#[derive(Debug, Clone)]
/// 原生组件分类,只描述业务组件种类;每种展示几何由布局和 Mount 另行生成。
pub enum Kind {
// 原生文字默认内容/离散样式;换行宽高来自各出现位置的约束。
Text {
value: String,
size: u32,
color: String,
},
// 原生矩形自然宽高、圆角和填充色,最终像素尺寸由布局分配。
Rect {
width: f32,
height: f32,
radius: f32,
color: String,
},
// 单一自然间距叶节点,不持有子组成。
Spacer(f32),
// 有限滚动视口的自然尺寸,滚动偏移和范围属于 Mount。
Scroll {
width: f32,
height: f32,
},
// 线性横向容器,孩子组成顺序由 Data.children 保存。
Row,
// 线性纵向容器,与 Row 共享通用盒模型及弹性策略。
Column,
// 叠放容器,子项共享内容区域,各自独立对齐而非线性累积。
Overlay,
// 二维世界组成根,子项业务 x/y 可带符号。
Scene2D,
// 承载 Scene3D 的有限视口,场景与相机引用保存于 Data。
Viewport3D {
width: f32,
height: f32,
},
// 承载唯一 Scene2D 根的有限二维视口,独立相机负责世界到视图投影。
CanvasViewport {
width: f32,
height: f32,
},
// 具有默认按钮视觉/事件协议的容器种类。
Button,
// 页面业务根,包含导航和兼容表面状态,生命周期有别于普通 Block。
Page,
}
@@ -60,7 +77,9 @@ pub enum Kind {
pub struct Block {
// 共享赋值单元 Cell;其值可为最终 Data 或另一个 Block,从而保留别名身份并转发属性/类型查询。
pub state: ObjectRef,
// 包装是否为 Page,限制共享赋值不能跨越页面与普通组件的生命周期边界。
pub page: bool,
// 该业务 owner 的任务作用域,独立于 Data 替换保留;trace 负责向 VM 暴露拥有边。
pub tasks: Option<ObjectRef>,
}
@@ -71,6 +90,7 @@ impl ObjectPayload for Block {
}
/// 沿 Block 的共享 Cell 定位属性存储,使别名替换后读取到同一份新 Data。
fn attribute_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
// VM 属性查询先读共享 Cell;不是对象意味着内部状态损坏,不能返回一个空属性集来掩盖。
let Value::Object(value) = store.cell_get(self.state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
@@ -90,9 +110,11 @@ impl ObjectPayload for Block {
/// 从 Block 当前 Data 解析其类型转发目标,使共享替换后的类型查询仍一致。
fn type_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
// VM 类型查询同样沿 Cell 解析,防止别名的属性与名义类型指向不同替代对象。
let Value::Object(value) = store.cell_get(self.state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
// 若仍是包装 Block 则继续转发;到最终 Data 时保留当前包装本身的名义类型。
if store.object(value)?.payload().as_any().is::<Block>() {
Ok(Some(value))
} else {
@@ -116,7 +138,9 @@ impl ObjectPayload for Block {
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// Aster GC 追踪共享 Cell 与 owner 任务作用域;别名链不能隐藏在宿主 Arc 图中。
visit(self.state);
// 存在业务 owner 任务作用域时强持有它,替换视觉 Data 不会把该作用域漏出追踪。
if let Some(tasks) = self.tasks {
visit(tasks);
}
@@ -127,6 +151,7 @@ impl ObjectPayload for Block {
source: ObjectRef,
store: &ObjectStore,
) -> Result<Option<ObjectRef>, VmError> {
// VM 询问赋值是否共享 Cell 时,只接收另一 Block 包装;其他值交回常规赋值规则。
if !store.object(source)?.payload().as_any().is::<Self>() {
return Ok(None);
}
@@ -145,36 +170,61 @@ impl ObjectPayload for Block {
#[derive(Debug, Clone)]
/// 组件当前业务描述与组成。与一次求出的屏幕几何分开保存,替换或属性变化会触发派生快照失效。
pub struct Data {
// 构造时原生种类及其默认视觉值;当前托管属性由 visual_data 覆盖到副本。
pub kind: Kind,
// 有顺序的组成边,slot 保留身份,Vec 顺序决定布局/绘制展开顺序。
pub children: Vec<Child>,
// 容器统一内边距,单边 padding_edges 为 Some 时优先覆盖。
pub padding: f32,
// 线性子项间距,参与自然尺寸、排列和剩余空间分配。
pub gap: f32,
// 父主轴有剩余空间时的增长权重,不是已分配像素宽高。
pub flex: f32,
// 主轴 basis、min/max 与 shrink 规则,供布局求解分配尺寸。
pub flex_policy: crate::layout::FlexPolicy,
// 容器主轴剩余空间的对齐策略,与子项 flex 尺寸分配区分。
pub main_align: crate::layout::MainAlign,
// 容器交叉轴默认对齐,子项 align_self 可单独覆盖。
pub cross_align: crate::layout::CrossAlign,
// None 编码 auto,表示继承当前父出现位置策略;共享孩子在不同父下可有不同几何。
pub align_self: Option<crate::layout::CrossAlign>,
// 子项外边距,参与父固有尺寸与分配,不包含在本项边框盒宽高中。
pub margin: crate::layout::Insets,
// 边框盒 auto/fill/显式尺寸及 min/max 限制,解码后由布局应用。
pub sizing: crate::box_model::Sizing,
// 四边覆盖顺序为上、右、下、左;None 表示回退统一 padding,不等于零。
pub padding_edges: [Option<f32>; 4],
// 四边边框厚度,占据边框盒内部空间,并生成独立绘制命令。
pub border: crate::layout::Insets,
// 已验证颜色文本,供边框绘制使用;不是业务内容自身的填充色。
pub border_color: String,
// overflow=clip 时限制后代的绘制与命中,不自动赋予滚动能力。
pub clip_children: bool,
// CanvasViewport/Viewport3D 使用的相机强引用;共享相机需源代码显式复用对象。
pub camera: Option<ObjectRef>,
// 三维视口的 Scene3D 业务根,面板 UI 由场景提取流程展开。
pub scene3d: Option<ObjectRef>,
// 三维视口自定义控制器;提取时检查名义 update,输入路径才调用它。
pub controller: Option<ObjectRef>,
// ScrollBlock 可滚动方向;实际偏移与有限范围按每个 Mount 保存。
pub scroll_axis: crate::scroll::Axis,
// Scene2D/canvas 等定位使用的带符号业务坐标,不能当作已投影屏幕位置。
pub position: [f32; 2],
// Page 兼容表面模式及 pan/zoom,具体页面身份在发布时补入。
pub surface: crate::page::Surface,
// 下次插入的单调 slot,初始孩子按 0..len 编号,后续重排不把旧 slot 重新编号。
pub next_slot: u64,
// Page 导航历史是真实托管拥有边;后退/前进可重新展示同一个对象,而非复制快照。
pub history: Vec<ObjectRef>,
// Page 当前导航历史位置,与 history 一起维护前进/后退关系。
pub history_index: usize,
// 目标拥有的属性连接,连接再持有 compute;取消令牌不反向保活目标。
pub bindings: Vec<ObjectRef>,
// 旧 textOf 源对象与字段名推导入口;通用 text 绑定建立后清除以避免竞争。
pub text_binding: Option<(ObjectRef, String)>,
// 兼容 onClick 直接回调,事件路径调用,GC 需保活该函数。
pub click: Option<ObjectRef>,
// 对象拥有的事件连接,派发按这些连接决定监听器资格。
pub subscriptions: Vec<ObjectRef>,
// 源可读写的视觉字段存储;Data 自身保存结构边与构造默认值,提取时再将当前属性覆盖到派生副本。
pub properties: Option<ObjectRef>,
@@ -185,6 +235,8 @@ impl Data {
pub fn new(kind: Kind, children: Vec<ObjectRef>, padding: f32, gap: f32) -> Self {
// 先为输入孩子分配稳定 slot,next_slot 从当前长度继续,后续追加不会复用被删除条目的编号。
let next_slot = children.len() as u64;
// UI 构造器传入的种类/孩子/统一 padding 与 gap 形成默认描述,其余布局策略先取无约束默认值。
// 顺序编号孩子形成独立组成边,后续插入/删除只移动 Vec,不重编号旧 slot。
Self {
kind,
children: children
@@ -257,33 +309,43 @@ impl ObjectPayload for Data {
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// 所有结构字段中出现的托管强引用必须枚举:场景/相机、属性存储、孩子、历史、绑定、订阅和回调。
// 三维场景根属于业务拥有边,不能只在渲染快照中借用而漏掉 GC。
if let Some(scene) = self.scene3d {
visit(scene);
}
// 自定义控制器可能持有源模型/闭包,需要进入循环回收图。
if let Some(controller) = self.controller {
visit(controller);
}
// 相机可被多个视口共享,Data 对它的每条强边都要被追踪。
if let Some(camera) = self.camera {
visit(camera);
}
// 普通源字段存储可能反向引用组件或页面,必须让 VM 看到这类环。
if let Some(properties) = self.properties {
visit(properties);
}
// 每条组成边都拥有孩子,不能因业务身份重复而漏计托管引用。
for child in &self.children {
visit(child.block);
}
// 页面历史需要保留可再次展示的业务对象,不是临时绘制快照。
for item in &self.history {
visit(*item);
}
// 旧 textOf 推导源仍是强引用,直到该入口被清除。
if let Some((source, _)) = &self.text_binding {
visit(*source);
}
// 连接由目标拥有,连接 payload 再追踪有效 compute。
for binding in &self.bindings {
visit(*binding);
}
// 事件连接持有回调所需图,取消和回收由事件连接状态决定。
for token in &self.subscriptions {
visit(*token);
}
// 兼容单回调同样要保活,不能只追踪通用 subscriptions。
if let Some(handler) = self.click {
visit(handler);
}
@@ -292,6 +354,7 @@ impl ObjectPayload for Data {
/// 分配 Data、共享 Cell 和 Block 包装,并让托管图拥有必要引用。
pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef, VmError> {
// UI 构造器调用 allocate;先固定包装的 Page 标记,后续共享赋值据此检查类别兼容。
let page = matches!(data.kind, Kind::Page);
// 分配拥有边前检查每个孩子都是有效有限 Block 图;非法组成直接失败,不截断部分 UI。
for child in &data.children {
@@ -299,10 +362,12 @@ pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef,
}
// 把原生默认参数显式写成普通属性,使源赋值、绑定及精确依赖跟踪观察到同一存储。
let properties = cx.new_object()?;
// 将当前 kind 允许的默认字段写入新 properties,使源写入与精确依赖观察同一个对象。
for (name, value) in native_properties(&data) {
cx.set_attr(properties, name, value)?;
}
data.properties = Some(properties);
// 托管化完整描述后再创建 Cell/Block,拥有链由 VM 分配机制维护。
let data = cx.allocate(data)?;
// 最终建立 Block -> Cell -> Data -> properties/children 拥有链,均在 VM 中参与追踪和循环回收。
let state = cx.new_cell(Some(Value::Object(data)))?;
@@ -317,6 +382,7 @@ pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef,
/// ownership graph hidden from the VM's cycle collector.
/// 解析一个 Block 当前指向的 Data,返回底层身份和借用描述。
pub fn resolve(store: &ObjectStore, block: ObjectRef) -> Result<(ObjectRef, &Data), VmError> {
// 编辑、绑定和协议辅助调用只需最终 Data;有缓存需求的调用方应保留 tracked 返回的依赖路径。
let (reference, data, _) = resolve_tracked(store, block)?;
Ok((reference, data))
}
@@ -326,16 +392,22 @@ pub fn resolve_tracked(
store: &ObjectStore,
block: ObjectRef,
) -> Result<(ObjectRef, &Data, Vec<ObjectRef>), VmError> {
// 当前待解析包装,从调用方给定的业务别名开始,而非直接信任旧 Data 句柄。
let mut current = block;
// 解析转发链的全局去重集合;同一包装再次出现表示纯转发循环。
let mut seen = std::collections::HashSet::new();
// RenderCache 借用这份身份路径建立失效观察,不把依赖列表当作持久 GC 根。
let mut dependencies = Vec::new();
// 逐层走 Block→Cell→替代 Block,直到最终 Data 或明确错误,不按固定深度截断。
loop {
// 解析整条转发路径防环;依赖收集同时记录每层 Block 与 Cell,任一转发变更都会让视觉缓存失效。
if !seen.insert(current) {
return Err(VmError::InvalidArgument("cyclic Block reference"));
}
// 每一层都检查确为 Block payload,禁止任意对象被当成转发节点。
let state = store.payload::<Block>(current, "Block")?.state;
dependencies.extend([current, state]);
// Cell 内容须为托管对象,Nil/数值等状态不能产生合法视觉描述。
let Value::Object(value) = store.cell_get(state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
@@ -355,6 +427,9 @@ 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 次展开的组成。
/// allocate/Scene3D 面板构造传 None,只检查已有图;共享赋值传目标 Cell,
/// Children 的 append/insert/replace 和 Page 的 render/navigate 则传目标 Data。
/// 因此 forbidden_state 虽沿用 state 名称,实际可以是 Cell 或 Data 身份;二者都不能在新子图中出现。
pub fn validate(
store: &ObjectStore,
root: ObjectRef,
@@ -365,20 +440,29 @@ pub fn validate(
Enter(ObjectRef),
Exit(ObjectRef),
}
// validate 由新组成/共享赋值入口调用;显式栈保存尚待展开的进入/退出步骤。
let mut work = vec![Step::Enter(root)];
// 只记录当前 DFS 祖先 Cell,兄弟分支复用同一孩子是合法共享而非循环。
let mut path = std::collections::HashSet::new();
// 计数实际展开工作量,重复共享出现也计入 10000 上限。
let mut visits = 0;
// 弹出后进先出工作项,避免用宿主递归栈承载深层业务图。
while let Some(step) = work.pop() {
// 退出清祖先标记;进入检查预算、禁用目标和所有孩子,不产出任何部分 UI。
match step {
// 子树已完成后移除祖先,允许后面的兄弟再次使用同一 Cell。
Step::Exit(state) => {
path.remove(&state);
}
// 每次组成或转发出现都检查,不能按不同对象数去重而绕过展开预算。
Step::Enter(block) => {
// 预算按实际展开次数而非不同对象数计算,共享子树重复出现也计入资源工作量。
visits += 1;
// 展开超限明确拒绝,不能返回部分孩子让 UI 悄悄缺项。
if visits > 10_000 {
return Err(VmError::ExecutionLimit("expanded Block graph"));
}
// 要求每个待展开节点为 Block,取共享 Cell 作为路径身份。
let state = store.payload::<Block>(block, "Block")?.state;
// path 只追踪当前祖先 Cell,允许其他分支共享同一组件;forbidden_state 额外阻止新边回到将被修改的目标。
if forbidden_state == Some(state) || !path.insert(state) {
@@ -387,9 +471,12 @@ pub fn validate(
));
}
work.push(Step::Exit(state));
// 与 resolve 一样拒绝非对象 Cell,避免非法状态混进组成拥有链。
let Value::Object(value) = store.cell_get(state)? else {
return Err(VmError::InvalidArgument("invalid Block state"));
};
// Children/Page 编辑传入的是 Data 身份,要在此比较 Cell 的值;共享赋值传入的 Cell 已在上方比较。
// 例如给容器追加它自己的别名时,包装身份可能不同,最终 Data 相同仍须拒绝。
if forbidden_state == Some(value) {
return Err(VmError::InvalidArgument(
"Block composition would create a cycle",
@@ -397,6 +484,7 @@ pub fn validate(
}
// Data 展开孩子,非 Data 则沿共享赋值转发链继续;显式 Exit 在子树完成后清除祖先标记。
if let Ok(data) = store.payload::<Data>(value, "Block data") {
// 逆序压栈让检查按原孩子顺序展开;每次出现都验证,重复子树不能跳过预算。
for child in data.children.iter().rev() {
work.push(Step::Enter(child.block));
}
@@ -418,8 +506,10 @@ pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result<Arc<UiNode>, VmE
/// Native visual parameters are ordinary managed attributes too. Composition,
/// histories and connections remain structural payload edges.
/// 列出本类型可投影到托管属性的原生字段及默认值。
/// allocate 用返回值初始化 properties;visual_data 用字段名重读当前值;RenderCache 用同一集合登记精确失效依赖。
/// 新增原生视觉字段时需同时补这里与 apply_visual_property,否则可能出现可解码但不进入快照/缓存观察的字段。
pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
/// 把已有 f32 默认字段编码为源 Number;这里不做范围校验,读取解码由 apply_visual_property 处理。
fn number(value: f32) -> Value {
Value::Number((value as f64).into())
}
@@ -455,6 +545,7 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
(
"overflow",
Value::Text(
// 布尔内部状态编码成公开 overflow 策略文本,供源字段读取与后续解析一致往返。
if data.clip_children {
"clip"
} else {
@@ -472,6 +563,7 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
),
),
];
// 按上右下左为四边输出覆盖值,None 与显式零必须保持可区分。
for (name, edge) in ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]
.into_iter()
.zip(data.padding_edges)
@@ -482,7 +574,9 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
edge.map_or_else(|| Value::Text("auto".into()), number),
));
}
// 只为实际原生 kind 公开专有默认字段;没有对应字段的种类不凭同名自定义属性改变行为。
match &data.kind {
// 三维视口带有限宽高、场景、控制器和相机,None 控制器编码为明确文本 none。
Kind::Viewport3D { width, height } => fields.extend([
("width", number(*width)),
("height", number(*height)),
@@ -494,22 +588,26 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
),
("camera", data.camera.map_or(Value::Nil, Value::Object)),
]),
// 二维有限视口公开宽高和 Camera2D,Scene2D 仍通过结构 children 持有。
Kind::CanvasViewport { width, height } => fields.extend([
("width", number(*width)),
("height", number(*height)),
("camera", data.camera.map_or(Value::Nil, Value::Object)),
]),
// 兼容 Page 表面公开 mode/pan/zoom,不把导航历史作为普通视觉字段。
Kind::Page => fields.extend([
("mode", Value::Text(data.surface.mode.name().into())),
("panX", number(data.surface.pan.x)),
("panY", number(data.surface.pan.y)),
("zoom", number(data.surface.zoom)),
]),
// 文字的内容、离散字号及颜色进入普通属性存储,允许绑定和精确读观察。
Kind::Text { value, size, color } => fields.extend([
("text", Value::Text(value.clone())),
("size", Value::Number((*size as i64).into())),
("color", Value::Text(color.clone())),
]),
// 矩形默认几何与填充色可由托管属性覆盖,出现位置的实际分配另由布局决定。
Kind::Rect {
width,
height,
@@ -521,11 +619,13 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
("radius", number(*radius)),
("color", Value::Text(color.clone())),
]),
// 滚动容器公开初始尺寸和轴策略,实际偏移不放到共享业务字段。
Kind::Scroll { width, height } => fields.extend([
("width", number(*width)),
("height", number(*height)),
("axis", Value::Text(data.scroll_axis.name().into())),
]),
// Spacer 仅公开自身自然间距 size。
Kind::Spacer(size) => fields.push(("size", number(*size))),
_ => {}
}
@@ -545,10 +645,13 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
// 只改本轮派生副本,逐个读取实际属性并验证;读取字段名集合来自 native_properties,不遍历任意用户字段。
let mut data = data.clone();
// 提取必须有托管字段存储;没有时说明分配图不完整,不能退回构造默认值。
let properties = data
.properties
.ok_or(VmError::InvalidArgument("missing Block properties"))?;
// 字段集合由原生种类确定,每项从当前存储读取;RenderCache 调用方另用同一字段集合登记精确缓存依赖。
for (name, _) in native_properties(&data) {
// 借用读取的源值先解码到副本,错误不会反写已存在的业务描述。
let value = store.get_attr(properties, name)?;
apply_visual_property(&mut data, name, &value)?;
}
@@ -556,11 +659,13 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
if data.flex_policy.min > data.flex_policy.max {
return Err(VmError::InvalidArgument("flexMin exceeds flexMax"));
}
// 全部字段解码后才比较两轴 min/max,避免字段覆盖次序造成错误地提前拒绝。
if data.sizing.min_width > data.sizing.max_width
|| data.sizing.min_height > data.sizing.max_height
{
return Err(VmError::InvalidArgument("box minimum exceeds maximum"));
}
// 有限二维视口另要求合法 Camera2D 与唯一 Scene2D 根,不能仅靠字段类型通过完整协议。
if matches!(data.kind, Kind::CanvasViewport { .. }) {
crate::viewport::camera(
store,
@@ -580,9 +685,11 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
));
}
}
// 有自定义控制器时先验证名义方法;未指定 controller 则沿默认相机手势路径。
if matches!(data.kind, Kind::Viewport3D { .. })
&& let Some(controller) = data.controller
{
// 控制器必须有名义类型,不能依赖实例上的同名动态字段冒充 update 方法。
let definition = store.type_of(controller)?.ok_or(VmError::InvalidArgument(
"3D controller requires a nominal update method",
))?;
@@ -592,6 +699,7 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
.ok_or(VmError::InvalidArgument(
"3D controller must define update(camera, gesture)",
))?;
// 包括 receiver 的三参形状需可接受;这里只校验,不在展示提取期间执行控制器副作用。
if store
.call_arity(method)?
.is_none_or(|arity| !arity.accepts(3))
@@ -605,14 +713,20 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
}
/// 按属性名检查和解码源值,再更新对应视觉字段;非法尺寸、枚举或颜色被拒绝。
/// binding::refresh 在写入托管属性前用副本试解码;visual_data 在全量覆盖后另查 min/max 和视口协议。
/// 本函数只验证本次值并更新传入 Data,不自行写 properties,也不保证多个相关字段组合起来一定合法。
pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Result<(), VmError> {
// 绑定刷新在写回前、visual_data 在读取时调用本解码器;通用类型/范围失败统一为视觉属性错误。
let bad = || VmError::InvalidArgument("invalid native visual property");
// 常规视觉长度/系数只接受非负有限值;坐标和 pan 在下面走允许负数的独立路径。
let number = || -> Result<f32, VmError> {
// 仅接受名义 Number,拒绝可格式化成数字的字符串/对象。
let Value::Number(value) = value else {
return Err(bad());
};
// 转为布局使用的 f32 后再检查有限性和上界,防止大数溢出为无穷。
let n = value.to_string().parse::<f32>().map_err(|_| bad())?;
// 拒绝 NaN、无穷、负数和超过全局视觉长度预算的数,保护后续布局/像素尺寸计算。
if !n.is_finite() || !(0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(&n) {
return Err(bad());
}
@@ -620,7 +734,9 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
};
// x/y 对所有组件生效;页面 pan/zoom 只作用 Page。这里不是任意字段存在性校验,不适用种类会跳过更新。
if matches!(name, "x" | "y" | "panX" | "panY" | "zoom") {
// 坐标路径允许带符号数,但仍使用 coordinate 的有限值/范围检查。
let n = crate::scroll::coordinate(value)?;
// x/y 更新业务位置;pan/zoom 只对 Page 解释,其他同名字段不影响原生描述。
match name {
"x" => data.position[0] = n,
"y" => data.position[1] = n,
@@ -637,13 +753,16 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
}
return Ok(());
}
// 旧 Page mode 仅接受可解析字符串,其他组件的自定义 mode 不在此解释。
if matches!(data.kind, Kind::Page) && name == "mode" {
// mode 的枚举解析只接受字符串;Number 等类型不做隐式转换。
let Value::Text(name) = value else {
return Err(bad());
};
data.surface.mode = crate::page::Mode::parse(name)?;
return Ok(());
}
// 先处理跨种类盒模型字段及视口引用,成功分支立即返回,避免落入后面的 kind 专有解码。
match name {
// controller 接受对象或明确的字符串 none,不能拿 Nil 隐式取消;后续 visual_data 检查其名义方法。
"controller" if matches!(data.kind, Kind::Viewport3D { .. }) => {
@@ -656,11 +775,13 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
}
// camera/scene 这里仅检查 Object 并记录身份,实际 payload/结构验证由各视口快照入口继续执行。
"camera"
// 只有二维/三维视口把 camera 当作原生相机引用,其他种类不会因同名自定义字段改变协议。
if matches!(
data.kind,
Kind::CanvasViewport { .. } | Kind::Viewport3D { .. }
) =>
{
// 相机先要求对象身份,具体 2D/3D payload 与名义类型交由相应快照路径验证。
let Value::Object(camera) = value else {
return Err(bad());
};
@@ -668,6 +789,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
return Ok(());
}
"scene" if matches!(data.kind, Kind::Viewport3D { .. }) => {
// Scene3D 引用先保留业务身份,完整图验证发生在三维提取入口。
let Value::Object(scene) = value else {
return Err(bad());
};
@@ -675,6 +797,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
return Ok(());
}
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
// 四边覆盖数组固定按上右下左索引,保证与 native_properties 输出次序一致。
let index = match name {
"paddingTop" => 0,
"paddingRight" => 1,
@@ -689,23 +812,28 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
};
return Ok(());
}
// 上边框厚度占据边框盒内的顶侧空间,使用非负有限长度。
"borderTop" => {
data.border.top = number()?;
return Ok(());
}
// 右边框厚度用于内容区扣减和独立边框绘制。
"borderRight" => {
data.border.right = number()?;
return Ok(());
}
// 下边框厚度影响内容可用高,不能当成外 margin。
"borderBottom" => {
data.border.bottom = number()?;
return Ok(());
}
// 左边框厚度进入内容起点和裁剪区的横向内缩。
"borderLeft" => {
data.border.left = number()?;
return Ok(());
}
"borderColor" => {
// 边框颜色必须为字符串,再由 parse_color 验证实际颜色格式。
let Value::Text(color) = value else {
return Err(bad());
};
@@ -714,6 +842,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
return Ok(());
}
"overflow" => {
// overflow 是策略枚举,不接受 Bool 等近似表示,未知字符串明确报错。
let Value::Text(overflow) = value else {
return Err(bad());
};
@@ -729,42 +858,52 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
};
return Ok(());
}
// 边框盒宽可为 auto/fill/显式尺寸,由 Dimension 解析保留策略而非提前求像素。
"boxWidth" => {
data.sizing.width = crate::box_model::Dimension::parse(value)?;
return Ok(());
}
// 边框盒高同样保留 auto/fill 语义,有限父分配由布局阶段提供。
"boxHeight" => {
data.sizing.height = crate::box_model::Dimension::parse(value)?;
return Ok(());
}
// 记录宽度下限,min/max 的组合一致性等全部字段覆盖后检查。
"minWidth" => {
data.sizing.min_width = number()?;
return Ok(());
}
// 记录高度下限,参与布局夹取且不能为负。
"minHeight" => {
data.sizing.min_height = number()?;
return Ok(());
}
// 记录有限宽度上限,约束 fill/stretch 与显式尺寸的最终结果。
"maxWidth" => {
data.sizing.max_width = number()?;
return Ok(());
}
// 记录有限高度上限,避免 stretch 绕过盒尺寸边界。
"maxHeight" => {
data.sizing.max_height = number()?;
return Ok(());
}
// 更新顶侧外边距,影响父交叉轴/主轴定位而不改子项边框盒厚度。
"marginTop" => {
data.margin.top = number()?;
return Ok(());
}
// 更新右侧外边距,参与父自然宽及剩余主轴空间扣减。
"marginRight" => {
data.margin.right = number()?;
return Ok(());
}
// 更新底侧外边距,参与父自然高及后续子项排列。
"marginBottom" => {
data.margin.bottom = number()?;
return Ok(());
}
// 更新左侧外边距,参与子项起点和父宽度分配。
"marginLeft" => {
data.margin.left = number()?;
return Ok(());
@@ -780,30 +919,37 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
};
return Ok(());
}
// 弹性主轴分配下限,与 flexMax 的大小关系由 visual_data 整体检查。
"flexMin" => {
data.flex_policy.min = number()?;
return Ok(());
}
// 弹性主轴分配上限,达到边界后剩余空间可重新分给其他项。
"flexMax" => {
data.flex_policy.max = number()?;
return Ok(());
}
// 主轴不足时的收缩权重,零表示不参与该项加权收缩。
"flexShrink" => {
data.flex_policy.shrink = number()?;
return Ok(());
}
_ => {}
}
// 通用增长权重也按非负有限数解码;它不等价于 boxWidth=fill。
if name == "flex" {
data.flex = number()?;
return Ok(());
}
// 后续只有容器解释 mainAlign/crossAlign/padding/gap,叶子同名自定义字段保持业务含义。
let container = data.is_container();
// alignSelf 可由任何孩子独立声明,crossAlign 只解释容器配置;auto 恢复继承父交叉轴策略。
if name == "alignSelf" || (container && name == "crossAlign") {
// alignSelf/crossAlign 都是字符串策略,先检查类型再解析 auto 或具体对齐方式。
let Value::Text(value) = value else {
return Err(bad());
};
// 孩子可用 auto 取消自身覆盖;父容器 crossAlign 则必须是具体策略。
if name == "alignSelf" {
data.align_self = if value == "auto" {
None
@@ -815,19 +961,25 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
}
return Ok(());
}
// 主轴对齐仅供容器使用,要求枚举文本并交给统一解析器验证。
if container && name == "mainAlign" {
// 主轴策略不能用数值或对象代替文本,统一从 MainAlign 解析允许集合。
let Value::Text(value) = value else {
return Err(bad());
};
data.main_align = crate::layout::MainAlign::parse(value)?;
return Ok(());
}
// 最后按 kind 解释自然尺寸、文本、颜色与滚动轴;不存在的原生字段不改业务属性本身。
match (&mut data.kind, name) {
// 三维视口自然宽高采用相同长度限制,实际分配还受盒模型与父约束影响。
(Kind::Viewport3D { width, .. }, "width") => *width = number()?,
(Kind::Viewport3D { height, .. }, "height") => *height = number()?,
// 二维视口自然尺寸有限,不能因世界内容无界而让视口本身无限展开。
(Kind::CanvasViewport { width, .. }, "width") => *width = number()?,
(Kind::CanvasViewport { height, .. }, "height") => *height = number()?,
(Kind::Text { value: text, .. }, "text") => {
// 原生文字内容只接受源字符串,不能将对象 repr 自动变为显示内容。
let Value::Text(value) = value else {
return Err(bad());
};
@@ -838,6 +990,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
text.clone_from(value);
}
(Kind::Text { size, .. }, "size") => {
// 文字 size 先通过通用数值界限,再限制为离散倍率,不能截断小数。
let n = number()?;
// Text size 是 1..8 的离散倍率,不能把任意浮点字号通过 as u32 悄悄截断。
if !(1.0..=8.0).contains(&n) || n.fract() != 0.0 {
@@ -846,6 +999,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
*size = n as u32;
}
(Kind::Text { color, .. } | Kind::Rect { color, .. }, "color") => {
// 文字/矩形填充色共用字符串检查;通过格式验证后才替换派生值。
let Value::Text(value) = value else {
return Err(bad());
};
@@ -854,16 +1008,20 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
color.clone_from(value);
}
(Kind::Scroll { .. }, "axis") => {
// ScrollBlock.axis 必须为字符串,再验证允许的 both/vertical/horizontal 集合。
let Value::Text(name) = value else {
return Err(bad());
};
data.scroll_axis = crate::scroll::Axis::parse(name)?;
}
// 滚动视口自然宽高与内容尺寸分开;真实分配可能被源 arrange 或盒模型覆盖。
(Kind::Scroll { width, .. }, "width") => *width = number()?,
(Kind::Scroll { height, .. }, "height") => *height = number()?,
// 矩形自然尺寸及圆角都按非负有限长度解码,不直接写任何 Mount 的排列结果。
(Kind::Rect { width, .. }, "width") => *width = number()?,
(Kind::Rect { height, .. }, "height") => *height = number()?,
(Kind::Rect { radius, .. }, "radius") => *radius = number()?,
// Spacer 自然间距使用同一数值界限,方向由所在布局协议决定。
(Kind::Spacer(size), "size") => *size = number()?,
// 剩余 kind 专有尺寸与容器字段按各自规则覆盖;未知字段忽略,不在此处删除或修改用户自定义属性。
(_, "padding") if container => data.padding = number()?,
+140 -1
View File
@@ -1,6 +1,9 @@
//! 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。
//! RenderDocument/Frame 包含绘制命令、Mount 几何、页面标签和相机投影。发布前统一检查数量、坐标、颜色及索引一致性。
//! 屏幕命中、逻辑坐标和世界 UI 的射线映射必须与绘制采用同一变换;这里的 ID 是数据身份,不是窗口线程可操作的托管引用。
//! 调用路径:API worker 用 NamespaceState 提交/清空候选;桥接发布前调用 validate。
//! 窗口壳用 Frame 命中结果构造输入,worker 再复核;软件渲染入口用 display_list 转为设备坐标命令。
//! 所有发布值拥有绘制所需原生数据,不能要求窗口线程通过 ID 解引用 VM。
//!
use std::collections::HashSet;
use std::sync::Arc;
@@ -14,21 +17,26 @@ use crate::{
ui_bridge::UiNode,
};
// HTTP/Console 未指定名字时的默认命名空间,初始化状态和源入口应使用同一值。
pub const DEFAULT_NAMESPACE: &str = "app.main";
#[derive(Clone, Debug)]
/// HTTP/GUI 共享的最近完整发布状态;submission 决定结果归属,revision 表示实际发布次数。
pub struct NamespaceState {
// 命名空间显示/API 身份,区别于每次输入的 submission 与 VM session。
pub name: String,
// 成功替换或 clear 才推进的发布版本,用于调用方识别可见文档更新。
pub revision: u64,
// 较新完整 PUT/DELETE 会改变提交身份;旧输入即使晚完成也不得覆盖新结果。
pub submission: u64,
// 最近完整发布的文档;失败候选不替换其几何,但 worker 可在旧完整画面上追加 fault 诊断后重新提交。
pub document: RenderDocument,
// viewport 是主窗口逻辑页面区域;window_viewports 同时记录仍存活的副窗口。
pub viewport: Option<crate::layout::Size>,
pub window_viewports: std::collections::BTreeMap<u64, crate::layout::Size>,
// 原生命令与运行时世代一起发布,防止窗口 ID 在跨 VM 时被误用。
pub runtime_session: u64,
// 待窗口壳消费的世代化原生命令,按同窗动作合并;sequence 只在各窗口内单调,不承诺跨窗口的全局发生顺序。
pub window_commands: Vec<crate::window::Envelope>,
}
@@ -66,12 +74,14 @@ impl NamespaceState {
return;
}
// 本方法接纳传入 session 作为当前世代,调用方负责只在成功发布边界调用;同资源同动作保留最新请求。
// 同一资源的同类动作保留最新请求,Focus/Resize 等不同动作仍分别排队。
for command in commands {
self.window_commands
.retain(|old| !old.command.same_kind(&command));
self.window_commands
.push(crate::window::Envelope { session, command });
}
// 按各命令原 sequence 排序,恢复同窗保留动作的先后;不同窗口的序号可以相同,不能由此推导跨窗时间顺序。
self.window_commands
.sort_by_key(|command| command.command.sequence);
}
@@ -84,11 +94,13 @@ impl NamespaceState {
}
/// 仅当提交身份仍是最新时替换完整文档并递增版本;已被取代的结果不发布。
/// 发布调用方须先完成文档验证与适用的世代检查;这里不自动 validate,也不设置 runtime_session 或发布窗口命令。
pub fn commit(&mut self, submission: u64, document: RenderDocument) -> Result<Option<u64>> {
// 先核对归属,再计算新版本;失败或过时的结果不会替换已显示文档。
if submission != self.submission {
return Ok(None);
}
// 先计算可用新版本,溢出时整个提交失败且旧文档仍在。
let revision = self
.revision
.checked_add(1)
@@ -100,7 +112,9 @@ impl NamespaceState {
/// 推进提交身份和发布版本,替换为空文档并清除运行时命令,使旧提交不能覆盖清空结果。
pub fn clear(&mut self) -> Result<u64> {
// clear 也创建新提交身份,让先前在途的合法候选失去发布资格。
let submission = self.next_submission()?;
// 发布版本与提交令牌都先检查溢出,状态更新集中在全部检查成功之后。
let revision = self
.revision
.checked_add(1)
@@ -119,15 +133,19 @@ impl NamespaceState {
/// 可以离开 VM 线程的完整展示值,包含源码投影、根帧和其他窗口的文档。
pub struct RenderDocument {
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
// 主文档携带的副窗口投影,最多一层,不是可任意递归的窗口树。
pub windows: std::collections::BTreeMap<u64, RenderDocument>,
#[serde(skip)]
// 仅本机增量渲染使用,不序列化;JSON 文档仍可凭 frame.commands 绘制,不需要持有 VM 句柄。
pub ui_root: Option<Arc<UiNode>>,
#[serde(default)]
// 完整输入源码或 Console 历史,Sources 使用原文与结构树范围定位。
pub source: String,
#[serde(default)]
// 可选源码投影根;空文档可无根,和视觉 ui_root 是不同的数据。
pub root: Option<CodeNode>,
#[serde(default)]
// 当前窗口完整视觉帧,副窗口文档也各有自己的 frame.window。
pub frame: Frame,
}
@@ -136,6 +154,7 @@ impl RenderDocument {
pub fn validate(&self) -> Result<()> {
// 校验页面兼容相机的缩放、pan 和有限 extent;独立视口变换随后按各表与 Mount 另行检查。
let surface = &self.frame.surface;
// 所有页面兼容相机分量先检查范围,之后窗口绘制与输入可使用同一有限变换。
if !surface.zoom.is_finite()
|| !(0.1..=4.0).contains(&surface.zoom)
|| ![surface.pan.x, surface.pan.y]
@@ -147,11 +166,14 @@ impl RenderDocument {
{
bail!("invalid page camera or extent");
}
// 主窗口之外最多十五个副窗口,与完整桌面窗口预算一致。
if self.windows.len() > 15 {
bail!("too many window projections");
}
// 副窗口只允许一层投影,键须等于其 frame.window,不能重复嵌入主窗口或再带嵌套 windows。
// 按每个副窗口独立校验帧,不允许借嵌套 map 绕开窗口计数。
for (id, document) in &self.windows {
// 副窗口不能冒用主窗口 ID,键/帧身份须相等,且不得再次嵌套 windows。
if *id == crate::window::MAIN_WINDOW
|| *id != document.frame.window
|| !document.windows.is_empty()
@@ -160,13 +182,16 @@ impl RenderDocument {
}
document.validate()?;
}
// 源码预算按 UTF-8 字节计,不以字符数绕过 1 MiB 输入限制。
if self.source.len() > 1_048_576 {
bail!("source exceeds the 1 MiB limit");
}
// 绘制命令预算独立于 Mount 数量,防止单个源 paint 产生无界命令。
if self.frame.commands.len() > 10_000 {
bail!("frame exceeds the 10,000 command limit");
}
// 零窗口 ID 无效,不作为默认主窗口的别名接纳。
if self.frame.window == 0 {
bail!("invalid window identity");
}
@@ -177,27 +202,35 @@ impl RenderDocument {
{
bail!("invalid command clip table");
}
// 只校验存在的裁剪矩形;None 表示该命令不加这层裁剪。
for clip in self.frame.command_clips.iter().flatten() {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
// 视图表与裁剪表同样是平行索引,非空半表会使绘制参数错配。
if !self.frame.command_views.is_empty()
&& self.frame.command_views.len() != self.frame.commands.len()
{
bail!("invalid command view table");
}
// 各二维变换先检查缩放/平移合法性,后面的绘制转换可据此使用。
for view in self.frame.command_views.iter().flatten() {
// 拒绝不可逆或超界视图变换,避免绘制与命中给出互相矛盾的坐标。
if !view.valid() {
bail!("invalid command view transform");
}
}
// 逐条分派到命令自己的几何/文本/材质检查,不能只检查命令总数。
for command in &self.frame.commands {
command.validate()?;
}
// 出现位置也设独立上限,防止少量命令背后展开过多可命中节点。
if self.frame.mounts.len() > 10_000 {
bail!("frame exceeds the 10000 mount limit");
}
// 标签 ID 必须非零且唯一,最多一个活动页;标题按 UTF-8 字节限长,标签数受 MAX_PAGES 限制。
// 记录全部标签身份,集合同时用于检测重复;active 数量另外限制为至多一个。
let mut tab_ids = HashSet::new();
// 同时检查标签数量、活动项数和每项身份/标题预算,不能只验证某个当前活动页。
if self.frame.tabs.len() > crate::ui_bridge::MAX_PAGES
|| self.frame.tabs.iter().filter(|tab| tab.active).count() > 1
|| self
@@ -209,18 +242,24 @@ impl RenderDocument {
bail!("invalid tab projection");
}
// 先收集全部 Mount ID 并校验几何,再第二遍核对关系下标,允许链接指向帧中较后位置。
// 先建整帧身份集合,第二轮才能允许关系链接指向后面才出现的节点。
let mut mount_ids = HashSet::new();
// 首轮逐项检查变换、身份与几何,收集关系检查所需的完整 ID 集合。
for mount in &self.frame.mounts {
// Mount 的二维变换与绘制表独立检查,不能因为命令合法就信任输入几何。
if mount.view.is_some_and(|view| !view.valid()) {
bail!("invalid Mount view transform");
}
// 业务 block_id 可因共享重复,但每个出现的 Mount ID 必须非零且帧内唯一。
if mount.id == 0 || mount.block_id.is_empty() || !mount_ids.insert(mount.id) {
bail!("invalid or duplicate mount identity");
}
validate_rect(mount.x, mount.y, mount.width, mount.height, 0.0)?;
// 祖先 clip 与节点原几何分别校验,裁剪不能包含 NaN 或负尺寸。
if let Some(clip) = mount.clip {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
// 相机手势额外内容区裁剪也须合法,避免手势使用未验证矩形。
if let Some(clip) = mount.viewport_clip {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
@@ -235,11 +274,14 @@ impl RenderDocument {
}
}
// 此处只保证关系目标在帧内,不做完整树环/双向一致性验证;组成与生命周期层承担更强结构不变量。
// 第二轮使用完整 ID 集合检查关系归属;这里不证明双向一致性或无环。
for mount in &self.frame.mounts {
// 只枚举实际存在的关系 ID,None 是合法边界,不补造关系。
for linked in [mount.parent, mount.first_child, mount.next_sibling]
.into_iter()
.flatten()
{
// 任何父/子/兄弟关系都必须落在本窗口帧内。
if !mount_ids.contains(&linked) {
bail!("mount links outside this frame");
}
@@ -256,7 +298,9 @@ impl RenderDocument {
{
bail!("frame root does not match its visual projection");
}
// 源码树单独检查节点 ID 唯一,不能和视觉 Mount ID 集合混用。
let mut ids = HashSet::new();
// 无源码树是合法空发布;有根则递归检查深度、范围和身份。
if let Some(root) = &self.root {
root.validate(0, &mut ids)?;
}
@@ -265,11 +309,14 @@ impl RenderDocument {
/// 将逻辑命令转换到设备比例并应用逐命令视图/裁剪;不自动调用 validate,发布入口须先校验。
pub fn display_list(&self, scale: f32) -> Result<DisplayList> {
// 窗口渲染调用此转换,借用已发布完整帧,不访问候选 VM 或业务对象。
let frame = &self.frame;
// 新建拥有数据的设备显示列表,先清背景再按逻辑绘制顺序追加。
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(parse_color(&frame.background)?));
// 按命令顺序取对应 clip/view;缺项按无裁剪/单位变换处理,完整文档合法性应在发布入口预先验证。
for (index, command) in frame.commands.iter().enumerate() {
// 表可整体缺省;存在裁剪时为单条命令压入/弹出,防止影响后续无关命令。
let clip = frame.command_clips.get(index).copied().flatten();
// clip 已在父视图/面板对应空间,只乘设备 scale;命令本体则乘 view.zoom 再加 view 平移。
if let Some(clip) = clip {
@@ -277,6 +324,7 @@ impl RenderDocument {
clip[0], clip[1], clip[2], clip[3], scale,
)));
}
// 没有逐命令 view 时使用单位变换;实际缩放由设备 scale×view.zoom 组成。
let view = frame
.command_views
.get(index)
@@ -289,6 +337,7 @@ impl RenderDocument {
.to_draw_command(scale * view.zoom)?
.translated(Point::new(view.x * scale, view.y * scale)),
);
// 本命令已经绘制,配对弹出先前压入的 clip,维持列表栈平衡。
if clip.is_some() {
list.push(DrawCommand::PopClip);
}
@@ -299,6 +348,7 @@ impl RenderDocument {
.and_then(|id| frame.mounts.iter().find(|m| m.id == id))
.filter(|mount| mount.panel.is_none())
{
// 焦点框使用与目标同一祖先裁剪,不能穿出滚动容器或有限面板。
if let Some(c) = focus.clip {
list.push(DrawCommand::PushClip(scaled_rect(
c[0], c[1], c[2], c[3], scale,
@@ -324,6 +374,7 @@ impl RenderDocument {
}
.to_draw_command(scale)?,
);
// 焦点框单独配对弹出 clip,避免把之后的故障提示也裁掉。
if focus.clip.is_some() {
list.push(DrawCommand::PopClip);
}
@@ -365,14 +416,20 @@ impl RenderDocument {
#[derive(Clone, Debug, Serialize, Deserialize)]
/// Sources 使用的源码层次节点;ID、名称、种类和行列范围都属于代码投影而不是运行时对象句柄。
pub struct CodeNode {
// 源码节点稳定选择键,带命名空间/提交来源;与视觉 block_id 字符串用途不同。
pub id: String,
// 源声明或语句的显示名称,Sources 用于树列表标题,不作为全局唯一键。
pub name: String,
// 源码结构类别(如 script/module/function),不等于运行时视觉组件种类。
pub kind: String,
#[serde(default)]
// 原源码起始行,零表示缺少来源,不能默认当作第一行。
pub start_line: usize,
#[serde(default)]
// 原源码结束行;两端都已知时必须不早于 start_line。
pub end_line: usize,
#[serde(default)]
// 源码语法投影的有序孩子,验证 ID 唯一性时共享整树集合。
pub children: Vec<CodeNode>,
}
@@ -383,9 +440,11 @@ impl CodeNode {
if depth > 64 {
bail!("architecture tree exceeds 64 levels");
}
// 空源码节点 ID 无法供 Sources 稳定选中或查找,发布时拒绝。
if self.id.is_empty() {
bail!("architecture node id cannot be empty");
}
// 集合覆盖整棵树,两个不同分支也不能重用同一节点 ID。
if !ids.insert(&self.id) {
bail!("duplicate architecture node id: {}", self.id);
}
@@ -393,6 +452,7 @@ impl CodeNode {
if self.start_line > 0 && self.end_line > 0 && self.start_line > self.end_line {
bail!("invalid source range for node {}", self.id);
}
// 递归共享全局身份集合并增加深度,不能每个子树重新计数而绕过约束。
for child in &self.children {
child.validate(depth + 1, ids)?;
}
@@ -401,6 +461,7 @@ impl CodeNode {
/// 按请求的代码节点身份或路径查找对应的源码投影。
fn find(&self, id: &str) -> Option<&CodeNode> {
// node(id) 先匹配当前节点,未命中再按孩子顺序递归返回首个匹配。
if self.id == id {
return Some(self);
}
@@ -409,38 +470,50 @@ impl CodeNode {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
/// 一个窗口的一次完整视觉发布:有序命令及其裁剪/变换、出现几何、焦点和版本。平行索引表必须和命令数量一致。
/// 一个窗口的视觉帧数据:有序命令及其裁剪/变换、出现几何、焦点和版本。布局/绘制也用此结构保存未发布草稿。
/// 是否完整可见由运行时的验证与稳定发布边界决定;仅持有 Frame 或 layout_version 不能证明它已发布。
pub struct Frame {
// 命中索引是可重建的本地优化,不作为序列化协议的一部分保存。
#[serde(skip)]
pub hit_index: Arc<crate::viewport::spatial::Index>,
#[serde(default)]
// 页面级兼容 pan/zoom 与内容范围;独立相机另由 command_views/Mount.view 表达。
pub surface: crate::page::Surface,
#[serde(default = "default_window")]
// 非零原生窗口身份,副窗口 map 的键必须与它相同。
pub window: u64,
#[serde(default = "default_background")]
// 页面清屏颜色文本,每帧显示列表先生成一条 Clear。
pub background: String,
#[serde(default)]
// 按最终绘制顺序保存逻辑命令,后项覆盖前项;不包含显示列表追加的焦点/故障层。
pub commands: Vec<RenderCommand>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
// 逐命令有效裁剪 [x,y,width,height];可整体为空,否则必须与 commands 等长。
pub command_clips: Vec<Option<[f32; 4]>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
// 逐命令二维投影,缺省单位变换;clip 已在投影后的对应视图空间。
pub command_views: Vec<Option<crate::viewport::Transform>>,
#[serde(skip)]
// 仅本机装配源覆盖/面板时使用的命令出现归属表,不进入 JSON;display_list 绘制本身不读取它。
pub command_mounts: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
// 本窗口所有出现位置的平面索引,业务身份可重复,Mount ID 必须唯一。
pub mounts: Vec<MountGeometry>,
#[serde(default)]
// 本次布局发布版本,与 session/window 一起判断原生输入是否过期。
pub layout_version: u64,
#[serde(default)]
// 运行时世代与 layout_version 共同确定输入归属;仅布局版本相同不能跨 VM 接受事件。
pub session: u64,
#[serde(default)]
// 焦点 Mount ID,普通根焦点在 display_list 追加,面板焦点已组合进纹理。
pub focus: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
// 当前窗口标签投影,ID 唯一且最多一个 active。
pub tabs: Vec<crate::tabs::Tab>,
#[serde(default)]
// 停止域诊断,显示列表追加提示且 hit_test 拒绝继续选择交互目标。
pub fault: Option<String>,
}
@@ -474,32 +547,48 @@ pub struct MountGeometry {
// 可嵌套的世界 UI 映射,把页面坐标还原到此出现所在面板纹理空间。
pub panel: Option<Arc<crate::scene3d::panel::Mapping>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
// 是否二维相机视口,供窗口手势定位选择。
pub canvas_viewport: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
// 是否三维视口,原生拾取/相机事件据此进入对应协议。
pub scene3d_viewport: bool,
#[serde(default)]
// 三维视口是否具备相机控制器,camera_at 不只凭 scene3d_viewport 接受手势。
pub camera_controls: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 视口内容区的额外裁剪,供相机手势锚点检查;区别于一般 Mount 的祖先 clip。
pub viewport_clip: Option<[f32; 4]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 布局空间到当前视图/面板纹理空间的二维变换,None 为单位变换。
pub view: Option<crate::viewport::Transform>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 祖先合成后的可见裁剪,与投影后的点比较,不能当作布局空间矩形。
pub clip: Option<[f32; 4]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 当前有效滚动位置和上限,不是原始请求值;非滚动容器为 None。
pub scroll: Option<[f32; 4]>,
// 本帧内出现身份,重建/换页可变化,不是托管 ObjectRef。
pub id: u64,
// 业务对象身份文本,共享对象的多个 Mount 可拥有同一 block_id。
pub block_id: String,
// 父出现 ID;关系目标必须在本帧中,不允许跨窗口链接。
pub parent: Option<u64>,
// 首个子出现 ID,通过 next_sibling 遍历同级。
pub first_child: Option<u64>,
// 下一个同级出现 ID,保留组成展开次序。
pub next_sibling: Option<u64>,
// 出现的布局空间绝对横坐标,投影通过 view/panel 另算。
pub x: f32,
// 出现的布局空间绝对纵坐标,不随相机直接改写。
pub y: f32,
// 本次布局分配宽度,可不同于共享业务对象的自然 width。
pub width: f32,
// 本次布局分配高度,不和其他出现位置共用几何存储。
pub height: f32,
// 原生粗命中资格,真正源 hitTest/事件监听状态仍由 worker 复核。
pub interactive: bool,
#[serde(default)]
// 是否存在 pointer 系列处理需求,供窗口指针输入路由使用。
pub pointer_events: bool,
}
@@ -518,12 +607,16 @@ impl MountGeometry {
}
/// 把视图位移换算成目标出现的局部位移,处理缩放与世界 UI 投影差异。
pub fn logical_delta(&self, point: Option<[f32; 2]>, delta: [f32; 2]) -> Option<[f32; 2]> {
// page_input 的相机手势和 UiBridge 的 scroll 输入共用;返回的是本出现布局单位,视口相机自身的 zoom 由各调用方再解释。
// 累计二维缩放用于把最终视图差值还原到布局位移;透视面板还需先投影两个端点。
let zoom = self.view.unwrap_or_default().zoom;
// 普通二维映射为线性,只除累计 zoom 即可;世界面板透视非线性,必须映射两个点求差。
if self.panel.is_none() {
return Some([delta[0] / zoom, delta[1] / zoom]);
}
// 面板投影非线性,缺少当前位置就无法正确换算 delta,明确返回 None。
let point = point?;
// 先反投影手势基点;clipped=false 允许捕获拖动移出面板,投影失败则停止处理。
let current = self.view_point(Point::new(point[0], point[1]), false)?;
// 对 point 与 point+delta 分别投影,使用 clipped=false 允许捕获拖动越出面板;投影不可逆则返回 None。
let previous =
@@ -535,6 +628,7 @@ impl MountGeometry {
}
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
pub fn contains(&self, point: Point) -> bool {
// 先完成世界面板反投影和遮挡检查,失败直接视为未命中。
let Some(point) = self.view_point(point, true) else {
return false;
};
@@ -550,6 +644,8 @@ impl MountGeometry {
impl Frame {
/// 从顶层绘制顺序向后查找可控制的视口,命中与裁剪均成立才接收相机手势。
pub fn camera_at(&self, point: Point) -> Option<&MountGeometry> {
// 本方法不检查 frame.fault 或 session;shell 接纳输入的外层流程仍须验证运行域和发布身份。
// 窗口手势目标选择使用空间索引候选次序,但仍须逐个通过视口种类、外框和内容裁剪。
self.hit_index
.candidates(point, self.mounts.len())
.into_iter()
@@ -595,18 +691,27 @@ fn default_background() -> String {
/// 可校验、可序列化的逻辑绘制命令;转换到设备像素在 display_list 阶段完成。
pub enum RenderCommand {
#[serde(skip_deserializing)]
// 仅本机可构造的不可变图像资源命令,禁止从外部 JSON 反序列化任意图像资源。
Image {
// 旋转/缩放围绕的逻辑原点;与下面局部图像矩形偏移分开保存。
x: f32,
y: f32,
// 显示矩形的逻辑尺寸,可与原始像素宽高不同;设备比例在 to_draw_command 中应用。
width: f32,
height: f32,
// 相对 origin 的局部矩形起点,Sprite 锚点通过负偏移表示,不移动资源本身的像素。
offset_x: f32,
offset_y: f32,
// 弧度旋转角,设备同比缩放不改变角度。
angle: f32,
// 整体混合透明度,发布验证要求位于 [0,1]。
opacity: f32,
// 可选遮罩着色;渲染器用 tint 替换采样 RGB,保留图像采样的 alpha,不使用 tint.alpha;None 保留原图颜色。
tint: Option<Color>,
// 原生像素由 Arc 独立保活;GUI 绘制无需用源对象 ID 查 VM 堆。
image: Arc<crate::render::image::Image>,
},
// 屏幕矩形中的三维 View 快照,世界几何和相机数据由原生 Arc 独立拥有。
Scene3D {
x: f32,
y: f32,
@@ -614,6 +719,7 @@ pub enum RenderCommand {
height: f32,
view: Arc<crate::scene3d::View>,
},
// 逻辑坐标填充矩形,颜色保存文本,设备缩放和通道解码在显示列表转换时完成。
RoundedRect {
x: f32,
y: f32,
@@ -623,6 +729,7 @@ pub enum RenderCommand {
radius: f32,
color: String,
},
// 逻辑坐标描边矩形,stroke_width 与半径都参与设备同比缩放。
StrokeRoundedRect {
x: f32,
y: f32,
@@ -634,6 +741,7 @@ pub enum RenderCommand {
stroke_width: f32,
color: String,
},
// 逻辑文字和离散字号,可选 wrap_bounds 明确是否换行;旧数据缺省为不换行。
Text {
x: f32,
y: f32,
@@ -649,7 +757,9 @@ pub enum RenderCommand {
impl RenderCommand {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
pub(crate) fn validate(&self) -> Result<()> {
// RenderDocument.validate 逐条调用,按命令种类检查自身数据;不在这里尝试光栅化。
match self {
// 本机图片命令除了外框还包含偏移、角度和透明度,资源像素尺寸必须非零。
Self::Image {
x,
y,
@@ -663,6 +773,7 @@ impl RenderCommand {
..
} => {
validate_rect(*x, *y, *width, *height, 0.)?;
// 所有变换参数有限且 opacity 在 [0,1],防止无效混合或图像采样。
if ![offset_x, offset_y, angle, opacity]
.iter()
.all(|v| v.is_finite())
@@ -673,6 +784,7 @@ impl RenderCommand {
bail!("invalid image command");
}
}
// 三维视口外框与 view 快照各自验证,场景数据不能仅因外框合法就直接绘制。
Self::Scene3D {
x,
y,
@@ -681,10 +793,12 @@ impl RenderCommand {
view,
} => {
validate_rect(*x, *y, *width, *height, 0.0)?;
// View 的投影/场景不变量失败时拒绝发布,不让无效射线/深度计算进入渲染。
if !view.valid() {
bail!("invalid 3D view");
}
}
// 填充矩形检查有限非负尺寸/圆角,再解码颜色格式。
Self::RoundedRect {
x,
y,
@@ -696,6 +810,7 @@ impl RenderCommand {
validate_rect(*x, *y, *width, *height, *radius)?;
parse_color(color)?;
}
// 描边矩形除公共几何外还必须具有正且有限的描边宽度。
Self::StrokeRoundedRect {
x,
y,
@@ -712,6 +827,7 @@ impl RenderCommand {
}
parse_color(color)?;
}
// 文字无框时只要求坐标有限;有 wrap 框时还校验框和端点的协议范围。
Self::Text {
x,
y,
@@ -720,12 +836,14 @@ impl RenderCommand {
color,
wrap_bounds,
} => {
// 首先拒绝文字原点 NaN/无穷,后续测量与绘制不能处理这类坐标。
if !x.is_finite() || !y.is_finite() {
bail!("text coordinates must be finite");
}
// 有换行框时同时限制框尺寸、原点与右/下端点;无框文本在此只校验坐标有限性和文本/字号预算。
if let Some([width, height]) = wrap_bounds {
validate_rect(*x, *y, *width, *height, 0.0)?;
// 换行框本身及其远端坐标都不得超过视觉长度预算,避免原点与宽高相加后越界。
if [*x, *y, *width, *height, *x + *width, *y + *height]
.iter()
.any(|value| value.abs() > crate::ui_bridge::MAX_DIMENSION as f32)
@@ -733,9 +851,11 @@ impl RenderCommand {
bail!("text wrap bounds exceed limits");
}
}
// 单条文字按 UTF-8 字节限长 16 KiB,不按字符数估算资源用量。
if value.len() > 16_384 {
bail!("text command exceeds 16 KiB");
}
// size 是 1..8 的样式等级,不是任意像素字号。
if !(1..=8).contains(size) {
bail!("text size must be between 1 and 8");
}
@@ -747,7 +867,9 @@ impl RenderCommand {
/// 把一条已验证的发布命令变成原生绘制原语,缩放几何和文字样式。
fn to_draw_command(&self, scale: f32) -> Result<DrawCommand> {
// display_list 调用这里把逻辑命令同比缩放为设备原语;二维平移由调用方随后统一追加。
Ok(match self {
// 图像保留不可变资源 Arc,原点/局部矩形按比例缩放,角度、透明度和 tint 保持其含义。
Self::Image {
x,
y,
@@ -767,6 +889,7 @@ impl RenderCommand {
opacity: *opacity,
tint: *tint,
},
// 只缩放三维视口屏幕矩形,内部相机与世界场景仍由 View 快照解释。
Self::Scene3D {
x,
y,
@@ -777,6 +900,7 @@ impl RenderCommand {
rect: scaled_rect(*x, *y, *width, *height, scale),
view: view.clone(),
},
// 填充矩形位置/尺寸/圆角同比缩放,颜色转为渲染器通道值。
Self::RoundedRect {
x,
y,
@@ -789,6 +913,7 @@ impl RenderCommand {
radius: radius * scale,
color: parse_color(color)?,
},
// 描边宽度也乘设备比例,保持与矩形外观一致的视觉厚度。
Self::StrokeRoundedRect {
x,
y,
@@ -812,12 +937,14 @@ impl RenderCommand {
wrap_bounds,
// 根据是否提供换行边界选择 WrappedText 或 Text;两者都使用同一等级字号与设备缩放。
} => match wrap_bounds {
// 有 wrap 框时产生 WrappedText,排字区域和样式同时缩放,保持换行语义。
Some([width, height]) => DrawCommand::WrappedText {
rect: scaled_rect(*x, *y, *width, *height, scale),
value: value.clone(),
style: crate::text::TextStyle::ui(*size).scaled(scale),
color: parse_color(color)?,
},
// 旧 JSON 未提供 wrap_bounds 时保留不换行 Text 行为,不凭测量结果补造裁剪。
None => DrawCommand::Text {
origin: Point::new(x * scale, y * scale),
value: value.clone(),
@@ -841,6 +968,7 @@ fn default_text_size() -> u32 {
/// 统一检查有限矩形坐标、尺寸与可选圆角,阻止异常值进入光栅循环。
fn validate_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Result<()> {
// 各命令/clip 共用基础几何检查;世界坐标可负,但四个分量必须有限。
if !x.is_finite() || !y.is_finite() || !width.is_finite() || !height.is_finite() {
bail!("rectangle values must be finite");
}
@@ -858,6 +986,7 @@ fn scaled_rect(x: f32, y: f32, width: f32, height: f32, scale: f32) -> Rect {
/// 解析支持的十六进制 RGB/RGBA 文本,拒绝错误长度或非法字符。
pub fn parse_color(value: &str) -> Result<Color> {
// 属性解码、文档验证和显示列表转换共用颜色入口,必须带显式 # 前缀。
let hex = value
.strip_prefix('#')
.ok_or_else(|| anyhow::anyhow!("color must begin with #"))?;
@@ -865,6 +994,7 @@ pub fn parse_color(value: &str) -> Result<Color> {
if !hex.is_ascii() || (hex.len() != 6 && hex.len() != 8) {
bail!("color must use #RRGGBB or #RRGGBBAA");
}
// 已确认 ASCII 和长度后,可安全按字节成对解析 R/G/B/A,非法十六进制仍返回错误。
let parse = |range| u8::from_str_radix(&hex[range], 16);
Ok(Color::rgba(
parse(0..2)?,
@@ -880,6 +1010,7 @@ mod tests {
#[test]
/// 旧文字JSON缺wrap_bounds仍走不换行命令;合法边界随DPI缩放,负宽度在发布验证中拒绝。
fn text_wrap_bounds_validate_and_legacy_text_remains_unwrapped() {
// 从缺省 wrap_bounds 的旧 JSON 反序列化,验证兼容默认值而非手工构造近似命令。
let mut command: RenderCommand = serde_json::from_str(
r##"{"type":"text","x":0,"y":0,"value":"text","size":1,"color":"#000000"}"##,
)
@@ -888,6 +1019,7 @@ mod tests {
command.to_draw_command(1.0).unwrap(),
DrawCommand::Text { .. }
));
// 添加合法 40×20 框,后面以 1.25 DPI 核对实际绘制原语尺寸。
if let RenderCommand::Text { wrap_bounds, .. } = &mut command {
*wrap_bounds = Some([40.0, 20.0]);
}
@@ -895,6 +1027,7 @@ mod tests {
assert!(
matches!(command.to_draw_command(1.25).unwrap(),DrawCommand::WrappedText{rect,..}if rect.width==50.0 && rect.height==25.0)
);
// 再破坏为负宽,要求发布验证拒绝,而不是把尺寸夹到零继续显示。
if let RenderCommand::Text { wrap_bounds, .. } = &mut command {
*wrap_bounds = Some([-1.0, 20.0]);
}
@@ -906,6 +1039,7 @@ mod tests {
#[test]
/// 默认空文档可通过验证且没有结构根/绘图命令,支持初始状态与清空发布。
fn empty_document_is_valid_and_blank() {
// 默认值代表初始化/清空后的合法空状态,既不需要源码树也不需要业务命令。
let document = RenderDocument::default();
document.validate().unwrap();
assert!(document.root.is_none());
@@ -915,6 +1049,7 @@ mod tests {
#[test]
/// 检查六位色默认alpha255,八位色按RRGGBBAA解码,避免将末尾透明度当成首通道。
fn parses_rgb_and_rgba_colors() {
// 六位色默认不透明,八位色最后两位为 alpha,分别核对通道顺序。
assert_eq!(
parse_color("#102030").unwrap(),
Color::rgba(16, 32, 48, 255)
@@ -932,13 +1067,16 @@ mod window_projection_tests {
#[test]
/// 同会话窗口命令可累积,新会话发布先丢旧命令;clear重置runtime_session并清空所有原生命令。
fn replacement_and_reset_discard_old_window_projections() {
// 模拟 API 发布状态,先在同一 VM session 累积不同动作,再更换世代。
let mut state = NamespaceState::default();
// 第一动作 Focus 与随后 Resize 类型不同,同世代合并不应互相覆盖。
let command = crate::window::Command {
window: 1,
sequence: 1,
action: crate::window::Action::Focus,
};
state.publish_window_commands(1, vec![command.clone()]);
// 第二动作使用更大 sequence,检验同世代累积及换世代清旧命令。
let resize = crate::window::Command {
window: 1,
sequence: 2,
@@ -952,6 +1090,7 @@ mod window_projection_tests {
state.publish_window_commands(2, vec![resize]);
assert_eq!(state.window_commands.len(), 1);
assert_eq!(state.window_commands[0].session, 2);
// clear 应清除当前 runtime_session 和待传输命令,旧 VM 请求不能在重置后落到新窗口。
state.clear().unwrap();
assert_eq!(state.runtime_session, 0);
assert!(state.window_commands.is_empty());
+3
View File
@@ -1,4 +1,7 @@
//! 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。
//! main.rs 通过 crate 根重导出这些模块,旧的 crate::block 等调用路径仍落在本目录。
//! 依赖方向:bridge/runtime 操作这些托管模型;窗口壳只消费发布值,不能直接修改 VM 中的对象图。
//! 新读者可先看 block(业务身份)→ presentation/mount(每窗口出现)→ snapshot/document(发布数据)。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 维护托管属性绑定及其读依赖;计算结果写入目标,绑定令牌不应让隐藏目标意外常驻。
+154 -2
View File
@@ -2,6 +2,10 @@
//! Mount 保存父子关系、出现几何和布局期间的查询上下文;MountTable 用弱索引定位对象,不靠索引强行保活业务树。
//! 同步会安排 onMount/onUnmount。布局取消时使用 Checkpoint 恢复未发布状态,避免半完成的出现关系影响下一次刷新。
//!
//! 调用路径:UiBridge 在布局前 checkpoint/synchronize(preparing=true),完成后提交几何,取消则 restore。
//! 源 measure/arrange/事件通过 Method 调用 Mount API;生命周期返回 vm_sequence 驱动,调用方安排实际执行。
//! 活动 Mount 由 desktop.mounts 强持有;MountTable 与反向层次关系仅保存弱索引。
//!
//! Managed presentation contexts. The active desktop owns the mount dictionary;
//! reverse links and the host registry are weak, so shared content cannot retain a Page.
use crate::document::{Frame, MountGeometry};
@@ -19,14 +23,19 @@ use std::{
pub struct Mount {
// 组件是强引用;页面、父节点和兄弟链接是弱引用,防止反向链接形成保活环。
pub block: ObjectRef,
// 所属 Page 弱身份,用于区分换页后的出现上下文,不让共享内容反向保活页面。
pub page: Option<WeakObjectRef>,
// 父出现弱身份,源 parent() 在目标已消失时返回 Nil。
pub parent: Option<WeakObjectRef>,
// 首子出现弱身份,后续兄弟沿 next_sibling 查找。
pub first_child: Option<WeakObjectRef>,
// 同级下一出现弱身份,几何帧中的 ID 链在同步第二遍解析为此链接。
pub next_sibling: Option<WeakObjectRef>,
// 已发布几何与每次出现的滚动位置分开保存,同一 Block 的多个位置互不覆盖。
// 桥接同步的几何与每次出现的滚动位置分开保存;准备期可暂存结构几何,不能据此认定 GUI 已发布。
pub geometry: MountGeometry,
// 此出现的用户请求偏移;帧中 geometry.scroll 才是钳制后的有效位置与上限。
pub scroll_offset: [f32; 2],
// 最后同步的布局版本,Bounds 查询会一并返回,便于调用方识别几何属于哪轮发布。
// 最后同步的布局版本,Bounds 查询会一并返回;准备同步也会写本轮版本,不等于 GUI 已接收该版本。
pub version: u64,
// false 后禁止依赖当前几何的操作;外部即使还持有 Mount 对象也不能把它当活动出现。
pub mounted: bool,
@@ -34,6 +43,7 @@ pub struct Mount {
pub tasks: Option<ObjectRef>,
// Private derived scratch, installed before the read-only layout call and
// cleared by geometry commit. It contains no managed references or roots.
// 布局只读协议使用的临时派生上下文;不拥有托管根,提交或退役时清空。
pub layout: Option<Arc<Mutex<crate::layout::Context>>>,
}
@@ -58,6 +68,7 @@ impl ObjectPayload for Mount {
// 仅 block 和 tasks 是 payload 的强拥有边;page/parent/child/sibling 是弱边,不参与 trace。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
visit(self.block);
// Aster GC 只额外追踪存在的任务作用域,弱页面/层次链不参与强引用追踪。
if let Some(tasks) = self.tasks {
visit(tasks);
}
@@ -67,30 +78,48 @@ impl ObjectPayload for Mount {
#[derive(Debug, Clone, Copy)]
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
enum Query {
// 读取本出现已提交或排列期可用的 Bounds;测量阶段不可提前读几何。
Bounds,
// 设置此出现的绝对滚动请求,实际范围由滚动层钳制。
ScrollTo,
// 在此出现请求偏移上累加位移,不写共享 Block 的滚动状态。
ScrollBy,
// 查询有效滚动位置和最大值,不直接返回未钳制请求。
ScrollPosition,
// 联动滚动祖先,把此出现送入有效可见区域。
ScrollIntoView,
// 用此视口有限尺寸和场景边界拟合相机。
FitView,
// 从此三维视口查询射线交点和原托管 mesh。
Pick3D,
// 本出现局部坐标经二维/面板投影变为页面视图坐标。
ToView,
// 页面视图点逆投影到本出现局部坐标,失败可返回 Nil。
ToLocal,
// 弱查询父 Mount,不制造新的父拥有关系。
Parent,
// 弱查询首子 Mount,空或失效均返回 Nil。
FirstChild,
// 弱查询下一同级出现,不按相同业务对象去重。
NextSibling,
// 弱查询所属页面,退役上下文可能仍有页面弱观察。
Page,
// 请求滚入可见并取得焦点,真正窗口发布在后续阶段完成。
Focus,
// 只查询此 Mount 的提交几何包含关系,不执行源 Block.hitTest。
HitTest,
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
// 本桌面名义类型构造的绑定方法契约,Bounds 返回类型也从此提取。
signature: endor_core::CallSignature,
// 方法实际绑定的 Mount,由 ContextNativeFunction captures 保活。
owner: ObjectRef,
// 方法对象拥有 Mount,但不能经桌面反向保活整个窗口;需要桌面能力时再 upgrade。
desktop: WeakObjectRef,
// 操作分派枚举,同时决定显式 arity、效果和返回契约。
query: Query,
}
@@ -102,6 +131,7 @@ impl NativeCallable for Method {
/// 适配器已绑定 Mount;坐标测试/转换与scrollTo/By接受两个数,其余层次/几何/焦点查询零参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(
// 这些坐标/滚动方法需要两个 Number,其余操作无显式实参,receiver 已捕获。
if matches!(
self.query,
Query::HitTest
@@ -120,6 +150,7 @@ impl NativeCallable for Method {
/// 读取具体出现位置,先检查仍挂载与几何可用性,再执行层次、坐标、焦点、滚动或拾取操作,避免使用已退役上下文。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// VM 调用绑定方法后先复制当前 payload,避免分配返回对象或改堆时持有旧存储借用。
let mount = cx.store().payload::<Mount>(self.owner, "Mount")?.clone();
// 层次查询允许观察已经脱离的对象(通常得到 Nil);几何、焦点、滚动、拾取等必须仍挂接。
if !mount.mounted
@@ -154,10 +185,13 @@ impl NativeCallable for Method {
} else {
0
};
// 入口再次核对实际参数数目,防止绕过元数据路径导致 args 下标越界。
if args.len() != arity {
return Err(VmError::InvalidArgument("incorrect Mount method arity"));
}
// 所有分支统一产生 Value,再交给 NativeResult::Return 的 VM 拥有关系协议。
let value = match self.query {
// 坐标互转都要求已经提交的几何,不能混用本轮布局临时 bounds 与旧相机映射。
Query::ToView | Query::ToLocal => {
// 布局期间几何尚未提交,坐标转换不能把临时 bounds 与上帧投影混用。
if mount.layout.is_some() {
@@ -165,13 +199,17 @@ impl NativeCallable for Method {
"coordinate conversion requires committed geometry",
));
}
// 显式输入是本地/视图坐标,按 coordinate 规则拒绝非法数值后再做空间转换。
let point = crate::geometry::Point::new(
crate::scroll::coordinate(&args[0])?,
crate::scroll::coordinate(&args[1])?,
);
// 根据查询方向决定正向或逆向映射;Option 保留不可投影/遮挡的失败结果。
let point =
// 查询方向决定先正投影局部点还是逆投影视图点;不能仅交换输出字段代替逆变换。
if matches!(self.query, Query::ToView) {
// ToView 先将局部点加节点原点,再套二维 view;若在世界面板内,还继续向外投影到页面。
// 先加本节点布局原点再套累计二维 view,之后如有 panel 再投影到外层页面。
let point = mount.geometry.view.unwrap_or_default().point(
crate::geometry::Point::new(
point.x + mount.geometry.x,
@@ -194,7 +232,9 @@ impl NativeCallable for Method {
};
// 转换成功才分配名义 Point;失败返回 Nil,不把无效数值写进结果属性。
if let Some(point) = point {
// 成功后创建名义 Point 的字段存储,不把转换结果作为未标类型的近似对象返回。
let result = cx.new_object()?;
// 需升级方法捕获的弱桌面以取得 Point 类型,桌面失效时明确报错。
let desktop = cx.upgrade(self.desktop)?.ok_or(VmError::InvalidArgument(
"coordinate conversion desktop expired",
))?;
@@ -220,15 +260,19 @@ impl NativeCallable for Method {
)?;
Value::Nil
}
// 滚动层负责嵌套祖先、裁剪与偏移夹取,此适配器只提供具体出现身份。
Query::ScrollIntoView => {
crate::scroll::into_view(cx, self.owner, self.desktop)?;
Value::Nil
}
// 拟合以 Mount 的有限视口尺寸为准;不是全局 Page 相机重置。
Query::FitView => {
crate::viewport::fit(cx, &mount)?;
Value::Nil
}
// 射线拾取需要所属桌面构造名义命中值,先确认弱桌面仍有效。
Query::Pick3D => {
// 升级拾取所需桌面,不能通过旧 Mount 在已回收窗口环境里构造类型。
let desktop = cx
.upgrade(self.desktop)?
.ok_or(VmError::InvalidArgument("viewport desktop expired"))?;
@@ -244,17 +288,21 @@ impl NativeCallable for Method {
}
// 返回帧中已钳制的有效位置与最大偏移,不直接暴露可能尚未投影的 scroll_offset 原值。
Query::ScrollPosition => {
// 只有 geometry.scroll 存在的出现才可查询滚动状态,普通节点明确报错。
let p = mount
.geometry
.scroll
.ok_or(VmError::InvalidArgument("Mount is not scrollable"))?;
// 滚动查询返回 x/y/maxX/maxY 的新记录,字段源于帧有效投影。
let result = cx.new_object()?;
// 逐项把有效偏移与上限写入返回记录,不暴露内部可变数组。
for (name, value) in [("x", p[0]), ("y", p[1]), ("maxX", p[2]), ("maxY", p[3])] {
cx.set_attr(result, name, Value::Number((value as f64).into()))?;
}
Value::Object(result)
}
Query::Bounds => {
// source_layout::Step 在调用 arrange 前填临时 Context.bounds;measure 阶段保持 None,避免读取尚未决定的分配。
// 布局只读调用中可查询当前安排阶段的临时 bounds;测量时尚无 bounds,明确报错。
// 非布局期则读取最后提交的 MountGeometry。
let geometry = if let Some(layout) = &mount.layout {
@@ -273,6 +321,7 @@ impl NativeCallable for Method {
mount.geometry.height,
]
};
// 创建 Bounds 结果对象;名义定义由本方法签名保证与当前桌面一致。
let bounds = cx.new_object()?;
// 从本方法返回签名取得 Bounds 的名义类型,避免生成一个仅字段相似的普通对象。
let endor_core::CallType::Single(endor_core::AnnotationType::Nominal(definition)) =
@@ -281,6 +330,7 @@ impl NativeCallable for Method {
return Err(VmError::InvalidArgument("invalid Mount bounds signature"));
};
cx.attach_type(bounds, *definition)?;
// 按 x/y/width/height 写入所选几何,另附 mount.version 作为 layoutVersion。
for (name, value) in [
("x", geometry[0]),
("y", geometry[1]),
@@ -299,9 +349,11 @@ impl NativeCallable for Method {
// 请求焦点先让出现进入可见范围,再检查可聚焦资格并写入桌面请求;后续发布负责实际生效。
Query::Focus => {
crate::scroll::into_view(cx, self.owner, self.desktop)?;
// 只有可交互出现可以聚焦;滚入可见请求先由滚动层处理,焦点资格不由字符串类型名决定。
if !mount.geometry.interactive {
return Err(VmError::InvalidArgument("Mount is not focusable"));
}
// 焦点写入当前桌面的待处理请求,弱桌面过期则不能再设置。
let desktop = cx
.upgrade(self.desktop)?
.ok_or(VmError::InvalidArgument("Mount window has expired"))?;
@@ -315,22 +367,27 @@ impl NativeCallable for Method {
}
// 这里是 Mount 已提交几何的 contains 查询;源 Block 的 hitTest 覆盖由 source_hit 驱动另行执行。
Query::HitTest => {
// 布局临时上下文存在时 contains 不保证对应最终投影,必须拒绝源 Mount.hitTest。
if mount.layout.is_some() {
return Err(VmError::InvalidArgument(
"Mount hitTest is unavailable during layout",
));
}
// 命中坐标只要求 Number 可转有限 f32;与 scroll::coordinate 不同,此处不限制绝对值至一百万。
// hitTest 的输入只需有限 f32;此闭包刻意不用带一百万绝对值上限的滚动坐标解析。
let number = |value: &Value| -> Result<f32, VmError> {
// 只接受 Number,不把字符串或其他值隐式转为坐标。
let Value::Number(number) = value else {
return Err(VmError::InvalidArgument(
"point coordinates must be numbers",
));
};
// 解析到实际几何使用的 f32,溢出为无穷仍由下一步拒绝。
let number = number
.to_string()
.parse::<f32>()
.map_err(|_| VmError::InvalidArgument("invalid point coordinate"))?;
// NaN/无穷没有有效包含关系,不能送入 Rect 或面板射线计算。
if !number.is_finite() {
return Err(VmError::InvalidArgument("invalid point coordinate"));
}
@@ -341,7 +398,9 @@ impl NativeCallable for Method {
number(&args[1])?,
)))
}
// 其余枚举仅为弱层次查询,允许观察已脱离上下文;穷尽匹配保证不会把几何操作落到此处。
query => {
// 选择对应弱边再尝试升级,不额外保存或强连父/子/页面。
let weak = match query {
Query::Parent => mount.parent,
Query::FirstChild => mount.first_child,
@@ -368,10 +427,13 @@ fn mount_signature(
query: Query,
) -> Result<endor_core::CallSignature, VmError> {
use endor_core::{AnnotationType as T, CallEffect, CallSignature, CallType};
// 每个返回名义类型从该桌面 types 获取,不能拿另一 VM 同名类型拼接契约。
let nominal = |name| crate::ui_bridge::desktop_type(store, name, desktop).map(T::Nominal);
// 层次、拾取和坐标转换可能没有结果,使用 nullable 名义类型;变更操作返回 Unit。
let returns = match query {
// bounds 总返回 Bounds,测量期不可用时走错误而非 nullable。
Query::Bounds => nominal("Bounds")?.into(),
// 层次弱边可能为空或目标已回收,因此结果允许 Nil。
Query::Parent | Query::FirstChild | Query::NextSibling => {
CallType::nullable(nominal("Mount")?)
}
@@ -379,6 +441,7 @@ fn mount_signature(
Query::HitTest => T::Bool.into(),
Query::Pick3D => CallType::nullable(nominal("RayHit3D")?),
Query::ToView | Query::ToLocal => CallType::nullable(nominal("Point")?),
// 滚动位置目前返回普通记录,契约 Any 与实现字段记录保持一致。
Query::ScrollPosition => T::Any.into(),
_ => T::Unit.into(),
};
@@ -391,6 +454,7 @@ fn mount_signature(
} else {
CallEffect::ReadOnly
};
// 显式参数契约与 Method.arity 保持相同分组,receiver 不计入已绑定实参。
let parameters = if matches!(
query,
Query::HitTest
@@ -404,6 +468,7 @@ fn mount_signature(
} else {
vec![]
};
// 先构造参数与效果,再替换精确返回契约,保留 nullable 名义类型信息。
let mut signature = CallSignature::exact(parameters, T::Any, effect);
signature.returns = returns;
Ok(signature)
@@ -414,29 +479,42 @@ fn mount_signature(
pub struct MountTable {
// Mount ID 到托管弱身份,真实强拥有者是 desktop.mounts 字典;order 单独保存原布局顺序。
pub active: HashMap<u64, WeakObjectRef>,
// 原布局先序 ID 列表,弱 HashMap 本身无序,生命周期退役需要这个确定顺序。
order: Vec<u64>,
}
#[derive(Debug)]
/// 同步出现关系时的共享临时工作状态,保存本轮重用、建立和退役安排。
/// MountTable::synchronize 包装它为零参 callable,让全部分配与写屏障经过 NativeContext;用户生命周期回调另由返回驱动执行。
struct Sync {
// 本次同步所属桌面,由同步函数 captures 显式保活,持有活动挂载字典。
desktop: ObjectRef,
// 本次活动 Page,可为空;同步内部转弱身份写入 Mount,避免反向保活。
page: Option<ObjectRef>,
// 业务 Block 弱注册表,用它把发布帧中的身份文本解析为现存对象。
nodes: Vec<WeakObjectRef>,
// 上一活动 Mount 弱表,用于按出现 ID 尝试复用。
old: HashMap<u64, WeakObjectRef>,
// 上一帧先序出现顺序,逆序退役用于保证孩子先于父卸载。
old_order: Vec<u64>,
// 本轮拥有的原生几何帧,数据不依赖外部借用生命周期。
frame: Frame,
// true 为布局准备阶段,安装临时 Context 并保留已有滚动偏移;false 为几何提交,清 Context。
preparing: bool,
// 同步成功后交回新弱索引,宿主函数调用结束后由 MountTable 移出。
output: Arc<Mutex<HashMap<u64, WeakObjectRef>>>,
}
#[derive(Debug)]
/// 将一个生命周期回调及参数接入同一 VM 调用序列。
struct LifecycleCall {
// 已经按 Block 名义类型解析到的生命周期源/原生方法。
function: ObjectRef,
// 调用时显式 receiver,包装 captures 保持它到序列实际执行。
block: ObjectRef,
// 当前具体出现上下文,作为第二参数传入同一个业务 Block 的钩子。
mount: ObjectRef,
// 卸载包装先取消 owner 任务;挂载包装不做取消。
unmount: bool,
}
impl NativeCallable for LifecycleCall {
@@ -464,11 +542,14 @@ fn lifecycle_call(
mount: ObjectRef,
name: &'static str,
) -> Result<ObjectRef, VmError> {
// Sync 为每个新增/退役出现建立包装,从 Mount 找到实际业务 receiver。
let block = cx.store().payload::<Mount>(mount, "Mount")?.block;
// 生命周期通过名义类型查找,不能被普通实例同名字段绕开协议。
let definition = cx
.store()
.type_of(block)?
.ok_or(VmError::InvalidArgument("untyped mounted Block"))?;
// 按 onMount/onUnmount 查真实方法,缺失类型/方法都表示协议未安装完整。
let function = cx
.store()
.lookup_method(definition, name)?
@@ -496,23 +577,31 @@ impl NativeCallable for Sync {
fn invoke(&self, cx: &mut NativeContext<'_>, _args: &[Value]) -> Result<NativeResult, VmError> {
// 帧只含 Block 字符串身份,先将弱节点表升级为本次同步可用的实际对象映射。
let mut blocks = HashMap::new();
// 弱注册表不保活隐藏业务树;只升级当前仍存活的 Block 为本次调用临时根。
for weak in &self.nodes {
// 失效弱节点直接忽略,帧若仍引用其身份则在下面明确拒绝。
if let Some(block) = cx.upgrade(*weak)? {
blocks.insert(block.to_string(), block);
}
}
// 把捕获的活动 Page 转为弱身份写进各 Mount,用于上下文比较和源码 page()。
let page = self
.page
.map(|page| cx.store().downgrade(page))
.transpose()?;
// 新帧 ID→托管 Mount 临时映射,第一遍全部建立后再填关系。
let mut objects = HashMap::new();
// 记录真实新建出现的先序次序,最后仅这些对象安排 onMount。
let mut created = Vec::new();
// 第一遍按帧顺序为每个几何找业务 Block 并复用/分配 Mount。
for geometry in &self.frame.mounts {
// 帧 block_id 只能对应仍可升级的业务对象,不能凭字符串新建一个替代身份。
let block = *blocks
.get(&geometry.block_id)
.ok_or(VmError::InvalidArgument(
"frame references an unavailable Block",
))?;
// 先按同一 Mount ID 找旧弱对象,失效项不具备复用资格。
let old = self
.old
.get(&geometry.id)
@@ -521,14 +610,17 @@ impl NativeCallable for Sync {
.flatten();
// Mount ID 相同仍不足以复用:必须仍挂接、页面相同、真实 Block 相同,防止身份误归属。
let reusable = if let Some(old) = old {
// 检查旧 Mount 的真实 payload,页面/业务对象不一致时即使数值 ID 相同也必须新建。
let mount = cx.store().payload::<Mount>(old, "Mount")?;
(mount.mounted && mount.page == page && mount.block == block).then_some(old)
} else {
None
};
// 通过全部资格检查才复用,保留已有任务作用域和用户滚动偏移。
let object = if let Some(old) = reusable {
old
} else {
// 新出现从无关系、零偏移、无任务和无临时 layout 开始,随后安装类型与方法。
let object = cx.allocate(Mount {
block,
page,
@@ -561,6 +653,7 @@ impl NativeCallable for Sync {
("requestFocus", Query::Focus),
("hitTest", Query::HitTest),
] {
// 方法函数只捕获本 Mount;弱桌面用于按需获取能力,避免方法反向保活整个窗口。
let function = cx.allocate(ContextNativeFunction::new(
name,
Method {
@@ -581,12 +674,17 @@ impl NativeCallable for Sync {
objects.insert(geometry.id, object);
}
// 先建立全部 Mount,再第二遍补链接,保证 parent/child/sibling 无论先后都能找到目标身份。
// 第二遍建立新的强拥有字典,桌面切换到此字典后才能释放旧出现集合。
let root = cx.new_dict()?;
// 第二遍补齐所有弱层次关系和本轮几何,并将每个活动出现强持有在新字典中。
for geometry in &self.frame.mounts {
// 所有 Mount 已先创建,parent/child/sibling 可指向帧中任意先后位置。
let object = objects[&geometry.id];
// 帧链接 ID 在完整临时映射中转弱对象身份;不让父子边额外形成强引用环。
let link = |id: Option<u64>| -> Result<Option<WeakObjectRef>, VmError> {
id.map(|id| cx.store().downgrade(objects[&id])).transpose()
};
// 组装最终本轮 payload,任务作用域保留,关系/几何/版本使用本帧数据。
let mount = Mount {
tasks: cx.store().payload::<Mount>(object, "Mount")?.tasks,
block: blocks[&geometry.block_id],
@@ -610,6 +708,7 @@ impl NativeCallable for Sync {
None
},
};
// 与旧 payload 比较避免无变化 replace_payload 造成大量 Contents 日志。
let old = cx.store().payload::<Mount>(object, "Mount")?;
// 只在上下文、几何、版本、链接或公开挂接属性确实需更新时替换 payload,减少无关变更日志。
if self.preparing
@@ -641,15 +740,19 @@ impl NativeCallable for Sync {
Value::Object(object),
)?;
}
// 生命周期包装按“全部卸载→全部新挂载”顺序收集,实际源执行留给返回 driver。
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() {
// 从保存的确定顺序取旧身份,不依赖 HashMap 随机迭代安排卸载。
let weak = self.old[id];
// 对象仍存活且没有作为同一 ID 的新对象复用时才退役,避免重复调用 onUnmount。
if let Some(object) = cx.upgrade(weak)?
&& objects.get(id) != Some(&object)
{
// 退役副本先撤去挂接及结构链接,随后才安装卸载包装,旧句柄不能继续用于几何操作。
let mut mount = cx.store().payload::<Mount>(object, "Mount")?.clone();
// 先撤销挂接和全部关系,再安排回调;onUnmount 不能继续使用失效几何或沿旧链操作。
mount.mounted = false;
@@ -663,10 +766,13 @@ impl NativeCallable for Sync {
}
}
// 新建顺序沿帧先序,onMount 默认父先子后;全部旧退役回调排在新挂载回调之前。
// preparing=true 也会生成这些包装,但 UiBridge 保存驱动到完成时才执行;取消准备时只释放驱动,不触发钩子。
// 按新帧先序安排挂载回调,父先于子;复用出现不重复通知。
for object in created {
hooks.push(lifecycle_call(cx, object, "onMount")?);
}
// 同步只返回生命周期序列,不在此处立即执行用户回调;调用方决定在后续阶段何时运行。
// 无钩子返回 Nil,有钩子构造 vm_sequence;同步本身不直接递归执行源函数。
let driver = if hooks.is_empty() {
None
} else {
@@ -677,6 +783,7 @@ impl NativeCallable for Sync {
self.desktop,
)?)
};
// 新宿主索引仅保存弱引用,字典和生命周期包装才是实际托管拥有者。
let weak = objects
.into_iter()
.map(|(id, object)| Ok((id, cx.store().downgrade(object)?)))
@@ -696,20 +803,27 @@ impl NativeCallable for Sync {
/// Explicitly owned roots protect the old presentation while preparation
/// temporarily replaces its desktop dictionary and Mount payloads.
/// 布局准备前的挂载快照与临时保留,用于取消恢复;成功提交后必须释放其保留引用。
/// UiBridge 在 source_layout 尚未完成的错误/取消路径调用 restore,进入完成投影/提交路径后调用 release。
/// 只覆盖挂载字典、旧 Mount payload 和两项公开属性,不保存 Page/业务字段、窗口命令或任务内部状态的完整事务快照。
pub struct Checkpoint {
// 旧弱表与顺序副本,restore 成功后替换回宿主表。
table: MountTable,
// 原 desktop.mounts 字典身份;由 roots 显式保活,None 表示原先没有该属性。
dictionary: Option<ObjectRef>,
// 保存 payload 与 mounted/layoutVersion 两项公开属性;None 表示原先没有属性,回滚要删除。
states: Vec<(ObjectRef, Mount, Option<Value>, Option<Value>)>,
// 检查点实际 retain/upgrade 得到的拥有引用,成功或失败退出都必须 release。
roots: Vec<ObjectRef>,
}
impl Checkpoint {
/// 释放检查点为回滚临时保留的托管根,成功提交后不再需要这些引用。
pub fn release(self, vm: &mut Vm) -> Result<(), VmError> {
// 聚合释放错误,单个根释放失败不阻止清理其他已保留根。
let mut result = Ok(());
// 逆序释放所有临时根,单项失败仍继续尝试余下根,最终返回最后一次释放错误。
for root in self.roots.into_iter().rev() {
// 保留最后一次释放错误,调用方仍知道资源清理没有完全成功。
if let Err(error) = vm.release(root) {
result = Err(error);
}
@@ -724,14 +838,21 @@ impl Checkpoint {
desktop: ObjectRef,
table: &mut MountTable,
) -> Result<(), VmError> {
// 先暂存恢复错误,让下方无论成败都尝试释放检查点拥有根。
// 不调用 onUnmount/onMount;准备态尚未执行这些通知,恢复只修复宿主派生关系,避免制造一轮虚假生命周期。
let result = (|| {
// 回滚先识别本轮新增的出现并将其脱离,再恢复原有 Mount payload、公开属性和桌面拥有字典。
let old: std::collections::HashSet<_> =
self.states.iter().map(|state| state.0).collect();
// 遍历准备阶段当前出现,识别不属于旧检查点的临时新建 Mount。
for weak in table.ordered() {
// 弱升级提供临时强根,处理结果暂存后必须释放再传播错误。
if let Some(object) = vm.upgrade(weak)? {
// 捕获单个对象退役过程的结果,确保后面的 release 仍执行。
let result = (|| {
// 只把准备阶段新出现脱离,旧出现将由保存 payload 原样恢复。
if !old.contains(&object) {
// 新对象即使仍被外部源引用,也必须标为未挂接并清除临时几何上下文与弱层次。
let mut mount = vm.payload::<Mount>(object, "Mount")?.clone();
mount.mounted = false;
mount.layout = None;
@@ -748,13 +869,16 @@ impl Checkpoint {
result?;
}
}
// 恢复每个旧 payload 和两项公开属性,不能只改宿主索引而留下 VM 状态不一致。
for (object, mount, mounted, version) in &self.states {
vm.replace_payload(*object, mount.clone())?;
// 原属性“缺失”和“存在且为 Nil”等值状态不同,逐项按 Option 恢复。
for (name, value) in [("mounted", mounted), ("layoutVersion", version)] {
// 原属性存在则恢复旧值,不存在则 del_attr;已缺失可忽略,其余删除错误仍要传播。
if let Some(value) = value {
vm.set_attr(*object, name, value.clone())?;
} else {
// 原本不存在的属性应删除;已经缺失视为已恢复,其他错误仍传播。
match vm.del_attr(*object, name) {
Ok(()) | Err(VmError::AttributeNotFound(_)) => {}
Err(error) => return Err(error),
@@ -762,9 +886,11 @@ impl Checkpoint {
}
}
}
// 恢复旧桌面字典的强拥有关系;原来没有字典时必须删去准备阶段新字典。
if let Some(dictionary) = self.dictionary {
vm.set_attr(desktop, "mounts", Value::Object(dictionary))?;
} else {
// 缺失可忽略,其他删除失败意味着恢复未完成,不能直接报告成功。
match vm.del_attr(desktop, "mounts") {
Ok(()) | Err(VmError::AttributeNotFound(_)) => {}
Err(error) => return Err(error),
@@ -774,6 +900,7 @@ impl Checkpoint {
Ok(())
})();
// 恢复过程失败也要释放检查点根;先尝试两项操作,再按恢复错误优先的顺序返回。
// 先尝试释放全部检查点根,再优先返回恢复错误;不能因恢复失败跳过根清理。
let released = self.release(vm);
result?;
released
@@ -783,32 +910,42 @@ impl Checkpoint {
impl MountTable {
/// 保留布局准备前的 Mount 状态,供失败或取消路径恢复。
pub fn checkpoint(&self, vm: &mut Vm, desktop: ObjectRef) -> Result<Checkpoint, VmError> {
// 布局准备入口读取原挂载字典,保留“属性不存在”状态;非对象现值是非法内部状态。
let dictionary = match vm.get_attr(desktop, "mounts") {
Ok(Value::Object(dictionary)) => Some(dictionary),
Err(VmError::AttributeNotFound(_)) => None,
Ok(_) => return Err(VmError::InvalidArgument("invalid mount dictionary")),
Err(error) => return Err(error),
};
// 记录每次显式 retain/upgrade 的拥有引用,部分构造失败时也可统一清理。
let mut roots = Vec::new();
// 保留旧字典,避免准备阶段 desktop.mounts 换成新字典后旧出现提前回收,导致无法恢复。
if let Some(dictionary) = dictionary {
vm.retain(dictionary)?;
roots.push(dictionary);
}
// 先保存表/字典,逐项累积状态和根,不能在全部成功前交给调用方。
let mut checkpoint = Checkpoint {
table: self.clone(),
dictionary,
states: Vec::new(),
roots,
};
// 把中途读取/保留错误暂存,创建失败时仍释放已经取得的全部拥有根。
let result = (|| {
// 按旧展示顺序保存活动对象,便于恢复一致的上下文而不受 HashMap 顺序影响。
for weak in self.ordered() {
// 只有仍存活的 Mount 可保存,弱升级本身提供一个检查点拥有根。
if let Some(object) = vm.upgrade(weak)? {
// upgrade 已提供一个拥有根,直接记录待释放列表,不重复 retain;随后读取失败也会统一清理。
checkpoint.roots.push(object);
// 复制当前出现 payload,恢复时连同滚动请求、任务作用域及临时布局状态一起还原。
let mount = vm.payload::<Mount>(object, "Mount")?.clone();
// 保存 mounted/layoutVersion 两项独立源字段,不能假设它们与 payload 一定同步或类型固定。
let mut attributes = Vec::new();
// 逐项区分有值/缺失,其他读取错误不应被误当作缺失。
for name in ["mounted", "layoutVersion"] {
// 读取返回对象 Value 时还需额外 retain,Rust Value 副本本身不增加 VM 根。
let value = match vm.get_attr(object, name) {
Ok(value) => Some(value),
Err(VmError::AttributeNotFound(_)) => None,
@@ -843,9 +980,13 @@ impl MountTable {
/// context and release its owning dictionary through the normal heap barrier.
/// 退役当前出现表,撤销挂载关系并准备不再展示的生命周期状态。
pub fn retire(&mut self, vm: &mut Vm, desktop: ObjectRef) -> Result<(), VmError> {
// 故障域退役按孩子先父后清除出现,但绝不调用任何源 onUnmount。
for weak in self.ordered().into_iter().rev() {
// 当前仍存活的旧句柄要置为 detached;每次升级的强根处理后立即释放。
if let Some(object) = vm.upgrade(weak)? {
// 暂存 payload/公开属性更新结果,让故障路径也能清理弱升级拥有根。
let result = (|| {
// 清挂接、临时 layout 与父子兄弟链接,旧源句柄不得继续操作关闭的展示上下文。
let mut mount = vm.payload::<Mount>(object, "Mount")?.clone();
mount.mounted = false;
mount.layout = None;
@@ -860,7 +1001,9 @@ impl MountTable {
}
}
// 故障退役不执行用户回调;将 desktop.mounts 换成空字典,再清除原生索引,撤销桌面拥有关系。
// 通过正常 VM 写屏障将 desktop.mounts 换为空字典,撤销实际托管强拥有边。
let dictionary = vm.new_dict()?;
// 先保存写入结果,再释放新字典的局部分配根;桌面属性成功后持有字典。
let result = vm.set_attr(desktop, "mounts", Value::Object(dictionary));
vm.release(dictionary)?;
result?;
@@ -871,6 +1014,7 @@ impl MountTable {
/// 按原始展示顺序返回弱 Mount 身份,供事件和协议驱动器保持稳定顺序。
pub fn ordered(&self) -> Vec<WeakObjectRef> {
// 过滤仍在 active 的 ID 并保留旧帧顺序,事件/生命周期调用方不直接遍历 HashMap。
self.order
.iter()
.filter_map(|id| self.active.get(id).copied())
@@ -887,10 +1031,13 @@ impl MountTable {
frame: &Frame,
preparing: bool,
) -> Result<Option<ObjectRef>, VmError> {
// 同步函数与宿主表之间共享一次性输出弱表;Mutex 只传递索引,不承担托管根。
// 本函数不自行创建恢复点;准备调用方须先 checkpoint。调用失败时已有 VM 写入不由 self.active 的保留自动撤销。
let output = Arc::new(Mutex::new(HashMap::new()));
// 同步入口显式保活桌面与可选页面;旧表和节点表本身仍是弱索引,不增加隐式根。
let mut captures = vec![desktop];
captures.extend(page);
// 构造无参 native 同步入口,显式 captures 保活桌面和可选页面,其他输入为原生值/弱观察。
let function = vm.allocate(ContextNativeFunction::new(
"mount-sync",
Sync {
@@ -906,8 +1053,10 @@ impl MountTable {
captures,
))?;
// 先保存调用结果再释放同步入口,错误时同样清理;只有成功后才替换活动索引与顺序。
// 让所有分配/写入在所属 VM 的 NativeContext 中执行,返回值可能是待执行生命周期驱动。
let result = vm.call(function, &[]);
vm.release(function)?;
// 先释放入口函数后再传播调用错误;失败不安装半完成的宿主 active/order。
let result = result?;
// 从共享输出移动弱表而不是克隆;生命周期驱动若存在则作为拥有结果交给调用方释放。
self.active = std::mem::take(
@@ -916,9 +1065,11 @@ impl MountTable {
.map_err(|_| VmError::InvalidArgument("Mount registry poisoned"))?,
);
self.order = frame.mounts.iter().map(|mount| mount.id).collect();
// 仅 Object 驱动或 Nil 是合法同步返回;其他值先释放再报告内部协议错误。
match result {
Value::Object(hooks) => Ok(Some(hooks)),
Value::Nil => Ok(None),
// 非预期返回值也统一经 release_value 清理,再报告同步返回协议错误。
value => {
vm.release_value(value)?;
Err(VmError::InvalidArgument("invalid lifecycle batch"))
@@ -927,4 +1078,5 @@ impl MountTable {
}
}
// 兼容旧 crate::mount::collection 调用路径,实际对象列表拥有/索引实现集中在 object_list 模块。
pub use crate::object_list::collection;
+49
View File
@@ -1,6 +1,11 @@
//! 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。
//! 用于子节点组成、布局结果和几何输入等需要保留对象身份的场景;不是窗口线程共享的可变容器。
//!
//! 调用路径:桥接 bootstrap 注册 Constructor;Mount/视觉协议/绘图入口调用 collection 生成返回列表。
//! source-layout/graphics/三维等消费者用 items 借用成员;源代码通过 at/append/remove/extend/added 操作。
//! 列表只允许对象成员;子类普通字段另按名义类型检查,不能把标量字段值混进成员前缀。
//! items/at 读取真实 payload;count/first 是构造或原地编辑时同步写入的普通属性,不是反向驱动成员序列的存储。
//!
//! Managed indexed host results; values and the at method are traced edges.
use endor_core::{
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
@@ -28,6 +33,8 @@ impl ObjectPayload for ObjectList {
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// Aster 引用追踪逐条遍历成员拥有边;重复成员也逐条 visit,不能按对象身份去重而改变引用计数图。
// 变量名 mount 沿用最初用途,但列表也可持有 PaintCommand、场景元素等其他对象。
for mount in &self.0 {
visit(*mount);
}
@@ -53,6 +60,7 @@ impl NativeCallable for At {
/// 要求索引可精确转为非负平台整数,检查列表范围后返回对应托管成员,不复制成员对象。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// VM 调用已绑定 at 时仍检查实参数形状,不能依赖调用方一定执行签名预检查。
let [Value::Number(index)] = args else {
return Err(VmError::InvalidArgument("object list at expects an index"));
};
@@ -61,35 +69,50 @@ impl NativeCallable for At {
.as_i64()
.and_then(|v| usize::try_from(v).ok())
.ok_or(VmError::InvalidArgument("invalid list index"))?;
// 根据已验证平台索引借用真实成员句柄;越界返回错误,不像 first 空列表那样返回 Nil。
let mount = *cx
.store()
.payload::<ObjectList>(self.0, "Object list")?
.0
.get(index)
.ok_or(VmError::InvalidArgument("list index out of range"))?;
// Return 交回原托管身份,由 VM 的 native 返回协议处理结果拥有关系,不复制成员对象。
Ok(NativeResult::Return(Value::Object(mount)))
}
}
#[derive(Debug)]
// append/remove/extend/added 共用的绑定适配器,操作差异由三个标志决定。
struct Edit {
// 当前列表接收者,方法 captures 强持有它,列表属性再指回方法形成可追踪托管环。
owner: ObjectRef,
// 新列表/函数的名义类型从该桌面解析,不误接到其他 VM 的类型注册表。
desktop: ObjectRef,
// true 时把唯一实参解释为删除索引;否则是追加成员(extend 优先)。
remove: bool,
// added 返回新列表而不改原接收者;原地操作则更新 payload/count/first。
persistent: bool,
// true 时唯一实参须为另一 ObjectList,将其成员按顺序追加。
extend: bool,
}
impl NativeCallable for Edit {
// 绑定接收者已经捕获,四种编辑操作都只接收一个显式参数。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
// 由 VM 调用绑定编辑方法;先在 Vec 副本中验证/编辑,成功后才更新原列表或分配返回列表。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 复制借用成员句柄以结束原列表借用;改副本本身不会修改 VM 拥有边。
let mut members = items(cx.store(), self.owner)?.to_vec();
// extend 接收对象列表,不把整个列表作为一个成员追加;先检查合并后的总预算。
if self.extend {
// 要求实参为对象,再由 items 验证确为宿主 ObjectList payload。
let [Value::Object(other)] = args else {
return Err(VmError::InvalidArgument("extend expects ObjectList"));
};
// 借用另一列表当前成员;owner==other 时复制副本已建好,仍可安全按原序列扩展。
let incoming = items(cx.store(), *other)?;
// 合并后不能超过 10000,即使每个输入列表单独都合法也须重新检查。
if members.len() + incoming.len() > 10_000 {
return Err(VmError::InvalidArgument(
"object list exceeds 10000 members",
@@ -97,9 +120,12 @@ impl NativeCallable for Edit {
}
members.extend_from_slice(incoming);
} else if self.remove {
// 删除路径只修改指定索引,不按业务身份去重或删除所有相同成员。
// 索引类型必须为 Number,后面再拒绝小数、负数和越界值。
let [Value::Number(index)] = args else {
return Err(VmError::InvalidArgument("remove expects index"));
};
// 先精确转 i64/usize,再比较现有长度;无效索引不改变原 payload。
let index = index
.as_i64()
.and_then(|v| usize::try_from(v).ok())
@@ -107,9 +133,11 @@ impl NativeCallable for Edit {
.ok_or(VmError::InvalidArgument("list index out of range"))?;
members.remove(index);
} else {
// 单项追加只接受对象句柄,不能装入 Nil、字符串或数字作为宿主成员。
let [Value::Object(item)] = args else {
return Err(VmError::InvalidArgument("append expects object"));
};
// 追加前已有 10000 项时拒绝,不能先写原列表再报预算错误。
if members.len() >= 10_000 {
return Err(VmError::InvalidArgument(
"object list exceeds 10000 members",
@@ -117,6 +145,8 @@ impl NativeCallable for Edit {
}
members.push(*item);
}
// added 的持久式结果使用默认 ObjectList 类型,原列表(含其子类字段)保持不变。
// 复制的是成员句柄序列;新旧列表会同时拥有相同业务对象,不深拷贝成员,也不继承原列表的子类普通字段。
if self.persistent {
return Ok(NativeResult::Return(Value::Object(collection(
cx,
@@ -124,12 +154,15 @@ impl NativeCallable for Edit {
self.desktop,
)?)));
}
// 原地路径重新计算元信息,payload 替换与 count/first 属性写入都经过 VM 写屏障。
let count = members.len();
// 空列表 first=Nil;非空保存第一个原成员身份,不构造副本。
let first = members
.first()
.copied()
.map(Value::Object)
.unwrap_or(Value::Nil);
// 替换 payload 让 VM 更新成员引用追踪并产生 Contents 变化,依赖消费者可观察编辑。
cx.replace_payload(self.owner, ObjectList(members))?;
cx.set_attr(self.owner, "count", Value::Number((count as i64).into()))?;
cx.set_attr(self.owner, "first", first)?;
@@ -138,11 +171,14 @@ impl NativeCallable for Edit {
}
/// 将已有对象引用包装为新的 ObjectList,并安装默认名义类型及下标查询方法。
/// Mount 查询、布局/绘制协议记录、graphics.flow 和 Window.pages 等宿主入口调用;这里只检查列表预算,不验证成员业务种类。
/// 消费 Arrangement、DisplayList 或场景集合的入口仍需逐项验证相应 payload/名义类型,不能把 ObjectList 当作元素类型保证。
pub fn collection(
cx: &mut NativeContext<'_>,
mounts: Vec<ObjectRef>,
desktop: ObjectRef,
) -> Result<ObjectRef, VmError> {
// 宿主返回的集合不带源子类字段,统一使用桌面的默认 ObjectList 名义类型。
build(cx, mounts, desktop, None)
}
@@ -154,6 +190,7 @@ fn build(
desktop: ObjectRef,
definition: Option<ObjectRef>,
) -> Result<ObjectRef, VmError> {
// 构造入口也检查预算,不能只在编辑入口检查而让宿主一次生成超大列表。
if mounts.len() > 10_000 {
return Err(VmError::InvalidArgument(
"object list exceeds 10000 members",
@@ -165,8 +202,11 @@ fn build(
.copied()
.map(Value::Object)
.unwrap_or(Value::Nil);
// 在成员 Vec 移入 payload 之前保存数量,供普通 count 属性使用。
let count = mounts.len();
// ObjectList payload 声明全部成员强拥有边,VM 分配负责登记引用。
let set = cx.allocate(ObjectList(mounts))?;
// 源子类构造保留请求的名义类型;普通 collection 返回值才用默认类型。
if let Some(definition) = definition {
cx.attach_type(set, definition)?;
} else {
@@ -178,12 +218,14 @@ fn build(
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))?;
// 逐个安装四种绑定操作,标志表是方法语义的唯一分派来源。
for (name, remove, persistent, extend) in [
("append", false, false, false),
("remove", true, false, false),
("added", false, true, false),
("extend", false, false, true),
] {
// 显式 captures=[set,desktop] 保活接收者与类型环境,循环可由 Aster GC 看到。
let method = cx.allocate(ContextNativeFunction::new(
name,
Edit {
@@ -202,6 +244,7 @@ fn build(
}
/// 借用受检查列表的条目;返回值的有效期仍依赖原托管对象存活。
/// source_layout/source_paint/graphics 等原生消费者直接调用,不执行源 at 方法或自定义迭代器;需要修改 VM 时先复制句柄结束借用。
pub fn items(store: &endor_core::ObjectStore, list: ObjectRef) -> Result<&[ObjectRef], VmError> {
Ok(&store.payload::<ObjectList>(list, "ObjectList")?.0)
}
@@ -209,7 +252,9 @@ pub fn items(store: &endor_core::ObjectStore, list: ObjectRef) -> Result<&[Objec
#[derive(Debug)]
/// ObjectList 原生类型的可调用构造器,负责检查传入子对象。
pub struct Constructor {
// bootstrap 提供的可选构造契约,子类构造仍在 invoke 按字段后缀切分参数。
pub signature: Option<endor_core::CallSignature>,
// 构造列表与绑定方法的所属桌面,captures 由注册构造器的 bootstrap 负责保活。
pub desktop: ObjectRef,
}
impl NativeCallable for Constructor {
@@ -224,6 +269,7 @@ impl NativeCallable for Constructor {
/// 按请求类型字段数切分成员/字段,禁止覆盖列表协议保留属性,验证后分配列表并初始化源字段。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// VM 类型调用传入请求 Type 为首参,允许构造 ObjectList 名义子类。
let [Value::Object(definition), values @ ..] = args else {
return Err(VmError::InvalidArgument("ObjectList type argument"));
};
@@ -255,6 +301,7 @@ impl NativeCallable for Constructor {
let members = values[..count]
.iter()
.map(|value| {
// 只检查成员前缀的每项是对象;源子类标量字段已经从后缀单独校验。
if let Value::Object(object) = value {
Ok(*object)
} else {
@@ -264,7 +311,9 @@ impl NativeCallable for Constructor {
}
})
.collect::<Result<Vec<_>, _>>()?;
// 所有字段/成员验证完成后才分配,避免坏字段参数先产生原生列表副作用。
let list = build(cx, members, self.desktop, Some(*definition))?;
// 按声明顺序初始化子类字段,保留契约检查后的原 Value 身份。
for (field, value) in fields.iter().zip(&values[count..]) {
cx.set_attr(list, &field.name, value.clone())?;
}
+56 -2
View File
@@ -1,6 +1,10 @@
//! 一个原生窗口对应一份 Presentation:独立视口、Mount、测量缓存、焦点与依赖记录。
//! 多个窗口仍使用同一个 VM 和共享业务对象。读取版本失效时只标记相关展示需要刷新,不能因此复制一份业务状态。
//!
//! 调用路径:bootstrap/新窗口入口创建 Presentation;UiBridge 收集 VM changes 后对每窗口调用 observe_changes。
//! Block.invalidate 的桥接路径调用 invalidate,源布局/绘制完成后用 split_reads 保存本窗口实际读取。
//! 本结构是 VM 线程内宿主状态,不能把其借用 ObjectRef 或弱表直接交给窗口线程操作。
//!
//! Per-window rendering state. Business objects and code remain in one VM.
//! Registries and caches borrow checked identities; the managed desktop owns
//! presentation roots through its pending/mount dictionaries.
@@ -13,39 +17,60 @@ use std::sync::Arc;
pub(crate) struct Presentation {
// 所属托管桌面身份;此结构借用身份,真正的展示根由托管桌面字典维持。
pub desktop: ObjectRef,
// 窗口正在退役的标记,阻止常规新展示请求在关闭阶段复活旧上下文。
pub closing: bool,
// 当前窗口标签字典的借用身份,真正拥有边由托管 desktop 保持。
pub tabs: ObjectRef,
// 当前已生效 Page 的借用身份;页面生命期仍由托管展示根维持。
pub active: Option<ObjectRef>,
// 双层 Option 区分三态:None 无请求,Some(None) 请求清空,Some(Some(page)) 请求切页。
pub pending: Option<Option<ObjectRef>>,
// 最近原生根快照,比较 Arc/内容以决定是否需要新的布局,不强持有 VM 业务对象。
pub last_snapshot: Option<Arc<UiNode>>,
// 弱身份到原生稳定标签 ID 的映射;不能因为标签历史而保活已经关闭的页面。
pub tab_ids: Vec<(WeakObjectRef, u64)>,
// 分配新原生标签 ID 的单调计数,与可被回收的 Page 弱身份分开。
pub next_tab: u64,
// 最近一次桥接 commit_frame 保存的标签草稿,用于检测标题/活动项/顺序变化;不保证 GUI 已收到该帧。
pub last_tabs: Vec<crate::tabs::Tab>,
// 当前页面兼容表面相机和模式,随本窗口展示更新。
pub surface: crate::page::Surface,
// 托管业务图到原生视觉树的缓存及反向依赖,不能替代源测量缓存。
pub render_cache: crate::snapshot::RenderCache,
// 本窗口几何求解、出现身份与布局缓存,多个窗口共享业务树但不共享此状态。
pub layout: crate::layout::LayoutRuntime,
// 本窗口活动 Mount 的弱索引与发布顺序,托管字典负责强拥有。
pub mounts: crate::mount::MountTable,
// commit_frame 已同步 Mount 的布局版本,输入检查据此拒绝旧几何;后续 paint/稳定发布仍可能尚未完成。
// 字段名沿用 published_version,但不代表此值已经写入 GUI 共享文档,也不是整个源输入的成功标记。
pub published_version: u64,
// 即使根快照身份未变也要求重新发布一帧,例如源绘制读取的数据发生变化时。
pub force_frame: bool,
// 精确名称/字典种类依赖,与下面按整个对象版本记录的 reads 分开检查。
pub paint_names: Vec<endor_core::ReadDependency>,
// 源布局精确名字/字典类别读取,与 layout_reads 的整对象版本分别检查。
pub layout_names: Vec<endor_core::ReadDependency>,
// 源 paint 的对象内容版本观察,仅借用身份,不 retain 对象。
pub paint_reads: std::collections::BTreeMap<ObjectRef, u64>,
// 观察到读取失效的累计次数,饱和累加用于诊断,不作为实际刷新次数。
pub dependency_invalidations: usize,
// 最近失效的阶段/对象或名字诊断;同次两类检查都命中时后者覆盖前者。
pub last_invalid_dependency: String,
// 源 measure/arrange 实际读取的整对象版本,更新后按该版本观察决定刷新。
pub layout_reads: std::collections::BTreeMap<ObjectRef, u64>,
// 源测量的参数/依赖缓存,paint-only 显式失效不清除此缓存。
pub measure_cache: crate::source_layout::Cache,
// 最近快照提取实际重建节点统计,供开发工具解释刷新成本。
pub snapshot_visited: usize,
// 最近快照提取直接复用条目统计,与 visited 分开展示。
pub snapshot_reused: usize,
}
impl Presentation {
/// 比较布局/绘制读取版本和名称依赖,标记需要刷新并通知快照缓存。
/// UiBridge::prepare_frame 将同一 ChangeBatch 广播所有窗口;这里只设置失效状态,实际提取/布局由各窗随后推进。
pub fn observe_changes(&mut self, vm: &Vm, changes: &ChangeBatch) {
// 先检查粗粒度对象版本,layout 优先于 paint;每类只记录第一个失效项用于诊断。
// 先检查粗粒度对象版本,layout 优先于 paint;合并后的两阶段读取序列只记录第一个失效项用于诊断。
// 对象已经释放时版本查询失败,也视作失效,而非继续使用旧缓存。
if let Some((phase, object, version)) = self
.layout_reads
@@ -60,6 +85,7 @@ impl Presentation {
{
self.force_frame = true;
self.dependency_invalidations = self.dependency_invalidations.saturating_add(1);
// 失效诊断尽量附 payload 种类;对象已释放时用 released,而不是解引用过期句柄。
let kind = vm
.object(*object)
.map(|value| value.payload().kind_name())
@@ -69,7 +95,7 @@ impl Presentation {
vm.change_version(*object).ok()
);
}
// 再核对名称级依赖,避免仅凭对象版本漏掉名称解析路径上的变化。
// 再核对精确依赖,既包括对象属性名,也包括命名空间查找和字典种类,保留 VM 记录的原粒度。
// 两个检查独立执行,因此同次观察可能分别增加计数,后者诊断可覆盖前者。
if let Some((phase, dependency)) = self
.layout_names
@@ -84,8 +110,11 @@ impl Presentation {
{
self.force_frame = true;
self.dependency_invalidations = self.dependency_invalidations.saturating_add(1);
// Name 依赖显示具体名字,其他精确记录显示字典类别变化,不假装都有属性名。
let name = match dependency {
// 名字解析路径中的具体名称用于调试哪些全局/属性访问使布局或绘制失效。
endor_core::ReadDependency::Name { name, .. } => name.as_str(),
// 非 Name 分支表示字典种类观察,仍需保留其失效事实。
_ => "<dictionary kind>",
};
self.last_invalid_dependency = format!("{phase} name {}:{name}", dependency.object());
@@ -98,6 +127,7 @@ impl Presentation {
}
/// 按尺寸、内容或绘制失效原因清理对应缓存,避免把所有变化都当成相同重建。
/// calls::Invalidate 已校验 reason,并按最终共享 Data 收集 affected 中的全部 Block 别名;这里不再自行扫描其他窗口。
pub fn invalidate(
&mut self,
cx: &mut NativeContext<'_>,
@@ -107,6 +137,7 @@ impl Presentation {
// 仅绘制失效不影响测量;其余原因清除测量结果并按 Block ID 使布局缓存失效。
if reason != "paint" {
self.measure_cache.invalidate(affected);
// 源测量缓存按业务身份失效,布局缓存则使用其身份文本;同一窗口所有受影响对象都处理。
for block in affected {
self.layout.invalidate(&block.to_string());
}
@@ -117,7 +148,9 @@ impl Presentation {
.invalidate_objects(affected.iter().copied());
}
// 仅当受影响 Block 在该窗口有活动出现时强制刷新,避免无关窗口重复发帧。
// 逐个弱升级本窗口活动出现,只在此窗口确有受影响 Block 时 force_frame。
for weak in self.mounts.active.values() {
// 已消失的 Mount 不触发刷新;升级成功后检查它的业务 Block 是否属于 affected。
if let Some(mount) = cx.upgrade(*weak)?
&& affected.contains(
&cx.store()
@@ -134,6 +167,7 @@ impl Presentation {
/// 用桌面与标签注册表身份初始化窗口状态,布局和视觉缓存从空状态开始。
// 这里只初始化窗口级缓存,不创建 VM、不复制业务对象;active/pending 留空等待窗口展示请求。
pub fn new(desktop: ObjectRef, tabs: ObjectRef) -> Self {
// 这里只初始化本窗口宿主状态;不分配新的 VM 或业务对象,尚无页面、根快照和读观察。
Self {
desktop,
closing: false,
@@ -170,14 +204,20 @@ pub(crate) fn split_reads(
std::collections::BTreeMap<ObjectRef, u64>,
Vec<endor_core::ReadDependency>,
) {
// 整体对象观察按身份归并到有序表,后续 observe_changes 比较该对象的当前版本。
let mut objects = std::collections::BTreeMap::new();
// 其他精确依赖保持原枚举与顺序,不能降级为对象版本而丢掉名字解析语义。
let mut names = Vec::new();
// 整对象依赖按身份收集到有序表,重复项以最后一次版本为准;其余精确依赖保持原记录。
// 调用方先排除各窗口 desktop 记账读取;本函数不负责过滤宿主身份,也不替调用方取得新的版本。
for dependency in reads {
// 按 ReadDependency 种类拆分;不会 retain 借用身份或执行源函数。
match dependency {
// 同对象重复观察以最后出现的版本覆盖先前版本,与本轮实际观察结束状态一致。
endor_core::ReadDependency::Object { object, version } => {
objects.insert(object, version);
}
// Name/字典类别等精确记录完整保留,交给 ObjectStore 的有效性检查器解释。
dependency => names.push(dependency),
}
}
@@ -193,8 +233,11 @@ mod tests {
fn independent_view_geometry_and_shared_vm_reads_do_not_add_roots() {
// 测试在同一 VM 中建立两个 Presentation,验证各自布局尺寸/出现 ID 独立,读取依赖共享业务对象。
let mut vm = Vm::new().unwrap();
// 第一窗口以独立 desktop/tabs 与独立 LayoutRuntime 初始化,但仍属于同一个 VM。
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());
// 左窗口设 100×80,右窗口设 200×160,同一自然 500×500 根会被各自约束。
left.layout.viewport = Some(Size {
width: 100.0,
height: 80.0,
@@ -203,6 +246,7 @@ mod tests {
width: 200.0,
height: 160.0,
});
// 手工不可变原生矩形快照以同一 block_id 供两窗口布局,专门隔离展示几何状态。
let root = UiNode {
surface: None,
flex: 0.0,
@@ -228,26 +272,36 @@ mod tests {
color: "#237A45".into(),
},
};
// 分别执行真实布局,验证共享快照在左右窗口产生不同分配宽度。
let left_frame = left.layout.frame(&root).unwrap();
// 右窗口也读取同一 root,但不应沿用左侧出现身份或约束结果。
let right_frame = right.layout.frame(&root).unwrap();
assert_eq!(left_frame.mounts[0].width, 100.0);
assert_eq!(right_frame.mounts[0].width, 200.0);
// 只清左窗口出现,下一帧应分配新左 ID,而右窗口不受影响。
left.layout.clear_occurrences();
// 新左帧应更换出现 ID,验证 clear_occurrences 的实际效果。
let new_left = left.layout.frame(&root).unwrap();
// 未清理的右窗口保留原 ID,说明身份序列/缓存没有跨 Presentation 串用。
let same_right = right.layout.frame(&root).unwrap();
assert_ne!(new_left.mounts[0].id, left_frame.mounts[0].id);
assert_eq!(same_right.mounts[0].id, right_frame.mounts[0].id);
// 在 VM 中创建共享业务读取源,让两窗口记录不同阶段对同一对象的依赖。
let shared = vm.new_object().unwrap();
// 保存写入前的版本;left.paint_reads 与 right.layout_reads 都只借用该身份。
let version = vm.change_version(shared).unwrap();
left.paint_reads.insert(shared, version);
right.layout_reads.insert(shared, version);
// 实际写入使对象版本改变;两类读取观察都应设置各自 force_frame。
vm.set_attr(shared, "value", endor_core::Value::Bool(true))
.unwrap();
// 同一 VM 变化批次送给两窗口,记录克隆不能制造业务对象副本或新的 GC 根。
let changes = vm.take_changes();
left.observe_changes(&vm, &changes);
right.observe_changes(&vm, &changes);
assert!(left.force_frame && right.force_frame);
// 最后释放业务对象并回收:两份读取记录都不应成为托管根,弱引用应升级失败。
// 只保留弱句柄再释放业务根,最后 collect_cycles 后应无法升级,证明 reads 不保活源对象。
let weak = vm.downgrade(shared).unwrap();
vm.release(shared).unwrap();
vm.collect_cycles();
+17
View File
@@ -1,6 +1,9 @@
//! 安装 Visual、EventTarget、Children 和 Presentable 等能力契约。
//! 契约描述对象能响应什么源方法;具体测量、绘制、事件执行仍在各自协议驱动器中完成。
//!
//! 调用路径:ui_bridge::bootstrap 先创建名义类型/原生方法,再调用 install,把能力同时公开到 ui 和 desktop.types。
//! Aster 在 implement 注册、覆写和调用边界使用 TypedMethodRequirement 检查契约;安装期间不执行用户方法体。
//!
//! Host capability definitions over Aster's explicit managed trait relations.
use endor_core::{
AnnotationType, CallArity, CallEffect, CallSignature, ObjectRef, TypedMethodRequirement, Value,
@@ -18,6 +21,7 @@ pub fn install(
let Value::Object(trait_type) = vm.get_attr(ui, "Trait")? else {
return Err(VmError::InvalidArgument("missing Trait type"));
};
// 使用当前桌面的真实类型字典,能力与其 Window/Block 注解必须来自同一运行时。
let Value::Object(types) = vm.get_attr(desktop, "types")? else {
return Err(VmError::InvalidArgument("missing type registry"));
};
@@ -31,6 +35,7 @@ pub fn install(
Some("Block"),
),
(
// 事件能力同时约束订阅、源码派发和目标默认方法,原生签名来自 Block。
"EventTarget",
vec![("on", 3), ("dispatch", 2), ("onEvent", 1)],
Some("Block"),
@@ -48,10 +53,12 @@ pub fn install(
],
Some("ContainerBlock"),
),
// Presentable 显式声明展示能力,不能仅因对象有同名 present 就隐式启用。
("Presentable", vec![("present", 1)], None),
] {
// 从当前 ui 模块查名义类型身份,不靠字符串名称把另一个运行时的类型误接进来。
let owner_definition = if let Some(owner) = owner {
// 有默认提供者时要求 ui 上的名义类型存在,避免装入缺少方法基线的能力。
let Value::Object(definition) = vm.get_attr(ui, owner)? else {
return Err(VmError::InvalidArgument("missing protocol owner"));
};
@@ -59,10 +66,13 @@ pub fn install(
} else {
None
};
// 本能力的每项方法要求在此收集,全部契约可构造后才分配 trait 对象。
let mut requirements = Vec::new();
// 逐方法复用原生参数/返回/效果,再统一将 receiver 放宽为 Any。
for (method, parameters) in methods {
// 优先复用原生方法参数/返回注解和调用效果,缺少原生签名元数据时拒绝安装。
let mut signature = if let Some(owner) = owner_definition {
// 按名义 owner 的方法表查真实函数,不从实例字段借用一个碰巧同名的 callable。
let function = vm
.object_store()
.lookup_method(owner, method)?
@@ -74,7 +84,9 @@ pub fn install(
))?
} else {
// Presentable 没有默认 owner,手工声明 present(receiver, Window) -> Block,调用效果为 External。
// Presentable 的窗口参数绑定当前桌面 Window 名义类型,不使用宽泛 Object 替代要求。
let window = crate::ui_bridge::desktop_type(vm.object_store(), "Window", desktop)?;
// 返回必须满足当前桌面 Block 契约,具体实现可返回更具体的视觉子类。
let block = crate::ui_bridge::desktop_type(vm.object_store(), "Block", desktop)?;
CallSignature::exact(
vec![AnnotationType::Any, AnnotationType::Nominal(window)],
@@ -82,6 +94,8 @@ pub fn install(
CallEffect::External,
)
};
// Aster typed trait 要求固定的规范调用形状;例如 on 以 receiver+事件名+回调+阶段四参描述。
// 这只修改 trait 的签名副本,不收紧原生 on/dispatch 方法本身允许省略尾参的 arity。
signature.arity = CallArity::exact(parameters + 1);
// 接收者类型改为 Any,使能力可由其他符合方法契约的名义类型实现,不把契约绑定死在 Block。
signature.parameters[0] = AnnotationType::Any.into();
@@ -90,12 +104,15 @@ pub fn install(
signature,
});
}
// VM 创建受类型约束的 trait;后面保活、附 Trait 名义类型并公开两个查询入口。
// EndorTraitObject.trace 会追踪签名中的名义类型,requirements 不是只供显示的无拥有关系文本。
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))?;
// 默认 owner 显式 implement;没有 owner 的 Presentable 不能自动授予全部类型。
if let Some(owner) = owner_definition {
// 有默认提供者时显式登记 implements 关系;Presentable 留待具体类型声明,不自动给所有 Block 安装。
vm.implement_trait(owner, capability)?;
+169 -1
View File
@@ -2,6 +2,10 @@
//! 构造器和字段仍属于 VM;快照复制必要的数值、不可变网格和依赖,渲染器不读取托管堆。
//! 场景遍历组合局部变换、检查环和资源限制,并为 Panel3D 提取独立 UI 根。控制器更新相机;拾取结果再映射回托管节点。
//!
//! 调用路径:bootstrap 用 Constructor 元数据安装类型/工厂,ui_bridge::calls 调用 allocate/transform/control。
//! RenderCache 调用 snapshot_reusing 生成 View 与面板孩子;Mount.pick3D 和原生指针适配器调用 pick。
//! 三维 renderer 只接收不可变原生数值;ObjectRef 读取依赖与 PanelMount.content 始终留在 VM 驱动侧。
//!
//! Traced 3D objects and checked immutable snapshots. No managed handle enters
//! the renderer; cameras and node transforms remain editable Endor attributes.
use crate::{
@@ -17,22 +21,35 @@ use std::{collections::HashSet, sync::Arc};
#[derive(Debug, Clone, Copy)]
/// 源 3D 类型的构造元数据入口,统一管理名称、参数、数量和可变子项。
pub enum Constructor {
// 三轴数值对象,供顶点、交点、法线与重心权重使用。
Vec3,
// 三角形顶点索引记录 a/b/c,组装 Geometry 时再验证整数与范围。
Triangle,
// 复制顶点/三角形列表为不可变原生网格,不安装可放置节点标记。
Geometry,
// 组合已有 Geometry3D 与不透明颜色,拥有几何的托管属性引用。
Mesh,
// 创建长宽深盒网格,再沿 Mesh 路径保存几何与材质。
Box,
// 可放置场景组合节点,孩子列表可包含共享节点。
Group,
// 场景根,额外保存 ambient/background,不参与网格式 TRS。
Scene,
// 方向光,用方向向量、颜色和强度描述,不使用网格欧拉角。
Light,
// 透视相机,快照时读取 fov 作为投影参数。
Perspective,
// 正交相机,快照时读取 height 作为可见世界高度。
Orthographic,
// 默认轨道控制器,只保存可编辑速度,处理相机手势时才修改相机。
Orbit,
// 世界平面承载普通 Block UI,世界尺寸和纹理分辨率分别保存。
Panel,
}
impl Constructor {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
// bootstrap 用这些稳定名义类型名安装/查询桌面注册表,不能与小写工厂名混用。
match self {
Self::Vec3 => "Vec3",
Self::Triangle => "Triangle3D",
@@ -50,6 +67,7 @@ impl Constructor {
}
/// 取得本构造器对应的源工厂函数名称。
pub fn function(self) -> &'static str {
// 桥接函数路由按这些公开工厂名识别构造器,与 name 的名义类型标识一一对应。
match self {
Self::Vec3 => "vec3",
Self::Triangle => "triangle3D",
@@ -67,6 +85,7 @@ impl Constructor {
}
/// 反查源构造器名称对应的原生 3D 种类,不认识的名称返回空值。
pub fn from_function(name: &str) -> Option<Self> {
// 只在显式注册的构造器集合里反查,未知名字交给其他桥接分支处理。
[
Self::Vec3,
Self::Triangle,
@@ -99,6 +118,7 @@ impl Constructor {
}
/// 列出本构造器的参数类型名称,供签名安装与源调用检查使用。
pub fn parameters(self) -> &'static [&'static str] {
// 安装参数注解时按显式实参顺序提供类型;Group/Scene 的可变节点规则另由 variadic/arity 解释。
match self {
Self::Vec3 | Self::Triangle => &["Number", "Number", "Number"],
Self::Geometry => &["ObjectList", "ObjectList"],
@@ -177,6 +197,7 @@ fn invalid(message: &'static str) -> VmError {
}
/// 把源 Value 转换为受检查的托管对象引用,拒绝非对象输入。
fn object_ref(value: &Value) -> Result<ObjectRef, VmError> {
// 构造与快照共用对象边界检查,只有真正托管对象才能交给 payload/列表读取。
if let Value::Object(object) = value {
Ok(*object)
} else {
@@ -189,6 +210,7 @@ fn number(value: &Value) -> Result<f32, VmError> {
}
/// 把当前源值或文本选择解码成此调用需要的文本表示。
fn text(value: &Value) -> Result<String, VmError> {
// 材质、背景和手势种类只接受源文本,不把对象 repr 隐式转换为策略或颜色。
if let Value::Text(text) = value {
Ok(text.clone())
} else {
@@ -202,12 +224,14 @@ fn set_number(
name: &str,
value: f32,
) -> Result<(), VmError> {
// 所有构造/控制器/变换写回都走 NativeContext 属性入口,保留类型检查与变化日志。
cx.set_attr(object, name, crate::native_visual::value(value))
}
/// 解析颜色值并检查其满足当前原生绘制约定。
fn color(value: &str) -> Result<[f32; 3], VmError> {
// 3D 不透明材质拒绝半透明颜色,转换为 [0,1] RGB 数值;普通二维 UI 的 alpha 不受这里限制。
let color = crate::document::parse_color(value).map_err(|_| invalid("invalid 3D color"))?;
// 三维表面目前按不透明材质处理,非全 alpha 明确拒绝,不能静默丢透明通道。
if color.alpha != 255 {
return Err(invalid("3D opaque material color requires full alpha"));
}
@@ -238,12 +262,16 @@ pub fn allocate(
Constructor::Orthographic => NodeKind::Orthographic,
Constructor::Orbit => NodeKind::Orbit,
Constructor::Panel => NodeKind::Panel,
// Geometry 是特殊返回路径,复制原生缓冲后立即附请求类型,不继续分配 Node 标记。
Constructor::Geometry => {
// Geometry 构造只接受受控 ObjectList,逐成员验证 Vec3 payload 并复制数值;以后改源 Vec3 不会改已建几何。
// 第一 ObjectList 的每个成员都必须是 Vec3,逐字段读取后复制进原生 Vec3 数组。
let vertices = object_list::items(cx.store(), object_ref(&args[0])?)?
.iter()
.map(|&item| {
// 短借用当前 ObjectStore,不在原生几何内保存成员 ObjectRef。
let p = cx.store();
// 名义字段形状不够,payload 种类必须确为 Vector,防止 Triangle/任意对象冒充顶点。
if p.payload::<Node>(item, "Vec3")?.0 != NodeKind::Vector {
return Err(invalid("geometry vertices require Vec3"));
}
@@ -254,16 +282,21 @@ pub fn allocate(
))
})
.collect::<Result<Vec<_>, VmError>>()?;
// 第二 ObjectList 转为 u32 三元索引数组,值检查与整网格的下标范围检查分开执行。
let triangles = object_list::items(cx.store(), object_ref(&args[1])?)?
.iter()
.map(|&item| {
// 成员必须为 Triangle 标记,不能因有 a/b/c 属性就接受其他对象。
if cx.store().payload::<Node>(item, "Triangle3D")?.0 != NodeKind::Triangle {
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)?)?;
// 负数/小数索引立即失败,不能通过 as u32 截断生成另一个三角形。
if value < 0. || value.fract() != 0. {
return Err(invalid("triangle indices must be nonnegative integers"));
}
@@ -272,6 +305,7 @@ pub fn allocate(
Ok(triangle)
})
.collect::<Result<Vec<_>, VmError>>()?;
// 将两份复制后的数组组成无 UV 的自定义几何;顶点/三角预算在 valid 统一检查。
let geometry = scene3d::Geometry {
vertices,
triangles,
@@ -283,15 +317,20 @@ pub fn allocate(
"invalid Geometry3D buffers or geometry budget exceeded",
));
}
// 只有完整网格合法才将原生 Arc 包装为托管 Geometry,之后改源列表不会改变它。
let object = cx.allocate(Geometry(Arc::new(geometry)))?;
cx.attach_type(object, definition)?;
return Ok(object);
}
};
// 其余构造器先分配可编辑 Node 标记,实际数值/引用由普通托管属性保存。
let instance = cx.allocate(Node(kind))?;
cx.attach_type(instance, definition)?;
// 按节点种类初始化必需字段;通用 TRS 在各专有字段之后统一安装。
match kind {
// 这两种记录同为三数输入,只是公开字段名不同;Geometry 消费时再施加索引专用约束。
NodeKind::Vector | NodeKind::Triangle => {
// 按构造种类选择 xyz 或 abc,再与已由桥接检查数量的 args 逐项配对。
for (name, value) in (if kind == NodeKind::Vector {
["x", "y", "z"]
} else {
@@ -303,13 +342,17 @@ pub fn allocate(
set_number(cx, instance, name, number(value)?)?;
}
}
// Mesh 与 Box 最终都保存 Geometry 引用及颜色文本,但网格来源不同。
NodeKind::Mesh => {
// Box 现场生成 cuboid,普通 Mesh 使用调用方给定几何,两路径合并后统一校验材质。
let (geometry, material) = if matches!(constructor, Constructor::Box) {
// 三轴尺寸先转为有限 f32,再拒绝负值;零值允许生成退化面。
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"));
}
// 生成盒网格后为它安装 Geometry3D 名义类型,作为 Mesh.geometry 的强属性引用。
let geometry =
cx.allocate(Geometry(Arc::new(scene3d::Geometry::cuboid(w, h, d))))?;
crate::ui_bridge::attach_desktop_type(cx, geometry, "Geometry3D", desktop)?;
@@ -324,8 +367,11 @@ pub fn allocate(
}
// 孩子只接受 Mesh/Group/Light/Panel 标记,不允许把相机、控制器或任意 Object 放进场景组成。
NodeKind::Group | NodeKind::Scene => {
// Group/Scene 的可变实参必须全为对象,逐项 payload 检查后才包装列表。
let children = args.iter().map(object_ref).collect::<Result<Vec<_>, _>>()?;
// 每个成员都必须是能放进场景的节点;相机和控制器不是可绘制组成孩子。
for child in &children {
// 检查 NodeKind 而非仅检查共同的 Node payload,因为相机/向量也使用该 payload。
if !matches!(
cx.store().payload::<Node>(*child, "3D node")?.0,
NodeKind::Mesh | NodeKind::Group | NodeKind::Light | NodeKind::Panel
@@ -338,12 +384,15 @@ pub fn allocate(
// 托管 children 列表拥有子节点;Node payload 自身只是标记,属性拥有边由 VM 正常追踪。
let children = object_list::collection(cx, children, desktop)?;
cx.set_attr(instance, "children", Value::Object(children))?;
// 只有场景根初始化环境光和背景,普通 Group 不隐式提供这些全局场景字段。
if kind == NodeKind::Scene {
set_number(cx, instance, "ambient", 0.25)?;
cx.set_attr(instance, "background", Value::Text("#F1F3F6".into()))?;
}
}
// 方向分量初始按数值写入;零方向与强度范围等在提取快照时完整检查。
NodeKind::Light => {
// 前三实参依次写方向,后两实参分别检查颜色和强度数值。
for (name, value) in ["directionX", "directionY", "directionZ"]
.into_iter()
.zip(args)
@@ -356,6 +405,7 @@ pub fn allocate(
}
// 世界宽高与 UI 像素分辨率独立:初始纹理 512×320,content 是普通 Block 图,稍后参与独立有限布局。
NodeKind::Panel => {
// Panel 的第三参数是普通 UI 根,构造时先验证其 Block 图再安装 content 强引用。
let content = object_ref(&args[2])?;
crate::block::validate(cx.store(), content, None)?;
set_number(cx, instance, "width", number(&args[0])?)?;
@@ -365,7 +415,9 @@ pub fn allocate(
cx.set_attr(instance, "content", Value::Object(content))?;
cx.set_attr(instance, "background", Value::Text("#FFFFFF".into()))?;
}
// 两种相机共用位置/target/up/near/far 默认字段,快照按种类只取实际投影参数。
NodeKind::Perspective | NodeKind::Orthographic => {
// 为相机建立完整非退化初始观察基,保留 fov/height 以支持对应投影。
for (name, value) in [
("x", 0.),
("y", 0.),
@@ -384,6 +436,7 @@ pub fn allocate(
set_number(cx, instance, name, value)?;
}
}
// 控制器仅初始化转速与平移速度;不持有相机,源可替换控制器对象。
NodeKind::Orbit => {
set_number(cx, instance, "rotationSpeed", 0.005)?;
set_number(cx, instance, "panSpeed", 1.)?;
@@ -391,6 +444,7 @@ pub fn allocate(
}
// 只有可放置节点安装 TRS;相机用 position/target/up,方向光用方向向量,不复用网格欧拉角字段。
if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) {
// Mesh/Group/Panel 统一从单位缩放、零位置/角度开始,世界矩阵在快照遍历时组合。
for (name, value) in [
("x", 0.),
("y", 0.),
@@ -410,7 +464,9 @@ pub fn allocate(
/// 快照字段读取器,同时记录实际观察到的对象/属性依赖。
struct Reader<'a> {
// 借用当前 VM 堆,Reader 只在同步快照/控制器调用内存活,不跨线程。
store: &'a ObjectStore,
// 精确对象/字段键集合,返回 RenderCache 建反向依赖;集合不 retain 身份。
reads: HashSet<(ObjectRef, String)>,
}
impl Reader<'_> {
@@ -448,6 +504,7 @@ impl Reader<'_> {
));
}
};
// 将相机所有输入复制为原生数值,后续 valid 同时验证观察基与投影近远范围。
let camera = Camera {
position: self.vector(object, ["x", "y", "z"])?,
target: self.vector(object, ["targetX", "targetY", "targetZ"])?,
@@ -456,6 +513,7 @@ impl Reader<'_> {
far: self.number(object, "far")?,
projection,
};
// 单字段有限不代表组合有效;位置=目标、退化 up 或非法投影范围必须整体拒绝。
if !camera.valid() {
return Err(invalid("invalid 3D camera basis or projection range"));
}
@@ -465,8 +523,11 @@ impl Reader<'_> {
/// 从场景中提取的有限 UI 根,携带稳定面板 key 和逻辑排版尺寸。
pub struct PanelMount {
// 此面板出现路径的稳定 key,交给 Block 组成 slot 及纹理映射,不等于面板业务身份。
pub key: u64,
// 仍在 VM 侧参与独立有限布局的 Block 根,只作为返回的借用身份,不进入 renderer。
pub content: ObjectRef,
// 面板 UI 的逻辑像素分辨率,与世界平面的 width/height 无关。
pub size: crate::layout::Size,
}
// 三部分分别是可跨线程的不可变 View、VM 内缓存用的属性依赖、仍由 VM 驱动布局的面板 Block 根。
@@ -481,28 +542,37 @@ pub fn snapshot(
scene: ObjectRef,
camera: ObjectRef,
) -> Result<Snapshot, VmError> {
// 一次性提取入口无旧 Scene/BVH 可复用;pick 使用此路径获得本次实际字段读取。
snapshot_reusing(store, scene, camera, None)
}
/// 遍历场景并记录字段依赖;如果世界数据未变则复用不可变场景和 BVH,只替换摄像机视图。
/// RenderCache 传入同一 Scene3D 的旧场景;仍完整读取当前场景并比较数值,复用并不意味着跳过遍历或沿用旧依赖。
pub fn snapshot_reusing(
store: &ObjectStore,
scene: ObjectRef,
camera: ObjectRef,
reuse: Option<Arc<scene3d::Scene>>,
) -> Result<Snapshot, VmError> {
// 根必须是 Scene 标记,不能直接把 Mesh/Group 当作完整场景根使用。
if store.payload::<Node>(scene, "Scene3D")?.0 != NodeKind::Scene {
return Err(invalid("Viewport3D requires a Scene3D"));
}
// 建立本轮精确字段观察器,数值快照与依赖来源在同一次遍历中形成。
let mut reader = Reader {
store,
reads: HashSet::new(),
};
// 相机先独立复制/校验,复用旧世界场景也不能复用过时的相机值。
let camera = reader.camera(camera)?;
// 读取场景环境光,数值解析只保证有限范围,下一步再限为 [0,1]。
let ambient = reader.number(scene, "ambient")?;
// 拒绝非法环境光系数,不以渲染时钳色掩盖错误源参数。
if !(0.0..=1.0).contains(&ambient) {
return Err(invalid("Scene3D.ambient expects 0 through 1"));
}
// 背景使用同一不透明三维颜色解码,与普通二维 UI alpha 规则区分。
let background = color(&text(&reader.value(scene, "background")?)?)?;
// 构建本轮完整世界描述,暂不建立加速结构;最后若值相等可以复用旧 Scene/BVH。
let mut output = scene3d::Scene {
meshes: Vec::new(),
lights: Vec::new(),
@@ -512,12 +582,19 @@ pub fn snapshot_reusing(
};
// 显式进入/退出栈保存节点、父世界矩阵、退出标记与路径 key;path 仅追踪祖先,因此允许共享子树多次出现。
let mut work = vec![(scene, Matrix::default(), false, 0u64)];
// 收集面板 key/UI 根/逻辑尺寸,顺序要与输出 Mesh 中的面板对应。
let mut panels = Vec::new();
// 当前祖先链用于拒绝组成环,退出后移除,允许共享业务节点在其他路径出现。
let mut path = HashSet::new();
// 实例预算按展开出现次数,重复业务对象同样消耗绘制工作。
let mut visits = 0;
// 累计所有普通 Mesh 出现的三角数,Arc 共享不能绕开场景工作预算。
let mut triangles = 0;
// 累计普通 Mesh 出现顶点数;面板与整体预算另由 View.valid 等检查。
let mut vertices = 0;
// 显式 DFS 同时传递父世界矩阵与路径 key,不用宿主递归栈遍历深图。
while let Some((object, parent, finish, key)) = work.pop() {
// 当前子树已完整展开,移除祖先标记以允许后续兄弟再次使用同一对象。
if finish {
path.remove(&object);
continue;
@@ -527,11 +604,14 @@ pub fn snapshot_reusing(
if visits > 4096 {
return Err(VmError::ExecutionLimit("3D scene occurrences"));
}
// 同一身份只在祖先路径重复时为环,不能全局去重而漏掉共享实例。
if !path.insert(object) {
return Err(invalid("cyclic 3D scene graph"));
}
work.push((object, parent, true, key));
// 每项必须是托管 Node 标记,再按具体 NodeKind 限制可进入场景图的节点。
let kind = store.payload::<Node>(object, "3D node")?.0;
// 仅可放置的 Mesh/Group/Panel 读取自身 TRS,其他种类继承父矩阵。
let world = if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) {
// 局部先缩放、旋转、平移,再乘父 world;Scene/Light 不新增 TRS,方向光仍受祖先 Group 变换。
parent.compose(Matrix::trs(
@@ -542,11 +622,16 @@ pub fn snapshot_reusing(
} else {
parent
};
// Group/Scene 压孩子,Mesh/Panel 产生绘制实例,Light 产生照明;其他类型明确拒绝。
match kind {
NodeKind::Scene | NodeKind::Group => {
// 通过 Reader 记录 children 属性引用,items 再验证它确为 ObjectList。
// 此处记录的是 owner.children 属性键;items 借用列表 payload,不会给 reader.reads 自动添加列表内容依赖。
let list = object_ref(&reader.value(object, "children")?)?;
// 逆序压栈保留列表原始遍历次序,每个子出现都继承当前 world。
for (index, child) in object_list::items(store, list)?.iter().enumerate().rev() {
use std::hash::{Hash, Hasher};
// 路径哈希从父 key/下标/子身份构造,面板后续显式检测碰撞。
let mut hasher = std::collections::hash_map::DefaultHasher::new();
// 路径 key 包含父 key、当前孩子索引和托管身份,区分同一 Panel 节点在不同路径的出现。
// 此 key 与普通 Block slot 不同,场景列表重排会改变路径身份。
@@ -557,17 +642,22 @@ pub fn snapshot_reusing(
}
}
NodeKind::Mesh => {
// Mesh.geometry 字段读取建立依赖,随后要求其 payload 是不可变 Geometry。
let geometry = object_ref(&reader.value(object, "geometry")?)?;
// 克隆原生 Arc 保活数值缓冲,renderer 不需要托管 geometry 句柄。
let geometry = store.payload::<Geometry>(geometry, "Geometry3D")?.0.clone();
// 每次 mesh 出现都累计完整几何,Arc 共享只节约存储,不减少本帧实例绘制工作预算。
triangles += geometry.triangles.len();
vertices += geometry.vertices.len();
// 顶点工作超过场景预算立即拒绝,不因为几何缓冲共享就少计实例成本。
if vertices > 262_144 {
return Err(VmError::ExecutionLimit("3D scene vertices"));
}
// 三角工作量同样按实例累计,用独立上限防止大网格或大量共享网格超预算。
if triangles > scene3d::MAX_TRIANGLES {
return Err(VmError::ExecutionLimit("3D scene triangles"));
}
// 材质字段每轮提取为 RGB,颜色改变可通过精确依赖驱动 Mesh 快照更新。
let material = color(&text(&reader.value(object, "color")?)?)?;
output.meshes.push(scene3d::Mesh {
identity: object.to_string(),
@@ -582,6 +672,7 @@ pub fn snapshot_reusing(
// u64 hash can turn negative when native Bounds are created.
// Bounds.slot 经有符号整数 API 传递,清最高位避免变负;仍显式检测碰撞,不能假设哈希必然唯一。
let key = key & i64::MAX as u64;
// 清最高位后仍需按已收集的出现 key 检测碰撞;不能让两块 UI 共享同一个纹理/组成身份。
if panels.iter().any(|panel: &PanelMount| panel.key == key) {
return Err(invalid("colliding 3D UI occurrence identities"));
}
@@ -589,10 +680,15 @@ pub fn snapshot_reusing(
if panels.len() >= 64 {
return Err(VmError::ExecutionLimit("3D UI panels"));
}
// 读取世界平面宽高,必须为正;它们决定场景几何,不是纹理像素尺寸。
let width = reader.number(object, "width")?;
// 世界高度与宽度独立,不能以 uiHeight 替代。
let height = reader.number(object, "height")?;
// UI 横向分辨率用有限整数逻辑像素,稍后作为面板 Block 布局宽度。
let ui_width = reader.number(object, "uiWidth")?;
// UI 纵向分辨率单独检查整数和 1..2048 范围。
let ui_height = reader.number(object, "uiHeight")?;
// 同一检查同时约束正世界尺寸与两轴 UI 整数分辨率,拒绝零面积面板。
if width <= 0.
|| height <= 0.
|| ![ui_width, ui_height]
@@ -603,8 +699,10 @@ pub fn snapshot_reusing(
"Panel3D requires positive world dimensions and integer UI dimensions from 1 through 2048",
));
}
// 取当前 Panel.content 的真实 Block 引用,并验证可以沿共享转发解析。
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 {
@@ -641,17 +739,22 @@ pub fn snapshot_reusing(
});
}
NodeKind::Light => {
// 方向光数量独立限制为 32,不由网格实例预算代替。
if output.lights.len() >= 32 {
return Err(VmError::ExecutionLimit("3D scene lights"));
}
// 先读源方向向量,随后通过父线性变换并归一,不能让平移改变方向。
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"))?;
// 方向光颜色转原生 RGB,与强度分量分别检查。
let color = color(&text(&reader.value(object, "color")?)?)?;
// 有限值还需限在 [0,32],避免把任意世界数值当作照明系数。
let intensity = reader.number(object, "intensity")?;
// 超范围强度报告源错误,不能只在像素输出时饱和掩盖。
if !(0.0..=32.0).contains(&intensity) {
return Err(invalid("light intensity expects 0 through 32"));
}
@@ -671,6 +774,7 @@ pub fn snapshot_reusing(
output.acceleration = Arc::new(scene3d::spatial::Index::build(&output.meshes));
Arc::new(output)
};
// 将最终世界 Arc 与本轮相机组合为 View;renderer/拾取共享同一套原生场景描述。
let view = Arc::new(scene3d::View { scene, camera });
// 提取各项之后仍做完整视图校验,覆盖累计世界变换、光照、面板及整体资源不变量。
if !view.valid() {
@@ -686,11 +790,15 @@ pub fn pick(
mount: &crate::mount::Mount,
point: crate::geometry::Point,
) -> Result<Value, VmError> {
// 布局未提交时不允许射线查询,避免临时 Bounds 与旧视口/面板投影混用。
if mount.layout.is_some() {
return Err(invalid("pick3D requires a committed viewport"));
}
// 先沿 Mount.block 的共享赋值解析当前描述,而非信任挂载时的旧快照。
let (_, data) = crate::block::resolve(cx.store(), mount.block)?;
// 用当前托管属性覆盖副本,再验证实际种类确为 Viewport3D。
let data = crate::block::visual_data(cx.store(), data)?;
// 普通 Block 或二维视口没有三维射线协议,明确拒绝而非返回任意 Nil。
if !matches!(data.kind, crate::block::Kind::Viewport3D { .. }) {
return Err(invalid("pick3D requires a Viewport3D"));
}
@@ -698,8 +806,11 @@ pub fn pick(
if !mount.geometry.contains(point) {
return Ok(Value::Nil);
}
// 为计算当前盒模型 inset 提取原生视觉根,射线归一化必须使用实际内容区域。
let node = crate::block::snapshot(cx.store(), mount.block)?;
// padding/border 从有效视觉样式取得,不能把整个边框盒宽高直接作为相机视口。
let inset = crate::layout::insets(&node);
// 先逆投影面板/二维 view 到布局坐标;遮挡或不可逆时返回无命中。
let Some(point) = mount.geometry.logical_point(point) else {
return Ok(Value::Nil);
};
@@ -708,19 +819,23 @@ pub fn pick(
(mount.geometry.width - inset.horizontal()).max(0.),
(mount.geometry.height - inset.vertical()).max(0.),
);
// 零内容面积没有可定义的视口归一化坐标,提前返回 Nil 避免除零。
if width <= 0. || height <= 0. {
return Ok(Value::Nil);
}
// 本次重新提取场景与相机,并保留实际字段读取对象用于关联原托管 Mesh。
let (view, reads, _) = snapshot(
cx.store(),
data.scene3d.ok_or(invalid("missing Scene3D"))?,
data.camera.ok_or(invalid("missing 3D camera"))?,
)?;
// 减去 Mount 布局原点与内容 inset,再归一到视口;宽高比用于相机投影。
let hit = view.pick(
(point.x - mount.geometry.x - inset.left) / width,
(point.y - mount.geometry.y - inset.top) / height,
width / height,
);
// 射线没有穿过可见几何时正常返回 Nil,不创建伪造 RayHit3D。
let Some(hit) = hit else {
return Ok(Value::Nil);
};
@@ -729,6 +844,7 @@ pub fn pick(
.iter()
.find_map(|(object, _)| (object.to_string() == hit.identity).then_some(*object))
.ok_or(invalid("picked mesh is unavailable"))?;
// 创建本桌面的名义 RayHit3D,mesh/node 属性强持有原业务对象。
let result = cx.new_object()?;
crate::ui_bridge::attach_desktop_type(cx, result, "RayHit3D", desktop)?;
cx.set_attr(result, "mesh", Value::Object(mesh))?;
@@ -757,7 +873,9 @@ pub fn pick(
Vec3::new(hit.barycentric[0], hit.barycentric[1], hit.barycentric[2]),
),
] {
// 结果向量字段须为本桌面 Vec3 名义类型,不能只返回相似的普通 xyz 记录。
let definition = crate::ui_bridge::desktop_type(cx.store(), "Vec3", desktop)?;
// 按相同构造规则生成交点/法线/重心记录,结果属性负责持有这些返回对象。
let vector = allocate(
cx,
desktop,
@@ -775,10 +893,13 @@ pub fn pick(
}
#[derive(Debug)]
/// 指针拾取的中间结果,保留交点与所属实例信息供 Event/RayHit3D 使用。
/// UiBridge 为三维点击分配的零参原生适配器;invoke 才执行拾取并将 RayHit3D/Nil 放进 Event.hit。
pub struct PointerPick {
// 原生指针目标的托管 Mount,由调用方包装的 captures 保活。
pub mount: ObjectRef,
// 当前窗口桌面,用来构造 Event/RayHit3D 名义值与派发。
pub desktop: ObjectRef,
// 原输入的页面屏幕坐标,invoke 再按当前 Mount 投影执行精确拾取。
pub point: crate::geometry::Point,
}
@@ -790,38 +911,55 @@ pub fn control(
camera_ref: ObjectRef,
gesture: ObjectRef,
) -> Result<(), VmError> {
// 只处理默认 OrbitController 标记;自定义源控制器由桥接协议调用自己的 update。
if cx.store().payload::<Node>(controller, "OrbitController")?.0 != NodeKind::Orbit {
return Err(invalid("invalid orbit controller"));
}
// 控制器复用字段解码器以取得一致的数值检查;这里的 reads 只为本次读取使用,不返回视觉缓存。
let mut reader = Reader {
store: cx.store(),
reads: HashSet::new(),
};
// 先复制并验证相机观察基,再基于已知有效状态计算新位置/target。
let camera = reader.camera(camera_ref)?;
// 手势逻辑横位移,由窗口/面板映射后的 CameraGesture 提供。
let dx = reader.number(gesture, "dx")?;
// 手势逻辑纵位移,平移计算会显式处理屏幕 y 向下的方向。
let dy = reader.number(gesture, "dy")?;
// 策略字符串决定 orbit/pan/zoom,未知手势不能默认为某种运动。
let kind = text(&reader.value(gesture, "kind")?)?;
// 默认轨道控制围绕 target 使用 position-target 偏移和半径;相机有效性保证观察方向不退化。
let offset = camera.position - camera.target;
// position−target 的长度为轨道半径,相机有效性已排除零观察方向。
let radius = offset.length();
// 先只在原生数值副本中计算,最后统一检查世界预算并写回。
let mut position = camera.position;
// orbit 通常固定目标,pan 同时移动目标;变量副本与源属性暂时分离。
let mut target = camera.target;
// 分支各自检查速度/倍率,计算结束再共享写回位置与目标。
match kind.as_str() {
// 围绕相机 target 旋转位置,使用源 up 向量建立轨道基,不强制世界固定 y 轴。
"scene-orbit" => {
// 旋转速度单位为手势像素到弧度系数,必须在 [0,1] 内。
let speed = reader.number(controller, "rotationSpeed")?;
// 非法速度拒绝整个手势,不先修改某一轴再报错。
if !(0.0..=1.0).contains(&speed) {
return Err(invalid("rotationSpeed expects 0 through 1"));
}
// 归一相机 up,若退化则无法建立稳定轨道水平面。
let up = camera
.up
.normalized()
.ok_or(invalid("camera up cannot be zero"))?;
// 将偏移分解为 up 方向和水平面方向,用水平旋转更新 yaw、asin(vertical) 更新 pitch。
let vertical = (offset.dot(up) / radius).clamp(-1., 1.);
// 去除 up 分量后归一,位于极点时无法定义 yaw 水平基,明确要求更换 up。
let horizontal = (offset - up * offset.dot(up))
.normalized()
.ok_or(invalid("orbit at pole requires a different up vector"))?;
// 横位移乘速度形成 yaw 弧度,sin_cos 用同一角度提供旋转系数。
let (sine, cosine) = (dx * speed).sin_cos();
// 用 up×horizontal 生成绕 up 的正交旋转分量,保留水平单位方向。
let horizontal = horizontal * cosine + up.cross(horizontal) * sine;
// 俯仰限制在两极内侧 0.01 弧度,避免绕极点时水平基退化并导致轨道跳变。
let pitch = (vertical.asin() + dy * speed).clamp(
@@ -830,27 +968,38 @@ pub fn control(
);
position = target + horizontal * (radius * pitch.cos()) + up * (radius * pitch.sin());
}
// 沿当前相机 right/up 平移,位置和目标加同一 delta,保持朝向与半径。
"scene-pan" => {
// 平移速度可取 [0,100],与旋转速度使用不同范围和单位。
let speed = reader.number(controller, "panSpeed")?;
// 速度超范围时拒绝,不能把任意源数字当成无限世界平移系数。
if !(0.0..=100.0).contains(&speed) {
return Err(invalid("panSpeed expects 0 through 100"));
}
// 手势视口高至少按 1 处理,避免像素→世界比例计算除零。
let height = reader.number(gesture, "height")?.max(1.);
// 把逻辑像素位移换成世界单位:透视按 target 距离处的可见高度,正交按固定投影高度。
let units = match camera.projection {
// 透视按当前 target 距离截面的可见高度,把屏幕位移换算为世界单位。
Projection::Perspective { fov } => {
2. * radius * (fov.to_radians() / 2.).tan() / height
}
// 正交可见高度固定,世界单位/像素与轨道距离无关。
Projection::Orthographic { height: extent } => extent / height,
};
// 使用相机实际观察基的 right/up,不把屏幕平移误映射成世界固定 xyz。
let basis = camera.basis().ok_or(invalid("invalid camera basis"))?;
// 横位移沿 right,屏幕向下 dy 对应负 up,最后再乘用户 panSpeed。
let delta = basis.right * (dx * units * speed) + basis.up * (-dy * units * speed);
// 平移同时移动 position 和 target,保留观察方向与轨道半径;屏幕 y 向下所以 up 分量取负。
position = position + delta;
target = target + delta;
}
// 倍率大于一拉近:透视缩短轨道距离,正交则缩小投影可见高度。
"scene-zoom" => {
// 倍率从 CameraGesture 读取,必须为正且处于约定 [0.1,10]。
let factor = reader.number(gesture, "factor")?;
// 拒绝零/负/过大倍率,后续无需为反向或无限缩放定义额外语义。
if !(0.1..=10.0).contains(&factor) {
return Err(invalid("zoom factor expects 0.1 through 10"));
}
@@ -863,6 +1012,7 @@ pub fn control(
(height / factor).clamp(0.001, 1_000_000.),
)?;
} else {
// 透视新距离夹在近裁面两倍与 far 边界之间,再沿原 offset 方向缩放。
let next =
(radius / factor).clamp(camera.near * 2., camera.far.max(camera.near * 2.));
position = target + offset * (next / radius);
@@ -874,6 +1024,8 @@ pub fn control(
if !position.valid() || !target.valid() {
return Err(invalid("camera movement exceeds world budget"));
}
// 位置与 target 逐轴经 NativeContext 写回,变化日志让下一视觉快照观察到更新。
// 正交 zoom 分支还会单独写 height;这些是普通属性写入,不构成多字段事务回滚,失败处理由外层 VM 调用路径负责。
for (name, value) in [
("x", position.x),
("y", position.y),
@@ -894,8 +1046,11 @@ pub fn transform(
args: &[Value],
rotate: bool,
) -> Result<(), VmError> {
// 桥接 translate/rotate 入口先根据 payload 判定可放置目标,不按字段形状接受普通 Object。
let kind = cx.store().payload::<Node>(owner, "3D transform target")?.0;
// 相机可做位置平移,但本接口不以网格欧拉角方式旋转相机。
let camera = matches!(kind, NodeKind::Perspective | NodeKind::Orthographic);
// 限制 Mesh/Group/Panel/相机这组明确目标,额外拒绝 rotate+camera 组合。
if !matches!(
kind,
NodeKind::Mesh
@@ -915,14 +1070,19 @@ pub fn transform(
};
// 先收集并验证全部轴再写入,避免验证到第二/三轴才发现越界时已修改前面轴。
let mut updates = Vec::new();
// 按固定 xyz 顺序读取三轴增量,桥接调用方已经检查了实参个数。
for (index, name) in names.into_iter().enumerate() {
// 每个增量先按带符号有限坐标规则解析,不能因最终角度归一而接受 NaN。
let delta = number(&args[index])?;
// 把增量加到原字段副本,仍未写入源对象。
let value = number(&cx.store().get_attr(owner, name)?)? + delta;
// 相加后也需检查有限性;平移值还必须留在世界范围,旋转随后归一角度。
if !value.is_finite() || !rotate && value.abs() > 1_000_000. {
return Err(invalid("3D transform exceeds world budget"));
}
updates.push((
name,
// 只有旋转应用 rem_euclid(360),平移仍保留带符号世界坐标。
if rotate {
// 角度归一到 [0,360),支持负增量而不使用产生负余数的普通取模。
value.rem_euclid(360.)
@@ -932,14 +1092,18 @@ pub fn transform(
));
// 相机平移同步改 target,保留朝向;不把“移动摄像机”意外变成绕固定目标转动。
if camera {
// 相机平移还需同步相应 target 轴,不能移动位置却把观察目标固定不动。
let name = ["targetX", "targetY", "targetZ"][index];
// 读取 target 旧值加相同增量,与 position 一起先收集验证。
let value = number(&cx.store().get_attr(owner, name)?)? + delta;
// target 越界时也拒绝全部更新,不能只回滚相机位置而保留一部分目标写入。
if value.abs() > 1_000_000. {
return Err(invalid("camera target exceeds world budget"));
}
updates.push((name, value));
}
}
// 所有轴验证通过后才执行普通字段写入;参数检查阶段不会产生部分轴位移。
for (name, value) in updates {
set_number(cx, owner, name, value)?;
}
@@ -956,12 +1120,16 @@ impl endor_core::NativeCallable for PointerPick {
cx: &mut NativeContext<'_>,
_: &[Value],
) -> Result<endor_core::NativeResult, VmError> {
// VM 序列执行此适配器时重读当前 Mount payload,不使用窗口线程提交的旧业务快照。
let mount = cx
.store()
.payload::<crate::mount::Mount>(self.mount, "Mount")?
.clone();
// 先从当前几何/场景做精确射线拾取,结果保留原业务 Mesh 身份或 Nil。
let hit = pick(cx, self.desktop, &mount, self.point)?;
// 点击 Event 使用同桌面的名义类型,避免拾取后转为无契约的临时记录。
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
// 创建真实 click Event,再写入 hit 字段并交统一事件派发处理传播与监听器。
let event = crate::event_dispatch::new_event(cx, definition, "click")?;
// 点击事件把精确拾取值放到 hit(未击中则 Nil),仍以 Viewport 的 Block/Mount 作为事件传播目标。
cx.set_attr(event, "hit", hit)?;
+98 -1
View File
@@ -2,6 +2,10 @@
//! 缓存只借用可检查的对象身份,不增加托管根。对象释放后必须清理缓存索引;快照仍可持有独立的原生数值数据。
//! 干净子树直接复用 Arc;脏节点重新检查属性、方法、绑定和子项,循环与展开数量在发布前拒绝。
//!
//! 调用路径:UiBridge::prepare_frame 广播 VM ChangeBatch 经 Presentation 调用 invalidate,再按页面根 snapshot;reclaim 在回收后调用 prune。
//! block::snapshot 也可用临时空缓存一次性提取;持久窗口缓存只保留原生 UiNode Arc,不 retain 托管身份。
//! 协议方法/属性改变会使业务快照失效,但相机、布局和绘制各自还有后续阶段的缓存边界。
//!
//! Object-to-visual dependencies. Cached results never own VM references.
use crate::{
block::{self, Kind},
@@ -16,15 +20,20 @@ use std::{
#[derive(Debug)]
/// 某个业务组件的视觉快照,以及使它失效的读取依赖和子项身份。
struct Cached {
// 已提取的原生不可变视觉树,窗口/布局可复用 Arc,不能由其中的身份文本操作 VM。
node: Arc<UiNode>,
// 重建时实际登记的反向读取键,remove 必须逐一撤销,避免旧读取持续误失效。
dependencies: Vec<Dependency>,
// 当前组成孩子的借用身份,仅维护父链反向索引,不形成新的托管强边。
children: Vec<ObjectRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
/// 反向依赖键:区分整个对象内容和精确属性名称,减少无关字段变化引起的失效。
struct Dependency {
// 读取源的借用身份,对象可能已被回收,使用前需由 prune/store 查询处理失效。
object: ObjectRef,
// Some 表示精确字段;None 表示对象内容/方法表等整体依赖,与 ChangeKey 粒度对应。
property: Option<String>,
}
@@ -41,29 +50,38 @@ pub struct RenderCache {
parents: HashMap<ObjectRef, HashSet<ObjectRef>>,
// 已失效但尚未重建的 Block 集合,同时用于传播去重。
dirty: HashSet<ObjectRef>,
// 最近一次失效原因诊断,供开发工具/统计展示,不决定缓存正确性。
pub last_invalidation: String,
// 本次 snapshot 实际重建的进入次数,按调用重置并用于展开预算。
pub visited: usize,
// 本次直接命中干净缓存的节点数,不等于整个复用子树的展开节点数。
pub reused: usize,
}
impl RenderCache {
/// 把 VM 变化映射到读取过这些属性/对象的视觉节点,再沿父链向上传播脏标记。
pub fn invalidate(&mut self, batch: ChangeBatch) {
// 收集首批受变化影响的业务 Block,随后统一交给父链失效传播。
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 使用整个对象键;与提取时登记的依赖粒度一致。
// 变化键与提取时记录的 Dependency 完全同构,保证属性名与 Contents 不混用。
let key = Dependency {
object: change.object,
property: match change.key {
// 字段写入只匹配该名字的精确读取者。
ChangeKey::Attribute(name) => Some(name),
// 对象 payload/方法内容变化匹配整个对象的依赖。
ChangeKey::Contents => None,
},
};
// 同一个源可被多个业务快照读取,全部加入队列;去重与父链传播在下一入口处理。
if let Some(owners) = self.dependents.get(&key) {
self.last_invalidation = format!("{} {:?}", key.object, key.property);
queue.extend(owners);
@@ -75,7 +93,9 @@ impl RenderCache {
/// 迭代传播显式失效,使用集合避免共享父链重复遍历或循环入队。
pub fn invalidate_objects(&mut self, objects: impl IntoIterator<Item = ObjectRef>) {
// 显式失效入口也复用同一队列算法,调用方可传多个共享节点。
let mut queue: VecDeque<_> = objects.into_iter().collect();
// 广度推进向上的父链,不递归进入 Rust 栈;dirty 同时作为传播去重集合。
while let Some(block) = queue.pop_front() {
// 只有第一次加入 dirty 才继续向父链传播,避免共享祖先被反复展开。
if self.dirty.insert(block)
@@ -88,20 +108,27 @@ impl RenderCache {
/// 撤销旧缓存的反向读取边和父子边,为重新登记该节点做准备。
fn remove(&mut self, block: ObjectRef) -> Option<Cached> {
// 先移除本体,若根本没缓存则无需撤边;返回旧 Arc 供调用方比较是否可复用。
let old = self.entries.remove(&block)?;
// 旧边必须先撤销,否则节点重建后仍会被已经不再读取的对象触发失效。
// 撤销旧快照的每个读取反向边,新建依赖可能已不再读取这些对象/属性。
for source in &old.dependencies {
// 只移除当前 Block,不能让其他仍读取该源的快照失去失效通知。
if let Some(owners) = self.dependents.get_mut(source) {
owners.remove(&block);
// 没有读取者时清空索引项,避免死亡/过时依赖键持续积累。
if owners.is_empty() {
self.dependents.remove(source);
}
}
}
// 只撤销当前 Block 对孩子的父边;其他共享父节点仍保留。空集合及时删除以缩小索引。
// 撤销当前父对孩子的索引边,共享孩子仍可能保留其他父。
for child in &old.children {
// 找到孩子所有父集合,仅删除本 Block 的组成关系。
if let Some(parents) = self.parents.get_mut(child) {
parents.remove(&block);
// 孩子已无缓存父时删除反向项,避免无用身份长期占缓存内存。
if parents.is_empty() {
self.parents.remove(child);
}
@@ -112,6 +139,7 @@ impl RenderCache {
/// 删除托管堆中已不存在的身份,缓存本身不负责保持业务对象存活。
pub fn prune(&mut self, store: &ObjectStore) {
// 桥接回收/准备入口调用 prune,先丢弃托管场景根已经不存在的原生场景缓存。
self.scenes3d
.retain(|object, _| store.object(*object).is_ok());
// 先收集死亡身份,再修改 entries 与反向索引,避免一边借用遍历一边删除。
@@ -121,29 +149,38 @@ impl RenderCache {
.filter(|v| store.object(**v).is_err())
.copied()
.collect();
// 每个死亡 Block 都需 remove 撤销依赖和父边,不只删除 entries 本体。
for block in dead {
self.remove(block);
// 只有成功建立新条目后才清脏标记;前面任何校验失败都不会把节点错误标成干净。
// 此处身份已死亡,不会再重建;清除其遗留脏标记,避免弱索引变成长期墓碑。
self.dirty.remove(&block);
}
}
/// 按显式栈遍历托管组成;复用干净子树,重建脏节点,并在完成前检查循环和资源上限。
/// 持久缓存依赖调用方及时送入变更批次并在 GC 后 prune;干净命中不会重新验证其整棵业务子图。
/// 中途出错不返回根快照,但此前已重建的子项仍可留在缓存;缓存更新本身不等于 GUI 发布,也不回滚业务对象。
pub fn snapshot(
&mut self,
store: &ObjectStore,
root: ObjectRef,
) -> Result<Arc<UiNode>, VmError> {
// 按本次提取重置工作统计,缓存内容与 dirty 则跨调用保留。
self.visited = 0;
self.reused = 0;
// 使用进入/退出两阶段的显式 DFS,避免深层组成消耗 Rust 调用栈。
// values 是后序结果栈:每个退出节点消费刚完成的孩子快照,再压入自己。
// 每项为业务 Block 与后序完成标记;false 先展开孩子,true 再消费孩子结果组装父。
let mut work = vec![(root, false)];
// 当前祖先 Data 集合防止组合环,不是整棵图的全局去重,合法共享孩子仍可多次出现。
let mut path = HashSet::new();
// 原生子树后序结果栈,保留兄弟已有结果,父完成时只取尾部本节点孩子。
let mut values = Vec::<Arc<UiNode>>::new();
// 显式 DFS 驱动全部提取,发生错误就拒绝这次快照,不返回半棵树。
while let Some((block, finish)) = work.pop() {
// 入栈时先尝试复用;退出标记表示孩子已形成快照,可以组装父节点。
if !finish {
// 干净且存在条目才能复用;仅不在 dirty 中并不能证明已缓存。
if !self.dirty.contains(&block)
&& let Some(cached) = self.entries.get(&block)
{
@@ -153,14 +190,18 @@ impl RenderCache {
}
// 计数的是本次实际展开次数;共享节点可复用缓存,脏图展开仍有 10000 次上限。
self.visited += 1;
// 超过实际重建展开预算明确报错,不能按固定节点数截断显示。
if self.visited > 10_000 {
return Err(VmError::ExecutionLimit("expanded Block graph"));
}
}
// 提取前验证实例存储与类型契约;resolve_tracked 同时提供数据以及经过的代理/存储依赖。
store.check_typed_instance_storage(block)?;
// 取得共享转发后的真实 Data 及 Block/Cell 路径,让替换身份和中间单元变化也触发重建。
let (data_ref, raw_data, raw_dependencies) = block::resolve_tracked(store, block)?;
// 托管属性只覆盖本轮副本,提取不改业务对象;后面的三维面板展开也只修改此副本。
let mut data = block::visual_data(store, raw_data)?;
// 把转发链各对象登记为 Contents 依赖,随后再添加精确字段和协议方法键。
let mut dependencies: Vec<_> = raw_dependencies
.into_iter()
.map(|object| Dependency {
@@ -169,10 +210,12 @@ impl RenderCache {
})
.collect();
// 方法名称查找也构成依赖。即使属性数值未变,协议方法被替换也必须让快照失效。
// 方法可被实例存储阴影,记录末端属性存储中所有受契约约束的方法名。
let storage = *store
.attribute_path(block)?
.last()
.expect("nonempty attribute path");
// 类型方法名也需对实例字段写入失效,以便重建时报告非法阴影而非复用旧合法快照。
for name in store.typed_method_names(block)? {
dependencies.push(Dependency {
object: storage,
@@ -180,7 +223,9 @@ impl RenderCache {
});
}
// 原生视觉属性按精确字段登记,避免不参与视觉的其他属性写入触发整个节点重建。
// 原生字段存储独立于 Data 结构 payload,按每个公开视觉字段建立精确依赖。
if let Some(properties) = data.properties {
// 只登记该原生 kind 实际解释的字段,不遍历所有业务自定义属性。
for (name, _) in block::native_properties(&data) {
dependencies.push(Dependency {
object: properties,
@@ -188,9 +233,11 @@ impl RenderCache {
});
}
}
// 二维相机字段进入视口快照依赖,只有 camera 本身的业务变化影响对应视口。
if matches!(data.kind, Kind::CanvasViewport { .. })
&& let Some(camera) = data.camera
{
// 记录三项相机参数的精确读依赖,源控制器等无关字段不使二维视图重建。
for name in ["x", "y", "zoom"] {
dependencies.push(Dependency {
object: camera,
@@ -199,11 +246,15 @@ impl RenderCache {
}
}
// 3D 面板把托管内容映射为子 Block,同时保留尺寸与稳定 slot,供后续布局和出现身份使用。
// 三维提取返回的每块面板 UI 分辨率,顺序必须和替换后的 children 相同。
let mut panel_sizes = Vec::new();
// 只有 Viewport3D 提取三维 View;普通种类不能凭同名 scene 字段触发场景遍历。
let view3d = if matches!(data.kind, Kind::Viewport3D { .. }) {
// 三维视口必须有 Scene3D 业务根;缺失即拒绝本次快照。
let scene = data
.scene3d
.ok_or(VmError::InvalidArgument("missing Scene3D"))?;
// 传入同场景旧原生 Arc 以便只改相机时复用场景;同时收集真实字段读取及面板组成。
let (view, reads, panels) = crate::scene3d_model::snapshot_reusing(
store,
scene,
@@ -218,6 +269,7 @@ impl RenderCache {
}));
data.children = panels
.into_iter()
// 每块面板内容作为 UI 孩子,panel.key 成为稳定 slot;另并行保存 UI 尺寸供布局使用。
.map(|panel| {
panel_sizes.push(panel.size);
crate::block::Child {
@@ -231,29 +283,40 @@ impl RenderCache {
None
};
// path 只追踪当前祖先链,因此允许同一业务组件在不同分支多次出现。
// 进入/退出两次读取都在同一同步提取调用内,不运行用户源函数;因此退出时孩子数和 Data 身份仍对应进入时压入的工作项。
// 首次进入登记祖先与展开孩子;finish=true 表示孩子结果已齐,下一步可以组装本节点。
if !finish {
// 同一 Data 在当前祖先链再次出现才是环;与兄弟分支共享区分。
if !path.insert(data_ref) {
return Err(VmError::InvalidArgument("cyclic Block composition"));
}
work.push((block, true));
// 逆序压栈使孩子实际完成顺序仍与组成顺序一致,退出标记排在全部孩子之后。
for child in data.children.iter().rev() {
work.push((child.block, false));
}
continue;
}
path.remove(&data_ref);
// 只有仍有 handler 且 kind 以 pointer 开头的连接提供 pointer_events,断开令牌不再算交互需求。
let pointer_connection = data.subscriptions.iter().any(|&o| {
store
.payload::<crate::event_dispatch::Connection>(o, "Subscription")
.is_ok_and(|s| s.handler.is_some() && s.kind.starts_with("pointer"))
});
// 协议键包含类型身份、实际方法身份及其内容版本,供布局/绘制缓存识别实现变化。
// 协议指纹供后续布局/绘制缓存比较;不能仅凭业务 Block ID 认定实现没变。
let mut protocol_key = String::new();
// 无类型或无 onEvent 时默认没有自定义事件能力,随后按名义方法识别。
let mut custom_event = false;
// 名义类型与其方法表决定实际协议,不从任意同名实例值推断视觉实现。
if let Some(definition) = store.type_of(block)? {
protocol_key.push_str(&definition.to_string());
// 五种视觉/事件协议方法都记录句柄及内容版本,payload 保持句柄不变也会改变指纹。
for name in ["measure", "arrange", "paint", "hitTest", "onEvent"] {
// 仅已解析到的实际方法加入依赖;类型链另登记,负责将来新增/替换方法的失效。
if let Some(method) = store.lookup_method(definition, name)? {
// 使用对象内容版本识别同函数身份下的实现替换,不只把方法名拼入字符串。
let version = store.change_version(method)?;
protocol_key.push_str(&format!("/{method}:{version}"));
dependencies.push(Dependency {
@@ -263,13 +326,17 @@ impl RenderCache {
}
}
// 不仅记录最终方法,还沿基类链登记类型内容依赖,使继承关系或方法表变化能触发重建。
// 保存当前解析到的 onEvent,再遍历基类链登记方法表依赖。
let event = store.lookup_method(definition, "onEvent")?;
// 从动态类型向基类推进,每层都属于方法查找的读取来源。
let mut current = Some(definition);
// 继承关系/任一基类表变化都能影响查找结果,不能只观察最终方法函数。
while let Some(definition) = current {
dependencies.push(Dependency {
object: definition,
property: None,
});
// 名义类型 payload 提供 base 链,错误类型不能被当成链尾默默接受。
let item = store.payload::<endor_core::EndorTypeObject>(definition, "Type")?;
current = item.base();
}
@@ -281,29 +348,38 @@ impl RenderCache {
.unwrap_or(false);
}
// 订阅连接的取消会改变 handler;记录连接内容依赖,使交互资格能随取消及时更新。
// 另累计 click 监听资格,与 pointer 系列、内建 Button 和源 onEvent 合并。
let mut click_connection = false;
// 所有事件连接的 handler 取消都会写 payload,Contents 依赖让交互标记及时重算。
for connection in &data.subscriptions {
dependencies.push(Dependency {
object: *connection,
property: None,
});
// 从真实 Connection payload 判断事件种类与有效 handler,不能凭连接句柄存在就当作活跃。
let item = store
.payload::<crate::event_dispatch::Connection>(*connection, "Connection")?;
click_connection |= item.kind == "click" && item.handler.is_some();
}
// 逆序压入孩子确保后序结果与 data.children 原顺序一致;只取尾部的本节点孩子,保留兄弟结果。
// 每个孩子在后序栈恰有一个结果;按数量拆尾部,其他兄弟结果留给祖先。
let children = values.split_off(values.len() - data.children.len());
// 将业务描述转为不依赖 VM 存活的原生 UiKind,孩子 Arc 按种类移入一次。
let kind = match &data.kind {
Kind::Text { value, size, color } => {
// 旧 textOf 路径按实际源字段提取字符串。属性路径中间对象用整对象依赖,末端用字段依赖。
// text_binding 兼容路径动态读取其源值,普通文字则采用已解码的 text 字段副本。
let value = if let Some((source, field)) = &data.text_binding {
// 源属性可能经过共享转发链,各中间身份变化都可能改变最后读取的值。
let storage = store.attribute_path(*source)?;
// 中间节点登记 Contents,只有末端属性存储用精确字段名,兼顾转发变更与精确失效。
for (index, object) in storage.iter().enumerate() {
dependencies.push(Dependency {
object: *object,
property: (index + 1 == storage.len()).then(|| field.clone()),
});
}
// 按源 Value 类型转换显示文本,对象 str 还需登记该对象内容依赖。
match store.get_attr(*source, field)? {
Value::Nil => "nil".into(),
Value::Bool(v) => v.to_string(),
@@ -327,6 +403,7 @@ impl RenderCache {
color: color.clone(),
}
}
// 矩形只复制自然视觉数据,实际出现宽高由后续布局决定。
Kind::Rect {
width,
height,
@@ -338,6 +415,7 @@ impl RenderCache {
radius: *radius,
color: color.clone(),
},
// 滚动视口快照保存尺寸/轴与原生孩子,不保存每个 Mount 的偏移。
Kind::Scroll { width, height } => UiKind::Scroll {
axis: data.scroll_axis,
width: *width,
@@ -346,7 +424,9 @@ impl RenderCache {
gap: data.gap,
children,
},
// 间距叶节点无需组成快照,保留自然尺寸供布局使用。
Kind::Spacer(size) => UiKind::Spacer(*size),
// 三维 View 和面板尺寸与内容孩子一同发布,缺失 View 必须报错。
Kind::Viewport3D { width, height } => UiKind::Viewport3D {
width: *width,
height: *height,
@@ -355,6 +435,7 @@ impl RenderCache {
panels: panel_sizes,
children,
},
// 二维有限视口保存已解码 Camera2D 值与 Scene2D 孩子,后续投影不再查询 VM。
Kind::CanvasViewport { width, height } => UiKind::CanvasViewport {
width: *width,
height: *height,
@@ -371,42 +452,50 @@ impl RenderCache {
.children
.iter()
.map(|child| {
// Scene2D 的每个孩子可能是别名,先解析当前 Data 再读取经过属性覆盖的位置。
let (_, child) = block::resolve(store, child.block)?;
Ok(block::visual_data(store, child)?.position)
})
.collect::<Result<Vec<_>, VmError>>()?,
children,
},
// Overlay 各孩子叠放在相同内容区域,不携带 Row/Column 线性 gap 策略。
Kind::Overlay => UiKind::Overlay {
padding: data.padding,
children,
},
// 普通 Row 保留 padding/gap 与有序孩子,位置由布局计算。
Kind::Row => UiKind::Row {
padding: data.padding,
gap: data.gap,
children,
},
// 兼容 canvas Page 与 Scene2D 一样提取业务位置,表面 pan/zoom 另在 UiNode 上保存。
Kind::Page if data.surface.mode == crate::page::Mode::Canvas => UiKind::Canvas {
positions: data
.children
.iter()
.map(|child| {
// 旧 Page canvas 同样取每个出现对应业务子的当前 x/y,不把父排列结果当作世界位置。
let (_, data) = block::resolve(store, child.block)?;
Ok(block::visual_data(store, data)?.position)
})
.collect::<Result<Vec<_>, VmError>>()?,
children,
},
// 旧横向页面模式复用 Row 内容布局,页面表面元信息仍独立保留。
Kind::Page if data.surface.mode == crate::page::Mode::Horizontal => UiKind::Row {
padding: data.padding,
gap: data.gap,
children,
},
// Column 和其余旧页面模式使用纵向内容排列。
Kind::Column | Kind::Page => UiKind::Column {
padding: data.padding,
gap: data.gap,
children,
},
// 按钮作为带默认视觉/事件语义的容器保留孩子与内边距。
Kind::Button => UiKind::Button {
padding: data.padding,
gap: data.gap,
@@ -416,6 +505,7 @@ impl RenderCache {
// 统一外层样式与交互标志。UiKind 只表示具体内容,UiNode 保存所有类别共享的视觉元信息。
let next = UiNode {
surface: matches!(data.kind, Kind::Page).then(|| {
// 仅 Page 附带表面快照,补入公开页面身份文本而非持有 VM 引用。
let mut surface = data.surface.clone();
surface.page = block.to_string();
surface
@@ -443,7 +533,9 @@ impl RenderCache {
kind,
};
// 即使本次重算结果相同,也要重新登记依赖。shallow_eq 相同时复用旧 Arc,减少下游无效重建。
// 撤销旧依赖/父边,即使浅层视觉结果相等也必须按本轮读取重新登记。
let old = self.remove(block);
// 结果浅相等才复用旧 Arc,减少下游无效工作;变化结果建立新不可变节点。
let node = if let Some(old) = old.as_ref().filter(|old| old.node.shallow_eq(&next)) {
old.node.clone()
} else {
@@ -452,16 +544,21 @@ impl RenderCache {
// 同一属性可能由不同提取路径重复读取,排序去重后再建立反向边。
dependencies.sort_unstable();
dependencies.dedup();
// 保留孩子业务身份用于父链反向索引,与 UiKind 内的原生 Arc 拥有关系分开。
let child_refs: Vec<_> = data.children.iter().map(|child| child.block).collect();
// 为每个去重读取键登记当前 Block,供下个 VM ChangeBatch 找到首批失效者。
for source in &dependencies {
self.dependents
.entry(source.clone())
.or_default()
.insert(block);
}
// 每个共享孩子可有多个父,子项失效时需要沿所有父边传播脏标记。
// 这里按 Block 身份保存父集合,不按出现 slot 重复登记;slot/顺序仍保留在 UiNode 中供后续 Mount 结构分配。
for child in &child_refs {
self.parents.entry(*child).or_default().insert(block);
}
// 节点和全部反向边建立后才安装新条目,最后清 dirty 并把结果交回后序栈。
self.entries.insert(
block,
Cached {
+107 -3
View File
@@ -2,6 +2,10 @@
//! 源码 resize/focus/setTitle 先产生纯数据命令;只有所属输入成功发布后,GUI 线程才应用它们。
//! 窗口 ID 和运行时 session 共同限定资源归属;切换 VM 或关闭窗口时不能让旧命令作用到新资源。
//!
//! 调用路径:bootstrap 调用 install 建立主窗口;桥接新窗口/页面 API 调用 create、登记、重排与 close。
//! ReplSession 收集 take_commands 后,由 API worker 为成功发布的命令附 session,GUI 再执行对应 OS 操作。
//! 生命周期退役使用 retire/unregister_page_host 的宿主入口,不借已停止 VM 重跑源回调。
//!
//! Managed window resources and versioned, coalesced native projections.
//! The VM owns Page roots; only scalar commands cross to the GUI thread.
use crate::{native_visual, object_list, ui_bridge::desktop_type};
@@ -11,22 +15,32 @@ use endor_core::{
};
use std::sync::{Arc, Mutex};
// 主窗口的资源 ID 固定为 1,壳可在 VM 替换后继续存在;真正命令归属还要检查 session。
pub const MAIN_WINDOW: u64 = 1;
// 源 resize 的每轴逻辑尺寸上限,独立于世界场景坐标和软件渲染像素预算。
pub const MAX_WINDOW_DIMENSION: f64 = 4096.0;
#[derive(Debug, Clone, PartialEq)]
/// 可投影到原生窗口的动作类别,如尺寸、标题、焦点与关闭。
pub enum Action {
// 纯数据逻辑宽高请求,不会从 VM 线程直接调用 OS resize。
Resize { width: f64, height: f64 },
// 完整新标题文本,同类请求合并时取最后一次。
SetTitle(String),
// 屏幕整数坐标,允许负数支持多显示器布局。
MoveTo { x: i32, y: i32 },
// 请求操作系统聚焦窗口,区别于某个 Mount 的页面焦点。
Focus,
// 源关闭流程完成后的原生退役请求,旧 session 不得关闭新资源。
Close,
}
#[derive(Debug, Clone, PartialEq)]
/// 成功发布后由 GUI 执行的一条原生窗口请求,含资源身份和动作顺序。
pub struct Command {
// 目标原生资源 ID,与外层 Envelope.session 一起确定归属。
pub window: u64,
// 单窗口动作先后序号,合并可产生空洞但不得反向执行。
pub sequence: u64,
// 请求内容只含可离开 VM 的数值/文本,不含 ObjectRef。
pub action: Action,
}
impl Command {
@@ -40,20 +54,27 @@ impl Command {
#[derive(Debug, Clone)]
/// 附带运行时 session 的命令,阻止旧域请求在新 VM 上生效。
pub struct Envelope {
// 产生该命令的运行时世代,API 发布状态切换 VM 时丢弃旧世代请求。
pub session: u64,
// 已合并排序的窗口动作,GUI 先核对世代再作用到 OS 窗口。
pub command: Command,
}
#[derive(Debug, Clone)]
/// 托管窗口资源状态与页面拥有关系;本对象存在不等于 OS 窗口已创建。
pub struct ManagedWindow {
// VM 内窗口资源 ID,创建托管对象不代表同 ID OS 窗口已经完成创建。
pub id: u64,
// 每窗口拥有独立 desktop、tabs 与页面顺序;同 VM 的类型注册表可共享,不复制业务模型。
pub desktop: ObjectRef,
// 逻辑关闭标志,阻止继续操作/登记页面;id/pages 查询仍允许。
pub closed: bool,
// 窗口 owner 的任务作用域强引用,真实关闭时先取消再退役页面。
pub tasks: Option<ObjectRef>,
// 托管页面字典拥有关系,与 pages 顺序 Vec 一起维护;字典键为 Page 身份文本。
tabs: ObjectRef,
// 页面身份的有序强拥有集合,tabs 字典同时提供托管注册入口;移动顺序只调整此 Vec。
pages: Vec<ObjectRef>,
// 最近已入队动作的单调序号,enqueue 先检查溢出再替换 payload。
sequence: u64,
// Transport state only. Draining it must not change a language object's
// version or create a render-dependency feedback loop.
@@ -79,11 +100,15 @@ impl ObjectPayload for ManagedWindow {
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// GC 追踪独立桌面,保证挂载字典与待展示状态属于可见托管拥有图。
visit(self.desktop);
// 只追踪存在的任务作用域,任务自己的图再由任务 payload 暴露。
if let Some(tasks) = self.tasks {
visit(tasks);
}
// tabs 字典与 pages 都是真实拥有边,不能仅在宿主 Vec 保存句柄而漏掉引用追踪。
visit(self.tabs);
// 依序强持有全部登记 Page,不只保留当前活动页;重排不改变这些对象身份。
for page in &self.pages {
visit(*page);
}
@@ -95,6 +120,7 @@ fn invalid(message: &'static str) -> VmError {
}
/// 把源数值转换为有限屏幕坐标,拒绝越界或不可表示的值。
fn screen_coordinate(value: &Value) -> Result<i32, VmError> {
// moveTo 参数要求整数 Number,不把文本屏幕位置或小数隐式截断。
let Value::Number(value) = value else {
return Err(invalid("screen position requires integer coordinates"));
};
@@ -107,13 +133,16 @@ fn screen_coordinate(value: &Value) -> Result<i32, VmError> {
}
/// 从源 Value 取得窗口操作需要的数值,保留错误类型诊断。
fn scalar(value: &Value) -> Result<f64, VmError> {
// resize 每轴要求 Number,错误类型在进入原生命令队列前拒绝。
let Value::Number(value) = value else {
return Err(invalid("window size requires numbers"));
};
// 先转 f64 再检查有限性和逻辑窗口尺寸范围,超界不会在 OS 侧被静默钳制。
let number = value
.to_string()
.parse::<f64>()
.map_err(|_| invalid("invalid window size"))?;
// 每轴至少为 1、最多 4096,NaN/无穷同样非法。
if !number.is_finite() || !(1.0..=MAX_WINDOW_DIMENSION).contains(&number) {
return Err(invalid("window size exceeds limits"));
}
@@ -122,19 +151,29 @@ fn scalar(value: &Value) -> Result<f64, VmError> {
#[derive(Debug, Clone, Copy)]
/// 当前宿主 API 的操作分类,分派代码负责参数校验与状态更新。
enum Operation {
// 返回稳定资源数值身份,窗口已关闭后仍可查询。
Id,
// 读取此窗口 desktop 的当前逻辑视口,未提供尺寸时失败。
Viewport,
// 返回按登记顺序的新对象列表,成员仍为原 Page 引用。
Pages,
// 排队修改原生逻辑尺寸,读协议期间禁止。
Resize,
// 排队移动 OS 窗口到带符号整数屏幕位置。
MoveTo,
// 排队更新标题,按字节限长并拒绝控制字符。
SetTitle,
// 排队原生聚焦;不会直接选择某个页面 Mount。
Focus,
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
// 安装时从当前桌面类型构造的未绑定方法契约,首个参数是 Window receiver。
signature: endor_core::CallSignature,
// 统一适配器的行为分派,同时决定 arity 和结果/效果类型。
operation: Operation,
// 类型注册所在主桌面,由 native captures 保活;读取 viewport 时使用 receiver 自己的 desktop。
desktop: ObjectRef,
}
impl NativeCallable for Method {
@@ -142,7 +181,7 @@ impl NativeCallable for Method {
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(self.signature.clone())
}
/// 计入 Window 接收者:resize/moveTo 共三参,setTitle 两参,其他查询/关闭/聚焦只要接收者。
/// 计入 Window 接收者:resize/moveTo 共三参,setTitle 两参,其他查询/聚焦只要接收者。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(match self.operation {
Operation::Resize | Operation::MoveTo => 3,
@@ -151,11 +190,13 @@ impl NativeCallable for Method {
}))
}
/// 验证窗口资源与关闭状态;查询返回托管值,原生变更生成受世代保护的待执行命令,关闭走资源生命周期。
/// 验证窗口资源与关闭状态;查询返回托管值,原生变更生成待执行命令;世代封装与关闭生命周期由外层入口处理。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 未绑定原生方法从首参取得真正 receiver,再验证 ManagedWindow payload。
let Some(Value::Object(owner)) = args.first() else {
return Err(invalid("Window method requires a Window"));
};
// 复制当前资源状态,后续分配查询返回值时不持有 payload 借用。
let data = cx
.store()
.payload::<ManagedWindow>(*owner, "Window")?
@@ -164,25 +205,33 @@ impl NativeCallable for Method {
if data.closed && !matches!(self.operation, Operation::Id | Operation::Pages) {
return Err(invalid("native window is closed"));
}
// 即使签名元数据已声明 arity,入口仍检查实际实参数,避免直接 native 调用越界。
let expected = match self.operation {
Operation::Resize | Operation::MoveTo => 3,
Operation::SetTitle => 2,
_ => 1,
};
// 形状不符立即拒绝,不能读取缺少的宽高/标题实参。
if args.len() != expected {
return Err(invalid("incorrect Window method argument count"));
}
// 查询构造托管返回值;变更分支只排队命令,最终统一交 VM 返回协议处理。
let result = match self.operation {
// 关闭对象仍能返回原 ID,不能借查询 ID 来重开资源。
Operation::Id => Value::Number((data.id as i64).into()),
// 复制成员列表到新 ObjectList,成员身份和原登记顺序保持,原窗口集合不被修改。
Operation::Pages => {
Value::Object(object_list::collection(cx, data.pages, self.desktop)?)
}
// 读取该 Window 自己 desktop 的逻辑视口,封装成 MeasureResult;尚未由原生侧提供尺寸时明确失败。
Operation::Viewport => {
// receiver 的逻辑 viewport 尚未由宿主提供时不构造假定默认尺寸。
let Value::Object(input) = cx.store().get_attr(data.desktop, "viewport")? else {
return Err(invalid("window viewport is unavailable"));
};
// 逐项读取此窗口现有视口记录,MeasureResult 只包装已知尺寸。
let width = cx.store().get_attr(input, "width")?;
// 高度与宽度都来自 receiver.desktop,而非安装方法的主 desktop。
let height = cx.store().get_attr(input, "height")?;
Value::Object(native_visual::record(
cx,
@@ -191,6 +240,7 @@ impl NativeCallable for Method {
vec![("width", width), ("height", height)],
)?)
}
// 余下为原生资源变更,先检查当前只读范围再解码动作参数。
operation => {
// 原生命令虽暂存在内存,也属于变更,不能从只读测量/绘制回调发起窗口操作。
if cx.store().is_read_only() {
@@ -198,16 +248,20 @@ impl NativeCallable for Method {
"window operations are forbidden during read-only rendering",
));
}
// 参数全部解码成功后才 enqueue,非法标题或尺寸不会产生半个窗口动作。
let action = match operation {
// 两轴独立转受限屏幕整数,可负但不可有小数。
Operation::MoveTo => Action::MoveTo {
x: screen_coordinate(&args[1])?,
y: screen_coordinate(&args[2])?,
},
// 两轴独立验证有限逻辑尺寸,不把业务世界坐标范围用于 OS 窗口大小。
Operation::Resize => Action::Resize {
width: scalar(&args[1])?,
height: scalar(&args[2])?,
},
Operation::SetTitle => {
// 标题必须是源文本,不能使用对象 str 等隐式转换触发额外源行为。
let Value::Text(title) = &args[1] else {
return Err(invalid("window title requires text"));
};
@@ -220,6 +274,7 @@ impl NativeCallable for Method {
Operation::Focus => Action::Focus,
_ => unreachable!(),
};
// 所有正常方法请求经同一排队屏障,调用成功仍不代表 GUI 已执行。
enqueue(cx, *owner, action)?;
Value::Nil
}
@@ -228,11 +283,14 @@ impl NativeCallable for Method {
}
}
/// 把合法窗口操作追加到所属窗口的待发布命令,不能直接在 VM 线程操作 OS。
/// Method::invoke 先检查尺寸/标题/只读规则;工具激活和 close 也直接调用本入口,因此 closed 与动作参数并不在这里重验。
/// 返回成功只表示 outbox 已排队;worker 成功发布并封装 session 后,shell::app 才可能执行动作。
pub fn enqueue(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
action: Action,
) -> Result<(), VmError> {
// 内部关闭也可经此入口入队 Close;此函数不重复做公开方法的 closed 检查。
let mut data = cx
.store()
.payload::<ManagedWindow>(owner, "Window")?
@@ -242,12 +300,15 @@ pub fn enqueue(
.sequence
.checked_add(1)
.ok_or(invalid("window command sequence exhausted"))?;
// 把已验证动作与资源 ID/新序号组合为独立传输值。
let command = Command {
window: data.id,
sequence: data.sequence,
action,
};
// 克隆 Arc 使 outbox 借用与即将 move 的 payload 分离;transport 不含托管根。
let queue = data.outbox.clone();
// 队列锁毒化时报错,不继续修改 VM 版本或丢失原请求。
let mut queue = queue
.lock()
.map_err(|_| invalid("window outbox poisoned"))?;
@@ -255,26 +316,31 @@ pub fn enqueue(
// 必须先成功经过 VM 写屏障,才修改不可回滚的原生 outbox;只读调用不能先留下待发命令再报错。
cx.replace_payload(owner, data)?;
// 同窗口同动作只保留最后请求,其他类别保持先后;此处只入队,GUI 是否执行取决于后续成功发布。
// 例如 Resize(seq1)、Focus(seq2)、Resize(seq3) 会留下 Focus(seq2)、Resize(seq3),不能保留首次 Resize 的旧序号。
queue.retain(|old| !old.same_kind(&command));
queue.push(command);
Ok(())
}
/// 将页面登记到其窗口,维护托管所有权与页面顺序。
/// UiBridge::show_in 在调用前处理跨窗撤登记;本入口只保证该窗口内不重复,不负责查找或迁移其他窗口的同一 Page。
pub fn register_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
page: ObjectRef,
) -> Result<(), VmError> {
// 登记会改变 Page 拥有关系,不允许从只读布局/绘制回调产生可见资源副作用。
if cx.store().is_read_only() {
return Err(invalid(
"opening a Page is forbidden during read-only rendering",
));
}
// 读取当前窗口页集合副本,后面以 payload 替换让 VM 正确更新拥有边。
let mut data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
// 关闭资源不能重新登记页面,避免旧窗口被页面 open 隐式复活。
if data.closed {
return Err(invalid("native window is closed"));
}
@@ -296,11 +362,13 @@ pub fn unregister_page(
window: ObjectRef,
page: ObjectRef,
) -> Result<(), VmError> {
// 撤销登记同样是业务图写入,只读协议中必须在删字典/列表前拒绝。
if cx.store().is_read_only() {
return Err(invalid(
"closing a Page is forbidden during read-only rendering",
));
}
// 复制现有有序页集合,下面先检查目标确实在该窗口登记。
let mut data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
@@ -321,7 +389,9 @@ pub fn unregister_page_host(
window: ObjectRef,
page: ObjectRef,
) -> Result<(), VmError> {
// 宿主已确定收尾时直接使用 VM 写屏障,不创建源调用或执行关闭回调。
let mut data = vm.payload::<ManagedWindow>(window, "Window")?.clone();
// 存在才同时移除顺序拥有边和 tabs 字典边,缺失则幂等返回。
if data.pages.contains(&page) {
data.pages.retain(|value| *value != page);
vm.dict_delete(data.tabs, &Value::Text(page.to_string()))?;
@@ -337,21 +407,26 @@ pub fn move_page(
page: ObjectRef,
position: usize,
) -> Result<(), VmError> {
// 只读取同窗口已登记 Page 集合,本函数不创建/迁移页面或安排新生命周期。
let mut data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
// 已关闭窗口不能再重排其旧标签上下文。
if data.closed {
return Err(invalid("native window is closed"));
}
// 按原 Page 身份定位当前位置,名字/标题可能重复,不能据此识别页面。
let from = data
.pages
.iter()
.position(|candidate| *candidate == page)
.ok_or(invalid("Page is not open in this window"))?;
// position 是最终数组索引,不是允许等于 len 的插入缝。
if position >= data.pages.len() {
return Err(invalid("tab position out of range"));
}
// 验证来源/位置后,实际写入前仍要拦截只读范围。
if cx.store().is_read_only() {
return Err(invalid(
"tab ordering is forbidden during read-only rendering",
@@ -359,6 +434,7 @@ pub fn move_page(
}
// 只重排当前窗口中的页面,不迁移到另一窗口,也不销毁或重建 Page/Mount;跨窗口迁移由桥接层组合操作。
if from != position {
// 先从原位置移除同一引用,再插入最终位置;不分配 Page 副本或改 tabs 字典身份。
let page = data.pages.remove(from);
data.pages.insert(position, page);
cx.replace_payload(window, data)?;
@@ -367,11 +443,15 @@ pub fn move_page(
}
/// 借用一个有效托管窗口的已登记页面集合。
pub fn pages(store: &ObjectStore, window: ObjectRef) -> Result<&[ObjectRef], VmError> {
// 借用真实 payload 页顺序,调用方不能把返回切片跨 VM 修改保存。
Ok(&store.payload::<ManagedWindow>(window, "Window")?.pages)
}
/// 取走当前窗口累积的命令,后续由成功发布结果携带到 GUI。
/// 正常发布路径会封装返回值;故障清理路径也调用本函数但直接丢弃结果,所以 drain 本身不能视为发布成功。
pub fn take_commands(vm: &Vm, window: ObjectRef) -> Result<Vec<Command>, VmError> {
// 读取 payload 只取得共享 transport outbox;drain 不应制造业务依赖写入。
let data = vm.payload::<ManagedWindow>(window, "Window")?;
// 锁内移出本轮全部动作,若毒化则保留错误,不假装没有窗口命令。
let mut queue = data
.outbox
.lock()
@@ -388,8 +468,11 @@ pub fn install(
desktop: ObjectRef,
tabs: ObjectRef,
) -> Result<ObjectRef, VmError> {
// bootstrap 已建立名义 Window 类型,安装此类型的方法与主窗口实例。
let definition = desktop_type(vm.object_store(), "Window", desktop)?;
// 所有原生方法也附本域 Function 类型,使源回调/反射查询识别一致。
let function_type = desktop_type(vm.object_store(), "Function", desktop)?;
// 显式列出此适配器提供的方法;close 由桥接资源生命周期入口单独处理。
for (name, operation) in [
("id", Operation::Id),
("viewport", Operation::Viewport),
@@ -400,26 +483,32 @@ pub fn install(
("focus", Operation::Focus),
] {
use endor_core::{AnnotationType as T, CallEffect, CallSignature};
// 按操作声明参数、返回和效果,读查询可用于只读协议,资源变更均为 Mutating。
let (parameters, returns, effect) = match operation {
Operation::Id => (vec![], T::Number, CallEffect::ReadOnly),
// pages 返回默认 ObjectList,其成员是同一 VM 的真实 Page。
Operation::Pages => (
vec![],
T::Nominal(desktop_type(vm.object_store(), "ObjectList", desktop)?),
CallEffect::ReadOnly,
),
// viewport 返回名义 MeasureResult,不能只声明 Any 让错误记录蒙混通过。
Operation::Viewport => (
vec![],
T::Nominal(desktop_type(vm.object_store(), "MeasureResult", desktop)?),
CallEffect::ReadOnly,
),
// 两数变更返回 Unit,显式标 Mutating 防止只读调用绕过入口检查。
Operation::Resize | Operation::MoveTo => {
(vec![T::Number, T::Number], T::Unit, CallEffect::Mutating)
}
Operation::SetTitle => (vec![T::String], T::Unit, CallEffect::Mutating),
Operation::Focus => (vec![], T::Unit, CallEffect::Mutating),
};
// 未绑定方法签名先放 Window receiver,再追加显式参数,与 Method.arity 一致。
let mut arguments = vec![T::Nominal(definition)];
arguments.extend(parameters);
// native 捕获桌面类型环境,真正 receiver 仍由调用实参提供,可服务同 VM 的副窗口。
let function = vm.allocate(ContextNativeFunction::new(
name,
Method {
@@ -448,6 +537,7 @@ pub fn install(
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(
@@ -461,25 +551,31 @@ pub fn install(
/// Create a managed native-window resource. The GUI creates the OS resource only
/// after the owning candidate/input publishes its immutable projection.
/// 建立托管窗口、desktop 与页面容器;原生窗口通过命令发布后出现。
/// 建立托管窗口、desktop 与页面容器;桥接强制生成该窗口的首帧,GUI 根据成功发布的窗口投影创建 OS 资源。
/// Action 没有 Create 变体;这里返回的三元组由 ui_bridge::create_window 用于建立独立 Presentation。
pub fn create(
cx: &mut NativeContext<'_>,
master: ObjectRef,
id: u64,
definition: ObjectRef,
) -> Result<(ObjectRef, ObjectRef, ObjectRef), VmError> {
// 源 Window 构造也是资源变更,必须在分配新 desktop/tabs 前阻止只读协议调用。
if cx.store().is_read_only() {
return Err(invalid(
"window creation is forbidden during read-only rendering",
));
}
// 每窗口独立桌面保存 viewport、mounts、请求等展示状态,业务名义类型仍与主域共享。
let desktop = cx.new_object()?;
// 副窗口页面字典从空集合开始,不复制主窗口的登记页面。
let tabs = cx.new_dict()?;
// 附加桌面共享主域的同一 types 字典身份,保证跨窗口对象和协议使用同一名义类型体系。
// 复制的是 Value::Object 身份,不克隆另一份类型体系,跨窗页面/回调仍使用同一名义类型。
let types = cx.store().get_attr(master, "types")?;
cx.set_attr(desktop, "types", types)?;
cx.set_attr(desktop, "viewport", Value::Nil)?;
cx.set_attr(desktop, "tabs", Value::Object(tabs))?;
// 建立逻辑资源并附请求 Window 名义类型;此处只安排 VM 内可达图。
let window = cx.allocate(ManagedWindow {
id,
desktop,
@@ -492,6 +588,7 @@ pub fn create(
})?;
cx.attach_type(window, definition)?;
cx.set_attr(desktop, "window", Value::Object(window))?;
// 统一注册表必须由 master 提供,缺失说明运行时安装不完整,不能私自创建孤立窗口集合。
let Value::Object(windows) = cx.store().get_attr(master, "windows")? else {
return Err(invalid("window registry missing"));
};
@@ -502,20 +599,25 @@ pub fn create(
/// 取消窗口/页面任务并撤销登记,标记 closed 后安排原生关闭;重复关闭直接返回。
pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmError> {
// 关闭会取消任务并撤销 Page 强拥有边,必须先拒绝只读协议中的资源副作用。
if cx.store().is_read_only() {
return Err(invalid(
"window close is forbidden during read-only rendering",
));
}
// 读取当前页集合做取消/撤销;循环内 payload 会多次更新,后面必须重读。
let mut data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
// 重复 close 幂等,不再次取消/撤销或生成重复原生 Close。
if data.closed {
return Ok(());
}
// 真实关闭边界先取消窗口与所有页面 owner 的任务,再撤销页面登记,防止晚到结果继续更新已关闭资源。
// cancel_owner 只取消传入 owner 的作用域;具体 Mount 的任务随后在桥接刷新卸载出现时清理,不在此遍历 Mount 表。
crate::tasks::cancel_owner(cx, window)?;
// 遍历旧页顺序副本,unregister_page 可以安全更新真实集合;每页 owner 任务先取消。
for page in data.pages.clone() {
crate::tasks::cancel_owner(cx, page)?;
unregister_page(cx, window, page)?;
@@ -533,7 +635,9 @@ pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmErro
/// Host teardown for a stopped VM: no binding, dispatch or source execution.
/// 撤销已失效窗口的托管资源关系,不重新执行源关闭请求。
/// UiBridge::shutdown/retire_window 先处理任务或 Mount,再调用这里断 Page 拥有边;不等同于源 close 的排队关闭流程。
pub fn retire(vm: &mut Vm, window: ObjectRef) -> Result<(), VmError> {
// 已停止域的宿主路径只解除托管页面拥有边并标记 closed,任务/OS 清理由外层退役流程负责。
let mut data = vm.payload::<ManagedWindow>(window, "Window")?.clone();
// 故障域的宿主退役仅断开页面拥有边并标 closed,不跑用户回调、不重新生成源关闭命令。
vm.dict_clear(data.tabs)?;
+24
View File
@@ -1,6 +1,10 @@
//! 有限布局盒子的尺寸策略。区分 auto、固定值和 fill,并处理 min/max 对约束与最终尺寸的限制。
//! margin、border、padding 的空间分配由布局层结合本模块完成;场景世界范围不被当成无限布局盒子。
//!
//! 调用路径:Block 属性解码使用 Dimension.parse,native_properties 用 value 公开当前策略。
//! layout 的测量/分配入口调用 available 收窄内容约束,再用 resolve 结合自然尺寸求边框盒大小。
//! 本模块只处理数值策略,不读 VM 或安排 Mount 生命周期;min/max 一致性由模型提取预先检查。
//!
//! Native border-box sizing. An unbounded axis cannot resolve `fill`.
use crate::{layout::Size, ui_bridge::MAX_DIMENSION};
use anyhow::Result;
@@ -10,14 +14,18 @@ use endor_core::{Value, VmError};
/// 一个布局轴的尺寸策略:随内容、固定值或占用有限可用空间。
pub enum Dimension {
#[default]
// 从自然内容尺寸求候选值;不是占满父轴的同义词。
Auto,
// 显式边框盒长度,最终仍会受自身 min/max 与父可用上限限制。
Fixed(f32),
// 使用父级有限分配上限,父轴无界时必须报错,不能生成无限几何。
Fill,
}
impl Dimension {
/// 把内部尺寸策略投影为相应的源属性表示。
pub fn value(self) -> Value {
// 把内部枚举编码为源字段值,保留 auto/fill 字符串与固定数值的可区分性。
match self {
Self::Auto => Value::Text("auto".into()),
Self::Fill => Value::Text("fill".into()),
@@ -27,11 +35,13 @@ impl Dimension {
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &Value) -> Result<Self, VmError> {
// 仅接受约定文本或真实 Number,错误类型/未知字符串不做隐式转换。
match value {
Value::Text(text) if text == "auto" => Ok(Self::Auto),
Value::Text(text) if text == "fill" => Ok(Self::Fill),
Value::Number(_) => Ok(Self::Fixed(crate::scroll::coordinate(value).and_then(
|n| {
// coordinate 已检查有限性与绝对值预算,盒尺寸再额外排除负数。
if n >= 0.0 {
Ok(n)
} else {
@@ -51,17 +61,24 @@ impl Dimension {
#[derive(Debug, Clone, Copy, PartialEq)]
/// border-box 尺寸策略与双轴上下限,实际求解仍要结合父级约束。
pub struct Sizing {
// 横轴 auto/fixed/fill 策略,按边框盒而非内容盒解释。
pub width: Dimension,
// 纵轴独立尺寸策略,不能沿用横轴是否有限的判断。
pub height: Dimension,
// 横轴自身下限,父可用宽更小时仍以父上限优先。
pub min_width: f32,
// 纵轴自身下限,单位为逻辑长度。
pub min_height: f32,
// 横轴自身上限,available 在内容测量前应用以产生正确文字换行。
pub max_width: f32,
// 纵轴自身上限,默认 MAX_DIMENSION 同时作为无界分配哨兵。
pub max_height: f32,
}
impl Default for Sizing {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
// 默认跟随内容,零下限与全局最大值不提前冻结自然尺寸;构造器可随后覆盖。
Self {
width: Dimension::Auto,
height: Dimension::Auto,
@@ -78,12 +95,16 @@ impl Sizing {
/// 将 [最小宽, 最小高, 最大宽, 最大高] 收窄,供子内容测量和文本换行使用。
/// 固定尺寸限制最大可用空间;最后缩小最小约束以保持 min <= max。这里不直接计算最终盒子。
pub fn available(self, mut constraints: [f32; 4]) -> [f32; 4] {
// layout::shape/child_constraints 先调用这里;返回仍是边框盒约束,border/padding 由布局调用方随后扣除。
// Auto 的自身 min 不在这里抬高父最小值;最终 resolve 再应用自身 min/max,避免提前把内容测量冻结成分配尺寸。
// 先套自身 max,再应用 Fixed;Auto/Fill 在测量阶段保留父级允许的空间。
constraints[2] = constraints[2].min(self.max_width);
constraints[3] = constraints[3].min(self.max_height);
// 固定宽先抬到自身 min_width,再收紧父最大宽,保证文本按实际可用盒宽测量。
if let Dimension::Fixed(width) = self.width {
constraints[2] = constraints[2].min(width.max(self.min_width));
}
// 纵轴独立应用同一规则;不改变另一轴策略,也不在此填充 Fill。
if let Dimension::Fixed(height) = self.height {
constraints[3] = constraints[3].min(height.max(self.min_height));
}
@@ -93,6 +114,7 @@ impl Sizing {
}
/// 结合内容期望尺寸、轴策略和父级约束求最终盒子尺寸;无界轴不能解析 fill。
/// layout::measure_shape 的 Shape::Sized 路径调用;intrinsic 已包含该形状的有效内缩量,不在这里重复加 padding/border。
pub fn resolve(self, intrinsic: Size, constraints: [f32; 4]) -> Result<Size> {
/// 针对单个尺寸轴应用约束与边界规则,供双轴尺寸求解复用。
fn axis(
@@ -118,8 +140,10 @@ impl Sizing {
}
};
// 先满足自身 min/max,再服从父级上限;父级空间比 min 小时,父级上限优先。
// min/max 的合法性由模型预检;这个数值助手不修复反向上下限,不能把任意未校验 Sizing 直接送进 clamp。
Ok(value.clamp(min, max).min(available))
}
// 横纵轴分别求解,只取父最大分配 constraints[2/3];输入的最小约束不在此额外强加。
Ok(Size {
width: axis(
self.width,
+29
View File
@@ -1,5 +1,7 @@
//! 面向设备像素的绘制指令集合。它承载矩形、文字、裁剪与 3D 表面等原语,不直接操作 VM。
//! 平移和缩放统一作用于命令几何;文字样式和裁剪也要使用一致的设备比例。
//! 调用路径:RenderDocument.display_list 将已验证逻辑命令转为这些设备原语;shell/system_ui 也可直接组装。
//! SoftwareRenderer 顺序消费 commands;合成页面/工具面板时用 translate/extend,不能把共享场景数据本身平移。
//!
use crate::geometry::{Point, Rect};
use crate::text::TextStyle;
@@ -7,9 +9,13 @@ use crate::text::TextStyle;
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
/// 设备绘制使用的 RGBA 字节通道,区别于源代码的颜色文本。
pub struct Color {
// 红通道字节;源十六进制颜色由 document.parse_color 解码。
pub red: u8,
// 绿通道字节,颜色本身不带坐标或设备比例。
pub green: u8,
// 蓝通道字节,与输出软件像素通道组合。
pub blue: u8,
// 透明度 0..255,混合方式由具体原语渲染器解释。
pub alpha: u8,
}
@@ -40,11 +46,17 @@ impl Color {
pub enum DrawCommand {
/// Immutable RGBA image mapped from its local rectangle by rotation.
Image {
// 不可变 RGBA 资源 Arc,窗口渲染无需访问源对象或重新解码 PNG。
image: std::sync::Arc<crate::render::image::Image>,
// 设备坐标旋转原点,平移只移动它,不改局部 rect 的偏移。
origin: Point,
// 相对 origin 的局部采样矩形,旋转时绕 origin 映射。
rect: Rect,
// 旋转角参数,平移/同比缩放不改变它。
angle: f32,
// 整体不透明度系数,与资源像素 alpha 在采样时组合。
opacity: f32,
// 可选染色通道值,不属于位置/尺寸变换。
tint: Option<Color>,
},
// 在 rect 内绘制不可变 3D 视图;平移/缩放改变视口矩形,不改写共享场景或相机。
@@ -92,6 +104,7 @@ impl DrawCommand {
pub fn translated(mut self, delta: Point) -> Self {
// 平移只改变位置,保留尺寸、圆角、文字样式和颜色;消费 self 后返回修改后的命令。
match &mut self {
// 这些原语的定位直接在 rect 上,移动矩形原点即可,尺寸保持不变。
Self::WrappedText { rect, .. }
| Self::Scene3D { rect, .. }
| Self::PushClip(rect)
@@ -100,10 +113,12 @@ impl DrawCommand {
rect.x += delta.x;
rect.y += delta.y;
}
// 文字与图片使用 origin 定位;图片 rect 是局部偏移,不能在此跟着重复平移。
Self::Text { origin, .. } | Self::Image { origin, .. } => {
origin.x += delta.x;
origin.y += delta.y;
}
// Clear/PopClip 无定位几何,平移不产生任何变化。
_ => {}
}
self
@@ -113,6 +128,7 @@ impl DrawCommand {
#[derive(Clone, Debug, Default)]
/// 有序绘制原语容器;命令顺序同时约束叠放和裁剪栈行为。
pub struct DisplayList {
// 拥有按执行顺序排列的原语、字符串与原生资源 Arc,列表本身不引用 VM。
commands: Vec<DrawCommand>,
}
@@ -120,19 +136,25 @@ impl DisplayList {
/// 按设备比例缩放命令,文字、边框和矩形不能分别使用不一致比例。
pub fn scale(&mut self, scale: f32) {
// 原地累计缩放:重复调用会相乘,并非设置绝对缩放值。调用方负责提供有效设备比例。
// scene 合成宿主 chrome 时调用;只改视口/局部几何,不重新解码 Image 或重建 Scene3D 世界数据。
for command in &mut self.commands {
// 先处理各变体独有的长度(字体、圆角、描边),再把公共矩形交给下方统一缩放。
let rect = match command {
// 有限文字框交给公共矩形缩放,样式先同步乘比例,避免换行框与字形不一致。
DrawCommand::WrappedText { rect, style, .. } => {
*style = style.scaled(scale);
Some(rect)
}
// 裁剪属于设备几何,和所裁内容使用相同比例。
DrawCommand::PushClip(rect) => Some(rect),
// 只改三维显示区域,不缩放共享世界网格和相机数值。
DrawCommand::Scene3D { rect, .. } => Some(rect),
// 圆角作为长度先缩放,再返回矩形供统一处理。
DrawCommand::FillRoundedRect { rect, radius, .. } => {
*radius *= scale;
Some(rect)
}
// 半径与描边宽也要缩放,不能只改外框而保留旧粗细。
DrawCommand::StrokeRoundedRect {
rect,
radius,
@@ -143,6 +165,7 @@ impl DisplayList {
*width *= scale;
Some(rect)
}
// 图片 origin 和局部 rect 均在设备单位,分别缩放后返回 None,防止公共分支再缩放一次。
DrawCommand::Image { origin, rect, .. } => {
origin.x *= scale;
origin.y *= scale;
@@ -152,12 +175,14 @@ impl DisplayList {
rect.height *= scale;
None
}
// 无框文字单独缩放原点和样式,没有矩形需要返回公共路径。
DrawCommand::Text { origin, style, .. } => {
origin.x *= scale;
origin.y *= scale;
*style = style.scaled(scale);
None
}
// Clear/PopClip 不含长度数据,保持原样。
_ => None,
};
// 矩形的原点和宽高一起缩放,保证裁剪范围、文本框与实际绘制内容对齐。
@@ -177,7 +202,9 @@ impl DisplayList {
/// 平移当前显示列表中的命令,供整块页面或管理面板放入窗口坐标。
// 与单条 translated 相同,但直接修改整个列表;Clear/PopClip 没有位置需要移动。
pub fn translate(&mut self, delta: Point) {
// 保持原语顺序逐项移动,不能按种类分组而打乱覆盖关系和裁剪栈。
for command in &mut self.commands {
// 与单条 translated 保持同一空间规则:rect 型移原点,图片只移旋转 origin。
match command {
DrawCommand::WrappedText { rect, .. }
| DrawCommand::Scene3D { rect, .. }
@@ -191,6 +218,7 @@ impl DisplayList {
origin.x += delta.x;
origin.y += delta.y;
}
// 无几何的状态原语保留顺序但不做坐标更新。
DrawCommand::Clear(_) | DrawCommand::PopClip => {}
}
}
@@ -204,6 +232,7 @@ impl DisplayList {
/// 按原顺序合并另一组绘制命令,不在这里重排层次。
// 消费另一个列表并移动其命令,不复制字符串或场景数据,也不在连接处补裁剪指令。
pub fn extend(&mut self, other: DisplayList) {
// 连接处不自动隔离 Clear 或裁剪状态;scene 合并页面时会自行跳过额外 Clear,并用外层 PushClip/PopClip 限制页面区域。
self.commands.extend(other.commands);
}
}
+11
View File
@@ -1,9 +1,13 @@
//! 共享的二维点和矩形值。命中判断在调用方完成坐标系转换后使用,矩形本身不隐式处理相机。
//! 调用路径:布局与 viewport 生成点/矩形,文档命中先做坐标逆变换再调用 contains;软件渲染也用它们表达设备区域。
//! 这里只是数值容器,不隐式校验/夹取/做相机变换;有限性和尺寸合法性在各协议入口检查。
//!
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
/// 二维坐标值;具体处于逻辑、局部还是设备空间由调用上下文决定。
pub struct Point {
// 横坐标原值,单位与空间由构造方约定。
pub x: f32,
// 纵坐标原值,不假定一定是世界向上或屏幕向下。
pub y: f32,
}
@@ -17,9 +21,13 @@ impl Point {
#[derive(Clone, Copy, Debug, Default, PartialEq)]
/// 轴对齐矩形,保存原点和宽高,用于布局、裁剪与命中。
pub struct Rect {
// 左侧原点,在同一坐标系中与 point.x 比较。
pub x: f32,
// 顶部原点,在同一坐标系中与 point.y 比较。
pub y: f32,
// 横向长度,不存储右端点;contains 使用 x+width。
pub width: f32,
// 纵向长度,不存储下端点;调用方负责非负尺寸约定。
pub height: f32,
}
@@ -36,6 +44,9 @@ impl Rect {
/// 用闭区间比较判断点是否命中,左右上下边缘都计入;调用前需将点转换到矩形所在空间。
pub fn contains(self, point: Point) -> bool {
// 闭区间四边比较;自身不处理裁剪叠加、圆角或世界面板遮挡,这些属于更上层命中流程。
// 相邻矩形共享边缘时两者都可命中,Frame/工具命中调用方按绘制顺序选目标;这里不负责消除重叠。
// 零宽/零高矩形也可能命中其线段/端点;要求正面积的裁剪或视口调用方必须另外判断尺寸。
point.x >= self.x
&& point.x <= self.x + self.width
&& point.y >= self.y
+77
View File
@@ -1,3 +1,8 @@
//! 有界不可变 RGBA 图片资源,由原生 Arc 共享,准备阶段解码/排字,逐帧软件渲染只采样像素。
//! 调用路径:graphics 的图像加载入口调用 png,text_flow 调用 text 缓存设备分辨率字词。
//! SoftwareRenderer.draw_image 逆映射目标像素后调用 sample;graphics 使用 outline 取得排版避让轮廓。
//! id 只用于原生缓存身份比较,不是 VM 句柄,也不表示两个相同像素资源必有相同 ID。
//!
//! Immutable, bounded RGBA images shared by display lists. Decoding and text
//! preparation happen outside the frame loop; the CPU backend samples them.
use crate::{
@@ -8,21 +13,32 @@ use crate::{
use std::{io::Cursor, sync::Arc};
#[derive(Debug, serde::Serialize)]
// 像素、尺寸和轮廓在构造完成后由 Arc 只读共享;序列化省略原始像素与轮廓。
pub struct Image {
// 资源实际像素宽,png/text 构造保证至少为一。
pub width: u32,
// 资源实际像素高,与绘制局部逻辑 rect 可按 scale 不同。
pub height: u32,
// 每次准备新资源取得的进程内数值身份,PartialEq 只比较它以便快速判断缓存复用。
pub id: u64,
#[serde(skip)]
// 行优先 RGBA 字节数组,保存 straight alpha;采样时临时转预乘权重避免透明边黑晕。
pixels: Vec<u8>,
#[serde(skip)]
// PNG 的 alpha 保守凸包,用于图形排版避让;预渲染文字目前返回空轮廓。
pub outline: Vec<crate::geometry::Point>,
}
impl Image {
// 将受预算约束的 PNG 解码为 RGBA,并一次性生成透明轮廓;格式/解码错误直接交给加载调用方。
pub fn png(bytes: &[u8]) -> anyhow::Result<Arc<Self>> {
// 借用输入字节建立内存解码器,不在帧循环执行文件 I/O。
let mut decoder = png::Decoder::new(Cursor::new(bytes));
// 展开调色板/低位深并去掉 16 位低字节,使后面每个颜色样本可按 8 位通道读取。
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
// 先读取 PNG 头,像素预算在分配输出缓冲前检查。
let mut reader = decoder.read_info()?;
// 头部尺寸非零且总像素不超过 4194304,使用 u64 乘法避免 u32 乘积溢出。
let info = reader.info();
anyhow::ensure!(
info.width > 0
@@ -30,24 +46,35 @@ impl Image {
&& u64::from(info.width) * u64::from(info.height) <= 4_194_304,
"image exceeds pixel budget"
);
// 按解码器计算的输出字节数分配,计算溢出时拒绝而非猜测缓冲长度。
let mut buffer = vec![
0;
reader
.output_buffer_size()
.ok_or_else(|| anyhow::anyhow!("image size overflow"))?
];
// 解码一帧并取得实际输出布局,只消费 buffer_size 范围,不读取未写尾部。
let info = reader.next_frame(&mut buffer)?;
// 预留统一四通道容量,原 PNG 可以是灰度、RGB 或 RGBA。
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
// 按解码后的每像素样本数遍历,所有格式归并成同一 RGBA 存储。
for chunk in buffer[..info.buffer_size()].chunks_exact(info.color_type.samples()) {
// 灰度复制到 RGB,缺 alpha 填 255;若 Indexed 未展开则报错,不能把索引当颜色通道。
let rgba = match info.color_type {
// 原生四通道直接复制,保留源 alpha。
png::ColorType::Rgba => [chunk[0], chunk[1], chunk[2], chunk[3]],
// 三通道图片默认全不透明,不从相邻像素误读 alpha。
png::ColorType::Rgb => [chunk[0], chunk[1], chunk[2], 255],
// 单灰度通道复制到三色,完全不透明。
png::ColorType::Grayscale => [chunk[0], chunk[0], chunk[0], 255],
// 灰度/alpha 双通道保留透明度,前三色仍同值。
png::ColorType::GrayscaleAlpha => [chunk[0], chunk[0], chunk[0], chunk[1]],
// EXPAND 后正常不应残留索引图,若仍存在就拒绝不支持的输出布局。
png::ColorType::Indexed => anyhow::bail!("unexpanded indexed PNG"),
};
pixels.extend_from_slice(&rgba);
}
// 资源准备阶段扫描 alpha 生成保守轮廓,后续排版不必每帧重新遍历图片像素。
let outline = alpha_hull(&pixels, info.width, info.height);
Ok(Arc::new(Self {
width: info.width,
@@ -60,20 +87,29 @@ impl Image {
/// Cache a shaped word at the actual device resolution. Padding preserves
/// italic/serif overhangs. Returns its logical image rectangle and advance.
// 按实际设备 scale 预渲染一段已排字文本,返回共享资源、逻辑图片框和不含 padding 的文字 advance。
pub fn text(
value: &str,
style: TextStyle,
scale: f32,
color: Color,
) -> Result<(Arc<Self>, Rect, f32), &'static str> {
// 用逻辑 TextStyle 测量自然字形 advance/行高;返回 advance 不随设备 DPI 改变。
// 调用链 graphics::Prepare 已检查 scale/字号,Prepared::new 把同一份样式传入;此助手只另查单图像素预算。
let size = text::measure(value, style);
// 按字号预留约 30% 整数逻辑边距,容纳斜体/衬线伸出而不裁掉字形。
let pad = (style.font_size * 0.3).ceil();
// 两侧 padding 后乘设备比例再向上取整,实际资源至少一像素宽。
let width = ((size.width + pad * 2.) * scale).ceil().max(1.) as u32;
// 高度也含两侧留白并按设备比例取整,与 width 分别计算。
let height = ((size.height + pad * 2.) * scale).ceil().max(1.) as u32;
// 准备文字资源另限 1048576 像素,避免单段文本缓冲无限增长。
if u64::from(width) * u64::from(height) > 1_048_576 {
return Err("prepared text image exceeds pixel budget");
}
// 零初始化 RGBA 即透明背景,字形回调只修改实际覆盖的像素。
let mut pixels = vec![0; width as usize * height as usize * 4];
// 以设备字号绘制,负 padding 框与下面回调偏移共同把逻辑文字原点放在资源留白之后。
text::draw_clipped(
value,
style.scaled(scale),
@@ -81,19 +117,25 @@ impl Image {
None,
Rect::new(-pad * scale, -pad * scale, width as f32, height as f32),
|x, y, c| {
// 把绘制回调坐标移到含左 padding 的缓冲位置,按设备像素舍入。
let x = x + (pad * scale).round() as i32;
// 纵向也加 top padding,保持字形基线与返回的逻辑图片框一致。
let y = y + (pad * scale).round() as i32;
// 排除所有缓冲外像素,防止字形 overhang 或舍入造成越界写入。
if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 {
return;
}
// 行优先像素索引乘四得到 RGBA 字节起点,先通过上面坐标范围检查。
let i = (y as usize * width as usize + x as usize) * 4;
// All glyphs have the same ink color; alpha-over preserves overlaps.
// 相同墨色只覆盖 RGB,alpha 按新覆盖率 over 旧覆盖率合成,保留字形重叠处的覆盖,避免后画的抗锯齿边抹淡旧墨迹。
pixels[i..i + 3].copy_from_slice(&[c.red, c.green, c.blue]);
pixels[i + 3] = (u16::from(c.alpha)
+ u16::from(pixels[i + 3]) * (255 - u16::from(c.alpha)) / 255)
as u8;
},
);
// 返回的新图有独立 id;逻辑 rect 左上为负 padding,绘图能保持原文字基线与 advance 对齐。
Ok((
Arc::new(Self {
width,
@@ -107,30 +149,45 @@ impl Image {
))
}
// 软件渲染器传入逆映射的归一化纹理点和已夹取 opacity;预乘 alpha 双线性插值后返回 straight RGBA。
pub fn sample(&self, u: f32, v: f32, opacity: f32) -> Color {
// draw_image 在目标局部矩形内算 u/v 后调用;本方法夹取采样邻居而非定义图像外透明区,不能代替目标覆盖范围检查。
// 归一化 u 映射到像素中心索引,减 0.5 使 texel 中心落在整数坐标。
let x = u * self.width as f32 - 0.5;
// v 使用同样中心约定,不把资源边缘当作像素中心。
let y = v * self.height as f32 - 0.5;
// 取左上采样格的整数基点,四个邻居随后夹在资源边界。
let (ix, iy) = (x.floor() as i32, y.floor() as i32);
// 小数部分作为双线性权重,四项权重之和为一。
let (fx, fy) = (x - x.floor(), y - y.floor());
// 前三项累计 RGB×alpha×权重,第四项累计 alpha×权重,避免透明像素 RGB 污染边缘。
let mut sum = [0.; 4];
// 四邻像素按二维线性乘积权重采样,边缘通过 clamping 复制最近 texel。
for (dx, dy, w) in [
(0, 0, (1. - fx) * (1. - fy)),
(1, 0, fx * (1. - fy)),
(0, 1, (1. - fx) * fy),
(1, 1, fx * fy),
] {
// 横轴邻居夹到有效像素列,不在透明边缘之外读越界数据。
let px = (ix + dx).clamp(0, self.width as i32 - 1) as usize;
// 纵轴独立夹取,图片宽高不同也不能复用同一边界。
let py = (iy + dy).clamp(0, self.height as i32 - 1) as usize;
// 转换资源像素位置为四通道字节偏移。
let i = (py * self.width as usize + px) * 4;
// alpha 保留 0..255 数值再乘权重,前三色同用该权重以实现预乘插值。
let a = f32::from(self.pixels[i + 3]) * w;
// 只累计 RGB 三通道,最终 alpha 单独累加,避免重复乘整体 opacity。
for (j, s) in sum.iter_mut().enumerate().take(3) {
*s += f32::from(self.pixels[i + j]) * a;
}
sum[3] += a;
}
// 完全透明时直接返回透明黑,避免除以零且不泄露透明像素的无意义 RGB。
if sum[3] <= 0. {
return Color::rgba(0, 0, 0, 0);
}
// RGB 除累计 alpha 恢复 straight 通道,整体 opacity 只作用最终 alpha。
Color::rgba(
(sum[0] / sum[3]).round() as u8,
(sum[1] / sum[3]).round() as u8,
@@ -143,50 +200,70 @@ impl Image {
/// Conservative convex silhouette from opaque row endpoints. Unlike an image's
/// rectangle, this excludes empty transparent margins. Generated once per PNG.
fn alpha_hull(pixels: &[u8], width: u32, height: u32) -> Vec<crate::geometry::Point> {
// png 构造后只算一次;返回以源像素为单位的保守凸包,graphics::ImageExclusion 再按 Sprite 尺寸/偏移/角度变换。
use crate::geometry::Point;
// 先收集各有效行左右端像素的四角点,后面求整体凸包,而非逐像素精确边界。
let mut points = Vec::new();
// 按行扫描 alpha,阈值 16 去掉几乎透明的外围像素。
for y in 0..height {
// 只保留 alpha>=16 的列号,颜色通道不影响避让轮廓。
let xs: Vec<_> = (0..width)
.filter(|&x| pixels[((y * width + x) * 4 + 3) as usize] >= 16)
.collect();
// 该行有有效像素才生成候选角点,空透明行不参与外形。
if let (Some(&left), Some(&right)) = (xs.first(), xs.last()) {
// 右端取 right+1 表达像素外边界,不把末列中心当作轮廓右缘。
for x in [left as f32, right as f32 + 1.] {
// 上下各取像素行边界,保守覆盖整个端点像素单元。
for yy in [y as f32, y as f32 + 1.] {
points.push(Point::new(x, yy));
}
}
}
}
// 按 x/y 全序排序,后面单调链要求稳定几何次序;随后去掉重复角点。
points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
points.dedup();
// 不足三点直接返回,无法构成面积凸多边形,不补造图片矩形。
if points.len() < 3 {
return points;
}
// 二维叉积符号判断转向,单调链移除非凸与共线中间点。
let cross =
|a: Point, b: Point, c: Point| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
// 结果栈先构造下链,再逆序追加上链。
let mut hull: Vec<Point> = Vec::new();
// 按升序构造第一条凸包链,每个候选最多入栈/出栈一次。
for &p in &points {
// 新点使末两点不构成严格左转时弹出中间点,保留外侧端点。
while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0. {
hull.pop();
}
hull.push(p);
}
// 记录下链末端位置,构造上链时不能越过这条边界。
let lower = hull.len();
// 反向跳过已在下链末端的最右点,继续构造上链并闭合。
for &p in points.iter().rev().skip(1) {
// 与下链相同转向规则,但保留已完成下链;最后会去掉重复闭合首点。
while hull.len() > lower && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0. {
hull.pop();
}
hull.push(p);
}
// 首点在上下链闭合时出现两次,移除末尾重复点,返回不含闭合副本的凸包。
hull.pop();
hull
}
// 为每个准备完成的原生资源分配进程内身份;只需要原子递增取号,不承担读写像素的跨线程发布屏障。
fn next_id() -> u64 {
// 计数器从一开始,Relaxed 只用于取号;资源的安全共享由不可变 Arc 实现。
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl PartialEq for Image {
// 原生缓存比较资源身份而非扫描像素;两次解码相同文件也会是不同资源 ID。
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
+195 -1
View File
@@ -2,6 +2,10 @@
//! Row/Column/Overlay、边距、边框、内边距、对齐和弹性空间都在有限约束内计算;过大的尺寸和坐标返回错误。
//! 缓存按组件形状、子项尺寸和约束判断可复用性。frame 先在克隆状态中求解,成功才替换运行状态,防止失败污染已发布几何。
//!
//! 调用路径:UiBridge/原生视觉入口调用 LayoutRuntime 或 frame_with_viewport 进行纯原生布局。
//! source_layout 复用约束、measure_native、flex_sizes、arrange_native 等单节点函数,在外层驱动源协议收敛。
//! 本模块只处理不可变 UiNode 和原生数值,不执行 VM 或持有托管根;每次出现身份由父 ID/slot/业务身份共同确定。
//!
//! Iterative measure/arrange/paint over derived visual data. Every occurrence
//! has its own geometry; measurement caches never become semantic object state.
use crate::{
@@ -12,14 +16,19 @@ use crate::{
use anyhow::{Context as _, Result, bail};
use std::collections::{HashMap, HashSet};
// 一次原生整树构建最多展开一万个出现,共享业务对象的不同位置也各计一次。
const MAX_LAYOUT_VISITS: usize = 10_000;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
/// 上右下左四边的空间占用,用于 margin、padding 和 border 计算。
pub struct Insets {
// 顶侧占用,来自上 margin/padding/border 的哪一层由调用方确定。
pub top: f32,
// 右侧占用,horizontal 与 left 相加,不含父容器自身位置。
pub right: f32,
// 底侧占用,vertical 与 top 相加。
pub bottom: f32,
// 左侧占用,安排内容原点时加到父边框盒 x。
pub left: f32,
}
@@ -47,15 +56,20 @@ impl Insets {
/// 交叉轴对齐规则;Stretch 需要结合子项是否自动尺寸决定实际拉伸。
pub enum CrossAlign {
#[default]
// 对齐内容区起点;内容溢出时其他偏移模式也退回起点。
Start,
// 按交叉轴非负剩余空间的一半偏移,不改变自然尺寸。
Center,
// 按交叉轴全部非负剩余空间偏移。
End,
// 偏移为零,尺寸拉伸由 flex_sizes 按 Auto/上下限另行计算。
Stretch,
}
impl CrossAlign {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
// 模型字段编码用稳定小写名字,alignSelf 的 auto 不属于此枚举,由 Option 在模型层表示。
match self {
Self::Start => "start",
Self::Center => "center",
@@ -66,6 +80,7 @@ impl CrossAlign {
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
// 拒绝未知对齐策略;auto 应在调用方转为无自身覆盖,不能解析成某个具体父策略。
match value {
"start" => Ok(Self::Start),
"center" => Ok(Self::Center),
@@ -81,6 +96,7 @@ impl CrossAlign {
fn offset(self, available: f32, occupied: f32) -> f32 {
// 溢出时剩余空间归零,Center/End 都退化为起点对齐,避免把内容前缘移出可达滚动范围。
let spare = (available - occupied).max(0.0);
// Stretch 的位置与 Start 相同,实际可拉伸尺寸在分配阶段已经决定。
match self {
Self::Start | Self::Stretch => 0.0,
Self::Center => spare / 2.0,
@@ -93,14 +109,19 @@ impl CrossAlign {
/// 容器主轴的内容对齐或剩余间隔分配方式。
pub enum MainAlign {
#[default]
// 主轴从内容起点顺序排列。
Start,
// 把正剩余空间平分到内容前后,保持既定子项尺寸与固定 gap。
Center,
// 将正剩余空间放到首项之前。
End,
// 至少两个孩子时把剩余空间均匀加到项间,原固定 gap 仍保留。
SpaceBetween,
}
impl MainAlign {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
// 把已验证主轴枚举编码回源属性,字段名与布局分派共享这组名称。
match self {
Self::Start => "start",
Self::Center => "center",
@@ -110,6 +131,7 @@ impl MainAlign {
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
// 主轴只支持四个明确策略,不接受交叉轴 stretch 或任意别名。
match value {
"start" => Ok(Self::Start),
"center" => Ok(Self::Center),
@@ -128,7 +150,9 @@ impl MainAlign {
pub struct FlexPolicy {
// Some 指定主轴首选基准,None 使用首次内容测量;grow/fill 的缺省基准在 flex_sizes 中解释为零。
pub basis: Option<f32>,
// 主轴弹性分配下限,还要与对应盒模型 min/max 求交。
pub min: f32,
// 主轴弹性分配上限,达到后退出剩余空间的再分配。
pub max: f32,
// 收缩权重还会乘 basis;默认零表示空间不足时不主动压缩该项。
pub shrink: f32,
@@ -148,25 +172,35 @@ impl Default for FlexPolicy {
#[derive(Default, Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
/// 逻辑宽高值,不隐含窗口像素比例或相机缩放。
pub struct Size {
// 逻辑横向长度,测量/分配上下文决定是自然尺寸还是实际边框盒宽。
pub width: f32,
// 逻辑纵向长度,不携带 DPI 或相机倍率。
pub height: f32,
}
#[derive(Debug, Clone, PartialEq)]
/// 影响原生测量的缓存形状,避免把颜色等纯绘制变化视为尺寸变化。
enum Shape {
// 非默认盒策略把尺寸约束也纳入缓存键,防止同业务对象在不同父分配中误用结果。
Sized(Box<Shape>, crate::box_model::Sizing, [f32; 4]),
// 文本内容、字号等级与有效换行宽都影响测量,颜色不进入键。
Text(String, u32, f32),
// 固定自然宽高,亦用于视口、间隔和场景外包范围。
Rect(f32, f32),
// 方向、有效 inset、gap 和孩子外尺寸一起决定容器自然尺寸。
Container(bool, Insets, f32, Vec<Size>),
// 为内容形状套上实际 inset,文字自然测量需加回边框/内边距。
Padded(Box<Shape>, Insets),
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
/// 结构出现身份键,区分同一业务对象在不同父位置的展示。
struct Key {
// 父出现 Mount ID;同一业务节点在不同父下要有不同出现身份。
parent: Option<u64>,
// 父组成边的稳定 slot,不是当前数组下标或屏幕坐标。
slot: u64,
// 孩子业务身份文本,替换为不同对象时同一 slot 也不能误复用旧出现。
block: String,
}
@@ -177,27 +211,41 @@ pub struct LayoutRuntime {
sizes: HashMap<String, (Shape, Size)>,
// 出现身份取父 Mount、稳定 slot 和 Block 身份三元组,区别于仅按业务组件分配一个全局 ID。
ids: HashMap<Key, u64>,
// 已分配 ID 高水位,清出现表或取消重试也不应降低。
next_id: u64,
// 每次成功 structure/frame 构建递增,供后续 Mount 与输入归属校验。
pub version: u64,
// 本次实际执行 measure_shape 的缓存未命中次数,不是扫描节点总数。
pub measured: usize,
// 完整原生构建安排出现次数;结构准备不记为已完成排列。
pub arranged: usize,
// 纯原生完整构建记一轮,结构准备记零;源布局驱动另统计其迭代。
pub settle_passes: usize,
// 此同步原生构建的工作片统计,源协议可恢复切片由外层驱动维护。
pub slices: usize,
// 本窗口逻辑可用大小,未知时使用有限数值的无界哨兵。
pub viewport: Option<Size>,
// 可选旧 Page 模式,用于生成根轴约束;独立场景视口另有自己的有限盒。
pub page_mode: Option<crate::page::Mode>,
}
/// 迭代遍历中的一个出现,保存父子索引、约束与稳定 ID。
struct Item<'a> {
// 借用当前原生不可变视觉节点,构建完成前根树需保持存活。
node: &'a UiNode,
// items 中父出现索引,发布时再转换为父 Mount ID。
parent: Option<usize>,
// items 中有序子出现索引,同一 UiNode Arc 可出现在多个索引。
children: Vec<usize>,
// 由结构 Key 复用或新分配的本出现 ID。
id: u64,
// 此出现独立的 [minW,minH,maxW,maxH],不能按共享业务对象合并。
constraints: [f32; 4],
}
/// 检查尺寸非负、有限且不超过布局上限。
fn bounded(value: f32) -> Result<f32> {
// 所有最终测量长度经此边界检查,NaN/负数/超预算都导致本次布局失败。
if !value.is_finite() || !(0.0..=MAX_DIMENSION as f32).contains(&value) {
bail!("UI layout exceeds the 1000000 coordinate/size limit");
}
@@ -206,6 +254,7 @@ fn bounded(value: f32) -> Result<f32> {
/// 检查允许有正负方向的坐标仍在安全布局范围内。
fn coordinate(value: f32) -> Result<f32> {
// 位置允许负方向,但有限性和绝对值预算仍约束最终原点及远端。
if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 {
bail!("UI coordinate exceeds the 1000000 limit");
}
@@ -233,9 +282,11 @@ fn content_limit(available: f32, inset: f32) -> f32 {
}
/// 把父盒子的可用尺寸传递给子项,扣除容器内边距并考虑主轴是否可延伸。
pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
// 先让父自身 boxWidth/Height 与 max 约束进入可用空间,再选择孩子的轴规则。
let parent = node.sizing.available(parent);
// Overlay 双轴都受内容盒上限约束;不像 Row/Column 在主轴自然展开。
if let UiKind::Overlay { .. } = node.kind {
// Overlay 双轴内容区扣 border+有效 padding,不让孩子共享线性主轴无界规则。
let padding = insets(node);
return [
0.0,
@@ -244,6 +295,7 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
content_limit(parent[3], padding.vertical()),
];
}
// 二维世界及其观察容器不拿屏幕宽强迫世界内容换行,世界约束使用无界哨兵。
if matches!(
node.kind,
UiKind::Canvas { .. } | UiKind::Scene2D { .. } | UiKind::CanvasViewport { .. }
@@ -251,6 +303,7 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
// 世界 Canvas/Scene2D 与其观察视口不把屏幕尺寸传入世界布局,场景孩子用无界轴约束。
return root_constraints(None);
}
// 滚动孩子约束基于视口实际分配及允许轴,构造宽高先受父约束夹取。
if let UiKind::Scroll { width, height, .. } = node.kind {
return arranged_child_constraints(
node,
@@ -278,6 +331,7 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
/// 从子项可用范围扣除外边距,并使最小值不超过调整后的最大值。
pub fn margin_constraints(child: &UiNode, mut constraints: [f32; 4]) -> [f32; 4] {
// 按横纵轴分别扣两侧 margin,不能用某一轴的总外边距同时改另一轴。
for (axis, margin) in [child.margin.horizontal(), child.margin.vertical()]
.into_iter()
.enumerate()
@@ -302,11 +356,15 @@ pub fn occurrence_constraints(
child_index: usize,
) -> [f32; 4] {
// 世界面板 UI 根必须精确按纹理分辨率布局,最小与最大相同,不让外层视口世界尺寸泄入纹理空间。
// build 的初始展开、native_visual 的独立 arrange 与 source_layout 收敛都按同一 child_index 解释这些输入,不能按 Block ID 合并共享孩子。
if let UiKind::Viewport3D { panels, .. } = &parent.kind {
// 面板 child_index 与 panels/children 保持同序,该 UI 根按自己的纹理尺寸精确布局。
let size = panels[child_index];
return [size.width, size.height, size.width, size.height];
}
// Fill 初次测量需扣父内容 inset,后面再由 margin_constraints 扣孩子外边距。
let inset = insets(parent);
// 两轴独立判断 fill 与当前 offer 是否有限,不能因另一轴有限就允许无界 fill。
for (axis, dimension, available, padding) in [
(0, child.sizing.width, offer.width, inset.horizontal()),
(1, child.sizing.height, offer.height, inset.vertical()),
@@ -330,6 +388,7 @@ pub fn initial_child_constraints(
constraints: [f32; 4],
child_index: usize,
) -> [f32; 4] {
// 父自身盒策略先收窄初次可分配最大值,提供给 occurrence_constraints 补有限 fill offer。
let offer = parent.sizing.available(constraints);
occurrence_constraints(
parent,
@@ -348,15 +407,18 @@ pub fn initial_child_constraints(
pub fn arranged_child_constraints(node: &UiNode, available: [f32; 4], bounds: Size) -> [f32; 4] {
// 滚动允许轴无界以容纳内容,禁用轴使用实际已安排的视口尺寸,文字才能按最终交叉轴宽度换行。
if let UiKind::Scroll { axis, .. } = node.kind {
// 禁用滚动轴使用此出现已安排 bounds 扣 inset,不能继续按最初构造参数给文字宽度。
let padding = insets(node);
return [
0.0,
0.0,
// 允许横向滚动时内容宽使用无界哨兵;禁止横向时用实际内容视口有限宽以驱动换行。
if axis.horizontal() {
MAX_DIMENSION as f32
} else {
(bounds.width - padding.horizontal()).max(0.0)
},
// 纵轴独立判断是否可延伸,禁用轴必须受当前出现 bounds 限制。
if axis.vertical() {
MAX_DIMENSION as f32
} else {
@@ -397,6 +459,7 @@ pub fn insets(node: &UiNode) -> Insets {
/// 提取容器方向与间距等原生布局配置,叶节点不参与容器规则。
fn container(node: &UiNode) -> Option<(bool, Insets, f32)> {
// 只识别线性 Row/Column/Button/Scroll,Overlay/世界场景在调用方有独立布局分支。
match &node.kind {
UiKind::Row { gap, .. } => Some((true, insets(node), *gap)),
UiKind::Scroll { gap, .. } => Some((false, insets(node), *gap)),
@@ -409,20 +472,26 @@ fn container(node: &UiNode) -> Option<(bool, Insets, f32)> {
/// 根据容器方向选择主轴,供 flex 和对齐共享同一判断。
pub fn main_axis(node: &UiNode) -> Option<usize> {
// row=true 映射横轴 0,纵向映射轴 1;非线性节点返回 None。
container(node).map(|(row, _, _)| usize::from(!row))
}
/// 根据形状键计算期望尺寸,包括文字换行、容器子项和盒模型占用。
fn measure_shape(shape: &Shape) -> Result<Size> {
// 只根据 Shape 计算原生自然/盒策略尺寸,不修改 UiNode、出现身份或任何业务对象。
let size = match shape {
// 先测内部内容形状,再由完整盒策略按当前约束求边框盒尺寸。
Shape::Sized(shape, sizing, constraints) => {
sizing.resolve(measure_shape(shape)?, *constraints)?
}
// 内容自然尺寸外加 inset,尺寸结果仍会在函数尾做全局预算检查。
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 {
width: intrinsic.width.min(*width),
@@ -445,6 +514,7 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
padding.vertical()
}
+ gap * children.len().saturating_sub(1) as f32;
// 交叉轴取最大孩子外尺寸,再加父交叉轴 inset,不把横向孩子高相加。
let cross = children
.iter()
.map(|s| if *row { s.height } else { s.width })
@@ -454,6 +524,7 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
} else {
padding.horizontal()
};
// 将主/交叉轴长度还原为物理 width/height,方向不同不改变计算规则。
if *row {
Size {
width: along,
@@ -474,14 +545,18 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
/// 建立影响测量的形状键,将布局属性、子项测量结果和约束组合起来。
fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Shape> {
// 先应用当前盒策略得到内容可测量上限,文字宽度键必须反映该次出现的有效约束。
let available = node.sizing.available(constraints);
// 按 UiKind 提取只影响测量的数据,纯绘制颜色/材质不会制造测量缓存失效。
let shape = match &node.kind {
UiKind::Overlay { .. } => {
// Overlay 自然尺寸使用孩子含 margin 的最大外框,再加自身有效 inset。
let padding = insets(node);
// Overlay 取含各自 margin 的子盒最大宽高,不把重叠项逐个相加。
let size = children.into_iter().zip(node.children()).fold(
Size::default(),
|size, (child, node)| {
// 先把此孩子 margin 加到自身边框盒,避免只看内容尺寸漏算外占用。
let child = node.margin.outer(child);
Size {
width: size.width.max(child.width),
@@ -499,6 +574,7 @@ fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Sh
Shape::Rect(*width, *height)
}
UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } => {
// 世界内容外包范围从零开始,因此只有负坐标且右下端仍为负时不会扩张正向 extent。
let mut size = Size::default();
// 场景尺寸仅累计孩子右下端最大值;负方向内容不向左上平移整体,导航仍由相机负责。
for (position, child) in positions.iter().zip(children) {
@@ -508,6 +584,7 @@ fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Sh
Shape::Rect(size.width, size.height)
}
UiKind::Text { value, size, .. } => {
// 文字内容有效宽由本次 available 扣有效 inset,测量后 Padded 再加回。
let padding = insets(node);
Shape::Padded(
Box::new(Shape::Text(
@@ -522,7 +599,9 @@ fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Sh
UiKind::Spacer(size) => Shape::Rect(*size, *size),
UiKind::Scroll { width, height, .. } => Shape::Rect(*width, *height),
_ => {
// 剩余种类必须是已知线性容器;未知原生协议明确失败,不猜测为 Column。
let (row, padding, gap) = container(node).context("unknown native visual protocol")?;
// 缓存键保存含每个孩子 margin 的外尺寸,margin 改变即使孩子本身尺寸不变也需重测父。
let children = children
.into_iter()
.zip(node.children())
@@ -539,7 +618,7 @@ fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Sh
})
}
/// 执行原生节点测量并应用布局约束,作为源协议的原生实现。
/// 按形状键求原生自然/盒策略尺寸;完整输入 min/max 的最终夹取由原生 Measure 或整树调用方负责。
pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Size> {
measure_shape(&shape(node, children, constraints)?)
}
@@ -549,19 +628,26 @@ pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4])
#[derive(Debug, Default)]
/// 只读布局调用期间提供给 Mount 查询的派生上下文,不作为业务状态保留。
pub struct Context {
// 本轮当前出现的不可变根节点;准备未完成为 None,不可回退旧快照伪装。
pub node: Option<std::sync::Arc<UiNode>>,
// 孩子本轮受约束实测尺寸,供原生 measure/arrange 复用而不重复跑源子树。
pub children: Vec<Size>,
// 本轮首次测量的首选尺寸,不应被上轮 flex 实际分配覆盖,否则反复测量会改变伸缩基准。
pub preferred: Vec<Size>,
// 排列阶段可用的临时 [x,y,w,h];测量阶段为 None,禁止提前读几何。
pub bounds: Option<[f32; 4]>,
}
/// 为单个原生节点按已分配几何生成内容命令;容器背景/边框与整树遍历由外层组织。
pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderCommand> {
// 文字内容盒需要减 inset 并带 wrap_bounds;Rect/Button 背景则画完整 border-box。
// 保留分配 Size,防止 Text 模式中的字号字段 size 遮蔽后误用为像素盒宽高。
let size_for_text = size;
// 文字和三维视口绘制内容区,普通矩形/按钮仍使用完整边框盒。
let inset = insets(node);
// 每个节点至多生成一条内容/背景命令,边框和孩子由外层 paint_commands/遍历追加。
match &node.kind {
// 三维 View 占内容区,扣掉边框/内边距后负面积夹为零,世界矩阵保持不变。
UiKind::Viewport3D { view, .. } => Some(RenderCommand::Scene3D {
x: x + inset.left,
y: y + inset.top,
@@ -569,6 +655,7 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderC
height: (size.height - inset.vertical()).max(0.),
view: view.clone(),
}),
// 文字原点移到内容起点,wrap_bounds 与本出现实际内容宽高保持一致。
UiKind::Text { value, size, color } => Some(RenderCommand::Text {
x: x + inset.left,
y: y + inset.top,
@@ -580,6 +667,7 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderC
(size_for_text.height - inset.vertical()).max(0.0),
]),
}),
// 矩形采用已分配的完整边框盒,自然 width/height 不覆盖父布局结果。
UiKind::Rect { radius, color, .. } => Some(RenderCommand::RoundedRect {
x,
y,
@@ -588,6 +676,7 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderC
radius: *radius,
color: color.clone(),
}),
// 默认按钮背景覆盖边框盒,固定原生圆角/底色,子文字另外展开。
UiKind::Button { .. } => Some(RenderCommand::RoundedRect {
x,
y,
@@ -596,15 +685,18 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderC
radius: 7.0,
color: "#E3EAF7".into(),
}),
// 纯容器/场景根没有默认内容命令,不代表其孩子可以跳过绘制。
_ => None,
}
}
/// 从一个节点及其分配几何生成原生绘制命令,包括背景、边框与内容。
pub fn paint_commands(node: &UiNode, x: f32, y: f32, size: Size) -> Vec<RenderCommand> {
// 先收集可选内容命令,再顺序追加四边填充条,保证边框覆盖自身内容边缘。
let mut commands: Vec<_> = paint_native(node, x, y, size).into_iter().collect();
// 边框按四个零圆角条分别追加,宽厚各自钳到盒大小;不使用单一 stroke_width 混淆不等厚四边。
let border = node.border;
// 按上、下、左、右四条矩形逐一绘制边框,允许每边厚度不同。
for [x, y, width, height] in [
[x, y, size.width, border.top.min(size.height)],
[
@@ -621,6 +713,7 @@ pub fn paint_commands(node: &UiNode, x: f32, y: f32, size: Size) -> Vec<RenderCo
size.height,
],
] {
// 零厚边框无需命令,尺寸已夹到本盒边界,避免负厚度或空面积进入绘制列表。
if width > 0.0 && height > 0.0 {
commands.push(RenderCommand::RoundedRect {
x,
@@ -640,6 +733,7 @@ pub fn paint_commands(node: &UiNode, x: f32, y: f32, size: Size) -> Vec<RenderCo
/// remeasurement must not turn a prior allocation into a new intrinsic basis.
/// 判断子项主轴是否采用 fill,从而参与剩余空间分配。
fn main_fill(child: &UiNode, row: bool) -> bool {
// 只解释线性父主轴对应的孩子盒策略,交叉轴 fill 不增加主轴增长权重。
(if row {
child.sizing.width
} else {
@@ -649,7 +743,9 @@ fn main_fill(child: &UiNode, row: bool) -> bool {
/// 取出主轴 flex/尺寸策略共同约束下的最小和最大分配范围。
fn main_limits(child: &UiNode, row: bool) -> (f32, f32) {
// 盒模型提供物理轴的 min/max,下面与 flex_policy 范围求交。
let sizing = child.sizing;
// 根据父方向选择孩子的宽或高界限,不能将 flexMin 永远解释为宽度。
let (min, max) = if row {
(sizing.min_width, sizing.max_width)
} else {
@@ -663,19 +759,25 @@ fn main_limits(child: &UiNode, row: bool) -> (f32, f32) {
/// 按权重分配主轴剩余空间,尊重每项 min/max;达到上限的项退出后重新分配剩余量。
pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[Size]) -> Vec<Size> {
// native_visual::Arrange 与本模块 build 共用;所有输入数组须与 node.children 同序同长度,本函数不替调用方验证平行表。
// 返回分配尺寸不等于重新测量后的自然尺寸,文字换行等反馈由 source_layout 的后续收敛轮处理。
// Overlay 各孩子独立填内容盒,不共享主轴剩余量;普通线性容器才做下方加权增长/收缩。
if matches!(node.kind, UiKind::Overlay { .. }) {
// Overlay 各项独享同一有限内容区,不把空间按兄弟总权重分割。
let inset = insets(node);
return children
.iter()
.zip(node.children())
.map(|(size, child)| {
// 复制孩子实测值,只修改符合 fill/stretch 条件的轴,不改共享 UiNode。
let mut size = *size;
// Overlay 横轴显式 fill 才占扣 margin 后可用宽,再受盒 min/max 夹取。
if child.sizing.width == crate::box_model::Dimension::Fill {
size.width = (bounds.width - inset.horizontal() - child.margin.horizontal())
.max(0.0)
.clamp(child.sizing.min_width, child.sizing.max_width);
}
// Overlay 纵轴 fill 或 auto+stretch 可扩高;显式 fixed 高度不被父对齐覆盖。
if child.sizing.height == crate::box_model::Dimension::Fill
|| (child.sizing.height == crate::box_model::Dimension::Auto
&& child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch)
@@ -688,19 +790,27 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
})
.collect();
}
// 非 Overlay 的非线性节点没有主轴弹性分配,原样返回孩子尺寸。
let Some((row, padding, gap)) = container(node) else {
return children.to_vec();
};
// 按视觉树孩子顺序读取策略,和输入 children/preferred 尺寸保持同序。
let nodes = node.children();
// 保留原实测尺寸副本,交叉轴拉伸与主轴重分配依次写到该返回值。
let mut output = children.to_vec();
// 先处理交叉轴 Stretch,仅 Auto 维度可拉伸;明确 Fixed 不因父对齐策略被改写。
// 先按每个孩子 alignSelf 或父 crossAlign 判断交叉轴资格,不依赖主轴是否有增长权重。
for (child, size) in nodes.iter().zip(&mut output) {
// 只有有效对齐为 stretch 才进入尺寸改写,center/end 仅在排列时产生偏移。
if child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch {
// 取得孩子盒轴策略和 min/max,拉伸仍不能超过这些明确界限。
let sizing = child.sizing;
// Row 的交叉轴为高度,只拉伸 auto 高,扣父 inset 与孩子上下 margin。
if row && sizing.height == crate::box_model::Dimension::Auto {
size.height = (bounds.height - padding.vertical() - child.margin.vertical())
.max(0.0)
.clamp(sizing.min_height, sizing.max_height);
// 纵向父的交叉轴为宽度,同样只拉伸 auto 宽,不修改 fixed。
} else if !row && sizing.width == crate::box_model::Dimension::Auto {
size.width = (bounds.width - padding.horizontal() - child.margin.horizontal())
.max(0.0)
@@ -722,6 +832,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
- nodes
.iter()
.map(|child| {
// 扣除每个孩子在主轴两侧的 margin,不能把横向父的上下 margin 算入可分配宽。
if row {
child.margin.horizontal()
} else {
@@ -737,6 +848,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
.zip(preferred)
.map(|(child, size)| {
child.flex_policy.basis.unwrap_or_else(|| {
// 有增长或主轴 fill 且没有显式 basis 时从零参与分配,避免把原自然宽重复计入。
if child.flex > 0.0 || main_fill(child, row) {
0.0
} else if row {
@@ -747,23 +859,28 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
}) as f64
})
.collect();
// 每个原 basis 先夹主轴合法范围,再根据总剩余量决定增长或收缩。
let mut sizes: Vec<f64> = nodes
.iter()
.zip(&bases)
.map(|(child, basis)| {
// 按孩子当前主轴取 flex/box 交集,这份界限同时用于初始夹取与终点饱和。
let (min, max) = main_limits(child, row);
basis.clamp(min as f64, max as f64)
})
.collect();
// 初始 basis 先钳 min/max,剩余正数为增长,负数为收缩;非参与项仍占据其合法基准尺寸。
let free = space - sizes.iter().sum::<f64>();
// free>0 进入增长,否则是收缩;即使无可分配余量也不会重新发明自然基准。
let growing = free > 0.0;
// 增长按 flex,主轴 Fill 且 flex=0 用权重1;收缩按 shrink×原 basis,保持大内容承担较多缩减。
let weights: Vec<f64> = nodes
.iter()
.zip(&bases)
.map(|(child, basis)| {
// 增长读取 flex(fill 且 flex=0 时给单位权重),收缩读取 shrink×原始 basis。
if growing {
// 纯 fill 的默认增长权重为一;显式 flex 则使用其用户权重。
if main_fill(child, row) && child.flex == 0.0 {
1.0
} else {
@@ -774,10 +891,13 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
}
})
.collect();
// 增长端点是各自 max,收缩端点是各自 min,方便以剩余容量统一分配。
let limits: Vec<f64> = nodes
.iter()
.map(|child| {
// 本次取分配终点,策略选择取决于整体 free 的符号,不按单个孩子单独猜测。
let (min, max) = main_limits(child, row);
// 选择本轮饱和终点,之后用绝对容量统一处理增长与收缩。
if growing { max as f64 } else { min as f64 }
})
.collect();
@@ -792,12 +912,19 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
}
})
.collect();
// 剩余增长/收缩工作量取绝对值,方向只在更新 sizes 时施加。
let mut remaining = free.abs();
// 只对正权重未饱和项继续分配;饱和项退出后重算剩余权重,避免被夹掉的空间丢失。
while remaining > 0.0 && !active.is_empty() {
// 每轮以当前 active 权重总和为分母,不继续把已触顶/触底项计入比例。
let total = active.iter().map(|&i| weights[i]).sum::<f64>();
// 累计本轮真实分配量,受单项容量限制后可能小于 remaining。
let mut spent = 0.0;
// 记录是否有项达到限制;无饱和时按比例已分完,应停止而不追逐浮点残差。
let mut saturated = false;
// 全部项使用同一轮 remaining 比例,不能边更新 remaining 边偏向后项。
for &i in &active {
// 本项距离增长/收缩端点的绝对容量,本轮 amount 不得超过它。
let capacity = (limits[i] - sizes[i]).abs();
// 本轮按同一 remaining 比例计算,再限制到该项剩余容量;不能为单项超配后再粗暴钳制丢掉剩余量。
let amount = (remaining * weights[i] / total).min(capacity);
@@ -812,7 +939,9 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
if !saturated {
break;
}
// 饱和后筛出仍有容量的项,下一轮只在这些项中重新分配剩余量。
active.retain(|&i| {
// 增长保留 size<max,收缩保留 size>min,正权重条件在初始 active 已验证。
if growing {
sizes[i] < limits[i]
} else {
@@ -820,7 +949,9 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
}
});
}
// 只把最终主轴数值写回 output,交叉轴保留前面的 stretch 结果。
for (size, along) in output.iter_mut().zip(sizes) {
// 横向父写 width,纵向父写 height,不能用同一个 Size 字段表示所有主轴。
if row {
size.width = along as f32;
} else {
@@ -842,8 +973,11 @@ pub fn arrange_native(
if matches!(node.kind, UiKind::Viewport3D { .. }) {
return Ok(node.children().iter().map(|_| (0., 0.)).collect());
}
// Overlay 各孩子独立在同一内容盒内对齐,不能按线性游标累加位置。
if let UiKind::Overlay { .. } = node.kind {
// 取得含 border 的有效内容 inset,后面的对齐可用范围扣除它。
let padding = insets(node);
// Overlay 把 mainAlign 的 center/end 映射为横轴偏移;其他主轴模式在此按 start 处理。
let horizontal = match node.main_align {
MainAlign::Center => CrossAlign::Center,
MainAlign::End => CrossAlign::End,
@@ -853,7 +987,9 @@ pub fn arrange_native(
.iter()
.zip(node.children())
.map(|(size, child)| {
// 对齐比较占用含孩子 margin,最终原点再加左/上 margin。
let outer = child.margin.outer(*size);
// 每项独立计算绝对原点,兄弟的大小或数量不改变本项对齐空间。
let point = (
x + padding.left
+ child.margin.left
@@ -873,7 +1009,9 @@ pub fn arrange_native(
})
.collect();
}
// 二维视口里的 Scene2D 根先放在内容起点,世界相机投影由后续 viewport 处理。
if matches!(node.kind, UiKind::CanvasViewport { .. }) {
// 边框/内边距影响视口内容原点,但不重写场景内部业务 x/y。
let inset = insets(node);
return Ok(node
.children()
@@ -881,19 +1019,24 @@ pub fn arrange_native(
.map(|_| (x + inset.left, y + inset.top))
.collect());
}
// 世界布局直接用各孩子业务位置加父原点,不做线性 gap 或主轴对齐。
if let UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } = &node.kind {
return Ok(positions
.iter()
.map(|point| (x + point[0], y + point[1]))
.collect());
}
// 其余种类只有线性容器才安排孩子,叶节点返回空位置表。
let Some((row, padding, gap)) = container(node) else {
return Ok(vec![]);
};
// 按实际分配后的主轴外尺寸与固定 gap 求总占用,margin 不能漏算。
let occupied = children
.iter()
.zip(node.children())
.map(|(child, node)| {
// 根据父方向取宽+左右 margin 或高+上下 margin,交叉尺寸不进入主轴占用。
// 这里 child 是实测 Size,node 是对应孩子样式;父自身 inset 在后面的 spare 计算中另扣。
if row {
child.width + node.margin.horizontal()
} else {
@@ -902,7 +1045,9 @@ pub fn arrange_native(
})
.sum::<f32>()
+ gap * children.len().saturating_sub(1) as f32;
// 父实际分配的主轴边框盒长度,下一步扣父 inset 与孩子总占用。
let available = if row { bounds.width } else { bounds.height };
// 不足空间时剩余量夹为零,center/end 不产生负偏移让内容前缘不可达。
let spare = (available
- if row {
padding.horizontal()
@@ -911,6 +1056,7 @@ pub fn arrange_native(
}
- occupied)
.max(0.0);
// 主轴 center/end 只分配剩余空白,不再更改 flex 阶段已经求好的尺寸。
let offset = match node.main_align {
MainAlign::Center => spare / 2.0,
MainAlign::End => spare,
@@ -922,13 +1068,21 @@ pub fn arrange_native(
} else {
0.0
};
// 线性游标从父起侧 inset 加整体 offset 开始,后续逐项累积外尺寸和间距。
let mut cursor = if row { padding.left } else { padding.top } + offset;
// 输出只含孩子原点,顺序对应 children 和 node.children,不携带托管引用。
let mut points = Vec::with_capacity(children.len());
// 逐孩子按实际尺寸、各自 margin 与 alignSelf 计算,避免把共享业务对象的两处位置合并。
for (index, child) in children.iter().enumerate() {
// 用同一位置索引取得样式,不能按 block_id 查找唯一孩子而误处理中重复出现。
let child_node = &node.children()[index];
// 本项四边外占用,主轴推进和交叉对齐分别使用对应两边和。
let margin = child_node.margin;
// 显式 alignSelf 优先,None 才继承此父出现的 crossAlign。
let align = child_node.align_self.unwrap_or(node.cross_align);
// 在父交叉内容尺寸中按孩子外尺寸求非负偏移,不修改主轴游标。
let cross = align.offset(
// 孩子交叉外尺寸包含对应两边 margin,与父内容尺寸使用同一物理轴。
if row {
bounds.height - padding.vertical()
} else {
@@ -940,6 +1094,7 @@ pub fn arrange_native(
child.width + margin.horizontal()
},
);
// 把主轴 cursor 与交叉偏移按方向还原成绝对 x/y,并叠加父与孩子起侧边距。
let point = if row {
(
x + cursor + margin.left,
@@ -971,6 +1126,7 @@ pub fn arrange_native(
impl LayoutRuntime {
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
pub fn root_constraints(&self) -> [f32; 4] {
// 有旧页面模式时按其有限/延伸轴生成根约束,否则用普通视口最大约束。
self.page_mode.map_or_else(
|| root_constraints(self.viewport),
|mode| mode.constraints(self.viewport),
@@ -978,10 +1134,12 @@ impl LayoutRuntime {
}
/// 撤销指定业务组件的测量缓存,下一次遇到该节点重新求值。
pub fn invalidate(&mut self, block: &str) {
// 只移除指定业务身份最后一份测量键,其他节点缓存和稳定出现 ID 不受此入口影响。
self.sizes.remove(block);
}
/// 提高新 Mount ID 的分配下限,避免取消/重试后复用已经暴露过的出现身份。
pub fn reserve_identity_floor(&mut self, floor: u64) {
// 取消恢复后把临时分配过的 ID 高水位带回,不能重新分配可能已暴露的身份。
self.next_id = self.next_id.max(floor);
}
/// 返回出现 ID 的当前分配高水位,供桥接层在取消/重试后继续保持唯一性。
@@ -998,7 +1156,10 @@ impl LayoutRuntime {
pub fn frame(&mut self, root: &UiNode) -> Result<Frame> {
// Stage all cache/identity changes; a rejected frame leaves the last
// valid occurrence registry intact and never publishes partial geometry.
// 缓存、ID 分配和统计都先在副本上变更,build 失败不污染当前运行时。
let mut next = self.clone();
// 完整原生构建成功才提交副本;返回 Frame 仍由桥接添加投影/发布上下文。
// build 尚未建立命令 owner/clip/view 与命中索引;正常桥接随后经 scroll::project、viewport::project 重建这些表,不能直接视为可交互发布结果。
let frame = next.build(root, false)?;
*self = next;
Ok(frame)
@@ -1007,7 +1168,9 @@ impl LayoutRuntime {
/// Allocate stable occurrence identities without measuring or publishing geometry.
/// 先建立稳定的出现身份和父子关系,但不把此阶段当成最终测量结果。
pub fn structure(&mut self, root: &UiNode) -> Result<Frame> {
// 结构准备也使用事务式副本,失败不能留下部分新出现映射。
let mut next = self.clone();
// 只分配身份和链接,暂用零几何;后续源布局才提供有效测量与排列结果。
let frame = next.build(root, true)?;
*self = next;
Ok(frame)
@@ -1022,6 +1185,7 @@ impl LayoutRuntime {
) -> Result<Size> {
// 缓存只有完整 Shape 相等才复用,纯颜色变化不在测量 Shape 中,因此无需重做尺寸求解。
let shape = shape(node, children, constraints)?;
// 同业务 ID 还必须完整 Shape 相等才命中;不同约束共享对象可能轮流覆盖最后键。
if let Some((_, size)) = self
.sizes
.get(&node.block_id)
@@ -1030,6 +1194,7 @@ impl LayoutRuntime {
return Ok(*size);
}
self.measured += 1;
// 实际未命中才测量,成功后更新 sizes;失败不安装不可用的测量条目。
let size = measure_shape(&shape)?;
self.sizes.insert(node.block_id.clone(), (shape, size));
Ok(size)
@@ -1037,15 +1202,20 @@ impl LayoutRuntime {
/// 迭代展开出现树、计算约束和几何、产生绘制命令;结构准备与完整布局共用此驱动。
fn build(&mut self, root: &UiNode, structural: bool) -> Result<Frame> {
// 按本次构建重置计数;structural 只准备身份,不把零几何当一次已完成测量/排列。
self.measured = 0;
self.arranged = 0;
self.settle_passes = usize::from(!structural);
self.slices = usize::from(!structural);
// 将树展开为父先子后出现数组,每个位置保存自己的约束与身份。
let mut items = Vec::<Item<'_>>::new();
// 用显式栈构建父先子后 items,根采用 slot0/无父;孩子逆序入栈保持原始布局顺序。
let mut work = vec![(root, None::<usize>, 0, self.root_constraints())];
// 本轮结构键集合用于检测重复 slot,并在成功后移除过时 ID 映射。
let mut used = HashSet::new();
// 显式 DFS 遍历原生视觉树,共享 Arc 在不同 slot/父下仍会形成独立出现。
while let Some((node, parent, slot, constraints)) = work.pop() {
// 按本次展开条目数检查一万上限,超限拒绝整帧而不是截断余下孩子。
if items.len() >= MAX_LAYOUT_VISITS {
bail!("expanded UI layout exceeds 10000 nodes");
}
@@ -1053,6 +1223,7 @@ impl LayoutRuntime {
if node.slots.len() != node.children().len() {
bail!("invalid child slot table");
}
// 稳定身份包含父 Mount ID、组成 slot 和业务 ID;不是仅凭对象身份分配一个 ID。
let key = Key {
parent: parent.map(|i| items[i].id),
slot,
@@ -1073,7 +1244,9 @@ impl LayoutRuntime {
if !used.insert(key) {
bail!("duplicate child slot in one presentation");
}
// 保存本出现的数组索引,后面作为孩子 parent 与尺寸/位置数组下标。
let index = items.len();
// 将当前索引追加到父孩子列表,保持实际遍历次序供后序测量与兄弟链接。
if let Some(parent) = parent {
items[parent].children.push(index);
}
@@ -1084,6 +1257,7 @@ impl LayoutRuntime {
id,
constraints,
});
// 孩子逆序压栈以按原组成顺序出栈,初始约束同时按该出现的父 offer 细化。
for (child_index, (child, slot)) in
node.children().iter().zip(&node.slots).enumerate().rev()
{
@@ -1095,7 +1269,9 @@ impl LayoutRuntime {
));
}
}
// 每个出现一个尺寸槽,不按共享业务 ID 合并几何;结构准备保留这些零值占位。
let mut sizes = vec![Size::default(); items.len()];
// 完整路径才反向测量,使每个父求自然尺寸时孩子尺寸已经可用。
if !structural {
// 反向 items 自底向上测量,孩子尺寸已经求出;structural=true 完全跳过此步骤,零几何仅用于挂载准备。
for (index, item) in items.iter().enumerate().rev() {
@@ -1109,19 +1285,28 @@ impl LayoutRuntime {
);
}
}
// 原点按出现数组独立保存,根为零,父排列后写入各子槽。
let mut points = vec![(0.0_f32, 0.0_f32); items.len()];
// 最终发布几何数组容量按出现数预留,但不会在构建失败时替换旧运行状态。
let mut mounts = Vec::with_capacity(items.len());
// 先序展开时按节点内容/边框顺序追加业务命令,后续投影阶段再处理视口和滚动。
let mut commands = Vec::new();
// 默认每个出现无后继,下面只对同一父的相邻孩子建立 next_sibling。
let mut siblings = vec![None; items.len()];
// 先按每个父的孩子顺序建立 next_sibling,最后一个默认 None;这与业务孩子列表身份不同。
// 父先序数组不保证相邻元素就是同级,须显式按每个父的 children 建链接。
for item in &items {
// 只把相邻孩子前项连向后项,最后一个保留 None,不串到下个父的子树。
for pair in item.children.windows(2) {
siblings[pair[0]] = Some(items[pair[1]].id);
}
}
// 按先序安排,父已先给孩子写入尺寸/原点,当前项可生成对应 Mount 与自身绘制。
for (index, item) in items.iter().enumerate() {
self.arranged += usize::from(!structural);
// 读取此出现原点,接着检查原点和加宽高后的两个远端都在有符号坐标预算中。
let (x, y) = points[index];
// 取本出现实际尺寸,可能已被父 flex 分配覆盖,不能重新读取业务自然尺寸。
let size = sizes[index];
coordinate(x)?;
coordinate(y)?;
@@ -1151,19 +1336,25 @@ impl LayoutRuntime {
interactive: item.node.interactive,
pointer_events: item.node.pointer_events,
});
// 只有完整路径生成内容/边框命令,结构准备不得把零几何发布为实际画面。
if !structural {
commands.extend(paint_commands(item.node, x, y, size));
}
// 结构准备只需要身份/链接,直接跳过孩子尺寸分配和位置安排。
if structural {
continue;
}
// 收集此父孩子当前尺寸作为单次纯原生分配的输入,源协议多轮 remeasure 在外层完成。
// 本路径不会在 flex 改宽后回头重测子树;source_layout 已把需要弹性/拉伸反馈的树排除在小原生直接路径之外。
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);
// 按出现索引写回 flex/拉伸实际分配,不改变共享 UiNode 的自然属性。
for (child, allocated) in item.children.iter().zip(&child_sizes) {
sizes[*child] = *allocated;
}
// 把 arrange_native 的同序位置表写入每个子出现槽,之后先序遍历即可消费。
for (child, point) in
item.children
.iter()
@@ -1174,6 +1365,7 @@ impl LayoutRuntime {
}
// 成功求解后丢弃已消失出现和不在本树的测量缓存,避免历史页面不断积累派生条目。
self.ids.retain(|key, _| used.contains(key));
// 成功后只保留本树出现过的业务测量键,避免切页后旧派生尺寸持续积累。
let blocks: HashSet<_> = items
.iter()
.map(|item| item.node.block_id.as_str())
@@ -1196,11 +1388,13 @@ impl LayoutRuntime {
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
pub fn frame(root: &UiNode) -> Result<Frame> {
// 一次性调用没有跨帧缓存;需要稳定出现身份的窗口应保留自己的 LayoutRuntime。
LayoutRuntime::default().frame(root)
}
/// 为指定逻辑视口建立临时布局运行时并求完整帧。
pub fn frame_with_viewport(root: &UiNode, viewport: Size) -> Result<Frame> {
// 为独立原生测量/测试建立有限视口运行时,不创建 VM 或 OS 窗口资源。
LayoutRuntime {
viewport: Some(viewport),
..Default::default()
+5
View File
@@ -1,4 +1,7 @@
//! 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。
//! 调用链:model/snapshot 提供不可变 UiNode,layout/source_layout 求几何,source_paint 产出发布命令。
//! document 转成 display_list 后由 software_renderer 光栅化;scene 组合窗口壳层,viewport/scroll 与输入复用坐标规则。
//! main.rs 的 crate 根重导出保留现有内部路径;image/text_flow 另以 render 子模块路径供桥接调用。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 计算边框、内外边距和内容区域,并应用容器主轴/交叉轴分配规则。
@@ -32,5 +35,7 @@ pub(crate) mod text;
/// 处理二维场景相机、局部视口投影和空间查询,供绘制与命中使用相同变换。
pub(crate) mod viewport;
/// 解码与缓存不可变 RGBA 资源、预渲染文字,并提供带 alpha 的纹理采样与轮廓。
pub mod image;
/// 以预备字词图片和避让轮廓安排自由文字排版,几何与输出绘制资源分开保留。
pub mod text_flow;
+88
View File
@@ -1,6 +1,10 @@
//! 让原生组件也通过 Endor 的 measure/arrange/paint 协议被调用。
//! 将 Constraints、Size、Bounds 与 PaintCommand 在托管值和 Rust 值之间转换;原生方法与源覆盖方法共用调用契约。
//!
//! 调用路径:bootstrap 调用 install 安装 Block 默认协议与 Point 辅助方法。
//! 源子类可显式调用基类方法;source_layout 在 staged Context 中复用已测孩子,独立调用则运行完整原生布局。
//! is_intrinsic 供协议驱动判断能否跳过 VM 调用;仅认可本实现及同一名义类型注册表,不能凭函数名判定。
//!
//! Native visual protocol entry points. Values are ordinary managed Endor
//! objects; geometry is scoped to a live occurrence, never stored on a Block.
use crate::{
@@ -21,6 +25,7 @@ fn invalid(message: &'static str) -> VmError {
}
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
fn object(value: &Value) -> Result<ObjectRef, VmError> {
// 协议 receiver/输入必须为真实托管对象,不能接受仅可字符串化的标量。
if let Value::Object(object) = value {
Ok(*object)
} else {
@@ -34,9 +39,11 @@ pub(crate) fn number(
name: &str,
signed: bool,
) -> Result<f32, VmError> {
// 所有几何字段从当前属性存储读取,类型必须为 Number,不在这里尝试源函数或转换运算。
let Value::Number(value) = cx.store().get_attr(object, name)? else {
return Err(invalid("visual field must be a Number"));
};
// 转到布局实际使用的 f32 后再检查有限/范围,防止大数溢出为无穷。
let value = value
.to_string()
.parse::<f32>()
@@ -53,6 +60,7 @@ pub(crate) fn value(value: f32) -> Value {
Value::Number((value as f64).into())
}
/// 新建指定名义类型的托管记录,并逐项设置字段,作为视觉协议的结果返回。
/// 默认协议、graphics 和 Window 查询等原生入口共用;这里只附类型/写字段,结果几何与列表成员的语义仍由消费者解码验证。
pub(crate) fn record(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
@@ -62,6 +70,7 @@ pub(crate) fn record(
// 先赋精确名义类型,再初始化字段;只读协议可创建结果对象,但不能借机写入旧业务图。
let object = cx.new_object()?;
attach_desktop_type(cx, object, kind, desktop)?;
// 新结果记录在本次调用内逐字段初始化,VM 可区分派生对象写入与旧业务图修改。
for (key, value) in fields {
cx.set_attr(object, key, value)?;
}
@@ -78,6 +87,7 @@ fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size,
},
)
.map_err(|_| invalid("native measurement exceeds layout limits"))?;
// 只取已求出的根出现尺寸,空布局不是合法测量返回,明确报错。
let root = frame
.mounts
.first()
@@ -90,16 +100,23 @@ fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size,
#[derive(Debug, Clone, Copy)]
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
enum Query {
// 按 Constraints 返回本出现的自然/受限 MeasureResult。
Measure,
// 按 Bounds 和孩子测量返回带 slot 的 Arrangement。
Arrange,
// 把默认原生绘制编码为源可组合的 DisplayList。
Paint,
// 按本出现已提交的宽高检查局部 Point,返回 Bool。
HitTest,
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
// 操作分类决定输入/返回契约与分派实现。
query: Query,
// 名义类型注册环境,native captures 保活它;同 VM 窗口可共享 types。
desktop: ObjectRef,
// 含 Block receiver、Mount 和协议输入的三参只读契约。
signature: CallSignature,
}
/// Only this actual implementation can use the matching intrinsic fast path.
@@ -119,6 +136,7 @@ pub(crate) fn is_intrinsic(
else {
return Ok(false);
};
// 即使是 ContextNativeFunction,也必须确为本 Method 实现,其他 native payload 不享受该快速路径。
let Some(method) = function.implementation::<Method>() else {
return Ok(false);
};
@@ -155,7 +173,9 @@ impl NativeCallable for Method {
let [owner, context, input] = args else {
return Err(invalid("visual method expects a Mount and an input value"));
};
// receiver 必须为对象,后面的 Mount.block 归属检查使用同一原始业务身份。
let owner = object(owner)?;
// 从显式上下文对象取得真实 Mount,不凭 Bounds 形状推断出现关系。
let mount = cx.store().payload::<Mount>(object(context)?, "Mount")?;
// 默认协议也校验上下文归属,防止调用者把另一个出现的几何套到当前组件。
if !mount.mounted || mount.block != owner {
@@ -163,12 +183,15 @@ impl NativeCallable for Method {
"visual context is detached or belongs to another Block",
));
}
// 复制最后提交几何,只有非 staged 的命中/独立调用使用这份出现值。
let geometry = mount.geometry.clone();
// 布局阶段从临时 Context 取当前节点、孩子实测尺寸与首选尺寸;克隆派生数据后释放锁再进行计算。
// 源覆盖显式调用 Block.measure/arrange 时也会进入这里,必须沿用本轮子测量,不能重新用默认算法覆盖源孩子的返回值。
let staged = mount
.layout
.as_ref()
.map(|layout| {
// 锁内只克隆当前节点和孩子尺寸,源/原生计算期间不持临时布局 Context 锁。
let layout = layout
.lock()
.map_err(|_| invalid("layout context poisoned"))?;
@@ -179,13 +202,18 @@ impl NativeCallable for Method {
))
})
.transpose()?;
// 第三参数须为对象,具体名义输入由安装的签名约束,字段数值在各分支继续检查。
let input = object(input)?;
// 默认 hitTest 检查局部点在已提交盒内,采用左上包含、右下排除;布局阶段几何不稳定,禁止调用。
// 先处理只依赖已提交局部盒的默认命中,成功后无需构建视觉树或结果记录。
if matches!(self.query, Query::HitTest) {
// 布局期间可能有临时尺寸,默认 hitTest 不能把它当作最终可交互几何。
if staged.is_some() {
return Err(invalid("hitTest is unavailable during layout"));
}
// 默认 hitTest 的 Point 为本出现局部坐标,允许带符号以自然排除盒外点。
let x = number(cx, input, "x", true)?;
// 纵坐标独立解析,与 x 一起采用右/下排除的半开盒范围。
let y = number(cx, input, "y", true)?;
return Ok(NativeResult::Return(Value::Bool(
x >= 0.0 && y >= 0.0 && x < geometry.width && y < geometry.height,
@@ -203,11 +231,17 @@ impl NativeCallable for Method {
} else {
block::snapshot(cx.store(), owner)?
};
// measure/arrange/paint 产生本域名义结果记录,几何从不写回共享 Block。
let result = match self.query {
// 读取并验证四项约束,staged 复用孩子测量,非 staged 走完整原生测量。
Query::Measure => {
// 横轴最小约束非负有限,与 maxWidth 配对检查顺序。
let min_w = number(cx, input, "minWidth", false)?;
// 纵轴最小约束非负有限,不能从横向约束推断。
let min_h = number(cx, input, "minHeight", false)?;
// 横轴最大可用宽影响文字换行与子项分配。
let max_w = number(cx, input, "maxWidth", false)?;
// 纵轴最大可用高与 minHeight 组合,非法区间拒绝而非交换两端。
let max_h = number(cx, input, "maxHeight", false)?;
// 最小值不可超过最大值,否则后面的 clamp 会无意义甚至失败;各数值已通过非负有限检查。
if min_w > max_w || min_h > max_h {
@@ -231,11 +265,17 @@ impl NativeCallable for Method {
],
)?)
}
// 排列输入 Bounds 使用绝对布局位置和实际分配大小,结果按稳定 slot 标识孩子。
Query::Arrange => {
// 父布局框横原点可负,需受有限世界范围约束。
let x = number(cx, input, "x", true)?;
// 父布局框纵原点可负,和高度共同验证下端点。
let y = number(cx, input, "y", true)?;
// 分配宽必须非负,不能把负宽当作镜像变换。
let width = number(cx, input, "width", false)?;
// 分配高必须非负,两轴不依赖旧 Mount 自然尺寸。
let height = number(cx, input, "height", false)?;
// 各分量合法后仍检查正向远端是否越出视觉预算;这里保持原协议的端点规则。
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
return Err(invalid("visual bounds exceed limits"));
}
@@ -265,6 +305,7 @@ impl NativeCallable for Method {
.collect::<Result<Vec<_>, _>>()?
};
// preferred 保留弹性分配所需首选大小,与已受约束的实测 children 区分;无 staged 时两者相同。
// 收敛循环可能反复改变 children 的分配,preferred 不随每轮分配累计变化,避免 flex 把上轮结果当新自然基准。
let preferred = staged
.as_ref()
.map_or(children.as_slice(), |(_, _, preferred)| {
@@ -273,11 +314,14 @@ impl NativeCallable for Method {
// 先依据父盒分配 flex 尺寸,再由 arrange_native 求位置,避免先定位再改变孩子大小导致重叠。
let children =
layout::flex_sizes(&node, Size { width, height }, &children, preferred);
// 尺寸求解完成后再排列位置,返回顺序对应 children/slots 三者的同一索引。
let points = layout::arrange_native(&node, x, y, Size { width, height }, &children)
.map_err(|_| invalid("native arrangement exceeds limits"))?;
// 收集每个孩子的新 Bounds 记录,最后包装为受控 ObjectList。
let mut result = Vec::new();
// 每个返回 Bounds 附稳定 slot,与 children 当前顺序分开;调用方可按 slot 校验组成身份。
for ((slot, size), (x, y)) in node.slots.iter().zip(children).zip(points) {
// 每条返回记录只描述当前孩子的分配,另附 slot 供源布局驱动验证归属。
let bounds = record(
cx,
self.desktop,
@@ -301,17 +345,26 @@ impl NativeCallable for Method {
vec![("children", Value::Object(result))],
)?)
}
// 把默认绘制原生命令编码回源 PaintCommand,供源覆盖调用基类后追加或改新结果。
Query::Paint => {
// 本次 paint 使用给定布局 x,不把共享业务 x 当成最终出现位置。
let x = number(cx, input, "x", true)?;
// 本次 paint 使用给定布局 y,投影到面板/设备空间在后续显示阶段处理。
let y = number(cx, input, "y", true)?;
// 绘制宽是当前出现分配的非负宽度,与其他共享出现可以不同。
let width = number(cx, input, "width", false)?;
// 绘制高独立解析,结合 y 检查远端上界。
let height = number(cx, input, "height", false)?;
// 绘制输入也执行与 arrange 相同的正向端点预算检查。
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
return Err(invalid("visual bounds exceed limits"));
}
// 本次 paint 的派生结果集合,不能包含运行时可变 VM 场景对象。
// 普通原生 paint 可由 source_paint 快速路径直接保留默认命令;源覆盖调用基类 paint 时才需要这里反向编码为托管结果。
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 {
x,
@@ -321,6 +374,7 @@ impl NativeCallable for Method {
view,
} => {
// 3D 通过不可变 PaintView payload 暴露场景快照,不把运行中 VM 的场景对象交给后续绘制线程。
// PaintView payload 包装已拥有的原生 View,后续源解码无需从渲染线程读托管场景。
let snapshot = cx.allocate(crate::scene3d::PaintView(view))?;
vec![
("kind", Value::Text("scene3d".into())),
@@ -339,6 +393,7 @@ impl NativeCallable for Method {
color,
wrap_bounds,
} => {
// 先写文字必需字段,只有存在 wrap_bounds 才成对添加 wrapWidth/wrapHeight。
let mut fields = vec![
("kind", Value::Text("text".into())),
("x", value(x)),
@@ -373,9 +428,11 @@ impl NativeCallable for Method {
// 当前反向编码支持明确列出的命令;新增 RenderCommand 变体时须同步此转换,不能静默丢弃。
_ => return Err(invalid("unsupported native paint command")),
};
// 给每条命令附本域 PaintCommand 类型,字段数据仍只属于本次结果。
let command = record(cx, self.desktop, "PaintCommand", fields)?;
commands.push(command);
}
// 先生成命令对象列表,再放进名义 DisplayList 记录的 commands 字段。
let commands = crate::object_list::collection(cx, commands, self.desktop)?;
Value::Object(record(
cx,
@@ -393,8 +450,11 @@ impl NativeCallable for Method {
#[derive(Debug)]
/// Point 的矩形/圆形命中辅助方法,保存结果类型和具体几何判定种类。
struct PointMethod {
// Point 的当前桌面名义类型,用于检查 receiver,而非仅比较 x/y 字段。
expected: ObjectRef,
// true 选择圆内距离,否则选择 Bounds 半开矩形包含判断。
circle: bool,
// 两个辅助方法均为只读 Bool 返回契约,参数形状由 circle 决定。
signature: CallSignature,
}
impl NativeCallable for PointMethod {
@@ -413,6 +473,7 @@ impl NativeCallable for PointMethod {
/// 先验证 Point 名义类型与有限坐标,再分别计算圆内距离或 Bounds 包含关系,返回 Bool。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 取隐式 Point receiver,错误实参形状在字段读取之前拒绝。
let Some(Value::Object(point)) = args.first() else {
return Err(invalid("Point method requires a Point"));
};
@@ -420,9 +481,13 @@ impl NativeCallable for PointMethod {
if !cx.store().is_instance(*point, self.expected)? {
return Err(invalid("Point method requires a Point"));
}
// 先读并验证 receiver 的有限 x,绝对值不超过视觉世界范围。
let x = number(cx, *point, "x", true)?;
// y 使用同样规则,后续圆距离再提升到 f64 运算。
let y = number(cx, *point, "y", true)?;
// 按适配器分类检查圆心/半径或 Bounds,最终统一返回 Bool。
let inside = if self.circle {
// 圆分支要求四项实际参数,receiver 已在前面检查,另外三项必须为 Number。
let [
_,
Value::Number(cx_value),
@@ -434,17 +499,22 @@ impl NativeCallable for PointMethod {
};
// 圆心和半径按 f64 解析及求平方距离,在有界数值内避免不必要的 f32 精度损失。
let parse = |value: &endor_core::EndorNumber| -> Result<f64, VmError> {
// 圆心/半径转 f64,避免距离平方过早损失到 f32 精度。
let value = value
.to_string()
.parse::<f64>()
.map_err(|_| invalid("invalid circle number"))?;
// 圆字段也须有限且不超过同一世界范围,半径非负随后另查。
if !value.is_finite() || value.abs() > MAX_DIMENSION {
return Err(invalid("circle number exceeds bounds"));
}
Ok(value)
};
// Point.x 到圆心横差,以 f64 求平方距离。
let dx = x as f64 - parse(cx_value)?;
// Point.y 到圆心纵差,不能在圆形测试中误用矩形裁剪规则。
let dy = y as f64 - parse(cy_value)?;
// 半径先按通用圆数值规则解析,再排除负数;零只包含圆心。
let radius = parse(radius)?;
// 半径不能负;零半径只包含圆心,边界使用 <= 因此圆周也命中。
if radius < 0.0 {
@@ -452,12 +522,17 @@ impl NativeCallable for PointMethod {
}
dx * dx + dy * dy <= radius * radius
} else {
// 矩形分支必须正好两参,Bounds 名义类型由安装签名检查,字段数值仍逐项验证。
let [_, Value::Object(bounds)] = args else {
return Err(invalid("inside requires Bounds"));
};
// 边框左原点可负,Point 坐标必须与 Bounds 在同一空间。
let left = number(cx, *bounds, "x", true)?;
// 边框顶原点独立读取,右/下边界由宽高相加决定。
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
@@ -474,16 +549,24 @@ pub fn install(
block_type: ObjectRef,
function_type: ObjectRef,
) -> Result<(), VmError> {
// bootstrap 已创建视觉值类型,以下只取得当前桌面的名义身份来构造契约。
let nominal = |name| {
crate::ui_bridge::desktop_type(vm.object_store(), name, desktop)
.map(AnnotationType::Nominal)
};
// 第二参数固定为 Mount,协议不能只拿 Block 或任意 Bounds 冒充上下文。
let mount = nominal("Mount")?;
// measure 输入约束记录的名义类型。
let constraints = nominal("Constraints")?;
// arrange/paint 输入边框盒的名义类型,Point.inside 也使用它。
let bounds = nominal("Bounds")?;
// hitTest 输入局部 Point,后续 Point 辅助方法 receiver 也用该注解。
let point = nominal("Point")?;
// measure 返回 MeasureResult,源覆写可返回其更具体子类。
let measure = nominal("MeasureResult")?;
// arrange 返回 Arrangement,孩子 Bounds 经受控列表持有。
let arrangement = nominal("Arrangement")?;
// paint 返回源 DisplayList,区别于窗口线程消费的原生显示列表容器。
let display = nominal("DisplayList")?;
// 把四个默认方法安装到 Block,源子类可覆盖;所有视觉协议声明 ReadOnly,允许派生结果分配而不修改模型。
for (name, query, input, returns) in [
@@ -492,6 +575,7 @@ pub fn install(
("paint", Query::Paint, bounds, display),
("hitTest", Query::HitTest, point, AnnotationType::Bool),
] {
// 每个默认协议函数 captures 保活桌面环境,方法表再持有该函数。
let function = vm.allocate(ContextNativeFunction::new(
name,
Method {
@@ -509,19 +593,23 @@ pub fn install(
vm.attach_type(function, function_type)?;
vm.define_method(block_type, name, function)?;
}
// Point 辅助方法安装在同一桌面的真实类型注册表上。
let registry = object(&vm.get_attr(desktop, "types")?)?;
// 取得具体 Point Type 对象,用作 expected receiver 及 define_method 目标。
let point_type = object(
&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)] {
// 分别建立 inside/inCircle 的参数契约与适配器,captures 保活 Point 类型。
let function = vm.allocate(ContextNativeFunction::new(
name,
PointMethod {
expected: point_type,
circle,
signature: CallSignature::exact(
// 圆测试为 receiver+三数,矩形测试为 receiver+Bounds,参数元数据与 PointMethod.arity 一致。
if circle {
vec![
point,
+23
View File
@@ -1,6 +1,10 @@
//! 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。
//! 新组合应优先使用普通布局、ScrollView 和独立场景视口;保留这里是为了兼容已有 window/vertical/horizontal/canvas 页面。
//!
//! 调用路径:Block.mode 属性解码用 Mode.parse;根布局按 Mode.constraints 选择有限/可延伸轴。
//! scene/输入导航使用 Surface 的正逆变换和可见性判断,内容变化后用 clamp 收敛兼容页面偏移。
//! 本模块不创建窗口、不修改 VM;Page 表面导航与独立 CanvasViewport/ScrollBlock 是不同状态。
//!
//! Page space is independent of the native window. The camera is applied once
//! for painting and inverted once for input; layout and Mounts stay in world units.
use crate::{geometry::Point, layout::Size};
@@ -25,6 +29,7 @@ pub enum Mode {
impl Mode {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
// 源字段编码与序列化采用同一组小写策略名,布局方向不由显示标题推断。
match self {
Self::Window => "window",
Self::Vertical => "vertical",
@@ -35,6 +40,7 @@ impl Mode {
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(name: &str) -> Result<Self, VmError> {
// 仅接受四个兼容模式,未知值明确拒绝,不能默认为 window。
match name {
"window" => Ok(Self::Window),
"vertical" => Ok(Self::Vertical),
@@ -48,10 +54,12 @@ impl Mode {
/// 为旧页面模式生成有限轴和可延伸轴的根约束。
pub fn constraints(self, viewport: Option<Size>) -> [f32; 4] {
// LayoutRuntime::root_constraints 用于页面根测量;这里的可延伸轴仍有数值上限,不会向布局传 f32::INFINITY。
// 未收到原生视口尺寸时采用布局默认根约束;不能凭空建立固定宽高。
let Some(size) = viewport else {
return crate::layout::root_constraints(None);
};
// MAX_DIMENSION 在根约束中充当可延伸轴上限,不把它当作真实窗口尺寸。
let limit = crate::ui_bridge::MAX_DIMENSION as f32;
// 数组采用 [最小宽, 最小高, 最大宽, 最大高];竖/横页面仍至少占满当前视口。
match self {
@@ -66,12 +74,14 @@ impl Mode {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
/// 页面级兼容相机状态和有限内容范围,支持绘制与命中正逆变换。
pub struct Surface {
// 兼容 Page 空间策略,决定根约束及偏移夹取规则。
pub mode: Mode,
// 发布给原生侧的页面身份文本,不是托管强引用。
pub page: String,
// 视图左上角对应的世界坐标;绘制时先减 pan 再乘 zoom。
pub pan: Point,
#[serde(default = "unit")]
// 世界到视图的均匀比例,反变换要求非零有限;模型/文档入口负责合法范围。
pub zoom: f32,
// 布局产出的内容宽高,用于有限模式钳制;不决定 Canvas 的导航边界。
pub extent: Size,
@@ -85,6 +95,7 @@ fn unit() -> f32 {
impl Default for Surface {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
// 初始 Window 表面无页面身份、零偏移且单位缩放,可作为合法空发布值。
Self {
mode: Mode::Window,
page: String::new(),
@@ -108,6 +119,7 @@ impl Surface {
/// 把页面视图坐标反算为旧 Page 相机下的世界坐标。
// 输入在视图空间,先除缩放再加世界偏移;调用方需保证 zoom 有限且大于零。
pub fn to_world(&self, point: Point) -> Point {
// shell 先去除侧栏/设备比例等宿主偏移,再调用本逆变换;不在这里处理 Mount.view 或三维面板的第二层映射。
Point::new(
point.x / self.zoom + self.pan.x,
point.y / self.zoom + self.pan.y,
@@ -125,8 +137,10 @@ impl Surface {
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
pub fn contains(&self, point: [f32; 2], viewport: Option<Size>) -> bool {
// UiBridge 在点击/滚动前用它排除页面外输入;point 必须已经是页面世界点,不能把原始设备像素传进来。
// 传入的数组是世界坐标。转换后使用左/上包含、右/下排除的视口边界;
// 未知视口时仍排除负坐标,但不限制右/下界。
// 传入世界坐标先投影到视图空间,再按视口半开边界检查,不能与 Rect.contains 的闭边界混淆。
let point = self.to_view(Point::new(point[0], point[1]));
point.x >= 0.0
&& point.y >= 0.0
@@ -135,13 +149,16 @@ impl Surface {
/// 只对有限页面按实际内容范围钳制偏移;自由画布不按内容边缘停止导航。
pub fn clamp(&mut self, viewport: Size) {
// UiBridge 布局完成后和 page_input 导航后调用;只修改这个 Surface 数值副本,写回 Page 属性或发布 Frame 由调用方完成。
// Window 重置所有导航;单轴模式清除另一轴并恢复单位缩放。
// 内容小于视口时 max(0) 令可滚动范围归零,避免 clamp 出现反向上下限。
match self.mode {
// 普通窗口页面关闭导航能力,每次夹取恢复零 pan 与 zoom=1。
Mode::Window => {
self.pan = Point::default();
self.zoom = 1.0;
}
// 纵向模式仅允许 y 在内容高−视口高内移动,清横向偏移并禁用缩放。
Mode::Vertical => {
self.zoom = 1.0;
self.pan.x = 0.0;
@@ -150,6 +167,7 @@ impl Surface {
.y
.clamp(0.0, (self.extent.height - viewport.height).max(0.0));
}
// 横向模式同理仅允许 x 移动,内容缩短后最大偏移随之减少。
Mode::Horizontal => {
self.zoom = 1.0;
self.pan.y = 0.0;
@@ -158,6 +176,7 @@ impl Surface {
.x
.clamp(0.0, (self.extent.width - viewport.width).max(0.0));
}
// 自由画布不按内容边缘夹取,保留可查看负世界坐标与空白区域的导航。
Mode::Canvas => {}
}
}
@@ -174,9 +193,12 @@ mod tests {
let mut view = Surface::new(Mode::Canvas);
view.pan = Point::new(-240.0, 120.0);
view.zoom = 0.75;
// 固定视图指针位置作为缩放锚点,计算缩放前它对应的世界点。
let pointer = Point::new(300.0, 180.0);
// 保存锚点世界位置,改变 zoom 后反求 pan,保证 to_view(world) 仍等于 pointer。
let world = view.to_world(pointer);
assert_eq!(view.to_view(world), pointer);
// 模拟指针锚定缩放而不是绕世界原点缩放,随后检查可见与不可见点。
view.zoom = 2.0;
view.pan = Point::new(
world.x - pointer.x / view.zoom,
@@ -214,6 +236,7 @@ mod tests {
height: 600.0,
});
assert_eq!(view.pan, Point::new(0.0, 200.0));
// 内容缩到视口之内后重新夹取,旧 y=200 必须回零,不能继续显示内容外空白。
view.extent.height = 100.0;
view.clamp(Size {
width: 400.0,
+15
View File
@@ -1,5 +1,6 @@
//! 合成整个原生窗口的显示列表:页面画面、宿主侧栏和开发工具绘制。
//! 窗口逻辑尺寸与设备像素比例在这里衔接。页面内容先应用其视角并裁剪,宿主控件的区域和命中布局另外返回。
//! shell/app 为每个 OS 窗口用各自已发布文档调用 build_scene;本模块只组装设备命令,不进入 VM 或刷新源协议。
//!
use crate::{
activity::Activity,
@@ -12,10 +13,13 @@ use crate::{
#[derive(Clone, Debug, Default)]
/// 宿主管理区域的几何布局,供窗口事件分辨页面与工具命中。
pub struct SceneLayout {
// 开发工具的逻辑坐标布局;页面业务对象的命中几何仍由 RenderDocument 管理。
pub developer_tools: DeveloperToolsLayout,
}
/// 组合页面、开发工具和侧栏的设备像素显示列表,同时返回供命中使用的宿主布局。
/// shell/app 的窗口绘制路径调用此入口;width/height 是整窗设备像素,scale 将逻辑坐标换成设备坐标。
/// 返回的显示列表交给窗口渲染,layout 则保留同帧开发工具命中区域,避免输入使用另一帧的几何。
pub fn build_scene(
width: u32,
height: u32,
@@ -32,8 +36,10 @@ pub fn build_scene(
.iter()
.find(|tab| tab.active)
.and_then(|tab| tab.developer_tool.as_deref());
// 先切换当前工具,再通知文档变化;工具绘制由此读取与活动标签一致的状态。
developer_tools_state.select(tool);
developer_tools_state.document_updated(document);
// left 是侧栏占用的逻辑宽度,surface 保存业务页面的 pan/zoom;二者属于不同坐标层。
let left = sidebar.left();
let surface = &document.frame.surface;
// width/height 为设备像素,sidebar.left 为逻辑单位,先乘 scale 得到页面可用裁剪区域。
@@ -44,6 +50,7 @@ pub fn build_scene(
height as f32,
);
// 管理页使用单位页面缩放,避免业务 Page 相机改变宿主工具字号;普通页叠加旧 Surface zoom。
// Mount/逐命令相机投影已在文档内,Surface 是外层旧 Page 视角,设备 scale 则属于整个 OS 窗口,三者不能重复应用。
let zoom = if tool.is_some() { 1.0 } else { surface.zoom };
// 显示列表转换失败时降级为空默认文档;这只保护原生绘制,不把无效命令伪装成成功执行。
let mut page = document.display_list(scale * zoom).unwrap_or_else(|_| {
@@ -56,6 +63,7 @@ pub fn build_scene(
(left - surface.pan.x * zoom) * scale,
-surface.pan.y * zoom * scale,
));
// 从空列表开始按背景、页面、工具、侧栏的顺序叠加;后加入的命令绘制在前面内容之上。
let mut display_list = DisplayList::default();
// 全窗只做一次 Clear,后续跳过页面列表自身 Clear,防止其覆盖侧栏或突破页面裁剪。
display_list.push(DrawCommand::Clear(
@@ -63,12 +71,15 @@ pub fn build_scene(
.unwrap_or(crate::system_ui::theme::BG),
));
display_list.push(DrawCommand::PushClip(content));
// 按页面原有顺序复制命令;页面自身的嵌套裁剪仍处于 content 这个外层裁剪之内。
for command in page.commands() {
// Clear 是整目标缓冲清屏操作,只有非 Clear 命令可以并入已建立背景的窗口列表。
if !matches!(command, DrawCommand::Clear(_)) {
display_list.push(command.clone());
}
}
display_list.push(DrawCommand::PopClip);
// 工具先在扣除侧栏的独立画布内构建,宽度用饱和减法防止窄窗口发生无符号下溢。
let (mut developer_list, mut developer_layout) = developer_tools::build(
width.saturating_sub((left * scale) as u32),
height,
@@ -78,6 +89,7 @@ pub fn build_scene(
activity,
);
// 工具命令在设备空间平移,交互布局在逻辑空间平移,两者比例不同但实际对齐同一页面起点。
// 输入路由保留的 developer_layout 不能再乘 scale;shell 的 pointer 已转换为逻辑单位,只有像素绘制列表使用设备比例。
developer_list.translate(Point::new(left * scale, 0.0));
developer_layout.translate(left, 0.0);
display_list.extend(developer_list);
@@ -89,6 +101,7 @@ pub fn build_scene(
);
chrome.scale(scale);
display_list.extend(chrome);
// 把绘制输出与这次构建产生的交互布局绑定返回,调用方无需重新计算工具位置。
DisplayScene {
display_list,
layout: SceneLayout {
@@ -99,6 +112,8 @@ pub fn build_scene(
/// 整窗合成结果:显示列表与同帧管理交互几何一起返回。
pub struct DisplayScene {
// 已完成缩放与平移的整窗绘制命令,供 shell 的渲染路径消费。
pub display_list: DisplayList,
// 保留逻辑坐标供宿主输入路由使用,不能直接当成设备像素命中区域。
pub layout: SceneLayout,
}
+124
View File
@@ -1,6 +1,8 @@
//! 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。
//! 不可变 View 可以缓存光栅结果;相机变化会产生新视图。当前不是 GPU 后端,像素和光栅工作预算用于限制分配与循环工作量。
//! 面板 UI 先渲染为有限纹理,再以透视校正 UV 映射进世界。与相机相关的投影宽高比由具体视口提供。
//! model::scene3d 提供不可变 Scene/View;source_paint 经 panel::compose 装配面板内容,SoftwareRenderer 再调用 surface 取得像素。
//! View::pick 只返回原生 Hit,关联回托管 Mesh 和构造 Event/RayHit3D 仍由模型/桥接层负责。
//!
//! Software 3D surfaces share the application's existing CPU compositor. Mesh
//! visibility and picking use the same camera and transformed world triangles.
@@ -11,19 +13,24 @@ use math::{Camera, Matrix, Projection, Vec3};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
// 单份 Geometry 和全场景累计三角形都使用此上限;顶点另有单几何及全场景两种预算。
pub const MAX_TRIANGLES: usize = 65_536;
pub const MAX_VERTICES: usize = 32_768;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 不可变顶点、三角形和可选 UV;多个网格可共享同一数值缓冲。
pub struct Geometry {
// 局部坐标顶点;渲染与拾取都先乘 Mesh.world,再执行相机空间操作。
pub vertices: Vec<Vec3>,
// 每组三个下标引用 vertices;valid 在边界检查索引,热循环随后直接索引。
pub triangles: Vec<[u32; 3]>,
#[serde(default)]
// 缺省为空;非空时与顶点等长,裁剪交点继承插值 UV,采样时再钳到纹理范围。
pub uv: Vec<[f32; 2]>,
}
impl Geometry {
/// 检查顶点与三角数量、索引范围、坐标有效性及逐顶点 UV;允许缺省 UV,不在此限制 UV 到单位区间。
// model 的场景快照与 View::valid 调用几何校验;这里不保证三角形有非零面积,退化面在后续计算跳过。
pub fn valid(&self) -> bool {
// 检查顶点/三角形资源量和所有索引范围;UV 可省略,否则必须逐顶点对应且每个分量有限。
// 不在这里强制 UV 位于 [0,1],纹理采样阶段再钳制。
@@ -40,6 +47,7 @@ impl Geometry {
.all(|&index| (index as usize) < self.vertices.len())
}
/// 生成以原点为中心的盒体顶点和三角形索引;位置和旋转由网格世界矩阵另行描述。
// model/scene3d 的盒体几何构造路径调用;返回可共享的数值网格,不在这里创建 VM 对象。
pub fn cuboid(width: f32, height: f32, depth: f32) -> Self {
// 八个角点共享位置,每个面拆两个三角形;尺寸有效性由构造调用边界检查,cuboid 本身只生成数值。
let (x, y, z) = (width / 2., height / 2., depth / 2.);
@@ -77,31 +85,42 @@ impl Geometry {
pub struct Mesh {
// 纯文本业务关联身份,用于拾取返回;不作为渲染线程可操作的托管引用。
pub identity: String,
// 共享局部几何;同一 Geometry 可配不同 world 矩阵形成多个实例。
pub geometry: Arc<Geometry>,
// 局部到世界的累计仿射矩阵,父子变换已在快照遍历阶段组合。
pub world: Matrix,
// 归一化 RGB 材质色,普通网格逐通道乘光照;有纹理面板直接使用纹理颜色。
pub color: [f32; 3],
// Some 表示世界 UI 面板,纹理由 panel::texture 生成;None 走普通材质分支。
pub panel: Option<Panel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 面板内普通 UI 的绘制命令及其裁剪/二维变换,逻辑排版不随外部相机改变。
pub struct UiSurface {
// panel::compose 把所属 Mount 的命令装入这里;clips/views 按命令索引一一对应。
pub commands: Vec<crate::document::RenderCommand>,
// 每条命令在所属面板纹理空间的裁剪,None 表示不额外裁剪;长度必须与 commands 相同。
pub clips: Vec<Option<[f32; 4]>>,
// 同索引二维投影,转换命令时先缩放再平移;不是外层世界平面的相机变换。
pub views: Vec<Option<crate::viewport::Transform>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 世界平面的有限 UI 纹理描述,包含像素尺寸、背景与内部显示内容。
pub struct Panel {
// 一次面板实例的稳定 key,panel::Mapping 用它在视图内找到对应网格。
pub key: u64,
// 纹理分辨率,单位为 UI 像素;与网格世界宽高分开,View::valid 限制每轴至多 2048。
pub width: u32,
pub height: u32,
pub background: [f32; 3],
// 装配后的普通 UI 子树;None 时纹理只保留背景,Arc 允许不可变快照共享内容。
pub surface: Option<Arc<UiSurface>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 方向光的原生数值,方向和强度在场景快照阶段校验。
pub struct Light {
// 光线传播方向;Lambert 使用其反向和面法线点积,快照校验要求近似单位长度。
pub direction: Vec3,
pub color: [f32; 3],
pub intensity: f32,
@@ -109,8 +128,10 @@ pub struct Light {
#[derive(Debug, Clone, Serialize, Deserialize)]
/// 不含摄像机的世界实例和光照集合;BVH 是可丢弃的派生缓存。
pub struct Scene {
// 原场景实例顺序也是等深度绘制和拾取的优先顺序,空间查询后仍须保留这个约定。
pub meshes: Vec<Mesh>,
pub lights: Vec<Light>,
// 所有通道共用的环境光底值,方向光在光栅化时逐通道累加。
pub ambient: f32,
pub background: [f32; 3],
#[serde(skip)]
@@ -129,11 +150,14 @@ impl PartialEq for Scene {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 显式相机与共享世界的组合,一个 Scene 可由多个独立 View 观察。
pub struct View {
// 世界网格、光照和可复用 BVH;多个相机 View 可共享同一个世界快照。
pub scene: Arc<Scene>,
// 本视图复制的相机参数,更新业务相机后应生成新 View,不能修改已经交给渲染线程的旧快照。
pub camera: Camera,
}
#[derive(Debug)]
/// 只读绘制结果使用的名义包装,让源 PaintCommand 可以携带不可变 3D 快照。
// native_visual 创建此 payload,source_paint 解码绘制命令时取出 Arc<View>;渲染线程只使用不可变数值快照。
pub struct PaintView(pub Arc<View>);
impl endor_core::ObjectPayload for PaintView {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
@@ -148,18 +172,26 @@ impl endor_core::ObjectPayload for PaintView {
#[derive(Debug, Clone, PartialEq)]
/// 射线与实际三角形的精确交点,含实例身份、距离、法线和重心坐标。
pub struct Hit {
// 命中的 Scene.meshes 下标,用于面板遮挡判断;identity 则供 model 重新关联业务 Mesh。
pub instance: usize,
// 命中实例对应的业务身份文本;同一 Mesh 可多次出现,具体出现仍需 instance 区分。
pub identity: String,
// 单位射线按 distance 步进得到的世界交点,不是视口像素或面板局部坐标。
pub point: Vec3,
// 变换后三角形边叉积的世界单位法线,非均匀缩放后也按真实世界面求解。
pub normal: Vec3,
// 沿单位射线的世界距离;near/far 校验另用相机前向深度,二者不能混用。
pub distance: f32,
// 原几何中的三角形下标,barycentric 依次对应该面三个顶点的权重。
pub triangle: usize,
pub barycentric: [f32; 3],
}
impl View {
/// 递归验证相机、材质、灯光、网格/面板表面及全场景资源预算;世界 UI 嵌套最多八层。
// 绘制命令验证和 render_aspect 都调用此入口,确保直接索引、纹理尺寸和相机计算所需的前提。
pub fn valid(&self) -> bool {
// DEPTH 按线程累计当前调用链,不是场景内网格个数;面板命令 validate 可能再次调用 View::valid。
thread_local! { static DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) }; }
// 验证可能递归进入面板内的 3D;作用域结束时归还深度,错误返回也不泄漏计数。
struct Guard;
@@ -169,7 +201,9 @@ impl View {
DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
// 只有成功增加深度的调用才创建下方 Guard;到达八层时直接失败,不能误减尚未增加的计数。
if !DEPTH.with(|depth| {
// 达到上限不修改计数,外层仍负责恢复自己的深度。
if depth.get() >= 8 {
false
} else {
@@ -181,6 +215,7 @@ impl View {
}
// 深度检查通过才建立 Guard,之后任一短路校验失败都会恢复计数;上限覆盖嵌套面板中的 3D 命令。
let _guard = Guard;
// 局部闭包统一校验背景、材质和光色,拒绝 NaN/无穷及超出 [0,1] 的通道。
let color_valid = |color: [f32; 3]| {
color
.iter()
@@ -224,11 +259,13 @@ impl View {
&& panel.width <= 2048
&& panel.height <= 2048
&& color_valid(panel.background)
// 无子树时只验证面板本身;有子树时还要检查平行表、二维投影和所有嵌套命令。
&& panel.surface.as_ref().is_none_or(|surface| {
surface.commands.len() <= 10001
&& surface.commands.len() == surface.views.len()
&& surface.commands.len() == surface.clips.len()
&& surface.views.iter().flatten().all(|view| view.valid())
// 裁剪原点允许负数,但所有分量必须有限,宽高不能为负。
&& surface.clips.iter().flatten().all(|clip| {
clip.iter().all(|value| value.is_finite())
&& clip[2] >= 0.
@@ -241,6 +278,7 @@ impl View {
})
})
})
// 每盏方向光单独验证方向、颜色和强度;前面的灯数上限限制后续逐面光照循环。
&& self.scene.lights.iter().all(|light| {
color_valid(light.color)
&& light.direction.valid()
@@ -251,6 +289,7 @@ impl View {
})
}
/// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。
// model 的 pick3D 协议和 panel::Mapping 可见命中调用;这里只返回原生 Hit,不操作托管对象。
pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option<Hit> {
// 拾取只接受有效相机、视口内归一化点及正有限宽高比;不在此完整重验几何,调用方应先验证 View。
if !self.camera.valid()
@@ -261,8 +300,10 @@ impl View {
{
return None;
}
// 相机生成单位方向射线,所以三角形返回参数可作为世界距离;basis 用于后面的 near/far 深度筛选。
let ray = self.camera.ray(u, v, aspect)?;
let basis = self.camera.basis()?;
// 累积目前最近的有效三角形;temporary 只在缓存索引不可用时持有本次重建的 BVH。
let mut hit: Option<Hit> = None;
let temporary;
// 无缓存或网格数失配时本次临时重建;数量相同的内容变更须由快照层生成新索引,不靠 matches 检测。
@@ -272,21 +313,27 @@ impl View {
temporary = spatial::Index::build(&self.scene.meshes);
&temporary
};
// 候选只有包围盒命中保证,仍需逐面精确求交;entry 是进入盒子的距离下界。
for (entry, instance) in index.ray(ray) {
// BVH 候选按包围盒进入距离排序;后续盒子起点已比最近真实交点远时,可安全停止查询。
if hit.as_ref().is_some_and(|hit| entry > hit.distance) {
break;
}
// instance 指向原场景下标,不能把 BVH 返回顺序当成业务身份。
let mesh = &self.scene.meshes[instance];
// 每个面先变换三个顶点到世界空间,射线与这些真实顶点求交而非局部包围盒。
for (triangle, indices) in mesh.geometry.triangles.iter().enumerate() {
let [a, b, c] =
indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
// 平行、退化或交点在三角形外时跳过当前面,继续查找其他候选。
let Some((distance, barycentric)) = ray.triangle(a, b, c) else {
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
|| hit.as_ref().is_some_and(|hit| {
@@ -312,35 +359,42 @@ impl View {
});
}
}
// 返回 None 是正常未命中;不把包围盒候选伪装为表面交点。
hit
}
}
/// 把有限颜色通道转换成最终像素使用的字节。
// rgb 的内部转换步骤;调用边界负责颜色合法性,最终输出仍钳制过亮的光照结果。
fn channel(value: f32) -> u32 {
(value.clamp(0., 1.) * 255.).round() as u32
}
/// 将三个颜色通道打包为软件像素缓冲使用的 RGB 值。
// render_aspect 与 panel::texture 用它初始化背景或打包面颜色,布局为 0xRRGGBB,不携带透明度。
pub fn rgb(color: [f32; 3]) -> u32 {
channel(color[0]) << 16 | channel(color[1]) << 8 | channel(color[2])
}
/// 某个不可变视图在特定分辨率和宽高比下的光栅结果。
struct CachedSurface {
// 强持有视图使缓存键的 Arc 身份在条目存活期间稳定;不通过场景内容哈希判等。
view: Arc<View>,
width: u32,
height: u32,
aspect: f32,
// 最终像素可直接由多个绘制请求共享,缓存淘汰后外部 Arc 仍可继续读取。
pixels: Arc<[u32]>,
}
#[derive(Default)]
/// 限制条目数和总像素数的表面缓存;不承担场景业务所有权。
struct SurfaceCache {
entries: std::collections::VecDeque<CachedSurface>,
// 所有缓存条目像素总数,插入和淘汰同步增减;这不是 render_aspect 的递归工作预算。
pixels: usize,
}
/// 按不可变视图身份、分辨率和投影比例查光栅缓存;未命中时在锁外绘制,支持嵌套世界 UI。
// software_renderer 遇到 DrawCommand::Scene3D 时调用;尺寸可按 LOD 降低,aspect 仍来自原始布局矩形。
pub fn surface(
view: &Arc<View>,
width: u32,
@@ -348,10 +402,14 @@ pub fn surface(
aspect: f32,
) -> Result<Arc<[u32]>, &'static str> {
// 全局缓存以 View Arc 身份+输出尺寸+aspect 为键;值相等但不同 Arc 的视图不会自动命中。
// 缓存跨 VM 存续但只持原生快照/像素;关闭源域不要求旧条目立刻清空,也不会因该缓存保活托管业务对象。
static CACHE: std::sync::OnceLock<std::sync::Mutex<SurfaceCache>> = std::sync::OnceLock::new();
// 进程内惰性初始化共享缓存;锁中毒按错误返回,不绕过锁继续使用状态。
let cache = CACHE.get_or_init(Default::default);
// 短作用域只做查找和 Arc 克隆;离开这个块释放锁,后面的渲染可以递归请求其他 3D 表面。
{
let cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
// 键必须同时匹配 Arc 身份、像素宽高和投影宽高比;相同像素尺寸不代表相同投影。
if let Some(entry) = cache.entries.iter().find(|entry| {
Arc::ptr_eq(&entry.view, view)
&& entry.width == width
@@ -363,15 +421,19 @@ pub fn surface(
}
// Do not hold a cache lock across rendering: world UI can contain another
// 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;
};
cache.pixels -= entry.pixels.len();
}
// 下方给缓存克隆一个像素 Arc,再把本地 Arc 返回调用方;外部持有时间独立于队列淘汰。
cache.pixels += pixels.len();
cache.entries.push_back(CachedSurface {
view: view.clone(),
@@ -393,6 +455,7 @@ struct Vertex {
}
impl Vertex {
/// 按裁剪交点比例同时插值位置和 UV,保持新顶点的几何与纹理坐标一致。
// clip 在一条边跨裁剪面时调用;t 按两端带符号距离求得,同步移动位置和纹理参数。
fn lerp(self, other: Self, t: f32) -> Self {
Self {
position: self.position + (other.position - self.position) * t,
@@ -406,13 +469,16 @@ impl Vertex {
/// 生成相机空间六个视锥平面,供三角形裁剪与 BVH 可见性查询共用。
fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
// 平面内侧满足 ax+by+cz+d>=0。透视侧平面随 z 展开,正交侧平面是固定 x/y 边界。
// x/y 表示透视单位深度半宽高或正交固定半宽高,slope 用于在同一平面公式中选择 z 项。
let (x, y, slope) = match camera.projection {
// 透视侧边经过相机原点,其边界横纵坐标随正向深度线性增长。
Projection::Perspective { fov } => {
let y = (fov.to_radians() / 2.).tan();
(y * aspect, y, 1.)
}
Projection::Orthographic { height } => (height * aspect / 2., height / 2., 0.),
};
// 依次返回近、远、左、右、下、上六个内侧平面;不必单位化,因为这里只使用符号和距离比例。
[
[0., 0., 1., -camera.near],
[0., 0., -1., camera.far],
@@ -423,31 +489,41 @@ fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
]
}
/// 在透视除法前逐平面裁剪多边形,并同步插值 UV,避免跨近面顶点投影到极大坐标。
// render_aspect 对每个三角形调用,返回可能为空或顶点更多的凸多边形,随后用扇形重新三角化。
fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
// 每次只处理一个半空间,再把结果交给下一个平面;六轮结束后所有剩余顶点都在视锥内。
for [a, b, c, d] in frustum_planes(camera, aspect) {
// 上一轮已完全裁掉时不再访问 last,也无需继续处理其他平面。
if polygon.is_empty() {
break;
}
// 返回平面方程的带符号值,不要求是真实距离;同一边的两端共享系数,比例仍可用于求交。
let distance = |vertex: Vertex| {
let p = vertex.position;
a * p.x + b * p.y + c * p.z + d
};
// 本轮写入新多边形;遍历消耗旧 polygon,避免边遍历边插入导致重复处理。
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)));
}
// 当前点位于内侧或恰在面上时保留;外侧端点只可能贡献前面的边交点。
if after >= 0. {
output.push(current);
}
// 前进到下一条边,同时更新端点及其平面值,避免重复求上一端点距离。
previous = current;
before = after;
}
// 本轮输出成为下一平面的输入;返回的 UV 已包含所有裁剪交点插值。
polygon = output;
}
polygon
@@ -455,6 +531,7 @@ fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
#[cfg(test)]
/// 把当前绘制输入转换为输出表面;分配、裁剪和预算由本实现检查。
// 仅测试构建暴露的便利入口,按输出像素宽高推导 aspect;生产合成使用 surface 的显式 aspect。
pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static str> {
render_aspect(view, width, height, width as f32 / height as f32)
}
@@ -465,6 +542,7 @@ fn render_aspect(
height: u32,
aspect: f32,
) -> Result<Vec<u32>, &'static str> {
// 进入共享 RenderScope,让面板递归中的二维/三维分配共用预算;作用域退出由 RAII 恢复嵌套深度,已申请像素额度仍按本帧累计。
let _scope = crate::software_renderer::RenderScope::enter()?;
// 颜色与深度各占一份面积,既有每表面硬上限,也在下方计入递归累计像素预算。
let area = width as usize * height as usize;
@@ -482,8 +560,10 @@ fn render_aspect(
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];
// 前面 view.valid 已保证相机基可构造,因此这里的 unwrap 依赖该校验前提。
let camera = view.camera;
let basis = camera.basis().unwrap();
// 后续深度与 UV 是否使用倒深度校正只由投影类型决定,不能按网格是否带纹理决定。
let perspective = matches!(camera.projection, Projection::Perspective { .. });
// 点已经经过视锥裁剪,才做透视除法;屏幕 y 向下,保留相机 z 用于后续深度/UV 校正。
let project = |point: Vec3| {
@@ -497,8 +577,11 @@ fn render_aspect(
point.z,
)
};
// 累计所有三角形屏幕包围矩形的扫描面积,遮挡后未写像素的工作也计入。
// 这是本次表面的光栅工作量;递归面板的颜色/深度分配由上方 RenderScope 的共享像素额度另行累计,不能用缓存大小替代。
let mut work = 0usize;
let temporary;
// 与 pick 一样按网格数量决定是否使用已有索引;内容改变须由快照层重建,数量匹配不等于内容匹配。
let index = if view.scene.acceleration.matches(view.scene.meshes.len()) {
&*view.scene.acceleration
} else {
@@ -507,18 +590,23 @@ fn render_aspect(
};
// 宽阶段只筛选候选:离屏网格不进入后续纹理分配和逐三角形处理。
for instance in index.visible(camera, aspect) {
// visible 已恢复原场景顺序,等深度时先出现的实例保持优先。
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];
// 方向光的 direction 指传播方向,所以取反才是从表面指向光源的入射方向。
for source in &view.scene.lights {
let intensity = normal.dot(source.direction * -1.).max(0.) * source.intensity;
// 光色逐 RGB 通道调制强度,再加到同一面的环境光底值。
for (channel, color) in light.iter_mut().zip(source.color) {
*channel += intensity * color;
}
@@ -545,31 +633,39 @@ fn render_aspect(
camera,
aspect,
);
// 完全裁掉或仅剩线/点时没有可填充面积,不能进入扇形三角化。
if polygon.len() < 3 {
continue;
}
// 裁剪后可能多于三个顶点,用扇形重新三角化,保持同一平面的纹理参数。
for index in 1..polygon.len() - 1 {
// 扇形固定第一个顶点,依次连接相邻两点;保留这三个顶点的 UV 供每像素插值。
let vertices = [polygon[0], polygon[index], polygon[index + 1]];
let [a, b, c] = vertices.map(|vertex| project(vertex.position));
// 四个屏幕边界先取整再夹到缓冲尺寸,扫描使用左闭右开范围,避免数组越界。
let min_x = a.x.min(b.x).min(c.x).floor().max(0.) as u32;
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");
}
// 二维边函数给出有向面积,统一用于面积退化判断和像素中心的重心权重。
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);
// 投影后几乎共线的面不稳定且没有可见面积;这里的 area 已遮蔽上方缓冲面积变量。
if area.abs() < 1e-6 {
continue;
}
// 只扫描当前小三角形的包围矩形;深度缓冲会处理不同三角形之间的遮挡。
for y in min_y..max_y {
// 以像素中心而非左上角采样,两个坐标均加半个像素。
for x in min_x..max_x {
let px = x as f32 + 0.5;
let py = y as f32 + 0.5;
@@ -588,14 +684,18 @@ fn render_aspect(
} else {
weights[0] * a.z + weights[1] * b.z + weights[2] * c.z
};
// 按行主序定位颜色和深度数组中的同一像素;与外层三角形扇形下标是不同局部变量。
let index = y as usize * width as usize + x as usize;
// 只让最近表面写像素;等深度保留先绘制实例,与拾取的排序约定一致。
if depth < depths[index] {
// 通过严格小于测试后同步更新深度和颜色;没有通过时保留原像素及其深度。
depths[index] = depth;
pixels[index] =
// 只有纹理和面板描述同时存在才采样;普通网格直接使用前面计算的材质光照色。
if let (Some(texture), Some(panel)) = (&texture, &mesh.panel) {
// UV 也要除以深度再插值,否则倾斜面板会出现纹理拉伸失真。
let interpolated = |axis| {
// 透视下先对各顶点 UV 除以 z,再乘回当前插值深度;正交下直接按屏幕权重求和。
if perspective {
depth
* (weights[0] * vertices[0].uv[axis] / a.z
@@ -633,10 +733,14 @@ mod tests {
#[test]
/// 构造99个远离视锥的网格,宽阶段只保留正中实例;有/无预存BVH的精确拾取身份与距离必须一致。
fn spatial_index_culls_offscreen_meshes_and_preserves_ray_pick() {
// 从一个原点盒体开始,随后加入大量远离视锥的实例以验证索引筛选。
let mut view = view(Geometry::cuboid(1., 1., 1.));
// 测试需要修改快照,Arc::make_mut 保证独占可写场景,不改共享视图的语义。
let scene = Arc::make_mut(&mut view.scene);
let mesh = scene.meshes[0].clone();
// 保留原始实例为下标 0,其余盒体沿世界 x 轴每次平移 100,确保落在侧视锥之外。
for index in 1..100 {
// 复制几何和材质,只改变业务身份与世界平移,隔离测试中的变量。
let mut far = mesh.clone();
far.identity = format!("far{index}");
far.world = Matrix::trs(
@@ -646,20 +750,26 @@ 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);
// 先记录缓存路径的精确结果,避免后面清空副本索引影响比较基准。
let indexed = view.pick(0.5, 0.5, 1.).unwrap();
// 复制 View 后清空其派生缓存,场景几何和相机保持一致。
let mut plain = view.clone();
Arc::make_mut(&mut plain.scene).acceleration = Arc::default();
// 触发 pick 的临时重建分支;身份与距离断言共同验证加速不改变最终命中。
let fallback = plain.pick(0.5, 0.5, 1.).unwrap();
assert_eq!(indexed.identity, fallback.identity);
assert_eq!(indexed.distance, fallback.distance);
}
/// 构造测试最小视图:默认相机、原点红网格、纯环境光与白背景,不依赖业务VM或外部光源。
fn view(geometry: Geometry) -> View {
// 统一返回红色单网格、白背景与满环境光,像素断言可直接识别纯红,无需外部灯光。
View {
camera: Camera::default(),
scene: Arc::new(Scene {
@@ -680,6 +790,7 @@ mod tests {
#[test]
/// 跨越近裁剪面的三角形仍应产生像素,射线命中受同一 near/far 约束;缩短远面后命中消失。
fn triangles_crossing_near_plane_are_clipped_and_pick_uses_the_same_range() {
// 三角形一个顶点在 near 前方外侧,两个顶点在内侧,构造必须裁剪而不能整面丢弃的输入。
let mut view = view(Geometry {
uv: vec![],
vertices: vec![
@@ -689,6 +800,7 @@ mod tests {
],
triangles: vec![[0, 1, 2]],
});
// 把相机放原点朝 +z 看,令世界 z 直接对应裁剪深度,简化断言解释。
view.camera.position = Vec3::default();
view.camera.target = Vec3::new(0., 0., 1.);
view.camera.near = 1.;
@@ -697,30 +809,37 @@ mod tests {
// 三角形跨近面仍应产生可见红像素,拾取也必须尊重相同 near/far;缩短 far 后命中消失。
let pixels = render(&view, 64, 64).unwrap();
assert!(pixels.contains(&0xFF0000));
// 中心射线命中必须落在相机深度范围,不能只证明渲染输出存在。
let hit = view.pick(0.5, 0.5, 1.).unwrap();
assert!(hit.point.z >= view.camera.near && hit.point.z <= view.camera.far);
// 只收紧远面,令原中心命中退出有效深度范围,验证拾取同样受 far 裁剪。
view.camera.far = 1.1;
assert!(view.pick(0.5, 0.5, 1.).is_none());
}
#[test]
/// 验证局部TRS先于父变换作用于点;正交投影不同水平采样的射线方向相同但原点按可见宽度平移。
fn group_transform_order_and_orthographic_rays_are_consistent() {
// 局部先绕 y 轴转 90 度再平移 1,父层另外平移 10;输入点 z=1 最终应落在 x=12。
let local = Matrix::trs(
Vec3::new(1., 0., 0.),
Vec3::new(0., 90., 0.),
Vec3::new(1., 1., 1.),
);
// 父变换无旋转和缩放,用于区分 local 与 parent 的应用顺序。
let parent = Matrix::trs(
Vec3::new(10., 0., 0.),
Vec3::default(),
Vec3::new(1., 1., 1.),
);
// compose 先作用 local 再作用 parent;误换顺序会使父平移参与局部旋转而改变断言。
let transformed = parent.compose(local).point(Vec3::new(0., 0., 1.));
assert!((transformed.x - 12.).abs() < 1e-5 && transformed.z.abs() < 1e-5);
// 复用默认观察方向,仅改成高度为 4 的正交投影,检查平行射线与水平起点间距。
let camera = Camera {
projection: Projection::Orthographic { height: 4. },
..Camera::default()
};
// 归一化横坐标相差 0.5,对应可见宽度 4 的一半;中心纵坐标保持一致。
let left = camera.ray(0.25, 0.5, 1.).unwrap();
let right = camera.ray(0.75, 0.5, 1.).unwrap();
assert_eq!(left.direction, right.direction);
@@ -729,15 +848,20 @@ mod tests {
#[test]
/// 同一不可变视图/尺寸/aspect复用像素Arc;改变aspect重绘,超像素预算或退化相机必须拒绝。
fn surface_cache_reuses_immutable_frames_and_rejects_invalid_resources() {
// 缓存键使用 View 的 Arc 身份,因此两次调用必须引用同一个 Arc 而不是重新构造等值对象。
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));
// 只改变投影 aspect,输出尺寸仍为 64×64;既检查缓存未复用,也检查投影结果确实改变。
let wide = surface(&view, 64, 64, 2.).unwrap();
assert!(!Arc::ptr_eq(&first, &wide));
assert_ne!(first, wide);
// 1600 万像素超过单表面硬上限,应在大缓冲分配前返回错误。
assert!(render(&view, 4000, 4000).is_err());
// 单独复制视图并令 target 等于 position,验证退化观察基在 valid 和渲染入口都被拒绝。
let mut invalid = (*view).clone();
invalid.camera.target = invalid.camera.position;
assert!(!invalid.valid());
+33 -6
View File
@@ -6,7 +6,9 @@ use std::ops::{Add, Mul, Sub};
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
/// 三维数学向量;构造不校验分量,需在输入边界显式调用 valid,不是托管 Vec3 对象。
// model/scene3d 用它读取场景变换,原生光栅化、网格拾取和 panel 坐标映射共同复用此类型。
pub struct Vec3 {
// 三轴原始分量;表示位置、方向或重心权重取决于调用方,不在类型中隐式携带坐标空间。
pub x: f32,
pub y: f32,
pub z: f32,
@@ -48,21 +50,21 @@ impl Vec3 {
}
impl Add for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
/// 按分量相加;摄像机射线用它叠加前向与视平面偏移。
fn add(self, b: Self) -> Self {
Self::new(self.x + b.x, self.y + b.y, self.z + b.z)
}
}
impl Sub for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
/// 按分量相减;观察基与三角形求交用它得到相对位置和边向量。
fn sub(self, b: Self) -> Self {
Self::new(self.x - b.x, self.y - b.y, self.z - b.z)
}
}
impl Mul<f32> for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
/// 每个分量乘同一个标量;归一化、射线步进和光照计算用它调整向量长度。
fn mul(self, b: f32) -> Self {
Self::new(self.x * b, self.y * b, self.z * b)
}
@@ -70,9 +72,10 @@ impl Mul<f32> for Vec3 {
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 仿射 4×4 变换矩阵,支持局部到世界的组合与求逆。
// model/scene3d 从 TRS 构造并组合世界矩阵;panel 使用其逆矩阵将射线交点还原为局部 UI 位置。
pub struct Matrix(pub [f32; 16]);
impl Default for Matrix {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
/// 返回单位矩阵,供场景遍历的根变换使用;与局部矩阵组合时保持原变换。
fn default() -> Self {
Self([
1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
@@ -83,6 +86,7 @@ impl Matrix {
/// 求仿射世界矩阵的逆;退化或不可逆时返回空值。
pub fn inverse(self) -> Option<Self> {
// 这里按仿射矩阵处理:最后一行假设为 [0,0,0,1],只逆左上 3×3,不实现一般透视矩阵求逆。
// panel::Mapping 用它把世界射线交点还原到面板局部平面;零缩放等退化情况返回 None,由调用方停止映射。
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]);
@@ -121,7 +125,9 @@ impl Matrix {
pub fn compose(self, local: Self) -> Self {
// 行主序存储,点视作列向量;self.compose(local) = self × local,即先应用 local 再应用 self。
let mut out = [0.; 16];
// 逐行逐列计算输出的 16 个元素;k 遍历 self 的行与 local 的列,累加四项点积。
for row in 0..4 {
// row/col 都属于输出矩阵;不能交换两个乘数,否则父子变换顺序会反转。
for col in 0..4 {
out[row * 4 + col] = (0..4)
.map(|k| self.0[row * 4 + k] * local.0[k * 4 + col])
@@ -141,10 +147,11 @@ 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();
// Local X, then Y, then Z rotation; scale first, translation last.
// 点先缩放,再依次绕局部 X、Y、Z 旋转,最后加平移;这里直接展开组合矩阵以避免临时矩阵。
Self([
cz * cy * scale.x,
(cz * sy * sx - sz * cx) * scale.y,
@@ -177,16 +184,23 @@ pub enum Projection {
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 原生摄像机描述,包括观察基、近远面和投影参数;宽高比由视口提供。
// 场景发布时由 model 验证参数;View 渲染/拾取与 panel 映射随后使用同一份相机描述。
pub struct Camera {
// 世界空间眼睛位置;透视投影的全部拾取射线从这里出发。
pub position: Vec3,
// 世界空间观察目标,用 target-position 确定前向,而不是直接保存欧拉角。
pub target: Vec3,
// 提供上方向参考,basis 会重新正交化;与前向平行时无法形成观察基。
pub up: Vec3,
// 近远面按相机正向深度裁剪;valid 要求 0 < near < far。
pub near: f32,
// 最远可见正向深度;射线距离与此深度不是同一数值,斜向透视射线的命中要先换到观察基再裁剪。
pub far: f32,
// 透视角度或正交高度;相机本身不保存视口宽高比,render/pick 每次按具体出现传入 aspect。
pub projection: Projection,
}
impl Default for Camera {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
/// 返回从世界 z=5 观察原点的透视相机,供缺省场景与原生测试构造使用。
fn default() -> Self {
Self {
position: Vec3::new(0., 0., 5.),
@@ -201,8 +215,11 @@ impl Default for Camera {
#[derive(Debug, Clone, Copy)]
/// 摄像机的右、上、前单位基向量。
pub struct Basis {
// 观察平面的横向单位轴,来自 forward×up,不直接使用世界 X 轴。
pub right: Vec3,
// 与 right/forward 正交的上轴,可能不同于源相机未归一化的 up 参考向量。
pub up: Vec3,
// 从相机指向 target 的单位前向,view 以此方向为正深度。
pub forward: Vec3,
}
impl Camera {
@@ -211,7 +228,9 @@ impl Camera {
// forward 指向观察目标;up 输入只提供大致上方向,叉积后重新正交化。
// 位置等于目标、up 为零或与前向平行时,normalized 返回 None。
let forward = (self.target - self.position).normalized()?;
// 前向叉上方向产生右向;再次归一化,消除输入 up 长度对后续投影的影响。
let right = forward.cross(self.up).normalized()?;
// 用已确定的右向和前向重建垂直上向,让三个基向量彼此正交。
let up = right.cross(forward).normalized()?;
Some(Basis { right, up, forward })
}
@@ -227,6 +246,7 @@ impl Camera {
&& self.near > 0.
&& self.far > self.near
&& self.far <= 1_000_000.
// 投影类型决定最后的合法性条件;透视角度受限以避免极端 tan,正交高度必须为正。
&& match self.projection {
Projection::Perspective { fov } => fov.is_finite() && (1.0..=175.0).contains(&fov),
Projection::Orthographic { height } => {
@@ -242,6 +262,7 @@ impl Camera {
}
/// 从视口归一化坐标生成透视或正交射线;宽高比由具体视口提供。
pub fn ray(self, u: f32, v: f32, aspect: f32) -> Option<Ray> {
// View::pick 和 panel::Mapping 的逆投影都从这里生成射线;退化观察基直接向上传播 None。
let basis = self.basis()?;
// u/v 通常是视口内 [0,1],映射到 [-1,1] 并翻转屏幕 y。
// 此函数不钳制输入,捕获拖动可以从视口外继续生成射线;aspect 由调用方保证有效。
@@ -271,11 +292,14 @@ impl Camera {
#[derive(Debug, Clone, Copy)]
/// 世界坐标中的射线原点与方向,供网格和面板求交。
pub struct Ray {
// 世界空间起点;透视使用相机 position,正交则按视口点在相机平面上平移起点。
pub origin: Vec3,
// Camera::ray 返回单位方向;手工构造 Ray 不会自动归一化,triangle 返回的参数此时不保证等于欧氏距离。
pub direction: Vec3,
}
impl Ray {
/// 求射线与三角形相交距离和重心坐标,拒绝退化三角形及射线起点后方的交点。
// View::pick 遍历网格三角形时调用,使用返回的射线参数选最近命中,重心权重还可插值纹理坐标。
pub fn triangle(self, a: Vec3, b: Vec3, c: Vec3) -> Option<(f32, [f32; 3])> {
// Möller–Trumbore 求交:使用两条三角形边与叉积解重心系数,不先构造平面方程。
let ab = b - a;
@@ -286,6 +310,7 @@ impl Ray {
if determinant.abs() < 1e-7 {
return None;
}
// 复用行列式倒数以解重心系数;t 此处是从顶点 a 指向射线起点的向量。
let inverse = 1.0 / determinant;
let t = self.origin - a;
// u 是顶点 b 的重心权重,必须位于 [0,1];v 是 c 的权重,a 的权重由 1-u-v 得到。
@@ -293,8 +318,10 @@ impl Ray {
if !(0.0..=1.0).contains(&u) {
return None;
}
// 第二次叉积用于同时求顶点 c 的权重和射线参数,避免另外求解完整线性方程组。
let q = t.cross(ab);
let v = self.direction.dot(q) * inverse;
// v 为负或 b/c 权重和超过 1 都表示交点落在三角形外,即使它仍位于三角形所在平面。
if v < 0. || u + v > 1. {
return None;
}
+37
View File
@@ -21,16 +21,20 @@ pub struct Mapping {
pub owner: u64,
// 面板在场景中的稳定标识,不能仅凭 Block 身份选择某个具体面板出现。
pub key: u64,
// 保存这次出现所使用的不可变场景和相机;命中必须与生成画面的视图一致。
pub view: Arc<View>,
// [x,y,width,height] 是所属二维空间中的视口布局矩形,宽高也决定射线的 aspect。
pub rect: [f32; 4],
// 外层二维相机变换;逆映射先处理它,再进入 3D 摄像机射线计算。
pub outer: Transform,
// 当前层的二维裁剪,在逆 outer 之前检查;None 表示这一层没有额外矩形裁剪。
pub clip: Option<[f32; 4]>,
// 嵌套面板的外层链;反投影从父到子,正投影从子到父。
pub parent: Option<Arc<Mapping>>,
}
impl Mapping {
/// 沿父面板逆投影到当前平面的 UI 像素;要求可见时使用最近射线命中拒绝被遮挡的目标。
// RenderDocument 的面板坐标转换调用这里服务命中与事件派发;clipped=false 用于捕获后的连续拖动。
pub fn texture_point(&self, point: Point, clipped: bool) -> Option<Point> {
// 先把最外层窗口坐标逐层反投影为父面板像素,再检查当前面板所属空间的裁剪。
let point = if let Some(parent) = &self.parent {
@@ -45,19 +49,24 @@ impl Mapping {
{
return None;
}
// 先撤销当前层二维平移/缩放,后面的 rect 才能按未变换布局坐标归一化。
let point = self.outer.inverse(point);
let [x, y, width, height] = self.rect;
// 零或负视口没有可用面积,不能计算归一化位置或宽高比。
if width <= 0. || height <= 0. {
return None;
}
// rect 是 3D 视口在当前二维空间的位置,归一化坐标传给该视口相机;不是面板纹理坐标。
let (u, v) = ((point.x - x) / width, (point.y - y) / height);
// key 标识当前出现的面板网格;找不到时不退回其他面板,以免把事件发给错误对象。
let mesh = self.view.scene.meshes.iter().find(|mesh| {
mesh.panel
.as_ref()
.is_some_and(|panel| panel.key == self.key)
})?;
// panel 提供实际纹理像素宽高;它与世界几何尺寸分开,缩放网格不会改变 UI 的像素坐标。
let panel = mesh.panel.as_ref()?;
// 普通命中要求当前可见;捕获手势跳过这一步,使指针离开面板或被遮挡后仍能继续拖动。
if clipped {
// 指针落在平面投影内还不够:最近三角形必须属于本面板,不能点穿前方网格。
let hit = self.view.pick(u, v, width / height)?;
@@ -71,6 +80,7 @@ impl Mapping {
}
// 捕获拖动可以不要求指针仍位于面板内,但仍使用原面板平面的连续逆映射。
let ray = self.view.camera.ray(u, v, width / height)?;
// 平面中心与两条世界轴共同确定相交平面;不直接把局部 z 轴当作非均匀缩放后的法线。
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);
@@ -100,13 +110,16 @@ impl Mapping {
}
/// 把当前面板的 UI 像素正向投影到外层页面空间,供坐标查询和交互测试使用。
// model/mount 的坐标查询使用此正投影,将面板内布局位置还原到外层页面供调用者定位。
pub fn project_point(&self, point: Point) -> Option<Point> {
// Mount.toView 只请求几何位置,不能用这个成功结果推断目标当前可点击;遮挡和 clip 要由 texture_point(clipped=true) 另检。
let mesh = self.view.scene.meshes.iter().find(|mesh| {
mesh.panel
.as_ref()
.is_some_and(|panel| panel.key == self.key)
})?;
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 对称:纹理像素先转局部平面点,再进入世界空间。
@@ -115,6 +128,7 @@ impl Mapping {
(0.5 - point.y / panel.height as f32) * height,
0.,
));
// 世界点先转换成相机的右/上/前坐标,后续用正向深度决定裁剪和透视除数。
let camera = self.view.camera;
let local = camera.view(camera.basis()?, world);
// 点在近远面之外时无可用投影;此方法仅算位置,不检查前方遮挡或二维 clip。
@@ -127,6 +141,7 @@ impl Mapping {
Projection::Perspective { fov } => local.z * (fov.to_radians() / 2.).tan(),
Projection::Orthographic { height } => height / 2.,
};
// 归一化投影恢复为视口逻辑坐标,最后施加所属二维层的 outer 变换。
let point = self.outer.point(Point::new(
x + (local.x / (half * w / h) + 1.) * w / 2.,
y + (1. - local.y / half) * h / 2.,
@@ -143,32 +158,42 @@ impl Mapping {
#[derive(Clone)]
/// 归属明确的一条内部 UI 绘制命令,装配时还保留裁剪、变换和 owner。
struct Entry {
// 本出现生成的完整逻辑命令;递归 assemble 可仅替换其中 Scene3D 的不可变视图。
command: crate::document::RenderCommand,
// 所属二维/纹理空间裁剪,与 command 的位置投影分开保存。
clip: Option<[f32; 4]>,
// 命令的累计二维视图;装入 UiSurface 后仍与原命令保持同索引。
view: Option<Transform>,
// 原绘制出现的 Mount ID,用于分组、嵌套环检查,不是场景 mesh 下标。
owner: u64,
}
/// Collect a panel's ordinary paint subtree into its texture. Nested 3D
/// commands are assembled recursively before their containing texture.
/// 按面板所属视口和稳定 key 分组绘制命令,递归将内部 UI 装配进 3D 面板表面。
/// source_paint 在生成各 Mount 命令后调用;输入的 command_clips、command_views、command_mounts 必须与 commands 对齐,归属 ID 必须能查到 Mount。
pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
// 输入由 source_paint::finish 建立四张平行表并校验归属;本函数直接索引,不把任意未经检查的外部 Frame 当作可靠输入。
use crate::document::RenderCommand;
use std::collections::{HashMap, HashSet};
// 按 Mount ID 建立查找表,让每条命令直接取到所属面板映射,避免逐条扫描完整 Mount 列表。
let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 分组键包含视口 owner 和面板 key,防止同一场景在两个视口出现时互相混入绘制命令。
let mut groups = HashMap::<(u64, u64), Vec<Entry>>::new();
let mut roots = Vec::new();
// 每条命令都有出现归属;据此分流为窗口根命令或某个世界面板的内部命令。
for (index, command) in frame.commands.iter().enumerate() {
// owner 是该条绘制命令的 Mount ID;面板分组使用 mount.panel 内的外层视口 owner。
let owner = frame.command_mounts[index];
let mount = mounts[&owner];
// 克隆命令并携带同索引裁剪、二维变换,移动到纹理后仍保持原始绘制语义。
let entry = Entry {
command: command.clone(),
clip: frame.command_clips[index],
view: frame.command_views[index],
owner,
};
// 面板后代收进对应纹理分组;不属于面板的命令保留为根层,二者都维持输入相对顺序。
if let Some(panel) = &mount.panel {
groups
.entry((panel.owner, panel.key))
@@ -207,6 +232,7 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
path: &mut HashSet<u64>,
depth: usize,
) -> anyhow::Result<Entry> {
// 仅本次条目可变;没有面板的普通命令可原样返回,不需要复制整个场景。
let mut entry = entry;
if let RenderCommand::Scene3D { view, .. } = &mut entry.command
&& view.scene.meshes.iter().any(|mesh| mesh.panel.is_some())
@@ -218,8 +244,10 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
);
// 只构造新的原生快照,不写回源 Scene3D;同一业务场景仍可被其他视口共享。
let mut scene = (*view.scene).clone();
// 每个面板取得自己分组的 UI 命令;普通网格没有纹理子树,直接跳过。
for mesh in &mut scene.meshes {
if let Some(panel) = &mut mesh.panel {
// 一张表面保存三组平行数据;每个子条目装配成功后同时追加,保持索引对应。
let mut surface = super::UiSurface {
commands: Vec::new(),
clips: Vec::new(),
@@ -232,9 +260,11 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
surface.clips.push(child.clip);
surface.views.push(child.view);
}
// 即使分组为空也发布空表面,避免继承源快照中与本次出现无关的旧绘制内容。
panel.surface = Some(Arc::new(surface));
}
}
// 新场景替换本条目引用,相机保持不变;其他命令持有的 Arc<View> 不受影响。
*view = Arc::new(View {
scene: Arc::new(scene),
camera: view.camera,
@@ -244,12 +274,14 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
}
Ok(entry)
}
// 所有根条目共用临时祖先集合,每条递归返回时删除标记;depth 从根的 0 开始计算。
let mut path = HashSet::new();
// 面板内部命令已经移入 UiSurface,根帧只保留外层命令;四张平行元数据表同步重建。
frame.commands.clear();
frame.command_clips.clear();
frame.command_views.clear();
frame.command_mounts.clear();
// 递归错误会在清空之后返回,此函数本身不保证出错时恢复原帧;调用方需处理整帧失败。
for entry in roots {
let entry = assemble(entry, &groups, &mut path, 0)?;
frame.commands.push(entry.command);
@@ -261,11 +293,15 @@ pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
}
/// 为一个面板分配有限像素表面,并调用普通 UI 软件渲染;当前实现不会按面板内容独立缓存纹理。
/// 软件光栅化的面板纹理路径调用;本函数每次产生新像素数组,复用策略由外层调用方决定。
pub fn texture(panel: &super::Panel) -> Result<Vec<u32>, &'static str> {
// render_aspect 已通过 View.valid 验证面板分辨率和内部命令;这里复用普通软件合成,输出是不透明背景上的 RGB 纹理。
// 先向统一像素预算申请有限纹理面积,再分配背景缓冲;嵌套面板同样受该预算约束。
crate::software_renderer::reserve_pixels(panel.width as usize * panel.height as usize)?;
// u32 缓冲按纹理宽高铺满背景;没有 UiSurface 时直接返回这张纯背景纹理。
let mut pixels =
vec![super::rgb(panel.background); panel.width as usize * panel.height as usize];
// 有 UI 子树才生成显示列表并绘制;解析或渲染错误向上返回,不把半成品作为成功纹理。
if let Some(surface) = &panel.surface {
// 将普通 UI 表面包装为临时文档复用现有显示列表和软件渲染路径;比例 1 对应纹理像素。
let document = crate::document::RenderDocument {
@@ -278,6 +314,7 @@ pub fn texture(panel: &super::Panel) -> Result<Vec<u32>, &'static str> {
},
..Default::default()
};
// 比例为 1,保持子 UI 坐标和纹理像素一一对应;无效命令转换成纹理路径的简短错误。
let list = document
.display_list(1.)
.map_err(|_| "invalid 3D UI texture commands")?;
+53 -1
View File
@@ -9,13 +9,17 @@ use super::{
#[derive(Debug, Clone, Copy)]
/// 变换后网格的世界空间 AABB,做保守筛选而不是精确表面描述。
// Index::build 为每个非空 Mesh 构造,查询时用它拒绝整组不可能命中的实例。
pub struct Bounds {
// 世界 x/y/z 三轴最小值;max 为最大值,二者围成与世界轴对齐的保守外包围。
pub min: Vec3,
pub max: Vec3,
}
impl Bounds {
/// 变换网格顶点并求世界 AABB,作为空间索引的保守外包围。
// Index::build 调用;必须从真实变换后顶点取极值,不能只变换局部 min/max 两个角。
fn mesh(mesh: &Mesh) -> Option<Self> {
// 惰性遍历局部顶点并应用 world,每个非空网格只扫描一次来建立包围盒。
let mut points = mesh
.geometry
.vertices
@@ -23,6 +27,7 @@ impl Bounds {
.map(|point| mesh.world.point(*point));
// 空网格没有包围盒,返回 None 后不会进入树;首顶点初始化上下界,避免把原点误算进边界。
let first = points.next()?;
// 以首顶点为初始界,其余点逐分量扩大范围;fold 的最终值同时覆盖所有变换后顶点。
Some(points.fold(
Self {
min: first,
@@ -43,6 +48,7 @@ impl Bounds {
))
}
/// 求两个包围盒的最小共同外包围,供 BVH 父节点使用。
// Index::split 用 reduce 合并当前节点全部实例的界,父节点可一次剔除整棵子树。
fn union(self, other: Self) -> Self {
Self {
min: Vec3::new(
@@ -58,16 +64,20 @@ impl Bounds {
}
}
/// 取指定轴中心位置,供最长轴排序和分割使用。
// split 选择排序轴后用它计算中心;axis 只由内部逻辑产生 0/1/2,不接受任意外部下标。
fn center(self, axis: usize) -> f32 {
// 临时三分量数组便于按轴索引,避免为 x/y/z 写三套排序表达式。
let min = [self.min.x, self.min.y, self.min.z];
let max = [self.max.x, self.max.y, self.max.z];
(min[axis] + max[axis]) / 2.
}
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
// Index::ray 同时用它测试树节点和叶内实例;返回值是射线参数下界,不是精确表面交点。
fn ray(self, ray: Ray) -> Option<f32> {
// slab 算法逐轴交区间;near 从零开始,只保留射线前方,起点在盒内时返回零。
let mut near = 0f32;
let mut far = f32::INFINITY;
// 每个轴给出一个可行射线参数区间,逐轴求交后才得到穿过整个盒子的区间。
for (origin, direction, min, max) in [
(ray.origin.x, ray.direction.x, self.min.x, self.max.x),
(ray.origin.y, ray.direction.y, self.min.y, self.max.y),
@@ -75,29 +85,35 @@ impl Bounds {
] {
// 近乎平行该轴时不做除法;起点在 slab 外即可拒绝,在 slab 内则该轴不收窄距离。
if direction.abs() < 1e-12 {
// 近乎平行且起点在该轴两面之外时,射线无法进入盒子;其余轴不必继续测试。
if origin < min || origin > max {
return None;
}
continue;
}
// 求该轴两边界的射线参数,方向为负时 a/b 次序会反转,下面统一取 min/max。
let a = (min - origin) / direction;
let b = (max - origin) / direction;
// 方向可为负,先排序该轴进入/退出距离,再与之前各轴区间求交。
near = near.max(a.min(b));
far = far.min(a.max(b));
// 三个轴的参数交集为空时拒绝;far==near 的擦边仍保留,交给精确求交处理。
if far < near {
return None;
}
}
// 起点在盒内时 near 保持 0;View::pick 将该下界与已有最近三角形距离比较来提前结束。
Some(near)
}
/// 检查八角是否全落在任一视锥平面外侧;返回保守可见性,不代表真实三角形可见。
// Index::visible 对节点和叶内实例使用同一保守测试;相机基和六平面由查询入口统一准备。
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,
Vec3::new(
// 索引最低位选 x,接着两位选 y/z 的 min 或 max,组合恰好覆盖八个角点。
if index & 1 == 0 {
self.min.x
} else {
@@ -126,11 +142,14 @@ impl Bounds {
}
#[derive(Debug)]
/// BVH 叶或分支;叶保存实例索引,分支保存两个子节点索引。
// 节点保存在 Index.nodes 连续数组,分支通过下标连接子树,查询不持有递归引用。
enum Node {
// 少量实例的叶节点,items 是原 Scene.meshes 下标,并非几何中的三角形下标。
Leaf {
bounds: Bounds,
items: Vec<usize>,
},
// 内部节点合并两个子树的界;命中父界后再把左右子节点加入查询栈。
Branch {
bounds: Bounds,
left: usize,
@@ -139,7 +158,9 @@ enum Node {
}
impl Node {
/// 读取叶/分支统一的包围盒,隐藏节点种类差异。
// ray/visible 不必分别处理叶和分支的界,先统一测试,再决定是否展开子节点。
fn bounds(&self) -> Bounds {
// 两种节点共享同名 bounds 字段且 Bounds 可复制,返回值无需借用整棵树。
match self {
Self::Leaf { bounds, .. } | Self::Branch { bounds, .. } => *bounds,
}
@@ -149,32 +170,41 @@ impl Node {
#[derive(Debug, Default)]
/// 由不可变场景建立的包围盒树,供相机视锥和射线查询复用。
pub struct Index {
// 长度始终等于构建时网格数;空网格对应 None,不入树但保留原场景下标。
bounds: Vec<Option<Bounds>>,
// 节点数组先放父占位再递归放子节点,子树返回后补写父分支的左右下标。
nodes: Vec<Node>,
// 空输入或全空网格时没有根,查询从空栈开始自然返回空候选。
root: Option<usize>,
}
impl Index {
/// 用变换后的网格顶点建立世界 AABB 与 BVH;忽略空网格,树内仅保存原生几何下标。
// model/scene3d 在世界数据变化时建立缓存;View::pick/render_aspect 无匹配缓存时也调用此函数临时构建。
pub fn build(meshes: &[Mesh]) -> Self {
// 保留与原网格下标等长的包围盒数组;items 仅收集非空网格,最终候选仍指向原场景下标。
let bounds: Vec<_> = meshes.iter().map(Bounds::mesh).collect();
// 收集带有效界的原实例下标供后续排序分割;不复制几何,也不重排外部 Scene.meshes。
let mut items: Vec<_> = bounds
.iter()
.enumerate()
.filter_map(|(index, bounds)| bounds.map(|_| index))
.collect();
// 先保存完整 bounds 表,节点和根从空开始,递归分割只改变索引自身。
let mut index = Self {
bounds,
nodes: Vec::new(),
root: None,
};
// split 需要非空切片才能 reduce/unwrap;空集合直接保留 root=None。
if !items.is_empty() {
index.root = Some(index.split(&mut items));
}
index
}
/// 按包围盒最长轴的中心排序并递归二分,叶节点保留少量网格候选。
// build 首次调用,随后自身递归;每次切片保证非空,返回节点数组中的稳定下标。
fn split(&mut self, items: &mut [usize]) -> usize {
// 把当前切片内所有非空实例界合并为节点界;items 来自过滤后的集合,因此 unwrap 有前提。
let bounds = items
.iter()
.map(|&index| self.bounds[index].unwrap())
@@ -193,7 +223,9 @@ impl Index {
items: items.to_vec(),
};
} else {
// 较大的集合按合并包围盒的最长轴切分,避免总沿固定轴分割拉长的场景。
let extent = bounds.max - bounds.min;
// 相等时优先 x 再 y,选择只影响树形;最终查询排序恢复外部可见顺序。
let axis = if extent.x >= extent.y && extent.x >= extent.z {
0
} else if extent.y >= extent.z {
@@ -201,6 +233,7 @@ impl Index {
} else {
2
};
// 按选定轴的中心排序,total_cmp 提供确定的浮点比较规则;此处不按三角形或材质排序。
items.sort_unstable_by(|&a, &b| {
self.bounds[a]
.unwrap()
@@ -209,9 +242,11 @@ impl Index {
});
// 两半均非空,即使所有中心重合也能递减集合规模,保证递归终止。
let mid = items.len() / 2;
// 把可变切片分成互不重叠两半供递归使用,变量随后被各自节点下标遮蔽。
let (left, right) = items.split_at_mut(mid);
let left = self.split(left);
let right = self.split(right);
// 两个子树都已存在,再用同一父下标覆盖占位节点,连接左右子树。
self.nodes[index] = Node::Branch {
bounds,
left,
@@ -221,28 +256,38 @@ impl Index {
index
}
/// 检查索引覆盖的网格数量是否与场景相符;缺失缓存时允许重建。
// 这里只校验数量,不验证顶点或 world 变换;调用方必须在几何改变后重建索引。
// View 在查询前用它选择缓存或临时构建;不验证顶点或 world 变换,几何改变后须由快照层重建。
pub fn matches(&self, meshes: usize) -> bool {
// 仅为缺失/长度失配提供快速判断,不是网格内容指纹;相同数量下改 world/顶点仍须由不可变快照生产方重建。
self.bounds.len() == meshes
}
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
// View::pick 消费排序候选,再逐三角形求交与裁剪;本函数不检查相机 near/far。
pub fn ray(&self, ray: Ray) -> Vec<(f32, usize)> {
// 此处遍历后一次性返回全部盒候选;View::pick 对排序结果做精确求交和距离提前停止,不是让 BVH 直接返回最近表面。
// 空树的栈为空;显式栈避免查询递归,先测分支包围盒,未命中时整棵子树跳过。
let mut stack: Vec<_> = self.root.into_iter().collect();
// 收集 (盒进入参数, 原实例下标),遍历结束后统一排序以支持 pick 的距离提前终止。
let mut output = Vec::new();
// 深度优先弹出待查节点;树构建时已保证子节点下标有效,不需要在循环中重新验证。
while let Some(index) = stack.pop() {
// 节点整体不与射线相交,叶内实例或分支下的所有后代都可跳过。
if self.nodes[index].bounds().ray(ray).is_none() {
continue;
}
// 叶逐实例精测包围盒,分支继续展开;节点界命中不等于其中每个实例界都命中。
match &self.nodes[index] {
Node::Leaf { items, .. } => {
// 叶的合并界可能比单实例界大,因此逐项测试以减少最终三角形候选。
for &item in items {
// 只把实际穿过的实例盒加入结果,保留原下标用于查回 Scene.meshes。
if let Some(distance) = self.bounds[item].unwrap().ray(ray) {
output.push((distance, item));
}
}
}
Node::Branch { left, right, .. } => {
// 栈后进先出,右子树会先遍历;下方排序消除遍历顺序对候选次序的影响。
stack.push(*left);
stack.push(*right);
}
@@ -253,25 +298,32 @@ impl Index {
output
}
/// 用相机六平面保守判断包围盒可见性,返回保持原场景顺序的候选。
// render_aspect 用于剔除离屏网格,panel 纹理也只对返回候选生成,节约中间表面预算。
pub fn visible(&self, camera: Camera, aspect: f32) -> Vec<usize> {
// 相机无法建立有效正交基时没有可靠视锥,返回空候选,不继续使用无效投影。
let Some(basis) = camera.basis() else {
return Vec::new();
};
// 与 render_aspect 的三角形裁剪共享平面公式,确保宽阶段不会使用另一套投影约定。
let planes = super::frustum_planes(camera, aspect);
// 空根转换为空迭代器,非空根变成只有一个元素的初始查询栈。
let mut stack: Vec<_> = self.root.into_iter().collect();
// 输出原实例下标,最后升序排列以维持光栅化的同深度先绘制优先规则。
let mut output = Vec::new();
// 先测试节点合并界;完全处于任一视锥平面外的子树无需逐实例遍历。
while let Some(index) = stack.pop() {
if !self.nodes[index].bounds().visible(camera, basis, &planes) {
continue;
}
match &self.nodes[index] {
// 叶内继续逐实例做保守可见性筛选,不能把整个叶直接视为可见。
Node::Leaf { items, .. } => output.extend(
items
.iter()
.copied()
.filter(|item| self.bounds[*item].unwrap().visible(camera, basis, &planes)),
),
// 分支只负责加入后续待测节点,真正可见实例仍在叶中确认。
Node::Branch { left, right, .. } => {
stack.push(*left);
stack.push(*right);
+54
View File
@@ -1,5 +1,6 @@
//! 逐 Mount 的滚动状态、裁剪和输入目标选择。
//! 共享组件的不同出现保留独立偏移。嵌套滚动先选择可接收位移的内层,偏移在实际内容范围内钳制。
//! UiBridge 完成布局后先调用 project,viewport::project 再附相机/面板变换;Mount 方法调用 set/into_view 只写请求,等待下一轮投影。
//!
//! Per-occurrence scroll state and a single geometry/clip projection shared by
//! native paint, source paint and input. No business object is copied.
@@ -24,6 +25,7 @@ pub enum Axis {
}
impl Axis {
/// 返回内部枚举或对象对应的稳定源名称。
// model/block 的属性读取以此输出源文本,名称与 parse 接受的值保持对应。
pub fn name(self) -> &'static str {
match self {
Self::Both => "both",
@@ -32,6 +34,7 @@ impl Axis {
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
// model/block 写入 ScrollBlock.axis 时调用;未知字符串返回参数错误,不降级成默认双轴。
pub fn parse(name: &str) -> Result<Self, VmError> {
match name {
"both" => Ok(Self::Both),
@@ -43,16 +46,19 @@ impl Axis {
}
}
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
// project 计算可滚范围时调用;禁用水平轴意味着该轴上限为零,旧偏移也会被投影钳到零。
pub fn horizontal(self) -> bool {
self != Self::Vertical
}
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
// project 用于纵轴范围,Both 同时允许两个轴,Horizontal 只禁用此纵轴。
pub fn vertical(self) -> bool {
self != Self::Horizontal
}
}
/// 求两个裁剪矩形的交集,空区域仍保持合法非负尺寸。
// project 的普通 clip_children 和滚动视口共同使用,裁剪数据随后供绘制与命中复用。
fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
// 输入采用 [左, 上, 宽, 高]。交集左上取较大值,右下取较小值;
// 右/下边界再钳到左/上边界,保证完全错开时输出零面积而非负尺寸。
@@ -66,21 +72,26 @@ fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
]
}
/// 沿出现树累计滚动偏移和裁剪;遇到独立场景视口时按其有限边界重新建立投影范围。
// ui_bridge 在完成 Mount 布局后调用,随后 viewport::project 填充相机变换;此处只完成滚动和原生绘制投影。
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();
// 显式栈遍历不可变视觉树,nodes 按业务 Block 身份索引,只存语义描述而非每次出现的偏移。
let mut work = vec![root];
// 记录节点并展开子节点,后续出现用 block_id 查回同一业务视觉;最终绘制顺序来自 frame.mounts。
while let Some(node) = work.pop() {
nodes.insert(node.block_id.as_str(), node);
work.extend(node.children().iter().map(|node| node.as_ref()));
}
// Mount ID 映射到当前数组位置,后面的逆向范围汇总需要按 parent ID 直接访问父项。
let indices: HashMap<_, _> = frame
.mounts
.iter()
.enumerate()
.map(|(i, m)| (m.id, i))
.collect();
// 与 frame.mounts 等长的布尔表,每次出现都单独标记是否滚动容器,便于两个阶段共享判断。
let scrolling: Vec<_> = frame
.mounts
.iter()
@@ -92,6 +103,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
.mounts
.iter()
.map(|m| {
// 原始布局右下界作为范围起点;只有 Scroll 扣 inset,避免空内容凭 padding 产生溢出。
let node = nodes[m.block_id.as_str()];
let inset = if matches!(node.kind, UiKind::Scroll { .. }) {
layout::insets(node)
@@ -103,9 +115,12 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
.collect();
// Mount 按父先子后排列,反向扫描才能先汇总子树,再将边界向父级传播。
for i in (0..frame.mounts.len()).rev() {
// 此时所有 Mount 仍是未减滚动量的布局位置,汇总不能混入第二阶段的投影后坐标。
let m = &frame.mounts[i];
// 根出现没有父级,不向外传播;其余出现要求 parent 存在于本帧的 indices 表。
if let Some(parent) = m.parent {
let parent = indices[&parent];
// 二维/三维场景视口都以有限布局盒隔离世界内容范围,不把内部世界边界加到外层滚动上限。
if matches!(
nodes[frame.mounts[parent].block_id.as_str()].kind,
UiKind::CanvasViewport { .. } | UiKind::Viewport3D { .. }
@@ -126,17 +141,22 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
// 第二阶段从父到子投影。上下文保存祖先累计位移和裁剪,而非当前节点本身的偏移。
// 重建绘制命令及其平行元数据,保证显示、命中与裁剪使用同一套投影坐标。
let mut contexts = HashMap::<u64, (f32, f32, Option<[f32; 4]>)>::new();
// 重建这一轮原生命令;command_views 先清空,后续 viewport::project 根据滚动后的几何建立变换元数据。
frame.commands.clear();
frame.command_clips.clear();
frame.command_mounts.clear();
frame.command_views.clear();
// 父项先处理,子项才能从 contexts 取到累计位移和裁剪;i 同时索引原始 extent/scrolling 表。
for (i, m) in frame.mounts.iter_mut().enumerate() {
// 无父上下文时使用零位移、无裁剪;父上下文已包含祖先各层的有效滚动量。
let (dx, dy, clip) = m
.parent
.and_then(|id| contexts.get(&id).copied())
.unwrap_or_default();
let node = nodes[m.block_id.as_str()];
// insets 含有效盒模型边距,这里的右/下分量加回内容范围,以得到视口外的实际溢出。
let padding = layout::insets(node);
// 只有 Scroll 的配置决定限轴;非滚动节点暂用 Both 计算辅助值,但不会发布 m.scroll。
let axis = match nodes[m.block_id.as_str()].kind {
UiKind::Scroll { axis, .. } => axis,
_ => Axis::Both,
@@ -158,9 +178,11 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
m.x -= dx;
m.y -= dy;
m.clip = clip;
// 先继承祖先上下文,当前层的子裁剪和自身滚动随后只写入这个 child,不能影响当前容器边框。
let mut child = (dx, dy, clip);
// 普通裁剪使用边框内侧矩形,并与祖先裁剪求交,不能扩大祖先允许的区域。
if node.clip_children {
// clip_children 的区域从边框内缘开始,不把边框本身裁掉;各轴尺寸钳到非负。
let border = node.border;
let viewport = [
m.x + border.left.min(m.width),
@@ -173,11 +195,14 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
.unwrap_or(viewport),
);
}
// 仅滚动容器读取托管 Mount 的独立偏移,普通节点不访问该状态,也不产生滚动协议边界。
if scrolling[i] {
// 按出现 ID 查活动弱身份,缺失表示帧与挂载表不一致,不能拿同 Block 的其他出现代替。
let weak = table
.active
.get(&m.id)
.ok_or_else(|| anyhow::anyhow!("missing scroll Mount"))?;
// 升级后得到临时拥有根,payload 读取完成即显式 release,不把 VM 对象带入纯原生帧。
let owner = vm
.upgrade(*weak)?
.ok_or_else(|| anyhow::anyhow!("expired scroll Mount"))?;
@@ -192,7 +217,9 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
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]]);
// 滚动内容还受整个容器盒裁剪;如果前面 clip_children 已缩到边框内,则继续与它求交。
let viewport = [m.x, m.y, m.width, m.height];
// 子项累计位移加上本容器的有效偏移,同时收窄裁剪;容器自身 m.x/y 不减自己的 x/y。
child = (
dx + x,
dy + y,
@@ -208,7 +235,9 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
if matches!(node.kind, UiKind::Viewport3D { .. }) {
child = (0., 0., None);
}
// 保存当前出现对子项的最终上下文;遇到 3D 视口时已重置为其独立二维内容空间。
contexts.insert(m.id, child);
// 按当前出现的滚动后位置生成默认命令,同步附带 clip 与 owner;后续源 paint 可整体替换这些默认命令。
for command in layout::paint_commands(
nodes[m.block_id.as_str()],
m.x,
@@ -223,6 +252,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
frame.command_mounts.push(m.id);
}
}
// 整帧重建后检查一万条上限,失败由上层放弃草稿,不在这里发布超预算帧。
if frame.commands.len() > 10_000 {
bail!("scroll paint command budget exceeded");
}
@@ -230,7 +260,9 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
}
/// 把源滚动值解码为有限坐标,阻止非数或越界值进入投影。
// 除滚动协议外,Mount 坐标查询、盒模型及场景参数也复用这一有限数值转换;具体正负规则由调用边界补充。
pub fn coordinate(value: &Value) -> Result<f32, VmError> {
// 只接受 Number,本函数不对 Text 做隐式数值解析;后面的字符串转换来自 Number 自身表示。
let Value::Number(value) = value else {
return Err(VmError::InvalidArgument(
"scroll coordinates must be numbers",
@@ -241,12 +273,14 @@ pub fn coordinate(value: &Value) -> Result<f32, VmError> {
.to_string()
.parse::<f32>()
.map_err(|_| VmError::InvalidArgument("invalid scroll coordinate"))?;
// f32 转换可能溢成无穷,需在转换后同时检查有限性与绝对值预算。
if !n.is_finite() || n.abs() > 1_000_000.0 {
return Err(VmError::InvalidArgument("scroll coordinate exceeds limits"));
}
Ok(n)
}
/// 更新指定出现的滚动偏移,并钳制到本轴实际可滚动的内容范围。
// model/mount 的 scrollTo/scrollBy 和 into_view 共用入口,owner 指当前出现,desktop 为所属窗口弱身份。
pub fn set(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
@@ -261,10 +295,12 @@ pub fn set(
"scroll requires committed attached Mount",
));
}
// geometry.scroll 保存上次提交的 [有效x,有效y,最大x,最大y],缺失表示该出现不是可滚容器。
let bounds = mount
.geometry
.scroll
.ok_or(VmError::InvalidArgument("Mount is not scrollable"))?;
// 相对操作基于 Mount 保存的原始偏移加增量,绝对操作直接使用请求值,随后统一钳到已提交范围。
let next = if relative {
[
mount.scroll_offset[0] + point[0],
@@ -274,6 +310,8 @@ pub fn set(
point
};
mount.scroll_offset = [next[0].clamp(0.0, bounds[2]), next[1].clamp(0.0, bounds[3])];
// 这里使用上次提交的滚动上限;若业务内容随后缩短,新一轮 project 会再次钳制,几何提交再保存新的有效偏移。
// 写入前确认窗口仍存活,升级根由 NativeContext 临时管理;失效时不替换 Mount 偏移。
let desktop = cx
.upgrade(desktop)?
.ok_or(VmError::InvalidArgument("Mount window expired"))?;
@@ -283,11 +321,13 @@ pub fn set(
cx.set_attr(desktop, "scrollRequested", Value::Bool(true))
}
/// 沿祖先滚动容器调整偏移,让目标出现进入可见范围。
// model/mount 的 scrollIntoView 以及焦点相关路径调用,从最近祖先向外调整可滚容器。
pub fn into_view(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
desktop: WeakObjectRef,
) -> Result<(), VmError> {
// 先克隆目标描述以跨后续写入使用,读取目标几何后只遍历父链,不修改业务 Block。
let mount = cx.store().payload::<Mount>(owner, "Mount")?.clone();
if !mount.mounted || mount.layout.is_some() {
return Err(VmError::InvalidArgument(
@@ -301,8 +341,11 @@ pub fn into_view(
mount.geometry.width,
mount.geometry.height,
];
// 父关系保存弱身份,遍历时逐个升级;不会因一次滚动操作永久强持有整条祖先链。
let mut parent = mount.parent;
// 每层先记录下一父身份,再按当前祖先类型处理,保证本层 set 替换 payload 不影响遍历游标。
while let Some(weak) = parent {
// 父身份过期时停止外层调整,避免把已失效出现当成仍挂接的滚动容器。
let Some(owner) = cx.upgrade(weak)? else {
break;
};
@@ -317,22 +360,27 @@ pub fn into_view(
mount.geometry.height,
];
}
// 只有已提交几何带滚动范围的祖先需要调整,普通祖先只用于继续沿父链前进。
if let Some(offset) = mount.geometry.scroll {
// 目标起点在视口前则向前对齐;否则只补足末端溢出,已完全可见时返回零。
let delta = |start: f32, length: f32, viewport: f32, size: f32| {
// 目标起点在视口前方时优先对齐起点;目标比视口大时不会同时追求两端可见。
if start < viewport {
start - viewport
} else {
(start + length - viewport - size).max(0.0)
}
};
// 分别计算两轴最小请求位移,再加到当前有效 offset 并按各轴最大范围钳制。
let dx = delta(rect[0], rect[2], mount.geometry.x, mount.geometry.width);
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];
// 已完全可见或被边界钳制后不能移动时,不触发 set 的写屏障和 scrollRequested。
if x != offset[0] || y != offset[1] {
set(cx, owner, desktop, [x, y], false)?;
}
@@ -341,7 +389,10 @@ pub fn into_view(
Ok(())
}
/// 按逆出现顺序选择指针处可处理当前滚动方向的最内层容器。
// shell/app 在滚轮路由时调用,输入点已经进入页面坐标;返回出现 ID 后仍由所属 VM 执行滚动更新。
pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64> {
// shell 只借返回 ID 构造输入信封;本函数不运行源 hitTest、不校验 session/fault,也不直接修改任何滚动 owner。
// 按 ID 建立只读祖先查找,候选搜索选定最上层命中出现后只沿这一条父链移动。
let indices: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 逆序先取最上层命中的出现,再沿其祖先寻找能消费位移的滚动容器。
// 不搜索被覆盖的兄弟容器,内层到边界后才交给外层滚动。
@@ -350,6 +401,7 @@ pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64>
.iter()
.rev()
.find(|mount| mount.contains(point));
// 内层先尝试消费;该层无滚动能力或当前方向已到边界时,才检查父容器。
while let Some(mount) = current {
// 只有至少一轴钳制后真正改变偏移才接收此次输入;没有任何容器可移动则返回 None。
if mount.scroll.is_some_and(|s| {
@@ -357,6 +409,8 @@ pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64>
}) {
return Some(mount.id);
}
// 只向命中出现的祖先回退,不改为搜索下层兄弟,也不把一次 delta 拆给多个容器。
// 一旦内层任一轴能移动就选它;未消费的另一轴或超出边界的剩余量不会在本次选择中继续分发给外层。
current = mount.parent.and_then(|id| indices.get(&id).copied());
}
None
+83
View File
@@ -1,6 +1,9 @@
//! 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。
//! RenderScope 约束递归深度;累计像素预算限制嵌套 3D 和世界 UI 的中间表面分配。错误向上传递,不把半幅结果当成成功帧呈现。
//!
//! shell::app 的 redraw 在取得 softbuffer 后调用 render,成功后才 present;scene3d::panel 也调用它生成离屏 UI 纹理。
//! 本模块不访问 VM,也不重新运行源 paint,只执行目标像素坐标下的 DrawCommand;文字覆盖回调同样同步写入当前 Canvas。
//! render 原地改 target,不提供失败回滚;调用方必须尊重 Result,在错误时停止呈现或终止纹理生成,不把残留像素当作完整新帧。
use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
@@ -8,6 +11,7 @@ use crate::{
};
/// 显示列表的 CPU 执行入口,错误在原生 present 前向上传递。
// shell 的窗口呈现和 scene3d::panel 的离屏纹理都使用同一执行器,输入是已转换到目标像素空间的 DisplayList。
pub struct SoftwareRenderer;
// 预算按渲染线程维护,递归 3D/面板渲染共用同一组计数;不同线程之间不共享额度。
@@ -19,8 +23,10 @@ thread_local! {
pub(crate) struct RenderScope;
impl RenderScope {
/// 进入一次嵌套渲染并检查深度上限;最外层调用重新开始累计像素预算。
// SoftwareRenderer::render 与 scene3d::render_aspect 每次进入都建立作用域,面板中的嵌套视口继续累计。
pub fn enter() -> Result<Self, &'static str> {
RENDER_DEPTH.with(|depth| {
// 先读取进入前深度;拒绝分支不递增,因此不会产生没有配对 Guard 的计数。
let current = depth.get();
// 拒绝第 19 层进入,不先增加深度;成功返回的 RenderScope 析构时才对应减一。
if current >= 18 {
@@ -30,6 +36,7 @@ impl RenderScope {
if current == 0 {
PIXEL_UNITS.with(|pixels| pixels.set(0));
}
// 仅成功进入后递增;返回的 RenderScope 析构时归还这一层。
depth.set(current + 1);
Ok(Self)
})
@@ -42,10 +49,12 @@ impl Drop for RenderScope {
}
}
/// 累加本帧中间表面工作量,超过预算立即返回错误。
// 3D 颜色/深度缓冲和 UI 面板纹理分配前调用;普通目标缓冲由外层分配,不在这里自动计入。
pub(crate) fn reserve_pixels(units: usize) -> Result<(), &'static str> {
PIXEL_UNITS.with(|pixels| {
// 累计申请工作量而非当前存活像素;释放一个中间表面不会返还额度,饱和加法避免溢出绕过限制。
let next = pixels.get().saturating_add(units);
// 超额时不提交 next,错误向上传递结束本次离屏绘制;预算不是可释放再循环使用的内存额度。
if next > 16_777_216 {
return Err("nested render pixel budget exceeded");
}
@@ -56,6 +65,7 @@ pub(crate) fn reserve_pixels(units: usize) -> Result<(), &'static str> {
impl SoftwareRenderer {
/// 顺序执行显示列表并维护裁剪栈;3D 和面板可递归进入同一渲染预算域。
// 外部窗口和离屏面板调用;此函数不重新验证全部命令,也不负责创建或调整 target 长度。
pub fn render(
list: &DisplayList,
target: &mut [u32],
@@ -64,15 +74,19 @@ impl SoftwareRenderer {
) -> Result<(), &'static str> {
// RAII 保护让成功、提前返回和错误传播都恢复深度;target 长度须由调用方保证至少 width×height。
let _scope = RenderScope::enter()?;
// 借用传入像素缓冲建立当前画布,初始 clip 是整张目标;后续命令只修改画布状态或像素。
let mut canvas = Canvas {
pixels: target,
width,
height,
clip: Rect::new(0.0, 0.0, width as f32, height as f32),
};
// 保存 PushClip 之前的裁剪,PopClip 恢复上一层;栈只在本次 render 内存活。
let mut clips = Vec::new();
// 严格按列表顺序执行,后续不透明命令覆盖已有像素,透明命令与当时像素混合。
for command in list.commands() {
// 图像和 3D 各有专用采样路径,其余变体转交 Canvas;坐标不再经过布局或页面相机。
match command {
DrawCommand::Image {
image,
@@ -82,36 +96,45 @@ impl SoftwareRenderer {
opacity,
tint,
} => {
// 图像旋转与透明度在 draw_image 中处理;这里仅解引用命令字段,保持共享图像资源。
canvas.draw_image(image, *origin, *rect, *angle, *opacity, *tint);
}
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;
// 右界同时受命令矩形、当前 clip 和目标缓冲宽度限制,ceil 给出保守的整数扫描末端。
let right = (rect.x + rect.width)
.min(canvas.clip.x + canvas.clip.width)
.min(width as f32)
.ceil()
.max(0.) as u32;
// 底边界采用与右界相同的规则;后面的空交集检查同时防止不可见/退化矩形进入像素循环。
let bottom = (rect.y + rect.height)
.min(canvas.clip.y + canvas.clip.height)
.min(height as f32)
.ceil()
.max(0.) as u32;
// 交集为空或原矩形退化时直接跳过,避免申请无用 3D 表面并发生归一化除零。
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.);
// 按同一 lod 求两个输出轴并向上取整至少一个像素;rect 宽高仍决定相机 aspect。
let w = (rect.width * lod).ceil().max(1.) as u32;
let h = (rect.height * lod).ceil().max(1.) as u32;
// 获取不可变缓存像素,光栅化或预算错误立即传播;已经写入 target 的其他命令不会自动回滚。
let surface = crate::scene3d::surface(view, w, h, rect.width / rect.height)?;
// 只遍历裁剪可见的目标行列,来源坐标按完整 rect 映射而不是按裁剪交集重新拉伸。
for y in top..bottom {
// 目标像素中心在低分辨率 3D 表面上取最近邻,LOD 改变采样密度而非布局位置。
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;
// v 对应源表面的行索引,仍从完整 rect 原点映射,裁剪不会把剩余区域重新拉伸成整张图。
let v = (((y as f32 + 0.5 - rect.y) / rect.height) * h as f32)
.clamp(0., (h - 1) as f32)
as u32;
@@ -124,6 +147,7 @@ impl SoftwareRenderer {
DrawCommand::PushClip(rect) => {
// 先保存旧裁剪,再求交;嵌套 PushClip 只能收窄,矩形分离时宽高归零。
clips.push(canvas.clip);
// 以两个左上边界较大值为交集起点,右下边界较小值再钳到起点,确保非负尺寸。
let x = canvas.clip.x.max(rect.x);
let y = canvas.clip.y.max(rect.y);
canvas.clip = Rect::new(
@@ -145,6 +169,7 @@ impl SoftwareRenderer {
canvas.clip = clip;
}
}
// Clear 忽略 clip 填满整个切片;生成显示列表时应避免在局部子树中随意插入清屏。
DrawCommand::Clear(color) => canvas.clear(*color),
DrawCommand::FillRoundedRect {
rect,
@@ -177,15 +202,18 @@ impl SoftwareRenderer {
/// 借用目标像素切片并维护当前裁剪;图形/文字受裁剪,Clear 明确填满整个目标。
struct Canvas<'a> {
// 原地写入的不透明 0xRRGGBB 目标,生命周期只覆盖一次 render 调用。
pixels: &'a mut [u32],
width: u32,
height: u32,
// 当前目标空间矩形裁剪;图形扫描先收窄范围,blend_pixel 再检查最终像素坐标。
clip: Rect,
}
impl Canvas<'_> {
/// Inverse-map only clipped destination pixels. Images use straight RGBA;
/// bilinear interpolation happens in premultiplied alpha to avoid dark halos.
// render 的 Image 分支调用;对目标可见像素做逆旋转映射,避免正向散点产生空洞。
fn draw_image(
&mut self,
image: &crate::render::image::Image,
@@ -195,9 +223,11 @@ impl Canvas<'_> {
opacity: f32,
tint: Option<Color>,
) {
// 退化尺寸或非有限旋转/透明度没有可用映射;直接返回且不改变像素。
if rect.width <= 0. || rect.height <= 0. || !angle.is_finite() || !opacity.is_finite() {
return;
}
// angle 是弧度;四个局部矩形角旋转后求目标包围盒,用于限制扫描范围。
let (sin, cos) = angle.sin_cos();
let corners = [
(rect.x, rect.y),
@@ -205,26 +235,36 @@ impl Canvas<'_> {
(rect.x, rect.y + rect.height),
(rect.x + rect.width, rect.y + rect.height),
];
// 最小值从正无穷、最大值从负无穷初始化,确保首个角点建立真实范围,不误含原点。
let mut lo = Point::new(f32::INFINITY, f32::INFINITY);
let mut hi = Point::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
// 绕命令 origin 旋转局部角点;rect.x/y 仍是相对该 origin 的偏移。
for (x, y) in corners {
let px = origin.x + cos * x - sin * y;
// 同一旋转矩阵的纵分量,与 px 一起更新包围盒;原图像资源和命令 rect 都保持只读。
let py = origin.y + sin * x + cos * y;
lo.x = lo.x.min(px);
lo.y = lo.y.min(py);
hi.x = hi.x.max(px);
hi.y = hi.y.max(py);
}
// 旋转后的轴对齐包围盒还可能包含矩形外部像素,下面的 u/v 范围判断继续排除。
let (left, top, right, bottom) =
self.clamped_bounds(Rect::new(lo.x, lo.y, hi.x - lo.x, hi.y - lo.y));
// 只扫描包围盒与当前 clip/画布的交集,不按原始图像分辨率遍历。
for y in top..bottom {
for x in left..right {
// 目标像素中心先减 origin,再乘反向旋转矩阵,恢复局部矩形坐标。
let dx = x as f32 + 0.5 - origin.x;
let dy = y as f32 + 0.5 - origin.y;
// 减去局部 rect 原点并除宽高,得到采样函数接受的归一化纹理位置。
let u = (cos * dx + sin * dy - rect.x) / rect.width;
let v = (-sin * dx + cos * dy - rect.y) / rect.height;
// 旋转包围盒内但局部矩形外的点不能采样;右/下边界为开区间。
if (0. ..1.).contains(&u) && (0. ..1.).contains(&v) {
// 图像采样内部在预乘 alpha 空间插值,返回非预乘颜色供 blend_pixel 合成;整体透明度钳到 [0,1]。
let mut sample = image.sample(u, v, opacity.clamp(0., 1.));
// 有 tint 时替换 RGB 而保留采样 alpha,把图像当作覆盖遮罩使用,不逐通道乘原图颜色。
if let Some(tint) = tint {
sample.red = tint.red;
sample.green = tint.green;
@@ -237,18 +277,23 @@ impl Canvas<'_> {
}
/// 用不透明 RGB 填满目标切片;此命令忽略当前裁剪和输入颜色 alpha。
// render 的 Clear 分支调用;target 若大于 width×height,这里也会填满额外切片,因此调用方应传精确缓冲。
fn clear(&mut self, color: Color) {
// Clear 填整个提供的目标切片,不受当前 clip 约束;pack 忽略 alpha,清屏色作为不透明 RGB。
self.pixels.fill(pack(color));
}
/// 只遍历裁剪后的矩形像素,按圆角覆盖决定填充区域。
// render 的 FillRoundedRect 分支调用;圆角内像素交给 blend_pixel,统一透明度与边界判断。
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);
// bounds 顺序为 left/top/right/bottom,使用左闭右开的整数扫描区间。
for y in bounds.1..bounds.3 {
for x in bounds.0..bounds.2 {
// 只有落在圆角形状内才混合;clip 外的候选即使被保守范围包含也会被 blend_pixel 拒绝。
if rounded_rect_contains(rect, radius, x as f32 + 0.5, y as f32 + 0.5) {
self.blend_pixel(x, y, color);
}
@@ -257,10 +302,12 @@ impl Canvas<'_> {
}
/// 用外轮廓与内轮廓的差绘制边框,避免把边框当成整个矩形填充。
// render 的 StrokeRoundedRect 分支调用;边框占据外圆角形状减去内圆角形状的区域。
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(
rect.x + width,
@@ -268,12 +315,16 @@ impl Canvas<'_> {
(rect.width - width * 2.0).max(0.0),
(rect.height - width * 2.0).max(0.0),
);
// 内轮廓与缩进矩形配套;宽描边把内区缩到零时,内轮廓判定返回 false。
let inner_radius = (outer_radius - width).max(0.0);
// 只扫描外框包围盒,逐像素以同一中心采样比较外内形状。
for y in bounds.1..bounds.3 {
for x in bounds.0..bounds.2 {
// 像素中心坐标复用于外轮廓和内轮廓,避免两次判定使用不同采样约定。
let px = x as f32 + 0.5;
let py = y as f32 + 0.5;
// 外形内且内形外才属于描边;内部填充区域保持已有目标颜色。
if rounded_rect_contains(rect, outer_radius, px, py)
&& !rounded_rect_contains(inner, inner_radius, px, py)
{
@@ -284,29 +335,35 @@ impl Canvas<'_> {
}
/// 把文字原点转换为局部裁剪区域,交给有界字形绘制,再混合到目标缓冲。
// render 的 Text 分支调用;字形覆盖由 text 模块产生,本模块只处理目标位置、clip 和混合。
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;
// draw_clipped 接受字形局部坐标的 clip,减去取整后的文字原点,回调再加回同一原点,避免两次转换不一致。
let clip = Rect::new(
self.clip.x - x as f32,
self.clip.y - y as f32,
self.clip.width,
self.clip.height,
);
// None 表示没有段落换行宽度;回调收到字形局部像素,平移后仍经 blend_pixel 检查目标边界。
text::draw_clipped(value, style, color, None, clip, |dx, dy, pixel| {
self.blend_pixel(x + dx, y + dy, pixel)
});
}
/// 把段落矩形与当前裁剪相交,按受限宽度绘制,再恢复外层裁剪。
// render 的 WrappedText 分支调用;与普通文字共用字形路径,但临时加上段落框裁剪。
fn draw_wrapped_text(&mut self, rect: Rect, value: &str, style: TextStyle, color: Color) {
// 空段落框直接跳过,不进入文字排版也不修改当前 clip。
if rect.width <= 0.0 || rect.height <= 0.0 {
return;
}
// 段落文本临时收窄 clip 到文本框;下方调用完成后恢复 old,后续命令不能继承段落私有裁剪。
let old = self.clip;
let x = old.x.max(rect.x);
// x/y 是祖先裁剪与段落框交集的左上角,不替代原段落的排版原点;下面 left/top 仍从 rect 取值。
let y = old.y.max(rect.y);
self.clip = Rect::new(
x,
@@ -314,8 +371,10 @@ impl Canvas<'_> {
((old.x + old.width).min(rect.x + rect.width) - x).max(0.0),
((old.y + old.height).min(rect.y + rect.height) - y).max(0.0),
);
// 段落原点对齐整数像素,随后把交集 clip 换到文字局部空间;排版宽度仍保持原框宽。
let left = rect.x.round() as i32;
let top = rect.y.round() as i32;
// 只把交集移到字形局部空间;换行宽度仍在后面单独传 rect.width,不用裁掉后的 clip.width 重排文字。
let clip = Rect::new(
self.clip.x - left as f32,
self.clip.y - top as f32,
@@ -331,10 +390,12 @@ impl Canvas<'_> {
clip,
|dx, dy, pixel| self.blend_pixel(left + dx, top + dy, pixel),
);
// 文字回调同步执行完成后恢复外层 clip,后续显示命令不继承段落框限制。
self.clip = old;
}
/// 把连续矩形转换成目标缓冲内的整数像素范围,避免在不可见区域进行光栅循环。
// 填充、描边和图像共同使用;完全分离时可产生空的整数区间,调用方 range 循环自然不执行。
fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) {
// 左/上 floor,右/下 ceil 形成保守循环范围;blend_pixel 还会按当前 clip 检查实际整数像素。
(
@@ -352,6 +413,7 @@ impl Canvas<'_> {
}
/// 在画布与当前裁剪范围内进行透明度混合,防止文字/图形写出目标缓冲。
// 文字覆盖回调、矩形和图像最终都进入此处;3D 表面分支已经自行裁剪,直接复制 RGB。
fn blend_pixel(&mut self, x: i32, y: i32, source: Color) {
// 拒绝画布外、裁剪外与全透明像素;只有通过后才计算线性下标,避免负坐标转 usize。
if x < 0
@@ -367,6 +429,7 @@ impl Canvas<'_> {
return;
}
// 边界检查通过后才将坐标转 usize,目标下标按 width 逐行计算。
let index = y as usize * self.width as usize + x as usize;
// 全不透明像素可直接打包覆盖,省去目的通道解码与乘除法。
if source.alpha == 255 {
@@ -374,6 +437,7 @@ impl Canvas<'_> {
return;
}
// 半透明路径拆开已有 RGB,再用源 alpha 与剩余权重逐通道合成;目的缓冲没有单独 alpha。
let destination = self.pixels[index];
let dst_red = (destination >> 16) & 0xff;
let dst_green = (destination >> 8) & 0xff;
@@ -381,6 +445,7 @@ impl Canvas<'_> {
// 非预乘源颜色按 source*alpha + destination*(1-alpha) 合成,目标不存 alpha,逐通道整除 255。
let alpha = source.alpha as u32;
let inverse = 255 - alpha;
// red/green/blue 各自使用同一 alpha 和余量,计算提升到 u32 后整除,避免 u8 乘法溢出;最终目标仍是不透明 RGB。
let red = (source.red as u32 * alpha + dst_red * inverse) / 255;
let green = (source.green as u32 * alpha + dst_green * inverse) / 255;
let blue = (source.blue as u32 * alpha + dst_blue * inverse) / 255;
@@ -389,10 +454,13 @@ impl Canvas<'_> {
}
/// 测试一个像素采样点是否位于圆角矩形内,角区域使用圆弧距离。
// fill/stroke 的内部形状测试,参数 x/y 是像素中心;radius<=0 退化为普通矩形判断。
fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
// 零或负面积没有内部,描边内框缩到零时也依赖这一返回值。
if rect.width <= 0.0 || rect.height <= 0.0 {
return false;
}
// 普通矩形走直接边界比较,避免不必要的圆心距离计算。
if radius <= 0.0 {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
@@ -404,12 +472,14 @@ fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
// 先减大原点可降低浮点舍入导致 clamp 上下界反转的风险。
let local_x = x - rect.x;
let local_y = y - rect.y;
// 把局部点投影到圆心可达矩形,差向量长度就是到最近圆角中心/中心带的距离。
let dx = local_x - local_x.clamp(radius, rect.width - radius);
let dy = local_y - local_y.clamp(radius, rect.height - radius);
dx * dx + dy * dy <= radius * radius
}
/// 将颜色通道打包成目标缓冲的整数像素表示。
// clear 和全不透明 blend_pixel 直接使用;半透明路径先完成合成再自行打包。
fn pack(color: Color) -> u32 {
// 像素格式为 0x00RRGGBB,高字节不存透明度;透明度已在混合阶段折算进 RGB。
(color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32
@@ -422,6 +492,7 @@ mod tests {
#[test]
/// 画长文字进入嵌套裁剪,逐像素检查多行与裁剪交集;PopClip后的蓝矩形验证后续命令不沿用旧clip。
fn wrapped_text_uses_multiple_lines_respects_nested_clips_and_restores_the_clip() {
// 依次构造白底、祖先 clip、换行文字、PopClip、零宽文字与远处蓝框,用不同命令检测裁剪状态。
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(255, 255, 255)));
list.push(DrawCommand::PushClip(Rect::new(12.0, 5.0, 20.0, 40.0)));
@@ -443,13 +514,18 @@ mod tests {
radius: 0.0,
color: Color::rgb(0, 0, 255),
});
// 目标尺寸覆盖文字和蓝框,初始化后由 Clear 建立统一白背景。
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 {
// 按目标行宽 80 读取 RGB;此测试不依赖具体字体的逐像素位图。
let color = pixels[y * 80 + x];
// 识别文字及其抗锯齿边缘,检查位置并累计第二行存在性。
if color != 0xffffff && color != 0x0000ff {
assert!((12..32).contains(&x) && (5..45).contains(&y));
second_line |= y >= 25;
@@ -457,12 +533,14 @@ mod tests {
}
}
assert!(second_line);
// 蓝框在原祖先 clip 外,成功出现说明 PopClip 与段落私有 clip 都已恢复。
assert_eq!(pixels[50 * 80 + 70], 0x0000ff);
}
#[test]
/// 在黑底4x4缓冲画2x2红矩形,核对内部像素变红、外部仍黑,覆盖最小坐标/打包路径。
fn renderer_changes_pixels_inside_a_rectangle() {
// 建立黑底加红色无圆角小矩形,隔离最基本的填充与像素坐标路径。
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
list.push(DrawCommand::FillRoundedRect {
@@ -470,6 +548,7 @@ mod tests {
radius: 0.0,
color: Color::rgb(255, 0, 0),
});
// 4×4 行主序缓冲中下标 5 是 (1,1),下标 0 在矩形外,分别断言覆盖与保留。
let mut pixels = vec![0; 16];
SoftwareRenderer::render(&list, &mut pixels, 4, 4).unwrap();
assert_eq!(pixels[5], 0x00ff0000);
@@ -479,6 +558,7 @@ mod tests {
#[test]
/// 半透明白字叠黑底必须含中间抗锯齿值且通道不超128;alpha=0的文字完全不改像素。
fn text_edges_are_antialiased_and_respect_opacity() {
// 白色字形以 alpha=128 画在黑底,抗锯齿覆盖进一步降低边缘 alpha,输出应包含中间亮度。
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
list.push(DrawCommand::Text {
@@ -487,6 +567,7 @@ mod tests {
style: TextStyle::ui(1).scaled(1.25),
color: Color::rgba(255, 255, 255, 128),
});
// 留出足够宽度覆盖混合中英文字,不通过裁剪截掉全部内容来误判透明度。
let mut pixels = vec![0; 256 * 48];
SoftwareRenderer::render(&list, &mut pixels, 256, 48).unwrap();
assert!(pixels.iter().any(|pixel| *pixel > 0));
@@ -494,6 +575,7 @@ mod tests {
assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128));
assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255))));
// 另建全透明文字列表,不依赖上一张已绘制缓冲;零 alpha 应在混合入口直接跳过。
let mut invisible = DisplayList::default();
invisible.push(DrawCommand::Text {
origin: Point::new(2.0, 2.0),
@@ -501,6 +583,7 @@ mod tests {
style: TextStyle::ui(1),
color: Color::rgba(255, 255, 255, 0),
});
// 重新分配黑底后渲染透明字,整张仍为零才证明没有残留写入。
let mut pixels = vec![0; 256 * 48];
SoftwareRenderer::render(&invisible, &mut pixels, 256, 48).unwrap();
assert!(pixels.iter().all(|pixel| *pixel == 0));
+29
View File
@@ -1,5 +1,7 @@
//! 通过真正的源/原生 hitTest 协议确定指针目标。
//! 按逆绘制顺序筛选候选,并在同一只读 VM 调用与预算中比较按下、释放目标;透明或未命中的子项可以继续回退。
//! UiBridge::dispatch 已验证窗口、发布版本和页面可见范围,再把有序 Mount 弱表交给 resolve;这里不自行选择窗口。
//! 两个点都按本次调用的当前几何和方法重新检查,不保存鼠标按下时的旧业务快照,也不在本模块派发 click。
//!
//! Resolve pointer targets in paint order through source/native hitTest methods.
//! Both press and release selectors share one read-only VM call and budget.
@@ -11,6 +13,7 @@ use endor_core::{
#[derive(Debug)]
/// 验证 hitTest 返回的是 Bool,避免把任意真值隐式当成命中。
// selector 把源 hitTest 返回值交给此适配器,之后字节码才根据 Bool 决定返回或继续查找。
struct Boolean;
impl NativeCallable for Boolean {
/// 接收 hitTest 返回的唯一值,交给 invoke 严格检查 Bool。
@@ -29,6 +32,7 @@ impl NativeCallable for Boolean {
}
#[derive(Debug)]
/// 比较按下与释放的选择结果,只有同一目标才能继续点击分发。
// Prepare 生成的驱动先执行两次 selector,最后调用此比较器;对象相等比较的是实际 Mount 身份。
struct SameTarget;
impl NativeCallable for SameTarget {
/// 接收按下与释放两次命中选择的结果。
@@ -49,6 +53,7 @@ impl NativeCallable for SameTarget {
}
}
/// 构造只读命中选择驱动所需的托管代码与函数对象。
// selector 和 Prepare 共用代码工厂;传入常量形成托管引用边,保持协议函数、实参和中间驱动存活。
fn code(
cx: &mut NativeContext<'_>,
instructions: Vec<Instruction>,
@@ -61,22 +66,27 @@ fn code(
"<tessera>",
1,
);
// 先把校验后的字节码分配到当前 NativeContext,输出函数经托管引用拥有它及其常量。
let code = cx.allocate(EndorCodeObject::new(code))?;
// 驱动只读取显式常量和局部槽,空 globals 使它不依赖或修改业务脚本全局变量。
let globals = cx.new_dict()?;
cx.new_function("pointer hitTest", code, globals)
}
/// 按逆绘制顺序构造候选 hitTest 调用,先进行几何与局部坐标筛选。
// Prepare 分别为 press/release 构造选择器;准备阶段做原生粗筛,执行阶段才调用真实 hitTest 方法。
fn selector(
cx: &mut NativeContext<'_>,
candidates: &[WeakObjectRef],
point: [f32; 2],
desktop: ObjectRef,
) -> Result<ObjectRef, VmError> {
// 所有候选共享一个 Bool 验证器,放常量槽 0,避免每个候选重复创建适配器。
let validate = cx.allocate(ContextNativeFunction::new(
"validate hitTest",
Boolean,
vec![],
))?;
// 可变指令和常量池按候选依次追加;前面候选返回 true 即提前 ReturnValue,剩余候选不会执行。
let mut instructions = Vec::new();
let mut constants = vec![Value::Object(validate)];
// 候选按绘制顺序提供,逆序使最上层先参与测试;弱引用失效视为过期输入错误。
@@ -84,6 +94,7 @@ fn selector(
let mount = cx
.upgrade(*weak)?
.ok_or(VmError::InvalidArgument("pointer Mount expired"))?;
// 克隆 Mount 描述以便后面分配源 Point;不持有跨 NativeContext 分配的 store 借用。
let target = cx.store().payload::<Mount>(mount, "Mount")?.clone();
// 先排除已卸载、非交互和几何不命中的出现,避免为明显无效候选构造源调用。
if !target.mounted
@@ -94,6 +105,7 @@ fn selector(
{
continue;
}
// 几何是本次出现的布局与投影快照,同一 Block 的其他 Mount 不能共享这里的局部坐标。
let geometry = &target.geometry;
let point = crate::geometry::Point::new(point[0], point[1]);
// 把投影坐标反算到节点逻辑空间;不可逆/无效投影没有可用于 hitTest 的局部坐标。
@@ -103,6 +115,7 @@ fn selector(
// 源 Point 以节点左上为原点;这里再次验证范围,避免变换后的点落到节点盒子之外。
let x = point.x - geometry.x;
let y = point.y - geometry.y;
// 经过逻辑逆变换后再按本地盒检查,源 hitTest 不接收明显超出该出现范围的点。
if x < 0.0 || y < 0.0 || x > geometry.width || y > geometry.height {
continue;
}
@@ -111,15 +124,18 @@ fn selector(
.store()
.type_of(target.block)?
.ok_or(VmError::InvalidArgument("untyped hit target"))?;
// 方法按实际名义类型查找,既可命中原生默认实现,也可执行源覆盖。
let function = cx
.store()
.lookup_method(definition, "hitTest")?
.ok_or(VmError::InvalidArgument("missing hitTest method"))?;
// 为本次候选分配本桌面的 Point,先写 x/y 再加入常量池,辅助代码用它作为第三实参。
let local = cx.new_object()?;
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;索引供下面字节码复用。
// 记录当前候选四个常量的基址;start+2 是最终返回的 Mount,不能误返回共享 Block。
let start = constants.len() as u32;
constants.extend([
Value::Object(function),
@@ -129,6 +145,7 @@ fn selector(
]);
// false 分支只跳过“加载本 Mount 并返回”两条指令。按编码宽度计算,
// 常量索引大到需要扩展操作数时仍能跳到下一候选。
// 跳转偏移以两字节指令单元计,因此编码字节长度除以 2;扩展操作数的长度也计入。
let skip = (Instruction::encode_all(&[
Instruction::new(Opcode::LoadConst, start + 2),
Instruction::simple(Opcode::ReturnValue),
@@ -137,6 +154,7 @@ fn selector(
/ 2) as u32;
// 先调用 hitTest(block, mount, local),再把结果传给 validate。
// true 立即返回本 Mount;false 继续下一个候选,实现透明区域向下穿透。
// 两次 PushNull 占据 Call 的空接收者槽;block/mount/local 已显式加载,不能让 VM 再插入一次 receiver。
instructions.extend([
Instruction::new(Opcode::LoadConst, 0),
Instruction::simple(Opcode::PushNull),
@@ -153,6 +171,7 @@ fn selector(
]);
}
// 所有候选均未命中时显式返回 Nil;不会把最后一个 false 当作选择结果。
// 末尾追加独立 Nil 常量,零候选或全部返回 false 都走同一个无命中返回路径。
let end = constants.len() as u32;
constants.push(Value::Nil);
instructions.extend([
@@ -164,7 +183,9 @@ fn selector(
#[derive(Debug)]
/// 在一个只读调用中准备两次指针选择所需的候选与局部坐标。
struct Prepare {
// 候选来自已提交帧的交互出现,按绘制顺序排列,构造选择器时才逐项升级。
candidates: Vec<WeakObjectRef>,
// 按下与释放点都位于输入投影空间,各自独立做逆变换和协议命中,而非仅比较粗命中矩形。
press: [f32; 2],
release: [f32; 2],
desktop: ObjectRef,
@@ -179,7 +200,9 @@ impl NativeCallable for Prepare {
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 按下和释放分别构造选择器,但都在本次只读执行内调用,使用同一预算域。
let down = selector(cx, &self.candidates, self.press, self.desktop)?;
// 释放点重用同一候选序列;按下 selector 的最终结果只在执行期出现,准备时不提前猜测目标。
let up = selector(cx, &self.candidates, self.release, self.desktop)?;
// 分配比较器无须捕获业务根;真正待比较的 Mount 由驱动局部槽及实参保持生命周期。
let compare = cx.allocate(ContextNativeFunction::new(
"same pointer target",
SameTarget,
@@ -202,6 +225,7 @@ impl NativeCallable for Prepare {
Instruction::new(Opcode::Call, 2),
Instruction::simple(Opcode::ReturnValue),
];
// 为两个选择结果显式声明局部槽,常量顺序与上面的 LoadConst 0/1/2 对应。
let driver = code(
cx,
instructions,
@@ -224,6 +248,7 @@ impl NativeCallable for Prepare {
}
/// Returned Mount owns one root. No-hit and mismatched targets return None.
/// 在一次有界只读调用中分别选择按下与释放目标;仅二者为同一 Mount 时返回其拥有根。
// ui_bridge 的点击分发路径调用,成功的 Some 根由调用方使用后释放;本函数不直接派发业务 click。
pub fn resolve(
vm: &mut Vm,
desktop: ObjectRef,
@@ -242,6 +267,7 @@ pub fn resolve(
},
vec![desktop],
))?;
// 同步执行整个准备、两次命中及比较过程,共享总帧深/指令预算;不把未完成续体交给输入调用方。
let result = vm.call_read_only_with_limits(
prepare,
&[],
@@ -252,7 +278,10 @@ pub fn resolve(
);
// 先保存调用结果再释放入口根,因此 VM 返回错误时也能完成入口清理。
vm.release(prepare)?;
// 只取选择值,丢弃只读依赖集合;一次性输入命中不建立绘制或布局的持久失效依赖。
// 命中期间的只读限制作用于已有业务对象,准备器仍可分配 Point/驱动函数等临时结果,不能把 ReadOnly 解释成禁止任何分配。
let (value, _) = result.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
// 接受 Mount 或 Nil 两类驱动结果,其他值先释放其可能拥有根,再报告内部协议错误。
match value {
// 成功返回的 Mount 仍拥有一次根引用,交由调用方使用并释放;None 不转移任何对象根。
Value::Object(mount) => Ok(Some(mount)),
+156 -3
View File
@@ -4,6 +4,11 @@
//!
//! Bottom-up measurement and top-down arrangement share one read-only VM call.
//! The host scratch plan contains derived data only, never a second business heap.
//!
//! ui_bridge::begin_layout_frame 先建立临时 Mount 上下文和检查点,再调用 start;本模块只拥有求解草稿与 VM 续体。
//! Prepare 建立循环,Advance 依次安排 Step → 源方法/原生结果 → Collect,整轮结束由 Settle 决定继续还是停止。
//! Progress::Complete 只证明本轮布局驱动完成,桥接层仍需投影、提交几何及生命周期,运行时还需绘制并验证全窗口稳定。
//! Work::cancel 结束只读调用并释放入口根;恢复 Mount 检查点属于桥接层,不能把本模块取消误认为业务状态回滚。
use crate::{
document::Frame,
layout::{self, LayoutRuntime, Size},
@@ -24,13 +29,16 @@ use std::{
// Small native trees keep the allocation-free fast path. Larger trees use the
// same per-occurrence VM driver so even intrinsic work can yield between nodes.
// start 扫描真实协议与 flex 分配后使用此阈值;小原生树可直接求整帧,大树仍需逐出现驱动以便让出。
const NATIVE_FAST_PATH_MAX_NODES: usize = 64;
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
// 协议解码、缓存跟踪和驱动适配器共用参数错误构造;VM 错误最终由 Work::accept 转为宿主诊断。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 读取当前 desktop 的类型字典,为布局协议结果提供名义类型检查。
/// 读取当前 desktop 的类型字典身份,供测量缓存过滤宿主内部依赖。
// Collect 结束测量读取跟踪后调用,用来排除宿主类型注册表依赖,而非将其当业务读取缓存。
fn desktop_type_registry(cx: &NativeContext<'_>, desktop: ObjectRef) -> Result<ObjectRef, VmError> {
match cx.store().get_attr(desktop, "types")? {
Value::Object(object) => Ok(object),
@@ -43,22 +51,30 @@ struct Measurement {
// 缓存记录借用的业务身份及方法身份,与子测量、约束和依赖版本共同决定复用资格,不增加托管根。
owner: ObjectRef,
method: ObjectRef,
// 缓存复用条件包含所有子出现的测量尺寸;子尺寸变化即使未写当前 Block 属性也会导致不匹配。
children: Vec<Size>,
// [最小宽,最小高,最大宽,最大高];相同 Block 在不同父约束下不能共用旧测量。
constraints: [f32; 4],
// 已按出现约束钳制的最终测量输出,不保存源返回对象或为它增加拥有根。
size: Size,
// 源调用及返回字段解码的精确读取依赖,Step 逐项检查版本/名称有效性后才复用。
reads: Vec<endor_core::ReadDependency>,
}
/// Borrowed identities and versions only: this cache adds no managed roots.
#[derive(Debug, Default, Clone)]
/// 按 Mount 弱身份保存源测量结果,不为业务对象增加托管根。
pub struct Cache {
// 键为出现弱身份而非共享 Block;移除出现后由 start 清理,源方法/owner 变化由 Step 再检查。
entries: HashMap<WeakObjectRef, Measurement>,
// start 每轮清零的源测量缓存统计,原生 LayoutRuntime 自己维护另一套测量记账。
pub hits: usize,
pub misses: usize,
}
impl Cache {
/// 从测量缓存依赖中移除宿主私有 desktop 记账对象,防止内部同步造成自我失效。
// ui_bridge 合入缓存前传入全部窗口私有 desktop,防止另一窗口宿主记账也成为自我失效依赖。
pub(crate) fn exclude_host_desktops(&mut self, desktops: &HashSet<ObjectRef>) {
// 逐测量项过滤依赖,保留缓存输入与尺寸不变;这里只排除宿主对象,不重新执行 measure。
for entry in self.entries.values_mut() {
entry
.reads
@@ -66,11 +82,13 @@ impl Cache {
}
}
/// 移除受影响 owner 的源测量缓存;其他出现继续按其读取版本检查。
// presentation 的显式 invalidate 路径按受影响业务 owner 清理;同 Block 的多个出现缓存会一起移除。
pub fn invalidate(&mut self, owners: &HashSet<ObjectRef>) {
self.entries
.retain(|_, entry| !owners.contains(&entry.owner));
}
/// 清空本对象维护的当前内容或缓存,后续状态由调用方重新建立。
/// 清空所有源测量条目和统计,后续源测量再按当前输入重建。
// 小原生树切回直接布局等路径调用;清理源测量条目并重置命中/未命中统计。
pub fn clear(&mut self) {
self.entries.clear();
self.hits = 0;
@@ -82,10 +100,12 @@ impl Cache {
struct Pending {
// 只读子调用的精确读取跟踪区间 token,由 Step 开始、Collect 在结果解码后结束。
token: usize,
// Step 记下当前 Block 与实际方法,Collect 不再从可能不同的上下文重新猜测缓存身份。
owner: ObjectRef,
method: ObjectRef,
children: Vec<Size>,
constraints: [f32; 4],
// 本次出现弱身份,测量成功后作为 Cache 键;Pending 本身不额外保活 Mount。
key: WeakObjectRef,
}
#[derive(Debug)]
@@ -93,27 +113,35 @@ struct Pending {
struct Plan {
// 使用出现索引保存树和几何,不为每个位置建立第二份业务对象。
nodes: Vec<Arc<UiNode>>,
// 按出现下标保存直接孩子列表,和 nodes/sizes/bounds 一起形成平行数组。
children: Vec<Vec<usize>>,
// 每一轮最新测量值;preferred 则保留自然基准,避免弹性分配反馈后不断漂移。
sizes: Vec<Size>,
// preferred 是测量偏好,bounds 是父级实际分配;换行/flex 会使两者不同。
preferred: Vec<Option<Size>>,
bounds: Vec<Option<[f32; 4]>>,
// 本轮的原生测量运行时与统计副本,完成后才随 Draft 交回桥接层。
runtime: LayoutRuntime,
// 是否实际遇到源布局覆盖;大原生树也走驱动,但不因此发布宿主驱动读取为业务依赖。
custom: bool,
cache: Cache,
// 只为真正执行的源 measure 保存收集状态;原生结果或缓存命中不打开新跟踪区间。
pending: Vec<Option<Pending>>,
constraints: Vec<[f32; 4]>,
// Source-arranged dimensions remain exact for this solve, not business state.
// 每个出现两个轴的显式分配,Some 形成精确 min=max 约束;不是可写业务尺寸属性。
allocations: Vec<[Option<f32>; 2]>,
// 源 arrange 或需要 flex/stretch 的原生安排会记录显式孩子分配,供 Settle 反推精确约束。
custom_arrange: Vec<bool>,
native_arrange: Vec<bool>,
custom_measure: Vec<bool>,
// 源 measure 与默认结果不同的轴作为本次求解的内容上限;新一轮 start 会重新初始化。
measured_limits: Vec<[Option<f32>; 2]>,
// 保存几何状态用于检测收敛和重复状态,防止相互依赖布局无限循环。
previous: Option<Vec<u32>>,
seen: BTreeSet<Vec<u32>>,
}
// Step/Collect/Settle 共享纯原生求解计划;锁用于短时读写,源 VM 方法不在持锁期间执行。
type Shared = Arc<Mutex<Plan>>;
#[derive(Debug, Clone, Copy)]
/// 源视觉调用当前处于测量还是安排阶段,决定调用方法与结果解码。
@@ -133,9 +161,11 @@ impl Phase {
#[derive(Debug)]
/// 为某次出现准备一个 measure/arrange 调用,选择原生快速结果或真正的源覆盖。
struct Step {
// 当前步骤共享计划;index/phase 指定一次出现的一次测量或安排。
plan: Shared,
index: usize,
phase: Phase,
// Advance 为当前出现升级并显式捕获的强身份,仅当前辅助调用需要保活它。
mount: ObjectRef,
desktop: ObjectRef,
}
@@ -147,11 +177,14 @@ impl NativeCallable for Step {
/// 解析当前实际 measure/arrange 实现,决定原生快速路径或源调用,准备本节点输入及依赖收集,避免跳过源覆盖。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 先克隆 Mount 描述以跨后续 VM 分配使用;它带当前 Block 和临时布局上下文。
let mount = cx.store().payload::<Mount>(self.mount, "Mount")?.clone();
// 沿当前业务 Block 的真实类型查方法,不能只按节点种类判断有没有源覆盖。
let definition = cx
.store()
.type_of(mount.block)?
.ok_or(invalid("untyped layout target"))?;
// phase.method 决定 measure/arrange,缺少协议方法视为错误而不是静默回退。
let method = cx
.store()
.lookup_method(definition, self.phase.method())?
@@ -163,11 +196,13 @@ impl NativeCallable for Step {
self.phase.method(),
self.desktop,
)?;
// 在短锁内取得当前出现输入并更新计划记账,退出后才构造托管参数及真正源调用。
let (node, children, preferred, bounds, native_size) = {
let mut plan = self
.plan
.lock()
.map_err(|_| invalid("layout plan poisoned"))?;
// Arc 克隆只共享不可变视觉节点,children 则复制本轮已完成的直接子测量。
let node = plan.nodes[self.index].clone();
let children = plan.children[self.index]
.iter()
@@ -178,16 +213,19 @@ impl NativeCallable for Step {
.iter()
.map(|child| plan.preferred[*child].unwrap_or(plan.sizes[*child]))
.collect::<Vec<_>>();
// measure 不暴露旧安排边界;arrange 必须使用父级已分配的本轮 bounds。
let bounds = if matches!(self.phase, Phase::Arrange) {
plan.bounds[self.index]
} else {
None
};
// 每次 measure 重新记录实际是否源覆盖,Collect 据此决定是否比较默认测量以生成额外内容限制。
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(
plan.runtime
@@ -199,12 +237,15 @@ impl NativeCallable for Step {
})?,
)
} else {
// 遇到任一源协议就标记整轮 custom,finish 才会保留过滤后的业务布局读取。
if !intrinsic {
plan.custom = true;
}
if matches!(self.phase, Phase::Measure) {
// 测量缓存按本次 Mount 弱身份查找,仍需同时验证 owner、方法、孩子输入、约束及依赖。
let key = cx.store().downgrade(self.mount)?;
let constraints = plan.constraints[self.index];
// 只有所有条件同时满足才取旧尺寸;值存在但依赖过期与完全无缓存都走未命中分支。
let cached =
plan.cache
.entries
@@ -226,6 +267,7 @@ impl NativeCallable for Step {
} else {
plan.cache.misses += 1;
plan.runtime.measured += 1;
// token 先占位为 0;输入 Constraints 初始化完成后才开启精确跟踪并写入真实 token。
plan.pending[self.index] = Some(Pending {
token: 0,
owner: mount.block,
@@ -240,6 +282,7 @@ impl NativeCallable for Step {
None
}
};
// 每次安排都计数,并记录是否原生及是否需要显式分配,后面的收敛阶段依赖这两个标记。
if matches!(self.phase, Phase::Arrange) {
plan.runtime.arranged += 1;
plan.native_arrange[self.index] = intrinsic;
@@ -251,6 +294,7 @@ impl NativeCallable for Step {
// 先安装本轮节点、子测量和临时 bounds,让源代码调用基类或 Mount.bounds 时观察当前阶段数据。
let context = mount.layout.ok_or(invalid("missing pre-layout context"))?;
{
// 只更新当前出现的临时协议输入;不写 Plan 的兄弟位置,也不修改共享 Block 的业务属性。
let mut context = context
.lock()
.map_err(|_| invalid("layout context poisoned"))?;
@@ -268,7 +312,9 @@ impl NativeCallable for Step {
vec![("width", value(size.width)), ("height", value(size.height))],
)?)));
}
// 构造本桌面名义输入对象:measure 接收 Constraints,arrange 接收已分配 Bounds。
let input = match self.phase {
// 从 Plan 取当前约束后立刻释放短锁,record 的对象分配发生在锁外。
Phase::Measure => {
let constraints = self
.plan
@@ -287,6 +333,7 @@ impl NativeCallable for Step {
],
)?
}
// 父级没有分配当前边界时不能调用 arrange,否则孩子无法得到可靠的绝对布局坐标。
Phase::Arrange => {
let [x, y, width, height] =
bounds.ok_or(invalid("arrangement has no allocated bounds"))?;
@@ -314,6 +361,7 @@ impl NativeCallable for Step {
.ok_or(invalid("missing measurement tracking state"))?
.token = token;
}
// 交回 VM 调用 method(block, mount, input),结果由 Advance 安排的 Collect 统一收集。
Ok(NativeResult::Invoke {
function: method,
arguments: vec![
@@ -350,9 +398,11 @@ impl NativeCallable for Collect {
} else {
None
};
// 原生快路径、缓存包装和源覆盖都必须返回一个托管结果,不能用裸数字或 Nil 代替协议。
let [Value::Object(result)] = args else {
return Err(invalid("layout must return a managed result"));
};
// 阶段选择本桌面名义结果类型;后面的字段解码不能替代 is_instance 检查。
let expected = match self.phase {
Phase::Measure => "MeasureResult",
Phase::Arrange => "Arrangement",
@@ -364,6 +414,7 @@ impl NativeCallable for Collect {
{
return Err(invalid("layout returned the wrong result type"));
}
// 结果已经回到宿主,持锁更新当前计划;这里不再调用源 measure/arrange。
let mut plan = self
.plan
.lock()
@@ -383,10 +434,12 @@ impl NativeCallable for Collect {
// 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()
.map(|child| plan.sizes[*child])
.collect();
// 原生测量失败时转换成 None,下面两轴都会保守保留源尺寸限制,而不是忽略源返回值。
let native = layout::measure_native(
&plan.nodes[self.index],
children,
@@ -396,15 +449,18 @@ impl NativeCallable for Collect {
.map(|native| layout::constrained(native, plan.constraints[self.index]));
// Equality after remeasurement does not retract a source
// limit selected earlier in this solve. New renders start fresh.
// 只设置发生差异的轴;此前已记录的限制在本次求解中保持,不因后续尺寸巧合相等而撤销。
if !native.is_some_and(|native| native.width == size.width) {
plan.measured_limits[self.index][0] = Some(size.width);
}
if !native.is_some_and(|native| native.height == size.height) {
// 高度独立比较,源方法只改高时也要保留纵向内容上限,不能只根据宽度是否不同决定两轴。
plan.measured_limits[self.index][1] = Some(size.height);
}
}
// Decoding is part of the subcall dependency region: a source
// method may return an existing result whose fields can change.
// 原生或缓存命中没有 Pending,不结束不存在的跟踪区间;源测量在解码宽高之后结束,覆盖结果字段读取。
let tracked = pending
.map(|pending| {
cx.store()
@@ -412,12 +468,17 @@ impl NativeCallable for Collect {
.map(|reads| (pending, reads))
})
.transpose()?;
// 只有真正源测量才尝试写缓存;精确访问记录还需判断是否读取了不稳定的 Mount 内容。
if let Some((pending, reads)) = tracked {
// 若观察了 Mount 内容/几何等不稳定宿主状态,无法证明跨轮可复用,保守地删除该测量缓存。
let mut volatile = false;
// 类型字典用于协议名义检查,不是源业务状态;它和 desktop 都要从易变性判断与缓存依赖中排除。
let registry = desktop_type_registry(cx, self.desktop)?;
// accesses 提供内容读取细节,dependencies 则用于后面保留可校验依赖,两者用途不同。
for read in &reads.accesses {
// access 记录哪个对象被怎样读取,后面仅对真实 Mount 内容读取禁用缓存,不因普通身份比较一概禁用。
let object = read.object;
// 当前桌面和类型注册表属于宿主内部检查,跳过这类访问而不把它们认成易变业务输入。
if object == self.desktop || object == registry {
continue;
}
@@ -425,10 +486,12 @@ impl NativeCallable for Collect {
// source. Object-granular tracking cannot prove those
// accesses stable, so conservatively bypass its cache.
if cx.store().object(object)?.payload().as_any().is::<Mount>() {
// Mount 身份可用于查父关系,但内容读取可能涉及本轮派生几何;content_read 才决定此次测量是否易变。
volatile |= read.content_read;
continue;
}
}
// 观测到 Mount 内容读取就移除旧项,本次仍使用已得到的尺寸,只是不允许下次复用。
if volatile {
plan.cache.entries.remove(&pending.key);
} else {
@@ -460,15 +523,18 @@ impl NativeCallable for Collect {
// 仅首次填写 preferred;sizes 每轮更新,根测量直接给本轮根 bounds,安排才能从根向下展开。
plan.preferred[self.index].get_or_insert(size);
plan.sizes[self.index] = size;
// 根没有父安排可提供 bounds,直接以本轮测量从 (0,0) 建立根边界,随后正序 arrange 展开孩子。
if self.index == 0 {
plan.bounds[0] = Some([0.0, 0.0, size.width, size.height]);
}
}
Phase::Arrange => {
// Arrangement.children 必须指向受控对象列表;先取全部项,再按稳定 slot 分配到计划孩子。
let Value::Object(list) = cx.store().get_attr(*result, "children")? else {
return Err(invalid("Arrangement.children must be an ObjectList"));
};
let items = object_list::items(cx.store(), list)?;
// 固定当前父出现的直接孩子列表,源结果按 slot 查回这个列表,再转换为全局出现下标。
let children = plan.children[self.index].clone();
// 安排结果必须覆盖全部孩子,不能省略零尺寸项;结合下面 slot 唯一检查保证逐一对应。
if items.len() != children.len() {
@@ -484,40 +550,52 @@ impl NativeCallable for Collect {
.enumerate()
.map(|(index, slot)| (slot, index))
.collect();
// 记录已安排 slot 防止重复,项数相等加逐项唯一性共同保证每个孩子恰好分配一次。
let mut assigned = BTreeSet::new();
// 列表每项都需满足 Bounds 名义类型,不能只有 x/y/width/height 同名字段就当作合法安排。
let expected = desktop_type(cx.store(), "Bounds", self.desktop)?;
// 源返回列表可以重新排序,实际孩子用 slot 查表确定,不按列表位置直接对应。
for item in items {
// 每项需是本桌面 Bounds,之后 slot 和几何字段仍逐项校验。
if !cx.store().is_instance(*item, expected)? {
return Err(invalid("Arrangement child must be Bounds"));
}
let Value::Number(slot) = cx.store().get_attr(*item, "slot")? else {
// Bounds 名义类型合法仍不足以接受任意 slot 值,缺失或非 Number 要在建立孩子映射之前拒绝。
return Err(invalid("Arrangement child requires a slot"));
};
// slot 先要求可转为 i64 整数,再拒绝负值转为 u64;超出 i64 范围的值也不能作为此处身份。
let slot = slot
.as_i64()
.and_then(|value| u64::try_from(value).ok())
.ok_or(invalid("invalid arrangement slot"))?;
// 只接受当前父节点 slots 表中的身份,表外身份不能混入安排。
let position = slots
.get(&slot)
.copied()
.ok_or(invalid("Arrangement references a foreign child slot"))?;
// 重复项不能覆盖同一个孩子后遗漏另一个孩子,即使总体列表长度正确也要失败。
if !assigned.insert(slot) {
return Err(invalid("duplicate arrangement slot"));
}
// x/y 可负表示相对布局延伸,宽高必须非负且有限;下面还检查右下组合边界。
let bounds = [
number(cx, *item, "x", true)?,
number(cx, *item, "y", true)?,
number(cx, *item, "width", false)?,
number(cx, *item, "height", false)?,
];
// 单项数值合法仍可能在相加后超出几何预算,不能只检查四个字段各自范围。
if bounds[0] + bounds[2] > MAX_DIMENSION as f32
|| bounds[1] + bounds[3] > MAX_DIMENSION as f32
{
return Err(invalid("arrangement bounds exceed limits"));
}
// 将 slot 表内的位置转换为 Plan 的全局出现下标,用于写 bounds 与轴分配。
let child = children[position];
// 源安排或原生弹性分配的差异保留为显式轴分配,下一轮测量使用精确尺寸,确保文字按实际宽度重排。
if plan.custom_arrange[self.index] {
// 原生主轴 flex 分配即使恰好等于当前测量也显式保存,避免下一次父约束传播撤销已分配空间。
if plan.native_arrange[self.index]
&& let Some(main) = layout::main_axis(&plan.nodes[self.index])
{
@@ -526,13 +604,16 @@ impl NativeCallable for Collect {
// the next parent-constraint propagation pass.
plan.allocations[child][main] = Some(bounds[main + 2]);
}
// 源或弹性安排改变宽/高时分别设置精确轴分配,未变化且未显式要求的轴仍可按自然测量工作。
if bounds[2] != plan.sizes[child].width {
plan.allocations[child][0] = Some(bounds[2]);
}
if bounds[3] != plan.sizes[child].height {
// 高度差异单独形成精确纵向分配,和上面的宽度约束可同时存在,也可只约束其中一轴。
plan.allocations[child][1] = Some(bounds[3]);
}
}
// 保存本轮孩子绝对布局边界;正序 Arrange 随后轮到该孩子时才能取得自己的输入。
plan.bounds[child] = Some(bounds);
}
}
@@ -553,6 +634,7 @@ impl NativeCallable for Settle {
}
/// 由新 bounds 推导下一轮约束与自然尺寸,比较完整布局状态;稳定则结束,循环状态报错,否则继续同帧求解。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 收敛计算持 Plan 短锁,后面的临时 Mount 上下文清理放在锁外执行。
let repeat = {
let mut plan = self
.plan
@@ -561,29 +643,39 @@ impl NativeCallable for Settle {
plan.runtime.settle_passes += 1;
// 安排完成后按最终 bounds 从父到子推导下一轮约束,测量限制和源分配可反向影响换行高度。
let mut next = plan.constraints.clone();
// 按父先子后传播下一轮约束,next[parent] 先确定后才推导直接孩子。
for index in 0..plan.nodes.len() {
// 克隆直接孩子索引以结束对 plan.children 的借用,随后要在同一 Plan 内更新约束与显式分配。
let children = plan.children[index].clone();
let [_, _, width, height] =
plan.bounds[index].ok_or(invalid("unarranged occurrence"))?;
// 先继承本节点下一轮约束,再用源测量限制和实际已分配宽高调整可供孩子使用的上界。
let mut available = next[index];
// 源测量选择的内容上限优先加入 available,后面实际分配差异也可能继续覆盖该轴。
if let Some(width) = plan.measured_limits[index][0] {
available[2] = width;
}
if let Some(height) = plan.measured_limits[index][1] {
// 源测量给出的纵向内容限制只覆盖最大高,最小约束仍从 next 继承,再由孩子约束策略统一处理。
available[3] = height;
}
// 父级实际分配与测量不同,孩子的可用宽度应跟随分配,否则换行仍按旧宽度计算。
if width != plan.sizes[index].width {
available[2] = width;
}
if height != plan.sizes[index].height {
// 最终分配高度与测高不同,则孩子预算跟随实际分配;不把此值写回业务对象 height。
available[3] = height;
}
// 根据父盒模型与实际宽高计算孩子公共约束基础,随后再考虑各个出现的独立尺寸规则。
let base = layout::arranged_child_constraints(
&plan.nodes[index],
available,
Size { width, height },
);
// child_index 是父内顺序,child 是全局出现下标,两者不能互换用于平行数组。
for (child_index, child) in children.into_iter().enumerate() {
// 保留未施加精确轴分配的自然约束;交叉轴改变时,撤销旧原生主轴分配需要用它恢复。
let natural = layout::occurrence_constraints(
&plan.nodes[index],
&plan.nodes[child],
@@ -591,6 +683,7 @@ impl NativeCallable for Settle {
Size { width, height },
child_index,
);
// 从自然约束起步,再逐轴套入显式精确分配;natural 留作原生 flex 分配失效时恢复基线。
let mut constraints = natural;
// 显式轴分配同时设置最小与最大,区别于仅给孩子一个可自由缩小的最大可用宽度。
if let Some(width) = plan.allocations[child][0] {
@@ -598,10 +691,13 @@ impl NativeCallable for Settle {
constraints[2] = width;
}
if let Some(height) = plan.allocations[child][1] {
// 显式高同时成为 minHeight/maxHeight;这比仅给上限更强,使下一轮按安排分配重新测量。
constraints[1] = height;
constraints[3] = height;
}
// 线性布局才有主/交叉轴;交叉轴范围变化可触发文字重排,进而改变主轴自然尺寸。
if let Some(main) = layout::main_axis(&plan.nodes[index]) {
// 轴索引只有 0/1;Row 主轴横向、Column 主轴纵向,所以用 1-main 得到另一轴而非孩子顺序。
let cross = 1 - main;
// 交叉轴变化可能改变换行及主轴自然高度,清 preferred 让其重新测量;原生派生主轴分配也需暂时撤销。
if constraints[cross] != plan.constraints[child][cross]
@@ -610,6 +706,7 @@ impl NativeCallable for Settle {
// Wrapping or other cross-dependent measurement
// invalidates this occurrence's natural main size.
plan.preferred[child] = None;
// 只撤销原生派生主轴分配,源 arrange 的明确分配仍是权威约束,不能被自动回退覆盖。
if plan.native_arrange[index] {
// Derived flex sizes must not constrain their
// own replacement measurement. Source arrange
@@ -623,9 +720,11 @@ impl NativeCallable for Settle {
next[child] = constraints;
}
}
// 状态向量用于相邻稳定比较及历史重复检测;capacity 只是预分配提示,不限制最终编码长度。
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));
state.extend([
@@ -638,11 +737,13 @@ impl NativeCallable for Settle {
.into_iter()
.map(bits),
);
// Option 先编码存在标记再编码数值,使 None 和显式 Some(0) 在收敛判断中保持不同。
for dimension in plan.allocations[index] {
state.push(u32::from(dimension.is_some()));
state.push(dimension.map_or(0, bits));
}
state.push(u32::from(plan.preferred[index].is_some()));
// 首选尺寸同样带存在标记;缺失时写两个零占位以维持每项固定字段布局。
if let Some(size) = plan.preferred[index] {
state.extend([bits(size.width), bits(size.height)]);
} else {
@@ -653,6 +754,7 @@ impl NativeCallable for Settle {
if plan.previous.as_ref() == Some(&state) && plan.constraints == next {
false
} else {
// 与紧邻上轮不同却命中历史状态,说明出现振荡环,返回错误而不是无限重试。
if !plan.seen.insert(state.clone()) {
return Err(invalid("layout constraints did not converge (cycle)"));
}
@@ -663,11 +765,14 @@ impl NativeCallable for Settle {
true
}
};
// 只有需要新一轮才清理 VM 可观察的临时 Bounds;稳定结束时保留最终安排供后续提交。
if repeat {
// No stale geometry from a prior pass is observable during measurement.
// 逐项升级当前布局 Mount,清除上下文旧 bounds,防止下一轮 measure 观察上一轮安排几何。
for weak in self.mounts.iter() {
let mount = cx.upgrade(*weak)?.ok_or(invalid("layout Mount expired"))?;
if let Some(context) = &cx.store().payload::<Mount>(mount, "Mount")?.layout {
// 仍有临时上下文才清 bounds;其余输入留给下一轮 Step 更新,不在这里调用源 measure。
context
.lock()
.map_err(|_| invalid("layout context poisoned"))?
@@ -693,6 +798,7 @@ impl NativeCallable for Prepare {
}
/// 建立按出现位置推进的 Advance 和固定字节码循环,保持同一 VM 预算,避免首次让出前创建整树临时调用对象。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// Prepare 只建立共享游标适配器和固定循环;不会在首次让出前一次创建全树 Step/Collect。
let advance = cx.allocate(ContextNativeFunction::new(
"next layout occurrence",
Advance {
@@ -703,6 +809,7 @@ impl NativeCallable for Prepare {
},
vec![self.desktop],
))?;
// 固定循环以 Advance 返回的 Bool 决定继续,不为每个节点展开另一份巨大的驱动字节码。
let function = crate::vm_sequence::repeat_call(cx, advance, "source layout", self.desktop)?;
Ok(NativeResult::Invoke {
function,
@@ -717,6 +824,7 @@ struct Advance {
plan: Shared,
mounts: Arc<[WeakObjectRef]>,
desktop: ObjectRef,
// 每轮 [0,N) 为逆序测量,[N,2N) 为正序安排,2N 专门进入 Settle。
next: Mutex<usize>,
}
impl NativeCallable for Advance {
@@ -726,6 +834,7 @@ impl NativeCallable for Advance {
}
/// 按游标安排自底向上 measure 与自顶向下 arrange,短锁结束后才创建/执行当前节点调用,末尾进入收敛检查。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// mounts 与 Plan 出现数组同序等长,start 已检查数量,游标借这个长度选择阶段和下标。
let count = self.mounts.len();
// 游标取值/递增只在短锁内完成,不能持锁执行源协议,否则源调用基类会再次访问同一 Plan。
let position = {
@@ -734,6 +843,7 @@ impl NativeCallable for Advance {
.lock()
.map_err(|_| invalid("layout cursor poisoned"))?;
let position = *next;
// 只在节点步骤范围内递增;末尾 Settle 自己决定重置为 0 或让外层循环停止。
if position < count * 2 {
*next += 1;
}
@@ -741,14 +851,17 @@ impl NativeCallable for Advance {
};
// 每轮恰有 N 个逆序 measure 和 N 个正序 arrange,之后调用 Settle;需要重算时游标归零。
if position == count * 2 {
// 所有节点完成 measure/arrange 才调用收敛检查;它返回是否需要下一轮,不再增加独立 VM 总预算。
let settled = Settle {
plan: self.plan.clone(),
mounts: self.mounts.clone(),
}
.invoke(cx, &[])?;
let NativeResult::Return(Value::Bool(repeat)) = settled else {
// Settle 是宿主内部零参计算,必须同步返回 Bool;其他结果不能误作“已经稳定”而提前交付。
return Err(invalid("invalid settling result"));
};
// 需要继续时重置同一个游标,后续仍回到原 repeat_call 帧执行整轮;false 则结束布局。
if repeat {
*self
.next
@@ -763,6 +876,7 @@ impl NativeCallable for Advance {
} else {
(Phase::Arrange, position - count)
};
// 只升级当前出现,Step 显式捕获保证这一步的 Mount 活到收集完成,其他候选仍保留弱身份。
let mount = cx
.upgrade(self.mounts[index])?
.ok_or(invalid("layout Mount expired"))?;
@@ -778,6 +892,7 @@ impl NativeCallable for Advance {
},
vec![mount, self.desktop],
))?;
// 收集器保存同一 index/phase,保证方法结果回写正确出现而不依赖驱动游标当前值。
let collector = cx.allocate(ContextNativeFunction::new(
"collect layout",
Collect {
@@ -806,9 +921,11 @@ impl NativeCallable for Advance {
/// An unpublished layout frame and derived caches. These are merged only after
/// the driver completes; they are not another copy of application state.
/// 已经完成本阶段、但尚未由桥接层发布的派生结果,包含帧/缓存或真实读取依赖。
/// 未发布布局草稿及派生缓存;Work 持有期间仍在求解,Complete 后交桥接层投影与提交。
pub struct Draft {
// 未投影/未发布的布局草稿;ui_bridge 完成后还要执行滚动、视口投影与挂载提交。
pub frame: Frame,
// 完整求解统计和原生运行时缓存,与 frame、源 Cache 一起交付,不能单独提前覆盖已发布状态。
pub runtime: LayoutRuntime,
pub cache: Cache,
pub reads: Vec<endor_core::ReadDependency>,
@@ -817,6 +934,7 @@ pub struct Draft {
#[must_use = "pending layout work must be resumed or cancelled with its owning VM"]
/// 本次推进处于挂起还是完成;挂起工作必须继续恢复或显式取消。
pub enum Progress {
// ui_bridge 保存工作句柄和上下文,endor_host 之后驱动恢复或取消,不发布中间几何。
Pending(Work),
Complete(Draft),
}
@@ -825,8 +943,10 @@ pub enum Progress {
/// The prepare handle owns one root until completion or explicit cancellation.
/// 拥有未完成源调用及临时根的工作句柄;丢弃 Rust 值不能替代 VM 内的取消。
pub struct Work {
// 开始时的原生草稿与缓存状态独立拥有,挂起不携带外部可变引用。
state: Draft,
plan: Shared,
// 驱动入口拥有根,正常完成或 cancel 都需显式 release;Rust drop 不负责 VM 引用计数。
prepare: ObjectRef,
desktop: ObjectRef,
slices: usize,
@@ -834,13 +954,16 @@ pub struct Work {
impl Work {
/// 恢复当前 Work 拥有的 VM 续体,继续同一轮求解而不是重放 measure/arrange。
// ui_bridge::resume_layout_frame 调用,沿原只读续体继续;slices 统计包含 start 的第一片。
pub fn resume(mut self, vm: &mut Vm, slice: usize) -> Result<Progress> {
// resume 消费原 Work,计数包含最初 start;非法量子/恢复错误同样交 accept 取消原只读续体,不重建求解。
self.slices += 1;
let progress = vm.resume_read_only_call(slice);
self.accept(vm, progress)
}
/// 显式结束布局续体和只读域,释放本次尚未提交的临时根。
// 桥接层替换或撤销挂起布局时调用;消费 Work 结束续体,派生 Plan 不合入文档。
pub fn cancel(self, vm: &mut Vm) -> Result<()> {
// 取消与释放入口根均先尝试,再依次传播错误;普通 Rust drop 无法替代 VM 只读域清理。
let cancelled = vm.cancel_read_only_call();
@@ -856,6 +979,7 @@ impl Work {
vm: &mut Vm,
progress: Result<endor_core::ExecutionProgress, VmError>,
) -> Result<Progress> {
// Pending 保存所有本轮状态;Complete 释放入口后汇总,错误先取诊断再尝试取消清理。
match progress {
Ok(endor_core::ExecutionProgress::Pending { .. }) => Ok(Progress::Pending(self)),
Ok(endor_core::ExecutionProgress::Complete {
@@ -863,12 +987,14 @@ impl Work {
dependencies,
..
}) => {
// VM 调用域已经完成,先释放其入口根;finish 消费同一个 Work,并只读取最终 Plan 汇总布局草稿。
vm.release(self.prepare)?;
finish(vm, self, value, dependencies)
}
Err(error) => {
// An invalid quantum can fail without discarding the VM's
// continuation. No failed job may strand a read-only scope.
// 在取消调用可能清理 VM 执行上下文之前提取原始 traceback,避免最后只报告清理阶段位置。
let diagnostic = crate::endor_host::runtime_error(vm, error);
self.cancel(vm)?;
Err(diagnostic)
@@ -878,6 +1004,7 @@ impl Work {
}
/// 建立只读布局 Plan 与托管驱动函数;小型原生树可走直接路径,大树保留分片推进能力。
// ui_bridge::begin_layout_frame 提供本轮结构帧、临时 Mount 与缓存副本,结果由 accept_layout_progress 接续。
pub fn start(
vm: &mut Vm,
desktop: ObjectRef,
@@ -886,16 +1013,20 @@ pub fn start(
mut state: Draft,
slice: usize,
) -> Result<Progress> {
// 禁止零切片及同 VM 已有只读执行,不能覆盖另一个挂起续体。
anyhow::ensure!(slice > 0, "layout slice must be positive");
anyhow::ensure!(
!vm.has_suspended_call() && !vm.object_store().is_read_only(),
"cannot start layout while another read-only execution is active"
);
// 分字段借用草稿以准备本轮,cache 命中统计重置但条目先保留供下方筛选。
let frame = &mut state.frame;
let runtime = &mut state.runtime;
// 与 frame/runtime 借用同一 Draft 的独立字段,重置的是本轮源测量统计,不是清掉全部仍可复用的条目。
let cache = &mut state.cache;
cache.hits = 0;
cache.misses = 0;
// 当前出现弱身份集合用于剪掉退役缓存键;缓存条目内的业务借用身份不会因此增加根。
let live: HashSet<_> = mounts.iter().copied().collect();
// 先删除不再出现在此结构中的测量项,防止已移除的弱 Mount 身份无限积累。
cache.entries.retain(|mount, _| live.contains(mount));
@@ -904,20 +1035,25 @@ pub fn start(
// 扫描每个出现的实际 measure/arrange,实现全部为原生还不够,下面 flex/拉伸也会要求收敛驱动。
let mut custom = false;
for weak in &mounts {
// 扫描只为选择驱动路径,临时升级保证方法身份检查期间 Mount 不被回收;下面无论检查结果如何都 release。
let mount = vm
.upgrade(*weak)?
.ok_or_else(|| anyhow::anyhow!("layout Mount expired"))?;
// 方法检查错误先保存为 Result,下面总会尝试 release 临时升级的 Mount,再传播检查结果。
let result = (|| {
let block = vm.payload::<Mount>(mount, "Mount")?.block;
// 从真实业务对象取得类型,不能使用 UiNode 种类推断源覆盖是否存在。
let definition = vm
.object_store()
.type_of(block)?
.ok_or(invalid("untyped layout target"))?;
// measure 与 arrange 任一源覆盖或缺失都不能走小原生直接路径;缺失在正式步骤中报告协议错误。
for name in ["measure", "arrange"] {
let Some(method) = vm.object_store().lookup_method(definition, name)? else {
return Ok::<_, VmError>(true);
};
if !crate::native_visual::is_intrinsic(vm.object_store(), method, name, desktop)? {
// 任一协议为源覆盖就需要 VM 驱动,不能因另一协议仍是原生而整体走直接布局。
return Ok(true);
}
}
@@ -926,8 +1062,10 @@ pub fn start(
vm.release(mount)?;
custom |= result?;
}
// 用显式栈建立视觉先序列表,倒序压入孩子保持原兄弟顺序,与结构帧/Mount 的出现顺序对应。
let mut nodes = Vec::new();
let mut work = vec![root.clone()];
// 除了真实源覆盖,flex 或分配孩子的容器也需要收敛驱动,因为安排可能反过来改变测量。
while let Some(node) = work.pop() {
custom |= node.has_flex() || node.allocates_children();
work.extend(node.children().iter().rev().cloned());
@@ -937,7 +1075,9 @@ pub fn start(
// when every protocol method is native. Use the same settling driver.
// 仅小于等于64节点且无自定义/弹性分配的树走直接路径;大原生树仍经同一驱动以便分片让出。
if !custom && nodes.len() <= NATIVE_FAST_PATH_MAX_NODES {
// 直接路径不使用源测量缓存;保留的原生测量缓存属于 runtime,清空的是这里的源 Cache。
cache.clear();
// 小原生树一次生成完整帧,无须分配协议结果对象;前面的只读执行排他检查仍然适用。
let mut native = runtime.frame(&root)?;
// Only the structural pass consumes a published version.
// 结构准备已经分配本次发布版本,快速完整测量不能再多消耗一个可见布局版本。
@@ -952,6 +1092,7 @@ pub fn start(
nodes.len() == mounts.len() && nodes.len() == frame.mounts.len(),
"layout structure does not match Mount contexts"
);
// 按结构帧的 Mount ID 查父出现位置,再构造与 nodes 同下标的孩子索引表。
let indices: HashMap<_, _> = frame
.mounts
.iter()
@@ -959,6 +1100,7 @@ pub fn start(
.map(|(index, mount)| (mount.id, index))
.collect();
let mut children = vec![Vec::new(); nodes.len()];
// 只记录直接父子关系,根没有父项;协议的子尺寸和稳定 slot 都按这个顺序匹配。
for (index, mount) in frame.mounts.iter().enumerate() {
if let Some(parent) = mount.parent {
children[indices[&parent]].push(index);
@@ -966,6 +1108,7 @@ pub fn start(
}
// 从根向下初始化约束,之后每轮由 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() {
constraints[*child] = layout::initial_child_constraints(
@@ -976,6 +1119,7 @@ pub fn start(
);
}
}
// 以下平行数组均按出现数量初始化,None 代表尚未测量/安排或尚无明确轴分配。
let count = nodes.len();
// 把运行时/缓存克隆进未发布 Plan,失败或取消时上层可以丢弃派生求解结果而不提交半帧。
let plan = Arc::new(Mutex::new(Plan {
@@ -997,6 +1141,7 @@ pub fn start(
previous: None,
seen: BTreeSet::new(),
}));
// 共享 Plan 由原生适配器持有,desktop 显式作为托管捕获;Work 保存入口拥有根直到完成/取消。
let prepare = vm.allocate(ContextNativeFunction::new(
"prepare source layout",
Prepare {
@@ -1006,6 +1151,7 @@ pub fn start(
},
vec![desktop],
))?;
// 在执行第一片前绑定草稿、计划和入口,第一片完成或挂起都走相同 accept 分支。
let work = Work {
state,
plan,
@@ -1027,6 +1173,7 @@ pub fn start(
}
/// 验证求解已收敛,收集最终几何、读取依赖和缓存,返回尚待桥接层提交的 Draft。
// 只由 Work::accept 收到 VM Complete 后调用;驱动已经停止,prepare 根已经释放。
fn finish(
vm: &mut Vm,
mut work: Work,
@@ -1035,6 +1182,7 @@ fn finish(
) -> Result<Progress> {
// 驱动返回值仅为控制流结果,先释放;真正结果保存在原生 Plan,而非返回值中的第二份业务图。
vm.release_value(value)?;
// 借用最终计划并记录实际切片数,接下来只汇总结果,不再执行源布局或重启求解。
let mut plan = work
.plan
.lock()
@@ -1043,6 +1191,7 @@ fn finish(
let frame = &mut work.state.frame;
// 用最终收敛 bounds 更新 frame 的各出现;这里只生成默认内容命令,后续投影/源 paint 还会重建完整绘制表。
frame.commands.clear();
// 每次出现必须有最终 bounds,否则拒绝把缺失安排的草稿交给桥接层。
for (index, mount) in frame.mounts.iter_mut().enumerate() {
let [x, y, width, height] =
plan.bounds[index].ok_or_else(|| anyhow::anyhow!("unarranged occurrence"))?;
@@ -1050,6 +1199,7 @@ fn finish(
mount.y = y;
mount.width = width;
mount.height = height;
// 仅生成可用的默认单条绘制并检查有效性;完整 paint 与平行元数据在桥接投影/源绘制阶段重建。
if let Some(command) =
layout::paint_native(&plan.nodes[index], x, y, Size { width, height })
{
@@ -1057,6 +1207,7 @@ fn finish(
frame.commands.push(command);
}
}
// 同步取回最终运行时统计和源测量缓存,避免成功帧搭配上轮缓存。
work.state.runtime = plan.runtime.clone();
work.state.cache = plan.cache.clone();
// 大原生树可能使用分片驱动但没有真正源读取,因此无需把宿主驱动读取当业务布局依赖发布。
@@ -1064,6 +1215,7 @@ fn finish(
work.state.reads.clear();
return Ok(Progress::Complete(work.state));
}
// 源布局存在时才过滤真实读取;类型注册表属于宿主内部协议检查,不能作为业务失效依赖。
let registry = match vm.get_attr(work.desktop, "types")? {
Value::Object(object) => object,
_ => anyhow::bail!("missing type registry"),
@@ -1071,6 +1223,7 @@ fn finish(
work.state.reads = reads
.into_iter()
.filter(|dependency| {
// 剔除桌面、注册表、Mount 和已死亡身份,剩余依赖交 ui_bridge 做持续布局失效判断。
let object = dependency.object();
object != work.desktop
&& object != registry
+73 -2
View File
@@ -4,6 +4,10 @@
//!
//! Source paint dispatch produces checked immutable commands. One read-only VM
//! resumable call sequences all overrides, sharing one instruction/frame budget.
//!
//! ui_bridge::begin_paint_frame 在布局/挂载几何就绪后调用 start;Prepare/Advance 依帧顺序只执行真实源覆盖,Collect 解码其结果。
//! Work 保存原只读续体、未发布 frame 和已完成覆盖,resume 沿原位置继续;cancel 结束调用并释放入口,不发布部分命令。
//! finish 再为未覆盖出现生成默认命令,组装裁剪/变换/归属平行表和面板;桥接层登记依赖后,endor_host 仍需验证全窗口稳定。
use crate::{
document::{Frame, RenderCommand},
layout::{self, Size},
@@ -22,11 +26,14 @@ use std::{
};
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
// 字段解码、名义类型检查和原生驱动适配器共用此构造,错误回到只读 paint 调用链。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
// named_type、decode 和 Collect 提取对象字段时调用;不把标量转换成临时对象来接受错误输入。
fn object(value: Value) -> Result<ObjectRef, VmError> {
// 只有真实托管身份可交给 ObjectStore 查询;具体名义类型由使用这个身份的后续步骤检查。
if let Value::Object(object) = value {
Ok(object)
} else {
@@ -34,6 +41,7 @@ fn object(value: Value) -> Result<ObjectRef, VmError> {
}
}
/// 读取指定命令字段并要求其值为 Text;不隐式字符串化其他类型。
// decode 读取 kind/color/text 时调用;属性不存在的错误由 get_attr 原样传播。
fn text(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<String, VmError> {
if let Value::Text(value) = store.get_attr(object, name)? {
Ok(value)
@@ -42,7 +50,9 @@ fn text(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<String, Vm
}
}
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
// decode 的几何、字号和换行尺寸都经此入口;name 同时选择坐标可为负的规则。
fn number(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<f32, VmError> {
// 先要求 VM Number,再转换成原生 f32;不接受仅在文本上可解析的字符串。
let Value::Number(value) = store.get_attr(object, name)? else {
return Err(invalid("paint field must be a Number"));
};
@@ -60,7 +70,9 @@ fn number(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<f32, VmE
Ok(value)
}
/// 从当前 desktop 的类型注册表取得绘制结果必须符合的托管类型。
// Collect 和 decode 使用传入 desktop 所属 VM 的注册身份;跨窗口可共享同一类型域,不能仅按类型名字相同接受返回值。
fn named_type(store: &ObjectStore, desktop: ObjectRef, name: &str) -> Result<ObjectRef, VmError> {
// desktop.types 本身必须为托管注册表对象;缺失类型不能静默采用结构匹配。
let registry = object(store.get_attr(desktop, "types")?)?;
object(
store
@@ -69,6 +81,7 @@ fn named_type(store: &ObjectStore, desktop: ObjectRef, name: &str) -> Result<Obj
)
}
/// 把源方法返回的托管绘制值解码成受限 Rust 命令,检查名义类型、字段和绘制预算。
// Collect 对返回列表逐项调用;成功值已脱离 VM 对象字段,只有图片/场景等不可变资源引用被保留。
fn decode(
store: &ObjectStore,
command: ObjectRef,
@@ -78,8 +91,10 @@ fn decode(
if !store.is_instance(command, expected)? {
return Err(invalid("paint commands must be PaintCommand instances"));
}
// 所有变体先读取本地绘制原点;不会自动加 Mount 原点,源方法已收到布局 Bounds。
let x = number(store, command, "x")?;
let y = number(store, command, "y")?;
// kind 选择受支持的封闭命令集合,未知文本在 match 末尾返回错误。
let kind = text(store, command, "kind")?;
// 场景和图片命令持有独立资源,无需读取通用颜色字段。
let color = if matches!(kind.as_str(), "scene3d" | "image") {
@@ -87,7 +102,9 @@ fn decode(
} else {
text(store, command, "color")?
};
// 逐变体读取必需字段,随后再统一 validate;不能只凭 PaintCommand 名义类型跳过字段检查。
let result = match kind.as_str() {
// 图像共享 graphics 模块的资源、尺寸、旋转和透明度解码规则,不在这里重复实现。
"image" => crate::bridge::graphics::decode_image(store, command)?,
"scene3d" => {
// 这里仅接受宿主生成的 PaintView payload,克隆原生 Arc,不把活动的 VM 场景图传到绘制线程。
@@ -104,6 +121,7 @@ fn decode(
}
}
"roundedRect" | "strokeRoundedRect" => {
// 圆角填充与描边共享宽高半径;只有描边分支另外要求 strokeWidth。
let width = number(store, command, "width")?;
let height = number(store, command, "height")?;
let radius = number(store, command, "radius")?;
@@ -111,6 +129,7 @@ fn decode(
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
return Err(invalid("paint rectangle exceeds bounds"));
}
// 同一组几何字段决定两个不同命令变体;填充不能因为缺少 strokeWidth 而失败。
if kind == "roundedRect" {
RenderCommand::RoundedRect {
x,
@@ -143,7 +162,9 @@ fn decode(
store.get_attr(command, "wrapWidth"),
store.get_attr(command, "wrapHeight"),
) {
// 两个字段都缺失代表没有显式换行边界的文本;单个缺失或其他属性错误不属于该情况。
(Err(VmError::AttributeNotFound(_)), Err(VmError::AttributeNotFound(_))) => None,
// 两个字段存在后重新按数值规则读取,拒绝负长度或非 Number,而非只判断存在性。
(Ok(_), Ok(_)) => Some([
number(store, command, "wrapWidth")?,
number(store, command, "wrapHeight")?,
@@ -176,11 +197,14 @@ struct PaintOutput {
// 全轮源命令累计数,不能靠拆分多个源 DisplayList 绕过 10000 条总预算。
count: usize,
}
// Prepare、Advance、Collect 和 Work 共享纯原生输出;锁只保护短时记账,不跨源方法调用。
type Output = Arc<Mutex<PaintOutput>>;
#[derive(Debug)]
/// 检查源 paint 返回列表并将命令保存到当前出现的绘制结果。
struct Collect {
// 当前驱动所属桌面,用它查 DisplayList/PaintCommand 类型,并由原生函数 roots 保持存活。
desktop: ObjectRef,
// 当前出现 ID 是输出键,不是 Block 身份;一个 Block 多次出现允许返回不同画面。
mount: u64,
output: Output,
}
@@ -192,9 +216,11 @@ impl NativeCallable for Collect {
/// 验证返回名义类型并完整解码所有 PaintCommand,检查全轮命令预算后才把该出现位置结果写入输出表。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// collect_and_continue 把源 paint 的唯一返回值传给收集器;参数数量或外层类型错误都停止本轮。
let [Value::Object(result)] = args else {
return Err(invalid("paint must return a DisplayList"));
};
// 外层必须属于本桌面 DisplayList;普通含 commands 字段的对象不等同于名义绘制列表。
if !cx.store().is_instance(
*result,
named_type(cx.store(), self.desktop, "DisplayList")?,
@@ -204,6 +230,7 @@ impl NativeCallable for Collect {
// 先验证外层 DisplayList,再取其受控 ObjectList;所有成员解码成功后才写入 output。
let commands = object(cx.store().get_attr(*result, "commands")?)?;
let expected = named_type(cx.store(), self.desktop, "PaintCommand")?;
// 先取得受控列表中的对象身份,再逐项解码并 collect;任何单项失败都不会提交当前 Mount 的部分输出。
let commands = object_list::items(cx.store(), commands)?
.iter()
.map(|command| decode(cx.store(), *command, expected))
@@ -213,9 +240,11 @@ impl NativeCallable for Collect {
.output
.lock()
.map_err(|_| invalid("paint output poisoned"))?;
// 累计的是所有源覆盖的命令数,不能按 Mount 分拆规避一万条上限。
if output.count + commands.len() > 10_000 {
return Err(invalid("paint command budget exceeded"));
}
// 预算通过后才更新计数并保存完整列表;返回 Nil 只是辅助调用链结果,不是绘制命令。
output.count += commands.len();
output.commands.insert(self.mount, commands);
Ok(NativeResult::Return(Value::Nil))
@@ -225,6 +254,7 @@ impl NativeCallable for Collect {
/// 为当前出现准备 paint 方法、几何和类型检查上下文。
struct Prepare {
desktop: ObjectRef,
// 按帧绘制顺序保存出现 ID 和弱身份;驱动不会因等待绘制而强持有全部 Mount。
mounts: Vec<(u64, WeakObjectRef)>,
output: Output,
}
@@ -247,6 +277,7 @@ impl NativeCallable for Prepare {
},
vec![self.desktop],
))?;
// 固定 VM 字节码循环反复调用 advance,true 继续、false 结束;所有源方法共享同一次执行预算。
let function = crate::vm_sequence::repeat_call(cx, advance, "source paint", self.desktop)?;
Ok(NativeResult::Invoke {
function,
@@ -259,7 +290,9 @@ impl NativeCallable for Prepare {
/// 安排下一个出现的绘制调用,保持原始绘制顺序和共享 VM 预算。
struct Advance {
desktop: ObjectRef,
// Prepare 移交的固定顺序列表,在 VM 切片之间保持不变;游标只向前推进。
mounts: Arc<[(u64, WeakObjectRef)]>,
// 下一个待准备出现的下标;不是已输出命令数,跳过原生 paint 的出现也占一项。
next: Mutex<usize>,
output: Output,
}
@@ -276,6 +309,7 @@ impl NativeCallable for Advance {
.next
.lock()
.map_err(|_| invalid("paint cursor poisoned"))?;
// 复制当前项后只在存在该项时递增,列表结束后保持游标不再增长。
let occurrence = self.mounts.get(*next).copied();
*next += usize::from(occurrence.is_some());
occurrence
@@ -284,17 +318,22 @@ impl NativeCallable for Advance {
let Some((id, weak)) = occurrence else {
return Ok(NativeResult::Return(Value::Bool(false)));
};
// 进入本次出现时再升级弱引用;失效视为本帧上下文错误,不跳过后继续发布不完整画面。
let mount = cx
.upgrade(weak)?
.ok_or(invalid("paint Mount has expired"))?;
// 克隆原生 Mount 描述后再分配临时 Bounds,避免持有跨 VM 可变操作的 store 借用。
let data = cx.store().payload::<Mount>(mount, "Mount")?.clone();
// 身份仍存活不代表仍挂载;已经脱离出现树的 Mount 不能继续绘制。
if !data.mounted {
return Err(invalid("paint Mount is detached"));
}
// 从当前业务 Block 的类型解析 paint,支持源程序覆盖方法;不把旧布局快照当成方法定义缓存。
let definition = cx
.store()
.type_of(data.block)?
.ok_or(invalid("untyped paint target"))?;
// lookup_method 沿类型查找方法;缺失时明确报错,原生默认方法由下方 is_intrinsic 分支识别。
let function = cx
.store()
.lookup_method(definition, "paint")?
@@ -306,6 +345,7 @@ impl NativeCallable for Advance {
// 把已提交 Mount 几何封装为本轮临时 Bounds;新分配参数可初始化,业务模型仍处于只读域。
let bounds = cx.new_object()?;
attach_desktop_type(cx, bounds, "Bounds", self.desktop)?;
// 按布局绝对坐标填充 x/y/width/height;这些临时属性可初始化,不表示源方法能修改已有业务模型。
for (name, value) in [
("x", data.geometry.x),
("y", data.geometry.y),
@@ -337,6 +377,7 @@ impl NativeCallable for Advance {
"paint occurrence",
self.desktop,
)?;
// 交回 VM 执行这个零参数辅助函数,源方法及收集器仍由 VM 调用栈驱动,不在 Rust 中递归解释。
Ok(NativeResult::Invoke {
function,
arguments: vec![],
@@ -344,44 +385,54 @@ impl NativeCallable for Advance {
}
}
/// 已经完成本阶段、但尚未由桥接层发布的派生结果,包含帧/缓存或真实读取依赖。
/// 源绘制完成后的未发布结果:已装配帧、业务读取依赖和调度切片数。
pub struct Draft {
// 已经合并命令的原生帧;Draft 仍需桥接层提交,完成计算不等于发布成功。
// 已经合并命令的原生帧;桥接层仍需登记绘制依赖,运行时仍需完成文档验证与稳定性检查,不等于 GUI 已呈现。
pub frame: Frame,
// 保留源绘制的业务读取依赖,供下次写入后判定是否必须重绘。
pub reads: Vec<endor_core::ReadDependency>,
// 包括 start 的第一片和每次 resume;供桥接层记录实际绘制切片数。
pub slices: usize,
}
#[must_use = "pending paint must be resumed or cancelled on its owning VM"]
/// 本次推进处于挂起还是完成;挂起工作必须继续恢复或显式取消。
pub enum Progress {
// 运行时暂存此句柄,后续在原 VM 继续或取消,不能当作可提交帧。
Pending(Box<Work>),
// 只读调用结束且命令装配完成;UI bridge 仍需更新依赖,endor_host 再发布文档。
Complete(Box<Draft>),
}
/// Only unpublished native commands and an owning prepare root cross a pause.
/// 拥有未完成源调用及临时根的工作句柄;丢弃 Rust 值不能替代 VM 内的取消。
pub struct Work {
// 所属窗口的桌面根,用于完成时过滤宿主记账读取;prepare 的 VM roots 维持其生命周期。
desktop: ObjectRef,
// 跨切片保留的入口根;完成或取消都必须在所属 VM 中显式释放。
prepare: ObjectRef,
// 先前切片已收集的不可变原生命令;恢复续体时延续同一输出,不从头重跑已完成覆盖。
output: Output,
// 布局阶段的不可变视觉根,finish 通过它补齐未被源覆盖的原生绘制。
root: Option<Arc<UiNode>>,
// 尚未发布的布局帧,装配命令只修改这个工作副本。
frame: Frame,
slices: usize,
}
impl Work {
/// 继续当前绘制续体,保留其先前收集的命令和所属窗口上下文。
// ui_bridge::resume_paint_frame 传入原 VM 和切片预算,endor_host 保存返回的新 Pending 或处理 Complete。
pub fn resume(mut self, vm: &mut Vm, slice: usize) -> Result<Progress> {
// 恢复保留原执行预算,slice 只决定本次让出的指令配额,不能重置整个绘制上限。
self.slices += 1;
// 调用 VM 恢复同一个只读续体,结果统一交 accept 处理,避免开始/恢复使用不同收尾路径。
let progress = vm.resume_read_only_call(slice);
self.accept(vm, progress)
}
/// 关闭未完成绘制续体并释放其临时根,不发布半完成命令列表。
// ui_bridge 的窗口切换、输入替换或退役清理路径调用;消费 Work,禁止继续恢复同一个句柄。
pub fn cancel(self, vm: &mut Vm) -> Result<()> {
// 先执行取消和 release 两个操作,再依次传播错误;取消失败也仍尝试释放入口根。
let cancelled = vm.cancel_read_only_call();
@@ -397,6 +448,7 @@ impl Work {
vm: &mut Vm,
progress: Result<endor_core::ExecutionProgress, VmError>,
) -> Result<Progress> {
// 三种结果分别保留 Work、生成 Draft 或清理后返回诊断;只有 Complete 才进入 finish。
match progress {
// 挂起时工作句柄继续持有 frame/output/prepare,不能发布部分输出。
Ok(endor_core::ExecutionProgress::Pending { .. }) => {
@@ -423,6 +475,7 @@ impl Work {
}
/// 建立本轮只读绘制驱动和结果收集器;源覆盖与原生默认绘制最终保持同一出现顺序。
// ui_bridge::begin_paint_frame 在布局完成后调用;mounts 顺序来自 frame,root/frame 均是未发布的本轮草稿。
pub fn start(
vm: &mut Vm,
desktop: ObjectRef,
@@ -437,7 +490,9 @@ pub fn start(
!vm.has_suspended_call() && !vm.object_store().is_read_only(),
"cannot start paint while another read-only execution is active"
);
// 为整轮创建一个结果容器,跨所有 Mount 和所有挂起切片共享。
let output = Arc::new(Mutex::new(PaintOutput::default()));
// 分配有根的驱动入口;显式捕获 desktop,Work 负责完成或取消后释放入口根。
let prepare = vm.allocate(ContextNativeFunction::new(
"prepare source paint",
Prepare {
@@ -447,6 +502,7 @@ pub fn start(
},
vec![desktop],
))?;
// 在进入 VM 前绑定输入草稿、输出和入口根;即使第一片马上完成也走同一个 Work::accept。
let work = Work {
desktop,
prepare,
@@ -455,6 +511,7 @@ pub fn start(
frame,
slices: 1,
};
// 限制总帧深与总指令数,slice 只切分调度时间;源方法不能借多次 resume 重置总预算。
let result = vm.start_read_only_call(
prepare,
&[],
@@ -468,7 +525,9 @@ pub fn start(
}
/// 按 Mount 合并源覆盖和原生命令,补齐投影/裁剪表,再递归装配世界 UI 面板。
// 仅由 Work::accept 在 VM Complete 后调用,此时 prepare 已释放;输出容器中只有成功解码的完整覆盖。
fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -> Result<Progress> {
// 加锁读取整轮收集结果;finish 不再调用源 paint,锁不会跨越新的源执行。
let output = work
.output
.lock()
@@ -483,20 +542,26 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
slices: work.slices,
})));
}
// 根已在上面检查存在;借用 Arc 内节点建立索引,不把原始 UiNode 所有权转入新命令。
let root = work.root.as_deref();
// 保存桌面身份供末尾依赖过滤,frame 则可单独可变借用以写入新命令表。
let desktop = work.desktop;
let frame = &mut work.frame;
// 建立 Block ID 到不可变节点的索引;最终合并顺序仍以 frame.mounts 为准,不能按 map 排序。
let mut nodes = HashMap::new();
// 显式栈遍历视觉树,节点按 Block 文本身份索引;此遍历顺序不决定最终绘制顺序。
let mut pending: Vec<_> = root.into_iter().collect();
// 每个节点入表并展开其子节点,稍后每个 Mount 用 block_id 查回该节点的默认视觉。
while let Some(node) = pending.pop() {
nodes.insert(node.block_id.as_str(), node);
pending.extend(node.children().iter().map(|node| node.as_ref()));
}
// 四个输出数组同步增长:命令、裁剪、二维变换、出现归属;不能单独过滤其中某一表。
let mut commands = Vec::new();
let mut clips = Vec::new();
let mut views = Vec::new();
let mut owners = Vec::new();
// 最终顺序由布局帧的出现序列决定,既保留层叠顺序,也允许同一 Block 对应多个 Mount。
for mount in &frame.mounts {
// 记录本出现追加前的长度,用差值为新增命令同步扩展 clip/view/owner 三张平行表。
let before = commands.len();
@@ -504,6 +569,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
if let Some(custom) = output.commands.get(&mount.id) {
commands.extend(custom.iter().cloned());
} else {
// 无源覆盖时必须找到原生视觉节点;缺失说明布局/视觉投影不一致,不能默默漏画。
let node = nodes
.get(mount.block_id.as_str())
.ok_or_else(|| anyhow::anyhow!("paint projection has no node"))?;
@@ -517,6 +583,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
},
));
}
// 每条新增命令继承当前出现的裁剪、变换和 Mount ID,后续面板装配依赖这些平行元数据。
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));
@@ -525,18 +592,21 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
bail!("paint command budget exceeded");
}
}
// 全部 Mount 成功合并后再替换草稿帧的旧命令与元数据;尚未发布给窗口。
frame.commands = commands;
frame.command_clips = clips;
frame.command_views = views;
frame.command_mounts = owners;
// 统一命令和投影元数据就绪后,再把世界 UI 面板装配进对应 3D 场景。
crate::scene3d::panel::compose(frame)?;
// 类型注册表读取属于宿主检查,过滤它避免把内部类型查找当成业务 paint 依赖。
let registry = object(vm.get_attr(desktop, "types")?)?;
// 排除宿主桌面、类型注册表、Mount 以及已经死亡的临时身份,只保留业务读取。
// 纯原生帧不需要保留这次驱动的读取记录。
let reads = reads
.into_iter()
.filter(|dependency| {
// 对每条读取取所属对象:无源覆盖时全部丢弃,否则剔除宿主桌面、注册表、Mount 和已死亡身份。
let object = dependency.object();
!native_only
&& object != desktop
@@ -546,6 +616,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
.is_ok_and(|object| !object.payload().as_any().is::<Mount>())
})
.collect();
// 返回完整但未发布的草稿;桥接层接着记录依赖和切片数,运行时再完成帧提交。
Ok(Progress::Complete(Box::new(Draft {
frame: work.frame,
reads,
+66
View File
@@ -14,6 +14,7 @@ use crate::display_list::Color;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
/// 界面比例字体与源码等宽字体的选择,不直接指定某个必然安装的系统字体。
// TextStyle 携带字体选择,TextEngine::prepare_width 将其解析为实际字体族;Custom ID 由 register_font 返回。
pub enum TextFamily {
Interface,
Code,
@@ -24,13 +25,17 @@ pub enum TextFamily {
#[derive(Clone, Copy, Debug)]
/// 字号、行高和字体类别,测量与绘制必须使用兼容样式。
pub struct TextStyle {
// 目标坐标空间中的字体像素尺寸;scaled 同时缩放它与 line_height,以保持排版比例。
pub font_size: f32,
// 基线之间的布局高度,随同 font_size 缩放;空文本的测量也至少保留这一行高度。
pub line_height: f32,
// 字体发现策略或 register_font 返回的进程级 Custom ID,不是某个 VM 的托管字体对象身份。
pub family: TextFamily,
}
impl TextStyle {
/// 生成界面文字的基准字号与行高,size 是协议使用的字号倍率。
// 布局默认文本与宿主 UI 使用,协议等级乘基础 14/20 得到字号及行高;构造本身不验证等级范围。
pub fn ui(size: u32) -> Self {
Self {
font_size: 14.0 * size as f32,
@@ -40,6 +45,7 @@ impl TextStyle {
}
/// 生成源码/Console 使用的等宽风格基础尺寸。
// 侧栏、Console 和源码预览使用的等宽样式,实际字体由引擎启动时发现。
pub fn code() -> Self {
Self {
font_size: 13.0,
@@ -49,6 +55,7 @@ impl TextStyle {
}
/// 同比缩放字号与行高,保持设备比例变化后的排版比例一致。
// display_list 的设备比例、页面缩放和 draw_clipped 的字形 LOD 都用同一缩放操作。
pub fn scaled(self, scale: f32) -> Self {
Self {
font_size: self.font_size * scale,
@@ -61,13 +68,16 @@ impl TextStyle {
#[derive(Clone, Copy, Debug)]
/// 整形后实际占用的逻辑文字尺寸。
pub struct TextSize {
// 整形各行实际宽度的最大值;无换行与给定换行宽度的结果来自不同缓存键。
pub width: f32,
// 至少一行高,并覆盖最下方布局行的底部;屏幕裁剪不会截短这个测量结果。
pub height: f32,
}
#[derive(Clone, Hash, PartialEq, Eq)]
/// 文字整形缓存键,包含文本、样式和可选换行宽度。
struct TextKey {
// 缓存拥有文本副本,命中比较全部文本内容;不会借用源文件或 VM 字符串的生命周期。
value: String,
// 用浮点原始位模式做键,精确区分整形参数;不是把字号取整为 u32。
font_size: u32,
@@ -85,10 +95,15 @@ struct PreparedText {
/// 共享字体系统、字形缓存和整形缓存;调用通过全局锁串行访问。
struct TextEngine {
// 全进程共享的系统/自定义字体库;Mutex 同时串行保护发现、注册、整形与栅格缓存访问。
fonts: FontSystem,
// 已注册字体族名与内容哈希,数组下标就是 TextFamily::Custom ID,最多保留 32 个族。
custom: Vec<(String, u64)>,
// 启动时选中的等宽族名;None 时使用 FontSystem 的 Monospace 策略并保留字符回退。
code_family: Option<String>,
// 字形栅格缓存与整段 Buffer 缓存分开维护,draw_clipped 根据位图条数/字节量整批清理。
swash: SwashCache,
// 文本、浮点样式和换行宽度完整匹配才复用 Buffer;整批清理后不保留逐 VM 的历史缓存。
buffers: HashMap<TextKey, PreparedText>,
// 累计缓存文本的 UTF-8 字节数,只衡量源文本预算,并非 Buffer/字形的总内存。
source_bytes: usize,
@@ -96,6 +111,7 @@ struct TextEngine {
impl TextEngine {
/// 扫描系统字体,选择可用的界面/等宽字体偏好,并建立空整形和栅格缓存。
// engine 的 OnceLock 首次使用时调用,加载系统字体只做一次,测量与绘制随后共享此实例。
fn new() -> Self {
let mut fonts = FontSystem::new();
// Select installed, readable families rather than assuming an OS font
@@ -110,6 +126,7 @@ impl TextEngine {
.then(|| (*choice).to_owned())
})
};
// 界面族按安装情况选第一个可用偏好,不保证每个平台得到同一个字体或相同逐像素结果。
let sans = choose(&[
".AppleSystemUIFont",
"SF Pro Text",
@@ -119,10 +136,13 @@ impl TextEngine {
"DejaVu Sans",
"Liberation Sans",
]);
// 保留等宽族名供 Code 使用;不存在偏好族时回退 FontSystem 的 Monospace。
let mono = choose(&["Menlo", "Consolas", "DejaVu Sans Mono", "Liberation Mono"]);
// 只有找到已安装的偏好族才替换字体库默认 sans-serif 配置。
if let Some(name) = sans {
fonts.db_mut().set_sans_serif_family(name);
}
// 代码族配置与保存的 code_family 保持一致,具体字符缺失仍由整形系统做字体回退。
if let Some(name) = &mono {
fonts.db_mut().set_monospace_family(name);
}
@@ -137,11 +157,13 @@ impl TextEngine {
}
/// 对不指定换行宽度的文本复用统一整形准备过程。
// measure 的无换行入口,实际整形和缓存逻辑统一委托 prepare_width,避免另建自然宽度算法。
fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey {
self.prepare_width(value, style, None)
}
/// 为文本、字体样式和可选换行宽度建立整形缓存,超过缓存预算先清理旧结果。
// measure_wrapped 和 draw_clipped 共用;调用方已持引擎锁,返回键可直接用于访问 buffers 中的结果。
fn prepare_width(&mut self, value: &str, style: TextStyle, wrap_width: Option<f32>) -> TextKey {
// 键包含全部排版输入;命中后测量与绘制都复用同一 Buffer,避免两套排版产生偏差。
let key = TextKey {
@@ -151,6 +173,7 @@ impl TextEngine {
family: style.family,
wrap_width: wrap_width.map(f32::to_bits),
};
// 完全相同的文本、样式与宽度复用已整形 Buffer,返回前不再次累计 source_bytes。
if self.buffers.contains_key(&key) {
return key;
}
@@ -161,6 +184,7 @@ impl TextEngine {
self.buffers.clear();
self.source_bytes = 0;
}
// Custom 直接用注册 ID 查族名,因此调用方应只使用 register_font 返回的有效 ID。
let family = match style.family {
TextFamily::Interface => Family::SansSerif,
TextFamily::Custom(id) => Family::Name(&self.custom[id as usize].0),
@@ -171,6 +195,7 @@ impl TextEngine {
.as_deref()
.map_or(Family::Monospace, Family::Name),
};
// 先建立对应字体尺寸/行高的 Buffer,再设置换行宽度、文本和对齐,最后执行整形。
let mut buffer = Buffer::new(
&mut self.fonts,
Metrics::new(style.font_size, style.line_height),
@@ -196,10 +221,12 @@ impl TextEngine {
width: 0.0,
height: style.line_height,
};
// 逐行汇总最大实际宽度和最下行底,不按字符数量估算中文、比例字或换行高度。
for run in buffer.layout_runs() {
size.width = size.width.max(run.line_w);
size.height = size.height.max(run.line_top + run.line_height);
}
// 只在新整形条目插入时累计源文本字节;返回克隆键供调用方马上取测量值或字形。
self.source_bytes += value.len();
self.buffers
.insert(key.clone(), PreparedText { buffer, size });
@@ -215,13 +242,16 @@ fn engine() -> &'static Mutex<TextEngine> {
}
/// 返回不受换行宽度约束时的字体整形尺寸。
// layout 的原生文本测量、宿主控件尺寸及 fit 的宽度比较共用此入口,不设置自动换行宽度。
pub fn measure(value: &str, style: TextStyle) -> TextSize {
let mut engine = engine().lock().expect("text engine lock");
// prepare 确保当前缓存里存在此键,借用的 size 可复制返回而不把 Buffer 或锁带给调用方。
let key = engine.prepare(value, style);
engine.buffers[&key].size
}
/// 按可用宽度整形并测量多行高度,供布局与实际段落绘制共用。
// layout 在内容宽度约束下调用,完整段落高度用于布局;屏幕 clip 不影响测量宽度或行数。
pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
// 零或负可用宽度直接返回零宽与一行高,避免给整形器无效换行宽度。
if width <= 0.0 {
@@ -239,6 +269,7 @@ pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
/// bitmap and sample it at the requested size instead of allocating an enormous
/// font image. Layout and wrapping still use proportionally identical metrics.
/// 限制字形分辨率并只采样可见像素;保留文字布局比例,同时避免极端相机缩放产生巨型位图。
// software_renderer 的普通/换行文字路径调用,clip 和回调坐标都相对文字原点,最终平移与混合由 Canvas 负责。
pub fn draw_clipped(
value: &str,
style: TextStyle,
@@ -247,6 +278,7 @@ pub fn draw_clipped(
clip: crate::geometry::Rect,
mut pixel: impl FnMut(i32, i32, Color),
) {
// 空裁剪或无效字号直接跳过,不进入字体锁和字形位图生成;其他样式约束由调用边界保证。
if clip.width <= 0.
|| clip.height <= 0.
|| !style.font_size.is_finite()
@@ -255,9 +287,11 @@ pub fn draw_clipped(
return;
}
// 超大字号按比例降到最高 512 再栅格化;普通字号保持 1。
// LOD 限制的是中间字形位图分辨率;输出坐标仍按请求尺寸采样,既不是把页面缩小,也不改变调用方布局盒的尺寸。
// 字体、行高和换行宽度一起缩小,后续用 1/lod 恢复目标几何比例。
let lod = (512. / style.font_size).min(1.);
let mut engine = engine().lock().expect("text engine lock");
// 用同一 lod 缩放字号、行高和可选换行宽度,在低分辨率整形后以倒 lod 恢复输出空间。
let key = engine.prepare_width(value, style.scaled(lod), width.map(|width| width * lod));
// 拆借可变字段以便在保留 Buffer 借用时访问字体和 Swash 缓存,不复制整形结果。
let TextEngine {
@@ -266,6 +300,7 @@ pub fn draw_clipped(
buffers,
..
} = &mut *engine;
// 以下遍历只读已整形 Buffer,同时分开借用 fonts/swash 生成字形,不克隆整段布局。
let buffer = &buffers[&key].buffer;
// 从已有 image_cache 统计位图字节;不包含整形 Buffer,也不含字体数据库本身。
let mut cache_bytes: usize = swash
@@ -274,8 +309,10 @@ pub fn draw_clipped(
.flatten()
.map(|image| image.data.len())
.sum();
// 逐行保留基线位置,再逐个整形字形处理;一个源字素不一定对应一个渲染字形。
for run in buffer.layout_runs() {
for glyph in run.glyphs {
// 将字形布局转换为可缓存的物理位置/键,run.line_y 提供该行基线偏移。
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.
@@ -283,6 +320,7 @@ pub fn draw_clipped(
let guard = glyph.font_size * 4. / lod;
let gx = physical.x as f32 / lod;
let gy = physical.y as f32 / lod;
// 粗筛完全远离 clip 的字形可省下位图生成;通过粗筛后仍用实际 placement 做精确可见范围。
if gx + guard < clip.x
|| gx - guard > clip.x + clip.width
|| gy + guard < clip.y
@@ -296,10 +334,12 @@ pub fn draw_clipped(
cache_bytes = 0;
}
// 只对新生成的图像增加字节统计;缓存中的生成失败项由 None 跳过,不参与绘制。
// get_image 可能命中或新生成;只给新条目累加数据字节,避免重复命中把预算重复计费。
let cached = swash.image_cache.contains_key(&physical.cache_key);
let Some(image) = swash.get_image(fonts, physical.cache_key) else {
continue;
};
// 生成成功才增加位图数据长度;无可用图像的字形在上面 None 分支直接跳过。
if !cached {
cache_bytes += image.data.len();
}
@@ -322,6 +362,7 @@ pub fn draw_clipped(
.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));
// u/v 是位图内像素下标,先计算单像素索引;不同 content 分支再决定每像素字节数。
let index = (v * image.placement.width + u) as usize;
// 灰度 Mask 把覆盖率乘整体 alpha;Color 保留位图 RGB 并乘其 alpha。
// SubpixelMask 不适用此软件合成路径,直接跳过。
@@ -332,6 +373,7 @@ pub fn draw_clipped(
base.blue,
(u16::from(image.data[index]) * u16::from(color.alpha) / 255) as u8,
),
// 彩色字形每像素占 RGBA 四字节,保留位图 RGB 而非普通文字的 base RGB。
cosmic_text::SwashContent::Color => {
let i = index * 4;
Color::rgba(
@@ -353,6 +395,7 @@ pub fn draw_clipped(
/// Fit a single displayed source line without slicing a UTF-8/grapheme cluster.
/// 按真实字体测量截断到给定宽度,以字素簇边界拼接省略号,避免切开组合字符。
// 侧栏和工具的单行展示调用,结果只用于预览;不会改源文本、AST 范围或编辑器存储。
pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
use unicode_segmentation::UnicodeSegmentation;
@@ -361,6 +404,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
// 仅预览截断至约 8 KiB 字素边界,不改保存的源码。单个超长字素也不能被拆开。
let truncated = value.len() > 8192;
let value = if truncated {
// 在 8 KiB 内选择最后一个字素起点作为切片边界,超长首字素可能让预览正文变空。
let end = value
.grapheme_indices(true)
.map(|(index, _)| index)
@@ -376,6 +420,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
return value.to_owned();
}
let ellipsis = "…";
// 没有正宽度或连省略号都放不下时返回空串,不输出超出分配宽度的占位符。
if width <= 0.0 || measure(ellipsis, style).width > width {
return String::new();
}
@@ -384,6 +429,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
.grapheme_indices(true)
.map(|(start, part)| start + part.len())
.collect();
// 二分上下界是完整字素数量,不是 UTF-8 字节数;真正切片位置由 ends 转换。
let mut low = 0;
let mut high = ends.len();
// 二分搜索可容纳的最长字素前缀;取上中位数使成功分支能推进 low,避免相邻边界时死循环。
@@ -392,6 +438,7 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
let end = ends[count - 1];
// 省略号也参与真实整形测量,不能简单从总宽度减去字符数估算。
let candidate = format!("{}{ellipsis}", &value[..end]);
// 每次候选连同省略号一起整形测量;成功提高下界,失败排除当前长度及更长候选。
if measure(&candidate, style).width <= width {
low = count;
} else {
@@ -405,19 +452,27 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
/// Register an explicit face for graphics resources. Repeated identical loads
/// reuse the family; conflicting content and unbounded global growth fail.
/// 注册显式字体族并返回稳定 ID;同名但内容哈希不同,或超过族数量预算时拒绝注册。
// graphics 的字体资源 API 读取有限字体数据后调用,返回可放入绘制样式的稳定族 ID。
pub fn register_font(bytes: Vec<u8>, family: &str) -> anyhow::Result<TextFamily> {
// 引擎和 32 族配额属于整个进程,关闭或替换某个 VM 不会清此注册表;资源句柄只保存返回的 Custom ID。
use std::hash::{Hash, Hasher};
// 内容哈希与族名用于重复注册检查,哈希留在内存中,不替代文件路径或字体族名。
let mut hash = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hash);
let hash = hash.finish();
// 字体注册与整形/绘制共用全局锁,修改字体数据库期间不会有另一调用同时使用旧数据库。
let mut e = engine().lock().expect("text engine lock");
// 同名同哈希复用原 ID,同名不同内容直接报错,不替换已经发布给样式的字体身份。
if let Some((id, (_, old))) = e.custom.iter().enumerate().find(|(_, v)| v.0 == family) {
anyhow::ensure!(*old == hash, "font family already has different content");
return Ok(TextFamily::Custom(id as u32));
}
anyhow::ensure!(e.custom.len() < 32, "font registry exceeds 32 families");
// 保存数据库副本用于请求族名验证失败时回滚;成功注册才写 custom 和清整形缓存。
let previous = e.fonts.db().clone();
e.fonts.db_mut().load_font_data(bytes);
// 加载后检查字体库是否存在请求族名;这里按整个库查询,不是单独解析此次文件的族名列表。
if !e
.fonts
.db()
@@ -427,6 +482,7 @@ pub fn register_font(bytes: Vec<u8>, family: &str) -> anyhow::Result<TextFamily>
*e.fonts.db_mut() = previous;
anyhow::bail!("font data does not contain requested family");
}
// 尾部追加保持既有 ID 不变;字体库变化后旧 Buffer 可能引用旧选择,因此清空整形结果。
let id = e.custom.len() as u32;
e.custom.push((family.into(), hash));
e.buffers.clear();
@@ -443,6 +499,7 @@ mod tests {
/// 用一万倍缩放检验绘制回调被限制在32x32裁剪内,同时检查字形缓存字号不超过512以控制中间资源。
fn extreme_zoom_rasterizes_only_the_clip_with_bounded_glyph_sizes() {
// 极端缩放测试限制实际回调在 32×32 裁剪区,同时检查缓存字号不超过 512。
// 统计实际输出回调数,坐标断言验证只遍历 32×32 可见区域,不按巨型字形面积扫描。
let mut count = 0;
draw_clipped(
"M",
@@ -457,6 +514,7 @@ mod tests {
},
);
assert_eq!(count, 1024);
// 绘制结束已释放全局锁,再获取它检查真实字形缓存键中的字号,而非只看局部 lod 变量。
let engine = engine().lock().unwrap();
assert!(
engine
@@ -473,6 +531,7 @@ mod tests {
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);
let wide = measure_wrapped(text, style, 240.0);
@@ -481,7 +540,9 @@ mod tests {
assert!(narrow.width <= 70.1);
assert_eq!(measure(text, style).width, intrinsic.width);
assert_eq!(measure_wrapped(text, style, 0.0).width, 0.0);
// 检查真实缓存键和 Buffer 高度,除了测量结果还验证宽度确实参与缓存区分。
let mut engine = engine().lock().unwrap();
// 文本与样式相同,仅 Some(width) 不同,键及排版高度都应不同。
let narrow_key = engine.prepare_width(text, style, Some(70.0));
let wide_key = engine.prepare_width(text, style, Some(240.0));
assert!(narrow_key != wide_key);
@@ -497,6 +558,7 @@ mod tests {
let mono = TextStyle::code();
assert!((measure("WWWW", mono).width - measure("iiii", mono).width).abs() < 0.1);
assert_eq!(measure("one\ntwo", ui).height, ui.line_height * 2.0);
// 以实际整形结果为基准检查同比例字号的宽度变化,容许少量字体整形舍入差异。
let width = measure("Hello Endor", ui).width;
assert!((measure("Hello Endor", ui.scaled(1.25)).width - width * 1.25).abs() < 0.5);
}
@@ -506,8 +568,10 @@ mod tests {
fn system_fallback_shapes_chinese_without_missing_glyphs() {
let mut engine = engine().lock().unwrap();
// 两种字体风格都要求四个中文字符生成非零 glyph_id,确保系统回退实际找到了字形。
// 界面与代码字体各检查一次中文回退,缺少环境 CJK 字体时该测试会暴露缺字,不能只凭非零宽度判断。
for style in [TextStyle::ui(1), TextStyle::code()] {
let key = engine.prepare("中文字体", style);
// 直接查看整形 glyph_id,ID=0 表示缺字;中文用例期望四个字符各有可用字形。
let glyphs: Vec<_> = engine.buffers[&key]
.buffer
.layout_runs()
@@ -523,10 +587,12 @@ mod tests {
/// 按实际测量宽度截断带组合字符的文本,结果需是原文字素边界前缀加省略号,且整体不超宽。
fn ellipsis_fits_actual_width_and_preserves_graphemes() {
let style = TextStyle::code();
// 短代码行能完全放下时应原样返回,随后再测试带组合音标的必须截断输入。
let short_source = "let s = ui.text(\"中文字体\")";
assert_eq!(fit(short_source, 365.0, style), short_source);
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();
+50
View File
@@ -1,69 +1,103 @@
//! Width-independent word preparation and variable-width line filling. The
//! exclusion algorithm owns geometry; this module only consumes text in order.
//! graphics 的 Prepare 在这里生成可复用词/字素图像,Flow 每行提供避让后的区间,由 row 按文本顺序消费。
//! 字形资源不随列宽重新生成;Cursor 属于每次 Flow,放不下的内容留待后续区间或下一行。
use crate::{display_list::Color, geometry::Rect, render::image::Image, text::TextStyle};
use std::{collections::HashMap, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
/// 一段文字的不可变墨迹资源;整词和单字素都用同一结构,graphics::flow 按 Placement 引用它生成图片命令。
pub struct Ink {
// Image::text 产生的 RGBA 图片;Arc 允许重复文本和多次 Flow 共享像素,不为每个摆放位置复制。
pub image: Arc<Image>,
// 图片相对文字原点的逻辑矩形,包含负方向字形留白;不能只按 advance 宽度当作图片框。
pub rect: Rect,
// 字体测量的逻辑 advance,排版推进使用它;图片像素宽度还包含 scale 与留白。
pub width: f32,
}
#[derive(Debug)]
/// Unicode 换行机会之间的文字段,不只代表英文词;同时保留整段墨迹和按字素拆分的后备墨迹。
pub struct Word {
// 能完整容纳时使用整段图像,保留整段整形的字距与连字效果。
pub ink: Arc<Ink>,
// 按扩展字素簇生成的资源,避免拆开组合音标或 emoji;拆分模式的宽度来自逐字素独立测量。
pub letters: Vec<Arc<Ink>>,
// 该段末尾空白的逻辑测量宽度,放完正文后推进 x;换行符不参与这份空白测量。
pub space: f32,
// 原段以换行符结尾,消费完该段就结束当前 row,即使本行还有其他可用区间。
pub newline: bool,
}
#[derive(Debug)]
/// 与列宽无关的预排资源;graphics 的 TextResource 持有 Arc<Prepared>,每次 Flow 单独创建游标。
pub struct Prepared {
// 按 Unicode 换行机会分段的有序资源;行带不足时 Cursor 停在未消费段,资源本身不保存跨调用消费状态。
pub words: Vec<Word>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
/// 下一个待消费段及段内字素位置;Default 从 (0,0) 开始,跨一行的多个区间和后续行持续推进。
pub struct Cursor {
// 下一段在 Prepared.words 中的下标;等于 words.len() 表示文本已消费完毕。
pub word: usize,
// 为零表示尚未拆分/位于段首,非零表示前面字素已放置,不能再回头画整词。
pub letter: usize,
}
/// row 追加的纯几何结果,graphics::flow 随后转为 PaintCommand;不在排版循环内分配托管命令对象。
pub struct Placement {
// 指回 Prepared.words 的资源索引,不是图像像素下标;Flow 据此选择整段或字素 Ink。
pub word: usize,
// None 指整词墨迹,Some 指该词某一字素;不能把整词位置再当成已经拆分的单字素。
pub letter: Option<usize>,
// 本次 Flow 坐标空间中的文字逻辑原点;实际图片命令还会叠加 Ink.rect 的留白偏移。
pub x: f32,
pub y: f32,
}
impl Prepared {
/// graphics 的 Prepare 分支在校验文本长度、样式和设备比例后调用;这里再限制本份预排图片累计像素。
pub fn new(
value: &str,
style: TextStyle,
scale: f32,
color: Color,
) -> Result<Self, &'static str> {
// 缓存只在这一份 Prepared 构造期间存在,按文本复用整词/字素资源;同文样式固定,无需额外样式键。
let mut cache: HashMap<String, Arc<Ink>> = HashMap::new();
// 累计唯一墨迹图片面积,u64 乘法避免在统计前溢出;重复文本命中不重复计费。
let mut pixels = 0_u64;
// 整段和字素走同一图片构造闭包,共享资源检查与去重,不另外维护两套缓存。
let mut ink = |text: &str| -> Result<Arc<Ink>, &'static str> {
// 重复内容直接克隆 Arc;文本相同的段与字素也能共享同一张图。
if let Some(ink) = cache.get(text) {
return Ok(ink.clone());
}
// 先生成图像、逻辑留白框及 advance,再核算累计像素;单图自身预算由 Image::text 检查。
let (image, rect, width) = Image::text(text, style, scale, color)?;
pixels += u64::from(image.width) * u64::from(image.height);
// 超过本份预排的八百万级像素上限即返回错误,不发布部分 Prepared;本次生成图片也计入。
if pixels > 8_388_608 {
return Err("prepared text exceeds 8M pixel cache budget");
}
// 图片只封装一次,缓存和 Word 分别通过 Arc 保留;闭包的临时 HashMap 退出后资源仍由结果持有。
let ink = Arc::new(Ink { image, rect, width });
cache.insert(text.to_owned(), ink.clone());
Ok(ink)
};
let mut words = Vec::new();
// 保留段末换行符,使换行意图不会因分段丢失;空输入不产生任何 Word。
for para in value.split_inclusive('\n') {
// Unicode 换行迭代器给出字节结束位置,start 跟随推进以逐段切取 UTF-8 安全文本。
let mut start = 0;
// 使用每个换行机会作为可整段放置的单元;机会类型本身未参与判断,显式换行另由 newline 字段保留。
for (end, _) in unicode_linebreak::linebreaks(para) {
let part = &para[start..end];
// 把尾部空白与可见正文分开,正文生成墨迹,末尾空白只作为布局 advance。
let body = part.trim_end_matches(char::is_whitespace);
// 去除 CR/LF 后测尾部空白;没有把换行当成一行可绘制的空格宽度。
let space =
crate::text::measure(part[body.len()..].trim_matches(['\n', '\r']), style)
.width;
// 同时预生成整词和各字素;后续列宽或避让区间变化只改摆放,不再次调用字体栅格化。
words.push(Word {
ink: ink(body)?,
letters: body
@@ -81,6 +115,7 @@ impl Prepared {
/// The same cursor crosses left/right fragments. Narrow gaps never discard
/// a word; a word wider than every remaining fragment falls back to graphemes.
/// graphics::flow 对每条行带调用,ranges 由障碍几何计算并按从左到右给出;y 是整行文字原点纵坐标。
pub fn row(
&self,
cursor: &mut Cursor,
@@ -88,18 +123,26 @@ impl Prepared {
y: f32,
out: &mut Vec<Placement>,
) {
// 同一游标穿过一行所有碎片,ri 用于查看当前及后续区间原始宽度,防止过早拆词。
// ranges 的排序/有限性/最小宽度和跨行高度预算由 graphics::flow/ranges 保证;这里不搜索障碍,也不自行创建下一行。
for (ri, &(left, right)) in ranges.iter().enumerate() {
// 进入新区间从左边界开始,上一碎片的横向剩余空间不累计到这里。
let mut x = left;
// 仍有未消费文本才尝试摆放;每个成功分支推进词或字素,放不下则 break 到下一区间。
while let Some(word) = self.words.get(cursor.word) {
// 空正文不生成 Placement,也不推进其 space;若是空白换行段则只消费它并结束当前行。
if word.letters.is_empty() {
cursor.word += 1;
cursor.letter = 0;
// 显式换行结束整个 row,不只是当前碎片;下次调用从下一个词继续。
if word.newline {
return;
}
continue;
}
// 取当前及后续区间的完整最大宽度,不是仅当前剩余 right-x;用来判断整词能否留给稍后碎片/行。
let remaining_max = ranges[ri..].iter().map(|(l, r)| r - l).fold(0., f32::max);
// 只有尚未拆分且当前余宽容得下整词才走整词模式,0.001 容差吸收浮点边界误差。
if cursor.letter == 0 && word.ink.width <= right - x + 0.001 {
out.push(Placement {
word: cursor.word,
@@ -107,15 +150,20 @@ impl Prepared {
x,
y,
});
// 推进正文宽度和尾空白,再跳到下一词;尾空白本身不会产生图片命令。
x += word.ink.width + word.space;
cursor.word += 1;
if word.newline {
return;
}
} else if cursor.letter == 0 && word.ink.width <= remaining_max + 0.001 {
// 整词有望放入某个完整剩余区间时先保留它,结束当前碎片尝试,不因当前余宽小就拆成字素。
// remaining_max 包括当前碎片的完整宽度;即使只剩当前碎片,也会保留整词到下一行,而不是塞进本行剩余窄缝。
break;
} else {
// 词过宽或已经拆分才逐字素继续;这个分支保留跨碎片/跨行的 letter 下标。
let letter = &word.letters[cursor.letter];
// 连一个完整字素也放不下时不丢字,转到后续区间;若所有行都放不下,由外层高度预算结束 Flow。
if letter.width > right - x + 0.001 {
break;
}
@@ -125,8 +173,10 @@ impl Prepared {
x,
y,
});
// 成功放置一个字素后才递增游标,同一段的后续字素从新 x 继续。
x += letter.width;
cursor.letter += 1;
// 最后字素放完才消费尾空白并推进词;复位 letter,让下一词仍有整词放置机会。
if cursor.letter == word.letters.len() {
cursor.word += 1;
cursor.letter = 0;
+58 -1
View File
@@ -10,6 +10,7 @@ use endor_core::{ObjectPayload, ObjectRef, ObjectStore, VmError};
/// remain ordinary traced, bindable VM attributes.
#[derive(Debug)]
/// 托管 2D 相机的名义标记,实际位置/缩放是被追踪的普通属性。
// ui_bridge 创建相机时分配此 payload;camera 读取入口据此验证名义身份,属性值仍保存在 VM 属性存储。
pub struct Camera2D;
impl ObjectPayload for Camera2D {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
@@ -25,22 +26,29 @@ impl ObjectPayload for Camera2D {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
// 标记自身没有托管引用;x/y/zoom 等普通属性的追踪由对象存储负责,不在此重复枚举。
fn trace(&self, _: &mut dyn FnMut(ObjectRef)) {}
}
#[derive(Debug, Clone, PartialEq)]
/// 从 VM 提取的独立相机数值描述,渲染与布局投影不直接读取托管堆。
pub struct CameraView {
// 相机托管身份的文本形式,供快照比较和输入重新关联;原生描述本身不拥有 VM 根。
pub identity: String,
// 相机在世界空间的左上位置,投影时先从内容坐标中扣除,再乘 zoom。
pub x: f32,
// 世界纵向左上偏移,和 x 一样在投影前扣除,不是视口盒子的布局原点。
pub y: f32,
// 已校验 0.01..100 的相机倍率;嵌套后的累计倍率另受 Transform.valid 预算约束。
pub zoom: f32,
}
/// 读取并校验托管 Camera2D 的位置和缩放,生成原生投影描述。
// block/snapshot 提取 CanvasViewport 视觉、相机创建校验及 page_input 交互读取都共用此入口。
pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result<CameraView, VmError> {
// 先验证实际宿主 payload,避免只有 x/y/zoom 同名字段的普通对象冒充相机。
store.payload::<Camera2D>(object, "Camera2D")?;
// 三个属性都经有限 Number 转换,位置允许负值;zoom 随后再用专门正范围收紧。
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")?)?;
@@ -61,6 +69,7 @@ pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result<CameraView, VmEr
/// Fit finite scene bounds using this occurrence's allocation. Sharing a camera
/// is explicit, so fitting one view then intentionally affects all its users.
/// 根据这个 CanvasViewport 出现的有限内容范围调整其相机;显式共享相机会同步影响其他视口。
// model/mount 的 fitView 协议调用,按指定出现的内容分配更新其共享相机,不重新排列业务节点。
pub fn fit(
cx: &mut endor_core::NativeContext<'_>,
mount: &crate::mount::Mount,
@@ -71,17 +80,20 @@ pub fn fit(
"fitView requires a committed CanvasViewport Mount",
));
}
// 从当前 Block 解析有效视觉属性,取得真实相机引用,不能只信旧视觉快照里的相机数值。
let (_, data) = crate::block::resolve(cx.store(), mount.block)?;
let data = crate::block::visual_data(cx.store(), data)?;
let camera = data
.camera
.ok_or(VmError::InvalidArgument("missing viewport camera"))?;
// 首子出现是场景根,弱身份在 NativeContext 内升级保活;没有根不能推导场景边界。
let root = mount
.first_child
.map(|weak| cx.upgrade(weak))
.transpose()?
.flatten()
.ok_or(VmError::InvalidArgument("missing scene occurrence"))?;
// 复制场景根 Mount 的原生描述,后面遍历其直接孩子并以它的原点作坐标基准。
let scene = cx
.store()
.payload::<crate::mount::Mount>(root, "Mount")?
@@ -90,11 +102,14 @@ pub fn fit(
let mut next = scene.first_child;
let mut bounds: Option<[f32; 4]> = None;
let mut count = 0;
// 沿稳定 sibling 链逐项汇总,每步递增预算,异常长链不能让 fitView 无限扫描。
while let Some(weak) = next {
count += 1;
// 最多处理一万项,超过时在写相机属性之前报执行限制错误。
if count > 10_000 {
return Err(VmError::ExecutionLimit("fitView scene bounds"));
}
// 每个直接孩子必须仍然存活;失效不能静默跳过,否则拟合范围会遗漏内容。
let object = cx
.upgrade(weak)?
.ok_or(VmError::InvalidArgument("expired scene child"))?;
@@ -115,13 +130,16 @@ pub fn fit(
b[3].max(rect[3]),
]
}));
// 在读取当前孩子后推进到同层下一出现,不递归进入其内部世界或后代布局。
next = child.next_sibling;
}
// 读取视口当前盒模型,以扣除 padding/border 后的内容尺寸决定相机倍率。
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))
@@ -137,6 +155,7 @@ pub fn fit(
(0.0, 0.0, 1.0)
};
// 最终通过普通属性写入走 VM 变更追踪/绑定机制;共享该相机的其他视口也会观察到更新。
// 逐个普通属性写入,任一写入失败向上传播;这里没有把三次 set_attr 包成原子事务。
for (name, value) in [("x", x), ("y", y), ("zoom", zoom)] {
cx.set_attr(
camera,
@@ -150,12 +169,16 @@ pub fn fit(
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
/// 可组合的均匀缩放与平移;正向绘制和逆向命中必须使用同一结果。
pub struct Transform {
// 均匀缩放,point/rect 正向乘此值,inverse 撤销时除以此值;累计变换须经 valid。
pub zoom: f32,
// 缩放之后叠加的目标空间横向平移;then 组合时此值还须乘外层 zoom。
pub x: f32,
// 同一目标空间的纵向平移,不等同于 CameraView.y 的世界偏移。
pub y: f32,
}
impl Default for Transform {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
/// 返回不改变坐标的单位缩放与零平移,供根视图和面板纹理空间使用。
// 单位变换供无相机或独立面板纹理空间使用;None 的 mount.view 也表示这一效果。
fn default() -> Self {
Self {
zoom: 1.0,
@@ -166,11 +189,13 @@ impl Default for Transform {
}
impl Transform {
/// 将逻辑点按当前缩放和平移变换到外层视图。
// document 的坐标查询、绘制命令投影与 panel 映射共用,公式先缩放后平移。
pub fn point(self, point: Point) -> Point {
Point::new(point.x * self.zoom + self.x, point.y * self.zoom + self.y)
}
/// 将视图点反算回逻辑坐标,供命中和手势锚点计算使用。
// 不在这里额外校验 zoom,调用方必须先保证 valid;零缩放无法反算。
// MountGeometry 和 page_input 的手势计算使用,先撤平移再除缩放,不修改原布局几何。
pub fn inverse(self, point: Point) -> Point {
Point::new(
(point.x - self.x) / self.zoom,
@@ -178,7 +203,9 @@ impl Transform {
)
}
/// 使用同一缩放和平移转换矩形的位置与尺寸。
// project 用于把视口边界投到父空间;缩放为正的约束保证宽高无需翻转。
pub fn rect(self, rect: Rect) -> Rect {
// 只变换左上点,宽高乘同一 zoom;此类型不支持旋转或非均匀缩放。
let point = self.point(Point::new(rect.x, rect.y));
Rect::new(
point.x,
@@ -189,6 +216,7 @@ impl Transform {
}
/// 将当前变换再放入外层变换,保持绘制与逆向命中的组合顺序一致。
// 组合为 outer(self(point)):内层平移也要乘外层缩放,不能只把两次平移相加。
// project 在嵌套 CanvasViewport 时组合局部相机与祖先变换,返回 outer ∘ self。
pub fn then(self, outer: Self) -> Self {
Self {
zoom: self.zoom * outer.zoom,
@@ -197,6 +225,7 @@ impl Transform {
}
}
/// 验证累计二维变换:zoom 有限且在 (0,1000000],平移有限且绝对值不超过一万亿。
// 嵌套投影和命令校验在使用前检查累计预算,单个 Camera2D 合法不意味着任意多层组合都合法。
pub fn valid(self) -> bool {
// 允许累计平移远大于单个世界坐标范围,因为相机偏移还会乘缩放;同时限制嵌套累计放大。
self.zoom.is_finite()
@@ -210,6 +239,7 @@ impl Transform {
}
/// 计算视口与祖先裁剪的共同可见矩形。
// project 将当前视口边界与已累计祖先 clip 求交,不能扩大祖先允许绘制的范围。
fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
// clip 用 [x,y,width,height],分离的矩形返回零面积,不产生负宽高。
let x = a[0].max(b[0]);
@@ -223,6 +253,7 @@ fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
}
/// 遍历出现树,组合相机与父视图变换,建立裁剪和世界 UI 映射,最后重建屏幕命中索引。
// ui_bridge::finish_layout_frame 在 scroll::project 之后调用,完成后 Frame 同时具备命令投影和输入宽阶段索引。
pub fn project(
root: &crate::ui_bridge::UiNode,
frame: &mut crate::document::Frame,
@@ -230,8 +261,11 @@ pub fn project(
use crate::ui_bridge::UiKind;
use std::collections::HashMap;
use std::sync::Arc;
// 按 Block 身份建立语义视觉索引;多个 Mount 可查到同一节点,但每个出现单独组合视图。
// scroll::project 已把祖先滚动量计入 Mount.x/y;此处只附相机/面板映射,不能再从相机平移中重复扣滚动偏移。
let mut nodes = HashMap::new();
let mut work = vec![root];
// 显式栈只展开视觉节点树,不以此遍历顺序决定绘制或输入优先级。
while let Some(node) = work.pop() {
nodes.insert(node.block_id.as_str(), node);
work.extend(node.children().iter().map(|node| node.as_ref()));
@@ -242,6 +276,7 @@ pub fn project(
Option<[f32; 4]>,
Option<Arc<crate::scene3d::panel::Mapping>>,
);
// 上下文按父出现 ID 传给孩子;遍历要求 frame.mounts 父先子后,避免读取尚未建立的父投影。
let mut contexts = HashMap::<u64, Scope>::new();
// 保留 next_sibling 查找表,遍历可变 Mount 时仍可按稳定子出现顺序为面板根建立映射。
let next: HashMap<_, _> = frame
@@ -249,8 +284,10 @@ pub fn project(
.iter()
.map(|mount| (mount.id, mount.next_sibling))
.collect();
// 3D 视口先为后续面板根登记映射和纹理尺寸,轮到该根时再切换独立纹理坐标空间。
let mut panels =
HashMap::<u64, (Arc<crate::scene3d::panel::Mapping>, crate::layout::Size)>::new();
// 逐出现处理,读取当前业务视觉但只改 Frame 的派生投影,不写 VM 相机或业务节点属性。
for mount in &mut frame.mounts {
let node = nodes[mount.block_id.as_str()];
// 面板根进入独立纹理像素空间,二维变换重置为单位,裁剪重置为面板尺寸;普通节点沿用父上下文。
@@ -266,6 +303,7 @@ pub fn project(
.and_then(|parent| contexts.get(&parent).cloned())
.unwrap_or_default()
};
// 检查继承/重置后的累计二维变换,超预算时结束草稿处理,不把非法缩放传给后续命令。
anyhow::ensure!(
view.valid(),
"nested viewport transform exceeds numeric budget"
@@ -275,10 +313,14 @@ pub fn project(
mount.panel = panel.clone();
mount.clip = clip;
mount.viewport_clip = None;
// 当前出现使用继承的 view/clip;仅其子树才叠加本层相机或本层子裁剪。
// 因此相机移动场景内容,但视口本身的外框和内容裁剪仍固定在父布局分配的位置。
let mut child_view = view;
let mut child_clip = clip;
let inset = crate::layout::insets(node);
// 独立二维/三维视口裁内容盒,普通 clip_children 裁边框内,Scroll 最后补默认容器裁剪。
let viewport = if let UiKind::CanvasViewport { camera, .. } = &node.kind {
// 内容原点含左/上 inset,局部相机围绕该原点锚定,不围绕全页原点缩放。
let x = mount.x + inset.left;
let y = mount.y + inset.top;
// 围绕视口内容原点建立相机变换:原点保持锚定,世界内容先减相机位置再缩放,最后套父变换。
@@ -294,6 +336,7 @@ pub fn project(
(mount.width - inset.horizontal()).max(0.0),
(mount.height - inset.vertical()).max(0.0),
))
// 3D 摄像机由 View 负责,这里只建立二维内容矩形,不给世界网格再套一个 Camera2D。
} else if matches!(node.kind, UiKind::Viewport3D { .. }) {
Some(Rect::new(
mount.x + inset.left,
@@ -315,11 +358,13 @@ pub fn project(
} else {
None
};
// 只有产生了本层可见区域才收窄 child_clip;当前 Mount 的外层 clip 保持为继承值。
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)));
// 独立视口另存 viewport_clip,后续命中和面板 Mapping 使用内容区裁剪而非整个外框。
if mount.canvas_viewport || mount.scene3d_viewport {
mount.viewport_clip = child_clip;
}
@@ -331,8 +376,10 @@ pub fn project(
} = &node.kind
{
// 沿面板映射链限制嵌套深度,防止递归纹理装配和输入逆投影无限展开。
// 沿已有父面板映射链计数,不把普通二维嵌套计作 3D 纹理深度。
let mut depth = 0;
let mut ancestor = panel.clone();
// 只读遍历不可变 Arc 链,每前进一步计一层,限制下面新建映射后的总嵌套规模。
while let Some(mapping) = ancestor {
depth += 1;
ancestor = mapping.parent.clone();
@@ -341,7 +388,9 @@ pub fn project(
let mut child = mount.first_child;
// slots、面板尺寸和直接子出现按同序配对;缺少预期子出现时立即报错,不能把内容映射到错误面板。
for (key, size) in node.slots.iter().zip(sizes) {
// slots 与 sizes 由场景快照提供,当前直接子出现必须存在,不能因缺项错位关联后续面板。
let id = child.ok_or_else(|| anyhow::anyhow!("missing 3D UI root occurrence"))?;
// Mapping 保存所属视口的相机、布局矩形、外层二维变换和父面板链,供正逆坐标转换共用。
let mapping = Arc::new(crate::scene3d::panel::Mapping {
owner: mount.id,
key: *key,
@@ -358,11 +407,15 @@ pub fn project(
});
// 记录待遍历面板根的新上下文;之后走到该孩子时切换纹理空间,不把外层 clip 误当纹理像素。
panels.insert(id, (mapping, *size));
// 依照预存 sibling 表推进,不在可变遍历 frame.mounts 时再次借用整个出现数组。
child = next[&id];
}
}
// 保存孩子最终上下文;3D 面板根随后通过 panels 表覆盖成纹理空间入口。
contexts.insert(mount.id, (child_view, child_clip, panel));
}
// 投影完成后按命令 owner 查回对应出现,重建平行元数据;此时不再修改 Mount 几何。
// 这里只准备当前默认命令的投影;后续 source_paint 替换命令时也必须按出现归属维护对应元数据,不能沿用旧命令下标。
let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 按每条命令的 Mount 归属重建 view/clip,确保源绘制、默认绘制和输入都使用这一轮最终投影。
frame.command_views.clear();
@@ -371,6 +424,7 @@ pub fn project(
frame.command_mounts.len() == frame.commands.len(),
"missing command occurrence table"
);
// 每条命令复制所属出现的 view/clip,不能按命令自身位置猜测归属。
for owner in &frame.command_mounts {
let mount = mounts
.get(owner)
@@ -397,16 +451,19 @@ mod tests {
}
.valid()
);
// 构造非单位内外层变换,包含不同缩放和平移,防止只测试平移而漏掉组合次序。
let inner = Transform {
zoom: 2.0,
x: 12.0,
y: -40.0,
};
// 外层缩放也会缩放内层平移,后面的逐次点变换是独立于组合公式的参照。
let outer = Transform {
zoom: 0.5,
x: 80.0,
y: 20.0,
};
// 用非零且带负分量的输入点核对正向与逆向恢复,不改变该布局点本身。
let point = Point::new(-5.0, 100.0);
// 以两次实际点变换验证组合顺序,再检查逆变换和矩形尺寸,避免只验证某一种公式写法。
let composed = inner.then(outer);
+21 -1
View File
@@ -1,5 +1,6 @@
//! 有界屏幕网格,用来减少普通二维命中和相机目标查询的候选。
//! 巨大对象和 3D 面板进入后备集合,再由精确命中判断;索引预算不足不能导致遗漏目标。反序列化或索引长度不匹配时可退回完整候选。
//! 此处“屏幕”指已应用 Mount.view 的页面内部坐标,不含旧 Page Surface、设备 DPI 和宿主侧栏偏移;外层输入先撤销这些变换再查询。
//!
//! Bounded screen-space broad phase. Projective world UI uses the precise
//! ray-based narrow phase rather than an unsafe screen rectangle approximation.
@@ -18,12 +19,16 @@ pub struct Index {
}
impl Index {
/// 从帧几何构建 128 单位屏幕网格;大对象、投影面板和超预算项进入保守后备集合。
// viewport::project 完成所有出现的变换/裁剪后调用,Frame 保存此纯原生索引供命中和相机目标查询。
pub fn build(mounts: &[MountGeometry]) -> Self {
// 保存原 mounts 数量用于查询时的基本一致性检查,格子与 fallback 初始为空。
let mut result = Self {
count: mounts.len(),
..Self::default()
};
// 累计格子内的下标引用数,不是不同格子数,也不是有效 Mount 数。
let mut entries = 0;
// index 始终指原帧数组位置,跳过不可参与输入的节点也不压缩/重排其他下标。
for (index, mount) in mounts.iter().enumerate() {
// 只有可交互项、场景视口或滚动容器需要参与输入目标宽阶段,其余外观节点跳过。
if !mount.interactive
@@ -63,6 +68,7 @@ impl Index {
result.fallback.push(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)
@@ -72,8 +78,11 @@ impl Index {
result.fallback.push(index);
continue;
}
// 只有真正入网的项计入引用预算,fallback 不展开格子,因此不会被巨大矩形撑大索引。
entries += cells;
// 二维范围最多展开 256 格,同一出现可进入多格,每格内仍按原帧顺序追加。
for x in left..=right {
// 把当前原帧下标登记到每个覆盖格,查询点随后只需读取一格加 fallback。
for y in top..=bottom {
result.cells.entry((x, y)).or_default().push(index);
}
@@ -82,22 +91,28 @@ impl Index {
result
}
/// 返回当前网格格子与后备集合中的候选,去重并恢复逆绘制顺序。
// Frame 的 hit_test/camera_at 查询调用;返回的是保守下标集,调用方仍需检查实际几何、裁剪和资格。
pub fn candidates(&self, point: Point, count: usize) -> Vec<usize> {
// 索引总数与当前帧不一致时,下标集合不再可靠;直接返回整帧逆序候选。
// count 只能发现数量变化;若调用方原地移动同数量的 Mount,也必须重建索引,不能依赖这项检查自动识别陈旧几何。
if self.count != count {
return (0..count).rev().collect();
}
// 每次先复制全部后备下标,3D 面板必须保留给射线精确阶段,不能用二维格子筛掉。
// 返回顺序必须与索引建立时的 mounts 数组一致;这里只交还下标,不持有也不复制 MountGeometry。
let mut output = self.fallback.clone();
// 常规候选只来自指针当前格,未命中格子不影响后备集合继续参与。
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
}
}
/// 把屏幕坐标映射到有界整数网格坐标,处理负方向与极端数值。
// build 转换矩形边界与 candidates 转换查询点共用,保证负坐标和格子边界使用相同规则。
fn cell(value: f32) -> i32 {
// 每格 128 个屏幕单位;floor 让 -1 属于负格而不是零格,再限制到整数表示范围。
(value / 128.)
@@ -111,6 +126,7 @@ mod tests {
#[test]
/// 200个分散节点只返回局部网格候选;覆盖海量格的巨大前景节点进入后备集合后仍按前景顺序参与精确命中。
fn screen_grid_limits_candidates_and_keeps_huge_rectangles_hittable() {
// 借真实 ReplSession 生成交互 Mount 模板,确保测试几何包含生产路径所需字段,而非手写近似结构。
let (_, document) = crate::endor_host::ReplSession::load(
"page=Page()\npage.render(Button(\"hit\"))",
"app.main",
@@ -118,13 +134,16 @@ mod tests {
930,
)
.unwrap();
// 从真实文档挑一个可交互出现,随后只替换矩形、变换和裁剪以隔离网格筛选行为。
let template = document
.frame
.mounts
.iter()
.find(|mount| mount.interactive)
.unwrap();
// 保留原帧下标 0..199,每项横向间隔 400,大于 128 格宽,让原点只查到局部候选。
let mut mounts = Vec::new();
// 所有模板都清除视图与 clip,避免旧页面投影影响这组人工屏幕坐标。
for index in 0..200 {
let mut mount = template.clone();
mount.x = index as f32 * 400.;
@@ -135,6 +154,7 @@ mod tests {
mount.clip = None;
mounts.push(mount);
}
// 首次构建检查普通项的局部筛选,随后扩大最后一个出现并重建,验证超预算降级不漏目标。
let index = Index::build(&mounts);
assert_eq!(index.candidates(Point::new(5., 5.), mounts.len()), vec![0]);
// 把最前景节点扩大到覆盖海量格子,验证其进入后备集合后仍排在普通候选之前且可精确命中。
+137 -37
View File
@@ -2,6 +2,12 @@
//! HTTP 线程不操作 VM;共享 NamespaceState 只保存最近完整发布文档和资源命令。提交序号用来拒绝已被较新 PUT/DELETE 取代的结果。
//! 关闭流程先请求运行时有界清理,再通过完成通知让原生事件循环退出。
//!
//! main::main 调用 start,shell 持有 RuntimeControl;HTTP 请求经 handle/submit 入队,原生输入经 dispatch_native 入同一队列。
//! worker 子模块拥有实际 ReplSession;本文件的线程循环只决定继续哪个请求、何时等待、如何响应和退出。
//! 本地端点、观察器和 EventLoopProxy 都是 Tessera 宿主适配,不能把整个文件直接作为无 GUI 的 Nexus 核心。
//!
//! 成功入队仅表示接受执行请求,最终回执由 worker 完成或明确拒绝后发出;它与发布 revision 并非同一事件。
//! 普通请求队列和退出通道相互独立,pending 保存原请求与继续状态;退出优先于下一量子,但不由 HTTP 线程抢先操作 VM。
mod guide;
mod worker;
@@ -34,34 +40,47 @@ use crate::{
endor_host,
};
// 只监听 IPv4 回环固定端口,接口用于本进程原生输入和本机 agent,不是对外网络服务器。
// 只监听 IPv4 回环固定端口,供本机 agent/Console HTTP 调用;原生输入现在直接走同一 worker 队列。
pub const API_ADDRESS: &str = "127.0.0.1:4737";
// 请求流读取与 endor_host 的源预算一致,Content-Length 检查不能替代流上的硬上限。
const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64;
// GUI 更新回调只投递原生通知,不携带 VM/托管引用,也不在调用线程直接绘制窗口。
type Notify = Arc<dyn Fn() + Send + Sync>;
#[derive(Clone, Debug)]
/// 运行时清理的摘要与可选错误,供最终原生退出结果报告。
pub struct ShutdownOutcome {
// worker 生成的清理数量/故障摘要,供退出日志与 shell 最终报告使用。
pub summary: String,
// 清理失败时保留文本,不能因为退出事件已经发出就把失败说成正常关闭。
pub error: Option<String>,
}
// 完成槽和条件变量共享同一生命周期,退出发起者可有限等待或由 GUI ShutdownComplete 事件唤醒。
type ShutdownCompletion = Arc<(Mutex<Option<ShutdownOutcome>>, Condvar)>;
#[derive(Clone)]
/// 跨线程的退出控制句柄,使用有界通知和完成条件变量,不携带 VM。
pub struct RuntimeControl {
// 独立容量一的关闭通道,即使正常输入队列满了也能通知 worker 优先清理。
sender: SyncSender<()>,
// 合并重复退出请求,并让 HTTP/原生输入尽早拒绝新执行工作。
requested: Arc<AtomicBool>,
// worker 在退出收尾后填写结果,wait_shutdown 不在 GUI 内自行执行清理。
completion: ShutdownCompletion,
// start_server_observed 一次性装入共享执行队列,不能让每个原生窗口自行创建另一个 VM worker。
native_sender: Arc<OnceLock<SyncSender<BuildRequest>>>,
// 只读当前 namespace/submission 以给原生 job 盖身份,不包含 VM 对象图。
namespace: Arc<RwLock<NamespaceState>>,
}
impl RuntimeControl {
/// Only the native input relay calls this blocking acknowledgement path.
/// Window threads still use a bounded, nonblocking Input sender.
/// events::Client 的后台 relay 调用,向共享 worker 队列发送原生信封并等回执;GUI 仍只非阻塞发给 Client。
pub(crate) fn dispatch_native(&self, input: crate::events::Input) -> Result<()> {
// 检查退出门闩后才创建回执/工作,不让已关闭域继续排入源回调。
anyhow::ensure!(!self.is_exiting(), "application is exiting");
// 一个输入一个回执,relay 等待不会阻塞 GUI;容量一足以让 worker 写回后继续自身循环。
let (done, completed) = sync_channel(1);
// 短读锁只复制当前接受身份,发送/等待都在锁外,worker 发布时仍可取得写锁。
let job = {
// 原生来源由构造类型决定,HTTP 头或 JSON 不能伪造 RequestReply::Native。
let state = self
.namespace
.read()
@@ -86,7 +105,7 @@ impl RuntimeControl {
.map_err(anyhow::Error::msg)
}
/// 只发出一次退出请求,唤醒 worker 执行清理,并通过完成句柄通知原生循环。
/// shell 关闭流程调用,只通知一次,由 owning worker 清理 VM;true 表示已排入或此前已请求,不等于已清理成功。
pub fn request_shutdown(&self) -> bool {
// 原子标志合并重复退出请求;已请求直接返回 true,不在 GUI 线程重复排队清理。
if self.requested.swap(true, Ordering::AcqRel) {
@@ -100,14 +119,17 @@ impl RuntimeControl {
false
}
}
/// 读取退出标志,使窗口事件和新输入尽早停止进入运行时。
/// shell/原生 relay 查询退出请求状态,不读取完成槽,也不能由此推断资源已全部清理。
pub fn is_exiting(&self) -> bool {
self.requested.load(Ordering::Acquire)
}
/// 在明确超时内等待 worker 的清理结果,不能无限阻塞退出。
/// shell 最终退出收尾调用,有限等待 worker 保存的 ShutdownOutcome;None 为未取得结果,不是成功。
pub fn wait_shutdown(&self, timeout: std::time::Duration) -> Option<ShutdownOutcome> {
// 同一锁保护可选结果与等待谓词,避免“检查为空后错过通知”的竞争。
let (lock, ready) = &*self.completion;
// 锁异常直接返回 None,不在未知完成状态下继续阻塞。
let result = lock.lock().ok()?;
// wait_timeout_while 反复检查 Option,返回前重新拿锁,克隆纯文本结果交还 shell。
let (result, _) = ready
// 条件变量处理虚假唤醒,直到完成槽非空或超时;返回 None 也可能来自锁中毒,不能当作清理成功。
.wait_timeout_while(result, timeout, |result| result.is_none())
@@ -117,38 +139,53 @@ impl RuntimeControl {
}
/// worker 持有的关闭接收端、控制状态和 GUI 完成通知代理。
struct Shutdown {
// worker 每个控制边界 try_recv,关闭信号优先于下一片 VM 刷新或下一条普通输入。
receiver: Receiver<()>,
// 保存完成槽及已请求门闩,与 shell 手中的句柄是同一共享状态。
control: RuntimeControl,
// 完成后向原生事件循环发消息,worker 不直接调用窗口退出/绘制 API。
proxy: EventLoopProxy<AppEvent>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// 送入执行队列的请求类别,区分完整源码、持久输入、事件与重置。
enum RequestKind {
// 原生或 HTTP 输入,均由已有运行时执行,不启动新的程序堆。
Event,
// 完整程序 PUT:候选成功后才替换旧域和发布文档。
Code,
// 隔离表达式求值,不写活程序或 Console 的 globals。
Expression,
// 在真实持久 globals 上执行一轮交互,运行错误不回滚此前成功语句。
Repl,
// 显式清理/替换 Console 上下文,与完整 DELETE/code 的文档清空不同。
ResetRepl,
}
/// 保留原 HTTP 请求与提交身份的执行工作,完成或失败后由 worker 回应。
/// submit/dispatch_native 产生的纯数据工作信封,保留回应端和接受身份,worker 完成后才回应或确认过期。
struct BuildRequest {
// 原 HTTP 请求的观察包装或原生一次性回执,不能 Clone 成多次响应。
request: RequestReply,
// 原生来源只由进程内构造,HTTP 输入的事件 JSON 仍保存在 source 中由 worker 解码。
native: Option<crate::events::Input>,
// 执行语义标签,不从请求体猜是替换程序还是追加 Console。
kind: RequestKind,
// 已限制字节数并验证 UTF-8 的正文;原生事件无需字符串往返因此为空。
source: String,
// 路由所指 namespace,执行/发布时须仍匹配当前共享状态。
namespace: String,
// 完整提交的代际号,worker 核验后才允许替换发布值,旧结果不能覆盖新 PUT/DELETE。
submission: u64,
}
/// 绑定回环 API,启动请求接收与单 VM worker,返回活动日志及一次性退出控制句柄。
/// main::main 初始化时调用,绑定本机 API 并启动两个线程,返回 shell 使用的活动记录和 RuntimeControl。
pub fn start(
namespace: Arc<RwLock<NamespaceState>>,
proxy: EventLoopProxy<AppEvent>,
) -> Result<(Activity, RuntimeControl)> {
// 退出控制仅需一个消息槽,正常执行请求另走容量8队列,避免工作排满后无法通知关闭。
let (sender, receiver) = sync_channel(1);
// HTTP、shell 和 worker 共享请求/完成门闩,原生队列指针由实际线程启动函数一次性填入。
let control = RuntimeControl {
sender,
requested: Arc::new(AtomicBool::new(false)),
@@ -156,6 +193,7 @@ pub fn start(
native_sender: Arc::new(OnceLock::new()),
namespace: namespace.clone(),
};
// worker 专用接收端与通知代理;不复制 Receiver 给多个执行线程抢同一退出消息。
let shutdown = Shutdown {
receiver,
control: control.clone(),
@@ -165,9 +203,12 @@ pub fn start(
let server = Server::http(API_ADDRESS)
.map_err(|error| anyhow::anyhow!(error))
.with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?;
// 无 VM/文档 payload 的更新通知,GUI 收到后自行读最近完整 NamespaceState。
let notify: Notify = Arc::new(move || {
// 事件循环已退出时通知失败无需再触发 VM 故障,发布值本身仍由共享状态保存。
let _ = proxy.send_event(AppEvent::DocumentUpdated);
});
// Network/Console 观察器共享纯数据有界日志,和 VM 的 globals/堆状态分开。
let activity = Arc::new(Mutex::new(ActivityLog::default()));
start_server_observed(
Arc::new(server),
@@ -182,7 +223,7 @@ pub fn start(
}
#[cfg(test)]
/// 为本地监听服务建立请求处理线程,并把执行请求转交有界队列。
/// API 测试调用,使用测试自己绑定的 Server,不安装原生退出/输入端口,复用真实路由与 worker。
fn start_server(
server: Arc<Server>,
namespace: Arc<RwLock<NamespaceState>>,
@@ -199,7 +240,7 @@ fn start_server(
)
}
/// 启动带活动观察的 HTTP 服务,将请求状态变化通知管理界面。
/// start 与 start_server 共用线程启动器,建立唯一执行队列、VM worker 和只负责路由的 HTTP 接收线程。
fn start_server_observed(
server: Arc<Server>,
namespace: Arc<RwLock<NamespaceState>>,
@@ -212,18 +253,23 @@ fn start_server_observed(
// HTTP 接收与 VM 执行分离,最多8个排队请求;源代码只在单个 endor-worker 中操作 VM。
let (sender, receiver) = sync_channel::<BuildRequest>(8);
if let Some(native_port) = native_port {
// 原生输入接入同一个 sender,不能有绕过串行执行/刷新约束的第二条解释器路径。
native_port
.set(sender.clone())
.map_err(|_| anyhow::anyhow!("native input queue already attached"))?;
}
// worker 线程与 HTTP 线程各持发布状态 Arc,任何一方都不把读写锁贯穿源代码执行。
let worker_namespace = namespace.clone();
// worker 的发布通知与 HTTP 观察通知用途不同,均只投递原生事件。
let worker_notify = notify.clone();
// HTTP 线程仅借门闩判断是否拒绝新执行请求,不持 Shutdown 的独占接收端。
let exiting = shutdown
.as_ref()
.map(|shutdown| shutdown.control.requested.clone());
thread::Builder::new()
.name("tessera-endor-worker".into())
.spawn(move || {
// 所有实际 VM 会话只在这个线程构建/操作,Default 此时尚未创建任何用户程序。
let mut worker = Worker::default();
// 分片请求跨循环保留原 HTTP 请求和继续句柄;同一时刻只推进一个有未完成 VM 执行的输入。
let mut pending: Option<(BuildRequest, PendingRequest)> = None;
@@ -234,13 +280,16 @@ fn start_server_observed(
// 退出优先于下一量子:先撤去待响应请求,再由 shutdown_runtime 清理 VM 续体与生命周期资源。
if let Some((job, input)) = pending.take() {
drop(input);
// 客户端可能已断开,尽力回应后仍继续清理,不能因 socket 失败阻止应用退出。
let _ = job
.request
.respond(error_response(StatusCode(503), "application is exiting"));
}
// 清理完成文本含可能的错误,不因收到了退出请求就直接向 GUI 报成功。
let outcome = worker.shutdown_runtime();
// 排队请求全部明确回应503,不能留着客户端无期限等待已经停止的执行线程。
while let Ok(job) = receiver.try_recv() {
// 已接受但未执行的请求明确发 503;发送失败仅表示接收者已离开,不再重新入队。
let _ = job
.request
.respond(error_response(StatusCode(503), "application is exiting"));
@@ -252,17 +301,20 @@ fn start_server_observed(
current.document.frame.session = 0;
current.document.frame.focus = None;
for view in current.document.windows.values_mut() {
// 共享 VM 已清理,每个副窗保留画面都必须撤销交互会话和焦点,避免退出期间仍向旧目标投递输入。
view.frame.session = 0;
view.frame.focus = None;
}
}
eprintln!("{}", outcome.summary);
// 共享完成槽同时服务有限等待和 GUI 退出事件,先存 outcome 再通知。
let (lock, ready) = &*shutdown.control.completion;
if let Ok(mut result) = lock.lock() {
*result = Some(outcome.clone());
// 先在锁内保存清理结果再唤醒等待者,同时用 AppEvent 通知原生事件循环退出,两条路径复用同一 outcome。
ready.notify_all();
}
// 完成槽已先保存 outcome,GUI 事件投递失败不抹掉可由 wait_shutdown 取得的结果。
let _ = shutdown.proxy.send_event(AppEvent::ShutdownComplete {
error: outcome.error,
});
@@ -273,12 +325,11 @@ fn start_server_observed(
match worker.resume_request(&job, input, &worker_namespace, &worker_notify) {
RequestProgress::Pending(input) => {
pending = Some((job, input));
// A soft VM quantum is a control boundary, not a
// minimum-duration time slice. Yield to other ready
// threads without charging a timer delay per quantum.
// 软量子只提供控制边界,不强制睡固定毫秒,避免慢布局每片额外累计等待延迟。
thread::yield_now();
}
RequestProgress::Complete(response) => {
// 一次性回应消费 job;客户端断开不重执行已完成业务输入,否则会重复副作用。
let _ = job.request.respond(response);
}
}
@@ -286,6 +337,7 @@ fn start_server_observed(
}
// 空闲输入边界先推进视口刷新与任务,待执行刷新/协作任务时零等待,否则最多100ms检查一次。
worker.refresh_viewport(&worker_namespace, &worker_notify);
// 活跃视觉续体/可运行协程应立即再推进;只有未来计时/I/O 等待时才最多阻塞 100ms。
let wait = if worker.repl.as_ref().is_some_and(|(_, runtime)| {
runtime.has_pending_refresh() || runtime.has_cooperative_tasks()
}) {
@@ -295,8 +347,10 @@ fn start_server_observed(
};
match receiver.recv_timeout(wait) {
Ok(job) => {
// begin_request 既可能立即完成,也可能要求等待旧刷新或保存新的候选续体。
match worker.begin_request(&job, &worker_namespace, &worker_notify) {
RequestProgress::Pending(input) => {
// 请求与继续句柄绑定留到下轮,不能先回应成功后再执行它的 UI 刷新。
if job.native.is_none()
&& matches!(&input, PendingRequest::BeforeInput)
{
@@ -308,11 +362,13 @@ fn start_server_observed(
pending = Some((job, input));
}
RequestProgress::Complete(response) => {
// 同步完成也只尝试一次回执,发送失败不回滚 VM 已完成写入或发布文档。
let _ = job.request.respond(response);
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
// timeout 不是 worker 已停止,仅是检查拥有资格/继续下一轮空闲推进的边界。
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if worker.repl.as_ref().is_some_and(|(epoch, runtime)| {
worker_namespace.read().is_ok_and(|current| {
@@ -324,6 +380,7 @@ fn start_server_observed(
worker.repl = None;
}
if wait.is_zero() {
// 活跃续体/协程采用零接收超时;此处仅让出线程调度机会,下一循环仍先检查退出并推进原 VM。
thread::yield_now();
}
}
@@ -341,6 +398,7 @@ fn start_server_observed(
// 退出后拒绝写入/执行路由,GET 与 OPTIONS 仍可读取最近发布值或探测接口。
&& !matches!(request.method(), Method::Get | Method::Options)
{
// 写入/执行在退出门闩后拒绝,GET/OPTIONS 保留观察;响应失败同样继续接收其他只读请求。
let _ =
request.respond(error_response(StatusCode(503), "application is exiting"));
continue;
@@ -358,20 +416,21 @@ fn start_server_observed(
.context("start Tessera agent API thread")
}
// Capture only allowlisted headers: credentials and cookies never enter the inspector.
/// HTTP 请求的观察包装,在读取正文和响应时维护 Network 工具记录。
/// HTTP and in-process native jobs share one serial execution/refresh path.
/// Native envelopes cannot be created by an HTTP header or request body.
/// BuildRequest 的一次性回应端,HTTP 与进程内原生工作共享执行/刷新路径但保持不同来源身份。
/// HTTP 头或正文不能创建 Native 变体,也不能让任意远程报文走原生连续输入的宽松版本追随。
enum RequestReply {
Http(ObservedRequest),
Native(SyncSender<std::result::Result<(), String>>),
}
impl RequestReply {
/// worker 线程在请求终结/退出时调用,HTTP 发响应,Native 只写回一次确认或有限错误文本。
fn respond(self, response: Response<std::io::Cursor<Vec<u8>>>) -> std::io::Result<()> {
match self {
Self::Http(request) => request.respond(response),
Self::Native(done) => {
// 连续原生输入过期可能正常返回 409,按已消费处理,不把每条旧采样都显示成运行时故障。
let status = response.status_code().0;
// 原生侧不解析响应 JSON;非 200/409 取至多 2048 字符错误预览交 GUI 展示。
let result = if status == 200 || status == 409 {
Ok(())
} else {
@@ -380,6 +439,7 @@ impl RequestReply {
.take(2048)
.collect())
};
// relay 可能已因窗口退出丢弃接收端,worker 不应为此把已处理输入重新执行或停止。
let _ = done.send(result);
Ok(())
}
@@ -387,14 +447,19 @@ impl RequestReply {
}
}
/// handle 包装 tiny_http 请求,持有直到 worker 最终响应;只允许特定头部进入 Network 观察日志。
struct ObservedRequest {
// 真正 socket 请求所有权,respond 消费它,Deref 只让路由/正文读取复用 tiny_http 接口。
inner: Request,
// 共享有界观察日志,不持 VM 强引用或业务对象身份。
activity: Activity,
// begin 返回的日志记录身份,异步执行后仍能对应正确请求项。
id: u64,
// 日志正文/响应更新后通知 GUI,而非只在实际文档发布时刷新工具。
notify: Notify,
}
impl ObservedRequest {
/// 从请求提取允许观察的头部与显示来源,创建活动记录并通知工具界面。
/// handle 在路由前调用,提取白名单头和来源显示标签,不把凭据/Cookie 等复制到观察日志。
fn new(inner: Request, activity: Activity, notify: Notify) -> Self {
// 活动观察只保留 Content-Type/Length;X-Tessera-Origin 仅判来源标签,不把任意认证头复制进工具日志。
let headers = inner
@@ -415,6 +480,7 @@ impl ObservedRequest {
"HTTP"
}
.to_owned();
// 创建日志项后只留 id,worker 可以稍后再填响应,不借用锁中的 ActivityLog 记录。
let id = activity.lock().unwrap().begin(
inner.method().to_string(),
inner.url().to_owned(),
@@ -429,15 +495,18 @@ impl ObservedRequest {
notify,
}
}
/// 将受限请求正文记录到活动预览,不在观察器中执行源码。
/// submit 成功读取有界 UTF-8 正文后调用,写观察预览并通知工具,不执行源码或创建候选 VM。
fn record_body(&self, body: &str) {
self.activity.lock().unwrap().body(self.id, body);
(self.notify)();
}
/// 发送最终 HTTP 响应,并同步结束该条活动记录。
/// RequestReply::respond/handle 错误路径调用,先记预构造响应,再消费真实 tiny_http 请求发回客户端。
fn respond(self, response: Response<std::io::Cursor<Vec<u8>>>) -> std::io::Result<()> {
// 拆开元信息和内存正文 Cursor,记录预览后按原内容重建,避免观察器吃掉实际响应。
let status = response.status_code();
// 克隆头列表供日志和原响应同时使用,观察器不修改协议状态或内容类型。
let headers = response.headers().to_vec();
// 保留数据长度给重建响应,不按 Unicode 字符数重算 Content-Length。
let length = response.data_length();
// 响应正文本已在内存 Cursor,记录预览后用原 status/headers/reader/length 重建响应,不消费正文内容。
let reader = response.into_reader();
@@ -459,19 +528,19 @@ impl ObservedRequest {
}
impl Deref for ObservedRequest {
type Target = Request;
/// 将包装类型的只读访问转交给内部当前上下文。
/// Rust 在路由/头部访问时隐式调用,借用底层 Request;包装日志所有权不转移。
fn deref(&self) -> &Request {
&self.inner
}
}
impl DerefMut for ObservedRequest {
/// 将包装类型的可变访问转交给内部当前上下文。
/// submit/read_code 请求正文读取时隐式调用,访问底层流,不绕过有界读取检查。
fn deref_mut(&mut self) -> &mut Request {
&mut self.inner
}
}
/// 解析 HTTP 方法和路径;只读路由读取发布值,执行路由交给 worker。
/// HTTP 接收线程逐个请求调用,区分执行入队和纯发布值读取;执行请求由 worker 持有回应,不在此等 VM。
fn handle(
request: Request,
namespace: &Arc<RwLock<NamespaceState>>,
@@ -480,7 +549,9 @@ fn handle(
activity: &Activity,
observer_notify: &Notify,
) {
// 先建观察记录,即使路由非法也能在 Network 看到实际响应,不只记录成功执行请求。
let request = ObservedRequest::new(request, activity.clone(), observer_notify.clone());
// method 克隆为纯枚举,后面 submit 移动 request 所有权不会留下 Request 借用。
let method = request.method().clone();
// 路由匹配去掉查询串,namespace 从固定路径提取;不把 query 当作命名空间或源代码。
let path = request
@@ -490,32 +561,42 @@ fn handle(
.unwrap_or(request.url())
.to_owned();
// 三个 POST 后缀分别对应隔离表达式、持久 Console 和当前程序输入,不能因正文像源码就互换。
let evaluation = namespace_from_route(&path, "/evaluate");
// DELETE 同一个 /repl 地址表示重置该持久会话。
let repl = namespace_from_route(&path, "/repl");
// /events 接受 JSON 事件,后续仍由 worker 校验会话/窗口/布局身份。
let event = namespace_from_route(&path, "/events");
// PUT/code 完整替换,POST 在 events/repl/evaluate 中分类,DELETE/repl 是持久会话重置;其他交给资源读取/清空路径。
let route = if method == Method::Put {
// 完整替换只接受 /code,不让 PUT /repl 混成持久追加操作。
namespace_from_path(&path).map(|name| (name, RequestKind::Code))
} else if method == Method::Post {
// 先找事件,再 Console,再表达式;后缀严格匹配,命名空间不能含额外路径段。
event
.map(|name| (name, RequestKind::Event))
.or_else(|| repl.map(|name| (name, RequestKind::Repl)))
.or(evaluation.map(|name| (name, RequestKind::Expression)))
} else if method == Method::Delete {
// 这里只识别需 worker 清理的 /repl,DELETE /code 的发布状态撤销留给 code_request。
repl.map(|name| (name, RequestKind::ResetRepl))
} else {
// GET/OPTIONS 与不支持方法继续同步读取/错误响应,不创建执行 job。
None
};
if let Some((addressed, kind)) = route {
// submit 消费请求,队列/输入错误也由它负责响应;handle 不再重复发送第二个响应。
submit(request, addressed, namespace, sender, kind);
return;
}
// 这些只读/清空资源路径只读写 NamespaceState,不读取活 VM globals 或执行源函数。
let response = if method == Method::Get && path == "/agent" {
match namespace.read() {
Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)),
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
}
} else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) {
// /code 的 GET/DELETE/OPTIONS 统一走资源处理,路径匹配不等于 namespace 已被授权存在。
code_request(method, &addressed_namespace, namespace, notify)
} else if method == Method::Options {
text(StatusCode(204), "", "text/plain")
@@ -523,20 +604,23 @@ fn handle(
error_response(StatusCode(404), "route not found; start with GET /agent")
};
// 同步路由只回应一次;连接已断开也不应再次处理路由或重复 DELETE 的清空副作用。
let _ = request.respond(response);
}
/// 处理源码资源的 GET/DELETE 等请求,返回对应版本头或撤销运行时所有权。
/// handle 对 /code 的同步资源路径调用,GET 返回最近发布源码,DELETE 撤销发布/执行归属并通知 GUI。
fn code_request(
method: Method,
addressed_namespace: &str,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
// 先短读检查 namespace 和 GET 元信息;DELETE 需要下方先 drop 读锁再取得写锁。
let Ok(current) = namespace.read() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
if addressed_namespace != current.name {
// 单活 namespace 模型不自动创建另一个域,拼错名字直接 404 而非向当前程序投递。
return error_response(
StatusCode(404),
format!(
@@ -548,6 +632,7 @@ fn code_request(
// GET 返回最近成功发布的 source 而非正在排队的输入,并通过头部暴露 revision/session/window/layout 及逻辑视口。
if method == Method::Get {
// 响应正文来自成功发布的文档 source,排队中的候选/未稳定 Console 草稿不可提前读取。
let mut response = text(
StatusCode(200),
&current.document.source,
@@ -574,6 +659,7 @@ fn code_request(
&current.document.frame.layout_version.to_string(),
);
if let Some(viewport) = current.viewport {
// 尺寸头是逻辑可用 viewport,不是操作系统屏幕大小,未知时不造零值头。
add_header(
&mut response,
"X-Tessera-Viewport-Width",
@@ -594,8 +680,10 @@ fn code_request(
.as_ref()
.is_some_and(|root| root.kind == "repl-session")
{
// transcript 含多个已接受输入,调用者不应把它误当可直接替换的单个源快照。
"repl-transcript"
} else {
// 完整程序原文快照与独立 Console transcript 的返回含义明确区分。
"source-snapshot"
},
);
@@ -607,6 +695,7 @@ fn code_request(
match method {
Method::Delete => match namespace.write() {
Ok(mut current) => {
// clear 同时推进撤销身份;worker 后续检查归属时不会再提交先前候选的晚到结果。
let revision = match current.clear() {
Ok(revision) => revision,
Err(error) => return error_response(StatusCode(500), error.to_string()),
@@ -629,7 +718,7 @@ fn code_request(
}
}
/// 读取并检查请求体,分配提交身份,再把工作送进有界执行队列。
/// handle 的执行路由调用,先读有界 UTF-8 正文,再持发布锁完成入队和接受身份更新,不执行 VM。
fn submit(
mut request: ObservedRequest,
addressed: &str,
@@ -637,16 +726,20 @@ fn submit(
sender: &SyncSender<BuildRequest>,
kind: RequestKind,
) {
// 正文读取失败直接 400,不推进 submission 或创建一项只能在执行阶段再失败的队列工作。
let source = match read_code(&mut request) {
Ok(source) => source,
Err(error) => {
// 正文错误在入队前响应,失败连接不影响其他请求,也不分配新的提交身份。
let _ = request.respond(error_response(StatusCode(400), error.to_string()));
return;
}
};
// 读取成功后才保存受限活动正文预览;超长或非法 UTF-8 请求不会进入执行队列。
request.record_body(&source);
// 写锁把“接受新提交”与 worker 的“发布旧提交”互斥,不能先递增身份再入队失败。
let Ok(mut current) = namespace.write() else {
// 无法可靠读取发布身份时返回服务端错误,不能绕过锁以猜测 submission 接受新代码。
let _ = request.respond(error_response(
StatusCode(500),
"namespace lock is poisoned",
@@ -654,21 +747,25 @@ fn submit(
return;
};
if current.name != addressed {
// 路由名字只是请求指向,真正执行归属要匹配当前共享 namespace。
let _ = request.respond(error_response(StatusCode(404), "namespace is not active"));
return;
}
// 仅完整 Code 请求分配新 submission,事件/REPL/表达式继承当前身份,保留共享域输入顺序。
let submission = match if kind == RequestKind::Code {
// 先计算候选接受号而不改 current.submission;只有下面 try_send 成功才让旧提交失去发布资格。
current.next_submission()
} else {
Ok(current.submission)
} {
Ok(id) => id,
Err(error) => {
// 身份计数耗尽等错误尚未入队,保留当前有效 submission,不接受无法唯一标识的工作。
let _ = request.respond(error_response(StatusCode(500), error.to_string()));
return;
}
};
// HTTP 信封明确 native=None,正文即使声明某个字段也不能成为原生后台回执输入。
let job = BuildRequest {
request: RequestReply::Http(request),
native: None,
@@ -679,20 +776,21 @@ fn submit(
};
// 持 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.
Ok(()) => {
// 只有成功入队的完整 PUT 才把 submission 改成新值,未接受请求不会废掉已有工作。
if kind == RequestKind::Code {
current.submission = submission;
}
}
// 队列拒绝时不推进 submission,所以已经接受的旧工作不会因为本次失败请求而失去发布资格。
Err(TrySendError::Full(job)) => {
// try_send 把请求所有权退回,立即回应暂不可用;不等待空位占住 HTTP 接收线程。
let _ = job
.request
.respond(error_response(StatusCode(503), "build queue is full"));
}
Err(TrySendError::Disconnected(job)) => {
// worker 已消失与容量暂满分别报告,原 HTTP 请求仍在返回 job 中,可明确回应而非挂起。
let _ = job.request.respond(error_response(
StatusCode(503),
"execution worker is unavailable",
@@ -701,7 +799,7 @@ fn submit(
}
}
/// 生成较旧请求已被新提交取代的响应,拒绝晚到结果覆盖当前文档。
/// worker 在执行/发布边界发现提交已被新 PUT/DELETE 取代时调用,返回 409,不覆盖当前文档。
fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
json(
StatusCode(409),
@@ -710,19 +808,19 @@ fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
)
}
/// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。
/// handle 的各 API 路由解析调用,借用严格前后缀之间的单段 namespace,失败返回 None 不做模糊回退。
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('/'))
}
/// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。
/// handle 为 /code 资源调用的特化解析,复用 namespace_from_route 的单段/非空约束。
fn namespace_from_path(path: &str) -> Option<&str> {
namespace_from_route(path, "/code")
}
/// 按源码字节上限读取 UTF-8 请求体,读取失败或超限时返回诊断。
/// submit 在入队之前调用,已知长度预拒绝、真实流再硬限长,任何非 UTF-8 都不能进入 source 字符串。
fn read_code(request: &mut Request) -> Result<String> {
if request
.body_length()
@@ -739,13 +837,15 @@ fn read_code(request: &mut Request) -> Result<String> {
.read_to_string(&mut code)
.context("read code")?;
if code.len() as u64 > MAX_CODE_SIZE {
// code.len() 是 UTF-8 字节数,流读取上限多留一个字节用于探测,不能以字符数替代 1 MiB 源码预算。
anyhow::bail!("code exceeds the 1 MiB limit");
}
Ok(code)
}
/// 将前端/编译/执行诊断编码为 API 错误响应,保留用户可理解的位置和消息。
/// worker 执行/加载失败时调用,将 anyhow 错误链转成 422;有前端来源才附精确行列,不执行 VM 求值。
fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
// 先保存完整可读错误链,只有确有 SourceDiagnostic 才追加源位置字段。
let mut diagnostic =
serde_json::json!({ "severity": "error", "message": format!("{error:#}") });
// 有 SourceDiagnostic 才附行/列和半开字节范围;其他运行时错误仍保留完整错误链消息,不编造源码位置。
@@ -764,7 +864,7 @@ fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
)
}
/// 生成统一 JSON 错误响应,使调用方能区分协议错误与成功结果。
/// 路由、入队、退出及 worker 的协议/运行状态错误共用助手,保留调用者选择的 HTTP 状态码。
fn error_response(
status: StatusCode,
message: impl Into<String>,
@@ -772,7 +872,7 @@ fn error_response(
json(status, serde_json::json!({ "error": message.into() }))
}
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
/// 成功/错误 API 分支共用 JSON 编码入口,正文交 text 一次性包装成内存字节 Cursor。
fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
text(
status,
@@ -781,12 +881,12 @@ fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Curso
)
}
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
/// handle 的 GET /agent 调用,输出当前发布状态派生的 Markdown 指南,不把它标成 JSON。
fn markdown(status: StatusCode, value: String) -> Response<std::io::Cursor<Vec<u8>>> {
text(status, &value, "text/markdown; charset=utf-8")
}
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
/// json/markdown 和源码 GET/OPTIONS 共用响应助手,统一状态/内容类型和固定本机 CORS 头。
fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io::Cursor<Vec<u8>>> {
// 公共响应头只允许固定本机 Origin/方法/头名,非通配任意来源;这些头不替代回环绑定与路由校验。
let mut response = Response::from_string(value).with_status_code(status);
@@ -809,7 +909,7 @@ fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io
response
}
/// 向响应追加经过 HTTP 库检查的头字段,不直接拼接未验证的原始报文。
/// text/code_request 为内容类型、CORS 和版本信息调用,字段格式交 tiny_http 验证,非法头不直接拼接发送。
fn add_header(response: &mut Response<std::io::Cursor<Vec<u8>>>, name: &str, value: &str) {
// 由 HTTP 库验证字段格式,非法头被忽略,不把未校验字符拼进原始响应报文。
if let Ok(header) = Header::from_bytes(name, value) {
+23 -12
View File
@@ -1,12 +1,19 @@
//! 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。
//!
//! 指南正文是返回给客户端的静态 Markdown 模板,只有命名空间、版本、地址和内置示例按参数填充。
//! api::handle 的 GET /agent 分支在 NamespaceState 读锁内调用 agent_guide,随后按 text/markdown 返回。
//! 原始字符串中的 ```endor/JSON/HTTP 等代码块是给 API 使用者看的文档数据,不是 Rust 会执行的逻辑。
//! 这不是 API/预算反射生成器:正文中的能力、额度与演进说明需人工随实现维护,format! 不验证这些描述。
use super::*;
/// 为 /agent 生成可提交代码与查询接口的 Markdown 说明。
/// format! 使用具名插值注入 namespace.name/revision 与监听地址;示例在编译时嵌入,
/// 因而请求不会访问示例文件、执行示例或改变会话。正文中的 JSON/Endor 是文档内容。
/// 函数只读传入状态,不能由“指南显示某版本”推断新候选已成功发布;revision 仍以当前发布状态为准。
pub(super) fn agent_guide(namespace: &NamespaceState) -> String {
// 一次 format 生成完整响应字符串;原始字符串保留文档换行,具名插值在模板结束处统一提供。
// 原始模板内的双花括号输出单个 JSON 花括号;插入的 namespace/示例内容不会被 format 再次解释为格式串。
// 调用方当前在共享状态读锁内生成文本,返回的 String 自有内容,不在其中保留 NamespaceState 借用或 VM 句柄。
format!(
r##"# Tessera Agent API
@@ -122,15 +129,15 @@ ContainerBlock.insert(position,block) accepts a zero-based position through the
Source measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. Native TextBlock wrapping now uses the occurrence width in measurement/cache/painting; source measurement/arrangement now repeats until constraints and geometry settle; additional allocation policies remain pending; ScrollBlock scrolling is described below.
Source layout solves repeat in one read-only call until constraints/sizes/bounds are unchanged, reusing one bytecode frame. Source-arranged changed dimensions become exact per-axis constraints for this solve; source container measurements differing from native results constrain content using the same container policy. Selected limits reset on later inputs/resizes. Nonconsecutive repeated states report a cycle. No fixed pass/hop cutoff is used; all passes share the 1000000-instruction/128-frame budget and budget failure is explicit. Measurement clears provisional Mount geometry, and only complete settled frames publish. ui.stats().layoutPasses reports work; source/cache counters include all passes. VM annotation checks distinguish validation-only from content reads, preserving Mount-typed independent measurement caching; actual Mount reads still bypass conservatively. Resumable solving remains pending. See examples/layout-negotiation.endor.
Source layout solves repeat in one read-only call until constraints/sizes/bounds are unchanged, reusing one bytecode frame. Source-arranged changed dimensions become exact per-axis constraints for this solve; source container measurements differing from native results constrain content using the same container policy. Selected limits reset on later inputs/resizes. Nonconsecutive repeated states report a cycle. No fixed pass/hop cutoff is used; all passes share the 1000000-instruction/128-frame budget and budget failure is explicit. Measurement clears provisional Mount geometry, and only complete settled frames publish. ui.stats().layoutPasses reports work; source/cache counters include all passes. VM annotation checks distinguish validation-only from content reads, preserving Mount-typed independent measurement caching; actual Mount reads still bypass conservatively. The solve can pause and resume across worker turns as described below. See examples/layout-negotiation.endor.
TextBlock now wraps words/glyphs at its available width and measures the resulting height. Native and source-base measurement paths use current constraints; native shape/font caches include wrap width. Painting carries allocated width/height, clips glyphs and keeps ancestor scroll clipping. Source text PaintCommand may supply both wrapWidth/wrapHeight (finite nonnegative logical dimensions up to 1000000); neither preserves legacy unwrapped paint, and a partial pair is rejected. Native TextBlock.paint supplies the pair. These are derived draw bounds, not another business object. Management UI text remains unwrapped. Resumable solving and broad fractional-DPI validation remain pending. See examples/wrapped-text.endor.
TextBlock now wraps words/glyphs at its available width and measures the resulting height. Native and source-base measurement paths use current constraints; native shape/font caches include wrap width. Painting carries allocated width/height, clips glyphs and keeps ancestor scroll clipping. Source text PaintCommand may supply both wrapWidth/wrapHeight (finite nonnegative logical dimensions up to 1000000); neither preserves legacy unwrapped paint, and a partial pair is rejected. Native TextBlock.paint supplies the pair. These are derived draw bounds, not another business object. Management UI text remains unwrapped. Layout and paint can pause and resume; broad fractional-DPI validation remains pending. See examples/wrapped-text.endor.
Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects.
ScrollBlock(width,height,child) is a ContainerBlock with managed viewport dimensions and independent per-Mount offsets. mount.scrollTo(x,y), scrollBy(dx,dy), scrollPosition() and scrollIntoView() operate on actual occurrences. Position snapshots expose x/y/maxX/maxY from committed layout; relative requests accumulate and offsets clamp after content/viewport changes. Detached Mounts and read-only rendering mutations are rejected. scrollIntoView and requestFocus adjust nested scrollable ancestors. Native/source paint, focus outlines, hit testing and pointer selection share translated geometry and ancestor clips. Remounting starts a new offset. Paint x/y may be signed after scrolling; dimensions/radii remain nonnegative and coordinate budgets still apply. See examples/scroll.endor. Wheel chooses one eligible ancestor (not per-axis split); native scrollbar dragging, touch inertia and PageUp/PageDown remain pending.
Native wheel POST /events uses kind scroll with the normal window/session/layout_version, a scrollable mount ID, point=[x,y], delta=[dx,dy], and no press_point. Deltas are signed finite logical pixels within +/-1000000; positive moves toward the end of content. Other kinds must omit delta. Native Shift+wheel maps to horizontal. The host checks the active committed frame, target attachment and clipped viewport before invoking Mount.scrollBy. These fields are transport metadata; source code uses the Endor methods above.
Native wheel POST /events uses kind scroll with the normal window/session/layout_version, a scrollable mount ID, point=[x,y], delta=[dx,dy], and no press_point. Deltas are signed finite logical pixels within +/-1000000; positive moves toward the end of content. Ordinary pointer clicks omit delta; camera gestures use their own delta contracts described above and below. Native Shift+wheel maps to horizontal. The host checks the active committed frame, target attachment and clipped viewport before invoking Mount.scrollBy. These fields are transport metadata; source code uses the Endor methods above.
ui.after(owner,key,milliseconds,callback) returns Task. The zero-argument callback runs on the owning VM worker after at least the integer 0..86400000 ms delay and must return Unit. Idle polling can add up to 100 ms; execution load may delay delivery further. Task.cancel/active/status expose cancellation, eligibility and pending/running/completed/cancelled/failed state. One owner/key replaces the prior pending task. Owner scopes and callback captures are managed strong edges; scheduler and owner links are weak. ui is an application owner. Hidden live objects continue; Page close cancels its and its Mount tasks, even close/reopen within one input. Page movement preserves Page tasks but replaces Mounts. Window close cancels its Pages/tasks; reset/DELETE/program replacement discard old scheduler/VM and generation checks reject stale publication. Failed PUT preserves old tasks. Callback failure stops the domain and cancels remaining tasks; earlier writes do not roll back. Read-only rendering cannot schedule/cancel. Limits: 64 pending per owner, 1024 per runtime, 64 delivered per poll, 128 frames/100000 instructions per callback. Providers support timers, asynchronous text-file reads and HTTP(S) text GET; await is supported in ui.spawn calls.
@@ -160,20 +167,20 @@ Finite box layout: Overlay(padding, gap, children...) stacks children (gap is un
Independent 2D scenes are now implemented: Scene2D(children...) stores world content at child x/y; CanvasViewport(width,height,scene) is a finite layout box with its own Camera2D(). Camera2D(x=0,y=0,zoom=1) fields are ordinary managed attributes; zoom is 0.01–100. Sharing a scene does not share its camera; assigning second.camera=first.camera does. Scene content may contain layout containers and nested finite viewports; Overlay siblings stay fixed. Mount.fitView() fits finite direct scene-child bounds using that occurrence's allocation. Native wheel/pinch/space or middle drag targets the innermost viewport; drag retains its initial target. viewport-pan/viewport-zoom carry the viewport Mount and current frame identities, with signed logical page coordinates. Pan delta is measured before the viewport projection; zoom delta is [factor,0] with an in-content pointer anchor. Worker validation rejects stale/invalid input before invoking the VM. Draw and hit transforms compose consistently while layout geometry and measurement caches remain in world coordinates. ScrollView is a nominal ScrollBlock subtype. The default page uses these APIs; old Page.mode/panX/panY/zoom remain for compatibility. 3D support is described below. See examples/scene-viewports.endor.
3D basics are now implemented: Scene3D/Group3D, Box3D, immutable Geometry3D made from Vec3/Triangle3D ObjectLists, Mesh3D, DirectionalLight, and finite Viewport3D. PerspectiveCamera and OrthographicCamera expose x/y/z, targetX/Y/Z, upX/Y/Z, near/far, fov or height; viewport aspect is derived separately. New viewports have independent cameras and OrbitController; explicit camera assignment shares a camera. A controller can be replaced with an Endor object defining update(camera,gesture), or disabled by assigning "none". Native scene-orbit/scene-pan/scene-zoom events carry viewport Mount/frame identities. Mesh/group translate and rotate use native arithmetic; camera.translate moves position and target. This avoids the fixed Aster version's unsupported numeric arithmetic expressions. Mount.pick3D(x,y) returns nullable RayHit3D with managed mesh and world intersection data; pointer click Event.hit carries the same result. Rendering uses frustum clipping and a depth buffer in a bounded cached surface. World-space UI composition is described below; visibility and resource work is continuing. See examples/scene3d.endor and docs/scene-architecture.md.
3D basics are now implemented: Scene3D/Group3D, Box3D, immutable Geometry3D made from Vec3/Triangle3D ObjectLists, Mesh3D, DirectionalLight, and finite Viewport3D. PerspectiveCamera and OrthographicCamera expose x/y/z, targetX/Y/Z, upX/Y/Z, near/far, fov or height; viewport aspect is derived separately. New viewports have independent cameras and OrbitController; explicit camera assignment shares a camera. A controller can be replaced with an Endor object defining update(camera,gesture), or disabled by assigning "none". Native scene-orbit/scene-pan/scene-zoom events carry viewport Mount/frame identities. Mesh/group translate and rotate use native arithmetic; camera.translate moves position and target. The same scene attributes remain accessible to ordinary Endor code. Mount.pick3D(x,y) returns nullable RayHit3D with managed mesh and world intersection data; pointer click Event.hit carries the same result. Rendering uses frustum clipping and a depth buffer in a bounded cached surface. World-space UI composition is described below; visibility and resource work is continuing. See examples/scene3d.endor and docs/scene-architecture.md.
World UI is implemented with Panel3D(worldWidth,worldHeight,content). uiWidth/uiHeight provide an independent finite UI layout resolution (integer 1–2048); background is opaque. Ordinary Endor measure/arrange/paint/hitTest, shared objects, distinct Mounts and per-occurrence scroll state remain active. Panel transforms place a perspective-correct UI texture in the world. A visible ray hit maps through the plane to UI coordinates, then through any nested 2D camera; opaque geometry blocks UI input. Panel UI may contain another Viewport3D and further panels (maximum 8 world UI levels). Mount.toView(localX,localY) converts component-local coordinates to the page view; toLocal(viewX,viewY) performs the reverse when visible. Camera transport from a world UI viewport requires a pointer so pan/scroll increments can use the plane's projective mapping. Its scroll and camera gestures retain one target instead of driving both parent and child. Cyclic UI/scene composition and nested pixel allocations are bounded. See examples/world-ui.endor.
Block.flex is a nonnegative weight (default 0). Native containers reserve fixed measured children, padding and gaps, then share remaining allocated main-axis space between positive weights with zero basis. Actual child allocation feeds remeasurement. It does not request parent expansion. Further controls are described below.
Flex main-axis policy: flexBasis is "auto" by default (zero basis when flex > 0, otherwise the first measured size of this occurrence in the current solve); a number supplies an explicit basis. flexMin defaults to 0, flexMax to 1000000, and flexShrink to 0. All numbers are finite/nonnegative and at most 1000000; flexMin must not exceed flexMax. The parent clamps bases to min/max, distributes positive space by flex, and redistributes space left after a maximum is reached. Deficits shrink by flexShrink times the original basis, freezing at minima. A fixed child can opt into shrink without growth. Unresolvable minimum overflow is preserved, and remaining positive space follows mainAlign. The policy applies to width in a Row and height in Column-like containers; these are not independent width/height bounds. Source arrange overrides remain authoritative. Preferred sizes are derived per occurrence and reset for each new solve, never shared through Block state. See examples/flex-constraints.endor. Layout continuation remains unfinished.
Flex main-axis policy: flexBasis is "auto" by default (zero basis when flex > 0, otherwise the first measured size of this occurrence in the current solve); a number supplies an explicit basis. flexMin defaults to 0, flexMax to 1000000, and flexShrink to 0. All numbers are finite/nonnegative and at most 1000000; flexMin must not exceed flexMax. The parent clamps bases to min/max, distributes positive space by flex, and redistributes space left after a maximum is reached. Deficits shrink by flexShrink times the original basis, freezing at minima. A fixed child can opt into shrink without growth. Unresolvable minimum overflow is preserved, and remaining positive space follows mainAlign. The policy applies to width in a Row and height in Column-like containers; these are not independent width/height bounds. Source arrange overrides remain authoritative. Preferred sizes are derived per occurrence and reset for each new solve, never shared through Block state. See examples/flex-constraints.endor. Layout continuation is implemented through the saved jobs described below.
When an occurrence's cross-axis constraint changes during settling, its preferred main-axis size is invalidated and remeasured. Any obsolete native flex allocation on that axis is removed for the replacement measurement, so narrowing a nested Column can increase wrapped text height. Source arrange allocations remain explicit. Preferred-size presence and values participate in convergence/cycle detection.
The source layout driver now uses Aster's resumable read-only call API with a soft quantum of 256 instructions. Pauses retain the same VM frames, operand/local roots, return contracts and read tracking; total instruction/frame limits still apply across slices. ui.stats().layoutSlices counts slices in the most recent solve (native fast path: 1). The worker currently resumes Pending immediately after yielding its OS time slice. Parking the solve across worker turns, scheduling other work and handling newer inputs before completion are not implemented yet. Native callbacks are not preempted. Only the completed settled frame is published.
The source layout driver now uses Aster's resumable read-only call API with a soft quantum of 256 instructions. Pauses retain the same VM frames, operand/local roots, return contracts and read tracking; total instruction/frame limits still apply across slices. ui.stats().layoutSlices counts slices in the most recent solve (native fast path: 1). The worker retains Pending across turns, checks shutdown and newer viewport targets between quanta, and advances the saved continuation without replaying source. Later ordinary mutations wait for the current refresh to settle. Native callbacks are not preempted. Only the completed settled frame is published.
source_layout::start now returns either Complete(Draft) or Pending(Work). The host-owned Work retains the unpublished frame, derived runtime/cache snapshots, convergence plan and prepare root without borrowing caller frame/cache variables. Work::resume consumes the saved job and advances the same VM continuation; Work::cancel closes that continuation and releases the prepare root without merging a draft. A second start is rejected while any read-only execution is active. Every pending Work must be resumed or explicitly cancelled with its owning VM; dropping it is not cancellation. Synchronous ReplSession operations drain the same saved refresh state. The worker parks refresh jobs; cancellation restores still-pending Mount preparation.
@@ -182,13 +189,13 @@ source_layout::start now returns either Complete(Draft) or Pending(Work). The ho
UiBridge::begin_layout_frame/resume_layout_frame now expose PendingLayout around the source job. This bridge-level continuation also owns its target window, page snapshot, tab projection, viewport restoration and retained lifecycle hook root. Only the Complete branch projects scroll/focus, captures dependency versions, merges derived caches, commits Mount geometry and runs deferred hooks. Resume restores the saved window context, and failure releases hook roots. ReplSession uses this protocol through its saved refresh state. Viewport refresh supports cross-worker-turn scheduling and prepared-Mount rollback; callers must not drop a pending bridge layout as a substitute for cancellation.
Viewport refresh now advances through ReplSession's saved refresh state on worker idle turns. A turn advances one traversal stage or a 256-instruction layout quantum; a pending job shortens the next worker wait to 1 ms. Tasks/window commands and document publication wait for complete refresh. ui.stats().refreshYields reports cooperative idle returns. Mutating requests wait across worker turns for the existing job, so they cannot run inside its read-only scope. Mutating requests are serialized; newer viewport targets coalesce by cancelling unpublished layout. Initial source execution remains atomic; initial layout parks across worker turns. Explicit REPL/event refresh parks across worker turns. Idle tasks deliver at most one callback per turn and park subsequent layout; callbacks and individual native nodes remain atomic. Large intrinsic trees use the sliced driver; small intrinsic trees keep the fast path.
Viewport refresh now advances through ReplSession's saved refresh state on worker idle turns. A turn advances one traversal stage or a 256-instruction layout quantum; a pending job or runnable cooperative Task uses a zero-duration queue wait; idle or external waits use up to 100 ms. Tasks/window commands and document publication wait for complete refresh. ui.stats().refreshYields reports cooperative idle returns. Mutating requests wait across worker turns for the existing job, so they cannot run inside its read-only scope. Mutating requests are serialized; newer viewport targets coalesce by cancelling unpublished layout. Initial source execution remains atomic; initial layout parks across worker turns. Explicit REPL/event refresh parks across worker turns. Idle tasks deliver at most one callback per turn and park subsequent layout; callbacks and individual native nodes remain atomic. Large intrinsic trees use the sliced driver; small intrinsic trees keep the fast path.
Runtime mutation entry points reject direct calls while refresh work is pending, before executing source or changing metadata; task polling defers. The API worker drains the saved refresh before accepting those mutations, keeping the runtime usable. Incremental refresh keeps the prior complete document map until a stable pass, including across window traversal boundaries where no VM read-only frame is currently active.
New viewport targets now coalesce pending refresh work. The runtime cancels only an unfinished bridge layout, restores its Mount checkpoint, preserves completed per-window drafts/callback effects and restarts traversal with the latest viewport map. refreshRestarts counts target replacements; layoutCancels counts replacements that actually closed a pending VM layout. Unchanged targets resume the same job. A new external target resets its convergence budget; internal settling does not. Ordinary source/event mutations still serialize behind refresh rather than roll back application effects.
New viewport targets now coalesce pending refresh work. The runtime cancels unfinished layout or paint. Layout cancellation restores its Mount checkpoint; paint cancellation discards partial commands while retaining completed layout and lifecycle effects. Completed per-window drafts/callback effects are preserved while traversal restarts with the latest viewport map. refreshRestarts counts target replacements; layoutCancels counts replacements that actually closed a pending VM layout. Unchanged targets resume the same job. A new external target resets its convergence budget; internal settling does not. Ordinary source/event mutations still serialize behind refresh rather than roll back application effects.
Cancellation checkpoints own explicit VM roots until restored or released. Initial presentations may have no mount dictionary; cancellation restores that absence too. Saved metadata values retain object-valued attributes. A pending refresh can restart while keeping already completed window drafts; no onMount/onUnmount callback is replayed merely because the viewport target changed. Mount-owned tasks are cancelled immediately before an actual onUnmount callback, never during abandoned preparation.
@@ -235,8 +242,8 @@ Defaults above describe the host API; do not write keyword arguments in Endor.
Colors use #RRGGBB or #RRGGBBAA. Dimensions are finite numbers in 0..1000000.
Use newlines between complete top-level statements. Multiline calls, function
bodies and expression continuations are passed to Aster as complete inputs.
Limits: 1 MiB source, 4096 constructed nodes, 64 pages, 64 UI levels, 10000 expanded
layout nodes, 16 KiB per text, 128 VM frames. Script statements each receive an
Limits: 1 MiB source, 4096 constructed nodes, 64 pages, 10000 expanded
layout nodes, 16 KiB per text, 128 VM frames. Source AST nesting has a separate 64-level limit. Script statements each receive an
equal share of a total 1000000-instruction allowance. Older module initialization
and main execution each retain their own 1000000-instruction allowance.
@@ -249,13 +256,17 @@ and font fallback; Chinese needs an installed CJK font. Size 1 is 14 logical pix
with 20-pixel line height; larger sizes multiply both. Native Block methods include
setText/setColor, container append/insert/remove/replace/move, Page open/close/navigation,
and code-driven button.onClick/click. ui.state and ui.textOf provide explicit text
binding. Mouse routing, tab chrome/multiple windows and incremental layout remain
in progress; process isolation is future work. Console retains objects
binding. Mouse routing, sidebar page management, multiple windows and incremental
layout/paint are implemented; broader platform validation and process isolation remain future work. Console retains objects
only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox.
"##,
// 路由指向当前唯一活 namespace,不由请求查询串或任意模板文件决定。
name = namespace.name,
// 只显示最近发布的 revision,不把正在排队的 submission 当作已经成功的新版本。
revision = namespace.revision,
// 与 api::start 的实际监听常量一致;不扫描其他端口或猜测外部网络地址。
address = API_ADDRESS,
// include_str 在应用构建时嵌入例子,处理 GET 时不读磁盘,也不在 VM 运行这个例子。
example = include_str!("../../../examples/page.endor"),
)
}
+211 -7
View File
@@ -1,6 +1,9 @@
//! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。
//! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。
//!
//! Rust 测试框架调用下方 #[test];大多数测试通过 TestServer::request 访问真实 tiny_http 路由和串行 VM worker。
//! 少量测试直接安装 Worker 的活会话以精确触发发布身份边界,仍调用真实 ReplSession 而非模拟对象堆。
//! r# 字符串中的 Endor 是测试夹具,行号/诊断也可能是契约的一部分;注释只加在 Rust 测试逻辑外层。
use super::*;
use std::{
io::Write,
@@ -13,6 +16,7 @@ use std::{
/// 模拟 reset 后旧画面仍含编号 2 的窗口,而新 Console VM 恰好再次分配编号 2。
/// 关闭 session=0 的旧投影只能移除画面,不能按相同数字误关新 VM 的真实资源。
fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
// 旧运行时真实创建多个窗口,随后只保留其纯数据画面以模拟 Console reset 后的静态投影。
let (_, mut picture) = endor_host::ReplSession::load(
include_str!("../../../examples/windows.endor"),
"app.main",
@@ -25,6 +29,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
for view in picture.windows.values_mut() {
view.frame.session = 0;
}
// 新 VM 使用不同 session,但窗口分配器同样从 2 开始,故意形成“同号但不同代”的资源。
let mut fresh = endor_host::ReplSession::new(151).unwrap();
assert!(
fresh
@@ -32,16 +37,19 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
.is_err()
);
assert!(fresh.accepts_window(2));
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
document: picture,
submission: 1,
runtime_session: 151,
..NamespaceState::default()
}));
// 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。
let mut worker = Worker {
repl: Some((1, fresh)),
next_session: 151,
};
// 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。
let notify: Notify = Arc::new(|| {});
namespace.write().unwrap().window_viewports.insert(
2,
@@ -55,6 +63,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
namespace.read().unwrap().document.windows[&2].frame.session,
0
);
// 关闭信封指向 session=0 的旧投影,不能因新 VM 也有 window=2 就退役它。
let response = worker.close_inactive_window(
&crate::events::Input {
window: 2,
@@ -74,6 +83,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
);
assert_eq!(response.status_code(), StatusCode(200));
assert!(namespace.read().unwrap().document.windows.is_empty());
// 从 worker 真实活域验证新窗口仍存在,不仅检查画面副本是否被删。
let runtime = &mut worker.repl.as_mut().unwrap().1;
assert_eq!(runtime.id, 151);
assert!(runtime.accepts_window(2));
@@ -85,15 +95,19 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
/// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。
struct TestServer {
// 拥有实际 Server 以便 Drop 调用 unblock,中断 incoming_requests 等待。
server: Arc<Server>,
// 每例实际绑定端口,从 server_addr 读取而非硬编码应用默认地址。
address: SocketAddr,
// Option 允许 Drop 取出句柄只 join 一次,不从借用的 self 中移动裸 JoinHandle。
api: Option<thread::JoinHandle<()>>,
}
impl TestServer {
/// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。
/// 返回状态码、原始头和正文;10 秒超时使服务端死锁表现为测试失败而非无限等待。
/// 返回状态码、原始头和正文;连接后的 socket 读/写各设 10 秒超时,不是整个请求的统一截止时间。
fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) {
// 不用直调 handle 代替传输,真实 socket 覆盖 HTTP 接收、正文流和响应编码。
let mut stream = TcpStream::connect(self.address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
@@ -102,19 +116,23 @@ impl TestServer {
.set_write_timeout(Some(Duration::from_secs(10)))
.unwrap();
write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap();
// Connection: close 让响应读到 EOF,超时避免服务端死锁导致整套测试无限卡住。
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
// 按 HTTP 空行分头/体,保持响应原文供状态头与 JSON/源码两类断言分别检查。
let (headers, body) = response.split_once("\r\n\r\n").unwrap();
// 状态行第二词是状态码,输出与 headers/body 一起成为各测试的三元组接口。
let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap();
(status, headers.to_owned(), body.to_owned())
}
}
impl Drop for TestServer {
/// 解除服务端 accept 等待并等待线程结束,避免测试之间遗留监听线程。
/// 解除服务端 accept 等待并回收 HTTP 接收线程;此句柄不是 VM worker 的 JoinHandle。
fn drop(&mut self) {
self.server.unblock();
if let Some(api) = self.api.take() {
// 已解除 accept 等待,join 确保本例线程结束;线程异常由具体测试断言报告,不在 Drop 二次 panic。
let _ = api.join();
}
}
@@ -124,14 +142,19 @@ impl Drop for TestServer {
/// 通过 HTTP 提交覆盖 moveTab 的 Page 子类,再发送带帧身份的 tab-drop。
/// 检查源覆盖恰好调用一次、页面顺序改变,以及重放旧事件返回 409 而不损坏运行时。
fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// Page 子类在 moveTab 中递增共享计数,既检查实际重排,也能发现业务方法被挂起恢复重复调用。
let source = r#"class Tracked: Page {
calls: Object
function moveTab(self: Tracked, position: Number): Page { self.calls.increment() return Page.moveTab(self,position) }
@@ -144,10 +167,14 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
second.title="second"
second.render(TextBlock("second"))
first.activate()"#;
// 完整 PUT 成功后才从实际发布帧构造标签事件,不能使用 AST/标题推导 Tab ID。
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(result.0, 200, "{}", result.2);
// 用第一标签原 ID 指定目标插入缝 2,移动后版本应变化,同一正文再发必须过期。
let input = {
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
let current = namespace.read().unwrap();
// 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。
let frame = &current.document.frame;
crate::events::Input {
window: 1,
@@ -168,9 +195,12 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
};
// 保留同一事件正文用于重放测试,不能重新读取新 layout_version,否则就不是过期输入。
let body = serde_json::to_string(&input).unwrap();
// 第一遍走真实事件路由与源覆盖,应只重排一次,不因 worker 分片重试业务方法。
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
assert_eq!(response.0, 200, "{}", response.2);
// 再经持久 Console 读同一共享状态,避免仅凭标签排列看不出重复回调。
let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value");
// value 是 Console JSON 字段中的显示字符串,不是测试自己复制的 Endor 数值。
let value: serde_json::Value = serde_json::from_str(&response.2).unwrap();
assert_eq!(value["value"], "1");
assert_eq!(
@@ -190,14 +220,19 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() {
/// 零延时任务触发非法窗口尺寸,使后台回调先产生运行时故障。
/// 之后多次 REPL 请求都应被拒绝,同时保留原始 stop/window size 诊断,而非用后续错误覆盖。
fn background_failure_preserves_its_cause_after_rejected_console_input() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 零延时任务的源方法触发非法窗口尺寸,产生真正回调故障而非测试直接写 fault 字段。
let source = r#"class Failure {
function stop(self: Failure): Unit { ui.window().resize(0,600) }
}
@@ -205,10 +240,13 @@ fn background_failure_preserves_its_cause_after_rejected_console_input() {
page=Page()
page.render(TextBlock("preserved"))
task=ui.after(ui,"failure",0,failure.stop)"#;
// 源码候选本身先成功;随后 worker 空闲任务阶段才会触发 stop 中的 resize 错误。
let result = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(result.0, 200, "{}", result.2);
// 重复 Console 输入会被停用域拒绝,但不能覆盖首次回调真正的 window size/stop 诊断。
let repl = "/v1/namespaces/app.main/repl";
assert_eq!(client.request("POST", repl, "42").0, 422);
// 保留第一次故障纯文本,对比后续拒绝输入后仍为同一个原因。
let fault = namespace
.read()
.unwrap()
@@ -230,22 +268,30 @@ fn background_failure_preserves_its_cause_after_rejected_console_input() {
/// 依次创建未挂载对象、读取共享值、挂载、reset,再用完整 PUT 替换会话。
/// 验证未挂载输入不增 revision,reset 清绑定但保留画面,成功 PUT 才切换到新 VM。
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 同一 Console 路由跨多个请求复用真实 globals,/code 读的是可见源快照而非内部变量表。
let repl = "/v1/namespaces/app.main/repl";
// 用于确认 reset 后旧画面/源码仍保留,完整 PUT 才切换到新程序。
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);
// 将 HTTP 三元组中的正文解析为 API JSON,rendered/revision 与 value 各有独立断言。
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
assert_eq!(created["rendered"], false);
assert_eq!(created["revision"], 0);
// 第二次请求引用前一请求的对象,证明会话保留真实对象而非每次独立表达式求值。
let read: serde_json::Value =
serde_json::from_str(&client.request("POST", repl, "title").2).unwrap();
assert_eq!(read["value"], created["value"]);
@@ -253,6 +299,7 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
client.request("POST", repl, "ui.page().render(title)").0,
200
);
// render 后 GET 应识别为 transcript;reset 只清 VM 绑定,不应抹去此已发布正文。
let snapshot = client.request("GET", code, "");
assert!(
snapshot
@@ -280,34 +327,47 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
/// 经真实 HTTP 完成指南读取、PUT/GET、失败候选、再次 PUT 和 DELETE。
/// 同时检查诊断位置、源文本/版本头、空文档、错误 namespace 与通知次数,确保仅成功发布改变版本。
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
// 原子计数观察真实文档发布通知,不把 HTTP Network 观察更新混入这个断言。
let notifications = Arc::new(AtomicUsize::new(0));
// move 闭包持克隆 Arc,测试仍可通过原 Arc 在请求完成后读累计次数。
let counter = notifications.clone();
// GUI 被计数器替代,实际 worker/发布路径保持不变;只有有效发布才应递增。
let notify: Notify = Arc::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
// 装入计数通知,下面 PUT/DELETE 的总 revision 与通知数必须保持一致。
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 一个固定合法 namespace 用于全部 CRUD,最后另测错误 namespace 不应创建新域。
let route = "/v1/namespaces/app.main/code";
// 使用实际交付示例,成功 PUT 的 GET 原文应与它逐字相同。
let example = include_str!("../../../examples/page.endor");
// 指南 GET 只能观察状态,不能产生文档 revision 或运行示例。
let (status, _, guide) = client.request("GET", "/agent", "");
assert_eq!(status, 200);
assert!(guide.contains("let myui = ui.page()"));
// 首次成功 PUT 产生 revision 1,响应 JSON 的版本应对应共享状态。
let (status, _, body) = client.request("PUT", route, example);
assert_eq!(status, 200, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["revision"],
1
);
// GET 同时检查版本头与实际源正文,不只确认 HTTP 200。
let (status, headers, source) = client.request("GET", route, "");
assert_eq!(status, 200);
assert!(headers.to_lowercase().contains("x-tessera-revision: 1"));
assert_eq!(source, example);
// main 引用未定义名字使候选失败;422 必须保留第 1 行来源且不覆盖前面成功源,断言不区分编译/执行阶段。
let (status, _, body) =
client.request("PUT", route, "function main(): View { return missing }");
assert_eq!(status, 422, "{body}");
@@ -316,6 +376,7 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
1
);
assert_eq!(client.request("GET", route, "").2, example);
// 最小可见文字改动生成第二个有效候选;失败候选没有占用 revision,故此时应为 2。
let second = example.replace("Hello Endor", "Second");
assert_eq!(client.request("PUT", route, &second).0, 200);
assert_eq!(namespace.read().unwrap().revision, 2);
@@ -337,6 +398,7 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
/// 直接推进 worker 的 submission,模拟新候选已接受但尚未成功替换的时刻。
/// 旧 VM 的 retained 仍可读;clear 撤销 namespace 后再刷新,才释放 live owner。
fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
// 真实旧 VM 保留可读 retained 状态,新的 submission=2 模拟接受但尚未成功构建的 PUT。
let (runtime, document) = endor_host::ReplSession::load(
"retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))",
"app.main",
@@ -344,16 +406,19 @@ fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
277,
)
.unwrap();
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
document,
submission: 2,
runtime_session: runtime.id,
..NamespaceState::default()
}));
// 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。
let mut worker = Worker {
repl: Some((1, runtime)),
next_session: 277,
};
// 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。
let notify: Notify = Arc::new(|| {});
worker.refresh_viewport(&namespace, &notify);
assert_eq!(worker.repl.as_ref().unwrap().0, 2);
@@ -369,6 +434,7 @@ fn queued_candidate_keeps_live_owner_until_delete_revokes_it() {
"9"
);
{
// 再次接受更高序号仍只更新旧 live owner 的 epoch,不能销毁仍有权威 session 的堆。
let mut current = namespace.write().unwrap();
current.submission = 3;
worker.preserve_live_owner(&current);
@@ -387,20 +453,29 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
sync::{Mutex, mpsc},
time::Instant,
};
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
// 第一通道通知主测试“worker 已进入首次发布通知”,随后才可靠地安排第二 PUT。
let (entered_tx, entered_rx) = mpsc::channel();
// 第二通道让作用域守卫无条件解除 worker 阻塞,避免测试 panic 后线程 join 永远挂起。
let (release_tx, release_rx) = mpsc::channel();
// 通知是共享闭包;用 Mutex 包装接收端使其满足线程共享约束,只阻塞第一次通知。
let release_rx = Mutex::new(release_rx);
// 只阻塞第一个通知,DELETE 自身的新通知必须仍能正常返回,不依赖固定 sleep 猜时序。
let calls = AtomicUsize::new(0);
// 此处故意制造受控阻塞;生产通知只投递 GUI 事件,没有这个测试通道等待。
let notify: Notify = Arc::new(move || {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
// 信号先通知测试再等待释放,保证下面读 GET/DELETE 时 worker 确在有界暂停点。
entered_tx.send(()).unwrap();
release_rx.lock().unwrap().recv().unwrap();
}
});
// 用真实 HTTP 接收线程证明 VM worker 被阻塞时,GET/DELETE 仍不会被同一队列堵住。
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
@@ -412,19 +487,28 @@ fn http_delete_remains_available_and_invalidates_queued_put() {
impl Drop for Release {
// send 失败意味着接收端已结束,此时无需再阻塞测试清理。
fn drop(&mut self) {
// 最佳努力释放:接收端已停止时发送失败无需让析构再 panic。
let _ = self.0.send(());
}
}
thread::scope(|scope| {
// guard 在作用域返回或断言展开时都释放,随后 scoped 线程才能可靠 join。
let release = Release(release_tx);
// 两个请求线程只借同一客户端地址/服务生命周期,request 每次自己建立 TCP 连接。
let client = &client;
// 全部操作指同一资源,DELETE 的 epoch 变化必须使排队第二个 PUT 失效。
let route = "/v1/namespaces/app.main/code";
// 首尾都用同一有效源码,第二 PUT 的 409 不能归因于源码编译失败。
let example = include_str!("../../../examples/page.endor");
// 首次发布将触发通知阻塞;在接到 entered 之前不推断它已经执行到提交点。
let first = scope.spawn(move || client.request("PUT", route, example));
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
// 第二 PUT 由 HTTP 接收线程接受后在 VM 队列等待,source 与第一条完全相同。
let pending = scope.spawn(move || client.request("PUT", route, example));
// 明确截止时间限制接受序号观察,不把后台线程未调度当作已经过期或成功。
let deadline = Instant::now() + Duration::from_secs(10);
while namespace.read().unwrap().submission < 2 {
// 轮询真实 accepted submission,确认第二请求已入队后才发 DELETE,消除盲目等待竞态。
assert!(Instant::now() < deadline, "second PUT was not admitted");
thread::sleep(Duration::from_millis(5));
}
@@ -451,8 +535,10 @@ fn extracts_namespace_only_from_code_routes() {
}
#[test]
/// 对生成的指南做契约回归:保留代码 CRUD、可运行示例和当前地址,移除旧 export scene/display_list 暴露。
/// 对指南的关键文本做回归:保留 CRUD 标题、示例片段和当前地址,不出现旧 export scene/display_list 用语。
/// 这些 contains 断言不执行示例,也不证明指南中的全部接口、预算和调度描述都与实现一致。
fn guide_contains_only_code_crud_routes() {
// 只生成指南文本,不启动服务或执行内嵌示例;检查静态对外接口的关键用语。
let guide = agent_guide(&NamespaceState::default());
assert!(guide.contains("Code CRUD"));
assert!(guide.contains("let myui = ui.page()"));
@@ -465,6 +551,7 @@ fn guide_contains_only_code_crud_routes() {
/// 创建已经就绪的定时任务,先按前台模式刷新再读取计数,确保输入前不抢跑回调。
/// 随后允许空闲刷新并推进待完成工作,计数应从 0 变为 1。
fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() {
// ready 定时器会把共享 count 从 0 递增,前台模式不应提前消费它,因此提供明确可观察顺序。
let source = r#"
class Action {
count: Object
@@ -476,20 +563,25 @@ ui.after(ui,"ready",0,action.fire)
page=Page()
page.render(TextBlock("input priority"))
"#;
// 同步加载初始画面但不轮询就绪任务,下面才能单独比较前台/空闲两种 worker 行为。
let (runtime, document) = endor_host::ReplSession::load(source, "app.main", 1, 801).unwrap();
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
document,
submission: 1,
runtime_session: 801,
..NamespaceState::default()
}));
// 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。
let mut worker = Worker {
repl: Some((1, runtime)),
next_session: 801,
};
// 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。
let notify: Notify = Arc::new(|| {});
// 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。
worker.refresh_viewport_mode(&namespace, &notify, true);
// 从同一真实 globals 读取 count,若前台边界抢跑 callback 这里会错误地变成 1。
let value = worker
.repl
.as_mut()
@@ -500,8 +592,10 @@ page.render(TextBlock("input priority"))
.value;
assert_eq!(value, "0");
worker.refresh_viewport(&namespace, &notify);
// 空闲轮允许启动任务,若其刷新挂起,只继续视觉工作而不重复执行回调。
let runtime = &mut worker.repl.as_mut().unwrap().1;
for _ in 0..64 {
// 有界排空实际 PendingRefresh,退出条件来自运行时状态而非固定猜测需要几帧。
if !runtime.has_pending_refresh() {
break;
}
@@ -509,6 +603,7 @@ page.render(TextBlock("input priority"))
.resize_windows_incremental(&std::collections::BTreeMap::new(), false)
.unwrap();
}
// 完成空闲推进后 count 应恰为 1,既验证有交付,也排除挂起恢复重复调用。
let value = runtime.execute("count.value", "app.main").unwrap().value;
assert_eq!(value, "1");
}
@@ -517,7 +612,9 @@ page.render(TextBlock("input priority"))
/// 在负世界坐标放置按钮,通过 HTTP 平移/锚定缩放,再发送世界坐标 click。
/// 检查相机数值、旧帧重放 409、按钮计数,以及零倍率 400 不导致 VM fault。
fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 600.0,
@@ -525,12 +622,15 @@ fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() {
}),
..NamespaceState::default()
}));
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 按钮放在负世界坐标,主 Page 采用兼容 canvas 模式,事件必须经相机映射而不能只接非负外框位置。
let source = r#"
count=ui.state(0)
button=Button("world")
@@ -541,9 +641,12 @@ page=Page()
page.mode="canvas"
page.render(button)
"#;
// 加载真实画布,下面所有版本/会话由成功发布帧派生,不为测试手写假几何。
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
// 每次生成时读取新版本;只有显式保存的 pan 原文用于随后重放过期请求。
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
// Page 级相机要求 mount=0,不能把内容按钮的 Mount ID 当作整页导航目标。
let frame = &namespace.read().unwrap().document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
@@ -559,8 +662,11 @@ page.render(button)
})
.unwrap()
};
// 真正 HTTP /events 不享受原生版本追随,因此旧正文重发应该是 409。
let event_path = "/v1/namespaces/app.main/events";
// 负 pan 把负世界位置移入视野,增量不能被当作非负窗口尺寸拒绝。
let pan = camera("page-pan", [-240.0, -160.0], None);
// 第一次合法平移改变实际 Surface;再发同一正文则验证严格版本边界。
let response = client.request("POST", event_path, &pan);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(
@@ -568,6 +674,7 @@ page.render(button)
crate::geometry::Point::new(-240.0, -160.0)
);
assert_eq!(client.request("POST", event_path, &pan).0, 409);
// 倍率 2 和非原点锚点共同决定新 pan,后面的精确期望检查保持锚点世界位置的公式。
let response = client.request(
"POST",
event_path,
@@ -575,6 +682,8 @@ page.render(button)
);
assert_eq!(response.0, 200, "{}", response.2);
{
// 一次发布读锁内同时断言 zoom 和 pan,不能混读缩放前后不同版本。
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
let current = namespace.read().unwrap();
assert_eq!(current.document.frame.surface.zoom, 2.0);
assert_eq!(
@@ -582,9 +691,13 @@ page.render(button)
crate::geometry::Point::new(-180.0, -115.0)
);
}
// click 使用实际交互出现和世界负坐标,验证缩放后目标解析仍保留负坐标语义。
let input = {
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
let current = namespace.read().unwrap();
// 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。
let frame = &current.document.frame;
// 找真正按钮出现而非 Page 根,确认布局几何本身保留负世界位置。
let mount = frame.mounts.iter().find(|m| m.interactive).unwrap();
assert!(mount.x < 0.0 && mount.y < 0.0);
crate::events::Input {
@@ -600,14 +713,18 @@ page.render(button)
tab_drop: None,
}
};
// 发送一个 click 信封,point/press_point 同点供运行时校验按下与释放;没有模拟两次原生鼠标事件。
// 命中与回调仍走真实运行时,不用直接调用 count.increment 代替。
let response = client.request("POST", event_path, &serde_json::to_string(&input).unwrap());
assert_eq!(response.0, 200, "{}", response.2);
// 持久 Console 读取同一个按钮状态,证明 HTTP 事件进入当前 VM 且恰好执行一次。
let response = client.request("POST", "/v1/namespaces/app.main/repl", "count.value");
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"],
"1"
);
// 零倍率是结构/参数错误,应返回 400 且不污染 frame.fault 或使健康 VM 停止。
let response = client.request(
"POST",
event_path,
@@ -621,7 +738,9 @@ page.render(button)
/// 给局部 CanvasViewport 添加父内边距和自身左边框,检验页面坐标到内容相机锚点的转换。
/// 成功平移/缩放后核对相机状态;边框、边界与视口外锚点均须返回 400,保留健康运行时。
fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 600.0,
@@ -629,12 +748,15 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
}),
..NamespaceState::default()
}));
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 父 Column 的 20 内边距和视口左边框 10 让内容原点不同于外框,暴露漏减 inset 的锚点错误。
let source = r#"
content = Button("inside")
content.x = 40
@@ -645,10 +767,14 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
page = Page()
page.render(Column(20, 0, view))
"#;
// 正常发布一次基线,所有接下来手势仍指同一显式 Camera2D 对象。
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
// 按最新权威帧查 CanvasViewport 具体出现,不能用主页面 mount=0 发局部相机输入。
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
let current = namespace.read().unwrap();
// 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。
let frame = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
@@ -669,11 +795,15 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
})
.unwrap()
};
// 保留同一事件地址,平移/缩放行为只由输入 kind 和目标能力决定。
let path = "/v1/namespaces/app.main/events";
// 普通二维线性平移可无 point,仍必须有真实视口 Mount 和有限 delta。
let pan = camera("viewport-pan", [20.0, -10.0], None);
// 首次 pan 应成功,旧正文重发则过期;源模型相机状态后面经 REPL 验证。
let response = client.request("POST", path, &pan);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(client.request("POST", path, &pan).0, 409);
// 局部缩放锚点先撤父偏移/边框再计算,不能直接用屏幕 80/60 作为内容相对坐标。
let response = client.request(
"POST",
path,
@@ -685,6 +815,7 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
("view.camera.y", "10"),
("view.camera.zoom", "2"),
] {
// 三个源属性分别比对,外观近似正确不能掩盖 x/y/zoom 中某个维度的变换错误。
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(
@@ -694,6 +825,7 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
}
// 分别覆盖左边框、右侧边界及远离视口的锚点,避免只校验整个窗口范围。
for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] {
// 重新生成当前版本以保证失败来自锚点边界,而不是误用旧版本得到 409。
let response = client.request(
"POST",
path,
@@ -708,7 +840,9 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin
/// 通过真实 HTTP 检查透视缩放/orbit、正交缩放,再替换为源实现 PanOnly。
/// 比较相机数值与自定义控制器结果,并验证视口外锚点和禁用控制器的拒绝状态不会停掉 VM。
fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 400.,
@@ -716,12 +850,15 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
}),
..NamespaceState::default()
}));
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 提前定义可替换 PanOnly 源控制器,初始仍使用默认 OrbitController 和透视相机作基线。
let source = r##"
class PanOnly {
function update(self: PanOnly, camera: Object, gesture: CameraGesture): Unit { camera.translate(gesture.dx, gesture.dy, 0) }
@@ -730,10 +867,14 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
page = Page()
page.render(view)
"##;
// 通过实际语言构造 Scene3D/Viewport3D,不在测试宿主侧直接拼帧伪造相机能力。
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
// 每次按最新帧找到 scene3d_viewport,来源能力和几何版本都交真实 HTTP 路由校验。
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
let current = namespace.read().unwrap();
// 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。
let frame = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
@@ -754,12 +895,17 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
})
.unwrap()
};
// 同一事件路由支持不同源/原生控制器,不能为 PanOnly 建一条测试专用快捷调用。
let event_path = "/v1/namespaces/app.main/events";
// 视口内部锚点与倍率 2 应让默认透视相机距离减半,原正文重发仍必须 409。
let zoom = gesture("scene-zoom", [2., 0.], Some([100., 50.]));
// 保存第一次请求结果并打印正文诊断,便于区分参数错误和运行时调用失败。
let response = client.request("POST", event_path, &zoom);
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(client.request("POST", event_path, &zoom).0, 409);
// 经同一持久 Console 查询/修改真实相机,不从 Frame 近似反解相机模型值。
let value = |expression: &str| {
// helper 仍跨 HTTP,确保候选保留的 globals 与事件真正执行域是同一份。
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
assert_eq!(response.0, 200, "{}", response.2);
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
@@ -768,11 +914,14 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
.to_owned()
};
assert_eq!(value("view.camera.z"), "2.5");
// 默认 orbit 将横向增量解释为角度改变,检查 x 分量符合圆轨道而不是简单加 20。
let response = client.request("POST", event_path, &gesture("scene-orbit", [20., 0.], None));
assert_eq!(response.0, 200, "{}", response.2);
// 数字显示字符串转 f32 只用于三角近似容差,不放宽语言模型本身的类型契约。
let x = value("view.camera.x").parse::<f32>().unwrap();
assert!((x - 2.5 * 0.1f32.sin()).abs() < 0.0001);
value("view.camera = OrthographicCamera()");
// 换成正交相机后缩放应改 height,不能仍按透视距离规则改 z。
let response = client.request(
"POST",
event_path,
@@ -781,9 +930,11 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(value("view.camera.height"), "2.5");
value("view.controller = PanOnly()");
// 源控制器收到同一个 CameraGesture,行为应是精确平移 7,证明宿主没强行套默认 Orbit 规则。
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
assert_eq!(response.0, 200, "{}", response.2);
assert_eq!(value("view.camera.x"), "7");
// 视口外锚点在进入控制器前返回 400;既不执行源更新,也不把 VM 标为 fault。
let response = client.request(
"POST",
event_path,
@@ -792,6 +943,7 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
assert_eq!(response.0, 400);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
value("view.controller = \"none\"");
// 禁用控制器后原有 3D 外框仍存在,但手势能力失效,必须拒绝而不是自行补默认控制器。
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
assert_eq!(response.0, 409);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
@@ -801,7 +953,9 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
/// 在旋转的 Panel3D 上嵌入二维 CanvasViewport,经射线/UV 映射解释相机事件。
/// 缺失指针必须拒绝;带指针的平移/缩放只改变面板内相机,外层三维相机保持原值。
fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 400.,
@@ -809,12 +963,15 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
}),
..NamespaceState::default()
}));
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 倾斜 Panel3D 内放 CanvasViewport,使屏幕水平位移必须经射线/UV 非线性映射再解释成二维相机 pan。
let source = r##"
canvas=CanvasViewport(100,100,Scene2D(Button("UI in a world")))
panel=Panel3D(2,2,canvas)
@@ -825,10 +982,14 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
page=Page()
page.render(view)
"##;
// 初次 PUT 产生真实三维面板投影和嵌套二维 Mount,下面不硬编码其内部 ID。
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(response.0, 200, "{}", response.2);
// 专门选择 canvas_viewport 且 panel 非空的出现,避免误把外层三维相机当成目标。
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
let current = namespace.read().unwrap();
// 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。
let frame = &current.document.frame;
serde_json::to_string(&crate::events::Input {
window: 1,
@@ -849,6 +1010,7 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
})
.unwrap()
};
// 世界 UI 手势没有锚点不可可靠逆投影;第一条断言应 400 而不是执行近似屏幕平移。
let path = "/v1/namespaces/app.main/events";
assert_eq!(
client
@@ -856,13 +1018,16 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
.0,
400
);
// 给定屏幕中心锚点后,同一增量才有可计算的面板局部对应位移。
let response = client.request(
"POST",
path,
&gesture("viewport-pan", [10., 0.], Some([100., 100.])),
);
assert_eq!(response.0, 200, "{}", response.2);
// 查询真实模型数值,不只比视觉快照;返回 f32 供投影近似容差断言。
let value = |expression: &str| {
// 每次表达式仍走持久 Console,若被误路由到新 VM 会因 canvas/view 不存在失败。
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
assert_eq!(response.0, 200, "{}", response.2);
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
@@ -871,10 +1036,12 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
.parse::<f32>()
.unwrap()
};
// 倾斜投影的有效 x 大于屏幕增量 10,外层 view.camera.x 应保持零,证明目标空间正确。
let pan = value("canvas.camera.x");
// 面板倾斜后的投影位移不等于 UI 内部位移,不能把屏幕 delta 直接写入局部相机。
assert!(pan > 10.);
assert_eq!(value("view.camera.x"), 0.);
// 再以同一屏幕锚点缩放,验证前一步平移和当前 UV 锚定公式可以连续组合。
let response = client.request(
"POST",
path,
@@ -887,8 +1054,11 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
}
#[test]
/// 测试框架通过真实 HTTP 发送原始指针,验证局部坐标、按下/离开/取消状态,以及严格版本和结构拒绝不故障。
fn native_pointer_kinds_cross_real_http_route_with_local_coordinates_and_strict_versions() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 500.,
@@ -896,12 +1066,15 @@ fn native_pointer_kinds_cross_real_http_route_with_local_coordinates_and_strict_
}),
..Default::default()
}));
// 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// Tracker 只记录事件坐标和 held,目标放在有偏移的 Column 下,暴露漏减出现原点的坐标错误。
let source = r#"
class Tracker {
x:Number
@@ -921,11 +1094,16 @@ e=target.on("pointercancel",tracker.up)
page=Page()
page.render(Column(20,0,target))
"#;
// 安装原始 pointer 订阅并发布真实帧,不用测试直接赋值 tracker 字段模拟处理器。
let put = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(put.0, 200, "{}", put.2);
// 除刻意重放 body 外,每次重新读版本,确保状态断言对应当前有效帧上的事件。
let input = |kind: &str| {
// 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。
let current = namespace.read().unwrap();
// 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。
let frame = &current.document.frame;
// 明确选择有原始指针能力的目标,绝对坐标为该出现原点加固定局部 35/27。
let mount = frame.mounts.iter().find(|m| m.pointer_events).unwrap();
crate::events::Input {
window: 1,
@@ -940,11 +1118,12 @@ page.render(Column(20,0,target))
screen_position: None,
}
};
// 保存最初正文作后续过期重放,不能重建最新版本后宣称在验证旧事件拒绝。
let body = serde_json::to_string(&input("pointermove")).unwrap();
// 第一遍合法移动应把局部坐标传给源 Tracker;它本身不一定触发视觉树变化。
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
assert_eq!(response.0, 200, "{}", response.2);
// Tracker state alone need not publish a new picture. Expire the frame
// through a real visual mutation before asserting strict stale rejection.
// Tracker 业务状态未被画面读取,不保证版本变化;显式改 target.width 产生真实新几何再重放旧正文。
assert_eq!(
client
.request("POST", "/v1/namespaces/app.main/repl", "target.width=201")
@@ -958,6 +1137,7 @@ page.render(Column(20,0,target))
409
);
for (expr, expected) in [("tracker.x", "35"), ("tracker.y", "27")] {
// 逐轴验证源处理器收到的是 Mount 局部 35/27,而非 Column 偏移后的页面绝对坐标。
let r = client.request("POST", "/v1/namespaces/app.main/repl", expr);
assert_eq!(r.0, 200);
assert_eq!(
@@ -973,18 +1153,21 @@ page.render(Column(20,0,target))
("pointerdown", "1"),
("pointercancel", "0"),
] {
// 每组都以真实事件改变 held,leave/cancel 必须与 up 一样结束按住状态,而非继续保留 1。
let response = client.request(
"POST",
"/v1/namespaces/app.main/events",
&serde_json::to_string(&input(kind)).unwrap(),
);
assert_eq!(response.0, 200, "{}", response.2);
// 通过 Console 读同一对象最终状态,HTTP 200 本身不能证明处理器真的执行正确。
let r = client.request("POST", "/v1/namespaces/app.main/repl", "tracker.held");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&r.2).unwrap()["value"],
expected
);
}
// 只删必填 point,保持当前合法 session/版本,使失败原因明确是结构错误 400 而非过期 409。
let mut invalid = input("pointermove");
invalid.point = None;
assert_eq!(
@@ -1001,8 +1184,11 @@ page.render(Column(20,0,target))
}
#[test]
/// 测试框架直接向原生队列送可信信封,验证动画新帧可追随,但 HTTP 旧帧和旧 VM 身份仍不能执行。
fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() {
// 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
// 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 500.,
@@ -1010,7 +1196,9 @@ fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() {
}),
..Default::default()
}));
// 装入实际共享 BuildRequest sender,来源信任来自 Rust 枚举构造,不通过伪造 HTTP 头获得。
let native = Arc::new(OnceLock::new());
// 复用真实 worker/路由及回执处理,只不安装 GUI 退出句柄和观察通知。
let api = start_server_observed(
server.clone(),
namespace.clone(),
@@ -1021,11 +1209,13 @@ fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() {
Some(native.clone()),
)
.unwrap();
// 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// 模型保留 pointermove/down/up 三种订阅,后面视觉宽度变化只使版本更新、不替换原目标。
let source = r#"
class Tracker {
x:Number
@@ -1048,9 +1238,13 @@ page.render(Column(20,0,target))
.0,
200
);
// 捕获一次真实帧,之后所有原生手势故意沿用这个旧版本,以覆盖 worker 中的第二次版本追随。
let old = {
// 同锁读取完整捕获上下文,避免把新 layout_version 与旧几何坐标组合成假信封。
let state = namespace.read().unwrap();
// f 是已发布主帧,不是后台刷新草稿。
let f = &state.document.frame;
// m 的身份在同一个 VM 中保持稳定,改变宽度不应让旧捕获手势换目标。
let m = f.mounts.iter().find(|m| m.pointer_events).unwrap();
crate::events::Input {
window: 1,
@@ -1065,11 +1259,17 @@ page.render(Column(20,0,target))
screen_position: None,
}
};
// 模拟 relay 构造真正 Native 回执工作;send 本身是同步阻塞入队,下面 5 秒只限制入队后的回执等待。
// 安全拒绝可能映为 Ok,必须结合后面的模型值断言判断旧输入没有执行。
let send_native = |input: crate::events::Input| {
// 只采样最新接受 epoch/name,不改输入自己捕获的 session/Mount,后者仍须由 worker 校验。
let state = namespace.read().unwrap();
// epoch 属于队列接受身份,和 input.session 代表的原 VM 代际分别保留。
let epoch = state.submission;
// Clone 使发队列/等待时无需持 namespace 读锁,否则 worker 发布会被测试自己阻塞。
let name = state.name.clone();
drop(state);
// 每条输入一个回执,最多等 5 秒,确认 worker 已处理后再读取 Tracker 状态。
let (done, complete) = sync_channel(1);
native
.get()
@@ -1088,12 +1288,12 @@ page.render(Column(20,0,target))
.unwrap()
.unwrap();
};
// Every native gesture deliberately carries the old picture version; each
// intervening visual mutation simulates an animation publishing a frame.
// 每条原生事件前先做一次真实视觉变更模拟动画新帧,仍沿用 old 的布局版本验证可追随路径。
for (i, kind) in ["pointermove", "pointerdown", "pointerup"]
.into_iter()
.enumerate()
{
// 宽度每轮不同以确保发布新几何;只写相同值可能不更新版本,不能可靠覆盖追随逻辑。
assert_eq!(
client
.request(
@@ -1104,11 +1304,13 @@ page.render(Column(20,0,target))
.0,
200
);
// 只改 kind 不改捕获上下文,若 worker 没有在 BeforeInput 后再刷新版本就会拒绝这些合法原生动作。
let mut sample = old.clone();
sample.kind = kind.into();
send_native(sample);
}
for (code, value) in [("tracker.x", "37"), ("tracker.held", "0")] {
// 先确认跨多帧原生输入实际执行并保持 down/up 顺序,不把单纯回执成功当作处理器成功。
let r = client.request("POST", "/v1/namespaces/app.main/repl", code);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&r.2).unwrap()["value"],
@@ -1131,9 +1333,11 @@ page.render(Column(20,0,target))
.0,
200
);
// 完整 PUT 之后即使数字 Mount 恰好相同也不能追随旧 session,确认 stale down 不会把新 held 变 1。
let mut stale = old;
stale.kind = "pointerdown".into();
send_native(stale);
// Native 回执可能把安全拒绝映为 Ok,最终必须用新 VM 模型值为零证明旧代事件未执行。
let r = client.request("POST", "/v1/namespaces/app.main/repl", "tracker.held");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&r.2).unwrap()["value"],
+156 -40
View File
@@ -2,14 +2,23 @@
//! 输入、视口刷新与候选布局可以跨 worker 轮次挂起;中途只公布完整文档,不能让新输入进入未结束的只读调用。
//! 事件身份同时校验窗口、session、layout_version 和 Mount 能力。失败、替换与关闭分别处理,避免旧回调或旧命令泄漏到新 VM。
//!
//! Serial VM ownership, incremental requests, publication and shutdown.
//! api::start_server_observed 的唯一执行线程创建 Worker,每次循环调用 begin_request/resume_request 或 refresh_viewport。
//! HTTP/原生请求仍由外层持有,PendingRequest 只记录继续哪一步;本文件不重新读取请求流或回放已执行输入。
//! NamespaceState 是对外权威值,repl 是实际 VM 所有者;submission 是接受顺序,runtime_session 才表示画面仍属哪个 VM。
//!
//! 请求阶段不能互换:BeforeInput 尚未执行业务,可重新进入 begin_request;Input 已执行,只能恢复视觉工作;
//! Candidate 持有独立候选,成功前不替换 self.repl。外层 api 循环保留原 job,因此这里不会创建第二份回应端。
//! 接受 PUT 先改变 submission,候选成功提交后才切换 runtime_session;preserve_live_owner 在两者之间保留仍权威的旧域。
//! 源业务与视觉方法在 Namespace 锁外执行,文档/命令发布回到写锁内复核身份;错误响应和故障画面发布是两项不同结果。
use super::*;
// 响应已编码成内存字节,外层 RequestReply 可发 HTTP 或转换成原生回执,不需要读取 VM 返回值。
type HttpResponse = Response<std::io::Cursor<Vec<u8>>>;
// 外层 thread::Result 区分 Rust panic,内层 Result 区分可诊断编译/运行错误;正常结果另带待发布窗口命令。
type InputOutcome =
std::thread::Result<Result<(endor_host::ReplResult, Vec<crate::window::Command>)>>;
// 候选成功必须成套交回持久 VM、完整文档及命令;不能只返回快照后丢掉事件需要的真实运行时。
type CandidateOutcome = std::thread::Result<
Result<(
endor_host::ReplSession,
@@ -22,8 +31,11 @@ type CandidateOutcome = std::thread::Result<
pub(super) struct PendingInput {
// 输入开始时所属 submission,与 id(VM 会话)分开;新 PUT 接受时可保留正在执行的原 VM 至输入收敛。
epoch: u64,
// 实际运行时代际,和 epoch 一起核验;接受新 PUT 不代表已经切换到新 VM。
id: u64,
// 决定响应 execution 和错误停域规则,恢复展示时不再重新猜原始输入类别。
event: bool,
// 已经执行得到的值/统计,后续只补 document,不重复 callback 或 execute 取相同结果。
result: endor_host::ReplResult,
}
@@ -31,20 +43,25 @@ pub(super) struct PendingInput {
pub(super) enum PendingRequest {
// 仅等待旧只读刷新结束,业务输入尚未执行;恢复时可重新走入口,而 Input 阶段不能重放业务副作用。
BeforeInput,
// 输入业务部分已执行,保留活 repl 并只恢复该输入的视觉刷新。
Input(Box<PendingInput>),
// 新 PUT 的隔离候选还未发布,活 repl 仍保留旧程序,不能提前替换。
Candidate(Box<endor_host::ReplSession>),
}
/// 一次 worker 推进的结果:继续保留请求或交付最终响应。
pub(super) enum RequestProgress {
// 外层将原 job 和继续状态一起保存,下一量子进入 resume_request。
Pending(PendingRequest),
// 最终响应值,外层消费原 RequestReply 一次性回应,不再继续这个请求。
Complete(HttpResponse),
}
/// 只提取仍属于当前 session 的窗口尺寸,避免旧原生窗口给新候选提供错误约束。
/// 空闲刷新/输入/恢复路径在发布状态读锁内调用,筛出当前运行时合法尺寸,不执行 VM 或修改窗口表。
fn current_viewports(
current: &NamespaceState,
) -> std::collections::BTreeMap<u64, crate::layout::Size> {
// 主壳可跨 VM 使用;副窗必须仍在当前 document 且属于 runtime_session,旧数字 ID 本身不算归属。
let mut viewports: std::collections::BTreeMap<_, _> = current
.window_viewports
.iter()
@@ -69,12 +86,15 @@ fn current_viewports(
#[derive(Default)]
/// 唯一活跃 VM 的串行拥有者,另行管理候选和下一 session 身份。
pub(super) struct Worker {
// 元组第一项是当前接受提交 epoch,第二项是真实 VM;新 PUT 接受时旧 VM 仍可能保持权威。
pub(super) repl: Option<(u64, endor_host::ReplSession)>,
// 单调 session 分配高水位,即使候选失败也不复用已经分配的代际身份。
pub(super) next_session: u64,
}
impl Worker {
/// 撤销活跃运行时并有界清理窗口、Mount 和任务;故障域不再执行新的业务回调。
/// api 线程循环收到退出信号时调用,移出活运行时并生成纯文本 ShutdownOutcome,供原生退出报告。
pub(super) fn shutdown_runtime(&mut self) -> ShutdownOutcome {
// take 先撤 worker 的活域拥有关系,没有域时仍返回明确成功摘要而非伪造清理数量。
let Some((_, mut runtime)) = self.repl.take() else {
return ShutdownOutcome {
summary: "Tessera shutdown: no live runtime".into(),
@@ -83,6 +103,7 @@ impl Worker {
};
// 正常域允许有界关闭回调,故障域只做宿主清理;catch_unwind 防止清理 panic 跳过原生退出通知。
let callbacks = !runtime.is_faulted();
// 普通清理错误与 Rust panic 都转换成结果,不能让执行线程提前消失而使 GUI 永远等不到完成通知。
let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown()));
match result {
Ok(Ok((windows, mounts, tasks))) => ShutdownOutcome {
@@ -92,10 +113,12 @@ impl Worker {
error: None,
},
result => {
// 内部 panic 没有可安全继续执行的源错误对象,单独说明;正常 Err 则保留错误链。
let error = match result {
Ok(Err(error)) => format!("shutdown cleanup failed: {error:#}"),
_ => "shutdown cleanup failed after an internal panic".into(),
};
// 按 Unicode 字符限长,不切坏 UTF-8,也不把无限错误链塞入退出消息。
let error: String = error.chars().take(4096).collect();
ShutdownOutcome {
summary: error.clone(),
@@ -105,7 +128,7 @@ impl Worker {
}
}
/// 处理当前无法继续正常交互的窗口关闭,撤销原生资源与相应运行时记录。
/// begin_request 的故障/失活副窗 window-close 分支调用,做纯宿主退役,不运行任何源关闭/生命周期回调。
pub(super) fn close_inactive_window(
&mut self,
input: &crate::events::Input,
@@ -120,11 +143,14 @@ impl Worker {
"close the primary shell with its OS window control",
);
}
// 只有 epoch/session 都匹配才清该 VM 内部资源;相同窗口 ID 不授权操作另一个会话。
let owns_runtime = self.repl.as_ref().is_some_and(|(owner, runtime)| {
*owner == epoch && runtime.id == input.session && input.session != 0
});
// 退役内部失败时整个域被丢弃,后面需同时撤其他帧的会话/命令资格。
let mut discarded = false;
if owns_runtime {
// 接住退役 panic,保证后续仍能从共享文档删除原生副窗投影,而不保留半清理 VM。
let result = catch_unwind(AssertUnwindSafe(|| {
self.repl
.as_mut()
@@ -134,17 +160,20 @@ impl Worker {
}));
// 退役失败不能继续借用半清理 VM,放弃整个活域并在下方清其他帧的会话与命令资格。
if !matches!(result, Ok(Ok(()))) {
// Never keep a half-cleaned VM live or borrow another generation.
// 不借用其他代际补清理;放弃整个 repl 后下方只操作最近发布的纯数据文档。
self.repl = None;
discarded = true;
}
}
// VM 清理在锁外执行完才申请发布写锁,重新验证期间是否有 PUT/DELETE 接受。
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock poisoned");
};
// 锁外退役期间可能接受更新提交;过期请求不能再删除当前世代文档中的同编号窗口。
if current.submission != epoch {
return superseded(epoch);
}
// 只查副窗投影,原窗口已消失就返回冲突,不为关闭旧事件重建一个资源。
let Some(view) = current.document.windows.get(&input.window) else {
return error_response(StatusCode(409), "window is no longer active");
};
@@ -155,18 +184,22 @@ impl Worker {
"window belongs to another runtime generation",
);
}
// 在完整已发布文档副本上删目标,保留其他窗口画面;commit 再验证整份跨线程值。
let mut document = current.document.clone();
document.windows.remove(&input.window);
if discarded {
// 保留静态画面但所有交互身份失效,不能让旧按钮继续指向已丢弃的托管堆。
document.frame.session = 0;
document.frame.focus = None;
for view in document.windows.values_mut() {
// 丢弃的是整个共享 VM,所以剩余每个副窗都撤销交互身份,不能只清主画面或刚关闭的那个窗口。
view.frame.session = 0;
view.frame.focus = None;
}
current.runtime_session = 0;
current.window_commands.clear();
}
// 发布退役后的完整文档;只有 Some 才继续移除视口记录并唤醒 GUI,过期或校验失败都提前返回。
match current.commit(epoch, document) {
Ok(Some(_)) => {}
Ok(None) => return superseded(epoch),
@@ -181,7 +214,7 @@ impl Worker {
)
}
/// 在没有前台输入的 worker 轮次推进尺寸和任务引起的展示刷新。
/// api 的空闲 worker 轮调用,允许推进尺寸和任务回调;有前台输入等待时使用 before_input=true 的变体。
pub(super) fn refresh_viewport(
&mut self,
namespace: &Arc<RwLock<NamespaceState>>,
@@ -190,7 +223,7 @@ impl Worker {
self.refresh_viewport_mode(namespace, notify, false);
}
/// 在输入之间推进视口/任务刷新;提交序号或 session 已变时拒绝旧结果发布。
/// 空闲循环与 begin_request 共用推进器,锁外执行 VM、写锁内核对 epoch/session 后再发布,防止晚到覆盖。
pub(super) fn refresh_viewport_mode(
&mut self,
namespace: &Arc<RwLock<NamespaceState>>,
@@ -200,11 +233,13 @@ impl Worker {
// 在读锁内取得一个一致的提交身份和尺寸快照;耗时 VM 工作不占用共享状态写锁。
let (epoch, viewports) = match namespace.read() {
Ok(current) => {
// 同一读锁下复制身份和合法尺寸,避免使用来自不同发布世代的两个快照。
let viewports = current_viewports(&current);
(current.submission, viewports)
}
Err(_) => return,
};
// 无持久运行时就没有任务/尺寸可推进;GET 的旧静态文档本身不创造 VM。
let Some((owner, runtime)) = &mut self.repl else {
return;
};
@@ -215,28 +250,31 @@ impl Worker {
}
return;
}
// 保存执行前真实 VM 身份,发布时重新检查,不能只核对新的 submission。
let id = runtime.id;
// 耗时刷新不持 namespace 锁,HTTP 可接受新请求;普通错误/panic 留到写锁阶段区分处理。
let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
if runtime.is_faulted() {
// 故障域 poll_tasks 仅取消/回收旧任务,不再启动新的用户回调。
runtime.poll_tasks()?;
return Ok((None, Vec::new()));
}
// 先推进已有视觉工作/尺寸,再考虑新任务,不能在只读续体里插入业务写入。
let resized = runtime.resize_windows_incremental(&viewports, false)?;
if runtime.has_pending_refresh() {
return Ok((None, Vec::new()));
}
// 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.
// resized 已完整可以返回,任务新产生窗口命令仍扣留至其完整刷新结束。
return Ok((resized, Vec::new()));
}
Ok((completed.or(resized), runtime.take_window_commands()?))
@@ -250,8 +288,10 @@ impl Worker {
if current.submission != epoch || current.runtime_session != id {
return;
}
// success 可仅有命令而无新帧;错误发布故障条,panic 还要丢弃 VM 与命令世代。
let (document, commands) = match result {
Ok(Ok((document, commands))) => {
// 背景轮询成功有两类结果:有新文档则走下面原子提交,无文档则仅发布可能存在的窗口命令。
if let Some(document) = document {
(document, Some(commands))
} else {
@@ -259,6 +299,7 @@ impl Worker {
let projected = !commands.is_empty();
current.publish_window_commands(id, commands);
drop(current);
// 已释放 Namespace 写锁后才唤醒 GUI;没有命令也没有新帧时保持空闲,不凭轮询次数触发绘制。
if projected {
notify();
}
@@ -266,6 +307,7 @@ impl Worker {
}
}
failure => {
// 故障画面基于上次完整发布文档,不把此次失败的半成品几何泄漏出去。
let mut document = current.document.clone();
document.frame.fault = Some(match failure {
Ok(Err(error)) => format!("Runtime background update failed: {error:#}")
@@ -286,17 +328,20 @@ impl Worker {
(document, None)
}
};
// 只有提交成功才发布同世代原生命令和通知 GUI,错误/过期结果不能算一次有效刷新。
let changed = matches!(current.commit(epoch, document), Ok(Some(_)));
// 有效文档和对应命令属于同一会话,提交未接受时不能单独交付命令造成画面/原生窗口状态错配。
if changed && let Some(commands) = commands {
current.publish_window_commands(id, commands);
}
drop(current);
// changed 来自权威 commit 结果,不把失败或被替代的候选当作可见更新通知。
if changed {
notify();
}
}
/// 检查请求种类和归属,必要时先等已有刷新结束,再建立输入或候选任务。
/// api 线程循环收到 job 或恢复 BeforeInput 时调用;业务未执行前可等待旧刷新,执行后切换 PendingInput。
pub(super) fn begin_request(
&mut self,
job: &BuildRequest,
@@ -305,15 +350,16 @@ impl Worker {
) -> RequestProgress {
// 完整 PUT 独立候选,表达式隔离求值;只有 REPL/事件会进入当前持久域并等待已有刷新。
if job.kind == RequestKind::Code {
// PUT 创建隔离候选,不向当前 VM 追加语句;失败可保留旧程序的实际对象图。
return self.begin_program_request(job, namespace, notify);
}
if job.kind == RequestKind::Expression {
// evaluate 不访问活 VM/显示文档,无需等待当前只读刷新完成。
return RequestProgress::Complete(build(job, namespace, notify));
}
// Advance one existing refresh quantum, then park the HTTP request.
// No input statement executes inside the read-only scope; shutdown and
// newer viewport targets can be observed at every worker turn.
// 先只推进已有刷新一片,关闭和最新尺寸每个 worker 控制边界仍可见;未空闲不运行新语句。
self.refresh_viewport_mode(namespace, notify, true);
// 推进可能跨发布边界,重新读取当前接受序号,不沿用 job 作为最新状态。
let epoch = match namespace.read() {
Ok(current) => current.submission,
Err(_) => {
@@ -324,15 +370,18 @@ impl Worker {
}
};
if epoch != job.submission {
// 尚未执行的旧输入被新提交撤销,不能借“仍有旧 VM”在新接受顺序后插入旧队列动作。
return RequestProgress::Complete(superseded(job.submission));
}
// 重置丢弃内存会话但保留显示文档,清帧 session/focus 使旧画面不再接受旧对象事件。
if job.kind == RequestKind::ResetRepl {
// 丢真正 VM 的内存状态,画面只作为不可交互静态值保留,不声称用户对象仍可响应。
self.repl = None;
if let Ok(mut current) = namespace.write() {
current.document.frame.session = 0;
current.document.frame.focus = None;
for document in current.document.windows.values_mut() {
// 重置的是共享堆,所有副窗旧身份都失效;保留的是静态绘制文档,不是仍可接收事件的窗口业务对象。
document.frame.session = 0;
document.frame.focus = None;
}
@@ -345,18 +394,23 @@ impl Worker {
serde_json::json!({"ok":true,"execution":"repl-reset","persistent":true,"session_reset":true,"message":"Console memory cleared; displayed page preserved"}),
));
}
// 旧布局/绘制仍挂起时只保存等待阶段,不能在它的只读 VM 调用中执行新的 REPL 或事件副作用。
if self
.repl
.as_ref()
.is_some_and(|(_, runtime)| runtime.has_pending_refresh())
{
// BeforeInput 表示尚无业务副作用,恢复可重新走 begin_request;不能把已执行输入也放此状态。
return RequestProgress::Pending(PendingRequest::BeforeInput);
}
// 事件先做 JSON 结构、kind 白名单和 Input 字段校验,再用权威发布帧核对具体资源能力。
let event = if job.kind == RequestKind::Event {
// 可信原生来源取自进程内 job.native;HTTP 正文只能被严格反序列化成普通事件。
let mut input: crate::events::Input = if let Some(input) = &job.native {
// 原生 Input 仍需下面全部字段/能力验证,可信来源只影响连续输入能否追随新布局版本。
input.clone()
} else {
// deny_unknown_fields 和类型检查由 Input 的 serde 声明执行,不按任意 JSON 字段猜动作。
match serde_json::from_str(&job.source) {
Ok(input) => input,
Err(error) => {
@@ -367,6 +421,7 @@ impl Worker {
}
}
};
// 先按输入种类筛选再查资源能力:合法 JSON 不代表有权触发任意名字的宿主操作。
if !input.kind.starts_with("developer-open:")
&& !matches!(
input.kind.as_str(),
@@ -392,14 +447,17 @@ impl Worker {
| "window-close"
)
{
// 开发工具名字由后续工具白名单再查;其余只接受明确列出的原生输入类别。
return RequestProgress::Complete(error_response(
StatusCode(400),
"unsupported event kind",
));
}
if let Err(message) = input.validate() {
// 结构错属于 400,在碰任何活 VM 对象之前回应,不能当作过期帧冲突或源运行错误。
return RequestProgress::Complete(error_response(StatusCode(400), message));
}
// 以下窗口、session、版本与几何能力都从同一个权威发布状态读锁中核对。
let current = match namespace.read() {
Ok(current) => current,
Err(_) => {
@@ -409,8 +467,7 @@ impl Worker {
));
}
};
// Rebase trusted captured native gestures only after BeforeInput
// has drained the prior refresh. External HTTP remains strict.
// 原生 relay 排队期间可能又完成了 BeforeInput 刷新;在此再追随一次同目标新版本,HTTP 保持严格。
if job.native.is_some()
&& !crate::events::refresh_continuous_input(&mut input, &current)
{
@@ -419,9 +476,11 @@ impl Worker {
"native target was replaced or detached",
));
}
// 必须选输入声明的窗口帧,不能因为副窗缺失就回退到主窗口同数值 Mount。
let frame = if input.window == crate::window::MAIN_WINDOW {
&current.document.frame
} else {
// 副窗只来自 current.document.windows,GUI 残留 OS 窗口不自动代表活域资源。
let Some(document) = current.document.windows.get(&input.window) else {
return RequestProgress::Complete(error_response(
StatusCode(409),
@@ -435,9 +494,11 @@ impl Worker {
&& frame.window == input.window
&& frame.session == input.session
{
// 有效资源身份但 VM 已停的关闭可进入纯宿主退役,其他输入仍必须拒绝。
let owns = self.repl.as_ref().is_some_and(|(owner, runtime)| {
*owner == epoch && runtime.id == input.session && input.session != 0
});
// 画面故障、无对应活 VM 或 VM 内已 faulted 都说明不能调用可覆盖的源 Window.close。
let stopped = frame.fault.is_some()
|| !owns
|| self
@@ -445,6 +506,7 @@ impl Worker {
.as_ref()
.is_some_and(|(_, runtime)| runtime.is_faulted());
if stopped {
// close_inactive_window 需要发布写锁,调用前必须释放这里持有的读锁。
drop(current);
return RequestProgress::Complete(
self.close_inactive_window(&input, epoch, namespace, notify),
@@ -453,6 +515,7 @@ impl Worker {
}
// 标签拖放同时验证目标窗口的会话、版本、无故障及插入位置,源窗口校验不能替代目标校验。
if let Some(target) = &input.tab_drop {
// 拖放目标同样要找到原窗口权威帧,不能只验证源标签仍存在就信任目标下标。
let target_frame = if target.window == crate::window::MAIN_WINDOW {
Some(&current.document.frame)
} else {
@@ -463,6 +526,7 @@ impl Worker {
.map(|document| &document.frame)
};
if target_frame.is_none_or(|frame| {
// 列表插入缝允许等于 tabs.len,超过长度、换代、换几何或故障都拒绝。
frame.window != target.window
|| frame.session != input.session
|| frame.layout_version != target.layout_version
@@ -473,6 +537,7 @@ impl Worker {
.as_ref()
.is_none_or(|(_, runtime)| !runtime.accepts_tab_drop(&input))
{
// 错误是本次目标已过期,不让源 moveToWindow 方法运行到一半才发现无法重排。
return RequestProgress::Complete(error_response(
StatusCode(409),
"drop targets an obsolete or inactive frame",
@@ -488,6 +553,7 @@ impl Worker {
|| input.kind.starts_with("developer-open:")
|| matches!(input.kind.as_str(), "page-pan" | "page-zoom")
{
// 全窗关闭、工具打开与整页相机不指向某个 Mount,必须携带零目标,防止身份多义。
input.mount != 0
} else if matches!(
input.kind.as_str(),
@@ -496,26 +562,32 @@ impl Worker {
// 标签操作的 mount 字段承载 Tab ID,而普通输入承载 Mount ID;两套身份不能混着查询。
!frame.tabs.iter().any(|tab| tab.id == input.mount)
} else {
// 普通 Mount 输入查出现表再按 kind 校验对应能力,不能只验证存在同号几何。
!frame.mounts.iter().any(|mount| {
mount.id == input.mount
&& if input.kind.starts_with("viewport-") {
// 二维局部相机仅允许 CanvasViewport,普通可点击矩形不能变成相机目标。
mount.canvas_viewport
} else if input.kind.starts_with("scene-") {
// 3D 手势还要求显式控制器能力,scroll 要有滚动投影,点击/焦点则要求 interactive。
mount.scene3d_viewport && mount.camera_controls
} else if input.kind == "scroll" {
// 滚动投影存在才有滚动语义,不凭 Block 名义类型或外框可点击推测。
mount.scroll.is_some()
} else {
// 点击/焦点等至少要求交互资格;具体源 hitTest/指针订阅仍在 bridge 中再查。
mount.interactive
}
})
}
|| self.repl.as_ref().is_none_or(|(context, runtime)| {
// 发布帧与实际活域必须匹配;静态保留画面不能用作对不存在 VM 的调用凭据。
*context != epoch
|| runtime.id != input.session
|| (input.kind != "window-close" && !runtime.accepts_window(input.window))
})
{
// 同一统一门在任何源回调之前拒绝,不能先执行部分动作再补查会话身份。
return RequestProgress::Complete(error_response(
StatusCode(409),
"event targets an obsolete or inactive frame",
@@ -535,6 +607,7 @@ impl Worker {
&& !frame.mounts.iter().any(|mount| {
mount.id == input.mount
&& input.point.is_some_and(|point| {
// 先检查出现外框,再用面板/视图逆映射检测真实内容 clip,边框不作缩放锚点。
let point = crate::geometry::Point::new(point[0], point[1]);
mount.contains(point)
&& mount.viewport_clip.is_some_and(|clip| {
@@ -548,6 +621,7 @@ impl Worker {
})
})
{
// 锚点必须在实际内容区域内,外框/面板反投影验证失败就先拒绝,不运行相机控制器。
return RequestProgress::Complete(error_response(
StatusCode(400),
"zoom anchor is outside content viewport",
@@ -561,6 +635,7 @@ impl Worker {
.iter()
.any(|mount| mount.id == input.mount && mount.panel.is_some())
{
// 缺锚点的线性二维增量尚可解释,世界面板的非线性映射则无法形成可靠局部位移。
return RequestProgress::Complete(error_response(
StatusCode(400),
"world-space UI camera input requires a pointer",
@@ -568,8 +643,10 @@ impl Worker {
}
Some(input)
} else {
// Console 不是事件,不制造一个空 Input;下面按 source 执行同一持久会话的交互语句。
None
};
// 只有当前 epoch 没有实际会话才惰性创建空 Console;正常输入沿用已有 VM,不为每次 POST 复制 globals。
if self
.repl
.as_ref()
@@ -585,9 +662,11 @@ impl Worker {
));
}
};
// 空 Console 初建只沿用主壳尺寸,不从静态旧帧复制可变业务对象或副窗资源。
let viewport = namespace.read().ok().and_then(|current| current.viewport);
match endor_host::ReplSession::new_with_viewport(self.next_session, viewport) {
Ok(session) => {
// 仅共享状态仍是当前 epoch 才记录新命令世代;真正画面仍等待源程序打开页面。
if let Ok(mut current) = namespace.write()
&& current.submission == epoch
{
@@ -603,6 +682,7 @@ impl Worker {
}
}
}
// 业务执行前复制合法视口集合,之后整个 VM 调用不持 namespace 锁。
let viewports = match namespace.read() {
Ok(current) => current_viewports(&current),
Err(_) => {
@@ -612,9 +692,13 @@ impl Worker {
));
}
};
// 惰性创建已保证 repl 存在,借用这个实际会话而非重放历史或建立每请求临时 VM。
let session = &mut self.repl.as_mut().expect("initialized REPL").1;
// 保存实际会话号,执行后可能收到新 PUT,finish_input 会再次核验归属。
let id = session.id;
// 统一收集可诊断错误与内部 panic,后者必须丢弃域,不能继续借损坏对象图交互。
let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
// 两种输入只执行一次业务,挂起时 PendingInput 将保存这个返回值,只继续视觉阶段。
let result = if let Some(event) = &event {
session.dispatch_incremental(event, &viewports)?
} else {
@@ -628,8 +712,10 @@ impl Worker {
};
Ok((result, commands))
}));
// 是否需对可见帧加故障条取决于 VM 的实际执行状态,普通编译失败不等于整个域已损坏。
let faulted = session.is_faulted();
if session.has_pending_refresh() {
// 只有正常完成业务输入的结果可保存在 PendingInput;恢复时沿用 result,不再执行一次原始输入。
if let Ok(Ok((result, _))) = result {
return RequestProgress::Pending(PendingRequest::Input(Box::new(PendingInput {
epoch,
@@ -639,7 +725,7 @@ impl Worker {
})));
}
// 启动失败且残留挂起刷新时直接丢弃域,不能保留一个无法安全推进的 HTTP 请求。
// A failed start must never leave a request waiting on a broken VM.
// 不给无法恢复的执行错误留下“等待下一片可能成功”的请求,保留完整旧画面另作响应。
self.repl = None;
}
RequestProgress::Complete(self.finish_input(
@@ -654,7 +740,7 @@ impl Worker {
))
}
/// 恢复已挂起的请求,保持原 HTTP 响应和运行时身份直到最终完成。
/// api 外层循环保存原 job 后每量子调用,按继续状态分流,只有 BeforeInput 能重新进入业务入口。
pub(super) fn resume_request(
&mut self,
job: &BuildRequest,
@@ -663,6 +749,7 @@ impl Worker {
notify: &Notify,
) -> RequestProgress {
match pending {
// 业务尚未执行,可重新核验提交和等待条件;Input/Candidate 都只继续原执行域。
PendingRequest::BeforeInput => self.begin_request(job, namespace, notify),
PendingRequest::Input(input) => self.resume_input(job, input, namespace, notify),
PendingRequest::Candidate(runtime) => {
@@ -671,7 +758,7 @@ impl Worker {
}
}
/// 继续同一 Console/事件输入的展示刷新,不再次执行其业务回调。
/// resume_request 的 Input 分支调用,核验原会话并继续原视觉工作,保留先前返回值/统计不重执行语句。
fn resume_input(
&mut self,
job: &BuildRequest,
@@ -679,6 +766,7 @@ impl Worker {
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
// 可继续条件和最新合法尺寸在同一读锁中取得,执行时不长期持有发布锁。
let viewports = match namespace.read() {
// 正在执行输入只要仍属当前提交或仍拥有权威 VM 就可收敛;两项皆失效才彻底废弃。
Ok(current)
@@ -687,6 +775,7 @@ impl Worker {
current_viewports(&current)
}
Ok(_) => {
// 提交和权威 VM 都已撤销,不能把旧输入结果重新提交到另一个活动程序上。
self.repl = None;
return RequestProgress::Complete(superseded(job.submission));
}
@@ -698,15 +787,18 @@ impl Worker {
));
}
};
// PendingInput 不自己强持另一个运行时,必须与 Worker 当前拥有的真实 VM 一致。
let Some((epoch, session)) = &mut self.repl else {
return RequestProgress::Complete(error_response(
StatusCode(409),
"input runtime was discarded",
));
};
// 接受序号与真实 VM 会话号都须匹配;同 epoch 中若换过会话,也不能把旧 PendingInput 接到新堆续跑。
if *epoch != input.epoch || session.id != input.id {
return RequestProgress::Complete(superseded(job.submission));
}
// 只推进 resize/刷新,catch_unwind 保证源视觉方法 panic 后可撤销整个损坏域。
let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
// 这里只恢复 pending 展示/尺寸工作,不再调用 execute/dispatch,所以前一个量子的源副作用不会重放。
let document = session.resize_windows_incremental(&viewports, false)?;
@@ -715,10 +807,13 @@ impl Worker {
}
Ok(Some((document, session.take_window_commands()?)))
}));
// 恢复失败是否停域由实际 runtime 状态决定,交 finish_input 统一决定画面故障报告。
let faulted = session.is_faulted();
// Pending 原样保留输入元数据,Complete 才把新文档补入第一次业务执行的结果。
let result = match resumed {
Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Input(input)),
Ok(Ok(Some((document, commands)))) => {
// 不覆盖 value/input/bindings,避免一次挂起输入变成第二次 Console 记录。
input.result.document = document;
Ok(Ok((input.result, commands)))
}
@@ -738,7 +833,7 @@ impl Worker {
}
#[allow(clippy::too_many_arguments)]
/// 校验输入仍属于当前域,再一次性发布结果、元数据和原生窗口命令。
/// begin_request/resume_input 终结输入时调用,写锁内核验接受身份,提交完整文档与同 VM 命令后回应。
fn finish_input(
&mut self,
job: &BuildRequest,
@@ -750,34 +845,38 @@ impl Worker {
faulted: bool,
result: InputOutcome,
) -> HttpResponse {
// 锁覆盖提交检查/文档/命令世代更新,HTTP 新 PUT 不能插在验证与提交之间制造竞态。
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 {
// 已失去实际画面归属才彻底丢旧 VM,避免旧输入在另一代程序上产生回执或发布。
drop(current);
self.repl = None;
return superseded(job.submission);
}
// Accepted PUTs invalidate queued inputs, not a mutation already
// executing on this owner. Settle it before building the candidate
// so a failed replacement can preserve a consistent live heap.
// 新 PUT 撤销未执行旧队列项,但旧 VM 已开始的变更要先收敛,候选失败才有一致的旧堆可保留。
if let Some((owner, runtime)) = &mut self.repl
&& runtime.id == id
{
// 更新活 VM 的接受 epoch,而不改变其 session;不是把它冒充为新候选或复制对象图。
*owner = current.submission;
}
current.submission
} else {
// 接受序号没变,直接沿用原输入 epoch,不制造额外 revision 或 session。
epoch
};
match result {
Ok(Ok((result, commands))) => {
// 有标题/焦点等命令即使无新文档也需通知 GUI,和 rendered 的含义分别记录。
let projected = !commands.is_empty();
// rendered 只表示此次交付带新文档,成功执行但页面未变仍可返回 value/session;不因此虚增 revision。
let rendered = result.document.is_some();
if let Some(document) = result.document {
// 可见文档必须先完整验证并被当前 epoch 接受,再发布其窗口命令;不能先回成功响应后补提交。
match current.commit(epoch, document) {
Ok(Some(_)) => {}
Ok(None) => return superseded(job.submission),
@@ -785,8 +884,10 @@ impl Worker {
}
}
current.publish_window_commands(id, commands);
// 在释放锁前读取这次确定的 revision,响应不再为字符串化借用共享状态。
let revision = current.revision;
drop(current);
// 有新画面或仅有原生命令都要通知;纯值计算没有两者时不强行重绘,响应仍正常返回值。
if rendered || projected {
notify();
}
@@ -797,10 +898,8 @@ impl Worker {
}
Ok(Err(error)) => {
if event || faulted {
// 事件/渲染回调故障会影响整个共享 VM;单纯 Console 编译错误可只返回 422 而不覆盖画面。
let mut document = current.document.clone();
// 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())
@@ -808,8 +907,10 @@ impl Worker {
.unwrap_or_else(|| format!("{error:#}").chars().take(1024).collect());
document.frame.fault = Some(fault.clone());
for view in document.windows.values_mut() {
// 这些副窗共享已故障的持久 VM,同步显示同一原因,不能只在发生事件的一个窗口标记故障。
view.frame.fault = Some(fault.clone());
}
// 尽力提交故障条,主响应仍保留原始执行错误;不能因二次故障覆盖真正原因。
let _ = current.commit(epoch, document);
drop(current);
notify();
@@ -817,15 +918,18 @@ impl Worker {
compile_error(error)
}
Err(_) => {
// Rust panic 不作为普通语言错误继续执行,清运行时/命令资格但保留上次完整静态画面。
let mut document = current.document.clone();
document.frame.fault =
Some("Runtime discarded after an internal execution failure".into());
for view in document.windows.values_mut() {
// panic 后保留最后完整画面并附统一故障条,下面会撤销运行时资格,不能继续向旧对象派发输入。
view.frame.fault = document.frame.fault.clone();
}
// 输入 panic 保留最近画面但撤销运行时/命令并发布故障条,再将 self.repl 丢弃。
current.runtime_session = 0;
current.window_commands.clear();
// 优先保留原始 panic 的响应,故障画面提交尽力而为;不因二次提交失败重新使用已损坏 VM。
let _ = current.commit(epoch, document);
drop(current);
self.repl = None;
@@ -838,29 +942,32 @@ impl Worker {
}
}
/// An accepted PUT changes the submission, but does not revoke the old
/// runtime. DELETE clears runtime_session and does revoke it.
/// 接受较新提交不等于立即销毁旧 VM;候选失败时仍需保留当前可用运行时。
/// 新提交/候选过期或失败时调用,若旧 VM 仍拥有发布画面则只跟进 epoch;DELETE 已撤权威则丢弃它。
pub(super) fn preserve_live_owner(&mut self, current: &NamespaceState) {
// 调用方已持发布状态锁,本函数只对照身份修改 worker 本地拥有关系,不执行回调或生成新文档。
if let Some((epoch, runtime)) = &mut self.repl {
if current.runtime_session == runtime.id {
// 旧程序还是真正交互域,接受次序更新但其实际对象和 session 身份都不改变。
*epoch = current.submission;
} else {
// 权威已经撤销或切到另一代,不能仅因还持有 Rust ReplSession 就继续执行。
self.repl = None;
}
}
}
/// 为完整 PUT 建立隔离候选,保留旧运行时直到新候选完整成功。
/// begin_request 的 Code 分支调用,先核验接受身份,再分配新 session 创建隔离候选,旧 self.repl 保持不动。
fn begin_program_request(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
// 候选仅继承主壳当前逻辑尺寸,不继承旧程序的可变对象或副窗资源。
let viewport = match namespace.read() {
Ok(current) if current.submission == job.submission => current.viewport,
Ok(current) => {
// 排队期间已有更新 PUT/DELETE,当前候选还没执行就应拒绝,不浪费新 VM 构建。
self.preserve_live_owner(&current);
return RequestProgress::Complete(superseded(job.submission));
}
@@ -880,6 +987,7 @@ impl Worker {
));
}
};
// 分配过的 session 即使候选失败也不回收,避免排队旧输入命中下一次新域。
let id = self.next_session;
// 候选装载运行在独立 ReplSession,未成功前不修改 self.repl,旧程序仍是权威展示来源。
let started = catch_unwind(AssertUnwindSafe(|| {
@@ -891,6 +999,7 @@ impl Worker {
viewport,
)
}));
// 两层 Result 分别区分 panic 和语言/布局错误;None 文档表示保留完整候选继续视觉阶段。
let result = match started {
// 候选业务装载成功但布局未完成,整个候选移进 PendingRequest;不能把 None 当成空文档提交。
Ok(Ok((runtime, None))) => {
@@ -906,7 +1015,7 @@ impl Worker {
RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result))
}
/// 推进候选的布局与视口收敛,检查其是否已被新提交撤销。
/// resume_request 的 Candidate 分支调用,用同一个隔离 VM 继续布局/绘制,期间持续检查提交是否已过期。
fn resume_candidate(
&mut self,
job: &BuildRequest,
@@ -914,10 +1023,9 @@ impl Worker {
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> RequestProgress {
// 接受身份验证和主壳尺寸采样同锁,旧副窗口尺寸不作为新域的布局输入。
let viewports = match namespace.read() {
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
@@ -936,8 +1044,11 @@ impl Worker {
));
}
};
// 保留候选实际 session,不能在每片恢复时重新分配 ID 或重建 UI 类型域。
let id = runtime.id;
// 续跑视觉状态,不重执行顶层语句或 main;错误/panic 都交 finish_candidate 保留旧活程序。
let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
// 与活域一样,resize 可重定向外部目标或继续原 Work,但始终留在此候选 owning VM 上。
let document = runtime.resize_windows_incremental(&viewports, false)?;
if runtime.has_pending_refresh() {
return Ok(None);
@@ -947,6 +1058,7 @@ impl Worker {
document.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?;
Ok(Some((document, runtime.take_window_commands()?)))
}));
// Complete 才把持久 runtime、完整文档和命令作为一组交给切换发布,Pending 继续原 Box。
let result = match resumed {
Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Candidate(runtime)),
Ok(Ok(Some((document, commands)))) => Ok(Ok((*runtime, document, commands))),
@@ -956,7 +1068,7 @@ impl Worker {
RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result))
}
/// 候选成功才切换活跃 VM 和文档;失败保留先前已发布程序。
/// 完整加载/恢复候选终结时调用,写锁内二次核验 submission,只有完整成功才切换文档、命令与活 VM。
fn finish_candidate(
&mut self,
job: &BuildRequest,
@@ -965,13 +1077,16 @@ impl Worker {
id: u64,
result: CandidateOutcome,
) -> HttpResponse {
// 字节码/源视觉执行不持锁,只有最终这个切换边界持锁原子更新外部权威。
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock poisoned");
};
// 候选计算结束不代表仍被接受;先处理过期并保留权威旧域,再考虑把候选结果交给 commit。
if current.submission != job.submission {
self.preserve_live_owner(&current);
return superseded(job.submission);
}
// 失败分支遵循同一原则:候选未被完整接受就不把其 VM 装进 self.repl,不用半成品替换旧程序。
match result {
Ok(Ok((runtime, document, commands))) => match current.commit(job.submission, document)
{
@@ -990,8 +1105,6 @@ impl Worker {
Err(error) => error_response(StatusCode(500), error.to_string()),
},
failed => {
// The candidate never mutated the displayed program's heap.
// Restore that program's event epoch when a replacement fails.
// 候选失败只更新旧活域 epoch,保留其堆与页面;不存在旧域时也不会借用其他会话伪造成功。
self.preserve_live_owner(&current);
drop(current);
@@ -1007,7 +1120,7 @@ impl Worker {
}
}
/// 处理隔离表达式或一次性文档编译;编译前后核对 submission,只发布仍有效的完整快照。
/// begin_request 的 Expression 分支和 API 测试一次性构建路径调用;无持久 VM 输出,源码构建前后核验接受身份。
fn build(
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
@@ -1015,6 +1128,7 @@ fn build(
) -> Response<std::io::Cursor<Vec<u8>>> {
// 隔离表达式不读写持久 REPL 堆,也不发布文档,响应明确 persistent=false。
if job.kind == RequestKind::Expression {
// 表达式只回文本,不能从已有画面反取 globals,成功也明确 persistent=false。
return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) {
Ok(Ok(value)) => json(
StatusCode(200),
@@ -1024,20 +1138,22 @@ fn build(
Err(_) => error_response(StatusCode(500), "expression execution failed internally"),
};
}
// 一次性构建开始前先拒绝过期 job,节省无权发布的执行;锁外求值结束后仍须再次核验,不能只检查一次。
match namespace.read() {
Ok(current) if current.submission != job.submission => return superseded(job.submission),
Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"),
_ => {}
}
// Only an independent native snapshot crosses into the published state.
// A fresh VM is discarded on success, error and unwind alike.
// 一次性构建同样用独立 VM,计算锁外执行,返回后再次核对 submission 才能发布原生快照。
// 包住一次性 VM 执行,panic 也不会让 HTTP 客户端一直等待失去响应的 worker。
let result = catch_unwind(AssertUnwindSafe(|| {
endor_host::compile(&job.source, &job.namespace, job.submission)
}));
// 重新取发布写锁再检查 submission,候选计算期间新请求可接受但旧结果无权覆盖。
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
// 这是计算后的第二次检查;期间新提交即使源码相同也拥有新 submission,旧候选不得覆盖它。
if current.submission != job.submission {
return superseded(job.submission);
}
+259 -68
View File
File diff suppressed because it is too large Load Diff
+54 -4
View File
@@ -1,18 +1,28 @@
//! Integration acceptance through the actual Endor VM and published frames.
//! Rust 测试框架通过 endor_host 的真实 VM/发布帧接口验收 illustrated-manuscript Endor 示例。
//! 第一项检查指针驱动/火焰生命周期与基本像素,第二项检查 owner-scoped 动画任务、不同视口和绘制几何。
//! write_png 无论是否配置快照目录都会软件光栅化并检查背景像素;配置目录后才另写 PNG。
//! 图片是可选人工检查产物,没有读取参考图或做整图像素差分;测试源码字符串与像素期望都是验收数据。
use super::*;
use crate::document::RenderCommand;
#[test]
/// 测试框架调用,证明动画由真实源状态和指针事件驱动,取消输入后源粒子会退场而非原生演示循环继续跑。
fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() {
// 直接加载仓库随应用交付的示例,避免测试另写一份近似逻辑却无法覆盖实际程序。
let source = include_str!("../../../examples/illustrated-manuscript.endor");
// 固定会话代际只用于事件身份,几何/Mount ID 从真正发布的文档取出。
let (mut session, mut document) = ReplSession::load(source, "app.main", 1, 735).unwrap();
/// 本测试的同步求值助手,执行后只在有新文档时替换观察值,返回 Console 的字符串结果供断言。
fn evaluate(session: &mut ReplSession, document: &mut RenderDocument, source: &str) -> String {
// execute 复用持久 globals,不根据浏览文档伪造对象状态。
let result = session.execute(source, "app.main").unwrap();
if let Some(next) = result.document {
// 无变化时保留先前完整帧,不能用默认空文档替换它而误判渲染退场。
*document = next;
}
result.value
}
// 关闭自动计时器,后面显式 dragon.step 的次数才是唯一推进因素,消除定时采样干扰。
evaluate(&mut session, &mut document, "view.timer.cancel()");
document.validate().unwrap();
assert!(
@@ -23,13 +33,16 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() {
.any(|c| matches!(c, RenderCommand::Image { .. }))
);
assert!(document.frame.mounts.iter().any(|m| m.pointer_events));
// 读取模型的真实头段 x 作为基线,不靠图片相似度猜动画是否发生。
let before = evaluate(&mut session, &mut document, "dragon.segments.at(0).x");
// 目标必须具有 pointer_events,发布几何提供局部区域,不硬编码内部出现编号。
let target = document
.frame
.mounts
.iter()
.find(|m| m.pointer_events)
.unwrap();
// 用发布版本与实际目标构造合法原始指针,坐标放在画面内部,避免命中边缘的额外裁剪条件。
let mut input = crate::events::Input {
window: 1,
session: 735,
@@ -51,14 +64,17 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() {
.document
.unwrap_or(document);
for _ in 0..8 {
// 只推进源函数,证明位置变化来自已收到的指针目标与程序状态,不是宿主隐藏动画线程。
document = session
.execute("dragon.step()", "app.main")
.unwrap()
.document
.unwrap_or(document);
}
// 比较同一模型字段的前后值,检查显式推进阶段有实际位置变化,而非只生成更多绘图命令。
let after = evaluate(&mut session, &mut document, "dragon.segments.at(0).x");
assert_ne!(before, after);
// 事件每次使用最新实际几何版本,测试关注行为而非故意触发过期输入拒绝。
input.layout_version = document.frame.layout_version;
input.kind = "pointerdown".into();
document = session
@@ -67,6 +83,7 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() {
.document
.unwrap_or(document);
for _ in 0..3 {
// pointerdown 激活喷火状态,少量显式帧之后应有源粒子实例进入集合。
document = session
.execute("dragon.step()", "app.main")
.unwrap()
@@ -77,13 +94,16 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() {
evaluate(&mut session, &mut document, "dragon.flames.count"),
"0"
);
// 在源模型侧检查粒子尺寸预算,避免仅像素图看起来小却实际对象无限长大。
let max_size = evaluate(&mut session, &mut document, "dragon.flames.at(0).size < 20");
assert_eq!(max_size, "true");
input.layout_version = document.frame.layout_version;
input.kind = "pointercancel".into();
// 取消必须沿同一输入分派路径清 pressed,不直接从测试修改 dragon.pressed 代替处理器。
session.dispatch(&input).unwrap();
assert_eq!(evaluate(&mut session, &mut document, "dragon.pressed"), "0");
for _ in 0..16 {
// 取消后继续更新已有粒子寿命,最终集合归零,验证没有仍被按住状态持续补发新火焰。
document = session
.execute("dragon.step()", "app.main")
.unwrap()
@@ -94,32 +114,45 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() {
evaluate(&mut session, &mut document, "dragon.flames.count"),
"0"
);
// 真正发布列表交软件渲染器,不从源模型的“将要绘制”对象构造另一份测试专用画面。
let list = document.display_list(1.).unwrap();
// 预分配固定尺寸 RGB 像素,下面只要求至少一个奶油背景像素,不据此证明完整图像或全部图层正确。
let mut pixels = vec![0; 1024 * 800];
crate::software_renderer::SoftwareRenderer::render(&list, &mut pixels, 1024, 800).unwrap();
assert!(pixels.contains(&0xf4eee0));
}
/// 第二项集成测试共用的持久执行助手;保留最后完整文档,返回源值的 Console 表示,不重建 VM。
fn execute(session: &mut ReplSession, document: &mut RenderDocument, source: &str) -> String {
// 执行源代码及其同步刷新,Option 文档为空只代表没有新发布,不代表旧画面失效。
let result = session.execute(source, "app.main").unwrap();
if let Some(next) = result.document {
// 有新完整帧才覆盖测试观察值,后续输入版本与快照都从此值读取。
*document = next;
}
result.value
}
/// 第二项测试各阶段调用,先验证实际像素;仅明确配置快照目录时把同一像素编码为 PNG,便于人工检查。
fn write_png(document: &RenderDocument, w: u32, h: u32, scale: f32, name: &str) {
// scale 是物理像素倍率,显示列表和输出尺寸必须配套,不改变源程序使用的逻辑 viewport。
let list = document.display_list(scale).unwrap();
// 软件渲染覆盖真实像素数组,断言并非只检查命令里有一条背景矩形。
let mut pixels = vec![0; w as usize * h as usize];
crate::software_renderer::SoftwareRenderer::render(&list, &mut pixels, w, h).unwrap();
// 仅检查奶油背景像素超过 1000 个,不比较快照基线;喷火尺寸/方向另用实际源值断言。
assert!(pixels.iter().filter(|&&p| p == 0xf4eee0).count() > 1000);
if let Ok(dir) = std::env::var("TESSERA_MANUSCRIPT_SNAPSHOTS") {
// 未设置目录时不产生文件;有目录才创建编码器,快照输出是显式选择的测试附加产物。
std::fs::create_dir_all(&dir).unwrap();
// name 区分宽窄视口及静止/穿行/喷火阶段,图片来自当前实际发布帧而非 ImageGen。
let file =
std::fs::File::create(std::path::Path::new(&dir).join(format!("endor-{name}.png")))
.unwrap();
// 像素是 0xRRGGBB,编码为每通道 8 位 RGB,不能把 u32 内存字节直接当 RGBA 保存。
let mut encoder = png::Encoder::new(file, w, h);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
// 显式提取高到低三个颜色分量,避免平台端序影响导出的颜色。
let rgb: Vec<_> = pixels
.iter()
.flat_map(|p| [(p >> 16) as u8, (p >> 8) as u8, *p as u8])
@@ -133,12 +166,15 @@ fn write_png(document: &RenderDocument, w: u32, h: u32, scale: f32, name: &str)
}
#[test]
/// 测试框架调用,验证真实 owner 定时任务、宽窄/高倍视口、喷火上限和 Page 关闭后的动画取消。
fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
// 只追加测试推进函数,原示例本体不改;每次 step 前刷新 lastMove 以稳定触发运动场景。
let source = format!(
"{}\nfunction advanceTest():Unit {{dragon.lastMove=math.now() dragon.step()}}",
include_str!("../../../examples/illustrated-manuscript.endor")
);
for (width, height, scale) in [(1000., 900., 1.), (420., 760., 2.)] {
// 两种窗口使用独立 VM 与真实逻辑 viewport,窄视图同时覆盖高倍字体/图片采样。
let (mut session, mut document) = ReplSession::load_with_viewport(
&source,
"app.main",
@@ -147,15 +183,19 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
Some(crate::layout::Size { width, height }),
)
.unwrap();
// The actual owner-scoped Task calls script tick, not a native demo loop.
// 真实 owner-scoped Task 调用源 tick,先比较时间字段确认计时器而非宿主演示循环在推进。
let before = execute(&mut session, &mut document, "dragon.time");
// 等待 85ms 超过示例设定的 80ms 延时,再在所属 VM poll;实际调度可能更晚,sleep 不代表任务已经交付。
std::thread::sleep(std::time::Duration::from_millis(85));
if let Some(next) = session.poll_tasks().unwrap() {
// 只接收调度后真正完整发布文档,不从任务内部未收敛草稿截屏。
document = next;
}
// time 变化证明 owner/key 当前任务获得了一次有效交付。
let after = execute(&mut session, &mut document, "dragon.time");
assert_ne!(before, after);
execute(&mut session, &mut document, "view.timer.cancel()");
// 自动动画确认后取消,后续快照阶段由固定次数 step 控制;像素尺寸按逻辑尺寸乘 scale。
let (w, h) = ((width * scale) as u32, (height * scale) as u32);
write_png(
&document,
@@ -164,6 +204,7 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
scale,
if width > 500. { "rest" } else { "narrow-rest" },
);
// 找当前真实指针出现并克隆几何,下面分派会改 document,不能保留对旧文档的借用。
let mount = document
.frame
.mounts
@@ -171,7 +212,9 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
.find(|m| m.pointer_events)
.unwrap()
.clone();
// 用视口比例选锚点,宽窄布局都位于同类内容区域,不依赖固定像素偏移。
let point = [mount.x + mount.width * 0.58, mount.y + mount.height * 0.50];
// 会话/版本/目标均从本轮 VM 和已发布帧取得,确保命中的是此视口而不是另一测试残留。
let mut input = crate::events::Input {
window: 1,
session: 736,
@@ -190,6 +233,7 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
.document
.unwrap_or(document);
for _ in 0..12 {
// 固定推进源运动,生成穿行状态,快照仍走实际 scene/paint/软件渲染链。
execute(&mut session, &mut document, "advanceTest()");
}
write_png(
@@ -206,10 +250,11 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
.unwrap()
.document
.unwrap_or(document);
// Hold long enough to reach the steady-state particle cap and exercise
// the worst paint workload, then release/cancel through the event path.
// 按住并推进足够帧数达到粒子稳态上限,覆盖比静止更重的真实 paint 工作量。
// 计时仅作诊断输出,测试没有依赖机器速度的性能通过阈值。
let start = std::time::Instant::now();
for _ in 0..16 {
// 每次仍执行源 advanceTest,不能用原生测试循环直接生成粒子绕过语言能力。
execute(&mut session, &mut document, "advanceTest()");
}
eprintln!(
@@ -220,12 +265,14 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
execute(&mut session, &mut document, "dragon.flames.count <= 12"),
"true"
);
// 从源 artwork 与粒子速度对比最终图片角度,验证精灵轴校正符合源模型方向。
execute(
&mut session,
&mut document,
"function verifyFire(): Bool { art=dragon.artwork(0,0,1) c=art.at(art.count-1) p=dragon.flames.at(dragon.flames.count-1) return math.abs(c.angle+p.art.axis-math.atan2(p.vy,p.vx))<0.0001 }",
);
assert_eq!(execute(&mut session, &mut document, "verifyFire()"), "true");
// 单独验证最终绘图宽高,不只看粒子模型 size 字段,防止图片命令放大绕过尺寸预算。
execute(
&mut session,
&mut document,
@@ -242,7 +289,10 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
scale,
if width > 500. { "fire" } else { "narrow-fire" },
);
// 示例 ui.after 的 owner 是 page;关闭 Page 会取消该任务,onUnmount 还会显式取消最近 timer。
// 本例验证关闭后的最终 time,不单独区分 owner 清理与卸载钩子各自贡献的取消效果。
execute(&mut session, &mut document, "page.close()");
// 关闭时真实 time 是对照值,等待超过旧定时点再 poll 应完全不变。
let closed = execute(&mut session, &mut document, "dragon.time");
std::thread::sleep(std::time::Duration::from_millis(85));
session.poll_tasks().unwrap();
File diff suppressed because it is too large Load Diff

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